Files
siftlode/frontend/src/components/ui/DataTable.tsx
T
peter c3d88d9b20 refactor: component files export only components; lint fails on a warning
The 43 react-refresh/only-export-components warnings the gate let through meant an edit to a
provider (or AnchoredPopover, Toaster, plexDetailUi, …) could not be hot-swapped: Fast Refresh
reloads a module that also exports hooks or helpers.

- Each provider's contexts, the types its hooks return and the hooks themselves live in a sibling
  context module (navigationContext.ts, channelsContext.ts, …); the provider file keeps the
  component. loadAccountFilters moved next to the feed filters' defaults (feedFiltersInit.ts),
  following navigationInit/playlistsInit.
- Helpers that sat in component files have their own modules: welcomeMode, downloadTitle,
  plexPlayable, channelSync, ui/anchoredPopoverHooks, ui/pageToolbarSlot, ui/pagerContent,
  ui/toastLevels, ui/overlayOrder, ui/tableAlign, lib/plexDetail; the cells of the channel columns
  and the registry's page wrappers are components files of their own (channelCells, modulePages).
- usePersistedTab, an alias of useAccountPersistedState, is gone; its callers use the original.

npm run lint is now eslint --max-warnings 0, so a warning fails the gate like an error does.
2026-09-17 02:59:09 +02:00

341 lines
15 KiB
TypeScript

import { useEffect, useRef, useState, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { sortRows, cycleSort, type SortState } from "../../lib/columnSort";
import Pager from "./Pager";
import { hasPagerContent } from "./pagerContent";
import { PageToolbar } from "./PageShell";
import { ColumnHead } from "./tableHeader";
import { alignClass } from "./tableAlign";
import { filterRows, type Column, type FilterMap } from "../../lib/tableColumns";
import { navItemAttrs, useListNav } from "../../lib/listNav";
// 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 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) lives in `tableHeader.tsx`, the column
// contract and the filter predicate in `lib/tableColumns` — the playlist manager renders its own
// drag-reorderable body under the same headers without inheriting this component's ordering state.
// Re-exported so the many `import { type Column } from "./DataTable"` call sites keep working.
export type { Column } from "../../lib/tableColumns";
interface Persisted {
sort: SortState;
filters: FilterMap;
page: number;
size?: number;
}
// Stable empty-map identity for the controlled-but-value-omitted fallback, so `filters` keeps a
// constant reference across renders (a fresh `{}` would churn the persist effect + re-filter every
// render). Mirrors controlled sort's stable `null` fallback.
const EMPTY_FILTERS: FilterMap = {};
function loadPersist(key?: string): Persisted {
const empty: Persisted = { sort: null, filters: {}, page: 0 };
if (!key) return empty;
try {
const v = JSON.parse(localStorage.getItem(key) || "{}");
return { sort: v.sort ?? null, filters: v.filters ?? {}, page: v.page ?? 0, size: v.size };
} catch {
return empty;
}
}
export default function DataTable<T>({
rows,
columns,
rowKey,
pageSize = 10,
pageSizeOptions = [10, 25, 50, 100],
persistKey,
emptyText,
rowClassName,
rowProps,
controlsPosition = "bottom",
controlsLeading,
controlsInBand = false,
controlsBandClassName,
externalFilter,
resetFiltersToken,
sort: sortProp,
onSortChange,
filters: filtersProp,
onFiltersChange,
fixedLayout = false,
keyboardNav = false,
}: {
rows: T[];
columns: Column<T>[];
rowKey: (row: T) => string;
pageSize?: number;
pageSizeOptions?: number[];
persistKey?: string;
emptyText?: string;
rowClassName?: (row: T) => string;
// Extra DOM props per row — the escape hatch a caller needs to hang an INPUT PATH on a row it does
// not own the markup of. Today's only consumer is `useContextMenu().triggerProps` (right-click /
// long-press / Shift+F10), and both row shapes get it, so the wide table and the narrow card
// answer the same gesture. Deliberately not a bag of named callbacks: the table stays domain-free
// and never learns what a menu is.
rowProps?: (row: T) => Record<string, unknown>;
controlsPosition?: "top" | "bottom" | "both";
controlsLeading?: ReactNode;
// Render the controls row (controlsLeading + pager) into the shell's fixed toolbar band instead
// of inline, so it stays put above the sticky header while the rows scroll. `controlsBandClassName`
// aligns it with the page's fixed chrome (e.g. the same max-width wrapper).
controlsInBand?: boolean;
controlsBandClassName?: string;
// Set a column's filter from outside (e.g. "focus this channel" deep-links here). Applied
// whenever its value changes; the user can still clear it from the header afterwards.
externalFilter?: { key: string; value: string } | null;
// Bump this to clear all column filters — for navigation intents that mean "show me this
// set" (e.g. the header's "without full history" link), so a stale persisted filter can't
// hide the rows the user was just sent to see.
resetFiltersToken?: number;
// CONTROLLED sort (optional): when `onSortChange` is given, the caller owns the sort — the table
// reflects `sort` and reports header clicks instead of keeping its own — so another view of the
// same rows (the channel manager's cards) can share one sort. Omit both for the default internal,
// persistKey-backed sort.
sort?: SortState;
onSortChange?: (next: SortState) => void;
// CONTROLLED filters (optional): symmetric to controlled sort. When `onFiltersChange` is given the
// caller owns the column-filter map (and its persistence) — so the channel manager's top status
// chips, the Sync column funnel, and the card layout all share ONE filter state — and the table
// reflects `filters` and reports funnel changes instead of keeping its own. Omit both for the
// default internal, persistKey-backed filters.
filters?: FilterMap;
onFiltersChange?: (next: FilterMap) => void;
// Opt into `table-layout: fixed` (default is `auto`). In fixed mode the table width == its
// container at every zoom/text-size — the columns with an explicit `width` keep it, and the rest
// (the fluid text columns) split what remains and TRUNCATE instead of pushing the table wider than
// the viewport. Off by default so existing content-sized tables (playlists, discovery, Plex) are
// untouched; the channel manager turns it on so its row never scrolls horizontally out of view.
fixedLayout?: boolean;
// Take part in page keyboard navigation (`lib/listNav`): `j`/`k` and the arrows walk the rows, and
// Enter clicks the element a column's render marks `data-nav-open` (the channel name). Both row
// shapes register; listNav walks only the one on screen, so the hidden copy is never a stop.
keyboardNav?: boolean;
}) {
const { t } = useTranslation();
const initial = loadPersist(persistKey);
// Sort is controlled when the caller passes onSortChange (it then owns/persists it); otherwise
// the table keeps its own, seeded from and saved to persistKey.
const controlledSort = onSortChange !== undefined;
const [internalSort, setInternalSort] = useState<SortState>(initial.sort);
const sort = controlledSort ? (sortProp ?? null) : internalSort;
// Filters are controlled when the caller passes onFiltersChange (it then owns/persists the map);
// otherwise the table keeps its own, seeded from and saved to persistKey — symmetric to sort.
const controlledFilters = onFiltersChange !== undefined;
const [internalFilters, setInternalFilters] = useState<FilterMap>(initial.filters);
const filters = controlledFilters ? (filtersProp ?? EMPTY_FILTERS) : internalFilters;
const [page, setPage] = useState(initial.page);
const [size, setSize] = useState(initial.size ?? pageSize);
const bodyRef = useRef<HTMLTableSectionElement>(null);
const cardsRef = useRef<HTMLDivElement>(null);
useListNav(bodyRef, { enabled: keyboardNav });
useListNav(cardsRef, { enabled: keyboardNav });
// Commit a whole new filter map through the owner (controlled) or internal state. A ref holds the
// latest resolved map so the externalFilter/reset effects can derive from it without taking
// `filters` as a dep (which would re-run them on every filter change).
const filtersRef = useRef(filters);
filtersRef.current = filters;
const commitFilters = (next: FilterMap) => {
if (controlledFilters) onFiltersChange!(next);
else setInternalFilters(next);
};
// Persist page/size always, but only the sort/filter slices this table actually OWNS — a controlled
// caller persists those itself, so writing them here would just leave a stale, never-read duplicate.
useEffect(() => {
if (!persistKey) return;
localStorage.setItem(
persistKey,
JSON.stringify({
sort: controlledSort ? null : sort,
filters: controlledFilters ? {} : filters,
page,
size,
}),
);
}, [persistKey, controlledSort, controlledFilters, sort, filters, page, size]);
useEffect(() => {
if (!externalFilter) return;
commitFilters({ ...filtersRef.current, [externalFilter.key]: externalFilter.value });
setPage(0);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [externalFilter?.key, externalFilter?.value]);
// Clear column filters when the reset token advances. The ref starts at 0 (not at the prop)
// so a navigation that bumped the token still clears even when it remounts this table fresh
// (e.g. arriving from another page); a plain reload keeps the token at 0 and the effect is a
// no-op, so the user's persisted filters survive.
const resetRef = useRef(0);
useEffect(() => {
if (resetFiltersToken === undefined || resetFiltersToken === resetRef.current) return;
resetRef.current = resetFiltersToken;
commitFilters({});
setPage(0);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [resetFiltersToken]);
const sorted = sortRows(filterRows(rows, columns, filters), columns, sort);
// size <= 0 means "All" — one page with every row.
const allRows = size <= 0;
const totalPages = allRows ? 1 : Math.max(1, Math.ceil(sorted.length / size));
const safePage = Math.min(page, totalPages - 1);
const paged = allRows ? sorted : sorted.slice(safePage * size, safePage * size + size);
function toggleSort(key: string) {
const next = cycleSort(sort, key);
if (controlledSort) onSortChange!(next);
else setInternalSort(next);
setPage(0);
}
function applyFilter(key: string, value: string | string[]) {
commitFilters({ ...filters, [key]: value });
setPage(0);
}
// 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.
// Stays NULLABLE: the wrappers below decide whether to render a controls row at all, and an always
// -truthy element would leave an empty `my-3` band (and an empty in-band PageToolbar) on a table
// with nothing to page. The emptiness rule itself lives in Pager, so the two can't drift apart.
const pagerNode = hasPagerContent(sorted.length, size) ? (
<Pager
page={safePage}
size={size}
total={sorted.length}
pageSizeOptions={pageSizeOptions}
onPageChange={setPage}
onSizeChange={(s) => {
setSize(s);
setPage(0);
}}
sizeLabel={t("datatable.rowsPerPage")}
/>
) : null;
const controls =
controlsLeading != null || pagerNode ? (
<div className="flex items-center justify-between gap-3 my-3 text-sm flex-wrap">
<div className="flex items-center gap-3 flex-wrap">{controlsLeading}</div>
{pagerNode}
</div>
) : null;
// Bottom row for "both": just the pager, right-aligned (the leading controls stay at the top).
const bottomControls = pagerNode ? (
<div className="flex items-center justify-end gap-3 my-3 text-sm flex-wrap">{pagerNode}</div>
) : null;
const topControls =
controlsInBand && controls ? (
<PageToolbar>
<div className={controlsBandClassName}>{controls}</div>
</PageToolbar>
) : (
controls
);
return (
<div>
{(controlsPosition === "top" || controlsPosition === "both") && topControls}
{/* 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 ${fixedLayout ? "table-fixed" : ""}`}>
{/* In fixed-layout mode a colgroup drives the column widths: those with an explicit
`width` keep it, and the unspecified (fluid) columns split the remainder — so the
table never exceeds its container and the fluid cells truncate instead. */}
{fixedLayout && (
<colgroup>
{columns.map((col) => (
<col key={col.key} style={col.width ? { width: col.width } : undefined} />
))}
</colgroup>
)}
<ColumnHead
columns={columns}
sort={sort}
onToggleSort={toggleSort}
filters={filters}
onFilter={applyFilter}
/>
<tbody ref={bodyRef}>
{paged.map((row, i) => (
<tr
key={rowKey(row)}
{...rowProps?.(row)}
{...(keyboardNav && navItemAttrs(i))}
className={`border-b border-border/50 hover:bg-(--hover) transition ${
rowClassName?.(row) ?? ""
}`}
>
{columns.map((col) => (
<td
key={col.key}
style={col.width ? { width: col.width } : undefined}
className={`px-2 py-2 align-middle ${col.nowrap ? "whitespace-nowrap" : ""} ${alignClass(col.align)}`}
>
{col.render(row)}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
{/* Narrow screens: a compact card per row — the primary column as the heading, the
rest flowing in a single wrapping meta line (no full-width label/value gaps). */}
<div ref={cardsRef} className="md:hidden flex flex-col gap-2">
{paged.map((row, i) => (
<div
key={rowKey(row)}
{...rowProps?.(row)}
{...(keyboardNav && navItemAttrs(i))}
className={`glass-card rounded-xl p-3 ${rowClassName?.(row) ?? ""}`}
>
{columns
.filter((c) => c.cardPrimary)
.map((col) => (
<div key={col.key} className="font-medium">
{col.render(row)}
</div>
))}
<div className="flex flex-wrap items-center gap-x-3 gap-y-1.5 mt-2 text-sm">
{columns
.filter((c) => !c.hideInCard && !c.cardPrimary)
.map((col) => (
<span key={col.key} className="inline-flex items-center gap-1">
{col.cardLabel !== false && (
<span className="text-muted text-xs">{col.header}</span>
)}
{col.render(row)}
</span>
))}
</div>
</div>
))}
</div>
{paged.length === 0 && (
<div className="text-muted py-8 text-center text-sm">
{emptyText ?? t("datatable.empty")}
</div>
)}
{controlsPosition === "bottom" && controls}
{controlsPosition === "both" && bottomControls}
</div>
);
}