Every locale formatter takes the language as an argument. Under the React
Compiler a formatter that read the i18n singleton was memoized on its other
arguments, so a card kept "2 hr 4 min" after a switch to Hungarian; plexLang()
had the same flaw and left Plex queries in the old language. Components now
take the language from useTranslation, and the signature makes forgetting it
a type error.
- ratings, file sizes, stream stats: the language's decimal mark ("8,3")
- view and subscriber counts compact in the language ("1,2 E")
- seconds as the language's unit ("6 mp") in the sliders and the stats
- a person's birth and death dates as dates, not ISO strings
- A-Z lists by the shown name in the language's alphabet (compareText),
the tag catalog re-sorted by its translated names
- lint local/locale-format (was date-format) also refuses Intl.NumberFormat
outside lib/format.ts, localeCompare, and a component reading the language
off the i18n singleton
402 lines
18 KiB
TypeScript
402 lines
18 KiB
TypeScript
import { useCallback, 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 { columnHideClass, filterRows, type Column, type FilterMap } from "../../lib/tableColumns";
|
|
import { navItemAttrs, useListNav } from "../../lib/listNav";
|
|
import { useMediaQuery } from "../../lib/useMediaQuery";
|
|
import { SURFACE } from "./surfaces";
|
|
import { HiddenFilterCue } from "./HiddenFilterCue";
|
|
|
|
// 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,
|
|
reveal,
|
|
surfacedFilters,
|
|
}: {
|
|
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;
|
|
// Follow a row the caller just MOVED (a channel re-sorted by the priority its menu set): each new
|
|
// `seq` turns to the page that now holds the row `key`. Focus is the caller's (it knows which of
|
|
// the row's controls it came from); the page is the table's, so it is decided here.
|
|
reveal?: { key: string; seq: number } | null;
|
|
// Filter keys the caller shows somewhere besides their column (the channel manager's tag chips):
|
|
// the hidden-filter cue leaves those out, it would name them twice.
|
|
surfacedFilters?: string[];
|
|
}) {
|
|
const { t, i18n } = useTranslation();
|
|
const lang = i18n.language;
|
|
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 wide = useMediaQuery("(min-width: 48rem)");
|
|
const bodyRef = useRef<HTMLTableSectionElement>(null);
|
|
const cardsRef = useRef<HTMLDivElement>(null);
|
|
// Enabled with the layout that is mounted: `useListNav` reads its ref when `enabled` changes, so a
|
|
// flag that stayed on across a breakpoint left the newly mounted layout unregistered (no j/k) and
|
|
// the unmounted one registered.
|
|
useListNav(bodyRef, { enabled: keyboardNav && wide });
|
|
useListNav(cardsRef, { enabled: keyboardNav && !wide });
|
|
|
|
// 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;
|
|
// …and the same trick for the CALLBACK, one line further: a controlled caller almost always
|
|
// passes an inline arrow, so `onFiltersChange` is a fresh function on every render. Reading it
|
|
// through a ref is what lets `commitFilters` be stable, and a stable commitFilters is what lets
|
|
// the two effects below NAME it in their dep lists instead of switching the check off.
|
|
const onFiltersChangeRef = useRef(onFiltersChange);
|
|
onFiltersChangeRef.current = onFiltersChange;
|
|
const commitFilters = useCallback(
|
|
(next: FilterMap) => {
|
|
if (controlledFilters) onFiltersChangeRef.current!(next);
|
|
else setInternalFilters(next);
|
|
},
|
|
[controlledFilters],
|
|
);
|
|
|
|
// 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]);
|
|
|
|
// Destructured so the deps are the values the body actually reads. `externalFilter` itself is an
|
|
// object literal at every call site, so depending on it would re-apply the filter — and reset the
|
|
// page — on every parent render.
|
|
const externalKey = externalFilter?.key;
|
|
const externalValue = externalFilter?.value;
|
|
useEffect(() => {
|
|
if (externalKey === undefined || externalValue === undefined) return;
|
|
commitFilters({ ...filtersRef.current, [externalKey]: externalValue });
|
|
setPage(0);
|
|
}, [externalKey, externalValue, commitFilters]);
|
|
|
|
// 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);
|
|
}, [resetFiltersToken, commitFilters]);
|
|
|
|
const sorted = sortRows(filterRows(rows, columns, filters), columns, sort, lang);
|
|
|
|
// 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);
|
|
// Adjusted DURING render against the last seq seen (the house's key-change reset), so the frame
|
|
// that re-sorted the row already shows its new page. Seeded with the mount's seq: arriving with a
|
|
// stale reveal must not move a page nobody asked to move.
|
|
const [revealSeen, setRevealSeen] = useState(reveal?.seq);
|
|
if (reveal && reveal.seq !== revealSeen) {
|
|
setRevealSeen(reveal.seq);
|
|
const at = sorted.findIndex((row) => rowKey(row) === reveal.key);
|
|
if (at >= 0 && !allRows) setPage(Math.floor(at / size));
|
|
}
|
|
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);
|
|
}
|
|
const cue = (by: "width" | "cards") => (
|
|
<HiddenFilterCue
|
|
columns={columns}
|
|
filters={filters}
|
|
by={by}
|
|
surfaced={surfacedFilters}
|
|
onFilter={applyFilter}
|
|
/>
|
|
);
|
|
|
|
// 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}
|
|
{/* ONE layout, chosen by width — not both mounted with CSS hiding one: that doubled every
|
|
row's DOM and render cost, duplicated ids and test hooks, and handed keyboard nav and any
|
|
DOM query two copies of each row. `md` (48rem) is the breakpoint the CSS used.
|
|
Wide screens: table. The wrapper isn't clipped so header filter popovers can overflow. */}
|
|
{wide ? (
|
|
// A container, so a column hides by the table's own width (`tabletHidden`), not the window's.
|
|
<div className="@container">
|
|
{cue("width")}
|
|
<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}
|
|
className={columnHideClass(col, "col") || 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 state-layer 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)}${columnHideClass(col)}`}
|
|
>
|
|
{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). */
|
|
<>
|
|
{cue("cards")}
|
|
<div ref={cardsRef} className="flex flex-col gap-2">
|
|
{paged.map((row, i) => (
|
|
<div
|
|
key={rowKey(row)}
|
|
{...rowProps?.(row)}
|
|
{...(keyboardNav && navItemAttrs(i))}
|
|
className={`${SURFACE.card} 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>
|
|
);
|
|
}
|