/** * CaptureModal - Quick document creation modal. * * Features: * - Title, content, collection, provenance fields * - Auto-generates filename from title * - Remembers last used collection * - Shows capture receipt status after creation */ import { AlertCircleIcon, CheckCircle2Icon, ExternalLinkIcon, FolderIcon, Loader2Icon, } from "lucide-react"; import { useCallback, useEffect, useRef, useState } from "react"; import type { CaptureReceipt, CaptureSourceKind } from "../../../core/capture"; import type { WikiLinkDoc } from "./WikiLinkAutocomplete"; import { getNotePreset, NOTE_PRESETS, resolveNotePreset, } from "../../../core/note-presets"; import { apiFetch } from "../hooks/use-api"; import { getActiveWikiLinkQuery } from "../lib/wiki-link"; import { IndexingProgress } from "./IndexingProgress"; import { TagInput } from "./TagInput"; import { Button } from "./ui/button"; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from "./ui/dialog"; import { Input } from "./ui/input"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "./ui/select"; import { Textarea } from "./ui/textarea"; import { WikiLinkAutocomplete } from "./WikiLinkAutocomplete"; export interface CaptureModalProps { /** Whether the modal is open */ open: boolean; /** Prefill title when opening from another surface */ draftTitle?: string; /** Default collection from current workspace context */ defaultCollection?: string; /** Default folder path from current workspace context */ defaultFolderPath?: string; /** Optional preset id */ presetId?: string; /** Callback when open state changes */ onOpenChange: (open: boolean) => void; /** Callback when document created successfully */ onSuccess?: (uri: string) => void; } interface Collection { name: string; path: string; } interface CreateDocResponse { uri: string; path: string; jobId: string | null; note: string; openedExisting?: boolean; created?: boolean; relPath?: string; } type CaptureResponse = CaptureReceipt; interface CollectionsResponse { collections: Collection[]; } interface DocsAutocompleteResponse { docs: WikiLinkDoc[]; } const STORAGE_KEY = "gno-last-collection"; function sanitizeFilename(title: string): string { return title .toLowerCase() .trim() .replaceAll(/[^\w\s-]/g, "") .replaceAll(/\s+/g, "-") .replaceAll(/-+/g, "-") .replace(/^-|-$/g, ""); } type ModalState = "form" | "submitting" | "success" | "error"; const SOURCE_KINDS: CaptureSourceKind[] = [ "direct", "web", "email", "meeting", "chat", "file", "api", "unknown", ]; function statusLabel(status: CaptureReceipt["sync"]["status"]): string { switch (status) { case "completed": return "Completed"; case "pending": return "Pending"; case "running": return "Running"; case "skipped": return "Skipped"; case "failed": return "Failed"; case "not_requested": return "Not requested"; default: return "Unknown"; } } export function CaptureModal({ open, draftTitle = "", defaultCollection = "", defaultFolderPath = "", onOpenChange, onSuccess, presetId = "", }: CaptureModalProps) { // Form state const [title, setTitle] = useState(""); const [content, setContent] = useState(""); const [collection, setCollection] = useState(""); const [collections, setCollections] = useState([]); const [tags, setTags] = useState([]); const [selectedPresetId, setSelectedPresetId] = useState(presetId); const [sourceKind, setSourceKind] = useState("direct"); const [sourceTitle, setSourceTitle] = useState(""); const [sourceUrl, setSourceUrl] = useState(""); const [sourceAuthor, setSourceAuthor] = useState(""); const [sourceObservedAt, setSourceObservedAt] = useState(""); const [sourceExternalId, setSourceExternalId] = useState(""); const [contentTouched, setContentTouched] = useState(false); const [lastGeneratedContent, setLastGeneratedContent] = useState(""); const [wikiLinkDocs, setWikiLinkDocs] = useState([]); const [wikiLinkOpen, setWikiLinkOpen] = useState(false); const [wikiLinkQuery, setWikiLinkQuery] = useState(""); const [wikiLinkRange, setWikiLinkRange] = useState<{ start: number; end: number; } | null>(null); const [wikiLinkPosition, setWikiLinkPosition] = useState({ x: 24, y: 24 }); const [wikiLinkActiveIndex, setWikiLinkActiveIndex] = useState(-1); const contentRef = useRef(null); // Submission state const [state, setState] = useState("form"); const [error, setError] = useState(null); const [jobId, setJobId] = useState(null); const [createdUri, setCreatedUri] = useState(null); const [captureReceipt, setCaptureReceipt] = useState( null ); // Load collections useEffect(() => { if (!open) return; if (draftTitle.trim()) { setTitle(draftTitle); } setSelectedPresetId(presetId || "blank"); void apiFetch("/api/status").then(({ data }) => { if (data?.collections) { setCollections( data.collections.map((c) => ({ name: c.name, path: c.path })) ); const requestedCollection = defaultCollection.trim(); const lastUsed = localStorage.getItem(STORAGE_KEY); if ( requestedCollection && data.collections.some((c) => c.name === requestedCollection) ) { setCollection(requestedCollection); } else if ( lastUsed && data.collections.some((c) => c.name === lastUsed) ) { setCollection(lastUsed); } else { const firstCollection = data.collections.at(0); if (firstCollection) { setCollection(firstCollection.name); } } } }); }, [defaultCollection, draftTitle, open, presetId]); // Reset form when modal closes useEffect(() => { if (!open) { // Small delay to let close animation finish const timer = setTimeout(() => { setTitle(""); setContent(""); setTags([]); setSourceKind("direct"); setSourceTitle(""); setSourceUrl(""); setSourceAuthor(""); setSourceObservedAt(""); setSourceExternalId(""); setContentTouched(false); setLastGeneratedContent(""); setSelectedPresetId("blank"); setState("form"); setError(null); setJobId(null); setCreatedUri(null); setCaptureReceipt(null); setWikiLinkDocs([]); setWikiLinkOpen(false); setWikiLinkQuery(""); setWikiLinkRange(null); }, 200); return () => clearTimeout(timer); } }, [open]); // Validate form const isValid = title.trim() && content.trim() && collection; useEffect(() => { if (!open) { return; } const resolved = resolveNotePreset({ presetId: selectedPresetId || "blank", title: title.trim() || draftTitle.trim() || "Untitled", tags, }); const generatedContent = resolved?.content ?? ""; const shouldApply = !contentTouched || !content.trim() || content === lastGeneratedContent; setLastGeneratedContent(generatedContent); if (shouldApply) { setContent(generatedContent); if (resolved?.tags) { const nextTags = resolved.tags; const sameTags = nextTags.length === tags.length && nextTags.every((tag, index) => tags[index] === tag); if (!sameTags) { setTags(nextTags); } } } }, [ content, contentTouched, draftTitle, lastGeneratedContent, open, selectedPresetId, tags, title, ]); // Submit form const handleSubmit = useCallback(async () => { if (!isValid) return; setState("submitting"); setError(null); const source = { kind: sourceKind, ...(sourceTitle.trim() && { title: sourceTitle.trim() }), ...(sourceUrl.trim() && { url: sourceUrl.trim() }), ...(sourceAuthor.trim() && { author: sourceAuthor.trim() }), ...(sourceObservedAt.trim() && { observedAt: sourceObservedAt.trim() }), ...(sourceExternalId.trim() && { externalId: sourceExternalId.trim() }), }; const presetOnly = selectedPresetId && selectedPresetId !== "blank" && content === lastGeneratedContent; const submitPresetId = presetOnly && selectedPresetId && selectedPresetId !== "blank" ? selectedPresetId : undefined; const { data, error: err } = await apiFetch( "/api/capture", { method: "POST", body: JSON.stringify({ collection, title, folderPath: defaultFolderPath || undefined, content: presetOnly ? undefined : content, presetId: submitPresetId, collisionPolicy: "create_with_suffix", source, ...(tags.length > 0 && { tags }), }), } ); if (err) { setState("error"); setError(err); return; } if (data) { // Save last used collection localStorage.setItem(STORAGE_KEY, collection); setState("success"); setJobId(data.sync.jobId ?? null); setCreatedUri(data.uri); setCaptureReceipt(data); onSuccess?.(data.uri); } }, [ collection, content, defaultFolderPath, isValid, lastGeneratedContent, onSuccess, selectedPresetId, sourceAuthor, sourceExternalId, sourceKind, sourceObservedAt, sourceTitle, sourceUrl, tags, title, ]); // Handle keyboard submit const handleKeyDown = useCallback( (e: React.KeyboardEvent) => { if ((e.metaKey || e.ctrlKey) && e.key === "Enter" && isValid) { e.preventDefault(); void handleSubmit(); } }, [isValid, handleSubmit] ); // Open in editor const handleOpenInEditor = () => { if (createdUri) { onOpenChange(false); window.location.href = `/edit?uri=${encodeURIComponent(createdUri)}`; } }; const insertWikiLink = useCallback( (title: string) => { if (!wikiLinkRange) return; const nextContent = content.slice(0, wikiLinkRange.start) + `[[${title}]]` + content.slice(wikiLinkRange.end); setContent(nextContent); setWikiLinkOpen(false); setWikiLinkActiveIndex(-1); requestAnimationFrame(() => { const pos = wikiLinkRange.start + title.length + 4; contentRef.current?.focus(); contentRef.current?.setSelectionRange(pos, pos); }); }, [content, wikiLinkRange] ); const handleCreateLinkedNote = useCallback( async (linkedTitle: string) => { const { data, error: err } = await apiFetch( "/api/docs", { method: "POST", body: JSON.stringify({ collection, title: linkedTitle, folderPath: defaultFolderPath || undefined, content: `# ${linkedTitle}\n`, collisionPolicy: "open_existing", }), } ); if (err) { setError(err); return; } insertWikiLink(linkedTitle); if (data) { setWikiLinkDocs((current) => [ ...current, { title: linkedTitle, uri: data.uri, docid: data.uri, collection, }, ]); } }, [collection, defaultFolderPath, insertWikiLink] ); const handleContentInput = useCallback((nextContent: string) => { setContentTouched(true); setContent(nextContent); const cursorPos = contentRef.current?.selectionStart ?? nextContent.length; const activeQuery = getActiveWikiLinkQuery(nextContent, cursorPos); if (!activeQuery) { setWikiLinkOpen(false); setWikiLinkRange(null); return; } const textareaRect = contentRef.current?.getBoundingClientRect(); setWikiLinkRange({ start: activeQuery.start, end: activeQuery.end }); setWikiLinkQuery(activeQuery.query); setWikiLinkPosition({ x: textareaRect?.left ?? 24, y: (textareaRect?.top ?? 24) + 40, }); setWikiLinkOpen(true); setWikiLinkActiveIndex(0); }, []); useEffect(() => { if (!wikiLinkOpen) return; const params = new URLSearchParams({ limit: "8", query: wikiLinkQuery, }); if (collection) { params.set("collection", collection); } void apiFetch( `/api/docs/autocomplete?${params.toString()}` ).then(({ data }) => { setWikiLinkDocs(data?.docs ?? []); }); }, [collection, wikiLinkOpen, wikiLinkQuery]); return ( {state === "success" ? "Note created" : "New note"} {state === "form" && ( Create a new markdown document in your collection. )} {/* Form state */} {(state === "form" || state === "submitting") && (
{/* Title */}
setTitle(e.target.value)} placeholder="My new note" value={title} /> {title && (

{sanitizeFilename(title) || "untitled"}.md

)}
{/* Content */}