Spaces:
Paused
Paused
| import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; | |
| import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; | |
| import type { Issue, IssueDocument } from "@paperclipai/shared"; | |
| import { useLocation } from "@/lib/router"; | |
| import { ApiError } from "../api/client"; | |
| import { issuesApi } from "../api/issues"; | |
| import { useAutosaveIndicator } from "../hooks/useAutosaveIndicator"; | |
| import { queryKeys } from "../lib/queryKeys"; | |
| import { cn, relativeTime } from "../lib/utils"; | |
| import { MarkdownBody } from "./MarkdownBody"; | |
| import { MarkdownEditor, type MentionOption } from "./MarkdownEditor"; | |
| import { Button } from "@/components/ui/button"; | |
| import { Input } from "@/components/ui/input"; | |
| import { | |
| DropdownMenu, | |
| DropdownMenuContent, | |
| DropdownMenuItem, | |
| DropdownMenuSeparator, | |
| DropdownMenuTrigger, | |
| } from "@/components/ui/dropdown-menu"; | |
| import { Check, ChevronDown, ChevronRight, Copy, Download, FileText, MoreHorizontal, Plus, Trash2, X } from "lucide-react"; | |
| type DraftState = { | |
| key: string; | |
| title: string; | |
| body: string; | |
| baseRevisionId: string | null; | |
| isNew: boolean; | |
| }; | |
| type DocumentConflictState = { | |
| key: string; | |
| serverDocument: IssueDocument; | |
| localDraft: DraftState; | |
| showRemote: boolean; | |
| }; | |
| const DOCUMENT_AUTOSAVE_DEBOUNCE_MS = 900; | |
| const DOCUMENT_KEY_PATTERN = /^[a-z0-9][a-z0-9_-]*$/; | |
| const getFoldedDocumentsStorageKey = (issueId: string) => `paperclip:issue-document-folds:${issueId}`; | |
| function loadFoldedDocumentKeys(issueId: string) { | |
| if (typeof window === "undefined") return []; | |
| try { | |
| const raw = window.localStorage.getItem(getFoldedDocumentsStorageKey(issueId)); | |
| if (!raw) return []; | |
| const parsed = JSON.parse(raw); | |
| return Array.isArray(parsed) ? parsed.filter((value): value is string => typeof value === "string") : []; | |
| } catch { | |
| return []; | |
| } | |
| } | |
| function saveFoldedDocumentKeys(issueId: string, keys: string[]) { | |
| if (typeof window === "undefined") return; | |
| window.localStorage.setItem(getFoldedDocumentsStorageKey(issueId), JSON.stringify(keys)); | |
| } | |
| function renderBody(body: string, className?: string) { | |
| return <MarkdownBody className={className}>{body}</MarkdownBody>; | |
| } | |
| function isPlanKey(key: string) { | |
| return key.trim().toLowerCase() === "plan"; | |
| } | |
| function titlesMatchKey(title: string | null | undefined, key: string) { | |
| return (title ?? "").trim().toLowerCase() === key.trim().toLowerCase(); | |
| } | |
| function isDocumentConflictError(error: unknown) { | |
| return error instanceof ApiError && error.status === 409; | |
| } | |
| function downloadDocumentFile(key: string, body: string) { | |
| const blob = new Blob([body], { type: "text/markdown;charset=utf-8" }); | |
| const url = URL.createObjectURL(blob); | |
| const anchor = document.createElement("a"); | |
| anchor.href = url; | |
| anchor.download = `${key}.md`; | |
| document.body.appendChild(anchor); | |
| anchor.click(); | |
| anchor.remove(); | |
| URL.revokeObjectURL(url); | |
| } | |
| export function IssueDocumentsSection({ | |
| issue, | |
| canDeleteDocuments, | |
| mentions, | |
| imageUploadHandler, | |
| extraActions, | |
| }: { | |
| issue: Issue; | |
| canDeleteDocuments: boolean; | |
| mentions?: MentionOption[]; | |
| imageUploadHandler?: (file: File) => Promise<string>; | |
| extraActions?: ReactNode; | |
| }) { | |
| const queryClient = useQueryClient(); | |
| const location = useLocation(); | |
| const [confirmDeleteKey, setConfirmDeleteKey] = useState<string | null>(null); | |
| const [error, setError] = useState<string | null>(null); | |
| const [draft, setDraft] = useState<DraftState | null>(null); | |
| const [documentConflict, setDocumentConflict] = useState<DocumentConflictState | null>(null); | |
| const [foldedDocumentKeys, setFoldedDocumentKeys] = useState<string[]>(() => loadFoldedDocumentKeys(issue.id)); | |
| const [autosaveDocumentKey, setAutosaveDocumentKey] = useState<string | null>(null); | |
| const [copiedDocumentKey, setCopiedDocumentKey] = useState<string | null>(null); | |
| const [highlightDocumentKey, setHighlightDocumentKey] = useState<string | null>(null); | |
| const autosaveDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null); | |
| const copiedDocumentTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null); | |
| const hasScrolledToHashRef = useRef(false); | |
| const { | |
| state: autosaveState, | |
| markDirty, | |
| reset, | |
| runSave, | |
| } = useAutosaveIndicator(); | |
| const { data: documents } = useQuery({ | |
| queryKey: queryKeys.issues.documents(issue.id), | |
| queryFn: () => issuesApi.listDocuments(issue.id), | |
| }); | |
| const invalidateIssueDocuments = () => { | |
| queryClient.invalidateQueries({ queryKey: queryKeys.issues.detail(issue.id) }); | |
| queryClient.invalidateQueries({ queryKey: queryKeys.issues.documents(issue.id) }); | |
| }; | |
| const upsertDocument = useMutation({ | |
| mutationFn: async (nextDraft: DraftState) => | |
| issuesApi.upsertDocument(issue.id, nextDraft.key, { | |
| title: isPlanKey(nextDraft.key) ? null : nextDraft.title.trim() || null, | |
| format: "markdown", | |
| body: nextDraft.body, | |
| baseRevisionId: nextDraft.baseRevisionId, | |
| }), | |
| }); | |
| const deleteDocument = useMutation({ | |
| mutationFn: (key: string) => issuesApi.deleteDocument(issue.id, key), | |
| onSuccess: () => { | |
| setError(null); | |
| setConfirmDeleteKey(null); | |
| invalidateIssueDocuments(); | |
| }, | |
| onError: (err) => { | |
| setError(err instanceof Error ? err.message : "Failed to delete document"); | |
| }, | |
| }); | |
| const sortedDocuments = useMemo(() => { | |
| return [...(documents ?? [])].sort((a, b) => { | |
| if (a.key === "plan" && b.key !== "plan") return -1; | |
| if (a.key !== "plan" && b.key === "plan") return 1; | |
| return new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime(); | |
| }); | |
| }, [documents]); | |
| const hasRealPlan = sortedDocuments.some((doc) => doc.key === "plan"); | |
| const isEmpty = sortedDocuments.length === 0 && !issue.legacyPlanDocument; | |
| const newDocumentKeyError = | |
| draft?.isNew && draft.key.trim().length > 0 && !DOCUMENT_KEY_PATTERN.test(draft.key.trim()) | |
| ? "Use lowercase letters, numbers, -, or _, and start with a letter or number." | |
| : null; | |
| const resetAutosaveState = useCallback(() => { | |
| setAutosaveDocumentKey(null); | |
| reset(); | |
| }, [reset]); | |
| const markDocumentDirty = useCallback((key: string) => { | |
| setAutosaveDocumentKey(key); | |
| markDirty(); | |
| }, [markDirty]); | |
| const beginNewDocument = () => { | |
| resetAutosaveState(); | |
| setDocumentConflict(null); | |
| setDraft({ | |
| key: "", | |
| title: "", | |
| body: "", | |
| baseRevisionId: null, | |
| isNew: true, | |
| }); | |
| setError(null); | |
| }; | |
| const beginEdit = (key: string) => { | |
| const doc = sortedDocuments.find((entry) => entry.key === key); | |
| if (!doc) return; | |
| const conflictedDraft = documentConflict?.key === key ? documentConflict.localDraft : null; | |
| setFoldedDocumentKeys((current) => current.filter((entry) => entry !== key)); | |
| resetAutosaveState(); | |
| setDocumentConflict((current) => current?.key === key ? current : null); | |
| setDraft({ | |
| key: conflictedDraft?.key ?? doc.key, | |
| title: conflictedDraft?.title ?? doc.title ?? "", | |
| body: conflictedDraft?.body ?? doc.body, | |
| baseRevisionId: conflictedDraft?.baseRevisionId ?? doc.latestRevisionId, | |
| isNew: false, | |
| }); | |
| setError(null); | |
| }; | |
| const cancelDraft = () => { | |
| if (autosaveDebounceRef.current) { | |
| clearTimeout(autosaveDebounceRef.current); | |
| } | |
| resetAutosaveState(); | |
| setDocumentConflict(null); | |
| setDraft(null); | |
| setError(null); | |
| }; | |
| const commitDraft = useCallback(async ( | |
| currentDraft: DraftState | null, | |
| options?: { clearAfterSave?: boolean; trackAutosave?: boolean; overrideConflict?: boolean }, | |
| ) => { | |
| if (!currentDraft || upsertDocument.isPending) return false; | |
| const normalizedKey = currentDraft.key.trim().toLowerCase(); | |
| const normalizedBody = currentDraft.body.trim(); | |
| const normalizedTitle = currentDraft.title.trim(); | |
| const activeConflict = documentConflict?.key === normalizedKey ? documentConflict : null; | |
| if (activeConflict && !options?.overrideConflict) { | |
| if (options?.trackAutosave) { | |
| resetAutosaveState(); | |
| } | |
| return false; | |
| } | |
| if (!normalizedKey || !normalizedBody) { | |
| if (currentDraft.isNew) { | |
| setError("Document key and body are required"); | |
| } else if (!normalizedBody) { | |
| setError("Document body cannot be empty"); | |
| } | |
| if (options?.trackAutosave) { | |
| resetAutosaveState(); | |
| } | |
| return false; | |
| } | |
| if (!DOCUMENT_KEY_PATTERN.test(normalizedKey)) { | |
| setError("Document key must start with a letter or number and use only lowercase letters, numbers, -, or _."); | |
| if (options?.trackAutosave) { | |
| resetAutosaveState(); | |
| } | |
| return false; | |
| } | |
| const existing = sortedDocuments.find((doc) => doc.key === normalizedKey); | |
| if ( | |
| !currentDraft.isNew && | |
| existing && | |
| existing.body === currentDraft.body && | |
| (existing.title ?? "") === currentDraft.title | |
| ) { | |
| if (options?.clearAfterSave) { | |
| setDraft((value) => (value?.key === normalizedKey ? null : value)); | |
| } | |
| if (options?.trackAutosave) { | |
| resetAutosaveState(); | |
| } | |
| return true; | |
| } | |
| const save = async () => { | |
| const saved = await upsertDocument.mutateAsync({ | |
| ...currentDraft, | |
| key: normalizedKey, | |
| title: isPlanKey(normalizedKey) ? "" : normalizedTitle, | |
| body: currentDraft.body, | |
| baseRevisionId: options?.overrideConflict | |
| ? activeConflict?.serverDocument.latestRevisionId ?? currentDraft.baseRevisionId | |
| : currentDraft.baseRevisionId, | |
| }); | |
| setError(null); | |
| setDocumentConflict((current) => current?.key === normalizedKey ? null : current); | |
| setDraft((value) => { | |
| if (!value || value.key !== normalizedKey) return value; | |
| if (options?.clearAfterSave) return null; | |
| return { | |
| key: saved.key, | |
| title: saved.title ?? "", | |
| body: saved.body, | |
| baseRevisionId: saved.latestRevisionId, | |
| isNew: false, | |
| }; | |
| }); | |
| invalidateIssueDocuments(); | |
| }; | |
| try { | |
| if (options?.trackAutosave) { | |
| setAutosaveDocumentKey(normalizedKey); | |
| await runSave(save); | |
| } else { | |
| await save(); | |
| } | |
| return true; | |
| } catch (err) { | |
| if (isDocumentConflictError(err)) { | |
| try { | |
| const latestDocument = await issuesApi.getDocument(issue.id, normalizedKey); | |
| setDocumentConflict({ | |
| key: normalizedKey, | |
| serverDocument: latestDocument, | |
| localDraft: { | |
| key: normalizedKey, | |
| title: isPlanKey(normalizedKey) ? "" : normalizedTitle, | |
| body: currentDraft.body, | |
| baseRevisionId: currentDraft.baseRevisionId, | |
| isNew: false, | |
| }, | |
| showRemote: true, | |
| }); | |
| setFoldedDocumentKeys((current) => current.filter((key) => key !== normalizedKey)); | |
| setError(null); | |
| resetAutosaveState(); | |
| return false; | |
| } catch { | |
| setError("Document changed remotely and the latest version could not be loaded"); | |
| return false; | |
| } | |
| } | |
| setError(err instanceof Error ? err.message : "Failed to save document"); | |
| return false; | |
| } | |
| }, [documentConflict, invalidateIssueDocuments, issue.id, resetAutosaveState, runSave, sortedDocuments, upsertDocument]); | |
| const reloadDocumentFromServer = useCallback((key: string) => { | |
| if (documentConflict?.key !== key) return; | |
| const serverDocument = documentConflict.serverDocument; | |
| setDraft({ | |
| key: serverDocument.key, | |
| title: serverDocument.title ?? "", | |
| body: serverDocument.body, | |
| baseRevisionId: serverDocument.latestRevisionId, | |
| isNew: false, | |
| }); | |
| setDocumentConflict(null); | |
| resetAutosaveState(); | |
| setError(null); | |
| }, [documentConflict, resetAutosaveState]); | |
| const overwriteDocumentFromDraft = useCallback(async (key: string) => { | |
| if (documentConflict?.key !== key) return; | |
| const sourceDraft = | |
| draft && draft.key === key && !draft.isNew | |
| ? draft | |
| : documentConflict.localDraft; | |
| await commitDraft( | |
| { | |
| ...sourceDraft, | |
| baseRevisionId: documentConflict.serverDocument.latestRevisionId, | |
| }, | |
| { | |
| clearAfterSave: false, | |
| trackAutosave: true, | |
| overrideConflict: true, | |
| }, | |
| ); | |
| }, [commitDraft, documentConflict, draft]); | |
| const keepConflictedDraft = useCallback((key: string) => { | |
| if (documentConflict?.key !== key) return; | |
| setDraft(documentConflict.localDraft); | |
| setDocumentConflict((current) => | |
| current?.key === key | |
| ? { ...current, showRemote: false } | |
| : current, | |
| ); | |
| setError(null); | |
| }, [documentConflict]); | |
| const copyDocumentBody = useCallback(async (key: string, body: string) => { | |
| try { | |
| await navigator.clipboard.writeText(body); | |
| setCopiedDocumentKey(key); | |
| if (copiedDocumentTimerRef.current) { | |
| clearTimeout(copiedDocumentTimerRef.current); | |
| } | |
| copiedDocumentTimerRef.current = setTimeout(() => { | |
| setCopiedDocumentKey((current) => current === key ? null : current); | |
| }, 1400); | |
| } catch { | |
| setError("Could not copy document"); | |
| } | |
| }, []); | |
| const handleDraftBlur = async (event: React.FocusEvent<HTMLDivElement>) => { | |
| if (event.currentTarget.contains(event.relatedTarget as Node | null)) return; | |
| if (autosaveDebounceRef.current) { | |
| clearTimeout(autosaveDebounceRef.current); | |
| } | |
| await commitDraft(draft, { clearAfterSave: true, trackAutosave: true }); | |
| }; | |
| const handleDraftKeyDown = async (event: React.KeyboardEvent) => { | |
| if (event.key === "Escape") { | |
| event.preventDefault(); | |
| cancelDraft(); | |
| return; | |
| } | |
| if ((event.metaKey || event.ctrlKey) && event.key === "Enter") { | |
| event.preventDefault(); | |
| if (autosaveDebounceRef.current) { | |
| clearTimeout(autosaveDebounceRef.current); | |
| } | |
| await commitDraft(draft, { clearAfterSave: false, trackAutosave: true }); | |
| } | |
| }; | |
| useEffect(() => { | |
| setFoldedDocumentKeys(loadFoldedDocumentKeys(issue.id)); | |
| }, [issue.id]); | |
| useEffect(() => { | |
| hasScrolledToHashRef.current = false; | |
| }, [issue.id, location.hash]); | |
| useEffect(() => { | |
| const validKeys = new Set(sortedDocuments.map((doc) => doc.key)); | |
| setFoldedDocumentKeys((current) => { | |
| const next = current.filter((key) => validKeys.has(key)); | |
| if (next.length !== current.length) { | |
| saveFoldedDocumentKeys(issue.id, next); | |
| } | |
| return next; | |
| }); | |
| }, [issue.id, sortedDocuments]); | |
| useEffect(() => { | |
| saveFoldedDocumentKeys(issue.id, foldedDocumentKeys); | |
| }, [foldedDocumentKeys, issue.id]); | |
| useEffect(() => { | |
| if (!documentConflict) return; | |
| const latest = sortedDocuments.find((doc) => doc.key === documentConflict.key); | |
| if (!latest || latest.latestRevisionId === documentConflict.serverDocument.latestRevisionId) return; | |
| setDocumentConflict((current) => | |
| current?.key === latest.key | |
| ? { ...current, serverDocument: latest } | |
| : current, | |
| ); | |
| }, [documentConflict, sortedDocuments]); | |
| useEffect(() => { | |
| const hash = location.hash; | |
| if (!hash.startsWith("#document-")) return; | |
| const documentKey = decodeURIComponent(hash.slice("#document-".length)); | |
| const targetExists = sortedDocuments.some((doc) => doc.key === documentKey) | |
| || (documentKey === "plan" && Boolean(issue.legacyPlanDocument)); | |
| if (!targetExists || hasScrolledToHashRef.current) return; | |
| setFoldedDocumentKeys((current) => current.filter((key) => key !== documentKey)); | |
| const element = document.getElementById(`document-${documentKey}`); | |
| if (!element) return; | |
| hasScrolledToHashRef.current = true; | |
| setHighlightDocumentKey(documentKey); | |
| element.scrollIntoView({ behavior: "smooth", block: "center" }); | |
| const timer = setTimeout(() => setHighlightDocumentKey((current) => current === documentKey ? null : current), 3000); | |
| return () => clearTimeout(timer); | |
| }, [issue.legacyPlanDocument, location.hash, sortedDocuments]); | |
| useEffect(() => { | |
| return () => { | |
| if (autosaveDebounceRef.current) { | |
| clearTimeout(autosaveDebounceRef.current); | |
| } | |
| if (copiedDocumentTimerRef.current) { | |
| clearTimeout(copiedDocumentTimerRef.current); | |
| } | |
| }; | |
| }, []); | |
| useEffect(() => { | |
| if (!draft || draft.isNew) return; | |
| if (documentConflict?.key === draft.key) return; | |
| const existing = sortedDocuments.find((doc) => doc.key === draft.key); | |
| if (!existing) return; | |
| const hasChanges = | |
| existing.body !== draft.body || | |
| (existing.title ?? "") !== draft.title; | |
| if (!hasChanges) { | |
| if (autosaveState !== "saved") { | |
| resetAutosaveState(); | |
| } | |
| return; | |
| } | |
| markDocumentDirty(draft.key); | |
| if (autosaveDebounceRef.current) { | |
| clearTimeout(autosaveDebounceRef.current); | |
| } | |
| autosaveDebounceRef.current = setTimeout(() => { | |
| void commitDraft(draft, { clearAfterSave: false, trackAutosave: true }); | |
| }, DOCUMENT_AUTOSAVE_DEBOUNCE_MS); | |
| return () => { | |
| if (autosaveDebounceRef.current) { | |
| clearTimeout(autosaveDebounceRef.current); | |
| } | |
| }; | |
| }, [autosaveState, commitDraft, documentConflict, draft, markDocumentDirty, resetAutosaveState, sortedDocuments]); | |
| const documentBodyShellClassName = "mt-3 overflow-hidden rounded-md"; | |
| const documentBodyPaddingClassName = ""; | |
| const documentBodyContentClassName = "paperclip-edit-in-place-content min-h-[220px] text-[15px] leading-7"; | |
| const toggleFoldedDocument = (key: string) => { | |
| setFoldedDocumentKeys((current) => | |
| current.includes(key) | |
| ? current.filter((entry) => entry !== key) | |
| : [...current, key], | |
| ); | |
| }; | |
| return ( | |
| <div className="space-y-3"> | |
| {isEmpty && !draft?.isNew ? ( | |
| <div className="flex items-center justify-end gap-2"> | |
| {extraActions} | |
| <Button variant="outline" size="sm" onClick={beginNewDocument}> | |
| <Plus className="mr-1.5 h-3.5 w-3.5" /> | |
| New document | |
| </Button> | |
| </div> | |
| ) : ( | |
| <div className="flex items-center justify-between gap-2"> | |
| <h3 className="text-sm font-medium text-muted-foreground">Documents</h3> | |
| <div className="flex items-center gap-2"> | |
| {extraActions} | |
| <Button variant="outline" size="sm" onClick={beginNewDocument}> | |
| <Plus className="mr-1.5 h-3.5 w-3.5" /> | |
| New document | |
| </Button> | |
| </div> | |
| </div> | |
| )} | |
| {error && <p className="text-xs text-destructive">{error}</p>} | |
| {draft?.isNew && ( | |
| <div | |
| className="space-y-3 rounded-lg border border-border bg-accent/10 p-3" | |
| onBlurCapture={handleDraftBlur} | |
| onKeyDown={handleDraftKeyDown} | |
| > | |
| <Input | |
| autoFocus | |
| value={draft.key} | |
| onChange={(event) => | |
| setDraft((current) => current ? { ...current, key: event.target.value.toLowerCase() } : current) | |
| } | |
| placeholder="Document key" | |
| /> | |
| {newDocumentKeyError && ( | |
| <p className="text-xs text-destructive">{newDocumentKeyError}</p> | |
| )} | |
| {!isPlanKey(draft.key) && ( | |
| <Input | |
| value={draft.title} | |
| onChange={(event) => | |
| setDraft((current) => current ? { ...current, title: event.target.value } : current) | |
| } | |
| placeholder="Optional title" | |
| /> | |
| )} | |
| <MarkdownEditor | |
| value={draft.body} | |
| onChange={(body) => | |
| setDraft((current) => current ? { ...current, body } : current) | |
| } | |
| placeholder="Markdown body" | |
| bordered={false} | |
| className="bg-transparent" | |
| contentClassName="min-h-[220px] text-[15px] leading-7" | |
| mentions={mentions} | |
| imageUploadHandler={imageUploadHandler} | |
| onSubmit={() => void commitDraft(draft, { clearAfterSave: false, trackAutosave: false })} | |
| /> | |
| <div className="flex items-center justify-end gap-2"> | |
| <Button variant="outline" size="sm" onClick={cancelDraft}> | |
| <X className="mr-1.5 h-3.5 w-3.5" /> | |
| Cancel | |
| </Button> | |
| <Button | |
| size="sm" | |
| onClick={() => void commitDraft(draft, { clearAfterSave: false, trackAutosave: false })} | |
| disabled={upsertDocument.isPending} | |
| > | |
| {upsertDocument.isPending ? "Saving..." : "Create document"} | |
| </Button> | |
| </div> | |
| </div> | |
| )} | |
| {!hasRealPlan && issue.legacyPlanDocument ? ( | |
| <div | |
| id="document-plan" | |
| className={cn( | |
| "rounded-lg border border-amber-500/30 bg-amber-500/5 p-3 transition-colors duration-1000", | |
| highlightDocumentKey === "plan" && "border-primary/50 bg-primary/5", | |
| )} | |
| > | |
| <div className="mb-2 flex items-center gap-2"> | |
| <FileText className="h-4 w-4 text-amber-600" /> | |
| <span className="rounded-full border border-amber-500/30 px-2 py-0.5 font-mono text-[10px] uppercase tracking-[0.16em] text-amber-700 dark:text-amber-300"> | |
| PLAN | |
| </span> | |
| </div> | |
| <div className={documentBodyPaddingClassName}> | |
| {renderBody(issue.legacyPlanDocument.body, documentBodyContentClassName)} | |
| </div> | |
| </div> | |
| ) : null} | |
| <div className="space-y-3"> | |
| {sortedDocuments.map((doc) => { | |
| const activeDraft = draft?.key === doc.key && !draft.isNew ? draft : null; | |
| const activeConflict = documentConflict?.key === doc.key ? documentConflict : null; | |
| const isFolded = foldedDocumentKeys.includes(doc.key); | |
| const showTitle = !isPlanKey(doc.key) && !!doc.title?.trim() && !titlesMatchKey(doc.title, doc.key); | |
| return ( | |
| <div | |
| key={doc.id} | |
| id={`document-${doc.key}`} | |
| className={cn( | |
| "rounded-lg border border-border p-3 transition-colors duration-1000", | |
| highlightDocumentKey === doc.key && "border-primary/50 bg-primary/5", | |
| )} | |
| > | |
| <div className="flex items-start justify-between gap-3"> | |
| <div className="min-w-0"> | |
| <div className="flex items-center gap-2"> | |
| <button | |
| type="button" | |
| className="inline-flex h-5 w-5 items-center justify-center rounded-sm text-muted-foreground transition-colors hover:bg-accent/60 hover:text-foreground" | |
| onClick={() => toggleFoldedDocument(doc.key)} | |
| aria-label={isFolded ? `Expand ${doc.key} document` : `Collapse ${doc.key} document`} | |
| aria-expanded={!isFolded} | |
| > | |
| {isFolded ? <ChevronRight className="h-3.5 w-3.5" /> : <ChevronDown className="h-3.5 w-3.5" />} | |
| </button> | |
| <span className="rounded-full border border-border px-2 py-0.5 font-mono text-[10px] uppercase tracking-[0.16em] text-muted-foreground"> | |
| {doc.key} | |
| </span> | |
| <a | |
| href={`#document-${encodeURIComponent(doc.key)}`} | |
| className="text-[11px] text-muted-foreground transition-colors hover:text-foreground hover:underline" | |
| > | |
| rev {doc.latestRevisionNumber} • updated {relativeTime(doc.updatedAt)} | |
| </a> | |
| </div> | |
| {showTitle && <p className="mt-2 text-sm font-medium">{doc.title}</p>} | |
| </div> | |
| <div className="flex items-center gap-1"> | |
| <Button | |
| variant="ghost" | |
| size="icon-xs" | |
| className={cn( | |
| "text-muted-foreground transition-colors", | |
| copiedDocumentKey === doc.key && "text-foreground", | |
| )} | |
| title={copiedDocumentKey === doc.key ? "Copied" : "Copy document"} | |
| onClick={() => void copyDocumentBody(doc.key, activeDraft?.body ?? doc.body)} | |
| > | |
| {copiedDocumentKey === doc.key ? ( | |
| <Check className="h-3.5 w-3.5" /> | |
| ) : ( | |
| <Copy className="h-3.5 w-3.5" /> | |
| )} | |
| </Button> | |
| <DropdownMenu> | |
| <DropdownMenuTrigger asChild> | |
| <Button | |
| variant="ghost" | |
| size="icon-xs" | |
| className="text-muted-foreground" | |
| title="Document actions" | |
| > | |
| <MoreHorizontal className="h-3.5 w-3.5" /> | |
| </Button> | |
| </DropdownMenuTrigger> | |
| <DropdownMenuContent align="end"> | |
| <DropdownMenuItem | |
| onClick={() => downloadDocumentFile(doc.key, activeDraft?.body ?? doc.body)} | |
| > | |
| <Download className="h-3.5 w-3.5" /> | |
| Download document | |
| </DropdownMenuItem> | |
| {canDeleteDocuments ? <DropdownMenuSeparator /> : null} | |
| {canDeleteDocuments ? ( | |
| <DropdownMenuItem | |
| variant="destructive" | |
| onClick={() => setConfirmDeleteKey(doc.key)} | |
| > | |
| <Trash2 className="h-3.5 w-3.5" /> | |
| Delete document | |
| </DropdownMenuItem> | |
| ) : null} | |
| </DropdownMenuContent> | |
| </DropdownMenu> | |
| </div> | |
| </div> | |
| {!isFolded ? ( | |
| <div | |
| className="mt-3 space-y-3" | |
| onFocusCapture={() => { | |
| if (!activeDraft) { | |
| beginEdit(doc.key); | |
| } | |
| }} | |
| onBlurCapture={async (event) => { | |
| if (activeDraft) { | |
| await handleDraftBlur(event); | |
| } | |
| }} | |
| onKeyDown={async (event) => { | |
| if (activeDraft) { | |
| await handleDraftKeyDown(event); | |
| } | |
| }} | |
| > | |
| {activeConflict && ( | |
| <div className="rounded-md border border-amber-500/30 bg-amber-500/5 px-3 py-3"> | |
| <div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between"> | |
| <div className="space-y-1"> | |
| <p className="text-sm font-medium text-amber-200">Out of date</p> | |
| <p className="text-xs text-muted-foreground"> | |
| This document changed while you were editing. Your local draft is preserved and autosave is paused. | |
| </p> | |
| </div> | |
| <div className="flex flex-wrap items-center gap-2"> | |
| <Button | |
| variant="outline" | |
| size="sm" | |
| onClick={() => | |
| setDocumentConflict((current) => | |
| current?.key === doc.key | |
| ? { ...current, showRemote: !current.showRemote } | |
| : current, | |
| ) | |
| } | |
| > | |
| {activeConflict.showRemote ? "Hide remote" : "Review remote"} | |
| </Button> | |
| <Button | |
| variant="outline" | |
| size="sm" | |
| onClick={() => keepConflictedDraft(doc.key)} | |
| > | |
| Keep my draft | |
| </Button> | |
| <Button | |
| variant="outline" | |
| size="sm" | |
| onClick={() => reloadDocumentFromServer(doc.key)} | |
| > | |
| Reload remote | |
| </Button> | |
| <Button | |
| size="sm" | |
| onClick={() => void overwriteDocumentFromDraft(doc.key)} | |
| disabled={upsertDocument.isPending} | |
| > | |
| {upsertDocument.isPending ? "Saving..." : "Overwrite remote"} | |
| </Button> | |
| </div> | |
| </div> | |
| {activeConflict.showRemote && ( | |
| <div className="mt-3 rounded-md border border-border/70 bg-background/60 p-3"> | |
| <div className="mb-2 flex items-center gap-2 text-[11px] text-muted-foreground"> | |
| <span>Remote revision {activeConflict.serverDocument.latestRevisionNumber}</span> | |
| <span>•</span> | |
| <span>updated {relativeTime(activeConflict.serverDocument.updatedAt)}</span> | |
| </div> | |
| {!isPlanKey(doc.key) && activeConflict.serverDocument.title ? ( | |
| <p className="mb-2 text-sm font-medium">{activeConflict.serverDocument.title}</p> | |
| ) : null} | |
| {renderBody(activeConflict.serverDocument.body, "text-[14px] leading-7")} | |
| </div> | |
| )} | |
| </div> | |
| )} | |
| {activeDraft && !isPlanKey(doc.key) && ( | |
| <Input | |
| value={activeDraft.title} | |
| onChange={(event) => { | |
| markDocumentDirty(doc.key); | |
| setDraft((current) => current ? { ...current, title: event.target.value } : current); | |
| }} | |
| placeholder="Optional title" | |
| /> | |
| )} | |
| <div | |
| className={`${documentBodyShellClassName} ${documentBodyPaddingClassName} ${ | |
| activeDraft ? "" : "hover:bg-accent/10" | |
| }`} | |
| > | |
| <MarkdownEditor | |
| value={activeDraft?.body ?? doc.body} | |
| onChange={(body) => { | |
| markDocumentDirty(doc.key); | |
| setDraft((current) => { | |
| if (current && current.key === doc.key && !current.isNew) { | |
| return { ...current, body }; | |
| } | |
| return { | |
| key: doc.key, | |
| title: doc.title ?? "", | |
| body, | |
| baseRevisionId: doc.latestRevisionId, | |
| isNew: false, | |
| }; | |
| }); | |
| }} | |
| placeholder="Markdown body" | |
| bordered={false} | |
| className="bg-transparent" | |
| contentClassName={documentBodyContentClassName} | |
| mentions={mentions} | |
| imageUploadHandler={imageUploadHandler} | |
| onSubmit={() => void commitDraft(activeDraft ?? draft, { clearAfterSave: false, trackAutosave: true })} | |
| /> | |
| </div> | |
| <div className="flex min-h-4 items-center justify-end px-1"> | |
| <span | |
| className={`text-[11px] transition-opacity duration-150 ${ | |
| activeConflict | |
| ? "text-amber-300" | |
| : autosaveState === "error" | |
| ? "text-destructive" | |
| : "text-muted-foreground" | |
| } ${activeDraft ? "opacity-100" : "opacity-0"}`} | |
| > | |
| {activeDraft | |
| ? activeConflict | |
| ? "Out of date" | |
| : autosaveDocumentKey === doc.key | |
| ? autosaveState === "saving" | |
| ? "Autosaving..." | |
| : autosaveState === "saved" | |
| ? "Saved" | |
| : autosaveState === "error" | |
| ? "Could not save" | |
| : "" | |
| : "" | |
| : ""} | |
| </span> | |
| </div> | |
| </div> | |
| ) : null} | |
| {confirmDeleteKey === doc.key && ( | |
| <div className="mt-3 flex items-center justify-between gap-3 rounded-md border border-destructive/20 bg-destructive/5 px-4 py-3"> | |
| <p className="text-sm text-destructive font-medium"> | |
| Delete this document? This cannot be undone. | |
| </p> | |
| <div className="flex items-center gap-2 shrink-0"> | |
| <Button | |
| variant="ghost" | |
| size="sm" | |
| onClick={() => setConfirmDeleteKey(null)} | |
| disabled={deleteDocument.isPending} | |
| > | |
| Cancel | |
| </Button> | |
| <Button | |
| variant="destructive" | |
| size="sm" | |
| onClick={() => deleteDocument.mutate(doc.key)} | |
| disabled={deleteDocument.isPending} | |
| > | |
| {deleteDocument.isPending ? "Deleting..." : "Delete"} | |
| </Button> | |
| </div> | |
| </div> | |
| )} | |
| </div> | |
| ); | |
| })} | |
| </div> | |
| </div> | |
| ); | |
| } | |