import { useEffect, useLayoutEffect, useRef, useState } from "react"; import { Link, useNavigate, useParams } from "react-router"; import { API_URL } from "../config/api.ts"; import type { PlaylistWithDumps, RawDump, RawPlaylist, RawPlaylistWithDumps, } from "../model.ts"; import { deserializeDump, deserializePlaylist, deserializePlaylistWithDumps, } from "../model.ts"; import { playlistUrl } from "../utils/urls.ts"; import { useAuth } from "../hooks/useAuth.ts"; import { useWS } from "../hooks/useWS.ts"; import { relativeTime } from "../utils/relativeTime.ts"; import { DumpCard } from "../components/DumpCard.tsx"; import { PageShell } from "../components/PageShell.tsx"; 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 { TextEditor } from "../components/TextEditor.tsx"; import { FollowPlaylistButton } from "../components/FollowButton.tsx"; import { ErrorCard } from "../components/ErrorCard.tsx"; import { Tooltip } from "../components/Tooltip.tsx"; import { friendlyFetchError } from "../utils/apiError.ts"; type LoadState = | { status: "loading" } | { status: "error"; error: string } | { status: "loaded"; playlist: PlaylistWithDumps }; export function PlaylistDetail() { const { playlistId } = useParams<{ playlistId: string }>(); const navigate = useNavigate(); const { user, authFetch, token } = useAuth(); const { voteCounts, myVotes, castVote, removeVote, deletedDumpIds, lastDumpEvent, lastPlaylistEvent, } = useWS(); const [state, setState] = useState({ status: "loading" }); // Stable UUID for WS comparisons — avoids re-running effects on every state change const playlistUUID = state.status === "loaded" ? state.playlist.id : null; // activeDumpIds: which dumps are currently in the playlist (the canonical set) const [activeDumpIds, setActiveDumpIds] = useState>(new Set()); // fading: dumps whose removal is being animated — same cooldown→dismissing pattern as upvoted list const [fading, setFading] = useState< Record >({}); const cancels = useRef void>>(new Map()); // dragSrcRef: mutable ref so handleDragOver always sees the current source index // without stale closure issues (state would only update on next render). const dragSrcRef = useRef(null); const [dragOverIndex, setDragOverIndex] = useState(null); const [editOpen, setEditOpen] = useState(false); const [editTitle, setEditTitle] = useState(""); const [editDescription, setEditDescription] = useState(""); const [editIsPublic, setEditIsPublic] = useState(true); const [editSaving, setEditSaving] = useState(false); const [editError, setEditError] = useState(null); const [confirmDelete, setConfirmDelete] = useState(false); const [imageFile, setImageFile] = useState(null); const [imagePreview, setImagePreview] = useState(null); // Mirrors activeDumpIds for use in effects without adding it as a dep. // Updated on every render via useLayoutEffect so it's always current. const activeDumpIdsRef = useRef(activeDumpIds); // knownDumpIds: all dump IDs that belong to this playlist (for re-adding when dumps become public again) const knownDumpIdsRef = useRef>(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. const dumpOrderRef = useRef([]); useLayoutEffect(() => { activeDumpIdsRef.current = activeDumpIds; }); useEffect(() => () => { cancels.current.forEach((c) => c()); }, []); const fetchPlaylist = () => { if (!playlistId) return; setState({ status: "loading" }); fetch(`${API_URL}/api/playlists/${playlistId}`, { 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 RawPlaylistWithDumps, ); 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); setFading({}); cancels.current.forEach((c) => c()); cancels.current.clear(); }) .catch((err) => { setState({ status: "error", error: friendlyFetchError(err), }); }); }; useEffect(() => { fetchPlaylist(); }, [playlistId]); // Start the cooldown→dismissing→gone sequence for a dump being removed. // After the sequence completes, the dump is removed from state.playlist.dumps. function startFade(id: string) { if (cancels.current.has(id)) return; // already fading 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) return; // Remove from the playlist.dumps array now that animation is done setState((prev) => { if (prev.status !== "loaded") return prev; return { ...prev, playlist: { ...prev.playlist, dumps: prev.playlist.dumps.filter((d) => d.id !== id), }, }; }); kill(); }, 350); kill = () => { dead = true; clearTimeout(t2); setFading((f) => { const n = { ...f }; delete n[id]; return n; }); cancels.current.delete(id); }; cancels.current.set(id, () => kill()); }, 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()); } // WS: playlist metadata updated or deleted 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) => { const n = new Set(s); n.delete(id); return n; }); startFade(id); } } // 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. 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}` } : {}, }) .then((r) => r.ok ? r.json() : null) .then((body) => { if (!body?.success) return; const dump = deserializeDump(body.data as RawDump); 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 { ...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(() => {}); } } // Apply the server-authoritative order: active dumps in ev.dumpIds order, // fading dumps (not in newIds) appended at the end. setState((s) => { if (s.status !== "loaded") return s; const dumpMap = new Map(s.playlist.dumps.map((d) => [d.id, d])); return { ...s, playlist: { ...s.playlist, dumps: [ ...ev.dumpIds! .filter((id) => dumpMap.has(id)) .map((id) => dumpMap.get(id)!), ...s.playlist.dumps.filter((d) => !newIds.has(d.id)), ], }, }; }); dumpOrderRef.current = ev.dumpIds!; } 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]); // Filter out globally deleted dumps (dump was deleted entirely, not just removed from playlist) useEffect(() => { if (deletedDumpIds.size === 0) return; 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]); // Update dump metadata in-place; re-add if it was in this playlist but hidden (private→public) 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) { // 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])); 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; }); // Restore to activeDumpIds if re-added 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; // 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). 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. 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] }, }; }); // Update the ref and highlight index outside the updater (no side effects inside updaters). 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 { await authFetch(`${API_URL}/api/playlists/${playlistId}/order`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ dumpIds: activeDumps.map((d) => d.id) }), }); } catch { fetchPlaylist(); } }; const handleRemoveDump = (dumpId: string) => { if (!playlistId) return; // Fire-and-forget the API call; animate immediately authFetch(`${API_URL}/api/playlists/${playlistId}/dumps/${dumpId}`, { method: "DELETE", }).catch(() => { // On failure, cancel the fade and restore the item cancels.current.get(dumpId)?.(); setActiveDumpIds((prev) => new Set([...prev, dumpId])); }); setActiveDumpIds((prev) => { const n = new Set(prev); n.delete(dumpId); return n; }); startFade(dumpId); }; const handleCancelRemove = (dumpId: string) => { if (!playlistId) return; cancels.current.get(dumpId)?.(); setActiveDumpIds((prev) => new Set([...prev, dumpId])); // Re-add server-side since DELETE already fired authFetch(`${API_URL}/api/playlists/${playlistId}/dumps/${dumpId}`, { method: "POST", }).catch(() => {}); }; const openEdit = () => { if (state.status !== "loaded") return; setEditTitle(state.playlist.title); setEditDescription(state.playlist.description ?? ""); setEditIsPublic(state.playlist.isPublic); setImageFile(null); setImagePreview(null); setEditError(null); setEditOpen(true); }; const handleEditSave = async () => { if (!playlistId || state.status !== "loaded") return; setEditSaving(true); setEditError(null); try { const updateRes = await authFetch( `${API_URL}/api/playlists/${playlistId}`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ title: editTitle, description: editDescription || undefined, isPublic: editIsPublic, }), }, ); const updateJson = await updateRes.json() as { success: boolean; data: RawPlaylist; }; const updatedPlaylist = updateJson.success ? deserializePlaylist(updateJson.data) : null; if (imageFile) { const fd = new FormData(); fd.append("file", imageFile); await authFetch(`${API_URL}/api/playlists/${playlistId}/image`, { method: "POST", body: fd, }); } setEditOpen(false); if (updatedPlaylist) { navigate(playlistUrl(updatedPlaylist), { replace: true }); } else { fetchPlaylist(); } } catch (err) { setEditError(friendlyFetchError(err)); } finally { setEditSaving(false); } }; const handleDelete = async () => { if (!playlistId) return; await authFetch(`${API_URL}/api/playlists/${playlistId}`, { method: "DELETE", }); navigate("/"); }; if (state.status === "loading") { return (

Loading playlist…

); } if (state.status === "error") { return ( navigate("/")} > ← Back } /> ); } const { playlist } = state; const isOwner = !!user && user.id === playlist.userId; // Active dumps in playlist order; fading dumps appended so they stay visible const activeDumps = playlist.dumps.filter((d) => activeDumpIds.has(d.id)); const visibleDumps = playlist.dumps.filter((d) => activeDumpIds.has(d.id) || d.id in fading ); return (
{editOpen ? ( { setImageFile(file); setImagePreview(URL.createObjectURL(file)); }} /> ) : playlist.imageMime && ( )}
{editOpen ? (
setEditTitle(e.target.value)} autoFocus />
) : (

{playlist.title}

{!isOwner && ( )} {isOwner && ( )}
)} {editOpen ? ( ) : playlist.description && ( {playlist.description} )}
{editOpen ? (
) : ( <> {playlist.isPublic ? "public" : "private"} {playlist.ownerUsername && ( @{playlist.ownerUsername} )} {playlist.updatedAt && ( edited {relativeTime(playlist.updatedAt)} )} )}
{editError && ( )}
{visibleDumps.length === 0 ?

No dumps in this playlist yet.

: (
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; const cardCls = phase === "cooldown" ? "dump-card--fading" : phase === "dismissing" ? "dump-card--dismissing" : undefined; return (
handleDragStart(activeIndex) : undefined} onDragOver={isOwner && isActive ? (e) => handleDragOver(e, activeIndex) : undefined} onDragEnd={isOwner ? handleDragEnd : undefined} > {isOwner && isActive && ( )} {isOwner && (isActive ? ( ) : phase === "cooldown" && ( ))}
); })}
)} {confirmDelete && ( setConfirmDelete(false)} /> )}
); }