-
- {isSelected && (
+ {isBulkDeleteMode ? (
+ isBulkDeleteSelected &&
+ ) : isSelected ? (
- )}
-
+ ) : null}
+
{isEditing ? (
{project.emoji || "📁"}
@@ -542,8 +668,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,
],
)
@@ -701,18 +835,39 @@ export function SelectSpacesModal({
Select Space
- Filter your memories by space
+ {isBulkDeleteMode
+ ? "Choose spaces to permanently delete"
+ : "Filter your memories by space"}
-
-
+ {!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 93defe8a..689219b1 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 { ChevronDownIcon, Sparkles, XIcon, Loader2 } from "lucide-react"
+import { ChevronDownIcon, Sparkles, XIcon, Loader2, Trash2 } from "lucide-react"
import type { ContainerTagListType } from "@lib/types"
import { AUTO_CHAT_SPACE_ID } from "@/lib/chat-auto-space"
import { AddSpaceModal } from "./add-space-modal"
@@ -62,6 +62,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 {
@@ -107,7 +113,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
}>({
@@ -116,8 +122,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(() => {
@@ -219,14 +235,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: "",
})
},
[],
@@ -237,6 +263,7 @@ export function SpaceSelector({
deleteProjectMutation.mutate(
{
projectId: deleteDialog.project.id,
+ containerTag: deleteDialog.project.containerTag,
action: deleteDialog.action,
targetProjectId:
deleteDialog.action === "move"
@@ -265,6 +292,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) =>
@@ -394,6 +453,7 @@ export function SpaceSelector({
onNewSpace={handleNewSpace}
enableDelete={enableDelete}
onDeleteRequest={handleDeleteRequest}
+ onBulkDeleteRequest={handleBulkDeleteRequest}
/>
+
+
>
)
}
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/apps/web/middleware.ts b/apps/web/middleware.ts
index 885144ac..0473e315 100644
--- a/apps/web/middleware.ts
+++ b/apps/web/middleware.ts
@@ -71,6 +71,6 @@ export default async function proxy(request: Request) {
export const config = {
matcher: [
- "/((?!_next/static|_next/image|images|icon.png|monitoring|opengraph-image.png|bg-rectangle.png|onboarding|ingest|login|api/emails|mcp-supported-tools|mcp-icon.svg).*)",
+ "/((?!_next/static|_next/image|images|icon.png|favicon.ico|favicon-16x16.png|favicon-32x32.png|apple-touch-icon.png|android-chrome-192x192.png|android-chrome-512x512.png|manifest.webmanifest|site.webmanifest|monitoring|opengraph-image.png|bg-rectangle.png|onboarding|ingest|login|api/emails|mcp-supported-tools|mcp-icon.svg).*)",
],
}
diff --git a/apps/web/public/android-chrome-192x192.png b/apps/web/public/android-chrome-192x192.png
new file mode 100644
index 00000000..dbabef62
Binary files /dev/null and b/apps/web/public/android-chrome-192x192.png differ
diff --git a/apps/web/public/android-chrome-512x512.png b/apps/web/public/android-chrome-512x512.png
new file mode 100644
index 00000000..b92f2fd2
Binary files /dev/null and b/apps/web/public/android-chrome-512x512.png differ
diff --git a/apps/web/public/apple-touch-icon.png b/apps/web/public/apple-touch-icon.png
new file mode 100644
index 00000000..29d7e45a
Binary files /dev/null and b/apps/web/public/apple-touch-icon.png differ
diff --git a/apps/web/public/favicon-16x16.png b/apps/web/public/favicon-16x16.png
new file mode 100644
index 00000000..9e49fdb9
Binary files /dev/null and b/apps/web/public/favicon-16x16.png differ
diff --git a/apps/web/public/favicon-32x32.png b/apps/web/public/favicon-32x32.png
new file mode 100644
index 00000000..315238b6
Binary files /dev/null and b/apps/web/public/favicon-32x32.png differ
diff --git a/apps/web/public/favicon.ico b/apps/web/public/favicon.ico
new file mode 100644
index 00000000..c3041708
Binary files /dev/null and b/apps/web/public/favicon.ico differ
diff --git a/apps/web/public/site.webmanifest b/apps/web/public/site.webmanifest
new file mode 100644
index 00000000..95911504
--- /dev/null
+++ b/apps/web/public/site.webmanifest
@@ -0,0 +1,19 @@
+{
+ "name": "",
+ "short_name": "",
+ "icons": [
+ {
+ "src": "/android-chrome-192x192.png",
+ "sizes": "192x192",
+ "type": "image/png"
+ },
+ {
+ "src": "/android-chrome-512x512.png",
+ "sizes": "512x512",
+ "type": "image/png"
+ }
+ ],
+ "theme_color": "#ffffff",
+ "background_color": "#ffffff",
+ "display": "standalone"
+}
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,