v3: comments say what the code can't — the documentation pass wanted JSDoc on every export and every field of an exported interface, which filled the code with restatements ("The dump.", "Whether it is loading.", "The props of X.", "Renders the X.") and module docs that told a reader nothing ("The hot and new feeds, and the props every main feed of the index takes."); some were wrong as well as vague. Every comment in api/, shared/ and src/ is rewritten under one rule, now stated in the README's "Comments" section: every module and export still has a JSDoc, and it gives context (who uses it and where), usage (what a caller must know: a precondition, what it leaves to the caller, a side effect) or its place in the chain (where its input comes from, where its output goes); a field, parameter, private function or local gets one only when it adds to its parent's, and context about a whole type goes on the type; a type's doc is phrased from the value's side ("Returned to Y by X when Z", "Passed to Y by X"); full sentences, as short as they can be, no label-colon-list; // goes wherever the code isn't obvious and says why, and the section banners and narration go. Every claim was checked against the code, which turned up some forty comments that were wrong: DumpCard's claimed it was only used by the hot and new feeds, the WebSocket welcome claimed to be a socket's first message (presence comes first), UpdateUserRequest said null removes a description (it keeps it), deletedDumpIds leaves out that it also holds dumps made private, THEME_COLOR claimed to mirror index.html's, and the like. The routers' JSDoc now says where they are mounted and what becomes of a thrown APIException. doc-lint.ts lets a field without a doc through; a module or export without one still fails. No code changes; 204 backend and 628 frontend tests pass, and doc:lint, deno check, deno lint and tsc are clean.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
/**
|
||||
* A playlist's page.
|
||||
* Renders a playlist's page, where its owner and moderators also edit it and
|
||||
* reorder its dumps.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
@@ -53,7 +54,6 @@ import { FormError } from "../components/form/FormError.tsx";
|
||||
import { SubmitButton } from "../components/form/SubmitButton.tsx";
|
||||
import { useApiForm } from "../hooks/form/useApiForm.ts";
|
||||
|
||||
/** The page's state: loading, failed, or loaded with the playlist. */
|
||||
type LoadState =
|
||||
| { status: "loading" }
|
||||
| { status: "error"; error: string }
|
||||
@@ -61,9 +61,11 @@ type LoadState =
|
||||
|
||||
/**
|
||||
* Renders `/playlists/:playlistId` (an id or a slug), for anyone who may see
|
||||
* the playlist: its cover, title, description and owner, a follow button, and
|
||||
* its dumps, kept live by the socket. Its owner or a moderator can edit or
|
||||
* delete it, drag its dumps into a new order, and remove one (with an undo).
|
||||
* the playlist: a private one only to its owner. The playlist and the dumps
|
||||
* the viewer may see in it come from `GET /api/playlists/:playlistId`, and the
|
||||
* socket keeps them live. Its owner, or a user with `playlist:moderate`, can
|
||||
* edit or delete it, drag its dumps into a new order, and remove one, with a
|
||||
* short window to undo.
|
||||
*/
|
||||
export function PlaylistDetail(): React.JSX.Element {
|
||||
const { playlistId } = useParams<{ playlistId: string }>();
|
||||
@@ -80,7 +82,7 @@ export function PlaylistDetail(): React.JSX.Element {
|
||||
} = useWS();
|
||||
|
||||
const [state, setState] = useState<LoadState>({ status: "loading" });
|
||||
// Stable UUID for WS comparisons — avoids re-running effects on every state change
|
||||
// The socket names playlists by id, while the URL may hold the slug.
|
||||
const playlistUUID = state.status === "loaded" ? state.playlist.id : null;
|
||||
|
||||
useDocumentTitle(
|
||||
@@ -91,43 +93,42 @@ export function PlaylistDetail(): React.JSX.Element {
|
||||
: undefined,
|
||||
);
|
||||
|
||||
// activeDumpIds: which dumps are currently in the playlist (the canonical set)
|
||||
// The dumps in the playlist. `state.playlist.dumps` also keeps the removed
|
||||
// ones until their fade ends.
|
||||
const [activeDumpIds, setActiveDumpIds] = useState<Set<string>>(new Set());
|
||||
|
||||
// fading: dumps whose removal is being animated — same cooldown→dismissing pattern as upvoted list
|
||||
// A removed dump shows faded for 2 seconds, while it can be undone, then
|
||||
// slides out ("dismissing"), as on the upvoted page.
|
||||
const [fading, setFading] = useState<
|
||||
Record<string, "cooldown" | "dismissing">
|
||||
>({});
|
||||
const cancels = useRef<Map<string, () => void>>(new Map());
|
||||
// While an undo-remove is in flight (POST re-add + PUT reorder), holds the
|
||||
// desired dump order so intermediate WS dumps_updated events don't cause glitches.
|
||||
// An undo re-adds the dump, which the server puts first, then restores its
|
||||
// place. This holds that place's order until both requests are done, so the
|
||||
// socket's echo of the first doesn't flash the dump at the top.
|
||||
const pendingUndoOrderRef = useRef<string[] | null>(null);
|
||||
// Debounce timer for the reorder setState in dumps_updated so that rapid
|
||||
// consecutive events (POST re-add followed immediately by PUT reorder) are
|
||||
// coalesced — only the final order is applied, preventing the glitch on
|
||||
// other clients who don't have pendingUndoOrderRef.
|
||||
// Other clients see the same two echoes without that order, so a new order
|
||||
// from the socket is applied only once none follows for 80 ms.
|
||||
const dumpReorderTimerRef = useRef<ReturnType<typeof setTimeout> | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
// dragSrcRef: mutable ref so handleDragOver always sees the current source index
|
||||
// without stale closure issues (state would only update on next render).
|
||||
// A ref, since dragover fires faster than state renders.
|
||||
const dragSrcRef = useRef<number | null>(null);
|
||||
const [dragOverIndex, setDragOverIndex] = useState<number | null>(null);
|
||||
|
||||
const [editOpen, setEditOpen] = useState(false);
|
||||
const headerRef = useHeightTransition<HTMLDivElement>(editOpen);
|
||||
const [confirmDelete, setConfirmDelete] = useState(false);
|
||||
// A delete, removal or reorder the server refused.
|
||||
// A delete or a removal the server refused. A refused reorder just reloads.
|
||||
const [actionError, setActionError] = useState<string | null>(null);
|
||||
|
||||
// Mirrors activeDumpIds for use in effects without adding it as a dep.
|
||||
// Updated on every render via useLayoutEffect so it's always current.
|
||||
// For the socket effects, which must not re-run when it changes.
|
||||
const activeDumpIdsRef = useRef(activeDumpIds);
|
||||
// knownDumpIds: all dump IDs that belong to this playlist (for re-adding when dumps become public again)
|
||||
// Every dump seen in the playlist, so that one hidden when it turned private
|
||||
// comes back when it turns public.
|
||||
const knownDumpIdsRef = useRef<Set<string>>(new Set());
|
||||
// Authoritative dump order from the server (fetchPlaylist + dumps_updated events).
|
||||
// Used to re-insert dumps at their correct position after private→public transitions.
|
||||
// The server's last order, where such a dump comes back.
|
||||
const dumpOrderRef = useRef<string[]>([]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
@@ -146,9 +147,8 @@ export function PlaylistDetail(): React.JSX.Element {
|
||||
fetchAbortRef.current?.abort();
|
||||
const controller = new AbortController();
|
||||
fetchAbortRef.current = controller;
|
||||
// Deferred to a microtask so callers invoking this directly from an
|
||||
// effect body don't trip the synchronous-setState-in-effect check —
|
||||
// same timing for the user, just not in the same tick.
|
||||
// In a microtask, since the linter forbids a synchronous setState in the
|
||||
// effect that calls this. The user sees no difference.
|
||||
queueMicrotask(() => setState({ status: "loading" }));
|
||||
fetch(`${API_URL}/api/playlists/${playlistId}`, {
|
||||
signal: controller.signal,
|
||||
@@ -195,10 +195,10 @@ export function PlaylistDetail(): React.JSX.Element {
|
||||
return () => fetchAbortRef.current?.abort();
|
||||
}, [fetchPlaylist]);
|
||||
|
||||
// Start the cooldown→dismissing→gone sequence for a dump being removed.
|
||||
// After the sequence completes, the dump is removed from state.playlist.dumps.
|
||||
// Fades out dump `id`, then drops it from `state.playlist.dumps`. Its entry
|
||||
// in `cancels` stops the fade at whichever step it is.
|
||||
function startFade(id: string) {
|
||||
if (cancels.current.has(id)) return; // already fading
|
||||
if (cancels.current.has(id)) return;
|
||||
let dead = false;
|
||||
let kill = () => {};
|
||||
|
||||
@@ -220,7 +220,6 @@ export function PlaylistDetail(): React.JSX.Element {
|
||||
|
||||
const t2 = setTimeout(() => {
|
||||
if (dead) return;
|
||||
// Remove from the playlist.dumps array now that animation is done
|
||||
setState((prev) => {
|
||||
if (prev.status !== "loaded") return prev;
|
||||
return {
|
||||
@@ -260,21 +259,17 @@ export function PlaylistDetail(): React.JSX.Element {
|
||||
cancels.current.set(id, () => kill());
|
||||
}
|
||||
|
||||
// WS: playlist metadata updated or deleted
|
||||
// The socket's changes to this playlist: its dumps, its details, its end.
|
||||
useEffect(() => {
|
||||
if (!lastPlaylistEvent || !playlistUUID) return;
|
||||
const ev = lastPlaylistEvent;
|
||||
// Compare against the resolved UUID, not the URL param (which may be a slug)
|
||||
if (ev.playlistId !== playlistUUID) return;
|
||||
|
||||
if (ev.type === "dumps_updated" && ev.dumpIds) {
|
||||
const newIds = new Set(ev.dumpIds);
|
||||
for (const id of newIds) knownDumpIdsRef.current.add(id);
|
||||
// Use the ref so we always diff against the current activeDumpIds,
|
||||
// including changes from deletedDumpIds / lastDumpEvent effects.
|
||||
const prev = activeDumpIdsRef.current;
|
||||
|
||||
// Removed: were active, not in new set → fade out
|
||||
for (const id of prev) {
|
||||
if (!newIds.has(id)) {
|
||||
setActiveDumpIds((s) => {
|
||||
@@ -286,14 +281,12 @@ export function PlaylistDetail(): React.JSX.Element {
|
||||
}
|
||||
}
|
||||
|
||||
// Newly added IDs: cancel any fade, mark active, fetch dump data individually.
|
||||
// We never call fetchPlaylist here — that would reset state to "loading", cycle
|
||||
// playlistUUID, and re-trigger this effect in a loop.
|
||||
// An added dump is fetched alone. fetchPlaylist would go back through
|
||||
// "loading", which unsets playlistUUID and runs this effect again.
|
||||
for (const id of newIds) {
|
||||
if (!prev.has(id)) {
|
||||
cancels.current.get(id)?.();
|
||||
setActiveDumpIds((s) => new Set([...s, id]));
|
||||
// Capture ev.dumpIds so we can insert the new dump at its correct position.
|
||||
const orderedIds = ev.dumpIds!;
|
||||
fetch(`${API_URL}/api/dumps/${id}`, {
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||
@@ -305,7 +298,6 @@ export function PlaylistDetail(): React.JSX.Element {
|
||||
setState((s) => {
|
||||
if (s.status !== "loaded") return s;
|
||||
if (s.playlist.dumps.some((d) => d.id === dump.id)) return s;
|
||||
// Insert at the correct server-ordered position.
|
||||
const dumpMap = new Map(s.playlist.dumps.map((d) => [d.id, d]));
|
||||
dumpMap.set(dump.id, dump);
|
||||
return {
|
||||
@@ -326,11 +318,7 @@ export function PlaylistDetail(): React.JSX.Element {
|
||||
}
|
||||
}
|
||||
|
||||
// Debounce the reorder setState so rapid consecutive dumps_updated events
|
||||
// (e.g. POST re-add followed immediately by PUT reorder during an undo)
|
||||
// coalesce into a single update — only the final order is applied.
|
||||
// On the owner's client, pendingUndoOrderRef also suppresses the wrong
|
||||
// intermediate order; this debounce protects other clients on the WS.
|
||||
// Debounced: see dumpReorderTimerRef.
|
||||
if (dumpReorderTimerRef.current) {
|
||||
clearTimeout(dumpReorderTimerRef.current);
|
||||
}
|
||||
@@ -345,12 +333,11 @@ export function PlaylistDetail(): React.JSX.Element {
|
||||
.filter((id) => dumpMap.has(id))
|
||||
.map((id) => dumpMap.get(id)!);
|
||||
let ai = 0;
|
||||
// Replace each active slot with the next server-ordered active dump;
|
||||
// fading dumps keep their current slot unchanged.
|
||||
// The fading dumps keep their slots, the others take the new order,
|
||||
// and dumps whose fetch landed late go last.
|
||||
const merged = s.playlist.dumps.map((d) =>
|
||||
newIds.has(d.id) ? orderedActive[ai++] : d
|
||||
);
|
||||
// Append any newly added dumps not yet in the array.
|
||||
while (ai < orderedActive.length) merged.push(orderedActive[ai++]);
|
||||
return { ...s, playlist: { ...s.playlist, dumps: merged } };
|
||||
});
|
||||
@@ -375,11 +362,10 @@ export function PlaylistDetail(): React.JSX.Element {
|
||||
}
|
||||
}, [lastPlaylistEvent, playlistUUID, navigate, token]);
|
||||
|
||||
// Filter out globally deleted dumps (dump was deleted entirely, not just removed from playlist)
|
||||
// A deleted dump leaves at once, without the removal's fade.
|
||||
useEffect(() => {
|
||||
if (deletedDumpIds.size === 0) return;
|
||||
// Subscribing to the WS-pushed deleted-dump-ids set — setState here
|
||||
// mirrors external state into local state, no synchronous alternative.
|
||||
// It mirrors the socket's state, which only an effect can do.
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setState((prev) => {
|
||||
if (prev.status !== "loaded") return prev;
|
||||
@@ -396,7 +382,8 @@ export function PlaylistDetail(): React.JSX.Element {
|
||||
});
|
||||
}, [deletedDumpIds]);
|
||||
|
||||
// Update dump metadata in-place; re-add if it was in this playlist but hidden (private→public)
|
||||
// An edited dump changes in place, and one of the playlist's dumps that
|
||||
// turned public again comes back at its place in the server's order.
|
||||
useEffect(() => {
|
||||
if (!lastDumpEvent) return;
|
||||
const dump = lastDumpEvent;
|
||||
@@ -404,13 +391,10 @@ export function PlaylistDetail(): React.JSX.Element {
|
||||
if (prev.status !== "loaded") return prev;
|
||||
const idx = prev.playlist.dumps.findIndex((d) => d.id === dump.id);
|
||||
if (idx !== -1) {
|
||||
// Update in-place
|
||||
const dumps = [...prev.playlist.dumps];
|
||||
dumps[idx] = dump;
|
||||
return { ...prev, playlist: { ...prev.playlist, dumps } };
|
||||
}
|
||||
// Re-add if this dump belongs to the playlist and is now public,
|
||||
// inserting at its correct server-ordered position.
|
||||
if (!dump.isPrivate && knownDumpIdsRef.current.has(dump.id)) {
|
||||
const order = dumpOrderRef.current;
|
||||
const dumpMap = new Map(prev.playlist.dumps.map((d) => [d.id, d]));
|
||||
@@ -427,7 +411,6 @@ export function PlaylistDetail(): React.JSX.Element {
|
||||
}
|
||||
return prev;
|
||||
});
|
||||
// Restore to activeDumpIds if re-added
|
||||
if (!dump.isPrivate && knownDumpIdsRef.current.has(dump.id)) {
|
||||
setActiveDumpIds((prev) => {
|
||||
if (prev.has(dump.id)) return prev;
|
||||
@@ -444,16 +427,13 @@ export function PlaylistDetail(): React.JSX.Element {
|
||||
e.preventDefault();
|
||||
const src = dragSrcRef.current;
|
||||
if (src === null || src === index) return;
|
||||
// Only swap once the pointer has crossed the card's midpoint.
|
||||
// Without this, entering a card immediately re-triggers the swap in the
|
||||
// opposite direction (the two items keep bouncing back and forth).
|
||||
// Swapping as soon as the pointer enters a card would swap it straight
|
||||
// back, so the pointer must cross the card's middle first.
|
||||
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
|
||||
const mid = rect.top + rect.height / 2;
|
||||
if (src < index && e.clientY < mid) return; // dragging downward, not past mid yet
|
||||
if (src > index && e.clientY > mid) return; // dragging upward, not past mid yet
|
||||
// Update visual order in state. Use activeDumpIdsRef so the updater never
|
||||
// reads a stale closure — activeDumpIds can't change mid-drag but this is
|
||||
// the correct pattern for setState updaters.
|
||||
if (src < index && e.clientY < mid) return;
|
||||
if (src > index && e.clientY > mid) return;
|
||||
// The drag indices count only the dumps not fading, which go last.
|
||||
setState((prev) => {
|
||||
if (prev.status !== "loaded") return prev;
|
||||
const ids = activeDumpIdsRef.current;
|
||||
@@ -467,7 +447,6 @@ export function PlaylistDetail(): React.JSX.Element {
|
||||
playlist: { ...prev.playlist, dumps: [...reordered, ...fadingDumps] },
|
||||
};
|
||||
});
|
||||
// Update the ref and highlight index outside the updater (no side effects inside updaters).
|
||||
dragSrcRef.current = index;
|
||||
setDragOverIndex(index);
|
||||
};
|
||||
@@ -502,13 +481,12 @@ export function PlaylistDetail(): React.JSX.Element {
|
||||
|
||||
const handleRemoveDump = (dumpId: string) => {
|
||||
if (!playlistId) return;
|
||||
// Fire-and-forget the API call; animate immediately
|
||||
// Removed on the server at once; the undo adds it back.
|
||||
authFetch(`${API_URL}/api/playlists/${playlistId}/dumps/${dumpId}`, {
|
||||
method: "DELETE",
|
||||
}).then((res) => {
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
}).catch(() => {
|
||||
// Refused or offline: cancel the fade and restore the item
|
||||
cancels.current.get(dumpId)?.();
|
||||
setActiveDumpIds((prev) => new Set([...prev, dumpId]));
|
||||
setActionError(t`The dump couldn't be removed. Try again.`);
|
||||
@@ -525,17 +503,14 @@ export function PlaylistDetail(): React.JSX.Element {
|
||||
if (!playlistId || state.status !== "loaded") return;
|
||||
cancels.current.get(dumpId)?.();
|
||||
setActiveDumpIds((prev) => new Set([...prev, dumpId]));
|
||||
// Capture the desired order now (dump is still in playlist.dumps at its
|
||||
// original position; activeDumpIds hasn't been updated yet in this closure).
|
||||
// A fading dump keeps its slot in `state.playlist.dumps`, so that order is
|
||||
// the one to restore.
|
||||
const restoredIds = new Set([...activeDumpIds, dumpId]);
|
||||
const desiredOrder = state.playlist.dumps
|
||||
.filter((d) => restoredIds.has(d.id))
|
||||
.map((d) => d.id);
|
||||
// Hold the desired order so the WS handler ignores the intermediate
|
||||
// dumps_updated event from the POST (which puts the dump at the top).
|
||||
pendingUndoOrderRef.current = desiredOrder;
|
||||
// Re-add server-side since DELETE already fired, then immediately restore
|
||||
// the original position (addDumpToPlaylist would otherwise put it at top).
|
||||
// The server adds the dump first, so the order is restored after.
|
||||
authFetch(`${API_URL}/api/playlists/${playlistId}/dumps/${dumpId}`, {
|
||||
method: "POST",
|
||||
})
|
||||
@@ -585,7 +560,8 @@ export function PlaylistDetail(): React.JSX.Element {
|
||||
const res = await authFetch(`${API_URL}/api/playlists/${playlistId}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
if (!res.ok) await expectOk(res); // throws the server's message
|
||||
// expectOk throws the server's message.
|
||||
if (!res.ok) await expectOk(res);
|
||||
} catch (err) {
|
||||
setActionError(friendlyFetchError(err));
|
||||
return;
|
||||
@@ -622,10 +598,9 @@ export function PlaylistDetail(): React.JSX.Element {
|
||||
|
||||
const { playlist } = state;
|
||||
const isOwner = !!user && user.id === playlist.userId;
|
||||
// Owners edit their own playlists; moderators may edit any (playlist:moderate).
|
||||
const canEdit = isOwner || can(user, "playlist:moderate");
|
||||
|
||||
// Active dumps in playlist order; fading dumps appended so they stay visible
|
||||
// The fading dumps stay on screen, but can't be dragged.
|
||||
const activeDumps = playlist.dumps.filter((d) => activeDumpIds.has(d.id));
|
||||
const visibleDumps = playlist.dumps.filter((d) =>
|
||||
activeDumpIds.has(d.id) || d.id in fading
|
||||
@@ -814,15 +789,16 @@ export function PlaylistDetail(): React.JSX.Element {
|
||||
);
|
||||
}
|
||||
|
||||
/** The props of {@link PlaylistEditForm}. */
|
||||
/** Passed to {@link PlaylistEditForm} by {@link PlaylistDetail}. */
|
||||
interface PlaylistEditFormProps {
|
||||
playlist: PlaylistWithDumps;
|
||||
onCancel: () => void;
|
||||
/** Opens the page's confirmation, which deletes the playlist. */
|
||||
onRequestDelete: () => void;
|
||||
/** Called with the playlist as the server now has it. */
|
||||
onSaved: (playlist: Playlist) => void;
|
||||
}
|
||||
|
||||
/** The form's fields. */
|
||||
interface EditValues {
|
||||
title: string;
|
||||
description: string;
|
||||
@@ -831,7 +807,10 @@ interface EditValues {
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the playlist's edit form: title, description, visibility and cover.
|
||||
* Renders the form that edits the playlist, in place of the page's header. It
|
||||
* saves the details with `PUT /api/playlists/:playlistId`, sending only the
|
||||
* title and description that changed, then a new cover, if one was picked,
|
||||
* with `POST /api/playlists/:playlistId/image`.
|
||||
*/
|
||||
function PlaylistEditForm(
|
||||
{ playlist, onCancel, onRequestDelete, onSaved }: PlaylistEditFormProps,
|
||||
|
||||
Reference in New Issue
Block a user