Files
gerbeur/src/pages/PlaylistDetail.tsx
2026-03-22 16:08:12 +00:00

794 lines
27 KiB
TypeScript

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<LoadState>({ 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<Set<string>>(new Set());
// fading: dumps whose removal is being animated — same cooldown→dismissing pattern as upvoted list
const [fading, setFading] = useState<
Record<string, "cooldown" | "dismissing">
>({});
const cancels = useRef<Map<string, () => 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<number | null>(null);
const [dragOverIndex, setDragOverIndex] = useState<number | null>(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<string | null>(null);
const [confirmDelete, setConfirmDelete] = useState(false);
const [imageFile, setImageFile] = useState<File | null>(null);
const [imagePreview, setImagePreview] = 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.
const activeDumpIdsRef = useRef(activeDumpIds);
// knownDumpIds: all dump IDs that belong to this playlist (for re-adding when dumps become public again)
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.
const dumpOrderRef = useRef<string[]>([]);
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 (
<PageShell>
<p className="page-loading">Loading playlist</p>
</PageShell>
);
}
if (state.status === "error") {
return (
<PageError
message={state.error}
actions={
<button
className="btn-border"
type="button"
onClick={() => navigate("/")}
>
Back
</button>
}
/>
);
}
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 (
<PageShell>
<div className="playlist-detail-header">
<div className="playlist-detail-header-top">
{editOpen
? (
<ImagePicker
src={imagePreview ??
(playlist.imageMime
? `${API_URL}/api/playlists/${playlist.id}/image`
: null)}
alt="Cover"
size={72}
onChange={(file) => {
setImageFile(file);
setImagePreview(URL.createObjectURL(file));
}}
/>
)
: playlist.imageMime && (
<img
src={`${API_URL}/api/playlists/${playlist.id}/image`}
alt=""
className="playlist-detail-img"
/>
)}
<div className="playlist-detail-content">
{editOpen
? (
<div className="playlist-detail-title-row">
<input
type="text"
className="playlist-edit-input"
value={editTitle}
onChange={(e) => setEditTitle(e.target.value)}
autoFocus
/>
<button
type="button"
className="btn-primary"
disabled={editSaving}
onClick={handleEditSave}
>
{editSaving ? "Saving…" : "Save"}
</button>
<button
type="button"
className="form-cancel"
onClick={() => setEditOpen(false)}
>
Cancel
</button>
<button
type="button"
className="btn-danger"
onClick={() => setConfirmDelete(true)}
>
Delete
</button>
</div>
)
: (
<div className="playlist-detail-title-row">
<h1 className="playlist-detail-title">{playlist.title}</h1>
{!isOwner && (
<FollowPlaylistButton
targetPlaylistId={playlist.id}
isPublic={playlist.isPublic}
/>
)}
{isOwner && (
<button
type="button"
className="playlist-edit-btn"
onClick={openEdit}
>
Edit
</button>
)}
</div>
)}
{editOpen
? (
<TextEditor
className="playlist-edit-textarea"
value={editDescription}
onChange={setEditDescription}
placeholder="Description (optional)"
autoResize
rows={1}
/>
)
: playlist.description && (
<Markdown className="playlist-detail-description">
{playlist.description}
</Markdown>
)}
<div className="playlist-detail-meta">
{editOpen
? (
<div className="visibility-toggle playlist-edit-toggle">
<button
type="button"
className={editIsPublic ? "active" : ""}
onClick={() => setEditIsPublic(true)}
>
Public
</button>
<button
type="button"
className={!editIsPublic ? "active" : ""}
onClick={() => setEditIsPublic(false)}
>
Private
</button>
</div>
)
: (
<>
<span
className={`playlist-badge${
playlist.isPublic ? "" : " playlist-badge--private"
}`}
>
{playlist.isPublic ? "public" : "private"}
</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={`Edited ${playlist.updatedAt.toLocaleString()}`}
>
<span className="playlist-edited-label">
edited {relativeTime(playlist.updatedAt)}
</span>
</Tooltip>
)}
</>
)}
</div>
{editError && (
<ErrorCard title="Failed to save" message={editError} />
)}
</div>
</div>
</div>
{visibleDumps.length === 0
? <p className="empty-state">No dumps in this playlist yet.</p>
: (
<div
className="playlist-dump-list"
onDragOver={isOwner
? (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;
const cardCls = phase === "cooldown"
? "dump-card--fading"
: phase === "dismissing"
? "dump-card--dismissing"
: undefined;
return (
<div
key={dump.id}
className={`playlist-dump-item${
activeIndex === dragOverIndex && isActive
? " playlist-dump-item--drag-over"
: ""
}`}
draggable={isOwner && isActive}
onDragStart={isOwner && isActive
? () => handleDragStart(activeIndex)
: undefined}
onDragOver={isOwner && isActive
? (e) => handleDragOver(e, activeIndex)
: undefined}
onDragEnd={isOwner ? handleDragEnd : undefined}
>
{isOwner && 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={cardCls}
isOwner={!!user && user.id === dump.userId}
/>
{isOwner && (isActive
? (
<button
type="button"
className="playlist-remove-btn"
onClick={() => handleRemoveDump(dump.id)}
aria-label="Remove from playlist"
>
</button>
)
: phase === "cooldown" && (
<button
type="button"
className="playlist-cancel-btn"
onClick={() => handleCancelRemove(dump.id)}
aria-label="Cancel removal"
>
Undo
</button>
))}
</div>
);
})}
</div>
)}
{confirmDelete && (
<ConfirmModal
message="Delete this playlist? This cannot be undone."
confirmLabel="Delete playlist"
onConfirm={handleDelete}
onCancel={() => setConfirmDelete(false)}
/>
)}
</PageShell>
);
}