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({ 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[]; 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; 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(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(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(null); const cardsRef = useRef(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) ? ( { setSize(s); setPage(0); }} sizeLabel={t("datatable.rowsPerPage")} /> ) : null; const controls = controlsLeading != null || pagerNode ? (
{controlsLeading}
{pagerNode}
) : null; // Bottom row for "both": just the pager, right-aligned (the leading controls stay at the top). const bottomControls = pagerNode ? (
{pagerNode}
) : null; const topControls = controlsInBand && controls ? (
{controls}
) : ( controls ); return (
{(controlsPosition === "top" || controlsPosition === "both") && topControls} {/* Wide screens: table. The wrapper isn't clipped so header filter popovers can overflow. */}
{/* 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 && ( {columns.map((col) => ( ))} )} {paged.map((row, i) => ( {columns.map((col) => ( ))} ))}
{col.render(row)}
{/* 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). */}
{paged.map((row, i) => (
{columns .filter((c) => c.cardPrimary) .map((col) => (
{col.render(row)}
))}
{columns .filter((c) => !c.hideInCard && !c.cardPrimary) .map((col) => ( {col.cardLabel !== false && ( {col.header} )} {col.render(row)} ))}
))}
{paged.length === 0 && (
{emptyText ?? t("datatable.empty")}
)} {controlsPosition === "bottom" && controls} {controlsPosition === "both" && bottomControls}
); }