diff --git a/apps/web/app/(app)/layout.tsx b/apps/web/app/(app)/layout.tsx index 33c88e04..b2a27e07 100644 --- a/apps/web/app/(app)/layout.tsx +++ b/apps/web/app/(app)/layout.tsx @@ -2,12 +2,14 @@ import { EnsureWorkspace } from "@/components/ensure-workspace" import { NextAppResearchCta } from "@/components/next-app-research-cta" +import { PWAInstallPrompt } from "@/components/pwa-install-prompt" export default function AppLayout({ children }: { children: React.ReactNode }) { return ( <> {children} + ) } diff --git a/apps/web/app/icon.png b/apps/web/app/icon.png index 549d267d..29d7e45a 100644 Binary files a/apps/web/app/icon.png and b/apps/web/app/icon.png differ diff --git a/apps/web/app/layout.tsx b/apps/web/app/layout.tsx index 3a2af064..7b69f17e 100644 --- a/apps/web/app/layout.tsx +++ b/apps/web/app/layout.tsx @@ -20,6 +20,17 @@ const font = Space_Grotesk({ export const metadata: Metadata = { metadataBase: new URL("https://app.supermemory.ai"), description: "Your memories, wherever you are", + icons: { + icon: [ + { url: "/favicon.ico", sizes: "any" }, + { url: "/favicon-16x16.png", sizes: "16x16", type: "image/png" }, + { url: "/favicon-32x32.png", sizes: "32x32", type: "image/png" }, + ], + apple: [ + { url: "/apple-touch-icon.png", sizes: "180x180", type: "image/png" }, + ], + }, + manifest: "/manifest.webmanifest", title: "supermemory app", } diff --git a/apps/web/app/manifest.ts b/apps/web/app/manifest.ts index 01381381..b618ff25 100644 --- a/apps/web/app/manifest.ts +++ b/apps/web/app/manifest.ts @@ -11,10 +11,15 @@ export default function manifest(): MetadataRoute.Manifest { theme_color: "#000000", icons: [ { - src: "/images/logo.png", + src: "/android-chrome-192x192.png", sizes: "192x192", type: "image/png", }, + { + src: "/android-chrome-512x512.png", + sizes: "512x512", + type: "image/png", + }, ], } } diff --git a/apps/web/components/pwa-install-prompt.tsx b/apps/web/components/pwa-install-prompt.tsx new file mode 100644 index 00000000..b7776825 --- /dev/null +++ b/apps/web/components/pwa-install-prompt.tsx @@ -0,0 +1,419 @@ +"use client" + +import { useState, useEffect, useCallback, useRef } from "react" +import { cn } from "@lib/utils" +import { dmSansClassName, dmSans125ClassName } from "@/lib/fonts" +import { XIcon, Brain, Sparkles, Globe } from "lucide-react" +import { GradientLogo } from "@ui/assets/Logo" +import { AnimatePresence, motion } from "motion/react" + +const PWA_DISMISS_KEY = "pwa-install-dismissed" +const DISMISS_DURATION_MS = 7 * 24 * 60 * 60 * 1000 // 7 days + +type DeviceInfo = { + isIOS: boolean + isAndroid: boolean + isSafari: boolean + isChrome: boolean +} + +/** In-memory fallback when localStorage is unavailable */ +let memoryDismissed = false + +function getDeviceInfo(): DeviceInfo { + if (typeof window === "undefined") + return { isIOS: false, isAndroid: false, isSafari: false, isChrome: false } + const ua = navigator.userAgent + const isIOS = + /iPad|iPhone|iPod/.test(ua) || + (/Macintosh/.test(ua) && navigator.maxTouchPoints > 1) + const isAndroid = /Android/.test(ua) + + // Safari: contains "Safari" but not "CriOS", "FxiOS", "Chrome", "Edg", etc. + const isSafari = + /Safari/.test(ua) && !/CriOS|FxiOS|Chrome|Chromium|Edg|OPR|Opera/.test(ua) + // Chrome on Android: contains "Chrome" but not "Edg", "OPR", "Opera" + const isChrome = /Chrome/.test(ua) && !/Edg|OPR|Opera/.test(ua) + + return { isIOS, isAndroid, isSafari, isChrome } +} + +function isStandalone() { + if (typeof window === "undefined") return false + return ( + window.matchMedia("(display-mode: standalone)").matches || + (window.navigator as unknown as { standalone?: boolean }).standalone === + true + ) +} + +function isDismissed() { + if (typeof window === "undefined") return true + if (memoryDismissed) return true + try { + const dismissed = localStorage.getItem(PWA_DISMISS_KEY) + if (!dismissed) return false + const timestamp = Number.parseInt(dismissed, 10) + if (Date.now() - timestamp < DISMISS_DURATION_MS) return true + localStorage.removeItem(PWA_DISMISS_KEY) + return false + } catch { + return false + } +} + +const FEATURES = [ + { icon: Brain, label: "AI-powered memory" }, + { icon: Sparkles, label: "Chat with Nova" }, + { icon: Globe, label: "Access anywhere" }, +] + +export function PWAInstallPrompt() { + const [show, setShow] = useState(false) + const [device, setDevice] = useState({ + isIOS: false, + isAndroid: false, + isSafari: false, + isChrome: false, + }) + const [nativePrompt, setNativePrompt] = + useState(null) + const panelRef = useRef(null) + + useEffect(() => { + const info = getDeviceInfo() + setDevice(info) + + // Listen for the native install prompt (Chromium browsers on Android) + const handleBeforeInstall = (e: Event) => { + e.preventDefault() + setNativePrompt(e as BeforeInstallPromptEvent) + } + window.addEventListener("beforeinstallprompt", handleBeforeInstall) + + const isMobile = info.isIOS || info.isAndroid + if (isMobile && !isStandalone() && !isDismissed()) { + const timer = setTimeout(() => setShow(true), 1500) + return () => { + clearTimeout(timer) + window.removeEventListener("beforeinstallprompt", handleBeforeInstall) + } + } + return () => { + window.removeEventListener("beforeinstallprompt", handleBeforeInstall) + } + }, []) + + const dismiss = useCallback(() => { + setShow(false) + memoryDismissed = true + try { + localStorage.setItem(PWA_DISMISS_KEY, Date.now().toString()) + } catch {} + }, []) + + // Escape key handler + useEffect(() => { + if (!show) return + const handler = (e: KeyboardEvent) => { + if (e.key === "Escape") dismiss() + } + document.addEventListener("keydown", handler) + return () => document.removeEventListener("keydown", handler) + }, [show, dismiss]) + + // Focus the panel when shown for accessibility + useEffect(() => { + if (show && panelRef.current) { + panelRef.current.focus() + } + }, [show]) + + const handleInstall = useCallback(async () => { + if (nativePrompt) { + nativePrompt.prompt() + const { outcome } = await nativePrompt.userChoice + if (outcome === "accepted") { + setShow(false) + } + setNativePrompt(null) + } + dismiss() + }, [nativePrompt, dismiss]) + + /** + * Determine which instructions to show: + * - iOS + Safari → standard iOS steps + * - iOS + non-Safari → "open in Safari" hint + * - Android + Chrome (with native prompt) → trigger native install + * - Android + Chrome (no native prompt) → manual Chrome steps + * - Android + non-Chrome → "open in Chrome" hint + */ + const renderSteps = () => { + if (device.isIOS) { + if (device.isSafari) return + return + } + // Android + if (device.isChrome) return + return + } + + return ( + + {show && ( + + e.stopPropagation()} + role="dialog" + aria-modal="true" + aria-labelledby="pwa-install-title" + tabIndex={-1} + className={cn( + "w-full max-w-lg bg-[#1B1F24] rounded-t-[22px] p-6 pb-8 flex flex-col gap-5 outline-none", + dmSansClassName(), + )} + style={{ + boxShadow: + "0 -4px 24px 0 rgba(0, 0, 0, 0.4), 0.5px 0.5px 0.5px 0 rgba(255, 255, 255, 0.08) inset", + }} + > + {/* Header */} +
+
+
+ +
+
+

+ Supermemory +

+

+ Your memories, wherever you are +

+
+
+ +
+ + {/* Feature pills */} +
+ {FEATURES.map((f) => ( +
+ + + {f.label} + +
+ ))} +
+ + {/* Install steps */} +
+

+ Install for a better experience +

+ {renderSteps()} +
+ + {/* Action */} +
+ +
+
+
+ )} +
+ ) +} + +function StepRow({ + step, + children, +}: { + step: number + children: React.ReactNode +}) { + return ( +
+ + {step} + + {children} +
+ ) +} + +function IOSSteps() { + return ( +
+ + Tap the share button{" "} + {" "} + in Safari + + + Scroll down and tap{" "} + Add to Home Screen + + + Tap Add to install + +
+ ) +} + +function IOSNonSafariSteps() { + return ( +
+ + Open this page in Safari + + + Tap the share button{" "} + + + + Tap Add to Home Screen + +
+ ) +} + +function AndroidSteps() { + return ( +
+ + Tap the menu button{" "} + {" "} + in Chrome + + + Tap Add to Home screen + + + Tap Install to confirm + +
+ ) +} + +function AndroidNonChromeSteps() { + return ( +
+ + Open this page in Chrome + + + Tap the menu button{" "} + + + + Tap Add to Home screen + +
+ ) +} + +function ShareIcon({ className }: { className?: string }) { + return ( + + ) +} + +function MoreVertIcon({ className }: { className?: string }) { + return ( + + ) +} + +/** + * Type for the `beforeinstallprompt` event (not yet in TS lib). + * @see https://developer.mozilla.org/en-US/docs/Web/API/BeforeInstallPromptEvent + */ +interface BeforeInstallPromptEvent extends Event { + prompt(): Promise + userChoice: Promise<{ outcome: "accepted" | "dismissed" }> +} + +declare global { + interface WindowEventMap { + beforeinstallprompt: BeforeInstallPromptEvent + } +} diff --git a/apps/web/components/select-spaces-modal.tsx b/apps/web/components/select-spaces-modal.tsx index 703f5380..55fdaf9f 100644 --- a/apps/web/components/select-spaces-modal.tsx +++ b/apps/web/components/select-spaces-modal.tsx @@ -62,6 +62,13 @@ interface SelectSpacesModalProps { name: string containerTag: string }) => void + onBulkDeleteRequest?: ( + projects: { + id: string + name: string + containerTag: string + }[], + ) => void } type CategoryId = @@ -90,8 +97,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 @@ -205,6 +218,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( @@ -324,6 +344,9 @@ export function SelectSpacesModal({ if (!isOpen) { setNewKey(null) setEditingProject(null) + setIsBulkDeleteMode(false) + setBulkDeleteTags(new Set()) + setLastBulkDeleteTag(null) } }, [isOpen]) @@ -342,6 +365,9 @@ export function SelectSpacesModal({ onClose() setSearchQuery("") setEditingProject(null) + setIsBulkDeleteMode(false) + setBulkDeleteTags(new Set()) + setLastBulkDeleteTag(null) } }, [onClose], @@ -350,6 +376,9 @@ export function SelectSpacesModal({ const handleSelect = useCallback( (containerTag: string) => { setEditingProject(null) + setIsBulkDeleteMode(false) + setBulkDeleteTags(new Set()) + setLastBulkDeleteTag(null) onApply([containerTag]) setSearchQuery("") }, @@ -358,10 +387,20 @@ export function SelectSpacesModal({ const handleSelectAuto = useCallback(() => { setEditingProject(null) + setIsBulkDeleteMode(false) + setBulkDeleteTags(new Set()) + setLastBulkDeleteTag(null) onApply([AUTO_CHAT_SPACE_ID]) 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({ @@ -455,6 +494,7 @@ export function SelectSpacesModal({ const showAutoRow = useMemo(() => { if (!includeAuto) return false + if (isBulkDeleteMode) return false if (activeCategory !== "all" && activeCategory !== "my") return false const query = searchQuery.trim().toLowerCase() if (!query) return true @@ -463,7 +503,61 @@ export function SelectSpacesModal({ "let nova choose the right spaces".includes(query) || "discover spaces".includes(query) ) - }, [includeAuto, activeCategory, searchQuery]) + }, [includeAuto, isBulkDeleteMode, activeCategory, searchQuery]) + + 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) => { @@ -475,32 +569,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 || "📁"} @@ -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"}

- - - Close - +
+ {enableDelete && onBulkDeleteRequest && !activeDiscoverId && ( + + )} + + + Close + +
@@ -897,21 +1052,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 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} /> + + { + 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/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,