diff options
| author | Mahesh Sanikommmu <[email protected]> | 2025-08-16 18:50:10 -0700 |
|---|---|---|
| committer | Mahesh Sanikommmu <[email protected]> | 2025-08-16 18:50:10 -0700 |
| commit | 39003aff23d64ff1d96074d71521f6023c9bec01 (patch) | |
| tree | 3f870c04b3dce315bba1b21aa2da158494e71774 /apps/web/components | |
| parent | Merge pull request #355 from supermemoryai/archive (diff) | |
| download | supermemory-39003aff23d64ff1d96074d71521f6023c9bec01.tar.xz supermemory-39003aff23d64ff1d96074d71521f6023c9bec01.zip | |
New Version of Supermemory Consumer App
Diffstat (limited to 'apps/web/components')
20 files changed, 7159 insertions, 0 deletions
diff --git a/apps/web/components/connect-ai-modal.tsx b/apps/web/components/connect-ai-modal.tsx new file mode 100644 index 00000000..ca5240cd --- /dev/null +++ b/apps/web/components/connect-ai-modal.tsx @@ -0,0 +1,219 @@ +"use client" + +import { useIsMobile } from "@hooks/use-mobile" +import { Button } from "@ui/components/button" +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@ui/components/dialog" +import { Input } from "@ui/components/input" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@ui/components/select" +import { CopyableCell } from "@ui/copyable-cell" +import { CopyIcon, ExternalLink } from "lucide-react" +import Image from "next/image" +import { useState } from "react" +import { toast } from "sonner" + +const clients = { + cursor: "Cursor", + claude: "Claude Desktop", + vscode: "VSCode", + cline: "Cline", + "gemini-cli": "Gemini CLI", + "claude-code": "Claude Code", + "roo-cline": "Roo Cline", + witsy: "Witsy", + enconvo: "Enconvo", +} as const + +interface ConnectAIModalProps { + children: React.ReactNode +} + +export function ConnectAIModal({ children }: ConnectAIModalProps) { + const [client, setClient] = useState<keyof typeof clients>("cursor") + const [isOpen, setIsOpen] = useState(false) + const [showAllTools, setShowAllTools] = useState(false) + const isMobile = useIsMobile() + const installCommand = `npx -y install-mcp@latest https://api.supermemory.ai/mcp --client ${client} --oauth=yes` + + const copyToClipboard = () => { + navigator.clipboard.writeText(installCommand) + toast.success("Copied to clipboard!") + } + + return ( + <Dialog open={isOpen} onOpenChange={setIsOpen}> + <DialogTrigger asChild>{children}</DialogTrigger> + <DialogContent className="sm:max-w-4xl"> + <DialogHeader> + <DialogTitle>Connect Supermemory to Your AI</DialogTitle> + <DialogDescription> + Connect supermemory to your favorite AI tools using the Model Context Protocol (MCP). + This allows your AI assistant to create, search, and access your memories directly. + </DialogDescription> + </DialogHeader> + + <div className="mb-6 block md:hidden"> + <label + className="text-sm font-medium text-white/80 block mb-2" + htmlFor="mcp-server-url" + > + MCP Server URL + </label> + <div className="p-3 bg-white/5 rounded border border-white/10"> + <CopyableCell + className="font-mono text-sm text-blue-400" + value="https://api.supermemory.ai/mcp" + /> + </div> + <p className="text-xs text-white/50 mt-2"> + Click URL to copy to clipboard. Use this URL to configure supermemory in your AI assistant. + </p> + </div> + + <div className="space-y-6"> + <div className="hidden md:block"> + <h3 className="text-sm font-medium mb-3">Supported AI Tools</h3> + <div className="grid grid-cols-2 md:grid-cols-3 gap-3 text-sm"> + {Object.entries(clients) + .slice(0, showAllTools ? undefined : isMobile ? 4 : 6) + .map(([key, clientName]) => ( + <div + key={clientName} + className="flex items-center gap-3 px-3 py-3 bg-muted rounded-md" + > + <div className="w-8 h-8 relative flex-shrink-0 flex items-center justify-center"> + <Image + src={`/mcp-supported-tools/${key == "claude-code" ? "claude" : key}.png`} + alt={clientName} + width={isMobile ? 20 : 32} + height={isMobile ? 20 : 32} + className={"rounded object-contain"} + onError={(e) => { + const target = e.target as HTMLImageElement; + target.style.display = 'none'; + const parent = target.parentElement; + if (parent && !parent.querySelector('.fallback-text')) { + const fallback = document.createElement('span'); + fallback.className = 'fallback-text text-xs font-bold text-muted-foreground'; + fallback.textContent = clientName.substring(0, 2).toUpperCase(); + parent.appendChild(fallback); + } + }} + /> + </div> + <span className="text-sm font-medium">{clientName}</span> + </div> + ))} + </div> + {Object.entries(clients).length > 6 && ( + <div className="mt-3 text-center"> + <Button + variant="ghost" + size="sm" + onClick={() => setShowAllTools(!showAllTools)} + className="text-xs text-muted-foreground hover:text-foreground" + > + {showAllTools ? "Show Less" : `Show All`} + </Button> + </div> + )} + </div> + + <div className="hidden md:block"> + <h3 className="text-sm font-medium mb-3">Quick Installation</h3> + <div className="space-y-3 flex gap-2 items-center justify-between"> + <Select + value={client} + onValueChange={(value) => setClient(value as keyof typeof clients)} + > + <SelectTrigger className="w-48 mb-0"> + <SelectValue /> + </SelectTrigger> + <SelectContent> + {Object.entries(clients).map(([key, value]) => ( + <SelectItem key={key} value={key}> + <div className="flex items-center gap-2"> + <div className="w-4 h-4 relative flex-shrink-0 flex items-center justify-center"> + <Image + src={`/mcp-supported-tools/${key == "claude-code" ? "claude" : key}.png`} + alt={value} + width={16} + height={16} + className="rounded object-contain" + onError={(e) => { + const target = e.target as HTMLImageElement; + target.style.display = 'none'; + const parent = target.parentElement; + if (parent && !parent.querySelector('.fallback-text')) { + const fallback = document.createElement('span'); + fallback.className = 'fallback-text text-xs font-bold text-muted-foreground'; + fallback.textContent = value.substring(0, 1).toUpperCase(); + parent.appendChild(fallback); + } + }} + /> + </div> + {value} + </div> + </SelectItem> + ))} + </SelectContent> + </Select> + + <div className="relative w-full flex items-center"> + <Input + className="font-mono text-xs w-full pr-10" + readOnly + value={installCommand} + /> + + <Button + onClick={copyToClipboard} + className="absolute right-0 cursor-pointer" + variant="ghost" + > + <CopyIcon className="size-4" /> + </Button> + </div> + </div> + </div> + + <div> + <h3 className="text-sm font-medium mb-3">What You Can Do</h3> + <ul className="space-y-2 text-sm text-muted-foreground"> + <li>• Ask your AI to save important information as memories</li> + <li>• Search through your saved memories during conversations</li> + <li>• Get contextual information from your knowledge base</li> + <li>• Seamlessly integrate with your existing AI workflow</li> + </ul> + </div> + + <div className="flex justify-between items-center pt-4 border-t"> + <Button + variant="outline" + onClick={() => window.open("https://docs.supermemory.ai/supermemory-mcp/introduction", "_blank")} + > + <ExternalLink className="w-4 h-4 mr-2" /> + Learn More + </Button> + <Button onClick={() => setIsOpen(false)}> + Done + </Button> + </div> + </div> + </DialogContent> + </Dialog> + ) +} diff --git a/apps/web/components/create-project-dialog.tsx b/apps/web/components/create-project-dialog.tsx new file mode 100644 index 00000000..904c3f2d --- /dev/null +++ b/apps/web/components/create-project-dialog.tsx @@ -0,0 +1,119 @@ +"use client" + +import { Button } from "@repo/ui/components/button" +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@repo/ui/components/dialog" +import { Input } from "@repo/ui/components/input" +import { Label } from "@repo/ui/components/label" +import { Loader2 } from "lucide-react" +import { motion, AnimatePresence } from "motion/react" +import { useState } from "react" +import { useProjectMutations } from "@/hooks/use-project-mutations" + +interface CreateProjectDialogProps { + open: boolean + onOpenChange: (open: boolean) => void +} + +export function CreateProjectDialog({ open, onOpenChange }: CreateProjectDialogProps) { + const [projectName, setProjectName] = useState("") + const { createProjectMutation } = useProjectMutations() + + const handleClose = () => { + onOpenChange(false) + setProjectName("") + } + + const handleCreate = () => { + if (projectName.trim()) { + createProjectMutation.mutate(projectName, { + onSuccess: () => { + handleClose() + } + }) + } + } + + return ( + <AnimatePresence> + {open && ( + <Dialog open={open} onOpenChange={onOpenChange}> + <DialogContent className="sm:max-w-2xl bg-[#0f1419] backdrop-blur-xl border-white/10 text-white"> + <motion.div + initial={{ opacity: 0, scale: 0.95 }} + animate={{ opacity: 1, scale: 1 }} + exit={{ opacity: 0, scale: 0.95 }} + > + <DialogHeader> + <DialogTitle>Create New Project</DialogTitle> + <DialogDescription className="text-white/60"> + Give your project a unique name + </DialogDescription> + </DialogHeader> + <div className="grid gap-4 py-4"> + <motion.div + initial={{ opacity: 0, y: 10 }} + animate={{ opacity: 1, y: 0 }} + transition={{ delay: 0.1 }} + className="flex flex-col gap-2" + > + <Label htmlFor="projectName">Project Name</Label> + <Input + id="projectName" + className="bg-white/5 border-white/10 text-white" + placeholder="My Awesome Project" + value={projectName} + onChange={(e) => setProjectName(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter" && projectName.trim()) { + handleCreate() + } + }} + /> + <p className="text-xs text-white/50"> + This will help you organize your memories + </p> + </motion.div> + </div> + <DialogFooter> + <motion.div whileHover={{ scale: 1.05 }} whileTap={{ scale: 0.95 }}> + <Button + type="button" + variant="outline" + className="bg-white/5 hover:bg-white/10 border-white/10 text-white" + onClick={handleClose} + > + Cancel + </Button> + </motion.div> + <motion.div whileHover={{ scale: 1.05 }} whileTap={{ scale: 0.95 }}> + <Button + type="button" + className="bg-white/10 hover:bg-white/20 text-white border-white/20" + disabled={createProjectMutation.isPending || !projectName.trim()} + onClick={handleCreate} + > + {createProjectMutation.isPending ? ( + <> + <Loader2 className="h-4 w-4 animate-spin mr-2" /> + Creating... + </> + ) : ( + "Create Project" + )} + </Button> + </motion.div> + </DialogFooter> + </motion.div> + </DialogContent> + </Dialog> + )} + </AnimatePresence> + ) +} diff --git a/apps/web/components/glass-menu-effect.tsx b/apps/web/components/glass-menu-effect.tsx new file mode 100644 index 00000000..9d4d4b68 --- /dev/null +++ b/apps/web/components/glass-menu-effect.tsx @@ -0,0 +1,37 @@ +import { motion } from "motion/react" + +interface GlassMenuEffectProps { + rounded?: string + className?: string +} + +export function GlassMenuEffect({ + rounded = "rounded-[28px]", + className = "", +}: GlassMenuEffectProps) { + return ( + <motion.div + className={`absolute inset-0 ${className}`} + layout + style={{ + transform: "translateZ(0)", + willChange: "auto", + }} + transition={{ + layout: { + type: "spring", + damping: 35, + stiffness: 180, + }, + }} + > + <div + className={`absolute inset-0 backdrop-blur-md bg-white/5 border border-white/10 ${rounded}`} + style={{ + transform: "translateZ(0)", + willChange: "transform", + }} + /> + </motion.div> + ) +} diff --git a/apps/web/components/install-prompt.tsx b/apps/web/components/install-prompt.tsx new file mode 100644 index 00000000..cde987c4 --- /dev/null +++ b/apps/web/components/install-prompt.tsx @@ -0,0 +1,118 @@ +import { useEffect, useState } from "react" +import { motion, AnimatePresence } from "motion/react" +import { X, Download, Share } from "lucide-react" +import { Button } from "@repo/ui/components/button" + +export function InstallPrompt() { + const [isIOS, setIsIOS] = useState(false) + const [showPrompt, setShowPrompt] = useState(false) + const [deferredPrompt, setDeferredPrompt] = useState<any>(null) + + useEffect(() => { + const isIOSDevice = /iPad|iPhone|iPod/.test(navigator.userAgent) && !(window as any).MSStream + const isInStandaloneMode = window.matchMedia('(display-mode: standalone)').matches + const hasSeenPrompt = localStorage.getItem('install-prompt-dismissed') === 'true' + + setIsIOS(isIOSDevice) + + const isDevelopment = process.env.NODE_ENV === 'development' + setShowPrompt(!hasSeenPrompt && (isDevelopment || (!isInStandaloneMode && (isIOSDevice || 'serviceWorker' in navigator)))) + + const handleBeforeInstallPrompt = (e: Event) => { + e.preventDefault() + setDeferredPrompt(e) + if (!hasSeenPrompt) { + setShowPrompt(true) + } + } + + window.addEventListener('beforeinstallprompt', handleBeforeInstallPrompt) + + return () => { + window.removeEventListener('beforeinstallprompt', handleBeforeInstallPrompt) + } + }, []) + + const handleInstall = async () => { + if (deferredPrompt) { + deferredPrompt.prompt() + const { outcome } = await deferredPrompt.userChoice + if (outcome === 'accepted') { + localStorage.setItem('install-prompt-dismissed', 'true') + setShowPrompt(false) + } + setDeferredPrompt(null) + } + } + + const handleDismiss = () => { + localStorage.setItem('install-prompt-dismissed', 'true') + setShowPrompt(false) + } + + if (!showPrompt) { + return null + } + + return ( + <AnimatePresence> + <motion.div + animate={{ y: 0, opacity: 1 }} + exit={{ y: 100, opacity: 0 }} + initial={{ y: 100, opacity: 0 }} + className="fixed bottom-4 left-4 right-4 z-50 mx-auto max-w-sm md:hidden" + > + <div className="bg-black/90 backdrop-blur-md text-white rounded-2xl p-4 shadow-2xl border border-white/10"> + <div className="flex items-start justify-between mb-3"> + <div className="flex items-center gap-2"> + <div className="w-8 h-8 bg-[#0f1419] rounded-lg flex items-center justify-center"> + <Download className="w-4 h-4" /> + </div> + <h3 className="font-semibold text-sm">Install Supermemory</h3> + </div> + <Button + variant="ghost" + size="sm" + onClick={handleDismiss} + className="text-white/60 hover:text-white h-6 w-6 p-0" + > + <X className="w-4 h-4" /> + </Button> + </div> + + <p className="text-white/80 text-xs mb-4 leading-relaxed"> + Add Supermemory to your home screen for quick access and a better experience. + </p> + + {isIOS ? ( + <div className="space-y-3"> + <p className="text-white/70 text-xs flex items-center gap-1"> + 1. Tap the <Share className="w-3 h-3 inline" /> Share button in Safari + </p> + <p className="text-white/70 text-xs"> + 2. Select "Add to Home Screen" ➕ + </p> + <Button + variant="secondary" + size="sm" + onClick={handleDismiss} + className="w-full text-xs" + > + Got it + </Button> + </div> + ) : ( + <Button + onClick={handleInstall} + size="sm" + className="w-full bg-[#0f1419] hover:bg-[#1a1f2a] text-white text-xs" + > + <Download className="w-3 h-3 mr-1" /> + Add to Home Screen + </Button> + )} + </div> + </motion.div> + </AnimatePresence> + ) +} diff --git a/apps/web/components/memory-list-view.tsx b/apps/web/components/memory-list-view.tsx new file mode 100644 index 00000000..654a3ad1 --- /dev/null +++ b/apps/web/components/memory-list-view.tsx @@ -0,0 +1,802 @@ +"use client" + +import { + GoogleDocs, + GoogleDrive, + GoogleSheets, + GoogleSlides, + MicrosoftExcel, + MicrosoftOneNote, + MicrosoftPowerpoint, + MicrosoftWord, + NotionDoc, + OneDrive, + PDF, +} from "@repo/ui/assets/icons" +import { Badge } from "@repo/ui/components/badge" +import { Card, CardContent, CardHeader } from "@repo/ui/components/card" + +import { + Sheet, + SheetContent, + SheetHeader, + SheetTitle, +} from "@repo/ui/components/sheet" +import { + Drawer, + DrawerContent, + DrawerHeader, + DrawerTitle, +} from "@repo/ui/components/drawer" +import { colors } from "@repo/ui/memory-graph/constants" +import type { DocumentsWithMemoriesResponseSchema } from "@repo/validation/api" +import { Label1Regular } from "@ui/text/label/label-1-regular" +import { Brain, Calendar, ExternalLink, FileText, Sparkles } from "lucide-react" +import { useVirtualizer } from "@tanstack/react-virtual" +import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react" +import type { z } from "zod" +import { analytics } from "@/lib/analytics" +import useResizeObserver from "@/hooks/use-resize-observer" +import { useIsMobile } from "@hooks/use-mobile" +import { cn } from "@lib/utils" + +type DocumentsResponse = z.infer<typeof DocumentsWithMemoriesResponseSchema> +type DocumentWithMemories = DocumentsResponse["documents"][0] +type MemoryEntry = DocumentWithMemories["memoryEntries"][0] + +interface MemoryListViewProps { + children?: React.ReactNode + documents: DocumentWithMemories[] + isLoading: boolean + isLoadingMore: boolean + error: Error | null + totalLoaded: number + hasMore: boolean + loadMoreDocuments: () => Promise<void> +} + +const GreetingMessage = memo(() => { + const getGreeting = () => { + const hour = new Date().getHours() + if (hour < 12) return "Good morning" + if (hour < 17) return "Good afternoon" + return "Good evening" + } + + return ( + <div className="flex items-center gap-3 mb-3 px-4 md:mb-6 md:mt-3"> + <div> + <h1 + className="text-lg md:text-xl font-semibold" + style={{ color: colors.text.primary }} + > + {getGreeting()}! + </h1> + <p + className="text-xs md:text-sm" + style={{ color: colors.text.muted }} + > + Welcome back to your memory collection + </p> + </div> + </div> + ) +}) + +const formatDate = (date: string | Date) => { + const dateObj = new Date(date) + const now = new Date() + const currentYear = now.getFullYear() + const dateYear = dateObj.getFullYear() + + const monthNames = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"] + const month = monthNames[dateObj.getMonth()] + const day = dateObj.getDate() + + const getOrdinalSuffix = (n: number) => { + const s = ["th", "st", "nd", "rd"] + const v = n % 100 + return n + (s[(v - 20) % 10] || s[v] || s[0]!) + } + + const formattedDay = getOrdinalSuffix(day) + + if (dateYear !== currentYear) { + return `${month} ${formattedDay}, ${dateYear}` + } + + return `${month} ${formattedDay}` +} + +const formatDocumentType = (type: string) => { + // Special case for PDF + if (type.toLowerCase() === "pdf") return "PDF" + + // Replace underscores with spaces and capitalize each word + return type + .split("_") + .map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()) + .join(" ") +} + +const getDocumentIcon = (type: string, className: string) => { + const iconProps = { + className, + style: { color: colors.text.muted }, + } + + switch (type) { + case "google_doc": + return <GoogleDocs {...iconProps} /> + case "google_sheet": + return <GoogleSheets {...iconProps} /> + case "google_slide": + return <GoogleSlides {...iconProps} /> + case "google_drive": + return <GoogleDrive {...iconProps} /> + case "notion": + case "notion_doc": + return <NotionDoc {...iconProps} /> + case "word": + case "microsoft_word": + return <MicrosoftWord {...iconProps} /> + case "excel": + case "microsoft_excel": + return <MicrosoftExcel {...iconProps} /> + case "powerpoint": + case "microsoft_powerpoint": + return <MicrosoftPowerpoint {...iconProps} /> + case "onenote": + case "microsoft_onenote": + return <MicrosoftOneNote {...iconProps} /> + case "onedrive": + return <OneDrive {...iconProps} /> + case "pdf": + return <PDF {...iconProps} /> + default: + return <FileText {...iconProps} /> + } +} + +const getSourceUrl = (document: DocumentWithMemories) => { + if (document.type === "google_doc" && document.customId) { + return `https://docs.google.com/document/d/${document.customId}` + } + if (document.type === "google_sheet" && document.customId) { + return `https://docs.google.com/spreadsheets/d/${document.customId}` + } + if (document.type === "google_slide" && document.customId) { + return `https://docs.google.com/presentation/d/${document.customId}` + } + // Fallback to existing URL for all other document types + return document.url +} + +const MemoryDetailItem = memo(({ memory }: { memory: MemoryEntry }) => { + return ( + <button + className="p-4 rounded-lg border transition-all relative overflow-hidden cursor-pointer" + style={{ + backgroundColor: memory.isLatest + ? colors.memory.primary + : "rgba(255, 255, 255, 0.02)", + borderColor: memory.isLatest + ? colors.memory.border + : "rgba(255, 255, 255, 0.1)", + backdropFilter: "blur(8px)", + WebkitBackdropFilter: "blur(8px)", + }} + tabIndex={0} + type="button" + > + <div className="flex items-start gap-2 relative z-10"> + <div + className="p-1 rounded" + style={{ + backgroundColor: memory.isLatest + ? colors.memory.secondary + : "transparent", + }} + > + <Brain + className={`w-4 h-4 flex-shrink-0 transition-all ${memory.isLatest ? "text-blue-400" : "text-blue-400/50" + }`} + /> + </div> + <div className="flex-1 space-y-2"> + <Label1Regular + className="text-sm leading-relaxed text-left" + style={{ color: colors.text.primary }} + > + {memory.memory} + </Label1Regular> + <div className="flex items-center gap-2 flex-wrap"> + {memory.isForgotten && ( + <Badge + className="text-xs border-red-500/30 backdrop-blur-sm" + style={{ + backgroundColor: colors.status.forgotten, + color: "#dc2626", + backdropFilter: "blur(4px)", + WebkitBackdropFilter: "blur(4px)", + }} + variant="destructive" + > + Forgotten + </Badge> + )} + {memory.isLatest && ( + <Badge + className="text-xs border-blue-400/30 backdrop-blur-sm" + style={{ + backgroundColor: colors.memory.secondary, + color: colors.accent.primary, + backdropFilter: "blur(4px)", + WebkitBackdropFilter: "blur(4px)", + }} + variant="default" + > + Latest + </Badge> + )} + {memory.forgetAfter && ( + <Badge + className="text-xs backdrop-blur-sm" + style={{ + borderColor: colors.status.expiring, + color: colors.status.expiring, + backgroundColor: "rgba(251, 165, 36, 0.1)", + backdropFilter: "blur(4px)", + WebkitBackdropFilter: "blur(4px)", + }} + variant="outline" + > + Expires: {formatDate(memory.forgetAfter)} + </Badge> + )} + </div> + <div + className="flex items-center gap-4 text-xs" + style={{ color: colors.text.muted }} + > + <span className="flex items-center gap-1"> + <Calendar className="w-3 h-3" /> + {formatDate(memory.createdAt)} + </span> + <span className="font-mono">v{memory.version}</span> + {memory.sourceRelevanceScore && ( + <span + className="flex items-center gap-1" + style={{ + color: + memory.sourceRelevanceScore > 70 + ? colors.accent.emerald + : colors.text.muted, + }} + > + <Sparkles className="w-3 h-3" /> + {memory.sourceRelevanceScore}% + </span> + )} + </div> + </div> + </div> + </button> + ) +}) + +const DocumentCard = memo( + ({ + document, + onOpenDetails, + }: { + document: DocumentWithMemories + onOpenDetails: (document: DocumentWithMemories) => void + }) => { + const activeMemories = document.memoryEntries.filter((m) => !m.isForgotten) + const forgottenMemories = document.memoryEntries.filter( + (m) => m.isForgotten, + ) + + return ( + <Card + className="h-full mx-4 p-4 transition-all cursor-pointer group relative overflow-hidden border-0 gap-2 md:w-full" + onClick={() => { + analytics.documentCardClicked() + onOpenDetails(document) + }} + style={{ + backgroundColor: colors.document.primary, + }} + > + <CardHeader className="relative z-10 px-0"> + <div className="flex items-center justify-between gap-2"> + <div className="flex items-center gap-1"> + {getDocumentIcon(document.type, "w-4 h-4 flex-shrink-0")} + <p className={cn("text-sm font-medium line-clamp-1", document.url ? "max-w-[190px]" : "max-w-[200px]")}>{document.title || "Untitled Document"}</p> + </div> + {document.url && ( + <button + className="opacity-0 group-hover:opacity-100 transition-opacity p-1 rounded" + onClick={(e) => { + e.stopPropagation() + const sourceUrl = getSourceUrl(document) + window.open(sourceUrl ?? undefined, "_blank") + }} + style={{ + backgroundColor: "rgba(255, 255, 255, 0.05)", + color: colors.text.secondary, + }} + type="button" + > + <ExternalLink className="w-3 h-3" /> + </button> + )} + <div className="flex items-center gap-2 text-[10px] text-muted-foreground"> + <span>{formatDate(document.createdAt)}</span> + </div> + </div> + </CardHeader> + <CardContent className="relative z-10 px-0"> + {document.summary && ( + <p + className="text-xs line-clamp-2 mb-3" + style={{ color: colors.text.muted }} + > + {document.summary} + </p> + )} + <div className="flex items-center gap-2 flex-wrap"> + {activeMemories.length > 0 && ( + <Badge + className="text-xs text-accent-foreground" + style={{ + backgroundColor: colors.memory.secondary, + }} + variant="secondary" + > + <Brain className="w-3 h-3 mr-1" /> + {activeMemories.length}{" "} + {activeMemories.length === 1 ? "memory" : "memories"} + </Badge> + )} + {forgottenMemories.length > 0 && ( + <Badge + className="text-xs" + style={{ + borderColor: "rgba(255, 255, 255, 0.2)", + color: colors.text.muted, + }} + variant="outline" + > + {forgottenMemories.length} forgotten + </Badge> + )} + </div> + </CardContent> + </Card> + ) + }, +) + +const DocumentDetailSheet = memo( + ({ + document, + isOpen, + onClose, + isMobile, + }: { + document: DocumentWithMemories | null + isOpen: boolean + onClose: () => void + isMobile: boolean + }) => { + if (!document) return null + + const [isSummaryExpanded, setIsSummaryExpanded] = useState(false) + const activeMemories = document.memoryEntries.filter((m) => !m.isForgotten) + const forgottenMemories = document.memoryEntries.filter( + (m) => m.isForgotten, + ) + + const HeaderContent = ({ TitleComponent }: { TitleComponent: typeof SheetTitle | typeof DrawerTitle }) => ( + <div className="flex items-start justify-between gap-2"> + <div className="flex items-start gap-3 flex-1"> + <div + className="p-2 rounded-lg" + style={{ + backgroundColor: colors.document.secondary, + border: `1px solid ${colors.document.border}`, + }} + > + {getDocumentIcon(document.type, "w-5 h-5")} + </div> + <div className="flex-1"> + <TitleComponent style={{ color: colors.text.primary }}> + {document.title || "Untitled Document"} + </TitleComponent> + <div + className="flex items-center gap-2 mt-1 text-xs" + style={{ color: colors.text.muted }} + > + <span>{formatDocumentType(document.type)}</span> + <span>•</span> + <span> + {formatDate(document.createdAt)} + </span> + {document.url && ( + <> + <span>•</span> + <button + className="flex items-center gap-1 transition-all hover:gap-2" + onClick={() => { + const sourceUrl = getSourceUrl(document) + window.open(sourceUrl ?? undefined, "_blank") + }} + style={{ color: colors.accent.primary }} + type="button" + > + View source + <ExternalLink className="w-3 h-3" /> + </button> + </> + )} + </div> + </div> + </div> + </div> + ) + + const SummarySection = () => { + if (!document.summary) return null + + const shouldShowToggle = document.summary.length > 200 // Show toggle for longer summaries + + return ( + <div + className="mt-4 p-3 rounded-lg" + style={{ + backgroundColor: "rgba(255, 255, 255, 0.03)", + border: "1px solid rgba(255, 255, 255, 0.08)", + }} + > + <p + className={`text-sm ${!isSummaryExpanded ? 'line-clamp-3' : ''}`} + style={{ color: colors.text.muted }} + > + {document.summary} + </p> + {shouldShowToggle && ( + <button + onClick={() => setIsSummaryExpanded(!isSummaryExpanded)} + className="mt-2 text-xs hover:underline transition-all" + style={{ color: colors.accent.primary }} + type="button" + > + {isSummaryExpanded ? 'Show less' : 'Show more'} + </button> + )} + </div> + ) + } + + const MemoryContent = () => ( + <div className="p-6 space-y-6"> + {activeMemories.length > 0 && ( + <div> + <div + className="text-sm font-medium mb-4 flex items-start gap-2 px-3 py-2 rounded-lg" + style={{ + color: colors.text.secondary, + backgroundColor: colors.memory.primary, + border: `1px solid ${colors.memory.border}`, + }} + > + <Brain className="w-4 h-4 text-blue-400" /> + Active Memories ({activeMemories.length}) + </div> + <div className="space-y-3"> + {activeMemories.map((memory, index) => ( + <div + className="animate-in fade-in slide-in-from-right-2" + key={memory.id} + style={{ animationDelay: `${index * 50}ms` }} + > + <MemoryDetailItem memory={memory} /> + </div> + ))} + </div> + </div> + )} + + {forgottenMemories.length > 0 && ( + <div> + <div + className="text-sm font-medium mb-4 px-3 py-2 rounded-lg opacity-60" + style={{ + color: colors.text.muted, + backgroundColor: "rgba(255, 255, 255, 0.02)", + border: "1px solid rgba(255, 255, 255, 0.08)", + }} + > + Forgotten Memories ({forgottenMemories.length}) + </div> + <div className="space-y-3 opacity-40"> + {forgottenMemories.map((memory) => ( + <MemoryDetailItem key={memory.id} memory={memory} /> + ))} + </div> + </div> + )} + + {activeMemories.length === 0 && + forgottenMemories.length === 0 && ( + <div + className="text-center py-12 rounded-lg" + style={{ + backgroundColor: "rgba(255, 255, 255, 0.02)", + border: "1px solid rgba(255, 255, 255, 0.08)", + }} + > + <Brain + className="w-12 h-12 mx-auto mb-4 opacity-30" + style={{ color: colors.text.muted }} + /> + <p style={{ color: colors.text.muted }}> + No memories found for this document + </p> + </div> + )} + </div> + ) + + if (isMobile) { + return ( + <Drawer onOpenChange={onClose} open={isOpen}> + <DrawerContent + className="border-0 p-0 overflow-hidden max-h-[90vh]" + style={{ + backgroundColor: colors.background.secondary, + borderTop: `1px solid ${colors.document.border}`, + backdropFilter: "blur(20px)", + WebkitBackdropFilter: "blur(20px)", + }} + > + {/* Header section with glass effect */} + <div + className="p-4 relative border-b" + style={{ + backgroundColor: "rgba(255, 255, 255, 0.02)", + borderBottom: `1px solid ${colors.document.border}`, + }} + > + <DrawerHeader className="pb-0 px-0 text-left"> + <HeaderContent TitleComponent={DrawerTitle} /> + </DrawerHeader> + + <SummarySection /> + </div> + + <div className="flex-1 memory-drawer-scroll overflow-y-auto"> + <MemoryContent /> + </div> + </DrawerContent> + </Drawer> + ) + } + + return ( + <Sheet onOpenChange={onClose} open={isOpen}> + <SheetContent + className="w-full sm:max-w-2xl border-0 p-0 overflow-hidden" + style={{ + backgroundColor: colors.background.secondary, + borderLeft: `1px solid ${colors.document.border}`, + backdropFilter: "blur(20px)", + WebkitBackdropFilter: "blur(20px)", + }} + > + {/* Header section with glass effect */} + <div + className="p-6 relative" + style={{ + backgroundColor: "rgba(255, 255, 255, 0.02)", + borderBottom: `1px solid ${colors.document.border}`, + }} + > + <SheetHeader className="pb-0"> + <HeaderContent TitleComponent={SheetTitle} /> + </SheetHeader> + + <SummarySection /> + </div> + + <div className="h-[calc(100vh-200px)] memory-sheet-scroll overflow-y-auto"> + <MemoryContent /> + </div> + </SheetContent> + </Sheet> + ) + }, +) + +export const MemoryListView = ({ + children, + documents, + isLoading, + isLoadingMore, + error, + hasMore, + loadMoreDocuments, +}: MemoryListViewProps) => { + const [selectedSpace, _] = useState<string>("all") + const [selectedDocument, setSelectedDocument] = + useState<DocumentWithMemories | null>(null) + const [isDetailOpen, setIsDetailOpen] = useState(false) + const parentRef = useRef<HTMLDivElement>(null) + const containerRef = useRef<HTMLDivElement>(null) + const isMobile = useIsMobile() + + const gap = 14 + + const { width: containerWidth } = useResizeObserver(containerRef); + const columnWidth = isMobile ? containerWidth : 320 + const columns = Math.max(1, Math.floor((containerWidth + gap) / (columnWidth + gap))) + + // Filter documents based on selected space + const filteredDocuments = useMemo(() => { + if (!documents) return [] + + if (selectedSpace === "all") { + return documents + } + + return documents + .map((doc) => ({ + ...doc, + memoryEntries: doc.memoryEntries.filter( + (memory) => + (memory.spaceContainerTag ?? memory.spaceId) === selectedSpace, + ), + })) + .filter((doc) => doc.memoryEntries.length > 0) + }, [documents, selectedSpace]) + + const handleOpenDetails = useCallback((document: DocumentWithMemories) => { + analytics.memoryDetailOpened() + setSelectedDocument(document) + setIsDetailOpen(true) + }, []) + + const handleCloseDetails = useCallback(() => { + setIsDetailOpen(false) + setTimeout(() => setSelectedDocument(null), 300) + }, []) + + const virtualItems = useMemo(() => { + const items = [] + for (let i = 0; i < filteredDocuments.length; i += columns) { + items.push(filteredDocuments.slice(i, i + columns)) + } + return items + }, [filteredDocuments, columns]) + + const virtualizer = useVirtualizer({ + count: virtualItems.length, + getScrollElement: () => parentRef.current, + overscan: 5, + estimateSize: () => 200, + }) + + useEffect(() => { + const [lastItem] = [...virtualizer.getVirtualItems()].reverse() + + if (!lastItem || !hasMore || isLoadingMore) { + return + } + + if (lastItem.index >= virtualItems.length - 1) { + loadMoreDocuments() + } + }, [hasMore, isLoadingMore, loadMoreDocuments, virtualizer.getVirtualItems(), virtualItems.length]) + + // Always render with consistent structure + return ( + <> + <div + className="h-full overflow-hidden relative pb-20" + style={{ backgroundColor: colors.background.primary }} + ref={containerRef} + > + {error ? ( + <div className="h-full flex items-center justify-center p-4"> + <div className="rounded-xl overflow-hidden"> + <div + className="relative z-10 px-6 py-4" + style={{ color: colors.text.primary }} + > + Error loading documents: {error.message} + </div> + </div> + </div> + ) : isLoading ? ( + <div className="h-full flex items-center justify-center p-4"> + <div className="rounded-xl overflow-hidden"> + <div + className="relative z-10 px-6 py-4" + style={{ color: colors.text.primary }} + > + <div className="flex items-center gap-2"> + <Sparkles className="w-4 h-4 animate-spin text-blue-400" /> + <span>Loading memory list...</span> + </div> + </div> + </div> + </div> + ) : filteredDocuments.length === 0 && !isLoading ? ( + <div className="h-full flex items-center justify-center p-4"> + {children} + </div> + ) : ( + <div + ref={parentRef} + className="h-full overflow-auto mt-20 custom-scrollbar" + > + <GreetingMessage /> + + <div + className="w-full relative" + style={{ height: `${virtualizer.getTotalSize() + (virtualItems.length * gap)}px` }} + > + {virtualizer.getVirtualItems().map((virtualRow) => { + const rowItems = virtualItems[virtualRow.index] + if (!rowItems) return null + + return ( + <div + key={virtualRow.key} + data-index={virtualRow.index} + ref={virtualizer.measureElement} + className="absolute top-0 left-0 w-full" + style={{ transform: `translateY(${virtualRow.start + (virtualRow.index * gap)}px)` }} + > + <div + className="grid justify-start" + style={{ gridTemplateColumns: `repeat(${columns}, ${columnWidth}px)`, gap: `${gap}px` }} + > + {rowItems.map((document) => ( + <DocumentCard + key={document.id} + document={document} + onOpenDetails={handleOpenDetails} + /> + ))} + </div> + </div> + ) + })} + </div> + + {isLoadingMore && ( + <div className="py-8 flex items-center justify-center"> + <div className="flex items-center gap-2"> + <Sparkles className="w-4 h-4 animate-spin text-blue-400" /> + <span style={{ color: colors.text.primary }}> + Loading more memories... + </span> + </div> + </div> + )} + </div> + )} + </div> + + <DocumentDetailSheet + document={selectedDocument} + isOpen={isDetailOpen} + onClose={handleCloseDetails} + isMobile={isMobile} + /> + </> + ) +} diff --git a/apps/web/components/menu.tsx b/apps/web/components/menu.tsx new file mode 100644 index 00000000..622b94b1 --- /dev/null +++ b/apps/web/components/menu.tsx @@ -0,0 +1,618 @@ +"use client" + +import { useIsMobile } from "@hooks/use-mobile" +import { + fetchConsumerProProduct, + fetchMemoriesFeature, +} from "@repo/lib/queries" +import { Button } from "@repo/ui/components/button" +import { HeadingH2Bold } from "@repo/ui/text/heading/heading-h2-bold" +import { GlassMenuEffect } from "@ui/other/glass-effect" +import { useCustomer } from "autumn-js/react" +import { + MessageSquareMore, + Plus, + User, + X, +} from "lucide-react" +import { AnimatePresence, LayoutGroup, motion } from "motion/react" +import { useEffect, useState } from "react" +import { useMobilePanel } from "@/lib/mobile-panel-context" +import { TOUR_STEP_IDS } from "@/lib/tour-constants" +import { ProjectSelector } from "./project-selector" +import { useTour } from "./tour" +import { AddMemoryExpandedView, AddMemoryView } from "./views/add-memory" +import { MCPView } from "./views/mcp" +import { ProfileView } from "./views/profile" +import { useChatOpen } from "@/stores" +import { Drawer } from "vaul" + +const MCPIcon = ({ className }: { className?: string }) => { + return ( + <svg + className={className} + fill="currentColor" + fillRule="evenodd" + viewBox="0 0 24 24" + xmlns="http://www.w3.org/2000/svg" + > + <title>ModelContextProtocol</title> + <path d="M15.688 2.343a2.588 2.588 0 00-3.61 0l-9.626 9.44a.863.863 0 01-1.203 0 .823.823 0 010-1.18l9.626-9.44a4.313 4.313 0 016.016 0 4.116 4.116 0 011.204 3.54 4.3 4.3 0 013.609 1.18l.05.05a4.115 4.115 0 010 5.9l-8.706 8.537a.274.274 0 000 .393l1.788 1.754a.823.823 0 010 1.18.863.863 0 01-1.203 0l-1.788-1.753a1.92 1.92 0 010-2.754l8.706-8.538a2.47 2.47 0 000-3.54l-.05-.049a2.588 2.588 0 00-3.607-.003l-7.172 7.034-.002.002-.098.097a.863.863 0 01-1.204 0 .823.823 0 010-1.18l7.273-7.133a2.47 2.47 0 00-.003-3.537z" /> + <path d="M14.485 4.703a.823.823 0 000-1.18.863.863 0 00-1.204 0l-7.119 6.982a4.115 4.115 0 000 5.9 4.314 4.314 0 006.016 0l7.12-6.982a.823.823 0 000-1.18.863.863 0 00-1.204 0l-7.119 6.982a2.588 2.588 0 01-3.61 0 2.47 2.47 0 010-3.54l7.12-6.982z" /> + </svg> + ) +} + +function Menu({ id }: { id?: string }) { + const [isHovered, setIsHovered] = useState(false) + const [expandedView, setExpandedView] = useState< + "addUrl" | "mcp" | "projects" | "profile" | null + >(null) + const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false) + const [isCollapsing, setIsCollapsing] = useState(false) + const [showAddMemoryView, setShowAddMemoryView] = useState(false) + const isMobile = useIsMobile() + const { activePanel, setActivePanel } = useMobilePanel() + const { setMenuExpanded } = useTour() + const autumn = useCustomer() + const { setIsOpen } = useChatOpen() + + const { data: memoriesCheck } = fetchMemoriesFeature(autumn as any) + + const memoriesUsed = memoriesCheck?.usage ?? 0 + const memoriesLimit = memoriesCheck?.included_usage ?? 0 + + const { data: proCheck } = fetchConsumerProProduct(autumn as any) + + useEffect(() => { + if (memoriesCheck) { + console.log({ memoriesCheck }) + } + + if (proCheck) { + console.log({ proCheck }) + } + }, [memoriesCheck, proCheck]) + + const isProUser = proCheck?.allowed ?? false + + const shouldShowLimitWarning = + !isProUser && memoriesUsed >= memoriesLimit * 0.8 + + // Map menu item keys to tour IDs + const menuItemTourIds: Record<string, string> = { + addUrl: TOUR_STEP_IDS.MENU_ADD_MEMORY, + projects: TOUR_STEP_IDS.MENU_PROJECTS, + mcp: TOUR_STEP_IDS.MENU_MCP, + } + + const menuItems = [ + { + icon: Plus, + text: "Add Memory", + key: "addUrl" as const, + disabled: false, + }, + { + icon: MessageSquareMore, + text: "Chat", + key: "chat" as const, + disabled: false, + }, + { + icon: MCPIcon, + text: "MCP", + key: "mcp" as const, + disabled: false, + }, + { + icon: User, + text: "Profile", + key: "profile" as const, + disabled: false, + }, + ] + + const handleMenuItemClick = ( + key: "chat" | "addUrl" | "mcp" | "projects" | "profile", + ) => { + if (key === "chat") { + setIsOpen(true) + setIsMobileMenuOpen(false) + if (isMobile) { + setActivePanel("chat") + } + } else { + if (expandedView === key) { + setIsCollapsing(true) + setExpandedView(null) + } else if (key === "addUrl") { + setShowAddMemoryView(true) + setExpandedView(null) + } else { + setExpandedView(key) + } + if (isMobile) { + setActivePanel("menu") + } + } + } + + // Watch for active panel changes on mobile + useEffect(() => { + if (isMobile && activePanel !== "menu" && activePanel !== null) { + // Another panel became active, close the menu + setIsMobileMenuOpen(false) + setExpandedView(null) + } + }, [isMobile, activePanel]) + + // Notify tour provider about expansion state changes + useEffect(() => { + const isExpanded = isMobile + ? isMobileMenuOpen || !!expandedView + : isHovered || !!expandedView + setMenuExpanded(isExpanded) + }, [isMobile, isMobileMenuOpen, isHovered, expandedView, setMenuExpanded]) + + // Calculate width based on state + const menuWidth = expandedView || isCollapsing ? 600 : isHovered ? 160 : 56 + + // Dynamic z-index for mobile based on active panel + const mobileZIndex = isMobile && activePanel === "menu" ? "z-[70]" : "z-[100]" + + return ( + <> + {/* Desktop Menu */} + {!isMobile && ( + <LayoutGroup> + <div className="fixed h-screen w-full p-4 items-center top-0 left-0 pointer-events-none z-[60] flex"> + <motion.nav + animate={{ + width: menuWidth, + scale: 1, + }} + className="pointer-events-auto group relative flex text-sm font-medium flex-col items-start overflow-hidden rounded-3xl shadow-2xl" + id={id} + initial={{ width: 56, scale: 0.95 }} + layout + onMouseEnter={() => !expandedView && setIsHovered(true)} + onMouseLeave={() => !expandedView && setIsHovered(false)} + transition={{ + width: { + duration: 0.2, + ease: [0.4, 0, 0.2, 1], + }, + scale: { + duration: 0.5, + ease: [0.4, 0, 0.2, 1], + }, + layout: { + duration: 0.2, + ease: [0.4, 0, 0.2, 1], + }, + }} + > + {/* Glass effect background */} + <motion.div className="absolute inset-0" layout> + <GlassMenuEffect /> + </motion.div> + + {/* Menu content */} + <motion.div + className="relative z-20 flex flex-col gap-6 w-full" + layout + > + <AnimatePresence + initial={false} + mode="wait" + onExitComplete={() => setIsCollapsing(false)} + > + {!expandedView ? ( + <motion.div + animate={{ + opacity: 1, + }} + className="w-full flex flex-col gap-6 p-4" + exit={{ + opacity: 0, + transition: { + duration: 0.2, + ease: "easeOut", + }, + }} + initial={{ + opacity: 0, + }} + key="menu-items" + layout + style={{ + transform: "translateZ(0)", + willChange: "opacity", + }} + transition={{ + opacity: { + duration: 0.15, + ease: "easeInOut", + }, + }} + > + <div className="flex flex-col gap-6"> + {menuItems.map((item, index) => ( + <div key={item.key}> + <motion.button + animate={{ + opacity: 1, + y: 0, + scale: 1, + transition: { + duration: 0.1, + }, + }} + className={`flex w-full items-center text-white/80 transition-colors duration-100 hover:text-white cursor-pointer relative ${isHovered || expandedView ? "px-1" : ""}`} + id={menuItemTourIds[item.key]} + initial={{ opacity: 0, y: 20, scale: 0.95 }} + layout + onClick={() => handleMenuItemClick(item.key)} + type="button" + whileHover={{ + scale: 1.02, + transition: { duration: 0.1 }, + }} + whileTap={{ scale: 0.98 }} + > + <motion.div + animate={{ + scale: 1, + transition: { + delay: expandedView === null ? 0.15 : 0, + duration: 0.1, + }, + }} + initial={{ scale: 0.8 }} + layout="position" + > + <item.icon className="duration-200 h-6 w-6 drop-shadow-lg flex-shrink-0" /> + </motion.div> + <motion.p + animate={{ + opacity: isHovered ? 1 : 0, + x: isHovered ? 0 : -10, + }} + className="drop-shadow-lg absolute left-10 whitespace-nowrap flex items-center gap-2" + initial={{ opacity: 0, x: -10 }} + style={{ + transform: "translateZ(0)", + }} + transition={{ + duration: 0.3, + delay: index * 0.03, + ease: [0.4, 0, 0.2, 1], + }} + > + {item.text} + {/* Show warning indicator for Add Memory when limits approached */} + {shouldShowLimitWarning && + item.key === "addUrl" && ( + <span className="text-xs bg-yellow-500/20 text-yellow-400 px-1.5 py-0.5 rounded"> + {memoriesLimit - memoriesUsed} left + </span> + )} + </motion.p> + </motion.button> + {index === 0 && ( + <motion.div + animate={{ + opacity: 1, + scaleX: 1, + }} + className="w-full h-px bg-white/20 mt-3 origin-left" + initial={{ opacity: 0, scaleX: 0 }} + transition={{ + duration: 0.3, + delay: 0.1, + ease: [0.4, 0, 0.2, 1], + }} + /> + )} + </div> + ))} + </div> + </motion.div> + ) : ( + <motion.div + animate={{ + opacity: 1, + }} + className="w-full p-4" + exit={{ + opacity: 0, + transition: { + duration: 0.2, + ease: "easeOut", + }, + }} + initial={{ + opacity: 0, + }} + key="expanded-view" + layout + style={{ + transform: "translateZ(0)", + willChange: "opacity, transform", + }} + transition={{ + opacity: { + duration: 0.15, + ease: "easeInOut", + }, + }} + > + <motion.div + animate={{ opacity: 1, y: 0 }} + className="flex items-center justify-between mb-4" + initial={{ opacity: 0, y: -10 }} + layout + transition={{ + delay: 0.05, + duration: 0.2, + ease: [0.4, 0, 0.2, 1], + }} + > + <HeadingH2Bold className="text-white"> + {expandedView === "mcp" && "Model Context Protocol"} + {expandedView === "profile" && "Profile"} + </HeadingH2Bold> + <motion.div + animate={{ opacity: 1, scale: 1 }} + initial={{ opacity: 0, scale: 0.8 }} + transition={{ + delay: 0.08, + duration: 0.2, + }} + > + <Button + className="text-white/70 hover:text-white transition-colors duration-200" + onClick={() => { + setIsCollapsing(true) + setExpandedView(null) + }} + size="icon" + variant="ghost" + > + <X className="h-5 w-5" /> + </Button> + </motion.div> + </motion.div> + <motion.div + animate={{ opacity: 1, y: 0 }} + className="max-h-[70vh] overflow-y-auto pr-2" + initial={{ opacity: 0, y: 10 }} + transition={{ + delay: 0.1, + duration: 0.25, + ease: [0.4, 0, 0.2, 1], + }} + > + {expandedView === "mcp" && <MCPView />} + {expandedView === "profile" && <ProfileView />} + </motion.div> + </motion.div> + )} + </AnimatePresence> + </motion.div> + </motion.nav> + </div> + </LayoutGroup> + )} + + {/* Mobile Menu with Vaul Drawer */} + {isMobile && ( + <Drawer.Root + open={isMobileMenuOpen || !!expandedView} + onOpenChange={(open) => { + if (!open) { + setIsMobileMenuOpen(false) + setExpandedView(null) + setActivePanel(null) + } + }} + > + {/* Menu Trigger Button */} + {!isMobileMenuOpen && !expandedView && ( + <Drawer.Trigger asChild> + <div className={`fixed bottom-8 right-6 z-100 ${mobileZIndex}`}> + <motion.button + animate={{ scale: 1, opacity: 1 }} + className="w-14 h-14 flex items-center justify-center text-white rounded-full shadow-2xl" + initial={{ scale: 0.8, opacity: 0 }} + onClick={() => { + setIsMobileMenuOpen(true) + setActivePanel("menu") + }} + transition={{ + duration: 0.3, + ease: [0.4, 0, 0.2, 1], + }} + type="button" + whileHover={{ scale: 1.05 }} + whileTap={{ scale: 0.95 }} + > + {/* Glass effect background */} + <div className="absolute inset-0 rounded-full"> + <GlassMenuEffect rounded="rounded-full" /> + </div> + <svg + className="h-6 w-6 relative z-10" + fill="none" + stroke="currentColor" + strokeWidth={2} + viewBox="0 0 24 24" + > + <title>Open menu</title> + <path + d="M4 6h16M4 12h16M4 18h16" + strokeLinecap="round" + strokeLinejoin="round" + /> + </svg> + </motion.button> + </div> + </Drawer.Trigger> + )} + + <Drawer.Portal> + <Drawer.Overlay className="fixed inset-0 bg-black/40 z-[60]" /> + <Drawer.Content className="bg-transparent fixed bottom-0 left-0 right-0 z-[70] outline-none"> + <div className="w-full flex flex-col text-sm font-medium shadow-2xl relative overflow-hidden rounded-t-3xl max-h-[80vh]"> + {/* Glass effect background */} + <div className="absolute inset-0 rounded-t-3xl"> + <GlassMenuEffect rounded="rounded-t-3xl" /> + </div> + + {/* Drag Handle */} + <div className="relative z-20 flex justify-center py-3"> + <div className="w-12 h-1 bg-white/30 rounded-full" /> + </div> + + {/* Menu content */} + <div className="relative z-20 flex flex-col w-full px-6 pb-8"> + <AnimatePresence + initial={false} + mode="wait" + onExitComplete={() => setIsCollapsing(false)} + > + {!expandedView ? ( + <motion.div + animate={{ opacity: 1 }} + className="w-full flex flex-col gap-6" + exit={{ opacity: 0 }} + initial={{ opacity: 0 }} + key="menu-items-mobile" + layout + > + <motion.div + animate={{ opacity: 1, y: 0 }} + initial={{ opacity: 0, y: -10 }} + transition={{ delay: 0.08 }} + > + <ProjectSelector /> + </motion.div> + + {/* Menu Items */} + <div className="flex flex-col gap-3"> + {menuItems.map((item, index) => ( + <div key={item.key}> + <motion.button + animate={{ + opacity: 1, + y: 0, + transition: { + delay: 0.1 + index * 0.05, + duration: 0.3, + ease: "easeOut", + }, + }} + className="flex w-full items-center gap-3 px-2 py-2 text-white/90 hover:text-white hover:bg-white/10 rounded-lg cursor-pointer relative" + id={menuItemTourIds[item.key]} + initial={{ opacity: 0, y: 10 }} + layout + onClick={() => { + handleMenuItemClick(item.key) + if (item.key !== "mcp" && item.key !== "profile") { + setIsMobileMenuOpen(false) + } + }} + type="button" + whileHover={{ scale: 1.05 }} + whileTap={{ scale: 0.95 }} + > + <item.icon className="h-5 w-5 drop-shadow-lg flex-shrink-0" /> + <span className="drop-shadow-lg text-sm font-medium flex-1 text-left"> + {item.text} + </span> + {/* Show warning indicator for Add Memory when limits approached */} + {shouldShowLimitWarning && + item.key === "addUrl" && ( + <span className="text-xs bg-yellow-500/20 text-yellow-400 px-1.5 py-0.5 rounded"> + {memoriesLimit - memoriesUsed} left + </span> + )} + </motion.button> + {/* Add horizontal line after first item */} + {index === 0 && ( + <motion.div + animate={{ + opacity: 1, + scaleX: 1, + }} + className="w-full h-px bg-white/20 mt-2 origin-left" + initial={{ opacity: 0, scaleX: 0 }} + transition={{ + duration: 0.3, + delay: 0.15 + index * 0.05, + ease: [0.4, 0, 0.2, 1], + }} + /> + )} + </div> + ))} + </div> + </motion.div> + ) : ( + <motion.div + animate={{ opacity: 1 }} + className="w-full p-2 flex flex-col" + exit={{ opacity: 0 }} + initial={{ opacity: 0 }} + key="expanded-view-mobile" + layout + > + <div className="flex-1"> + <motion.div className="mb-4 flex items-center justify-between" layout> + <HeadingH2Bold className="text-white"> + {expandedView === "addUrl" && "Add Memory"} + {expandedView === "mcp" && "Model Context Protocol"} + {expandedView === "profile" && "Profile"} + </HeadingH2Bold> + <Button + className="text-white/70 hover:text-white transition-colors duration-200" + onClick={() => { + setIsCollapsing(true) + setExpandedView(null) + }} + size="icon" + variant="ghost" + > + <X className="h-5 w-5" /> + </Button> + </motion.div> + <div className="max-h-[60vh] overflow-y-auto pr-1"> + {expandedView === "addUrl" && ( + <AddMemoryExpandedView /> + )} + {expandedView === "mcp" && <MCPView />} + {expandedView === "profile" && <ProfileView />} + </div> + </div> + </motion.div> + )} + </AnimatePresence> + </div> + </div> + </Drawer.Content> + </Drawer.Portal> + </Drawer.Root> + )} + + {showAddMemoryView && ( + <AddMemoryView + initialTab="note" + onClose={() => setShowAddMemoryView(false)} + /> + )} + </> + ) +} + +export default Menu diff --git a/apps/web/components/project-selector.tsx b/apps/web/components/project-selector.tsx new file mode 100644 index 00000000..bb62d8ca --- /dev/null +++ b/apps/web/components/project-selector.tsx @@ -0,0 +1,566 @@ +"use client" + +import { $fetch } from "@repo/lib/api" +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query" +import { ChevronDown, FolderIcon, Plus, Trash2, Loader2, MoreVertical, MoreHorizontal } from "lucide-react" +import { motion, AnimatePresence } from "motion/react" +import { useState } from "react" +import { toast } from "sonner" +import { CreateProjectDialog } from "./create-project-dialog" +import { useProject } from "@/stores" +import { useProjectName } from "@/hooks/use-project-name" +import { useProjectMutations } from "@/hooks/use-project-mutations" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@repo/ui/components/dropdown-menu" +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@repo/ui/components/dialog" +import { Button } from "@repo/ui/components/button" +import { Label } from "@repo/ui/components/label" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@repo/ui/components/select" +import { DEFAULT_PROJECT_ID } from "@repo/lib/constants" + +interface Project { + id: string + name: string + containerTag: string + createdAt: string + updatedAt: string + isExperimental?: boolean +} + +export function ProjectSelector() { + const queryClient = useQueryClient() + const [isOpen, setIsOpen] = useState(false) + const [showCreateDialog, setShowCreateDialog] = useState(false) + const { selectedProject } = useProject() + const projectName = useProjectName() + const { switchProject, deleteProjectMutation } = useProjectMutations() + const [deleteDialog, setDeleteDialog] = useState<{ + open: boolean + project: null | { id: string; name: string; containerTag: string } + action: "move" | "delete" + targetProjectId: string + }>({ + open: false, + project: null, + action: "move", + targetProjectId: DEFAULT_PROJECT_ID, + }) + const [expDialog, setExpDialog] = useState<{ + open: boolean + projectId: string + }>({ + open: false, + projectId: "", + }) + + const { data: projects = [], isLoading } = useQuery({ + queryKey: ["projects"], + queryFn: async () => { + const response = await $fetch("@get/projects") + + if (response.error) { + throw new Error(response.error?.message || "Failed to load projects") + } + + return response.data?.projects || [] + }, + staleTime: 30 * 1000, + }) + + const enableExperimentalMutation = useMutation({ + mutationFn: async (projectId: string) => { + const response = await $fetch(`@post/projects/${projectId}/enable-experimental`) + if (response.error) { + throw new Error(response.error?.message || "Failed to enable experimental mode") + } + return response.data + }, + onSuccess: () => { + toast.success("Experimental mode enabled for project") + queryClient.invalidateQueries({ queryKey: ["projects"] }) + setExpDialog({ open: false, projectId: "" }) + }, + onError: (error) => { + toast.error("Failed to enable experimental mode", { + description: error instanceof Error ? error.message : "Unknown error", + }) + }, + }) + + const handleProjectSelect = (containerTag: string) => { + switchProject(containerTag) + setIsOpen(false) + } + + const handleCreateNewProject = () => { + setIsOpen(false) + setShowCreateDialog(true) + } + + return ( + <div className="relative"> + <motion.button + className="flex items-center gap-1.5 px-2 py-1.5 rounded-md bg-white/5 hover:bg-white/10 transition-colors" + onClick={() => setIsOpen(!isOpen)} + whileHover={{ scale: 1.01 }} + whileTap={{ scale: 0.99 }} + > + <FolderIcon className="h-3.5 w-3.5 text-white/70" /> + <span className="text-xs font-medium text-white/90 max-w-32 truncate"> + {isLoading ? "..." : projectName} + </span> + <motion.div + animate={{ rotate: isOpen ? 180 : 0 }} + transition={{ duration: 0.15 }} + > + <ChevronDown className="h-3 w-3 text-white/50" /> + </motion.div> + </motion.button> + + <AnimatePresence> + {isOpen && ( + <> + <motion.div + className="fixed inset-0 z-40" + initial={{ opacity: 0 }} + animate={{ opacity: 1 }} + exit={{ opacity: 0 }} + onClick={() => setIsOpen(false)} + /> + + <motion.div + className="absolute top-full left-0 mt-1 w-56 bg-[#0f1419] backdrop-blur-xl border border-white/10 rounded-md shadow-xl z-50 overflow-hidden" + initial={{ opacity: 0, y: -5, scale: 0.98 }} + animate={{ opacity: 1, y: 0, scale: 1 }} + exit={{ opacity: 0, y: -5, scale: 0.98 }} + transition={{ duration: 0.15 }} + > + <div className="p-1.5 max-h-64 overflow-y-auto"> + {/* Default Project */} + <motion.div + className={`flex items-center justify-between p-2 rounded-md transition-colors cursor-pointer ${ + selectedProject === DEFAULT_PROJECT_ID + ? "bg-white/15" + : "hover:bg-white/8" + }`} + onClick={() => handleProjectSelect(DEFAULT_PROJECT_ID)} + > + <div className="flex items-center gap-2"> + <FolderIcon className="h-3.5 w-3.5 text-white/70" /> + <span className="text-xs font-medium text-white">Default</span> + </div> + </motion.div> + + {/* User Projects */} + {projects + .filter((p: Project) => p.containerTag !== DEFAULT_PROJECT_ID) + .map((project: Project, index: number) => ( + <motion.div + key={project.id} + className={`flex items-center justify-between p-2 rounded-md transition-colors group ${ + selectedProject === project.containerTag + ? "bg-white/15" + : "hover:bg-white/8" + }`} + initial={{ opacity: 0, x: -5 }} + animate={{ opacity: 1, x: 0 }} + transition={{ delay: index * 0.03 }} + > + <div + className="flex items-center gap-2 flex-1 cursor-pointer" + onClick={() => handleProjectSelect(project.containerTag)} + > + <FolderIcon className="h-3.5 w-3.5 text-white/70" /> + <span className="text-xs font-medium text-white truncate max-w-32"> + {project.name} + </span> + </div> + <div className="flex items-center gap-1"> + <DropdownMenu> + <DropdownMenuTrigger asChild> + <motion.button + className="p-1 hover:bg-white/10 rounded transition-all" + onClick={(e) => e.stopPropagation()} + whileHover={{ scale: 1.1 }} + whileTap={{ scale: 0.9 }} + > + <MoreHorizontal className="h-3 w-3 text-white/50" /> + </motion.button> + </DropdownMenuTrigger> + <DropdownMenuContent + align="end" + className="bg-black/90 border-white/10" + > + {/* Show experimental toggle only if NOT experimental and NOT default project */} + {!project.isExperimental && + project.containerTag !== DEFAULT_PROJECT_ID && ( + <DropdownMenuItem + className="text-blue-400 hover:text-blue-300 cursor-pointer text-xs" + onClick={(e) => { + e.stopPropagation() + setExpDialog({ + open: true, + projectId: project.id, + }) + setIsOpen(false) + }} + > + <div className="h-3 w-3 mr-2 rounded border border-blue-400" /> + Enable Experimental Mode + </DropdownMenuItem> + )} + {project.isExperimental && ( + <DropdownMenuItem + className="text-blue-300/50 text-xs" + disabled + > + <div className="h-3 w-3 mr-2 rounded bg-blue-400" /> + Experimental Mode Active + </DropdownMenuItem> + )} + <DropdownMenuItem + className="text-red-400 hover:text-red-300 cursor-pointer text-xs" + onClick={(e) => { + e.stopPropagation() + setDeleteDialog({ + open: true, + project: { + id: project.id, + name: project.name, + containerTag: project.containerTag, + }, + action: "move", + targetProjectId: "", + }) + setIsOpen(false) + }} + > + <Trash2 className="h-3 w-3 mr-2" /> + Delete Project + </DropdownMenuItem> + </DropdownMenuContent> + </DropdownMenu> + </div> + </motion.div> + ))} + + <motion.div + className="flex items-center gap-2 p-2 rounded-md hover:bg-white/8 transition-colors cursor-pointer border-t border-white/10 mt-1" + onClick={handleCreateNewProject} + whileHover={{ x: 1 }} + initial={{ opacity: 0 }} + animate={{ opacity: 1 }} + transition={{ delay: (projects.length + 1) * 0.03 }} + > + <Plus className="h-3.5 w-3.5 text-white/70" /> + <span className="text-xs font-medium text-white/80">New Project</span> + </motion.div> + </div> + </motion.div> + </> + )} + </AnimatePresence> + + <CreateProjectDialog + open={showCreateDialog} + onOpenChange={setShowCreateDialog} + /> + + {/* Delete Project Dialog */} + <AnimatePresence> + {deleteDialog.open && deleteDialog.project && ( + <Dialog + onOpenChange={(open) => + setDeleteDialog((prev) => ({ ...prev, open })) + } + open={deleteDialog.open} + > + <DialogContent className="sm:max-w-2xl bg-black/90 backdrop-blur-xl border-white/10 text-white"> + <motion.div + animate={{ opacity: 1, scale: 1 }} + exit={{ opacity: 0, scale: 0.95 }} + initial={{ opacity: 0, scale: 0.95 }} + > + <DialogHeader> + <DialogTitle>Delete Project</DialogTitle> + <DialogDescription className="text-white/60"> + Are you sure you want to delete "{deleteDialog.project.name}"? + Choose what to do with the documents in this project. + </DialogDescription> + </DialogHeader> + <div className="grid gap-4 py-4"> + <div className="space-y-4"> + <div className="flex items-center space-x-2"> + <input + checked={deleteDialog.action === "move"} + className="w-4 h-4" + id="move" + name="action" + onChange={() => + setDeleteDialog((prev) => ({ + ...prev, + action: "move", + })) + } + type="radio" + /> + <Label + className="text-white cursor-pointer text-sm" + htmlFor="move" + > + Move documents to another project + </Label> + </div> + {deleteDialog.action === "move" && ( + <motion.div + animate={{ opacity: 1, height: "auto" }} + className="ml-6" + exit={{ opacity: 0, height: 0 }} + initial={{ opacity: 0, height: 0 }} + > + <Select + onValueChange={(value) => + setDeleteDialog((prev) => ({ + ...prev, + targetProjectId: value, + })) + } + value={deleteDialog.targetProjectId} + > + <SelectTrigger className="w-full bg-white/5 border-white/10 text-white"> + <SelectValue placeholder="Select target project..." /> + </SelectTrigger> + <SelectContent className="bg-black/90 backdrop-blur-xl border-white/10"> + <SelectItem + className="text-white hover:bg-white/10" + value={DEFAULT_PROJECT_ID} + > + Default Project + </SelectItem> + {projects + .filter( + (p: Project) => + p.id !== deleteDialog.project?.id && + p.containerTag !== DEFAULT_PROJECT_ID, + ) + .map((project: Project) => ( + <SelectItem + className="text-white hover:bg-white/10" + key={project.id} + value={project.id} + > + {project.name} + </SelectItem> + ))} + </SelectContent> + </Select> + </motion.div> + )} + <div className="flex items-center space-x-2"> + <input + checked={deleteDialog.action === "delete"} + className="w-4 h-4" + id="delete" + name="action" + onChange={() => + setDeleteDialog((prev) => ({ + ...prev, + action: "delete", + })) + } + type="radio" + /> + <Label + className="text-white cursor-pointer text-sm" + htmlFor="delete" + > + Delete all documents in this project + </Label> + </div> + {deleteDialog.action === "delete" && ( + <motion.p + animate={{ opacity: 1 }} + className="text-sm text-red-400 ml-6" + initial={{ opacity: 0 }} + > + ⚠️ This action cannot be undone. All documents will be + permanently deleted. + </motion.p> + )} + </div> + </div> + <DialogFooter> + <motion.div + whileHover={{ scale: 1.05 }} + whileTap={{ scale: 0.95 }} + > + <Button + className="bg-white/5 hover:bg-white/10 border-white/10 text-white" + onClick={() => + setDeleteDialog({ + open: false, + project: null, + action: "move", + targetProjectId: "", + }) + } + type="button" + variant="outline" + > + Cancel + </Button> + </motion.div> + <motion.div + whileHover={{ scale: 1.05 }} + whileTap={{ scale: 0.95 }} + > + <Button + className={`${deleteDialog.action === "delete" + ? "bg-red-600 hover:bg-red-700" + : "bg-white/10 hover:bg-white/20" + } text-white border-white/20`} + disabled={ + deleteProjectMutation.isPending || + (deleteDialog.action === "move" && + !deleteDialog.targetProjectId) + } + onClick={() => { + if (deleteDialog.project) { + deleteProjectMutation.mutate({ + projectId: deleteDialog.project.id, + action: deleteDialog.action, + targetProjectId: + deleteDialog.action === "move" + ? deleteDialog.targetProjectId + : undefined, + }, { + onSuccess: () => { + setDeleteDialog({ + open: false, + project: null, + action: "move", + targetProjectId: "", + }) + } + }) + } + }} + type="button" + > + {deleteProjectMutation.isPending ? ( + <> + <Loader2 className="h-4 w-4 animate-spin mr-2" /> + {deleteDialog.action === "move" + ? "Moving..." + : "Deleting..."} + </> + ) : deleteDialog.action === "move" ? ( + "Move & Delete Project" + ) : ( + "Delete Everything" + )} + </Button> + </motion.div> + </DialogFooter> + </motion.div> + </DialogContent> + </Dialog> + )} + </AnimatePresence> + + {/* Experimental Mode Confirmation Dialog */} + <AnimatePresence> + {expDialog.open && ( + <Dialog + onOpenChange={(open) => setExpDialog({ ...expDialog, open })} + open={expDialog.open} + > + <DialogContent className="sm:max-w-lg bg-black/90 backdrop-blur-xl border-white/10 text-white"> + <motion.div + animate={{ opacity: 1, scale: 1 }} + className="flex flex-col gap-4" + exit={{ opacity: 0, scale: 0.95 }} + initial={{ opacity: 0, scale: 0.95 }} + > + <DialogHeader> + <DialogTitle className="text-white"> + Enable Experimental Mode? + </DialogTitle> + <DialogDescription className="text-white/60"> + Experimental mode enables beta features and advanced memory + relationships for this project. + <br /> + <br /> + <span className="text-yellow-400 font-medium"> + Warning: + </span>{" "} + This action is{" "} + <span className="text-red-400 font-bold">irreversible</span> + . Once enabled, you cannot return to regular mode for this + project. + </DialogDescription> + </DialogHeader> + <DialogFooter> + <motion.div + whileHover={{ scale: 1.05 }} + whileTap={{ scale: 0.95 }} + > + <Button + className="bg-white/5 hover:bg-white/10 border-white/10 text-white" + onClick={() => + setExpDialog({ open: false, projectId: "" }) + } + type="button" + variant="outline" + > + Cancel + </Button> + </motion.div> + <motion.div + whileHover={{ scale: 1.05 }} + whileTap={{ scale: 0.95 }} + > + <Button + className="bg-blue-600 hover:bg-blue-700 text-white" + disabled={enableExperimentalMutation.isPending} + onClick={() => + enableExperimentalMutation.mutate(expDialog.projectId) + } + type="button" + > + {enableExperimentalMutation.isPending ? ( + <> + <Loader2 className="h-4 w-4 animate-spin mr-2" /> + Enabling... + </> + ) : ( + "Enable Experimental Mode" + )} + </Button> + </motion.div> + </DialogFooter> + </motion.div> + </DialogContent> + </Dialog> + )} + </AnimatePresence> + </div> + ) +} diff --git a/apps/web/components/referral-upgrade-modal.tsx b/apps/web/components/referral-upgrade-modal.tsx new file mode 100644 index 00000000..2ad1b18a --- /dev/null +++ b/apps/web/components/referral-upgrade-modal.tsx @@ -0,0 +1,290 @@ +"use client" + +import { useAuth } from "@lib/auth-context" +import { + fetchMemoriesFeature, + fetchSubscriptionStatus, +} from "@lib/queries" +import { Button } from "@repo/ui/components/button" +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle +} from "@repo/ui/components/dialog" +import { Input } from "@repo/ui/components/input" +import { Label } from "@repo/ui/components/label" +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@repo/ui/components/tabs" +import { HeadingH3Bold } from "@repo/ui/text/heading/heading-h3-bold" +import { useCustomer } from "autumn-js/react" +import { + CheckCircle, + Copy, + CreditCard, + Gift, + LoaderIcon, + Share2, + Users +} from "lucide-react" +import { motion } from "motion/react" +import Link from "next/link" +import { useState } from "react" + +interface ReferralUpgradeModalProps { + isOpen: boolean + onClose: () => void +} + +export function ReferralUpgradeModal({ isOpen, onClose }: ReferralUpgradeModalProps) { + const { user } = useAuth() + const autumn = useCustomer() + const [isLoading, setIsLoading] = useState(false) + const [copied, setCopied] = useState(false) + + const { data: memoriesCheck } = fetchMemoriesFeature(autumn as any) + const memoriesUsed = memoriesCheck?.usage ?? 0 + const memoriesLimit = memoriesCheck?.included_usage ?? 0 + + // Fetch subscription status + const { + data: status = { + consumer_pro: null, + }, + isLoading: isCheckingStatus, + } = fetchSubscriptionStatus(autumn as any) + + const isPro = status.consumer_pro + + // Handle upgrade + const handleUpgrade = async () => { + setIsLoading(true) + try { + await autumn.attach({ + productId: "consumer_pro", + successUrl: "https://app.supermemory.ai/", + }) + window.location.reload() + } catch (error) { + console.error(error) + setIsLoading(false) + } + } + + // Generate referral link (you'll need to implement this based on your referral system) + const referralLink = `https://app.supermemory.ai/ref/${user?.id || 'user'}` + + const handleCopyReferralLink = async () => { + try { + await navigator.clipboard.writeText(referralLink) + setCopied(true) + setTimeout(() => setCopied(false), 2000) + } catch (error) { + console.error('Failed to copy:', error) + } + } + + const handleShare = async () => { + if (navigator.share) { + try { + await navigator.share({ + title: 'Join Supermemory', + text: 'Check out Supermemory - the best way to organize and search your digital memories!', + url: referralLink, + }) + } catch (error) { + console.error('Error sharing:', error) + } + } else { + handleCopyReferralLink() + } + } + + if (user?.isAnonymous) { + return ( + <Dialog open={isOpen} onOpenChange={onClose}> + <DialogContent className="sm:max-w-md bg-[#0f1419] backdrop-blur-xl border-white/10 text-white"> + <motion.div + animate={{ opacity: 1, scale: 1 }} + exit={{ opacity: 0, scale: 0.95 }} + initial={{ opacity: 0, scale: 0.95 }} + > + <DialogHeader> + <DialogTitle>Get More Memories</DialogTitle> + <DialogDescription className="text-white/60"> + Sign in to access referrals and upgrade options + </DialogDescription> + </DialogHeader> + + <div className="text-center py-6"> + <Button + asChild + className="bg-white/10 hover:bg-white/20 text-white border-white/20" + > + <Link href="/login">Sign in</Link> + </Button> + </div> + </motion.div> + </DialogContent> + </Dialog> + ) + } + + return ( + <Dialog open={isOpen} onOpenChange={onClose}> + <DialogContent className="sm:max-w-lg bg-[#0f1419] backdrop-blur-xl border-white/10 text-white"> + <motion.div + animate={{ opacity: 1, scale: 1 }} + exit={{ opacity: 0, scale: 0.95 }} + initial={{ opacity: 0, scale: 0.95 }} + > + <DialogHeader className="mb-4"> + <DialogTitle>Get More Memories</DialogTitle> + <DialogDescription className="text-white/60"> + Expand your memory capacity through referrals or upgrades + </DialogDescription> + </DialogHeader> + + {/* Current Usage */} + <div className="bg-white/5 rounded-lg p-4 mb-6"> + <div className="flex justify-between items-center mb-2"> + <span className="text-sm text-white/70">Current Usage</span> + <span className={`text-sm ${memoriesUsed >= memoriesLimit ? "text-red-400" : "text-white/90"}`}> + {memoriesUsed} / {memoriesLimit} memories + </span> + </div> + <div className="w-full bg-white/10 rounded-full h-2"> + <div + className={`h-2 rounded-full transition-all ${ + memoriesUsed >= memoriesLimit ? "bg-red-500" : isPro ? "bg-green-500" : "bg-blue-500" + }`} + style={{ + width: `${Math.min((memoriesUsed / memoriesLimit) * 100, 100)}%`, + }} + /> + </div> + </div> + + {/* Tabs */} + <Tabs defaultValue="refer" className="w-full"> + <TabsList className="grid w-full grid-cols-2 bg-white/5"> + <TabsTrigger value="refer" className="flex items-center gap-2"> + <Users className="w-4 h-4" /> + Refer Friends + </TabsTrigger> + {!isPro && ( + <TabsTrigger value="upgrade" className="flex items-center gap-2"> + <CreditCard className="w-4 h-4" /> + Upgrade Plan + </TabsTrigger> + )} + </TabsList> + + <TabsContent value="refer" className="space-y-4 mt-6"> + <div className="text-center"> + <Gift className="w-12 h-12 text-blue-400 mx-auto mb-3" /> + <HeadingH3Bold className="text-white mb-2"> + Invite Friends, Get More Memories + </HeadingH3Bold> + <p className="text-white/70 text-sm"> + For every friend who joins, you both get +5 extra memories! + </p> + </div> + + <div className="bg-white/5 rounded-lg p-4"> + <Label className="text-sm text-white/70 mb-2 block"> + Your Referral Link + </Label> + <div className="flex gap-2"> + <Input + className="flex-1 bg-white/10 border-white/20 text-white" + readOnly + value={referralLink} + /> + <Button + className="bg-white/5 hover:bg-white/10 text-white border-white/20" + onClick={handleCopyReferralLink} + size="sm" + variant="outline" + > + {copied ? ( + <CheckCircle className="w-4 h-4" /> + ) : ( + <Copy className="w-4 h-4" /> + )} + </Button> + </div> + </div> + + <Button + className="w-full bg-white/5 hover:bg-white/10 text-white border-white/20" + onClick={handleShare} + variant="outline" + > + <Share2 className="w-4 h-4 mr-2" /> + Share Link + </Button> + </TabsContent> + + {!isPro && ( + <TabsContent value="upgrade" className="space-y-4 mt-6"> + <div className="text-center"> + <CreditCard className="w-12 h-12 text-purple-400 mx-auto mb-3" /> + <HeadingH3Bold className="text-white mb-2"> + Upgrade to Pro + </HeadingH3Bold> + <p className="text-white/70 text-sm"> + Get unlimited memories and advanced features + </p> + </div> + + <div className="bg-white/5 rounded-lg border border-white/10 p-4"> + <h4 className="font-medium text-white mb-3"> + Pro Plan Benefits + </h4> + <ul className="space-y-2"> + <li className="flex items-center gap-2 text-sm text-white/90"> + <CheckCircle className="h-4 w-4 text-green-400" /> + 500 memories (vs {memoriesLimit} free) + </li> + <li className="flex items-center gap-2 text-sm text-white/90"> + <CheckCircle className="h-4 w-4 text-green-400" /> + 10 connections + </li> + <li className="flex items-center gap-2 text-sm text-white/90"> + <CheckCircle className="h-4 w-4 text-green-400" /> + Advanced search + </li> + <li className="flex items-center gap-2 text-sm text-white/90"> + <CheckCircle className="h-4 w-4 text-green-400" /> + Priority support + </li> + </ul> + </div> + + <Button + className="w-full bg-blue-600 hover:bg-blue-700 text-white" + disabled={isLoading || isCheckingStatus} + onClick={handleUpgrade} + > + {isLoading || isCheckingStatus ? ( + <> + <LoaderIcon className="h-4 w-4 animate-spin mr-2" /> + Upgrading... + </> + ) : ( + "Upgrade to Pro - $15/month" + )} + </Button> + + <p className="text-xs text-white/50 text-center"> + Cancel anytime. No questions asked. + </p> + </TabsContent> + )} + </Tabs> + </motion.div> + </DialogContent> + </Dialog> + ) +} diff --git a/apps/web/components/spinner.tsx b/apps/web/components/spinner.tsx new file mode 100644 index 00000000..aad15e33 --- /dev/null +++ b/apps/web/components/spinner.tsx @@ -0,0 +1,8 @@ +import { cn } from "@lib/utils"; +import { Loader2 } from "lucide-react"; + +export function Spinner({ className }: { className?: string }) { + return ( + <Loader2 className={cn("size-4 animate-spin", className)} /> + ) +}
\ No newline at end of file diff --git a/apps/web/components/text-shimmer.tsx b/apps/web/components/text-shimmer.tsx new file mode 100644 index 00000000..bef0e7a9 --- /dev/null +++ b/apps/web/components/text-shimmer.tsx @@ -0,0 +1,57 @@ +'use client'; +import React, { useMemo, type JSX } from 'react'; +import { motion } from 'motion/react'; +import { cn } from '@lib/utils'; + +export type TextShimmerProps = { + children: string; + as?: React.ElementType; + className?: string; + duration?: number; + spread?: number; +}; + +function TextShimmerComponent({ + children, + as: Component = 'p', + className, + duration = 2, + spread = 2, +}: TextShimmerProps) { + const MotionComponent = motion.create( + Component as keyof JSX.IntrinsicElements + ); + + const dynamicSpread = useMemo(() => { + return children.length * spread; + }, [children, spread]); + + return ( + <MotionComponent + className={cn( + 'relative inline-block bg-[length:250%_100%,auto] bg-clip-text', + 'text-transparent [--base-color:#a1a1aa] [--base-gradient-color:#000]', + '[background-repeat:no-repeat,padding-box] [--bg:linear-gradient(90deg,#0000_calc(50%-var(--spread)),var(--base-gradient-color),#0000_calc(50%+var(--spread)))]', + 'dark:[--base-color:#71717a] dark:[--base-gradient-color:#ffffff] dark:[--bg:linear-gradient(90deg,#0000_calc(50%-var(--spread)),var(--base-gradient-color),#0000_calc(50%+var(--spread)))]', + className + )} + initial={{ backgroundPosition: '100% center' }} + animate={{ backgroundPosition: '0% center' }} + transition={{ + repeat: Infinity, + duration, + ease: 'linear', + }} + style={ + { + '--spread': `${dynamicSpread}px`, + backgroundImage: `var(--bg), linear-gradient(var(--base-color), var(--base-color))`, + } as React.CSSProperties + } + > + {children} + </MotionComponent> + ); +} + +export const TextShimmer = React.memo(TextShimmerComponent); diff --git a/apps/web/components/tour.tsx b/apps/web/components/tour.tsx new file mode 100644 index 00000000..981effbc --- /dev/null +++ b/apps/web/components/tour.tsx @@ -0,0 +1,413 @@ +"use client" + +import { Button } from "@repo/ui/components/button" +import { GlassMenuEffect } from "@repo/ui/other/glass-effect" +import { AnimatePresence, motion } from "motion/react" +import * as React from "react" +import { analytics } from "@/lib/analytics" + +// Types +export interface TourStep { + content: React.ReactNode + selectorId: string + position?: "top" | "bottom" | "left" | "right" | "center" + onClickWithinArea?: () => void +} + +interface TourContextType { + currentStep: number + totalSteps: number + nextStep: () => void + previousStep: () => void + endTour: () => void + isActive: boolean + isPaused: boolean + startTour: () => void + setSteps: (steps: TourStep[]) => void + steps: TourStep[] + isTourCompleted: boolean + setIsTourCompleted: (completed: boolean) => void + // Expansion state tracking + setMenuExpanded: (expanded: boolean) => void + setChatExpanded: (expanded: boolean) => void +} + +// Context +const TourContext = React.createContext<TourContextType | undefined>(undefined) + +export function useTour() { + const context = React.useContext(TourContext) + if (!context) { + throw new Error("useTour must be used within a TourProvider") + } + return context +} + +// Provider +interface TourProviderProps { + children: React.ReactNode + onComplete?: () => void + className?: string + isTourCompleted?: boolean +} + +export function TourProvider({ + children, + onComplete, + className, + isTourCompleted: initialCompleted = false, +}: TourProviderProps) { + const [currentStep, setCurrentStep] = React.useState(-1) + const [steps, setSteps] = React.useState<TourStep[]>([]) + const [isActive, setIsActive] = React.useState(false) + const [isTourCompleted, setIsTourCompleted] = React.useState(initialCompleted) + + // Track expansion states + const [isMenuExpanded, setIsMenuExpanded] = React.useState(false) + const [isChatExpanded, setIsChatExpanded] = React.useState(false) + + // Calculate if tour should be paused + const isPaused = React.useMemo(() => { + return isActive && (isMenuExpanded || isChatExpanded) + }, [isActive, isMenuExpanded, isChatExpanded]) + + const startTour = React.useCallback(() => { + console.debug("Starting tour with", steps.length, "steps") + analytics.tourStarted() + setCurrentStep(0) + setIsActive(true) + }, [steps]) + + const endTour = React.useCallback(() => { + setCurrentStep(-1) + setIsActive(false) + setIsTourCompleted(true) // Mark tour as completed when ended/skipped + analytics.tourSkipped() + if (onComplete) { + onComplete() + } + }, [onComplete]) + + const nextStep = React.useCallback(() => { + if (currentStep < steps.length - 1) { + setCurrentStep(currentStep + 1) + } else { + analytics.tourCompleted() + endTour() + setIsTourCompleted(true) + } + }, [currentStep, steps.length, endTour]) + + const previousStep = React.useCallback(() => { + if (currentStep > 0) { + setCurrentStep(currentStep - 1) + } + }, [currentStep]) + + const setMenuExpanded = React.useCallback((expanded: boolean) => { + setIsMenuExpanded(expanded) + }, []) + + const setChatExpanded = React.useCallback((expanded: boolean) => { + setIsChatExpanded(expanded) + }, []) + + const value = React.useMemo( + () => ({ + currentStep, + totalSteps: steps.length, + nextStep, + previousStep, + endTour, + isActive, + isPaused, + startTour, + setSteps, + steps, + isTourCompleted, + setIsTourCompleted, + setMenuExpanded, + setChatExpanded, + }), + [ + currentStep, + steps, + nextStep, + previousStep, + endTour, + isActive, + isPaused, + startTour, + isTourCompleted, + setMenuExpanded, + setChatExpanded, + ], + ) + + return ( + <TourContext.Provider value={value}> + {children} + {isActive && !isPaused && ( + <> + {console.log( + "Rendering TourHighlight for step:", + currentStep, + currentStep >= 0 && currentStep < steps.length + ? steps[currentStep] + : "No step", + )} + <TourHighlight + className={className} + currentStepIndex={currentStep} + steps={steps} + /> + </> + )} + </TourContext.Provider> + ) +} + +// Tour Highlight Component +function TourHighlight({ + currentStepIndex, + steps, + className, +}: { + currentStepIndex: number + steps: TourStep[] + className?: string +}) { + const { nextStep, previousStep, endTour } = useTour() + const [elementRect, setElementRect] = React.useState<DOMRect | null>(null) + + // Get current step safely + const step = + currentStepIndex >= 0 && currentStepIndex < steps.length + ? steps[currentStepIndex] + : null + + React.useEffect(() => { + if (!step) return + + // Use requestAnimationFrame to ensure DOM is ready + const rafId = requestAnimationFrame(() => { + const element = document.getElementById(step.selectorId) + console.debug( + "Looking for element with ID:", + step.selectorId, + "Found:", + !!element, + ) + if (element) { + const rect = element.getBoundingClientRect() + console.debug("Element rect:", { + id: step.selectorId, + top: rect.top, + left: rect.left, + width: rect.width, + height: rect.height, + bottom: rect.bottom, + right: rect.right, + }) + setElementRect(rect) + } + }) + + // Add click listener for onClickWithinArea + let clickHandler: ((e: MouseEvent) => void) | null = null + if (step.onClickWithinArea) { + const element = document.getElementById(step.selectorId) + if (element) { + clickHandler = (e: MouseEvent) => { + if (element.contains(e.target as Node)) { + step.onClickWithinArea?.() + } + } + document.addEventListener("click", clickHandler) + } + } + + return () => { + cancelAnimationFrame(rafId) + if (clickHandler) { + document.removeEventListener("click", clickHandler) + } + } + }, [step]) + + if (!step) return null + + // Keep the wrapper mounted but animate the content + return ( + <AnimatePresence mode="wait"> + {elementRect && ( + <motion.div + animate={{ opacity: 1 }} + exit={{ opacity: 0 }} + initial={{ opacity: 0 }} + key={`tour-step-${currentStepIndex}`} + transition={{ duration: 0.2 }} + > + {/* Highlight Border */} + <motion.div + animate={{ opacity: 1, scale: 1 }} + className={`fixed z-[101] pointer-events-none ${className}`} + exit={{ opacity: 0, scale: 0.95 }} + initial={{ opacity: 0, scale: 0.95 }} + style={{ + top: elementRect.top + window.scrollY, + left: elementRect.left + window.scrollX, + width: elementRect.width, + height: elementRect.height, + }} + > + <div className="absolute inset-0 rounded-lg outline-4 outline-blue-500/50 outline-offset-0" /> + </motion.div> + + {/* Tooltip with Glass Effect */} + <motion.div + animate={{ opacity: 1, y: 0 }} + className="fixed z-[102] w-72 rounded-lg shadow-xl overflow-hidden" + exit={{ opacity: 0, y: step.position === "top" ? 10 : -10 }} + initial={{ opacity: 0, y: step.position === "top" ? 10 : -10 }} + style={{ + top: (() => { + const baseTop = + step.position === "bottom" + ? elementRect.bottom + 8 + : step.position === "top" + ? elementRect.top - 200 + : elementRect.top + elementRect.height / 2 - 100 + + // Ensure tooltip stays within viewport + const maxTop = window.innerHeight - 250 // Leave space for tooltip height + const minTop = 10 + return Math.max(minTop, Math.min(baseTop, maxTop)) + })(), + left: (() => { + const baseLeft = + step.position === "right" + ? elementRect.right + 8 + : step.position === "left" + ? elementRect.left - 300 + : elementRect.left + elementRect.width / 2 - 150 + + // Ensure tooltip stays within viewport + const maxLeft = window.innerWidth - 300 // Tooltip width + const minLeft = 10 + return Math.max(minLeft, Math.min(baseLeft, maxLeft)) + })(), + }} + > + {/* Glass effect background */} + <GlassMenuEffect rounded="rounded-lg" /> + + {/* Content */} + <div className="relative z-10 p-4"> + <div className="mb-4 text-white">{step.content}</div> + <div className="flex items-center justify-between"> + <span className="text-sm text-gray-300"> + {currentStepIndex + 1} / {steps.length} + </span> + <div className="flex gap-2"> + <Button + className="border-white/20 text-white hover:bg-white/10" + onClick={endTour} + size="sm" + variant="outline" + > + Skip + </Button> + {currentStepIndex > 0 && ( + <Button + className="border-white/20 text-white hover:bg-white/10" + onClick={previousStep} + size="sm" + variant="outline" + > + Previous + </Button> + )} + <Button + className="bg-white/20 text-white hover:bg-white/30" + onClick={nextStep} + size="sm" + > + {currentStepIndex === steps.length - 1 ? "Finish" : "Next"} + </Button> + </div> + </div> + </div> + </motion.div> + </motion.div> + )} + </AnimatePresence> + ) +} + +// Tour Alert Dialog +interface TourAlertDialogProps { + open: boolean + onOpenChange: (open: boolean) => void +} + +export function TourAlertDialog({ open, onOpenChange }: TourAlertDialogProps) { + const { startTour, setIsTourCompleted } = useTour() + + const handleStart = () => { + console.debug("TourAlertDialog: Starting tour") + onOpenChange(false) + startTour() + } + + const handleSkip = () => { + analytics.tourSkipped() + setIsTourCompleted(true) // Mark tour as completed when skipped + onOpenChange(false) + } + + if (!open) return null + + return ( + <AnimatePresence> + <motion.div + animate={{ opacity: 1, scale: 1 }} + className="fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 z-[102] w-[90vw] max-w-2xl rounded-lg shadow-xl overflow-hidden" + exit={{ opacity: 0, scale: 0.95 }} + initial={{ opacity: 0, scale: 0.95 }} + > + {/* Glass effect background */} + <GlassMenuEffect rounded="rounded-lg" /> + + {/* Content */} + <div className="relative z-10 p-8 md:p-10 lg:p-12"> + <h2 className="text-3xl md:text-4xl font-bold text-white mb-4"> + Welcome to supermemory™ + </h2> + <p className="text-lg md:text-xl text-gray-200 mb-8 leading-relaxed"> + This is your personal knowledge graph where all your memories are + stored and connected. Let's take a quick tour to help you get + familiar with the interface. + </p> + <div className="flex gap-4 justify-end"> + <Button + className="border-white/20 text-white hover:bg-white/10 px-6 py-2 text-base" + onClick={handleSkip} + size="lg" + variant="outline" + > + Skip Tour + </Button> + <Button + className="bg-white/20 text-white hover:bg-white/30 px-6 py-2 text-base" + onClick={handleStart} + size="lg" + > + Start Tour + </Button> + </div> + </div> + </motion.div> + </AnimatePresence> + ) +} diff --git a/apps/web/components/views/add-memory.tsx b/apps/web/components/views/add-memory.tsx new file mode 100644 index 00000000..744faa35 --- /dev/null +++ b/apps/web/components/views/add-memory.tsx @@ -0,0 +1,1425 @@ +import { $fetch } from "@lib/api" +import { + fetchConsumerProProduct, + fetchMemoriesFeature, +} from "@repo/lib/queries" +import { Button } from "@repo/ui/components/button" +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@repo/ui/components/dialog" +import { Input } from "@repo/ui/components/input" +import { Label } from "@repo/ui/components/label" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@repo/ui/components/select" +import { + Tabs, + TabsContent, + TabsList, + TabsTrigger, +} from "@repo/ui/components/tabs" +import { Textarea } from "@repo/ui/components/textarea" +import { useForm } from "@tanstack/react-form" +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query" +import { + Dropzone, + DropzoneContent, + DropzoneEmptyState, +} from "@ui/components/shadcn-io/dropzone" +import { useCustomer } from "autumn-js/react" +import { + Brain, + FileIcon, + Link as LinkIcon, + Loader2, + PlugIcon, + Plus, + UploadIcon, +} from "lucide-react" +import { AnimatePresence, motion } from "motion/react" +import Link from "next/link" +import { useEffect, useState } from "react" +import { toast } from "sonner" +import { z } from "zod" +import { useProject } from "@/stores" +import { ConnectionsTabContent } from "./connections-tab-content" +import { analytics } from "@/lib/analytics" + +// // Processing status component +// function ProcessingStatus({ status }: { status: string }) { +// const statusConfig = { +// queued: { color: "text-yellow-400", label: "Queued", icon: "⏳" }, +// extracting: { color: "text-blue-400", label: "Extracting", icon: "📤" }, +// chunking: { color: "text-indigo-400", label: "Chunking", icon: "✂️" }, +// embedding: { color: "text-purple-400", label: "Embedding", icon: "🧠" }, +// indexing: { color: "text-pink-400", label: "Indexing", icon: "📝" }, +// unknown: { color: "text-gray-400", label: "Processing", icon: "⚙️" }, +// } + +// const config = +// statusConfig[status as keyof typeof statusConfig] || statusConfig.unknown + +// return ( +// <div className={`flex items-center gap-1 text-xs ${config.color}`}> +// <span>{config.icon}</span> +// <span>{config.label}</span> +// </div> +// ) +// } + +export function AddMemoryView({ + onClose, + initialTab = "note", +}: { + onClose?: () => void + initialTab?: "note" | "link" | "file" | "connect" +}) { + const queryClient = useQueryClient() + const { selectedProject, setSelectedProject } = useProject() + const [showAddDialog, setShowAddDialog] = useState(true) + const [selectedFiles, setSelectedFiles] = useState<File[]>([]) + const [activeTab, setActiveTab] = useState< + "note" | "link" | "file" | "connect" + >(initialTab) + const autumn = useCustomer() + const [showCreateProjectDialog, setShowCreateProjectDialog] = useState(false) + const [newProjectName, setNewProjectName] = useState("") + + // Check memory limits + const { data: memoriesCheck } = fetchMemoriesFeature(autumn as any) + + const memoriesUsed = memoriesCheck?.usage ?? 0 + const memoriesLimit = memoriesCheck?.included_usage ?? 0 + + // Check if user is pro + const { data: proCheck } = fetchConsumerProProduct(autumn as any) + const isProUser = proCheck?.allowed ?? false + + const canAddMemory = memoriesUsed < memoriesLimit + + // Fetch projects for the dropdown + const { data: projects = [], isLoading: isLoadingProjects } = useQuery({ + queryKey: ["projects"], + queryFn: async () => { + const response = await $fetch("@get/projects") + + if (response.error) { + throw new Error(response.error?.message || "Failed to load projects") + } + + return response.data?.projects || [] + }, + staleTime: 30 * 1000, + }) + + // Create project mutation + const createProjectMutation = useMutation({ + mutationFn: async (name: string) => { + const response = await $fetch("@post/projects", { + body: { name }, + }) + + if (response.error) { + throw new Error(response.error?.message || "Failed to create project") + } + + return response.data + }, + onSuccess: (data) => { + analytics.projectCreated() + toast.success("Project created successfully!") + setShowCreateProjectDialog(false) + setNewProjectName("") + queryClient.invalidateQueries({ queryKey: ["projects"] }) + // Set the newly created project as selected + if (data?.containerTag) { + setSelectedProject(data.containerTag) + // Update form values + addContentForm.setFieldValue("project", data.containerTag) + fileUploadForm.setFieldValue("project", data.containerTag) + } + }, + onError: (error) => { + toast.error("Failed to create project", { + description: error instanceof Error ? error.message : "Unknown error", + }) + }, + }) + + const addContentForm = useForm({ + defaultValues: { + content: "", + project: selectedProject || "sm_project_default", + }, + onSubmit: async ({ value, formApi }) => { + addContentMutation.mutate({ + content: value.content, + project: value.project, + contentType: activeTab as "note" | "link", + }) + formApi.reset() + }, + validators: { + onChange: z.object({ + content: z.string().min(1, "Content is required"), + project: z.string(), + }), + }, + }) + + // Re-validate content field when tab changes between note/link + // biome-ignore lint/correctness/useExhaustiveDependencies: It is what it is + useEffect(() => { + // Trigger validation of the content field when switching between note/link + if (activeTab === "note" || activeTab === "link") { + const currentValue = addContentForm.getFieldValue("content") + if (currentValue) { + addContentForm.validateField("content", "change") + } + } + }, [activeTab]) + + // Form for file upload metadata + const fileUploadForm = useForm({ + defaultValues: { + title: "", + description: "", + project: selectedProject || "sm_project_default", + }, + onSubmit: async ({ value, formApi }) => { + if (selectedFiles.length === 0) { + toast.error("Please select a file to upload") + return + } + + for (const file of selectedFiles) { + fileUploadMutation.mutate({ + file, + title: value.title || undefined, + description: value.description || undefined, + project: value.project, + }) + } + + formApi.reset() + setSelectedFiles([]) + }, + }) + + const handleUpgrade = async () => { + try { + await autumn.attach({ + productId: "consumer_pro", + successUrl: "https://app.supermemory.ai/", + }) + window.location.reload() + } catch (error) { + console.error(error) + } + } + + const addContentMutation = useMutation({ + mutationFn: async ({ + content, + project, + contentType, + }: { + content: string + project: string + contentType: "note" | "link" + }) => { + // close the modal + onClose?.() + + const processingPromise = (async () => { + // First, create the memory + const response = await $fetch("@post/memories", { + body: { + content: content, + containerTags: [project], + metadata: { + sm_source: "consumer", // Use "consumer" source to bypass limits + }, + }, + }) + + if (response.error) { + throw new Error( + response.error?.message || `Failed to add ${contentType}`, + ) + } + + const memoryId = response.data.id + + // Polling function to check status + const pollForCompletion = async (): Promise<any> => { + let attempts = 0 + const maxAttempts = 60 // Maximum 5 minutes (60 attempts * 5 seconds) + + while (attempts < maxAttempts) { + try { + const memory = await $fetch<{ status: string; content: string }>( + "@get/memories/" + memoryId, + ) + + if (memory.error) { + throw new Error( + memory.error?.message || "Failed to fetch memory status", + ) + } + + // Check if processing is complete + // Adjust this condition based on your API response structure + if ( + memory.data?.status === "done" || + // Sometimes the memory might be ready when it has content and no processing status + memory.data?.content + ) { + return memory.data + } + + // If still processing, wait and try again + await new Promise((resolve) => setTimeout(resolve, 5000)) // Wait 5 seconds + attempts++ + } catch (error) { + console.error("Error polling memory status:", error) + // Don't throw immediately, retry a few times + if (attempts >= 3) { + throw new Error("Failed to check processing status") + } + await new Promise((resolve) => setTimeout(resolve, 5000)) + attempts++ + } + } + + // If we've exceeded max attempts, throw an error + throw new Error("Memory processing timed out. Please check back later.") + } + + // Wait for completion + const completedMemory = await pollForCompletion() + return completedMemory + })() + + toast.promise(processingPromise, { + loading: "Processing...", + success: `${contentType === "link" ? "Link" : "Note"} created successfully!`, + error: (err) => `Failed to add ${contentType}: ${err instanceof Error ? err.message : "Unknown error"}`, + }) + + return processingPromise + }, + onMutate: async ({ content, project, contentType }) => { + console.log("🚀 onMutate starting...") + + // Cancel any outgoing refetches + await queryClient.cancelQueries({ queryKey: ["documents-with-memories", project] }) + console.log("✅ Cancelled queries") + + // Snapshot the previous value + const previousMemories = queryClient.getQueryData([ + "documents-with-memories", + project, + ]) + console.log("📸 Previous memories:", previousMemories) + + // Create optimistic memory + const optimisticMemory = { + id: `temp-${Date.now()}`, + content: contentType === "link" ? "" : content, + url: contentType === "link" ? content : null, + title: + contentType === "link" ? "Processing..." : content.substring(0, 100), + description: contentType === "link" ? "Extracting content..." : "Processing content...", + containerTags: [project], + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + status: "queued", + type: contentType, + metadata: { + processingStage: "queued", + processingMessage: "Added to processing queue" + }, + memoryEntries: [], + isOptimistic: true, + } + console.log("🎯 Created optimistic memory:", optimisticMemory) + + // Optimistically update to include the new memory + queryClient.setQueryData(["documents-with-memories", project], (old: any) => { + console.log("🔄 Old data:", old) + const newData = old + ? { + ...old, + documents: [optimisticMemory, ...(old.documents || [])], + totalCount: (old.totalCount || 0) + 1, + } + : { documents: [optimisticMemory], totalCount: 1 } + console.log("✨ New data:", newData) + return newData + }) + + console.log("✅ onMutate completed") + return { previousMemories, optimisticId: optimisticMemory.id } + }, + // If the mutation fails, roll back to the previous value + onError: (error, variables, context) => { + if (context?.previousMemories) { + queryClient.setQueryData( + ["documents-with-memories", variables.project], + context.previousMemories, + ) + } + }, + onSuccess: (_data, variables) => { + analytics.memoryAdded({ + type: variables.contentType === "link" ? "link" : "note", + project_id: variables.project, + content_length: variables.content.length, + }) + + queryClient.invalidateQueries({ queryKey: ["documents-with-memories", variables.project] }) + + setTimeout(() => { + queryClient.invalidateQueries({ queryKey: ["documents-with-memories", variables.project] }) + }, 30000) // 30 seconds + + setTimeout(() => { + queryClient.invalidateQueries({ queryKey: ["documents-with-memories", variables.project] }) + }, 120000) // 2 minutes + + setShowAddDialog(false) + onClose?.() + }, + }) + + const fileUploadMutation = useMutation({ + mutationFn: async ({ + file, + title, + description, + project, + }: { + file: File + title?: string + description?: string + project: string + }) => { + // TEMPORARILY DISABLED: Limit check disabled + // Check if user can add more memories + // if (!canAddMemory && !isProUser) { + // throw new Error( + // `Free plan limit reached (${memoriesLimit} memories). Upgrade to Pro for up to 500 memories.`, + // ); + // } + + const formData = new FormData() + formData.append("file", file) + formData.append("containerTags", JSON.stringify([project])) + + const response = await fetch( + `${process.env.NEXT_PUBLIC_BACKEND_URL}/v3/memories/file`, + { + method: "POST", + body: formData, + credentials: "include", + }, + ) + + if (!response.ok) { + const error = await response.json() + throw new Error(error.error || "Failed to upload file") + } + + const data = await response.json() + + // If we have metadata, we can update the document after creation + if (title || description) { + await $fetch(`@patch/memories/${data.id}`, { + body: { + metadata: { + ...(title && { title }), + ...(description && { description }), + sm_source: "consumer", // Use "consumer" source to bypass limits + }, + }, + }) + } + + return data + }, + // Optimistic update + onMutate: async ({ file, title, description, project }) => { + // Cancel any outgoing refetches + await queryClient.cancelQueries({ queryKey: ["documents-with-memories", project] }) + + // Snapshot the previous value + const previousMemories = queryClient.getQueryData([ + "documents-with-memories", + project, + ]) + + // Create optimistic memory for the file + const optimisticMemory = { + id: `temp-file-${Date.now()}`, + content: "", + url: null, + title: title || file.name, + description: description || `Uploading ${file.name}...`, + containerTags: [project], + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + status: "processing", + type: "file", + metadata: { + fileName: file.name, + fileSize: file.size, + mimeType: file.type, + }, + memoryEntries: [], + } + + // Optimistically update to include the new memory + queryClient.setQueryData(["documents-with-memories", project], (old: any) => { + if (!old) return { documents: [optimisticMemory], totalCount: 1 } + return { + ...old, + documents: [optimisticMemory, ...(old.documents || [])], + totalCount: (old.totalCount || 0) + 1, + } + }) + + // Return a context object with the snapshotted value + return { previousMemories } + }, + // If the mutation fails, roll back to the previous value + onError: (error, variables, context) => { + if (context?.previousMemories) { + queryClient.setQueryData( + ["documents-with-memories", variables.project], + context.previousMemories, + ) + } + toast.error("Failed to upload file", { + description: error instanceof Error ? error.message : "Unknown error", + }) + }, + onSuccess: (_data, variables) => { + analytics.memoryAdded({ + type: "file", + project_id: variables.project, + file_size: variables.file.size, + file_type: variables.file.type, + }) + toast.success("File uploaded successfully!", { + description: "Your file is being processed", + }) + setShowAddDialog(false) + onClose?.() + }, + // Always refetch after error or success + onSettled: () => { + queryClient.invalidateQueries({ queryKey: ["documents-with-memories"] }) + }, + }) + + return ( + <AnimatePresence mode="wait"> + {showAddDialog && ( + <Dialog + key="add-memory-dialog" + onOpenChange={(open) => { + setShowAddDialog(open) + if (!open) onClose?.() + }} + open={showAddDialog} + > + <DialogContent className="sm:max-w-3xl bg-[#0f1419] backdrop-blur-xl border-white/10 text-white z-[80]"> + <motion.div + animate={{ opacity: 1, scale: 1 }} + exit={{ opacity: 0, scale: 0.95 }} + initial={{ opacity: 0, scale: 0.95 }} + > + <DialogHeader> + <DialogTitle>Add to Memory</DialogTitle> + <DialogDescription className="text-white/60"> + Save any webpage, article, or file to your memory + </DialogDescription> + { + <motion.div + animate={{ opacity: 1, y: 0 }} + className="mt-2" + initial={{ opacity: 0, y: -10 }} + > + <div className="text-xs text-white/50"> + {memoriesUsed} of {memoriesLimit} memories used + {!isProUser && memoriesUsed >= memoriesLimit * 0.8 && ( + <span className="text-yellow-400 ml-2"> + • {memoriesLimit - memoriesUsed} remaining + </span> + )} + </div> + {!canAddMemory && !isProUser && ( + <motion.div + animate={{ opacity: 1, height: "auto" }} + className="mt-2 p-3 bg-yellow-500/10 border border-yellow-500/20 rounded-lg" + initial={{ opacity: 0, height: 0 }} + > + <p className="text-sm text-yellow-400"> + You've reached the free plan limit. + <Button + asChild + className="text-yellow-400 hover:text-yellow-300 px-2" + onClick={handleUpgrade} + size="sm" + variant="link" + > + Upgrade to Pro + </Button> + for up to 5000 memories. + </p> + </motion.div> + )} + </motion.div> + } + </DialogHeader> + + <Tabs + className="mt-4" + onValueChange={(v) => + setActiveTab(v as "note" | "link" | "file" | "connect") + } + value={activeTab} + > + <TabsList className="grid w-full grid-cols-4 bg-white/5"> + <TabsTrigger + className="data-[state=active]:bg-white/10" + value="note" + > + <Brain className="h-4 w-4 mr-2" /> + Note + </TabsTrigger> + <TabsTrigger + className="data-[state=active]:bg-white/10" + value="link" + > + <LinkIcon className="h-4 w-4 mr-2" /> + Link + </TabsTrigger> + <TabsTrigger + className="data-[state=active]:bg-white/10" + value="file" + > + <FileIcon className="h-4 w-4 mr-2" /> + File + </TabsTrigger> + <TabsTrigger + className="data-[state=active]:bg-white/10" + value="connect" + > + <PlugIcon className="h-4 w-4 mr-2" /> + Connect + </TabsTrigger> + </TabsList> + + <TabsContent className="space-y-4 mt-4" value="note"> + <form + onSubmit={(e) => { + e.preventDefault() + e.stopPropagation() + addContentForm.handleSubmit() + }} + > + <div className="grid gap-4"> + {/* Note Input */} + <motion.div + animate={{ opacity: 1, y: 0 }} + className="flex flex-col gap-2" + initial={{ opacity: 0, y: 10 }} + transition={{ delay: 0.1 }} + > + <label + className="text-sm font-medium" + htmlFor="note-content" + > + Note + </label> + <addContentForm.Field + name="content" + validators={{ + onChange: ({ value }) => { + if (!value || value.trim() === "") { + return "Note is required" + } + return undefined + }, + }} + > + {({ state, handleChange, handleBlur }) => ( + <> + <Textarea + className={`bg-white/5 border-white/10 text-white min-h-32 max-h-64 overflow-y-auto resize-none ${addContentMutation.isPending + ? "opacity-50" + : "" + }`} + disabled={addContentMutation.isPending} + id="note-content" + onBlur={handleBlur} + onChange={(e) => handleChange(e.target.value)} + placeholder="Write your note here..." + value={state.value} + /> + {state.meta.errors.length > 0 && ( + <motion.p + animate={{ opacity: 1, height: "auto" }} + className="text-sm text-red-400 mt-1" + exit={{ opacity: 0, height: 0 }} + initial={{ opacity: 0, height: 0 }} + > + {state.meta.errors + .map((error) => + typeof error === "string" + ? error + : (error?.message ?? + `Error: ${JSON.stringify(error)}`), + ) + .join(", ")} + </motion.p> + )} + </> + )} + </addContentForm.Field> + </motion.div> + + {/* Project Selection */} + <motion.div + animate={{ opacity: 1, y: 0 }} + className={`flex flex-col gap-2 ${addContentMutation.isPending ? "opacity-50" : "" + }`} + initial={{ opacity: 0, y: 10 }} + transition={{ delay: 0.15 }} + > + <label + className="text-sm font-medium" + htmlFor="note-project" + > + Project + </label> + <addContentForm.Field name="project"> + {({ state, handleChange }) => ( + <Select + disabled={ + isLoadingProjects || + addContentMutation.isPending + } + onValueChange={(value) => { + if (value === "create-new-project") { + setShowCreateProjectDialog(true) + } else { + handleChange(value) + } + }} + value={state.value} + > + <SelectTrigger + className="bg-white/5 border-white/10 text-white" + id="note-project" + > + <SelectValue placeholder="Select a project" /> + </SelectTrigger> + <SelectContent className="bg-black/90 backdrop-blur-xl border-white/10"> + <SelectItem + className="text-white hover:bg-white/10" + key="default" + value="sm_project_default" + > + Default Project + </SelectItem> + {projects + .filter( + (p) => + p.containerTag !== "sm_project_default" && + p.id, + ) + .map((project) => ( + <SelectItem + className="text-white hover:bg-white/10" + key={project.id || project.containerTag} + value={project.containerTag} + > + {project.name} + </SelectItem> + ))} + <SelectItem + className="text-white hover:bg-white/10 border-t border-white/10 mt-1" + key="create-new" + value="create-new-project" + > + <div className="flex items-center gap-2"> + <Plus className="h-4 w-4" /> + <span>Create new project</span> + </div> + </SelectItem> + </SelectContent> + </Select> + )} + </addContentForm.Field> + <p className="text-xs text-white/50 mt-1"> + Choose which project to save this note to + </p> + </motion.div> + </div> + <DialogFooter className="mt-6"> + <motion.div + whileHover={{ scale: 1.05 }} + whileTap={{ scale: 0.95 }} + > + <Button + className="bg-white/5 hover:bg-white/10 border-white/10 text-white" + onClick={() => { + setShowAddDialog(false) + onClose?.() + addContentForm.reset() + }} + type="button" + variant="outline" + > + Cancel + </Button> + </motion.div> + <motion.div + whileHover={{ scale: 1.05 }} + whileTap={{ scale: 0.95 }} + > + <Button + className="bg-white/10 hover:bg-white/20 text-white border-white/20" + disabled={ + addContentMutation.isPending || + !addContentForm.state.canSubmit + } + type="submit" + > + {addContentMutation.isPending ? ( + <> + <Loader2 className="h-4 w-4 animate-spin mr-2" /> + Adding... + </> + ) : ( + <> + <Plus className="h-4 w-4 mr-2" /> + Add Note + </> + )} + </Button> + </motion.div> + </DialogFooter> + </form> + </TabsContent> + + <TabsContent className="space-y-4 mt-4" value="link"> + <form + onSubmit={(e) => { + e.preventDefault() + e.stopPropagation() + addContentForm.handleSubmit() + }} + > + <div className="grid gap-4"> + {/* Link Input */} + <motion.div + animate={{ opacity: 1, y: 0 }} + className="flex flex-col gap-2" + initial={{ opacity: 0, y: 10 }} + transition={{ delay: 0.1 }} + > + <label + className="text-sm font-medium" + htmlFor="link-content" + > + Link + </label> + <addContentForm.Field + name="content" + validators={{ + onChange: ({ value }) => { + if (!value || value.trim() === "") { + return "Link is required" + } + try { + new URL(value) + return undefined + } catch { + return "Please enter a valid link" + } + }, + }} + > + {({ state, handleChange, handleBlur }) => ( + <> + <Input + className={`bg-white/5 border-white/10 text-white ${addContentMutation.isPending + ? "opacity-50" + : "" + }`} + disabled={addContentMutation.isPending} + id="link-content" + onBlur={handleBlur} + onChange={(e) => handleChange(e.target.value)} + placeholder="https://example.com/article" + value={state.value} + /> + {state.meta.errors.length > 0 && ( + <motion.p + animate={{ opacity: 1, height: "auto" }} + className="text-sm text-red-400 mt-1" + exit={{ opacity: 0, height: 0 }} + initial={{ opacity: 0, height: 0 }} + > + {state.meta.errors + .map((error) => + typeof error === "string" + ? error + : (error?.message ?? + `Error: ${JSON.stringify(error)}`), + ) + .join(", ")} + </motion.p> + )} + </> + )} + </addContentForm.Field> + </motion.div> + + {/* Project Selection */} + <motion.div + animate={{ opacity: 1, y: 0 }} + className={`flex flex-col gap-2 ${addContentMutation.isPending ? "opacity-50" : "" + }`} + initial={{ opacity: 0, y: 10 }} + transition={{ delay: 0.15 }} + > + <label + className="text-sm font-medium" + htmlFor="link-project" + > + Project + </label> + <addContentForm.Field name="project"> + {({ state, handleChange }) => ( + <Select + disabled={ + isLoadingProjects || + addContentMutation.isPending + } + onValueChange={(value) => { + if (value === "create-new-project") { + setShowCreateProjectDialog(true) + } else { + handleChange(value) + } + }} + value={state.value} + > + <SelectTrigger + className="bg-white/5 border-white/10 text-white" + id="link-project" + > + <SelectValue placeholder="Select a project" /> + </SelectTrigger> + <SelectContent className="bg-black/90 backdrop-blur-xl border-white/10"> + <SelectItem + className="text-white hover:bg-white/10" + key="default" + value="sm_project_default" + > + Default Project + </SelectItem> + {projects + .filter( + (p) => + p.containerTag !== "sm_project_default" && + p.id, + ) + .map((project) => ( + <SelectItem + className="text-white hover:bg-white/10" + key={project.id || project.containerTag} + value={project.containerTag} + > + {project.name} + </SelectItem> + ))} + <SelectItem + className="text-white hover:bg-white/10 border-t border-white/10 mt-1" + key="create-new" + value="create-new-project" + > + <div className="flex items-center gap-2"> + <Plus className="h-4 w-4" /> + <span>Create new project</span> + </div> + </SelectItem> + </SelectContent> + </Select> + )} + </addContentForm.Field> + <p className="text-xs text-white/50 mt-1"> + Choose which project to save this link to + </p> + </motion.div> + </div> + <DialogFooter className="mt-6"> + <motion.div + whileHover={{ scale: 1.05 }} + whileTap={{ scale: 0.95 }} + > + <Button + className="bg-white/5 hover:bg-white/10 border-white/10 text-white" + onClick={() => { + setShowAddDialog(false) + onClose?.() + addContentForm.reset() + }} + type="button" + variant="outline" + > + Cancel + </Button> + </motion.div> + <motion.div + whileHover={{ scale: 1.05 }} + whileTap={{ scale: 0.95 }} + > + <Button + className="bg-white/10 hover:bg-white/20 text-white border-white/20" + disabled={ + addContentMutation.isPending || + !addContentForm.state.canSubmit + } + type="submit" + > + {addContentMutation.isPending ? ( + <> + <Loader2 className="h-4 w-4 animate-spin mr-2" /> + Adding... + </> + ) : ( + <> + <Plus className="h-4 w-4 mr-2" /> + Add Link + </> + )} + </Button> + </motion.div> + </DialogFooter> + </form> + </TabsContent> + + <TabsContent className="space-y-4 mt-4" value="file"> + <form + onSubmit={(e) => { + e.preventDefault() + e.stopPropagation() + fileUploadForm.handleSubmit() + }} + > + <div className="grid gap-4"> + <motion.div + animate={{ opacity: 1, y: 0 }} + className="flex flex-col gap-2" + initial={{ opacity: 0, y: 10 }} + transition={{ delay: 0.1 }} + > + <label className="text-sm font-medium" htmlFor="file"> + File + </label> + <Dropzone + accept={{ + "application/pdf": [".pdf"], + "application/msword": [".doc"], + "application/vnd.openxmlformats-officedocument.wordprocessingml.document": + [".docx"], + "text/plain": [".txt"], + "text/markdown": [".md"], + "text/csv": [".csv"], + "application/json": [".json"], + "image/*": [ + ".png", + ".jpg", + ".jpeg", + ".gif", + ".webp", + ], + }} + className="bg-white/5 border-white/10 hover:bg-white/10 min-h-40" + maxFiles={10} + maxSize={10 * 1024 * 1024} // 10MB + onDrop={(acceptedFiles) => + setSelectedFiles(acceptedFiles) + } + src={selectedFiles} + > + <DropzoneEmptyState /> + <DropzoneContent className="overflow-auto" /> + </Dropzone> + </motion.div> + + <motion.div + animate={{ opacity: 1, y: 0 }} + className="flex flex-col gap-2" + initial={{ opacity: 0, y: 10 }} + transition={{ delay: 0.15 }} + > + <label + className="text-sm font-medium" + htmlFor="file-title" + > + Title (optional) + </label> + <fileUploadForm.Field name="title"> + {({ state, handleChange, handleBlur }) => ( + <Input + className="bg-white/5 border-white/10 text-white" + id="file-title" + onBlur={handleBlur} + onChange={(e) => handleChange(e.target.value)} + placeholder="Give this file a title" + value={state.value} + /> + )} + </fileUploadForm.Field> + </motion.div> + + <motion.div + animate={{ opacity: 1, y: 0 }} + className="flex flex-col gap-2" + initial={{ opacity: 0, y: 10 }} + transition={{ delay: 0.2 }} + > + <label + className="text-sm font-medium" + htmlFor="file-description" + > + Description (optional) + </label> + <fileUploadForm.Field name="description"> + {({ state, handleChange, handleBlur }) => ( + <Textarea + className="bg-white/5 border-white/10 text-white min-h-20 max-h-40 overflow-y-auto resize-none" + id="file-description" + onBlur={handleBlur} + onChange={(e) => handleChange(e.target.value)} + placeholder="Add notes or context about this file" + value={state.value} + /> + )} + </fileUploadForm.Field> + </motion.div> + + <motion.div + animate={{ opacity: 1, y: 0 }} + className="flex flex-col gap-2" + initial={{ opacity: 0, y: 10 }} + transition={{ delay: 0.25 }} + > + <label + className="text-sm font-medium" + htmlFor="file-project" + > + Project + </label> + <fileUploadForm.Field name="project"> + {({ state, handleChange }) => ( + <Select + disabled={isLoadingProjects} + onValueChange={(value) => { + if (value === "create-new-project") { + setShowCreateProjectDialog(true) + } else { + handleChange(value) + } + }} + value={state.value} + > + <SelectTrigger + className="bg-white/5 border-white/10 text-white" + id="file-project" + > + <SelectValue placeholder="Select a project" /> + </SelectTrigger> + <SelectContent className="bg-black/90 backdrop-blur-xl border-white/10"> + <SelectItem + className="text-white hover:bg-white/10" + key="default" + value="sm_project_default" + > + Default Project + </SelectItem> + {projects + .filter( + (p) => + p.containerTag !== "sm_project_default" && + p.id, + ) + .map((project) => ( + <SelectItem + className="text-white hover:bg-white/10" + key={project.id || project.containerTag} + value={project.containerTag} + > + {project.name} + </SelectItem> + ))} + <SelectItem + className="text-white hover:bg-white/10 border-t border-white/10 mt-1" + key="create-new" + value="create-new-project" + > + <div className="flex items-center gap-2"> + <Plus className="h-4 w-4" /> + <span>Create new project</span> + </div> + </SelectItem> + </SelectContent> + </Select> + )} + </fileUploadForm.Field> + <p className="text-xs text-white/50 mt-1"> + Choose which project to save this file to + </p> + </motion.div> + </div> + <DialogFooter className="mt-6"> + <motion.div + whileHover={{ scale: 1.05 }} + whileTap={{ scale: 0.95 }} + > + <Button + className="bg-white/5 hover:bg-white/10 border-white/10 text-white" + onClick={() => { + setShowAddDialog(false) + onClose?.() + fileUploadForm.reset() + setSelectedFiles([]) + }} + type="button" + variant="outline" + > + Cancel + </Button> + </motion.div> + <motion.div + whileHover={{ scale: 1.05 }} + whileTap={{ scale: 0.95 }} + > + <Button + className="bg-white/10 hover:bg-white/20 text-white border-white/20" + disabled={ + fileUploadMutation.isPending || + selectedFiles.length === 0 + } + type="submit" + > + {fileUploadMutation.isPending ? ( + <> + <Loader2 className="h-4 w-4 animate-spin mr-2" /> + Uploading... + </> + ) : ( + <> + <UploadIcon className="h-4 w-4 mr-2" /> + Upload File + </> + )} + </Button> + </motion.div> + </DialogFooter> + </form> + </TabsContent> + + <TabsContent className="space-y-4 mt-4" value="connect"> + <ConnectionsTabContent /> + </TabsContent> + </Tabs> + </motion.div> + </DialogContent> + </Dialog> + )} + + {/* Create Project Dialog */} + {showCreateProjectDialog && ( + <Dialog + key="create-project-dialog" + onOpenChange={setShowCreateProjectDialog} + open={showCreateProjectDialog} + > + <DialogContent className="sm:max-w-2xl bg-black/90 backdrop-blur-xl border-white/10 text-white z-[80]"> + <motion.div + animate={{ opacity: 1, scale: 1 }} + initial={{ opacity: 0, scale: 0.95 }} + > + <DialogHeader> + <DialogTitle>Create New Project</DialogTitle> + <DialogDescription className="text-white/60"> + Give your project a unique name + </DialogDescription> + </DialogHeader> + <div className="grid gap-4 py-4"> + <motion.div + animate={{ opacity: 1, y: 0 }} + className="flex flex-col gap-2" + initial={{ opacity: 0, y: 10 }} + transition={{ delay: 0.1 }} + > + <Label htmlFor="projectName">Project Name</Label> + <Input + className="bg-white/5 border-white/10 text-white" + id="projectName" + onChange={(e) => setNewProjectName(e.target.value)} + placeholder="My Awesome Project" + value={newProjectName} + /> + <p className="text-xs text-white/50"> + This will help you organize your memories + </p> + </motion.div> + </div> + <DialogFooter> + <motion.div + whileHover={{ scale: 1.05 }} + whileTap={{ scale: 0.95 }} + > + <Button + className="bg-white/5 hover:bg-white/10 border-white/10 text-white" + onClick={() => { + setShowCreateProjectDialog(false) + setNewProjectName("") + }} + type="button" + variant="outline" + > + Cancel + </Button> + </motion.div> + <motion.div + whileHover={{ scale: 1.05 }} + whileTap={{ scale: 0.95 }} + > + <Button + className="bg-white/10 hover:bg-white/20 text-white border-white/20" + disabled={ + createProjectMutation.isPending || !newProjectName.trim() + } + onClick={() => createProjectMutation.mutate(newProjectName)} + type="button" + > + {createProjectMutation.isPending ? ( + <> + <Loader2 className="h-4 w-4 animate-spin mr-2" /> + Creating... + </> + ) : ( + "Create Project" + )} + </Button> + </motion.div> + </DialogFooter> + </motion.div> + </DialogContent> + </Dialog> + )} + </AnimatePresence> + ) +} + +export function AddMemoryExpandedView() { + const [showDialog, setShowDialog] = useState(false) + const [selectedTab, setSelectedTab] = useState< + "note" | "link" | "file" | "connect" + >("note") + + const handleOpenDialog = (tab: "note" | "link" | "file" | "connect") => { + setSelectedTab(tab) + setShowDialog(true) + } + + return ( + <> + <motion.div + animate={{ opacity: 1, y: 0 }} + className="space-y-6" + initial={{ opacity: 0, y: 10 }} + > + <p className="text-sm text-white/70"> + Save any webpage, article, or file to your memory + </p> + + <div className="flex flex-wrap gap-2"> + <motion.div whileHover={{ scale: 1.05 }} whileTap={{ scale: 0.95 }}> + <Button + className="bg-white/10 hover:bg-white/20 text-white border-white/20" + onClick={() => handleOpenDialog("note")} + size="sm" + variant="outline" + > + <Brain className="h-4 w-4 mr-2" /> + Note + </Button> + </motion.div> + + <motion.div whileHover={{ scale: 1.05 }} whileTap={{ scale: 0.95 }}> + <Button + className="bg-white/10 hover:bg-white/20 text-white border-white/20" + onClick={() => handleOpenDialog("link")} + size="sm" + variant="outline" + > + <LinkIcon className="h-4 w-4 mr-2" /> + Link + </Button> + </motion.div> + + <motion.div whileHover={{ scale: 1.05 }} whileTap={{ scale: 0.95 }}> + <Button + className="bg-white/10 hover:bg-white/20 text-white border-white/20" + onClick={() => handleOpenDialog("file")} + size="sm" + variant="outline" + > + <FileIcon className="h-4 w-4 mr-2" /> + File + </Button> + </motion.div> + + <motion.div whileHover={{ scale: 1.05 }} whileTap={{ scale: 0.95 }}> + <Button + className="bg-white/10 hover:bg-white/20 text-white border-white/20" + onClick={() => handleOpenDialog("connect")} + size="sm" + variant="outline" + > + <PlugIcon className="h-4 w-4 mr-2" /> + Connect + </Button> + </motion.div> + </div> + </motion.div> + + {showDialog && ( + <AddMemoryView + initialTab={selectedTab} + onClose={() => setShowDialog(false)} + /> + )} + </> + ) +} diff --git a/apps/web/components/views/billing.tsx b/apps/web/components/views/billing.tsx new file mode 100644 index 00000000..b0c436bb --- /dev/null +++ b/apps/web/components/views/billing.tsx @@ -0,0 +1,261 @@ +import { useAuth } from "@lib/auth-context" +import { + fetchConnectionsFeature, + fetchMemoriesFeature, + fetchSubscriptionStatus, +} from "@lib/queries" +import { Button } from "@ui/components/button" +import { HeadingH3Bold } from "@ui/text/heading/heading-h3-bold" +import { useCustomer } from "autumn-js/react" +import { CheckCircle, LoaderIcon, X } from "lucide-react" +import { motion } from "motion/react" +import Link from "next/link" +import { useState, useEffect } from "react" +import { analytics } from "@/lib/analytics" + +export function BillingView() { + const autumn = useCustomer() + const { user } = useAuth() + const [isLoading, setIsLoading] = useState(false) + + useEffect(() => { + analytics.billingViewed() + }, []) + + const { data: memoriesCheck } = fetchMemoriesFeature(autumn as any) + + const memoriesUsed = memoriesCheck?.usage ?? 0 + const memoriesLimit = memoriesCheck?.included_usage ?? 0 + + const { data: connectionsCheck } = fetchConnectionsFeature(autumn as any) + + const connectionsUsed = connectionsCheck?.usage ?? 0 + + // Fetch subscription status with React Query + const { + data: status = { + consumer_pro: null, + }, + isLoading: isCheckingStatus, + } = fetchSubscriptionStatus(autumn as any) + + // Handle upgrade + const handleUpgrade = async () => { + analytics.upgradeInitiated() + setIsLoading(true) + try { + await autumn.attach({ + productId: "consumer_pro", + successUrl: "https://app.supermemory.ai/", + }) + analytics.upgradeCompleted() + window.location.reload() + } catch (error) { + console.error(error) + setIsLoading(false) + } + } + + // Handle manage billing + const handleManageBilling = async () => { + analytics.billingPortalOpened() + await autumn.openBillingPortal({ + returnUrl: "https://app.supermemory.ai", + }) + } + + const isPro = status.consumer_pro + + if (user?.isAnonymous) { + return ( + <motion.div + animate={{ opacity: 1, scale: 1 }} + className="text-center py-8" + initial={{ opacity: 0, scale: 0.9 }} + transition={{ type: "spring", damping: 20 }} + > + <p className="text-white/70 mb-4">Sign in to unlock premium features</p> + <motion.div whileHover={{ scale: 1.05 }} whileTap={{ scale: 0.95 }}> + <Button + asChild + className="bg-white/10 hover:bg-white/20 text-white border-white/20" + size="sm" + > + <Link href="/login">Sign in</Link> + </Button> + </motion.div> + </motion.div> + ) + } + + if (isPro) { + return ( + <motion.div + animate={{ opacity: 1, y: 0 }} + className="space-y-6" + initial={{ opacity: 0, y: 10 }} + > + <div className="space-y-3"> + <HeadingH3Bold className="text-white flex items-center gap-2"> + Pro Plan + <span className="text-xs bg-green-500/20 text-green-400 px-2 py-0.5 rounded-full"> + Active + </span> + </HeadingH3Bold> + <p className="text-sm text-white/70"> + You're enjoying expanded memory capacity with supermemory Pro! + </p> + </div> + + {/* Current Usage */} + <div className="space-y-3"> + <h4 className="text-sm font-medium text-white/90">Current Usage</h4> + <div className="space-y-2"> + <div className="flex justify-between items-center"> + <span className="text-sm text-white/70">Memories</span> + <span className="text-sm text-white/90"> + {memoriesUsed} / {memoriesLimit} + </span> + </div> + <div className="w-full bg-white/10 rounded-full h-2"> + <div + className="bg-green-500 h-2 rounded-full transition-all" + style={{ + width: `${Math.min((memoriesUsed / memoriesLimit) * 100, 100)}%`, + }} + /> + </div> + </div> + <div className="space-y-2"> + <div className="flex justify-between items-center"> + <span className="text-sm text-white/70">Connections</span> + <span className="text-sm text-white/90"> + {connectionsUsed} / 10 + </span> + </div> + </div> + </div> + + <motion.div whileHover={{ scale: 1.02 }} whileTap={{ scale: 0.98 }}> + <Button + className="bg-white/10 hover:bg-white/20 text-white border-white/20" + onClick={handleManageBilling} + size="sm" + variant="outline" + > + Manage Billing + </Button> + </motion.div> + </motion.div> + ) + } + + return ( + <motion.div + animate={{ opacity: 1, y: 0 }} + className="space-y-6" + initial={{ opacity: 0, y: 10 }} + > + {/* Current Usage - Free Plan */} + <div className="space-y-3"> + <HeadingH3Bold className="text-white">Current Plan: Free</HeadingH3Bold> + <div className="space-y-2"> + <div className="flex justify-between items-center"> + <span className="text-sm text-white/70">Memories</span> + <span + className={`text-sm ${memoriesUsed >= memoriesLimit ? "text-red-400" : "text-white/90"}`} + > + {memoriesUsed} / {memoriesLimit} + </span> + </div> + <div className="w-full bg-white/10 rounded-full h-2"> + <div + className={`h-2 rounded-full transition-all ${ + memoriesUsed >= memoriesLimit ? "bg-red-500" : "bg-blue-500" + }`} + style={{ + width: `${Math.min((memoriesUsed / memoriesLimit) * 100, 100)}%`, + }} + /> + </div> + </div> + </div> + + {/* Comparison */} + <div className="space-y-4"> + <HeadingH3Bold className="text-white">Upgrade to Pro</HeadingH3Bold> + + <div className="grid grid-cols-1 md:grid-cols-2 gap-4"> + {/* Free Plan */} + <div className="p-4 bg-white/5 rounded-lg border border-white/10"> + <h4 className="font-medium text-white/90 mb-3">Free Plan</h4> + <ul className="space-y-2"> + <li className="flex items-center gap-2 text-sm text-white/70"> + <CheckCircle className="h-4 w-4 text-green-400" /> + 200 memories + </li> + <li className="flex items-center gap-2 text-sm text-white/70"> + <X className="h-4 w-4 text-red-400" /> + No connections + </li> + <li className="flex items-center gap-2 text-sm text-white/70"> + <CheckCircle className="h-4 w-4 text-green-400" /> + Basic search + </li> + </ul> + </div> + + {/* Pro Plan */} + <div className="p-4 bg-gradient-to-br from-blue-500/10 to-purple-500/10 rounded-lg border border-blue-500/20"> + <h4 className="font-medium text-white mb-3 flex items-center gap-2"> + Pro Plan + <span className="text-xs bg-blue-500/20 text-blue-400 px-2 py-0.5 rounded-full"> + Recommended + </span> + </h4> + <ul className="space-y-2"> + <li className="flex items-center gap-2 text-sm text-white/90"> + <CheckCircle className="h-4 w-4 text-green-400" /> + 5000 memories + </li> + <li className="flex items-center gap-2 text-sm text-white/90"> + <CheckCircle className="h-4 w-4 text-green-400" /> + 10 connections + </li> + <li className="flex items-center gap-2 text-sm text-white/90"> + <CheckCircle className="h-4 w-4 text-green-400" /> + Advanced search + </li> + <li className="flex items-center gap-2 text-sm text-white/90"> + <CheckCircle className="h-4 w-4 text-green-400" /> + Priority support + </li> + </ul> + </div> + </div> + + <motion.div whileHover={{ scale: 1.02 }} whileTap={{ scale: 0.98 }}> + <Button + className="bg-gradient-to-r from-blue-500 to-purple-500 hover:from-blue-600 hover:to-purple-600 text-white border-0 w-full" + disabled={isLoading || isCheckingStatus} + onClick={handleUpgrade} + size="sm" + > + {isLoading || isCheckingStatus ? ( + <> + <LoaderIcon className="h-4 w-4 animate-spin mr-2" /> + Upgrading... + </> + ) : ( + <div>Upgrade to Pro - $15/month (only for first 100 users)</div> + )} + </Button> + </motion.div> + + <p className="text-xs text-white/50 text-center"> + Cancel anytime. No questions asked. + </p> + </div> + </motion.div> + ) +} diff --git a/apps/web/components/views/chat/chat-messages.tsx b/apps/web/components/views/chat/chat-messages.tsx new file mode 100644 index 00000000..0517ac8d --- /dev/null +++ b/apps/web/components/views/chat/chat-messages.tsx @@ -0,0 +1,322 @@ +'use client' + +import { useChat, useCompletion } from '@ai-sdk/react' +import { DefaultChatTransport } from 'ai' +import { useEffect, useRef, useState } from 'react' +import { useProject, usePersistentChat } from '@/stores' +import { Button } from '@ui/components/button' +import { Input } from '@ui/components/input' +import ReactMarkdown from 'react-markdown' +import remarkGfm from 'remark-gfm' +import { Spinner } from '../../spinner' +import { X, ArrowUp, Check, RotateCcw, Copy } from 'lucide-react' +import { useGraphHighlights } from '@/stores/highlights' +import { cn } from '@lib/utils' +import { TextShimmer } from '@/components/text-shimmer' +import { toast } from 'sonner' + +function useStickyAutoScroll(triggerKeys: ReadonlyArray<unknown>) { + const scrollContainerRef = useRef<HTMLDivElement>(null) + const bottomRef = useRef<HTMLDivElement>(null) + const [isAutoScroll, setIsAutoScroll] = useState(true) + const [isFarFromBottom, setIsFarFromBottom] = useState(false) + + function scrollToBottom(behavior: ScrollBehavior = 'auto') { + const node = bottomRef.current + if (node) node.scrollIntoView({ behavior, block: 'end' }) + } + + useEffect(function observeBottomVisibility() { + const container = scrollContainerRef.current + const sentinel = bottomRef.current + if (!container || !sentinel) return + + const observer = new IntersectionObserver((entries) => { + if (!entries || entries.length === 0) return + const isIntersecting = entries.some((e) => e.isIntersecting) + setIsAutoScroll(isIntersecting) + }, { root: container, rootMargin: '0px 0px 80px 0px', threshold: 0 }) + observer.observe(sentinel) + return () => observer.disconnect() + }, []) + + useEffect(function observeContentResize() { + const container = scrollContainerRef.current + if (!container) return + const resizeObserver = new ResizeObserver(() => { + if (isAutoScroll) scrollToBottom('auto') + const distanceFromBottom = container.scrollHeight - container.scrollTop - container.clientHeight + setIsFarFromBottom(distanceFromBottom > 100) + }) + resizeObserver.observe(container) + return () => resizeObserver.disconnect() + }, [isAutoScroll]) + + function enableAutoScroll() { + setIsAutoScroll(true) + } + + useEffect(function autoScrollOnNewContent() { + if (isAutoScroll) scrollToBottom('auto') + }, [isAutoScroll, ...triggerKeys]) + + function recomputeDistanceFromBottom() { + const container = scrollContainerRef.current + if (!container) return + const distanceFromBottom = container.scrollHeight - container.scrollTop - container.clientHeight + setIsFarFromBottom(distanceFromBottom > 100) + } + + useEffect(() => { + recomputeDistanceFromBottom() + }, [...triggerKeys]) + + function onScroll() { + recomputeDistanceFromBottom() + } + + return { scrollContainerRef, bottomRef, isAutoScroll, isFarFromBottom, onScroll, enableAutoScroll, scrollToBottom } as const +} + +export function ChatMessages() { + const { selectedProject } = useProject() + const { currentChatId, setCurrentChatId, setConversation, getCurrentConversation, setConversationTitle, getCurrentChat } = usePersistentChat() + + const activeChatIdRef = useRef<string | null>(null) + const shouldGenerateTitleRef = useRef<boolean>(false) + + const { setDocumentIds } = useGraphHighlights() + + const { messages, sendMessage, status, stop, setMessages, id, regenerate } = useChat({ + id: currentChatId ?? undefined, + transport: new DefaultChatTransport({ + api: `${process.env.NEXT_PUBLIC_BACKEND_URL}/chat`, + credentials: 'include', + body: { metadata: { projectId: selectedProject } }, + }), + maxSteps: 2, + onFinish: (result) => { + const activeId = activeChatIdRef.current + if (!activeId) return + if (result.message.role !== 'assistant') return + + if (shouldGenerateTitleRef.current) { + const textPart = result.message.parts.find((p: any) => p?.type === 'text') as any + const text = textPart?.text?.trim() + if (text) { + shouldGenerateTitleRef.current = false + complete(text) + } + } + }, + }) + + useEffect(() => { + activeChatIdRef.current = (currentChatId ?? id) ?? null + }, [currentChatId, id]) + + useEffect(() => { + if (id && id !== currentChatId) { + setCurrentChatId(id) + } + }, [id, currentChatId, setCurrentChatId]) + + useEffect(() => { + const msgs = getCurrentConversation() + setMessages(msgs ?? []) + setInput('') + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [currentChatId]) + + useEffect(() => { + const activeId = currentChatId ?? id + if (activeId && messages.length > 0) { + setConversation(activeId, messages) + } + }, [messages, currentChatId, id, setConversation]) + + const [input, setInput] = useState('') + const { complete } = useCompletion({ + api: `${process.env.NEXT_PUBLIC_BACKEND_URL}/chat/title`, + credentials: 'include', + onFinish: (_, completion) => { + const activeId = activeChatIdRef.current + if (!completion || !activeId) return + setConversationTitle(activeId, completion.trim()) + } + }) + + // Update graph highlights from the most recent tool-searchMemories output + useEffect(() => { + try { + const lastAssistant = [...messages].reverse().find((m) => m.role === 'assistant') + if (!lastAssistant) return + const lastSearchPart = [...(lastAssistant.parts as any[])] + .reverse() + .find((p) => p?.type === 'tool-searchMemories' && p?.state === 'output-available') + if (!lastSearchPart) return + const output = (lastSearchPart as any).output + const ids = Array.isArray(output?.results) + ? (output.results as any[]).map((r) => r?.documentId).filter(Boolean) as string[] + : [] + if (ids.length > 0) { + setDocumentIds(ids) + } + } catch { } + }, [messages, setDocumentIds]) + + useEffect(() => { + const currentSummary = getCurrentChat() + const hasTitle = Boolean(currentSummary?.title && currentSummary.title.trim().length > 0) + shouldGenerateTitleRef.current = !hasTitle + }, [currentChatId, id, getCurrentChat]) + const { scrollContainerRef, bottomRef, isFarFromBottom, onScroll, enableAutoScroll, scrollToBottom } = useStickyAutoScroll([messages, status]) + + return ( + <> + <div className="relative grow"> + <div ref={scrollContainerRef} onScroll={onScroll} className="flex flex-col gap-2 absolute inset-0 overflow-y-auto px-4 pt-4 pb-7 scroll-pb-7"> + {messages.map((message) => ( + <div key={message.id} className={cn('flex flex-col', message.role === 'user' ? 'items-end' : 'items-start')}> + <div className="flex flex-col gap-2 max-w-4/5 bg-white/10 py-3 px-4 rounded-lg"> + {message.parts + .filter((part) => ['text', 'tool-searchMemories', 'tool-addMemory'].includes(part.type)) + .map((part, index) => { + switch (part.type) { + case 'text': + return ( + <div key={index} className="prose prose-sm prose-invert max-w-none"> + <ReactMarkdown remarkPlugins={[remarkGfm]}>{(part as any).text}</ReactMarkdown> + </div> + ) + case 'tool-searchMemories': + switch (part.state) { + case 'input-available': + case 'input-streaming': + return ( + <div key={index} className="text-sm flex items-center gap-2 text-muted-foreground"> + <Spinner className="size-4" /> Searching memories... + </div> + ) + case 'output-error': + return ( + <div key={index} className="text-sm flex items-center gap-2 text-muted-foreground"> + <X className="size-4" /> Error recalling memories + </div> + ) + case 'output-available': + { + const output = (part as any).output + const foundCount = typeof output === 'object' && output !== null && 'count' in output ? Number(output.count) || 0 : 0 + const ids = Array.isArray(output?.results) ? (output.results as any[]).map((r) => r?.documentId).filter(Boolean) as string[] : [] + return ( + <div key={index} className="text-sm flex items-center gap-2 text-muted-foreground"> + <Check className="size-4" /> Found {foundCount} memories + </div> + ) + } + + } + case 'tool-addMemory': + switch (part.state) { + case 'input-available': + return ( + <div key={index} className="text-sm flex items-center gap-2 text-muted-foreground"> + <Spinner className="size-4" /> Adding memory... + </div> + ) + case 'output-error': + return ( + <div key={index} className="text-sm flex items-center gap-2 text-muted-foreground"> + <X className="size-4" /> Error adding memory + </div> + ) + case 'output-available': + return ( + <div key={index} className="text-sm flex items-center gap-2 text-muted-foreground"> + <Check className="size-4" /> Memory added + </div> + ) + case 'input-streaming': + return ( + <div key={index} className="text-sm flex items-center gap-2 text-muted-foreground"> + <Spinner className="size-4" /> Adding memory... + </div> + ) + } + } + + return null + })} + </div> + {message.role === 'assistant' && ( + <div className='flex items-center gap-0.5 mt-0.5'> + <Button variant="ghost" size="icon" className='size-7 text-muted-foreground hover:text-foreground' onClick={() => { + navigator.clipboard.writeText(message.parts.filter((p) => p.type === 'text')?.map((p) => (p as any).text).join('\n') ?? '') + toast.success('Copied to clipboard') + }}> + <Copy className="size-3.5" /> + </Button> + <Button variant="ghost" size="icon" className='size-6 text-muted-foreground hover:text-foreground' onClick={() => regenerate({ messageId: message.id })}> + <RotateCcw className="size-3.5" /> + </Button> + + </div> + )} + </div> + ))} + {status === 'submitted' && ( + <div className="flex text-muted-foreground justify-start gap-2 px-4 py-3 items-center w-full"> + <Spinner className="size-4" /> + <TextShimmer className='text-sm' duration={1.5}>Thinking...</TextShimmer> + </div> + )} + <div ref={bottomRef} /> + </div> + + <Button + type="button" + onClick={() => { + enableAutoScroll() + scrollToBottom('smooth') + }} + className={cn( + 'rounded-full w-fit mx-auto shadow-md z-10 absolute inset-x-0 bottom-4 flex justify-center', + 'transition-all duration-200 ease-out', + isFarFromBottom ? 'opacity-100 scale-100 pointer-events-auto' : 'opacity-0 scale-95 pointer-events-none', + )} + variant="default" + size="sm" + > + Scroll to bottom + </Button> + </div> + + <form + className="flex gap-2 px-4 pb-4 pt-1 relative" + onSubmit={(e) => { + e.preventDefault() + if (status === 'submitted') return + if (status === 'streaming') { + stop() + return + } + if (input.trim()) { + enableAutoScroll() + scrollToBottom('auto') + sendMessage({ text: input }) + setInput('') + } + }} + > + <div className="absolute top-0 left-0 -mt-7 w-full h-7 bg-gradient-to-t from-background to-transparent" /> + <Input className="w-full" value={input} onChange={(e) => setInput(e.target.value)} disabled={status === 'submitted'} placeholder="Say something..." /> + <Button type="submit" disabled={status === 'submitted'}> + {status === 'ready' ? <ArrowUp className="size-4" /> : status === 'submitted' ? <Spinner className="size-4" /> : <X className="size-4" />} + </Button> + </form> + </> + ) +} + + diff --git a/apps/web/components/views/chat/index.tsx b/apps/web/components/views/chat/index.tsx new file mode 100644 index 00000000..a547f590 --- /dev/null +++ b/apps/web/components/views/chat/index.tsx @@ -0,0 +1,124 @@ +'use client'; + +import { useChatOpen, useProject } from '@/stores'; +import { usePersistentChat } from '@/stores'; +import { Button } from '@ui/components/button'; +import { Plus, X, Trash2, HistoryIcon } from 'lucide-react'; +import { useMemo, useState } from 'react'; +import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@ui/components/dialog"; +import { ScrollArea } from '@ui/components/scroll-area'; +import { cn } from '@lib/utils'; +import { ChatMessages } from './chat-messages'; +import { formatDistanceToNow } from 'date-fns'; +import { analytics } from '@/lib/analytics'; + +export function ChatRewrite() { + const { setIsOpen } = useChatOpen(); + const { selectedProject } = useProject(); + const { conversations, currentChatId, setCurrentChatId, getCurrentChat } = usePersistentChat(); + + const [isDialogOpen, setIsDialogOpen] = useState(false); + + const sorted = useMemo(() => { + return [...conversations].sort((a, b) => (a.lastUpdated < b.lastUpdated ? 1 : -1)); + }, [conversations]); + + function handleNewChat() { + analytics.newChatStarted(); + const newId = crypto.randomUUID(); + setCurrentChatId(newId); + setIsDialogOpen(false); + } + + function formatRelativeTime(isoString: string): string { + return formatDistanceToNow(new Date(isoString), { addSuffix: true }); + } + + return ( + <div className='flex flex-col h-full overflow-y-hidden border-l bg-background'> + <div className="border-b px-4 py-3 flex justify-between items-center"> + <h3 className="text-lg font-semibold line-clamp-1 text-ellipsis overflow-hidden">{getCurrentChat()?.title ?? "New Chat"}</h3> + <div className="flex items-center gap-2"> + <Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}> + <DialogTrigger asChild> + <Button variant="outline" size="icon" onClick={() => analytics.chatHistoryViewed()}> + <HistoryIcon className='size-4 text-muted-foreground' /> + </Button> + </DialogTrigger> + <DialogContent className="sm:max-w-lg"> + <DialogHeader className="pb-4 border-b rounded-t-lg"> + <DialogTitle className=""> + Conversations + </DialogTitle> + <DialogDescription> + Project <span className="font-mono font-medium">{selectedProject}</span> + </DialogDescription> + </DialogHeader> + + <ScrollArea className="max-h-96"> + <div className="flex flex-col gap-1"> + {sorted.map((c) => { + const isActive = c.id === currentChatId; + return ( + <div + key={c.id} + role="button" + tabIndex={0} + onClick={() => { + setCurrentChatId(c.id); + setIsDialogOpen(false); + }} + className={cn( + 'flex items-center justify-between rounded-md px-3 py-2 outline-none', + 'transition-colors', + isActive ? 'bg-primary/10' : 'hover:bg-muted' + )} + aria-current={isActive ? 'true' : undefined} + > + <div className="min-w-0"> + <div className="flex items-center gap-2"> + <span className={cn('text-sm font-medium truncate', isActive ? 'text-foreground' : undefined)}> + {c.title || 'Untitled Chat'} + </span> + </div> + <div className="text-xs text-muted-foreground"> + Last updated {formatRelativeTime(c.lastUpdated)} + </div> + </div> + <Button + type="button" + variant="ghost" + size="icon" + onClick={(e) => { + e.stopPropagation(); + analytics.chatDeleted(); + }} + aria-label="Delete conversation" + > + <Trash2 className="size-4 text-muted-foreground" /> + </Button> + </div> + ); + })} + {sorted.length === 0 && ( + <div className="text-xs text-muted-foreground px-3 py-2">No conversations yet</div> + )} + </div> + </ScrollArea> + <Button variant="outline" size="lg" className="w-full border-dashed" onClick={handleNewChat}> + <Plus className='size-4 mr-1' /> New Conversation + </Button> + </DialogContent> + </Dialog> + <Button variant="outline" size="icon" onClick={handleNewChat}> + <Plus className='size-4 text-muted-foreground' /> + </Button> + <Button variant="outline" size="icon" onClick={() => setIsOpen(false)}> + <X className='size-4 text-muted-foreground' /> + </Button> + </div> + </div> + <ChatMessages /> + </div > + ); +}
\ No newline at end of file diff --git a/apps/web/components/views/connections-tab-content.tsx b/apps/web/components/views/connections-tab-content.tsx new file mode 100644 index 00000000..177c2a4d --- /dev/null +++ b/apps/web/components/views/connections-tab-content.tsx @@ -0,0 +1,382 @@ +"use client" + +import { $fetch } from "@lib/api" +import { + fetchConnectionsFeature, + fetchConsumerProProduct, +} from "@repo/lib/queries" +import { Button } from "@repo/ui/components/button" +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@repo/ui/components/dialog" +import { Skeleton } from "@repo/ui/components/skeleton" +import type { ConnectionResponseSchema } from "@repo/validation/api" +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query" +import { GoogleDrive, Notion, OneDrive } from "@ui/assets/icons" +import { useCustomer } from "autumn-js/react" +import { Plus, Trash2 } from "lucide-react" +import { AnimatePresence, motion } from "motion/react" +import Link from "next/link" +import { useEffect, useState } from "react" +import { toast } from "sonner" +import type { z } from "zod" +import { useProject } from "@/stores" +import { analytics } from "@/lib/analytics" + +// Define types +type Connection = z.infer<typeof ConnectionResponseSchema> + +// Connector configurations +const CONNECTORS = { + "google-drive": { + title: "Google Drive", + description: "Connect your Google Docs, Sheets, and Slides", + icon: GoogleDrive, + }, + notion: { + title: "Notion", + description: "Import your Notion pages and databases", + icon: Notion, + }, + onedrive: { + title: "OneDrive", + description: "Access your Microsoft Office documents", + icon: OneDrive, + }, +} as const + +type ConnectorProvider = keyof typeof CONNECTORS + +export function ConnectionsTabContent() { + const queryClient = useQueryClient() + const [showAddDialog, setShowAddDialog] = useState(false) + const { selectedProject } = useProject() + const autumn = useCustomer() + + const handleUpgrade = async () => { + try { + await autumn.attach({ + productId: "consumer_pro", + successUrl: "https://app.supermemory.ai/", + }) + window.location.reload() + } catch (error) { + console.error(error) + } + } + + const { data: connectionsCheck } = fetchConnectionsFeature(autumn as any) + const connectionsUsed = connectionsCheck?.balance ?? 0 + const connectionsLimit = connectionsCheck?.included_usage ?? 0 + + const { data: proCheck } = fetchConsumerProProduct(autumn as any) + const isProUser = proCheck?.allowed ?? false + + const canAddConnection = connectionsUsed < connectionsLimit + + // Fetch connections + const { + data: connections = [], + isLoading, + error, + } = useQuery({ + queryKey: ["connections"], + queryFn: async () => { + const response = await $fetch("@post/connections/list", { + body: { + containerTags: [], + }, + }) + + if (response.error) { + throw new Error(response.error?.message || "Failed to load connections") + } + + return response.data as Connection[] + }, + staleTime: 30 * 1000, + refetchInterval: 60 * 1000, + }) + + // Show error toast if connections fail to load + useEffect(() => { + if (error) { + toast.error("Failed to load connections", { + description: error instanceof Error ? error.message : "Unknown error", + }) + } + }, [error]) + + // Add connection mutation + const addConnectionMutation = useMutation({ + mutationFn: async (provider: ConnectorProvider) => { + // Check if user can add connections + if (!canAddConnection && !isProUser) { + throw new Error( + "Free plan doesn't include connections. Upgrade to Pro for unlimited connections.", + ) + } + + const response = await $fetch("@post/connections/:provider", { + params: { provider }, + body: { + redirectUrl: window.location.href, + containerTags: [selectedProject], + }, + }) + + // biome-ignore lint/style/noNonNullAssertion: its fine + if ("data" in response && !("error" in response.data!)) { + return response.data + } + + throw new Error(response.error?.message || "Failed to connect") + }, + onSuccess: (data, provider) => { + analytics.connectionAdded(provider) + analytics.connectionAuthStarted() + if (data?.authLink) { + window.location.href = data.authLink + } + }, + onError: (error, provider) => { + analytics.connectionAuthFailed() + toast.error(`Failed to connect ${provider}`, { + description: error instanceof Error ? error.message : "Unknown error", + }) + }, + }) + + // Delete connection mutation + const deleteConnectionMutation = useMutation({ + mutationFn: async (connectionId: string) => { + await $fetch(`@delete/connections/${connectionId}`) + }, + onSuccess: () => { + analytics.connectionDeleted() + toast.success( + "Connection removal has started. supermemory will permanently delete the documents in the next few minutes.", + ) + queryClient.invalidateQueries({ queryKey: ["connections"] }) + }, + onError: (error) => { + toast.error("Failed to remove connection", { + description: error instanceof Error ? error.message : "Unknown error", + }) + }, + }) + + const getProviderIcon = (provider: string) => { + const connector = CONNECTORS[provider as ConnectorProvider] + if (connector) { + const Icon = connector.icon + return <Icon className="h-10 w-10" /> + } + return <span className="text-2xl">📎</span> + } + + return ( + <div className="space-y-4"> + <div className="flex justify-between items-center mb-4"> + <div> + <p className="text-sm text-white/70"> + Connect your favorite services to import documents + </p> + {!isProUser && ( + <p className="text-xs text-white/50 mt-1"> + Connections require a Pro subscription + </p> + )} + </div> + <motion.div whileHover={{ scale: 1.05 }} whileTap={{ scale: 0.95 }}> + <Button + className="bg-white/10 hover:bg-white/20 text-white border-white/20" + disabled={!canAddConnection && !isProUser} + onClick={() => setShowAddDialog(true)} + size="sm" + > + <Plus className="h-4 w-4 mr-2" /> + Add + </Button> + </motion.div> + </div> + + {/* Show upgrade prompt for free users */} + {!isProUser && ( + <motion.div + animate={{ opacity: 1, y: 0 }} + className="p-4 bg-yellow-500/10 border border-yellow-500/20 rounded-lg" + initial={{ opacity: 0, y: -10 }} + > + <p className="text-sm text-yellow-400 mb-2"> + 🔌 Connections are a Pro feature + </p> + <p className="text-xs text-white/60 mb-3"> + Connect Google Drive, Notion, OneDrive and more to automatically + sync your documents. + </p> + <Button + asChild + className="bg-yellow-500/20 hover:bg-yellow-500/30 text-yellow-400 border-yellow-500/30" + onClick={handleUpgrade} + size="sm" + variant="secondary" + > + Upgrade to Pro + </Button> + </motion.div> + )} + + {isLoading ? ( + <div className="space-y-3"> + {[...Array(2)].map((_, i) => ( + <motion.div + animate={{ opacity: 1 }} + className="p-4 bg-white/5 rounded-lg" + initial={{ opacity: 0 }} + key={`skeleton-${Date.now()}-${i}`} + transition={{ delay: i * 0.1 }} + > + <Skeleton className="h-12 w-full bg-white/10" /> + </motion.div> + ))} + </div> + ) : connections.length === 0 ? ( + <motion.div + animate={{ opacity: 1, scale: 1 }} + className="text-center py-8" + initial={{ opacity: 0, scale: 0.9 }} + transition={{ type: "spring", damping: 20 }} + > + <p className="text-white/50 mb-4">No connections yet</p> + <motion.div whileHover={{ scale: 1.05 }} whileTap={{ scale: 0.95 }}> + <Button + className="bg-white/10 hover:bg-white/20 text-white border-white/20" + onClick={() => setShowAddDialog(true)} + size="sm" + variant="secondary" + > + Add Your First Connection + </Button> + </motion.div> + </motion.div> + ) : ( + <motion.div className="space-y-2"> + <AnimatePresence> + {connections.map((connection, index) => ( + <motion.div + animate={{ opacity: 1, x: 0 }} + className="flex items-center justify-between p-3 bg-white/5 rounded-lg hover:bg-white/10 transition-colors" + exit={{ opacity: 0, x: 20 }} + initial={{ opacity: 0, x: -20 }} + key={connection.id} + layout + transition={{ delay: index * 0.05 }} + > + <div className="flex items-center gap-3"> + <motion.div + animate={{ rotate: 0, opacity: 1 }} + initial={{ rotate: -180, opacity: 0 }} + transition={{ delay: index * 0.05 + 0.2 }} + > + {getProviderIcon(connection.provider)} + </motion.div> + <div> + <p className="font-medium text-white capitalize"> + {connection.provider.replace("-", " ")} + </p> + {connection.email && ( + <p className="text-sm text-white/60"> + {connection.email} + </p> + )} + </div> + </div> + <motion.div + whileHover={{ scale: 1.1 }} + whileTap={{ scale: 0.9 }} + > + <Button + className="text-white/50 hover:text-red-400" + disabled={deleteConnectionMutation.isPending} + onClick={() => + deleteConnectionMutation.mutate(connection.id) + } + size="icon" + variant="ghost" + > + <Trash2 className="h-4 w-4" /> + </Button> + </motion.div> + </motion.div> + ))} + </AnimatePresence> + </motion.div> + )} + + {/* Add Connection Dialog */} + <AnimatePresence> + {showAddDialog && ( + <Dialog onOpenChange={setShowAddDialog} open={showAddDialog}> + <DialogContent className="sm:max-w-2xl bg-black/90 backdrop-blur-xl border-white/10 text-white"> + <motion.div + animate={{ opacity: 1, scale: 1 }} + exit={{ opacity: 0, scale: 0.95 }} + initial={{ opacity: 0, scale: 0.95 }} + > + <DialogHeader> + <DialogTitle>Add a Connection</DialogTitle> + <DialogDescription className="text-white/60"> + Choose a service to connect and import your documents + </DialogDescription> + </DialogHeader> + <div className="grid gap-3 py-4"> + {Object.entries(CONNECTORS).map( + ([provider, config], index) => { + const Icon = config.icon + return ( + <motion.div + animate={{ opacity: 1, y: 0 }} + initial={{ opacity: 0, y: 20 }} + key={provider} + transition={{ delay: index * 0.05 }} + whileHover={{ scale: 1.02 }} + whileTap={{ scale: 0.98 }} + > + <Button + className="justify-start h-auto p-4 bg-white/5 hover:bg-white/10 border-white/10 text-white w-full" + disabled={addConnectionMutation.isPending} + onClick={() => { + addConnectionMutation.mutate( + provider as ConnectorProvider, + ) + setShowAddDialog(false) + // onClose?.() + }} + variant="outline" + > + <Icon className="h-8 w-8 mr-3" /> + <div className="text-left"> + <div className="font-medium">{config.title}</div> + <div className="text-sm text-white/60 mt-0.5"> + {config.description} + </div> + </div> + </Button> + </motion.div> + ) + }, + )} + </div> + </motion.div> + </DialogContent> + </Dialog> + )} + </AnimatePresence> + </div> + ) +} diff --git a/apps/web/components/views/mcp/index.tsx b/apps/web/components/views/mcp/index.tsx new file mode 100644 index 00000000..41ef97b8 --- /dev/null +++ b/apps/web/components/views/mcp/index.tsx @@ -0,0 +1,311 @@ +import { $fetch } from "@lib/api" +import { authClient } from "@lib/auth" +import { useAuth } from "@lib/auth-context" +import { useForm } from "@tanstack/react-form" +import { useMutation } from "@tanstack/react-query" +import { Button } from "@ui/components/button" +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@ui/components/dialog" +import { Input } from "@ui/components/input" +import { CopyableCell } from "@ui/copyable-cell" +import { Loader2 } from "lucide-react" +import { AnimatePresence, motion } from "motion/react" +import Image from "next/image" +import { generateSlug } from "random-word-slugs" +import { useState, useEffect } from "react" +import { toast } from "sonner" +import { z } from "zod/v4" +import { InstallationDialogContent } from "./installation-dialog-content" +import { analytics } from "@/lib/analytics" + +// Validation schemas +const mcpMigrationSchema = z.object({ + url: z + .string() + .min(1, "MCP Link is required") + .regex( + /^https:\/\/mcp\.supermemory\.ai\/[^/]+\/sse$/, + "Link must be in format: https://mcp.supermemory.ai/userId/sse", + ), +}) + +export function MCPView() { + const [isMigrateDialogOpen, setIsMigrateDialogOpen] = useState(false) + const projectId = localStorage.getItem("selectedProject") ?? "default" + const { org } = useAuth() + const [apiKey, setApiKey] = useState<string>() + const [isInstallDialogOpen, setIsInstallDialogOpen] = useState(false) + + useEffect(() => { + analytics.mcpViewOpened() + }, []) + const apiKeyMutation = useMutation({ + mutationFn: async () => { + if (apiKey) return apiKey + const res = await authClient.apiKey.create({ + metadata: { + organizationId: org?.id, + }, + name: generateSlug(), + prefix: `sm_${org?.id}_`, + }) + return res.key + }, + onSuccess: (data) => { + setApiKey(data) + setIsInstallDialogOpen(true) + }, + }) + + // Form for MCP migration + const mcpMigrationForm = useForm({ + defaultValues: { url: "" }, + onSubmit: async ({ value, formApi }) => { + const userId = extractUserIdFromMCPUrl(value.url) + if (userId) { + migrateMCPMutation.mutate({ userId, projectId }) + formApi.reset() + } + }, + validators: { + onChange: mcpMigrationSchema, + }, + }) + + const extractUserIdFromMCPUrl = (url: string): string | null => { + const regex = /^https:\/\/mcp\.supermemory\.ai\/([^/]+)\/sse$/ + const match = url.trim().match(regex) + return match?.[1] || null + } + + // Migrate MCP mutation + const migrateMCPMutation = useMutation({ + mutationFn: async ({ + userId, + projectId, + }: { + userId: string + projectId: string + }) => { + const response = await $fetch("@post/memories/migrate-mcp", { + body: { userId, projectId }, + }) + + if (response.error) { + throw new Error( + response.error?.message || "Failed to migrate documents", + ) + } + + return response.data + }, + onSuccess: (data) => { + toast.success("Migration completed!", { + description: `Successfully migrated ${data?.migratedCount} documents`, + }) + setIsMigrateDialogOpen(false) + }, + onError: (error) => { + toast.error("Migration failed", { + description: error instanceof Error ? error.message : "Unknown error", + }) + }, + }) + + return ( + <div className="space-y-6"> + <div> + <p className="text-sm text-white/70"> + Use MCP to create and access memories directly from your AI assistant. + Integrate supermemory with Claude Desktop, Cursor, and other AI tools. + </p> + </div> + + <div className="space-y-4"> + <div> + <label + className="text-sm font-medium text-white/80 block mb-2" + htmlFor="mcp-server-url" + > + MCP Server URL + </label> + <div className="p-3 bg-white/5 rounded border border-white/10"> + <CopyableCell + className="font-mono text-sm text-blue-400" + value="https://api.supermemory.ai/mcp" + /> + </div> + <p className="text-xs text-white/50 mt-2"> + Use this URL to configure supermemory in your AI assistant + </p> + </div> + + <div className="flex items-center gap-4"> + <Dialog + onOpenChange={setIsInstallDialogOpen} + open={isInstallDialogOpen} + > + <DialogTrigger asChild> + <Button + disabled={apiKeyMutation.isPending} + onClick={(e) => { + e.preventDefault() + e.stopPropagation() + apiKeyMutation.mutate() + }} + > + Install Now + </Button> + </DialogTrigger> + {apiKey && <InstallationDialogContent />} + </Dialog> + <motion.a + className="inline-block" + href="https://cursor.com/install-mcp?name=supermemory&config=JTdCJTIydXJsJTIyJTNBJTIyaHR0cHMlM0ElMkYlMkZhcGkuc3VwZXJtZW1vcnkuYWklMkZtY3AlMjIlN0Q%3D" + whileHover={{ scale: 1.05 }} + whileTap={{ scale: 0.95 }} + > + <Image + alt="Add supermemory MCP server to Cursor" + height="32" + src="https://cursor.com/deeplink/mcp-install-dark.svg" + width="128" + /> + </motion.a> + + <div className="h-8 w-px bg-white/10" /> + + <motion.div whileTap={{ scale: 0.95 }}> + <Button + className="bg-white/5 hover:bg-white/10 border-white/10 text-white h-8" + onClick={() => setIsMigrateDialogOpen(true)} + size="sm" + variant="outline" + > + Migrate from v1 + </Button> + </motion.div> + </div> + </div> + <AnimatePresence> + {isMigrateDialogOpen && ( + <Dialog + onOpenChange={setIsMigrateDialogOpen} + open={isMigrateDialogOpen} + > + <DialogContent className="sm:max-w-2xl bg-black/90 backdrop-blur-xl border-white/10 text-white"> + <motion.div + animate={{ opacity: 1, scale: 1 }} + exit={{ opacity: 0, scale: 0.95 }} + initial={{ opacity: 0, scale: 0.95 }} + > + <DialogHeader> + <DialogTitle>Migrate from MCP v1</DialogTitle> + <DialogDescription className="text-white/60"> + Migrate your MCP documents from the legacy system. + </DialogDescription> + </DialogHeader> + <form + onSubmit={(e) => { + e.preventDefault() + e.stopPropagation() + mcpMigrationForm.handleSubmit() + }} + > + <div className="grid gap-4"> + <motion.div + animate={{ opacity: 1, y: 0 }} + className="flex flex-col gap-2" + initial={{ opacity: 0, y: 10 }} + transition={{ delay: 0.1 }} + > + <label className="text-sm font-medium" htmlFor="mcpUrl"> + MCP Link + </label> + <mcpMigrationForm.Field name="url"> + {({ state, handleChange, handleBlur }) => ( + <> + <Input + className="bg-white/5 border-white/10 text-white" + id="mcpUrl" + onBlur={handleBlur} + onChange={(e) => handleChange(e.target.value)} + placeholder="https://mcp.supermemory.ai/your-user-id/sse" + value={state.value} + /> + {state.meta.errors.length > 0 && ( + <motion.p + animate={{ opacity: 1, height: "auto" }} + className="text-sm text-red-400 mt-1" + exit={{ opacity: 0, height: 0 }} + initial={{ opacity: 0, height: 0 }} + > + {state.meta.errors.join(", ")} + </motion.p> + )} + </> + )} + </mcpMigrationForm.Field> + <p className="text-xs text-white/50"> + Enter your old MCP Link in the format: <br /> + <span className="font-mono"> + https://mcp.supermemory.ai/userId/sse + </span> + </p> + </motion.div> + </div> + <div className="flex justify-end gap-3 mt-4"> + <motion.div + whileHover={{ scale: 1.05 }} + whileTap={{ scale: 0.95 }} + > + <Button + className="bg-white/5 hover:bg-white/10 border-white/10 text-white" + onClick={() => { + setIsMigrateDialogOpen(false) + mcpMigrationForm.reset() + }} + type="button" + variant="outline" + > + Cancel + </Button> + </motion.div> + <motion.div + whileHover={{ scale: 1.05 }} + whileTap={{ scale: 0.95 }} + > + <Button + className="bg-white/10 hover:bg-white/20 text-white border-white/20" + disabled={ + migrateMCPMutation.isPending || + !mcpMigrationForm.state.canSubmit + } + type="submit" + > + {migrateMCPMutation.isPending ? ( + <> + <Loader2 className="h-4 w-4 animate-spin mr-2" /> + Migrating... + </> + ) : ( + "Migrate" + )} + </Button> + </motion.div> + </div> + </form> + </motion.div> + </DialogContent> + </Dialog> + )} + </AnimatePresence> + </div> + ) +} diff --git a/apps/web/components/views/mcp/installation-dialog-content.tsx b/apps/web/components/views/mcp/installation-dialog-content.tsx new file mode 100644 index 00000000..3a6b2f3e --- /dev/null +++ b/apps/web/components/views/mcp/installation-dialog-content.tsx @@ -0,0 +1,79 @@ +import { Button } from "@ui/components/button" +import { + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@ui/components/dialog" +import { Input } from "@ui/components/input" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@ui/components/select" +import { CopyIcon } from "lucide-react" +import { useState } from "react" +import { toast } from "sonner" +import { analytics } from "@/lib/analytics" + +const clients = { + cursor: "Cursor", + claude: "Claude Desktop", + vscode: "VSCode", + cline: "Cline", + "roo-cline": "Roo Cline", + witsy: "Witsy", + enconvo: "Enconvo", + "gemini-cli": "Gemini CLI", + "claude-code": "Claude Code", +} as const + +export function InstallationDialogContent() { + const [client, setClient] = useState<keyof typeof clients>("cursor") + return ( + <DialogContent> + <DialogHeader> + <DialogTitle>Install the supermemory MCP Server</DialogTitle> + <DialogDescription> + Select the app you want to install supermemory MCP to, then run the + following command: + </DialogDescription> + </DialogHeader> + <div className="flex gap-2 items-center"> + <Input + className="font-mono text-xs!" + readOnly + value={`npx -y install-mcp@latest https://api.supermemory.ai/mcp --client ${client} --oauth=yes`} + /> + <Select + onValueChange={(value) => setClient(value as keyof typeof clients)} + value={client} + > + <SelectTrigger className="w-48"> + <SelectValue placeholder="Theme" /> + </SelectTrigger> + <SelectContent> + {Object.entries(clients).map(([key, value]) => ( + <SelectItem key={key} value={key}> + {value} + </SelectItem> + ))} + </SelectContent> + </Select> + </div> + <Button + onClick={() => { + navigator.clipboard.writeText( + `npx -y install-mcp@latest https://api.supermemory.ai/mcp --client ${client} --oauth=yes`, + ) + analytics.mcpInstallCmdCopied() + toast.success("Copied to clipboard!") + }} + > + <CopyIcon className="size-4" /> Copy Installation Command + </Button> + </DialogContent> + ) +} diff --git a/apps/web/components/views/profile.tsx b/apps/web/components/views/profile.tsx new file mode 100644 index 00000000..fd7334fe --- /dev/null +++ b/apps/web/components/views/profile.tsx @@ -0,0 +1,266 @@ +"use client" + +import { authClient } from "@lib/auth" +import { useAuth } from "@lib/auth-context" +import { + fetchConnectionsFeature, + fetchMemoriesFeature, + fetchSubscriptionStatus, +} from "@lib/queries" +import { Button } from "@repo/ui/components/button" +import { HeadingH3Bold } from "@repo/ui/text/heading/heading-h3-bold" +import { useCustomer } from "autumn-js/react" +import { CreditCard, LoaderIcon, LogOut, User, CheckCircle, X } from "lucide-react" +import { motion } from "motion/react" +import Link from "next/link" +import { usePathname, useRouter } from "next/navigation" +import { useState } from "react" +import { analytics } from "@/lib/analytics" + +export function ProfileView() { + const router = useRouter() + const pathname = usePathname() + const { user: session, org } = useAuth() + const organizations = org + const autumn = useCustomer() + const [isLoading, setIsLoading] = useState(false) + + const { data: memoriesCheck } = fetchMemoriesFeature(autumn as any) + const memoriesUsed = memoriesCheck?.usage ?? 0 + const memoriesLimit = memoriesCheck?.included_usage ?? 0 + + const { data: connectionsCheck } = fetchConnectionsFeature(autumn as any) + const connectionsUsed = connectionsCheck?.usage ?? 0 + + // Fetch subscription status with React Query + const { + data: status = { + consumer_pro: null, + }, + isLoading: isCheckingStatus, + } = fetchSubscriptionStatus(autumn as any) + + const isPro = status.consumer_pro + + const handleLogout = () => { + analytics.userSignedOut() + authClient.signOut() + router.push("/login") + } + + const handleUpgrade = async () => { + setIsLoading(true) + try { + await autumn.attach({ + productId: "consumer_pro", + successUrl: "https://app.supermemory.ai/", + }) + window.location.reload() + } catch (error) { + console.error(error) + setIsLoading(false) + } + } + + // Handle manage billing + const handleManageBilling = async () => { + await autumn.openBillingPortal({ + returnUrl: "https://app.supermemory.ai", + }) + } + + if (session?.isAnonymous) { + return ( + <div className="space-y-4"> + <motion.div + animate={{ opacity: 1, scale: 1 }} + className="text-center py-8" + initial={{ opacity: 0, scale: 0.9 }} + transition={{ type: "spring", damping: 20 }} + > + <p className="text-white/70 mb-4">Sign in to access your profile and billing</p> + <motion.div whileHover={{ scale: 1.05 }} whileTap={{ scale: 0.95 }}> + <Button + asChild + className="bg-white/10 hover:bg-white/20 text-white border-white/20" + size="sm" + > + <Link href="/login">Sign in</Link> + </Button> + </motion.div> + </motion.div> + </div> + ) + } + + return ( + <div className="space-y-4"> + {/* Profile Section */} + <div className="bg-white/5 rounded-lg p-4 space-y-3"> + <div className="flex items-center gap-3"> + <div className="w-10 h-10 bg-white/10 rounded-full flex items-center justify-center"> + <User className="w-5 h-5 text-white/80" /> + </div> + <div className="flex-1"> + <p className="text-white font-medium text-sm">{session?.email}</p> + <p className="text-white/60 text-xs">Logged in</p> + </div> + </div> + </div> + + {/* Billing Section */} + <div className="bg-white/5 rounded-lg p-4 space-y-3"> + <div className="flex items-center gap-3 mb-3"> + <div className="w-10 h-10 bg-white/10 rounded-full flex items-center justify-center"> + <CreditCard className="w-5 h-5 text-white/80" /> + </div> + <div className="flex-1"> + <HeadingH3Bold className="text-white text-sm"> + {isPro ? "Pro Plan" : "Free Plan"} + {isPro && ( + <span className="ml-2 text-xs bg-green-500/20 text-green-400 px-2 py-0.5 rounded-full"> + Active + </span> + )} + </HeadingH3Bold> + <p className="text-white/60 text-xs"> + {isPro ? "Expanded memory capacity" : "Basic plan"} + </p> + </div> + </div> + + {/* Usage Stats */} + <div className="space-y-2"> + <div className="flex justify-between items-center"> + <span className="text-sm text-white/70">Memories</span> + <span className={`text-sm ${memoriesUsed >= memoriesLimit ? "text-red-400" : "text-white/90"}`}> + {memoriesUsed} / {memoriesLimit} + </span> + </div> + <div className="w-full bg-white/10 rounded-full h-2"> + <div + className={`h-2 rounded-full transition-all ${ + memoriesUsed >= memoriesLimit ? "bg-red-500" : isPro ? "bg-green-500" : "bg-blue-500" + }`} + style={{ + width: `${Math.min((memoriesUsed / memoriesLimit) * 100, 100)}%`, + }} + /> + </div> + </div> + + {isPro && ( + <div className="flex justify-between items-center"> + <span className="text-sm text-white/70">Connections</span> + <span className="text-sm text-white/90"> + {connectionsUsed} / 10 + </span> + </div> + )} + + {/* Billing Actions */} + <div className="pt-2"> + {isPro ? ( + <motion.div whileHover={{ scale: 1.02 }} whileTap={{ scale: 0.98 }}> + <Button + className="w-full bg-white/10 hover:bg-white/20 text-white border-white/20" + onClick={handleManageBilling} + size="sm" + variant="outline" + > + Manage Billing + </Button> + </motion.div> + ) : ( + <motion.div whileHover={{ scale: 1.02 }} whileTap={{ scale: 0.98 }}> + <Button + className="w-full bg-gradient-to-r from-blue-500 to-purple-500 hover:from-blue-600 hover:to-purple-600 text-white border-0" + disabled={isLoading || isCheckingStatus} + onClick={handleUpgrade} + size="sm" + > + {isLoading || isCheckingStatus ? ( + <> + <LoaderIcon className="h-4 w-4 animate-spin mr-2" /> + Upgrading... + </> + ) : ( + "Upgrade to Pro - $15/month" + )} + </Button> + </motion.div> + )} + </div> + </div> + + {/* Plan Comparison - Only show for free users */} + {!isPro && ( + <div className="bg-white/5 rounded-lg p-4 space-y-4"> + <HeadingH3Bold className="text-white text-sm">Upgrade to Pro</HeadingH3Bold> + + <div className="grid grid-cols-1 md:grid-cols-2 gap-4"> + {/* Free Plan */} + <div className="p-3 bg-white/5 rounded-lg border border-white/10"> + <h4 className="font-medium text-white/90 mb-3 text-sm">Free Plan</h4> + <ul className="space-y-2"> + <li className="flex items-center gap-2 text-sm text-white/70"> + <CheckCircle className="h-4 w-4 text-green-400" /> + 200 memories + </li> + <li className="flex items-center gap-2 text-sm text-white/70"> + <X className="h-4 w-4 text-red-400" /> + No connections + </li> + <li className="flex items-center gap-2 text-sm text-white/70"> + <CheckCircle className="h-4 w-4 text-green-400" /> + Basic search + </li> + </ul> + </div> + + {/* Pro Plan */} + <div className="p-3 bg-gradient-to-br from-blue-500/10 to-purple-500/10 rounded-lg border border-blue-500/20"> + <h4 className="font-medium text-white mb-3 flex items-center gap-2 text-sm"> + Pro Plan + <span className="text-xs bg-blue-500/20 text-blue-400 px-2 py-0.5 rounded-full"> + Recommended + </span> + </h4> + <ul className="space-y-2"> + <li className="flex items-center gap-2 text-sm text-white/90"> + <CheckCircle className="h-4 w-4 text-green-400" /> + 5000 memories + </li> + <li className="flex items-center gap-2 text-sm text-white/90"> + <CheckCircle className="h-4 w-4 text-green-400" /> + 10 connections + </li> + <li className="flex items-center gap-2 text-sm text-white/90"> + <CheckCircle className="h-4 w-4 text-green-400" /> + Advanced search + </li> + <li className="flex items-center gap-2 text-sm text-white/90"> + <CheckCircle className="h-4 w-4 text-green-400" /> + Priority support + </li> + </ul> + </div> + </div> + + <p className="text-xs text-white/50 text-center"> + $15/month (only for first 100 users) • Cancel anytime. No questions asked. + </p> + </div> + )} + + <Button + className="w-full bg-red-500/20 hover:bg-red-500/30 text-red-200 border-red-500/30" + onClick={handleLogout} + variant="destructive" + > + <LogOut className="w-4 h-4 mr-2" /> + Sign Out + </Button> + </div> + ) +} diff --git a/apps/web/components/views/projects.tsx b/apps/web/components/views/projects.tsx new file mode 100644 index 00000000..faa9a317 --- /dev/null +++ b/apps/web/components/views/projects.tsx @@ -0,0 +1,742 @@ +"use client" + +import { $fetch } from "@lib/api" +import { Button } from "@repo/ui/components/button" + +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@repo/ui/components/dialog" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@repo/ui/components/dropdown-menu" +import { Input } from "@repo/ui/components/input" +import { Label } from "@repo/ui/components/label" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@repo/ui/components/select" +import { Skeleton } from "@repo/ui/components/skeleton" + +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query" + +import { FolderIcon, Loader2, MoreVertical, Plus, Trash2 } from "lucide-react" +import { AnimatePresence, motion } from "motion/react" + +import { useState } from "react" +import { toast } from "sonner" +import { useProject } from "@/stores" + +// Projects View Component +export function ProjectsView() { + const queryClient = useQueryClient() + const { selectedProject, setSelectedProject } = useProject() + const [showCreateDialog, setShowCreateDialog] = useState(false) + const [projectName, setProjectName] = useState("") + const [deleteDialog, setDeleteDialog] = useState<{ + open: boolean + project: null | { id: string; name: string; containerTag: string } + action: "move" | "delete" + targetProjectId: string + }>({ + open: false, + project: null, + action: "move", + targetProjectId: "", + }) + const [expDialog, setExpDialog] = useState<{ + open: boolean + projectId: string + }>({ + open: false, + projectId: "", + }) + + // Fetch projects + const { + data: projects = [], + isLoading, + // error, + } = useQuery({ + queryKey: ["projects"], + queryFn: async () => { + const response = await $fetch("@get/projects") + + if (response.error) { + throw new Error(response.error?.message || "Failed to load projects") + } + + return response.data?.projects || [] + }, + staleTime: 30 * 1000, + }) + + // Create project mutation + const createProjectMutation = useMutation({ + mutationFn: async (name: string) => { + const response = await $fetch("@post/projects", { + body: { name }, + }) + + if (response.error) { + throw new Error(response.error?.message || "Failed to create project") + } + + return response.data + }, + onSuccess: () => { + toast.success("Project created successfully!") + setShowCreateDialog(false) + setProjectName("") + queryClient.invalidateQueries({ queryKey: ["projects"] }) + }, + onError: (error) => { + toast.error("Failed to create project", { + description: error instanceof Error ? error.message : "Unknown error", + }) + }, + }) + + // Delete project mutation + const deleteProjectMutation = useMutation({ + mutationFn: async ({ + projectId, + action, + targetProjectId, + }: { + projectId: string + action: "move" | "delete" + targetProjectId?: string + }) => { + const response = await $fetch(`@delete/projects/${projectId}`, { + body: { action, targetProjectId }, + }) + + if (response.error) { + throw new Error(response.error?.message || "Failed to delete project") + } + + return response.data + }, + onSuccess: () => { + toast.success("Project deleted successfully") + setDeleteDialog({ + open: false, + project: null, + action: "move", + targetProjectId: "", + }) + queryClient.invalidateQueries({ queryKey: ["projects"] }) + + // If we deleted the selected project, switch to default + if (deleteDialog.project?.containerTag === selectedProject) { + setSelectedProject("sm_project_default") + } + }, + onError: (error) => { + toast.error("Failed to delete project", { + description: error instanceof Error ? error.message : "Unknown error", + }) + }, + }) + + // Enable experimental mode mutation + const enableExperimentalMutation = useMutation({ + mutationFn: async (projectId: string) => { + const response = await $fetch(`@post/projects/${projectId}/enable-experimental`) + if (response.error) { + throw new Error(response.error?.message || "Failed to enable experimental mode") + } + return response.data + }, + onSuccess: () => { + toast.success("Experimental mode enabled for project") + queryClient.invalidateQueries({ queryKey: ["projects"] }) + setExpDialog({ open: false, projectId: "" }) + }, + onError: (error) => { + toast.error("Failed to enable experimental mode", { + description: error instanceof Error ? error.message : "Unknown error", + }) + }, + }) + + // Handle project selection + const handleProjectSelect = (containerTag: string) => { + setSelectedProject(containerTag) + toast.success("Project switched successfully") + } + + return ( + <div className="space-y-4"> + <div className="mb-4"> + <p className="text-sm text-white/70"> + Organize your memories into separate projects + </p> + </div> + + <div className="flex justify-between items-center mb-4"> + <p className="text-sm text-white/50">Current project:</p> + <motion.div whileHover={{ scale: 1.05 }} whileTap={{ scale: 0.95 }}> + <Button + className="bg-white/10 hover:bg-white/20 text-white border-white/20" + onClick={() => setShowCreateDialog(true)} + size="sm" + > + <Plus className="h-4 w-4 mr-2" /> + New Project + </Button> + </motion.div> + </div> + + {isLoading ? ( + <div className="space-y-3"> + {[...Array(2)].map((_, i) => ( + <motion.div + animate={{ opacity: 1 }} + className="p-4 bg-white/5 rounded-lg" + initial={{ opacity: 0 }} + key={`skeleton-project-${Date.now()}-${i}`} + transition={{ delay: i * 0.1 }} + > + <Skeleton className="h-12 w-full bg-white/10" /> + </motion.div> + ))} + </div> + ) : projects.length === 0 ? ( + <motion.div + animate={{ opacity: 1, scale: 1 }} + className="text-center py-8" + initial={{ opacity: 0, scale: 0.9 }} + transition={{ type: "spring", damping: 20 }} + > + <p className="text-white/50 mb-4">No projects yet</p> + <motion.div whileHover={{ scale: 1.05 }} whileTap={{ scale: 0.95 }}> + <Button + className="bg-white/10 hover:bg-white/20 text-white border-white/20" + onClick={() => setShowCreateDialog(true)} + size="sm" + variant="secondary" + > + Create Your First Project + </Button> + </motion.div> + </motion.div> + ) : ( + <motion.div className="space-y-2"> + <AnimatePresence> + {/* Default project */} + <motion.div + animate={{ opacity: 1, x: 0 }} + className={`flex items-center justify-between p-3 rounded-lg transition-colors cursor-pointer ${selectedProject === "sm_project_default" + ? "bg-white/20 border border-white/30" + : "bg-white/5 hover:bg-white/10" + }`} + exit={{ opacity: 0, x: 20 }} + initial={{ opacity: 0, x: -20 }} + key="default-project" + layout + onClick={() => handleProjectSelect("sm_project_default")} + > + <div className="flex items-center gap-3"> + <motion.div + animate={{ rotate: 0, opacity: 1 }} + initial={{ rotate: -180, opacity: 0 }} + transition={{ delay: 0.1 }} + > + <FolderIcon className="h-5 w-5 text-white/80" /> + </motion.div> + <div> + <p className="font-medium text-white">Default Project</p> + <p className="text-sm text-white/60"> + Your default memory storage + </p> + </div> + </div> + {selectedProject === "sm_project_default" && ( + <motion.div + animate={{ scale: 1 }} + initial={{ scale: 0 }} + transition={{ type: "spring", damping: 20 }} + > + <div className="w-2 h-2 bg-green-400 rounded-full" /> + </motion.div> + )} + </motion.div> + + {/* User projects */} + {projects + .filter((p) => p.containerTag !== "sm_project_default") + .map((project, index) => ( + <motion.div + animate={{ opacity: 1, x: 0 }} + className={`flex items-center justify-between p-3 rounded-lg transition-colors cursor-pointer ${selectedProject === project.containerTag + ? "bg-white/20 border border-white/30" + : "bg-white/5 hover:bg-white/10" + }`} + exit={{ opacity: 0, x: 20 }} + initial={{ opacity: 0, x: -20 }} + key={project.id} + layout + onClick={() => handleProjectSelect(project.containerTag)} + transition={{ delay: (index + 1) * 0.05 }} + > + <div className="flex items-center gap-3"> + <motion.div + animate={{ rotate: 0, opacity: 1 }} + initial={{ rotate: -180, opacity: 0 }} + transition={{ delay: (index + 1) * 0.05 + 0.2 }} + > + <FolderIcon className="h-5 w-5 text-white/80" /> + </motion.div> + <div> + <p className="font-medium text-white">{project.name}</p> + <p className="text-sm text-white/60"> + Created{" "} + {new Date(project.createdAt).toLocaleDateString()} + </p> + </div> + </div> + <div className="flex items-center gap-2"> + {selectedProject === project.containerTag && ( + <motion.div + animate={{ scale: 1 }} + initial={{ scale: 0 }} + transition={{ type: "spring", damping: 20 }} + > + <div className="w-2 h-2 bg-green-400 rounded-full" /> + </motion.div> + )} + <DropdownMenu> + <DropdownMenuTrigger asChild> + <Button + className="text-white/50 hover:text-white" + onClick={(e) => e.stopPropagation()} + size="icon" + variant="ghost" + > + <MoreVertical className="h-4 w-4" /> + </Button> + </DropdownMenuTrigger> + <DropdownMenuContent + align="end" + className="bg-black/90 border-white/10" + > + {/* Show experimental toggle only if NOT experimental and NOT default project */} + {!project.isExperimental && + project.containerTag !== "sm_project_default" && ( + <DropdownMenuItem + className="text-blue-400 hover:text-blue-300 cursor-pointer" + onClick={(e) => { + e.stopPropagation() + setExpDialog({ + open: true, + projectId: project.id, + }) + }} + > + <div className="h-4 w-4 mr-2 rounded border border-blue-400" /> + Enable Experimental Mode + </DropdownMenuItem> + )} + {project.isExperimental && ( + <DropdownMenuItem + className="text-blue-300/50" + disabled + > + <div className="h-4 w-4 mr-2 rounded bg-blue-400" /> + Experimental Mode Active + </DropdownMenuItem> + )} + <DropdownMenuItem + className="text-red-400 hover:text-red-300 cursor-pointer" + onClick={(e) => { + e.stopPropagation() + setDeleteDialog({ + open: true, + project: { + id: project.id, + name: project.name, + containerTag: project.containerTag, + }, + action: "move", + targetProjectId: "", + }) + }} + > + <Trash2 className="h-4 w-4 mr-2" /> + Delete Project + </DropdownMenuItem> + </DropdownMenuContent> + </DropdownMenu> + </div> + </motion.div> + ))} + </AnimatePresence> + </motion.div> + )} + + {/* Create Project Dialog */} + <AnimatePresence> + {showCreateDialog && ( + <Dialog onOpenChange={setShowCreateDialog} open={showCreateDialog}> + <DialogContent className="sm:max-w-2xl bg-black/90 backdrop-blur-xl border-white/10 text-white"> + <motion.div + animate={{ opacity: 1, scale: 1 }} + exit={{ opacity: 0, scale: 0.95 }} + initial={{ opacity: 0, scale: 0.95 }} + > + <DialogHeader> + <DialogTitle>Create New Project</DialogTitle> + <DialogDescription className="text-white/60"> + Give your project a unique name + </DialogDescription> + </DialogHeader> + <div className="grid gap-4 py-4"> + <motion.div + animate={{ opacity: 1, y: 0 }} + className="flex flex-col gap-2" + initial={{ opacity: 0, y: 10 }} + transition={{ delay: 0.1 }} + > + <Label htmlFor="projectName">Project Name</Label> + <Input + className="bg-white/5 border-white/10 text-white" + id="projectName" + onChange={(e) => setProjectName(e.target.value)} + placeholder="My Awesome Project" + value={projectName} + /> + <p className="text-xs text-white/50"> + This will help you organize your memories + </p> + </motion.div> + </div> + <DialogFooter> + <motion.div + whileHover={{ scale: 1.05 }} + whileTap={{ scale: 0.95 }} + > + <Button + className="bg-white/5 hover:bg-white/10 border-white/10 text-white" + onClick={() => { + setShowCreateDialog(false) + setProjectName("") + }} + type="button" + variant="outline" + > + Cancel + </Button> + </motion.div> + <motion.div + whileHover={{ scale: 1.05 }} + whileTap={{ scale: 0.95 }} + > + <Button + className="bg-white/10 hover:bg-white/20 text-white border-white/20" + disabled={ + createProjectMutation.isPending || !projectName.trim() + } + onClick={() => createProjectMutation.mutate(projectName)} + type="button" + > + {createProjectMutation.isPending ? ( + <> + <Loader2 className="h-4 w-4 animate-spin mr-2" /> + Creating... + </> + ) : ( + "Create Project" + )} + </Button> + </motion.div> + </DialogFooter> + </motion.div> + </DialogContent> + </Dialog> + )} + </AnimatePresence> + + {/* Delete Project Dialog */} + <AnimatePresence> + {deleteDialog.open && deleteDialog.project && ( + <Dialog + onOpenChange={(open) => + setDeleteDialog((prev) => ({ ...prev, open })) + } + open={deleteDialog.open} + > + <DialogContent className="sm:max-w-3xl bg-black/90 backdrop-blur-xl border-white/10 text-white"> + <motion.div + animate={{ opacity: 1, scale: 1 }} + exit={{ opacity: 0, scale: 0.95 }} + initial={{ opacity: 0, scale: 0.95 }} + > + <DialogHeader> + <DialogTitle>Delete Project</DialogTitle> + <DialogDescription className="text-white/60"> + Are you sure you want to delete "{deleteDialog.project.name} + "? Choose what to do with the documents in this project. + </DialogDescription> + </DialogHeader> + <div className="grid gap-4 py-4"> + <div className="space-y-4"> + <div className="flex items-center space-x-2"> + <input + checked={deleteDialog.action === "move"} + className="w-4 h-4" + id="move" + name="action" + onChange={() => + setDeleteDialog((prev) => ({ + ...prev, + action: "move", + })) + } + type="radio" + /> + <Label + className="text-white cursor-pointer" + htmlFor="move" + > + Move documents to another project + </Label> + </div> + {deleteDialog.action === "move" && ( + <motion.div + animate={{ opacity: 1, height: "auto" }} + className="ml-6" + exit={{ opacity: 0, height: 0 }} + initial={{ opacity: 0, height: 0 }} + > + <Select + onValueChange={(value) => + setDeleteDialog((prev) => ({ + ...prev, + targetProjectId: value, + })) + } + value={deleteDialog.targetProjectId} + > + <SelectTrigger className="w-full bg-white/5 border-white/10 text-white"> + <SelectValue placeholder="Select target project..." /> + </SelectTrigger> + <SelectContent className="bg-black/90 backdrop-blur-xl border-white/10"> + <SelectItem + className="text-white hover:bg-white/10" + value="sm_project_default" + > + Default Project + </SelectItem> + {projects + .filter( + (p) => + p.id !== deleteDialog.project?.id && + p.containerTag !== "sm_project_default", + ) + .map((project) => ( + <SelectItem + className="text-white hover:bg-white/10" + key={project.id} + value={project.id} + > + {project.name} + </SelectItem> + ))} + </SelectContent> + </Select> + </motion.div> + )} + <div className="flex items-center space-x-2"> + <input + checked={deleteDialog.action === "delete"} + className="w-4 h-4" + id="delete" + name="action" + onChange={() => + setDeleteDialog((prev) => ({ + ...prev, + action: "delete", + })) + } + type="radio" + /> + <Label + className="text-white cursor-pointer" + htmlFor="delete" + > + Delete all documents in this project + </Label> + </div> + {deleteDialog.action === "delete" && ( + <motion.p + animate={{ opacity: 1 }} + className="text-sm text-red-400 ml-6" + initial={{ opacity: 0 }} + > + ⚠️ This action cannot be undone. All documents will be + permanently deleted. + </motion.p> + )} + </div> + </div> + <DialogFooter> + <motion.div + whileHover={{ scale: 1.05 }} + whileTap={{ scale: 0.95 }} + > + <Button + className="bg-white/5 hover:bg-white/10 border-white/10 text-white" + onClick={() => + setDeleteDialog({ + open: false, + project: null, + action: "move", + targetProjectId: "", + }) + } + type="button" + variant="outline" + > + Cancel + </Button> + </motion.div> + <motion.div + whileHover={{ scale: 1.05 }} + whileTap={{ scale: 0.95 }} + > + <Button + className={`${deleteDialog.action === "delete" + ? "bg-red-600 hover:bg-red-700" + : "bg-white/10 hover:bg-white/20" + } text-white border-white/20`} + disabled={ + deleteProjectMutation.isPending || + (deleteDialog.action === "move" && + !deleteDialog.targetProjectId) + } + onClick={() => { + if (deleteDialog.project) { + deleteProjectMutation.mutate({ + projectId: deleteDialog.project.id, + action: deleteDialog.action, + targetProjectId: + deleteDialog.action === "move" + ? deleteDialog.targetProjectId + : undefined, + }) + } + }} + type="button" + > + {deleteProjectMutation.isPending ? ( + <> + <Loader2 className="h-4 w-4 animate-spin mr-2" /> + {deleteDialog.action === "move" + ? "Moving..." + : "Deleting..."} + </> + ) : deleteDialog.action === "move" ? ( + "Move & Delete Project" + ) : ( + "Delete Everything" + )} + </Button> + </motion.div> + </DialogFooter> + </motion.div> + </DialogContent> + </Dialog> + )} + </AnimatePresence> + + {/* Experimental Mode Confirmation Dialog */} + <AnimatePresence> + {expDialog.open && ( + <Dialog + onOpenChange={(open) => setExpDialog({ ...expDialog, open })} + open={expDialog.open} + > + <DialogContent className="sm:max-w-lg bg-black/90 backdrop-blur-xl border-white/10 text-white"> + <motion.div + animate={{ opacity: 1, scale: 1 }} + className="flex flex-col gap-4" + exit={{ opacity: 0, scale: 0.95 }} + initial={{ opacity: 0, scale: 0.95 }} + > + <DialogHeader> + <DialogTitle className="text-white"> + Enable Experimental Mode? + </DialogTitle> + <DialogDescription className="text-white/60"> + Experimental mode enables beta features and advanced memory + relationships for this project. + <br /> + <br /> + <span className="text-yellow-400 font-medium"> + Warning: + </span>{" "} + This action is{" "} + <span className="text-red-400 font-bold">irreversible</span> + . Once enabled, you cannot return to regular mode for this + project. + </DialogDescription> + </DialogHeader> + <DialogFooter> + <motion.div + whileHover={{ scale: 1.05 }} + whileTap={{ scale: 0.95 }} + > + <Button + className="bg-white/5 hover:bg-white/10 border-white/10 text-white" + onClick={() => + setExpDialog({ open: false, projectId: "" }) + } + type="button" + variant="outline" + > + Cancel + </Button> + </motion.div> + <motion.div + whileHover={{ scale: 1.05 }} + whileTap={{ scale: 0.95 }} + > + <Button + className="bg-blue-600 hover:bg-blue-700 text-white" + disabled={enableExperimentalMutation.isPending} + onClick={() => + enableExperimentalMutation.mutate(expDialog.projectId) + } + type="button" + > + {enableExperimentalMutation.isPending ? ( + <> + <Loader2 className="h-4 w-4 animate-spin mr-2" /> + Enabling... + </> + ) : ( + "Enable Experimental Mode" + )} + </Button> + </motion.div> + </DialogFooter> + </motion.div> + </DialogContent> + </Dialog> + )} + </AnimatePresence> + </div> + ) +} |