1"use client";2import clsx from "clsx";3import type { ReactNode } from "react";4import { Empty, Skeleton } from "./ui";56export interface Column<T> {7 key: string;8 header: ReactNode;9 render?: (row: T, i: number) => ReactNode;10 num?: boolean;11 className?: string;12 /** hide below `sm` to save width on phones; still visible in the card view */13 hideSm?: boolean;14 mono?: boolean;15}1617interface Props<T> {18 columns: Column<T>[];19 rows: T[] | null | undefined;20 rowKey: (row: T, i: number) => string | number;21 loading?: boolean;22 empty?: ReactNode;23 /** Card layout under 640px (default) or always horizontal scroll */24 mobile?: "cards" | "scroll";25 maxHeight?: string;26 onRowClick?: (row: T) => void;27 footer?: ReactNode;28}2930function cell<T>(c: Column<T>, row: T, i: number): ReactNode {31 if (c.render) return c.render(row, i);32 const v = (row as Record<string, unknown>)[c.key];33 if (v === null || v === undefined) return <span className="text-ink-3">—</span>;34 if (typeof v === "object") return <code className="text-xs">{JSON.stringify(v)}</code>;35 return String(v);36}3738export function DataTable<T>({ columns, rows, rowKey, loading, empty, mobile = "cards", maxHeight, onRowClick, footer }: Props<T>) {39 if (loading && !rows) return <Skeleton rows={5} />;40 if (!rows || rows.length === 0) return <Empty>{empty ?? "No rows."}</Empty>;41 return (42 <div className={clsx(loading && "opacity-60 transition-opacity")}>43 {/* Cards (mobile) */}44 {mobile === "cards" && (45 <ul className="flex flex-col gap-2 sm:hidden">46 {rows.map((r, i) => (47 <li key={rowKey(r, i)} className={clsx("rounded-md border border-border bg-surface-1 p-3", onRowClick && "cursor-pointer active:bg-surface-2")} onClick={onRowClick ? () => onRowClick(r) : undefined}>48 <dl className="grid grid-cols-[minmax(0,40%)_1fr] gap-x-3 gap-y-1 text-sm">49 {columns.map((c) => (50 <div key={c.key} className="contents">51 <dt className="truncate text-xs text-ink-3">{c.header}</dt>52 <dd className={clsx("min-w-0 break-words", c.mono && "font-mono text-xs", c.num && "tnum")}>{cell(c, r, i)}</dd>53 </div>54 ))}55 </dl>56 </li>57 ))}58 </ul>59 )}60 {/* Table */}61 <div className={clsx("table-wrap", mobile === "cards" && "hidden sm:block")} style={maxHeight ? { maxHeight, overflowY: "auto" } : undefined}>62 <table className="table">63 <thead>64 <tr>65 {columns.map((c) => (66 <th key={c.key} className={clsx(c.num && "text-right", c.hideSm && "hidden md:table-cell", c.className)}>67 {c.header}68 </th>69 ))}70 </tr>71 </thead>72 <tbody>73 {rows.map((r, i) => (74 <tr key={rowKey(r, i)} className={clsx(onRowClick && "cursor-pointer")} onClick={onRowClick ? () => onRowClick(r) : undefined}>75 {columns.map((c) => (76 <td key={c.key} className={clsx(c.num && "num", c.mono && "font-mono text-xs", c.hideSm && "hidden md:table-cell", c.className)}>77 {cell(c, r, i)}78 </td>79 ))}80 </tr>81 ))}82 </tbody>83 </table>84 </div>85 {footer && <div className="mt-2 flex items-center justify-between gap-2 text-xs text-ink-3">{footer}</div>}86 </div>87 );88}89