bake canonical build harness (deterministic /ship for forks) [portfolio]
This commit is contained in:
@@ -0,0 +1,214 @@
|
||||
// ---------------------------------------------------------------------------
|
||||
// DataTable — pre-built sortable, paginated table wired to AnyDB
|
||||
// ---------------------------------------------------------------------------
|
||||
// PRE-INSTALLED. CodeWriterAgent just uses:
|
||||
// import { DataTable } from "@/components/DataTable"
|
||||
// <DataTable
|
||||
// collection="orders"
|
||||
// columns={[
|
||||
// { key: 'customer', label: 'Customer' },
|
||||
// { key: 'total_cents', label: 'Amount', render: (v) => `$${(v/100).toFixed(2)}` },
|
||||
// { key: 'status', label: 'Status', render: (v) => <Badge>{v}</Badge> },
|
||||
// { key: 'created_at', label: 'Date', render: (v) => new Date(v).toLocaleDateString() },
|
||||
// ]}
|
||||
// filter={{ status: 'active' }}
|
||||
// pageSize={10}
|
||||
// searchField="customer"
|
||||
// />
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
import { useState, useMemo } from "react";
|
||||
import { useCollection } from "@/hooks/use-anydb";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { ChevronUp, ChevronDown, Search, ChevronLeft, ChevronRight } from "lucide-react";
|
||||
|
||||
interface Column<T = any> {
|
||||
key: string;
|
||||
label: string;
|
||||
sortable?: boolean;
|
||||
render?: (value: any, row: T) => React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
interface DataTableProps<T = any> {
|
||||
collection: string;
|
||||
columns: Column<T>[];
|
||||
filter?: Record<string, any>;
|
||||
pageSize?: number;
|
||||
searchField?: string;
|
||||
searchPlaceholder?: string;
|
||||
onRowClick?: (row: T) => void;
|
||||
emptyMessage?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function DataTable<T extends Record<string, any> = any>({
|
||||
collection,
|
||||
columns,
|
||||
filter = {},
|
||||
pageSize = 10,
|
||||
searchField,
|
||||
searchPlaceholder = "Search...",
|
||||
onRowClick,
|
||||
emptyMessage = "No data found",
|
||||
className = "",
|
||||
}: DataTableProps<T>) {
|
||||
const [page, setPage] = useState(0);
|
||||
const [sortKey, setSortKey] = useState<string | null>(null);
|
||||
const [sortDir, setSortDir] = useState<1 | -1>(-1);
|
||||
const [search, setSearch] = useState("");
|
||||
|
||||
const queryFilter = useMemo(() => {
|
||||
const f = { ...filter };
|
||||
if (searchField && search.trim()) {
|
||||
f[searchField] = { $regex: search.trim(), $options: "i" };
|
||||
}
|
||||
return f;
|
||||
}, [filter, searchField, search]);
|
||||
|
||||
const sort = sortKey ? { [sortKey]: sortDir } : { created_at: -1 as const };
|
||||
|
||||
const { data, total, loading, error } = useCollection<T>(collection, {
|
||||
filter: queryFilter,
|
||||
sort,
|
||||
limit: pageSize,
|
||||
skip: page * pageSize,
|
||||
});
|
||||
|
||||
const totalPages = Math.ceil(total / pageSize);
|
||||
|
||||
const handleSort = (key: string) => {
|
||||
if (sortKey === key) {
|
||||
setSortDir(d => (d === 1 ? -1 : 1));
|
||||
} else {
|
||||
setSortKey(key);
|
||||
setSortDir(-1);
|
||||
}
|
||||
setPage(0);
|
||||
};
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="text-center py-8 text-destructive">
|
||||
<p>Failed to load data: {error}</p>
|
||||
<Button variant="outline" size="sm" className="mt-2" onClick={() => window.location.reload()}>
|
||||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`rounded-lg border bg-card ${className}`}>
|
||||
{/* Search bar */}
|
||||
{searchField && (
|
||||
<div className="p-4 border-b">
|
||||
<div className="relative max-w-sm">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder={searchPlaceholder}
|
||||
value={search}
|
||||
onChange={(e) => { setSearch(e.target.value); setPage(0); }}
|
||||
className="pl-9 h-10"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Table */}
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b bg-muted/50">
|
||||
{columns.map((col) => (
|
||||
<th
|
||||
key={col.key}
|
||||
className={`px-4 py-3 text-left font-medium text-muted-foreground ${
|
||||
col.sortable !== false ? "cursor-pointer select-none hover:text-foreground transition-colors" : ""
|
||||
} ${col.className || ""}`}
|
||||
onClick={() => col.sortable !== false && handleSort(col.key)}
|
||||
>
|
||||
<div className="flex items-center gap-1">
|
||||
{col.label}
|
||||
{col.sortable !== false && sortKey === col.key && (
|
||||
sortDir === 1 ? <ChevronUp className="h-3 w-3" /> : <ChevronDown className="h-3 w-3" />
|
||||
)}
|
||||
</div>
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{loading ? (
|
||||
Array.from({ length: pageSize }).map((_, i) => (
|
||||
<tr key={i} className="border-b">
|
||||
{columns.map((col) => (
|
||||
<td key={col.key} className="px-4 py-3">
|
||||
<Skeleton className="h-5 w-full" />
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))
|
||||
) : data.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={columns.length} className="px-4 py-12 text-center text-muted-foreground">
|
||||
{emptyMessage}
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
data.map((row, i) => (
|
||||
<tr
|
||||
key={row._id || i}
|
||||
className={`border-b transition-colors hover:bg-muted/30 ${
|
||||
onRowClick ? "cursor-pointer" : ""
|
||||
}`}
|
||||
onClick={() => onRowClick?.(row)}
|
||||
>
|
||||
{columns.map((col) => (
|
||||
<td key={col.key} className={`px-4 py-3 ${col.className || ""}`}>
|
||||
{col.render ? col.render(row[col.key], row) : String(row[col.key] ?? "")}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center justify-between px-4 py-3 border-t">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Showing {page * pageSize + 1}–{Math.min((page + 1) * pageSize, total)} of {total}
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={page === 0}
|
||||
onClick={() => setPage(p => p - 1)}
|
||||
className="h-8"
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<span className="text-sm tabular-nums">
|
||||
{page + 1} / {totalPages}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={page >= totalPages - 1}
|
||||
onClick={() => setPage(p => p + 1)}
|
||||
className="h-8"
|
||||
>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user