v3: code quality pass
This commit is contained in:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user