supermemory/apps/web/components/add-document/index.tsx
ved015 4e607f9fd7 fix: Add plugin document rendering and MCP preview support (#938)
<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" />
2026-05-15 18:26:37 +00:00

655 lines
18 KiB
TypeScript

"use client"
import { useState, useEffect, useCallback, useRef } from "react"
import { useQueryState } from "nuqs"
import { Dialog, DialogContent, DialogTitle } from "@repo/ui/components/dialog"
import { cn } from "@lib/utils"
import { dmSansClassName } from "@/lib/fonts"
import { FileTextIcon, GlobeIcon, ZapIcon, Loader2, XIcon } from "lucide-react"
import { Button } from "@ui/components/button"
import { ConnectContent } from "./connections"
import { NoteContent } from "./note"
import { LinkContent, type LinkData } from "./link"
import { FileContent, type FileData } from "./file"
import { useProject } from "@/stores"
import { toast } from "sonner"
import { useDocumentMutations } from "../../hooks/use-document-mutations"
import { useCustomer } from "autumn-js/react"
import { useTokenUsage } from "@/hooks/use-token-usage"
import { formatUsageNumber } from "@/lib/billing-utils"
import { SpaceSelector } from "../space-selector"
import { useIsMobile } from "@hooks/use-mobile"
import { addDocumentParam } from "@/lib/search-params"
type TabType = "note" | "link" | "file" | "connect"
interface AddDocumentModalProps {
isOpen: boolean
onClose: () => void
}
export function AddDocumentModal({ isOpen, onClose }: AddDocumentModalProps) {
const isMobile = useIsMobile()
return (
<Dialog open={isOpen} onOpenChange={(open: boolean) => !open && onClose()}>
<DialogContent
className={cn(
"border-none bg-[#1B1F24] flex flex-col",
isMobile
? "top-2! left-2! translate-x-0! translate-y-0! w-[calc(100vw-1rem)]! h-[calc(100dvh-1rem)]! max-w-none! max-h-none! rounded-[18px] p-0 gap-0 overflow-hidden"
: "w-[80%]! max-w-[1000px]! h-[80%]! max-h-[800px]! rounded-[22px] p-4 gap-3",
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">Add Document</DialogTitle>
<div className="min-h-0 flex-1 overflow-hidden">
<AddDocument onClose={onClose} isOpen={isOpen} />
</div>
</DialogContent>
</Dialog>
)
}
const tabs = [
{
id: "note" as const,
icon: FileTextIcon,
title: "Write a note",
description: "Save your thoughts, notes and summaries, as memories",
},
{
id: "link" as const,
icon: GlobeIcon,
title: "Save a link",
description: "Add any webpage into your searchable knowledge base",
},
{
id: "file" as const,
icon: FileTextIcon,
title: "Upload files",
description: "Turn images, PDFs, documents, and markdown into memories",
},
{
id: "connect" as const,
icon: ZapIcon,
title: "Connect knowledge bases",
description: "Sync with Google Drive, Notion and OneDrive and import data",
isPro: true,
},
]
export function AddDocument({
onClose,
isOpen,
}: {
onClose: () => void
isOpen?: boolean
}) {
const isMobile = useIsMobile()
const [addParam, setAddParam] = useQueryState("add", addDocumentParam)
const activeTab: TabType = addParam ?? "note"
const setActiveTab = useCallback(
(tab: TabType) => {
setAddParam(tab)
},
[setAddParam],
)
const { selectedProject: globalSelectedProject } = useProject()
const [localSelectedProject, setLocalSelectedProject] = useState<string>(
globalSelectedProject,
)
// Form data state for button click handling
const [noteContent, setNoteContent] = useState("")
const [linkData, setLinkData] = useState<LinkData>({
url: "",
title: "",
description: "",
})
const [fileData, setFileData] = useState<FileData>({
items: [],
title: "",
description: "",
})
const fileDataRef = useRef(fileData)
fileDataRef.current = fileData
const { noteMutation, linkMutation, fileMutation } = useDocumentMutations({
onClose,
})
const autumn = useCustomer()
const {
tokensUsed,
searchesUsed,
planUsagePct,
hasPaidPlan,
isLoading: isLoadingUsage,
} = useTokenUsage(autumn)
const [isUpgrading, setIsUpgrading] = useState(false)
useEffect(() => {
setLocalSelectedProject(globalSelectedProject)
}, [globalSelectedProject])
useEffect(() => {
if (!isOpen) {
setFileData({ items: [], title: "", description: "" })
}
}, [isOpen])
// Submit handlers
const handleNoteSubmit = useCallback(
(content: string) => {
if (!content.trim()) {
toast.error("Please enter some content")
return
}
noteMutation.mutate({ content, project: localSelectedProject })
},
[noteMutation, localSelectedProject],
)
const handleLinkSubmit = useCallback(
(data: LinkData) => {
if (!data.url.trim()) {
toast.error("Please enter a URL")
return
}
linkMutation.mutate({ url: data.url, project: localSelectedProject })
},
[linkMutation, localSelectedProject],
)
const handleFileSubmit = useCallback(
async (data: FileData) => {
const pending = data.items.filter((i) => i.status === "pending")
if (pending.length === 0) {
toast.error("Please add at least one file")
return
}
const applyMeta = pending.length === 1
setFileData((prev) => ({
...prev,
items: prev.items.map((i) =>
i.status === "pending" ? { ...i, status: "uploading" as const } : i,
),
}))
try {
const result = await fileMutation.mutateAsync({
fileEntries: pending.map((i) => ({ id: i.id, file: i.file })),
title: applyMeta ? data.title || undefined : undefined,
description: applyMeta ? data.description || undefined : undefined,
project: localSelectedProject,
})
setFileData((prev) => ({
...prev,
items: prev.items.map((i) => {
if (i.status !== "uploading") return i
const fail = result.failures.find((f) => f.id === i.id)
if (fail) {
return {
...i,
status: "error" as const,
errorMessage: fail.message,
}
}
return { ...i, status: "success" as const }
}),
}))
} catch {
setFileData((prev) => ({
...prev,
items: prev.items.map((i) =>
i.status === "uploading"
? {
...i,
status: "error" as const,
errorMessage: "Upload failed",
}
: i,
),
}))
}
},
[fileMutation, localSelectedProject],
)
// Data change handlers
const handleNoteContentChange = useCallback((content: string) => {
setNoteContent(content)
}, [])
const handleLinkDataChange = useCallback((data: LinkData) => {
setLinkData(data)
}, [])
const handleFileDataChange = useCallback((data: FileData) => {
setFileData(data)
}, [])
const handleButtonClick = () => {
switch (activeTab) {
case "note":
handleNoteSubmit(noteContent)
break
case "link":
handleLinkSubmit(linkData)
break
case "file":
void handleFileSubmit(fileData)
break
}
}
const isSubmitting =
noteMutation.isPending || linkMutation.isPending || fileMutation.isPending
const fileTabHasPending = fileData.items.some((i) => i.status === "pending")
const fileTabSubmitDisabled =
activeTab === "file" && (!fileTabHasPending || isSubmitting)
return (
<div className="flex h-full min-h-0 flex-col overflow-hidden text-white md:flex-row md:space-x-5">
<div
className={cn(
"flex flex-col justify-between",
isMobile
? "w-full shrink-0 border-b border-[#0F1621] bg-[#1B1F24] px-3 pt-3 pb-3"
: "w-1/3",
)}
>
{isMobile && (
<div className="mb-3 flex items-center justify-between">
<div>
<p
className={cn(
"text-sm font-medium text-white",
dmSansClassName(),
)}
>
Add memory
</p>
<p className="text-xs text-[#737373]">
Save something to recall later
</p>
</div>
<button
type="button"
onClick={onClose}
disabled={isSubmitting}
className="flex size-9 items-center justify-center rounded-full border border-[#1F2937] bg-[#0D121A] text-[#8B8B8B] transition-colors hover:text-white disabled:opacity-50"
aria-label="Close add memory"
>
<XIcon className="size-4" />
</button>
</div>
)}
<div
className={cn(
isMobile ? "grid grid-cols-4 gap-1" : "flex flex-col gap-1",
)}
>
{tabs.map((tab) => (
<TabButton
key={tab.id}
active={activeTab === tab.id}
onClick={() => setActiveTab(tab.id)}
icon={tab.icon}
title={tab.title}
description={tab.description}
isPro={tab.isPro}
compact={isMobile}
/>
))}
</div>
{isMobile && (
<div className="mt-3 flex flex-col gap-2">
<div className="flex justify-between items-center">
<span
className={cn(
"text-[#FAFAFA] text-sm font-medium",
dmSansClassName(),
)}
>
Plan usage
</span>
<span
className={cn(
"text-sm font-medium tabular-nums",
hasPaidPlan ? "text-[#4BA0FA]" : "text-[#737373]",
dmSansClassName(),
)}
>
{isLoadingUsage
? "…"
: `${planUsagePct < 1 && planUsagePct > 0 ? "< 1" : Math.round(planUsagePct)}% used`}
</span>
</div>
<div className="h-2 w-full rounded-[40px] bg-[#2E353D] p-px overflow-hidden">
<div
className="h-full rounded-[40px]"
style={{
width: `${planUsagePct}%`,
background:
planUsagePct > 80
? "#ef4444"
: hasPaidPlan
? "linear-gradient(to right, #4BA0FA 80%, #002757 100%)"
: "#0054AD",
}}
title={`${formatUsageNumber(tokensUsed)} tokens · ${formatUsageNumber(searchesUsed)} queries`}
/>
</div>
{!isLoadingUsage && (
<p
className={cn(
"text-xs text-[#737373] tabular-nums",
dmSansClassName(),
)}
>
{formatUsageNumber(tokensUsed)} tokens ·{" "}
{formatUsageNumber(searchesUsed)} queries
</p>
)}
</div>
)}
{!isMobile && (
<div data-testid="usage-counter" className="flex flex-col gap-3 mr-4">
<div className="flex flex-col gap-2">
<div className="flex justify-between items-center">
<span
className={cn(
"text-[#FAFAFA] text-sm font-medium",
dmSansClassName(),
)}
>
Plan usage
</span>
<span
className={cn(
"text-sm font-medium tabular-nums",
hasPaidPlan ? "text-[#4BA0FA]" : "text-[#737373]",
dmSansClassName(),
)}
>
{isLoadingUsage
? "…"
: `${planUsagePct < 1 && planUsagePct > 0 ? "< 1" : Math.round(planUsagePct)}% used`}
</span>
</div>
<div className="h-2 w-full rounded-[40px] bg-[#2E353D] p-px overflow-hidden">
<div
className="h-full rounded-[40px]"
style={{
width: `${planUsagePct}%`,
background:
planUsagePct > 80
? "#ef4444"
: hasPaidPlan
? "linear-gradient(to right, #4BA0FA 80%, #002757 100%)"
: "#0054AD",
}}
title={`${formatUsageNumber(tokensUsed)} tokens · ${formatUsageNumber(searchesUsed)} queries`}
/>
</div>
{!isLoadingUsage && (
<p
className={cn(
"text-xs text-[#737373] tabular-nums",
dmSansClassName(),
)}
>
{formatUsageNumber(tokensUsed)} tokens ·{" "}
{formatUsageNumber(searchesUsed)} queries
</p>
)}
</div>
{!hasPaidPlan && (
<button
type="button"
onClick={async () => {
setIsUpgrading(true)
try {
const result = await autumn.attach({
planId: "api_pro",
successUrl: `${window.location.origin}/settings#account`,
})
if (result?.paymentUrl) {
window.open(result.paymentUrl, "_self")
return
}
autumn.refetch?.()
} catch (error) {
console.error(error)
toast.error("Failed to start checkout. Please try again.")
} finally {
setIsUpgrading(false)
}
}}
disabled={isUpgrading}
className={cn(
"relative w-full h-9 rounded-[10px] flex items-center justify-center",
"text-[#FAFAFA] font-medium text-[13px]",
"disabled:opacity-60 disabled:cursor-not-allowed",
"cursor-pointer transition-opacity hover:opacity-90",
dmSansClassName(),
)}
style={{
background:
"linear-gradient(182.37deg, #0ff0d2 -91.53%, #5bd3fb -67.8%, #1e0ff0 95.17%)",
boxShadow:
"1px 1px 2px 0px #1A88FF inset, 0 2px 10px 0 rgba(5, 1, 0, 0.20)",
}}
>
{isUpgrading ? (
<>
<Loader2 className="size-3 animate-spin mr-1.5" />
Upgrading
</>
) : (
"Upgrade to Pro"
)}
<div className="absolute inset-0 pointer-events-none rounded-[inherit] shadow-[inset_1px_1px_2px_1px_#1A88FF]" />
</button>
)}
</div>
)}
</div>
<div
className={cn(
"flex min-h-0 flex-1 flex-col",
isMobile ? "w-full px-3 pt-3" : "w-2/3 px-1",
)}
>
<div className="min-h-0 flex-1 overflow-auto scrollbar-thin">
{activeTab === "note" && (
<NoteContent
onSubmit={handleNoteSubmit}
onContentChange={handleNoteContentChange}
isSubmitting={noteMutation.isPending}
isOpen={isOpen}
/>
)}
{activeTab === "link" && (
<LinkContent
onSubmit={handleLinkSubmit}
onDataChange={handleLinkDataChange}
isSubmitting={linkMutation.isPending}
isOpen={isOpen}
/>
)}
{activeTab === "file" && (
<FileContent
data={fileData}
onDataChange={handleFileDataChange}
onRequestSubmit={() => {
void handleFileSubmit(fileDataRef.current)
}}
isSubmitting={fileMutation.isPending}
isOpen={isOpen}
/>
)}
{activeTab === "connect" && (
<ConnectContent selectedProject={localSelectedProject} />
)}
</div>
<div
className={cn(
"flex shrink-0 gap-2",
isMobile
? "mx-[-0.75rem] mt-3 border-t border-[#0F1621] bg-[#1B1F24] px-3 py-3 pb-[max(0.75rem,env(safe-area-inset-bottom))]"
: "justify-between pt-3",
)}
>
{!isMobile && (
<SpaceSelector
selectedProjects={[localSelectedProject]}
onValueChange={(projects) =>
setLocalSelectedProject(projects[0] ?? localSelectedProject)
}
variant="insideOut"
/>
)}
<div
className={cn(
"flex items-center gap-2",
isMobile && "w-full justify-end",
)}
>
<Button
variant="ghost"
onClick={onClose}
disabled={isSubmitting}
className={cn(
"cursor-pointer rounded-full text-[#737373]",
isMobile && "h-11 px-4",
)}
>
Cancel
</Button>
{activeTab !== "connect" && (
<Button
variant="insideOut"
onClick={handleButtonClick}
disabled={
activeTab === "file" ? fileTabSubmitDisabled : isSubmitting
}
className={cn(isMobile && "h-11 min-w-[8rem] px-5")}
>
{isSubmitting ? (
<>
<Loader2 className="size-4 animate-spin mr-2" />
Adding
</>
) : (
<>
+ Add {activeTab}{" "}
{!isMobile && (
<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>
)}
</div>
</div>
</div>
</div>
)
}
function TabButton({
active,
onClick,
icon: Icon,
title,
description,
isPro,
compact,
}: {
active: boolean
onClick: () => void
icon: React.ComponentType<{ className?: string }>
title: string
description: string
isPro?: boolean
compact?: boolean
}) {
if (compact) {
return (
<button
type="button"
onClick={onClick}
className={cn(
"relative flex h-14 min-w-0 flex-col items-center justify-center gap-1 rounded-xl px-1 text-center transition-colors focus:outline-none focus:ring-0",
active
? "bg-[#0F141B] text-white shadow-inside-out ring-1 ring-[#2261CA33]"
: "text-[#8B8B8B] hover:bg-[#14161A]/50",
dmSansClassName(),
)}
>
<Icon className="size-3.5 shrink-0" />
<span
className={cn(
"min-w-0 truncate text-xs font-medium leading-none",
dmSansClassName(),
)}
>
{title.split(" ")[0]}
</span>
{isPro && (
<span className="absolute top-1 right-1 rounded bg-[#4BA0FA] px-1 py-0.5 text-[7px] font-semibold leading-none text-black">
PRO
</span>
)}
</button>
)
}
return (
<button
type="button"
onClick={onClick}
className={cn(
"flex items-start gap-3 p-4 rounded-[16px] text-left transition-colors w-full focus:outline-none focus:ring-0",
active
? "bg-[#14161A] shadow-inside-out"
: "hover:bg-[#14161A]/50 hover:shadow-[inset_0_2px_4px_rgba(0,0,0,0.3),inset_0_1px_2px_rgba(0,0,0,0.1)]",
dmSansClassName(),
)}
>
<Icon className={cn("size-5 mt-0.5 shrink-0 text-white")} />
<div className="flex flex-col gap-0.5 text-[16px]">
<div className="flex items-center justify-between gap-2">
<span className={cn("font-medium text-white", dmSansClassName())}>
{title}
</span>
{isPro && (
<span className="bg-[#4BA0FA] text-black text-[10px] font-semibold px-1.5 py-0.5 rounded">
PRO
</span>
)}
</div>
<span className="text-[#737373]">{description}</span>
</div>
</button>
)
}