All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 41s
936 lines
32 KiB
TypeScript
936 lines
32 KiB
TypeScript
import {
|
|
useCallback,
|
|
useEffect,
|
|
useLayoutEffect,
|
|
useRef,
|
|
useState,
|
|
} from "react";
|
|
import { Link, useNavigate, useParams } from "react-router";
|
|
import { t } from "@lingui/core/macro";
|
|
import { Trans } from "@lingui/react/macro";
|
|
import { API_URL, VALIDATION } from "../config/api.ts";
|
|
import { CountedInput } from "../components/CountedInput.tsx";
|
|
import type {
|
|
Playlist,
|
|
PlaylistWithDumps,
|
|
RawDump,
|
|
RawPlaylist,
|
|
RawPlaylistWithDumps,
|
|
ReorderPlaylistRequest,
|
|
UpdatePlaylistRequest,
|
|
} from "../model.ts";
|
|
import {
|
|
deserializeDump,
|
|
deserializePlaylist,
|
|
deserializePlaylistWithDumps,
|
|
} from "../model.ts";
|
|
import { playlistUrl } from "../utils/urls.ts";
|
|
import { can } from "../utils/permissions.ts";
|
|
import { useAuth } from "../hooks/useAuth.ts";
|
|
import { useDocumentTitle } from "../hooks/useDocumentTitle.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 { Tooltip } from "../components/Tooltip.tsx";
|
|
import { friendlyFetchError } from "../utils/apiError.ts";
|
|
import { Controller } from "react-hook-form";
|
|
import {
|
|
expectOk,
|
|
FormError,
|
|
FormProvider,
|
|
SubmitButton,
|
|
useApiForm,
|
|
} from "../components/form/index.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;
|
|
|
|
useDocumentTitle(
|
|
state.status === "loaded"
|
|
? state.playlist.title
|
|
: state.status === "error"
|
|
? null
|
|
: undefined,
|
|
);
|
|
|
|
// 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());
|
|
// 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.
|
|
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.
|
|
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).
|
|
const dragSrcRef = useRef<number | null>(null);
|
|
const [dragOverIndex, setDragOverIndex] = useState<number | null>(null);
|
|
|
|
const [editOpen, setEditOpen] = useState(false);
|
|
const [confirmDelete, setConfirmDelete] = useState(false);
|
|
|
|
// 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());
|
|
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;
|
|
// 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.
|
|
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 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();
|
|
if (dumpReorderTimerRef.current) {
|
|
clearTimeout(dumpReorderTimerRef.current);
|
|
dumpReorderTimerRef.current = null;
|
|
}
|
|
})
|
|
.catch((err) => {
|
|
if (err.name === "AbortError") return;
|
|
setState({
|
|
status: "error",
|
|
error: friendlyFetchError(err),
|
|
});
|
|
});
|
|
}, [playlistId, token]);
|
|
|
|
useEffect(() => {
|
|
fetchPlaylist();
|
|
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.
|
|
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(() => {});
|
|
}
|
|
}
|
|
|
|
// 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.
|
|
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;
|
|
// Replace each active slot with the next server-ordered active dump;
|
|
// fading dumps keep their current slot unchanged.
|
|
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 } };
|
|
});
|
|
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]);
|
|
|
|
// Filter out globally deleted dumps (dump was deleted entirely, not just removed from playlist)
|
|
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.
|
|
// 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]);
|
|
|
|
// 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),
|
|
} satisfies ReorderPlaylistRequest,
|
|
),
|
|
});
|
|
} 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 || 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).
|
|
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).
|
|
authFetch(`${API_URL}/api/playlists/${playlistId}/dumps/${dumpId}`, {
|
|
method: "POST",
|
|
})
|
|
.then(() =>
|
|
authFetch(`${API_URL}/api/playlists/${playlistId}/order`, {
|
|
method: "PUT",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify(
|
|
{ dumpIds: desiredOrder } satisfies ReorderPlaylistRequest,
|
|
),
|
|
})
|
|
)
|
|
.finally(() => {
|
|
pendingUndoOrderRef.current = null;
|
|
})
|
|
.catch(() => {});
|
|
};
|
|
|
|
const openEdit = () => setEditOpen(true);
|
|
|
|
const handleEditSaved = (updated: Playlist) => {
|
|
setEditOpen(false);
|
|
navigate(playlistUrl(updated), { replace: true });
|
|
};
|
|
|
|
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">
|
|
<Trans>Loading playlist…</Trans>
|
|
</p>
|
|
</PageShell>
|
|
);
|
|
}
|
|
|
|
if (state.status === "error") {
|
|
return (
|
|
<PageError
|
|
message={state.error}
|
|
actions={
|
|
<button
|
|
className="btn-border"
|
|
type="button"
|
|
onClick={() => navigate("/")}
|
|
>
|
|
<Trans>← Back</Trans>
|
|
</button>
|
|
}
|
|
/>
|
|
);
|
|
}
|
|
|
|
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
|
|
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
|
|
? (
|
|
<PlaylistEditForm
|
|
playlist={playlist}
|
|
onCancel={() => setEditOpen(false)}
|
|
onRequestDelete={() => setConfirmDelete(true)}
|
|
onSaved={handleEditSaved}
|
|
/>
|
|
)
|
|
: (
|
|
<>
|
|
{playlist.imageMime && (
|
|
<img
|
|
src={`${API_URL}/api/playlists/${playlist.id}/image`}
|
|
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>
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{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;
|
|
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={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={cardCls}
|
|
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}
|
|
onCancel={() => setConfirmDelete(false)}
|
|
/>
|
|
)}
|
|
</PageShell>
|
|
);
|
|
}
|
|
|
|
interface PlaylistEditFormProps {
|
|
playlist: PlaylistWithDumps;
|
|
onCancel: () => void;
|
|
onRequestDelete: () => void;
|
|
onSaved: (playlist: Playlist) => void;
|
|
}
|
|
|
|
interface EditValues {
|
|
title: string;
|
|
description: string;
|
|
isPublic: boolean;
|
|
image: File | null;
|
|
}
|
|
|
|
function PlaylistEditForm(
|
|
{ playlist, onCancel, onRequestDelete, onSaved }: PlaylistEditFormProps,
|
|
) {
|
|
const { authFetch } = useAuth();
|
|
const [imagePreview, setImagePreview] = useState<string | null>(null);
|
|
const form = useApiForm<EditValues>({
|
|
defaultValues: {
|
|
title: playlist.title,
|
|
description: playlist.description ?? "",
|
|
isPublic: playlist.isPublic,
|
|
image: null,
|
|
},
|
|
});
|
|
const description = form.watch("description");
|
|
|
|
const onSubmit = form.submit(
|
|
async ({ title, description, isPublic, image }) => {
|
|
const updated = deserializePlaylist(
|
|
await expectOk<RawPlaylist>(
|
|
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);
|
|
await authFetch(`${API_URL}/api/playlists/${playlist.id}/image`, {
|
|
method: "POST",
|
|
body: fd,
|
|
});
|
|
}
|
|
|
|
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
|
|
? `${API_URL}/api/playlists/${playlist.id}/image`
|
|
: null)}
|
|
alt="Cover"
|
|
size={72}
|
|
onChange={(file) => {
|
|
field.onChange(file);
|
|
setImagePreview(URL.createObjectURL(file));
|
|
}}
|
|
/>
|
|
)}
|
|
/>
|
|
|
|
<div className="playlist-detail-content playlist-edit-content">
|
|
<Controller
|
|
name="title"
|
|
control={form.control}
|
|
render={({ field }) => (
|
|
<CountedInput
|
|
className="playlist-edit-input"
|
|
value={field.value}
|
|
onChange={field.onChange}
|
|
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}
|
|
placeholder={t`Description (optional)`}
|
|
autoResize
|
|
rows={2}
|
|
maxLength={VALIDATION.PLAYLIST_DESCRIPTION_MAX}
|
|
/>
|
|
)}
|
|
/>
|
|
|
|
<Controller
|
|
name="isPublic"
|
|
control={form.control}
|
|
render={({ field }) => (
|
|
<div className="visibility-toggle playlist-edit-toggle">
|
|
<button
|
|
type="button"
|
|
className={field.value ? "active" : ""}
|
|
onClick={() => field.onChange(true)}
|
|
>
|
|
<Trans>Public</Trans>
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className={!field.value ? "active" : ""}
|
|
onClick={() => field.onChange(false)}
|
|
>
|
|
<Trans>Private</Trans>
|
|
</button>
|
|
</div>
|
|
)}
|
|
/>
|
|
|
|
<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={description.length >
|
|
VALIDATION.PLAYLIST_DESCRIPTION_MAX}
|
|
>
|
|
<Trans>Save</Trans>
|
|
</SubmitButton>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</form>
|
|
</FormProvider>
|
|
);
|
|
}
|