diff options
| -rw-r--r-- | apps/web/components/memory-list-view.tsx | 285 | ||||
| -rw-r--r-- | apps/web/components/views/add-memory/index.tsx | 419 | ||||
| -rw-r--r-- | apps/web/components/views/connections-tab-content.tsx | 162 | ||||
| -rw-r--r-- | apps/web/components/views/integrations.tsx | 231 | ||||
| -rw-r--r-- | packages/lib/queries.ts | 74 |
5 files changed, 652 insertions, 519 deletions
diff --git a/apps/web/components/memory-list-view.tsx b/apps/web/components/memory-list-view.tsx index 2cff96fd..b91e2562 100644 --- a/apps/web/components/memory-list-view.tsx +++ b/apps/web/components/memory-list-view.tsx @@ -1,47 +1,56 @@ -"use client"; +"use client" -import { useIsMobile } from "@hooks/use-mobile"; -import { cn } from "@lib/utils"; -import { Badge } from "@repo/ui/components/badge"; -import { Card, CardContent, CardHeader } from "@repo/ui/components/card"; -import { colors } from "@repo/ui/memory-graph/constants"; -import type { DocumentsWithMemoriesResponseSchema } from "@repo/validation/api"; -import { useVirtualizer } from "@tanstack/react-virtual"; +import { useIsMobile } from "@hooks/use-mobile" +import { cn } from "@lib/utils" +import { Badge } from "@repo/ui/components/badge" +import { Card, CardContent, CardHeader } from "@repo/ui/components/card" import { - Brain, - ExternalLink, - Sparkles, -} from "lucide-react"; -import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"; -import type { z } from "zod"; -import useResizeObserver from "@/hooks/use-resize-observer"; -import { analytics } from "@/lib/analytics"; + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from "@repo/ui/components/alert-dialog" +import { colors } from "@repo/ui/memory-graph/constants" +import type { DocumentsWithMemoriesResponseSchema } from "@repo/validation/api" +import { useVirtualizer } from "@tanstack/react-virtual" +import { Brain, ExternalLink, Sparkles, Trash2 } from "lucide-react" +import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react" +import type { z } from "zod" +import useResizeObserver from "@/hooks/use-resize-observer" +import { analytics } from "@/lib/analytics" +import { useDeleteDocument } from "@lib/queries" +import { useProject } from "@/stores" -import { MemoryDetail } from "./memories/memory-detail"; -import { getDocumentIcon } from "@/lib/document-icon"; -import { formatDate, getSourceUrl } from "./memories"; +import { MemoryDetail } from "./memories/memory-detail" +import { getDocumentIcon } from "@/lib/document-icon" +import { formatDate, getSourceUrl } from "./memories" -type DocumentsResponse = z.infer<typeof DocumentsWithMemoriesResponseSchema>; -type DocumentWithMemories = DocumentsResponse["documents"][0]; +type DocumentsResponse = z.infer<typeof DocumentsWithMemoriesResponseSchema> +type DocumentWithMemories = DocumentsResponse["documents"][0] interface MemoryListViewProps { - children?: React.ReactNode; - documents: DocumentWithMemories[]; - isLoading: boolean; - isLoadingMore: boolean; - error: Error | null; - totalLoaded: number; - hasMore: boolean; - loadMoreDocuments: () => Promise<void>; + 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"; - }; + 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"> @@ -57,28 +66,30 @@ const GreetingMessage = memo(() => { </p> </div> </div> - ); -}); + ) +}) const DocumentCard = memo( ({ document, onOpenDetails, + onDelete, }: { - document: DocumentWithMemories; - onOpenDetails: (document: DocumentWithMemories) => void; + document: DocumentWithMemories + onOpenDetails: (document: DocumentWithMemories) => void + onDelete: (document: DocumentWithMemories) => void }) => { - const activeMemories = document.memoryEntries.filter((m) => !m.isForgotten); + 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); + analytics.documentCardClicked() + onOpenDetails(document) }} style={{ backgroundColor: colors.document.primary, @@ -101,9 +112,9 @@ const DocumentCard = memo( <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"); + e.stopPropagation() + const sourceUrl = getSourceUrl(document) + window.open(sourceUrl ?? undefined, "_blank") }} style={{ backgroundColor: "rgba(255, 255, 255, 0.05)", @@ -128,38 +139,84 @@ const DocumentCard = memo( {document.content} </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 className="flex items-center justify-between"> + <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> + + <AlertDialog> + <AlertDialogTrigger asChild> + <button + className="opacity-0 group-hover:opacity-100 transition-opacity p-1.5 rounded-md hover:bg-red-500/20" + onClick={(e) => { + e.stopPropagation() + }} + style={{ + color: colors.text.muted, + }} + type="button" + > + <Trash2 className="w-3.5 h-3.5" /> + </button> + </AlertDialogTrigger> + <AlertDialogContent> + <AlertDialogHeader> + <AlertDialogTitle>Delete Document</AlertDialogTitle> + <AlertDialogDescription> + Are you sure you want to delete this document and all its + related memories? This action cannot be undone. + </AlertDialogDescription> + </AlertDialogHeader> + <AlertDialogFooter> + <AlertDialogCancel + onClick={(e) => { + e.stopPropagation() + }} + > + Cancel + </AlertDialogCancel> + <AlertDialogAction + className="bg-red-600 hover:bg-red-700 text-white" + onClick={(e) => { + e.stopPropagation() + onDelete(document) + }} + > + Delete + </AlertDialogAction> + </AlertDialogFooter> + </AlertDialogContent> + </AlertDialog> </div> </CardContent> </Card> - ); + ) }, -); +) export const MemoryListView = ({ children, @@ -170,29 +227,38 @@ export const MemoryListView = ({ hasMore, loadMoreDocuments, }: MemoryListViewProps) => { - const [selectedSpace, _] = useState<string>("all"); + 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(); + useState<DocumentWithMemories | null>(null) + const [isDetailOpen, setIsDetailOpen] = useState(false) + const parentRef = useRef<HTMLDivElement>(null) + const containerRef = useRef<HTMLDivElement>(null) + const isMobile = useIsMobile() + const { selectedProject } = useProject() + const deleteDocumentMutation = useDeleteDocument(selectedProject) - const gap = 14; + const gap = 14 - const { width: containerWidth } = useResizeObserver(containerRef); - const columnWidth = isMobile ? containerWidth : 320; + const handleDeleteDocument = useCallback( + (document: DocumentWithMemories) => { + deleteDocumentMutation.mutate(document.id) + }, + [deleteDocumentMutation], + ) + + 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 (!documents) return [] if (selectedSpace === "all") { - return documents; + return documents } return documents @@ -203,44 +269,44 @@ export const MemoryListView = ({ (memory.spaceContainerTag ?? memory.spaceId) === selectedSpace, ), })) - .filter((doc) => doc.memoryEntries.length > 0); - }, [documents, selectedSpace]); + .filter((doc) => doc.memoryEntries.length > 0) + }, [documents, selectedSpace]) const handleOpenDetails = useCallback((document: DocumentWithMemories) => { - analytics.memoryDetailOpened(); - setSelectedDocument(document); - setIsDetailOpen(true); - }, []); + analytics.memoryDetailOpened() + setSelectedDocument(document) + setIsDetailOpen(true) + }, []) const handleCloseDetails = useCallback(() => { - setIsDetailOpen(false); - setTimeout(() => setSelectedDocument(null), 300); - }, []); + setIsDetailOpen(false) + setTimeout(() => setSelectedDocument(null), 300) + }, []) const virtualItems = useMemo(() => { - const items = []; + const items = [] for (let i = 0; i < filteredDocuments.length; i += columns) { - items.push(filteredDocuments.slice(i, i + columns)); + items.push(filteredDocuments.slice(i, i + columns)) } - return items; - }, [filteredDocuments, columns]); + return items + }, [filteredDocuments, columns]) const virtualizer = useVirtualizer({ count: virtualItems.length, getScrollElement: () => parentRef.current, overscan: 5, estimateSize: () => 200, - }); + }) useEffect(() => { - const [lastItem] = [...virtualizer.getVirtualItems()].reverse(); + const [lastItem] = [...virtualizer.getVirtualItems()].reverse() if (!lastItem || !hasMore || isLoadingMore) { - return; + return } if (lastItem.index >= virtualItems.length - 1) { - loadMoreDocuments(); + loadMoreDocuments() } }, [ hasMore, @@ -248,15 +314,15 @@ export const MemoryListView = ({ 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} + style={{ backgroundColor: colors.background.primary }} > {error ? ( <div className="h-full flex items-center justify-center p-4"> @@ -301,8 +367,8 @@ export const MemoryListView = ({ }} > {virtualizer.getVirtualItems().map((virtualRow) => { - const rowItems = virtualItems[virtualRow.index]; - if (!rowItems) return null; + const rowItems = virtualItems[virtualRow.index] + if (!rowItems) return null return ( <div @@ -326,11 +392,12 @@ export const MemoryListView = ({ key={`${document.id}-${virtualRow.index}-${columnIndex}`} document={document} onOpenDetails={handleOpenDetails} + onDelete={handleDeleteDocument} /> ))} </div> </div> - ); + ) })} </div> @@ -355,5 +422,5 @@ export const MemoryListView = ({ isMobile={isMobile} /> </> - ); -}; + ) +} diff --git a/apps/web/components/views/add-memory/index.tsx b/apps/web/components/views/add-memory/index.tsx index ff1fa077..74469f3b 100644 --- a/apps/web/components/views/add-memory/index.tsx +++ b/apps/web/components/views/add-memory/index.tsx @@ -1,9 +1,9 @@ -import { $fetch } from "@lib/api"; +import { $fetch } from "@lib/api" import { fetchConsumerProProduct, fetchMemoriesFeature, -} from "@repo/lib/queries"; -import { Button } from "@repo/ui/components/button"; +} from "@repo/lib/queries" +import { Button } from "@repo/ui/components/button" import { Dialog, DialogContent, @@ -11,18 +11,18 @@ import { DialogFooter, DialogHeader, DialogTitle, -} from "@repo/ui/components/dialog"; -import { Input } from "@repo/ui/components/input"; -import { Label } from "@repo/ui/components/label"; -import { Textarea } from "@repo/ui/components/textarea"; -import { useForm } from "@tanstack/react-form"; -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +} from "@repo/ui/components/dialog" +import { Input } from "@repo/ui/components/input" +import { Label } from "@repo/ui/components/label" +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"; +} from "@ui/components/shadcn-io/dropzone" +import { useCustomer } from "autumn-js/react" import { Brain, FileIcon, @@ -31,37 +31,40 @@ import { PlugIcon, Plus, UploadIcon, -} from "lucide-react"; -import { AnimatePresence, motion } from "motion/react"; -import { useEffect, useState } from "react"; -import { toast } from "sonner"; -import { z } from "zod"; -import { analytics } from "@/lib/analytics"; -import { useProject } from "@/stores"; -import { ConnectionsTabContent } from "../connections-tab-content"; -import { ActionButtons } from "./action-buttons"; -import { MemoryUsageRing } from "./memory-usage-ring"; -import { ProjectSelection } from "./project-selection"; -import { TabButton } from "./tab-button"; -import dynamic from "next/dynamic"; - -const TextEditor = dynamic(() => import("./text-editor").then(mod => ({ default: mod.TextEditor })), { - loading: () => ( - <div className="bg-white/5 border border-white/10 rounded-md"> - <div className="flex-1 min-h-48 max-h-64 overflow-y-auto flex items-center justify-center text-white/70"> - Loading editor... - </div> - <div className="p-1 flex items-center gap-2 bg-white/5 backdrop-blur-sm rounded-b-md"> - <div className="flex items-center gap-1 opacity-50"> - <div className="h-8 w-8 bg-white/10 rounded-sm animate-pulse" /> - <div className="h-8 w-8 bg-white/10 rounded-sm animate-pulse" /> - <div className="h-8 w-8 bg-white/10 rounded-sm animate-pulse" /> +} from "lucide-react" +import { AnimatePresence, motion } from "motion/react" +import { useEffect, useState } from "react" +import { toast } from "sonner" +import { z } from "zod" +import { analytics } from "@/lib/analytics" +import { useProject } from "@/stores" +import { ConnectionsTabContent } from "../connections-tab-content" +import { ActionButtons } from "./action-buttons" +import { MemoryUsageRing } from "./memory-usage-ring" +import { ProjectSelection } from "./project-selection" +import { TabButton } from "./tab-button" +import dynamic from "next/dynamic" + +const TextEditor = dynamic( + () => import("./text-editor").then((mod) => ({ default: mod.TextEditor })), + { + loading: () => ( + <div className="bg-white/5 border border-white/10 rounded-md"> + <div className="flex-1 min-h-48 max-h-64 overflow-y-auto flex items-center justify-center text-white/70"> + Loading editor... + </div> + <div className="p-1 flex items-center gap-2 bg-white/5 backdrop-blur-sm rounded-b-md"> + <div className="flex items-center gap-1 opacity-50"> + <div className="h-8 w-8 bg-white/10 rounded-sm animate-pulse" /> + <div className="h-8 w-8 bg-white/10 rounded-sm animate-pulse" /> + <div className="h-8 w-8 bg-white/10 rounded-sm animate-pulse" /> + </div> </div> </div> - </div> - ), - ssr: false, -}); + ), + ssr: false, + }, +) // // Processing status component // function ProcessingStatus({ status }: { status: string }) { @@ -89,80 +92,74 @@ export function AddMemoryView({ onClose, initialTab = "note", }: { - onClose?: () => void; - initialTab?: "note" | "link" | "file" | "connect"; + 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 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(""); + >(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; + const { data: memoriesCheck } = fetchMemoriesFeature(autumn) - // Check if user is pro - const { data: proCheck } = fetchConsumerProProduct(autumn as any); - const isProUser = proCheck?.allowed ?? false; - - const canAddMemory = memoriesUsed < memoriesLimit; + const memoriesUsed = memoriesCheck?.usage ?? 0 + const memoriesLimit = memoriesCheck?.included_usage ?? 0 // Fetch projects for the dropdown const { data: projects = [], isLoading: isLoadingProjects } = useQuery({ queryKey: ["projects"], queryFn: async () => { - const response = await $fetch("@get/projects"); + const response = await $fetch("@get/projects") if (response.error) { - throw new Error(response.error?.message || "Failed to load projects"); + throw new Error(response.error?.message || "Failed to load projects") } - return response.data?.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"); + throw new Error(response.error?.message || "Failed to create project") } - return response.data; + return response.data }, onSuccess: (data) => { - analytics.projectCreated(); - toast.success("Project created successfully!"); - setShowCreateProjectDialog(false); - setNewProjectName(""); - queryClient.invalidateQueries({ queryKey: ["projects"] }); + 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); + setSelectedProject(data.containerTag) // Update form values - addContentForm.setFieldValue("project", data.containerTag); - fileUploadForm.setFieldValue("project", data.containerTag); + 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: { @@ -174,8 +171,8 @@ export function AddMemoryView({ content: value.content, project: value.project, contentType: activeTab as "note" | "link", - }); - formApi.reset(); + }) + formApi.reset() }, validators: { onChange: z.object({ @@ -183,19 +180,19 @@ export function AddMemoryView({ 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"); + const currentValue = addContentForm.getFieldValue("content") if (currentValue) { - addContentForm.validateField("content", "change"); + addContentForm.validateField("content", "change") } } - }, [activeTab]); + }, [activeTab]) // Form for file upload metadata const fileUploadForm = useForm({ @@ -206,8 +203,8 @@ export function AddMemoryView({ }, onSubmit: async ({ value, formApi }) => { if (selectedFiles.length === 0) { - toast.error("Please select a file to upload"); - return; + toast.error("Please select a file to upload") + return } for (const file of selectedFiles) { @@ -216,25 +213,13 @@ export function AddMemoryView({ title: value.title || undefined, description: value.description || undefined, project: value.project, - }); + }) } - formApi.reset(); - setSelectedFiles([]); + 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 ({ @@ -242,12 +227,12 @@ export function AddMemoryView({ project, contentType, }: { - content: string; - project: string; - contentType: "note" | "link"; + content: string + project: string + contentType: "note" | "link" }) => { // close the modal - onClose?.(); + onClose?.() const processingPromise = (async () => { // First, create the memory @@ -259,31 +244,31 @@ export function AddMemoryView({ 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; + 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) + 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 @@ -293,58 +278,58 @@ export function AddMemoryView({ // Sometimes the memory might be ready when it has content and no processing status memory.data?.content ) { - return memory.data; + return memory.data } // If still processing, wait and try again - await new Promise((resolve) => setTimeout(resolve, 5000)); // Wait 5 seconds - attempts++; + await new Promise((resolve) => setTimeout(resolve, 5000)) // Wait 5 seconds + attempts++ } catch (error) { - console.error("Error polling memory status:", 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"); + throw new Error("Failed to check processing status") } - await new Promise((resolve) => setTimeout(resolve, 5000)); - attempts++; + 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; - })(); + 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; + return processingPromise }, onMutate: async ({ content, project, contentType }) => { - console.log("🚀 onMutate starting..."); + console.log("🚀 onMutate starting...") // Cancel any outgoing refetches await queryClient.cancelQueries({ queryKey: ["documents-with-memories", project], - }); - console.log("✅ Cancelled queries"); + }) + console.log("✅ Cancelled queries") // Snapshot the previous value const previousMemories = queryClient.getQueryData([ "documents-with-memories", project, - ]); - console.log("📸 Previous memories:", previousMemories); + ]) + console.log("📸 Previous memories:", previousMemories) // Create optimistic memory const optimisticMemory = { @@ -368,28 +353,28 @@ export function AddMemoryView({ }, memoryEntries: [], isOptimistic: true, - }; - console.log("🎯 Created optimistic memory:", optimisticMemory); + } + 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); + 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; + : { documents: [optimisticMemory], totalCount: 1 } + console.log("✨ New data:", newData) + return newData }, - ); + ) - console.log("✅ onMutate completed"); - return { previousMemories, optimisticId: optimisticMemory.id }; + console.log("✅ onMutate completed") + return { previousMemories, optimisticId: optimisticMemory.id } }, // If the mutation fails, roll back to the previous value onError: (error, variables, context) => { @@ -397,7 +382,7 @@ export function AddMemoryView({ queryClient.setQueryData( ["documents-with-memories", variables.project], context.previousMemories, - ); + ) } }, onSuccess: (_data, variables) => { @@ -405,28 +390,28 @@ export function AddMemoryView({ 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 + }) + }, 30000) // 30 seconds setTimeout(() => { queryClient.invalidateQueries({ queryKey: ["documents-with-memories", variables.project], - }); - }, 120000); // 2 minutes + }) + }, 120000) // 2 minutes - setShowAddDialog(false); - onClose?.(); + setShowAddDialog(false) + onClose?.() }, - }); + }) const fileUploadMutation = useMutation({ mutationFn: async ({ @@ -435,10 +420,10 @@ export function AddMemoryView({ description, project, }: { - file: File; - title?: string; - description?: string; - project: string; + file: File + title?: string + description?: string + project: string }) => { // TEMPORARILY DISABLED: Limit check disabled // Check if user can add more memories @@ -448,9 +433,9 @@ export function AddMemoryView({ // ); // } - const formData = new FormData(); - formData.append("file", file); - formData.append("containerTags", JSON.stringify([project])); + 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`, @@ -459,14 +444,14 @@ export function AddMemoryView({ body: formData, credentials: "include", }, - ); + ) if (!response.ok) { - const error = await response.json(); - throw new Error(error.error || "Failed to upload file"); + const error = await response.json() + throw new Error(error.error || "Failed to upload file") } - const data = await response.json(); + const data = await response.json() // If we have metadata, we can update the document after creation if (title || description) { @@ -478,23 +463,23 @@ export function AddMemoryView({ sm_source: "consumer", // Use "consumer" source to bypass limits }, }, - }); + }) } - return data; + 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 = { @@ -514,23 +499,23 @@ export function AddMemoryView({ 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 }; + 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 }; + return { previousMemories } }, // If the mutation fails, roll back to the previous value onError: (error, variables, context) => { @@ -538,11 +523,11 @@ export function AddMemoryView({ 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({ @@ -550,18 +535,18 @@ export function AddMemoryView({ 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?.(); + }) + setShowAddDialog(false) + onClose?.() }, // Always refetch after error or success onSettled: () => { - queryClient.invalidateQueries({ queryKey: ["documents-with-memories"] }); + queryClient.invalidateQueries({ queryKey: ["documents-with-memories"] }) }, - }); + }) return ( <AnimatePresence mode="wait"> @@ -569,8 +554,8 @@ export function AddMemoryView({ <Dialog key="add-memory-dialog" onOpenChange={(open) => { - setShowAddDialog(open); - if (!open) onClose?.(); + setShowAddDialog(open) + if (!open) onClose?.() }} open={showAddDialog} > @@ -597,26 +582,26 @@ export function AddMemoryView({ <div className="bg-white/5 p-1 h-10 sm:h-8 rounded-md flex overflow-x-auto"> <TabButton icon={Brain} - label="Note" isActive={activeTab === "note"} + label="Note" onClick={() => setActiveTab("note")} /> <TabButton icon={LinkIcon} - label="Link" isActive={activeTab === "link"} + label="Link" onClick={() => setActiveTab("link")} /> <TabButton icon={FileIcon} - label="File" isActive={activeTab === "file"} + label="File" onClick={() => setActiveTab("file")} /> <TabButton icon={PlugIcon} - label="Connect" isActive={activeTab === "connect"} + label="Connect" onClick={() => setActiveTab("connect")} /> </div> @@ -629,9 +614,9 @@ export function AddMemoryView({ <div className="space-y-4"> <form onSubmit={(e) => { - e.preventDefault(); - e.stopPropagation(); - addContentForm.handleSubmit(); + e.preventDefault() + e.stopPropagation() + addContentForm.handleSubmit() }} > <div className="grid gap-4"> @@ -647,9 +632,9 @@ export function AddMemoryView({ validators={{ onChange: ({ value }) => { if (!value || value.trim() === "") { - return "Note is required"; + return "Note is required" } - return undefined; + return undefined }, }} > @@ -729,9 +714,9 @@ export function AddMemoryView({ <ActionButtons onCancel={() => { - setShowAddDialog(false); - onClose?.(); - addContentForm.reset(); + setShowAddDialog(false) + onClose?.() + addContentForm.reset() }} submitText="Add Note" submitIcon={Plus} @@ -747,9 +732,9 @@ export function AddMemoryView({ <div className="space-y-4"> <form onSubmit={(e) => { - e.preventDefault(); - e.stopPropagation(); - addContentForm.handleSubmit(); + e.preventDefault() + e.stopPropagation() + addContentForm.handleSubmit() }} > <div className="grid gap-4"> @@ -771,13 +756,13 @@ export function AddMemoryView({ validators={{ onChange: ({ value }) => { if (!value || value.trim() === "") { - return "Link is required"; + return "Link is required" } try { - new URL(value); - return undefined; + new URL(value) + return undefined } catch { - return "Please enter a valid link"; + return "Please enter a valid link" } }, }} @@ -855,9 +840,9 @@ export function AddMemoryView({ <ActionButtons onCancel={() => { - setShowAddDialog(false); - onClose?.(); - addContentForm.reset(); + setShowAddDialog(false) + onClose?.() + addContentForm.reset() }} submitText="Add Link" submitIcon={Plus} @@ -873,9 +858,9 @@ export function AddMemoryView({ <div className="space-y-4"> <form onSubmit={(e) => { - e.preventDefault(); - e.stopPropagation(); - fileUploadForm.handleSubmit(); + e.preventDefault() + e.stopPropagation() + fileUploadForm.handleSubmit() }} > <div className="grid gap-4"> @@ -1007,10 +992,10 @@ export function AddMemoryView({ <ActionButtons onCancel={() => { - setShowAddDialog(false); - onClose?.(); - fileUploadForm.reset(); - setSelectedFiles([]); + setShowAddDialog(false) + onClose?.() + fileUploadForm.reset() + setSelectedFiles([]) }} submitText="Upload File" submitIcon={UploadIcon} @@ -1080,8 +1065,8 @@ export function AddMemoryView({ <Button className="bg-white/5 hover:bg-white/10 border-white/10 text-white w-full sm:w-auto" onClick={() => { - setShowCreateProjectDialog(false); - setNewProjectName(""); + setShowCreateProjectDialog(false) + setNewProjectName("") }} type="button" variant="outline" @@ -1118,19 +1103,19 @@ export function AddMemoryView({ </Dialog> )} </AnimatePresence> - ); + ) } export function AddMemoryExpandedView() { - const [showDialog, setShowDialog] = useState(false); + const [showDialog, setShowDialog] = useState(false) const [selectedTab, setSelectedTab] = useState< "note" | "link" | "file" | "connect" - >("note"); + >("note") const handleOpenDialog = (tab: "note" | "link" | "file" | "connect") => { - setSelectedTab(tab); - setShowDialog(true); - }; + setSelectedTab(tab) + setShowDialog(true) + } return ( <> @@ -1201,5 +1186,5 @@ export function AddMemoryExpandedView() { /> )} </> - ); + ) } diff --git a/apps/web/components/views/connections-tab-content.tsx b/apps/web/components/views/connections-tab-content.tsx index 64aa35ef..14a63e8f 100644 --- a/apps/web/components/views/connections-tab-content.tsx +++ b/apps/web/components/views/connections-tab-content.tsx @@ -1,34 +1,22 @@ -"use client"; +"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 { Trash2 } from "lucide-react"; -import { AnimatePresence, motion } from "motion/react"; -import Link from "next/link"; -import { useEffect } from "react"; -import { toast } from "sonner"; -import type { z } from "zod"; -import { analytics } from "@/lib/analytics"; -import { useProject } from "@/stores"; +import { $fetch } from "@lib/api" +import { Button } from "@repo/ui/components/button" +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 { Trash2 } from "lucide-react" +import { AnimatePresence, motion } from "motion/react" +import { useEffect, useState } from "react" +import { toast } from "sonner" +import type { z } from "zod" +import { analytics } from "@/lib/analytics" +import { useProject } from "@/stores" // Define types -type Connection = z.infer<typeof ConnectionResponseSchema>; +type Connection = z.infer<typeof ConnectionResponseSchema> // Connector configurations const CONNECTORS = { @@ -47,35 +35,45 @@ const CONNECTORS = { description: "Access your Microsoft Office documents", icon: OneDrive, }, -} as const; +} as const -type ConnectorProvider = keyof typeof CONNECTORS; +type ConnectorProvider = keyof typeof CONNECTORS export function ConnectionsTabContent() { - const queryClient = useQueryClient(); - const { selectedProject } = useProject(); - const autumn = useCustomer(); + const queryClient = useQueryClient() + const { selectedProject } = useProject() + const autumn = useCustomer() + const [isProUser, setIsProUser] = useState(false) const handleUpgrade = async () => { try { await autumn.attach({ productId: "consumer_pro", successUrl: "https://app.supermemory.ai/", - }); - window.location.reload(); + }) + window.location.reload() } catch (error) { - console.error(error); + console.error(error) } - }; + } - const { data: connectionsCheck } = fetchConnectionsFeature(autumn as any); - const connectionsUsed = connectionsCheck?.balance ?? 0; - const connectionsLimit = connectionsCheck?.included_usage ?? 0; + // Set pro user status when autumn data loads + useEffect(() => { + if (!autumn.isLoading) { + setIsProUser( + autumn.customer?.products.some( + (product) => product.id === "consumer_pro", + ) ?? false, + ) + } + }, [autumn.isLoading, autumn.customer]) - const { data: proCheck } = fetchConsumerProProduct(autumn as any); - const isProUser = proCheck?.allowed ?? false; + // Get connections data directly from autumn customer + const connectionsFeature = autumn.customer?.features?.connections + const connectionsUsed = connectionsFeature?.usage ?? 0 + const connectionsLimit = connectionsFeature?.included_usage ?? 0 - const canAddConnection = connectionsUsed < connectionsLimit; + const canAddConnection = connectionsUsed < connectionsLimit // Fetch connections const { @@ -89,28 +87,26 @@ export function ConnectionsTabContent() { body: { containerTags: [], }, - }); + }) if (response.error) { - throw new Error( - response.error?.message || "Failed to load connections", - ); + throw new Error(response.error?.message || "Failed to load connections") } - return response.data as Connection[]; + 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]); + }, [error]) // Add connection mutation const addConnectionMutation = useMutation({ @@ -119,7 +115,7 @@ export function ConnectionsTabContent() { 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", { @@ -128,57 +124,61 @@ export function ConnectionsTabContent() { redirectUrl: window.location.href, containerTags: [selectedProject], }, - }); + }) // biome-ignore lint/style/noNonNullAssertion: its fine if ("data" in response && !("error" in response.data!)) { - return response.data; + return response.data } - throw new Error(response.error?.message || "Failed to connect"); + throw new Error(response.error?.message || "Failed to connect") }, onSuccess: (data, provider) => { - analytics.connectionAdded(provider); - analytics.connectionAuthStarted(); + analytics.connectionAdded(provider) + analytics.connectionAuthStarted() + autumn.track({ + featureId: "connections", + value: 1, + }) if (data?.authLink) { - window.location.href = data.authLink; + window.location.href = data.authLink } }, onError: (error, provider) => { - analytics.connectionAuthFailed(); + 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}`); + await $fetch(`@delete/connections/${connectionId}`) }, onSuccess: () => { - analytics.connectionDeleted(); + analytics.connectionDeleted() toast.success( "Connection removal has started. supermemory will permanently delete the documents in the next few minutes.", - ); - queryClient.invalidateQueries({ queryKey: ["connections"] }); + ) + 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]; + const connector = CONNECTORS[provider as ConnectorProvider] if (connector) { - const Icon = connector.icon; - return <Icon className="h-10 w-10" />; + const Icon = connector.icon + return <Icon className="h-10 w-10" /> } - return <span className="text-2xl">📎</span>; - }; + return <span className="text-2xl">📎</span> + } return ( <div className="space-y-4"> @@ -186,7 +186,12 @@ export function ConnectionsTabContent() { <p className="text-sm text-white/70"> Connect your favorite services to import documents </p> - {!isProUser && ( + {isProUser && !autumn.isLoading && ( + <p className="text-xs text-white/50 mt-1"> + {connectionsUsed} of {connectionsLimit} connections used + </p> + )} + {!isProUser && !autumn.isLoading && ( <p className="text-xs text-white/50 mt-1"> Connections require a Pro subscription </p> @@ -194,7 +199,7 @@ export function ConnectionsTabContent() { </div> {/* Show upgrade prompt for free users */} - {!isProUser && ( + {!autumn.isLoading && !isProUser && ( <motion.div animate={{ opacity: 1, y: 0 }} className="p-4 bg-yellow-500/10 border border-yellow-500/20 rounded-lg" @@ -208,7 +213,6 @@ export function ConnectionsTabContent() { 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" @@ -306,7 +310,7 @@ export function ConnectionsTabContent() { </h3> <div className="grid gap-3"> {Object.entries(CONNECTORS).map(([provider, config], index) => { - const Icon = config.icon; + const Icon = config.icon return ( <motion.div animate={{ opacity: 1, y: 0 }} @@ -320,7 +324,7 @@ export function ConnectionsTabContent() { 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); + addConnectionMutation.mutate(provider as ConnectorProvider) }} variant="outline" > @@ -333,10 +337,10 @@ export function ConnectionsTabContent() { </div> </Button> </motion.div> - ); + ) })} </div> </div> </div> - ); + ) } diff --git a/apps/web/components/views/integrations.tsx b/apps/web/components/views/integrations.tsx index 5174aeca..4ac44c02 100644 --- a/apps/web/components/views/integrations.tsx +++ b/apps/web/components/views/integrations.tsx @@ -1,38 +1,35 @@ -import { $fetch } from "@lib/api"; -import { authClient } from "@lib/auth"; -import { useAuth } from "@lib/auth-context"; -import { generateId } from "@lib/generate-id"; +import { $fetch } from "@lib/api" +import { authClient } from "@lib/auth" +import { useAuth } from "@lib/auth-context" +import { generateId } from "@lib/generate-id" import { ADD_MEMORY_SHORTCUT_URL, SEARCH_MEMORY_SHORTCUT_URL, -} from "@repo/lib/constants"; -import { - fetchConnectionsFeature, - fetchConsumerProProduct, -} from "@repo/lib/queries"; -import { Button } from "@repo/ui/components/button"; +} from "@repo/lib/constants" +import { fetchConnectionsFeature } from "@repo/lib/queries" +import { Button } from "@repo/ui/components/button" import { Dialog, DialogContent, DialogHeader, DialogPortal, 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 { Check, Copy, Smartphone, Trash2 } from "lucide-react"; -import { motion } from "motion/react"; -import Image from "next/image"; -import { useEffect, useId, useState } from "react"; -import { toast } from "sonner"; -import type { z } from "zod"; -import { analytics } from "@/lib/analytics"; -import { useProject } from "@/stores"; - -type Connection = z.infer<typeof ConnectionResponseSchema>; +} 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 { Check, Copy, Smartphone, Trash2 } from "lucide-react" +import { motion } from "motion/react" +import Image from "next/image" +import { useEffect, useId, useState } from "react" +import { toast } from "sonner" +import type { z } from "zod" +import { analytics } from "@/lib/analytics" +import { useProject } from "@/stores" + +type Connection = z.infer<typeof ConnectionResponseSchema> const CONNECTORS = { "google-drive": { @@ -50,9 +47,9 @@ const CONNECTORS = { description: "Access your Microsoft Office documents", icon: OneDrive, }, -} as const; +} as const -type ConnectorProvider = keyof typeof CONNECTORS; +type ConnectorProvider = keyof typeof CONNECTORS const ChromeIcon = ({ className }: { className?: string }) => ( <svg @@ -83,41 +80,49 @@ const ChromeIcon = ({ className }: { className?: string }) => ( d="M95.252 47.628h82.479A95.237 95.237 0 0 0 142.87 12.76 95.23 95.23 0 0 0 95.245 0a95.222 95.222 0 0 0-47.623 12.767 95.23 95.23 0 0 0-34.856 34.872l41.24 71.43.011.006a47.62 47.62 0 0 1-.015-47.633 47.61 47.61 0 0 1 41.252-23.815z" /> </svg> -); +) export function IntegrationsView() { - const { org } = useAuth(); - const queryClient = useQueryClient(); - const { selectedProject } = useProject(); - const autumn = useCustomer(); - const [showApiKeyModal, setShowApiKeyModal] = useState(false); - const [apiKey, setApiKey] = useState<string>(""); - const [copied, setCopied] = useState(false); + const { org } = useAuth() + const queryClient = useQueryClient() + const { selectedProject } = useProject() + const autumn = useCustomer() + const [showApiKeyModal, setShowApiKeyModal] = useState(false) + const [apiKey, setApiKey] = useState<string>("") + const [copied, setCopied] = useState(false) + const [isProUser, setIsProUser] = useState(false) const [selectedShortcutType, setSelectedShortcutType] = useState< "add" | "search" | null - >(null); - const apiKeyId = useId(); + >(null) + const apiKeyId = useId() const handleUpgrade = async () => { try { await autumn.attach({ productId: "consumer_pro", successUrl: "https://app.supermemory.ai/", - }); - window.location.reload(); + }) + window.location.reload() } catch (error) { - console.error(error); + console.error(error) } - }; + } - const { data: connectionsCheck } = fetchConnectionsFeature(autumn as any); - const connectionsUsed = connectionsCheck?.balance ?? 0; - const connectionsLimit = connectionsCheck?.included_usage ?? 0; + useEffect(() => { + if (!autumn.isLoading) { + setIsProUser( + autumn.customer?.products.some( + (product) => product.id === "consumer_pro", + ) ?? false, + ) + } + }, [autumn.isLoading, autumn.customer]) - const { data: proCheck } = fetchConsumerProProduct(autumn as any); - const isProUser = proCheck?.allowed ?? false; + const { data: connectionsCheck } = fetchConnectionsFeature(autumn) + const connectionsUsed = connectionsCheck?.balance ?? 0 + const connectionsLimit = connectionsCheck?.included_usage ?? 0 - const canAddConnection = connectionsUsed < connectionsLimit; + const canAddConnection = connectionsUsed < connectionsLimit const { data: connections = [], @@ -130,19 +135,17 @@ export function IntegrationsView() { body: { containerTags: [], }, - }); + }) if (response.error) { - throw new Error( - response.error?.message || "Failed to load connections", - ); + throw new Error(response.error?.message || "Failed to load connections") } - return response.data as Connection[]; + return response.data as Connection[] }, staleTime: 30 * 1000, refetchInterval: 60 * 1000, - }); + }) useEffect(() => { if (connectionsError) { @@ -151,16 +154,16 @@ export function IntegrationsView() { connectionsError instanceof Error ? connectionsError.message : "Unknown error", - }); + }) } - }, [connectionsError]); + }, [connectionsError]) const addConnectionMutation = useMutation({ mutationFn: async (provider: ConnectorProvider) => { 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", { @@ -169,47 +172,47 @@ export function IntegrationsView() { redirectUrl: window.location.href, containerTags: [selectedProject], }, - }); + }) // biome-ignore lint/style/noNonNullAssertion: its fine if ("data" in response && !("error" in response.data!)) { - return response.data; + return response.data } - throw new Error(response.error?.message || "Failed to connect"); + throw new Error(response.error?.message || "Failed to connect") }, onSuccess: (data, provider) => { - analytics.connectionAdded(provider); - analytics.connectionAuthStarted(); + analytics.connectionAdded(provider) + analytics.connectionAuthStarted() if (data?.authLink) { - window.location.href = data.authLink; + window.location.href = data.authLink } }, onError: (error, provider) => { - analytics.connectionAuthFailed(); + analytics.connectionAuthFailed() toast.error(`Failed to connect ${provider}`, { description: error instanceof Error ? error.message : "Unknown error", - }); + }) }, - }); + }) const deleteConnectionMutation = useMutation({ mutationFn: async (connectionId: string) => { - await $fetch(`@delete/connections/${connectionId}`); + await $fetch(`@delete/connections/${connectionId}`) }, onSuccess: () => { - analytics.connectionDeleted(); + analytics.connectionDeleted() toast.success( "Connection removal has started. supermemory will permanently delete all documents related to the connection in the next few minutes.", - ); - queryClient.invalidateQueries({ queryKey: ["connections"] }); + ) + queryClient.invalidateQueries({ queryKey: ["connections"] }) }, onError: (error) => { toast.error("Failed to remove connection", { description: error instanceof Error ? error.message : "Unknown error", - }); + }) }, - }); + }) const createApiKeyMutation = useMutation({ mutationFn: async () => { @@ -220,60 +223,60 @@ export function IntegrationsView() { }, name: `ios-${generateId().slice(0, 8)}`, prefix: `sm_${org?.id}_`, - }); - return res.key; + }) + return res.key }, onSuccess: (apiKey) => { - setApiKey(apiKey); - setShowApiKeyModal(true); - setCopied(false); - handleCopyApiKey(); + setApiKey(apiKey) + setShowApiKeyModal(true) + setCopied(false) + handleCopyApiKey() }, onError: (error) => { toast.error("Failed to create API key", { description: error instanceof Error ? error.message : "Unknown error", - }); + }) }, - }); + }) const handleShortcutClick = (shortcutType: "add" | "search") => { - setSelectedShortcutType(shortcutType); - createApiKeyMutation.mutate(); - }; + setSelectedShortcutType(shortcutType) + createApiKeyMutation.mutate() + } const handleCopyApiKey = async () => { try { - await navigator.clipboard.writeText(apiKey); - setCopied(true); - toast.success("API key copied to clipboard!"); - setTimeout(() => setCopied(false), 2000); + await navigator.clipboard.writeText(apiKey) + setCopied(true) + toast.success("API key copied to clipboard!") + setTimeout(() => setCopied(false), 2000) } catch { - toast.error("Failed to copy API key"); + toast.error("Failed to copy API key") } - }; + } const handleOpenShortcut = () => { if (!selectedShortcutType) { - toast.error("No shortcut type selected"); - return; + toast.error("No shortcut type selected") + return } if (selectedShortcutType === "add") { - window.open(ADD_MEMORY_SHORTCUT_URL, "_blank"); + window.open(ADD_MEMORY_SHORTCUT_URL, "_blank") } else if (selectedShortcutType === "search") { - window.open(SEARCH_MEMORY_SHORTCUT_URL, "_blank"); + window.open(SEARCH_MEMORY_SHORTCUT_URL, "_blank") } - }; + } const handleDialogClose = (open: boolean) => { - setShowApiKeyModal(open); + setShowApiKeyModal(open) if (!open) { // Reset state when dialog closes - setSelectedShortcutType(null); - setApiKey(""); - setCopied(false); + setSelectedShortcutType(null) + setApiKey("") + setCopied(false) } - }; + } return ( <div className="space-y-4 sm:space-y-4 custom-scrollbar"> @@ -362,15 +365,15 @@ export function IntegrationsView() { <svg className="h-5 w-5 text-green-400" fill="none" - viewBox="0 0 24 24" stroke="currentColor" + viewBox="0 0 24 24" > <title>Connection Link Icon</title> <path + d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1" strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} - d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1" /> </svg> </div> @@ -390,7 +393,7 @@ export function IntegrationsView() { </div> {/* Show upgrade prompt for free users */} - {!isProUser && ( + {!autumn.isLoading && !isProUser && ( <motion.div animate={{ opacity: 1, y: 0 }} className="p-4 bg-yellow-500/10 border border-yellow-500/20 rounded-lg mb-3" @@ -432,11 +435,11 @@ export function IntegrationsView() { ) : ( <div className="space-y-2"> {Object.entries(CONNECTORS).map(([provider, config], index) => { - const Icon = config.icon; + const Icon = config.icon const connection = connections.find( (conn) => conn.provider === provider, - ); - const isConnected = !!connection; + ) + const isConnected = !!connection return ( <motion.div @@ -462,14 +465,14 @@ export function IntegrationsView() { </p> {isConnected ? ( <div className="flex items-center gap-1"> - <div className="w-2 h-2 bg-green-400 rounded-full"></div> + <div className="w-2 h-2 bg-green-400 rounded-full" /> <span className="text-xs text-green-400 font-medium"> Connected </span> </div> ) : ( <div className="hidden sm:flex items-center gap-1"> - <div className="w-2 h-2 bg-gray-400 rounded-full"></div> + <div className="w-2 h-2 bg-gray-400 rounded-full" /> <span className="text-xs text-gray-400 font-medium"> Disconnected </span> @@ -509,7 +512,7 @@ export function IntegrationsView() { ) : ( <div className="flex items-center justify-between gap-2 w-full sm:w-auto"> <div className="sm:hidden flex items-center gap-1"> - <div className="w-2 h-2 bg-gray-400 rounded-full"></div> + <div className="w-2 h-2 bg-gray-400 rounded-full" /> <span className="text-xs text-gray-400 font-medium"> Disconnected </span> @@ -520,12 +523,14 @@ export function IntegrationsView() { className="flex-shrink-0" > <Button - className="bg-blue-600/20 hover:bg-blue-600/30 text-blue-400 border-blue-600/30 min-w-[80px]" - disabled={addConnectionMutation.isPending} + className="bg-blue-600/20 hover:bg-blue-600/30 text-blue-400 border-blue-600/30 min-w-[80px] disabled:cursor-not-allowed" + disabled={ + addConnectionMutation.isPending || !isProUser + } onClick={() => { addConnectionMutation.mutate( provider as ConnectorProvider, - ); + ) }} size="sm" variant="outline" @@ -540,7 +545,7 @@ export function IntegrationsView() { )} </div> </motion.div> - ); + ) })} </div> )} @@ -665,5 +670,5 @@ export function IntegrationsView() { </DialogPortal> </Dialog> </div> - ); + ) } diff --git a/packages/lib/queries.ts b/packages/lib/queries.ts index 36338445..0902f378 100644 --- a/packages/lib/queries.ts +++ b/packages/lib/queries.ts @@ -1,5 +1,12 @@ -import { useQuery } from "@tanstack/react-query" +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query" import type { useCustomer } from "autumn-js/react" +import { $fetch } from "./api" +import { toast } from "sonner" +import type { DocumentsWithMemoriesResponseSchema } from "../validation/api" +import type { z } from "zod" + +type DocumentsResponse = z.infer<typeof DocumentsWithMemoriesResponseSchema> +type DocumentWithMemories = DocumentsResponse["documents"][0] export const fetchSubscriptionStatus = ( autumn: ReturnType<typeof useCustomer>, @@ -84,3 +91,68 @@ export const fetchProProduct = (autumn: ReturnType<typeof useCustomer>) => staleTime: 30 * 1000, // 30 seconds gcTime: 5 * 60 * 1000, // 5 minutes }) + +export const useDeleteDocument = (selectedProject: string) => { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: async (documentId: string) => { + // context for LLM: delete/memories/:documentId is documents delete endpoint not memories delete endpoint + const response = await $fetch(`@delete/memories/${documentId}`) + if (response.error) { + throw new Error(response.error?.message || "Failed to delete document") + } + return response.data + }, + onMutate: async (documentId: string) => { + await queryClient.cancelQueries({ + queryKey: ["documents-with-memories", selectedProject], + }) + + const previousData = queryClient.getQueryData([ + "documents-with-memories", + selectedProject, + ]) + + queryClient.setQueryData( + ["documents-with-memories", selectedProject], + (old: unknown) => { + if (!old || typeof old !== "object") return old + const typedOld = old as { + pages?: Array<{ documents?: DocumentWithMemories[] }> + } + return { + ...typedOld, + pages: typedOld.pages?.map((page) => ({ + ...page, + documents: page.documents?.filter( + (doc: DocumentWithMemories) => doc.id !== documentId, + ), + })), + } + }, + ) + + return { previousData } + }, + onSuccess: () => { + toast.success("Memory deleted successfully") + }, + onError: (error, _documentId, context) => { + if (context?.previousData) { + queryClient.setQueryData( + ["documents-with-memories", selectedProject], + context.previousData, + ) + } + toast.error("Failed to delete memory", { + description: error instanceof Error ? error.message : "Unknown error", + }) + }, + onSettled: () => { + queryClient.invalidateQueries({ + queryKey: ["documents-with-memories", selectedProject], + }) + }, + }) +} |