"use client" import { useState, useEffect, useCallback, useRef } from "react" import { useQueryState } from "nuqs" import { Dialog, DialogContent, DialogTitle } from "@repo/ui/components/dialog" import { Drawer, DrawerContent, DrawerTitle } from "@repo/ui/components/drawer" import { cn } from "@lib/utils" import { dmSansClassName } from "@/lib/fonts" import { FileTextIcon, GlobeIcon, ZapIcon, Loader2 } 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" import { usePromoCode } from "@/hooks/use-promo-code" type TabType = "note" | "link" | "file" | "connect" interface AddDocumentModalProps { isOpen: boolean onClose: () => void } export function AddDocumentModal({ isOpen, onClose }: AddDocumentModalProps) { const isMobile = useIsMobile() if (isMobile) { return ( !open && onClose()} shouldScaleBackground > 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(), )} > Add Document ) } return ( !open && onClose()}> Add Document ) } const tabs = [ { id: "note" as const, icon: FileTextIcon, title: "Write a note", compactLabel: "Note", description: "Save your thoughts, notes and summaries, as memories", }, { id: "link" as const, icon: GlobeIcon, title: "Save a link", compactLabel: "Links", description: "Add any webpage into your searchable knowledge base", }, { id: "file" as const, icon: FileTextIcon, title: "Upload files", compactLabel: "Files", description: "Turn images, PDFs, documents, and markdown into memories", }, { id: "connect" as const, icon: ZapIcon, title: "Connect knowledge bases", compactLabel: "Connect", 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( globalSelectedProject, ) // Form data state for button click handling const [noteContent, setNoteContent] = useState("") const [linkData, setLinkData] = useState({ url: "", title: "", description: "", }) const [fileData, setFileData] = useState({ items: [], title: "", description: "", }) const fileDataRef = useRef(fileData) fileDataRef.current = fileData const { noteMutation, linkMutation, bulkLinkMutation, fileMutation } = useDocumentMutations({ onClose, }) const autumn = useCustomer() const promoCode = usePromoCode() 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: "" }) setNoteContent("") setLinkData({ url: "", 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) => { // In bulk mode the selection is the source of truth, not the raw textarea. if (data.bulkUrls) { if (data.bulkUrls.length >= 2) { bulkLinkMutation.mutate({ urls: data.bulkUrls, project: localSelectedProject, }) return } const [onlyUrl] = data.bulkUrls if (onlyUrl) { linkMutation.mutate({ url: onlyUrl, project: localSelectedProject }) return } toast.error("Select at least one link") return } if (!data.url.trim()) { toast.error("Please enter a URL") return } linkMutation.mutate({ url: data.url, project: localSelectedProject }) }, [linkMutation, bulkLinkMutation, 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 handleNoteImportLinks = useCallback( (urls: string[]) => { setLinkData({ url: urls.join("\n"), title: "", description: "" }) setActiveTab("link") }, [setActiveTab], ) 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 || bulkLinkMutation.isPending || fileMutation.isPending const fileTabHasPending = fileData.items.some((i) => i.status === "pending") const fileTabSubmitDisabled = activeTab === "file" && (!fileTabHasPending || isSubmitting) const linkBulkCount = linkData.bulkUrls?.length ?? 0 const linkTabSubmitDisabled = activeTab === "link" && linkData.bulkUrls !== undefined && linkBulkCount === 0 const spaceSelector = ( setLocalSelectedProject(projects[0] ?? localSelectedProject) } variant="insideOut" compact={isMobile} triggerClassName={cn(isMobile && "h-12 shrink-0")} /> ) return ( {isMobile && !hasPaidPlan && ( { setIsUpgrading(true) try { const result = await autumn.attach({ planId: "api_pro", discounts: promoCode.getDiscounts(), successUrl: `${window.location.origin}/settings#account`, }) promoCode.clear() 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( "shrink-0 cursor-pointer rounded-full bg-[#0054AD]/30 px-2.5 py-1 text-[11px] font-medium text-[#4BA0FA] transition-colors hover:bg-[#0054AD]/50 disabled:opacity-60", dmSansClassName(), )} > {isUpgrading ? "Upgrading…" : "Upgrade"} )} {!isMobile && ( {tabs.map((tab) => ( setActiveTab(tab.id)} icon={tab.icon} title={tab.title} compactLabel={tab.compactLabel} description={tab.description} isPro={tab.isPro} /> ))} Plan usage {isLoadingUsage ? "…" : `${planUsagePct < 1 && planUsagePct > 0 ? "< 1" : Math.round(planUsagePct)}% used`} 80 ? "#ef4444" : hasPaidPlan ? "linear-gradient(to right, #4BA0FA 80%, #002757 100%)" : "#0054AD", }} title={`${formatUsageNumber(tokensUsed)} tokens · ${formatUsageNumber(searchesUsed)} queries`} /> {!isLoadingUsage && ( {formatUsageNumber(tokensUsed)} tokens ·{" "} {formatUsageNumber(searchesUsed)} queries )} {!hasPaidPlan && ( { setIsUpgrading(true) try { const result = await autumn.attach({ planId: "api_pro", discounts: promoCode.getDiscounts(), successUrl: `${window.location.origin}/settings#account`, }) promoCode.clear() 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 ? ( <> Upgrading… > ) : ( "Upgrade to Pro" )} )} )} {isMobile && ( {tabs.map((tab) => ( setActiveTab(tab.id)} icon={tab.icon} title={tab.title} compactLabel={tab.compactLabel} description={tab.description} isPro={tab.isPro} compact /> ))} )} {activeTab === "note" && ( )} {activeTab === "link" && ( )} {activeTab === "file" && ( { void handleFileSubmit(fileDataRef.current) }} isSubmitting={fileMutation.isPending} isOpen={isOpen} /> )} {activeTab === "connect" && ( )} {!isMobile && spaceSelector} {isMobile && spaceSelector} {!isMobile && ( Cancel )} {activeTab !== "connect" && ( {isSubmitting ? ( <> Adding… > ) : activeTab === "link" && linkBulkCount >= 2 ? ( `+ Add ${linkBulkCount} memories` ) : ( <> + Add {activeTab}{" "} {!isMobile && ( ⌘+Enter )} > )} )} ) } function TabButton({ active, onClick, icon: Icon, title, compactLabel, description, isPro, compact, }: { active: boolean onClick: () => void icon: React.ComponentType<{ className?: string }> title: string compactLabel?: string description: string isPro?: boolean compact?: boolean }) { if (compact) { return ( {compactLabel ?? title.split(" ")[0]} {isPro && ( )} ) } return ( {title} {isPro && ( PRO )} {description} ) }
{formatUsageNumber(tokensUsed)} tokens ·{" "} {formatUsageNumber(searchesUsed)} queries