import { useEffect, useRef, useState, type ReactNode } from "react"; import { useTranslation } from "react-i18next"; 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 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. // Re-exported so the many `import { type Column } from "./DataTable"` call sites keep working. export type { Column } from "./tableHeader"; interface Persisted { sort: SortState; filters: FilterMap; page: number; size?: number; } 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, controlsPosition = "bottom", controlsLeading, controlsInBand = false, controlsBandClassName, externalFilter, resetFiltersToken, sort: sortProp, onSortChange, }: { rows: T[]; columns: Column[]; rowKey: (row: T) => string; pageSize?: number; pageSizeOptions?: number[]; persistKey?: string; emptyText?: string; rowClassName?: (row: T) => string; 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; }) { 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; const [filters, setFilters] = useState(initial.filters); const [page, setPage] = useState(initial.page); const [size, setSize] = useState(initial.size ?? pageSize); useEffect(() => { if (persistKey) localStorage.setItem(persistKey, JSON.stringify({ sort, filters, page, size })); }, [persistKey, sort, filters, page, size]); useEffect(() => { if (!externalFilter) return; setFilters((f) => ({ ...f, [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; setFilters({}); setPage(0); }, [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[]) { setFilters((f) => ({ ...f, [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. */}
{paged.map((row) => ( {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) => (
{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}
); }