Merge feature/s4e-playlist-sort-undo: playlist sort + reusable undo/redo

This commit is contained in:
npeter83 2026-06-15 21:52:35 +02:00
commit ac73725735
9 changed files with 325 additions and 12 deletions

View file

@ -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 AZ) 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<string, Video[]>();
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<Video[]>([]);
// 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<Video[]>([], {
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<SortMode>("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<string>("");
// The open player, as an index into `items` (the queue); null = closed.
const [playingIndex, setPlayingIndex] = useState<number | null>(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 }) {
</div>
</div>
{editable && items.length > 1 && (
<div className="flex items-center gap-2 mb-3 flex-wrap max-w-3xl">
<span className="text-xs text-muted">{t("playlists.sortLabel")}</span>
<select
value={sortMode}
onChange={(e) => {
const m = e.target.value as SortMode;
setSortMode(m);
applySort(m, groupBy);
}}
className="bg-card border border-border rounded-md px-2 py-1 text-xs outline-none focus:border-accent"
>
<option value="manual">{t("playlists.sortManual")}</option>
<option value="title-asc">{t("playlists.sortTitleAsc")}</option>
<option value="title-desc">{t("playlists.sortTitleDesc")}</option>
<option value="dur-asc">{t("playlists.sortDurAsc")}</option>
<option value="dur-desc">{t("playlists.sortDurDesc")}</option>
</select>
<label className="inline-flex items-center gap-1.5 text-xs text-muted cursor-pointer select-none">
<input
type="checkbox"
checked={groupBy}
onChange={(e) => {
setGroupBy(e.target.checked);
applySort(sortMode, e.target.checked);
}}
className="accent-accent"
/>
{t("playlists.groupByChannel")}
</label>
<div className="ml-auto">
<UndoToolbar
canUndo={order.canUndo}
canRedo={order.canRedo}
onUndo={undo}
onRedo={redo}
/>
</div>
</div>
)}
{items.length === 0 ? (
<div className="text-sm text-muted grid place-items-center text-center py-12">
<div>

View file

@ -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 (
<div className="inline-flex items-center gap-1">
<button onClick={onUndo} disabled={!canUndo} title={t("common.undo")} aria-label={t("common.undo")} className={btn}>
<Undo2 className="w-4 h-4" />
</button>
<button onClick={onRedo} disabled={!canRedo} title={t("common.redo")} aria-label={t("common.redo")} className={btn}>
<Redo2 className="w-4 h-4" />
</button>
</div>
);
}

View file

@ -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.",

View file

@ -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 AZ",
"sortTitleDesc": "Titel ZA",
"sortDurAsc": "Kürzeste zuerst",
"sortDurDesc": "Längste zuerst",
"groupByChannel": "Nach Kanal gruppieren"
}

View file

@ -6,6 +6,8 @@
"confirm": "Confirm",
"confirmTitle": "Are you sure?",
"save": "Save",
"undo": "Undo",
"redo": "Redo",
"loading": "Loading…",
"language": "Language",
"somethingWrong": "Something went wrong.",

View file

@ -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 AZ",
"sortTitleDesc": "Title ZA",
"sortDurAsc": "Shortest first",
"sortDurDesc": "Longest first",
"groupByChannel": "Group by channel"
}

View file

@ -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.",

View file

@ -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 AZ",
"sortTitleDesc": "Cím ZA",
"sortDurAsc": "Legrövidebb elöl",
"sortDurDesc": "Leghosszabb elöl",
"groupByChannel": "Csoportosítás csatorna szerint"
}

View file

@ -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<T> {
value: T;
set: (next: T) => void;
reset: (value: T) => void;
undo: () => void;
redo: () => void;
canUndo: boolean;
canRedo: boolean;
}
interface History<T> {
past: T[];
present: T;
future: T[];
}
export function useUndoable<T>(
initial: T,
opts?: { onApply?: (value: T) => void; limit?: number }
): Undoable<T> {
const [, force] = useReducer((n: number) => n + 1, 0);
const ref = useRef<History<T>>({ 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,
};
}