1"use client";
2
3
15import { useEffect } from "react";
16import { usePathname } from "next/navigation";
17import { CalendarClock, Eye, ExternalLink, X } from "lucide-react";
18import { cn } from "@/lib/utils";
19import { formatRelativeTime } from "@/lib/utils";
20import {
21 DatabaseProperty,
22 RecordDto,
23 pageUrlFromPath,
24 useDatabaseRecord
25} from "@/hooks/use-database";
26import { formatDateValue } from "@/components/database/cell-renderer";
27import { CellEditor } from "@/components/database/cell-editor";
28
29export interface RecordPanelProps {
30 databaseId: string;
31 record: RecordDto;
32 properties: DatabaseProperty[];
33 onClose: () => void;
34 readOnly?: boolean;
35}
36
37const SKIP_TYPES = new Set(["button", "created_time", "created_by", "last_edited_time", "last_edited_by", "unique_id"]);
38
39export function RecordPanel({ databaseId, record, properties, onClose, readOnly }: RecordPanelProps) {
40
41 const { data: fresh, isLoading } = useDatabaseRecord(databaseId, record.id);
42 const r = fresh ?? record;
43 const titleProp = properties.find((p) => p.isTitle) ?? properties.find((p) => p.type === "title");
44
45 useEffect(() => {
46 const onKey = (e: KeyboardEvent) => {
47 if (e.key === "Escape") onClose();
48 };
49 document.addEventListener("keydown", onKey);
50 return () => document.removeEventListener("keydown", onKey);
51 }, [onClose]);
52
53 const editableProps = properties.filter((p) => !SKIP_TYPES.has(p.type) && p !== titleProp);
54
55 const pathname = usePathname();
56
57 const openPage = () => {
58 if (!r.pageId) return;
59
60
61 window.open(pageUrlFromPath(pathname, r.pageId), "_blank", "noopener,noreferrer");
62 };
63
64 return (
65 <>
66 {}
67 <button
68 type="button"
69 className="fixed inset-0 z-40 cursor-default bg-black/20"
70 onClick={onClose}
71 aria-label="Close record panel"
72 tabIndex={-1}
73 />
74 <aside className="fixed inset-y-0 right-0 z-50 flex w-full max-w-md flex-col border-l border-border bg-background shadow-xl" role="dialog" aria-modal="true" aria-label="Record details">
75 {}
76 <div className="flex items-center gap-2 border-b border-border px-4 py-3">
77 <button type="button" className="flex min-w-0 flex-1 items-center gap-2 text-left" onClick={openPage} title="Open record page">
78 <span className="text-lg" aria-hidden>
79 {r.icon ?? "📄"}
80 </span>
81 <span className="truncate text-sm font-medium">{titleProp?.name ?? "Title"}</span>
82 <ExternalLink className="size-3.5 shrink-0 text-muted-foreground" aria-hidden />
83 </button>
84 <button type="button" className="rounded p-1 text-muted-foreground hover:bg-muted" onClick={onClose} aria-label="Close">
85 <X className="size-4" />
86 </button>
87 </div>
88
89 <div className="min-h-0 flex-1 overflow-y-auto">
90 {}
91 <div className="border-b border-border px-4 py-3">
92 {titleProp && (
93 <CellEditor
94 record={r}
95 property={titleProp}
96 onDone={() => undefined}
97 className="text-base font-semibold"
98 />
99 )}
100 </div>
101
102 {}
103 <div className="divide-y divide-border/50">
104 {isLoading && <p className="px-4 py-3 text-xs text-muted-foreground">Loading…</p>}
105 {editableProps.map((p) => (
106 <div key={p.id} className="flex items-start gap-3 px-4 py-2.5">
107 <span className="w-28 shrink-0 truncate pt-1.5 text-xs text-muted-foreground" title={p.name}>
108 {p.name}
109 {p.required && <span className="ml-0.5 text-destructive">*</span>}
110 </span>
111 <div className="min-w-0 flex-1">
112 {readOnly ? (
113 <span className="text-sm">{renderPlain(r, p)}</span>
114 ) : (
115 <CellEditor record={r} property={p} onDone={() => undefined} />
116 )}
117 </div>
118 </div>
119 ))}
120 </div>
121
122 {}
123 <div className="space-y-1.5 border-t border-border px-4 py-3 text-xs text-muted-foreground">
124 <p className="inline-flex items-center gap-1.5">
125 <Eye className="size-3.5" aria-hidden />
126 Record · {r.id.slice(-8)}
127 </p>
128 <p>
129 Created {formatRelativeTime(r.createdTime)}
130 {r.createdBy ? ` by ${r.createdBy.slice(-8)}` : ""}
131 </p>
132 <p className="inline-flex items-center gap-1.5">
133 <CalendarClock className="size-3.5" aria-hidden />
134 Last edited {formatRelativeTime(r.lastEditedTime)}
135 </p>
136 <p className="text-[10px] opacity-70">v{r.version}</p>
137 </div>
138 </div>
139 </aside>
140 </>
141 );
142}
143
144
145function renderPlain(record: RecordDto, p: DatabaseProperty): string {
146 const v = record.properties[p.id];
147 if (!v) return "—";
148 if (typeof v.text === "string") return v.text;
149 if (typeof v.value === "number" || typeof v.value === "string" || typeof v.value === "boolean") return String(v.value);
150 if (v.optionId) return v.optionId.slice(-6);
151 if (v.optionIds?.length) return `${v.optionIds.length} selected`;
152 if (v.userIds?.length) return `${v.userIds.length} person(s)`;
153 if (v.recordIds?.length) return `${v.recordIds.length} linked`;
154 if (v.fileIds?.length) return `${v.fileIds.length} file(s)`;
155 if (v.start) return formatDateValue(v.start);
156 if (v.value != null) return String(v.value);
157 return "—";
158}
159