v3: every paged list loads its pages one way, PlaylistDetail is split like the profile, and the comments keep the README's rules — Index, FollowedFeed, useUserDumpFeed, UserPlaylists, UserListPopover, Notifications, Search and the profile's followed users each hand-wrote "fetch the next page, append it, clear loadingMore on failure". A list the API pages is now a PagedList whose next page useLoadMore appends, and a list that loads on its own goes through usePagedList, which is useFetchState (now taking an initial value, as a restored cache, that starts loaded) plus useLoadMore. Pages are read by pageUrl with readPage (a refusal throws) or readFirstPage (a refusal is empty, as fetchFirstPage was), signed-in reads go through authFetch, and useFeedCache's saveState takes the list. Notifications keeps the socket's arrivals as their own list merged at render, as Index does with recentDumps, and marks them read in one effect. PlaylistDetail (963 lines) only puts the pieces together (146): usePlaylist holds the playlist, its live sync and its edits, PlaylistHeader, PlaylistEditForm and PlaylistDumpList are one file each, and LiveDumpCard is the vote wiring DumpCardList and the playlist both use. What it re-invented is shared: the load is useFetchState, a dump going private and back is usePositionAwareSync, deletes and edits are useDumpListSync (which also keeps comment counts, which the page didn't). A removal is sent when its fade ends, or as the page is left, so an undo sends nothing: the removal/re-add/reorder dance, the pending-undo order and the 80 ms debounce of its echoes are gone. A refused reorder goes back to the server's last order and says so, instead of reloading the page. The comments written since the documentation pass broke its rules in places: module docs as a label and a colon ("The "dumps" tab of a profile: …"), noun-phrase fragments on types, fields and constants, stale lists of callers, over-long lines; they are sentences now, and the ones the refactor made wrong (PlaylistDetail as useFading's and FollowPlaylistButton's caller, "the profiles" as usePositionAwareSync's only users) say what is true. 650 frontend and 214 backend tests pass (new: usePagedList, useFetchState's initial, pageUrl/readPage, the deferred removal, a dump returning at its place in a playlist), and tsc, eslint, doc:lint, deno check, deno lint and deno fmt are clean.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -5,557 +5,57 @@
|
||||
* @module
|
||||
*/
|
||||
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { Link, useNavigate, useParams } from "react-router";
|
||||
import { useState } from "react";
|
||||
import { useNavigate, useParams } from "react-router";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { API_URL } from "../config/api.ts";
|
||||
import { VALIDATION } from "../../shared/validation.ts";
|
||||
import { CountedInput } from "../components/shared/CountedInput.tsx";
|
||||
import type {
|
||||
Playlist,
|
||||
PlaylistWithDumps,
|
||||
ReorderPlaylistRequest,
|
||||
UpdatePlaylistRequest,
|
||||
} from "../../shared/playlist.ts";
|
||||
import type { Wire } from "../../shared/api.ts";
|
||||
import type { Dump } from "../../shared/dump.ts";
|
||||
import {
|
||||
deserializeDump,
|
||||
deserializePlaylist,
|
||||
deserializePlaylistWithDumps,
|
||||
} from "../utils/shared/deserialize.ts";
|
||||
import { playlistImageUrl, playlistUrl } from "../utils/shared/urls.ts";
|
||||
import { can } from "../utils/auth/permissions.ts";
|
||||
import { useAuth } from "../hooks/auth/useAuth.ts";
|
||||
import { useDocumentTitle } from "../hooks/shared/useDocumentTitle.ts";
|
||||
import { useHeightTransition } from "../hooks/shared/useHeightTransition.ts";
|
||||
import { fadeClassName, useFading } from "../hooks/feed/useFading.ts";
|
||||
import { useWS } from "../hooks/ws/useWS.ts";
|
||||
import { relativeTime } from "../utils/shared/relativeTime.ts";
|
||||
import { DumpCard } from "../components/dump/DumpCard.tsx";
|
||||
import { usePlaylist } from "../hooks/playlist/usePlaylist.ts";
|
||||
import { PageShell } from "../components/layout/PageShell.tsx";
|
||||
import { PageError } from "../components/layout/PageError.tsx";
|
||||
import { ErrorCard } from "../components/shared/ErrorCard.tsx";
|
||||
import { ConfirmModal } from "../components/shared/ConfirmModal.tsx";
|
||||
import { ImagePicker } from "../components/shared/ImagePicker.tsx";
|
||||
import { Markdown } from "../components/editor/Markdown.tsx";
|
||||
import { TextEditor } from "../components/editor/TextEditor.tsx";
|
||||
import { FollowPlaylistButton } from "../components/social/FollowButton.tsx";
|
||||
import { Tooltip } from "../components/shared/Tooltip.tsx";
|
||||
import { VisibilitySegmented } from "../components/form/SegmentedField.tsx";
|
||||
import { expectOk, friendlyFetchError } from "../utils/shared/api.ts";
|
||||
import { Controller, FormProvider } from "react-hook-form";
|
||||
import { FormError } from "../components/form/FormError.tsx";
|
||||
import { SubmitButton } from "../components/form/SubmitButton.tsx";
|
||||
import { useApiForm } from "../hooks/form/useApiForm.ts";
|
||||
|
||||
type LoadState =
|
||||
| { status: "loading" }
|
||||
| { status: "error"; error: string }
|
||||
| { status: "loaded"; playlist: PlaylistWithDumps };
|
||||
import { PlaylistHeader } from "../components/playlist/PlaylistHeader.tsx";
|
||||
import { PlaylistEditForm } from "../components/playlist/PlaylistEditForm.tsx";
|
||||
import { PlaylistDumpList } from "../components/playlist/PlaylistDumpList.tsx";
|
||||
|
||||
/**
|
||||
* Renders `/playlists/:playlistId` (an id or a slug), for anyone who may see
|
||||
* 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.
|
||||
* the playlist: a private one only to its owner. `usePlaylist` loads it and
|
||||
* keeps it 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 }>();
|
||||
const navigate = useNavigate();
|
||||
const { user, authFetch, token, mediaToken } = useAuth();
|
||||
const { user } = useAuth();
|
||||
const {
|
||||
voteCounts,
|
||||
myVotes,
|
||||
castVote,
|
||||
removeVote,
|
||||
deletedDumpIds,
|
||||
lastDumpEvent,
|
||||
lastPlaylistEvent,
|
||||
} = useWS();
|
||||
|
||||
const [state, setState] = useState<LoadState>({ status: "loading" });
|
||||
// The socket names playlists by id, while the URL may hold the slug.
|
||||
const playlistUUID = state.status === "loaded" ? state.playlist.id : null;
|
||||
|
||||
useDocumentTitle(
|
||||
state.status === "loaded"
|
||||
? state.playlist.title
|
||||
: state.status === "error"
|
||||
? null
|
||||
: undefined,
|
||||
);
|
||||
|
||||
// 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());
|
||||
|
||||
// A removed dump shows faded for 2 seconds, while it can be undone, then
|
||||
// slides out, as on the upvoted page, and leaves `state.playlist.dumps`.
|
||||
const { fading, startFading, cancelFading, cancelAll } = useFading(
|
||||
(id) =>
|
||||
setState((prev) =>
|
||||
prev.status !== "loaded" ? prev : {
|
||||
...prev,
|
||||
playlist: {
|
||||
...prev.playlist,
|
||||
dumps: prev.playlist.dumps.filter((d) => d.id !== id),
|
||||
},
|
||||
}
|
||||
),
|
||||
);
|
||||
// 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);
|
||||
// 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,
|
||||
);
|
||||
|
||||
// A ref, since dragover fires faster than state renders.
|
||||
const dragSrcRef = useRef<number | null>(null);
|
||||
const [dragOverIndex, setDragOverIndex] = useState<number | null>(null);
|
||||
state,
|
||||
fading,
|
||||
actionError,
|
||||
applyDetails,
|
||||
remove,
|
||||
undoRemove,
|
||||
move,
|
||||
saveOrder,
|
||||
deletePlaylist,
|
||||
} = usePlaylist(playlistId);
|
||||
|
||||
const [editOpen, setEditOpen] = useState(false);
|
||||
const headerRef = useHeightTransition<HTMLDivElement>(editOpen);
|
||||
const [confirmDelete, setConfirmDelete] = useState(false);
|
||||
// A delete or a removal the server refused. A refused reorder just reloads.
|
||||
const [actionError, setActionError] = useState<string | null>(null);
|
||||
|
||||
// For the socket effects, which must not re-run when it changes.
|
||||
const activeDumpIdsRef = useRef(activeDumpIds);
|
||||
// Every dump of the playlist seen so far, so that one hidden when it turned
|
||||
// private comes back when it turns public. A dump removed from the playlist
|
||||
// leaves it.
|
||||
const knownDumpIdsRef = useRef<Set<string>>(new Set());
|
||||
// The removals still in flight, by dump, which an undo must wait for.
|
||||
const removalsRef = useRef<Map<string, Promise<unknown>>>(new Map());
|
||||
// The server's last order, where such a dump comes back.
|
||||
const dumpOrderRef = useRef<string[]>([]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
activeDumpIdsRef.current = activeDumpIds;
|
||||
});
|
||||
|
||||
useEffect(() => () => {
|
||||
if (dumpReorderTimerRef.current) clearTimeout(dumpReorderTimerRef.current);
|
||||
}, []);
|
||||
|
||||
const fetchAbortRef = useRef<AbortController | null>(null);
|
||||
|
||||
const fetchPlaylist = useCallback(() => {
|
||||
if (!playlistId) return;
|
||||
fetchAbortRef.current?.abort();
|
||||
const controller = new AbortController();
|
||||
fetchAbortRef.current = controller;
|
||||
// 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,
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||
})
|
||||
.then((r) => {
|
||||
if (!r.ok) {
|
||||
throw new Error(
|
||||
r.status === 404 ? "Playlist not found" : `HTTP ${r.status}`,
|
||||
);
|
||||
}
|
||||
return r.json();
|
||||
})
|
||||
.then((body) => {
|
||||
if (!body.success) throw new Error("Failed to load playlist");
|
||||
const pl = deserializePlaylistWithDumps(
|
||||
body.data as Wire<PlaylistWithDumps>,
|
||||
);
|
||||
setState({ status: "loaded", playlist: pl });
|
||||
const ids = new Set(pl.dumps.map((d) => d.id));
|
||||
const order = pl.dumps.map((d) => d.id);
|
||||
setActiveDumpIds(ids);
|
||||
dumpOrderRef.current = order;
|
||||
for (const id of ids) knownDumpIdsRef.current.add(id);
|
||||
cancelAll();
|
||||
if (dumpReorderTimerRef.current) {
|
||||
clearTimeout(dumpReorderTimerRef.current);
|
||||
dumpReorderTimerRef.current = null;
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
if (err.name === "AbortError") return;
|
||||
setState({
|
||||
status: "error",
|
||||
error: friendlyFetchError(err),
|
||||
});
|
||||
});
|
||||
}, [playlistId, token, cancelAll]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchPlaylist();
|
||||
return () => fetchAbortRef.current?.abort();
|
||||
}, [fetchPlaylist]);
|
||||
|
||||
// The socket's changes to this playlist: its dumps, its details, its end.
|
||||
useEffect(() => {
|
||||
if (!lastPlaylistEvent || !playlistUUID) return;
|
||||
const ev = lastPlaylistEvent;
|
||||
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);
|
||||
const prev = activeDumpIdsRef.current;
|
||||
|
||||
for (const id of prev) {
|
||||
if (!newIds.has(id)) {
|
||||
// Shown until now and gone from the list: removed from the
|
||||
// playlist, not hidden (that comes as `dump_deleted`).
|
||||
knownDumpIdsRef.current.delete(id);
|
||||
setActiveDumpIds((s) => {
|
||||
const n = new Set(s);
|
||||
n.delete(id);
|
||||
return n;
|
||||
});
|
||||
startFading(id);
|
||||
}
|
||||
}
|
||||
|
||||
// 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)) {
|
||||
cancelFading(id);
|
||||
setActiveDumpIds((s) => new Set([...s, id]));
|
||||
const orderedIds = ev.dumpIds!;
|
||||
fetch(`${API_URL}/api/dumps/${id}`, {
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||
})
|
||||
.then((r) => r.ok ? r.json() : null)
|
||||
.then((body) => {
|
||||
if (!body?.success) return;
|
||||
const dump = deserializeDump(body.data as Wire<Dump>);
|
||||
setState((s) => {
|
||||
if (s.status !== "loaded") return s;
|
||||
if (s.playlist.dumps.some((d) => d.id === dump.id)) return s;
|
||||
const dumpMap = new Map(s.playlist.dumps.map((d) => [d.id, d]));
|
||||
dumpMap.set(dump.id, dump);
|
||||
return {
|
||||
...s,
|
||||
playlist: {
|
||||
...s.playlist,
|
||||
dumps: [
|
||||
...orderedIds
|
||||
.filter((oid) => dumpMap.has(oid))
|
||||
.map((oid) => dumpMap.get(oid)!),
|
||||
...s.playlist.dumps.filter((d) => !newIds.has(d.id)),
|
||||
],
|
||||
},
|
||||
};
|
||||
});
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
// Debounced: see dumpReorderTimerRef.
|
||||
if (dumpReorderTimerRef.current) {
|
||||
clearTimeout(dumpReorderTimerRef.current);
|
||||
}
|
||||
const orderToApply = pendingUndoOrderRef.current ?? ev.dumpIds!;
|
||||
const serverOrder = ev.dumpIds!;
|
||||
dumpReorderTimerRef.current = setTimeout(() => {
|
||||
dumpReorderTimerRef.current = null;
|
||||
setState((s) => {
|
||||
if (s.status !== "loaded") return s;
|
||||
const dumpMap = new Map(s.playlist.dumps.map((d) => [d.id, d]));
|
||||
const orderedActive = orderToApply
|
||||
.filter((id) => dumpMap.has(id))
|
||||
.map((id) => dumpMap.get(id)!);
|
||||
let ai = 0;
|
||||
// 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
|
||||
);
|
||||
while (ai < orderedActive.length) merged.push(orderedActive[ai++]);
|
||||
return { ...s, playlist: { ...s.playlist, dumps: merged } };
|
||||
});
|
||||
dumpOrderRef.current = serverOrder;
|
||||
}, 80);
|
||||
} else if (ev.type === "updated" && ev.playlist) {
|
||||
setState((prev) => {
|
||||
if (prev.status !== "loaded") return prev;
|
||||
return {
|
||||
...prev,
|
||||
playlist: {
|
||||
...prev.playlist,
|
||||
title: ev.playlist!.title,
|
||||
description: ev.playlist!.description,
|
||||
isPublic: ev.playlist!.isPublic,
|
||||
imageMime: ev.playlist!.imageMime,
|
||||
},
|
||||
};
|
||||
});
|
||||
} else if (ev.type === "deleted") {
|
||||
navigate("/");
|
||||
}
|
||||
}, [
|
||||
lastPlaylistEvent,
|
||||
playlistUUID,
|
||||
navigate,
|
||||
token,
|
||||
startFading,
|
||||
cancelFading,
|
||||
]);
|
||||
|
||||
// A deleted dump leaves at once, without the removal's fade.
|
||||
useEffect(() => {
|
||||
if (deletedDumpIds.size === 0) return;
|
||||
// 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;
|
||||
const filtered = prev.playlist.dumps.filter((d) =>
|
||||
!deletedDumpIds.has(d.id)
|
||||
);
|
||||
if (filtered.length === prev.playlist.dumps.length) return prev;
|
||||
return { ...prev, playlist: { ...prev.playlist, dumps: filtered } };
|
||||
});
|
||||
setActiveDumpIds((prev) => {
|
||||
const n = new Set(prev);
|
||||
for (const id of deletedDumpIds) n.delete(id);
|
||||
return n;
|
||||
});
|
||||
}, [deletedDumpIds]);
|
||||
|
||||
// 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;
|
||||
setState((prev) => {
|
||||
if (prev.status !== "loaded") return prev;
|
||||
const idx = prev.playlist.dumps.findIndex((d) => d.id === dump.id);
|
||||
if (idx !== -1) {
|
||||
const dumps = [...prev.playlist.dumps];
|
||||
dumps[idx] = dump;
|
||||
return { ...prev, playlist: { ...prev.playlist, dumps } };
|
||||
}
|
||||
if (!dump.isPrivate && knownDumpIdsRef.current.has(dump.id)) {
|
||||
const order = dumpOrderRef.current;
|
||||
const dumpMap = new Map(prev.playlist.dumps.map((d) => [d.id, d]));
|
||||
dumpMap.set(dump.id, dump);
|
||||
const reinserted = order.length > 0
|
||||
? [
|
||||
...order.filter((id) => dumpMap.has(id)).map((id) =>
|
||||
dumpMap.get(id)!
|
||||
),
|
||||
...prev.playlist.dumps.filter((d) => !new Set(order).has(d.id)),
|
||||
]
|
||||
: [...prev.playlist.dumps, dump];
|
||||
return { ...prev, playlist: { ...prev.playlist, dumps: reinserted } };
|
||||
}
|
||||
return prev;
|
||||
});
|
||||
if (!dump.isPrivate && knownDumpIdsRef.current.has(dump.id)) {
|
||||
setActiveDumpIds((prev) => {
|
||||
if (prev.has(dump.id)) return prev;
|
||||
return new Set([...prev, dump.id]);
|
||||
});
|
||||
}
|
||||
}, [lastDumpEvent]);
|
||||
|
||||
const handleDragStart = (index: number) => {
|
||||
dragSrcRef.current = index;
|
||||
};
|
||||
|
||||
const handleDragOver = (e: React.DragEvent, index: number) => {
|
||||
e.preventDefault();
|
||||
const src = dragSrcRef.current;
|
||||
if (src === null || src === index) return;
|
||||
// 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;
|
||||
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;
|
||||
const activeDumps = prev.playlist.dumps.filter((d) => ids.has(d.id));
|
||||
const fadingDumps = prev.playlist.dumps.filter((d) => !ids.has(d.id));
|
||||
const reordered = [...activeDumps];
|
||||
const [moved] = reordered.splice(src, 1);
|
||||
reordered.splice(index, 0, moved);
|
||||
return {
|
||||
...prev,
|
||||
playlist: { ...prev.playlist, dumps: [...reordered, ...fadingDumps] },
|
||||
};
|
||||
});
|
||||
dragSrcRef.current = index;
|
||||
setDragOverIndex(index);
|
||||
};
|
||||
|
||||
const handleDragEnd = async () => {
|
||||
const src = dragSrcRef.current;
|
||||
dragSrcRef.current = null;
|
||||
setDragOverIndex(null);
|
||||
if (src === null || state.status !== "loaded" || !playlistId) return;
|
||||
const activeDumps = state.playlist.dumps.filter((d) =>
|
||||
activeDumpIds.has(d.id)
|
||||
);
|
||||
try {
|
||||
const res = await authFetch(
|
||||
`${API_URL}/api/playlists/${playlistId}/order`,
|
||||
{
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(
|
||||
{
|
||||
dumpIds: activeDumps.map((d) => d.id),
|
||||
} satisfies ReorderPlaylistRequest,
|
||||
),
|
||||
},
|
||||
);
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
} catch {
|
||||
// Back to the order the server actually kept.
|
||||
fetchPlaylist();
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveDump = (dumpId: string) => {
|
||||
if (!playlistId || state.status !== "loaded") return;
|
||||
const index = state.playlist.dumps.findIndex((d) => d.id === dumpId);
|
||||
const dump = state.playlist.dumps[index];
|
||||
knownDumpIdsRef.current.delete(dumpId);
|
||||
// Removed on the server at once; the undo adds it back.
|
||||
const removal = authFetch(
|
||||
`${API_URL}/api/playlists/${playlistId}/dumps/${dumpId}`,
|
||||
{ method: "DELETE" },
|
||||
).then((res) => {
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
}).catch(() => {
|
||||
cancelFading(dumpId);
|
||||
knownDumpIdsRef.current.add(dumpId);
|
||||
setActiveDumpIds((prev) => new Set([...prev, dumpId]));
|
||||
// The fade may have taken it out of the list already.
|
||||
setState((s) => {
|
||||
if (s.status !== "loaded" || !dump) return s;
|
||||
if (s.playlist.dumps.some((d) => d.id === dumpId)) return s;
|
||||
const dumps = [...s.playlist.dumps];
|
||||
dumps.splice(Math.min(index, dumps.length), 0, dump);
|
||||
return { ...s, playlist: { ...s.playlist, dumps } };
|
||||
});
|
||||
setActionError(t`The dump couldn't be removed. Try again.`);
|
||||
}).finally(() => removalsRef.current.delete(dumpId));
|
||||
removalsRef.current.set(dumpId, removal);
|
||||
setActiveDumpIds((prev) => {
|
||||
const n = new Set(prev);
|
||||
n.delete(dumpId);
|
||||
return n;
|
||||
});
|
||||
startFading(dumpId);
|
||||
};
|
||||
|
||||
const handleCancelRemove = (dumpId: string) => {
|
||||
if (!playlistId || state.status !== "loaded") return;
|
||||
cancelFading(dumpId);
|
||||
setActiveDumpIds((prev) => new Set([...prev, dumpId]));
|
||||
// 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);
|
||||
pendingUndoOrderRef.current = desiredOrder;
|
||||
knownDumpIdsRef.current.add(dumpId);
|
||||
// Added back only once the removal has landed, or the two could cross.
|
||||
// The server adds the dump first, so the order is restored after.
|
||||
(removalsRef.current.get(dumpId) ?? Promise.resolve())
|
||||
.then(() =>
|
||||
authFetch(`${API_URL}/api/playlists/${playlistId}/dumps/${dumpId}`, {
|
||||
method: "POST",
|
||||
})
|
||||
)
|
||||
.then((res) => {
|
||||
// 409: the removal failed, so it never left.
|
||||
if (!res.ok && res.status !== 409) {
|
||||
throw new Error(`HTTP ${res.status}`);
|
||||
}
|
||||
return authFetch(`${API_URL}/api/playlists/${playlistId}/order`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(
|
||||
{ dumpIds: desiredOrder } satisfies ReorderPlaylistRequest,
|
||||
),
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
knownDumpIdsRef.current.delete(dumpId);
|
||||
setActiveDumpIds((prev) => {
|
||||
const n = new Set(prev);
|
||||
n.delete(dumpId);
|
||||
return n;
|
||||
});
|
||||
startFading(dumpId);
|
||||
setActionError(t`The dump couldn't be put back. Try again.`);
|
||||
})
|
||||
.finally(() => {
|
||||
pendingUndoOrderRef.current = null;
|
||||
});
|
||||
};
|
||||
|
||||
const openEdit = () => setEditOpen(true);
|
||||
|
||||
const handleEditSaved = (updated: Playlist) => {
|
||||
setEditOpen(false);
|
||||
// Shown at once rather than left to the socket's echo, which may never
|
||||
// come (disconnected); a new slug also moves the URL.
|
||||
setState((prev) =>
|
||||
prev.status === "loaded"
|
||||
? {
|
||||
...prev,
|
||||
playlist: {
|
||||
...prev.playlist,
|
||||
...updated,
|
||||
dumps: prev.playlist.dumps,
|
||||
},
|
||||
}
|
||||
: prev
|
||||
);
|
||||
if (state.status === "loaded" && updated.slug !== state.playlist.slug) {
|
||||
navigate(playlistUrl(updated), { replace: true });
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!playlistId) return;
|
||||
setConfirmDelete(false);
|
||||
setActionError(null);
|
||||
try {
|
||||
const res = await authFetch(`${API_URL}/api/playlists/${playlistId}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
// expectOk throws the server's message.
|
||||
if (!res.ok) await expectOk(res);
|
||||
} catch (err) {
|
||||
setActionError(friendlyFetchError(err));
|
||||
return;
|
||||
}
|
||||
navigate("/");
|
||||
};
|
||||
useDocumentTitle(
|
||||
state.status === "loaded"
|
||||
? state.data.title
|
||||
: state.status === "error"
|
||||
? null
|
||||
: undefined,
|
||||
);
|
||||
|
||||
if (state.status === "loading") {
|
||||
return (
|
||||
@@ -584,15 +84,9 @@ export function PlaylistDetail(): React.JSX.Element {
|
||||
);
|
||||
}
|
||||
|
||||
const { playlist } = state;
|
||||
const isOwner = !!user && user.id === playlist.userId;
|
||||
const canEdit = isOwner || can(user, "playlist:moderate");
|
||||
|
||||
// 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
|
||||
);
|
||||
const playlist = state.data;
|
||||
const canEdit = (!!user && user.id === playlist.userId) ||
|
||||
can(user, "playlist:moderate");
|
||||
|
||||
return (
|
||||
<PageShell>
|
||||
@@ -604,81 +98,19 @@ export function PlaylistDetail(): React.JSX.Element {
|
||||
playlist={playlist}
|
||||
onCancel={() => setEditOpen(false)}
|
||||
onRequestDelete={() => setConfirmDelete(true)}
|
||||
onSaved={handleEditSaved}
|
||||
onSaved={(updated) => {
|
||||
setEditOpen(false);
|
||||
// Shown at once rather than left to the socket's echo,
|
||||
// which may never come (disconnected).
|
||||
applyDetails(updated);
|
||||
}}
|
||||
/>
|
||||
)
|
||||
: (
|
||||
<>
|
||||
{playlist.imageMime && (
|
||||
<img
|
||||
src={playlistImageUrl(playlist, mediaToken)}
|
||||
alt=""
|
||||
className="playlist-detail-img"
|
||||
/>
|
||||
)}
|
||||
<div className="playlist-detail-content">
|
||||
<div className="playlist-detail-title-row">
|
||||
<h1 className="playlist-detail-title">{playlist.title}</h1>
|
||||
{!isOwner && (
|
||||
<FollowPlaylistButton
|
||||
targetPlaylistId={playlist.id}
|
||||
isPublic={playlist.isPublic}
|
||||
/>
|
||||
)}
|
||||
{canEdit && (
|
||||
<button
|
||||
type="button"
|
||||
className="playlist-edit-btn"
|
||||
onClick={openEdit}
|
||||
>
|
||||
<Trans>Edit</Trans>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{playlist.description && (
|
||||
<Markdown className="playlist-detail-description">
|
||||
{playlist.description}
|
||||
</Markdown>
|
||||
)}
|
||||
|
||||
<div className="playlist-detail-meta">
|
||||
<span
|
||||
className={`playlist-badge${
|
||||
playlist.isPublic ? "" : " playlist-badge--private"
|
||||
}`}
|
||||
>
|
||||
{playlist.isPublic
|
||||
? <Trans>public</Trans>
|
||||
: <Trans>private</Trans>}
|
||||
</span>
|
||||
{playlist.ownerUsername && (
|
||||
<Link
|
||||
to={`/users/${playlist.ownerUsername}`}
|
||||
className="playlist-detail-owner"
|
||||
>
|
||||
@{playlist.ownerUsername}
|
||||
</Link>
|
||||
)}
|
||||
<Tooltip text={playlist.createdAt.toLocaleString()}>
|
||||
<time dateTime={playlist.createdAt.toISOString()}>
|
||||
{relativeTime(playlist.createdAt)}
|
||||
</time>
|
||||
</Tooltip>
|
||||
{playlist.updatedAt && (
|
||||
<Tooltip
|
||||
text={t`Edited ${playlist.updatedAt.toLocaleString()}`}
|
||||
>
|
||||
<span className="playlist-edited-label">
|
||||
<Trans>
|
||||
edited {relativeTime(playlist.updatedAt)}
|
||||
</Trans>
|
||||
</span>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
<PlaylistHeader
|
||||
playlist={playlist}
|
||||
onEdit={canEdit ? () => setEditOpen(true) : undefined}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -687,277 +119,28 @@ export function PlaylistDetail(): React.JSX.Element {
|
||||
<ErrorCard title={t`Something went wrong`} message={actionError} />
|
||||
)}
|
||||
|
||||
{visibleDumps.length === 0
|
||||
? (
|
||||
<p className="empty-state">
|
||||
<Trans>No dumps in this playlist yet.</Trans>
|
||||
</p>
|
||||
)
|
||||
: (
|
||||
<div
|
||||
className="playlist-dump-list"
|
||||
onDragOver={canEdit ? (e) => e.preventDefault() : undefined}
|
||||
>
|
||||
{visibleDumps.map((dump) => {
|
||||
const isActive = activeDumpIds.has(dump.id);
|
||||
const phase = fading[dump.id];
|
||||
// drag index is within the active-only list
|
||||
const activeIndex = isActive ? activeDumps.indexOf(dump) : -1;
|
||||
<PlaylistDumpList
|
||||
dumps={playlist.dumps}
|
||||
fading={fading}
|
||||
{...(canEdit && {
|
||||
onRemove: remove,
|
||||
onUndoRemove: undoRemove,
|
||||
onMove: move,
|
||||
onMoved: saveOrder,
|
||||
})}
|
||||
/>
|
||||
|
||||
return (
|
||||
<div
|
||||
key={dump.id}
|
||||
className={`playlist-dump-item${
|
||||
activeIndex === dragOverIndex && isActive
|
||||
? " playlist-dump-item--drag-over"
|
||||
: ""
|
||||
}`}
|
||||
draggable={canEdit && isActive}
|
||||
onDragStart={canEdit && isActive
|
||||
? () => handleDragStart(activeIndex)
|
||||
: undefined}
|
||||
onDragOver={canEdit && isActive
|
||||
? (e) => handleDragOver(e, activeIndex)
|
||||
: undefined}
|
||||
onDragEnd={canEdit ? handleDragEnd : undefined}
|
||||
>
|
||||
{canEdit && isActive && (
|
||||
<span className="drag-handle" aria-hidden>⠿</span>
|
||||
)}
|
||||
<DumpCard
|
||||
dump={dump}
|
||||
voteCount={voteCounts[dump.id] ?? dump.voteCount}
|
||||
voted={myVotes.has(dump.id)}
|
||||
canVote={!!user}
|
||||
castVote={castVote}
|
||||
removeVote={removeVote}
|
||||
className={fadeClassName(phase)}
|
||||
isOwner={!!user && user.id === dump.userId}
|
||||
/>
|
||||
{canEdit && (isActive
|
||||
? (
|
||||
<button
|
||||
type="button"
|
||||
className="playlist-remove-btn"
|
||||
onClick={() => handleRemoveDump(dump.id)}
|
||||
aria-label={t`Remove from playlist`}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
)
|
||||
: phase === "cooldown" && (
|
||||
<button
|
||||
type="button"
|
||||
className="playlist-cancel-btn"
|
||||
onClick={() => handleCancelRemove(dump.id)}
|
||||
aria-label={t`Cancel removal`}
|
||||
>
|
||||
<Trans>Undo</Trans>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{confirmDelete && (
|
||||
<ConfirmModal
|
||||
message={t`Delete this playlist? This cannot be undone.`}
|
||||
confirmLabel={t`Delete playlist`}
|
||||
onConfirm={handleDelete}
|
||||
onConfirm={() => {
|
||||
setConfirmDelete(false);
|
||||
void deletePlaylist();
|
||||
}}
|
||||
onCancel={() => setConfirmDelete(false)}
|
||||
/>
|
||||
)}
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
|
||||
/** 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;
|
||||
}
|
||||
|
||||
interface EditValues {
|
||||
title: string;
|
||||
description: string;
|
||||
isPublic: boolean;
|
||||
image: File | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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,
|
||||
) {
|
||||
const { authFetch, mediaToken } = useAuth();
|
||||
const [imagePreview, setImagePreview] = useState<string | null>(null);
|
||||
// Each preview holds the file in memory until its URL is revoked: when the
|
||||
// next one replaces it, or when the form closes.
|
||||
useEffect(() => {
|
||||
if (!imagePreview) return;
|
||||
return () => URL.revokeObjectURL(imagePreview);
|
||||
}, [imagePreview]);
|
||||
const form = useApiForm<EditValues>({
|
||||
defaultValues: {
|
||||
title: playlist.title,
|
||||
description: playlist.description ?? "",
|
||||
isPublic: playlist.isPublic,
|
||||
image: null,
|
||||
},
|
||||
});
|
||||
const description = form.watch("description");
|
||||
const title = form.watch("title");
|
||||
|
||||
const onSubmit = form.submit(
|
||||
async ({ title, description, isPublic, image }) => {
|
||||
const updated = deserializePlaylist(
|
||||
await expectOk<Wire<Playlist>>(
|
||||
await authFetch(`${API_URL}/api/playlists/${playlist.id}`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(
|
||||
{
|
||||
...(title !== playlist.title ? { title } : {}),
|
||||
...(description !== (playlist.description ?? "")
|
||||
? { description: description || null }
|
||||
: {}),
|
||||
isPublic,
|
||||
} satisfies UpdatePlaylistRequest,
|
||||
),
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
if (image) {
|
||||
const fd = new FormData();
|
||||
fd.append("file", image);
|
||||
// The upload answers with the playlist as it now stands (new cover,
|
||||
// new updatedAt), which is what the page should show.
|
||||
onSaved(deserializePlaylist(
|
||||
await expectOk<Wire<Playlist>>(
|
||||
await authFetch(`${API_URL}/api/playlists/${playlist.id}/image`, {
|
||||
method: "POST",
|
||||
body: fd,
|
||||
}),
|
||||
),
|
||||
));
|
||||
return;
|
||||
}
|
||||
|
||||
onSaved(updated);
|
||||
},
|
||||
);
|
||||
|
||||
return (
|
||||
<FormProvider {...form}>
|
||||
{
|
||||
/* `display: contents` keeps the image-beside-content flex layout while
|
||||
still giving us a real <form> for submit + Enter handling. */
|
||||
}
|
||||
<form style={{ display: "contents" }} onSubmit={onSubmit}>
|
||||
<Controller
|
||||
name="image"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<ImagePicker
|
||||
src={imagePreview ??
|
||||
(playlist.imageMime
|
||||
? playlistImageUrl(playlist, mediaToken)
|
||||
: null)}
|
||||
alt="Cover"
|
||||
size={72}
|
||||
className="animate-in"
|
||||
onChange={(file) => {
|
||||
field.onChange(file);
|
||||
setImagePreview(URL.createObjectURL(file));
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="playlist-detail-content playlist-edit-content animate-in">
|
||||
<Controller
|
||||
name="title"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<CountedInput
|
||||
className="playlist-edit-input"
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Escape") onCancel();
|
||||
}}
|
||||
autoFocus
|
||||
placeholder={t`Playlist title`}
|
||||
maxLength={VALIDATION.PLAYLIST_TITLE_MAX}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Controller
|
||||
name="description"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<TextEditor
|
||||
className="playlist-edit-textarea"
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
onSubmit={() => void onSubmit()}
|
||||
onCancel={onCancel}
|
||||
placeholder={t`Description (optional)`}
|
||||
autoResize
|
||||
rows={2}
|
||||
maxLength={VALIDATION.PLAYLIST_DESCRIPTION_MAX}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Controller
|
||||
name="isPublic"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<VisibilitySegmented
|
||||
className="playlist-edit-toggle"
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormError title={t`Failed to save`} />
|
||||
|
||||
<div className="form-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="btn-danger"
|
||||
onClick={onRequestDelete}
|
||||
>
|
||||
<Trans>Delete</Trans>
|
||||
</button>
|
||||
<div className="form-actions-right">
|
||||
<button type="button" className="form-cancel" onClick={onCancel}>
|
||||
<Trans>Cancel</Trans>
|
||||
</button>
|
||||
<SubmitButton
|
||||
pendingLabel={<Trans>Saving…</Trans>}
|
||||
disabled={!title.trim() ||
|
||||
description.length > VALIDATION.PLAYLIST_DESCRIPTION_MAX}
|
||||
>
|
||||
<Trans>Save</Trans>
|
||||
</SubmitButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</FormProvider>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user