diff --git a/apps/web/components/select-spaces-modal.tsx b/apps/web/components/select-spaces-modal.tsx index f82a5d67..4162dd57 100644 --- a/apps/web/components/select-spaces-modal.tsx +++ b/apps/web/components/select-spaces-modal.tsx @@ -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>(new Set()) + const [lastBulkDeleteTag, setLastBulkDeleteTag] = useState( + 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( @@ -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, + ) => { + if (isEditing) return + if (isBulkDeleteMode) { + if (canBulkDelete) { + toggleBulkDeleteTag(project.containerTag, e.shiftKey) + } + return + } + handleSelect(project.containerTag) + } return (
-
- {isSelected && ( + {isBulkDeleteMode ? ( + isBulkDeleteSelected && + ) : isSelected ? (
- )} -
+ ) : null} + {isEditing ? (
{project.emoji || "📁"} @@ -519,8 +641,9 @@ export function SelectSpacesModal({ ) : ( )} - {canEdit && !isEditing && ( + {canEdit && !isEditing && !isBulkDeleteMode && ( )} - {enableDelete && !isDefault && !isEditing && onDeleteRequest && ( - - )} + {enableDelete && + !isDefault && + !isEditing && + !isBulkDeleteMode && + onDeleteRequest && ( + + )}
) }, [ cancelEditing, + bulkDeleteTags, currentSelection, editingProject, enableDelete, handleEditKeyDown, handleSelect, + isBulkDeleteMode, onDeleteRequest, pluginMetaMap, saveEditing, startEditing, + toggleBulkDeleteTag, updateProjectMutation.isPending, ], ) @@ -636,18 +766,39 @@ export function SelectSpacesModal({ Select Space

- Filter your memories by space + {isBulkDeleteMode + ? "Choose spaces to permanently delete" + : "Filter your memories by space"}

- - - Close - +
+ {enableDelete && onBulkDeleteRequest && !activeDiscoverId && ( + + )} + + + Close + +
@@ -823,21 +974,67 @@ export function SelectSpacesModal({
- {showNewSpace && - onNewSpace && - !activeCategory.startsWith("discover:") && ( -
- + {!activeCategory.startsWith("discover:") && + (isBulkDeleteMode || (showNewSpace && onNewSpace)) && ( +
+ {isBulkDeleteMode ? ( + <> +

+ {bulkDeleteCount === 0 + ? "No spaces selected" + : `${bulkDeleteCount} ${ + bulkDeleteCount === 1 ? "space" : "spaces" + } selected`} +

+
+ + +
+ + ) : ( + <> + + {showNewSpace && onNewSpace && ( + + )} + + )}
)} diff --git a/apps/web/components/space-selector.tsx b/apps/web/components/space-selector.tsx index bbeeace6..3b0daa71 100644 --- a/apps/web/components/space-selector.tsx +++ b/apps/web/components/space-selector.tsx @@ -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([]) 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} /> + + { + if (!open) handleBulkDeleteCancel() + }} + > + +
+
+
+ + Delete {bulkDeleteDialog.projects.length}{" "} + {bulkDeleteDialog.projects.length === 1 ? "space" : "spaces"}? + + + This permanently deletes the selected container tags and every + document and memory inside them. This cannot be undone. + +
+ + + Close + +
+ +
+
+ {bulkDeleteDialog.projects.slice(0, 8).map((project) => ( +
+ + {project.name} +
+ ))} + {bulkDeleteDialog.projects.length > 8 && ( +

+ +{bulkDeleteDialog.projects.length - 8} more +

+ )} +
+
+ + + +
+ + +
+
+
+
) } diff --git a/apps/web/hooks/use-project-mutations.ts b/apps/web/hooks/use-project-mutations.ts index 71694bab..b15699bd 100644 --- a/apps/web/hooks/use-project-mutations.ts +++ b/apps/web/hooks/use-project-mutations.ts @@ -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(["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, } diff --git a/packages/lib/api.ts b/packages/lib/api.ts index 8a7bfdee..76d62fda 100644 --- a/packages/lib/api.ts +++ b/packages/lib/api.ts @@ -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,