import { useEffect, useRef, useState } from "react"; import { Link, useNavigate, useParams } from "react-router"; import { API_URL } from "../config/api.ts"; import type { PlaylistWithDumps, RawPlaylistWithDumps } from "../model.ts"; import { deserializePlaylistWithDumps } from "../model.ts"; 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 { FollowPlaylistButton } from "../components/FollowButton.tsx"; import { ErrorCard } from "../components/ErrorCard.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, lastPlaylistEvent, } = useWS(); const [state, setState] = useState({ status: "loading" }); // 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()); const [dragSrcIndex, setDragSrcIndex] = useState(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); // prevActiveDumpIds: used by the WS effect to diff incoming dumpIds const prevActiveDumpIdsRef = useRef | null>(null); const descriptionRef = useRef(null); 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)); setActiveDumpIds(ids); prevActiveDumpIdsRef.current = ids; 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 || !playlistId) return; const ev = lastPlaylistEvent; if (ev.playlistId !== playlistId) return; if (ev.type === "dumps_updated" && ev.dumpIds) { const newIds = new Set(ev.dumpIds); const prev = prevActiveDumpIdsRef.current ?? new Set(); // 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); } } // Re-added while fading → cancel fade, restore to active for (const id of newIds) { if (!prev.has(id)) { if (cancels.current.has(id)) { cancels.current.get(id)!(); } // If this is a brand-new dump we haven't seen, re-fetch setState((s) => { if (s.status !== "loaded") return s; const known = s.playlist.dumps.some((d) => d.id === id); if (!known) { // Trigger a re-fetch asynchronously setTimeout(fetchPlaylist, 0); } return s; }); setActiveDumpIds((s) => new Set([...s, id])); } } // Reorder active dumps to match the new server order, // keeping fading dumps at their current visual positions. setState((prev) => { if (prev.status !== "loaded") return prev; const dumpMap = new Map(prev.playlist.dumps.map((d) => [d.id, d])); const activeQueue = ev.dumpIds! .filter((id) => dumpMap.has(id)) .map((id) => dumpMap.get(id)!); let qi = 0; const result = prev.playlist.dumps .filter((d) => dumpMap.has(d.id)) .map((d) => newIds.has(d.id) ? activeQueue[qi++] : d); while (qi < activeQueue.length) result.push(activeQueue[qi++]); return { ...prev, playlist: { ...prev.playlist, dumps: result }, }; }); prevActiveDumpIdsRef.current = newIds; } 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, playlistId]); // 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]); const handleDragStart = (index: number) => setDragSrcIndex(index); const handleDragOver = (e: React.DragEvent, index: number) => { e.preventDefault(); if (dragSrcIndex === null || dragSrcIndex === index) return; setState((prev) => { if (prev.status !== "loaded") return prev; // Only reorder among active dumps const activeDumps = prev.playlist.dumps.filter((d) => activeDumpIds.has(d.id) ); const fadingDumps = prev.playlist.dumps.filter((d) => !activeDumpIds.has(d.id) ); const reordered = [...activeDumps]; const [moved] = reordered.splice(dragSrcIndex, 1); reordered.splice(index, 0, moved); setDragSrcIndex(index); setDragOverIndex(index); return { ...prev, playlist: { ...prev.playlist, dumps: [...reordered, ...fadingDumps] }, }; }); }; const handleDragEnd = async () => { if (state.status !== "loaded" || !playlistId) return; setDragSrcIndex(null); setDragOverIndex(null); 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(() => {}); }; useEffect(() => { const el = descriptionRef.current; if (!el) return; el.style.height = "auto"; el.style.height = `${el.scrollHeight}px`; }, [editDescription, editOpen]); 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 { await authFetch(`${API_URL}/api/playlists/${playlistId}`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ title: editTitle, description: editDescription || undefined, isPublic: editIsPublic, }), }); if (imageFile) { const fd = new FormData(); fd.append("file", imageFile); await authFetch(`${API_URL}/api/playlists/${playlistId}/image`, { method: "POST", body: fd, }); } setEditOpen(false); 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 ? (