ui: delete document and related memories dialog (#420)

This commit is contained in:
MaheshtheDev 2025-09-13 22:27:50 +00:00
parent 7b632e16eb
commit 82eb173635
2 changed files with 249 additions and 110 deletions

View file

@ -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}
/>
</>
);
};
)
}

View file

@ -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],
})
},
})
}