1"use client";
2
3
15import { memo, useMemo, useState } from "react";
16import { useQuery } from "@tanstack/react-query";
17import {
18 AlertTriangle,
19 Check,
20 ChevronRight,
21 ExternalLink,
22 File as FileIcon,
23 Link2,
24 Mail,
25 Paperclip,
26 Phone,
27 User as UserIcon
28} from "lucide-react";
29import { cn } from "@/lib/utils";
30import { apiFetch } from "@/lib/api";
31import {
32 DatabaseProperty,
33 RecordDto,
34 RecordValue,
35 findOption,
36 useRelations,
37 useUpdateRecord
38} from "@/hooks/use-database";
39
40export interface UserRef {
41 id: string;
42 name: string;
43 avatarUrl?: string | null;
44}
45
46
47const COLOR_FALLBACK: Record<string, string> = {
48 default: "transparent",
49 gray: "#f1f1ef",
50 brown: "#f7e9e7",
51 orange: "#fbecdd",
52 yellow: "#fdf3d0",
53 green: "#edf3ec",
54 blue: "#e7f0f6",
55 purple: "#f1edf7",
56 pink: "#faedf3",
57 red: "#fdebec"
58};
59const COLOR_DOT: Record<string, string> = {
60 gray: "#787774",
61 brown: "#9f6b53",
62 orange: "#d9730d",
63 yellow: "#cb912f",
64 green: "#448361",
65 blue: "#337ea9",
66 purple: "#9065b0",
67 pink: "#c14c8a",
68 red: "#d44c47"
69};
70
71function optionStyle(color?: string): React.CSSProperties {
72 const c = color ?? "default";
73 return {
74 backgroundColor: `var(--factory-color-${c}_bg, ${COLOR_FALLBACK[c] ?? "transparent"})`,
75 color: `var(--factory-color-${c}, inherit)`
76 };
77}
78
79
80
81
82
83
88export function formatNumberValue(value: unknown, config?: { format?: string; currency?: string; precision?: number }): string {
89 if (value == null || value === "") return "—";
90 const n = typeof value === "number" ? value : Number(value);
91 if (!Number.isFinite(n)) return String(value);
92 const precision = config?.precision ?? 2;
93 switch (config?.format) {
94 case "currency":
95 return new Intl.NumberFormat(undefined, {
96 style: "currency",
97 currency: config?.currency ?? "USD",
98 maximumFractionDigits: precision
99 }).format(n);
100 case "percent":
101 return new Intl.NumberFormat(undefined, {
102 style: "percent",
103 maximumFractionDigits: precision
104 }).format(n);
105 case "decimal":
106 return new Intl.NumberFormat(undefined, {
107 minimumFractionDigits: 0,
108 maximumFractionDigits: precision
109 }).format(n);
110 case "scientific":
111 return n.toExponential(precision);
112 default:
113 return new Intl.NumberFormat(undefined, { maximumFractionDigits: precision }).format(n);
114 }
115}
116
117
118export function formatDateValue(iso: string | null | undefined, config?: { includeTime?: boolean; timeZone?: string | null }): string {
119 if (!iso) return "";
120 const d = new Date(iso);
121 if (Number.isNaN(d.getTime())) return "";
122 const timeZone = config?.timeZone ?? "UTC";
123 const date = new Intl.DateTimeFormat("en-CA", {
124 timeZone,
125 year: "numeric",
126 month: "short",
127 day: "2-digit"
128 }).format(d);
129 if (!config?.includeTime) return date;
130 const time = new Intl.DateTimeFormat("en-GB", {
131 timeZone,
132 hour: "2-digit",
133 minute: "2-digit"
134 }).format(d);
135 return `${date} ${time}`;
136}
137
138function formatBytes(bytes: number): string {
139 if (!Number.isFinite(bytes) || bytes < 0) return "";
140 if (bytes < 1024) return `${bytes} B`;
141 if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
142 if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
143 return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`;
144}
145
146
147
148
149
150export function UserAvatar({ user, size = 20 }: { user: UserRef; size?: number }) {
151 if (user.avatarUrl) {
152 return (
153 <img
154 src={user.avatarUrl}
155 alt={user.name}
156 className="rounded-full object-cover shrink-0"
157 style={{ width: size, height: size }}
158 />
159 );
160 }
161 return (
162 <span
163 className="inline-flex items-center justify-center rounded-full bg-muted text-muted-foreground shrink-0"
164 style={{ width: size, height: size, fontSize: size * 0.5 }}
165 aria-hidden
166 >
167 {(user.name ?? "?").slice(0, 1).toUpperCase()}
168 </span>
169 );
170}
171
172function PersonCellContent({ userIds, users }: { userIds?: string[]; users?: Record<string, UserRef> }) {
173 const ids = userIds ?? [];
174 if (ids.length === 0) return null;
175 return (
176 <span className="flex flex-wrap items-center gap-1">
177 {ids.slice(0, 3).map((id) => {
178 const u = users?.[id];
179 return (
180 <span key={id} className="inline-flex items-center gap-1 rounded-full bg-muted px-1.5 py-0.5 text-xs">
181 {u ? <UserAvatar user={u} size={14} /> : <UserIcon className="size-3" aria-hidden />}
182 {u?.name ?? id.slice(-4)}
183 </span>
184 );
185 })}
186 {ids.length > 3 && <span className="text-xs text-muted-foreground">+{ids.length - 3}</span>}
187 </span>
188 );
189}
190
191
192
193
194
195interface FileMeta {
196 id: string;
197 name: string;
198 size: number;
199 mimeType: string;
200 url?: string | null;
201 thumbnailUrl?: string | null;
202}
203
204
205function useFileMeta(fileId: string | undefined): FileMeta | null {
206 const q = useQuery({
207 queryKey: ["file", fileId ?? "none"],
208 enabled: !!fileId,
209 queryFn: async () => {
210 const b = await apiFetch(`/files/${fileId}`);
211 const d = b && typeof b === "object" && "data" in (b as Record<string, unknown>)
212 ? (b as { data: FileMeta }).data
213 : (b as FileMeta);
214 return d;
215 },
216 staleTime: 5 * 60 * 1000
217 });
218 return q.data ?? null;
219}
220
221function FileChip({ fileId }: { fileId: string }) {
222 const meta = useFileMeta(fileId);
223 const [preview, setPreview] = useState(false);
224 return (
225 <button
226 type="button"
227 className="inline-flex items-center gap-1 rounded border px-1.5 py-0.5 text-xs hover:bg-muted"
228 onClick={() => {
229
230
231 const url = meta?.url ?? `/api/preview/file/${fileId}`;
232 setPreview(true);
233 window.open(url, "_blank", "noopener");
234 setPreview(false);
235 }}
236 title={meta?.name ?? fileId}
237 >
238 {meta?.mimeType?.startsWith("image/") ? (
239 meta.thumbnailUrl ? (
240 <img src={meta.thumbnailUrl} alt="" className="size-4 rounded object-cover" />
241 ) : (
242 <FileIcon className="size-3.5" aria-hidden />
243 )
244 ) : (
245 <Paperclip className="size-3.5" aria-hidden />
246 )}
247 <span className="max-w-32 truncate">{meta?.name ?? "file…"}</span>
248 {meta && <span className="text-muted-foreground">{formatBytes(meta.size)}</span>}
249 </button>
250 );
251}
252
253function FilesCellContent({ fileIds }: { fileIds?: string[] }) {
254 const ids = fileIds ?? [];
255 if (ids.length === 0) return null;
256 return (
257 <span className="flex flex-wrap items-center gap-1">
258 {ids.slice(0, 3).map((id) => (
259 <FileChip key={id} fileId={id} />
260 ))}
261 {ids.length > 3 && <span className="text-xs text-muted-foreground">+{ids.length - 3}</span>}
262 </span>
263 );
264}
265
266
267
268
269
270function RelationCellContent({
271 value,
272 property,
273 onOpenRecord,
274 titleOf
275}: {
276 value?: RecordValue;
277 property: DatabaseProperty;
278 onOpenRecord?: (record: RecordDto) => void;
279
280 titleOf?: (recordId: string) => string;
281}) {
282 const ids = value?.recordIds ?? [];
283 const targetDatabaseId = property.config.targetDatabaseId;
284 const { data: resolved } = useRelations(targetDatabaseId, targetDatabaseId, ids);
285 if (ids.length === 0) return null;
286 return (
287 <span className="flex flex-wrap items-center gap-1">
288 {ids.slice(0, 3).map((id) => {
289 const r = resolved?.find((x) => x.recordId === id)?.record;
290 const label = r
291 ? titleOf?.(id) ?? r.title ?? "Record"
292 : titleOf?.(id) ?? "…";
293 return (
294 <button
295 key={id}
296 type="button"
297 className="inline-flex items-center gap-0.5 rounded-full border px-2 py-0.5 text-xs hover:bg-muted"
298 onClick={(e) => {
299 e.stopPropagation();
300 if (r && onOpenRecord) onOpenRecord(r);
301 }}
302 >
303 {label}
304 <ChevronRight className="size-3" aria-hidden />
305 </button>
306 );
307 })}
308 {ids.length > 3 && <span className="text-xs text-muted-foreground">+{ids.length - 3}</span>}
309 </span>
310 );
311}
312
313
314
315
316
317export interface CellRendererProps {
318 record: RecordDto;
319 property: DatabaseProperty;
320 value?: RecordValue | null;
321
322 readOnly?: boolean;
323
324 onEdit?: (record: RecordDto, property: DatabaseProperty) => void;
325
326 users?: Record<string, UserRef>;
327
328 onOpenRecord?: (record: RecordDto) => void;
329}
330
331const READ_ONLY_TYPES = new Set([
332 "created_time",
333 "created_by",
334 "last_edited_time",
335 "last_edited_by",
336 "rollup",
337 "formula",
338 "unique_id"
339]);
340
341export const isEditableProperty = (p: DatabaseProperty) => !READ_ONLY_TYPES.has(p.type) && p.type !== "button";
342
343
359const INTERACTIVE_CELL_TYPES = new Set(["relation", "files"]);
360
361function renderValue(props: CellRendererProps): React.ReactNode {
362 const { record, property: p, value, users, onOpenRecord } = props;
363 const v = value ?? null;
364
365 switch (p.type) {
366 case "title":
367 case "heading": {
368 const text = v?.text ?? "";
369 if (!text) return <span className="text-muted-foreground">Untitled</span>;
370
371
372
373
374 return (
375 <span className="block truncate font-medium" title={text}>
376 {record.icon ? `${record.icon} ` : ""}
377 {text}
378 </span>
379 );
380 }
381 case "text":
382 case "rich_text":
383 return <span className="block truncate" title={v?.text}>{v?.text ?? null}</span>;
384 case "number": {
385
386 if (p.config.format === "percent") {
387 const n = typeof v?.value === "number" ? v.value : Number(v?.value ?? 0);
388 const pct = Number.isFinite(n) ? Math.max(0, Math.min(100, n * 100)) : 0;
389 return (
390 <span className="flex w-full items-center gap-2" title={`${pct.toFixed(0)}%`}>
391 <span className="h-1.5 flex-1 overflow-hidden rounded-full bg-muted">
392 <span
393 className="block h-full rounded-full bg-primary transition-all"
394 style={{ width: `${pct}%` }}
395 />
396 </span>
397 <span className="w-9 text-right text-xs tabular-nums text-muted-foreground">{pct.toFixed(0)}%</span>
398 </span>
399 );
400 }
401 return <span className="tabular-nums">{formatNumberValue(v?.value, p.config)}</span>;
402 }
403 case "select": {
404 const opt = findOption(p.config.options, v?.optionId);
405 if (!opt) return null;
406 return (
407 <span className="inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs" style={optionStyle(opt.color)}>
408 {opt.name}
409 </span>
410 );
411 }
412 case "multi_select": {
413 const opts = (v?.optionIds ?? [])
414 .map((id) => findOption(p.config.options, id))
415 .filter((o): o is NonNullable<typeof o> => Boolean(o));
416 if (opts.length === 0) return null;
417 return (
418 <span className="flex flex-wrap items-center gap-1">
419 {opts.slice(0, 3).map((o) => (
420 <span key={o!.id} className="rounded-full px-2 py-0.5 text-xs" style={optionStyle(o!.color)}>
421 {o!.name}
422 </span>
423 ))}
424 {opts.length > 3 && <span className="text-xs text-muted-foreground">+{opts.length - 3}</span>}
425 </span>
426 );
427 }
428 case "status": {
429 const opt = findOption(p.config.options, v?.optionId);
430 if (!opt) return null;
431 return (
432 <span className="inline-flex items-center gap-1.5 rounded-full px-2 py-0.5 text-xs" style={optionStyle(opt.color)}>
433 <span
434 className="size-2 rounded-full"
435 style={{ backgroundColor: `var(--factory-color-${opt.color}, ${COLOR_DOT[opt.color] ?? "#999"})` }}
436 aria-hidden
437 />
438 {opt.name}
439 </span>
440 );
441 }
442 case "date":
443 return <span className="text-sm">{formatDateValue(v?.start, p.config)}</span>;
444 case "date_range":
445 return (
446 <span className="text-sm">
447 {formatDateValue(v?.start, p.config)}
448 {v?.end ? ` → ${formatDateValue(v.end, p.config)}` : ""}
449 </span>
450 );
451 case "person":
452 return <PersonCellContent userIds={v?.userIds} users={users} />;
453 case "created_by":
454 case "last_edited_by": {
455 const id = v?.userId ?? (p.type === "created_by" ? record.createdBy : record.lastEditedBy) ?? undefined;
456 if (!id) return null;
457 return <PersonCellContent userIds={[id]} users={users} />;
458 }
459
460
461
462
463
464
465 case "email":
466 return v?.value ? (
467 <span className="inline-flex items-center gap-1 text-sm">
468 <Mail className="size-3.5 text-muted-foreground" aria-hidden />
469 {v.value}
470 </span>
471 ) : null;
472 case "phone":
473 return v?.value ? (
474 <span className="inline-flex items-center gap-1 text-sm">
475 <Phone className="size-3.5 text-muted-foreground" aria-hidden />
476 {v.value}
477 </span>
478 ) : null;
479 case "url":
480 return v?.value ? (
481 <span className="inline-flex items-center gap-1 text-sm text-primary">
482 <Link2 className="size-3.5" aria-hidden />
483 {v.title || v.value}
484 </span>
485 ) : null;
486 case "checkbox":
487 return (
488 <span
489 className={cn(
490 "inline-flex size-4 items-center justify-center rounded border",
491 v?.value ? "border-primary bg-primary text-primary-foreground" : "bg-background"
492 )}
493 aria-hidden
494 >
495 {v?.value ? <Check className="size-3" /> : null}
496 </span>
497 );
498 case "files":
499 return <FilesCellContent fileIds={v?.fileIds} />;
500 case "created_time":
501 return <span className="text-sm text-muted-foreground">{formatDateValue(record.createdTime, p.config)}</span>;
502 case "last_edited_time":
503 return <span className="text-sm text-muted-foreground">{formatDateValue(record.lastEditedTime, p.config)}</span>;
504 case "relation":
505 return <RelationCellContent value={v} property={p} onOpenRecord={onOpenRecord} />;
506 case "rollup": {
507
508 if (v?.value != null) {
509 if (p.config.aggregate === "PERCENT") return <span className="tabular-nums">{formatNumberValue(v.value, { format: "percent" })}</span>;
510 return <span className="tabular-nums">{formatNumberValue(v.value, { format: "number", precision: 2 })}</span>;
511 }
512 if (Array.isArray(v?.values)) return <span className="tabular-nums">{(v?.values as unknown[]).length}</span>;
513 return <span className="text-muted-foreground">—</span>;
514 }
515 case "formula": {
516
517
518 if (v?.error) {
519 return (
520 <span title={`formula error: ${String(v.error)}`} className="inline-flex items-center gap-1 text-sm text-destructive">
521 <AlertTriangle className="size-3.5" aria-hidden />
522 error
523 </span>
524 );
525 }
526 if (v?.value == null) return <span className="text-muted-foreground">—</span>;
527 if (typeof v.value === "boolean") return <span>{v.value ? "Yes" : "No"}</span>;
528 if (typeof v.value === "number") {
529 return (
530 <span className="tabular-nums">
531 {formatNumberValue(v.value, v.type === "percent" ? { format: "percent" } : { format: "number", precision: 2 })}
532 </span>
533 );
534 }
535 if (typeof v.value === "string") {
536 if (/^\d{4}-\d{2}-\d{2}/.test(v.value)) return <span className="text-sm">{formatDateValue(v.value, p.config)}</span>;
537 return <span className="block truncate">{v.value}</span>;
538 }
539 return <span className="block truncate" title={JSON.stringify(v.value)}>{JSON.stringify(v.value)}</span>;
540 }
541 case "button":
542
543
544 return <span className="rounded-full border px-2 py-0.5 text-xs text-muted-foreground">{p.name}</span>;
545 case "unique_id":
546 return <span className="font-mono text-xs text-muted-foreground">{v?.value ?? ""}</span>;
547 default: {
548
549 const raw = v ? JSON.stringify(v) : "";
550 return raw ? (
551 <span className="block truncate font-mono text-xs text-muted-foreground" title={raw}>
552 {raw}
553 </span>
554 ) : null;
555 }
556 }
557}
558
559
560
561
562
563function ButtonsCell({ record, property }: { record: RecordDto; property: DatabaseProperty }) {
564 const updateRecord = useUpdateRecord(record.databaseId);
565 const actions = (property.config.actions ?? []) as {
566 type: string;
567 config?: { propertyId?: string; value?: RecordValue; url?: string; pageId?: string };
568 }[];
569
570 const run = async () => {
571 for (const a of actions) {
572 if (a.type === "set_property" && a.config?.propertyId) {
573 await updateRecord.mutateAsync({
574 recordId: record.id,
575 version: record.version,
576 properties: { [a.config.propertyId]: a.config.value ?? {} }
577 });
578 } else if (a.type === "navigate" && a.config) {
579 if (a.config.url) window.open(a.config.url, "_blank", "noopener");
580 if (a.config.pageId) window.location.assign(`/page/${a.config.pageId}`);
581 } else {
582
583 console.info("button action not client-executable", a.type);
584 }
585 }
586 };
587
588 if (actions.length === 0) return null;
589 return (
590 <button
591 type="button"
592 disabled={updateRecord.isPending}
593 className="inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-medium hover:bg-muted disabled:opacity-50"
594 onClick={(e) => {
595 e.stopPropagation();
596 void run();
597 }}
598 >
599 {property.config.actions && "⚡ "}
600 {property.name}
601 </button>
602 );
603}
604
605
606
607export const CellRenderer = memo(function CellRenderer(props: CellRendererProps) {
608 const { record, property, readOnly, onEdit } = props;
609
610 if (property.type === "button") {
611 return readOnly ? renderValue(props) : <ButtonsCell record={record} property={property} />;
612 }
613
614 const editable = !readOnly && isEditableProperty(property);
615
616
617
618
619
620 if (INTERACTIVE_CELL_TYPES.has(property.type)) {
621 return (
622 <div
623 className="flex h-full w-full items-center overflow-hidden rounded px-1 text-left text-sm"
624 aria-label={`${property.name} cell`}
625 >
626 {renderValue(props)}
627 </div>
628 );
629 }
630
631 return (
632 <button
633 type="button"
634 disabled={!editable}
635 className={cn(
636 "flex h-full w-full items-center overflow-hidden rounded px-1 text-left text-sm",
637 editable ? "cursor-text hover:bg-muted/60 focus-visible:ring-1 focus-visible:ring-ring" : "cursor-default"
638 )}
639 onClick={(e) => {
640 e.stopPropagation();
641 if (editable) onEdit?.(record, property);
642 }}
643 aria-label={`${property.name} cell${editable ? " (click to edit)" : ""}`}
644 title={editable ? undefined : undefined}
645 >
646 {renderValue(props)}
647 </button>
648 );
649});
650
651
655export function cellToText(
656 value: RecordValue | undefined | null,
657 property: DatabaseProperty,
658 users?: Record<string, UserRef>
659): string {
660 if (value == null) return "";
661 if (value.text != null) return value.text;
662 if (value.value != null) {
663 if (property.type === "checkbox") return value.value ? "Yes" : "No";
664 if (property.type === "number") return formatNumberValue(value.value, property.config);
665 return String(value.value);
666 }
667 if (value.optionId && property.config.options) {
668 return findOption(property.config.options, value.optionId)?.name ?? "";
669 }
670 if (value.optionIds?.length && property.config.options) {
671 return value.optionIds
672 .map((id) => findOption(property.config.options, id)?.name)
673 .filter(Boolean)
674 .join(", ");
675 }
676 if (value.start) return formatDateValue(value.start, property.config);
677 if (value.end) return `${formatDateValue(value.start, property.config)} → ${formatDateValue(value.end, property.config)}`;
678 if (value.userIds?.length) {
679 return value.userIds.map((id) => users?.[id]?.name ?? id).join(", ");
680 }
681 if (value.recordIds) return `${value.recordIds.length} linked`;
682 if (value.fileIds) return `${value.fileIds.length} file(s)`;
683 if (value.error) return "⚠ formula error";
684 return "";
685}
686