v3: follows, notifications, invite-only registration, unread markers

This commit is contained in:
khannurien
2026-03-21 18:42:47 +00:00
parent 7c098e7c4c
commit 608c6bc6a8
55 changed files with 4743 additions and 884 deletions

View File

@@ -43,7 +43,14 @@ export function Dump() {
const [comments, setComments] = useState<Comment[]>([]);
const { user, token } = useAuth();
const { voteCounts, myVotes, castVote, removeVote, lastDumpEvent, lastCommentEvent } = useWS();
const {
voteCounts,
myVotes,
castVote,
removeVote,
lastDumpEvent,
lastCommentEvent,
} = useWS();
useEffect(() => {
if (!selectedDump) return;
@@ -114,7 +121,9 @@ export function Dump() {
if (!lastCommentEvent || lastCommentEvent.dumpId !== selectedDump) return;
if (lastCommentEvent.type === "created" && lastCommentEvent.comment) {
setComments((prev) => {
if (prev.some((c) => c.id === lastCommentEvent.comment!.id)) return prev;
if (prev.some((c) => c.id === lastCommentEvent.comment!.id)) {
return prev;
}
return [...prev, lastCommentEvent.comment!];
});
} else if (

View File

@@ -78,7 +78,11 @@ export function DumpEdit() {
});
} else {
const body: UpdateDumpRequest = state.dump.kind === "url"
? { url: url.trim() || undefined, comment: comment.trim() || undefined, isPrivate }
? {
url: url.trim() || undefined,
comment: comment.trim() || undefined,
isPrivate,
}
: { comment: comment.trim() || undefined, isPrivate };
res = await authFetch(`${API_URL}/api/dumps/${state.dump.id}`, {
method: "PUT",
@@ -263,20 +267,22 @@ export function DumpEdit() {
/>
</div>
<label className="toggle-row">
<span className="toggle-label">Public</span>
<span className="toggle-switch">
<input
type="checkbox"
checked={!isPrivate}
onChange={(e) => setIsPrivate(!e.target.checked)}
/>
<span className="toggle-thumb" />
</span>
{isPrivate && (
<span className="toggle-hint">Only visible to you</span>
)}
</label>
<div className="dump-mode-toggle">
<button
type="button"
className={!isPrivate ? "active" : ""}
onClick={() => setIsPrivate(false)}
>
Public
</button>
<button
type="button"
className={isPrivate ? "active" : ""}
onClick={() => setIsPrivate(true)}
>
Private
</button>
</div>
<div className="form-actions">
<button

View File

@@ -1,4 +1,10 @@
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
import {
useCallback,
useEffect,
useLayoutEffect,
useRef,
useState,
} from "react";
import { Link, useLocation } from "react-router";
import { Avatar } from "../components/Avatar.tsx";
@@ -7,10 +13,15 @@ import { AppHeader } from "../components/AppHeader.tsx";
import { API_URL } from "../config/api.ts";
import { deserializeDump, type Dump, type PaginatedData, type RawDump } from "../model.ts";
import {
deserializeDump,
type Dump,
type PaginatedData,
type RawDump,
type User,
} from "../model.ts";
import { useFeedCache } from "../hooks/useFeedCache.ts";
import { useAuth } from "../hooks/useAuth.ts";
import { useWS } from "../hooks/useWS.ts";
import { useInfiniteScroll } from "../hooks/useInfiniteScroll.ts";
@@ -24,15 +35,88 @@ const hydrateDump = (raw: Dump): Dump =>
type DumpsState =
| { status: "loading" }
| { status: "error"; error: string }
| { status: "loaded"; dumps: Dump[]; hasMore: boolean; page: number; loadingMore: boolean };
| {
status: "loaded";
dumps: Dump[];
hasMore: boolean;
page: number;
loadingMore: boolean;
};
type SortMode = "new" | "hot";
type FeedTab = "hot" | "new" | "followed";
type FollowedSection = "users" | "playlists";
function hotScore(dump: Dump): number {
const ageHours = (Date.now() - dump.createdAt.getTime()) / 3_600_000;
return (dump.voteCount + 1) / Math.pow(ageHours + 2, 1.5);
}
// ── FollowedSubFeed ──────────────────────────────────────────────────────────
interface FollowedSubFeedProps {
state: DumpsState;
voteCounts: Record<string, number>;
myVotes: Set<string>;
user: User | null;
castVote: (id: string) => void;
removeVote: (id: string) => void;
deletedDumpIds: Set<string>;
emptyMessage: string;
onLoadMore: () => void;
}
function FollowedSubFeed({
state,
voteCounts,
myVotes,
user,
castVote,
removeVote,
deletedDumpIds,
emptyMessage,
onLoadMore,
}: FollowedSubFeedProps) {
const hasMore = state.status === "loaded" && state.hasMore &&
!state.loadingMore;
const sentinelRef = useInfiniteScroll(onLoadMore, hasMore);
if (state.status === "loading") {
return <p className="index-status">Loading</p>;
}
if (state.status === "error") {
return <p className="index-status index-status--error">{state.error}</p>;
}
const visible = state.dumps.filter((d) => !deletedDumpIds.has(d.id));
if (visible.length === 0) {
return <p className="index-status">{emptyMessage}</p>;
}
return (
<>
<ul className="dump-feed">
{visible.map((dump) => (
<DumpCard
key={dump.id}
dump={dump}
voteCount={voteCounts[dump.id] ?? dump.voteCount}
voted={myVotes.has(dump.id)}
canVote={!!user}
castVote={castVote}
removeVote={removeVote}
isOwner={user?.id === dump.userId}
/>
))}
</ul>
<div ref={sentinelRef} />
{state.loadingMore && <p className="feed-loading-more">Loading more</p>}
</>
);
}
// ── Index ────────────────────────────────────────────────────────────────────
export function Index() {
const location = useLocation();
const justDeletedId = (location.state as { deletedDumpId?: string } | null)
@@ -49,22 +133,70 @@ export function Index() {
removeVote,
} = useWS();
const { cached, saveState } = useFeedCache<Dump>(`feed:index:${user?.id ?? "guest"}`, hydrateDump);
// Main feed
const { cached, saveState } = useFeedCache<Dump>(
`feed:index:${user?.id ?? "guest"}`,
hydrateDump,
);
const [dumpsState, setDumpsState] = useState<DumpsState>(() =>
cached
? { status: "loaded", dumps: cached.items, hasMore: cached.hasMore, page: cached.page, loadingMore: false }
? {
status: "loaded",
dumps: cached.items,
hasMore: cached.hasMore,
page: cached.page,
loadingMore: false,
}
: { status: "loading" }
);
const [sort, setSort] = useState<SortMode>("hot");
const mainFetchDone = useRef(false);
// Followed feeds
const { cached: cachedFollowedUsers, saveState: saveFollowedUsers } =
useFeedCache<Dump>(
`feed:followed-users:${user?.id ?? "guest"}`,
hydrateDump,
);
const { cached: cachedFollowedPlaylists, saveState: saveFollowedPlaylists } =
useFeedCache<Dump>(
`feed:followed-playlists:${user?.id ?? "guest"}`,
hydrateDump,
);
const [followedUsersDumps, setFollowedUsersDumps] = useState<DumpsState>({
status: "loading",
});
const [followedPlaylistsDumps, setFollowedPlaylistsDumps] = useState<
DumpsState
>({ status: "loading" });
const [tab, setTab] = useState<FeedTab>("hot");
const [followedSection, setFollowedSection] = useState<FollowedSection>(
"users",
);
// When the logo is clicked it navigates to / with state { tab: "hot" }, producing
// a new location.key even if already on /. React to that to reset the active tab.
useEffect(() => {
const st = location.state as { tab?: string } | null;
if (st?.tab === "hot" || st?.tab === "new" || st?.tab === "followed") {
setTab(st.tab as FeedTab);
}
}, [location]);
// ── Main feed fetch ──
useEffect(() => {
if (cached) return; // restored from cache, skip fetch
if (mainFetchDone.current || cached) return;
mainFetchDone.current = true;
(async () => {
try {
const res = await fetch(`${API_URL}/api/dumps/?page=1&limit=${PAGE_SIZE}`, {
headers: token ? { Authorization: `Bearer ${token}` } : {},
});
const res = await fetch(
`${API_URL}/api/dumps/?page=1&limit=${PAGE_SIZE}`,
{
headers: token ? { Authorization: `Bearer ${token}` } : {},
},
);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const body = await res.json();
const { items, hasMore } = body.data as PaginatedData<RawDump>;
@@ -82,13 +214,96 @@ export function Index() {
});
}
})();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
}, [cached, token]);
// ── Followed feeds fetch (lazy, on first tab open) ──
useEffect(() => {
if (tab !== "followed" || !user || !token) return;
if (followedUsersDumps.status === "loading") {
if (cachedFollowedUsers) {
setFollowedUsersDumps({
status: "loaded",
dumps: cachedFollowedUsers.items,
hasMore: cachedFollowedUsers.hasMore,
page: cachedFollowedUsers.page,
loadingMore: false,
});
} else {
fetch(`${API_URL}/api/follows/feed/users?page=1&limit=${PAGE_SIZE}`, {
headers: { Authorization: `Bearer ${token}` },
})
.then((r) => r.json())
.then((body) => {
const { items, hasMore } = body.data as PaginatedData<RawDump>;
setFollowedUsersDumps({
status: "loaded",
dumps: items.map(deserializeDump),
hasMore,
page: 1,
loadingMore: false,
});
})
.catch((err) =>
setFollowedUsersDumps({
status: "error",
error: err instanceof Error ? err.message : "Failed to load",
})
);
}
}
if (followedPlaylistsDumps.status === "loading") {
if (cachedFollowedPlaylists) {
setFollowedPlaylistsDumps({
status: "loaded",
dumps: cachedFollowedPlaylists.items,
hasMore: cachedFollowedPlaylists.hasMore,
page: cachedFollowedPlaylists.page,
loadingMore: false,
});
} else {
fetch(
`${API_URL}/api/follows/feed/playlists?page=1&limit=${PAGE_SIZE}`,
{
headers: { Authorization: `Bearer ${token}` },
},
)
.then((r) => r.json())
.then((body) => {
const { items, hasMore } = body.data as PaginatedData<RawDump>;
setFollowedPlaylistsDumps({
status: "loaded",
dumps: items.map(deserializeDump),
hasMore,
page: 1,
loadingMore: false,
});
})
.catch((err) =>
setFollowedPlaylistsDumps({
status: "error",
error: err instanceof Error ? err.message : "Failed to load",
})
);
}
}
}, [
tab,
user?.id,
token,
cachedFollowedUsers,
cachedFollowedPlaylists,
followedUsersDumps.status,
followedPlaylistsDumps.status,
]);
// ── Load-more callbacks ──
const loadMore = useCallback(() => {
if (
dumpsState.status !== "loaded" ||
!dumpsState.hasMore ||
dumpsState.status !== "loaded" || !dumpsState.hasMore ||
dumpsState.loadingMore
) return;
const nextPage = dumpsState.page + 1;
@@ -120,12 +335,92 @@ export function Index() {
);
}, [dumpsState, token]);
const loadMoreFollowedUsers = useCallback(() => {
if (
followedUsersDumps.status !== "loaded" ||
!followedUsersDumps.hasMore ||
followedUsersDumps.loadingMore ||
!token
) return;
const nextPage = followedUsersDumps.page + 1;
setFollowedUsersDumps((s) =>
s.status === "loaded" ? { ...s, loadingMore: true } : s
);
fetch(
`${API_URL}/api/follows/feed/users?page=${nextPage}&limit=${PAGE_SIZE}`,
{
headers: { Authorization: `Bearer ${token}` },
},
)
.then((r) => r.json())
.then((body) => {
const { items, hasMore } = body.data as PaginatedData<RawDump>;
setFollowedUsersDumps((s) =>
s.status === "loaded"
? {
...s,
dumps: [...s.dumps, ...items.map(deserializeDump)],
hasMore,
page: nextPage,
loadingMore: false,
}
: s
);
})
.catch(() =>
setFollowedUsersDumps((s) =>
s.status === "loaded" ? { ...s, loadingMore: false } : s
)
);
}, [followedUsersDumps, token]);
const loadMoreFollowedPlaylists = useCallback(() => {
if (
followedPlaylistsDumps.status !== "loaded" ||
!followedPlaylistsDumps.hasMore ||
followedPlaylistsDumps.loadingMore ||
!token
) return;
const nextPage = followedPlaylistsDumps.page + 1;
setFollowedPlaylistsDumps((s) =>
s.status === "loaded" ? { ...s, loadingMore: true } : s
);
fetch(
`${API_URL}/api/follows/feed/playlists?page=${nextPage}&limit=${PAGE_SIZE}`,
{
headers: { Authorization: `Bearer ${token}` },
},
)
.then((r) => r.json())
.then((body) => {
const { items, hasMore } = body.data as PaginatedData<RawDump>;
setFollowedPlaylistsDumps((s) =>
s.status === "loaded"
? {
...s,
dumps: [...s.dumps, ...items.map(deserializeDump)],
hasMore,
page: nextPage,
loadingMore: false,
}
: s
);
})
.catch(() =>
setFollowedPlaylistsDumps((s) =>
s.status === "loaded" ? { ...s, loadingMore: false } : s
)
);
}, [followedPlaylistsDumps, token]);
// ── Scroll save effects ──
const sentinelRef = useInfiniteScroll(
loadMore,
dumpsState.status === "loaded" && dumpsState.hasMore && !dumpsState.loadingMore,
dumpsState.status === "loaded" && dumpsState.hasMore &&
!dumpsState.loadingMore,
);
// Save scroll position + loaded state to sessionStorage on scroll
useEffect(() => {
if (dumpsState.status !== "loaded") return;
let timer: ReturnType<typeof setTimeout>;
@@ -133,25 +428,80 @@ export function Index() {
clearTimeout(timer);
timer = setTimeout(() => {
if (dumpsState.status === "loaded") {
saveState(dumpsState.dumps, dumpsState.page, dumpsState.hasMore, window.scrollY);
saveState(
dumpsState.dumps,
dumpsState.page,
dumpsState.hasMore,
globalThis.scrollY,
);
}
}, 100);
};
window.addEventListener("scroll", onScroll, { passive: true });
return () => { window.removeEventListener("scroll", onScroll); clearTimeout(timer); };
globalThis.addEventListener("scroll", onScroll, { passive: true });
return () => {
globalThis.removeEventListener("scroll", onScroll);
clearTimeout(timer);
};
}, [dumpsState, saveState]);
// Restore scroll position after cache restoration
useEffect(() => {
if (followedUsersDumps.status !== "loaded") return;
let timer: ReturnType<typeof setTimeout>;
const onScroll = () => {
clearTimeout(timer);
timer = setTimeout(() => {
if (followedUsersDumps.status === "loaded") {
saveFollowedUsers(
followedUsersDumps.dumps,
followedUsersDumps.page,
followedUsersDumps.hasMore,
globalThis.scrollY,
);
}
}, 100);
};
globalThis.addEventListener("scroll", onScroll, { passive: true });
return () => {
globalThis.removeEventListener("scroll", onScroll);
clearTimeout(timer);
};
}, [followedUsersDumps, saveFollowedUsers]);
useEffect(() => {
if (followedPlaylistsDumps.status !== "loaded") return;
let timer: ReturnType<typeof setTimeout>;
const onScroll = () => {
clearTimeout(timer);
timer = setTimeout(() => {
if (followedPlaylistsDumps.status === "loaded") {
saveFollowedPlaylists(
followedPlaylistsDumps.dumps,
followedPlaylistsDumps.page,
followedPlaylistsDumps.hasMore,
globalThis.scrollY,
);
}
}, 100);
};
globalThis.addEventListener("scroll", onScroll, { passive: true });
return () => {
globalThis.removeEventListener("scroll", onScroll);
clearTimeout(timer);
};
}, [followedPlaylistsDumps, saveFollowedPlaylists]);
// ── Scroll restoration ──
const scrollRestored = useRef(false);
useLayoutEffect(() => {
if (cached?.scrollY == null || scrollRestored.current) return;
if (dumpsState.status === "loaded") {
window.scrollTo(0, cached.scrollY);
globalThis.scrollTo(0, cached.scrollY);
scrollRestored.current = true;
}
// cached is stable (read once), safe to omit
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [dumpsState.status]);
}, [dumpsState.status, cached]);
// ── Derived values ──
const loading = dumpsState.status === "loading";
const error = dumpsState.status === "error" ? dumpsState.error : null;
@@ -163,11 +513,13 @@ export function Index() {
.filter((d) => !deletedDumpIds.has(d.id) && d.id !== justDeletedId);
const sortedDumps = [...combined].sort(
sort === "hot"
? (a, b) => hotScore(b) - hotScore(a)
: (a, b) => b.createdAt.getTime() - a.createdAt.getTime(),
tab === "new"
? (a, b) => b.createdAt.getTime() - a.createdAt.getTime()
: (a, b) => hotScore(b) - hotScore(a),
);
// ── Render ──
const presenceRow = (
<div className="index-presence">
{onlineUsers.map((u) => (
@@ -188,22 +540,31 @@ export function Index() {
</div>
);
const sortButtons = !loading && !error && combined.length > 0 && (
const tabBar = (
<div className="feed-sort">
<button
type="button"
className={`feed-sort-btn${sort === "hot" ? " active" : ""}`}
onClick={() => setSort("hot")}
className={`feed-sort-btn${tab === "hot" ? " active" : ""}`}
onClick={() => setTab("hot")}
>
Hot
</button>
<button
type="button"
className={`feed-sort-btn${sort === "new" ? " active" : ""}`}
onClick={() => setSort("new")}
className={`feed-sort-btn${tab === "new" ? " active" : ""}`}
onClick={() => setTab("new")}
>
New
</button>
{user && (
<button
type="button"
className={`feed-sort-btn${tab === "followed" ? " active" : ""}`}
onClick={() => setTab("followed")}
>
Followed
</button>
)}
</div>
);
@@ -213,43 +574,102 @@ export function Index() {
centerSlot={
<div className="header-center-slot">
{presenceRow}
{sortButtons}
{tabBar}
</div>
}
/>
{/* Shown only on narrow viewports */}
<div className="index-below-header">
{sortButtons}
{tabBar}
{presenceRow}
</div>
{loading && <p className="index-status">Loading</p>}
{error && <p className="index-status index-status--error">{error}</p>}
{/* Hot / New feed */}
{tab !== "followed" && (
<>
{loading && <p className="index-status">Loading</p>}
{error && <p className="index-status index-status--error">{error}</p>}
{!loading && !error && combined.length === 0 && (
<p className="index-status">No dumps yet. Be the first!</p>
{!loading && !error && combined.length === 0 && (
<p className="index-status">No dumps yet. Be the first!</p>
)}
{!loading && !error && combined.length > 0 && (
<ul className="dump-feed">
{sortedDumps.map((dump) => (
<DumpCard
key={dump.id}
dump={dump}
voteCount={voteCounts[dump.id] ?? dump.voteCount}
voted={myVotes.has(dump.id)}
canVote={!!user}
castVote={castVote}
removeVote={removeVote}
isOwner={user?.id === dump.userId}
/>
))}
</ul>
)}
<div ref={sentinelRef} />
{loadingMore && <p className="feed-loading-more">Loading more</p>}
</>
)}
{!loading && !error && combined.length > 0 && (
<ul className="dump-feed">
{sortedDumps.map((dump) => (
<DumpCard
key={dump.id}
dump={dump}
voteCount={voteCounts[dump.id] ?? dump.voteCount}
voted={myVotes.has(dump.id)}
canVote={!!user}
{/* Followed feed */}
{tab === "followed" && user && (
<div className="followed-feed">
<div className="feed-sort followed-sub-nav">
<button
type="button"
className={`feed-sort-btn${
followedSection === "users" ? " active" : ""
}`}
onClick={() => setFollowedSection("users")}
>
From people
</button>
<button
type="button"
className={`feed-sort-btn${
followedSection === "playlists" ? " active" : ""
}`}
onClick={() => setFollowedSection("playlists")}
>
From playlists
</button>
</div>
{followedSection === "users" && (
<FollowedSubFeed
state={followedUsersDumps}
voteCounts={voteCounts}
myVotes={myVotes}
user={user}
castVote={castVote}
removeVote={removeVote}
isOwner={user?.id === dump.userId}
deletedDumpIds={deletedDumpIds}
emptyMessage="Follow some users to see their dumps here."
onLoadMore={loadMoreFollowedUsers}
/>
))}
</ul>
)}
)}
<div ref={sentinelRef} />
{loadingMore && <p className="feed-loading-more">Loading more</p>}
{followedSection === "playlists" && (
<FollowedSubFeed
state={followedPlaylistsDumps}
voteCounts={voteCounts}
myVotes={myVotes}
user={user}
castVote={castVote}
removeVote={removeVote}
deletedDumpIds={deletedDumpIds}
emptyMessage="Follow some public playlists to see their dumps here."
onLoadMore={loadMoreFollowedPlaylists}
/>
)}
</div>
)}
</div>
);
}

View File

@@ -1,192 +0,0 @@
import { useCallback, useEffect, useState } from "react";
import { API_URL } from "../config/api.ts";
import type { Playlist, RawPlaylist } from "../model.ts";
import { deserializePlaylist, type PaginatedData } from "../model.ts";
import { useAuth } from "../hooks/useAuth.ts";
import { useWS } from "../hooks/useWS.ts";
import { useInfiniteScroll } from "../hooks/useInfiniteScroll.ts";
import { NewPlaylistForm } from "../components/NewPlaylistForm.tsx";
import { ConfirmModal } from "../components/ConfirmModal.tsx";
import { PlaylistCard } from "../components/PlaylistCard.tsx";
import { PageShell } from "../components/PageShell.tsx";
const PAGE_SIZE = 20;
type State =
| { status: "loading" }
| { status: "error"; error: string }
| { status: "loaded"; playlists: Playlist[]; hasMore: boolean; page: number; loadingMore: boolean };
export function MyPlaylists() {
const { user, authFetch, token } = useAuth();
const { lastPlaylistEvent, deletedPlaylistIds } = useWS();
const [state, setState] = useState<State>({ status: "loading" });
const [confirmDeleteId, setConfirmDeleteId] = useState<string | null>(null);
useEffect(() => {
if (!user) return;
fetch(`${API_URL}/api/users/${user.username}/playlists?page=1&limit=${PAGE_SIZE}`, {
headers: { Authorization: `Bearer ${token}` },
})
.then((r) => r.json())
.then((body) => {
if (!body.success) throw new Error("Failed to load");
const { items, hasMore } = body.data as PaginatedData<RawPlaylist>;
setState({
status: "loaded",
playlists: items.map(deserializePlaylist),
hasMore,
page: 1,
loadingMore: false,
});
})
.catch((err) =>
setState({
status: "error",
error: err instanceof Error ? err.message : "Failed to load playlists",
})
);
}, [user?.username]);
const loadMore = useCallback(() => {
if (state.status !== "loaded" || !state.hasMore || state.loadingMore || !user) return;
const nextPage = state.page + 1;
setState((s) => s.status === "loaded" ? { ...s, loadingMore: true } : s);
fetch(
`${API_URL}/api/users/${user.username}/playlists?page=${nextPage}&limit=${PAGE_SIZE}`,
{ headers: { Authorization: `Bearer ${token}` } },
)
.then((r) => r.json())
.then((body) => {
const { items, hasMore } = body.data as PaginatedData<RawPlaylist>;
setState((s) =>
s.status === "loaded"
? {
...s,
playlists: [...s.playlists, ...items.map(deserializePlaylist)],
hasMore,
page: nextPage,
loadingMore: false,
}
: s
);
})
.catch(() =>
setState((s) => s.status === "loaded" ? { ...s, loadingMore: false } : s)
);
}, [state, user, token]);
const sentinelRef = useInfiniteScroll(
loadMore,
state.status === "loaded" && state.hasMore && !state.loadingMore,
);
// Real-time WS updates
useEffect(() => {
if (!lastPlaylistEvent || !user) return;
const ev = lastPlaylistEvent;
if (ev.type === "created" && ev.playlist?.userId === user.id) {
setState((s) => {
if (s.status !== "loaded") return s;
if (s.playlists.some((p) => p.id === ev.playlist!.id)) return s;
return { ...s, playlists: [ev.playlist!, ...s.playlists] };
});
} else if (ev.type === "updated" && ev.playlist?.userId === user.id) {
setState((s) =>
s.status === "loaded"
? {
...s,
playlists: s.playlists.map((p) =>
p.id === ev.playlist!.id ? ev.playlist! : p
),
}
: s
);
} else if (ev.type === "deleted") {
setState((s) =>
s.status === "loaded"
? {
...s,
playlists: s.playlists.filter((p) => p.id !== ev.playlistId),
}
: s
);
}
}, [lastPlaylistEvent, user]);
useEffect(() => {
if (!deletedPlaylistIds.size) return;
setState((s) =>
s.status === "loaded"
? {
...s,
playlists: s.playlists.filter((p) => !deletedPlaylistIds.has(p.id)),
}
: s
);
}, [deletedPlaylistIds]);
const handleDelete = async (playlistId: string) => {
await authFetch(`${API_URL}/api/playlists/${playlistId}`, {
method: "DELETE",
});
setState((s) =>
s.status === "loaded"
? { ...s, playlists: s.playlists.filter((p) => p.id !== playlistId) }
: s
);
};
return (
<PageShell>
<div className="my-playlists-header">
<h1 className="my-playlists-title">My Playlists</h1>
<NewPlaylistForm
toggleClassName="btn-primary"
onCreated={(p) =>
setState((s) => {
if (s.status !== "loaded") return s;
if (s.playlists.some((pl) => pl.id === p.id)) return s;
return { ...s, playlists: [p, ...s.playlists] };
})}
/>
</div>
{state.status === "loading" && <p className="page-loading">Loading</p>}
{state.status === "error" && <p className="form-error">{state.error}</p>}
{state.status === "loaded" && (
state.playlists.length === 0
? <p className="empty-state">No playlists yet. Create one!</p>
: (
<ul className="dump-feed">
{state.playlists.map((p) => (
<PlaylistCard
key={p.id}
playlist={p}
onDelete={() => setConfirmDeleteId(p.id)}
/>
))}
</ul>
)
)}
<div ref={sentinelRef} />
{state.status === "loaded" && state.loadingMore && (
<p className="feed-loading-more">Loading more</p>
)}
{confirmDeleteId && (
<ConfirmModal
message="Delete this playlist? This cannot be undone."
confirmLabel="Delete playlist"
onConfirm={() => {
handleDelete(confirmDeleteId);
setConfirmDeleteId(null);
}}
onCancel={() => setConfirmDeleteId(null)}
/>
)}
</PageShell>
);
}

340
src/pages/Notifications.tsx Normal file
View File

@@ -0,0 +1,340 @@
import { useEffect, useState } from "react";
import { Link } from "react-router";
import { API_URL } from "../config/api.ts";
import { useAuth } from "../hooks/useAuth.ts";
import { useWS } from "../hooks/useWS.ts";
import type {
DumpUpvotedData,
Notification,
NotificationData,
PaginatedData,
PlaylistDumpAddedData,
PlaylistFollowedData,
RawNotification,
UserDumpPostedData,
UserFollowedData,
} from "../model.ts";
import { deserializeNotification } from "../model.ts";
import { PageShell } from "../components/PageShell.tsx";
const PAGE_SIZE = 30;
type State =
| { status: "loading" }
| { status: "error"; error: string }
| {
status: "loaded";
items: Notification[];
hasMore: boolean;
page: number;
loadingMore: boolean;
};
type NotifIconKind = "upvote" | "follow" | "dump" | "playlist";
function notifIconKind(type: Notification["type"]): NotifIconKind {
switch (type) {
case "dump_upvoted":
return "upvote";
case "playlist_followed":
return "follow";
case "user_followed":
return "follow";
case "user_dump_posted":
return "dump";
case "playlist_dump_added":
return "playlist";
}
}
function NotifIcon({ type }: { type: Notification["type"] }) {
const kind = notifIconKind(type);
const glyphs: Record<NotifIconKind, string> = {
upvote: "▲",
follow: "►",
dump: "🚚",
playlist: "📜",
};
return (
<span className={`notif-icon notif-icon--${kind}`}>
{glyphs[kind]}
</span>
);
}
function notificationContent(n: Notification): React.ReactNode {
const data = n.data as NotificationData;
switch (n.type) {
case "user_followed": {
const d = data as UserFollowedData;
return (
<>
<Link to={`/users/${d.followerUsername}`} className="notif-link">
{d.followerUsername}
</Link>
{" started following you"}
</>
);
}
case "playlist_followed": {
const d = data as PlaylistFollowedData;
return (
<>
<Link to={`/users/${d.followerUsername}`} className="notif-link">
{d.followerUsername}
</Link>
{" followed your playlist "}
<Link to={`/playlists/${d.playlistId}`} className="notif-link">
{d.playlistTitle}
</Link>
</>
);
}
case "user_dump_posted": {
const d = data as UserDumpPostedData;
return (
<>
<Link to={`/users/${d.dumperUsername}`} className="notif-link">
{d.dumperUsername}
</Link>
{" posted "}
<Link to={`/dumps/${d.dumpId}`} className="notif-link">
{d.dumpTitle}
</Link>
</>
);
}
case "playlist_dump_added": {
const d = data as PlaylistDumpAddedData;
return (
<>
<Link to={`/dumps/${d.dumpId}`} className="notif-link">
{d.dumpTitle}
</Link>
{" was added to "}
<Link to={`/playlists/${d.playlistId}`} className="notif-link">
{d.playlistTitle}
</Link>
</>
);
}
case "dump_upvoted": {
const d = data as DumpUpvotedData;
return (
<>
<Link to={`/users/${d.voterUsername}`} className="notif-link">
{d.voterUsername}
</Link>
{" upvoted "}
<Link to={`/dumps/${d.dumpId}`} className="notif-link">
{d.dumpTitle}
</Link>
</>
);
}
default:
return "New notification";
}
}
function timeAgo(date: Date): string {
const secs = Math.floor((Date.now() - date.getTime()) / 1000);
if (secs < 60) return "just now";
const mins = Math.floor(secs / 60);
if (mins < 60) return `${mins}m ago`;
const hrs = Math.floor(mins / 60);
if (hrs < 24) return `${hrs}h ago`;
const days = Math.floor(hrs / 24);
if (days < 7) return `${days}d ago`;
return date.toLocaleDateString(undefined, { month: "short", day: "numeric" });
}
function startOfDay(d: Date): number {
return new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime();
}
function groupByDate(
items: Notification[],
): { label: string; items: Notification[] }[] {
const todayTs = startOfDay(new Date());
const yesterdayTs = todayTs - 86_400_000;
const buckets: Record<string, Notification[]> = {};
for (const n of items) {
const ts = startOfDay(n.createdAt);
const key = ts >= todayTs
? "Today"
: ts >= yesterdayTs
? "Yesterday"
: "Earlier";
(buckets[key] ??= []).push(n);
}
return (["Today", "Yesterday", "Earlier"] as const)
.filter((k) => buckets[k]?.length)
.map((label) => ({ label, items: buckets[label] }));
}
export function Notifications() {
const { authFetch } = useAuth();
const { clearUnreadNotifications, lastNotification } = useWS();
const [state, setState] = useState<State>({ status: "loading" });
useEffect(() => {
// 1. Fetch with original read state so unread items are highlighted
// 2. Only after displaying, mark all read on the server
authFetch(`${API_URL}/api/notifications?page=1&limit=${PAGE_SIZE}`)
.then((r) => r.json())
.then((body) => {
if (!body.success) throw new Error("Failed to load");
const data = body.data as PaginatedData<RawNotification>;
setState({
status: "loaded",
items: data.items.map(deserializeNotification),
hasMore: data.hasMore,
page: 1,
loadingMore: false,
});
// Mark read server-side after we've shown the unread state
return authFetch(`${API_URL}/api/notifications/read-all`, {
method: "POST",
});
})
.then(() => {
clearUnreadNotifications();
setState((s) =>
s.status === "loaded"
? { ...s, items: s.items.map((n) => ({ ...n, read: true })) }
: s
);
})
.catch((err) => {
if (err instanceof Error && err.message === "Failed to load") {
setState({ status: "error", error: err.message });
}
});
}, []);
useEffect(() => {
if (!lastNotification) return;
setState((s) => {
if (s.status !== "loaded") return s;
if (s.items.some((n) => n.id === lastNotification.id)) return s;
// Keep as unread so it gets highlighted when it arrives
return { ...s, items: [lastNotification, ...s.items] };
});
}, [lastNotification]);
const loadMore = () => {
if (state.status !== "loaded" || !state.hasMore || state.loadingMore) {
return;
}
const nextPage = state.page + 1;
setState((s) => s.status === "loaded" ? { ...s, loadingMore: true } : s);
authFetch(
`${API_URL}/api/notifications?page=${nextPage}&limit=${PAGE_SIZE}`,
)
.then((r) => r.json())
.then((body) => {
const data = body.data as PaginatedData<RawNotification>;
setState((s) =>
s.status === "loaded"
? {
...s,
items: [...s.items, ...data.items.map(deserializeNotification)],
hasMore: data.hasMore,
page: nextPage,
loadingMore: false,
}
: s
);
})
.catch(() =>
setState((s) =>
s.status === "loaded" ? { ...s, loadingMore: false } : s
)
);
};
const totalUnread = state.status === "loaded"
? state.items.filter((n) => !n.read).length
: 0;
return (
<PageShell>
<div className="notifications-page">
<div className="notifications-header">
<h1 className="notifications-title">
<span className="notifications-title-bell">🔔</span>
Notifications
</h1>
{state.status === "loaded" && totalUnread > 0 && (
<span className="notifications-unread-pill">
{totalUnread} new
</span>
)}
</div>
{state.status === "loading" && <p className="page-loading">Loading</p>}
{state.status === "error" && <p className="form-error">{state.error}
</p>}
{state.status === "loaded" && state.items.length === 0 && (
<div className="notifications-empty">
<span className="notifications-empty-icon">🔕</span>
<p>Nothing here yet.</p>
<p className="notifications-empty-hint">
You'll be notified when someone follows your playlists, upvotes
your dumps, or posts new content.
</p>
</div>
)}
{state.status === "loaded" && state.items.length > 0 &&
groupByDate(state.items).map(({ label, items }) => (
<section key={label} className="notif-group">
<h2 className="notif-group-label">{label}</h2>
<ul className="notification-list">
{items.map((n) => (
<li
key={n.id}
className={`notification-item${
!n.read ? " notification-item--unread" : ""
}`}
>
<NotifIcon type={n.type} />
<div className="notification-body">
<span className="notification-content">
{notificationContent(n)}
</span>
<time
className="notification-time"
dateTime={n.createdAt.toISOString()}
>
{timeAgo(n.createdAt)}
</time>
</div>
{!n.read && (
<span className="notif-dot" aria-hidden="true" />
)}
</li>
))}
</ul>
</section>
))}
{state.status === "loaded" && state.hasMore && (
<button
type="button"
className="load-more-btn"
onClick={loadMore}
disabled={state.loadingMore}
>
{state.loadingMore ? "Loading…" : "Load more"}
</button>
)}
</div>
</PageShell>
);
}

View File

@@ -1,5 +1,5 @@
import { useEffect, useRef, useState } from "react";
import { useNavigate, useParams } from "react-router";
import { Link, useNavigate, useParams } from "react-router";
import { API_URL } from "../config/api.ts";
import type { PlaylistWithDumps, RawPlaylistWithDumps } from "../model.ts";
import { deserializePlaylistWithDumps } from "../model.ts";
@@ -12,6 +12,7 @@ import { PageError } from "../components/PageError.tsx";
import { ConfirmModal } from "../components/ConfirmModal.tsx";
import { ImagePicker } from "../components/ImagePicker.tsx";
import { Markdown } from "../components/Markdown.tsx";
import { FollowPlaylistButton } from "../components/FollowButton.tsx";
type LoadState =
| { status: "loading" }
@@ -356,7 +357,6 @@ export function PlaylistDetail() {
setEditOpen(true);
};
const handleEditSave = async () => {
if (!playlistId || state.status !== "loaded") return;
setEditSaving(true);
@@ -392,7 +392,9 @@ export function PlaylistDetail() {
const handleDelete = async () => {
if (!playlistId) return;
await authFetch(`${API_URL}/api/playlists/${playlistId}`, { method: "DELETE" });
await authFetch(`${API_URL}/api/playlists/${playlistId}`, {
method: "DELETE",
});
navigate("/");
};
@@ -460,15 +462,58 @@ export function PlaylistDetail() {
<div className="playlist-detail-content">
{editOpen
? (
<input
type="text"
className="playlist-edit-input"
value={editTitle}
onChange={(e) => setEditTitle(e.target.value)}
autoFocus
/>
<div className="playlist-detail-title-row">
<input
type="text"
className="playlist-edit-input"
value={editTitle}
onChange={(e) => setEditTitle(e.target.value)}
autoFocus
/>
<button
type="button"
className="btn-primary"
disabled={editSaving}
onClick={handleEditSave}
>
{editSaving ? "Saving…" : "Save"}
</button>
<button
type="button"
className="form-cancel"
onClick={() => setEditOpen(false)}
>
Cancel
</button>
<button
type="button"
className="btn-danger"
onClick={() => setConfirmDelete(true)}
>
Delete
</button>
</div>
)
: <h1 className="playlist-detail-title">{playlist.title}</h1>}
: (
<div className="playlist-detail-title-row">
<h1 className="playlist-detail-title">{playlist.title}</h1>
{!isOwner && (
<FollowPlaylistButton
targetPlaylistId={playlist.id}
isPublic={playlist.isPublic}
/>
)}
{isOwner && (
<button
type="button"
className="playlist-edit-btn"
onClick={openEdit}
>
Edit
</button>
)}
</div>
)}
{editOpen
? (
@@ -516,6 +561,14 @@ export function PlaylistDetail() {
>
{playlist.isPublic ? "public" : "private"}
</span>
{playlist.ownerUsername && (
<Link
to={`/users/${playlist.ownerUsername}`}
className="playlist-detail-owner"
>
@{playlist.ownerUsername}
</Link>
)}
<time
dateTime={playlist.createdAt.toISOString()}
title={playlist.createdAt.toLocaleString()}
@@ -527,47 +580,6 @@ export function PlaylistDetail() {
</div>
{editError && <p className="form-error">{editError}</p>}
</div>
{isOwner && (
<div className="playlist-header-actions">
{editOpen
? (
<>
<button
type="button"
className="btn-primary"
disabled={editSaving}
onClick={handleEditSave}
>
{editSaving ? "Saving…" : "Save"}
</button>
<button
type="button"
className="btn-secondary"
onClick={() => setEditOpen(false)}
>
Cancel
</button>
<button
type="button"
className="btn-danger"
onClick={() => setConfirmDelete(true)}
>
Delete
</button>
</>
)
: (
<button
type="button"
className="playlist-edit-btn"
onClick={openEdit}
>
Edit
</button>
)}
</div>
)}
</div>
</div>

260
src/pages/UserDumps.tsx Normal file
View File

@@ -0,0 +1,260 @@
import {
useCallback,
useEffect,
useLayoutEffect,
useRef,
useState,
} from "react";
import { Link, useParams } from "react-router";
import { API_URL } from "../config/api.ts";
import type { Dump, PaginatedData, PublicUser, RawDump } from "../model.ts";
import { deserializeDump, deserializePublicUser } from "../model.ts";
import { useAuth } from "../hooks/useAuth.ts";
import { useWS } from "../hooks/useWS.ts";
import { useInfiniteScroll } from "../hooks/useInfiniteScroll.ts";
import { useFeedCache } from "../hooks/useFeedCache.ts";
import { Avatar } from "../components/Avatar.tsx";
import { DumpCard } from "../components/DumpCard.tsx";
import { DumpCreateModal } from "../components/DumpCreateModal.tsx";
import { PageShell } from "../components/PageShell.tsx";
import { PageError } from "../components/PageError.tsx";
const PAGE_SIZE = 20;
const hydrateDump = (raw: Dump): Dump =>
deserializeDump(raw as unknown as RawDump);
type State =
| { status: "loading" }
| { status: "error"; error: string }
| {
status: "loaded";
profileUser: PublicUser;
dumps: Dump[];
hasMore: boolean;
page: number;
loadingMore: boolean;
};
export function UserDumps() {
const { username } = useParams();
const { user: me, token } = useAuth();
const { voteCounts, myVotes, castVote, removeVote } = useWS();
const { cached, saveState } = useFeedCache<Dump>(
`feed:user-dumps-full:${username ?? ""}`,
hydrateDump,
);
const [state, setState] = useState<State>({ status: "loading" });
const [createModalOpen, setCreateModalOpen] = useState(false);
useEffect(() => {
if (!username) return;
setState({ status: "loading" });
if (cached) {
fetch(`${API_URL}/api/users/${username}`)
.then((r) => r.json())
.then((body) => {
if (!body.success) throw new Error("User not found");
setState({
status: "loaded",
profileUser: deserializePublicUser(body.data),
dumps: cached.items,
hasMore: cached.hasMore,
page: cached.page,
loadingMore: false,
});
})
.catch((err) =>
setState({
status: "error",
error: err instanceof Error ? err.message : "Failed to load",
})
);
return;
}
const authHeaders: HeadersInit = token
? { Authorization: `Bearer ${token}` }
: {};
Promise.all([
fetch(`${API_URL}/api/users/${username}`),
fetch(
`${API_URL}/api/users/${username}/dumps?page=1&limit=${PAGE_SIZE}`,
{ headers: authHeaders },
),
])
.then(([userRes, dumpsRes]) =>
Promise.all([userRes.json(), dumpsRes.json()])
)
.then(([userBody, dumpsBody]) => {
if (!userBody.success) throw new Error("User not found");
const { items, hasMore } = dumpsBody.success
? dumpsBody.data as PaginatedData<RawDump>
: { items: [], hasMore: false };
setState({
status: "loaded",
profileUser: deserializePublicUser(userBody.data),
dumps: items.map(deserializeDump),
hasMore,
page: 1,
loadingMore: false,
});
})
.catch((err) =>
setState({
status: "error",
error: err instanceof Error ? err.message : "Failed to load",
})
);
}, [username]);
const loadMore = useCallback(() => {
if (
state.status !== "loaded" || !state.hasMore || state.loadingMore ||
!username
) return;
const nextPage = state.page + 1;
setState((s) => s.status === "loaded" ? { ...s, loadingMore: true } : s);
fetch(
`${API_URL}/api/users/${username}/dumps?page=${nextPage}&limit=${PAGE_SIZE}`,
{ headers: token ? { Authorization: `Bearer ${token}` } : {} },
)
.then((r) => r.json())
.then((body) => {
const { items, hasMore } = body.data as PaginatedData<RawDump>;
setState((s) =>
s.status === "loaded"
? {
...s,
dumps: [...s.dumps, ...items.map(deserializeDump)],
hasMore,
page: nextPage,
loadingMore: false,
}
: s
);
})
.catch(() =>
setState((s) =>
s.status === "loaded" ? { ...s, loadingMore: false } : s
)
);
}, [state, username, token]);
const sentinelRef = useInfiniteScroll(
loadMore,
state.status === "loaded" && state.hasMore && !state.loadingMore,
);
useEffect(() => {
if (state.status !== "loaded") return;
let timer: ReturnType<typeof setTimeout>;
const onScroll = () => {
clearTimeout(timer);
timer = setTimeout(() => {
if (state.status !== "loaded") return;
saveState(state.dumps, state.page, state.hasMore, globalThis.scrollY);
}, 100);
};
globalThis.addEventListener("scroll", onScroll, { passive: true });
return () => {
globalThis.removeEventListener("scroll", onScroll);
clearTimeout(timer);
};
}, [state, saveState]);
const scrollRestored = useRef(false);
useLayoutEffect(() => {
if (cached?.scrollY == null || scrollRestored.current) return;
if (state.status === "loaded") {
globalThis.scrollTo(0, cached.scrollY);
scrollRestored.current = true;
}
}, [state.status, cached]);
if (state.status === "loading") {
return (
<PageShell>
<p className="page-loading">Loading</p>
</PageShell>
);
}
if (state.status === "error") {
return (
<PageError
message={state.error}
actions={
<Link to={`/users/${username}`} className="logout-btn">
Back to profile
</Link>
}
/>
);
}
const { profileUser, dumps, hasMore, loadingMore } = state;
const isOwnProfile = me?.username === profileUser.username;
return (
<PageShell>
<div className="profile-subpage-header">
<Link
to={`/users/${username}`}
className="profile-subpage-back"
>
{profileUser.username}
</Link>
<div className="profile-subpage-title-row">
<Avatar
userId={profileUser.id}
username={profileUser.username}
hasAvatar={!!profileUser.avatarMime}
size={36}
/>
<h1 className="profile-subpage-title">Dumps</h1>
{isOwnProfile && (
<button
type="button"
className="new-playlist-toggle"
onClick={() => setCreateModalOpen(true)}
>
+ New dump
</button>
)}
</div>
</div>
{createModalOpen && (
<DumpCreateModal onClose={() => setCreateModalOpen(false)} />
)}
{dumps.length === 0
? <p className="empty-state">Nothing here yet.</p>
: (
<ul className="dump-feed">
{dumps.map((dump) => (
<DumpCard
key={dump.id}
dump={dump}
voteCount={voteCounts[dump.id] ?? dump.voteCount}
voted={myVotes.has(dump.id)}
canVote={!!me}
castVote={castVote}
removeVote={removeVote}
isOwner={isOwnProfile}
/>
))}
</ul>
)}
<div ref={sentinelRef} />
{loadingMore && <p className="feed-loading-more">Loading more</p>}
{!hasMore && dumps.length > 0 && (
<p className="index-status">All {dumps.length} dumps loaded.</p>
)}
</PageShell>
);
}

494
src/pages/UserPlaylists.tsx Normal file
View File

@@ -0,0 +1,494 @@
import {
useCallback,
useEffect,
useLayoutEffect,
useRef,
useState,
} from "react";
import { Link, useParams } from "react-router";
import { API_URL } from "../config/api.ts";
import type {
PaginatedData,
Playlist,
PublicUser,
RawPlaylist,
} from "../model.ts";
import { deserializePlaylist, deserializePublicUser } from "../model.ts";
import { useAuth } from "../hooks/useAuth.ts";
import { useWS } from "../hooks/useWS.ts";
import { useInfiniteScroll } from "../hooks/useInfiniteScroll.ts";
import { useFeedCache } from "../hooks/useFeedCache.ts";
import { Avatar } from "../components/Avatar.tsx";
import { PlaylistCard } from "../components/PlaylistCard.tsx";
import { NewPlaylistForm } from "../components/NewPlaylistForm.tsx";
import { ConfirmModal } from "../components/ConfirmModal.tsx";
import { PageShell } from "../components/PageShell.tsx";
import { PageError } from "../components/PageError.tsx";
const PAGE_SIZE = 20;
const hydratePlaylist = (raw: Playlist): Playlist =>
deserializePlaylist(raw as unknown as RawPlaylist);
interface PlaylistFeed {
items: Playlist[];
hasMore: boolean;
page: number;
loadingMore: boolean;
}
type State =
| { status: "loading" }
| { status: "error"; error: string }
| {
status: "loaded";
profileUser: PublicUser;
created: PlaylistFeed;
followed: PlaylistFeed;
};
function initialFeed(items: Playlist[], hasMore: boolean): PlaylistFeed {
return { items, hasMore, page: 1, loadingMore: false };
}
export function UserPlaylists() {
const { username } = useParams();
const { user: me, authFetch, token } = useAuth();
const { lastPlaylistEvent, deletedPlaylistIds } = useWS();
const { cached: cachedCreated, saveState: saveCreated } = useFeedCache<
Playlist
>(
`feed:user-playlists:${username ?? ""}`,
hydratePlaylist,
);
const { cached: cachedFollowed, saveState: saveFollowed } = useFeedCache<
Playlist
>(
`feed:user-followed-playlists:${username ?? ""}`,
hydratePlaylist,
);
const [state, setState] = useState<State>({ status: "loading" });
const [confirmDeleteId, setConfirmDeleteId] = useState<string | null>(null);
useEffect(() => {
if (!username) return;
setState({ status: "loading" });
const authHeaders: HeadersInit = token
? { Authorization: `Bearer ${token}` }
: {};
if (cachedCreated && cachedFollowed) {
fetch(`${API_URL}/api/users/${username}`)
.then((r) => r.json())
.then((body) => {
if (!body.success) throw new Error("User not found");
setState({
status: "loaded",
profileUser: deserializePublicUser(body.data),
created: {
items: cachedCreated.items,
hasMore: cachedCreated.hasMore,
page: cachedCreated.page,
loadingMore: false,
},
followed: {
items: cachedFollowed.items,
hasMore: cachedFollowed.hasMore,
page: cachedFollowed.page,
loadingMore: false,
},
});
})
.catch((err) =>
setState({
status: "error",
error: err instanceof Error ? err.message : "Failed to load",
})
);
return;
}
Promise.all([
fetch(`${API_URL}/api/users/${username}`),
fetch(
`${API_URL}/api/users/${username}/playlists?page=1&limit=${PAGE_SIZE}`,
{ headers: authHeaders },
),
fetch(
`${API_URL}/api/users/${username}/followed-playlists?page=1&limit=${PAGE_SIZE}`,
),
])
.then(([userRes, createdRes, followedRes]) =>
Promise.all([userRes.json(), createdRes.json(), followedRes.json()])
)
.then(([userBody, createdBody, followedBody]) => {
if (!userBody.success) throw new Error("User not found");
const createdData = createdBody.success
? createdBody.data as PaginatedData<RawPlaylist>
: { items: [], hasMore: false };
const followedData = followedBody.success
? followedBody.data as PaginatedData<RawPlaylist>
: { items: [], hasMore: false };
setState({
status: "loaded",
profileUser: deserializePublicUser(userBody.data),
created: initialFeed(
createdData.items.map(deserializePlaylist),
createdData.hasMore,
),
followed: initialFeed(
followedData.items.map(deserializePlaylist),
followedData.hasMore,
),
});
})
.catch((err) =>
setState({
status: "error",
error: err instanceof Error ? err.message : "Failed to load",
})
);
}, [username]);
const loadMoreCreated = useCallback(() => {
if (
state.status !== "loaded" || !state.created.hasMore ||
state.created.loadingMore || !username
) return;
const nextPage = state.created.page + 1;
setState((s) =>
s.status === "loaded"
? { ...s, created: { ...s.created, loadingMore: true } }
: s
);
fetch(
`${API_URL}/api/users/${username}/playlists?page=${nextPage}&limit=${PAGE_SIZE}`,
{ headers: token ? { Authorization: `Bearer ${token}` } : {} },
)
.then((r) => r.json())
.then((body) => {
const { items, hasMore } = body.data as PaginatedData<RawPlaylist>;
setState((s) =>
s.status === "loaded"
? {
...s,
created: {
items: [...s.created.items, ...items.map(deserializePlaylist)],
hasMore,
page: nextPage,
loadingMore: false,
},
}
: s
);
})
.catch(() =>
setState((s) =>
s.status === "loaded"
? { ...s, created: { ...s.created, loadingMore: false } }
: s
)
);
}, [state, username, token]);
const loadMoreFollowed = useCallback(() => {
if (
state.status !== "loaded" || !state.followed.hasMore ||
state.followed.loadingMore || !username
) return;
const nextPage = state.followed.page + 1;
setState((s) =>
s.status === "loaded"
? { ...s, followed: { ...s.followed, loadingMore: true } }
: s
);
fetch(
`${API_URL}/api/users/${username}/followed-playlists?page=${nextPage}&limit=${PAGE_SIZE}`,
)
.then((r) => r.json())
.then((body) => {
const { items, hasMore } = body.data as PaginatedData<RawPlaylist>;
setState((s) =>
s.status === "loaded"
? {
...s,
followed: {
items: [...s.followed.items, ...items.map(deserializePlaylist)],
hasMore,
page: nextPage,
loadingMore: false,
},
}
: s
);
})
.catch(() =>
setState((s) =>
s.status === "loaded"
? { ...s, followed: { ...s.followed, loadingMore: false } }
: s
)
);
}, [state, username]);
const createdSentinelRef = useInfiniteScroll(
loadMoreCreated,
state.status === "loaded" && state.created.hasMore &&
!state.created.loadingMore,
);
const followedSentinelRef = useInfiniteScroll(
loadMoreFollowed,
state.status === "loaded" && state.followed.hasMore &&
!state.followed.loadingMore,
);
// Real-time WS playlist updates
useEffect(() => {
if (!lastPlaylistEvent || state.status !== "loaded") return;
const ev = lastPlaylistEvent;
const isOwnProfile = me?.username === state.profileUser.username;
if (ev.type === "created" && ev.playlist?.userId === state.profileUser.id) {
if (ev.playlist.isPublic || isOwnProfile) {
setState((s) => {
if (s.status !== "loaded") return s;
if (s.created.items.some((p) => p.id === ev.playlist!.id)) return s;
return {
...s,
created: {
...s.created,
items: [ev.playlist!, ...s.created.items],
},
};
});
}
} else if (ev.type === "updated") {
setState((s) => {
if (s.status !== "loaded") return s;
const updatedCreated = ev.playlist?.userId === state.profileUser.id
? s.created.items
.map((p) => p.id === ev.playlist!.id ? ev.playlist! : p)
.filter((p) => p.isPublic || isOwnProfile)
: s.created.items;
const updatedFollowed = s.followed.items.map((p) =>
p.id === ev.playlist?.id ? ev.playlist! : p
).filter((p) => p.isPublic);
return {
...s,
created: { ...s.created, items: updatedCreated },
followed: { ...s.followed, items: updatedFollowed },
};
});
} else if (ev.type === "deleted") {
setState((s) =>
s.status !== "loaded" ? s : {
...s,
created: {
...s.created,
items: s.created.items.filter((p) => p.id !== ev.playlistId),
},
followed: {
...s.followed,
items: s.followed.items.filter((p) => p.id !== ev.playlistId),
},
}
);
}
}, [lastPlaylistEvent, me]);
useEffect(() => {
if (!deletedPlaylistIds.size || state.status !== "loaded") return;
setState((s) =>
s.status !== "loaded" ? s : {
...s,
created: {
...s.created,
items: s.created.items.filter((p) => !deletedPlaylistIds.has(p.id)),
},
followed: {
...s.followed,
items: s.followed.items.filter((p) => !deletedPlaylistIds.has(p.id)),
},
}
);
}, [deletedPlaylistIds]);
// Scroll save
useEffect(() => {
if (state.status !== "loaded") return;
let timer: ReturnType<typeof setTimeout>;
const onScroll = () => {
clearTimeout(timer);
timer = setTimeout(() => {
if (state.status !== "loaded") return;
const y = globalThis.scrollY;
saveCreated(
state.created.items,
state.created.page,
state.created.hasMore,
y,
);
saveFollowed(
state.followed.items,
state.followed.page,
state.followed.hasMore,
y,
);
}, 100);
};
globalThis.addEventListener("scroll", onScroll, { passive: true });
return () => {
globalThis.removeEventListener("scroll", onScroll);
clearTimeout(timer);
};
}, [state, saveCreated, saveFollowed]);
const scrollRestored = useRef(false);
useLayoutEffect(() => {
if (cachedCreated?.scrollY == null || scrollRestored.current) return;
if (state.status === "loaded") {
globalThis.scrollTo(0, cachedCreated.scrollY);
scrollRestored.current = true;
}
}, [state.status, cachedCreated]);
const handleDelete = async (playlistId: string) => {
await authFetch(`${API_URL}/api/playlists/${playlistId}`, {
method: "DELETE",
});
setState((s) =>
s.status === "loaded"
? {
...s,
created: {
...s.created,
items: s.created.items.filter((p) => p.id !== playlistId),
},
}
: s
);
};
if (state.status === "loading") {
return (
<PageShell>
<p className="page-loading">Loading</p>
</PageShell>
);
}
if (state.status === "error") {
return (
<PageError
message={state.error}
actions={
<Link to={`/users/${username}`} className="logout-btn">
Back to profile
</Link>
}
/>
);
}
const { profileUser, created, followed } = state;
const isOwnProfile = me?.username === profileUser.username;
return (
<PageShell>
<div className="profile-subpage-header">
<Link to={`/users/${username}`} className="profile-subpage-back">
{profileUser.username}
</Link>
<div className="profile-subpage-title-row">
<Avatar
userId={profileUser.id}
username={profileUser.username}
hasAvatar={!!profileUser.avatarMime}
size={36}
/>
<h1 className="profile-subpage-title">Playlists</h1>
{isOwnProfile && (
<NewPlaylistForm
toggleClassName="btn-primary"
onCreated={(p) =>
setState((s) => {
if (s.status !== "loaded") return s;
if (s.created.items.some((pl) => pl.id === p.id)) return s;
return {
...s,
created: { ...s.created, items: [p, ...s.created.items] },
};
})}
/>
)}
</div>
</div>
<section className="profile-section">
<div className="profile-section-header">
<h2 className="profile-section-title">
Created ({created.items.length}
{created.hasMore ? "+" : ""})
</h2>
</div>
{created.items.length === 0
? <p className="empty-state">No playlists yet.</p>
: (
<ul className="dump-feed">
{created.items.map((p) => (
<PlaylistCard
key={p.id}
playlist={p}
isOwner={isOwnProfile}
onDelete={isOwnProfile
? () => setConfirmDeleteId(p.id)
: undefined}
/>
))}
</ul>
)}
<div ref={createdSentinelRef} />
{created.loadingMore && (
<p className="feed-loading-more">Loading more</p>
)}
</section>
<section className="profile-section">
<div className="profile-section-header">
<h2 className="profile-section-title">
Followed ({followed.items.length}
{followed.hasMore ? "+" : ""})
</h2>
</div>
{followed.items.length === 0
? <p className="empty-state">No followed playlists yet.</p>
: (
<ul className="dump-feed">
{followed.items.map((p) => (
<PlaylistCard key={p.id} playlist={p} />
))}
</ul>
)}
<div ref={followedSentinelRef} />
{followed.loadingMore && (
<p className="feed-loading-more">Loading more</p>
)}
</section>
{confirmDeleteId && (
<ConfirmModal
message="Delete this playlist? This cannot be undone."
confirmLabel="Delete playlist"
onConfirm={() => {
handleDelete(confirmDeleteId);
setConfirmDeleteId(null);
}}
onCancel={() => setConfirmDeleteId(null)}
/>
)}
</PageShell>
);
}

View File

@@ -1,5 +1,5 @@
import React, { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
import { useNavigate, useParams } from "react-router";
import React, { useEffect, useLayoutEffect, useRef, useState } from "react";
import { Link, useNavigate, useParams } from "react-router";
import { API_URL } from "../config/api.ts";
import type { Dump, PaginatedData, PublicUser } from "../model.ts";
@@ -19,15 +19,66 @@ import { PageShell } from "../components/PageShell.tsx";
import { PageError } from "../components/PageError.tsx";
import { useAuth } from "../hooks/useAuth.ts";
import { useWS } from "../hooks/useWS.ts";
import { useInfiniteScroll } from "../hooks/useInfiniteScroll.ts";
import type { Playlist, RawPlaylist } from "../model.ts";
import { deserializePlaylist } from "../model.ts";
import { useFeedCache } from "../hooks/useFeedCache.ts";
import { DumpCreateModal } from "../components/DumpCreateModal.tsx";
import { FollowUserButton } from "../components/FollowButton.tsx";
const PAGE_SIZE = 20;
const hydrateDump = (raw: Dump): Dump => deserializeDump(raw as unknown as RawDump);
function InviteButton() {
const { authFetch } = useAuth();
const [inviteUrl, setInviteUrl] = useState<string | null>(null);
const [copied, setCopied] = useState(false);
const [error, setError] = useState<string | null>(null);
async function generate() {
try {
const res = await authFetch(`${API_URL}/api/invites`, { method: "POST" });
const body = await res.json();
if (body.success) {
const url =
`${globalThis.location.origin}/register?token=${body.data.token}`;
setInviteUrl(url);
} else {
setError("Failed to generate invite");
}
} catch {
setError("Failed to generate invite");
}
}
async function copy() {
if (!inviteUrl) return;
await navigator.clipboard.writeText(inviteUrl);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}
if (inviteUrl) {
return (
<div className="invite-result">
<span className="invite-url">{inviteUrl}</span>
<button type="button" className="invite-copy-btn" onClick={copy}>
{copied ? "Copied!" : "Copy"}
</button>
</div>
);
}
return (
<div className="invite-generate">
<button type="button" className="invite-btn" onClick={generate}>
+ Invite someone
</button>
{error && <p className="form-error">{error}</p>}
</div>
);
}
const hydrateDump = (raw: Dump): Dump =>
deserializeDump(raw as unknown as RawDump);
const hydratePlaylist = (raw: Playlist): Playlist =>
deserializePlaylist(raw as unknown as RawPlaylist);
@@ -75,7 +126,9 @@ export function UserPublicProfile() {
`feed:profile-votes:${username ?? ""}`,
hydrateDump,
);
const { cached: cachedPlaylists, saveState: savePlaylists } = useFeedCache<Playlist>(
const { cached: cachedPlaylists, saveState: savePlaylists } = useFeedCache<
Playlist
>(
`feed:profile-playlists:${username ?? ""}`,
hydratePlaylist,
);
@@ -104,31 +157,64 @@ export function UserPublicProfile() {
setState({
status: "loaded",
user: deserializePublicUser(body.data),
dumps: { items: cachedDumps.items, hasMore: cachedDumps.hasMore, page: cachedDumps.page, loadingMore: false },
votes: { items: cachedVotes.items, hasMore: cachedVotes.hasMore, page: cachedVotes.page, loadingMore: false },
playlists: { items: cachedPlaylists.items, hasMore: cachedPlaylists.hasMore, page: cachedPlaylists.page, loadingMore: false },
dumps: {
items: cachedDumps.items,
hasMore: cachedDumps.hasMore,
page: cachedDumps.page,
loadingMore: false,
},
votes: {
items: cachedVotes.items,
hasMore: cachedVotes.hasMore,
page: cachedVotes.page,
loadingMore: false,
},
playlists: {
items: cachedPlaylists.items,
hasMore: cachedPlaylists.hasMore,
page: cachedPlaylists.page,
loadingMore: false,
},
});
setProfileVotedIds(new Set(cachedVotes.items.map((d) => d.id)));
})
.catch((err) =>
setState({ status: "error", error: err instanceof Error ? err.message : "Failed to load profile" })
setState({
status: "error",
error: err instanceof Error
? err.message
: "Failed to load profile",
})
);
return;
}
(async () => {
try {
const authHeaders = token ? { Authorization: `Bearer ${token}` } : {};
const authHeaders: HeadersInit = token
? { Authorization: `Bearer ${token}` }
: {};
const [userRes, dumpsRes, votesRes, playlistsRes] = await Promise.all([
fetch(`${API_URL}/api/users/${username}`),
fetch(`${API_URL}/api/users/${username}/dumps?page=1&limit=${PAGE_SIZE}`, { headers: authHeaders }),
fetch(`${API_URL}/api/users/${username}/votes?page=1&limit=${PAGE_SIZE}`, { headers: authHeaders }),
fetch(`${API_URL}/api/users/${username}/playlists?page=1&limit=${PAGE_SIZE}`, { headers: authHeaders }),
fetch(
`${API_URL}/api/users/${username}/dumps?page=1&limit=${PAGE_SIZE}`,
{ headers: authHeaders },
),
fetch(
`${API_URL}/api/users/${username}/votes?page=1&limit=${PAGE_SIZE}`,
{ headers: authHeaders },
),
fetch(
`${API_URL}/api/users/${username}/playlists?page=1&limit=${PAGE_SIZE}`,
{ headers: authHeaders },
),
]);
if (!userRes.ok) {
throw new Error(
userRes.status === 404 ? "User not found" : `HTTP ${userRes.status}`,
userRes.status === 404
? "User not found"
: `HTTP ${userRes.status}`,
);
}
@@ -154,7 +240,10 @@ export function UserPublicProfile() {
setState({
status: "loaded",
user: deserializePublicUser(userBody.data),
dumps: initialList(dumpsData.items.map(deserializeDump), dumpsData.hasMore),
dumps: initialList(
dumpsData.items.map(deserializeDump),
dumpsData.hasMore,
),
votes: initialList(voteItems, votesData.hasMore),
playlists: initialList(
playlistsData.items.map(deserializePlaylist),
@@ -189,7 +278,10 @@ export function UserPublicProfile() {
myVotes.has(d.id) && !prev.has(d.id) && !voteIds.has(d.id)
);
if (toAdd.length === 0) return s;
return { ...s, votes: { ...s.votes, items: [...toAdd, ...s.votes.items] } };
return {
...s,
votes: { ...s.votes, items: [...toAdd, ...s.votes.items] },
};
});
prevMyVotesRef.current = new Set(myVotes);
}, [myVotes, me, profileUserId]);
@@ -219,10 +311,16 @@ export function UserPublicProfile() {
if (!body.success) return;
const dump = deserializeDump(body.data);
setState((s) => {
if (s.status !== "loaded" || s.votes.items.some((d) => d.id === dumpId)) {
if (
s.status !== "loaded" ||
s.votes.items.some((d) => d.id === dumpId)
) {
return s;
}
return { ...s, votes: { ...s.votes, items: [dump, ...s.votes.items] } };
return {
...s,
votes: { ...s.votes, items: [dump, ...s.votes.items] },
};
});
})
.catch(() => {});
@@ -243,7 +341,10 @@ export function UserPublicProfile() {
if (s.playlists.items.some((p) => p.id === ev.playlist!.id)) return s;
return {
...s,
playlists: { ...s.playlists, items: [ev.playlist!, ...s.playlists.items] },
playlists: {
...s.playlists,
items: [ev.playlist!, ...s.playlists.items],
},
};
});
}
@@ -278,7 +379,9 @@ export function UserPublicProfile() {
if (deletedPlaylistIds.size === 0 || state.status !== "loaded") return;
setState((s) => {
if (s.status !== "loaded") return s;
const filtered = s.playlists.items.filter((p) => !deletedPlaylistIds.has(p.id));
const filtered = s.playlists.items.filter((p) =>
!deletedPlaylistIds.has(p.id)
);
if (filtered.length === s.playlists.items.length) return s;
return { ...s, playlists: { ...s.playlists, items: filtered } };
});
@@ -292,14 +395,22 @@ export function UserPublicProfile() {
clearTimeout(timer);
timer = setTimeout(() => {
if (state.status !== "loaded") return;
const y = window.scrollY;
const y = globalThis.scrollY;
saveDumps(state.dumps.items, state.dumps.page, state.dumps.hasMore, y);
saveVotes(state.votes.items, state.votes.page, state.votes.hasMore, y);
savePlaylists(state.playlists.items, state.playlists.page, state.playlists.hasMore, y);
savePlaylists(
state.playlists.items,
state.playlists.page,
state.playlists.hasMore,
y,
);
}, 100);
};
window.addEventListener("scroll", onScroll, { passive: true });
return () => { window.removeEventListener("scroll", onScroll); clearTimeout(timer); };
globalThis.addEventListener("scroll", onScroll, { passive: true });
return () => {
globalThis.removeEventListener("scroll", onScroll);
clearTimeout(timer);
};
}, [state, saveDumps, saveVotes, savePlaylists]);
// Restore scroll position after cache restoration
@@ -307,94 +418,10 @@ export function UserPublicProfile() {
useLayoutEffect(() => {
if (cachedDumps?.scrollY == null || scrollRestored.current) return;
if (state.status === "loaded") {
window.scrollTo(0, cachedDumps.scrollY);
globalThis.scrollTo(0, cachedDumps.scrollY);
scrollRestored.current = true;
}
// cachedDumps is stable (read once), safe to omit
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [state.status]);
const loadMoreDumps = useCallback(() => {
if (state.status !== "loaded" || !state.dumps.hasMore || state.dumps.loadingMore || !username) return;
const nextPage = state.dumps.page + 1;
setState((s) => s.status === "loaded" ? { ...s, dumps: { ...s.dumps, loadingMore: true } } : s);
fetch(`${API_URL}/api/users/${username}/dumps?page=${nextPage}&limit=${PAGE_SIZE}`, {
headers: token ? { Authorization: `Bearer ${token}` } : {},
})
.then((r) => r.json())
.then((body) => {
const { items, hasMore } = body.data as PaginatedData<RawDump>;
setState((s) =>
s.status === "loaded"
? {
...s,
dumps: {
items: [...s.dumps.items, ...items.map(deserializeDump)],
hasMore,
page: nextPage,
loadingMore: false,
},
}
: s
);
})
.catch(() => setState((s) => s.status === "loaded" ? { ...s, dumps: { ...s.dumps, loadingMore: false } } : s));
}, [state, username, token]);
const loadMoreVotes = useCallback(() => {
if (state.status !== "loaded" || !state.votes.hasMore || state.votes.loadingMore || !username) return;
const nextPage = state.votes.page + 1;
setState((s) => s.status === "loaded" ? { ...s, votes: { ...s.votes, loadingMore: true } } : s);
fetch(`${API_URL}/api/users/${username}/votes?page=${nextPage}&limit=${PAGE_SIZE}`, {
headers: token ? { Authorization: `Bearer ${token}` } : {},
})
.then((r) => r.json())
.then((body) => {
const { items, hasMore } = body.data as PaginatedData<RawDump>;
setState((s) =>
s.status === "loaded"
? {
...s,
votes: {
items: [...s.votes.items, ...items.map(deserializeDump)],
hasMore,
page: nextPage,
loadingMore: false,
},
}
: s
);
})
.catch(() => setState((s) => s.status === "loaded" ? { ...s, votes: { ...s.votes, loadingMore: false } } : s));
}, [state, username, token]);
const loadMorePlaylists = useCallback(() => {
if (state.status !== "loaded" || !state.playlists.hasMore || state.playlists.loadingMore || !username) return;
const nextPage = state.playlists.page + 1;
setState((s) => s.status === "loaded" ? { ...s, playlists: { ...s.playlists, loadingMore: true } } : s);
fetch(
`${API_URL}/api/users/${username}/playlists?page=${nextPage}&limit=${PAGE_SIZE}`,
{ headers: token ? { Authorization: `Bearer ${token}` } : {} },
)
.then((r) => r.json())
.then((body) => {
const { items, hasMore } = body.data as PaginatedData<RawPlaylist>;
setState((s) =>
s.status === "loaded"
? {
...s,
playlists: {
items: [...s.playlists.items, ...items.map(deserializePlaylist)],
hasMore,
page: nextPage,
loadingMore: false,
},
}
: s
);
})
.catch(() => setState((s) => s.status === "loaded" ? { ...s, playlists: { ...s.playlists, loadingMore: false } } : s));
}, [state, username, token]);
}, [state.status, cachedDumps]);
const handleAvatarUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
@@ -432,7 +459,10 @@ export function UserPublicProfile() {
setState((prev) =>
prev.status === "loaded"
? { ...prev, user: { ...prev.user, avatarMime: body.data?.avatarMime } }
? {
...prev,
user: { ...prev.user, avatarMime: body.data?.avatarMime },
}
: prev
);
} catch {
@@ -504,11 +534,37 @@ export function UserPublicProfile() {
</div>
<div>
<h1 className="profile-username">{profileUser.username}</h1>
{profileUser.invitedByUsername
? (
<p className="profile-invited-by">
invited by{" "}
<Link
to={`/users/${profileUser.invitedByUsername}`}
className="profile-invited-by-link"
>
@{profileUser.invitedByUsername}
</Link>
</p>
)
: (
<p className="profile-invited-by profile-invited-by--founding">
O.G.
</p>
)}
{avatarError && <p className="form-error">{avatarError}</p>}
{!isOwnProfile && (
<FollowUserButton
targetUserId={profileUser.id}
targetUsername={profileUser.username}
/>
)}
{isOwnProfile && (
<button type="button" className="logout-btn" onClick={logout}>
Log out
</button>
<div className="profile-own-actions">
<InviteButton />
<button type="button" className="logout-btn" onClick={logout}>
Log out
</button>
</div>
)}
</div>
</div>
@@ -523,9 +579,7 @@ export function UserPublicProfile() {
castVote={castVote}
removeVote={removeVote}
isOwnProfile={isOwnProfile}
hasMore={dumps.hasMore}
loadingMore={dumps.loadingMore}
onLoadMore={loadMoreDumps}
viewAllHref={`/users/${profileUser.username}/dumps`}
/>
<UpvotedDumpList
@@ -537,16 +591,15 @@ export function UserPublicProfile() {
canVote={!!me}
castVote={castVote}
removeVote={removeVote}
hasMore={votes.hasMore}
loadingMore={votes.loadingMore}
onLoadMore={loadMoreVotes}
viewAllHref={`/users/${profileUser.username}/upvoted`}
/>
</div>
<section className="profile-section" id="playlists">
<div className="profile-section-header">
<h2 className="profile-section-title">
Playlists ({playlists.items.length}{playlists.hasMore ? "+" : ""})
Playlists ({playlists.items.length}
{playlists.hasMore ? "+" : ""})
</h2>
{isOwnProfile && (
<NewPlaylistForm
@@ -556,7 +609,10 @@ export function UserPublicProfile() {
if (s.playlists.items.some((pl) => pl.id === p.id)) return s;
return {
...s,
playlists: { ...s.playlists, items: [p, ...s.playlists.items] },
playlists: {
...s.playlists,
items: [p, ...s.playlists.items],
},
};
})}
/>
@@ -567,38 +623,23 @@ export function UserPublicProfile() {
: (
<ul className="dump-feed">
{playlists.items.map((p) => (
<PlaylistCard key={p.id} playlist={p} />
<PlaylistCard key={p.id} playlist={p} isOwner={isOwnProfile} />
))}
</ul>
)}
<PlaylistSentinel
hasMore={playlists.hasMore}
loadingMore={playlists.loadingMore}
onLoadMore={loadMorePlaylists}
/>
{playlists.items.length > 0 && (
<Link
to={`/users/${profileUser.username}/playlists`}
className="profile-view-all"
>
View all
</Link>
)}
</section>
</PageShell>
);
}
// ── Sentinel wrapper (keeps hooks at top level) ──────────────────────────────
function PlaylistSentinel(
{ hasMore, loadingMore, onLoadMore }: {
hasMore: boolean;
loadingMore: boolean;
onLoadMore: () => void;
},
) {
const sentinelRef = useInfiniteScroll(onLoadMore, hasMore && !loadingMore);
return (
<>
<div ref={sentinelRef} />
{loadingMore && <p className="feed-loading-more">Loading more</p>}
</>
);
}
// ── Plain dump list ──────────────────────────────────────────────────────────
function DumpList(
@@ -611,9 +652,7 @@ function DumpList(
castVote,
removeVote,
isOwnProfile,
hasMore,
loadingMore,
onLoadMore,
viewAllHref,
}: {
title: string;
dumps: Dump[];
@@ -623,13 +662,10 @@ function DumpList(
castVote: (id: string) => void;
removeVote: (id: string) => void;
isOwnProfile?: boolean;
hasMore: boolean;
loadingMore: boolean;
onLoadMore: () => void;
viewAllHref: string;
},
) {
const [createModalOpen, setCreateModalOpen] = useState(false);
const sentinelRef = useInfiniteScroll(onLoadMore, hasMore && !loadingMore);
return (
<section className="profile-section">
<div className="profile-section-header">
@@ -665,8 +701,9 @@ function DumpList(
))}
</ul>
)}
<div ref={sentinelRef} />
{loadingMore && <p className="feed-loading-more">Loading more</p>}
{dumps.length > 0 && (
<Link to={viewAllHref} className="profile-view-all">View all </Link>
)}
</section>
);
}
@@ -683,9 +720,7 @@ function UpvotedDumpList(
canVote,
castVote,
removeVote,
hasMore,
loadingMore,
onLoadMore,
viewAllHref,
}: {
title: string;
dumps: Dump[];
@@ -695,15 +730,14 @@ function UpvotedDumpList(
canVote: boolean;
castVote: (id: string) => void;
removeVote: (id: string) => void;
hasMore: boolean;
loadingMore: boolean;
onLoadMore: () => void;
viewAllHref: string;
},
) {
const [fading, setFading] = useState<Record<string, "cooldown" | "dismissing">>({});
const [fading, setFading] = useState<
Record<string, "cooldown" | "dismissing">
>({});
const cancels = useRef<Map<string, () => void>>(new Map());
const prevVotedIds = useRef<Set<string> | null>(null);
const sentinelRef = useInfiniteScroll(onLoadMore, hasMore && !loadingMore);
useEffect(() => () => {
cancels.current.forEach((c) => c());
@@ -809,8 +843,9 @@ function UpvotedDumpList(
})}
</ul>
)}
<div ref={sentinelRef} />
{loadingMore && <p className="feed-loading-more">Loading more</p>}
{visibleDumps.length > 0 && (
<Link to={viewAllHref} className="profile-view-all">View all </Link>
)}
</section>
);
}

View File

@@ -1,13 +1,18 @@
import { useState } from "react";
import { useEffect, useState } from "react";
import type { SubmitEvent } from "react";
import { Link, useNavigate } from "react-router";
import { Link, useNavigate, useSearchParams } from "react-router";
import { API_URL } from "../config/api.ts";
import { deserializeAuthResponse } from "../model.ts";
import { useAuth } from "../hooks/useAuth.ts";
import { PageShell } from "../components/PageShell.tsx";
type UserRegisterState =
type TokenState =
| { status: "checking" }
| { status: "invalid" }
| { status: "valid" };
type FormState =
| { status: "idle" }
| { status: "submitting" }
| { status: "error"; error: string };
@@ -15,13 +20,29 @@ type UserRegisterState =
export function UserRegister() {
const navigate = useNavigate();
const { login } = useAuth();
const [searchParams] = useSearchParams();
const token = searchParams.get("token") ?? "";
const [state, setState] = useState<UserRegisterState>({ status: "idle" });
const [tokenState, setTokenState] = useState<TokenState>({
status: "checking",
});
const [formState, setFormState] = useState<FormState>({ status: "idle" });
useEffect(() => {
if (!token) {
setTokenState({ status: "invalid" });
return;
}
fetch(`${API_URL}/api/invites/${encodeURIComponent(token)}`)
.then((r) => {
setTokenState(r.ok ? { status: "valid" } : { status: "invalid" });
})
.catch(() => setTokenState({ status: "invalid" }));
}, [token]);
const handleSubmit = async (e: SubmitEvent<HTMLFormElement>) => {
e.preventDefault();
setState({ status: "submitting" });
setFormState({ status: "submitting" });
const formData = new FormData(e.currentTarget);
const username = formData.get("username");
@@ -31,34 +52,56 @@ export function UserRegister() {
const res = await fetch(`${API_URL}/api/users/register`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, password }),
body: JSON.stringify({ username, password, inviteToken: token }),
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const apiResponse = await res.json();
if (apiResponse.success) {
login(deserializeAuthResponse(apiResponse.data));
navigate("/");
} else {
setState({ status: "error", error: apiResponse.error.message });
setFormState({
status: "error",
error: apiResponse.error?.message ?? "Registration failed.",
});
}
} catch (err) {
setState({
setFormState({
status: "error",
error: err instanceof Error ? err.message : "Registration failed.",
});
}
};
if (tokenState.status === "checking") {
return (
<PageShell centered>
<p className="page-loading">Checking invite</p>
</PageShell>
);
}
if (tokenState.status === "invalid") {
return (
<PageShell centered>
<div className="auth-card">
<h1 className="auth-card-title">Invalid invite</h1>
<p className="auth-card-footer">
This invite link is missing, expired, or already used.
</p>
</div>
</PageShell>
);
}
return (
<PageShell centered>
<div className="auth-card">
<h1 className="auth-card-title">Register</h1>
{state.status === "error" && (
<div className="error-banner">{state.error}</div>
{formState.status === "error" && (
<div className="error-banner">{formState.error}</div>
)}
<form onSubmit={handleSubmit} className="auth-form">
@@ -67,7 +110,7 @@ export function UserRegister() {
type="text"
placeholder="Username"
required
disabled={state.status === "submitting"}
disabled={formState.status === "submitting"}
autoFocus
/>
<input
@@ -75,14 +118,14 @@ export function UserRegister() {
type="password"
placeholder="Password"
required
disabled={state.status === "submitting"}
disabled={formState.status === "submitting"}
/>
<button
type="submit"
className="btn-primary"
disabled={state.status === "submitting"}
disabled={formState.status === "submitting"}
>
{state.status === "submitting" ? "Registering…" : "Register"}
{formState.status === "submitting" ? "Registering…" : "Register"}
</button>
</form>

392
src/pages/UserUpvoted.tsx Normal file
View File

@@ -0,0 +1,392 @@
import {
useCallback,
useEffect,
useLayoutEffect,
useRef,
useState,
} from "react";
import { Link, useParams } from "react-router";
import { API_URL } from "../config/api.ts";
import type { Dump, PaginatedData, PublicUser, RawDump } from "../model.ts";
import { deserializeDump, deserializePublicUser } from "../model.ts";
import { useAuth } from "../hooks/useAuth.ts";
import { useWS } from "../hooks/useWS.ts";
import { useInfiniteScroll } from "../hooks/useInfiniteScroll.ts";
import { useFeedCache } from "../hooks/useFeedCache.ts";
import { Avatar } from "../components/Avatar.tsx";
import { DumpCard } from "../components/DumpCard.tsx";
import { PageShell } from "../components/PageShell.tsx";
import { PageError } from "../components/PageError.tsx";
const PAGE_SIZE = 20;
const hydrateDump = (raw: Dump): Dump =>
deserializeDump(raw as unknown as RawDump);
type State =
| { status: "loading" }
| { status: "error"; error: string }
| {
status: "loaded";
profileUser: PublicUser;
votes: Dump[];
hasMore: boolean;
page: number;
loadingMore: boolean;
};
export function UserUpvoted() {
const { username } = useParams();
const { user: me, token } = useAuth();
const { voteCounts, myVotes, lastVoteEvent, castVote, removeVote } = useWS();
const { cached, saveState } = useFeedCache<Dump>(
`feed:user-upvoted-full:${username ?? ""}`,
hydrateDump,
);
const [state, setState] = useState<State>({ status: "loading" });
const [votedIds, setVotedIds] = useState<Set<string>>(new Set());
const [fading, setFading] = useState<
Record<string, "cooldown" | "dismissing">
>({});
const cancels = useRef<Map<string, () => void>>(new Map());
const prevVotedIds = useRef<Set<string> | null>(null);
const prevMyVotesRef = useRef<Set<string> | null>(null);
useEffect(() => () => {
cancels.current.forEach((c) => c());
}, []);
useEffect(() => {
if (!username) return;
setState({ status: "loading" });
setVotedIds(new Set());
prevVotedIds.current = null;
prevMyVotesRef.current = null;
if (cached) {
fetch(`${API_URL}/api/users/${username}`)
.then((r) => r.json())
.then((body) => {
if (!body.success) throw new Error("User not found");
const voteIds = new Set(cached.items.map((d) => d.id));
setState({
status: "loaded",
profileUser: deserializePublicUser(body.data),
votes: cached.items,
hasMore: cached.hasMore,
page: cached.page,
loadingMore: false,
});
setVotedIds(voteIds);
})
.catch((err) =>
setState({
status: "error",
error: err instanceof Error ? err.message : "Failed to load",
})
);
return;
}
const authHeaders: HeadersInit = token
? { Authorization: `Bearer ${token}` }
: {};
Promise.all([
fetch(`${API_URL}/api/users/${username}`),
fetch(
`${API_URL}/api/users/${username}/votes?page=1&limit=${PAGE_SIZE}`,
{ headers: authHeaders },
),
])
.then(([userRes, votesRes]) =>
Promise.all([userRes.json(), votesRes.json()])
)
.then(([userBody, votesBody]) => {
if (!userBody.success) throw new Error("User not found");
const { items, hasMore } = votesBody.success
? votesBody.data as PaginatedData<RawDump>
: { items: [], hasMore: false };
const voteItems = items.map(deserializeDump);
setState({
status: "loaded",
profileUser: deserializePublicUser(userBody.data),
votes: voteItems,
hasMore,
page: 1,
loadingMore: false,
});
setVotedIds(new Set(voteItems.map((d) => d.id)));
})
.catch((err) =>
setState({
status: "error",
error: err instanceof Error ? err.message : "Failed to load",
})
);
}, [username]);
const profileUserId = state.status === "loaded" ? state.profileUser.id : null;
// Own profile: keep votedIds in sync with myVotes
useEffect(() => {
if (!profileUserId || me?.id !== profileUserId) return;
setVotedIds(new Set(myVotes));
if (prevMyVotesRef.current === null) {
prevMyVotesRef.current = new Set(myVotes);
return;
}
const prev = prevMyVotesRef.current;
setState((s) => {
if (s.status !== "loaded") return s;
const voteIdSet = new Set(s.votes.map((d) => d.id));
const toAdd = [...myVotes].filter((id) =>
!prev.has(id) && !voteIdSet.has(id)
);
if (toAdd.length === 0) return s;
// Newly voted items will arrive via lastVoteEvent fetch below
return s;
});
prevMyVotesRef.current = new Set(myVotes);
}, [myVotes, me, profileUserId]);
// WS vote events
useEffect(() => {
if (!lastVoteEvent || !profileUserId) return;
const { dumpId, voterId, action } = lastVoteEvent;
if (voterId !== profileUserId) return;
if (action === "remove") {
setVotedIds((prev) => {
const n = new Set(prev);
n.delete(dumpId);
return n;
});
} else {
setVotedIds((prev) => new Set([...prev, dumpId]));
fetch(`${API_URL}/api/dumps/${dumpId}`)
.then((r) => r.json())
.then((body) => {
if (!body.success) return;
const dump = deserializeDump(body.data);
setState((s) => {
if (s.status !== "loaded" || s.votes.some((d) => d.id === dumpId)) {
return s;
}
return { ...s, votes: [dump, ...s.votes] };
});
})
.catch(() => {});
}
}, [lastVoteEvent, profileUserId]);
// Fade animation when items leave votedIds
useEffect(() => {
if (prevVotedIds.current === null) {
prevVotedIds.current = new Set(votedIds);
return;
}
const prev = prevVotedIds.current;
for (const id of prev) {
if (!votedIds.has(id) && !cancels.current.has(id)) {
let dead = false;
let kill = () => {};
kill = () => {
dead = true;
setFading((f) => {
const n = { ...f };
delete n[id];
return n;
});
cancels.current.delete(id);
};
cancels.current.set(id, () => kill());
setFading((f) => ({ ...f, [id]: "cooldown" }));
const t1 = setTimeout(() => {
if (dead) return;
setFading((f) => ({ ...f, [id]: "dismissing" }));
const t2 = setTimeout(() => {
if (!dead) kill();
}, 350);
kill = () => {
dead = true;
clearTimeout(t2);
setFading((f) => {
const n = { ...f };
delete n[id];
return n;
});
cancels.current.delete(id);
};
}, 2000);
kill = () => {
dead = true;
clearTimeout(t1);
setFading((f) => {
const n = { ...f };
delete n[id];
return n;
});
cancels.current.delete(id);
};
cancels.current.set(id, () => kill());
}
}
for (const id of votedIds) {
if (!prev.has(id) && cancels.current.has(id)) {
cancels.current.get(id)!();
}
}
prevVotedIds.current = new Set(votedIds);
}, [votedIds]);
const loadMore = useCallback(() => {
if (
state.status !== "loaded" || !state.hasMore || state.loadingMore ||
!username
) return;
const nextPage = state.page + 1;
setState((s) => s.status === "loaded" ? { ...s, loadingMore: true } : s);
fetch(
`${API_URL}/api/users/${username}/votes?page=${nextPage}&limit=${PAGE_SIZE}`,
{ headers: token ? { Authorization: `Bearer ${token}` } : {} },
)
.then((r) => r.json())
.then((body) => {
const { items, hasMore } = body.data as PaginatedData<RawDump>;
const newItems = items.map(deserializeDump);
setState((s) =>
s.status === "loaded"
? {
...s,
votes: [...s.votes, ...newItems],
hasMore,
page: nextPage,
loadingMore: false,
}
: s
);
setVotedIds((prev) => new Set([...prev, ...newItems.map((d) => d.id)]));
})
.catch(() =>
setState((s) =>
s.status === "loaded" ? { ...s, loadingMore: false } : s
)
);
}, [state, username, token]);
const sentinelRef = useInfiniteScroll(
loadMore,
state.status === "loaded" && state.hasMore && !state.loadingMore,
);
useEffect(() => {
if (state.status !== "loaded") return;
let timer: ReturnType<typeof setTimeout>;
const onScroll = () => {
clearTimeout(timer);
timer = setTimeout(() => {
if (state.status !== "loaded") return;
saveState(state.votes, state.page, state.hasMore, globalThis.scrollY);
}, 100);
};
globalThis.addEventListener("scroll", onScroll, { passive: true });
return () => {
globalThis.removeEventListener("scroll", onScroll);
clearTimeout(timer);
};
}, [state, saveState]);
const scrollRestored = useRef(false);
useLayoutEffect(() => {
if (cached?.scrollY == null || scrollRestored.current) return;
if (state.status === "loaded") {
globalThis.scrollTo(0, cached.scrollY);
scrollRestored.current = true;
}
}, [state.status, cached]);
if (state.status === "loading") {
return (
<PageShell>
<p className="page-loading">Loading</p>
</PageShell>
);
}
if (state.status === "error") {
return (
<PageError
message={state.error}
actions={
<Link to={`/users/${username}`} className="logout-btn">
Back to profile
</Link>
}
/>
);
}
const { profileUser, votes, hasMore, loadingMore } = state;
const visibleDumps = votes.filter((d) =>
votedIds.has(d.id) || d.id in fading
);
return (
<PageShell>
<div className="profile-subpage-header">
<Link to={`/users/${username}`} className="profile-subpage-back">
{profileUser.username}
</Link>
<div className="profile-subpage-title-row">
<Avatar
userId={profileUser.id}
username={profileUser.username}
hasAvatar={!!profileUser.avatarMime}
size={36}
/>
<h1 className="profile-subpage-title">Upvoted</h1>
</div>
</div>
{visibleDumps.length === 0
? <p className="empty-state">Nothing here yet.</p>
: (
<ul className="dump-feed">
{visibleDumps.map((dump) => {
const phase = fading[dump.id];
const extraCls = phase === "cooldown"
? "dump-card--fading"
: phase === "dismissing"
? "dump-card--dismissing"
: undefined;
return (
<DumpCard
key={dump.id}
dump={dump}
voteCount={voteCounts[dump.id] ?? dump.voteCount}
voted={myVotes.has(dump.id)}
canVote={!!me}
castVote={castVote}
removeVote={removeVote}
className={extraCls}
/>
);
})}
</ul>
)}
<div ref={sentinelRef} />
{loadingMore && <p className="feed-loading-more">Loading more</p>}
{!hasMore && visibleDumps.length > 0 && (
<p className="index-status">All {votes.length} upvoted dumps loaded.</p>
)}
</PageShell>
);
}