From 9872a0115cff607c5c9b86e67061e9fd691a8098 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Wed, 17 Jun 2026 19:16:23 +0200 Subject: [PATCH] feat(ui): reusable DataTable component Generic client-side table: per-column sort, in-header filters (text/select/multi), built-in pagination with a user-set page size (incl. All) and an editable jump-to-page box. Sort/filter/page/size persist to localStorage (survive F5). Falls back to a compact card list below md. Intended for reuse across modules (channels now, playlists next). --- frontend/src/components/DataTable.tsx | 421 ++++++++++++++++++++ frontend/src/i18n/locales/de/datatable.json | 15 + frontend/src/i18n/locales/en/datatable.json | 15 + frontend/src/i18n/locales/hu/datatable.json | 15 + 4 files changed, 466 insertions(+) create mode 100644 frontend/src/components/DataTable.tsx create mode 100644 frontend/src/i18n/locales/de/datatable.json create mode 100644 frontend/src/i18n/locales/en/datatable.json create mode 100644 frontend/src/i18n/locales/hu/datatable.json diff --git a/frontend/src/components/DataTable.tsx b/frontend/src/components/DataTable.tsx new file mode 100644 index 0000000..ef1d5a8 --- /dev/null +++ b/frontend/src/components/DataTable.tsx @@ -0,0 +1,421 @@ +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; + // 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, +}: { + 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; +}) { + 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 (!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} +
+ ); +} diff --git a/frontend/src/i18n/locales/de/datatable.json b/frontend/src/i18n/locales/de/datatable.json new file mode 100644 index 0000000..5e67f41 --- /dev/null +++ b/frontend/src/i18n/locales/de/datatable.json @@ -0,0 +1,15 @@ +{ + "filter": "Filtern", + "clear": "Löschen", + "all": "Alle", + "rowsPerPage": "Zeilen pro Seite", + "noOptions": "Keine Optionen", + "empty": "Nichts anzuzeigen.", + "pager": { + "prev": "Zurück", + "next": "Weiter", + "page": "Seite {{page}} von {{total}}", + "pageLabel": "Seite", + "ofTotal": "von {{total}}" + } +} diff --git a/frontend/src/i18n/locales/en/datatable.json b/frontend/src/i18n/locales/en/datatable.json new file mode 100644 index 0000000..84c78e7 --- /dev/null +++ b/frontend/src/i18n/locales/en/datatable.json @@ -0,0 +1,15 @@ +{ + "filter": "Filter", + "clear": "Clear", + "all": "All", + "rowsPerPage": "Rows per page", + "noOptions": "No options", + "empty": "Nothing to show.", + "pager": { + "prev": "Previous", + "next": "Next", + "page": "Page {{page}} of {{total}}", + "pageLabel": "Page", + "ofTotal": "of {{total}}" + } +} diff --git a/frontend/src/i18n/locales/hu/datatable.json b/frontend/src/i18n/locales/hu/datatable.json new file mode 100644 index 0000000..269704c --- /dev/null +++ b/frontend/src/i18n/locales/hu/datatable.json @@ -0,0 +1,15 @@ +{ + "filter": "Szűrés", + "clear": "Törlés", + "all": "Mind", + "rowsPerPage": "Sor/oldal", + "noOptions": "Nincs lehetőség", + "empty": "Nincs megjeleníthető elem.", + "pager": { + "prev": "Előző", + "next": "Következő", + "page": "{{page}}. oldal / {{total}}", + "pageLabel": "Oldal", + "ofTotal": "/ {{total}}" + } +}