spb/note-spboucher-ai
Public
1"use client";2import { useCallback, useEffect, useRef, useState } from "react";3import { DndContext, PointerSensor, useSensor, useSensors, closestCenter, type DragEndEvent } from "@dnd-kit/core";4import { SortableContext, useSortable, verticalListSortingStrategy, arrayMove } from "@dnd-kit/sortable";5import { CSS } from "@dnd-kit/utilities";6import { Plus, GripVertical, Trash2 } from "lucide-react";7import { Popover, PopoverTrigger, PopoverContent } from "@/components/ui/primitives";8import { api } from "@/lib/client/api";9import { cn } from "@/lib/utils";10import type { Property, RowOut } from "@/lib/databases/types";11import { useDb } from "../hooks";12import { Cell } from "../cells/Cell";13import { TypeIcon } from "../meta";14import { PropertyEditor } from "../PropertyEditor";15import { groupRows } from "../group";16import { OPTION_CLASS } from "../colors";1718const MIN_W = 80;19export function TableView() {20 const ctx = useDb(); const cols = ctx.visibleProps; const widths = ctx.view.config.widths ?? {};21 const [editing, setEditing] = useState<string | null>(null); const [sel, setSel] = useState<Set<string>>(new Set()); const [editProp, setEditProp] = useState<string | null>(null); const [adding, setAdding] = useState(false);22 const [localW, setLocalW] = useState<Record<string, number>>({}); const w = (p: Property) => localW[p.id] ?? widths[p.id] ?? p.width ?? (p.type === "title" ? 260 : 180);23 const gb = ctx.view.config.groupBy ? ctx.props.find((p) => p.id === ctx.view.config.groupBy) : undefined; const groups = groupRows(ctx.rows, gb);24 const canReorder = !gb && !(ctx.view.config.sorts?.length) && !ctx.search; const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 6 } }));25 const flat = groups.flatMap((g) => g.rows);26 const nav = useCallback((e: React.KeyboardEvent) => {27 const el = (e.target as HTMLElement).closest<HTMLElement>("[data-cell]"); if (!el || editing) return; const [r, c] = el.dataset.cell!.split(":").map(Number);28 const go = (nr: number, nc: number) => { const t = el.closest("table")?.querySelector<HTMLElement>(`[data-cell="${nr}:${nc}"]`); if (t) { e.preventDefault(); t.focus(); } };29 if (e.key === "ArrowDown") go(r + 1, c); else if (e.key === "ArrowUp") go(r - 1, c); else if (e.key === "ArrowRight" || (e.key === "Tab" && !e.shiftKey)) go(r, c + 1); else if (e.key === "ArrowLeft" || (e.key === "Tab" && e.shiftKey)) go(r, c - 1);30 else if (e.key === "Enter" || e.key === " ") { e.preventDefault(); const row = flat[r], p = cols[c]; if (row && p) { if (p.type === "checkbox") ctx.setCell(row.id, { [p.id]: !row.values[p.id] }); else setEditing(`${row.id}:${p.id}`); } }31 else if (e.key === "Escape") setEditing(null);32 }, [editing, flat, cols, ctx]);33 const startResize = (p: Property, e: React.PointerEvent) => { e.preventDefault(); const x0 = e.clientX, w0 = w(p); let cur = w0; const move = (ev: PointerEvent) => { cur = Math.max(MIN_W, w0 + ev.clientX - x0); setLocalW((s) => ({ ...s, [p.id]: cur })); }; const up = () => { window.removeEventListener("pointermove", move); window.removeEventListener("pointerup", up); ctx.patchView({ widths: { ...widths, [p.id]: cur } }); }; window.addEventListener("pointermove", move); window.addEventListener("pointerup", up); };34 const onDragEnd = (e: DragEndEvent) => { const { active, over } = e; if (!over || active.id === over.id) return; const ids = flat.map((r) => r.id); const next = arrayMove(ids, ids.indexOf(String(active.id)), ids.indexOf(String(over.id))); api(`/api/databases/${ctx.db.id}/rows`, { method: "PATCH", json: { order: next } }).then(() => ctx.refresh()); };35 useEffect(() => { setSel((s) => new Set([...s].filter((id) => flat.some((r) => r.id === id)))); }, [ctx.rows.length]); // eslint-disable-line react-hooks/exhaustive-deps36 let ri = 0;37 return (38 <div className="relative">39 {sel.size > 0 && <div className="mb-2 flex items-center gap-2 rounded-md border border-line bg-bg-elev px-2 py-1 text-[12px] shadow-card"><span className="font-medium">{sel.size} selected</span><button type="button" onClick={async () => { await ctx.removeRows([...sel]); setSel(new Set()); }} className="flex h-7 items-center gap-1 rounded-md px-2 text-danger hover:bg-danger/10"><Trash2 className="size-3.5" /> Delete</button><button type="button" onClick={() => setSel(new Set())} className="ml-auto text-fg-muted hover:text-fg">Clear</button></div>}40 <div className="overflow-x-auto rounded-md border border-line">41 <DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={onDragEnd}><SortableContext items={flat.map((r) => r.id)} strategy={verticalListSortingStrategy}>42 <table className="w-max min-w-full border-collapse text-[13px]" onKeyDown={nav}>43 <thead><tr className="border-b border-line bg-bg-sunk/40">44 <th className="sticky left-0 z-[3] w-8 bg-bg p-0"><div className="h-9" /></th>45 {cols.map((p, ci) => (46 <th key={p.id} style={{ width: w(p), minWidth: w(p), maxWidth: w(p) }} className={cn("group/h relative p-0 text-left font-normal", ci === 0 && "sticky left-8 z-[3] bg-bg")}>47 <Popover open={editProp === p.id} onOpenChange={(o) => setEditProp(o ? p.id : null)}><PopoverTrigger asChild><button type="button" className="flex h-9 w-full items-center gap-1.5 truncate px-2 text-[12px] font-medium text-fg-muted hover:bg-bg-sunk"><TypeIcon type={p.type} /><span className="truncate">{p.name}</span></button></PopoverTrigger><PopoverContent className="w-auto"><PropertyEditor prop={p} onClose={() => setEditProp(null)} /></PopoverContent></Popover>48 <div onPointerDown={(e) => startResize(p, e)} className="absolute right-0 top-0 z-[1] h-full w-2 cursor-col-resize touch-none opacity-0 hover:opacity-100 group-hover/h:opacity-100"><div className="mx-auto h-full w-px bg-line-strong" /></div>49 </th>))}50 <th className="w-10 p-0"><Popover open={adding} onOpenChange={setAdding}><PopoverTrigger asChild><button type="button" className="grid h-9 w-10 place-items-center text-fg-faint hover:bg-bg-sunk hover:text-fg"><Plus className="size-4" /></button></PopoverTrigger><PopoverContent className="w-auto"><PropertyEditor onClose={() => setAdding(false)} /></PopoverContent></Popover></th>51 </tr></thead>52 <tbody>53 {groups.map((g) => (g.rows.length || !gb ? [54 gb && <tr key={`g-${g.key}`}><td colSpan={cols.length + 2} className="sticky left-0 bg-bg px-1 pb-1 pt-3"><span className={cn("inline-flex items-center gap-1.5 rounded-md px-1.5 py-0.5 text-[12px] font-medium", OPTION_CLASS[g.color])}>{g.label}<span className="opacity-60">{g.rows.length}</span></span></td></tr>,55 ...g.rows.map((row) => <Row key={row.id} row={row} ri={ri++} cols={cols} w={w} editing={editing} setEditing={setEditing} selected={sel.has(row.id)} onSelect={(v) => setSel((s) => { const n = new Set(s); v ? n.add(row.id) : n.delete(row.id); return n; })} canReorder={canReorder} />),56 gb && <tr key={`a-${g.key}`}><td colSpan={cols.length + 2} className="sticky left-0 bg-bg p-0"><button type="button" onClick={() => ctx.addRow(g.value === undefined ? {} : { [gb.id]: g.value })} className="flex h-8 items-center gap-1 px-2 text-[12px] text-fg-faint hover:text-fg"><Plus className="size-3.5" /> New</button></td></tr>,57 ] : null))}58 {!gb && <tr><td colSpan={cols.length + 2} className="sticky left-0 bg-bg p-0"><button type="button" onClick={() => ctx.addRow()} className="flex h-9 w-full items-center gap-1 px-2 text-[13px] text-fg-faint hover:bg-bg-sunk hover:text-fg"><Plus className="size-4" /> New row</button></td></tr>}59 </tbody>60 <tfoot><tr><td colSpan={cols.length + 2} className="sticky left-0 px-2 py-1.5 text-[11px] text-fg-faint">{ctx.total} row{ctx.total === 1 ? "" : "s"}{ctx.rows.length < ctx.total ? ` · showing ${ctx.rows.length}` : ""}</td></tr></tfoot>61 </table>62 </SortableContext></DndContext>63 </div>64 </div>65 );66}67function Row({ row, ri, cols, w, editing, setEditing, selected, onSelect, canReorder }: { row: RowOut; ri: number; cols: Property[]; w: (p: Property) => number; editing: string | null; setEditing: (k: string | null) => void; selected: boolean; onSelect: (v: boolean) => void; canReorder: boolean }) {68 const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: row.id, disabled: !canReorder });69 return (70 <tr ref={setNodeRef} style={{ transform: CSS.Transform.toString(transform), transition }} className={cn("group/row border-b border-line hover:bg-bg-sunk/60", isDragging && "relative z-10 bg-bg-elev shadow-pop", selected && "bg-accent-soft/40")}>71 <td className="sticky left-0 z-[2] w-8 bg-bg p-0 align-top"><div className="flex h-9 items-center justify-center gap-0.5"><input type="checkbox" checked={selected} onChange={(e) => onSelect(e.target.checked)} className={cn("size-3.5 accent-[var(--accent)]", !selected && "opacity-0 group-hover/row:opacity-100")} aria-label="Select row" />{canReorder && <button type="button" {...attributes} {...listeners} className="hidden cursor-grab text-fg-faint group-hover/row:block md:block md:opacity-0 md:group-hover/row:opacity-100"><GripVertical className="size-3.5" /></button>}</div></td>72 {cols.map((p, ci) => { const key = `${row.id}:${p.id}`; return (73 <td key={p.id} style={{ width: w(p), minWidth: w(p), maxWidth: w(p) }} className={cn("p-0 align-top", ci === 0 && "sticky left-8 z-[2] bg-bg group-hover/row:bg-bg-sunk", ci > 0 && "border-l border-line")}>74 <div data-cell={`${ri}:${ci}`} tabIndex={0} className="h-full min-h-9 outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-accent/40"><Cell p={p} row={row} editing={editing === key} onEdit={(o) => setEditing(o ? key : null)} /></div>75 </td>); })}76 <td className="p-0" />77 </tr>78 );79}80