Merge: promote dev to prod
This commit is contained in:
@@ -6,6 +6,7 @@ import { Check, ListPlus, Plus, Youtube } from "lucide-react";
|
||||
import { api, type Playlist } from "../lib/api";
|
||||
import { useDismiss } from "../lib/useDismiss";
|
||||
import { useScrollFade } from "../lib/useScrollFade";
|
||||
import { playlistName } from "../lib/playlistName";
|
||||
|
||||
// A small popover (portaled to body, so the feed grid can't clip it) for toggling a
|
||||
// video's membership in the user's playlists, with inline "new playlist" creation.
|
||||
@@ -159,7 +160,7 @@ export default function AddToPlaylist({
|
||||
{pl.has_video && <Check className="w-3 h-3" />}
|
||||
</span>
|
||||
<span className="truncate flex-1 text-left">
|
||||
{pl.kind === "watch_later" ? t("playlists.watchLater") : pl.name}
|
||||
{playlistName(pl, t)}
|
||||
</span>
|
||||
{(pl.source === "youtube" || pl.yt_playlist_id) && (
|
||||
<Youtube className="w-3 h-3 shrink-0 text-muted" />
|
||||
|
||||
@@ -5,6 +5,11 @@ import Modal from "./Modal";
|
||||
// App-styled, promise-based replacement for window.confirm. Wrap the tree in
|
||||
// <ConfirmProvider> once, then anywhere: const confirm = useConfirm();
|
||||
// if (await confirm({ message: "…", danger: true })) { … }
|
||||
//
|
||||
// For a decision with more than two outcomes use `useChoice()` instead — never chain two
|
||||
// confirms, because the second dialog's Cancel/Escape/backdrop is then indistinguishable from
|
||||
// a deliberate answer (the playlist delete used to read a dismissal as "delete here only" and
|
||||
// went ahead and deleted).
|
||||
export interface ConfirmOptions {
|
||||
title?: string;
|
||||
message: ReactNode;
|
||||
@@ -13,62 +18,122 @@ export interface ConfirmOptions {
|
||||
danger?: boolean;
|
||||
}
|
||||
|
||||
export interface Choice {
|
||||
id: string;
|
||||
label: string;
|
||||
danger?: boolean;
|
||||
}
|
||||
|
||||
export interface ChoiceOptions {
|
||||
title?: string;
|
||||
message: ReactNode;
|
||||
cancelLabel?: string;
|
||||
/** Rendered left→right after Cancel. Resolves to the clicked choice's `id`. */
|
||||
choices: Choice[];
|
||||
}
|
||||
|
||||
type ChoiceFn = (opts: ChoiceOptions) => Promise<string | null>;
|
||||
type ConfirmFn = (opts: ConfirmOptions) => Promise<boolean>;
|
||||
|
||||
const ConfirmContext = createContext<ConfirmFn>(() => Promise.resolve(false));
|
||||
const ChoiceContext = createContext<ChoiceFn>(() => Promise.resolve(null));
|
||||
|
||||
/** Two outcomes: resolves true on confirm, false on cancel/Escape/backdrop. */
|
||||
export function useConfirm(): ConfirmFn {
|
||||
return useContext(ConfirmContext);
|
||||
const ask = useContext(ChoiceContext);
|
||||
return useCallback(
|
||||
async (opts: ConfirmOptions) => {
|
||||
const picked = await ask({
|
||||
title: opts.title,
|
||||
message: opts.message,
|
||||
cancelLabel: opts.cancelLabel,
|
||||
choices: [
|
||||
{ id: "confirm", label: opts.confirmLabel ?? "", danger: opts.danger },
|
||||
],
|
||||
});
|
||||
return picked === "confirm";
|
||||
},
|
||||
[ask]
|
||||
);
|
||||
}
|
||||
|
||||
/** Three or more outcomes. Resolves to the chosen id, or **null** when the user cancels,
|
||||
* presses Escape or clicks the backdrop — dismissal is never one of the actions. */
|
||||
export function useChoice(): ChoiceFn {
|
||||
return useContext(ChoiceContext);
|
||||
}
|
||||
|
||||
export function ConfirmProvider({ children }: { children: ReactNode }) {
|
||||
const { t } = useTranslation();
|
||||
const [state, setState] = useState<{
|
||||
opts: ConfirmOptions;
|
||||
resolve: (ok: boolean) => void;
|
||||
opts: ChoiceOptions;
|
||||
resolve: (id: string | null) => void;
|
||||
} | null>(null);
|
||||
|
||||
const confirm = useCallback<ConfirmFn>(
|
||||
(opts) => new Promise<boolean>((resolve) => setState({ opts, resolve })),
|
||||
const ask = useCallback<ChoiceFn>(
|
||||
(opts) => new Promise<string | null>((resolve) => setState({ opts, resolve })),
|
||||
[]
|
||||
);
|
||||
|
||||
function close(ok: boolean) {
|
||||
state?.resolve(ok);
|
||||
function close(id: string | null) {
|
||||
state?.resolve(id);
|
||||
setState(null);
|
||||
}
|
||||
|
||||
const choices = state?.opts.choices ?? [];
|
||||
// Focus Cancel when any offered action is destructive: the confirm button used to take
|
||||
// autoFocus unconditionally, so a stray Enter on an open dialog executed "Delete user" or
|
||||
// "Clear audit log" without the user ever looking at it.
|
||||
const anyDanger = choices.some((c) => c.danger);
|
||||
const multi = choices.length > 1;
|
||||
|
||||
return (
|
||||
<ConfirmContext.Provider value={confirm}>
|
||||
<ChoiceContext.Provider value={ask}>
|
||||
{children}
|
||||
{state && (
|
||||
<Modal
|
||||
title={state.opts.title ?? t("common.confirmTitle")}
|
||||
onClose={() => close(false)}
|
||||
maxWidth="max-w-sm"
|
||||
onClose={() => close(null)}
|
||||
maxWidth={choices.length > 1 ? "max-w-md" : "max-w-sm"}
|
||||
>
|
||||
<p className="text-sm text-muted leading-relaxed">{state.opts.message}</p>
|
||||
<div className="flex justify-end gap-2 mt-5">
|
||||
{/* 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
|
||||
onClick={() => close(false)}
|
||||
className="px-4 py-2 rounded-lg text-sm border border-border text-muted hover:text-fg hover:bg-surface transition"
|
||||
autoFocus={anyDanger}
|
||||
onClick={() => close(null)}
|
||||
className={`px-4 py-2 rounded-lg text-sm border border-border text-muted hover:text-fg hover:bg-surface transition ${
|
||||
multi ? "w-full order-last" : ""
|
||||
}`}
|
||||
>
|
||||
{state.opts.cancelLabel ?? t("common.cancel")}
|
||||
</button>
|
||||
<button
|
||||
autoFocus
|
||||
onClick={() => close(true)}
|
||||
className={`px-4 py-2 rounded-lg text-sm font-semibold transition ${
|
||||
state.opts.danger
|
||||
? "bg-red-500 text-white hover:bg-red-600"
|
||||
: "bg-accent text-accent-fg hover:opacity-90"
|
||||
}`}
|
||||
>
|
||||
{state.opts.confirmLabel ?? t("common.confirm")}
|
||||
</button>
|
||||
{choices.map((c, i) => (
|
||||
<button
|
||||
key={c.id}
|
||||
autoFocus={!anyDanger && i === choices.length - 1}
|
||||
onClick={() => close(c.id)}
|
||||
className={`px-4 py-2 rounded-lg text-sm font-semibold transition ${
|
||||
multi ? "w-full" : ""
|
||||
} ${
|
||||
c.danger
|
||||
? "bg-red-500 text-white hover:bg-red-600"
|
||||
: "bg-accent text-accent-fg hover:opacity-90"
|
||||
}`}
|
||||
>
|
||||
{c.label || t("common.confirm")}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</Modal>
|
||||
)}
|
||||
</ConfirmContext.Provider>
|
||||
</ChoiceContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,58 +1,29 @@
|
||||
import { useEffect, useRef, useState, type ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ArrowDown, ArrowUp, ListFilter, X } from "lucide-react";
|
||||
import { useDismiss } from "../lib/useDismiss";
|
||||
import { useScrollFade } from "../lib/useScrollFade";
|
||||
import { sortRows, cycleSort, type SortState } from "../lib/columnSort";
|
||||
import Pager, { hasPagerContent } from "./Pager";
|
||||
import { PageToolbar } from "./PageShell";
|
||||
import {
|
||||
ColumnHead,
|
||||
alignClass,
|
||||
filterRows,
|
||||
type Column,
|
||||
type FilterMap,
|
||||
} from "./tableHeader";
|
||||
|
||||
// A reusable, client-side data table: per-column sort + filter (in the header) + pagination,
|
||||
// with its sort/filter/page state optionally persisted to localStorage so a reload (F5) keeps
|
||||
// the view. Columns are declared by the caller; the table is generic over the row type, so
|
||||
// other modules (e.g. the playlist manager) can reuse it. Below `md` it falls back to a card
|
||||
// list built from the same column definitions.
|
||||
// other modules can reuse it. Below `md` it falls back to a card list built from the same column
|
||||
// definitions.
|
||||
//
|
||||
// The header row itself (sort cell + filter funnel/popover + the filter predicate) lives in
|
||||
// `tableHeader.tsx` — the playlist manager renders its own drag-reorderable body under the same
|
||||
// headers without inheriting this component's ordering state.
|
||||
|
||||
// The multi-select filter's option list, capped and scrollable. Its own component (module level,
|
||||
// not nested in DataTable) because it owns a hook: FilterPopover is redeclared on every DataTable
|
||||
// render, so React remounts it — state parked in there would churn.
|
||||
function FilterOptionList({ children }: { children: ReactNode }) {
|
||||
const fade = useScrollFade();
|
||||
return (
|
||||
<div
|
||||
ref={fade.ref}
|
||||
style={fade.style}
|
||||
className="flex flex-col gap-0.5 max-h-60 overflow-y-auto no-scrollbar"
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
// Re-exported so the many `import { type Column } from "./DataTable"` call sites keep working.
|
||||
export type { Column } from "./tableHeader";
|
||||
|
||||
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 {
|
||||
sort: SortState;
|
||||
filters: FilterMap;
|
||||
@@ -71,10 +42,6 @@ function loadPersist(key?: string): Persisted {
|
||||
}
|
||||
}
|
||||
|
||||
function isActive(value: string | string[] | undefined): boolean {
|
||||
return Array.isArray(value) ? value.length > 0 : !!value;
|
||||
}
|
||||
|
||||
export default function DataTable<T>({
|
||||
rows,
|
||||
columns,
|
||||
@@ -132,8 +99,6 @@ export default function DataTable<T>({
|
||||
const [filters, setFilters] = useState<FilterMap>(initial.filters);
|
||||
const [page, setPage] = useState(initial.page);
|
||||
const [size, setSize] = useState(initial.size ?? pageSize);
|
||||
const [openFilter, setOpenFilter] = useState<string | null>(null);
|
||||
const popRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (persistKey) localStorage.setItem(persistKey, JSON.stringify({ sort, filters, page, size }));
|
||||
@@ -158,21 +123,7 @@ export default function DataTable<T>({
|
||||
setPage(0);
|
||||
}, [resetFiltersToken]);
|
||||
|
||||
useDismiss(!!openFilter, () => setOpenFilter(null), popRef);
|
||||
|
||||
const filtered = rows.filter((row) =>
|
||||
columns.every((col) => {
|
||||
const f = col.filter;
|
||||
if (!f) return true;
|
||||
const val = filters[col.key];
|
||||
if (!isActive(val)) return true;
|
||||
if (f.kind === "text") return f.get(row).toLowerCase().includes(String(val).toLowerCase());
|
||||
if (f.kind === "select") return f.test(row, String(val));
|
||||
return f.test(row, val as string[]);
|
||||
})
|
||||
);
|
||||
|
||||
const sorted = sortRows(filtered, columns, sort);
|
||||
const sorted = sortRows(filterRows(rows, columns, filters), columns, sort);
|
||||
|
||||
// size <= 0 means "All" — one page with every row.
|
||||
const allRows = size <= 0;
|
||||
@@ -191,95 +142,6 @@ export default function DataTable<T>({
|
||||
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
|
||||
// (controlsPosition "both") WITHOUT repeating the leading controls (e.g. the status chips) — those
|
||||
// are header controls and belong only at the top.
|
||||
@@ -329,57 +191,13 @@ export default function DataTable<T>({
|
||||
{/* Wide screens: table. The wrapper isn't clipped so header filter popovers can overflow. */}
|
||||
<div className="hidden md:block">
|
||||
<table className="w-full border-collapse text-sm">
|
||||
{/* Sticky header row: the column headers stay put at the top of the page scroller while
|
||||
the rows scroll under them. `bg-bg` on each cell keeps rows from showing through; the
|
||||
border rides a pseudo-element (box-shadow) since a sticky <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 (the header itself
|
||||
stays crisp — the page scroller's own top fade is off on these pages). */}
|
||||
<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>
|
||||
<ColumnHead
|
||||
columns={columns}
|
||||
sort={sort}
|
||||
onToggleSort={toggleSort}
|
||||
filters={filters}
|
||||
onFilter={applyFilter}
|
||||
/>
|
||||
<tbody>
|
||||
{paged.map((row) => (
|
||||
<tr
|
||||
@@ -392,7 +210,7 @@ export default function DataTable<T>({
|
||||
<td
|
||||
key={col.key}
|
||||
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)}
|
||||
</td>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -12,6 +12,7 @@ import SidePanel from "./SidePanel";
|
||||
import PanelGroups, { type PanelItem } from "./PanelGroups";
|
||||
import { usePlaylists, usePlaylistsActions } from "./PlaylistsProvider";
|
||||
import { useMe } from "../lib/useMe";
|
||||
import { playlistName } from "../lib/playlistName";
|
||||
|
||||
type PlSort = { key: "custom" | "name" | "count" | "duration"; dir: "asc" | "desc"; dirtyFirst: boolean };
|
||||
const PL_SORT_DEFAULT: PlSort = { key: "custom", dir: "asc", dirtyFirst: false };
|
||||
@@ -69,8 +70,7 @@ export default function PlaylistsRail({
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [playlists, selectedId, listQuery.data]);
|
||||
|
||||
const plName = (p: { kind: string; name: string }) =>
|
||||
p.kind === "watch_later" ? t("playlists.watchLater") : p.name;
|
||||
const plName = (p: { kind: string; name: string }) => playlistName(p, t);
|
||||
|
||||
const sortedPlaylists = useMemo(() => {
|
||||
const sign = plSort.dir === "asc" ? 1 : -1;
|
||||
|
||||
@@ -0,0 +1,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",
|
||||
"demoTitle": "Demo account",
|
||||
"demoSharedNotice": "You're in the shared demo account. Everything you do here — playlists, hidden videos, settings — is communal and may be changed by other demo visitors at the same time.",
|
||||
"backToTop": "Back to top"
|
||||
"backToTop": "Back to top",
|
||||
"retry": "Retry"
|
||||
}
|
||||
|
||||
@@ -42,16 +42,9 @@
|
||||
"pushDone": "“{{name}}” synced to YouTube ✓",
|
||||
"pushPartial": "Synced, but {{count}} item(s) were skipped.",
|
||||
"pushFailed": "Couldn't sync to YouTube.",
|
||||
"deleteOnYoutubeTitle": "Delete on YouTube too?",
|
||||
"deleteOnYoutubeMsg": "Also delete “{{name}}” from your YouTube account? Choose “Here only” to keep it on YouTube.",
|
||||
"deleteOnYoutubeConfirm": "Delete on YouTube",
|
||||
"deleteHereOnly": "Here only",
|
||||
"deleteHereOnly": "Delete here only",
|
||||
"deleteYtFailed": "Couldn't delete on YouTube; removed here only.",
|
||||
"sortLabel": "Sort",
|
||||
"sortManual": "Custom order",
|
||||
"sortTitle": "Title",
|
||||
"sortDuration": "Duration",
|
||||
"sortChannel": "Channel",
|
||||
"dirAsc": "Ascending",
|
||||
"dirDesc": "Descending",
|
||||
"groupByChannel": "Group by channel",
|
||||
@@ -59,5 +52,36 @@
|
||||
"railSortName": "Name",
|
||||
"railSortCount": "Item count",
|
||||
"railSortDuration": "Total length",
|
||||
"dirtyFirst": "Unsynced first"
|
||||
"dirtyFirst": "Unsynced first",
|
||||
"colTitle": "Title",
|
||||
"colChannel": "Channel",
|
||||
"colDuration": "Length",
|
||||
"removeItem": "Remove",
|
||||
"removeItemOf": "Remove “{{title}}” from the playlist",
|
||||
"confirmRemove": "Remove “{{title}}” from this playlist?",
|
||||
"dragHandle": "Drag to reorder",
|
||||
"dragHandleOf": "Reorder “{{title}}”",
|
||||
"dragBlockedSort": "Reordering by hand is off while a column sort or grouping is applied.",
|
||||
"dragBlockedFilter": "Reordering by hand is off while a filter is applied.",
|
||||
"clearView": "Reset view",
|
||||
"saveOrder": "Save this order",
|
||||
"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",
|
||||
"demoTitle": "Demo fiók",
|
||||
"demoSharedNotice": "Egy közös demo fiókban vagy. Minden, amit itt csinálsz — lejátszási listák, elrejtett videók, beállítások — közös, és más demo látogatók egyszerre módosíthatják.",
|
||||
"backToTop": "Vissza a tetejére"
|
||||
"backToTop": "Vissza a tetejére",
|
||||
"retry": "Újra"
|
||||
}
|
||||
|
||||
@@ -42,16 +42,9 @@
|
||||
"pushDone": "„{{name}}” szinkronizálva a YouTube-ra ✓",
|
||||
"pushPartial": "Szinkronizálva, de {{count}} elem kimaradt.",
|
||||
"pushFailed": "Nem sikerült a YouTube-ra szinkronizálás.",
|
||||
"deleteOnYoutubeTitle": "Törlöd a YouTube-on is?",
|
||||
"deleteOnYoutubeMsg": "Törlöd a(z) „{{name}}” listát a YouTube-fiókodból is? A „Csak itt” megtartja a YouTube-on.",
|
||||
"deleteOnYoutubeConfirm": "Törlés a YouTube-on",
|
||||
"deleteHereOnly": "Csak itt",
|
||||
"deleteHereOnly": "Törlés csak itt",
|
||||
"deleteYtFailed": "Nem sikerült törölni a YouTube-on; csak itt lett eltávolítva.",
|
||||
"sortLabel": "Rendezés",
|
||||
"sortManual": "Egyéni sorrend",
|
||||
"sortTitle": "Cím",
|
||||
"sortDuration": "Hossz",
|
||||
"sortChannel": "Csatorna",
|
||||
"dirAsc": "Növekvő",
|
||||
"dirDesc": "Csökkenő",
|
||||
"groupByChannel": "Csoportosítás csatorna szerint",
|
||||
@@ -59,5 +52,36 @@
|
||||
"railSortName": "Név",
|
||||
"railSortCount": "Elemszám",
|
||||
"railSortDuration": "Összhossz",
|
||||
"dirtyFirst": "Nem szinkronizáltak elöl"
|
||||
"dirtyFirst": "Nem szinkronizáltak elöl",
|
||||
"colTitle": "Cím",
|
||||
"colChannel": "Csatorna",
|
||||
"colDuration": "Hossz",
|
||||
"removeItem": "Eltávolítás",
|
||||
"removeItemOf": "„{{title}}” eltávolítása a listáról",
|
||||
"confirmRemove": "Eltávolítod a(z) „{{title}}” videót erről a listáról?",
|
||||
"dragHandle": "Húzd az átrendezéshez",
|
||||
"dragHandleOf": "„{{title}}” átrendezése",
|
||||
"dragBlockedSort": "A kézi átrendezés ki van kapcsolva, amíg oszloprendezés vagy csoportosítás van érvényben.",
|
||||
"dragBlockedFilter": "A kézi átrendezés ki van kapcsolva, amíg szűrő van érvényben.",
|
||||
"clearView": "Nézet visszaállítása",
|
||||
"saveOrder": "Sorrend mentése",
|
||||
"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);
|
||||
@@ -14,6 +14,29 @@ export interface ReleaseEntry {
|
||||
}
|
||||
|
||||
export const RELEASE_NOTES: ReleaseEntry[] = [
|
||||
{
|
||||
version: "0.48.0",
|
||||
date: "2026-07-21",
|
||||
summary:
|
||||
"Playlists get real column headers — and sorting one no longer rewrites it behind your back.",
|
||||
features: [
|
||||
"The playlist view has sortable, filterable column headers: sort by title, channel or length, and filter by title or channel to find something in a long list.",
|
||||
"Sorting is now only a way of LOOKING at the list. Your playlist's own order stays exactly as you saved it until you press \"Save this order\" — so trying a sort can no longer rewrite the list, or leave a YouTube-synced playlist marked as unsynced. Saving is undoable like any other reorder.",
|
||||
"The list tells you which view you are in: whether the current sort matches your saved order, differs from it and can be saved, or is filtered (where saving would drop the hidden videos, so it is not offered). The \"#\" column always shows each video's position in the SAVED order.",
|
||||
"Drag-reordering now works from the keyboard: focus a drag handle, press space, move with the arrow keys and press space again.",
|
||||
],
|
||||
fixes: [
|
||||
"Deleting a playlist synced with YouTube asked twice, and cancelling the second question still deleted it locally. It is one question now, with each outcome on its own button — and closing the dialog deletes nothing.",
|
||||
"Confirmation dialogs put the keyboard focus on Cancel when the action is destructive. Previously the confirm button took focus, so a stray Enter on an open dialog could run \"Delete user\" or \"Clear audit log\".",
|
||||
"Removing a video from a playlist now asks first, and keeps your undo history for the videos that remain.",
|
||||
"Typing in a table's column filter re-created the input on every keystroke, which moved the cursor and broke accented and IME input mid-word. This affected the channel manager, Discover and the audit log as well.",
|
||||
"A playlist that failed to load showed \"Loading…\" forever; it now says so and offers to retry. Failed reorders and renames are reported instead of passing silently.",
|
||||
"Reordering a playlist no longer requires YouTube write access — it never needed it.",
|
||||
],
|
||||
chores: [
|
||||
"The table column header is now shared between the playlist and the other tables, and the playlist's view logic is covered by unit tests.",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "0.47.0",
|
||||
date: "2026-07-20",
|
||||
|
||||
@@ -15,6 +15,7 @@ export interface Undoable<T> {
|
||||
value: T;
|
||||
set: (next: T) => void;
|
||||
reset: (value: T) => void;
|
||||
rebase: (map: (value: T) => T) => void;
|
||||
undo: () => void;
|
||||
redo: () => void;
|
||||
canUndo: boolean;
|
||||
@@ -77,11 +78,27 @@ export function useUndoable<T>(
|
||||
force();
|
||||
}, []);
|
||||
|
||||
// Apply the same transformation to EVERY snapshot — past, present and future — and keep the
|
||||
// history. For changes that are outside the undoable dimension but invalidate the snapshots:
|
||||
// removing a playlist item is not itself undoable, yet dropping it from each stored order keeps
|
||||
// the user's reorder history valid and usable instead of throwing it away (which is what a
|
||||
// `reset` does). No `onApply`: the caller is already persisting the change by its own route.
|
||||
const rebase = useCallback((map: (value: T) => T) => {
|
||||
const s = ref.current;
|
||||
ref.current = {
|
||||
past: s.past.map(map),
|
||||
present: map(s.present),
|
||||
future: s.future.map(map),
|
||||
};
|
||||
force();
|
||||
}, []);
|
||||
|
||||
const h = ref.current;
|
||||
return {
|
||||
value: h.present,
|
||||
set,
|
||||
reset,
|
||||
rebase,
|
||||
undo,
|
||||
redo,
|
||||
canUndo: h.past.length > 0,
|
||||
|
||||
Reference in New Issue
Block a user