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" />
655 lines
18 KiB
TypeScript
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>
|
|
)
|
|
}
|