1"use client";
2
3
11import { useEffect, useState } from "react";
12import { useMutation } from "@tanstack/react-query";
13import { Loader2 } from "lucide-react";
14import { pageApi, uploadFile } from "@/lib/api";
15import { useInsertBlock } from "@/hooks/use-blocks";
16import { toast } from "@/stores/ui-store";
17import { cn } from "@/lib/utils";
18import { Field, Input } from "@/components/ui/form";
19
20type Tab = "image" | "link" | "table" | "database";
21
22const TABS: { id: Tab; label: string }[] = [
23 { id: "image", label: "Image" },
24 { id: "link", label: "Link" },
25 { id: "table", label: "Table" },
26 { id: "database", label: "Database" }
27];
28
29export interface InsertDialogProps {
30 open: boolean;
31 onClose: () => void;
32
33 initialTab?: Tab;
34
35 afterBlockId: string | null;
36 parentId: string | null;
37 pageId: string;
38
40 workspaceId: string;
41}
42
43export function InsertDialog({ open, onClose, initialTab, afterBlockId, parentId, pageId, workspaceId }: InsertDialogProps) {
44 const [tab, setTab] = useState<Tab>(initialTab ?? "image");
45 const insertBlock = useInsertBlock(pageId);
46
47 useEffect(() => {
48 if (open) setTab(initialTab ?? "image");
49 }, [open, initialTab]);
50
51 useEffect(() => {
52 if (!open) return;
53 const onKey = (e: KeyboardEvent) => {
54 if (e.key === "Escape") onClose();
55 };
56 window.addEventListener("keydown", onKey);
57 return () => window.removeEventListener("keydown", onKey);
58 }, [open, onClose]);
59
60 if (!open) return null;
61
62 return (
63 <div className="fixed inset-0 z-[85] flex items-center justify-center p-4">
64 <div className="absolute inset-0 bg-black/40" onClick={onClose} aria-hidden />
65 <div
66 role="dialog"
67 aria-modal="true"
68 aria-label="Insert"
69 data-testid="insert-dialog"
70 className="relative w-full max-w-md rounded-lg border border-border bg-card shadow-2xl"
71 >
72 <div className="flex items-center justify-between border-b border-border px-4 py-3">
73 <h2 className="text-base font-semibold">Insert</h2>
74 <button
75 type="button"
76 onClick={onClose}
77 className="rounded p-1 text-muted-foreground hover:bg-accent hover:text-foreground"
78 aria-label="Close insert dialog"
79 >
80 ✕
81 </button>
82 </div>
83
84 <div role="tablist" aria-label="Insert type" className="flex gap-1 border-b border-border px-4 pt-3">
85 {TABS.map((t) => (
86 <button
87 key={t.id}
88 type="button"
89 role="tab"
90 aria-selected={tab === t.id}
91 onClick={() => setTab(t.id)}
92 className={cn(
93 "rounded-t-md px-3 py-1.5 text-sm font-medium",
94 tab === t.id ? "border border-b-0 border-border bg-background text-foreground" : "text-muted-foreground hover:text-foreground"
95 )}
96 >
97 {t.label}
98 </button>
99 ))}
100 </div>
101
102 <div className="p-4">
103 {tab === "image" ? (
104 <ImageTab
105 workspaceId={workspaceId}
106 onInsert={async (src, caption) => {
107 await insertBlock.mutateAsync({
108 parentId,
109 afterBlockId,
110 type: "image",
111 properties: { src, caption }
112 });
113 onClose();
114 }}
115 />
116 ) : null}
117 {tab === "link" ? (
118 <LinkTab
119 onInsert={async (url, title) => {
120 await insertBlock.mutateAsync({
121 parentId,
122 afterBlockId,
123 type: "bookmark",
124 properties: { src: url, title }
125 });
126 onClose();
127 }}
128 />
129 ) : null}
130 {tab === "table" ? (
131 <TableTab
132 onInsert={async (rows, cols) => {
133 await insertBlock.mutateAsync({
134 parentId,
135 afterBlockId,
136 type: "table",
137 properties: {
138 columns: Array.from({ length: cols }, (_, i) => ({
139 id: `col${i + 1}`,
140 type: "text",
141 width: 160,
142 label: `Column ${i + 1}`
143 })),
144 rows: Array.from({ length: rows }, () => Array(cols).fill(""))
145 }
146 });
147 onClose();
148 }}
149 />
150 ) : null}
151 {tab === "database" ? (
152 <DatabaseTab
153 pageId={pageId}
154 parentId={parentId}
155 workspaceId={workspaceId}
156 onInsert={async (databaseId) => {
157 await insertBlock.mutateAsync({
158 parentId,
159 afterBlockId,
160 type: "database",
161 properties: { databaseId }
162 });
163 onClose();
164 }}
165 />
166 ) : null}
167 </div>
168 </div>
169 </div>
170 );
171}
172
173
174
175function ImageTab({
176 onInsert,
177 workspaceId
178}: {
179 onInsert: (src: string, caption: string) => Promise<void>;
180
183 workspaceId?: string;
184}) {
185 const [file, setFile] = useState<File | null>(null);
186 const [caption, setCaption] = useState("");
187 const [uploading, setUploading] = useState(false);
188
189 const submit = async () => {
190 if (!file) {
191 toast("error", "Choose an image file first");
192 return;
193 }
194 setUploading(true);
195 try {
196
197 const meta = await uploadFile(file, undefined, { workspaceId });
198
199
200 if (meta.status !== "ready" || !meta.url) {
201 toast("error", "Upload was blocked by the virus scan — contact an admin");
202 return;
203 }
204 await onInsert(meta.url, caption.trim());
205 } catch (err) {
206 toast("error", "Upload failed", err instanceof Error ? err.message : undefined);
207 } finally {
208 setUploading(false);
209 }
210 };
211
212 return (
213 <form
214 className="space-y-3"
215 onSubmit={(e) => {
216 e.preventDefault();
217 void submit();
218 }}
219 >
220 <label className="flex cursor-pointer flex-col items-center justify-center gap-2 rounded-md border border-dashed border-border p-6 text-sm text-muted-foreground hover:border-foreground/50">
221 <span>{file ? file.name : "Click to choose an image"}</span>
222 <span className="text-xs">PNG, JPG, GIF, WebP — up to 500 MB</span>
223 <input
224 type="file"
225 accept="image/*"
226 className="sr-only"
227 onChange={(e) => setFile(e.target.files?.[0] ?? null)}
228 />
229 </label>
230 <Field label="Caption (optional)" htmlFor="img-caption">
231 <Input id="img-caption" value={caption} onChange={(e) => setCaption(e.target.value)} />
232 </Field>
233 <button
234 type="submit"
235 disabled={uploading || !file}
236 className="inline-flex h-9 w-full items-center justify-center gap-2 rounded-md bg-primary text-sm font-medium text-primary-foreground hover:opacity-90 disabled:opacity-50"
237 >
238 {uploading ? <Loader2 className="size-4 animate-spin" aria-hidden /> : null}
239 {uploading ? "Uploading…" : "Insert image"}
240 </button>
241 </form>
242 );
243}
244
245function LinkTab({ onInsert }: { onInsert: (url: string, title: string) => Promise<void> }) {
246 const [url, setUrl] = useState("");
247 const [title, setTitle] = useState("");
248
249 const submit = async () => {
250 if (!/^https?:\/\
251 toast("error", "Enter a valid http(s) URL");
252 return;
253 }
254 await onInsert(url, title.trim() || url);
255 };
256
257 return (
258 <form
259 className="space-y-3"
260 onSubmit={(e) => {
261 e.preventDefault();
262 void submit();
263 }}
264 >
265 <Field label="URL" htmlFor="link-url" error={!/^https?:\/\
266 <Input id="link-url" type="url" placeholder="https://example.com" value={url} onChange={(e) => setUrl(e.target.value)} />
267 </Field>
268 <Field label="Title (optional)" htmlFor="link-title">
269 <Input id="link-title" placeholder="Example page" value={title} onChange={(e) => setTitle(e.target.value)} />
270 </Field>
271 <button
272 type="submit"
273 disabled={!url}
274 className="h-9 w-full rounded-md bg-primary text-sm font-medium text-primary-foreground hover:opacity-90 disabled:opacity-50"
275 >
276 Insert bookmark
277 </button>
278 </form>
279 );
280}
281
282function TableTab({ onInsert }: { onInsert: (rows: number, cols: number) => Promise<void> }) {
283 const [rows, setRows] = useState(3);
284 const [cols, setCols] = useState(3);
285
286 return (
287 <form
288 className="space-y-3"
289 onSubmit={(e) => {
290 e.preventDefault();
291 void onInsert(Math.min(50, Math.max(1, rows)), Math.min(12, Math.max(1, cols)));
292 }}
293 >
294 <div className="flex gap-3">
295 <Field label="Rows" htmlFor="tbl-rows">
296 <Input
297 id="tbl-rows"
298 type="number"
299 min={1}
300 max={50}
301 value={rows}
302 onChange={(e) => setRows(Number(e.target.value) || 1)}
303 />
304 </Field>
305 <Field label="Columns" htmlFor="tbl-cols">
306 <Input
307 id="tbl-cols"
308 type="number"
309 min={1}
310 max={12}
311 value={cols}
312 onChange={(e) => setCols(Number(e.target.value) || 1)}
313 />
314 </Field>
315 </div>
316 <button type="submit" className="h-9 w-full rounded-md bg-primary text-sm font-medium text-primary-foreground hover:opacity-90">
317 Insert table
318 </button>
319 </form>
320 );
321}
322
323function DatabaseTab({
324 pageId,
325 parentId,
326 workspaceId,
327 onInsert
328}: {
329 pageId: string;
330 parentId: string | null;
331 workspaceId: string;
332 onInsert: (databaseId: string) => Promise<void>;
333}) {
334 const [title, setTitle] = useState("");
335 const create = useMutation({
336 mutationFn: async () => {
337
338
339
340 const page = await pageApi.create({ workspaceId, parentId, title, type: "database" });
341 return page;
342 }
343 });
344
345 const submit = async () => {
346 if (title.trim().length < 1) {
347 toast("error", "Give the database a name");
348 return;
349 }
350 try {
351 const page = await create.mutateAsync();
352
353
354
355
356
357 await onInsert(page.databaseId ?? page.id);
358 } catch (err) {
359 toast("error", "Could not create database", err instanceof Error ? err.message : undefined);
360 }
361 };
362
363 return (
364 <form
365 className="space-y-3"
366 onSubmit={(e) => {
367 e.preventDefault();
368 void submit();
369 }}
370 >
371 <Field label="Database name" htmlFor="db-title">
372 <Input id="db-title" placeholder="Tasks" value={title} onChange={(e) => setTitle(e.target.value)} />
373 </Field>
374 <p className="text-xs text-muted-foreground">
375 A new database page with a default Title property is created. Choose an existing database
376 via “Convert → Database” on a page.
377 </p>
378 <button
379 type="submit"
380 disabled={create.isPending}
381 className="inline-flex h-9 w-full items-center justify-center gap-2 rounded-md bg-primary text-sm font-medium text-primary-foreground hover:opacity-90 disabled:opacity-50"
382 >
383 {create.isPending ? <Loader2 className="size-4 animate-spin" aria-hidden /> : null}
384 {create.isPending ? "Creating…" : "Create database"}
385 </button>
386 </form>
387 );
388}
389