"use client" import { Dialog, DialogContent, DialogTitle } from "@repo/ui/components/dialog" import { Drawer, DrawerContent, DrawerTitle } from "@repo/ui/components/drawer" import type { DocumentsWithMemoriesResponseSchema } from "@repo/validation/api" import { Tabs, TabsContent, TabsList, TabsTrigger } from "@ui/components/tabs" import { ArrowUpRightIcon, XIcon, Loader2, Trash2Icon, CheckIcon, CopyIcon, } from "lucide-react" import type { z } from "zod" import * as DialogPrimitive from "@radix-ui/react-dialog" import { cn } from "@lib/utils" import { Title } from "./title" import { Summary as DocumentSummary } from "./summary" import { dmSansClassName } from "@/lib/fonts" import { GraphListMemories, type MemoryEntry } from "./graph-list-memories" import { DocumentContent } from "./content" import { useState, useEffect, useCallback, useMemo } from "react" import { motion, AnimatePresence } from "motion/react" import { useDocumentMutations } from "@/hooks/use-document-mutations" import type { UseMutationResult } from "@tanstack/react-query" import { toast } from "sonner" import { useIsMobile } from "@hooks/use-mobile" import { parsePluginDocument } from "@/lib/plugin-document" import { useFullDocumentContent } from "@/hooks/use-full-document" type DocumentsResponse = z.infer type DocumentWithMemories = DocumentsResponse["documents"][0] function getDocumentSourceUrl(document: DocumentWithMemories): string { const url = document.url ?? "" const googleDocTypes: Record = { google_doc: "https://docs.google.com/document/d/", google_sheet: "https://docs.google.com/spreadsheets/d/", google_slide: "https://docs.google.com/presentation/d/", } const prefix = document.type ? googleDocTypes[document.type] : null if (!prefix) return url if (document.customId) { return `${prefix}${document.customId}/edit` } // Extract ID from API URL like docs.googleapis.com/v1/documents/{id} const apiMatch = url.match( /docs\.googleapis\.com\/v1\/documents\/([a-zA-Z0-9_-]+)/, ) if (apiMatch?.[1]) { return `${prefix}${apiMatch[1]}/edit` } return url } interface DocumentModalProps { document: DocumentWithMemories | null isOpen: boolean onClose: () => void } interface DeleteButtonProps { documentId: string | null | undefined customId: string | null | undefined deleteMutation: UseMutationResult< unknown, Error, { documentId: string }, unknown > } function isTemporaryId(id: string | null | undefined): boolean { if (!id) return false return id.startsWith("temp-") || id.startsWith("temp-file-") } function DeleteButton({ documentId, customId, deleteMutation, }: DeleteButtonProps) { const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false) const handleDelete = useCallback(() => { const id = documentId ?? customId if (!id) return // Check both IDs to ensure we catch temporary documents regardless of which ID is used if (isTemporaryId(documentId) || isTemporaryId(customId)) { // this is when user added document immediately and trying to delete toast.error("Cannot delete document", { description: "This document is still being processed. Please wait.", }) return } deleteMutation.mutate({ documentId: id as string }) }, [documentId, customId, deleteMutation]) return ( {!deleteConfirmOpen ? ( setDeleteConfirmOpen(true)} tabIndex={-1} className="bg-[#0D121A] size-7 flex items-center justify-center rounded-full transition-opacity hover:opacity-100 focus-visible:ring-2 focus-visible:ring-offset-2 focus:outline-none cursor-pointer shadow-[inset_0_2px_4px_rgba(0,0,0,0.3),inset_0_1px_2px_rgba(0,0,0,0.1)]" disabled={deleteMutation.isPending} > Delete document ) : ( )} ) } function CopySessionIdButton({ sessionId }: { sessionId: string }) { const [copied, setCopied] = useState(false) const handleCopy = useCallback(async () => { try { await navigator.clipboard.writeText(sessionId) setCopied(true) toast.success("Copy session id") setTimeout(() => setCopied(false), 1500) } catch { toast.error("Failed to copy session id") } }, [sessionId]) return ( ) } export function DocumentModal({ document: _document, isOpen, onClose, }: DocumentModalProps) { const isMobile = useIsMobile() const { updateMutation, deleteMutation } = useDocumentMutations({ onClose }) const fullContent = useFullDocumentContent(_document?.id, isOpen) const effectiveDocument = useMemo( () => _document && fullContent !== null ? { ..._document, content: fullContent } : _document, [_document, fullContent], ) const { initialEditorContent, initialEditorString } = useMemo(() => { const content = effectiveDocument?.content as string | null | undefined return { initialEditorContent: content ?? undefined, initialEditorString: content ?? "", } }, [effectiveDocument?.content]) const pluginDocument = useMemo( () => parsePluginDocument(effectiveDocument), [effectiveDocument], ) const [draftContentString, setDraftContentString] = useState(initialEditorString) const [editorResetNonce, setEditorResetNonce] = useState(0) const [lastSavedContent, setLastSavedContent] = useState(null) const resetEditor = useCallback(() => { setDraftContentString(initialEditorString) setEditorResetNonce((n) => n + 1) setLastSavedContent(null) }, [initialEditorString]) useEffect(() => { setDraftContentString(initialEditorString) setEditorResetNonce((n) => n + 1) setLastSavedContent(null) }, [initialEditorString]) useEffect(() => { if (!isOpen) { resetEditor() } }, [isOpen, resetEditor]) const hasUnsavedChanges = draftContentString !== initialEditorString && draftContentString !== lastSavedContent const handleSave = useCallback(() => { if (!_document?.id) return updateMutation.mutate( { documentId: _document.id, content: draftContentString }, { onSuccess: (_data, variables) => setLastSavedContent(variables.content), }, ) }, [_document?.id, draftContentString, updateMutation]) const textEditorProps = useMemo( () => ({ documentId: _document?.id ?? "", editorResetNonce, initialEditorContent, hasUnsavedChanges, isSaving: updateMutation.isPending, onContentChange: setDraftContentString, onSave: handleSave, onReset: resetEditor, }), [ _document?.id, editorResetNonce, initialEditorContent, hasUnsavedChanges, updateMutation.isPending, handleSave, resetEditor, ], ) const hasDocumentInsights = Boolean( _document?.summary || pluginDocument?.summary || (_document?.memoryEntries && _document.memoryEntries.length > 0), ) const documentPreview = (
) const documentInsights = (
{_document && (_document.summary || pluginDocument?.summary) && ( )} {_document?.memoryEntries && _document.memoryEntries.length > 0 && ( )}
) const modalContent = ( <> {isMobile ? ( {_document?.title} - Document ) : ( {_document?.title} - Document )}
</div> <div className="flex items-center gap-1.5 md:gap-2 shrink-0"> {pluginDocument?.kind === "claude-code-doc" && _document?.customId && ( <CopySessionIdButton sessionId={_document.customId} /> )} <DeleteButton documentId={_document?.id} customId={_document?.customId} deleteMutation={deleteMutation} /> {_document?.url && ( <a href={getDocumentSourceUrl(_document)} target="_blank" rel="noopener noreferrer" className={cn( "flex items-center gap-1 bg-[#0D121A] rounded-full shadow-[inset_0_2px_4px_rgba(0,0,0,0.3),inset_0_1px_2px_rgba(0,0,0,0.1)]", isMobile ? "size-7 justify-center" : "px-3 py-2", )} > {!isMobile && <span className="line-clamp-1">Visit source</span>} <ArrowUpRightIcon className="size-4 text-[#737373]" /> </a> )} {isMobile ? ( <button className="bg-[#0D121A] size-7 flex items-center justify-center rounded-full transition-opacity hover:opacity-100 focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus:outline-none disabled:pointer-events-none cursor-pointer [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 shadow-[inset_0_2px_4px_rgba(0,0,0,0.3),inset_0_1px_2px_rgba(0,0,0,0.1)]" type="button" tabIndex={-1} onClick={onClose} > <XIcon stroke="#737373" /> <span className="sr-only">Close</span> </button> ) : ( <DialogPrimitive.Close className="bg-[#0D121A] size-7 flex items-center justify-center rounded-full transition-opacity hover:opacity-100 focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus:outline-none disabled:pointer-events-none cursor-pointer [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 shadow-[inset_0_2px_4px_rgba(0,0,0,0.3),inset_0_1px_2px_rgba(0,0,0,0.1)]" data-slot="dialog-close" type="button" tabIndex={-1} > <XIcon stroke="#737373" /> <span className="sr-only">Close</span> </DialogPrimitive.Close> )} </div> </div> {isMobile && hasDocumentInsights ? ( <Tabs defaultValue="content" className="flex min-h-0 flex-1 flex-col pt-1.5" > <TabsList className="grid h-11 w-full grid-cols-2 rounded-full border border-[#263142] bg-[#0A1019] p-1 shadow-[inset_0_1px_2px_rgba(255,255,255,0.04),0_1px_3px_rgba(0,0,0,0.35)]"> <TabsTrigger value="content" className="rounded-full text-[15px] font-medium text-[#8E99AA] transition-colors data-[state=active]:bg-[#0B2B60]! data-[state=active]:text-[#F8FAFC] data-[state=active]:shadow-[inset_0_1px_1px_rgba(255,255,255,0.08),0_1px_4px_rgba(54,155,253,0.18)]" > Content </TabsTrigger> <TabsTrigger value="insights" className="rounded-full text-[15px] font-medium text-[#8E99AA] transition-colors data-[state=active]:bg-[#0B2B60]! data-[state=active]:text-[#F8FAFC] data-[state=active]:shadow-[inset_0_1px_1px_rgba(255,255,255,0.08),0_1px_4px_rgba(54,155,253,0.18)]" > Insights </TabsTrigger> </TabsList> <TabsContent value="content" className="mt-4 flex min-h-0 flex-1"> {documentPreview} </TabsContent> <TabsContent value="insights" className="mt-4 flex min-h-0 flex-1 flex-col overflow-y-auto pb-1 scrollbar-thin" > {documentInsights} </TabsContent> </Tabs> ) : isMobile ? ( <div className="flex min-h-0 flex-1 pt-1.5">{documentPreview}</div> ) : ( <div className="flex-1 grid grid-cols-1 md:grid-cols-[2fr_1fr] gap-3 min-h-0 overflow-hidden"> {documentPreview} {documentInsights} </div> )} </> ) if (isMobile) { return ( <Drawer open={isOpen} onOpenChange={(open: boolean) => !open && onClose()} shouldScaleBackground > <DrawerContent className={cn( "flex flex-col gap-0 border-none bg-[#1B1F24] p-0", "h-[88svh] max-h-[88svh] overflow-hidden rounded-t-[22px]", "[&>div:first-child]:bg-[#3A4252] [&>div:first-child]:h-1 [&>div:first-child]:w-9 [&>div:first-child]:mt-2.5 [&>div:first-child]:mb-1", dmSansClassName(), )} style={{ boxShadow: "0 -12px 40px rgba(0, 0, 0, 0.45), 0.711px 0.711px 0.711px 0 rgba(255, 255, 255, 0.10) inset", }} > <div className="flex min-h-0 flex-1 flex-col gap-3 overflow-hidden px-3 pt-2 pb-4"> {modalContent} </div> </DrawerContent> </Drawer> ) } return ( <Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}> <DialogContent className={cn( "p-0 border-none bg-[#1B1F24] flex flex-col px-3 md:px-4 pt-3 pb-4 gap-3", "w-[80%]! max-w-[1158px]! h-[86%]! max-h-[684px]! rounded-[22px]", dmSansClassName(), )} style={{ boxShadow: "0 2.842px 14.211px 0 rgba(0, 0, 0, 0.25), 0.711px 0.711px 0.711px 0 rgba(255, 255, 255, 0.10) inset", }} showCloseButton={false} > {modalContent} </DialogContent> </Dialog> ) }