Merge remote-tracking branch 'origin/main' into Dhravya/chat-page-image-style
# Conflicts: # apps/web/components/select-spaces-modal.tsx # apps/web/components/space-selector.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 (
|
||||
<>
|
||||
<EnsureWorkspace>{children}</EnsureWorkspace>
|
||||
<NextAppResearchCta />
|
||||
<PWAInstallPrompt />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 33 KiB After Width: | Height: | Size: 30 KiB |
|
|
@ -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",
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
|
|
|
|||
419
apps/web/components/pwa-install-prompt.tsx
Normal file
|
|
@ -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<DeviceInfo>({
|
||||
isIOS: false,
|
||||
isAndroid: false,
|
||||
isSafari: false,
|
||||
isChrome: false,
|
||||
})
|
||||
const [nativePrompt, setNativePrompt] =
|
||||
useState<BeforeInstallPromptEvent | null>(null)
|
||||
const panelRef = useRef<HTMLDivElement>(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 <IOSSteps />
|
||||
return <IOSNonSafariSteps />
|
||||
}
|
||||
// Android
|
||||
if (device.isChrome) return <AndroidSteps />
|
||||
return <AndroidNonChromeSteps />
|
||||
}
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{show && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="fixed inset-0 z-50 flex items-end justify-center bg-black/60 backdrop-blur-sm"
|
||||
onClick={dismiss}
|
||||
>
|
||||
<motion.div
|
||||
ref={panelRef}
|
||||
initial={{ y: "100%" }}
|
||||
animate={{ y: 0 }}
|
||||
exit={{ y: "100%" }}
|
||||
transition={{ type: "spring", damping: 28, stiffness: 300 }}
|
||||
onClick={(e) => 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 */}
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="size-12 rounded-[12px] bg-[#0D121A] flex items-center justify-center border border-[rgba(115,115,115,0.15)]">
|
||||
<GradientLogo className="size-7" />
|
||||
</div>
|
||||
<div>
|
||||
<h2
|
||||
id="pwa-install-title"
|
||||
className={cn(
|
||||
"text-[18px] font-semibold text-[#fafafa]",
|
||||
dmSans125ClassName(),
|
||||
)}
|
||||
>
|
||||
Supermemory
|
||||
</h2>
|
||||
<p className="text-[14px] text-[#737373] font-medium">
|
||||
Your memories, wherever you are
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={dismiss}
|
||||
className="bg-[#0D121A] size-7 flex items-center justify-center rounded-full border border-[rgba(115,115,115,0.2)] shrink-0"
|
||||
style={{
|
||||
boxShadow:
|
||||
"0 0.711px 2.842px 0 rgba(0, 0, 0, 0.25), 0.178px 0.178px 0.178px 0 rgba(255, 255, 255, 0.10) inset",
|
||||
}}
|
||||
>
|
||||
<XIcon className="size-4 text-[#737373]" />
|
||||
<span className="sr-only">Close</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Feature pills */}
|
||||
<div className="flex gap-2">
|
||||
{FEATURES.map((f) => (
|
||||
<div
|
||||
key={f.label}
|
||||
className="flex-1 flex flex-col items-center gap-2 py-3 px-2 rounded-[12px] bg-[#0D121A] border border-[rgba(115,115,115,0.1)]"
|
||||
>
|
||||
<f.icon className="size-5 text-[#4BA0FA]" />
|
||||
<span className="text-[12px] text-[#d0dae7] font-medium text-center leading-tight">
|
||||
{f.label}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Install steps */}
|
||||
<div className="flex flex-col gap-3">
|
||||
<p
|
||||
className={cn(
|
||||
"text-[15px] font-semibold text-[#fafafa]",
|
||||
dmSans125ClassName(),
|
||||
)}
|
||||
>
|
||||
Install for a better experience
|
||||
</p>
|
||||
{renderSteps()}
|
||||
</div>
|
||||
|
||||
{/* Action */}
|
||||
<div className="flex flex-col gap-2 mt-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={
|
||||
nativePrompt && device.isAndroid && device.isChrome
|
||||
? handleInstall
|
||||
: dismiss
|
||||
}
|
||||
className={cn(
|
||||
"w-full py-3 rounded-[12px] bg-[#2261CA] text-white text-[15px] font-semibold transition-colors hover:bg-[#1a4fa0]",
|
||||
dmSansClassName(),
|
||||
)}
|
||||
>
|
||||
{nativePrompt && device.isAndroid && device.isChrome
|
||||
? "Install now"
|
||||
: "Got it"}
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
)
|
||||
}
|
||||
|
||||
function StepRow({
|
||||
step,
|
||||
children,
|
||||
}: {
|
||||
step: number
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="size-7 shrink-0 rounded-full bg-[#0D121A] border border-[rgba(115,115,115,0.15)] flex items-center justify-center text-[13px] font-semibold text-[#4BA0FA]">
|
||||
{step}
|
||||
</span>
|
||||
<span className="text-[14px] text-[#d0dae7]">{children}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function IOSSteps() {
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<StepRow step={1}>
|
||||
Tap the share button{" "}
|
||||
<ShareIcon className="inline-block size-4 align-text-bottom mx-0.5 text-[#4BA0FA]" />{" "}
|
||||
in Safari
|
||||
</StepRow>
|
||||
<StepRow step={2}>
|
||||
Scroll down and tap{" "}
|
||||
<strong className="text-[#fafafa]">Add to Home Screen</strong>
|
||||
</StepRow>
|
||||
<StepRow step={3}>
|
||||
Tap <strong className="text-[#fafafa]">Add</strong> to install
|
||||
</StepRow>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function IOSNonSafariSteps() {
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<StepRow step={1}>
|
||||
Open this page in <strong className="text-[#fafafa]">Safari</strong>
|
||||
</StepRow>
|
||||
<StepRow step={2}>
|
||||
Tap the share button{" "}
|
||||
<ShareIcon className="inline-block size-4 align-text-bottom mx-0.5 text-[#4BA0FA]" />
|
||||
</StepRow>
|
||||
<StepRow step={3}>
|
||||
Tap <strong className="text-[#fafafa]">Add to Home Screen</strong>
|
||||
</StepRow>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function AndroidSteps() {
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<StepRow step={1}>
|
||||
Tap the menu button{" "}
|
||||
<MoreVertIcon className="inline-block size-4 align-text-bottom mx-0.5 text-[#4BA0FA]" />{" "}
|
||||
in Chrome
|
||||
</StepRow>
|
||||
<StepRow step={2}>
|
||||
Tap <strong className="text-[#fafafa]">Add to Home screen</strong>
|
||||
</StepRow>
|
||||
<StepRow step={3}>
|
||||
Tap <strong className="text-[#fafafa]">Install</strong> to confirm
|
||||
</StepRow>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function AndroidNonChromeSteps() {
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<StepRow step={1}>
|
||||
Open this page in <strong className="text-[#fafafa]">Chrome</strong>
|
||||
</StepRow>
|
||||
<StepRow step={2}>
|
||||
Tap the menu button{" "}
|
||||
<MoreVertIcon className="inline-block size-4 align-text-bottom mx-0.5 text-[#4BA0FA]" />
|
||||
</StepRow>
|
||||
<StepRow step={3}>
|
||||
Tap <strong className="text-[#fafafa]">Add to Home screen</strong>
|
||||
</StepRow>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ShareIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className={className}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M4 12v8a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-8" />
|
||||
<polyline points="16 6 12 2 8 6" />
|
||||
<line x1="12" y1="2" x2="12" y2="15" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function MoreVertIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
className={className}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<circle cx="12" cy="5" r="2" />
|
||||
<circle cx="12" cy="12" r="2" />
|
||||
<circle cx="12" cy="19" r="2" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<void>
|
||||
userChoice: Promise<{ outcome: "accepted" | "dismissed" }>
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface WindowEventMap {
|
||||
beforeinstallprompt: BeforeInstallPromptEvent
|
||||
}
|
||||
}
|
||||
|
|
@ -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<Set<string>>(new Set())
|
||||
const [lastBulkDeleteTag, setLastBulkDeleteTag] = useState<string | null>(
|
||||
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<string | null>(
|
||||
|
|
@ -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<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>
|
||||
|
|
@ -542,8 +668,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 ? (
|
||||
|
|
@ -587,7 +714,7 @@ export function SelectSpacesModal({
|
|||
</span>
|
||||
</button>
|
||||
)}
|
||||
{canEdit && !isEditing && (
|
||||
{canEdit && !isEditing && !isBulkDeleteMode && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
|
|
@ -600,37 +727,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,
|
||||
],
|
||||
)
|
||||
|
|
@ -701,18 +835,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">
|
||||
|
|
@ -897,21 +1052,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>
|
||||
|
|
|
|||
|
|
@ -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<string[]>([])
|
||||
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}
|
||||
/>
|
||||
|
||||
<Dialog
|
||||
|
|
@ -651,6 +711,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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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).*)",
|
||||
],
|
||||
}
|
||||
|
|
|
|||
BIN
apps/web/public/android-chrome-192x192.png
Normal file
|
After Width: | Height: | Size: 32 KiB |
BIN
apps/web/public/android-chrome-512x512.png
Normal file
|
After Width: | Height: | Size: 181 KiB |
BIN
apps/web/public/apple-touch-icon.png
Normal file
|
After Width: | Height: | Size: 30 KiB |
BIN
apps/web/public/favicon-16x16.png
Normal file
|
After Width: | Height: | Size: 801 B |
BIN
apps/web/public/favicon-32x32.png
Normal file
|
After Width: | Height: | Size: 2 KiB |
BIN
apps/web/public/favicon.ico
Normal file
|
After Width: | Height: | Size: 15 KiB |
19
apps/web/public/site.webmanifest
Normal file
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||