feat(playlists): hybrid table over the drag list, and guard its destructive paths
The column sort and the filters are a VIEW; the playlist's stored order only changes when the user presses "Save this order", which still goes through the undoable path. Picking a sort used to write straight to the server, so merely looking at the list by title rewrote the playlist and marked a YouTube-linked mirror dirty. The table: - playlistColumns.tsx holds the column defs; the body renders them as table rows and fills the grip cell itself, since the handle needs the row's sortable listeners that a render closure can't reach. - The # column shows the STORED position, so a view-sort never renumbers the playlist under the reader. - Saving is withheld while a filter is active (it would drop the hidden items), and drag is disabled under any view, with the reason spelled out rather than silently doing nothing. - applyView/sameOrder live in lib/playlistView.ts with tests: the sort and the channel grouping compose, and getting that wrong produces a plausible order that the save button would then persist. - dnd-kit KeyboardSensor: the grip was focusable and called itself "reorder", but only a pointer could move it. The guards: - Delete is ONE dialog with every outcome. It was two chained confirms where the second one's Cancel/Escape/backdrop meant "delete here only" — dismissing the dialog deleted the playlist. ConfirmProvider gained useChoice() for it. - ConfirmProvider focuses Cancel when an offered action is destructive; the confirm button took autoFocus unconditionally, so a stray Enter on an open dialog ran "Delete user" or "Clear audit log". - Removing an item asks first, and rebases the undo history instead of throwing it away: useUndoable.rebase maps every snapshot, so the reorder history stays valid for the remaining items. - A failed detail query showed "Loading…" forever; it now says so and offers a retry. Rename and reorder failures are surfaced instead of swallowed (reorder was a bare .then(), which also produced an unhandled rejection). - Local reorder/remove no longer require YouTube write scope. - playlistName() replaces the watch-later localizer copied into three files.
This commit is contained in:
@@ -6,6 +6,7 @@ import { Check, ListPlus, Plus, Youtube } from "lucide-react";
|
||||
import { api, type Playlist } from "../lib/api";
|
||||
import { useDismiss } from "../lib/useDismiss";
|
||||
import { useScrollFade } from "../lib/useScrollFade";
|
||||
import { playlistName } from "../lib/playlistName";
|
||||
|
||||
// A small popover (portaled to body, so the feed grid can't clip it) for toggling a
|
||||
// video's membership in the user's playlists, with inline "new playlist" creation.
|
||||
@@ -159,7 +160,7 @@ export default function AddToPlaylist({
|
||||
{pl.has_video && <Check className="w-3 h-3" />}
|
||||
</span>
|
||||
<span className="truncate flex-1 text-left">
|
||||
{pl.kind === "watch_later" ? t("playlists.watchLater") : pl.name}
|
||||
{playlistName(pl, t)}
|
||||
</span>
|
||||
{(pl.source === "youtube" || pl.yt_playlist_id) && (
|
||||
<Youtube className="w-3 h-3 shrink-0 text-muted" />
|
||||
|
||||
@@ -5,6 +5,11 @@ import Modal from "./Modal";
|
||||
// App-styled, promise-based replacement for window.confirm. Wrap the tree in
|
||||
// <ConfirmProvider> once, then anywhere: const confirm = useConfirm();
|
||||
// if (await confirm({ message: "…", danger: true })) { … }
|
||||
//
|
||||
// For a decision with more than two outcomes use `useChoice()` instead — never chain two
|
||||
// confirms, because the second dialog's Cancel/Escape/backdrop is then indistinguishable from
|
||||
// a deliberate answer (the playlist delete used to read a dismissal as "delete here only" and
|
||||
// went ahead and deleted).
|
||||
export interface ConfirmOptions {
|
||||
title?: string;
|
||||
message: ReactNode;
|
||||
@@ -13,62 +18,108 @@ export interface ConfirmOptions {
|
||||
danger?: boolean;
|
||||
}
|
||||
|
||||
export interface Choice {
|
||||
id: string;
|
||||
label: string;
|
||||
danger?: boolean;
|
||||
}
|
||||
|
||||
export interface ChoiceOptions {
|
||||
title?: string;
|
||||
message: ReactNode;
|
||||
cancelLabel?: string;
|
||||
/** Rendered left→right after Cancel. Resolves to the clicked choice's `id`. */
|
||||
choices: Choice[];
|
||||
}
|
||||
|
||||
type ChoiceFn = (opts: ChoiceOptions) => Promise<string | null>;
|
||||
type ConfirmFn = (opts: ConfirmOptions) => Promise<boolean>;
|
||||
|
||||
const ConfirmContext = createContext<ConfirmFn>(() => Promise.resolve(false));
|
||||
const ChoiceContext = createContext<ChoiceFn>(() => Promise.resolve(null));
|
||||
|
||||
/** Two outcomes: resolves true on confirm, false on cancel/Escape/backdrop. */
|
||||
export function useConfirm(): ConfirmFn {
|
||||
return useContext(ConfirmContext);
|
||||
const ask = useContext(ChoiceContext);
|
||||
return useCallback(
|
||||
async (opts: ConfirmOptions) => {
|
||||
const picked = await ask({
|
||||
title: opts.title,
|
||||
message: opts.message,
|
||||
cancelLabel: opts.cancelLabel,
|
||||
choices: [
|
||||
{ id: "confirm", label: opts.confirmLabel ?? "", danger: opts.danger },
|
||||
],
|
||||
});
|
||||
return picked === "confirm";
|
||||
},
|
||||
[ask]
|
||||
);
|
||||
}
|
||||
|
||||
/** Three or more outcomes. Resolves to the chosen id, or **null** when the user cancels,
|
||||
* presses Escape or clicks the backdrop — dismissal is never one of the actions. */
|
||||
export function useChoice(): ChoiceFn {
|
||||
return useContext(ChoiceContext);
|
||||
}
|
||||
|
||||
export function ConfirmProvider({ children }: { children: ReactNode }) {
|
||||
const { t } = useTranslation();
|
||||
const [state, setState] = useState<{
|
||||
opts: ConfirmOptions;
|
||||
resolve: (ok: boolean) => void;
|
||||
opts: ChoiceOptions;
|
||||
resolve: (id: string | null) => void;
|
||||
} | null>(null);
|
||||
|
||||
const confirm = useCallback<ConfirmFn>(
|
||||
(opts) => new Promise<boolean>((resolve) => setState({ opts, resolve })),
|
||||
const ask = useCallback<ChoiceFn>(
|
||||
(opts) => new Promise<string | null>((resolve) => setState({ opts, resolve })),
|
||||
[]
|
||||
);
|
||||
|
||||
function close(ok: boolean) {
|
||||
state?.resolve(ok);
|
||||
function close(id: string | null) {
|
||||
state?.resolve(id);
|
||||
setState(null);
|
||||
}
|
||||
|
||||
const choices = state?.opts.choices ?? [];
|
||||
// Focus Cancel when any offered action is destructive: the confirm button used to take
|
||||
// autoFocus unconditionally, so a stray Enter on an open dialog executed "Delete user" or
|
||||
// "Clear audit log" without the user ever looking at it.
|
||||
const anyDanger = choices.some((c) => c.danger);
|
||||
|
||||
return (
|
||||
<ConfirmContext.Provider value={confirm}>
|
||||
<ChoiceContext.Provider value={ask}>
|
||||
{children}
|
||||
{state && (
|
||||
<Modal
|
||||
title={state.opts.title ?? t("common.confirmTitle")}
|
||||
onClose={() => close(false)}
|
||||
maxWidth="max-w-sm"
|
||||
onClose={() => close(null)}
|
||||
maxWidth={choices.length > 1 ? "max-w-md" : "max-w-sm"}
|
||||
>
|
||||
<p className="text-sm text-muted leading-relaxed">{state.opts.message}</p>
|
||||
<div className="flex justify-end gap-2 mt-5">
|
||||
<div className="flex justify-end gap-2 mt-5 flex-wrap">
|
||||
<button
|
||||
onClick={() => close(false)}
|
||||
autoFocus={anyDanger}
|
||||
onClick={() => close(null)}
|
||||
className="px-4 py-2 rounded-lg text-sm border border-border text-muted hover:text-fg hover:bg-surface transition"
|
||||
>
|
||||
{state.opts.cancelLabel ?? t("common.cancel")}
|
||||
</button>
|
||||
<button
|
||||
autoFocus
|
||||
onClick={() => close(true)}
|
||||
className={`px-4 py-2 rounded-lg text-sm font-semibold transition ${
|
||||
state.opts.danger
|
||||
? "bg-red-500 text-white hover:bg-red-600"
|
||||
: "bg-accent text-accent-fg hover:opacity-90"
|
||||
}`}
|
||||
>
|
||||
{state.opts.confirmLabel ?? t("common.confirm")}
|
||||
</button>
|
||||
{choices.map((c, i) => (
|
||||
<button
|
||||
key={c.id}
|
||||
autoFocus={!anyDanger && i === choices.length - 1}
|
||||
onClick={() => close(c.id)}
|
||||
className={`px-4 py-2 rounded-lg text-sm font-semibold transition ${
|
||||
c.danger
|
||||
? "bg-red-500 text-white hover:bg-red-600"
|
||||
: "bg-accent text-accent-fg hover:opacity-90"
|
||||
}`}
|
||||
>
|
||||
{c.label || t("common.confirm")}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</Modal>
|
||||
)}
|
||||
</ConfirmContext.Provider>
|
||||
</ChoiceContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { lazy, Suspense, useEffect, useRef, useState } from "react";
|
||||
import { lazy, Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
DndContext,
|
||||
KeyboardSensor,
|
||||
PointerSensor,
|
||||
closestCenter,
|
||||
useSensor,
|
||||
@@ -12,13 +13,14 @@ import {
|
||||
import {
|
||||
SortableContext,
|
||||
arrayMove,
|
||||
sortableKeyboardCoordinates,
|
||||
useSortable,
|
||||
verticalListSortingStrategy,
|
||||
} from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import {
|
||||
ArrowDown,
|
||||
ArrowUp,
|
||||
AlertTriangle,
|
||||
Check,
|
||||
ExternalLink,
|
||||
GripVertical,
|
||||
ListPlus,
|
||||
@@ -28,66 +30,36 @@ import {
|
||||
RotateCcw,
|
||||
Trash2,
|
||||
Upload,
|
||||
X,
|
||||
Youtube,
|
||||
} from "lucide-react";
|
||||
import { api, type Video } from "../lib/api";
|
||||
import { formatDuration } from "../lib/format";
|
||||
import { notify } from "../lib/notifications";
|
||||
import { notifyYouTubeActionError } from "../lib/youtubeErrors";
|
||||
import { playlistName } from "../lib/playlistName";
|
||||
import { useUndoable } from "../lib/useUndoable";
|
||||
import { cycleSort, type SortState } from "../lib/columnSort";
|
||||
import { applyView, sameOrder } from "../lib/playlistView";
|
||||
const PlayerModal = lazy(() => import("./PlayerModal"));
|
||||
import UndoToolbar from "./UndoToolbar";
|
||||
import { PageToolbar } from "./PageShell";
|
||||
import { useConfirm } from "./ConfirmProvider";
|
||||
import { useChoice, useConfirm } from "./ConfirmProvider";
|
||||
import { usePlaylists, usePlaylistsActions } from "./PlaylistsProvider";
|
||||
import { useMe } from "../lib/useMe";
|
||||
import { ColumnHead, alignClass, filterRows, isActive, type Column, type FilterMap } from "./tableHeader";
|
||||
import { GRIP_KEY, playlistColumns } from "./playlistColumns";
|
||||
|
||||
type SortKey = "manual" | "title" | "duration" | "channel";
|
||||
type SortDir = "asc" | "desc";
|
||||
|
||||
function comparator(key: SortKey, dir: SortDir): ((a: Video, b: Video) => number) | null {
|
||||
if (key === "manual") return null;
|
||||
const sign = dir === "asc" ? 1 : -1;
|
||||
if (key === "duration")
|
||||
return (a, b) => {
|
||||
const av = a.duration_seconds;
|
||||
const bv = b.duration_seconds;
|
||||
if (av == null && bv == null) return 0;
|
||||
if (av == null) return 1; // nulls always last, both directions
|
||||
if (bv == null) return -1;
|
||||
return sign * (av - bv);
|
||||
};
|
||||
return (a, b) => {
|
||||
const av = (key === "channel" ? a.channel_title : a.title) ?? "";
|
||||
const bv = (key === "channel" ? b.channel_title : b.title) ?? "";
|
||||
return sign * av.localeCompare(bv);
|
||||
};
|
||||
}
|
||||
|
||||
// Sort a playlist's items. When `group` is on, items are grouped by channel (groups ordered
|
||||
// by channel name in the chosen direction) and the chosen sort is applied within each group;
|
||||
// with key "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[], key: SortKey, dir: SortDir, group: boolean): Video[] {
|
||||
const cmp = comparator(key, dir);
|
||||
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 sign = dir === "asc" ? 1 : -1;
|
||||
const keys = [...groups.keys()].sort((a, b) => sign * 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;
|
||||
}
|
||||
// The playlist detail is a HYBRID table (E4 S4): real sortable/filterable column headers over a
|
||||
// body that is still a drag-reorderable @dnd-kit list.
|
||||
//
|
||||
// The distinction that shapes the whole module: **the column sort and the filters are a VIEW**,
|
||||
// while the playlist's own order is stored data. Before S4 the sort control wrote straight through
|
||||
// to the server, so picking "sort by title" silently rewrote the playlist and marked a
|
||||
// YouTube-linked mirror dirty. Now nothing is written until the user presses "Save this order",
|
||||
// which still goes through the undoable path — the capability is intact, it just stopped being a
|
||||
// side effect of looking at the list.
|
||||
//
|
||||
// It composes `tableHeader`'s pieces instead of rendering a `DataTable`, because DataTable owns
|
||||
// its own filter/sort/page state and the stored order needs a single owner.
|
||||
|
||||
const idsKey = (items: Video[]) =>
|
||||
items
|
||||
@@ -97,86 +69,56 @@ const idsKey = (items: Video[]) =>
|
||||
|
||||
function Row({
|
||||
video,
|
||||
index,
|
||||
readOnly,
|
||||
onPlay,
|
||||
onRemove,
|
||||
columns,
|
||||
dragDisabled,
|
||||
dragHint,
|
||||
}: {
|
||||
video: Video;
|
||||
index: number;
|
||||
readOnly?: boolean;
|
||||
onPlay: () => void;
|
||||
onRemove: () => void;
|
||||
columns: Column<Video>[];
|
||||
dragDisabled: boolean;
|
||||
dragHint: string;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } =
|
||||
useSortable({ id: video.id, disabled: readOnly });
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
opacity: isDragging ? 0.6 : 1,
|
||||
};
|
||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
|
||||
id: video.id,
|
||||
disabled: dragDisabled,
|
||||
});
|
||||
return (
|
||||
<div
|
||||
<tr
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
className="flex items-center gap-2.5 p-2 rounded-lg glass-card glass-hover"
|
||||
style={{
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
opacity: isDragging ? 0.6 : 1,
|
||||
}}
|
||||
className="border-b border-border/50 hover:bg-card/40 transition"
|
||||
>
|
||||
{readOnly ? (
|
||||
<span className="w-4" />
|
||||
) : (
|
||||
<button
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
className="text-muted hover:text-fg cursor-grab active:cursor-grabbing"
|
||||
aria-label="reorder"
|
||||
{columns.map((col) => (
|
||||
<td
|
||||
key={col.key}
|
||||
style={col.width ? { width: col.width } : undefined}
|
||||
className={`px-2 py-1.5 align-middle ${col.nowrap ? "whitespace-nowrap" : ""} ${alignClass(col.align)}`}
|
||||
>
|
||||
<GripVertical className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
<span className="text-xs text-muted w-4 text-center tabular-nums">{index + 1}</span>
|
||||
<button
|
||||
onClick={onPlay}
|
||||
aria-label={`${t("card.play")} — ${video.title}`}
|
||||
className="relative w-[62px] h-[35px] rounded overflow-hidden bg-surface shrink-0 group"
|
||||
>
|
||||
{video.thumbnail_url && (
|
||||
<img src={video.thumbnail_url} alt="" className="w-full h-full object-cover" />
|
||||
)}
|
||||
<span className="absolute inset-0 grid place-items-center bg-black/40 opacity-0 group-hover:opacity-100 transition">
|
||||
<Play className="w-4 h-4 text-white fill-current" />
|
||||
</span>
|
||||
</button>
|
||||
<button onClick={onPlay} className="min-w-0 flex-1 text-left">
|
||||
<div className="text-[13px] text-fg truncate">{video.title}</div>
|
||||
<div className="text-[11px] text-muted truncate">{video.channel_title}</div>
|
||||
</button>
|
||||
{video.duration_seconds != null ? (
|
||||
<span className="text-[11px] text-muted tabular-nums">
|
||||
{formatDuration(video.duration_seconds)}
|
||||
</span>
|
||||
) : video.live_status === "live" || video.live_status === "upcoming" ? (
|
||||
<span
|
||||
className={`text-[10px] font-semibold uppercase tracking-wide px-1.5 py-0.5 rounded ${
|
||||
video.live_status === "live"
|
||||
? "bg-red-500/90 text-white"
|
||||
: "bg-card border border-border text-muted"
|
||||
}`}
|
||||
>
|
||||
{t(`card.${video.live_status}`)}
|
||||
</span>
|
||||
) : null}
|
||||
{!readOnly && (
|
||||
<button
|
||||
onClick={onRemove}
|
||||
title="remove"
|
||||
className="text-muted hover:text-fg p-1"
|
||||
aria-label="remove from playlist"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{col.key === GRIP_KEY ? (
|
||||
// The one cell the column defs can't render: the handle needs this row's sortable
|
||||
// attributes/listeners. Disabled (not hidden) under a view, so the affordance stays
|
||||
// put and can explain itself instead of silently doing nothing.
|
||||
<button
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
disabled={dragDisabled}
|
||||
title={dragDisabled ? dragHint : t("playlists.dragHandle")}
|
||||
aria-label={t("playlists.dragHandleOf", { title: video.title })}
|
||||
className="text-muted enabled:hover:text-fg enabled:cursor-grab enabled:active:cursor-grabbing disabled:opacity-30 disabled:cursor-not-allowed"
|
||||
>
|
||||
<GripVertical className="w-4 h-4" />
|
||||
</button>
|
||||
) : (
|
||||
col.render(video)
|
||||
)}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -190,31 +132,44 @@ export default function Playlists() {
|
||||
const { t } = useTranslation();
|
||||
const qc = useQueryClient();
|
||||
const confirm = useConfirm();
|
||||
const choose = useChoice();
|
||||
const [renaming, setRenaming] = useState(false);
|
||||
const [renameValue, setRenameValue] = 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.
|
||||
// The stored order is undoable: drag and "save this order" both 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(() => {
|
||||
// Refresh the sidebar (cover may change) and the detail (so the now-dirty state,
|
||||
// and the Reset/Unsynced indicators, show without an F5). The membership guard
|
||||
// keeps the refetch from wiping the undo history on a pure reorder.
|
||||
qc.invalidateQueries({ queryKey: ["playlists"] });
|
||||
qc.invalidateQueries({ queryKey: ["playlist", selectedId] });
|
||||
});
|
||||
api
|
||||
.reorderPlaylist(
|
||||
selectedId,
|
||||
next.map((v) => v.id)
|
||||
)
|
||||
.then(() => {
|
||||
// Refresh the sidebar (cover may change) and the detail (so the now-dirty state,
|
||||
// and the Reset/Unsynced indicators, show without an F5). The membership guard
|
||||
// keeps the refetch from wiping the undo history on a pure reorder.
|
||||
qc.invalidateQueries({ queryKey: ["playlists"] });
|
||||
qc.invalidateQueries({ queryKey: ["playlist", selectedId] });
|
||||
})
|
||||
.catch((e) => {
|
||||
// Was a bare .then(): a failed reorder left the UI showing an order the server had
|
||||
// rejected, and produced an unhandled rejection. Say so and resync from the server.
|
||||
notifyYouTubeActionError(e, t("playlists.reorderFailed"));
|
||||
qc.invalidateQueries({ queryKey: ["playlist", selectedId] });
|
||||
});
|
||||
},
|
||||
});
|
||||
const items = order.value;
|
||||
const [sortKey, setSortKey] = useState<SortKey>("manual");
|
||||
const [sortDir, setSortDir] = useState<SortDir>("asc");
|
||||
// VIEW state — never written to the server on its own.
|
||||
const [viewSort, setViewSort] = useState<SortState>(null);
|
||||
const [filters, setFilters] = useState<FilterMap>({});
|
||||
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.
|
||||
// The open player, as an index into the DISPLAYED rows (the queue); null = closed.
|
||||
const [playingIndex, setPlayingIndex] = useState<number | null>(null);
|
||||
|
||||
const listQuery = useQuery({
|
||||
@@ -244,43 +199,85 @@ export default function Playlists() {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [detail]);
|
||||
|
||||
// Reset the sort controls when switching playlists (the order itself is per-playlist).
|
||||
// Reset the view when switching playlists (the stored order itself is per-playlist).
|
||||
useEffect(() => {
|
||||
setSortKey("manual");
|
||||
setSortDir("asc");
|
||||
setViewSort(null);
|
||||
setFilters({});
|
||||
setGroupBy(false);
|
||||
}, [selectedId]);
|
||||
|
||||
function applySort(key: SortKey, dir: SortDir, group: boolean) {
|
||||
order.set(sortItems(items, key, dir, group));
|
||||
}
|
||||
const undo = () => {
|
||||
setSortKey("manual");
|
||||
setGroupBy(false);
|
||||
order.undo();
|
||||
};
|
||||
const redo = () => {
|
||||
setSortKey("manual");
|
||||
setGroupBy(false);
|
||||
order.redo();
|
||||
};
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, { activationConstraint: { distance: 4 } })
|
||||
);
|
||||
|
||||
// Watch later is a built-in playlist: show a localized name and no rename/delete.
|
||||
const plName = (p: { kind: string; name: string }) =>
|
||||
p.kind === "watch_later" ? t("playlists.watchLater") : p.name;
|
||||
|
||||
const builtin = detail?.kind === "watch_later";
|
||||
// YouTube-mirrored playlists can be edited locally and synced back (the read-sync skips a
|
||||
// dirty mirror so it won't clobber unpushed edits); the YT-link button marks their origin.
|
||||
const editable = !builtin;
|
||||
const linked = editable && !!detail?.yt_playlist_id;
|
||||
// LOCAL editing (reorder, remove) needs no YouTube write scope — `canWrite` gates only the
|
||||
// push/delete-on-YouTube paths. Folding it in here would have taken drag and remove away from
|
||||
// every account without the scope, which they have always had.
|
||||
const canEdit = editable;
|
||||
const [pushing, setPushing] = useState(false);
|
||||
const [reverting, setReverting] = useState(false);
|
||||
|
||||
const onPlay = useCallback((v: Video) => {
|
||||
setPlayingIndex((_) => shownRef.current.findIndex((x) => x.id === v.id));
|
||||
}, []);
|
||||
const onRemove = useCallback((v: Video) => {
|
||||
removeRef.current(v);
|
||||
}, []);
|
||||
|
||||
const storedIndex = useMemo(() => {
|
||||
const m = new Map(items.map((v, i) => [v.id, i]));
|
||||
return (v: Video) => m.get(v.id) ?? 0;
|
||||
}, [items]);
|
||||
|
||||
const columns = useMemo(
|
||||
() => playlistColumns({ t, indexOf: storedIndex, onPlay, onRemove, canEdit }),
|
||||
[t, storedIndex, onPlay, onRemove, canEdit]
|
||||
);
|
||||
|
||||
const filterActive = Object.values(filters).some(isActive);
|
||||
const shown = useMemo(
|
||||
() => applyView(filterRows(items, columns, filters), columns, viewSort, groupBy),
|
||||
[items, columns, filters, viewSort, groupBy]
|
||||
);
|
||||
// Read by the memoized onPlay callback without making it depend on the list.
|
||||
const shownRef = useRef(shown);
|
||||
shownRef.current = shown;
|
||||
|
||||
const viewActive = viewSort !== null || groupBy || filterActive;
|
||||
// Saving a FILTERED view would drop every hidden item from the playlist, so the button only
|
||||
// offers itself when the view is a pure reordering of the whole list.
|
||||
const canSaveOrder = canEdit && !filterActive && !sameOrder(shown, items);
|
||||
|
||||
function toggleSort(key: string) {
|
||||
setViewSort((s) => cycleSort(s, key));
|
||||
}
|
||||
function applyFilter(key: string, value: string | string[]) {
|
||||
setFilters((f) => ({ ...f, [key]: value }));
|
||||
}
|
||||
function clearView() {
|
||||
setViewSort(null);
|
||||
setFilters({});
|
||||
setGroupBy(false);
|
||||
}
|
||||
function saveOrder() {
|
||||
order.set(shown); // onApply persists it
|
||||
clearView();
|
||||
}
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, { activationConstraint: { distance: 4 } }),
|
||||
// The grip was focusable and called itself "reorder", but only a pointer could move it.
|
||||
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates })
|
||||
);
|
||||
|
||||
const plName = (p: { kind: string; name: string }) => playlistName(p, t);
|
||||
|
||||
function refreshAll() {
|
||||
qc.invalidateQueries({ queryKey: ["playlists"] });
|
||||
qc.invalidateQueries({ queryKey: ["playlist", selectedId] });
|
||||
}
|
||||
|
||||
async function revertToYoutube() {
|
||||
if (selectedId == null || !detail || reverting) return;
|
||||
const ok = await confirm({
|
||||
@@ -345,7 +342,10 @@ export default function Playlists() {
|
||||
const r = await api.pushPlaylist(selectedId);
|
||||
refreshAll();
|
||||
if (r.failures.length) {
|
||||
notify({ level: "warning", message: t("playlists.pushPartial", { count: r.failures.length }) });
|
||||
notify({
|
||||
level: "warning",
|
||||
message: t("playlists.pushPartial", { count: r.failures.length }),
|
||||
});
|
||||
} else {
|
||||
notify({ level: "success", message: t("playlists.pushDone", { name: plName(detail) }) });
|
||||
}
|
||||
@@ -356,32 +356,31 @@ export default function Playlists() {
|
||||
}
|
||||
}
|
||||
|
||||
function refreshAll() {
|
||||
qc.invalidateQueries({ queryKey: ["playlists"] });
|
||||
qc.invalidateQueries({ queryKey: ["playlist", selectedId] });
|
||||
}
|
||||
|
||||
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;
|
||||
// A manual drag breaks any active sort/grouping; reflect that in the controls.
|
||||
setSortKey("manual");
|
||||
setGroupBy(false);
|
||||
order.set(arrayMove(items, oldIndex, newIndex)); // onApply persists
|
||||
}
|
||||
|
||||
async function removeItem(videoId: string) {
|
||||
async function removeItem(video: Video) {
|
||||
if (selectedId == null) return;
|
||||
// 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);
|
||||
const ok = await confirm({
|
||||
title: t("playlists.removeItem"),
|
||||
message: t("playlists.confirmRemove", { title: video.title }),
|
||||
confirmLabel: t("playlists.removeItem"),
|
||||
danger: true,
|
||||
});
|
||||
if (!ok) return;
|
||||
// A removal isn't itself undoable, but it no longer THROWS AWAY the reorder history: rebasing
|
||||
// drops the item from every snapshot, so the stack stays valid for what's left.
|
||||
const next = items.filter((v) => v.id !== video.id);
|
||||
order.rebase((list) => list.filter((v) => v.id !== video.id));
|
||||
lastSetRef.current = idsKey(next);
|
||||
try {
|
||||
await api.removeFromPlaylist(selectedId, videoId);
|
||||
await api.removeFromPlaylist(selectedId, video.id);
|
||||
refreshAll();
|
||||
} catch {
|
||||
// The optimistic removal above didn't stick — resync from the server to restore the row.
|
||||
@@ -389,37 +388,43 @@ export default function Playlists() {
|
||||
refreshAll();
|
||||
}
|
||||
}
|
||||
// Kept in a ref so the memoized column callbacks don't rebuild on every state change.
|
||||
const removeRef = useRef(removeItem);
|
||||
removeRef.current = removeItem;
|
||||
|
||||
async function saveRename() {
|
||||
if (selectedId == null) return;
|
||||
const name = renameValue.trim();
|
||||
setRenaming(false);
|
||||
if (name && name !== detail?.name) {
|
||||
await api.renamePlaylist(selectedId, name);
|
||||
try {
|
||||
await api.renamePlaylist(selectedId, name);
|
||||
} catch (e) {
|
||||
notifyYouTubeActionError(e, t("playlists.renameFailed"));
|
||||
}
|
||||
refreshAll();
|
||||
}
|
||||
}
|
||||
|
||||
async function deletePlaylist() {
|
||||
if (selectedId == null || !detail) return;
|
||||
const ok = await confirm({
|
||||
// ONE dialog with every outcome. It used to be two chained confirms, where the second one's
|
||||
// Cancel/Escape/backdrop meant "delete here only" — dismissing the dialog deleted the playlist.
|
||||
const offerYoutube = linked && canWrite;
|
||||
const picked = await choose({
|
||||
title: t("playlists.delete"),
|
||||
message: t("playlists.confirmDelete", { name: detail.name }),
|
||||
confirmLabel: t("playlists.delete"),
|
||||
danger: true,
|
||||
message: offerYoutube
|
||||
? t("playlists.confirmDeleteLinked", { name: plName(detail) })
|
||||
: t("playlists.confirmDelete", { name: plName(detail) }),
|
||||
choices: offerYoutube
|
||||
? [
|
||||
{ id: "here", label: t("playlists.deleteHereOnly"), danger: true },
|
||||
{ id: "both", label: t("playlists.deleteOnYoutubeConfirm"), danger: true },
|
||||
]
|
||||
: [{ id: "here", label: t("playlists.delete"), danger: true }],
|
||||
});
|
||||
if (!ok) return;
|
||||
// For a YouTube-linked playlist, offer to delete it on YouTube too (Cancel = here only).
|
||||
let onYoutube = false;
|
||||
if (linked && canWrite) {
|
||||
onYoutube = await confirm({
|
||||
title: t("playlists.deleteOnYoutubeTitle"),
|
||||
message: t("playlists.deleteOnYoutubeMsg", { name: detail.name }),
|
||||
confirmLabel: t("playlists.deleteOnYoutubeConfirm"),
|
||||
cancelLabel: t("playlists.deleteHereOnly"),
|
||||
danger: true,
|
||||
});
|
||||
}
|
||||
if (picked == null) return;
|
||||
const onYoutube = picked === "both";
|
||||
const deletedId = selectedId;
|
||||
// Pick the next selection from what's left *now* (don't go via null, or the
|
||||
// auto-select effect would re-pick the just-deleted id from the stale list).
|
||||
@@ -428,7 +433,10 @@ export default function Playlists() {
|
||||
try {
|
||||
await api.deletePlaylist(deletedId, onYoutube);
|
||||
} catch {
|
||||
notify({ level: "warning", message: t("playlists.deleteYtFailed") });
|
||||
notify({
|
||||
level: "warning",
|
||||
message: onYoutube ? t("playlists.deleteYtFailed") : t("playlists.deleteFailed"),
|
||||
});
|
||||
}
|
||||
qc.removeQueries({ queryKey: ["playlist", deletedId] });
|
||||
qc.invalidateQueries({ queryKey: ["playlists"] });
|
||||
@@ -441,237 +449,261 @@ export default function Playlists() {
|
||||
});
|
||||
}
|
||||
|
||||
// Stable identity: PlayerModal's keydown/scroll-lock effect depends on it, so a fresh closure
|
||||
// every render re-subscribed those listeners and re-ran focusModal() under the open player.
|
||||
const closePlayer = useCallback(() => setPlayingIndex(null), []);
|
||||
|
||||
const dragHint = filterActive
|
||||
? t("playlists.dragBlockedFilter")
|
||||
: t("playlists.dragBlockedSort");
|
||||
|
||||
if (selectedId == null) {
|
||||
return <div className="text-muted text-sm p-4">{t("playlists.pickOne")}</div>;
|
||||
}
|
||||
// The page used to render "Loading…" forever when the detail query failed — an error read as a
|
||||
// slow network that never arrived.
|
||||
if (detailQuery.isError) {
|
||||
return (
|
||||
<div className="text-sm p-4 flex flex-col items-start gap-3">
|
||||
<span className="inline-flex items-center gap-2 text-muted">
|
||||
<AlertTriangle className="w-4 h-4 text-red-400" />
|
||||
{t("playlists.loadFailed")}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => detailQuery.refetch()}
|
||||
className="px-3 py-1.5 rounded-lg border border-border text-xs text-muted hover:text-fg hover:border-accent transition"
|
||||
>
|
||||
{t("common.retry")}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (!detail) {
|
||||
return <div className="text-muted text-sm p-4">{t("playlists.loading")}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="px-4 pb-4 pt-3">
|
||||
{selectedId == null || !detail ? (
|
||||
<div className="text-muted text-sm p-4">
|
||||
{selectedId == null ? t("playlists.pickOne") : t("playlists.loading")}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Header (title, actions, sort/group) is fixed chrome — it portals into the shell's
|
||||
fixed band, so only the video list scrolls. */}
|
||||
<PageToolbar>
|
||||
<div className="px-4 pt-3 pb-2">
|
||||
<div className="flex gap-3 items-start mb-4">
|
||||
<div className="w-24 h-[54px] rounded-lg bg-card overflow-hidden shrink-0">
|
||||
{items[0]?.thumbnail_url && (
|
||||
<img
|
||||
src={items[0].thumbnail_url}
|
||||
alt=""
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
{renaming ? (
|
||||
<input
|
||||
autoFocus
|
||||
value={renameValue}
|
||||
onChange={(e) => setRenameValue(e.target.value)}
|
||||
onBlur={saveRename}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") saveRename();
|
||||
if (e.key === "Escape") setRenaming(false);
|
||||
}}
|
||||
className="text-lg font-semibold bg-card border border-border rounded-md px-2 py-0.5 outline-none focus:border-accent"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<h2 className="text-lg font-semibold truncate">{plName(detail)}</h2>
|
||||
{editable && (
|
||||
<button
|
||||
onClick={() => {
|
||||
setRenameValue(detail.name);
|
||||
setRenaming(true);
|
||||
}}
|
||||
title={t("playlists.rename")}
|
||||
aria-label={t("playlists.rename")}
|
||||
className="shrink-0 text-muted hover:text-fg"
|
||||
>
|
||||
<Pencil className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
{detail.yt_playlist_id && (
|
||||
<a
|
||||
href={`https://www.youtube.com/playlist?list=${detail.yt_playlist_id}`}
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
title={t("playlists.openOnYoutube")}
|
||||
aria-label={t("playlists.openOnYoutube")}
|
||||
className="shrink-0 text-muted hover:text-accent"
|
||||
>
|
||||
<ExternalLink className="w-3.5 h-3.5" />
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="text-xs text-muted mt-0.5">
|
||||
{t("playlists.itemCount", { count: items.length })}
|
||||
</div>
|
||||
<div className="flex gap-1.5 mt-3 flex-wrap items-center">
|
||||
<button
|
||||
onClick={() => items.length && setPlayingIndex(0)}
|
||||
disabled={!items.length}
|
||||
className="inline-flex items-center gap-1.5 text-xs font-semibold px-3 py-1.5 rounded-lg bg-accent text-accent-fg disabled:opacity-40 hover:opacity-90 transition"
|
||||
>
|
||||
<Play className="w-3.5 h-3.5 fill-current" /> {t("playlists.playAll")}
|
||||
</button>
|
||||
{editable && canWrite && (
|
||||
<button
|
||||
onClick={pushToYoutube}
|
||||
disabled={pushing}
|
||||
title={linked ? t("playlists.pushToYoutube") : t("playlists.exportToYoutube")}
|
||||
aria-label={linked ? t("playlists.pushToYoutube") : t("playlists.exportToYoutube")}
|
||||
className={`inline-flex items-center justify-center p-2 rounded-lg border transition disabled:opacity-40 ${
|
||||
detail.dirty
|
||||
? "border-accent text-accent hover:bg-accent/10"
|
||||
: "border-border text-muted hover:text-fg hover:border-accent"
|
||||
}`}
|
||||
>
|
||||
{pushing ? (
|
||||
<RefreshCw className="w-3.5 h-3.5 animate-spin" />
|
||||
) : linked ? (
|
||||
<Youtube className="w-3.5 h-3.5" />
|
||||
) : (
|
||||
<Upload className="w-3.5 h-3.5" />
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
{linked && detail.dirty && (
|
||||
<button
|
||||
onClick={revertToYoutube}
|
||||
disabled={reverting}
|
||||
title={t("playlists.revert")}
|
||||
aria-label={t("playlists.revert")}
|
||||
className="inline-flex items-center justify-center p-2 rounded-lg border border-border text-muted hover:text-fg hover:border-accent transition disabled:opacity-40"
|
||||
>
|
||||
<RotateCcw className={`w-3.5 h-3.5 ${reverting ? "animate-spin" : ""}`} />
|
||||
</button>
|
||||
)}
|
||||
{/* Header (title, actions, view controls) is fixed chrome — it portals into the shell's
|
||||
fixed band, so only the video list scrolls. */}
|
||||
<PageToolbar>
|
||||
<div className="px-4 pt-3 pb-2">
|
||||
<div className="flex gap-3 items-start mb-4">
|
||||
<div className="w-24 h-[54px] rounded-lg bg-card overflow-hidden shrink-0">
|
||||
{items[0]?.thumbnail_url && (
|
||||
<img src={items[0].thumbnail_url} alt="" className="w-full h-full object-cover" />
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
{renaming ? (
|
||||
<input
|
||||
autoFocus
|
||||
value={renameValue}
|
||||
onChange={(e) => setRenameValue(e.target.value)}
|
||||
onBlur={saveRename}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") saveRename();
|
||||
if (e.key === "Escape") setRenaming(false);
|
||||
}}
|
||||
aria-label={t("playlists.rename")}
|
||||
className="text-lg font-semibold bg-card border border-border rounded-md px-2 py-0.5 outline-none focus:border-accent"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<h2 className="text-lg font-semibold truncate">{plName(detail)}</h2>
|
||||
{editable && (
|
||||
<button
|
||||
onClick={deletePlaylist}
|
||||
title={t("playlists.delete")}
|
||||
aria-label={t("playlists.delete")}
|
||||
className="inline-flex items-center justify-center p-2 rounded-lg border border-border text-muted hover:text-red-400 hover:border-red-400/50 transition"
|
||||
onClick={() => {
|
||||
setRenameValue(detail.name);
|
||||
setRenaming(true);
|
||||
}}
|
||||
title={t("playlists.rename")}
|
||||
aria-label={t("playlists.rename")}
|
||||
className="shrink-0 text-muted hover:text-fg"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
<Pencil className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
{linked && detail.dirty && (
|
||||
<span
|
||||
title={t("playlists.unsyncedHint")}
|
||||
className="inline-flex items-center gap-1.5 text-[11px] text-accent ml-1"
|
||||
{detail.yt_playlist_id && (
|
||||
<a
|
||||
href={`https://www.youtube.com/playlist?list=${detail.yt_playlist_id}`}
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
title={t("playlists.openOnYoutube")}
|
||||
aria-label={t("playlists.openOnYoutube")}
|
||||
className="shrink-0 text-muted hover:text-accent"
|
||||
>
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-accent" />
|
||||
{t("playlists.unsynced")}
|
||||
</span>
|
||||
<ExternalLink className="w-3.5 h-3.5" />
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="text-xs text-muted mt-0.5">
|
||||
{t("playlists.itemCount", { count: items.length })}
|
||||
{filterActive && ` · ${t("playlists.filteredCount", { count: shown.length })}`}
|
||||
</div>
|
||||
<div className="flex gap-1.5 mt-3 flex-wrap items-center">
|
||||
<button
|
||||
onClick={() => shown.length && setPlayingIndex(0)}
|
||||
disabled={!shown.length}
|
||||
className="inline-flex items-center gap-1.5 text-xs font-semibold px-3 py-1.5 rounded-lg bg-accent text-accent-fg disabled:opacity-40 hover:opacity-90 transition"
|
||||
>
|
||||
<Play className="w-3.5 h-3.5 fill-current" /> {t("playlists.playAll")}
|
||||
</button>
|
||||
{editable && canWrite && (
|
||||
<button
|
||||
onClick={pushToYoutube}
|
||||
disabled={pushing}
|
||||
title={linked ? t("playlists.pushToYoutube") : t("playlists.exportToYoutube")}
|
||||
aria-label={linked ? t("playlists.pushToYoutube") : t("playlists.exportToYoutube")}
|
||||
className={`inline-flex items-center justify-center p-2 rounded-lg border transition disabled:opacity-40 ${
|
||||
detail.dirty
|
||||
? "border-accent text-accent hover:bg-accent/10"
|
||||
: "border-border text-muted hover:text-fg hover:border-accent"
|
||||
}`}
|
||||
>
|
||||
{pushing ? (
|
||||
<RefreshCw className="w-3.5 h-3.5 animate-spin" />
|
||||
) : linked ? (
|
||||
<Youtube className="w-3.5 h-3.5" />
|
||||
) : (
|
||||
<Upload className="w-3.5 h-3.5" />
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
{linked && detail.dirty && (
|
||||
<button
|
||||
onClick={revertToYoutube}
|
||||
disabled={reverting}
|
||||
title={t("playlists.revert")}
|
||||
aria-label={t("playlists.revert")}
|
||||
className="inline-flex items-center justify-center p-2 rounded-lg border border-border text-muted hover:text-fg hover:border-accent transition disabled:opacity-40"
|
||||
>
|
||||
<RotateCcw className={`w-3.5 h-3.5 ${reverting ? "animate-spin" : ""}`} />
|
||||
</button>
|
||||
)}
|
||||
{editable && (
|
||||
<button
|
||||
onClick={deletePlaylist}
|
||||
title={t("playlists.delete")}
|
||||
aria-label={t("playlists.delete")}
|
||||
className="inline-flex items-center justify-center p-2 rounded-lg border border-border text-muted hover:text-red-400 hover:border-red-400/50 transition"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
{linked && detail.dirty && (
|
||||
<span
|
||||
title={t("playlists.unsyncedHint")}
|
||||
className="inline-flex items-center gap-1.5 text-[11px] text-accent ml-1"
|
||||
>
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-accent" />
|
||||
{t("playlists.unsynced")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</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={sortKey}
|
||||
onChange={(e) => {
|
||||
const k = e.target.value as SortKey;
|
||||
setSortKey(k);
|
||||
applySort(k, sortDir, 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">{t("playlists.sortTitle")}</option>
|
||||
<option value="duration">{t("playlists.sortDuration")}</option>
|
||||
<option value="channel">{t("playlists.sortChannel")}</option>
|
||||
</select>
|
||||
{items.length > 1 && (
|
||||
<div className="flex items-center gap-2 mb-3 flex-wrap max-w-5xl">
|
||||
<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)}
|
||||
className="accent-accent"
|
||||
/>
|
||||
{t("playlists.groupByChannel")}
|
||||
</label>
|
||||
{viewActive && (
|
||||
<button
|
||||
onClick={() => {
|
||||
const d = sortDir === "asc" ? "desc" : "asc";
|
||||
setSortDir(d);
|
||||
if (sortKey !== "manual" || groupBy) applySort(sortKey, d, groupBy);
|
||||
}}
|
||||
disabled={sortKey === "manual" && !groupBy}
|
||||
title={sortDir === "asc" ? t("playlists.dirAsc") : t("playlists.dirDesc")}
|
||||
className="p-1.5 rounded-lg border border-border text-muted enabled:hover:text-fg enabled:hover:border-accent disabled:opacity-30 transition"
|
||||
onClick={clearView}
|
||||
className="text-xs text-muted hover:text-accent underline underline-offset-2 transition"
|
||||
>
|
||||
{sortDir === "asc" ? (
|
||||
<ArrowUp className="w-4 h-4" />
|
||||
) : (
|
||||
<ArrowDown className="w-4 h-4" />
|
||||
)}
|
||||
{t("playlists.clearView")}
|
||||
</button>
|
||||
<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(sortKey, sortDir, e.target.checked);
|
||||
}}
|
||||
className="accent-accent"
|
||||
/>
|
||||
{t("playlists.groupByChannel")}
|
||||
</label>
|
||||
)}
|
||||
{canSaveOrder && (
|
||||
<button
|
||||
onClick={saveOrder}
|
||||
title={t("playlists.saveOrderHint")}
|
||||
className="inline-flex items-center gap-1.5 text-xs font-semibold px-2.5 py-1 rounded-lg border border-accent text-accent hover:bg-accent/10 transition"
|
||||
>
|
||||
<Check className="w-3.5 h-3.5" /> {t("playlists.saveOrder")}
|
||||
</button>
|
||||
)}
|
||||
{viewActive && (
|
||||
<span className="text-[11px] text-muted/80">{dragHint}</span>
|
||||
)}
|
||||
{canEdit && (
|
||||
<div className="ml-auto">
|
||||
<UndoToolbar
|
||||
canUndo={order.canUndo}
|
||||
canRedo={order.canRedo}
|
||||
onUndo={undo}
|
||||
onRedo={redo}
|
||||
onUndo={order.undo}
|
||||
onRedo={order.redo}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
</div>
|
||||
</PageToolbar>
|
||||
)}
|
||||
</div>
|
||||
</PageToolbar>
|
||||
|
||||
{items.length === 0 ? (
|
||||
<div className="text-sm text-muted grid place-items-center text-center py-12">
|
||||
<div>
|
||||
<ListPlus className="w-6 h-6 mx-auto mb-2 opacity-60" />
|
||||
{t("playlists.empty")}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragEnd={onDragEnd}
|
||||
{items.length === 0 ? (
|
||||
<div className="text-sm text-muted grid place-items-center text-center py-12">
|
||||
<div>
|
||||
<ListPlus className="w-6 h-6 mx-auto mb-2 opacity-60" />
|
||||
{t("playlists.empty")}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={onDragEnd}>
|
||||
{/* Not clipped, so the header's filter popovers can overflow the table box. */}
|
||||
<div className="max-w-5xl">
|
||||
<table className="w-full border-collapse text-sm">
|
||||
<ColumnHead
|
||||
columns={columns}
|
||||
sort={viewSort}
|
||||
onToggleSort={toggleSort}
|
||||
filters={filters}
|
||||
onFilter={applyFilter}
|
||||
/>
|
||||
<SortableContext
|
||||
items={shown.map((v) => v.id)}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
<SortableContext
|
||||
items={items.map((v) => v.id)}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
<div className="space-y-1.5 max-w-3xl">
|
||||
{items.map((v, i) => (
|
||||
<Row
|
||||
key={v.id}
|
||||
video={v}
|
||||
index={i}
|
||||
onPlay={() => setPlayingIndex(i)}
|
||||
onRemove={() => removeItem(v.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
<tbody>
|
||||
{shown.map((v) => (
|
||||
<Row
|
||||
key={v.id}
|
||||
video={v}
|
||||
columns={columns}
|
||||
dragDisabled={!canEdit || viewActive}
|
||||
dragHint={dragHint}
|
||||
/>
|
||||
))}
|
||||
</tbody>
|
||||
</SortableContext>
|
||||
</table>
|
||||
{shown.length === 0 && (
|
||||
<div className="text-muted py-8 text-center text-sm">
|
||||
{t("playlists.noneMatchFilter")}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</DndContext>
|
||||
)}
|
||||
|
||||
{playingIndex != null && items[playingIndex] && (
|
||||
{playingIndex != null && shown[playingIndex] && (
|
||||
<Suspense fallback={null}>
|
||||
<PlayerModal
|
||||
video={items[playingIndex]}
|
||||
queue={items}
|
||||
video={shown[playingIndex]}
|
||||
queue={shown}
|
||||
startIndex={playingIndex}
|
||||
startAt={null}
|
||||
onClose={() => setPlayingIndex(null)}
|
||||
onClose={closePlayer}
|
||||
onState={playerState}
|
||||
/>
|
||||
</Suspense>
|
||||
|
||||
@@ -12,6 +12,7 @@ import SidePanel from "./SidePanel";
|
||||
import PanelGroups, { type PanelItem } from "./PanelGroups";
|
||||
import { usePlaylists, usePlaylistsActions } from "./PlaylistsProvider";
|
||||
import { useMe } from "../lib/useMe";
|
||||
import { playlistName } from "../lib/playlistName";
|
||||
|
||||
type PlSort = { key: "custom" | "name" | "count" | "duration"; dir: "asc" | "desc"; dirtyFirst: boolean };
|
||||
const PL_SORT_DEFAULT: PlSort = { key: "custom", dir: "asc", dirtyFirst: false };
|
||||
@@ -69,8 +70,7 @@ export default function PlaylistsRail({
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [playlists, selectedId, listQuery.data]);
|
||||
|
||||
const plName = (p: { kind: string; name: string }) =>
|
||||
p.kind === "watch_later" ? t("playlists.watchLater") : p.name;
|
||||
const plName = (p: { kind: string; name: string }) => playlistName(p, t);
|
||||
|
||||
const sortedPlaylists = useMemo(() => {
|
||||
const sign = plSort.dir === "asc" ? 1 : -1;
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
import { Play, X } from "lucide-react";
|
||||
import type { TFunction } from "i18next";
|
||||
import type { Video } from "../lib/api";
|
||||
import { formatDuration } from "../lib/format";
|
||||
import type { Column } from "./tableHeader";
|
||||
|
||||
// Column definitions for the playlist detail table, in their own file like `channelColumns.tsx`.
|
||||
//
|
||||
// The `grip` column carries no renderer: the drag handle needs the `useSortable` attributes that
|
||||
// only exist inside the row component, which a `render(row)` closure can't reach. It stays in the
|
||||
// list anyway so ONE column array drives both the header and the body — the row renderer fills
|
||||
// that cell itself. Everything else is an ordinary cell.
|
||||
export const GRIP_KEY = "grip";
|
||||
|
||||
export function playlistColumns({
|
||||
t,
|
||||
indexOf,
|
||||
onPlay,
|
||||
onRemove,
|
||||
canEdit,
|
||||
}: {
|
||||
t: TFunction;
|
||||
/** Position in the STORED order (1-based on screen) — not the row's position in the current view,
|
||||
* so a view-sort never renumbers the playlist under the reader. */
|
||||
indexOf: (v: Video) => number;
|
||||
onPlay: (v: Video) => void;
|
||||
onRemove: (v: Video) => void;
|
||||
canEdit: boolean;
|
||||
}): Column<Video>[] {
|
||||
const cols: Column<Video>[] = [
|
||||
{ key: GRIP_KEY, header: "", width: "2.25rem", render: () => null, hideInCard: true },
|
||||
{
|
||||
key: "pos",
|
||||
header: "#",
|
||||
align: "right",
|
||||
width: "3rem",
|
||||
nowrap: true,
|
||||
hideInCard: true,
|
||||
render: (v) => <span className="text-xs text-muted tabular-nums">{indexOf(v) + 1}</span>,
|
||||
},
|
||||
{
|
||||
key: "title",
|
||||
header: t("playlists.colTitle"),
|
||||
sortable: true,
|
||||
sortValue: (v) => v.title ?? "",
|
||||
filter: { kind: "text", get: (v) => v.title ?? "" },
|
||||
cardPrimary: true,
|
||||
render: (v) => (
|
||||
<div className="flex items-center gap-2.5 min-w-0">
|
||||
<button
|
||||
onClick={() => onPlay(v)}
|
||||
aria-label={`${t("card.play")} — ${v.title}`}
|
||||
className="relative w-[62px] h-[35px] rounded overflow-hidden bg-surface shrink-0 group"
|
||||
>
|
||||
{v.thumbnail_url && (
|
||||
<img src={v.thumbnail_url} alt="" className="w-full h-full object-cover" />
|
||||
)}
|
||||
<span className="absolute inset-0 grid place-items-center bg-black/40 opacity-0 group-hover:opacity-100 group-focus-visible:opacity-100 transition">
|
||||
<Play className="w-4 h-4 text-white fill-current" />
|
||||
</span>
|
||||
</button>
|
||||
<button onClick={() => onPlay(v)} className="min-w-0 flex-1 text-left">
|
||||
<span className="block text-[13px] text-fg truncate max-w-[34rem]">{v.title}</span>
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "channel",
|
||||
header: t("playlists.colChannel"),
|
||||
sortable: true,
|
||||
sortValue: (v) => v.channel_title ?? "",
|
||||
filter: { kind: "text", get: (v) => v.channel_title ?? "" },
|
||||
render: (v) => (
|
||||
<span className="text-[13px] text-muted truncate block max-w-[14rem]">{v.channel_title}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "duration",
|
||||
header: t("playlists.colDuration"),
|
||||
align: "right",
|
||||
nowrap: true,
|
||||
sortable: true,
|
||||
// Nulls (live/upcoming, or not yet enriched) sort last in BOTH directions in the old
|
||||
// comparator; sortRows is a plain numeric compare, so park them at the end of an ascending
|
||||
// sort with a large sentinel rather than letting them read as zero-length.
|
||||
sortValue: (v) => v.duration_seconds ?? Number.MAX_SAFE_INTEGER,
|
||||
render: (v) =>
|
||||
v.duration_seconds != null ? (
|
||||
<span className="text-[11px] text-muted tabular-nums">
|
||||
{formatDuration(v.duration_seconds)}
|
||||
</span>
|
||||
) : v.live_status === "live" || v.live_status === "upcoming" ? (
|
||||
<span
|
||||
className={`text-[10px] font-semibold uppercase tracking-wide px-1.5 py-0.5 rounded ${
|
||||
v.live_status === "live"
|
||||
? "bg-red-500/90 text-white"
|
||||
: "bg-card border border-border text-muted"
|
||||
}`}
|
||||
>
|
||||
{t(`card.${v.live_status}`)}
|
||||
</span>
|
||||
) : null,
|
||||
},
|
||||
];
|
||||
|
||||
if (canEdit) {
|
||||
cols.push({
|
||||
key: "actions",
|
||||
header: "",
|
||||
align: "right",
|
||||
width: "2.5rem",
|
||||
nowrap: true,
|
||||
render: (v) => (
|
||||
<button
|
||||
onClick={() => onRemove(v)}
|
||||
title={t("playlists.removeItem")}
|
||||
aria-label={t("playlists.removeItemOf", { title: v.title })}
|
||||
className="text-muted hover:text-fg p-1"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
return cols;
|
||||
}
|
||||
@@ -17,5 +17,6 @@
|
||||
"accessRequestsReview": "Review",
|
||||
"demoTitle": "Demo account",
|
||||
"demoSharedNotice": "You're in the shared demo account. Everything you do here — playlists, hidden videos, settings — is communal and may be changed by other demo visitors at the same time.",
|
||||
"backToTop": "Back to top"
|
||||
"backToTop": "Back to top",
|
||||
"retry": "Retry"
|
||||
}
|
||||
|
||||
@@ -42,16 +42,10 @@
|
||||
"pushDone": "“{{name}}” synced to YouTube ✓",
|
||||
"pushPartial": "Synced, but {{count}} item(s) were skipped.",
|
||||
"pushFailed": "Couldn't sync to YouTube.",
|
||||
"deleteOnYoutubeTitle": "Delete on YouTube too?",
|
||||
"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.",
|
||||
"sortLabel": "Sort",
|
||||
"sortManual": "Custom order",
|
||||
"sortTitle": "Title",
|
||||
"sortDuration": "Duration",
|
||||
"sortChannel": "Channel",
|
||||
"dirAsc": "Ascending",
|
||||
"dirDesc": "Descending",
|
||||
"groupByChannel": "Group by channel",
|
||||
@@ -59,5 +53,25 @@
|
||||
"railSortName": "Name",
|
||||
"railSortCount": "Item count",
|
||||
"railSortDuration": "Total length",
|
||||
"dirtyFirst": "Unsynced first"
|
||||
"dirtyFirst": "Unsynced first",
|
||||
"colTitle": "Title",
|
||||
"colChannel": "Channel",
|
||||
"colDuration": "Length",
|
||||
"removeItem": "Remove",
|
||||
"removeItemOf": "Remove “{{title}}” from the playlist",
|
||||
"confirmRemove": "Remove “{{title}}” from this playlist?",
|
||||
"dragHandle": "Drag to reorder",
|
||||
"dragHandleOf": "Reorder “{{title}}”",
|
||||
"dragBlockedSort": "Reordering by hand is off while a column sort or grouping is applied.",
|
||||
"dragBlockedFilter": "Reordering by hand is off while a filter is applied.",
|
||||
"clearView": "Reset view",
|
||||
"saveOrder": "Save this order",
|
||||
"saveOrderHint": "Store the order you see as the playlist's own order (undoable).",
|
||||
"filteredCount": "{{count}} shown",
|
||||
"noneMatchFilter": "No videos match the filter.",
|
||||
"loadFailed": "Couldn't load this playlist.",
|
||||
"reorderFailed": "Couldn't save the new order.",
|
||||
"renameFailed": "Couldn't rename the playlist.",
|
||||
"deleteFailed": "Couldn't delete the playlist.",
|
||||
"confirmDeleteLinked": "Delete the playlist “{{name}}”? It is synced with YouTube — choose whether to delete it there as well. This can't be undone."
|
||||
}
|
||||
|
||||
@@ -17,5 +17,6 @@
|
||||
"accessRequestsReview": "Áttekintés",
|
||||
"demoTitle": "Demo fiók",
|
||||
"demoSharedNotice": "Egy közös demo fiókban vagy. Minden, amit itt csinálsz — lejátszási listák, elrejtett videók, beállítások — közös, és más demo látogatók egyszerre módosíthatják.",
|
||||
"backToTop": "Vissza a tetejére"
|
||||
"backToTop": "Vissza a tetejére",
|
||||
"retry": "Újra"
|
||||
}
|
||||
|
||||
@@ -42,16 +42,10 @@
|
||||
"pushDone": "„{{name}}” szinkronizálva a YouTube-ra ✓",
|
||||
"pushPartial": "Szinkronizálva, de {{count}} elem kimaradt.",
|
||||
"pushFailed": "Nem sikerült a YouTube-ra szinkronizálás.",
|
||||
"deleteOnYoutubeTitle": "Törlöd a YouTube-on is?",
|
||||
"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.",
|
||||
"sortLabel": "Rendezés",
|
||||
"sortManual": "Egyéni sorrend",
|
||||
"sortTitle": "Cím",
|
||||
"sortDuration": "Hossz",
|
||||
"sortChannel": "Csatorna",
|
||||
"dirAsc": "Növekvő",
|
||||
"dirDesc": "Csökkenő",
|
||||
"groupByChannel": "Csoportosítás csatorna szerint",
|
||||
@@ -59,5 +53,25 @@
|
||||
"railSortName": "Név",
|
||||
"railSortCount": "Elemszám",
|
||||
"railSortDuration": "Összhossz",
|
||||
"dirtyFirst": "Nem szinkronizáltak elöl"
|
||||
"dirtyFirst": "Nem szinkronizáltak elöl",
|
||||
"colTitle": "Cím",
|
||||
"colChannel": "Csatorna",
|
||||
"colDuration": "Hossz",
|
||||
"removeItem": "Eltávolítás",
|
||||
"removeItemOf": "„{{title}}” eltávolítása a listáról",
|
||||
"confirmRemove": "Eltávolítod a(z) „{{title}}” videót erről a listáról?",
|
||||
"dragHandle": "Húzd az átrendezéshez",
|
||||
"dragHandleOf": "„{{title}}” átrendezése",
|
||||
"dragBlockedSort": "A kézi átrendezés ki van kapcsolva, amíg oszloprendezés vagy csoportosítás van érvényben.",
|
||||
"dragBlockedFilter": "A kézi átrendezés ki van kapcsolva, amíg szűrő van érvényben.",
|
||||
"clearView": "Nézet visszaállítása",
|
||||
"saveOrder": "Sorrend mentése",
|
||||
"saveOrderHint": "A látott sorrend legyen a lista saját sorrendje (visszavonható).",
|
||||
"filteredCount": "{{count}} látszik",
|
||||
"noneMatchFilter": "Egy videó sem felel meg a szűrőnek.",
|
||||
"loadFailed": "Nem sikerült betölteni ezt a listát.",
|
||||
"reorderFailed": "Nem sikerült elmenteni az új sorrendet.",
|
||||
"renameFailed": "Nem sikerült átnevezni a listát.",
|
||||
"deleteFailed": "Nem sikerült törölni a listát.",
|
||||
"confirmDeleteLinked": "Törlöd a(z) „{{name}}” listát? A YouTube-bal szinkronban van — válaszd ki, hogy ott is törlődjön-e. Nem vonható vissza."
|
||||
}
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
import type { TFunction } from "i18next";
|
||||
|
||||
/** Watch later is a built-in playlist whose stored `name` is not translatable — show the localized
|
||||
* label instead. Was hand-inlined in Playlists, PlaylistsRail and AddToPlaylist; one copy now. */
|
||||
export function playlistName(p: { kind: string; name: string }, t: TFunction): string {
|
||||
return p.kind === "watch_later" ? t("playlists.watchLater") : p.name;
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { applyView, sameOrder } from "./playlistView";
|
||||
import type { Column } from "../components/tableHeader";
|
||||
import type { Video } from "./api";
|
||||
|
||||
// The playlist's view derivation (E4 S4). What makes it worth a test: the column sort and the
|
||||
// channel grouping compose — groups are ordered by channel name in the SORT's direction, and the
|
||||
// sort then applies within each group. Getting that backwards silently produces a plausible-looking
|
||||
// order, and the whole "save this order" feature writes whatever this function returned.
|
||||
|
||||
const v = (id: string, title: string, channel: string, secs: number | null = 60): Video =>
|
||||
({ id, title, channel_title: channel, duration_seconds: secs }) as Video;
|
||||
|
||||
const columns = [
|
||||
{ key: "title", header: "Title", render: () => null, sortable: true, sortValue: (x: Video) => x.title ?? "" },
|
||||
{ key: "channel", header: "Channel", render: () => null, sortable: true, sortValue: (x: Video) => x.channel_title ?? "" },
|
||||
{
|
||||
key: "duration",
|
||||
header: "Length",
|
||||
render: () => null,
|
||||
sortable: true,
|
||||
sortValue: (x: Video) => x.duration_seconds ?? Number.MAX_SAFE_INTEGER,
|
||||
},
|
||||
] as Column<Video>[];
|
||||
|
||||
const titles = (rows: Video[]) => rows.map((r) => r.title);
|
||||
|
||||
describe("applyView", () => {
|
||||
const rows = [
|
||||
v("1", "delta", "Beta"),
|
||||
v("2", "alpha", "Alpha"),
|
||||
v("3", "charlie", "Beta"),
|
||||
v("4", "bravo", "Alpha"),
|
||||
];
|
||||
|
||||
it("returns the stored order untouched with no sort and no grouping", () => {
|
||||
expect(applyView(rows, columns, null, false)).toBe(rows);
|
||||
});
|
||||
|
||||
it("sorts by the named column", () => {
|
||||
expect(titles(applyView(rows, columns, { key: "title", dir: "asc" }, false))).toEqual([
|
||||
"alpha",
|
||||
"bravo",
|
||||
"charlie",
|
||||
"delta",
|
||||
]);
|
||||
expect(titles(applyView(rows, columns, { key: "title", dir: "desc" }, false))).toEqual([
|
||||
"delta",
|
||||
"charlie",
|
||||
"bravo",
|
||||
"alpha",
|
||||
]);
|
||||
});
|
||||
|
||||
it("groups by channel, ordering the groups by name and sorting within each", () => {
|
||||
expect(titles(applyView(rows, columns, { key: "title", dir: "asc" }, true))).toEqual([
|
||||
"alpha", // Alpha
|
||||
"bravo", // Alpha
|
||||
"charlie", // Beta
|
||||
"delta", // Beta
|
||||
]);
|
||||
});
|
||||
|
||||
it("reverses the GROUP order too when the sort is descending", () => {
|
||||
expect(titles(applyView(rows, columns, { key: "title", dir: "desc" }, true))).toEqual([
|
||||
"delta", // Beta first, because the direction flips the group order as well
|
||||
"charlie",
|
||||
"bravo", // then Alpha
|
||||
"alpha",
|
||||
]);
|
||||
});
|
||||
|
||||
it("groups without a sort by keeping each group's stored order", () => {
|
||||
expect(titles(applyView(rows, columns, null, true))).toEqual([
|
||||
"alpha",
|
||||
"bravo",
|
||||
"delta",
|
||||
"charlie",
|
||||
]);
|
||||
});
|
||||
|
||||
it("parks unknown durations at the end of an ascending sort, not at zero", () => {
|
||||
const mixed = [v("1", "long", "A", 900), v("2", "live", "A", null), v("3", "short", "A", 30)];
|
||||
expect(titles(applyView(mixed, columns, { key: "duration", dir: "asc" }, false))).toEqual([
|
||||
"short",
|
||||
"long",
|
||||
"live",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("sameOrder", () => {
|
||||
const a = [v("1", "x", "A"), v("2", "y", "A")];
|
||||
|
||||
it("is true for the same ids in the same positions", () => {
|
||||
expect(sameOrder(a, [v("1", "different title", "B"), v("2", "y", "A")])).toBe(true);
|
||||
});
|
||||
|
||||
it("is false when the order differs", () => {
|
||||
expect(sameOrder(a, [a[1]!, a[0]!])).toBe(false);
|
||||
});
|
||||
|
||||
it("is false when the length differs — a filtered view is never 'the same order'", () => {
|
||||
expect(sameOrder(a, [a[0]!])).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
import { sortRows, type SortState } from "./columnSort";
|
||||
|
||||
// The playlist detail's VIEW derivation: what the reader sees, as opposed to the playlist's stored
|
||||
// order. Kept structural (no `Column`, no component imports) like `columnSort.ts`, so it stays pure
|
||||
// and testable — the "save this order" button writes whatever `applyView` returned, which is reason
|
||||
// enough for this to be covered rather than eyeballed.
|
||||
|
||||
interface Grouped {
|
||||
channel_title?: string | null;
|
||||
}
|
||||
interface Sortable<T> {
|
||||
key: string;
|
||||
sortValue?: (row: T) => string | number;
|
||||
}
|
||||
|
||||
/** Filters are applied by the caller; this is sort, then optional channel grouping. Groups are
|
||||
* ordered by channel name in the sort's direction, and the sort applies WITHIN each group (with no
|
||||
* sort, each group keeps its stored order). */
|
||||
export function applyView<T extends Grouped>(
|
||||
rows: T[],
|
||||
columns: Sortable<T>[],
|
||||
sort: SortState,
|
||||
group: boolean
|
||||
): T[] {
|
||||
const sorted = sortRows(rows, columns, sort);
|
||||
if (!group) return sorted;
|
||||
const groups = new Map<string, T[]>();
|
||||
for (const v of sorted) {
|
||||
const k = v.channel_title ?? "";
|
||||
const g = groups.get(k);
|
||||
if (g) g.push(v);
|
||||
else groups.set(k, [v]);
|
||||
}
|
||||
const sign = sort?.dir === "desc" ? -1 : 1;
|
||||
const keys = [...groups.keys()].sort((a, b) => sign * a.localeCompare(b));
|
||||
return keys.flatMap((k) => groups.get(k)!);
|
||||
}
|
||||
|
||||
/** Same items in the same positions. Identity is the id, so a refetch that changed only a title
|
||||
* doesn't read as a reorder — and a shorter list (a filtered view) is never "the same order". */
|
||||
export const sameOrder = (a: { id: string }[], b: { id: string }[]) =>
|
||||
a.length === b.length && a.every((v, i) => v.id === b[i]!.id);
|
||||
@@ -15,6 +15,7 @@ export interface Undoable<T> {
|
||||
value: T;
|
||||
set: (next: T) => void;
|
||||
reset: (value: T) => void;
|
||||
rebase: (map: (value: T) => T) => void;
|
||||
undo: () => void;
|
||||
redo: () => void;
|
||||
canUndo: boolean;
|
||||
@@ -77,11 +78,27 @@ export function useUndoable<T>(
|
||||
force();
|
||||
}, []);
|
||||
|
||||
// Apply the same transformation to EVERY snapshot — past, present and future — and keep the
|
||||
// history. For changes that are outside the undoable dimension but invalidate the snapshots:
|
||||
// removing a playlist item is not itself undoable, yet dropping it from each stored order keeps
|
||||
// the user's reorder history valid and usable instead of throwing it away (which is what a
|
||||
// `reset` does). No `onApply`: the caller is already persisting the change by its own route.
|
||||
const rebase = useCallback((map: (value: T) => T) => {
|
||||
const s = ref.current;
|
||||
ref.current = {
|
||||
past: s.past.map(map),
|
||||
present: map(s.present),
|
||||
future: s.future.map(map),
|
||||
};
|
||||
force();
|
||||
}, []);
|
||||
|
||||
const h = ref.current;
|
||||
return {
|
||||
value: h.present,
|
||||
set,
|
||||
reset,
|
||||
rebase,
|
||||
undo,
|
||||
redo,
|
||||
canUndo: h.past.length > 0,
|
||||
|
||||
Reference in New Issue
Block a user