"use client" import { Dialog, DialogContent, DialogTitle } from "@repo/ui/components/dialog" import type { DocumentsWithMemoriesResponseSchema } from "@repo/validation/api" import { ArrowUpRightIcon, XIcon, Loader2, Trash2Icon, CheckIcon } from "lucide-react" import type { z } from "zod" import * as DialogPrimitive from "@radix-ui/react-dialog" import { cn } from "@lib/utils" import dynamic from "next/dynamic" import { Title } from "./title" import { Summary as DocumentSummary } from "./summary" import { dmSansClassName } from "@/lib/fonts" import { GraphListMemories, type MemoryEntry } from "./graph-list-memories" import { YoutubeVideo } from "./content/yt-video" import { TweetContent } from "./content/tweet" import { isTwitterUrl } from "@/lib/url-helpers" import { NotionDoc } from "./content/notion-doc" import { TextEditor } from "../text-editor" import { useState, useEffect, useCallback, useMemo } from "react" import { motion, AnimatePresence } from "motion/react" import { Button } from "@repo/ui/components/button" import { useDocumentMutations } from "@/hooks/use-document-mutations" import type { UseMutationResult } from "@tanstack/react-query" import { toast } from "sonner" // Dynamically importing to prevent DOMMatrix error const PdfViewer = dynamic( () => import("./content/pdf").then((mod) => ({ default: mod.PdfViewer })), { ssr: false, loading: () => (
Loading PDF viewer...
), }, ) as typeof import("./content/pdf").PdfViewer type DocumentsResponse = z.infer type DocumentWithMemories = DocumentsResponse["documents"][0] 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] w-7 h-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 ) : ( )} ) } export function DocumentModal({ document: _document, isOpen, onClose, }: DocumentModalProps) { const { updateMutation, deleteMutation } = useDocumentMutations({ onClose }) const { initialEditorContent, initialEditorString } = useMemo(() => { const content = _document?.content as string | null | undefined return { initialEditorContent: content ?? undefined, initialEditorString: content ?? "", } }, [_document?.content]) 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]) return ( !open && onClose()}> {_document?.title} - Document
</div> <div className="flex items-center gap-2 shrink-0"> <DeleteButton documentId={_document?.id} customId={_document?.customId} deleteMutation={deleteMutation} /> {_document?.url && ( <a href={_document.url} target="_blank" rel="noopener noreferrer" className="line-clamp-1 px-3 py-2 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)]" > Visit source <ArrowUpRightIcon className="w-4 h-4 text-[#737373]" /> </a> )} <DialogPrimitive.Close className="bg-[#0D121A] w-7 h-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> <div className="flex-1 grid grid-cols-[2fr_1fr] gap-3 overflow-hidden min-h-0"> <div id="document-preview" className={cn( "bg-[#14161A] rounded-[14px] overflow-hidden flex flex-col shadow-[inset_0_2px_4px_rgba(0,0,0,0.3),inset_0_1px_2px_rgba(0,0,0,0.1)] relative", )} > {(_document?.type === "tweet" || (_document?.url && isTwitterUrl(_document.url))) && ( <TweetContent url={_document?.url} tweetMetadata={ _document?.metadata?.sm_internal_twitter_metadata } /> )} {_document?.type === "text" && ( <> <div className="p-4 overflow-y-auto flex-1 scrollbar-thin"> <TextEditor key={`${_document.id}-${editorResetNonce}`} content={initialEditorContent} onContentChange={setDraftContentString} onSubmit={handleSave} /> </div> <AnimatePresence> {hasUnsavedChanges && ( <motion.div initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: 20 }} transition={{ duration: 0.2 }} className="absolute bottom-4 left-1/2 -translate-x-1/2 flex items-center gap-3 px-4 py-2 bg-[#1B1F24] rounded-full shadow-[0_4px_20px_rgba(0,0,0,0.4),inset_1px_1px_1px_rgba(255,255,255,0.1)]" > <span className="text-sm text-[#737373]"> Unsaved changes </span> <Button variant="ghost" size="sm" onClick={resetEditor} disabled={updateMutation.isPending} className="text-[#737373]/80 hover:text-white rounded-full px-3" > Cancel </Button> <Button variant="insideOut" size="sm" onClick={handleSave} disabled={updateMutation.isPending} className="hover:text-white rounded-full px-4" > {updateMutation.isPending ? ( <> <Loader2 className="size-4 animate-spin mr-1" /> Saving... </> ) : ( <> Save <span className={cn( "bg-[#21212180] border border-[#73737333] text-[#737373] rounded-sm px-1 py-0.5 text-[10px] flex items-center justify-center", dmSansClassName(), )} > ⌘+Enter </span> </> )} </Button> </motion.div> )} </AnimatePresence> </> )} {_document?.type === "pdf" && <PdfViewer url={_document.url} />} {_document?.type === "notion_doc" && ( <NotionDoc content={_document.content ?? ""} /> )} {_document?.url?.includes("youtube.com") && ( <YoutubeVideo url={_document.url} /> )} </div> <div id="document-memories-summary" className={cn( "gap-3 flex flex-col overflow-hidden", dmSansClassName(), )} > {_document?.summary && ( <DocumentSummary memoryEntries={_document.memoryEntries} summary={_document.summary} createdAt={_document.createdAt} /> )} {_document?.memoryEntries && _document.memoryEntries.length > 0 && ( <GraphListMemories memoryEntries={_document.memoryEntries as MemoryEntry[]} /> )} </div> </div> </DialogContent> </Dialog> ) }