Add bulk space deletion

This commit is contained in:
Dhravya Shah 2026-05-16 20:49:31 -07:00
parent 2fe01a5290
commit 63ab3ba25d
4 changed files with 547 additions and 65 deletions

View file

@ -59,6 +59,13 @@ interface SelectSpacesModalProps {
name: string
containerTag: string
}) => void
onBulkDeleteRequest?: (
projects: {
id: string
name: string
containerTag: string
}[],
) => void
}
type CategoryId =
@ -86,8 +93,14 @@ export function SelectSpacesModal({
onNewSpace,
enableDelete = false,
onDeleteRequest,
onBulkDeleteRequest,
}: SelectSpacesModalProps) {
const [searchQuery, setSearchQuery] = useState("")
const [isBulkDeleteMode, setIsBulkDeleteMode] = useState(false)
const [bulkDeleteTags, setBulkDeleteTags] = useState<Set<string>>(new Set())
const [lastBulkDeleteTag, setLastBulkDeleteTag] = useState<string | null>(
null,
)
const [editingProject, setEditingProject] = useState<{
id: string
containerTag: string
@ -200,6 +213,13 @@ export function SelectSpacesModal({
if (isOpen) setActiveCategory(defaultCategory)
}, [isOpen, defaultCategory])
useEffect(() => {
if (!activeDiscoverId) return
setIsBulkDeleteMode(false)
setBulkDeleteTags(new Set())
setLastBulkDeleteTag(null)
}, [activeDiscoverId])
const { org } = useAuth()
const queryClient = useQueryClient()
const [connectingPluginId, setConnectingPluginId] = useState<string | null>(
@ -319,6 +339,9 @@ export function SelectSpacesModal({
if (!isOpen) {
setNewKey(null)
setEditingProject(null)
setIsBulkDeleteMode(false)
setBulkDeleteTags(new Set())
setLastBulkDeleteTag(null)
}
}, [isOpen])
@ -337,6 +360,9 @@ export function SelectSpacesModal({
onClose()
setSearchQuery("")
setEditingProject(null)
setIsBulkDeleteMode(false)
setBulkDeleteTags(new Set())
setLastBulkDeleteTag(null)
}
},
[onClose],
@ -345,12 +371,22 @@ export function SelectSpacesModal({
const handleSelect = useCallback(
(containerTag: string) => {
setEditingProject(null)
setIsBulkDeleteMode(false)
setBulkDeleteTags(new Set())
setLastBulkDeleteTag(null)
onApply([containerTag])
setSearchQuery("")
},
[onApply],
)
const handleBulkModeToggle = useCallback(() => {
setEditingProject(null)
setBulkDeleteTags(new Set())
setLastBulkDeleteTag(null)
setIsBulkDeleteMode((prev) => !prev)
}, [])
const startEditing = useCallback((project: ContainerTagListType) => {
const name = project.name ?? project.containerTag
setEditingProject({
@ -442,6 +478,60 @@ export function SelectSpacesModal({
[filteredProjects, recentSet],
)
const visibleBulkDeleteTags = useMemo(
() =>
[...recentProjects, ...mainList]
.filter((project) => project.containerTag !== DEFAULT_PROJECT_ID)
.map((project) => project.containerTag),
[recentProjects, mainList],
)
const toggleBulkDeleteTag = useCallback(
(containerTag: string, shiftKey = false) => {
setBulkDeleteTags((prev) => {
const next = new Set(prev)
const currentIndex = visibleBulkDeleteTags.indexOf(containerTag)
const anchorIndex = lastBulkDeleteTag
? visibleBulkDeleteTags.indexOf(lastBulkDeleteTag)
: -1
if (shiftKey && currentIndex !== -1 && anchorIndex !== -1) {
const start = Math.min(anchorIndex, currentIndex)
const end = Math.max(anchorIndex, currentIndex)
for (const tag of visibleBulkDeleteTags.slice(start, end + 1)) {
next.add(tag)
}
} else if (next.has(containerTag)) {
next.delete(containerTag)
} else {
next.add(containerTag)
}
return next
})
setLastBulkDeleteTag(containerTag)
},
[lastBulkDeleteTag, visibleBulkDeleteTags],
)
const bulkDeleteProjects = useMemo(
() =>
allSpaces
.filter(
(project) =>
project.containerTag !== DEFAULT_PROJECT_ID &&
bulkDeleteTags.has(project.containerTag),
)
.map((project) => ({
id: project.id,
name: project.name ?? project.containerTag,
containerTag: project.containerTag,
})),
[allSpaces, bulkDeleteTags],
)
const bulkDeleteCount = bulkDeleteProjects.length
const renderRow = useCallback(
(project: ContainerTagListType) => {
const isSelected = currentSelection === project.containerTag
@ -452,32 +542,64 @@ export function SelectSpacesModal({
const pluginIdLabel = pluginProjectName || plugin?.projectId
const isDefault = project.containerTag === DEFAULT_PROJECT_ID
const canEdit = !isDefault && !plugin
const canBulkDelete = enableDelete && !isDefault
const isEditing = editingProject?.containerTag === project.containerTag
const isBulkDeleteSelected = bulkDeleteTags.has(project.containerTag)
const trimmedEditName = editingProject?.name.trim() ?? ""
const isSaveDisabled =
!trimmedEditName ||
trimmedEditName === editingProject?.originalName.trim() ||
updateProjectMutation.isPending
const handleRowAction = (
e: React.MouseEvent<HTMLButtonElement, MouseEvent>,
) => {
if (isEditing) return
if (isBulkDeleteMode) {
if (canBulkDelete) {
toggleBulkDeleteTag(project.containerTag, e.shiftKey)
}
return
}
handleSelect(project.containerTag)
}
return (
<div
key={project.containerTag}
className={cn(
"group flex min-w-0 max-w-full items-center gap-3 w-full px-3 py-2.5 rounded-[12px] transition-colors",
isSelected
(isBulkDeleteMode ? isBulkDeleteSelected : isSelected)
? "bg-[#14161A] shadow-inside-out"
: "hover:bg-[#14161A]/50",
isBulkDeleteMode &&
!canBulkDelete &&
"cursor-not-allowed opacity-45",
)}
>
<div
<button
type="button"
onClick={handleRowAction}
disabled={isBulkDeleteMode && !canBulkDelete}
aria-label={
isBulkDeleteMode ? "Select space for deletion" : "Select space"
}
aria-pressed={isBulkDeleteMode ? isBulkDeleteSelected : isSelected}
className={cn(
"w-4 h-4 rounded-full border-2 flex items-center justify-center shrink-0 transition-colors",
isSelected ? "border-[#4BA0FA]" : "border-[#737373]",
"w-4 h-4 rounded-full border-2 flex items-center justify-center shrink-0 transition-colors cursor-pointer disabled:cursor-not-allowed",
isBulkDeleteMode
? isBulkDeleteSelected
? "border-red-400 bg-red-400/10"
: "border-[#737373]"
: isSelected
? "border-[#4BA0FA]"
: "border-[#737373]",
)}
>
{isSelected && (
{isBulkDeleteMode ? (
isBulkDeleteSelected && <Check className="size-3 text-red-300" />
) : isSelected ? (
<div className="w-2 h-2 rounded-full bg-[#4BA0FA]" />
)}
</div>
) : null}
</button>
{isEditing ? (
<div className="flex min-w-0 flex-1 items-center gap-2">
<span className="shrink-0 text-lg">{project.emoji || "📁"}</span>
@ -519,8 +641,9 @@ export function SelectSpacesModal({
) : (
<button
type="button"
onClick={() => handleSelect(project.containerTag)}
className="flex min-w-0 flex-1 items-center gap-3 text-left cursor-pointer focus:outline-none focus:ring-0"
onClick={handleRowAction}
disabled={isBulkDeleteMode && !canBulkDelete}
className="flex min-w-0 flex-1 items-center gap-3 text-left cursor-pointer focus:outline-none focus:ring-0 disabled:cursor-not-allowed"
>
{plugin ? (
plugin.iconSrc ? (
@ -564,7 +687,7 @@ export function SelectSpacesModal({
</span>
</button>
)}
{canEdit && !isEditing && (
{canEdit && !isEditing && !isBulkDeleteMode && (
<button
type="button"
onClick={(e) => {
@ -577,37 +700,44 @@ export function SelectSpacesModal({
<Pencil className="size-3.5" />
</button>
)}
{enableDelete && !isDefault && !isEditing && onDeleteRequest && (
<button
type="button"
onClick={(e) => {
e.stopPropagation()
onDeleteRequest({
id: project.id,
name: project.name,
containerTag: project.containerTag,
})
}}
aria-label="Delete space"
className="shrink-0 opacity-0 group-hover:opacity-100 transition-opacity p-1.5 rounded-full hover:bg-red-500/15 cursor-pointer focus:outline-none"
>
<Trash2 className="size-3.5 text-red-400" />
</button>
)}
{enableDelete &&
!isDefault &&
!isEditing &&
!isBulkDeleteMode &&
onDeleteRequest && (
<button
type="button"
onClick={(e) => {
e.stopPropagation()
onDeleteRequest({
id: project.id,
name: project.name ?? project.containerTag,
containerTag: project.containerTag,
})
}}
aria-label="Delete space"
className="shrink-0 opacity-0 group-hover:opacity-100 transition-opacity p-1.5 rounded-full hover:bg-red-500/15 cursor-pointer focus:outline-none"
>
<Trash2 className="size-3.5 text-red-400" />
</button>
)}
</div>
)
},
[
cancelEditing,
bulkDeleteTags,
currentSelection,
editingProject,
enableDelete,
handleEditKeyDown,
handleSelect,
isBulkDeleteMode,
onDeleteRequest,
pluginMetaMap,
saveEditing,
startEditing,
toggleBulkDeleteTag,
updateProjectMutation.isPending,
],
)
@ -636,18 +766,39 @@ export function SelectSpacesModal({
Select Space
</p>
<p className="text-[#737373] font-medium text-[14px] leading-[1.35]">
Filter your memories by space
{isBulkDeleteMode
? "Choose spaces to permanently delete"
: "Filter your memories by space"}
</p>
</div>
<DialogPrimitive.Close
className="bg-[#0D121A] w-7 h-7 flex items-center justify-center focus:ring-ring rounded-full transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 border border-[rgba(115,115,115,0.2)] shrink-0"
style={{
boxShadow: "inset 1.313px 1.313px 3.938px 0px rgba(0,0,0,0.7)",
}}
>
<XIcon stroke="#737373" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
<div className="flex shrink-0 items-center gap-2">
{enableDelete && onBulkDeleteRequest && !activeDiscoverId && (
<button
type="button"
onClick={handleBulkModeToggle}
className={cn(
"flex h-7 items-center gap-1.5 rounded-full bg-[#0D121A] px-2.5 text-[12px] font-medium transition-colors hover:bg-[#121820] focus:outline-none",
isBulkDeleteMode ? "text-[#fafafa]" : "text-[#737373]",
)}
style={{
boxShadow:
"inset 1.313px 1.313px 3.938px 0px rgba(0,0,0,0.7)",
}}
>
<Trash2 className="size-3.5" />
{isBulkDeleteMode ? "Cancel" : "Bulk delete"}
</button>
)}
<DialogPrimitive.Close
className="bg-[#0D121A] w-7 h-7 flex items-center justify-center focus:ring-ring rounded-full transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 border border-[rgba(115,115,115,0.2)] shrink-0"
style={{
boxShadow: "inset 1.313px 1.313px 3.938px 0px rgba(0,0,0,0.7)",
}}
>
<XIcon stroke="#737373" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</div>
</div>
<div className="mt-4 flex min-h-0 flex-1 flex-col gap-5 overflow-hidden px-4 pb-4 sm:min-h-[420px] sm:flex-row sm:gap-3">
@ -823,21 +974,67 @@ export function SelectSpacesModal({
</div>
</div>
{showNewSpace &&
onNewSpace &&
!activeCategory.startsWith("discover:") && (
<div className="flex items-center justify-end border-t border-[rgba(82,89,102,0.18)] px-4 py-3">
<button
type="button"
onClick={onNewSpace}
className={cn(
"flex items-center gap-2 px-4 py-2 rounded-full text-[13px] font-medium text-[#fafafa] bg-[#14161A] shadow-inside-out hover:bg-[#121820] transition-colors cursor-pointer focus:outline-none focus:ring-0",
dmSansClassName(),
)}
>
<Plus className="size-4" />
New space
</button>
{!activeCategory.startsWith("discover:") &&
(isBulkDeleteMode || (showNewSpace && onNewSpace)) && (
<div className="flex items-center justify-between gap-3 border-t border-[rgba(82,89,102,0.18)] px-4 py-3">
{isBulkDeleteMode ? (
<>
<p className="min-w-0 text-[13px] font-medium text-[#737373]">
{bulkDeleteCount === 0
? "No spaces selected"
: `${bulkDeleteCount} ${
bulkDeleteCount === 1 ? "space" : "spaces"
} selected`}
</p>
<div className="flex shrink-0 items-center gap-2">
<button
type="button"
onClick={handleBulkModeToggle}
className={cn(
"px-3 py-2 text-[13px] font-medium text-[#737373] transition-colors hover:text-[#fafafa]",
dmSansClassName(),
)}
>
Cancel
</button>
<button
type="button"
disabled={bulkDeleteCount === 0}
onClick={() => {
if (bulkDeleteCount === 0) return
onBulkDeleteRequest?.(bulkDeleteProjects)
setIsBulkDeleteMode(false)
setBulkDeleteTags(new Set())
setLastBulkDeleteTag(null)
}}
className={cn(
"flex items-center gap-2 rounded-full bg-red-600 px-4 py-2 text-[13px] font-medium text-white transition-colors hover:bg-red-700 disabled:cursor-not-allowed disabled:opacity-40",
dmSansClassName(),
)}
>
<Trash2 className="size-4" />
Delete selected
</button>
</div>
</>
) : (
<>
<span />
{showNewSpace && onNewSpace && (
<button
type="button"
onClick={onNewSpace}
className={cn(
"flex items-center gap-2 px-4 py-2 rounded-full text-[13px] font-medium text-[#fafafa] bg-[#14161A] shadow-inside-out hover:bg-[#121820] transition-colors cursor-pointer focus:outline-none focus:ring-0",
dmSansClassName(),
)}
>
<Plus className="size-4" />
New space
</button>
)}
</>
)}
</div>
)}
</DialogContent>

View file

@ -7,7 +7,7 @@ import { cn } from "@lib/utils"
import { $fetch } from "@lib/api"
import { dmSans125ClassName, dmSansClassName } from "@/lib/fonts"
import { DEFAULT_PROJECT_ID } from "@lib/constants"
import { XIcon, Loader2 } from "lucide-react"
import { XIcon, Loader2, Trash2 } from "lucide-react"
import type { ContainerTagListType } from "@lib/types"
import { AddSpaceModal } from "./add-space-modal"
import { SelectSpacesModal } from "./select-spaces-modal"
@ -60,6 +60,12 @@ const triggerVariants = {
const RECENTS_KEY = "nova:space-selector:recents"
const RECENTS_MAX = 10
type DeleteProjectTarget = {
id: string
name: string
containerTag: string
}
function readRecents(): string[] {
if (typeof window === "undefined") return []
try {
@ -104,7 +110,7 @@ export function SpaceSelector({
const [recents, setRecents] = useState<string[]>([])
const [deleteDialog, setDeleteDialog] = useState<{
open: boolean
project: { id: string; name: string; containerTag: string } | null
project: DeleteProjectTarget | null
action: "move" | "delete"
targetProjectId: string
}>({
@ -113,8 +119,18 @@ export function SpaceSelector({
action: "move",
targetProjectId: "",
})
const [bulkDeleteDialog, setBulkDeleteDialog] = useState<{
open: boolean
projects: DeleteProjectTarget[]
confirmation: string
}>({
open: false,
projects: [],
confirmation: "",
})
const { deleteProjectMutation } = useProjectMutations()
const { deleteProjectMutation, deleteProjectsMutation } =
useProjectMutations()
const { allProjects, isLoading } = useContainerTags()
useEffect(() => {
@ -211,14 +227,24 @@ export function SpaceSelector({
setShowCreateDialog(true)
}, [])
const handleDeleteRequest = useCallback(
(project: { id: string; name: string; containerTag: string }) => {
const handleDeleteRequest = useCallback((project: DeleteProjectTarget) => {
setShowSelectSpacesModal(false)
setDeleteDialog({
open: true,
project,
action: "move",
targetProjectId: "",
})
}, [])
const handleBulkDeleteRequest = useCallback(
(projects: DeleteProjectTarget[]) => {
if (projects.length === 0) return
setShowSelectSpacesModal(false)
setDeleteDialog({
setBulkDeleteDialog({
open: true,
project,
action: "move",
targetProjectId: "",
projects,
confirmation: "",
})
},
[],
@ -229,6 +255,7 @@ export function SpaceSelector({
deleteProjectMutation.mutate(
{
projectId: deleteDialog.project.id,
containerTag: deleteDialog.project.containerTag,
action: deleteDialog.action,
targetProjectId:
deleteDialog.action === "move"
@ -257,6 +284,38 @@ export function SpaceSelector({
})
}
const handleBulkDeleteCancel = () => {
setBulkDeleteDialog({
open: false,
projects: [],
confirmation: "",
})
}
const handleBulkDeleteConfirm = () => {
if (
bulkDeleteDialog.confirmation !== "DELETE" ||
bulkDeleteDialog.projects.length === 0
) {
return
}
deleteProjectsMutation.mutate(
{
projects: bulkDeleteDialog.projects,
},
{
onSettled: () => {
setBulkDeleteDialog({
open: false,
projects: [],
confirmation: "",
})
},
},
)
}
const availableTargetProjects = useMemo(() => {
const filtered = allProjects.filter(
(p: ContainerTagListType) =>
@ -377,6 +436,7 @@ export function SpaceSelector({
onNewSpace={handleNewSpace}
enableDelete={enableDelete}
onDeleteRequest={handleDeleteRequest}
onBulkDeleteRequest={handleBulkDeleteRequest}
/>
<Dialog
@ -634,6 +694,129 @@ export function SpaceSelector({
</div>
</DialogContent>
</Dialog>
<Dialog
open={bulkDeleteDialog.open}
onOpenChange={(open: boolean) => {
if (!open) handleBulkDeleteCancel()
}}
>
<DialogContent
className={cn(
"w-[90%]! max-w-[520px]! border-none bg-[#1B1F24] flex flex-col p-4 gap-4 rounded-[22px]",
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",
}}
showCloseButton={false}
>
<div className="flex flex-col gap-4">
<div className="flex justify-between items-start gap-4">
<div className="pl-1 space-y-1 flex-1">
<DialogTitle
className={cn(
"font-semibold text-[#fafafa]",
dmSans125ClassName(),
)}
>
Delete {bulkDeleteDialog.projects.length}{" "}
{bulkDeleteDialog.projects.length === 1 ? "space" : "spaces"}?
</DialogTitle>
<DialogDescription className="text-[#737373] font-medium text-[15px] leading-[1.4]">
This permanently deletes the selected container tags and every
document and memory inside them. This cannot be undone.
</DialogDescription>
</div>
<DialogPrimitive.Close
className="bg-[#0D121A] w-7 h-7 flex items-center justify-center focus:ring-ring rounded-full transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 border border-[rgba(115,115,115,0.2)] shrink-0"
style={{
boxShadow:
"inset 1.313px 1.313px 3.938px 0px rgba(0,0,0,0.7)",
}}
>
<XIcon stroke="#737373" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</div>
<div className="rounded-[12px] bg-[#14161A] p-3 shadow-inside-out">
<div className="max-h-36 space-y-1 overflow-y-auto pr-1 scrollbar-thin">
{bulkDeleteDialog.projects.slice(0, 8).map((project) => (
<div
key={project.containerTag}
className="flex min-w-0 items-center gap-2 text-[13px] text-[#fafafa]"
>
<Trash2 className="size-3.5 shrink-0 text-red-400" />
<span className="truncate">{project.name}</span>
</div>
))}
{bulkDeleteDialog.projects.length > 8 && (
<p className="text-[12px] text-[#737373]">
+{bulkDeleteDialog.projects.length - 8} more
</p>
)}
</div>
</div>
<label className="space-y-2">
<span className="block text-[13px] font-medium text-[#FAFAFA]">
Type DELETE to confirm
</span>
<input
type="text"
value={bulkDeleteDialog.confirmation}
onChange={(e) =>
setBulkDeleteDialog((prev) => ({
...prev,
confirmation: e.target.value,
}))
}
className={cn(
"w-full rounded-[12px] border border-[rgba(82,89,102,0.35)] bg-[#0D121A] px-3 py-2.5 text-sm font-medium text-[#fafafa] shadow-inside-out placeholder:text-[#737373] focus:outline-none focus:ring-1 focus:ring-red-400/40",
dmSansClassName(),
)}
placeholder="DELETE"
autoComplete="off"
/>
</label>
<div className="flex items-center justify-end gap-[22px]">
<button
type="button"
onClick={handleBulkDeleteCancel}
disabled={deleteProjectsMutation.isPending}
className={cn(
"text-[#737373] font-medium text-[14px] cursor-pointer transition-colors hover:text-[#999]",
dmSansClassName(),
)}
>
Cancel
</button>
<Button
variant="insideOut"
onClick={handleBulkDeleteConfirm}
disabled={
deleteProjectsMutation.isPending ||
bulkDeleteDialog.confirmation !== "DELETE" ||
bulkDeleteDialog.projects.length === 0
}
className="rounded-full bg-red-600 px-4 py-[10px] hover:bg-red-700 border-red-700"
>
{deleteProjectsMutation.isPending ? (
<>
<Loader2 className="size-4 animate-spin mr-2" />
Deleting...
</>
) : (
"Delete permanently"
)}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
</>
)
}

View file

@ -5,6 +5,13 @@ import { useMutation, useQueryClient } from "@tanstack/react-query"
import { toast } from "sonner"
import { useProject } from "@/stores"
import type { ContainerTagListType, Project } from "@lib/types"
import { DEFAULT_PROJECT_ID } from "@lib/constants"
type ProjectDeleteTarget = {
id: string
containerTag: string
name?: string
}
export function useProjectMutations() {
const queryClient = useQueryClient()
@ -45,16 +52,21 @@ export function useProjectMutations() {
const deleteProjectMutation = useMutation({
mutationFn: async ({
projectId,
containerTag,
action,
targetProjectId,
}: {
projectId: string
containerTag: string
action: "move" | "delete"
targetProjectId?: string
}) => {
const response = await $fetch(`@delete/projects/${projectId}`, {
body: { action, targetProjectId },
})
const response =
action === "delete"
? await $fetch(`@delete/container-tags/${containerTag}`)
: await $fetch(`@delete/projects/${projectId}`, {
body: { action, targetProjectId },
})
if (response.error) {
throw new Error(response.error?.message || "Failed to delete project")
@ -68,7 +80,10 @@ export function useProjectMutations() {
const allTags =
queryClient.getQueryData<ContainerTagListType[]>(["container-tags"]) ||
[]
const deletedProject = allTags.find((p) => p.id === variables.projectId)
const deletedProject =
variables.action === "delete"
? { containerTag: variables.containerTag }
: allTags.find((p) => p.id === variables.projectId)
if (
deletedProject?.containerTag &&
@ -89,6 +104,81 @@ export function useProjectMutations() {
},
})
const deleteProjectsMutation = useMutation({
mutationFn: async ({ projects }: { projects: ProjectDeleteTarget[] }) => {
const results = await Promise.allSettled(
projects.map(async (project) => {
const response = await $fetch(
`@delete/container-tags/${project.containerTag}`,
)
if (response.error) {
throw new Error(
response.error?.message || `Failed to delete ${project.name}`,
)
}
return {
project,
data: response.data,
}
}),
)
return {
successful: results
.filter((result) => result.status === "fulfilled")
.map((result) => result.value),
failed: results
.filter((result) => result.status === "rejected")
.map((result) => result.reason),
}
},
onSuccess: (result, variables) => {
const deletedTags = new Set(
result.successful.map(({ project }) => project.containerTag),
)
if (selectedProjects.some((tag) => deletedTags.has(tag))) {
const remainingSelected = selectedProjects.filter(
(tag) => !deletedTags.has(tag),
)
setSelectedProjects(
remainingSelected.length > 0
? remainingSelected
: [DEFAULT_PROJECT_ID],
)
}
queryClient.invalidateQueries({ queryKey: ["projects"] })
queryClient.invalidateQueries({ queryKey: ["container-tags"] })
if (result.failed.length > 0) {
toast.error(
`Deleted ${result.successful.length} of ${variables.projects.length} spaces`,
{
description:
result.failed[0] instanceof Error
? result.failed[0].message
: "Some spaces could not be deleted.",
},
)
return
}
toast.success(
variables.projects.length === 1
? "Space deleted successfully"
: `${variables.projects.length} spaces deleted successfully`,
)
},
onError: (error) => {
toast.error("Failed to delete spaces", {
description: error instanceof Error ? error.message : "Unknown error",
})
},
})
const updateProjectMutation = useMutation({
mutationFn: async ({
containerTag,
@ -192,6 +282,7 @@ export function useProjectMutations() {
return {
createProjectMutation,
deleteProjectMutation,
deleteProjectsMutation,
updateProjectMutation,
switchProject,
}

View file

@ -261,6 +261,17 @@ export const apiSchema = createSchema({
containerTag: z.string(),
}),
},
"@delete/container-tags/:containerTag": {
output: z.object({
success: z.boolean(),
containerTag: z.string(),
deletedDocumentsCount: z.number(),
deletedMemoriesCount: z.number(),
}),
params: z.object({
containerTag: z.string(),
}),
},
"@post/projects": {
input: CreateProjectSchema,
output: ProjectSchema,