import { useEffect, useRef, useState, type ReactNode } from "react"; import { useTranslation } from "react-i18next"; import { ArrowDown, ArrowUp, ChevronLeft, ChevronRight, ListFilter, X } from "lucide-react"; // 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. export type ColumnFilter = | { 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 { key: string; header: string; render: (row: T) => ReactNode; align?: "left" | "right" | "center"; width?: string; sortable?: boolean; sortValue?: (row: T) => string | number; filter?: ColumnFilter; // 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 SortState = { key: string; dir: "asc" | "desc" } | null; type FilterMap = Record; 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; } } function isActive(value: string | string[] | undefined): boolean { return Array.isArray(value) ? value.length > 0 : !!value; } export default function DataTable({ rows, columns, rowKey, pageSize = 10, pageSizeOptions = [10, 25, 50, 100], persistKey, emptyText, rowClassName, controlsPosition = "bottom", controlsLeading, externalFilter, resetFiltersToken, }: { 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; // 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; }) { const { t } = useTranslation(); const initial = loadPersist(persistKey); const [sort, setSort] = useState(initial.sort); const [filters, setFilters] = useState(initial.filters); const [page, setPage] = useState(initial.page); const [size, setSize] = useState(initial.size ?? pageSize); const [openFilter, setOpenFilter] = useState(null); // Local text for the editable "jump to page" box (committed on Enter/blur). const [pageInput, setPageInput] = useState("1"); const popRef = useRef(null); 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]); useEffect(() => { if (!openFilter) return; function onDown(e: MouseEvent) { if (!popRef.current?.contains(e.target as Node)) setOpenFilter(null); } function onKey(e: KeyboardEvent) { if (e.key === "Escape") setOpenFilter(null); } document.addEventListener("mousedown", onDown); document.addEventListener("keydown", onKey); return () => { document.removeEventListener("mousedown", onDown); document.removeEventListener("keydown", onKey); }; }, [openFilter]); 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 sortCol = sort ? columns.find((c) => c.key === sort.key) : undefined; const sorted = sort && sortCol?.sortValue ? [...filtered].sort((a, b) => { const av = sortCol.sortValue!(a); const bv = sortCol.sortValue!(b); const cmp = typeof av === "number" && typeof bv === "number" ? av - bv : String(av).localeCompare(String(bv)); return sort.dir === "asc" ? cmp : -cmp; }) : filtered; // 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); useEffect(() => setPageInput(String(safePage + 1)), [safePage]); function commitPageInput() { const n = parseInt(pageInput, 10); if (!isNaN(n)) setPage(Math.min(totalPages - 1, Math.max(0, n - 1))); else setPageInput(String(safePage + 1)); } function toggleSort(key: string) { setSort((s) => !s || s.key !== key ? { key, dir: "asc" } : s.dir === "asc" ? { key, dir: "desc" } : null ); setPage(0); } function applyFilter(key: string, value: string | string[]) { setFilters((f) => ({ ...f, [key]: value })); setPage(0); } const align = (a?: "left" | "right" | "center") => a === "right" ? "text-right" : a === "center" ? "text-center" : "text-left"; function FilterPopover({ col }: { col: Column }) { const f = col.filter!; const val = filters[col.key]; return (
{f.kind === "text" && ( 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" && (
{f.options.map((o) => ( ))}
)} {f.kind === "multi" && (
{f.options.length === 0 && ( {t("datatable.noOptions")} )} {f.options.map((o) => { const arr = (val as string[]) ?? []; const on = arr.includes(o.value); return ( ); })}
)} {isActive(val) && ( )}
); } const controls = controlsLeading != null || sorted.length > 0 ? (
{controlsLeading}
{totalPages > 1 && (
{t("datatable.pager.pageLabel")} setPageInput(e.target.value)} onBlur={commitPageInput} onKeyDown={(e) => e.key === "Enter" && commitPageInput()} aria-label={t("datatable.pager.pageLabel")} className="w-14 bg-card border border-border rounded-md px-1.5 py-1 text-xs text-center tabular-nums outline-none focus:border-accent" /> {t("datatable.pager.ofTotal", { total: totalPages })}
)} {sorted.length > 0 && ( )}
) : null; return (
{(controlsPosition === "top" || controlsPosition === "both") && controls} {/* Wide screens: table. The wrapper isn't clipped so header filter popovers can overflow. */}
{columns.map((col) => { const sorted_ = sort?.key === col.key; const filterOn = isActive(filters[col.key]); return ( ); })} {paged.map((row) => ( {columns.map((col) => ( ))} ))}
{col.filter && ( )} {openFilter === col.key && col.filter && }
{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" || controlsPosition === "both") && controls}
); }