From 40c0cbfc5a2a9fc6204a683a0c1bcb3dc8073753 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Mon, 15 Jun 2026 21:52:28 +0200 Subject: [PATCH] feat(playlists): sort + group-by-channel with reusable undo/redo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a sort toolbar to the Playlists page: Title A-Z/Z-A, shortest/longest first, and a 'group by channel' toggle that groups items by channel (A-Z) and applies the chosen sort within each group. Applying a sort reorders the items and persists via the existing reorder API. The item order is now an undoable value: drag, sort and grouping all go through it, so every change is reversible via undo/redo buttons and Ctrl/Cmd+Z / Ctrl+Y (Ctrl+Shift+Z). Built on two reusable pieces, not playlist-specific: - useUndoable — a generic past/present/future snapshot hook (set/undo/redo/reset + canUndo/canRedo), ref-backed so callbacks are stable and never see a stale snapshot; onApply runs the side effect (here: persist order). - UndoToolbar — undo/redo buttons + keyboard shortcuts, plain props so any undo source can use it. Undo history is reset only when the item set actually changes (different playlist or add/remove), so a refetch confirming our own reorder doesn't wipe it; membership changes (remove) clear history since they aren't reorder-undoable. Trilingual. --- frontend/src/components/Playlists.tsx | 153 ++++++++++++++++++-- frontend/src/components/UndoToolbar.tsx | 61 ++++++++ frontend/src/i18n/locales/de/common.json | 2 + frontend/src/i18n/locales/de/playlists.json | 9 +- frontend/src/i18n/locales/en/common.json | 2 + frontend/src/i18n/locales/en/playlists.json | 9 +- frontend/src/i18n/locales/hu/common.json | 2 + frontend/src/i18n/locales/hu/playlists.json | 9 +- frontend/src/lib/useUndoable.ts | 90 ++++++++++++ 9 files changed, 325 insertions(+), 12 deletions(-) create mode 100644 frontend/src/components/UndoToolbar.tsx create mode 100644 frontend/src/lib/useUndoable.ts diff --git a/frontend/src/components/Playlists.tsx b/frontend/src/components/Playlists.tsx index 6817e78..0053e4e 100644 --- a/frontend/src/components/Playlists.tsx +++ b/frontend/src/components/Playlists.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { @@ -32,9 +32,57 @@ import { import { api, type Playlist, type Video } from "../lib/api"; import { formatDuration } from "../lib/format"; import { notify } from "../lib/notifications"; +import { useUndoable } from "../lib/useUndoable"; import PlayerModal from "./PlayerModal"; +import UndoToolbar from "./UndoToolbar"; import { useConfirm } from "./ConfirmProvider"; +type SortMode = "manual" | "title-asc" | "title-desc" | "dur-asc" | "dur-desc"; + +function comparator(mode: SortMode): ((a: Video, b: Video) => number) | null { + switch (mode) { + case "title-asc": + return (a, b) => (a.title ?? "").localeCompare(b.title ?? ""); + case "title-desc": + return (a, b) => (b.title ?? "").localeCompare(a.title ?? ""); + case "dur-asc": + return (a, b) => (a.duration_seconds ?? Infinity) - (b.duration_seconds ?? Infinity); + case "dur-desc": + return (a, b) => (b.duration_seconds ?? -Infinity) - (a.duration_seconds ?? -Infinity); + default: + return null; + } +} + +// Sort a playlist's items. When `group` is on, items are grouped by channel (groups ordered +// by channel name A–Z) and the chosen sort is applied within each group; with sort "manual" +// the within-group order is left as-is. Returns the same array reference when nothing would +// change, so it doesn't create a spurious undo entry. +function sortItems(items: Video[], mode: SortMode, group: boolean): Video[] { + const cmp = comparator(mode); + if (!group) return cmp ? [...items].sort(cmp) : items; + const groups = new Map(); + for (const v of items) { + const k = v.channel_title ?? ""; + const g = groups.get(k); + if (g) g.push(v); + else groups.set(k, [v]); + } + const keys = [...groups.keys()].sort((a, b) => a.localeCompare(b)); + const out: Video[] = []; + for (const k of keys) { + const g = groups.get(k)!; + out.push(...(cmp ? [...g].sort(cmp) : g)); + } + return out; +} + +const idsKey = (items: Video[]) => + items + .map((v) => v.id) + .sort() + .join(","); + function Row({ video, index, @@ -116,7 +164,23 @@ export default function Playlists({ canWrite }: { canWrite: boolean }) { const [newName, setNewName] = useState(""); const [renaming, setRenaming] = useState(false); const [renameValue, setRenameValue] = useState(""); - const [items, setItems] = useState([]); + // The item order is undoable: drag, sort and group all go through `order.set`, so each is + // reversible (buttons + Ctrl+Z/Y). onApply persists the new order to the server. + const order = useUndoable([], { + onApply: (next) => { + if (selectedId == null) return; + api + .reorderPlaylist(selectedId, next.map((v) => v.id)) + .then(() => qc.invalidateQueries({ queryKey: ["playlists"] })); + }, + }); + const items = order.value; + const [sortMode, setSortMode] = useState("manual"); + const [groupBy, setGroupBy] = useState(false); + // Tracks the membership (id set) currently loaded into `order`, so a refetch that only + // re-confirms our own reorder doesn't wipe the undo history — we reset history only when + // the actual set of items changes (different playlist, or an add/remove). + const lastSetRef = useRef(""); // The open player, as an index into `items` (the queue); null = closed. const [playingIndex, setPlayingIndex] = useState(null); @@ -150,9 +214,35 @@ export default function Playlists({ canWrite }: { canWrite: boolean }) { const detail = detailQuery.data; useEffect(() => { - if (detail) setItems(detail.items); + if (!detail) return; + const key = idsKey(detail.items); + if (key !== lastSetRef.current) { + lastSetRef.current = key; + order.reset(detail.items); + } + // eslint-disable-next-line react-hooks/exhaustive-deps }, [detail]); + // Reset the sort controls when switching playlists (the order itself is per-playlist). + useEffect(() => { + setSortMode("manual"); + setGroupBy(false); + }, [selectedId]); + + function applySort(mode: SortMode, group: boolean) { + order.set(sortItems(items, mode, group)); + } + const undo = () => { + setSortMode("manual"); + setGroupBy(false); + order.undo(); + }; + const redo = () => { + setSortMode("manual"); + setGroupBy(false); + order.redo(); + }; + const sensors = useSensors( useSensor(PointerSensor, { activationConstraint: { distance: 4 } }) ); @@ -252,21 +342,25 @@ export default function Playlists({ canWrite }: { canWrite: boolean }) { qc.invalidateQueries({ queryKey: ["playlist", selectedId] }); } - async function onDragEnd(e: DragEndEvent) { + function onDragEnd(e: DragEndEvent) { const { active: a, over } = e; if (!over || a.id === over.id || selectedId == null) return; const oldIndex = items.findIndex((v) => v.id === a.id); const newIndex = items.findIndex((v) => v.id === over.id); if (oldIndex < 0 || newIndex < 0) return; - const next = arrayMove(items, oldIndex, newIndex); - setItems(next); - await api.reorderPlaylist(selectedId, next.map((v) => v.id)); - qc.invalidateQueries({ queryKey: ["playlist", selectedId] }); + // A manual drag breaks any active sort/grouping; reflect that in the controls. + setSortMode("manual"); + setGroupBy(false); + order.set(arrayMove(items, oldIndex, newIndex)); // onApply persists } async function removeItem(videoId: string) { if (selectedId == null) return; - setItems((cur) => cur.filter((v) => v.id !== videoId)); + // Membership changes aren't undoable: reset the order to the new set (clearing history) + // and keep lastSetRef in sync so the follow-up refetch doesn't reset again. + const next = items.filter((v) => v.id !== videoId); + order.reset(next); + lastSetRef.current = idsKey(next); await api.removeFromPlaylist(selectedId, videoId); refreshAll(); } @@ -491,6 +585,47 @@ export default function Playlists({ canWrite }: { canWrite: boolean }) { + {editable && items.length > 1 && ( +
+ {t("playlists.sortLabel")} + + +
+ +
+
+ )} + {items.length === 0 ? (
diff --git a/frontend/src/components/UndoToolbar.tsx b/frontend/src/components/UndoToolbar.tsx new file mode 100644 index 0000000..5588757 --- /dev/null +++ b/frontend/src/components/UndoToolbar.tsx @@ -0,0 +1,61 @@ +import { useEffect } from "react"; +import { useTranslation } from "react-i18next"; +import { Redo2, Undo2 } from "lucide-react"; + +// Reusable undo/redo buttons with Ctrl/Cmd+Z (undo) and Ctrl/Cmd+Y or Ctrl/Cmd+Shift+Z +// (redo) shortcuts. Pairs with useUndoable but takes plain props, so it works with any +// undo source. The shortcuts ignore keystrokes while a text field is focused. +export default function UndoToolbar({ + canUndo, + canRedo, + onUndo, + onRedo, +}: { + canUndo: boolean; + canRedo: boolean; + onUndo: () => void; + onRedo: () => void; +}) { + const { t } = useTranslation(); + + useEffect(() => { + function onKey(e: KeyboardEvent) { + if (!(e.ctrlKey || e.metaKey)) return; + const tgt = e.target as HTMLElement | null; + if ( + tgt && + (tgt.tagName === "INPUT" || + tgt.tagName === "TEXTAREA" || + tgt.isContentEditable) + ) + return; + const k = e.key.toLowerCase(); + if (k === "z" && !e.shiftKey) { + if (canUndo) { + e.preventDefault(); + onUndo(); + } + } else if (k === "y" || (k === "z" && e.shiftKey)) { + if (canRedo) { + e.preventDefault(); + onRedo(); + } + } + } + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, [canUndo, canRedo, onUndo, onRedo]); + + const btn = + "p-1.5 rounded-lg border border-border text-muted enabled:hover:text-fg enabled:hover:border-accent disabled:opacity-30 transition"; + return ( +
+ + +
+ ); +} diff --git a/frontend/src/i18n/locales/de/common.json b/frontend/src/i18n/locales/de/common.json index 96a3e73..8ae17fd 100644 --- a/frontend/src/i18n/locales/de/common.json +++ b/frontend/src/i18n/locales/de/common.json @@ -6,6 +6,8 @@ "confirm": "Bestätigen", "confirmTitle": "Bist du sicher?", "save": "Speichern", + "undo": "Rückgängig", + "redo": "Wiederholen", "loading": "Wird geladen…", "language": "Sprache", "somethingWrong": "Etwas ist schiefgelaufen.", diff --git a/frontend/src/i18n/locales/de/playlists.json b/frontend/src/i18n/locales/de/playlists.json index 3e4ca7f..cb72cfc 100644 --- a/frontend/src/i18n/locales/de/playlists.json +++ b/frontend/src/i18n/locales/de/playlists.json @@ -34,5 +34,12 @@ "deleteOnYoutubeMsg": "„{{name}}“ auch aus deinem YouTube-Konto löschen? Wähle „Nur hier“, um sie auf YouTube zu behalten.", "deleteOnYoutubeConfirm": "Auf YouTube löschen", "deleteHereOnly": "Nur hier", - "deleteYtFailed": "Löschen auf YouTube fehlgeschlagen; nur hier entfernt." + "deleteYtFailed": "Löschen auf YouTube fehlgeschlagen; nur hier entfernt.", + "sortLabel": "Sortierung", + "sortManual": "Eigene Reihenfolge", + "sortTitleAsc": "Titel A–Z", + "sortTitleDesc": "Titel Z–A", + "sortDurAsc": "Kürzeste zuerst", + "sortDurDesc": "Längste zuerst", + "groupByChannel": "Nach Kanal gruppieren" } diff --git a/frontend/src/i18n/locales/en/common.json b/frontend/src/i18n/locales/en/common.json index a33b2b4..228f33a 100644 --- a/frontend/src/i18n/locales/en/common.json +++ b/frontend/src/i18n/locales/en/common.json @@ -6,6 +6,8 @@ "confirm": "Confirm", "confirmTitle": "Are you sure?", "save": "Save", + "undo": "Undo", + "redo": "Redo", "loading": "Loading…", "language": "Language", "somethingWrong": "Something went wrong.", diff --git a/frontend/src/i18n/locales/en/playlists.json b/frontend/src/i18n/locales/en/playlists.json index 2f61a01..feb104a 100644 --- a/frontend/src/i18n/locales/en/playlists.json +++ b/frontend/src/i18n/locales/en/playlists.json @@ -34,5 +34,12 @@ "deleteOnYoutubeMsg": "Also delete “{{name}}” from your YouTube account? Choose “Here only” to keep it on YouTube.", "deleteOnYoutubeConfirm": "Delete on YouTube", "deleteHereOnly": "Here only", - "deleteYtFailed": "Couldn't delete on YouTube; removed here only." + "deleteYtFailed": "Couldn't delete on YouTube; removed here only.", + "sortLabel": "Sort", + "sortManual": "Custom order", + "sortTitleAsc": "Title A–Z", + "sortTitleDesc": "Title Z–A", + "sortDurAsc": "Shortest first", + "sortDurDesc": "Longest first", + "groupByChannel": "Group by channel" } diff --git a/frontend/src/i18n/locales/hu/common.json b/frontend/src/i18n/locales/hu/common.json index d155cd1..d010adc 100644 --- a/frontend/src/i18n/locales/hu/common.json +++ b/frontend/src/i18n/locales/hu/common.json @@ -6,6 +6,8 @@ "confirm": "Megerősítés", "confirmTitle": "Biztos vagy benne?", "save": "Mentés", + "undo": "Visszavonás", + "redo": "Újra", "loading": "Betöltés…", "language": "Nyelv", "somethingWrong": "Hiba történt.", diff --git a/frontend/src/i18n/locales/hu/playlists.json b/frontend/src/i18n/locales/hu/playlists.json index 233cb49..9fd8abd 100644 --- a/frontend/src/i18n/locales/hu/playlists.json +++ b/frontend/src/i18n/locales/hu/playlists.json @@ -34,5 +34,12 @@ "deleteOnYoutubeMsg": "Törlöd a(z) „{{name}}” listát a YouTube-fiókodból is? A „Csak itt” megtartja a YouTube-on.", "deleteOnYoutubeConfirm": "Törlés a YouTube-on", "deleteHereOnly": "Csak itt", - "deleteYtFailed": "Nem sikerült törölni a YouTube-on; csak itt lett eltávolítva." + "deleteYtFailed": "Nem sikerült törölni a YouTube-on; csak itt lett eltávolítva.", + "sortLabel": "Rendezés", + "sortManual": "Egyéni sorrend", + "sortTitleAsc": "Cím A–Z", + "sortTitleDesc": "Cím Z–A", + "sortDurAsc": "Legrövidebb elöl", + "sortDurDesc": "Leghosszabb elöl", + "groupByChannel": "Csoportosítás csatorna szerint" } diff --git a/frontend/src/lib/useUndoable.ts b/frontend/src/lib/useUndoable.ts new file mode 100644 index 0000000..89f86ff --- /dev/null +++ b/frontend/src/lib/useUndoable.ts @@ -0,0 +1,90 @@ +import { useCallback, useReducer, useRef } from "react"; + +// A generic, reusable undo/redo container for a single piece of state. +// +// It keeps a past/present/future snapshot stack: `set` records the current value into the +// past and clears the redo stack; `undo`/`redo` walk the stack. Every value change (set, +// undo, redo) calls `onApply(value)` — the side effect that makes the change real (e.g. +// persist to the server). `reset` installs a fresh value and clears history WITHOUT calling +// onApply (use it when the underlying data is reloaded from the source of truth). +// +// Not playlist-specific: any component with an undoable value (sort order, a draft, a set +// of filters…) can use it. State lives in a ref so the callbacks are stable and never read +// a stale snapshot; a reducer-based force-render keeps the view in sync. +export interface Undoable { + value: T; + set: (next: T) => void; + reset: (value: T) => void; + undo: () => void; + redo: () => void; + canUndo: boolean; + canRedo: boolean; +} + +interface History { + past: T[]; + present: T; + future: T[]; +} + +export function useUndoable( + initial: T, + opts?: { onApply?: (value: T) => void; limit?: number } +): Undoable { + const [, force] = useReducer((n: number) => n + 1, 0); + const ref = useRef>({ past: [], present: initial, future: [] }); + const onApplyRef = useRef(opts?.onApply); + onApplyRef.current = opts?.onApply; + const limit = opts?.limit ?? 100; + + const set = useCallback( + (next: T) => { + const s = ref.current; + if (Object.is(next, s.present)) return; + const past = [...s.past, s.present]; + if (past.length > limit) past.shift(); + ref.current = { past, present: next, future: [] }; + force(); + onApplyRef.current?.(next); + }, + [limit] + ); + + const undo = useCallback(() => { + const s = ref.current; + if (!s.past.length) return; + const prev = s.past[s.past.length - 1]; + ref.current = { + past: s.past.slice(0, -1), + present: prev, + future: [s.present, ...s.future], + }; + force(); + onApplyRef.current?.(prev); + }, []); + + const redo = useCallback(() => { + const s = ref.current; + if (!s.future.length) return; + const [next, ...rest] = s.future; + ref.current = { past: [...s.past, s.present], present: next, future: rest }; + force(); + onApplyRef.current?.(next); + }, []); + + const reset = useCallback((value: T) => { + ref.current = { past: [], present: value, future: [] }; + force(); + }, []); + + const h = ref.current; + return { + value: h.present, + set, + reset, + undo, + redo, + canUndo: h.past.length > 0, + canRedo: h.future.length > 0, + }; +}