mirror of
https://github.com/supermemoryai/supermemory.git
synced 2026-10-09 03:18:04 +00:00
feat: bulk delete documents in nova app (#777)

This commit is contained in:
parent
a0514e7a44
commit
84d0fb802a
10 changed files with 501 additions and 133 deletions
|
|
@ -137,13 +137,66 @@ export default function NewPage() {
|
|||
const resetDraft = useQuickNoteDraftReset(selectedProject)
|
||||
const { draft: quickNoteDraft } = useQuickNoteDraft(selectedProject || "")
|
||||
|
||||
const { noteMutation } = useDocumentMutations({
|
||||
const { noteMutation, bulkDeleteMutation } = useDocumentMutations({
|
||||
onClose: () => {
|
||||
resetDraft()
|
||||
setIsFullscreen(false)
|
||||
},
|
||||
})
|
||||
|
||||
const [selectedDocumentIds, setSelectedDocumentIds] = useState<Set<string>>(
|
||||
new Set(),
|
||||
)
|
||||
const [isSelectionMode, setIsSelectionMode] = useState(false)
|
||||
|
||||
const handleToggleSelection = useCallback((documentId: string) => {
|
||||
setSelectedDocumentIds((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(documentId)) {
|
||||
next.delete(documentId)
|
||||
} else {
|
||||
next.add(documentId)
|
||||
}
|
||||
return next
|
||||
})
|
||||
}, [])
|
||||
|
||||
const handleClearSelection = useCallback(() => {
|
||||
setSelectedDocumentIds(new Set())
|
||||
setIsSelectionMode(false)
|
||||
}, [])
|
||||
|
||||
const handleEnterSelectionMode = useCallback(() => {
|
||||
setIsSelectionMode(true)
|
||||
}, [])
|
||||
|
||||
const handleSelectAllVisible = useCallback((visibleIds: string[]) => {
|
||||
setSelectedDocumentIds((prev) => {
|
||||
const next = new Set(prev)
|
||||
for (const id of visibleIds) {
|
||||
next.add(id)
|
||||
}
|
||||
return next
|
||||
})
|
||||
}, [])
|
||||
|
||||
const handleBulkDelete = useCallback(() => {
|
||||
const ids = Array.from(selectedDocumentIds)
|
||||
if (ids.length === 0) return
|
||||
bulkDeleteMutation.mutate(
|
||||
{ documentIds: ids },
|
||||
{
|
||||
onSuccess: () => {
|
||||
setSelectedDocumentIds(new Set())
|
||||
setIsSelectionMode(false)
|
||||
if (selectedDocument && ids.includes(selectedDocument.id ?? "")) {
|
||||
setDocId(null)
|
||||
}
|
||||
},
|
||||
},
|
||||
)
|
||||
}, [selectedDocumentIds, bulkDeleteMutation, selectedDocument, setDocId])
|
||||
|
||||
type SpaceHighlightsResponse = {
|
||||
highlights: HighlightItem[]
|
||||
questions: string[]
|
||||
|
|
@ -349,6 +402,14 @@ export default function NewPage() {
|
|||
<MemoriesGrid
|
||||
isChatOpen={chatOpen}
|
||||
onOpenDocument={handleOpenDocument}
|
||||
isSelectionMode={isSelectionMode}
|
||||
selectedDocumentIds={selectedDocumentIds}
|
||||
onEnterSelectionMode={handleEnterSelectionMode}
|
||||
onToggleSelection={handleToggleSelection}
|
||||
onClearSelection={handleClearSelection}
|
||||
onSelectAllVisible={handleSelectAllVisible}
|
||||
onBulkDelete={handleBulkDelete}
|
||||
isBulkDeleting={bulkDeleteMutation.isPending}
|
||||
quickNoteProps={{
|
||||
onSave: handleQuickNoteSave,
|
||||
onMaximize: handleMaximize,
|
||||
|
|
|
|||
|
|
@ -185,7 +185,9 @@ export default function MigrateMCPPage() {
|
|||
className="bg-white/5 border-white/10 text-white placeholder:text-slate-500 focus:border-blue-500/50 focus:ring-blue-500/20 transition-all duration-200 pl-4 pr-4 py-3 rounded-xl"
|
||||
disabled={migrateMutation.isPending}
|
||||
id="mcpUrl"
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setMcpUrl(e.target.value)}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
||||
setMcpUrl(e.target.value)
|
||||
}
|
||||
placeholder="https://mcp.supermemory.ai/userId/sse"
|
||||
type="url"
|
||||
value={mcpUrl}
|
||||
|
|
@ -205,7 +207,9 @@ export default function MigrateMCPPage() {
|
|||
className="bg-white/5 border-white/10 text-white placeholder:text-slate-500 focus:border-blue-500/50 focus:ring-blue-500/20 transition-all duration-200 pl-4 pr-4 py-3 rounded-xl"
|
||||
disabled={migrateMutation.isPending}
|
||||
id="projectId"
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setProjectId(e.target.value)}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
||||
setProjectId(e.target.value)
|
||||
}
|
||||
placeholder="Project ID (default: 'default')"
|
||||
type="text"
|
||||
value={projectId}
|
||||
|
|
|
|||
|
|
@ -675,104 +675,104 @@ export function ChatSidebar({
|
|||
<HistoryIcon className="size-4 text-[#737373]" />
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-lg bg-[#0A0E14] border-[#17181AB2] text-white">
|
||||
<DialogHeader className="pb-4 border-b border-[#17181AB2]">
|
||||
<DialogTitle>Chat History</DialogTitle>
|
||||
<DialogDescription className="text-[#737373]">
|
||||
Project: {selectedProject}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<ScrollArea className="max-h-96">
|
||||
{isLoadingThreads ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<SuperLoader label="Loading..." />
|
||||
</div>
|
||||
) : threads.length === 0 ? (
|
||||
<div className="text-sm text-[#737373] text-center py-8">
|
||||
No conversations yet
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-1">
|
||||
{threads.map((thread) => {
|
||||
const isActive = thread.id === currentChatId
|
||||
return (
|
||||
<button
|
||||
key={thread.id}
|
||||
type="button"
|
||||
onClick={() => loadThread(thread.id)}
|
||||
className={cn(
|
||||
"flex items-center justify-between rounded-md px-3 py-2 w-full text-left transition-colors",
|
||||
isActive
|
||||
? "bg-[#267BF1]/10"
|
||||
: "hover:bg-[#17181A]",
|
||||
)}
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm font-medium truncate">
|
||||
{thread.title || "Untitled Chat"}
|
||||
</div>
|
||||
<div className="text-xs text-[#737373]">
|
||||
{formatRelativeTime(thread.updatedAt)}
|
||||
</div>
|
||||
<DialogContent className="sm:max-w-lg bg-[#0A0E14] border-[#17181AB2] text-white">
|
||||
<DialogHeader className="pb-4 border-b border-[#17181AB2]">
|
||||
<DialogTitle>Chat History</DialogTitle>
|
||||
<DialogDescription className="text-[#737373]">
|
||||
Project: {selectedProject}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<ScrollArea className="max-h-96">
|
||||
{isLoadingThreads ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<SuperLoader label="Loading..." />
|
||||
</div>
|
||||
) : threads.length === 0 ? (
|
||||
<div className="text-sm text-[#737373] text-center py-8">
|
||||
No conversations yet
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-1">
|
||||
{threads.map((thread) => {
|
||||
const isActive = thread.id === currentChatId
|
||||
return (
|
||||
<button
|
||||
key={thread.id}
|
||||
type="button"
|
||||
onClick={() => loadThread(thread.id)}
|
||||
className={cn(
|
||||
"flex items-center justify-between rounded-md px-3 py-2 w-full text-left transition-colors",
|
||||
isActive
|
||||
? "bg-[#267BF1]/10"
|
||||
: "hover:bg-[#17181A]",
|
||||
)}
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm font-medium truncate">
|
||||
{thread.title || "Untitled Chat"}
|
||||
</div>
|
||||
{confirmingDeleteId === thread.id ? (
|
||||
<div className="flex items-center gap-1 ml-2">
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
deleteThread(thread.id)
|
||||
}}
|
||||
className="bg-red-500 text-white hover:bg-red-600 h-7 w-7"
|
||||
>
|
||||
<Check className="size-3" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setConfirmingDeleteId(null)
|
||||
}}
|
||||
className="h-7 w-7"
|
||||
>
|
||||
<XIcon className="size-3 text-[#737373]" />
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-xs text-[#737373]">
|
||||
{formatRelativeTime(thread.updatedAt)}
|
||||
</div>
|
||||
</div>
|
||||
{confirmingDeleteId === thread.id ? (
|
||||
<div className="flex items-center gap-1 ml-2">
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
deleteThread(thread.id)
|
||||
}}
|
||||
className="bg-red-500 text-white hover:bg-red-600 h-7 w-7"
|
||||
>
|
||||
<Check className="size-3" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setConfirmingDeleteId(thread.id)
|
||||
setConfirmingDeleteId(null)
|
||||
}}
|
||||
className="h-7 w-7 ml-2"
|
||||
className="h-7 w-7"
|
||||
>
|
||||
<Trash2 className="size-3 text-[#737373]" />
|
||||
<XIcon className="size-3 text-[#737373]" />
|
||||
</Button>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</ScrollArea>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full border-dashed border-[#73737333] bg-transparent hover:bg-[#17181A]"
|
||||
onClick={() => {
|
||||
handleNewChat()
|
||||
setIsHistoryOpen(false)
|
||||
}}
|
||||
>
|
||||
<Plus className="size-4 mr-1" /> New Conversation
|
||||
</Button>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setConfirmingDeleteId(thread.id)
|
||||
}}
|
||||
className="h-7 w-7 ml-2"
|
||||
>
|
||||
<Trash2 className="size-3 text-[#737373]" />
|
||||
</Button>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</ScrollArea>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full border-dashed border-[#73737333] bg-transparent hover:bg-[#17181A]"
|
||||
onClick={() => {
|
||||
handleNewChat()
|
||||
setIsHistoryOpen(false)
|
||||
}}
|
||||
>
|
||||
<Plus className="size-4 mr-1" /> New Conversation
|
||||
</Button>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<Button
|
||||
variant="headers"
|
||||
className="rounded-full text-base gap-3 h-10! border-[#73737333] bg-[#0D121A] cursor-pointer"
|
||||
|
|
|
|||
|
|
@ -31,6 +31,17 @@ import { HighlightsCard, type HighlightItem } from "./highlights-card"
|
|||
import { GraphCard } from "./memory-graph"
|
||||
import { Button } from "@ui/components/button"
|
||||
import { categoriesParam } from "@/lib/search-params"
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@ui/components/alert-dialog"
|
||||
import { CheckIcon, Trash2Icon, XIcon } from "lucide-react"
|
||||
|
||||
// Document category type
|
||||
type DocumentCategory =
|
||||
|
|
@ -84,6 +95,14 @@ interface HighlightsProps {
|
|||
interface MemoriesGridProps {
|
||||
isChatOpen: boolean
|
||||
onOpenDocument: (document: DocumentWithMemories) => void
|
||||
isSelectionMode?: boolean
|
||||
selectedDocumentIds?: Set<string>
|
||||
onEnterSelectionMode?: () => void
|
||||
onToggleSelection?: (documentId: string) => void
|
||||
onClearSelection?: () => void
|
||||
onSelectAllVisible?: (visibleIds: string[]) => void
|
||||
onBulkDelete?: () => void
|
||||
isBulkDeleting?: boolean
|
||||
quickNoteProps?: QuickNoteProps
|
||||
highlightsProps?: HighlightsProps
|
||||
}
|
||||
|
|
@ -91,11 +110,20 @@ interface MemoriesGridProps {
|
|||
export function MemoriesGrid({
|
||||
isChatOpen,
|
||||
onOpenDocument,
|
||||
isSelectionMode = false,
|
||||
selectedDocumentIds = new Set(),
|
||||
onEnterSelectionMode,
|
||||
onToggleSelection,
|
||||
onClearSelection,
|
||||
onSelectAllVisible,
|
||||
onBulkDelete,
|
||||
isBulkDeleting = false,
|
||||
quickNoteProps,
|
||||
highlightsProps,
|
||||
}: MemoriesGridProps) {
|
||||
const [showBulkDeleteConfirm, setShowBulkDeleteConfirm] = useState(false)
|
||||
const { user } = useAuth()
|
||||
const { selectedProjects, effectiveContainerTags } = useProject()
|
||||
const { effectiveContainerTags } = useProject()
|
||||
const isMobile = useIsMobile()
|
||||
const [selectedCategories, setSelectedCategories] = useQueryState(
|
||||
"categories",
|
||||
|
|
@ -241,11 +269,31 @@ export function MemoriesGrid({
|
|||
|
||||
const handleCardClick = useCallback(
|
||||
(document: DocumentWithMemories) => {
|
||||
onOpenDocument(document)
|
||||
if (isSelectionMode && onToggleSelection && document.id) {
|
||||
onToggleSelection(document.id)
|
||||
} else {
|
||||
onOpenDocument(document)
|
||||
}
|
||||
},
|
||||
[onOpenDocument],
|
||||
[isSelectionMode, onToggleSelection, onOpenDocument],
|
||||
)
|
||||
|
||||
const handleSelectAllVisible = useCallback(() => {
|
||||
if (onSelectAllVisible) {
|
||||
onSelectAllVisible(documents.map((d) => d.id).filter(Boolean) as string[])
|
||||
}
|
||||
}, [documents, onSelectAllVisible])
|
||||
|
||||
const handleBulkDeleteClick = useCallback(() => {
|
||||
if (selectedDocumentIds.size === 0) return
|
||||
setShowBulkDeleteConfirm(true)
|
||||
}, [selectedDocumentIds.size])
|
||||
|
||||
const handleBulkDeleteConfirm = useCallback(() => {
|
||||
setShowBulkDeleteConfirm(false)
|
||||
onBulkDelete?.()
|
||||
}, [onBulkDelete])
|
||||
|
||||
const renderMasonryItem = useCallback(
|
||||
({
|
||||
index,
|
||||
|
|
@ -257,13 +305,21 @@ export function MemoriesGrid({
|
|||
width: number
|
||||
}) => {
|
||||
if (data.type === "document") {
|
||||
const doc = data.data
|
||||
return (
|
||||
<ErrorBoundary>
|
||||
<DocumentCard
|
||||
index={index}
|
||||
data={data.data}
|
||||
data={doc}
|
||||
width={width}
|
||||
onClick={handleCardClick}
|
||||
isSelectionMode={isSelectionMode}
|
||||
isSelected={doc.id ? selectedDocumentIds.has(doc.id) : false}
|
||||
onToggleSelection={
|
||||
doc.id && onToggleSelection
|
||||
? () => onToggleSelection(doc.id as string)
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</ErrorBoundary>
|
||||
)
|
||||
|
|
@ -271,7 +327,7 @@ export function MemoriesGrid({
|
|||
|
||||
return null
|
||||
},
|
||||
[handleCardClick],
|
||||
[handleCardClick, isSelectionMode, selectedDocumentIds, onToggleSelection],
|
||||
)
|
||||
|
||||
if (!user) {
|
||||
|
|
@ -286,37 +342,140 @@ export function MemoriesGrid({
|
|||
|
||||
return (
|
||||
<div className="relative">
|
||||
<div id="filter-pills" className="flex flex-wrap gap-1.5 mb-3">
|
||||
<Button
|
||||
className={cn(
|
||||
dmSansClassName(),
|
||||
"rounded-full border border-[#161F2C] bg-[#0D121A] px-2.5 py-1 text-xs h-auto hover:bg-[#00173C] hover:border-[#2261CA33]",
|
||||
selectedCategories.length === 0 &&
|
||||
"bg-[#00173C] border-[#2261CA33]",
|
||||
)}
|
||||
onClick={handleSelectAll}
|
||||
>
|
||||
All
|
||||
{facetsData?.total !== undefined && (
|
||||
<span className="ml-1 text-[#737373]">({facetsData.total})</span>
|
||||
)}
|
||||
</Button>
|
||||
{facetsData?.facets.map((facet: DocumentFacet) => (
|
||||
<div
|
||||
id="filter-pills"
|
||||
className="flex items-center justify-between gap-4 mb-3"
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<Button
|
||||
key={facet.category}
|
||||
className={cn(
|
||||
dmSansClassName(),
|
||||
"rounded-full border border-[#161F2C] bg-[#0D121A] px-2.5 py-1 text-xs h-auto hover:bg-[#00173C] hover:border-[#2261CA33]",
|
||||
selectedCategories.includes(facet.category) &&
|
||||
selectedCategories.length === 0 &&
|
||||
"bg-[#00173C] border-[#2261CA33]",
|
||||
)}
|
||||
onClick={() => handleCategoryToggle(facet.category)}
|
||||
onClick={handleSelectAll}
|
||||
>
|
||||
{facet.label}
|
||||
<span className="ml-1 text-[#737373]">({facet.count})</span>
|
||||
All
|
||||
{facetsData?.total !== undefined && (
|
||||
<span className="ml-1 text-[#737373]">({facetsData.total})</span>
|
||||
)}
|
||||
</Button>
|
||||
))}
|
||||
{facetsData?.facets.map((facet: DocumentFacet) => (
|
||||
<Button
|
||||
key={facet.category}
|
||||
className={cn(
|
||||
dmSansClassName(),
|
||||
"rounded-full border border-[#161F2C] bg-[#0D121A] px-2.5 py-1 text-xs h-auto hover:bg-[#00173C] hover:border-[#2261CA33]",
|
||||
selectedCategories.includes(facet.category) &&
|
||||
"bg-[#00173C] border-[#2261CA33]",
|
||||
)}
|
||||
onClick={() => handleCategoryToggle(facet.category)}
|
||||
>
|
||||
{facet.label}
|
||||
<span className="ml-1 text-[#737373]">({facet.count})</span>
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
{isSelectionMode && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Exit selection mode"
|
||||
className="w-8 h-8 flex items-center justify-center rounded-full border border-[#161F2C] bg-[#0D121A] hover:bg-[#00173C] transition-colors cursor-pointer"
|
||||
onClick={onClearSelection}
|
||||
>
|
||||
<XIcon className="w-4 h-4 text-[#737373]" />
|
||||
</button>
|
||||
{selectedDocumentIds.size > 0 ? (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
dmSansClassName(),
|
||||
"text-xs text-[#737373] hover:text-white transition-colors cursor-pointer",
|
||||
)}
|
||||
onClick={handleSelectAllVisible}
|
||||
>
|
||||
Select all
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
dmSansClassName(),
|
||||
"flex items-center gap-1 text-xs text-red-400 hover:text-red-300 transition-colors cursor-pointer disabled:opacity-50",
|
||||
)}
|
||||
onClick={handleBulkDeleteClick}
|
||||
disabled={isBulkDeleting}
|
||||
>
|
||||
<Trash2Icon className="w-3 h-3" />
|
||||
Delete ({selectedDocumentIds.size})
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<p className={cn(dmSansClassName(), "text-xs text-[#737373]")}>
|
||||
Select one or more documents
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{!isSelectionMode && onEnterSelectionMode && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Enter selection mode"
|
||||
className="w-8 h-8 flex items-center justify-center rounded-full border border-[#161F2C] bg-[#0D121A] hover:bg-[#00173C] transition-colors cursor-pointer"
|
||||
onClick={onEnterSelectionMode}
|
||||
>
|
||||
<div className="w-3 h-3 rounded-[2.25px] border border-[#737373]" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AlertDialog
|
||||
open={showBulkDeleteConfirm}
|
||||
onOpenChange={setShowBulkDeleteConfirm}
|
||||
>
|
||||
<AlertDialogContent
|
||||
className={cn(
|
||||
"border-none bg-[#1B1F24] p-4 gap-4 rounded-[22px] max-w-[400px]",
|
||||
dmSansClassName(),
|
||||
)}
|
||||
style={{
|
||||
boxShadow:
|
||||
"0 2.842px 14.211px 0 rgba(0, 0, 0, 0.25), 0.711px 0.711px 0.711px 0 rgba(255, 255, 255, 0.10) inset",
|
||||
}}
|
||||
>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle className="text-[#FAFAFA] font-medium">
|
||||
Delete selected memories?
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription className="text-[#737373]">
|
||||
This will permanently delete {selectedDocumentIds.size}{" "}
|
||||
{selectedDocumentIds.size === 1 ? "memory" : "memories"}. This
|
||||
action cannot be undone.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter className="flex-row gap-2 sm:justify-end">
|
||||
<AlertDialogCancel
|
||||
className="border-none bg-transparent text-[#737373] hover:bg-[#14161A]/50 hover:text-white rounded-full cursor-pointer"
|
||||
onClick={() => setShowBulkDeleteConfirm(false)}
|
||||
>
|
||||
Cancel
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
className="bg-red-600 hover:bg-red-700 text-white border-none rounded-[10px] cursor-pointer"
|
||||
onClick={handleBulkDeleteConfirm}
|
||||
disabled={isBulkDeleting}
|
||||
>
|
||||
{isBulkDeleting ? "Deleting…" : "Delete"}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
{error ? (
|
||||
<div className="h-full flex items-center justify-center p-4">
|
||||
<div className="text-center text-muted-foreground">
|
||||
|
|
@ -411,18 +570,31 @@ function DocumentUrlDisplay({ url }: { url: string }) {
|
|||
)
|
||||
}
|
||||
|
||||
function isTemporaryId(id: string | null | undefined): boolean {
|
||||
if (!id) return false
|
||||
return id.startsWith("temp-") || id.startsWith("temp-file-")
|
||||
}
|
||||
|
||||
const DocumentCard = memo(
|
||||
({
|
||||
index: _index,
|
||||
data: document,
|
||||
width,
|
||||
onClick,
|
||||
isSelectionMode = false,
|
||||
isSelected = false,
|
||||
onToggleSelection,
|
||||
}: {
|
||||
index: number
|
||||
data: DocumentWithMemories
|
||||
width: number
|
||||
onClick: (document: DocumentWithMemories) => void
|
||||
isSelectionMode?: boolean
|
||||
isSelected?: boolean
|
||||
onToggleSelection?: () => void
|
||||
}) => {
|
||||
const canSelect =
|
||||
!isTemporaryId(document.id) && !isTemporaryId(document.customId)
|
||||
const [rotation, setRotation] = useState({ rotateX: 0, rotateY: 0 })
|
||||
const cardRef = useRef<HTMLButtonElement>(null)
|
||||
const [ogData, setOgData] = useState<OgData | null>(null)
|
||||
|
|
@ -465,8 +637,12 @@ const DocumentCard = memo(
|
|||
}
|
||||
}, [needsOgData, ogData, isLoadingOg, document.url])
|
||||
|
||||
useEffect(() => {
|
||||
if (isSelectionMode) setRotation({ rotateX: 0, rotateY: 0 })
|
||||
}, [isSelectionMode])
|
||||
|
||||
const handleMouseMove = (e: React.MouseEvent<HTMLButtonElement>) => {
|
||||
if (!cardRef.current) return
|
||||
if (isSelectionMode || !cardRef.current) return
|
||||
|
||||
const rect = cardRef.current.getBoundingClientRect()
|
||||
const centerX = rect.left + rect.width / 2
|
||||
|
|
@ -487,12 +663,32 @@ const DocumentCard = memo(
|
|||
}
|
||||
|
||||
return (
|
||||
<div className="p-2" style={{ width }}>
|
||||
<div className="p-2 relative" style={{ width }}>
|
||||
{isSelectionMode && canSelect && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={isSelected ? "Deselect" : "Select"}
|
||||
className="absolute top-5 right-5 z-10 flex items-center justify-center cursor-pointer"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onToggleSelection?.()
|
||||
}}
|
||||
>
|
||||
{isSelected ? (
|
||||
<div className="w-3 h-3 rounded-[2.25px] border border-[#369BFD] bg-[#369BFD] flex items-center justify-center">
|
||||
<CheckIcon className="w-2 h-2 text-white" strokeWidth={3} />
|
||||
</div>
|
||||
) : (
|
||||
<div className="w-3 h-3 rounded-[2.25px] border border-[#737373]" />
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
id={document.id ? `document-card-${document.id}` : undefined}
|
||||
ref={cardRef}
|
||||
type="button"
|
||||
className={cn(
|
||||
"rounded-[22px] bg-[#1B1F24] px-1 space-y-2 pt-1 cursor-pointer w-full",
|
||||
"rounded-[22px] bg-[#1B1F24] px-1 space-y-2 pt-1 cursor-pointer w-full relative overflow-hidden",
|
||||
"border-none text-left transition-transform duration-200 ease-out",
|
||||
document.type === "image" ||
|
||||
document.metadata?.mimeType?.toString().startsWith("image/")
|
||||
|
|
@ -505,10 +701,15 @@ const DocumentCard = memo(
|
|||
style={{
|
||||
boxShadow:
|
||||
"0 2.842px 14.211px 0 rgba(0, 0, 0, 0.25), 0.711px 0.711px 0.711px 0 rgba(255, 255, 255, 0.10) inset",
|
||||
transform: `perspective(1000px) rotateX(${rotation.rotateX}deg) rotateY(${rotation.rotateY}deg)`,
|
||||
transformStyle: "preserve-3d",
|
||||
transform: isSelectionMode
|
||||
? "none"
|
||||
: `perspective(1000px) rotateX(${rotation.rotateX}deg) rotateY(${rotation.rotateY}deg)`,
|
||||
transformStyle: isSelectionMode ? undefined : "preserve-3d",
|
||||
}}
|
||||
>
|
||||
{isSelectionMode && isSelected && (
|
||||
<div className="absolute inset-0 bg-[rgba(75,160,250,0.25)] rounded-[22px] z-1 pointer-events-none" />
|
||||
)}
|
||||
<ContentPreview document={document} ogData={ogData} />
|
||||
{!(
|
||||
document.type === "image" ||
|
||||
|
|
|
|||
|
|
@ -445,9 +445,7 @@ export const MemoryGraph = ({
|
|||
variant={variant}
|
||||
/>
|
||||
|
||||
{!isLoading &&
|
||||
!nodes.some((n) => n.type === "document") &&
|
||||
children}
|
||||
{!isLoading && !nodes.some((n) => n.type === "document") && children}
|
||||
|
||||
<div
|
||||
className="w-full h-full relative overflow-hidden touch-none select-none"
|
||||
|
|
|
|||
|
|
@ -29,8 +29,6 @@ type InfiniteQueryData = {
|
|||
pageParams: number[]
|
||||
}
|
||||
|
||||
type QueryData = DocumentsQueryData | InfiniteQueryData
|
||||
|
||||
interface UseDocumentMutationsOptions {
|
||||
onClose?: () => void
|
||||
}
|
||||
|
|
@ -140,6 +138,62 @@ function removeDocumentFromQueryData(
|
|||
return old
|
||||
}
|
||||
|
||||
function removeDocumentsFromQueryData(
|
||||
old: unknown,
|
||||
documentIds: Set<string>,
|
||||
): unknown {
|
||||
if (!old || typeof old !== "object" || documentIds.size === 0) return old
|
||||
|
||||
const data = old as Record<string, unknown>
|
||||
|
||||
if ("pages" in data && Array.isArray(data.pages)) {
|
||||
return {
|
||||
...data,
|
||||
pages: data.pages.map((page: unknown) => {
|
||||
const p = page as Record<string, unknown>
|
||||
if (!p?.documents || !Array.isArray(p.documents)) return page
|
||||
const filtered = (p.documents as DocumentWithId[]).filter(
|
||||
(doc) =>
|
||||
!documentIds.has(doc.id ?? "") &&
|
||||
!documentIds.has(doc.customId ?? ""),
|
||||
)
|
||||
const removed =
|
||||
(p.documents as DocumentWithId[]).length - filtered.length
|
||||
return {
|
||||
...p,
|
||||
documents: filtered,
|
||||
pagination: p.pagination
|
||||
? {
|
||||
...(p.pagination as Record<string, unknown>),
|
||||
totalItems: Math.max(
|
||||
0,
|
||||
((p.pagination as Record<string, number>).totalItems ?? 0) -
|
||||
removed,
|
||||
),
|
||||
}
|
||||
: p.pagination,
|
||||
}
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
if ("documents" in data && Array.isArray(data.documents)) {
|
||||
const filtered = (data.documents as DocumentWithId[]).filter(
|
||||
(doc) =>
|
||||
!documentIds.has(doc.id ?? "") && !documentIds.has(doc.customId ?? ""),
|
||||
)
|
||||
const removed =
|
||||
(data.documents as DocumentWithId[]).length - filtered.length
|
||||
return {
|
||||
...data,
|
||||
documents: filtered,
|
||||
totalCount: Math.max(0, ((data.totalCount as number) ?? 0) - removed),
|
||||
}
|
||||
}
|
||||
|
||||
return old
|
||||
}
|
||||
|
||||
async function cancelAndSnapshotQueries(
|
||||
queryClient: QueryClient,
|
||||
): Promise<[unknown, unknown][]> {
|
||||
|
|
@ -460,11 +514,50 @@ export function useDocumentMutations({
|
|||
},
|
||||
})
|
||||
|
||||
const bulkDeleteMutation = useMutation({
|
||||
mutationFn: async ({ documentIds }: { documentIds: string[] }) => {
|
||||
const response = await $fetch("@delete/documents/bulk", {
|
||||
body: { ids: documentIds },
|
||||
})
|
||||
|
||||
if (response.error) {
|
||||
throw new Error(response.error?.message || "Failed to delete documents")
|
||||
}
|
||||
|
||||
return response.data
|
||||
},
|
||||
onMutate: async ({ documentIds }) => {
|
||||
const previousQueries = await cancelAndSnapshotQueries(queryClient)
|
||||
const idSet = new Set(documentIds)
|
||||
|
||||
queryClient.setQueriesData(
|
||||
{ queryKey: ["documents-with-memories"] },
|
||||
(old) => removeDocumentsFromQueryData(old, idSet),
|
||||
)
|
||||
|
||||
return { previousQueries }
|
||||
},
|
||||
onError: (error, _variables, context) => {
|
||||
restoreQueriesFromSnapshot(queryClient, context?.previousQueries)
|
||||
toast.error("Failed to delete documents", {
|
||||
description: error instanceof Error ? error.message : "Unknown error",
|
||||
})
|
||||
},
|
||||
onSuccess: (_data, variables) => {
|
||||
analytics.documentsBulkDeleted({ count: variables.documentIds.length })
|
||||
toast.success(
|
||||
`${variables.documentIds.length} document${variables.documentIds.length === 1 ? "" : "s"} deleted`,
|
||||
)
|
||||
queryClient.invalidateQueries({ queryKey: ["documents-with-memories"] })
|
||||
},
|
||||
})
|
||||
|
||||
return {
|
||||
noteMutation,
|
||||
linkMutation,
|
||||
fileMutation,
|
||||
updateMutation,
|
||||
deleteMutation,
|
||||
bulkDeleteMutation,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -161,6 +161,9 @@ export const analytics = {
|
|||
documentDeleted: (props: { document_id: string }) =>
|
||||
safeCapture("document_deleted", props),
|
||||
|
||||
documentsBulkDeleted: (props: { count: number }) =>
|
||||
safeCapture("documents_bulk_deleted", props),
|
||||
|
||||
documentEdited: (props: { document_id: string }) =>
|
||||
safeCapture("document_edited", props),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,8 @@ import {
|
|||
AnalyticsChatResponseSchema,
|
||||
AnalyticsMemoryResponseSchema,
|
||||
AnalyticsUsageResponseSchema,
|
||||
BulkDeleteMemoriesResponseSchema,
|
||||
BulkDeleteMemoriesSchema,
|
||||
ConnectionResponseSchema,
|
||||
CreateProjectSchema,
|
||||
DeleteProjectResponseSchema,
|
||||
|
|
@ -170,6 +172,12 @@ export const apiSchema = createSchema({
|
|||
params: z.object({ id: z.string() }),
|
||||
},
|
||||
|
||||
// Bulk delete memories
|
||||
"@delete/documents/bulk": {
|
||||
body: BulkDeleteMemoriesSchema,
|
||||
output: BulkDeleteMemoriesResponseSchema,
|
||||
},
|
||||
|
||||
// Search operations
|
||||
"@post/search": {
|
||||
input: SearchRequestSchema,
|
||||
|
|
|
|||
|
|
@ -5,7 +5,8 @@ const SEARCH_MEMORY_SHORTCUT_URL =
|
|||
const ADD_MEMORY_SHORTCUT_URL =
|
||||
"https://www.icloud.com/shortcuts/0fd3e855be444845b457f94c78c2c8d9"
|
||||
const RAYCAST_EXTENSION_URL = "https://www.raycast.com/supermemory/supermemory"
|
||||
const CHROME_EXTENSION_URL = "https://chromewebstore.google.com/detail/supermemory/afpgkkipfdpeaflnpoaffkcankadgjfc"
|
||||
const CHROME_EXTENSION_URL =
|
||||
"https://chromewebstore.google.com/detail/supermemory/afpgkkipfdpeaflnpoaffkcankadgjfc"
|
||||
|
||||
export {
|
||||
BIG_DIMENSIONS_NEW,
|
||||
|
|
|
|||
|
|
@ -740,10 +740,9 @@ export const MemoryGraph = ({
|
|||
)}
|
||||
|
||||
{/* Show welcome screen when no memories exist */}
|
||||
{!isLoading &&
|
||||
(!data || !nodes.some((n) => n.type === "document")) && (
|
||||
<>{children}</>
|
||||
)}
|
||||
{!isLoading && (!data || !nodes.some((n) => n.type === "document")) && (
|
||||
<>{children}</>
|
||||
)}
|
||||
|
||||
{/* Graph container */}
|
||||
<div className={styles.graphContainer} ref={containerRef}>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue