"use client"; /** * Insert dialog (opened from the slash menu / toolbar): a modal with tabs. * * Image — file picker → presigned upload (lib/api uploadFile) → image block * Link — url + title → bookmark block * Table — rows × cols → table block with the grid pre-created * Database — new database (title) or choose an existing one */ import { useEffect, useState } from "react"; import { useMutation } from "@tanstack/react-query"; import { Loader2 } from "lucide-react"; import { pageApi, uploadFile } from "@/lib/api"; import { useInsertBlock } from "@/hooks/use-blocks"; import { toast } from "@/stores/ui-store"; import { cn } from "@/lib/utils"; import { Field, Input } from "@/components/ui/form"; type Tab = "image" | "link" | "table" | "database"; const TABS: { id: Tab; label: string }[] = [ { id: "image", label: "Image" }, { id: "link", label: "Link" }, { id: "table", label: "Table" }, { id: "database", label: "Database" } ]; export interface InsertDialogProps { open: boolean; onClose: () => void; /** Tab shown first (defaults to image). */ initialTab?: Tab; /** Insert after this block (parentId = that block's parent). */ afterBlockId: string | null; parentId: string | null; pageId: string; /** TASK-0100 S5: the workspace the new database page is created in (the * API's nested POST /workspaces/:workspaceId/pages needs it in the path). */ workspaceId: string; } export function InsertDialog({ open, onClose, initialTab, afterBlockId, parentId, pageId, workspaceId }: InsertDialogProps) { const [tab, setTab] = useState(initialTab ?? "image"); const insertBlock = useInsertBlock(pageId); useEffect(() => { if (open) setTab(initialTab ?? "image"); }, [open, initialTab]); useEffect(() => { if (!open) return; const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); }; window.addEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey); }, [open, onClose]); if (!open) return null; return (

Insert

{TABS.map((t) => ( ))}
{tab === "image" ? ( { await insertBlock.mutateAsync({ parentId, afterBlockId, type: "image", properties: { src, caption } }); onClose(); }} /> ) : null} {tab === "link" ? ( { await insertBlock.mutateAsync({ parentId, afterBlockId, type: "bookmark", properties: { src: url, title } }); onClose(); }} /> ) : null} {tab === "table" ? ( { await insertBlock.mutateAsync({ parentId, afterBlockId, type: "table", properties: { columns: Array.from({ length: cols }, (_, i) => ({ id: `col${i + 1}`, type: "text", width: 160, label: `Column ${i + 1}` })), rows: Array.from({ length: rows }, () => Array(cols).fill("")) } }); onClose(); }} /> ) : null} {tab === "database" ? ( { await insertBlock.mutateAsync({ parentId, afterBlockId, type: "database", properties: { databaseId } }); onClose(); }} /> ) : null}
); } /* --------------------------------------------------------------- tabs */ function ImageTab({ onInsert, workspaceId }: { onInsert: (src: string, caption: string) => Promise; /** Workspace the file lands in (sent as x-factory-workspace on the * session/complete calls) — without it a multi-workspace user's upload * silently routes to their FIRST workspace. */ workspaceId?: string; }) { const [file, setFile] = useState(null); const [caption, setCaption] = useState(""); const [uploading, setUploading] = useState(false); const submit = async () => { if (!file) { toast("error", "Choose an image file first"); return; } setUploading(true); try { // lib/api: presigned upload session → PUT bytes → complete → FileMeta.url. const meta = await uploadFile(file, undefined, { workspaceId }); // A quarantined (VIRUS_DETECTED) file has no signed URL — don't insert // a broken block; the files browser / block renders the red card. if (meta.status !== "ready" || !meta.url) { toast("error", "Upload was blocked by the virus scan — contact an admin"); return; } await onInsert(meta.url, caption.trim()); } catch (err) { toast("error", "Upload failed", err instanceof Error ? err.message : undefined); } finally { setUploading(false); } }; return (
{ e.preventDefault(); void submit(); }} > setCaption(e.target.value)} />
); } function LinkTab({ onInsert }: { onInsert: (url: string, title: string) => Promise }) { const [url, setUrl] = useState(""); const [title, setTitle] = useState(""); const submit = async () => { if (!/^https?:\/\//.test(url)) { toast("error", "Enter a valid http(s) URL"); return; } await onInsert(url, title.trim() || url); }; return (
{ e.preventDefault(); void submit(); }} > setUrl(e.target.value)} /> setTitle(e.target.value)} />
); } function TableTab({ onInsert }: { onInsert: (rows: number, cols: number) => Promise }) { const [rows, setRows] = useState(3); const [cols, setCols] = useState(3); return (
{ e.preventDefault(); void onInsert(Math.min(50, Math.max(1, rows)), Math.min(12, Math.max(1, cols))); }} >
setRows(Number(e.target.value) || 1)} /> setCols(Number(e.target.value) || 1)} />
); } function DatabaseTab({ pageId, parentId, workspaceId, onInsert }: { pageId: string; parentId: string | null; workspaceId: string; onInsert: (databaseId: string) => Promise; }) { const [title, setTitle] = useState(""); const create = useMutation({ mutationFn: async () => { // Create the database page, then wrap it in a database block. The // database itself is initialized server-side with default properties // (title) when the page is created with type=database. const page = await pageApi.create({ workspaceId, parentId, title, type: "database" }); return page; } }); const submit = async () => { if (title.trim().length < 1) { toast("error", "Give the database a name"); return; } try { const page = await create.mutateAsync(); // The block must reference the seeded Database row's id, NOT the page id: // `Database.id` is its own cuid (schema) and `GET /databases/:id` 404s for // a page id — the table view then renders nothing (verified in the // Phase-15 E2E diag). `page.service.create` returns the Database id as a // sibling of the page; fall back to the page id only if absent. await onInsert(page.databaseId ?? page.id); } catch (err) { toast("error", "Could not create database", err instanceof Error ? err.message : undefined); } }; return (
{ e.preventDefault(); void submit(); }} > setTitle(e.target.value)} />

A new database page with a default Title property is created. Choose an existing database via “Convert → Database” on a page.

); }