mirror of
https://github.com/supermemoryai/supermemory.git
synced 2026-05-17 12:20:04 +00:00
<h3>Implement comprehensive plugin document rendering support including MCP previews and plugin specific content handling.</h3> <br> <br> <img width="1680" height="471" alt="Screenshot 2026-05-12 at 8 24 49 PM" src="https://github.com/user-attachments/assets/f1294bc2-2841-4833-9f01-ac47b8c52c01" /> <br> <br> <img width="1680" height="963" alt="Screenshot 2026-05-12 at 8 28 25 PM" src="https://github.com/user-attachments/assets/9436c7ab-3b9b-4366-86fd-1465407ff0f9" />
381 lines
12 KiB
TypeScript
381 lines
12 KiB
TypeScript
"use client"
|
|
|
|
import { Dialog, DialogContent, DialogTitle } from "@repo/ui/components/dialog"
|
|
import type { DocumentsWithMemoriesResponseSchema } from "@repo/validation/api"
|
|
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 { PluginDetails } from "./plugin-details"
|
|
|
|
type DocumentsResponse = z.infer<typeof DocumentsWithMemoriesResponseSchema>
|
|
type DocumentWithMemories = DocumentsResponse["documents"][0]
|
|
|
|
function getDocumentSourceUrl(document: DocumentWithMemories): string {
|
|
const url = document.url ?? ""
|
|
const googleDocTypes: Record<string, string> = {
|
|
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 (
|
|
<AnimatePresence mode="wait">
|
|
{!deleteConfirmOpen ? (
|
|
<motion.button
|
|
key="trash"
|
|
type="button"
|
|
initial={{ opacity: 0, scale: 0.8 }}
|
|
animate={{ opacity: 1, scale: 1 }}
|
|
exit={{ opacity: 0, scale: 0.8 }}
|
|
transition={{ duration: 0.15 }}
|
|
onClick={() => 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}
|
|
>
|
|
<Trash2Icon className="size-4 text-red-500" />
|
|
<span className="sr-only">Delete document</span>
|
|
</motion.button>
|
|
) : (
|
|
<motion.div
|
|
key="confirm"
|
|
initial={{ opacity: 0, scale: 0.8 }}
|
|
animate={{ opacity: 1, scale: 1 }}
|
|
exit={{ opacity: 0, scale: 0.8 }}
|
|
transition={{ duration: 0.15 }}
|
|
className="flex items-center gap-1 px-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)]"
|
|
>
|
|
<button
|
|
type="button"
|
|
onClick={() => setDeleteConfirmOpen(false)}
|
|
disabled={deleteMutation.isPending}
|
|
className="size-6 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 disabled:opacity-50 disabled:cursor-not-allowed"
|
|
>
|
|
<XIcon className="size-4 text-[#737373]" />
|
|
<span className="sr-only">Cancel delete</span>
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={handleDelete}
|
|
disabled={deleteMutation.isPending}
|
|
className="size-6 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 disabled:opacity-50 disabled:cursor-not-allowed"
|
|
>
|
|
{deleteMutation.isPending ? (
|
|
<Loader2 className="size-4 text-green-500 animate-spin" />
|
|
) : (
|
|
<CheckIcon className="size-4 text-green-500" />
|
|
)}
|
|
<span className="sr-only">Confirm delete</span>
|
|
</button>
|
|
</motion.div>
|
|
)}
|
|
</AnimatePresence>
|
|
)
|
|
}
|
|
|
|
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 (
|
|
<button
|
|
type="button"
|
|
onClick={handleCopy}
|
|
tabIndex={-1}
|
|
title="Copy session id"
|
|
aria-label="Copy session id"
|
|
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)]"
|
|
>
|
|
{copied ? (
|
|
<CheckIcon className="w-4 h-4 text-green-500" />
|
|
) : (
|
|
<CopyIcon className="w-4 h-4 text-[#737373]" />
|
|
)}
|
|
<span className="sr-only">Copy session id</span>
|
|
</button>
|
|
)
|
|
}
|
|
|
|
export function DocumentModal({
|
|
document: _document,
|
|
isOpen,
|
|
onClose,
|
|
}: DocumentModalProps) {
|
|
const isMobile = useIsMobile()
|
|
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 pluginDocument = useMemo(
|
|
() => parsePluginDocument(_document),
|
|
[_document],
|
|
)
|
|
|
|
const [draftContentString, setDraftContentString] =
|
|
useState(initialEditorString)
|
|
const [editorResetNonce, setEditorResetNonce] = useState(0)
|
|
const [lastSavedContent, setLastSavedContent] = useState<string | null>(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,
|
|
],
|
|
)
|
|
|
|
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",
|
|
isMobile
|
|
? "w-[calc(100vw-1rem)]! h-[calc(100dvh-1rem)]! max-w-none! max-h-none! rounded-xl"
|
|
: "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}
|
|
>
|
|
<DialogTitle className="sr-only">
|
|
{_document?.title} - Document
|
|
</DialogTitle>
|
|
<div className="flex items-center justify-between h-fit gap-2 md:gap-4">
|
|
<div className="flex-1 min-w-0">
|
|
<Title
|
|
title={_document?.title}
|
|
documentType={_document?.type ?? "text"}
|
|
url={_document?.url}
|
|
pluginIconSrc={pluginDocument?.pluginIconSrc}
|
|
/>
|
|
</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>
|
|
)}
|
|
<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>
|
|
<div className="flex-1 grid grid-cols-1 md: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",
|
|
)}
|
|
>
|
|
<DocumentContent
|
|
document={_document}
|
|
textEditorProps={textEditorProps}
|
|
pluginDocument={pluginDocument}
|
|
/>
|
|
</div>
|
|
<div
|
|
id="document-memories-summary"
|
|
className={cn(
|
|
"gap-3 flex flex-col overflow-hidden",
|
|
dmSansClassName(),
|
|
)}
|
|
>
|
|
{pluginDocument &&
|
|
pluginDocument.kind !== "claude-code-doc" &&
|
|
pluginDocument.kind !== "openclaw-session" && (
|
|
<PluginDetails parsed={pluginDocument} />
|
|
)}
|
|
{_document && (_document.summary || pluginDocument?.summary) && (
|
|
<DocumentSummary
|
|
memoryEntries={_document.memoryEntries}
|
|
summary={
|
|
(pluginDocument?.summary ?? _document.summary) as string
|
|
}
|
|
createdAt={_document.createdAt}
|
|
/>
|
|
)}
|
|
{_document?.memoryEntries && _document.memoryEntries.length > 0 && (
|
|
<GraphListMemories
|
|
memoryEntries={_document.memoryEntries as MemoryEntry[]}
|
|
documentId={_document.id}
|
|
/>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</DialogContent>
|
|
</Dialog>
|
|
)
|
|
}
|