diff options
| author | MaheshtheDev <[email protected]> | 2025-09-13 22:27:50 +0000 |
|---|---|---|
| committer | MaheshtheDev <[email protected]> | 2025-09-13 22:27:50 +0000 |
| commit | 82eb17363531e75d6153a4d2095874f6d5d19eaf (patch) | |
| tree | ad497e31039eab7fbe0d5414692349bab66f4e45 | |
| parent | fix: connections activation autumn (#419) (diff) | |
| download | supermemory-82eb17363531e75d6153a4d2095874f6d5d19eaf.tar.xz supermemory-82eb17363531e75d6153a4d2095874f6d5d19eaf.zip | |
ui: delete document and related memories dialog (#420)
| -rw-r--r-- | apps/web/components/memory-list-view.tsx | 285 | ||||
| -rw-r--r-- | packages/lib/queries.ts | 74 |
2 files changed, 249 insertions, 110 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/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], + }) + }, + }) +} |