Overhaul the Dench web app with a comprehensive visual redesign and several major feature additions across the chat interface, workspace, and agent runtime layer. Theme & Design System - Replace the dark-only palette with a full light/dark theme system that respects system preference via localStorage + inline script (no FOUC). - Introduce new design tokens: glassmorphism surfaces, semantic colors (success/warning/error/info), object-type chip palettes, and a tiered shadow scale (sm/md/lg/xl). - Add Instrument Serif + Inter via Google Fonts for a refined typographic hierarchy; headings use the serif face, body uses Inter. - Rebrand UI from "Ironclaw" to "Dench" across the landing page and metadata. Chat & Chain-of-Thought - Rewrite the chain-of-thought component with inline media detection and rendering — images, video, audio, and PDFs referenced in agent output are now displayed directly in the conversation thread. - Add status indicator parts (e.g. "Preparing response...", "Optimizing session context...") that render as subtle activity badges instead of verbose reasoning blocks. - Integrate react-markdown with remark-gfm for proper markdown rendering in assistant messages (tables, strikethrough, autolinks, etc.). - Improve report-block splitting and lazy-loaded ReportCard rendering. Workspace - Introduce @tanstack/react-table for the object table, replacing the hand-rolled table with full column sorting, fuzzy filtering via match-sorter-utils, row selection, and bulk actions. - Add a new media viewer component for in-workspace image/video/PDF preview. - New API routes: bulk-delete entries, field management (CRUD + reorder), raw-file serving endpoint for media assets. - Redesign workspace sidebar, empty state, and entry detail modal with the new theme tokens and improved layout. Agent Runtime - Switch web agent execution from --local to gateway-routed mode so concurrent chat threads share the gateway's lane-based concurrency system, eliminating cross-process file-lock contention. - Advertise "tool-events" capability during WebSocket handshake so the gateway streams tool start/update/result events to the UI. - Add new agent callback hooks: onLifecycleStart, onCompactionStart/End, and onToolUpdate for richer real-time feedback. - Forward media URLs emitted by agent events into the chat stream. Dependencies - Add @tanstack/match-sorter-utils and @tanstack/react-table to the web app. Published as ironclaw@2026.2.10-1. Co-authored-by: Cursor <cursoragent@cursor.com>
807 lines
25 KiB
TypeScript
807 lines
25 KiB
TypeScript
"use client";
|
|
|
|
import React, { useState, useCallback, useMemo, useRef, useEffect } from "react";
|
|
import {
|
|
useReactTable,
|
|
getCoreRowModel,
|
|
getSortedRowModel,
|
|
getFilteredRowModel,
|
|
getPaginationRowModel,
|
|
flexRender,
|
|
type ColumnDef,
|
|
type SortingState,
|
|
type ColumnFiltersState,
|
|
type VisibilityState,
|
|
type Row,
|
|
type OnChangeFn,
|
|
type PaginationState,
|
|
} from "@tanstack/react-table";
|
|
import {
|
|
DndContext,
|
|
closestCenter,
|
|
type DragEndEvent,
|
|
PointerSensor,
|
|
useSensor,
|
|
useSensors,
|
|
} from "@dnd-kit/core";
|
|
import {
|
|
SortableContext,
|
|
horizontalListSortingStrategy,
|
|
useSortable,
|
|
arrayMove,
|
|
} from "@dnd-kit/sortable";
|
|
import { CSS } from "@dnd-kit/utilities";
|
|
import { rankItem } from "@tanstack/match-sorter-utils";
|
|
|
|
/* ─── Types ─── */
|
|
|
|
export type RowAction<TData> = {
|
|
label: string;
|
|
onClick?: (row: TData) => void;
|
|
icon?: React.ReactNode;
|
|
variant?: "default" | "destructive";
|
|
};
|
|
|
|
export type DataTableProps<TData, TValue> = {
|
|
columns: ColumnDef<TData, TValue>[];
|
|
data: TData[];
|
|
loading?: boolean;
|
|
// search
|
|
searchPlaceholder?: string;
|
|
enableGlobalFilter?: boolean;
|
|
// sorting
|
|
enableSorting?: boolean;
|
|
// row selection
|
|
enableRowSelection?: boolean;
|
|
rowSelection?: Record<string, boolean>;
|
|
onRowSelectionChange?: OnChangeFn<Record<string, boolean>>;
|
|
bulkActions?: React.ReactNode;
|
|
// column features
|
|
enableColumnReordering?: boolean;
|
|
onColumnReorder?: (newOrder: string[]) => void;
|
|
initialColumnVisibility?: VisibilityState;
|
|
// pagination
|
|
pageSize?: number;
|
|
// actions
|
|
onRefresh?: () => void;
|
|
onAdd?: () => void;
|
|
addButtonLabel?: string;
|
|
onRowClick?: (row: TData, index: number) => void;
|
|
rowActions?: (row: TData) => RowAction<TData>[];
|
|
// toolbar
|
|
toolbarExtra?: React.ReactNode;
|
|
title?: string;
|
|
titleIcon?: React.ReactNode;
|
|
// sticky
|
|
stickyFirstColumn?: boolean;
|
|
};
|
|
|
|
/* ─── Fuzzy filter ─── */
|
|
|
|
function fuzzyFilter(
|
|
row: Row<unknown>,
|
|
columnId: string,
|
|
filterValue: string,
|
|
) {
|
|
const result = rankItem(row.getValue(columnId), filterValue);
|
|
return result.passed;
|
|
}
|
|
|
|
/* ─── Sortable header cell (DnD) ─── */
|
|
|
|
function SortableHeader({
|
|
id,
|
|
children,
|
|
style,
|
|
className,
|
|
}: {
|
|
id: string;
|
|
children: React.ReactNode;
|
|
style?: React.CSSProperties;
|
|
className?: string;
|
|
}) {
|
|
const {
|
|
attributes,
|
|
listeners,
|
|
setNodeRef,
|
|
transform,
|
|
transition,
|
|
isDragging,
|
|
} = useSortable({ id });
|
|
|
|
const dragStyle: React.CSSProperties = {
|
|
...style,
|
|
transform: CSS.Translate.toString(transform),
|
|
transition,
|
|
opacity: isDragging ? 0.5 : 1,
|
|
cursor: "grab",
|
|
};
|
|
|
|
return (
|
|
<th
|
|
ref={setNodeRef}
|
|
style={dragStyle}
|
|
className={className}
|
|
{...attributes}
|
|
{...listeners}
|
|
>
|
|
{children}
|
|
</th>
|
|
);
|
|
}
|
|
|
|
/* ─── Sort icon ─── */
|
|
|
|
function SortIcon({ direction }: { direction: "asc" | "desc" | false }) {
|
|
if (!direction) {
|
|
return (
|
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" style={{ opacity: 0.25 }}>
|
|
<path d="m7 15 5 5 5-5" /><path d="m7 9 5-5 5 5" />
|
|
</svg>
|
|
);
|
|
}
|
|
return (
|
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
{direction === "asc" ? <path d="m5 12 7-7 7 7" /> : <path d="m19 12-7 7-7-7" />}
|
|
</svg>
|
|
);
|
|
}
|
|
|
|
/* ─── Main component ─── */
|
|
|
|
export function DataTable<TData, TValue>({
|
|
columns,
|
|
data,
|
|
loading = false,
|
|
searchPlaceholder = "Search...",
|
|
enableGlobalFilter = true,
|
|
enableSorting = true,
|
|
enableRowSelection = false,
|
|
rowSelection: externalRowSelection,
|
|
onRowSelectionChange,
|
|
bulkActions,
|
|
enableColumnReordering = false,
|
|
onColumnReorder,
|
|
initialColumnVisibility,
|
|
pageSize: defaultPageSize = 100,
|
|
onRefresh,
|
|
onAdd,
|
|
addButtonLabel = "+ Add",
|
|
onRowClick,
|
|
rowActions,
|
|
toolbarExtra,
|
|
title,
|
|
titleIcon,
|
|
stickyFirstColumn: stickyFirstProp = true,
|
|
}: DataTableProps<TData, TValue>) {
|
|
const [sorting, setSorting] = useState<SortingState>([]);
|
|
const [globalFilter, setGlobalFilter] = useState("");
|
|
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
|
|
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>(initialColumnVisibility ?? {});
|
|
const [internalRowSelection, setInternalRowSelection] = useState<Record<string, boolean>>({});
|
|
const [showColumnsMenu, setShowColumnsMenu] = useState(false);
|
|
const [stickyFirstColumn, setStickyFirstColumn] = useState(stickyFirstProp);
|
|
const [isScrolled, setIsScrolled] = useState(false);
|
|
const [pagination, setPagination] = useState<PaginationState>({ pageIndex: 0, pageSize: defaultPageSize });
|
|
const columnsMenuRef = useRef<HTMLDivElement>(null);
|
|
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
|
|
|
const rowSelectionState = externalRowSelection !== undefined ? externalRowSelection : internalRowSelection;
|
|
|
|
// Extract column ID from ColumnDef
|
|
const getColumnId = useCallback((c: ColumnDef<TData, TValue>): string => {
|
|
if ("id" in c && typeof c.id === "string") {return c.id;}
|
|
if ("accessorKey" in c && typeof c.accessorKey === "string") {return c.accessorKey;}
|
|
return "";
|
|
}, []);
|
|
|
|
// Column order for DnD — include "select" at start and "actions" at end
|
|
// so TanStack doesn't push them to the end of the table
|
|
const buildColumnOrder = useCallback(
|
|
(dataCols: ColumnDef<TData, TValue>[]) => {
|
|
const dataOrder = dataCols.map(getColumnId);
|
|
const order: string[] = [];
|
|
if (enableRowSelection) {order.push("select");}
|
|
order.push(...dataOrder);
|
|
if (rowActions) {order.push("actions");}
|
|
return order;
|
|
},
|
|
[getColumnId, enableRowSelection, rowActions],
|
|
);
|
|
|
|
const [columnOrder, setColumnOrder] = useState<string[]>(() =>
|
|
buildColumnOrder(columns),
|
|
);
|
|
|
|
// Update column order when columns change
|
|
useEffect(() => {
|
|
setColumnOrder(buildColumnOrder(columns));
|
|
}, [columns, buildColumnOrder]);
|
|
|
|
// DnD sensors
|
|
const sensors = useSensors(
|
|
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
|
|
);
|
|
|
|
const handleDragEnd = useCallback(
|
|
(event: DragEndEvent) => {
|
|
const { active, over } = event;
|
|
if (over && active.id !== over.id) {
|
|
setColumnOrder((old) => {
|
|
const oldIndex = old.indexOf(active.id as string);
|
|
const newIndex = old.indexOf(over.id as string);
|
|
const newOrder = arrayMove(old, oldIndex, newIndex);
|
|
onColumnReorder?.(newOrder);
|
|
return newOrder;
|
|
});
|
|
}
|
|
},
|
|
[onColumnReorder],
|
|
);
|
|
|
|
// Scroll tracking for sticky column shadow
|
|
const handleScroll = useCallback((e: React.UIEvent<HTMLDivElement>) => {
|
|
setIsScrolled(e.currentTarget.scrollLeft > 0);
|
|
}, []);
|
|
|
|
// Close columns menu on click outside
|
|
useEffect(() => {
|
|
function handleClick(e: MouseEvent) {
|
|
if (columnsMenuRef.current && !columnsMenuRef.current.contains(e.target as Node)) {
|
|
setShowColumnsMenu(false);
|
|
}
|
|
}
|
|
if (showColumnsMenu) {
|
|
document.addEventListener("mousedown", handleClick);
|
|
return () => document.removeEventListener("mousedown", handleClick);
|
|
}
|
|
}, [showColumnsMenu]);
|
|
|
|
// Build selection column
|
|
const selectionColumn: ColumnDef<TData> | null = enableRowSelection
|
|
? {
|
|
id: "select",
|
|
header: ({ table }) => (
|
|
<input
|
|
type="checkbox"
|
|
checked={table.getIsAllPageRowsSelected()}
|
|
onChange={table.getToggleAllPageRowsSelectedHandler()}
|
|
className="w-3.5 h-3.5 rounded accent-[var(--color-accent)] cursor-pointer"
|
|
/>
|
|
),
|
|
cell: ({ row }) => (
|
|
<input
|
|
type="checkbox"
|
|
checked={row.getIsSelected()}
|
|
onChange={row.getToggleSelectedHandler()}
|
|
onClick={(e) => e.stopPropagation()}
|
|
className="w-3.5 h-3.5 rounded accent-[var(--color-accent)] cursor-pointer"
|
|
/>
|
|
),
|
|
size: 40,
|
|
enableSorting: false,
|
|
enableHiding: false,
|
|
}
|
|
: null;
|
|
|
|
// Build actions column
|
|
const actionsColumn: ColumnDef<TData> | null = rowActions
|
|
? {
|
|
id: "actions",
|
|
header: () => null,
|
|
cell: ({ row }) => (
|
|
<RowActionsMenu
|
|
row={row.original}
|
|
actions={rowActions(row.original)}
|
|
/>
|
|
),
|
|
size: 48,
|
|
enableSorting: false,
|
|
enableHiding: false,
|
|
}
|
|
: null;
|
|
|
|
const allColumns = useMemo(() => {
|
|
const cols: ColumnDef<TData, TValue>[] = [];
|
|
if (selectionColumn) {cols.push(selectionColumn as ColumnDef<TData, TValue>);}
|
|
cols.push(...columns);
|
|
if (actionsColumn) {cols.push(actionsColumn as ColumnDef<TData, TValue>);}
|
|
return cols;
|
|
}, [columns, selectionColumn, actionsColumn]);
|
|
|
|
const table = useReactTable({
|
|
data,
|
|
columns: allColumns,
|
|
state: {
|
|
sorting,
|
|
globalFilter,
|
|
columnFilters,
|
|
columnVisibility,
|
|
rowSelection: rowSelectionState,
|
|
columnOrder: enableColumnReordering ? columnOrder : undefined,
|
|
pagination,
|
|
},
|
|
onSortingChange: setSorting,
|
|
onGlobalFilterChange: setGlobalFilter,
|
|
onColumnFiltersChange: setColumnFilters,
|
|
onColumnVisibilityChange: setColumnVisibility,
|
|
onRowSelectionChange: (updater) => {
|
|
if (onRowSelectionChange) {
|
|
onRowSelectionChange(updater);
|
|
} else {
|
|
setInternalRowSelection(updater);
|
|
}
|
|
},
|
|
onPaginationChange: setPagination,
|
|
getCoreRowModel: getCoreRowModel(),
|
|
getSortedRowModel: enableSorting ? getSortedRowModel() : undefined,
|
|
getFilteredRowModel: getFilteredRowModel(),
|
|
getPaginationRowModel: getPaginationRowModel(),
|
|
enableRowSelection,
|
|
enableSorting,
|
|
globalFilterFn: fuzzyFilter,
|
|
columnResizeMode: "onChange",
|
|
});
|
|
|
|
const selectedCount = Object.keys(rowSelectionState).filter((k) => rowSelectionState[k]).length;
|
|
const visibleColumns = table.getVisibleFlatColumns().filter((c) => c.id !== "select" && c.id !== "actions");
|
|
|
|
// ─── Render ───
|
|
|
|
return (
|
|
<div className="flex flex-col h-full">
|
|
{/* Toolbar */}
|
|
<div
|
|
className="flex items-center gap-2 px-4 py-2.5 flex-shrink-0 flex-wrap"
|
|
style={{ borderBottom: "1px solid var(--color-border)" }}
|
|
>
|
|
{title && (
|
|
<div className="flex items-center gap-2 mr-2">
|
|
{titleIcon}
|
|
<span className="text-sm font-semibold" style={{ color: "var(--color-text)" }}>
|
|
{title}
|
|
</span>
|
|
</div>
|
|
)}
|
|
|
|
{/* Search */}
|
|
{enableGlobalFilter && (
|
|
<div className="relative flex-1 min-w-[180px] max-w-[320px]">
|
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="absolute left-3 top-1/2 -translate-y-1/2" style={{ color: "var(--color-text-muted)" }}>
|
|
<circle cx="11" cy="11" r="8" /><path d="m21 21-4.3-4.3" />
|
|
</svg>
|
|
<input
|
|
type="text"
|
|
value={globalFilter}
|
|
onChange={(e) => setGlobalFilter(e.target.value)}
|
|
placeholder={searchPlaceholder}
|
|
className="w-full pl-9 pr-3 py-1.5 text-xs rounded-full outline-none"
|
|
style={{
|
|
background: "var(--color-surface-hover)",
|
|
color: "var(--color-text)",
|
|
border: "1px solid var(--color-border)",
|
|
}}
|
|
/>
|
|
{globalFilter && (
|
|
<button
|
|
type="button"
|
|
onClick={() => setGlobalFilter("")}
|
|
className="absolute right-2.5 top-1/2 -translate-y-1/2"
|
|
style={{ color: "var(--color-text-muted)" }}
|
|
>
|
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M18 6 6 18" /><path d="m6 6 12 12" /></svg>
|
|
</button>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Bulk actions */}
|
|
{selectedCount > 0 && bulkActions && (
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-xs font-medium" style={{ color: "var(--color-text-muted)" }}>
|
|
{selectedCount} selected
|
|
</span>
|
|
{bulkActions}
|
|
</div>
|
|
)}
|
|
|
|
<div className="flex-1" />
|
|
|
|
{toolbarExtra}
|
|
|
|
{/* Refresh */}
|
|
{onRefresh && (
|
|
<button
|
|
type="button"
|
|
onClick={onRefresh}
|
|
className="p-1.5 rounded-lg"
|
|
style={{ color: "var(--color-text-muted)" }}
|
|
title="Refresh"
|
|
>
|
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
<path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8" /><path d="M21 3v5h-5" /><path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16" /><path d="M3 21v-5h5" />
|
|
</svg>
|
|
</button>
|
|
)}
|
|
|
|
{/* Columns menu */}
|
|
<div className="relative" ref={columnsMenuRef}>
|
|
<button
|
|
type="button"
|
|
onClick={() => setShowColumnsMenu((v) => !v)}
|
|
className="flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg text-xs font-medium"
|
|
style={{
|
|
color: "var(--color-text-muted)",
|
|
border: "1px solid var(--color-border)",
|
|
background: showColumnsMenu ? "var(--color-surface-hover)" : "transparent",
|
|
}}
|
|
>
|
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
<rect width="18" height="18" x="3" y="3" rx="2" /><path d="M9 3v18" /><path d="M15 3v18" />
|
|
</svg>
|
|
Columns
|
|
</button>
|
|
{showColumnsMenu && (
|
|
<div
|
|
className="absolute right-0 top-full mt-1 z-50 min-w-[200px] rounded-xl overflow-hidden py-1"
|
|
style={{
|
|
background: "var(--color-surface)",
|
|
border: "1px solid var(--color-border)",
|
|
boxShadow: "var(--shadow-lg)",
|
|
}}
|
|
>
|
|
{/* Sticky first col toggle */}
|
|
<label
|
|
className="flex items-center gap-2 px-3 py-2 text-xs cursor-pointer"
|
|
style={{ color: "var(--color-text-muted)", borderBottom: "1px solid var(--color-border)" }}
|
|
>
|
|
<input
|
|
type="checkbox"
|
|
checked={stickyFirstColumn}
|
|
onChange={() => setStickyFirstColumn((v) => !v)}
|
|
className="w-3.5 h-3.5 rounded accent-[var(--color-accent)]"
|
|
/>
|
|
Freeze first column
|
|
</label>
|
|
{visibleColumns.length === 0 ? (
|
|
<div className="px-3 py-2 text-xs" style={{ color: "var(--color-text-muted)" }}>
|
|
No toggleable columns
|
|
</div>
|
|
) : (
|
|
table.getAllLeafColumns()
|
|
.filter((c) => c.id !== "select" && c.id !== "actions" && c.getCanHide())
|
|
.map((column) => (
|
|
<label
|
|
key={column.id}
|
|
className="flex items-center gap-2 px-3 py-1.5 text-xs cursor-pointer"
|
|
style={{ color: "var(--color-text)" }}
|
|
>
|
|
<input
|
|
type="checkbox"
|
|
checked={column.getIsVisible()}
|
|
onChange={column.getToggleVisibilityHandler()}
|
|
className="w-3.5 h-3.5 rounded accent-[var(--color-accent)]"
|
|
/>
|
|
{typeof column.columnDef.header === "string"
|
|
? column.columnDef.header
|
|
: column.id}
|
|
</label>
|
|
))
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Add button */}
|
|
{onAdd && (
|
|
<button
|
|
type="button"
|
|
onClick={onAdd}
|
|
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium"
|
|
style={{
|
|
background: "var(--color-accent)",
|
|
color: "white",
|
|
}}
|
|
>
|
|
{addButtonLabel}
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
{/* Table */}
|
|
<div
|
|
ref={scrollContainerRef}
|
|
className="flex-1 overflow-auto"
|
|
onScroll={handleScroll}
|
|
>
|
|
{loading ? (
|
|
<LoadingSkeleton columnCount={allColumns.length} />
|
|
) : data.length === 0 ? (
|
|
<div className="flex flex-col items-center justify-center py-20 gap-3">
|
|
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" style={{ color: "var(--color-text-muted)", opacity: 0.4 }}>
|
|
<rect width="18" height="18" x="3" y="3" rx="2" /><path d="M3 9h18" /><path d="M3 15h18" /><path d="M9 3v18" />
|
|
</svg>
|
|
<p className="text-sm" style={{ color: "var(--color-text-muted)" }}>No data</p>
|
|
</div>
|
|
) : (
|
|
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
|
|
<table className="w-full text-sm" style={{ borderCollapse: "separate", borderSpacing: 0 }}>
|
|
<thead>
|
|
{table.getHeaderGroups().map((headerGroup) => (
|
|
<tr key={headerGroup.id}>
|
|
<SortableContext items={columnOrder} strategy={horizontalListSortingStrategy}>
|
|
{headerGroup.headers.map((header, colIdx) => {
|
|
const isFirstData = colIdx === (enableRowSelection ? 1 : 0);
|
|
const isSticky = stickyFirstColumn && isFirstData;
|
|
const isSelectCol = header.id === "select";
|
|
const isActionsCol = header.id === "actions";
|
|
const canSort = header.column.getCanSort();
|
|
|
|
const headerStyle: React.CSSProperties = {
|
|
borderColor: "var(--color-border)",
|
|
background: "var(--color-surface)",
|
|
position: "sticky",
|
|
top: 0,
|
|
zIndex: isSticky || isSelectCol ? 31 : 30,
|
|
...(isSticky ? { left: enableRowSelection ? 40 : 0, boxShadow: isScrolled ? "4px 0 8px -2px rgba(0,0,0,0.08)" : "none" } : {}),
|
|
...(isSelectCol ? { left: 0, position: "sticky", zIndex: 31, width: 40 } : {}),
|
|
width: header.getSize(),
|
|
};
|
|
|
|
const content = header.isPlaceholder
|
|
? null
|
|
: flexRender(header.column.columnDef.header, header.getContext());
|
|
|
|
if (enableColumnReordering && !isSelectCol && !isActionsCol) {
|
|
return (
|
|
<SortableHeader
|
|
key={header.id}
|
|
id={header.id}
|
|
style={headerStyle}
|
|
className="text-left px-3 py-2.5 font-medium text-xs uppercase tracking-wider whitespace-nowrap border-b select-none"
|
|
>
|
|
<span
|
|
className={`flex items-center gap-1 ${canSort ? "cursor-pointer" : ""}`}
|
|
onClick={canSort ? header.column.getToggleSortingHandler() : undefined}
|
|
>
|
|
{content}
|
|
{canSort && <SortIcon direction={header.column.getIsSorted()} />}
|
|
</span>
|
|
</SortableHeader>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<th
|
|
key={header.id}
|
|
style={headerStyle}
|
|
className="text-left px-3 py-2.5 font-medium text-xs uppercase tracking-wider whitespace-nowrap border-b select-none"
|
|
>
|
|
<span
|
|
className={`flex items-center gap-1 ${canSort ? "cursor-pointer" : ""}`}
|
|
onClick={canSort ? header.column.getToggleSortingHandler() : undefined}
|
|
style={{ color: "var(--color-text-muted)" }}
|
|
>
|
|
{content}
|
|
{canSort && <SortIcon direction={header.column.getIsSorted()} />}
|
|
</span>
|
|
</th>
|
|
);
|
|
})}
|
|
</SortableContext>
|
|
</tr>
|
|
))}
|
|
</thead>
|
|
<tbody>
|
|
{table.getRowModel().rows.map((row, rowIdx) => {
|
|
const isSelected = row.getIsSelected();
|
|
return (
|
|
<tr
|
|
key={row.id}
|
|
className={`transition-colors duration-75 ${onRowClick ? "cursor-pointer" : ""}`}
|
|
style={{
|
|
background: isSelected
|
|
? "var(--color-accent-light)"
|
|
: rowIdx % 2 === 0
|
|
? "transparent"
|
|
: "var(--color-surface)",
|
|
}}
|
|
onClick={() => onRowClick?.(row.original, rowIdx)}
|
|
onMouseEnter={(e) => {
|
|
if (!isSelected)
|
|
{(e.currentTarget as HTMLElement).style.background = "var(--color-surface-hover)";}
|
|
}}
|
|
onMouseLeave={(e) => {
|
|
if (!isSelected)
|
|
{(e.currentTarget as HTMLElement).style.background =
|
|
rowIdx % 2 === 0 ? "transparent" : "var(--color-surface)";}
|
|
}}
|
|
>
|
|
{row.getVisibleCells().map((cell, colIdx) => {
|
|
const isFirstData = colIdx === (enableRowSelection ? 1 : 0);
|
|
const isSticky = stickyFirstColumn && isFirstData;
|
|
const isSelectCol = cell.column.id === "select";
|
|
|
|
const cellStyle: React.CSSProperties = {
|
|
borderColor: "var(--color-border)",
|
|
...(isSticky
|
|
? {
|
|
position: "sticky" as const,
|
|
left: enableRowSelection ? 40 : 0,
|
|
zIndex: 20,
|
|
background: isSelected
|
|
? "var(--color-accent-light)"
|
|
: "var(--color-bg)",
|
|
boxShadow: isScrolled ? "4px 0 8px -2px rgba(0,0,0,0.08)" : "none",
|
|
}
|
|
: {}),
|
|
...(isSelectCol
|
|
? {
|
|
position: "sticky" as const,
|
|
left: 0,
|
|
zIndex: 20,
|
|
background: isSelected
|
|
? "var(--color-accent-light)"
|
|
: "var(--color-bg)",
|
|
width: 40,
|
|
}
|
|
: {}),
|
|
};
|
|
|
|
return (
|
|
<td
|
|
key={cell.id}
|
|
className="px-3 py-2 border-b whitespace-nowrap"
|
|
style={cellStyle}
|
|
>
|
|
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
|
</td>
|
|
);
|
|
})}
|
|
</tr>
|
|
);
|
|
})}
|
|
</tbody>
|
|
</table>
|
|
</DndContext>
|
|
)}
|
|
</div>
|
|
|
|
{/* Pagination footer */}
|
|
{!loading && data.length > 0 && (
|
|
<div
|
|
className="flex items-center justify-between px-4 py-2 text-xs flex-shrink-0"
|
|
style={{
|
|
borderTop: "1px solid var(--color-border)",
|
|
color: "var(--color-text-muted)",
|
|
}}
|
|
>
|
|
<span>
|
|
Showing {table.getRowModel().rows.length} of {data.length} results
|
|
{selectedCount > 0 && ` (${selectedCount} selected)`}
|
|
</span>
|
|
<div className="flex items-center gap-2">
|
|
<span>Rows per page</span>
|
|
<select
|
|
value={pagination.pageSize}
|
|
onChange={(e) => setPagination((p) => ({ ...p, pageSize: Number(e.target.value), pageIndex: 0 }))}
|
|
className="px-1.5 py-0.5 rounded-md text-xs outline-none"
|
|
style={{
|
|
background: "var(--color-surface-hover)",
|
|
color: "var(--color-text)",
|
|
border: "1px solid var(--color-border)",
|
|
}}
|
|
>
|
|
{[20, 50, 100, 250, 500].map((size) => (
|
|
<option key={size} value={size}>{size}</option>
|
|
))}
|
|
</select>
|
|
<span>
|
|
Page {pagination.pageIndex + 1} of {table.getPageCount()}
|
|
</span>
|
|
<div className="flex gap-0.5">
|
|
<PaginationButton onClick={() => setPagination((p) => ({ ...p, pageIndex: 0 }))} disabled={!table.getCanPreviousPage()} label="«" />
|
|
<PaginationButton onClick={() => table.previousPage()} disabled={!table.getCanPreviousPage()} label="‹" />
|
|
<PaginationButton onClick={() => table.nextPage()} disabled={!table.getCanNextPage()} label="›" />
|
|
<PaginationButton onClick={() => setPagination((p) => ({ ...p, pageIndex: table.getPageCount() - 1 }))} disabled={!table.getCanNextPage()} label="»" />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/* ─── Sub-components ─── */
|
|
|
|
function PaginationButton({ onClick, disabled, label }: { onClick: () => void; disabled: boolean; label: string }) {
|
|
return (
|
|
<button
|
|
type="button"
|
|
onClick={onClick}
|
|
disabled={disabled}
|
|
className="w-6 h-6 rounded flex items-center justify-center text-xs disabled:opacity-30"
|
|
style={{ color: "var(--color-text-muted)", border: "1px solid var(--color-border)" }}
|
|
// biome-ignore lint: using html entity label
|
|
dangerouslySetInnerHTML={{ __html: label }}
|
|
/>
|
|
);
|
|
}
|
|
|
|
function RowActionsMenu<TData>({ row, actions }: { row: TData; actions: RowAction<TData>[] }) {
|
|
const [open, setOpen] = useState(false);
|
|
const ref = useRef<HTMLDivElement>(null);
|
|
|
|
useEffect(() => {
|
|
function handleClick(e: MouseEvent) {
|
|
if (ref.current && !ref.current.contains(e.target as Node)) {
|
|
setOpen(false);
|
|
}
|
|
}
|
|
if (open) {
|
|
document.addEventListener("mousedown", handleClick);
|
|
return () => document.removeEventListener("mousedown", handleClick);
|
|
}
|
|
}, [open]);
|
|
|
|
return (
|
|
<div className="relative" ref={ref}>
|
|
<button
|
|
type="button"
|
|
onClick={(e) => { e.stopPropagation(); setOpen((v) => !v); }}
|
|
className="p-1 rounded-md"
|
|
style={{ color: "var(--color-text-muted)" }}
|
|
>
|
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><circle cx="12" cy="5" r="1.5" /><circle cx="12" cy="12" r="1.5" /><circle cx="12" cy="19" r="1.5" /></svg>
|
|
</button>
|
|
{open && (
|
|
<div
|
|
className="absolute right-0 top-full mt-1 z-50 min-w-[140px] rounded-xl overflow-hidden py-1"
|
|
style={{
|
|
background: "var(--color-surface)",
|
|
border: "1px solid var(--color-border)",
|
|
boxShadow: "var(--shadow-lg)",
|
|
}}
|
|
>
|
|
{actions.map((action, i) => (
|
|
<button
|
|
key={i}
|
|
type="button"
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
action.onClick?.(row);
|
|
setOpen(false);
|
|
}}
|
|
className="flex items-center gap-2 w-full px-3 py-1.5 text-xs text-left"
|
|
style={{
|
|
color: action.variant === "destructive" ? "var(--color-error)" : "var(--color-text)",
|
|
}}
|
|
>
|
|
{action.icon}
|
|
{action.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function LoadingSkeleton({ columnCount }: { columnCount: number }) {
|
|
return (
|
|
<div className="p-4 space-y-2">
|
|
{Array.from({ length: 12 }).map((_, i) => (
|
|
<div key={i} className="flex gap-3">
|
|
{Array.from({ length: Math.min(columnCount, 6) }).map((_, j) => (
|
|
<div
|
|
key={j}
|
|
className="h-8 rounded-lg animate-pulse flex-1"
|
|
style={{ background: "var(--color-surface-hover)", animationDelay: `${j * 50}ms` }}
|
|
/>
|
|
))}
|
|
</div>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|