diff --git a/frontend/src/components/AddToPlaylist.tsx b/frontend/src/components/AddToPlaylist.tsx
index 33f3f89..0c161a8 100644
--- a/frontend/src/components/AddToPlaylist.tsx
+++ b/frontend/src/components/AddToPlaylist.tsx
@@ -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 && }
- {pl.kind === "watch_later" ? t("playlists.watchLater") : pl.name}
+ {playlistName(pl, t)}
{(pl.source === "youtube" || pl.yt_playlist_id) && (
diff --git a/frontend/src/components/ConfirmProvider.tsx b/frontend/src/components/ConfirmProvider.tsx
index 6794a9b..13dee0f 100644
--- a/frontend/src/components/ConfirmProvider.tsx
+++ b/frontend/src/components/ConfirmProvider.tsx
@@ -5,6 +5,11 @@ import Modal from "./Modal";
// App-styled, promise-based replacement for window.confirm. Wrap the tree in
// 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,122 @@ 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;
type ConfirmFn = (opts: ConfirmOptions) => Promise;
-const ConfirmContext = createContext(() => Promise.resolve(false));
+const ChoiceContext = createContext(() => 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(
- (opts) => new Promise((resolve) => setState({ opts, resolve })),
+ const ask = useCallback(
+ (opts) => new Promise((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);
+ const multi = choices.length > 1;
+
return (
-
+
{children}
{state && (
close(false)}
- maxWidth="max-w-sm"
+ onClose={() => close(null)}
+ maxWidth={choices.length > 1 ? "max-w-md" : "max-w-sm"}
>
{state.opts.message}
-
+ {/* One action: the familiar Cancel/Confirm row. Two or more: a column, because side by
+ side they wrap raggedly and a wrapped button reads as a second, lesser group — and
+ these are peers. Ordered least to most destructive, top to bottom. */}
+
-
+ {choices.map((c, i) => (
+
+ ))}
)}
-
+
);
}
diff --git a/frontend/src/components/DataTable.tsx b/frontend/src/components/DataTable.tsx
index e6cf2ee..09fc18d 100644
--- a/frontend/src/components/DataTable.tsx
+++ b/frontend/src/components/DataTable.tsx
@@ -1,58 +1,29 @@
import { useEffect, useRef, useState, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
-import { ArrowDown, ArrowUp, ListFilter, X } from "lucide-react";
-import { useDismiss } from "../lib/useDismiss";
-import { useScrollFade } from "../lib/useScrollFade";
import { sortRows, cycleSort, type SortState } from "../lib/columnSort";
import Pager, { hasPagerContent } from "./Pager";
import { PageToolbar } from "./PageShell";
+import {
+ ColumnHead,
+ alignClass,
+ filterRows,
+ type Column,
+ type FilterMap,
+} from "./tableHeader";
// A reusable, client-side data table: per-column sort + filter (in the header) + pagination,
// with its sort/filter/page state optionally persisted to localStorage so a reload (F5) keeps
// the view. Columns are declared by the caller; the table is generic over the row type, so
-// other modules (e.g. the playlist manager) can reuse it. Below `md` it falls back to a card
-// list built from the same column definitions.
+// other modules can reuse it. Below `md` it falls back to a card list built from the same column
+// definitions.
+//
+// The header row itself (sort cell + filter funnel/popover + the filter predicate) lives in
+// `tableHeader.tsx` — the playlist manager renders its own drag-reorderable body under the same
+// headers without inheriting this component's ordering state.
-// The multi-select filter's option list, capped and scrollable. Its own component (module level,
-// not nested in DataTable) because it owns a hook: FilterPopover is redeclared on every DataTable
-// render, so React remounts it — state parked in there would churn.
-function FilterOptionList({ children }: { children: ReactNode }) {
- const fade = useScrollFade();
- return (
-
- {children}
-
- );
-}
+// Re-exported so the many `import { type Column } from "./DataTable"` call sites keep working.
+export type { Column } from "./tableHeader";
-type ColumnFilter =
- | { kind: "text"; get: (row: T) => string }
- | { kind: "select"; options: { value: string; label: string }[]; test: (row: T, value: string) => boolean }
- | { kind: "multi"; options: { value: string; label: string }[]; test: (row: T, values: string[]) => boolean };
-
-export interface Column {
- key: string;
- header: string;
- render: (row: T) => ReactNode;
- align?: "left" | "right" | "center";
- width?: string;
- sortable?: boolean;
- sortValue?: (row: T) => string | number;
- filter?: ColumnFilter;
- // Keep the cell on one line so the column auto-sizes to its content (table-layout: auto).
- nowrap?: boolean;
- // Card fallback (below md): `cardPrimary` renders as the card heading (no label); `hideInCard`
- // omits the column; `cardLabel:false` shows the value without its header label.
- cardPrimary?: boolean;
- hideInCard?: boolean;
- cardLabel?: boolean;
-}
-
-type FilterMap = Record;
interface Persisted {
sort: SortState;
filters: FilterMap;
@@ -71,10 +42,6 @@ function loadPersist(key?: string): Persisted {
}
}
-function isActive(value: string | string[] | undefined): boolean {
- return Array.isArray(value) ? value.length > 0 : !!value;
-}
-
export default function DataTable({
rows,
columns,
@@ -132,8 +99,6 @@ export default function DataTable({
const [filters, setFilters] = useState(initial.filters);
const [page, setPage] = useState(initial.page);
const [size, setSize] = useState(initial.size ?? pageSize);
- const [openFilter, setOpenFilter] = useState(null);
- const popRef = useRef(null);
useEffect(() => {
if (persistKey) localStorage.setItem(persistKey, JSON.stringify({ sort, filters, page, size }));
@@ -158,21 +123,7 @@ export default function DataTable({
setPage(0);
}, [resetFiltersToken]);
- useDismiss(!!openFilter, () => setOpenFilter(null), popRef);
-
- const filtered = rows.filter((row) =>
- columns.every((col) => {
- const f = col.filter;
- if (!f) return true;
- const val = filters[col.key];
- if (!isActive(val)) return true;
- if (f.kind === "text") return f.get(row).toLowerCase().includes(String(val).toLowerCase());
- if (f.kind === "select") return f.test(row, String(val));
- return f.test(row, val as string[]);
- })
- );
-
- const sorted = sortRows(filtered, columns, sort);
+ const sorted = sortRows(filterRows(rows, columns, filters), columns, sort);
// size <= 0 means "All" — one page with every row.
const allRows = size <= 0;
@@ -191,95 +142,6 @@ export default function DataTable({
setPage(0);
}
- const align = (a?: "left" | "right" | "center") =>
- a === "right" ? "text-right" : a === "center" ? "text-center" : "text-left";
-
- function FilterPopover({ col }: { col: Column }) {
- const f = col.filter!;
- const val = filters[col.key];
- return (
-
- );
- }
-
// The pager + rows-per-page cluster on its own, so it can be rendered a second time at the bottom
// (controlsPosition "both") WITHOUT repeating the leading controls (e.g. the status chips) — those
// are header controls and belong only at the top.
@@ -329,57 +191,13 @@ export default function DataTable({
{/* Wide screens: table. The wrapper isn't clipped so header filter popovers can overflow. */}
- {/* Sticky header row: the column headers stay put at the top of the page scroller while
- the rows scroll under them. `bg-bg` on each cell keeps rows from showing through; the
- border rides a pseudo-element (box-shadow) since a sticky
's own border scrolls
- away with the collapsed table box in some browsers. The `::after` paints a short
- fade just BELOW the header so rows dissolve as they slide under it (the header itself
- stays crisp — the page scroller's own top fade is off on these pages). */}
-
-
diff --git a/frontend/src/components/Playlists.tsx b/frontend/src/components/Playlists.tsx
index 5e2c9fc..626ddee 100644
--- a/frontend/src/components/Playlists.tsx
+++ b/frontend/src/components/Playlists.tsx
@@ -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,42 @@ 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";
+// 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.
-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();
- 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 neutral view: "#" ascending IS the stored order, so the header can show a real arrow for it
+// instead of the list looking unsorted (or, worse, looking sorted by whatever column the stored
+// order happens to correlate with — the user read a channel-grouped playlist as sorted by Channel).
+const STORED_SORT: SortState = { key: "pos", dir: "asc" };
+const isStoredSort = (s: SortState) => s?.key === "pos" && s.dir === "asc";
const idsKey = (items: Video[]) =>
items
@@ -97,86 +75,65 @@ 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
);
}
@@ -190,32 +147,51 @@ 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