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:
parent
87c5c2ab6a
commit
2fa5a5c080
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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue