v2: global player, infinite scroll, image picker, threaded comments

This commit is contained in:
khannurien
2026-03-21 13:55:22 +00:00
parent be426eb150
commit 7c098e7c4c
48 changed files with 4346 additions and 711 deletions

View File

@@ -1,13 +1,14 @@
import React, { useEffect, useRef, useState } from "react";
import React, { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
import { useNavigate, useParams } from "react-router";
import { API_URL } from "../config/api.ts";
import type { Dump, PublicUser } from "../model.ts";
import type { Dump, PaginatedData, PublicUser } from "../model.ts";
import {
deserializeAuthResponse,
deserializeDump,
deserializePublicUser,
deserializeUser,
type RawDump,
type RawUser,
} from "../model.ts";
import { Avatar } from "../components/Avatar.tsx";
@@ -18,8 +19,28 @@ 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";
const PAGE_SIZE = 20;
const hydrateDump = (raw: Dump): Dump => deserializeDump(raw as unknown as RawDump);
const hydratePlaylist = (raw: Playlist): Playlist =>
deserializePlaylist(raw as unknown as RawPlaylist);
interface PaginatedList<T> {
items: T[];
hasMore: boolean;
page: number;
loadingMore: boolean;
}
function initialList<T>(items: T[], hasMore: boolean): PaginatedList<T> {
return { items, hasMore, page: 1, loadingMore: false };
}
type ProfileState =
| { status: "loading" }
@@ -27,9 +48,9 @@ type ProfileState =
| {
status: "loaded";
user: PublicUser;
dumps: Dump[];
votes: Dump[];
playlists: Playlist[];
dumps: PaginatedList<Dump>;
votes: PaginatedList<Dump>;
playlists: PaginatedList<Playlist>;
};
export function UserPublicProfile() {
@@ -46,11 +67,22 @@ export function UserPublicProfile() {
deletedPlaylistIds,
} = useWS();
const { cached: cachedDumps, saveState: saveDumps } = useFeedCache<Dump>(
`feed:profile-dumps:${username ?? ""}`,
hydrateDump,
);
const { cached: cachedVotes, saveState: saveVotes } = useFeedCache<Dump>(
`feed:profile-votes:${username ?? ""}`,
hydrateDump,
);
const { cached: cachedPlaylists, saveState: savePlaylists } = useFeedCache<Playlist>(
`feed:profile-playlists:${username ?? ""}`,
hydratePlaylist,
);
const [state, setState] = useState<ProfileState>({ status: "loading" });
const [uploading, setUploading] = useState(false);
const [avatarError, setAvatarError] = useState<string | null>(null);
// Tracks which dumps the profile user currently has voted on (real-time).
// For own profile this mirrors myVotes; for others it's maintained separately.
const [profileVotedIds, setProfileVotedIds] = useState<Set<string>>(
new Set(),
);
@@ -61,22 +93,42 @@ export function UserPublicProfile() {
if (!username) return;
setState({ status: "loading" });
const allCached = cachedDumps && cachedVotes && cachedPlaylists;
if (allCached) {
// Only fetch the user object (lightweight, always fresh)
fetch(`${API_URL}/api/users/${username}`)
.then((r) => r.json())
.then((body) => {
if (!body.success) throw new Error("User not found");
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 },
});
setProfileVotedIds(new Set(cachedVotes.items.map((d) => d.id)));
})
.catch((err) =>
setState({ status: "error", error: err instanceof Error ? err.message : "Failed to load profile" })
);
return;
}
(async () => {
try {
const authHeaders = 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`),
fetch(`${API_URL}/api/users/${username}/votes`),
fetch(`${API_URL}/api/users/${username}/playlists`, {
headers: token ? { Authorization: `Bearer ${token}` } : {},
}),
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}`,
);
}
@@ -88,20 +140,28 @@ export function UserPublicProfile() {
playlistsRes.json(),
]);
const votes: Dump[] = votesBody.success
? votesBody.data.map(deserializeDump)
: [];
const playlists: Playlist[] = playlistsBody.success
? (playlistsBody.data as RawPlaylist[]).map(deserializePlaylist)
: [];
const votesData: PaginatedData<RawDump> = votesBody.success
? votesBody.data
: { items: [], total: 0, hasMore: false };
const playlistsData: PaginatedData<RawPlaylist> = playlistsBody.success
? playlistsBody.data
: { items: [], total: 0, hasMore: false };
const dumpsData: PaginatedData<RawDump> = dumpsBody.success
? dumpsBody.data
: { items: [], total: 0, hasMore: false };
const voteItems = votesData.items.map(deserializeDump);
setState({
status: "loaded",
user: deserializePublicUser(userBody.data),
dumps: dumpsBody.success ? dumpsBody.data.map(deserializeDump) : [],
votes,
playlists,
dumps: initialList(dumpsData.items.map(deserializeDump), dumpsData.hasMore),
votes: initialList(voteItems, votesData.hasMore),
playlists: initialList(
playlistsData.items.map(deserializePlaylist),
playlistsData.hasMore,
),
});
setProfileVotedIds(new Set(votes.map((d: Dump) => d.id)));
setProfileVotedIds(new Set(voteItems.map((d) => d.id)));
} catch (err) {
setState({
status: "error",
@@ -111,17 +171,12 @@ export function UserPublicProfile() {
})();
}, [username]);
// Stable primitive derived from state — only changes when navigating to a different profile.
// Using this instead of `state` directly avoids re-running effects on every vote update.
const profileUserId = state.status === "loaded" ? state.user.id : null;
// Own profile: keep profileVotedIds in sync with myVotes, and add newly-voted
// dumps (that belong to this user) to the votes list without a fetch.
// Own profile: keep profileVotedIds in sync with myVotes
useEffect(() => {
if (!profileUserId || me?.id !== profileUserId) return;
setProfileVotedIds(new Set(myVotes));
if (prevMyVotesRef.current === null) {
prevMyVotesRef.current = new Set(myVotes);
return;
@@ -129,17 +184,17 @@ export function UserPublicProfile() {
const prev = prevMyVotesRef.current;
setState((s) => {
if (s.status !== "loaded") return s;
const voteIds = new Set(s.votes.map((d) => d.id));
const toAdd = s.dumps.filter((d) =>
const voteIds = new Set(s.votes.items.map((d) => d.id));
const toAdd = s.dumps.items.filter((d) =>
myVotes.has(d.id) && !prev.has(d.id) && !voteIds.has(d.id)
);
if (toAdd.length === 0) return s;
return { ...s, votes: [...toAdd, ...s.votes] };
return { ...s, votes: { ...s.votes, items: [...toAdd, ...s.votes.items] } };
});
prevMyVotesRef.current = new Set(myVotes);
}, [myVotes, me, profileUserId]);
// Real-time upvoted list sync for any profile via WS vote events.
// Real-time upvoted list sync via WS vote events
useEffect(() => {
if (!lastVoteEvent || !profileUserId) return;
const { dumpId, voterId, action } = lastVoteEvent;
@@ -158,17 +213,16 @@ export function UserPublicProfile() {
if (!isOwnProfile) {
setProfileVotedIds((prev) => new Set([...prev, dumpId]));
}
// Always fetch on cast; the setState callback below deduplicates.
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)) {
if (s.status !== "loaded" || s.votes.items.some((d) => d.id === dumpId)) {
return s;
}
return { ...s, votes: [dump, ...s.votes] };
return { ...s, votes: { ...s.votes, items: [dump, ...s.votes.items] } };
});
})
.catch(() => {});
@@ -182,34 +236,39 @@ export function UserPublicProfile() {
const isOwnProfile = me?.id === profileUserId;
const ev = lastPlaylistEvent;
if (
ev.type === "created" && ev.playlist &&
ev.playlist.userId === profileUserId
) {
if (ev.type === "created" && ev.playlist?.userId === profileUserId) {
if (ev.playlist.isPublic || isOwnProfile) {
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] };
if (s.playlists.items.some((p) => p.id === ev.playlist!.id)) return s;
return {
...s,
playlists: { ...s.playlists, items: [ev.playlist!, ...s.playlists.items] },
};
});
}
} else if (
ev.type === "updated" && ev.playlist &&
ev.playlist.userId === profileUserId
) {
} else if (ev.type === "updated" && ev.playlist?.userId === profileUserId) {
setState((s) => {
if (s.status !== "loaded") return s;
const updated = s.playlists.map((p) =>
p.id === ev.playlist!.id ? ev.playlist! : p
).filter((p) => p.isPublic || isOwnProfile);
return { ...s, playlists: updated };
return {
...s,
playlists: {
...s.playlists,
items: s.playlists.items
.map((p) => p.id === ev.playlist!.id ? ev.playlist! : p)
.filter((p) => p.isPublic || isOwnProfile),
},
};
});
} else if (ev.type === "deleted") {
setState((s) => {
if (s.status !== "loaded") return s;
return {
...s,
playlists: s.playlists.filter((p) => p.id !== ev.playlistId),
playlists: {
...s.playlists,
items: s.playlists.items.filter((p) => p.id !== ev.playlistId),
},
};
});
}
@@ -219,12 +278,124 @@ export function UserPublicProfile() {
if (deletedPlaylistIds.size === 0 || state.status !== "loaded") return;
setState((s) => {
if (s.status !== "loaded") return s;
const filtered = s.playlists.filter((p) => !deletedPlaylistIds.has(p.id));
if (filtered.length === s.playlists.length) return s;
return { ...s, playlists: filtered };
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 } };
});
}, [deletedPlaylistIds]);
// Save scroll position + loaded state to sessionStorage on scroll
useEffect(() => {
if (state.status !== "loaded") return;
let timer: ReturnType<typeof setTimeout>;
const onScroll = () => {
clearTimeout(timer);
timer = setTimeout(() => {
if (state.status !== "loaded") return;
const y = window.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);
}, 100);
};
window.addEventListener("scroll", onScroll, { passive: true });
return () => { window.removeEventListener("scroll", onScroll); clearTimeout(timer); };
}, [state, saveDumps, saveVotes, savePlaylists]);
// Restore scroll position after cache restoration
const scrollRestored = useRef(false);
useLayoutEffect(() => {
if (cachedDumps?.scrollY == null || scrollRestored.current) return;
if (state.status === "loaded") {
window.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]);
const handleAvatarUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file || state.status !== "loaded") return;
@@ -261,10 +432,7 @@ 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 {
@@ -347,57 +515,91 @@ export function UserPublicProfile() {
<div className="profile-columns">
<DumpList
title={`Dumps (${dumps.length})`}
dumps={dumps}
title={`Dumps (${dumps.items.length}${dumps.hasMore ? "+" : ""})`}
dumps={dumps.items}
voteCounts={voteCounts}
myVotes={myVotes}
canVote={!!me}
castVote={castVote}
removeVote={removeVote}
isOwnProfile={isOwnProfile}
hasMore={dumps.hasMore}
loadingMore={dumps.loadingMore}
onLoadMore={loadMoreDumps}
/>
<UpvotedDumpList
title={`Upvoted (${profileVotedIds.size})`}
dumps={votes}
title={`Upvoted (${profileVotedIds.size}${votes.hasMore ? "+" : ""})`}
dumps={votes.items}
votedIds={profileVotedIds}
voteCounts={voteCounts}
myVotes={myVotes}
canVote={!!me}
castVote={castVote}
removeVote={removeVote}
hasMore={votes.hasMore}
loadingMore={votes.loadingMore}
onLoadMore={loadMoreVotes}
/>
</div>
<section className="profile-section" id="playlists">
<div className="profile-section-header">
<h2 className="profile-section-title">
Playlists ({playlists.length})
Playlists ({playlists.items.length}{playlists.hasMore ? "+" : ""})
</h2>
{isOwnProfile && (
<NewPlaylistForm
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] };
if (s.playlists.items.some((pl) => pl.id === p.id)) return s;
return {
...s,
playlists: { ...s.playlists, items: [p, ...s.playlists.items] },
};
})}
/>
)}
</div>
{playlists.length === 0
{playlists.items.length === 0
? <p className="empty-state">No playlists yet.</p>
: (
<ul className="dump-feed">
{playlists.map((p) => <PlaylistCard key={p.id} playlist={p} />)}
{playlists.items.map((p) => (
<PlaylistCard key={p.id} playlist={p} />
))}
</ul>
)}
<PlaylistSentinel
hasMore={playlists.hasMore}
loadingMore={playlists.loadingMore}
onLoadMore={loadMorePlaylists}
/>
</section>
</PageShell>
);
}
// ── Plain dump list (no dismiss behaviour) ──────────────────────────────────
// ── 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(
{
@@ -409,6 +611,9 @@ function DumpList(
castVote,
removeVote,
isOwnProfile,
hasMore,
loadingMore,
onLoadMore,
}: {
title: string;
dumps: Dump[];
@@ -418,9 +623,13 @@ function DumpList(
castVote: (id: string) => void;
removeVote: (id: string) => void;
isOwnProfile?: boolean;
hasMore: boolean;
loadingMore: boolean;
onLoadMore: () => void;
},
) {
const navigate = useNavigate();
const [createModalOpen, setCreateModalOpen] = useState(false);
const sentinelRef = useInfiniteScroll(onLoadMore, hasMore && !loadingMore);
return (
<section className="profile-section">
<div className="profile-section-header">
@@ -429,12 +638,15 @@ function DumpList(
<button
type="button"
className="new-playlist-toggle"
onClick={() => navigate("/dumps/new")}
onClick={() => setCreateModalOpen(true)}
>
+ New dump
</button>
)}
</div>
{createModalOpen && (
<DumpCreateModal onClose={() => setCreateModalOpen(false)} />
)}
{dumps.length === 0
? <p className="empty-state">Nothing here yet.</p>
: (
@@ -448,15 +660,18 @@ function DumpList(
canVote={canVote}
castVote={castVote}
removeVote={removeVote}
isOwner={isOwnProfile}
/>
))}
</ul>
)}
<div ref={sentinelRef} />
{loadingMore && <p className="feed-loading-more">Loading more</p>}
</section>
);
}
// ── Upvoted list: fades items out when votes are removed ────────────────────
// ── Upvoted list: fades items out when votes are removed ────────────────────
function UpvotedDumpList(
{
@@ -468,36 +683,33 @@ function UpvotedDumpList(
canVote,
castVote,
removeVote,
hasMore,
loadingMore,
onLoadMore,
}: {
title: string;
dumps: Dump[];
/** Which dumps the profile user currently has voted on. Drives visibility and animation. */
votedIds: Set<string>;
voteCounts: Record<string, number>;
/** Logged-in user's votes — used only for the vote button state on each card. */
myVotes: Set<string>;
canVote: boolean;
castVote: (id: string) => void;
removeVote: (id: string) => void;
hasMore: boolean;
loadingMore: boolean;
onLoadMore: () => void;
},
) {
// fading: items whose vote was just removed — dimmed during cooldown, then animating out
const [fading, setFading] = useState<
Record<string, "cooldown" | "dismissing">
>({});
// cancels: id → function that aborts the pending removal sequence
const [fading, setFading] = useState<Record<string, "cooldown" | "dismissing">>({});
const cancels = useRef<Map<string, () => void>>(new Map());
// prevVotedIds: null on first render (skip initial diff), then previous votedIds snapshot
const prevVotedIds = useRef<Set<string> | null>(null);
const sentinelRef = useInfiniteScroll(onLoadMore, hasMore && !loadingMore);
useEffect(() => () => {
cancels.current.forEach((c) => c());
}, []);
useEffect(() => {
// First run: capture baseline without triggering any fades
if (prevVotedIds.current === null) {
prevVotedIds.current = new Set(votedIds);
return;
@@ -505,7 +717,6 @@ function UpvotedDumpList(
const prev = prevVotedIds.current;
// Newly unvoted → start fade (idempotent: skip if already running)
for (const id of prev) {
if (!votedIds.has(id) && !cancels.current.has(id)) {
let dead = false;
@@ -554,7 +765,6 @@ function UpvotedDumpList(
}
}
// Newly re-voted while fading → cancel removal
for (const id of votedIds) {
if (!prev.has(id) && cancels.current.has(id)) {
cancels.current.get(id)!();
@@ -564,7 +774,6 @@ function UpvotedDumpList(
prevVotedIds.current = new Set(votedIds);
}, [votedIds]);
// Visible = currently voted OR within the fade-out animation window
const visibleDumps = dumps.filter((d) =>
votedIds.has(d.id) || d.id in fading
);
@@ -600,6 +809,8 @@ function UpvotedDumpList(
})}
</ul>
)}
<div ref={sentinelRef} />
{loadingMore && <p className="feed-loading-more">Loading more</p>}
</section>
);
}