Files
nuzlocke-tracker/frontend/src/components/admin/AdminTable.tsx
Julian Tabel 42b66ee9a2
All checks were successful
CI / backend-lint (push) Successful in 10s
CI / actions-lint (push) Successful in 16s
CI / frontend-lint (push) Successful in 21s
Implement dark-first design system with Geist typography (#28)
Co-authored-by: Julian Tabel <juliantabel.jt@gmail.com>
Co-committed-by: Julian Tabel <juliantabel.jt@gmail.com>
2026-02-17 20:48:42 +01:00

153 lines
4.6 KiB
TypeScript

import { type ReactNode, useMemo, useState } from 'react'
export interface Column<T> {
header: string
accessor: (row: T) => ReactNode
className?: string
sortKey?: (row: T) => string | number
}
type SortDir = 'asc' | 'desc'
interface AdminTableProps<T> {
columns: Column<T>[]
data: T[]
isLoading?: boolean
emptyMessage?: string
onRowClick?: (row: T) => void
keyFn: (row: T) => string | number
}
export function AdminTable<T>({
columns,
data,
isLoading,
emptyMessage = 'No data found.',
onRowClick,
keyFn,
}: AdminTableProps<T>) {
const [sortCol, setSortCol] = useState<string | null>(null)
const [sortDir, setSortDir] = useState<SortDir>('asc')
const handleSort = (header: string) => {
if (sortCol === header) {
if (sortDir === 'asc') {
setSortDir('desc')
} else {
// Third click: clear sort
setSortCol(null)
setSortDir('asc')
}
} else {
setSortCol(header)
setSortDir('asc')
}
}
const sortedData = useMemo(() => {
if (!sortCol) return data
const col = columns.find((c) => c.header === sortCol)
if (!col?.sortKey) return data
const key = col.sortKey
const sorted = [...data].sort((a, b) => {
const va = key(a)
const vb = key(b)
if (va < vb) return -1
if (va > vb) return 1
return 0
})
return sortDir === 'desc' ? sorted.reverse() : sorted
}, [data, sortCol, sortDir, columns])
if (isLoading) {
return (
<div className="border border-border-default rounded-lg overflow-hidden">
<table className="min-w-full divide-y divide-border-default">
<thead className="bg-surface-1">
<tr>
{columns.map((col) => (
<th
key={col.header}
className={`px-4 py-3 text-left text-xs font-medium text-text-tertiary uppercase tracking-wider ${col.className ?? ''}`}
>
{col.header}
</th>
))}
</tr>
</thead>
<tbody className="bg-surface-0 divide-y divide-border-default">
{Array.from({ length: 5 }).map((_, i) => (
<tr key={i}>
{columns.map((col) => (
<td key={col.header} className={`px-4 py-3 ${col.className ?? ''}`}>
<div className="h-4 bg-surface-3 rounded animate-pulse" />
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
)
}
if (data.length === 0) {
return (
<div className="text-center py-8 text-text-tertiary border border-border-default rounded-lg">
{emptyMessage}
</div>
)
}
return (
<div className="border border-border-default rounded-lg overflow-hidden">
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-border-default">
<thead className="bg-surface-1">
<tr>
{columns.map((col) => {
const sortable = !!col.sortKey
const active = sortCol === col.header
return (
<th
key={col.header}
onClick={sortable ? () => handleSort(col.header) : undefined}
className={`px-4 py-3 text-left text-xs font-medium text-text-tertiary uppercase tracking-wider ${col.className ?? ''} ${sortable ? 'cursor-pointer select-none hover:text-text-primary' : ''}`}
>
<span className="inline-flex items-center gap-1">
{col.header}
{sortable && active && (
<span className="text-blue-500">
{sortDir === 'asc' ? '\u2191' : '\u2193'}
</span>
)}
</span>
</th>
)
})}
</tr>
</thead>
<tbody className="bg-surface-0 divide-y divide-border-default">
{sortedData.map((row) => (
<tr
key={keyFn(row)}
onClick={onRowClick ? () => onRowClick(row) : undefined}
className={onRowClick ? 'cursor-pointer hover:bg-surface-2' : ''}
>
{columns.map((col) => (
<td
key={col.header}
className={`px-4 py-3 text-sm whitespace-nowrap ${col.className ?? ''}`}
>
{col.accessor(row)}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
</div>
)
}