feat(playlists): sort + group-by-channel with reusable undo/redo

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<T> — 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.
This commit is contained in:
npeter83 2026-06-15 21:52:28 +02:00
parent 1ddeac57cd
commit 40c0cbfc5a
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>
);
}