1"use client";
2
3
11import { useCallback, useMemo, useRef, useState } from "react";
12import {
13 ChevronDown,
14 ChevronLeft,
15 ChevronRight,
16 Copy,
17 Eye,
18 Filter,
19 MessageSquare,
20 Plus,
21 Sparkles,
22 Trash2,
23 X
24} from "lucide-react";
25import { cn } from "@/lib/utils";
26import { apiFetch } from "@/lib/api";
27import { toast } from "@/stores/ui-store";
28
29
30const notify = (t: { title: string; description?: string; variant?: string }) =>
31 toast(t.variant === "destructive" ? "error" : "success", t.title, t.description);
32import { useDatabaseStore } from "@/stores/database-store";
33import {
34 DatabaseProperty,
35 DatabaseView,
36 FilterCondition,
37 RecordDto,
38 RecordValue,
39 SortSpec,
40 UseDatabaseRecordsResult,
41 useBulkUpdateRecords,
42 useCreateRecord,
43 useDeleteRecord,
44 valueToText
45} from "@/hooks/use-database";
46import { CellRenderer, isEditableProperty } from "@/components/database/cell-renderer";
47import { CellEditor } from "@/components/database/cell-editor";
48import { PropertyEditor } from "@/components/database/property-editor";
49
50export interface ViewCommonProps {
51 databaseId: string;
52 view: DatabaseView;
53 properties: DatabaseProperty[];
54 records: UseDatabaseRecordsResult;
55
56 onOpenRecord: (record: RecordDto) => void;
57
58 onOpenRecordPage: (record: RecordDto) => void;
59
60 onSortChange: (sort: SortSpec[] | null) => void;
61
62 onFilterChange: (filter: FilterCondition | FilterCondition[] | null) => void;
63
64 onPageSizeChange?: (pageSize: number) => void;
65 readOnly?: boolean;
66}
67
68const PAGE_SIZE_WINDOW = 50;
69
70function visibleColumns(view: DatabaseView, properties: DatabaseProperty[]): DatabaseProperty[] {
71 const visible = view.config.visibleProperties;
72 const cols = visible
73 ? visible
74 .map((id) => properties.find((p) => p.id === id))
75 .filter((p): p is DatabaseProperty => !!p)
76 : properties;
77
78 const title = cols.find((p) => p.isTitle);
79 const rest = cols.filter((p) => p !== title);
80 return title ? [title, ...rest] : cols;
81}
82
83
84
85
86
87function ColumnQuickFilter({
88 property,
89 onApply,
90 onClear
91}: {
92 property: DatabaseProperty;
93 onApply: (c: FilterCondition | null) => void;
94 onClear: () => void;
95}) {
96 const [text, setText] = useState("");
97 const [optionId, setOptionId] = useState("");
98 const selectLike = property.type === "select" || property.type === "status" || property.type === "multi_select";
99 const textLike = ["title", "text", "rich_text", "email", "url"].includes(property.type);
100
101 return (
102 <div className="w-52 rounded-md border bg-popover p-2 shadow-md">
103 <p className="mb-1 text-xs font-medium">Filter: {property.name}</p>
104 {textLike && (
105 <input
106 className="mb-1 w-full rounded border border-input bg-background px-1.5 py-0.5 text-xs outline-none focus-visible:ring-1 focus-visible:ring-ring"
107 placeholder="Contains…"
108 value={text}
109 onChange={(e) => setText(e.target.value)}
110 />
111 )}
112 {selectLike && (
113 <select
114 className="mb-1 w-full rounded border border-input bg-background px-1.5 py-0.5 text-xs outline-none"
115 value={optionId}
116 onChange={(e) => setOptionId(e.target.value)}
117 >
118 <option value="">Any option…</option>
119 {(property.config.options ?? []).map((o) => (
120 <option key={o.id} value={o.id}>
121 {o.name}
122 </option>
123 ))}
124 </select>
125 )}
126 <div className="flex items-center gap-1">
127 <button
128 type="button"
129 className="flex-1 rounded bg-primary px-2 py-0.5 text-xs text-primary-foreground hover:bg-primary/90"
130 onClick={() =>
131 onApply(
132 textLike && text
133 ? { propertyId: property.id, op: "contains", value: { text } }
134 : selectLike && optionId
135 ? {
136 propertyId: property.id,
137 op: property.type === "multi_select" ? "contains_any" : "equals",
138 value: property.type === "multi_select" ? { optionIds: [optionId] } : { optionId }
139 }
140 : null
141 )
142 }
143 >
144 Apply
145 </button>
146 <button
147 type="button"
148 className="rounded px-2 py-0.5 text-xs text-muted-foreground hover:bg-muted"
149 onClick={() => {
150 onApply({ propertyId: property.id, op: "is_empty", value: undefined });
151 }}
152 >
153 Empty
154 </button>
155 <button type="button" className="rounded p-0.5 text-muted-foreground hover:bg-muted" onClick={onClear} aria-label="Clear column filter">
156 <X className="size-3.5" />
157 </button>
158 </div>
159 </div>
160 );
161}
162
163
164
165
166
167function BulkSetBar({
168 databaseId,
169 columns,
170 records,
171 selected
172}: {
173 databaseId: string;
174 columns: DatabaseProperty[];
175 records: RecordDto[];
176 selected: string[];
177}) {
178 const clearSelection = useDatabaseStore((s) => s.clearSelection);
179 const bulk = useBulkUpdateRecords(databaseId);
180 const settable = columns.filter((p) => isEditableProperty(p));
181 const [propId, setPropId] = useState<string>(settable.find((p) => p.type === "select" || p.type === "status")?.id ?? settable[0]?.id ?? "");
182 const [text, setText] = useState("");
183 const [optionId, setOptionId] = useState("");
184
185 const prop = columns.find((p) => p.id === propId);
186 const selectLike = prop?.type === "select" || prop?.type === "status";
187 const textLike = prop && ["title", "text", "rich_text", "email", "url", "phone"].includes(prop.type);
188 const isNumber = prop?.type === "number";
189 const isDate = prop?.type === "date";
190 const isCheckbox = prop?.type === "checkbox";
191
192 const canApply = selectLike ? !!optionId : isCheckbox ? true : !!text;
193
194 const apply = () => {
195 if (!prop) return;
196 const value: RecordValue = selectLike
197 ? { optionId }
198 : isCheckbox
199 ? { value: true }
200 : isNumber
201 ? { value: Number(text) }
202 : isDate
203 ? { start: new Date(text).toISOString(), end: null }
204 : { text, marks: [] };
205 const items = records
206 .filter((r) => selected.includes(r.id))
207 .map((r) => ({ recordId: r.id, properties: { [prop.id]: value }, version: r.version }));
208 bulk.mutate(
209 items,
210 {
211 onSuccess: (res) => {
212 clearSelection();
213 notify({
214 title: `Updated ${res.succeeded} record(s)`,
215 description: res.failed.length ? `${res.failed.length} failed` : undefined,
216 variant: res.failed.length ? "destructive" : "default"
217 });
218 setText("");
219 setOptionId("");
220 },
221 onError: (e) => notify({ title: "Bulk update failed", description: (e as Error).message, variant: "destructive" })
222 }
223 );
224 };
225
226 return (
227 <>
228 <select
229 className="max-w-32 rounded border border-input bg-background px-1.5 py-0.5 text-xs outline-none"
230 value={propId}
231 onChange={(e) => setPropId(e.target.value)}
232 aria-label="Property to set"
233 >
234 {settable.map((p) => (
235 <option key={p.id} value={p.id}>
236 {p.name}
237 </option>
238 ))}
239 </select>
240 {selectLike && (
241 <select
242 className="max-w-32 rounded border border-input bg-background px-1.5 py-0.5 text-xs outline-none"
243 value={optionId}
244 onChange={(e) => setOptionId(e.target.value)}
245 aria-label="Value"
246 >
247 <option value="">Value…</option>
248 {(prop?.config.options ?? []).map((o) => (
249 <option key={o.id} value={o.id}>
250 {o.name}
251 </option>
252 ))}
253 </select>
254 )}
255 {textLike && (
256 <input
257 className="w-32 rounded border border-input bg-background px-1.5 py-0.5 text-xs outline-none"
258 placeholder="Value"
259 value={text}
260 onChange={(e) => setText(e.target.value)}
261 />
262 )}
263 {isNumber && (
264 <input
265 type="number"
266 className="w-20 rounded border border-input bg-background px-1.5 py-0.5 text-xs outline-none"
267 value={text}
268 onChange={(e) => setText(e.target.value)}
269 />
270 )}
271 {isDate && (
272 <input
273 type="date"
274 className="rounded border border-input bg-background px-1.5 py-0.5 text-xs outline-none"
275 value={text}
276 onChange={(e) => setText(e.target.value)}
277 />
278 )}
279 <button
280 type="button"
281 disabled={!canApply || bulk.isPending}
282 className="rounded bg-primary px-2 py-0.5 text-xs text-primary-foreground hover:bg-primary/90 disabled:opacity-40"
283 onClick={apply}
284 >
285 {isCheckbox ? "Check all" : "Set"}
286 </button>
287 </>
288 );
289}
290
291
292
293export function TableView(props: ViewCommonProps) {
294 const {
295 databaseId,
296 view,
297 properties,
298 records,
299 onOpenRecord,
300 onOpenRecordPage,
301 onSortChange,
302 onFilterChange,
303 onPageSizeChange,
304 readOnly
305 } = props;
306
307 const { data, isLoading, isFetching, hasMore, loadMore, goPrev, canPrev } = records;
308 const columns = useMemo(() => visibleColumns(view, properties), [view, properties]);
309
310 const selected = useDatabaseStore((s) => s.selectedRecordIds);
311 const setSelected = useDatabaseStore((s) => s.setSelectedRecords);
312 const toggleSelected = useDatabaseStore((s) => s.toggleSelectedRecord);
313 const clearSelection = useDatabaseStore((s) => s.clearSelection);
314
315 const [editingCell, setEditingCell] = useState<{ recordId: string; propertyId: string } | null>(null);
316 const [addPropertyOpen, setAddPropertyOpen] = useState(false);
317
318
319
320
321 const [editProperty, setEditProperty] = useState<DatabaseProperty | null>(null);
322 const [quickFilter, setQuickFilter] = useState<string | null>(null);
323 const [confirmDelete, setConfirmDelete] = useState<string[] | null>(null);
324 const headerRef = useRef<HTMLDivElement>(null);
325
326 const createRecord = useCreateRecord(databaseId);
327 const deleteRecord = useDeleteRecord(databaseId);
328
329 const titleProp = columns.find((p) => p.isTitle) ?? columns[0];
330
331
332 const cycleSort = (propertyId: string) => {
333 const sorts = view.sort ?? [];
334 const idx = sorts.findIndex((s) => s.propertyId === propertyId);
335 const current = sorts[idx];
336 let next: SortSpec;
337 if (!current) next = { propertyId, direction: "asc" };
338 else if (current.direction === "asc") next = { propertyId, direction: "desc" };
339 else next = { propertyId, direction: "asc" as const };
340 const rest = sorts.filter((s) => s.propertyId !== propertyId);
341 if (!current || current.direction === "desc") {
342 onSortChange([...rest, next]);
343 } else if (current.direction === "asc" && next.direction === "desc") {
344 onSortChange([...rest, next]);
345 } else {
346 onSortChange(rest.length ? rest : null);
347 }
348 };
349
350
351 const duplicateRecord = async (record: RecordDto) => {
352 if (!record.pageId) return;
353 try {
354 await apiFetch(`/pages/${record.pageId}/duplicate`, { method: "POST", body: JSON.stringify({}) });
355 notify({ title: "Record duplicated" });
356 } catch (e) {
357 notify({ title: "Duplicate failed", description: (e as Error).message, variant: "destructive" });
358 }
359 };
360
361 const addRow = () => {
362
363
364 createRecord.mutate(
365 { title: "New record", properties: {} },
366 {
367 onSuccess: () => notify({ title: "Row added" }),
368 onError: (e) => notify({ title: "Add row failed", description: (e as Error).message, variant: "destructive" })
369 }
370 );
371 };
372
373
374 const allVisibleIds = data.records.map((r) => r.id);
375 const allSelected = allVisibleIds.length > 0 && allVisibleIds.every((id) => selected.includes(id));
376 const toggleAll = () => setSelected(allSelected ? [] : allVisibleIds);
377
378 const sortDirFor = (propertyId: string): "asc" | "desc" | null => {
379 const s = view.sort?.find((x) => x.propertyId === propertyId);
380 return s?.direction ?? null;
381 };
382
383 const renderRow = (record: RecordDto) => {
384 const isSelected = selected.includes(record.id);
385 return (
386 <div
387 key={record.id}
388 data-testid={`record-row-${record.id}`}
389 className={cn(
390 "group relative grid border-b border-border/60 text-sm transition-colors hover:bg-muted/40",
391 isSelected && "bg-primary/5"
392 )}
393 style={{ gridTemplateColumns: gridTemplate(columns) }}
394 >
395 {}
396 <div className="flex items-center justify-center border-r border-border/60 px-1">
397 <input
398 type="checkbox"
399 className="size-3.5 accent-[oklch(var(--primary))]"
400 checked={isSelected}
401 onChange={() => toggleSelected(record.id)}
402 aria-label={`Select ${recordTitleSafe(record, properties)}`}
403 onClick={(e) => e.stopPropagation()}
404 />
405 </div>
406 {}
407 {columns.map((p) => {
408 const isEditing = editingCell?.recordId === record.id && editingCell?.propertyId === p.id;
409 const width = view.config.columnWidths?.[p.id];
410 return (
411 <div
412 key={p.id}
413 className={cn(
414 "min-w-0 border-r border-border/40 px-2 py-1.5 last:border-r-0",
415 isEditing && "z-10"
416 )}
417 style={width ? { width } : undefined}
418 >
419 {isEditing ? (
420 <CellEditor
421 record={record}
422 property={p}
423 onDone={() => setEditingCell(null)}
424 className="min-w-full"
425 />
426 ) : (
427 <CellRenderer
428 record={record}
429 property={p}
430 value={record.properties[p.id]}
431 readOnly={readOnly}
432 onEdit={
433 readOnly ? undefined : (r, prop) => setEditingCell({ recordId: r.id, propertyId: prop.id })
434 }
435 onOpenRecord={onOpenRecordPage}
436 />
437 )}
438 </div>
439 );
440 })}
441 {}
442 {!readOnly && (
443 <div className="absolute right-2 top-1/2 hidden -translate-y-1/2 items-center gap-0.5 rounded-md border bg-background p-0.5 shadow-sm group-hover:flex">
444 <button type="button" className="rounded p-1 hover:bg-muted" title="Open record" onClick={() => onOpenRecord(record)}>
445 <Eye className="size-3.5" />
446 </button>
447 <button type="button" className="rounded p-1 hover:bg-muted" title="Duplicate" onClick={() => void duplicateRecord(record)}>
448 <Copy className="size-3.5" />
449 </button>
450 <button type="button" className="rounded p-1 hover:bg-muted" title="Comments" onClick={() => onOpenRecord(record)}>
451 <MessageSquare className="size-3.5" />
452 </button>
453 <button
454 type="button"
455 className="rounded p-1 text-destructive hover:bg-muted"
456 title="Delete"
457 onClick={() => setConfirmDelete([record.id])}
458 >
459 <Trash2 className="size-3.5" />
460 </button>
461 </div>
462 )}
463 </div>
464 );
465 };
466
467
468 const groups = data.groups;
469
470 return (
471 <div className="flex h-full flex-col">
472 {}
473 <div
474 ref={headerRef}
475 className="sticky top-0 z-10 grid border-b border-border bg-muted/60 text-xs font-medium text-muted-foreground backdrop-blur"
476 style={{ gridTemplateColumns: gridTemplate(columns) }}
477 >
478 <div className="flex items-center justify-center border-r border-border/60 px-1">
479 <input
480 type="checkbox"
481 className="size-3.5 accent-[oklch(var(--primary))]"
482 checked={allSelected}
483 onChange={toggleAll}
484 aria-label="Select all visible rows"
485 />
486 </div>
487 {columns.map((p) => {
488 const dir = sortDirFor(p.id);
489 const width = view.config.columnWidths?.[p.id];
490 return (
491 <div
492 key={p.id}
493 className="relative flex items-center gap-1 border-r border-border/60 px-2 py-1.5 last:border-r-0"
494 style={width ? { width } : undefined}
495 >
496 <button
497 type="button"
498 className="flex min-w-0 items-center gap-1 hover:text-foreground"
499 onClick={() => cycleSort(p.id)}
500 onDoubleClick={() => {
501 if (!readOnly) setEditProperty(p);
502 }}
503 title={readOnly ? `Sort by ${p.name}` : `Sort by ${p.name} · double-click to rename`}
504 >
505 {propertyIcon(p.type)}
506 <span className="truncate">{p.name}</span>
507 {dir && (
508 <ChevronDown className={cn("size-3", dir === "asc" && "rotate-180")} aria-hidden />
509 )}
510 </button>
511 {!readOnly && (
512 <div className="relative">
513 <button
514 type="button"
515 className="rounded p-0.5 opacity-0 hover:bg-muted group-hover:opacity-100 focus-visible:opacity-100"
516 onClick={() => setQuickFilter(quickFilter === p.id ? null : p.id)}
517 title={`Filter ${p.name}`}
518 aria-label={`Filter ${p.name}`}
519 >
520 <Filter className="size-3" />
521 </button>
522 {quickFilter === p.id && (
523 <div className="absolute right-0 top-6 z-30">
524 <ColumnQuickFilter
525 property={p}
526 onApply={(c) => {
527 onFilterChange(c);
528 setQuickFilter(null);
529 }}
530 onClear={() => {
531 onFilterChange(null);
532 setQuickFilter(null);
533 }}
534 />
535 </div>
536 )}
537 </div>
538 )}
539 </div>
540 );
541 })}
542 {}
543 {!readOnly && (
544 <div className="px-1">
545 <button
546 type="button"
547 className="rounded p-1 text-muted-foreground hover:bg-muted hover:text-foreground"
548 onClick={() => setAddPropertyOpen(true)}
549 title="Add property"
550 aria-label="Add property"
551 >
552 <Plus className="size-4" />
553 </button>
554 </div>
555 )}
556 </div>
557
558 {}
559 <div className="min-h-0 flex-1 overflow-auto">
560 {isLoading ? (
561 <div className="space-y-2 p-4">
562 {Array.from({ length: 8 }).map((_, i) => (
563 <div key={i} className="h-8 animate-pulse rounded bg-muted" />
564 ))}
565 </div>
566 ) : data.records.length === 0 ? (
567 <div className="flex flex-col items-center gap-3 py-16 text-center">
568 <p className="text-sm text-muted-foreground">
569 {properties.length === 0 ? "This database has no properties yet." : "No records match this view."}
570 </p>
571 {properties.length > 0 && !readOnly && (
572 <div className="flex items-center gap-2">
573 <button type="button" data-testid="add-row" className="inline-flex items-center gap-1 rounded-md bg-primary px-3 py-1.5 text-sm text-primary-foreground hover:bg-primary/90" onClick={addRow}>
574 <Plus className="size-4" /> Add row
575 </button>
576 <button
577 type="button"
578 disabled
579 title="AI row generation lands in Phase 12"
580 className="inline-flex cursor-not-allowed items-center gap-1 rounded-md border px-3 py-1.5 text-sm opacity-50"
581 >
582 <Sparkles className="size-4" /> AI generate
583 </button>
584 </div>
585 )}
586 {properties.length === 0 && !readOnly && (
587 <button type="button" className="inline-flex items-center gap-1 rounded-md bg-primary px-3 py-1.5 text-sm text-primary-foreground hover:bg-primary/90" onClick={() => setAddPropertyOpen(true)}>
588 <Plus className="size-4" /> Add property
589 </button>
590 )}
591 </div>
592 ) : (
593 <div className="relative">
594 {isFetching && <div className="absolute inset-x-0 top-0 z-20 h-0.5 animate-pulse bg-primary" />}
595 {groups
596 ? groups.map((g) => (
597 <div key={`${g.key.propertyId}:${JSON.stringify(g.key.value)}`}>
598 <div className="sticky top-0 z-[5] flex items-center justify-between border-b border-border bg-muted/80 px-3 py-1 text-xs font-medium backdrop-blur">
599 <span>
600 {g.key.label}
601 <span className="ml-1.5 font-normal text-muted-foreground">({g.count})</span>
602 </span>
603 </div>
604 {g.records.map(renderRow)}
605 </div>
606 ))
607 : data.records.map(renderRow)}
608 </div>
609 )}
610
611 {}
612 {!isLoading && data.records.length > 0 && (
613 <div className="flex items-center justify-between border-t border-border px-3 py-2 text-xs text-muted-foreground">
614 <div className="flex items-center gap-2">
615 <span>
616 {data.records.length} of {data.totalCount ?? "…"} records
617 </span>
618 {}
621 {onPageSizeChange && (
622 <label className="flex items-center gap-1">
623 <select
624 data-testid="page-size"
625 aria-label="Rows per page"
626 className="rounded border border-border bg-background px-1 py-0.5 text-xs"
627 value={view.pageSize ?? 50}
628 onChange={(e) => onPageSizeChange(Number(e.target.value))}
629 >
630 {[20, 50, 100, 200].map((n) => (
631 <option key={n} value={n}>
632 {n}
633 </option>
634 ))}
635 </select>
636 <span>per page</span>
637 </label>
638 )}
639 </div>
640 <div className="flex items-center gap-1">
641 {!readOnly && (
642 <button
643 type="button"
644 data-testid="add-row"
645 className="mr-1 inline-flex items-center gap-0.5 rounded border px-2 py-0.5 hover:bg-muted"
646 onClick={addRow}
647 title="Add row"
648 >
649 <Plus className="size-3.5" /> Add row
650 </button>
651 )}
652 <button
653 type="button"
654 disabled={!canPrev}
655 className="inline-flex items-center gap-0.5 rounded border px-2 py-0.5 hover:bg-muted disabled:opacity-40"
656 onClick={goPrev}
657 >
658 <ChevronLeft className="size-3.5" /> Prev
659 </button>
660 <button
661 type="button"
662 disabled={!hasMore}
663 className="inline-flex items-center gap-0.5 rounded border px-2 py-0.5 hover:bg-muted disabled:opacity-40"
664 onClick={loadMore}
665 >
666 Next <ChevronRight className="size-3.5" />
667 </button>
668 </div>
669 </div>
670 )}
671 </div>
672
673 {}
674 {selected.length > 0 && !readOnly && (
675 <div className="flex flex-wrap items-center gap-2 border-t border-border bg-muted/50 px-3 py-1.5 text-xs">
676 <span className="font-medium">{selected.length} selected</span>
677 <BulkSetBar
678 databaseId={databaseId}
679 columns={columns}
680 records={data.records}
681 selected={selected}
682 />
683 <button
684 type="button"
685 className="inline-flex items-center gap-1 rounded border border-destructive/40 px-2 py-0.5 text-destructive hover:bg-destructive/10"
686 onClick={() => setConfirmDelete(selected)}
687 >
688 <Trash2 className="size-3.5" /> Delete
689 </button>
690 <button type="button" className="ml-auto rounded p-0.5 text-muted-foreground hover:bg-muted" onClick={clearSelection} aria-label="Clear selection">
691 <X className="size-3.5" />
692 </button>
693 </div>
694 )}
695
696 {}
697 {confirmDelete && (
698 <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4">
699 <div className="w-full max-w-sm rounded-lg border bg-background p-4 shadow-lg">
700 <h3 className="text-sm font-medium">
701 Delete {confirmDelete.length === 1 ? "this record" : `${confirmDelete.length} records`}?
702 </h3>
703 <p className="mt-1 text-xs text-muted-foreground">
704 The record and its page are hard-deleted. This cannot be undone.
705 </p>
706 <div className="mt-3 flex justify-end gap-2">
707 <button type="button" className="rounded border px-3 py-1 text-sm hover:bg-muted" onClick={() => setConfirmDelete(null)}>
708 Cancel
709 </button>
710 <button
711 type="button"
712 className="rounded bg-destructive px-3 py-1 text-sm text-destructive-foreground hover:bg-destructive/90"
713 onClick={async () => {
714 for (const id of confirmDelete) {
715 try {
716 await deleteRecord.mutateAsync(id);
717 } catch (e) {
718 notify({ title: "Delete failed", description: (e as Error).message, variant: "destructive" });
719 }
720 }
721 setConfirmDelete(null);
722 clearSelection();
723 notify({ title: `Deleted ${confirmDelete.length} record(s)` });
724 }}
725 disabled={deleteRecord.isPending}
726 >
727 Delete
728 </button>
729 </div>
730 </div>
731 </div>
732 )}
733
734 {}
735 {addPropertyOpen && (
736 <PropertyEditor
737 databaseId={databaseId}
738 properties={properties}
739 mode="add"
740 onClose={() => setAddPropertyOpen(false)}
741 />
742 )}
743
744 {}
745 {editProperty && (
746 <PropertyEditor
747 databaseId={databaseId}
748 properties={properties}
749 property={editProperty}
750 mode="edit"
751 onClose={() => setEditProperty(null)}
752 />
753 )}
754 </div>
755 );
756}
757
758
759
760
761
762function gridTemplate(columns: DatabaseProperty[]): string {
763 const widths = columns.map((p) => {
764 const w = 160;
765 return `${w}px`;
766 });
767 return `28px ${widths.join(" ")} 32px`;
768}
769
770function recordTitleSafe(record: RecordDto, properties: DatabaseProperty[]): string {
771 const t = properties.find((p) => p.isTitle);
772 if (!t) return record.id.slice(-6);
773 return valueToText(record.properties[t.id], t) || "Untitled";
774}
775
776function titlePropertySafe(properties: DatabaseProperty[]) {
777 return properties.find((p) => p.isTitle) ?? properties.find((p) => p.type === "title");
778}
779
780function propertyIcon(type: string): React.ReactNode {
781 const map: Record<string, string> = {
782 title: "T",
783 text: "A",
784 rich_text: "¶",
785 number: "#",
786 select: "◉",
787 multi_select: "◌",
788 status: "●",
789 date: "📅",
790 date_range: "🗓",
791 person: "👤",
792 email: "@",
793 phone: "✆",
794 url: "🔗",
795 checkbox: "☑",
796 files: "📎",
797 created_time: "🕐",
798 created_by: "👤",
799 last_edited_time: "🕓",
800 last_edited_by: "👤",
801 relation: "↔",
802 rollup: "Σ",
803 formula: "ƒ",
804 button: "⚡",
805 unique_id: "ID"
806 };
807 return (
808 <span className="w-4 shrink-0 text-center text-[10px] text-muted-foreground" aria-hidden>
809 {map[type] ?? "•"}
810 </span>
811 );
812}
813