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).
This commit is contained in:
parent
4759103f26
commit
9872a0115c
4 changed files with 466 additions and 0 deletions
421
frontend/src/components/DataTable.tsx
Normal file
421
frontend/src/components/DataTable.tsx
Normal file
|
|
@ -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<T> =
|
||||
| { 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<T> {
|
||||
key: string;
|
||||
header: string;
|
||||
render: (row: T) => ReactNode;
|
||||
align?: "left" | "right" | "center";
|
||||
width?: string;
|
||||
sortable?: boolean;
|
||||
sortValue?: (row: T) => string | number;
|
||||
filter?: ColumnFilter<T>;
|
||||
// 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<string, string | string[]>;
|
||||
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<T>({
|
||||
rows,
|
||||
columns,
|
||||
rowKey,
|
||||
pageSize = 10,
|
||||
pageSizeOptions = [10, 25, 50, 100],
|
||||
persistKey,
|
||||
emptyText,
|
||||
rowClassName,
|
||||
controlsPosition = "bottom",
|
||||
controlsLeading,
|
||||
}: {
|
||||
rows: T[];
|
||||
columns: Column<T>[];
|
||||
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<SortState>(initial.sort);
|
||||
const [filters, setFilters] = useState<FilterMap>(initial.filters);
|
||||
const [page, setPage] = useState(initial.page);
|
||||
const [size, setSize] = useState(initial.size ?? pageSize);
|
||||
const [openFilter, setOpenFilter] = useState<string | null>(null);
|
||||
// Local text for the editable "jump to page" box (committed on Enter/blur).
|
||||
const [pageInput, setPageInput] = useState("1");
|
||||
const popRef = useRef<HTMLDivElement | null>(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<T> }) {
|
||||
const f = col.filter!;
|
||||
const val = filters[col.key];
|
||||
return (
|
||||
<div
|
||||
ref={popRef}
|
||||
className="glass-menu absolute left-0 top-full mt-1 z-30 w-52 rounded-xl p-2 animate-[popIn_0.16s_ease]"
|
||||
>
|
||||
{f.kind === "text" && (
|
||||
<input
|
||||
autoFocus
|
||||
value={(val as string) ?? ""}
|
||||
onChange={(e) => 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" && (
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<button
|
||||
onClick={() => applyFilter(col.key, "")}
|
||||
className={`text-left text-xs px-2 py-1.5 rounded-md transition ${
|
||||
!isActive(val) ? "bg-accent text-accent-fg" : "text-muted hover:bg-card"
|
||||
}`}
|
||||
>
|
||||
{t("datatable.all")}
|
||||
</button>
|
||||
{f.options.map((o) => (
|
||||
<button
|
||||
key={o.value}
|
||||
onClick={() => applyFilter(col.key, o.value)}
|
||||
className={`text-left text-xs px-2 py-1.5 rounded-md transition ${
|
||||
val === o.value ? "bg-accent text-accent-fg" : "text-muted hover:bg-card"
|
||||
}`}
|
||||
>
|
||||
{o.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{f.kind === "multi" && (
|
||||
<div className="flex flex-col gap-0.5 max-h-60 overflow-y-auto">
|
||||
{f.options.length === 0 && (
|
||||
<span className="text-xs text-muted px-2 py-1">{t("datatable.noOptions")}</span>
|
||||
)}
|
||||
{f.options.map((o) => {
|
||||
const arr = (val as string[]) ?? [];
|
||||
const on = arr.includes(o.value);
|
||||
return (
|
||||
<button
|
||||
key={o.value}
|
||||
onClick={() =>
|
||||
applyFilter(
|
||||
col.key,
|
||||
on ? arr.filter((x) => x !== o.value) : [...arr, o.value]
|
||||
)
|
||||
}
|
||||
className={`flex items-center gap-2 text-left text-xs px-2 py-1.5 rounded-md transition ${
|
||||
on ? "text-fg" : "text-muted hover:bg-card"
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`w-3.5 h-3.5 rounded border flex items-center justify-center ${
|
||||
on ? "bg-accent border-accent text-accent-fg" : "border-border"
|
||||
}`}
|
||||
>
|
||||
{on && <X className="w-2.5 h-2.5" />}
|
||||
</span>
|
||||
{o.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{isActive(val) && (
|
||||
<button
|
||||
onClick={() => applyFilter(col.key, f.kind === "multi" ? [] : "")}
|
||||
className="mt-1.5 w-full text-xs text-muted hover:text-accent transition"
|
||||
>
|
||||
{t("datatable.clear")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const controls =
|
||||
controlsLeading != null || sorted.length > 0 ? (
|
||||
<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>
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => setPage((p) => Math.max(0, p - 1))}
|
||||
disabled={safePage === 0}
|
||||
className="glass-card glass-hover flex items-center gap-1 px-3 py-1.5 rounded-lg disabled:opacity-40 transition"
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
{t("datatable.pager.prev")}
|
||||
</button>
|
||||
<span className="text-muted flex items-center gap-1.5">
|
||||
{t("datatable.pager.pageLabel")}
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={totalPages}
|
||||
value={pageInput}
|
||||
onChange={(e) => 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 })}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setPage((p) => Math.min(totalPages - 1, p + 1))}
|
||||
disabled={safePage >= totalPages - 1}
|
||||
className="glass-card glass-hover flex items-center gap-1 px-3 py-1.5 rounded-lg disabled:opacity-40 transition"
|
||||
>
|
||||
{t("datatable.pager.next")}
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{sorted.length > 0 && (
|
||||
<label className="flex items-center gap-2 text-muted">
|
||||
{t("datatable.rowsPerPage")}
|
||||
<select
|
||||
value={size}
|
||||
onChange={(e) => {
|
||||
setSize(Number(e.target.value));
|
||||
setPage(0);
|
||||
}}
|
||||
className="bg-card border border-border rounded-md px-1.5 py-1 text-xs outline-none focus:border-accent"
|
||||
>
|
||||
{pageSizeOptions.map((n) => (
|
||||
<option key={n} value={n}>
|
||||
{n}
|
||||
</option>
|
||||
))}
|
||||
<option value={0}>{t("datatable.all")}</option>
|
||||
</select>
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<div>
|
||||
{(controlsPosition === "top" || controlsPosition === "both") && controls}
|
||||
{/* Wide screens: table. The wrapper isn't clipped so header filter popovers can overflow. */}
|
||||
<div className="hidden md:block">
|
||||
<table className="w-full border-collapse text-sm">
|
||||
<thead>
|
||||
<tr className="text-muted border-b border-border">
|
||||
{columns.map((col) => {
|
||||
const sorted_ = sort?.key === col.key;
|
||||
const filterOn = isActive(filters[col.key]);
|
||||
return (
|
||||
<th
|
||||
key={col.key}
|
||||
style={col.width ? { width: col.width } : undefined}
|
||||
className={`relative font-medium px-2 py-2 ${align(col.align)}`}
|
||||
>
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<button
|
||||
onClick={() => col.sortable && toggleSort(col.key)}
|
||||
className={`inline-flex items-center gap-1 ${
|
||||
col.sortable ? "hover:text-fg cursor-pointer" : "cursor-default"
|
||||
}`}
|
||||
>
|
||||
{col.header}
|
||||
{col.sortable && sorted_ && sort && (
|
||||
sort.dir === "asc" ? (
|
||||
<ArrowUp className="w-3 h-3" />
|
||||
) : (
|
||||
<ArrowDown className="w-3 h-3" />
|
||||
)
|
||||
)}
|
||||
</button>
|
||||
{col.filter && (
|
||||
<button
|
||||
onClick={() => setOpenFilter((o) => (o === col.key ? null : col.key))}
|
||||
aria-label={t("datatable.filter")}
|
||||
className={`transition ${
|
||||
filterOn ? "text-accent" : "text-muted/60 hover:text-fg"
|
||||
}`}
|
||||
>
|
||||
<ListFilter className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
{openFilter === col.key && col.filter && <FilterPopover col={col} />}
|
||||
</th>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{paged.map((row) => (
|
||||
<tr
|
||||
key={rowKey(row)}
|
||||
className={`border-b border-border/50 hover:bg-card/40 transition ${
|
||||
rowClassName?.(row) ?? ""
|
||||
}`}
|
||||
>
|
||||
{columns.map((col) => (
|
||||
<td key={col.key} style={col.width ? { width: col.width } : undefined} className={`px-2 py-2 align-middle ${align(col.align)}`}>
|
||||
{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). */}
|
||||
<div className="md:hidden flex flex-col gap-2">
|
||||
{paged.map((row) => (
|
||||
<div
|
||||
key={rowKey(row)}
|
||||
className={`glass-card rounded-xl 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" || controlsPosition === "both") && controls}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue