Merge feature/s4e-playlist-sort-undo: playlist sort + reusable undo/redo
This commit is contained in:
commit
ac73725735
9 changed files with 325 additions and 12 deletions
|
|
@ -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<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>
|
||||
|
|
|
|||
61
frontend/src/components/UndoToolbar.tsx
Normal file
61
frontend/src/components/UndoToolbar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,8 @@
|
|||
"confirm": "Confirm",
|
||||
"confirmTitle": "Are you sure?",
|
||||
"save": "Save",
|
||||
"undo": "Undo",
|
||||
"redo": "Redo",
|
||||
"loading": "Loading…",
|
||||
"language": "Language",
|
||||
"somethingWrong": "Something went wrong.",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
90
frontend/src/lib/useUndoable.ts
Normal file
90
frontend/src/lib/useUndoable.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue