Merge: E4 S4 — playlist hybrid table (view-sort + explicit save)
Closes E4. The playlist detail gains real sortable/filterable column headers over the drag-reorderable list, and the crucial distinction: the sort and the filters are a VIEW, while the stored order changes only on drag or an explicit save. Picking a sort used to write straight through to the server. Also extracts the column header out of DataTable (shared by both tables now, which fixed the filter popover remounting on every keystroke), and closes eight findings from the 2026-07-20 reviews that lived in these 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 { api, type Playlist } from "../lib/api";
|
||||||
import { useDismiss } from "../lib/useDismiss";
|
import { useDismiss } from "../lib/useDismiss";
|
||||||
import { useScrollFade } from "../lib/useScrollFade";
|
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
|
// 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.
|
// 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" />}
|
{pl.has_video && <Check className="w-3 h-3" />}
|
||||||
</span>
|
</span>
|
||||||
<span className="truncate flex-1 text-left">
|
<span className="truncate flex-1 text-left">
|
||||||
{pl.kind === "watch_later" ? t("playlists.watchLater") : pl.name}
|
{playlistName(pl, t)}
|
||||||
</span>
|
</span>
|
||||||
{(pl.source === "youtube" || pl.yt_playlist_id) && (
|
{(pl.source === "youtube" || pl.yt_playlist_id) && (
|
||||||
<Youtube className="w-3 h-3 shrink-0 text-muted" />
|
<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
|
// App-styled, promise-based replacement for window.confirm. Wrap the tree in
|
||||||
// <ConfirmProvider> once, then anywhere: const confirm = useConfirm();
|
// <ConfirmProvider> once, then anywhere: const confirm = useConfirm();
|
||||||
// if (await confirm({ message: "…", danger: true })) { … }
|
// 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 {
|
export interface ConfirmOptions {
|
||||||
title?: string;
|
title?: string;
|
||||||
message: ReactNode;
|
message: ReactNode;
|
||||||
@@ -13,62 +18,122 @@ export interface ConfirmOptions {
|
|||||||
danger?: boolean;
|
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>;
|
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 {
|
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 }) {
|
export function ConfirmProvider({ children }: { children: ReactNode }) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [state, setState] = useState<{
|
const [state, setState] = useState<{
|
||||||
opts: ConfirmOptions;
|
opts: ChoiceOptions;
|
||||||
resolve: (ok: boolean) => void;
|
resolve: (id: string | null) => void;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
|
|
||||||
const confirm = useCallback<ConfirmFn>(
|
const ask = useCallback<ChoiceFn>(
|
||||||
(opts) => new Promise<boolean>((resolve) => setState({ opts, resolve })),
|
(opts) => new Promise<string | null>((resolve) => setState({ opts, resolve })),
|
||||||
[]
|
[]
|
||||||
);
|
);
|
||||||
|
|
||||||
function close(ok: boolean) {
|
function close(id: string | null) {
|
||||||
state?.resolve(ok);
|
state?.resolve(id);
|
||||||
setState(null);
|
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 (
|
return (
|
||||||
<ConfirmContext.Provider value={confirm}>
|
<ChoiceContext.Provider value={ask}>
|
||||||
{children}
|
{children}
|
||||||
{state && (
|
{state && (
|
||||||
<Modal
|
<Modal
|
||||||
title={state.opts.title ?? t("common.confirmTitle")}
|
title={state.opts.title ?? t("common.confirmTitle")}
|
||||||
onClose={() => close(false)}
|
onClose={() => close(null)}
|
||||||
maxWidth="max-w-sm"
|
maxWidth={choices.length > 1 ? "max-w-md" : "max-w-sm"}
|
||||||
>
|
>
|
||||||
<p className="text-sm text-muted leading-relaxed">{state.opts.message}</p>
|
<p className="text-sm text-muted leading-relaxed">{state.opts.message}</p>
|
||||||
<div className="flex justify-end gap-2 mt-5">
|
{/* 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. */}
|
||||||
|
<div
|
||||||
|
className={
|
||||||
|
multi
|
||||||
|
? "flex flex-col gap-2 mt-5"
|
||||||
|
: "flex justify-end gap-2 mt-5 flex-wrap"
|
||||||
|
}
|
||||||
|
>
|
||||||
<button
|
<button
|
||||||
onClick={() => close(false)}
|
autoFocus={anyDanger}
|
||||||
className="px-4 py-2 rounded-lg text-sm border border-border text-muted hover:text-fg hover:bg-surface transition"
|
onClick={() => close(null)}
|
||||||
|
className={`px-4 py-2 rounded-lg text-sm border border-border text-muted hover:text-fg hover:bg-surface transition ${
|
||||||
|
multi ? "w-full order-last" : ""
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
{state.opts.cancelLabel ?? t("common.cancel")}
|
{state.opts.cancelLabel ?? t("common.cancel")}
|
||||||
</button>
|
</button>
|
||||||
|
{choices.map((c, i) => (
|
||||||
<button
|
<button
|
||||||
autoFocus
|
key={c.id}
|
||||||
onClick={() => close(true)}
|
autoFocus={!anyDanger && i === choices.length - 1}
|
||||||
|
onClick={() => close(c.id)}
|
||||||
className={`px-4 py-2 rounded-lg text-sm font-semibold transition ${
|
className={`px-4 py-2 rounded-lg text-sm font-semibold transition ${
|
||||||
state.opts.danger
|
multi ? "w-full" : ""
|
||||||
|
} ${
|
||||||
|
c.danger
|
||||||
? "bg-red-500 text-white hover:bg-red-600"
|
? "bg-red-500 text-white hover:bg-red-600"
|
||||||
: "bg-accent text-accent-fg hover:opacity-90"
|
: "bg-accent text-accent-fg hover:opacity-90"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{state.opts.confirmLabel ?? t("common.confirm")}
|
{c.label || t("common.confirm")}
|
||||||
</button>
|
</button>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
)}
|
)}
|
||||||
</ConfirmContext.Provider>
|
</ChoiceContext.Provider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,58 +1,29 @@
|
|||||||
import { useEffect, useRef, useState, type ReactNode } from "react";
|
import { useEffect, useRef, useState, type ReactNode } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
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 { sortRows, cycleSort, type SortState } from "../lib/columnSort";
|
||||||
import Pager, { hasPagerContent } from "./Pager";
|
import Pager, { hasPagerContent } from "./Pager";
|
||||||
import { PageToolbar } from "./PageShell";
|
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,
|
// 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
|
// 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
|
// 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
|
// other modules can reuse it. Below `md` it falls back to a card list built from the same column
|
||||||
// list built from the same column definitions.
|
// 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,
|
// Re-exported so the many `import { type Column } from "./DataTable"` call sites keep working.
|
||||||
// not nested in DataTable) because it owns a hook: FilterPopover is redeclared on every DataTable
|
export type { Column } from "./tableHeader";
|
||||||
// render, so React remounts it — state parked in there would churn.
|
|
||||||
function FilterOptionList({ children }: { children: ReactNode }) {
|
|
||||||
const fade = useScrollFade();
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
ref={fade.ref}
|
|
||||||
style={fade.style}
|
|
||||||
className="flex flex-col gap-0.5 max-h-60 overflow-y-auto no-scrollbar"
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
type ColumnFilter<T> =
|
|
||||||
| { 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<T> {
|
|
||||||
key: string;
|
|
||||||
header: string;
|
|
||||||
render: (row: T) => ReactNode;
|
|
||||||
align?: "left" | "right" | "center";
|
|
||||||
width?: string;
|
|
||||||
sortable?: boolean;
|
|
||||||
sortValue?: (row: T) => string | number;
|
|
||||||
filter?: ColumnFilter<T>;
|
|
||||||
// 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<string, string | string[]>;
|
|
||||||
interface Persisted {
|
interface Persisted {
|
||||||
sort: SortState;
|
sort: SortState;
|
||||||
filters: FilterMap;
|
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<T>({
|
export default function DataTable<T>({
|
||||||
rows,
|
rows,
|
||||||
columns,
|
columns,
|
||||||
@@ -132,8 +99,6 @@ export default function DataTable<T>({
|
|||||||
const [filters, setFilters] = useState<FilterMap>(initial.filters);
|
const [filters, setFilters] = useState<FilterMap>(initial.filters);
|
||||||
const [page, setPage] = useState(initial.page);
|
const [page, setPage] = useState(initial.page);
|
||||||
const [size, setSize] = useState(initial.size ?? pageSize);
|
const [size, setSize] = useState(initial.size ?? pageSize);
|
||||||
const [openFilter, setOpenFilter] = useState<string | null>(null);
|
|
||||||
const popRef = useRef<HTMLDivElement | null>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (persistKey) localStorage.setItem(persistKey, JSON.stringify({ sort, filters, page, size }));
|
if (persistKey) localStorage.setItem(persistKey, JSON.stringify({ sort, filters, page, size }));
|
||||||
@@ -158,21 +123,7 @@ export default function DataTable<T>({
|
|||||||
setPage(0);
|
setPage(0);
|
||||||
}, [resetFiltersToken]);
|
}, [resetFiltersToken]);
|
||||||
|
|
||||||
useDismiss(!!openFilter, () => setOpenFilter(null), popRef);
|
const sorted = sortRows(filterRows(rows, columns, filters), columns, sort);
|
||||||
|
|
||||||
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);
|
|
||||||
|
|
||||||
// size <= 0 means "All" — one page with every row.
|
// size <= 0 means "All" — one page with every row.
|
||||||
const allRows = size <= 0;
|
const allRows = size <= 0;
|
||||||
@@ -191,95 +142,6 @@ export default function DataTable<T>({
|
|||||||
setPage(0);
|
setPage(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
const align = (a?: "left" | "right" | "center") =>
|
|
||||||
a === "right" ? "text-right" : a === "center" ? "text-center" : "text-left";
|
|
||||||
|
|
||||||
function FilterPopover({ col }: { col: Column<T> }) {
|
|
||||||
const f = col.filter!;
|
|
||||||
const val = filters[col.key];
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
ref={popRef}
|
|
||||||
className="glass-menu absolute left-0 top-full mt-1 z-chrome w-52 rounded-xl p-2 animate-[popIn_0.16s_ease]"
|
|
||||||
>
|
|
||||||
{f.kind === "text" && (
|
|
||||||
<input
|
|
||||||
autoFocus
|
|
||||||
value={(val as string) ?? ""}
|
|
||||||
onChange={(e) => applyFilter(col.key, e.target.value)}
|
|
||||||
placeholder={col.header}
|
|
||||||
className="w-full bg-card border border-border rounded-md px-2 py-1 text-xs outline-none focus:border-accent"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{f.kind === "select" && (
|
|
||||||
<div className="flex flex-col gap-0.5">
|
|
||||||
<button
|
|
||||||
onClick={() => applyFilter(col.key, "")}
|
|
||||||
className={`text-left text-xs px-2 py-1.5 rounded-md transition ${
|
|
||||||
!isActive(val) ? "bg-accent text-accent-fg" : "text-muted hover:bg-card"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{t("datatable.all")}
|
|
||||||
</button>
|
|
||||||
{f.options.map((o) => (
|
|
||||||
<button
|
|
||||||
key={o.value}
|
|
||||||
onClick={() => applyFilter(col.key, o.value)}
|
|
||||||
className={`text-left text-xs px-2 py-1.5 rounded-md transition ${
|
|
||||||
val === o.value ? "bg-accent text-accent-fg" : "text-muted hover:bg-card"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{o.label}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{f.kind === "multi" && (
|
|
||||||
<FilterOptionList>
|
|
||||||
{f.options.length === 0 && (
|
|
||||||
<span className="text-xs text-muted px-2 py-1">{t("datatable.noOptions")}</span>
|
|
||||||
)}
|
|
||||||
{f.options.map((o) => {
|
|
||||||
const arr = (val as string[]) ?? [];
|
|
||||||
const on = arr.includes(o.value);
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={o.value}
|
|
||||||
onClick={() =>
|
|
||||||
applyFilter(
|
|
||||||
col.key,
|
|
||||||
on ? arr.filter((x) => x !== o.value) : [...arr, o.value]
|
|
||||||
)
|
|
||||||
}
|
|
||||||
className={`flex items-center gap-2 text-left text-xs px-2 py-1.5 rounded-md transition ${
|
|
||||||
on ? "text-fg" : "text-muted hover:bg-card"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
className={`w-3.5 h-3.5 rounded border flex items-center justify-center ${
|
|
||||||
on ? "bg-accent border-accent text-accent-fg" : "border-border"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{on && <X className="w-2.5 h-2.5" />}
|
|
||||||
</span>
|
|
||||||
{o.label}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</FilterOptionList>
|
|
||||||
)}
|
|
||||||
{isActive(val) && (
|
|
||||||
<button
|
|
||||||
onClick={() => applyFilter(col.key, f.kind === "multi" ? [] : "")}
|
|
||||||
className="mt-1.5 w-full text-xs text-muted hover:text-accent transition"
|
|
||||||
>
|
|
||||||
{t("datatable.clear")}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// The pager + rows-per-page cluster on its own, so it can be rendered a second time at the bottom
|
// 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
|
// (controlsPosition "both") WITHOUT repeating the leading controls (e.g. the status chips) — those
|
||||||
// are header controls and belong only at the top.
|
// are header controls and belong only at the top.
|
||||||
@@ -329,57 +191,13 @@ export default function DataTable<T>({
|
|||||||
{/* Wide screens: table. The wrapper isn't clipped so header filter popovers can overflow. */}
|
{/* Wide screens: table. The wrapper isn't clipped so header filter popovers can overflow. */}
|
||||||
<div className="hidden md:block">
|
<div className="hidden md:block">
|
||||||
<table className="w-full border-collapse text-sm">
|
<table className="w-full border-collapse text-sm">
|
||||||
{/* Sticky header row: the column headers stay put at the top of the page scroller while
|
<ColumnHead
|
||||||
the rows scroll under them. `bg-bg` on each cell keeps rows from showing through; the
|
columns={columns}
|
||||||
border rides a pseudo-element (box-shadow) since a sticky <tr>'s own border scrolls
|
sort={sort}
|
||||||
away with the collapsed table box in some browsers. The `::after` paints a short
|
onToggleSort={toggleSort}
|
||||||
fade just BELOW the header so rows dissolve as they slide under it (the header itself
|
filters={filters}
|
||||||
stays crisp — the page scroller's own top fade is off on these pages). */}
|
onFilter={applyFilter}
|
||||||
<thead className="sticky top-0 z-base after:pointer-events-none after:absolute after:inset-x-0 after:top-full after:h-4 after:bg-[linear-gradient(to_bottom,var(--bg),transparent)] after:content-['']">
|
/>
|
||||||
<tr className="text-muted [&>th]:shadow-[inset_0_-1px_0_var(--border)]">
|
|
||||||
{columns.map((col) => {
|
|
||||||
const sorted_ = sort?.key === col.key;
|
|
||||||
const filterOn = isActive(filters[col.key]);
|
|
||||||
return (
|
|
||||||
<th
|
|
||||||
key={col.key}
|
|
||||||
style={col.width ? { width: col.width } : undefined}
|
|
||||||
className={`relative font-medium px-2 py-2 whitespace-nowrap bg-bg ${align(col.align)}`}
|
|
||||||
>
|
|
||||||
<span className="inline-flex items-center gap-1">
|
|
||||||
<button
|
|
||||||
onClick={() => col.sortable && toggleSort(col.key)}
|
|
||||||
className={`inline-flex items-center gap-1 ${
|
|
||||||
col.sortable ? "hover:text-fg cursor-pointer" : "cursor-default"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{col.header}
|
|
||||||
{col.sortable && sorted_ && sort && (
|
|
||||||
sort.dir === "asc" ? (
|
|
||||||
<ArrowUp className="w-3 h-3" />
|
|
||||||
) : (
|
|
||||||
<ArrowDown className="w-3 h-3" />
|
|
||||||
)
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
{col.filter && (
|
|
||||||
<button
|
|
||||||
onClick={() => setOpenFilter((o) => (o === col.key ? null : col.key))}
|
|
||||||
aria-label={t("datatable.filter")}
|
|
||||||
className={`transition ${
|
|
||||||
filterOn ? "text-accent" : "text-muted/60 hover:text-fg"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<ListFilter className="w-3.5 h-3.5" />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
{openFilter === col.key && col.filter && <FilterPopover col={col} />}
|
|
||||||
</th>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
<tbody>
|
||||||
{paged.map((row) => (
|
{paged.map((row) => (
|
||||||
<tr
|
<tr
|
||||||
@@ -392,7 +210,7 @@ export default function DataTable<T>({
|
|||||||
<td
|
<td
|
||||||
key={col.key}
|
key={col.key}
|
||||||
style={col.width ? { width: col.width } : undefined}
|
style={col.width ? { width: col.width } : undefined}
|
||||||
className={`px-2 py-2 align-middle ${col.nowrap ? "whitespace-nowrap" : ""} ${align(col.align)}`}
|
className={`px-2 py-2 align-middle ${col.nowrap ? "whitespace-nowrap" : ""} ${alignClass(col.align)}`}
|
||||||
>
|
>
|
||||||
{col.render(row)}
|
{col.render(row)}
|
||||||
</td>
|
</td>
|
||||||
|
|||||||
@@ -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 { useTranslation } from "react-i18next";
|
||||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import {
|
import {
|
||||||
DndContext,
|
DndContext,
|
||||||
|
KeyboardSensor,
|
||||||
PointerSensor,
|
PointerSensor,
|
||||||
closestCenter,
|
closestCenter,
|
||||||
useSensor,
|
useSensor,
|
||||||
@@ -12,13 +13,14 @@ import {
|
|||||||
import {
|
import {
|
||||||
SortableContext,
|
SortableContext,
|
||||||
arrayMove,
|
arrayMove,
|
||||||
|
sortableKeyboardCoordinates,
|
||||||
useSortable,
|
useSortable,
|
||||||
verticalListSortingStrategy,
|
verticalListSortingStrategy,
|
||||||
} from "@dnd-kit/sortable";
|
} from "@dnd-kit/sortable";
|
||||||
import { CSS } from "@dnd-kit/utilities";
|
import { CSS } from "@dnd-kit/utilities";
|
||||||
import {
|
import {
|
||||||
ArrowDown,
|
AlertTriangle,
|
||||||
ArrowUp,
|
Check,
|
||||||
ExternalLink,
|
ExternalLink,
|
||||||
GripVertical,
|
GripVertical,
|
||||||
ListPlus,
|
ListPlus,
|
||||||
@@ -28,66 +30,42 @@ import {
|
|||||||
RotateCcw,
|
RotateCcw,
|
||||||
Trash2,
|
Trash2,
|
||||||
Upload,
|
Upload,
|
||||||
X,
|
|
||||||
Youtube,
|
Youtube,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { api, type Video } from "../lib/api";
|
import { api, type Video } from "../lib/api";
|
||||||
import { formatDuration } from "../lib/format";
|
|
||||||
import { notify } from "../lib/notifications";
|
import { notify } from "../lib/notifications";
|
||||||
import { notifyYouTubeActionError } from "../lib/youtubeErrors";
|
import { notifyYouTubeActionError } from "../lib/youtubeErrors";
|
||||||
|
import { playlistName } from "../lib/playlistName";
|
||||||
import { useUndoable } from "../lib/useUndoable";
|
import { useUndoable } from "../lib/useUndoable";
|
||||||
|
import { cycleSort, type SortState } from "../lib/columnSort";
|
||||||
|
import { applyView, sameOrder } from "../lib/playlistView";
|
||||||
const PlayerModal = lazy(() => import("./PlayerModal"));
|
const PlayerModal = lazy(() => import("./PlayerModal"));
|
||||||
import UndoToolbar from "./UndoToolbar";
|
import UndoToolbar from "./UndoToolbar";
|
||||||
import { PageToolbar } from "./PageShell";
|
import { PageToolbar } from "./PageShell";
|
||||||
import { useConfirm } from "./ConfirmProvider";
|
import { useChoice, useConfirm } from "./ConfirmProvider";
|
||||||
import { usePlaylists, usePlaylistsActions } from "./PlaylistsProvider";
|
import { usePlaylists, usePlaylistsActions } from "./PlaylistsProvider";
|
||||||
import { useMe } from "../lib/useMe";
|
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";
|
// The playlist detail is a HYBRID table (E4 S4): real sortable/filterable column headers over a
|
||||||
type SortDir = "asc" | "desc";
|
// 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 {
|
// The neutral view: "#" ascending IS the stored order, so the header can show a real arrow for it
|
||||||
if (key === "manual") return null;
|
// instead of the list looking unsorted (or, worse, looking sorted by whatever column the stored
|
||||||
const sign = dir === "asc" ? 1 : -1;
|
// order happens to correlate with — the user read a channel-grouped playlist as sorted by Channel).
|
||||||
if (key === "duration")
|
const STORED_SORT: SortState = { key: "pos", dir: "asc" };
|
||||||
return (a, b) => {
|
const isStoredSort = (s: SortState) => s?.key === "pos" && s.dir === "asc";
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
const idsKey = (items: Video[]) =>
|
const idsKey = (items: Video[]) =>
|
||||||
items
|
items
|
||||||
@@ -97,86 +75,65 @@ const idsKey = (items: Video[]) =>
|
|||||||
|
|
||||||
function Row({
|
function Row({
|
||||||
video,
|
video,
|
||||||
index,
|
columns,
|
||||||
readOnly,
|
dragDisabled,
|
||||||
onPlay,
|
dragHint,
|
||||||
onRemove,
|
|
||||||
}: {
|
}: {
|
||||||
video: Video;
|
video: Video;
|
||||||
index: number;
|
columns: Column<Video>[];
|
||||||
readOnly?: boolean;
|
dragDisabled: boolean;
|
||||||
onPlay: () => void;
|
dragHint: string;
|
||||||
onRemove: () => void;
|
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } =
|
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
|
||||||
useSortable({ id: video.id, disabled: readOnly });
|
id: video.id,
|
||||||
const style = {
|
disabled: dragDisabled,
|
||||||
transform: CSS.Transform.toString(transform),
|
});
|
||||||
transition,
|
|
||||||
opacity: isDragging ? 0.6 : 1,
|
|
||||||
};
|
|
||||||
return (
|
return (
|
||||||
<div
|
<tr
|
||||||
ref={setNodeRef}
|
ref={setNodeRef}
|
||||||
style={style}
|
style={{
|
||||||
className="flex items-center gap-2.5 p-2 rounded-lg glass-card glass-hover"
|
transform: CSS.Transform.toString(transform),
|
||||||
|
// No transition on the row being dragged: dnd-kit drives `transform` from the pointer, so
|
||||||
|
// animating it makes the row trail the cursor. The others keep it for the reflow animation.
|
||||||
|
transition: isDragging ? undefined : transition,
|
||||||
|
opacity: isDragging ? 0.6 : 1,
|
||||||
|
}}
|
||||||
|
// transition-COLORS, not `transition` — the bare utility animates transform too, which is
|
||||||
|
// the same lag by another route (150ms behind the pointer, on every row).
|
||||||
|
className="border-b border-border/50 hover:bg-card/40 transition-colors"
|
||||||
>
|
>
|
||||||
{readOnly ? (
|
{columns.map((col) => (
|
||||||
<span className="w-4" />
|
<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)}`}
|
||||||
|
>
|
||||||
|
{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
|
<button
|
||||||
{...attributes}
|
{...attributes}
|
||||||
{...listeners}
|
{...(dragDisabled ? {} : listeners)}
|
||||||
className="text-muted hover:text-fg cursor-grab active:cursor-grabbing"
|
// `disabled` would drop it from the tab order AND suppress the tooltip, so the
|
||||||
aria-label="reorder"
|
// reason reached nobody. aria-disabled keeps it focusable and the name carries the why.
|
||||||
|
aria-disabled={dragDisabled || undefined}
|
||||||
|
onClick={(e) => dragDisabled && e.preventDefault()}
|
||||||
|
title={dragDisabled ? dragHint : t("playlists.dragHandle")}
|
||||||
|
aria-label={
|
||||||
|
dragDisabled ? dragHint : t("playlists.dragHandleOf", { title: video.title })
|
||||||
|
}
|
||||||
|
className={`text-muted ${dragDisabled ? "opacity-30 cursor-not-allowed" : "hover:text-fg cursor-grab active:cursor-grabbing"}`}
|
||||||
>
|
>
|
||||||
<GripVertical className="w-4 h-4" />
|
<GripVertical className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
|
) : (
|
||||||
|
col.render(video)
|
||||||
)}
|
)}
|
||||||
<span className="text-xs text-muted w-4 text-center tabular-nums">{index + 1}</span>
|
</td>
|
||||||
<button
|
))}
|
||||||
onClick={onPlay}
|
</tr>
|
||||||
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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -190,32 +147,51 @@ export default function Playlists() {
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
const confirm = useConfirm();
|
const confirm = useConfirm();
|
||||||
|
const choose = useChoice();
|
||||||
const [renaming, setRenaming] = useState(false);
|
const [renaming, setRenaming] = useState(false);
|
||||||
const [renameValue, setRenameValue] = useState("");
|
const [renameValue, setRenameValue] = useState("");
|
||||||
// The item order is undoable: drag, sort and group all go through `order.set`, so each is
|
// The stored order is undoable: drag and "save this order" both go through `order.set`, so each
|
||||||
// reversible (buttons + Ctrl+Z/Y). onApply persists the new order to the server.
|
// is reversible (buttons + Ctrl+Z/Y). onApply persists the new order to the server.
|
||||||
const order = useUndoable<Video[]>([], {
|
const order = useUndoable<Video[]>([], {
|
||||||
onApply: (next) => {
|
onApply: (next) => {
|
||||||
if (selectedId == null) return;
|
if (selectedId == null) return;
|
||||||
api.reorderPlaylist(selectedId, next.map((v) => v.id)).then(() => {
|
api
|
||||||
|
.reorderPlaylist(
|
||||||
|
selectedId,
|
||||||
|
next.map((v) => v.id)
|
||||||
|
)
|
||||||
|
.then(() => {
|
||||||
// Refresh the sidebar (cover may change) and the detail (so the now-dirty state,
|
// 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
|
// and the Reset/Unsynced indicators, show without an F5). The membership guard
|
||||||
// keeps the refetch from wiping the undo history on a pure reorder.
|
// keeps the refetch from wiping the undo history on a pure reorder.
|
||||||
qc.invalidateQueries({ queryKey: ["playlists"] });
|
qc.invalidateQueries({ queryKey: ["playlists"] });
|
||||||
qc.invalidateQueries({ queryKey: ["playlist", selectedId] });
|
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"));
|
||||||
|
// Clear the membership guard first: a reorder never changes the id SET, so without this
|
||||||
|
// the arriving detail is judged "same list" and the rejected order stays on screen — and
|
||||||
|
// becomes the base for the next drag.
|
||||||
|
lastSetRef.current = "";
|
||||||
|
qc.invalidateQueries({ queryKey: ["playlist", selectedId] });
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const items = order.value;
|
const items = order.value;
|
||||||
const [sortKey, setSortKey] = useState<SortKey>("manual");
|
// VIEW state — never written to the server on its own.
|
||||||
const [sortDir, setSortDir] = useState<SortDir>("asc");
|
const [viewSort, setViewSort] = useState<SortState>(STORED_SORT);
|
||||||
|
const [filters, setFilters] = useState<FilterMap>({});
|
||||||
const [groupBy, setGroupBy] = useState(false);
|
const [groupBy, setGroupBy] = useState(false);
|
||||||
// Tracks the membership (id set) currently loaded into `order`, so a refetch that only
|
// 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
|
// 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).
|
// the actual set of items changes (different playlist, or an add/remove).
|
||||||
const lastSetRef = useRef<string>("");
|
const lastSetRef = useRef<string>("");
|
||||||
// The open player, as an index into `items` (the queue); null = closed.
|
// The open player is tracked by video ID, not by a position: `shown` is derived, so a refetch or
|
||||||
const [playingIndex, setPlayingIndex] = useState<number | null>(null);
|
// a change of view would otherwise slide a different video under the index — or unmount the player
|
||||||
|
// mid-playback. PlayerModal already tracks its own queue by id; this is the parent half of that.
|
||||||
|
const [playingId, setPlayingId] = useState<string | null>(null);
|
||||||
|
|
||||||
const listQuery = useQuery({
|
const listQuery = useQuery({
|
||||||
queryKey: ["playlists"],
|
queryKey: ["playlists"],
|
||||||
@@ -236,7 +212,9 @@ export default function Playlists() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!detail) return;
|
if (!detail) return;
|
||||||
const key = idsKey(detail.items);
|
// Scoped to the playlist: two lists holding the SAME videos would otherwise look identical to
|
||||||
|
// the guard, so the second would render in the first's order and inherit its undo stack.
|
||||||
|
const key = `${selectedId}:${idsKey(detail.items)}`;
|
||||||
if (key !== lastSetRef.current) {
|
if (key !== lastSetRef.current) {
|
||||||
lastSetRef.current = key;
|
lastSetRef.current = key;
|
||||||
order.reset(detail.items);
|
order.reset(detail.items);
|
||||||
@@ -244,43 +222,101 @@ export default function Playlists() {
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [detail]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
setSortKey("manual");
|
setViewSort(STORED_SORT);
|
||||||
setSortDir("asc");
|
setFilters({});
|
||||||
setGroupBy(false);
|
setGroupBy(false);
|
||||||
}, [selectedId]);
|
}, [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";
|
const builtin = detail?.kind === "watch_later";
|
||||||
// YouTube-mirrored playlists can be edited locally and synced back (the read-sync skips a
|
// 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.
|
// dirty mirror so it won't clobber unpushed edits); the YT-link button marks their origin.
|
||||||
const editable = !builtin;
|
const editable = !builtin;
|
||||||
const linked = editable && !!detail?.yt_playlist_id;
|
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 [pushing, setPushing] = useState(false);
|
||||||
const [reverting, setReverting] = useState(false);
|
const [reverting, setReverting] = useState(false);
|
||||||
|
|
||||||
|
const onPlay = useCallback((v: Video) => setPlayingId(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 drag announcements, which must report the CURRENT positions without making the
|
||||||
|
// announcement closures a render dependency of DndContext.
|
||||||
|
const shownRef = useRef(shown);
|
||||||
|
shownRef.current = shown;
|
||||||
|
|
||||||
|
const viewActive = !isStoredSort(viewSort) || 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) {
|
||||||
|
// Cycling off any column lands back on the stored order rather than on a nameless "unsorted".
|
||||||
|
setViewSort((s) => cycleSort(s, key) ?? STORED_SORT);
|
||||||
|
}
|
||||||
|
function applyFilter(key: string, value: string | string[]) {
|
||||||
|
setFilters((f) => ({ ...f, [key]: value }));
|
||||||
|
}
|
||||||
|
function clearView() {
|
||||||
|
setViewSort(STORED_SORT);
|
||||||
|
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 posOf = (id: string | number) => shownRef.current.findIndex((v) => v.id === id) + 1;
|
||||||
|
const titleOf = (id: string | number) =>
|
||||||
|
shownRef.current.find((v) => v.id === id)?.title ?? "";
|
||||||
|
const total = () => shownRef.current.length;
|
||||||
|
const screenReaderInstructions = { draggable: t("playlists.dndInstructions") };
|
||||||
|
const announcements = {
|
||||||
|
onDragStart: ({ active }: { active: { id: string | number } }) =>
|
||||||
|
t("playlists.dndPickedUp", { title: titleOf(active.id), pos: posOf(active.id), total: total() }),
|
||||||
|
onDragOver: ({ active }: { active: { id: string | number } }) =>
|
||||||
|
t("playlists.dndMovedOver", { title: titleOf(active.id), pos: posOf(active.id), total: total() }),
|
||||||
|
onDragEnd: ({ active }: { active: { id: string | number } }) =>
|
||||||
|
t("playlists.dndDropped", { title: titleOf(active.id), pos: posOf(active.id), total: total() }),
|
||||||
|
onDragCancel: ({ active }: { active: { id: string | number } }) =>
|
||||||
|
t("playlists.dndCancelled", { title: titleOf(active.id), pos: posOf(active.id) }),
|
||||||
|
};
|
||||||
|
|
||||||
|
const plName = (p: { kind: string; name: string }) => playlistName(p, t);
|
||||||
|
|
||||||
|
function refreshAll() {
|
||||||
|
qc.invalidateQueries({ queryKey: ["playlists"] });
|
||||||
|
qc.invalidateQueries({ queryKey: ["playlist", selectedId] });
|
||||||
|
}
|
||||||
|
|
||||||
async function revertToYoutube() {
|
async function revertToYoutube() {
|
||||||
if (selectedId == null || !detail || reverting) return;
|
if (selectedId == null || !detail || reverting) return;
|
||||||
const ok = await confirm({
|
const ok = await confirm({
|
||||||
@@ -345,7 +381,10 @@ export default function Playlists() {
|
|||||||
const r = await api.pushPlaylist(selectedId);
|
const r = await api.pushPlaylist(selectedId);
|
||||||
refreshAll();
|
refreshAll();
|
||||||
if (r.failures.length) {
|
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 {
|
} else {
|
||||||
notify({ level: "success", message: t("playlists.pushDone", { name: plName(detail) }) });
|
notify({ level: "success", message: t("playlists.pushDone", { name: plName(detail) }) });
|
||||||
}
|
}
|
||||||
@@ -356,32 +395,31 @@ export default function Playlists() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function refreshAll() {
|
|
||||||
qc.invalidateQueries({ queryKey: ["playlists"] });
|
|
||||||
qc.invalidateQueries({ queryKey: ["playlist", selectedId] });
|
|
||||||
}
|
|
||||||
|
|
||||||
function onDragEnd(e: DragEndEvent) {
|
function onDragEnd(e: DragEndEvent) {
|
||||||
const { active: a, over } = e;
|
const { active: a, over } = e;
|
||||||
if (!over || a.id === over.id || selectedId == null) return;
|
if (!over || a.id === over.id || selectedId == null) return;
|
||||||
const oldIndex = items.findIndex((v) => v.id === a.id);
|
const oldIndex = items.findIndex((v) => v.id === a.id);
|
||||||
const newIndex = items.findIndex((v) => v.id === over.id);
|
const newIndex = items.findIndex((v) => v.id === over.id);
|
||||||
if (oldIndex < 0 || newIndex < 0) return;
|
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
|
order.set(arrayMove(items, oldIndex, newIndex)); // onApply persists
|
||||||
}
|
}
|
||||||
|
|
||||||
async function removeItem(videoId: string) {
|
async function removeItem(video: Video) {
|
||||||
if (selectedId == null) return;
|
if (selectedId == null) return;
|
||||||
// Membership changes aren't undoable: reset the order to the new set (clearing history)
|
const ok = await confirm({
|
||||||
// and keep lastSetRef in sync so the follow-up refetch doesn't reset again.
|
title: t("playlists.removeItem"),
|
||||||
const next = items.filter((v) => v.id !== videoId);
|
message: t("playlists.confirmRemove", { title: video.title }),
|
||||||
order.reset(next);
|
confirmLabel: t("playlists.removeItem"),
|
||||||
lastSetRef.current = idsKey(next);
|
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 = `${selectedId}:${idsKey(next)}`;
|
||||||
try {
|
try {
|
||||||
await api.removeFromPlaylist(selectedId, videoId);
|
await api.removeFromPlaylist(selectedId, video.id);
|
||||||
refreshAll();
|
refreshAll();
|
||||||
} catch {
|
} catch {
|
||||||
// The optimistic removal above didn't stick — resync from the server to restore the row.
|
// The optimistic removal above didn't stick — resync from the server to restore the row.
|
||||||
@@ -389,37 +427,43 @@ export default function Playlists() {
|
|||||||
refreshAll();
|
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() {
|
async function saveRename() {
|
||||||
if (selectedId == null) return;
|
if (selectedId == null) return;
|
||||||
const name = renameValue.trim();
|
const name = renameValue.trim();
|
||||||
setRenaming(false);
|
setRenaming(false);
|
||||||
if (name && name !== detail?.name) {
|
if (name && name !== detail?.name) {
|
||||||
|
try {
|
||||||
await api.renamePlaylist(selectedId, name);
|
await api.renamePlaylist(selectedId, name);
|
||||||
|
} catch (e) {
|
||||||
|
notifyYouTubeActionError(e, t("playlists.renameFailed"));
|
||||||
|
}
|
||||||
refreshAll();
|
refreshAll();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function deletePlaylist() {
|
async function deletePlaylist() {
|
||||||
if (selectedId == null || !detail) return;
|
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"),
|
title: t("playlists.delete"),
|
||||||
message: t("playlists.confirmDelete", { name: detail.name }),
|
message: offerYoutube
|
||||||
confirmLabel: t("playlists.delete"),
|
? t("playlists.confirmDeleteLinked", { name: plName(detail) })
|
||||||
danger: true,
|
: t("playlists.confirmDelete", { name: plName(detail) }),
|
||||||
|
choices: offerYoutube
|
||||||
|
? [
|
||||||
|
{ id: "here", label: t("playlists.deleteHereOnly"), danger: true },
|
||||||
|
{ id: "both", label: t("playlists.deleteBoth"), danger: true },
|
||||||
|
]
|
||||||
|
: [{ id: "here", label: t("playlists.delete"), danger: true }],
|
||||||
});
|
});
|
||||||
if (!ok) return;
|
if (picked == null) return;
|
||||||
// For a YouTube-linked playlist, offer to delete it on YouTube too (Cancel = here only).
|
const onYoutube = picked === "both";
|
||||||
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,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
const deletedId = selectedId;
|
const deletedId = selectedId;
|
||||||
// Pick the next selection from what's left *now* (don't go via null, or the
|
// 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).
|
// auto-select effect would re-pick the just-deleted id from the stale list).
|
||||||
@@ -428,7 +472,10 @@ export default function Playlists() {
|
|||||||
try {
|
try {
|
||||||
await api.deletePlaylist(deletedId, onYoutube);
|
await api.deletePlaylist(deletedId, onYoutube);
|
||||||
} catch {
|
} 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.removeQueries({ queryKey: ["playlist", deletedId] });
|
||||||
qc.invalidateQueries({ queryKey: ["playlists"] });
|
qc.invalidateQueries({ queryKey: ["playlists"] });
|
||||||
@@ -441,26 +488,70 @@ 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(() => setPlayingId(null), []);
|
||||||
|
|
||||||
|
// Name the current view in the user's terms: "Title ↑", "grouped by channel", or both.
|
||||||
|
const sortLabel = viewSort && !isStoredSort(viewSort)
|
||||||
|
? `${columns.find((c) => c.key === viewSort.key)?.header ?? viewSort.key} ${
|
||||||
|
viewSort.dir === "asc" ? "↑" : "↓"
|
||||||
|
}`
|
||||||
|
: null;
|
||||||
|
const viewName = groupBy
|
||||||
|
? sortLabel
|
||||||
|
? t("playlists.viewGroupedAnd", { sort: sortLabel })
|
||||||
|
: t("playlists.viewGrouped")
|
||||||
|
: (sortLabel ?? "");
|
||||||
|
const viewStatus = filterActive
|
||||||
|
? t("playlists.viewFiltered", { shown: shown.length, total: items.length })
|
||||||
|
: canSaveOrder
|
||||||
|
? t("playlists.viewDiffers", { view: viewName })
|
||||||
|
: t("playlists.viewMatches", { view: viewName });
|
||||||
|
|
||||||
|
// Order matters: a built-in list is never reorderable, whatever the view says.
|
||||||
|
const dragHint = !canEdit
|
||||||
|
? t("playlists.dragBlockedBuiltin")
|
||||||
|
: filterActive
|
||||||
|
? t("playlists.dragBlockedFilter")
|
||||||
|
: t("playlists.dragBlockedSort");
|
||||||
|
const playingIndex = playingId ? shown.findIndex((v) => v.id === playingId) : -1;
|
||||||
|
|
||||||
|
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 (
|
return (
|
||||||
<div className="px-4 pb-4 pt-3">
|
<div className="px-4 pb-4 pt-3">
|
||||||
{selectedId == null || !detail ? (
|
{/* Header (title, actions, view controls) is fixed chrome — it portals into the shell's
|
||||||
<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. */}
|
fixed band, so only the video list scrolls. */}
|
||||||
<PageToolbar>
|
<PageToolbar>
|
||||||
<div className="px-4 pt-3 pb-2">
|
<div className="px-4 pt-3 pb-2">
|
||||||
<div className="flex gap-3 items-start mb-4">
|
<div className="flex gap-3 items-start mb-4">
|
||||||
<div className="w-24 h-[54px] rounded-lg bg-card overflow-hidden shrink-0">
|
<div className="w-24 h-[54px] rounded-lg bg-card overflow-hidden shrink-0">
|
||||||
{items[0]?.thumbnail_url && (
|
{items[0]?.thumbnail_url && (
|
||||||
<img
|
<img src={items[0].thumbnail_url} alt="" className="w-full h-full object-cover" />
|
||||||
src={items[0].thumbnail_url}
|
|
||||||
alt=""
|
|
||||||
className="w-full h-full object-cover"
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
@@ -474,6 +565,7 @@ export default function Playlists() {
|
|||||||
if (e.key === "Enter") saveRename();
|
if (e.key === "Enter") saveRename();
|
||||||
if (e.key === "Escape") setRenaming(false);
|
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"
|
className="text-lg font-semibold bg-card border border-border rounded-md px-2 py-0.5 outline-none focus:border-accent"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
@@ -508,11 +600,12 @@ export default function Playlists() {
|
|||||||
)}
|
)}
|
||||||
<div className="text-xs text-muted mt-0.5">
|
<div className="text-xs text-muted mt-0.5">
|
||||||
{t("playlists.itemCount", { count: items.length })}
|
{t("playlists.itemCount", { count: items.length })}
|
||||||
|
{filterActive && ` · ${t("playlists.filteredCount", { count: shown.length })}`}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-1.5 mt-3 flex-wrap items-center">
|
<div className="flex gap-1.5 mt-3 flex-wrap items-center">
|
||||||
<button
|
<button
|
||||||
onClick={() => items.length && setPlayingIndex(0)}
|
onClick={() => shown.length && setPlayingId(shown[0]!.id)}
|
||||||
disabled={!items.length}
|
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"
|
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")}
|
<Play className="w-3.5 h-3.5 fill-current" /> {t("playlists.playAll")}
|
||||||
@@ -572,59 +665,52 @@ export default function Playlists() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{editable && items.length > 1 && (
|
{(items.length > 1 || viewActive) && (
|
||||||
<div className="flex items-center gap-2 mb-3 flex-wrap max-w-3xl">
|
<div className="flex items-center gap-2 mb-3 flex-wrap max-w-5xl">
|
||||||
<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>
|
|
||||||
<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"
|
|
||||||
>
|
|
||||||
{sortDir === "asc" ? (
|
|
||||||
<ArrowUp className="w-4 h-4" />
|
|
||||||
) : (
|
|
||||||
<ArrowDown className="w-4 h-4" />
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
<label className="inline-flex items-center gap-1.5 text-xs text-muted cursor-pointer select-none">
|
<label className="inline-flex items-center gap-1.5 text-xs text-muted cursor-pointer select-none">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={groupBy}
|
checked={groupBy}
|
||||||
onChange={(e) => {
|
onChange={(e) => setGroupBy(e.target.checked)}
|
||||||
setGroupBy(e.target.checked);
|
|
||||||
applySort(sortKey, sortDir, e.target.checked);
|
|
||||||
}}
|
|
||||||
className="accent-accent"
|
className="accent-accent"
|
||||||
/>
|
/>
|
||||||
{t("playlists.groupByChannel")}
|
{t("playlists.groupByChannel")}
|
||||||
</label>
|
</label>
|
||||||
|
{viewActive && (
|
||||||
|
<button
|
||||||
|
onClick={clearView}
|
||||||
|
className="text-xs text-muted hover:text-accent underline underline-offset-2 transition"
|
||||||
|
>
|
||||||
|
{t("playlists.clearView")}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{canSaveOrder && (
|
||||||
|
<button
|
||||||
|
onClick={saveOrder}
|
||||||
|
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>
|
||||||
|
)}
|
||||||
|
{/* ONE sentence that names the state. Three loose texts used to pile up here (reset
|
||||||
|
link + save hint + "reordering is off"), and none of them answered the question a
|
||||||
|
user actually has — "did I just change my playlist?". Worse, sorting a list whose
|
||||||
|
stored order ALREADY matches the sort produced a warning, no Save button and no
|
||||||
|
visible change, which reads as a malfunction. Now the line says which of the three
|
||||||
|
situations you are in; the disabled grip still explains itself on hover/focus. */}
|
||||||
|
{viewActive && (
|
||||||
|
<span className="text-[11px] text-muted/80">{viewStatus}</span>
|
||||||
|
)}
|
||||||
|
{canEdit && (
|
||||||
<div className="ml-auto">
|
<div className="ml-auto">
|
||||||
<UndoToolbar
|
<UndoToolbar
|
||||||
canUndo={order.canUndo}
|
canUndo={order.canUndo}
|
||||||
canRedo={order.canRedo}
|
canRedo={order.canRedo}
|
||||||
onUndo={undo}
|
onUndo={order.undo}
|
||||||
onRedo={redo}
|
onRedo={order.redo}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -642,36 +728,57 @@ export default function Playlists() {
|
|||||||
sensors={sensors}
|
sensors={sensors}
|
||||||
collisionDetection={closestCenter}
|
collisionDetection={closestCenter}
|
||||||
onDragEnd={onDragEnd}
|
onDragEnd={onDragEnd}
|
||||||
|
// dnd-kit's defaults are hardcoded English. They were harmless while only a pointer could
|
||||||
|
// drag; now that the keyboard can, they ARE the instructions for the feature.
|
||||||
|
accessibility={{ screenReaderInstructions, announcements }}
|
||||||
>
|
>
|
||||||
|
{/* Not clipped, so the header's filter popovers can overflow the table box. */}
|
||||||
|
<div className="max-w-5xl">
|
||||||
|
{/* table-fixed, not auto: every column but the title has a declared width, so the
|
||||||
|
title absorbs the rest and its `truncate` works. Auto layout sizes columns from
|
||||||
|
min-content, and a nowrap title cell's min-content is the entire title. */}
|
||||||
|
<table className="w-full table-fixed border-collapse text-sm">
|
||||||
|
<ColumnHead
|
||||||
|
columns={columns}
|
||||||
|
sort={viewSort}
|
||||||
|
onToggleSort={toggleSort}
|
||||||
|
filters={filters}
|
||||||
|
onFilter={applyFilter}
|
||||||
|
/>
|
||||||
<SortableContext
|
<SortableContext
|
||||||
items={items.map((v) => v.id)}
|
items={shown.map((v) => v.id)}
|
||||||
strategy={verticalListSortingStrategy}
|
strategy={verticalListSortingStrategy}
|
||||||
>
|
>
|
||||||
<div className="space-y-1.5 max-w-3xl">
|
<tbody>
|
||||||
{items.map((v, i) => (
|
{shown.map((v) => (
|
||||||
<Row
|
<Row
|
||||||
key={v.id}
|
key={v.id}
|
||||||
video={v}
|
video={v}
|
||||||
index={i}
|
columns={columns}
|
||||||
onPlay={() => setPlayingIndex(i)}
|
dragDisabled={!canEdit || viewActive}
|
||||||
onRemove={() => removeItem(v.id)}
|
dragHint={dragHint}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</tbody>
|
||||||
</SortableContext>
|
</SortableContext>
|
||||||
|
</table>
|
||||||
|
{shown.length === 0 && (
|
||||||
|
<div className="text-muted py-8 text-center text-sm">
|
||||||
|
{t("playlists.noneMatchFilter")}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</DndContext>
|
</DndContext>
|
||||||
)}
|
)}
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{playingIndex != null && items[playingIndex] && (
|
{playingIndex >= 0 && (
|
||||||
<Suspense fallback={null}>
|
<Suspense fallback={null}>
|
||||||
<PlayerModal
|
<PlayerModal
|
||||||
video={items[playingIndex]}
|
video={shown[playingIndex]!}
|
||||||
queue={items}
|
queue={shown}
|
||||||
startIndex={playingIndex}
|
startIndex={playingIndex}
|
||||||
startAt={null}
|
startAt={null}
|
||||||
onClose={() => setPlayingIndex(null)}
|
onClose={closePlayer}
|
||||||
onState={playerState}
|
onState={playerState}
|
||||||
/>
|
/>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import SidePanel from "./SidePanel";
|
|||||||
import PanelGroups, { type PanelItem } from "./PanelGroups";
|
import PanelGroups, { type PanelItem } from "./PanelGroups";
|
||||||
import { usePlaylists, usePlaylistsActions } from "./PlaylistsProvider";
|
import { usePlaylists, usePlaylistsActions } from "./PlaylistsProvider";
|
||||||
import { useMe } from "../lib/useMe";
|
import { useMe } from "../lib/useMe";
|
||||||
|
import { playlistName } from "../lib/playlistName";
|
||||||
|
|
||||||
type PlSort = { key: "custom" | "name" | "count" | "duration"; dir: "asc" | "desc"; dirtyFirst: boolean };
|
type PlSort = { key: "custom" | "name" | "count" | "duration"; dir: "asc" | "desc"; dirtyFirst: boolean };
|
||||||
const PL_SORT_DEFAULT: PlSort = { key: "custom", dir: "asc", dirtyFirst: false };
|
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
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [playlists, selectedId, listQuery.data]);
|
}, [playlists, selectedId, listQuery.data]);
|
||||||
|
|
||||||
const plName = (p: { kind: string; name: string }) =>
|
const plName = (p: { kind: string; name: string }) => playlistName(p, t);
|
||||||
p.kind === "watch_later" ? t("playlists.watchLater") : p.name;
|
|
||||||
|
|
||||||
const sortedPlaylists = useMemo(() => {
|
const sortedPlaylists = useMemo(() => {
|
||||||
const sign = plSort.dir === "asc" ? 1 : -1;
|
const sign = plSort.dir === "asc" ? 1 : -1;
|
||||||
|
|||||||
@@ -0,0 +1,140 @@
|
|||||||
|
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: "4.5rem",
|
||||||
|
nowrap: true,
|
||||||
|
hideInCard: true,
|
||||||
|
// Sorting by "#" ascending is the stored order itself — that is what makes it a truthful
|
||||||
|
// default for the header arrow, and a one-click way back from any other sort.
|
||||||
|
sortable: true,
|
||||||
|
sortValue: indexOf,
|
||||||
|
render: (v) => <span className="text-xs text-muted tabular-nums">{indexOf(v) + 1}</span>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "title",
|
||||||
|
header: t("playlists.colTitle"),
|
||||||
|
// The flexible column: under `table-fixed` (see Playlists.tsx) width:100% hands it every
|
||||||
|
// pixel the fixed columns don't claim. Auto layout could not do this — a `truncate` cell is
|
||||||
|
// `white-space: nowrap`, so its MIN-content width is the whole title, and the column grew
|
||||||
|
// until Length and the remove button hung off the page scroller.
|
||||||
|
width: "100%",
|
||||||
|
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">{v.title}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "channel",
|
||||||
|
header: t("playlists.colChannel"),
|
||||||
|
width: "12rem",
|
||||||
|
nowrap: true,
|
||||||
|
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">{v.channel_title}</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "duration",
|
||||||
|
header: t("playlists.colDuration"),
|
||||||
|
align: "right",
|
||||||
|
width: "5rem",
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,295 @@
|
|||||||
|
import { useEffect, useRef, useState, type ReactNode, type RefObject } 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 { type SortState } from "../lib/columnSort";
|
||||||
|
|
||||||
|
// The column-header machinery — the column contract, the sortable header row with its filter
|
||||||
|
// funnel + popover, and the filter predicate — lifted OUT of DataTable so a non-DataTable body can
|
||||||
|
// render under the same headers.
|
||||||
|
//
|
||||||
|
// Why it lives here and not in DataTable: DataTable owns its filter/sort/page state internally,
|
||||||
|
// while the playlist manager's body is a drag-reorderable list whose order is the persisted
|
||||||
|
// server-side one. Two owners of the same ordering fight; composing these pieces instead lets the
|
||||||
|
// playlist keep its stored order and still get real column headers. Same move as E4 S3's
|
||||||
|
// ChannelLayoutGrid, which builds a card view straight from the `Column<T>` render closures.
|
||||||
|
|
||||||
|
type ColumnFilter<T> =
|
||||||
|
| { 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<T> {
|
||||||
|
key: string;
|
||||||
|
header: string;
|
||||||
|
render: (row: T) => ReactNode;
|
||||||
|
align?: "left" | "right" | "center";
|
||||||
|
width?: string;
|
||||||
|
sortable?: boolean;
|
||||||
|
sortValue?: (row: T) => string | number;
|
||||||
|
filter?: ColumnFilter<T>;
|
||||||
|
// 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type FilterMap = Record<string, string | string[]>;
|
||||||
|
|
||||||
|
export function isActive(value: string | string[] | undefined): boolean {
|
||||||
|
return Array.isArray(value) ? value.length > 0 : !!value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Apply every column's own filter predicate. A column with no filter, or an empty value, passes. */
|
||||||
|
export function filterRows<T>(rows: T[], columns: Column<T>[], filters: FilterMap): T[] {
|
||||||
|
return 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[]);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const alignClass = (a?: "left" | "right" | "center") =>
|
||||||
|
a === "right" ? "text-right" : a === "center" ? "text-center" : "text-left";
|
||||||
|
|
||||||
|
// The multi-select filter's option list, capped and scrollable. Its own component because it owns a
|
||||||
|
// hook — see the FilterPopover note below for why nesting either of these would remount them.
|
||||||
|
function FilterOptionList({ children }: { children: ReactNode }) {
|
||||||
|
const fade = useScrollFade();
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={fade.ref}
|
||||||
|
style={fade.style}
|
||||||
|
className="flex flex-col gap-0.5 max-h-60 overflow-y-auto no-scrollbar"
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// MODULE LEVEL, deliberately. This used to be declared inside DataTable's body, which made it a NEW
|
||||||
|
// component type on every render: React unmounted and remounted the popover on each keystroke, so
|
||||||
|
// the caret jumped to the end and IME composition (accents, any CJK input) broke mid-word. The
|
||||||
|
// `autoFocus` masked it well enough that it read as "the filter is just twitchy".
|
||||||
|
function FilterPopover<T>({
|
||||||
|
col,
|
||||||
|
value,
|
||||||
|
onApply,
|
||||||
|
panelRef,
|
||||||
|
}: {
|
||||||
|
col: Column<T>;
|
||||||
|
value: string | string[] | undefined;
|
||||||
|
onApply: (key: string, value: string | string[]) => void;
|
||||||
|
panelRef: RefObject<HTMLDivElement>;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const f = col.filter!;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={panelRef}
|
||||||
|
className="glass-menu absolute left-0 top-full mt-1 z-chrome w-52 rounded-xl p-2 animate-[popIn_0.16s_ease]"
|
||||||
|
>
|
||||||
|
{f.kind === "text" && (
|
||||||
|
<input
|
||||||
|
autoFocus
|
||||||
|
value={(value as string) ?? ""}
|
||||||
|
onChange={(e) => onApply(col.key, e.target.value)}
|
||||||
|
placeholder={col.header}
|
||||||
|
className="w-full bg-card border border-border rounded-md px-2 py-1 text-xs outline-none focus:border-accent"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{f.kind === "select" && (
|
||||||
|
<div className="flex flex-col gap-0.5">
|
||||||
|
<button
|
||||||
|
onClick={() => onApply(col.key, "")}
|
||||||
|
className={`text-left text-xs px-2 py-1.5 rounded-md transition ${
|
||||||
|
!isActive(value) ? "bg-accent text-accent-fg" : "text-muted hover:bg-card"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{t("datatable.all")}
|
||||||
|
</button>
|
||||||
|
{f.options.map((o) => (
|
||||||
|
<button
|
||||||
|
key={o.value}
|
||||||
|
onClick={() => onApply(col.key, o.value)}
|
||||||
|
className={`text-left text-xs px-2 py-1.5 rounded-md transition ${
|
||||||
|
value === o.value ? "bg-accent text-accent-fg" : "text-muted hover:bg-card"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{o.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{f.kind === "multi" && (
|
||||||
|
<FilterOptionList>
|
||||||
|
{f.options.length === 0 && (
|
||||||
|
<span className="text-xs text-muted px-2 py-1">{t("datatable.noOptions")}</span>
|
||||||
|
)}
|
||||||
|
{f.options.map((o) => {
|
||||||
|
const arr = (value as string[]) ?? [];
|
||||||
|
const on = arr.includes(o.value);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={o.value}
|
||||||
|
onClick={() =>
|
||||||
|
onApply(col.key, on ? arr.filter((x) => x !== o.value) : [...arr, o.value])
|
||||||
|
}
|
||||||
|
className={`flex items-center gap-2 text-left text-xs px-2 py-1.5 rounded-md transition ${
|
||||||
|
on ? "text-fg" : "text-muted hover:bg-card"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`w-3.5 h-3.5 rounded border flex items-center justify-center ${
|
||||||
|
on ? "bg-accent border-accent text-accent-fg" : "border-border"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{on && <X className="w-2.5 h-2.5" />}
|
||||||
|
</span>
|
||||||
|
{o.label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</FilterOptionList>
|
||||||
|
)}
|
||||||
|
{isActive(value) && (
|
||||||
|
<button
|
||||||
|
onClick={() => onApply(col.key, f.kind === "multi" ? [] : "")}
|
||||||
|
className="mt-1.5 w-full text-xs text-muted hover:text-accent transition"
|
||||||
|
>
|
||||||
|
{t("datatable.clear")}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The sticky `<thead>`: one sortable/filterable cell per column.
|
||||||
|
*
|
||||||
|
* Sticky-header notes (kept from DataTable, where this look was designed): the header stays put at
|
||||||
|
* the top of the page scroller while rows scroll under it. `bg-bg` on each cell stops rows showing
|
||||||
|
* through; the bottom border rides a `box-shadow` because a sticky `<tr>`'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 while the header itself stays crisp — which is why
|
||||||
|
* pages using this pass `PageShell fadeTop={false}`. */
|
||||||
|
export function ColumnHead<T>({
|
||||||
|
columns,
|
||||||
|
sort,
|
||||||
|
onToggleSort,
|
||||||
|
filters,
|
||||||
|
onFilter,
|
||||||
|
}: {
|
||||||
|
columns: Column<T>[];
|
||||||
|
sort: SortState;
|
||||||
|
onToggleSort: (key: string) => void;
|
||||||
|
filters: FilterMap;
|
||||||
|
onFilter: (key: string, value: string | string[]) => void;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [openFilter, setOpenFilter] = useState<string | null>(null);
|
||||||
|
const panelRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
// The funnel that opened the panel. `useDismiss` must know about it too, or the trigger's own
|
||||||
|
// mousedown closes the panel and the click that follows immediately reopens it — clicking the
|
||||||
|
// funnel a second time then looks like it does nothing. Also the thing to hand focus back to.
|
||||||
|
const triggerRef = useRef<HTMLButtonElement | null>(null);
|
||||||
|
const restoreFocus = useRef(false);
|
||||||
|
useDismiss(!!openFilter, () => setOpenFilter(null), [panelRef, triggerRef]);
|
||||||
|
|
||||||
|
// Escape (or an outside click) used to unmount the focused input and strand focus on <body>,
|
||||||
|
// so the next Tab restarted from the top of the page.
|
||||||
|
useEffect(() => {
|
||||||
|
if (openFilter) {
|
||||||
|
restoreFocus.current = true;
|
||||||
|
} else if (restoreFocus.current) {
|
||||||
|
restoreFocus.current = false;
|
||||||
|
triggerRef.current?.focus();
|
||||||
|
}
|
||||||
|
}, [openFilter]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<thead className="sticky top-0 z-base after:pointer-events-none after:absolute after:inset-x-0 after:top-full after:h-4 after:bg-[linear-gradient(to_bottom,var(--bg),transparent)] after:content-['']">
|
||||||
|
<tr className="text-muted [&>th]:shadow-[inset_0_-1px_0_var(--border)]">
|
||||||
|
{columns.map((col) => {
|
||||||
|
const isSorted = sort?.key === col.key;
|
||||||
|
const filterOn = isActive(filters[col.key]);
|
||||||
|
return (
|
||||||
|
<th
|
||||||
|
key={col.key}
|
||||||
|
style={col.width ? { width: col.width } : undefined}
|
||||||
|
className={`relative font-medium px-2 py-2 whitespace-nowrap bg-bg ${alignClass(col.align)}`}
|
||||||
|
aria-sort={
|
||||||
|
col.sortable
|
||||||
|
? isSorted
|
||||||
|
? sort!.dir === "asc"
|
||||||
|
? "ascending"
|
||||||
|
: "descending"
|
||||||
|
: "none"
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<span className="inline-flex items-center gap-1">
|
||||||
|
{/* Only a sortable header is a control. A non-sortable one used to render a
|
||||||
|
focusable no-op button, which put unlabeled dead stops in the tab order —
|
||||||
|
two of them per playlist table, whose grip and actions headers are empty. */}
|
||||||
|
{col.sortable ? (
|
||||||
|
<button
|
||||||
|
onClick={() => onToggleSort(col.key)}
|
||||||
|
className="inline-flex items-center gap-1 hover:text-fg cursor-pointer"
|
||||||
|
>
|
||||||
|
{col.header}
|
||||||
|
{isSorted && sort && (
|
||||||
|
sort.dir === "asc" ? (
|
||||||
|
<ArrowUp className="w-3 h-3" />
|
||||||
|
) : (
|
||||||
|
<ArrowDown className="w-3 h-3" />
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<span>{col.header}</span>
|
||||||
|
)}
|
||||||
|
{col.filter && (
|
||||||
|
<button
|
||||||
|
// Captured at open time rather than bound with `ref`, so it survives the close
|
||||||
|
// (a `ref` scoped to the open column nulls out before focus can go back).
|
||||||
|
onClick={(e) => {
|
||||||
|
triggerRef.current = e.currentTarget;
|
||||||
|
setOpenFilter((o) => (o === col.key ? null : col.key));
|
||||||
|
}}
|
||||||
|
aria-label={t("datatable.filter")}
|
||||||
|
aria-expanded={openFilter === col.key}
|
||||||
|
aria-haspopup="dialog"
|
||||||
|
// Generous padding: the icon alone was a 14x14 hit target, the smallest in the app.
|
||||||
|
className={`-m-1.5 p-1.5 transition ${
|
||||||
|
filterOn ? "text-accent" : "text-muted/60 hover:text-fg"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<ListFilter className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
{openFilter === col.key && col.filter && (
|
||||||
|
<FilterPopover
|
||||||
|
col={col}
|
||||||
|
value={filters[col.key]}
|
||||||
|
onApply={onFilter}
|
||||||
|
panelRef={panelRef}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</th>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -17,5 +17,6 @@
|
|||||||
"accessRequestsReview": "Review",
|
"accessRequestsReview": "Review",
|
||||||
"demoTitle": "Demo account",
|
"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.",
|
"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,9 @@
|
|||||||
"pushDone": "“{{name}}” synced to YouTube ✓",
|
"pushDone": "“{{name}}” synced to YouTube ✓",
|
||||||
"pushPartial": "Synced, but {{count}} item(s) were skipped.",
|
"pushPartial": "Synced, but {{count}} item(s) were skipped.",
|
||||||
"pushFailed": "Couldn't sync to YouTube.",
|
"pushFailed": "Couldn't sync to YouTube.",
|
||||||
"deleteOnYoutubeTitle": "Delete on YouTube too?",
|
"deleteHereOnly": "Delete here only",
|
||||||
"deleteOnYoutubeMsg": "Also delete “{{name}}” from your YouTube account? Choose “Here only” to keep it on YouTube.",
|
|
||||||
"deleteOnYoutubeConfirm": "Delete on YouTube",
|
|
||||||
"deleteHereOnly": "Here only",
|
|
||||||
"deleteYtFailed": "Couldn't delete on YouTube; removed here only.",
|
"deleteYtFailed": "Couldn't delete on YouTube; removed here only.",
|
||||||
"sortLabel": "Sort",
|
"sortLabel": "Sort",
|
||||||
"sortManual": "Custom order",
|
|
||||||
"sortTitle": "Title",
|
|
||||||
"sortDuration": "Duration",
|
|
||||||
"sortChannel": "Channel",
|
|
||||||
"dirAsc": "Ascending",
|
"dirAsc": "Ascending",
|
||||||
"dirDesc": "Descending",
|
"dirDesc": "Descending",
|
||||||
"groupByChannel": "Group by channel",
|
"groupByChannel": "Group by channel",
|
||||||
@@ -59,5 +52,36 @@
|
|||||||
"railSortName": "Name",
|
"railSortName": "Name",
|
||||||
"railSortCount": "Item count",
|
"railSortCount": "Item count",
|
||||||
"railSortDuration": "Total length",
|
"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",
|
||||||
|
"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.",
|
||||||
|
"dragBlockedBuiltin": "This list's order is fixed — it can't be reordered by hand.",
|
||||||
|
"deleteBoth": "Delete here and on YouTube",
|
||||||
|
"dndInstructions": "Press space to pick up a video, the arrow keys to move it, space again to drop it, or escape to cancel.",
|
||||||
|
"dndPickedUp": "Picked up “{{title}}”, position {{pos}} of {{total}}.",
|
||||||
|
"dndMovedOver": "“{{title}}” moved to position {{pos}} of {{total}}.",
|
||||||
|
"dndDropped": "“{{title}}” dropped at position {{pos}} of {{total}}.",
|
||||||
|
"dndCancelled": "Move cancelled; “{{title}}” is back at position {{pos}}.",
|
||||||
|
"viewGrouped": "grouped by channel",
|
||||||
|
"viewGroupedAnd": "grouped by channel, {{sort}}",
|
||||||
|
"viewDiffers": "Temporary view ({{view}}) — the playlist's saved order is unchanged. Save it to keep this order.",
|
||||||
|
"viewMatches": "This view ({{view}}) already matches the playlist's saved order, so there is nothing to save.",
|
||||||
|
"viewFiltered": "Filtered view — {{shown}} of {{total}} shown. The order can't be saved while a filter is on."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,5 +17,6 @@
|
|||||||
"accessRequestsReview": "Áttekintés",
|
"accessRequestsReview": "Áttekintés",
|
||||||
"demoTitle": "Demo fiók",
|
"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.",
|
"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,9 @@
|
|||||||
"pushDone": "„{{name}}” szinkronizálva a YouTube-ra ✓",
|
"pushDone": "„{{name}}” szinkronizálva a YouTube-ra ✓",
|
||||||
"pushPartial": "Szinkronizálva, de {{count}} elem kimaradt.",
|
"pushPartial": "Szinkronizálva, de {{count}} elem kimaradt.",
|
||||||
"pushFailed": "Nem sikerült a YouTube-ra szinkronizálás.",
|
"pushFailed": "Nem sikerült a YouTube-ra szinkronizálás.",
|
||||||
"deleteOnYoutubeTitle": "Törlöd a YouTube-on is?",
|
"deleteHereOnly": "Törlés csak itt",
|
||||||
"deleteOnYoutubeMsg": "Törlöd a(z) „{{name}}” listát a YouTube-fiókodból is? A „Csak itt” megtartja a YouTube-on.",
|
|
||||||
"deleteOnYoutubeConfirm": "Törlés a YouTube-on",
|
|
||||||
"deleteHereOnly": "Csak itt",
|
|
||||||
"deleteYtFailed": "Nem sikerült törölni a YouTube-on; csak itt lett eltávolítva.",
|
"deleteYtFailed": "Nem sikerült törölni a YouTube-on; csak itt lett eltávolítva.",
|
||||||
"sortLabel": "Rendezés",
|
"sortLabel": "Rendezés",
|
||||||
"sortManual": "Egyéni sorrend",
|
|
||||||
"sortTitle": "Cím",
|
|
||||||
"sortDuration": "Hossz",
|
|
||||||
"sortChannel": "Csatorna",
|
|
||||||
"dirAsc": "Növekvő",
|
"dirAsc": "Növekvő",
|
||||||
"dirDesc": "Csökkenő",
|
"dirDesc": "Csökkenő",
|
||||||
"groupByChannel": "Csoportosítás csatorna szerint",
|
"groupByChannel": "Csoportosítás csatorna szerint",
|
||||||
@@ -59,5 +52,36 @@
|
|||||||
"railSortName": "Név",
|
"railSortName": "Név",
|
||||||
"railSortCount": "Elemszám",
|
"railSortCount": "Elemszám",
|
||||||
"railSortDuration": "Összhossz",
|
"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",
|
||||||
|
"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.",
|
||||||
|
"dragBlockedBuiltin": "Ennek a listának kötött a sorrendje — kézzel nem rendezhető át.",
|
||||||
|
"deleteBoth": "Törlés itt és a YouTube-on",
|
||||||
|
"dndInstructions": "Szóközzel felveszed a videót, a nyilakkal mozgatod, újabb szóközzel leteszed, Escape-pel megszakítod.",
|
||||||
|
"dndPickedUp": "„{{title}}” felvéve, {{pos}}. a(z) {{total}} közül.",
|
||||||
|
"dndMovedOver": "„{{title}}” a(z) {{pos}}. helyre került a(z) {{total}} közül.",
|
||||||
|
"dndDropped": "„{{title}}” letéve a(z) {{pos}}. helyre a(z) {{total}} közül.",
|
||||||
|
"dndCancelled": "Mozgatás megszakítva; „{{title}}” visszakerült a(z) {{pos}}. helyre.",
|
||||||
|
"viewGrouped": "csatorna szerint csoportosítva",
|
||||||
|
"viewGroupedAnd": "csatorna szerint csoportosítva, {{sort}}",
|
||||||
|
"viewDiffers": "Ideiglenes nézet ({{view}}) — a lista mentett sorrendje változatlan. Mentsd, ha ez maradjon.",
|
||||||
|
"viewMatches": "Ez a nézet ({{view}}) megegyezik a lista mentett sorrendjével, így nincs mit menteni.",
|
||||||
|
"viewFiltered": "Szűrt nézet — {{total}} elemből {{shown}} látszik. Szűrés közben a sorrend nem menthető."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
value: T;
|
||||||
set: (next: T) => void;
|
set: (next: T) => void;
|
||||||
reset: (value: T) => void;
|
reset: (value: T) => void;
|
||||||
|
rebase: (map: (value: T) => T) => void;
|
||||||
undo: () => void;
|
undo: () => void;
|
||||||
redo: () => void;
|
redo: () => void;
|
||||||
canUndo: boolean;
|
canUndo: boolean;
|
||||||
@@ -77,11 +78,27 @@ export function useUndoable<T>(
|
|||||||
force();
|
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;
|
const h = ref.current;
|
||||||
return {
|
return {
|
||||||
value: h.present,
|
value: h.present,
|
||||||
set,
|
set,
|
||||||
reset,
|
reset,
|
||||||
|
rebase,
|
||||||
undo,
|
undo,
|
||||||
redo,
|
redo,
|
||||||
canUndo: h.past.length > 0,
|
canUndo: h.past.length > 0,
|
||||||
|
|||||||
Reference in New Issue
Block a user