The column sort and the filters are a VIEW; the playlist's stored order only changes when the user presses "Save this order", which still goes through the undoable path. Picking a sort used to write straight to the server, so merely looking at the list by title rewrote the playlist and marked a YouTube-linked mirror dirty. The table: - playlistColumns.tsx holds the column defs; the body renders them as table rows and fills the grip cell itself, since the handle needs the row's sortable listeners that a render closure can't reach. - The # column shows the STORED position, so a view-sort never renumbers the playlist under the reader. - Saving is withheld while a filter is active (it would drop the hidden items), and drag is disabled under any view, with the reason spelled out rather than silently doing nothing. - applyView/sameOrder live in lib/playlistView.ts with tests: the sort and the channel grouping compose, and getting that wrong produces a plausible order that the save button would then persist. - dnd-kit KeyboardSensor: the grip was focusable and called itself "reorder", but only a pointer could move it. The guards: - Delete is ONE dialog with every outcome. It was two chained confirms where the second one's Cancel/Escape/backdrop meant "delete here only" — dismissing the dialog deleted the playlist. ConfirmProvider gained useChoice() for it. - ConfirmProvider focuses Cancel when an offered action is destructive; the confirm button took autoFocus unconditionally, so a stray Enter on an open dialog ran "Delete user" or "Clear audit log". - Removing an item asks first, and rebases the undo history instead of throwing it away: useUndoable.rebase maps every snapshot, so the reorder history stays valid for the remaining items. - A failed detail query showed "Loading…" forever; it now says so and offers a retry. Rename and reorder failures are surfaced instead of swallowed (reorder was a bare .then(), which also produced an unhandled rejection). - Local reorder/remove no longer require YouTube write scope. - playlistName() replaces the watch-later localizer copied into three files.
43 lines
1.7 KiB
TypeScript
43 lines
1.7 KiB
TypeScript
import { sortRows, type SortState } from "./columnSort";
|
|
|
|
// The playlist detail's VIEW derivation: what the reader sees, as opposed to the playlist's stored
|
|
// order. Kept structural (no `Column`, no component imports) like `columnSort.ts`, so it stays pure
|
|
// and testable — the "save this order" button writes whatever `applyView` returned, which is reason
|
|
// enough for this to be covered rather than eyeballed.
|
|
|
|
interface Grouped {
|
|
channel_title?: string | null;
|
|
}
|
|
interface Sortable<T> {
|
|
key: string;
|
|
sortValue?: (row: T) => string | number;
|
|
}
|
|
|
|
/** Filters are applied by the caller; this is sort, then optional channel grouping. Groups are
|
|
* ordered by channel name in the sort's direction, and the sort applies WITHIN each group (with no
|
|
* sort, each group keeps its stored order). */
|
|
export function applyView<T extends Grouped>(
|
|
rows: T[],
|
|
columns: Sortable<T>[],
|
|
sort: SortState,
|
|
group: boolean
|
|
): T[] {
|
|
const sorted = sortRows(rows, columns, sort);
|
|
if (!group) return sorted;
|
|
const groups = new Map<string, T[]>();
|
|
for (const v of sorted) {
|
|
const k = v.channel_title ?? "";
|
|
const g = groups.get(k);
|
|
if (g) g.push(v);
|
|
else groups.set(k, [v]);
|
|
}
|
|
const sign = sort?.dir === "desc" ? -1 : 1;
|
|
const keys = [...groups.keys()].sort((a, b) => sign * a.localeCompare(b));
|
|
return keys.flatMap((k) => groups.get(k)!);
|
|
}
|
|
|
|
/** Same items in the same positions. Identity is the id, so a refetch that changed only a title
|
|
* doesn't read as a reorder — and a shorter list (a filtered view) is never "the same order". */
|
|
export const sameOrder = (a: { id: string }[], b: { id: string }[]) =>
|
|
a.length === b.length && a.every((v, i) => v.id === b[i]!.id);
|