v3: code quality pass

This commit is contained in:
khannurien
2026-03-24 18:47:05 +00:00
parent cd4076343b
commit c293f3e706
39 changed files with 1464 additions and 1555 deletions

View File

@@ -6,11 +6,14 @@ import type {
RawDump,
RawPlaylist,
RawPlaylistWithDumps,
ReorderPlaylistRequest,
UpdatePlaylistRequest,
} from "../model.ts";
import {
deserializeDump,
deserializePlaylist,
deserializePlaylistWithDumps,
parseAPIResponse,
} from "../model.ts";
import { playlistUrl } from "../utils/urls.ts";
import { useAuth } from "../hooks/useAuth.ts";
@@ -59,6 +62,16 @@ export function PlaylistDetail() {
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).
@@ -90,6 +103,7 @@ export function PlaylistDetail() {
useEffect(() => () => {
cancels.current.forEach((c) => c());
if (dumpReorderTimerRef.current) clearTimeout(dumpReorderTimerRef.current);
}, []);
const fetchAbortRef = useRef<AbortController | null>(null);
@@ -126,6 +140,10 @@ export function PlaylistDetail() {
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;
@@ -272,25 +290,36 @@ export function PlaylistDetail() {
}
}
// 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!;
// 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;
@@ -416,7 +445,11 @@ export function PlaylistDetail() {
await authFetch(`${API_URL}/api/playlists/${playlistId}/order`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ dumpIds: activeDumps.map((d) => d.id) }),
body: JSON.stringify(
{
dumpIds: activeDumps.map((d) => d.id),
} satisfies ReorderPlaylistRequest,
),
});
} catch {
fetchPlaylist();
@@ -442,13 +475,36 @@ export function PlaylistDetail() {
};
const handleCancelRemove = (dumpId: string) => {
if (!playlistId) return;
if (!playlistId || state.status !== "loaded") return;
cancels.current.get(dumpId)?.();
setActiveDumpIds((prev) => new Set([...prev, dumpId]));
// Re-add server-side since DELETE already fired
// 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",
}).catch(() => {});
})
.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 = () => {
@@ -472,19 +528,20 @@ export function PlaylistDetail() {
{
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
...(editTitle !== state.playlist.title ? { title: editTitle } : {}),
...(editDescription !== (state.playlist.description ?? "")
? { description: editDescription || null }
: {}),
isPublic: editIsPublic,
}),
body: JSON.stringify(
{
...(editTitle !== state.playlist.title
? { title: editTitle }
: {}),
...(editDescription !== (state.playlist.description ?? "")
? { description: editDescription || null }
: {}),
isPublic: editIsPublic,
} satisfies UpdatePlaylistRequest,
),
},
);
const updateJson = await updateRes.json() as {
success: boolean;
data: RawPlaylist;
};
const updateJson = parseAPIResponse<RawPlaylist>(await updateRes.json());
const updatedPlaylist = updateJson.success
? deserializePlaylist(updateJson.data)
: null;