mirror of
https://github.com/supermemoryai/supermemory.git
synced 2026-10-07 02:58:11 +00:00
feat: create space, delete spaces and emoji picker (#687)
### Add user display name functionality and enhance space management with emoji support and deletion capabilities. ### What changed? - Added support for user display names, which are now stored and displayed throughout the app - Implemented emoji support for spaces (projects), allowing users to customize their space icons - Created a new `AddSpaceModal` component with emoji picker for creating spaces - Added space deletion functionality with options to move content to another space or delete everything - Enhanced the space selector UI to show emojis and delete options
This commit is contained in:
parent
32a7eff3af
commit
731808be68
11 changed files with 657 additions and 76 deletions
|
|
@ -54,6 +54,9 @@ export default function OnboardingLayout({
|
|||
|
||||
if (storedName) {
|
||||
setNameState(storedName)
|
||||
} else if (user?.displayUsername) {
|
||||
setNameState(user.displayUsername)
|
||||
localStorage.setItem("onboarding_name", user.displayUsername)
|
||||
} else if (user?.name) {
|
||||
setNameState(user.name)
|
||||
localStorage.setItem("onboarding_name", user.name)
|
||||
|
|
@ -66,7 +69,7 @@ export default function OnboardingLayout({
|
|||
// ignore parse errors
|
||||
}
|
||||
}
|
||||
}, [user?.name])
|
||||
}, [user?.displayUsername, user?.name])
|
||||
|
||||
const setName = useCallback((newName: string) => {
|
||||
setNameState(newName)
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ import {
|
|||
type WelcomeStep as WelcomeStepType,
|
||||
} from "./layout"
|
||||
import { gapVariants, orbVariants } from "@/lib/variants"
|
||||
import { authClient } from "@lib/auth"
|
||||
|
||||
function UserSupermemory({ name }: { name: string }) {
|
||||
return (
|
||||
|
|
@ -76,10 +77,17 @@ export default function WelcomePage() {
|
|||
goToStep,
|
||||
} = useWelcomeContext()
|
||||
|
||||
const handleSubmit = () => {
|
||||
const handleSubmit = async () => {
|
||||
localStorage.setItem("username", name)
|
||||
if (name.trim()) {
|
||||
setIsSubmitting(true)
|
||||
|
||||
try {
|
||||
await authClient.updateUser({ displayUsername: name.trim() })
|
||||
} catch (error) {
|
||||
console.error("Failed to update displayUsername:", error)
|
||||
}
|
||||
|
||||
goToStep("greeting")
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -34,11 +34,14 @@ export function CreateProjectDialog({
|
|||
|
||||
const handleCreate = () => {
|
||||
if (projectName.trim()) {
|
||||
createProjectMutation.mutate(projectName, {
|
||||
onSuccess: () => {
|
||||
handleClose()
|
||||
createProjectMutation.mutate(
|
||||
{ name: projectName },
|
||||
{
|
||||
onSuccess: () => {
|
||||
handleClose()
|
||||
},
|
||||
},
|
||||
})
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
200
apps/web/components/new/add-space-modal.tsx
Normal file
200
apps/web/components/new/add-space-modal.tsx
Normal file
|
|
@ -0,0 +1,200 @@
|
|||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { dmSans125ClassName, dmSansClassName } from "@/lib/fonts"
|
||||
import { Dialog, DialogContent } from "@repo/ui/components/dialog"
|
||||
import { cn } from "@lib/utils"
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog"
|
||||
import { XIcon, Loader2 } from "lucide-react"
|
||||
import { Button } from "@ui/components/button"
|
||||
import { useProjectMutations } from "@/hooks/use-project-mutations"
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@ui/components/popover"
|
||||
|
||||
const EMOJI_LIST = [
|
||||
"📁", "📂", "🗂️", "📚", "📖", "📝", "✏️", "📌",
|
||||
"🎯", "🚀", "💡", "⭐", "🔥", "💎", "🎨", "🎵",
|
||||
"🏠", "💼", "🛠️", "⚙️", "🔧", "📊", "📈", "💰",
|
||||
"🌟", "✨", "🌈", "🌸", "🌺", "🍀", "🌿", "🌴",
|
||||
"🐶", "🐱", "🦊", "🦁", "🐼", "🐨", "🦄", "🐝",
|
||||
"❤️", "💜", "💙", "💚", "💛", "🧡", "🖤", "🤍",
|
||||
]
|
||||
|
||||
export function AddSpaceModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
}: {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
}) {
|
||||
const [spaceName, setSpaceName] = useState("")
|
||||
const [emoji, setEmoji] = useState("📁")
|
||||
const [isEmojiOpen, setIsEmojiOpen] = useState(false)
|
||||
const { createProjectMutation } = useProjectMutations()
|
||||
|
||||
const handleClose = () => {
|
||||
onClose()
|
||||
setSpaceName("")
|
||||
setEmoji("📁")
|
||||
}
|
||||
|
||||
const handleCreate = () => {
|
||||
const trimmedName = spaceName.trim()
|
||||
if (!trimmedName) return
|
||||
|
||||
createProjectMutation.mutate(
|
||||
{ name: trimmedName, emoji: emoji || undefined },
|
||||
{
|
||||
onSuccess: () => {
|
||||
handleClose()
|
||||
},
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter" && spaceName.trim() && !createProjectMutation.isPending) {
|
||||
e.preventDefault()
|
||||
handleCreate()
|
||||
}
|
||||
}
|
||||
|
||||
const handleEmojiSelect = (selectedEmoji: string) => {
|
||||
setEmoji(selectedEmoji)
|
||||
setIsEmojiOpen(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={(open) => !open && handleClose()}>
|
||||
<DialogContent
|
||||
className={cn(
|
||||
"w-[90%]! max-w-[500px]! 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">
|
||||
<p className={cn("font-semibold text-[#fafafa]", dmSans125ClassName())}>
|
||||
Create new space
|
||||
</p>
|
||||
<p className={cn("text-[#737373] font-medium text-[16px] leading-[1.35]")}>
|
||||
Create spaces to organize your memories and documents and create a context rich environment
|
||||
</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)",
|
||||
}}
|
||||
data-slot="dialog-close"
|
||||
>
|
||||
<XIcon stroke="#737373" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-[6px] items-center">
|
||||
<Popover open={isEmojiOpen} onOpenChange={setIsEmojiOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
id="emoji-picker-trigger"
|
||||
className="bg-[#14161A] border border-[rgba(82,89,102,0.2)] flex items-center justify-center p-3 rounded-[12px] size-[45px] cursor-pointer transition-colors hover:bg-[#1a1e24]"
|
||||
style={{
|
||||
boxShadow:
|
||||
"0px 1px 2px 0px rgba(0,43,87,0.1), inset 0px 0px 0px 1px rgba(43,49,67,0.08), inset 0px 1px 1px 0px rgba(0,0,0,0.08), inset 0px 2px 4px 0px rgba(0,0,0,0.02)",
|
||||
}}
|
||||
>
|
||||
<span className="text-xl">{emoji}</span>
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
align="start"
|
||||
className="w-[280px] p-3 bg-[#14161A] border border-[rgba(82,89,102,0.2)] rounded-[12px]"
|
||||
style={{
|
||||
boxShadow:
|
||||
"0px 1px 2px 0px rgba(0,43,87,0.1), inset 0px 0px 0px 1px rgba(43,49,67,0.08)",
|
||||
}}
|
||||
>
|
||||
<div className="grid grid-cols-8 gap-1">
|
||||
{EMOJI_LIST.map((e) => (
|
||||
<button
|
||||
key={e}
|
||||
type="button"
|
||||
onClick={() => handleEmojiSelect(e)}
|
||||
className={cn(
|
||||
"size-8 flex items-center justify-center rounded-md text-lg cursor-pointer transition-colors hover:bg-[#1B1F24]",
|
||||
emoji === e && "bg-[#1B1F24] ring-1 ring-[rgba(115,115,115,0.3)]",
|
||||
)}
|
||||
>
|
||||
{e}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
<input
|
||||
type="text"
|
||||
id="space-name-input"
|
||||
value={spaceName}
|
||||
onChange={(e) => setSpaceName(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Space name"
|
||||
className={cn(
|
||||
"flex-1 bg-[#14161A] border border-[rgba(82,89,102,0.2)] px-4 py-3 rounded-[12px] text-[#fafafa] text-[14px] placeholder:text-[#737373] focus:outline-none focus:ring-1 focus:ring-[rgba(115,115,115,0.3)]",
|
||||
dmSansClassName(),
|
||||
)}
|
||||
style={{
|
||||
boxShadow:
|
||||
"0px 1px 2px 0px rgba(0,43,87,0.1), inset 0px 0px 0px 1px rgba(43,49,67,0.08), inset 0px 1px 1px 0px rgba(0,0,0,0.08), inset 0px 2px 4px 0px rgba(0,0,0,0.02)",
|
||||
}}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end gap-[22px]">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClose}
|
||||
disabled={createProjectMutation.isPending}
|
||||
className={cn(
|
||||
"text-[#737373] font-medium text-[14px] cursor-pointer transition-colors hover:text-[#999]",
|
||||
dmSansClassName(),
|
||||
)}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<Button
|
||||
variant="insideOut"
|
||||
onClick={handleCreate}
|
||||
disabled={!spaceName.trim() || createProjectMutation.isPending}
|
||||
className="px-4 py-[10px] rounded-full"
|
||||
>
|
||||
{createProjectMutation.isPending ? (
|
||||
<>
|
||||
<Loader2 className="size-4 animate-spin mr-2" />
|
||||
Creating...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="text-[10px] mr-1">+</span>
|
||||
Create Space
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
|
@ -3,7 +3,6 @@
|
|||
import { Logo } from "@ui/assets/Logo"
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@ui/components/avatar"
|
||||
import { useAuth } from "@lib/auth-context"
|
||||
import { useEffect, useState } from "react"
|
||||
import {
|
||||
LayoutGridIcon,
|
||||
Plus,
|
||||
|
|
@ -38,18 +37,16 @@ interface HeaderProps {
|
|||
|
||||
export function Header({ onAddMemory, onOpenMCP }: HeaderProps) {
|
||||
const { user } = useAuth()
|
||||
const [name, setName] = useState<string>("")
|
||||
const { selectedProject } = useProject()
|
||||
const { switchProject } = useProjectMutations()
|
||||
const router = useRouter()
|
||||
|
||||
useEffect(() => {
|
||||
const storedName =
|
||||
localStorage.getItem("username") || localStorage.getItem("userName") || ""
|
||||
setName(storedName)
|
||||
}, [])
|
||||
|
||||
const userName = name ? `${name.split(" ")[0]}'s` : "My"
|
||||
const displayName =
|
||||
user?.displayUsername ||
|
||||
localStorage.getItem("username") ||
|
||||
localStorage.getItem("userName") ||
|
||||
""
|
||||
const userName = displayName ? `${displayName.split(" ")[0]}'s` : "My"
|
||||
return (
|
||||
<div className="flex p-4 justify-between items-center">
|
||||
<div className="flex items-center justify-center gap-4 z-10!">
|
||||
|
|
@ -60,7 +57,7 @@ export function Header({ onAddMemory, onOpenMCP }: HeaderProps) {
|
|||
className="flex items-center rounded-lg px-2 py-1.5 -ml-2 cursor-pointer hover:bg-white/5 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50 transition-colors"
|
||||
>
|
||||
<Logo className="h-7" />
|
||||
{name && (
|
||||
{userName && (
|
||||
<div className="flex flex-col items-start justify-center ml-2">
|
||||
<p className="text-[#8B8B8B] text-[11px] leading-tight">
|
||||
{userName}
|
||||
|
|
@ -114,6 +111,7 @@ export function Header({ onAddMemory, onOpenMCP }: HeaderProps) {
|
|||
value={selectedProject}
|
||||
onValueChange={switchProject}
|
||||
showChevron
|
||||
enableDelete
|
||||
/>
|
||||
</div>
|
||||
<Tabs defaultValue="grid">
|
||||
|
|
|
|||
|
|
@ -1,20 +1,17 @@
|
|||
import { motion } from "motion/react"
|
||||
import { Logo } from "@ui/assets/Logo"
|
||||
import { useAuth } from "@lib/auth-context"
|
||||
import { useEffect, useState } from "react"
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@ui/components/avatar"
|
||||
|
||||
export function SetupHeader() {
|
||||
const { user } = useAuth()
|
||||
const [name, setName] = useState<string>("")
|
||||
|
||||
useEffect(() => {
|
||||
const storedName =
|
||||
localStorage.getItem("username") || localStorage.getItem("userName") || ""
|
||||
setName(storedName)
|
||||
}, [])
|
||||
|
||||
const userName = name ? `${name.split(" ")[0]}'s` : "My"
|
||||
const displayName =
|
||||
user?.displayUsername ||
|
||||
localStorage.getItem("username") ||
|
||||
localStorage.getItem("userName") ||
|
||||
""
|
||||
const userName = displayName ? `${displayName.split(" ")[0]}'s` : "My"
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
|
|
@ -25,7 +22,7 @@ export function SetupHeader() {
|
|||
>
|
||||
<div className="flex items-center z-10!">
|
||||
<Logo className="h-7" />
|
||||
{name && (
|
||||
{displayName && (
|
||||
<div className="flex flex-col items-start justify-center ml-2">
|
||||
<p className="text-[#8B8B8B] text-[11px] leading-tight">
|
||||
{userName}
|
||||
|
|
|
|||
|
|
@ -2,19 +2,31 @@
|
|||
|
||||
import { useState, useMemo } from "react"
|
||||
import { cn } from "@lib/utils"
|
||||
import { dmSansClassName } from "@/lib/fonts"
|
||||
import { dmSans125ClassName, dmSansClassName } from "@/lib/fonts"
|
||||
import { $fetch } from "@repo/lib/api"
|
||||
import { DEFAULT_PROJECT_ID } from "@repo/lib/constants"
|
||||
import { useQuery } from "@tanstack/react-query"
|
||||
import { ChevronsLeftRight, Plus } from "lucide-react"
|
||||
import { ChevronsLeftRight, Plus, Trash2, XIcon, Loader2 } from "lucide-react"
|
||||
import type { Project } from "@repo/lib/types"
|
||||
import { CreateProjectDialog } from "@/components/create-project-dialog"
|
||||
import { AddSpaceModal } from "./add-space-modal"
|
||||
import { useProjectMutations } from "@/hooks/use-project-mutations"
|
||||
import { motion } from "motion/react"
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@ui/components/dropdown-menu"
|
||||
import { Dialog, DialogContent } from "@repo/ui/components/dialog"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@repo/ui/components/select"
|
||||
import { Button } from "@repo/ui/components/button"
|
||||
|
||||
export interface SpaceSelectorProps {
|
||||
value: string
|
||||
|
|
@ -24,6 +36,7 @@ export interface SpaceSelectorProps {
|
|||
triggerClassName?: string
|
||||
contentClassName?: string
|
||||
showNewSpace?: boolean
|
||||
enableDelete?: boolean
|
||||
}
|
||||
|
||||
const triggerVariants = {
|
||||
|
|
@ -39,9 +52,23 @@ export function SpaceSelector({
|
|||
triggerClassName,
|
||||
contentClassName,
|
||||
showNewSpace = true,
|
||||
enableDelete = false,
|
||||
}: SpaceSelectorProps) {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const [showCreateDialog, setShowCreateDialog] = useState(false)
|
||||
const [deleteDialog, setDeleteDialog] = useState<{
|
||||
open: boolean
|
||||
project: { id: string; name: string; containerTag: string } | null
|
||||
action: "move" | "delete"
|
||||
targetProjectId: string
|
||||
}>({
|
||||
open: false,
|
||||
project: null,
|
||||
action: "move",
|
||||
targetProjectId: "",
|
||||
})
|
||||
|
||||
const { deleteProjectMutation } = useProjectMutations()
|
||||
|
||||
const { data: projects = [], isLoading } = useQuery({
|
||||
queryKey: ["projects"],
|
||||
|
|
@ -57,12 +84,15 @@ export function SpaceSelector({
|
|||
staleTime: 30 * 1000,
|
||||
})
|
||||
|
||||
const selectedProjectName = useMemo(() => {
|
||||
if (value === DEFAULT_PROJECT_ID) return "My Space"
|
||||
const selectedProject = useMemo(() => {
|
||||
if (value === DEFAULT_PROJECT_ID) return { name: "My Space", emoji: "📁" }
|
||||
const found = projects.find((p: Project) => p.containerTag === value)
|
||||
return found?.name ?? value
|
||||
return found ? { name: found.name, emoji: found.emoji } : { name: value, emoji: undefined }
|
||||
}, [projects, value])
|
||||
|
||||
const selectedProjectName = selectedProject.name
|
||||
const selectedProjectEmoji = selectedProject.emoji || "📁"
|
||||
|
||||
const handleSelect = (containerTag: string) => {
|
||||
onValueChange(containerTag)
|
||||
setIsOpen(false)
|
||||
|
|
@ -73,6 +103,79 @@ export function SpaceSelector({
|
|||
setShowCreateDialog(true)
|
||||
}
|
||||
|
||||
const handleDeleteClick = (
|
||||
e: React.MouseEvent,
|
||||
project: { id: string; name: string; containerTag: string },
|
||||
) => {
|
||||
e.stopPropagation()
|
||||
e.preventDefault()
|
||||
setDeleteDialog({
|
||||
open: true,
|
||||
project,
|
||||
action: "move",
|
||||
targetProjectId: "",
|
||||
})
|
||||
}
|
||||
|
||||
const handleDeleteConfirm = () => {
|
||||
if (!deleteDialog.project) return
|
||||
|
||||
deleteProjectMutation.mutate(
|
||||
{
|
||||
projectId: deleteDialog.project.id,
|
||||
action: deleteDialog.action,
|
||||
targetProjectId:
|
||||
deleteDialog.action === "move" ? deleteDialog.targetProjectId : undefined,
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
setDeleteDialog({
|
||||
open: false,
|
||||
project: null,
|
||||
action: "move",
|
||||
targetProjectId: "",
|
||||
})
|
||||
setIsOpen(false)
|
||||
},
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
const handleDeleteCancel = () => {
|
||||
setDeleteDialog({
|
||||
open: false,
|
||||
project: null,
|
||||
action: "move",
|
||||
targetProjectId: "",
|
||||
})
|
||||
}
|
||||
|
||||
const availableTargetProjects = useMemo(() => {
|
||||
const filtered = projects.filter(
|
||||
(p: Project) =>
|
||||
p.id !== deleteDialog.project?.id &&
|
||||
p.containerTag !== deleteDialog.project?.containerTag,
|
||||
)
|
||||
|
||||
const defaultProject = projects.find(
|
||||
(p: Project) => p.containerTag === DEFAULT_PROJECT_ID,
|
||||
)
|
||||
|
||||
const isDefaultProjectBeingDeleted =
|
||||
deleteDialog.project?.containerTag === DEFAULT_PROJECT_ID
|
||||
|
||||
if (defaultProject && !isDefaultProjectBeingDeleted) {
|
||||
const defaultProjectIncluded = filtered.some(
|
||||
(p: Project) => p.containerTag === DEFAULT_PROJECT_ID,
|
||||
)
|
||||
if (!defaultProjectIncluded) {
|
||||
return [defaultProject, ...filtered]
|
||||
}
|
||||
}
|
||||
|
||||
return filtered
|
||||
}, [projects, deleteDialog.project])
|
||||
|
||||
return (
|
||||
<>
|
||||
<DropdownMenu open={isOpen} onOpenChange={setIsOpen}>
|
||||
|
|
@ -86,7 +189,7 @@ export function SpaceSelector({
|
|||
triggerClassName,
|
||||
)}
|
||||
>
|
||||
<span className="text-sm font-bold tracking-[-0.98px]">📁</span>
|
||||
<span className="text-sm font-bold tracking-[-0.98px]">{selectedProjectEmoji}</span>
|
||||
<span className="text-sm font-medium text-white">
|
||||
{isLoading ? "..." : selectedProjectName}
|
||||
</span>
|
||||
|
|
@ -108,7 +211,7 @@ export function SpaceSelector({
|
|||
>
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex flex-col">
|
||||
{/* Default Project */}
|
||||
{/* Default Project - no delete allowed */}
|
||||
<DropdownMenuItem
|
||||
onClick={() => handleSelect(DEFAULT_PROJECT_ID)}
|
||||
className={cn(
|
||||
|
|
@ -119,7 +222,7 @@ export function SpaceSelector({
|
|||
)}
|
||||
>
|
||||
<span className="font-bold tracking-[-0.98px]">📁</span>
|
||||
<span>My Space</span>
|
||||
<span className="flex-1">My Space</span>
|
||||
</DropdownMenuItem>
|
||||
|
||||
{/* User Projects */}
|
||||
|
|
@ -130,14 +233,29 @@ export function SpaceSelector({
|
|||
key={project.id}
|
||||
onClick={() => handleSelect(project.containerTag)}
|
||||
className={cn(
|
||||
"flex items-center gap-2 px-3 py-2 rounded-md cursor-pointer text-white text-sm font-medium",
|
||||
"flex items-center gap-2 px-3 py-2 rounded-md cursor-pointer text-white text-sm font-medium group",
|
||||
value === project.containerTag
|
||||
? "bg-[#161E2B] border border-[rgba(115,115,115,0.1)]"
|
||||
: "opacity-50 hover:opacity-100 hover:bg-[#161E2B]/50",
|
||||
)}
|
||||
>
|
||||
<span className="font-bold tracking-[-0.98px]">📁</span>
|
||||
<span className="truncate">{project.name}</span>
|
||||
<span className="font-bold tracking-[-0.98px]">{project.emoji || "📁"}</span>
|
||||
<span className="truncate flex-1">{project.name}</span>
|
||||
{enableDelete && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) =>
|
||||
handleDeleteClick(e, {
|
||||
id: project.id,
|
||||
name: project.name,
|
||||
containerTag: project.containerTag,
|
||||
})
|
||||
}
|
||||
className="opacity-0 group-hover:opacity-100 transition-opacity p-1 rounded-full hover:bg-red-500/20"
|
||||
>
|
||||
<Trash2 className="size-3.5 text-red-500" />
|
||||
</button>
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</div>
|
||||
|
|
@ -160,10 +278,238 @@ export function SpaceSelector({
|
|||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<CreateProjectDialog
|
||||
open={showCreateDialog}
|
||||
onOpenChange={setShowCreateDialog}
|
||||
<AddSpaceModal
|
||||
isOpen={showCreateDialog}
|
||||
onClose={() => setShowCreateDialog(false)}
|
||||
/>
|
||||
|
||||
{/* Delete Confirmation Dialog - matching /new design system */}
|
||||
<Dialog
|
||||
open={deleteDialog.open}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setDeleteDialog({
|
||||
open: false,
|
||||
project: null,
|
||||
action: "move",
|
||||
targetProjectId: "",
|
||||
})
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogContent
|
||||
className={cn(
|
||||
"w-[90%]! max-w-[500px]! 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 id="delete-dialog-header" className="flex justify-between items-start gap-4">
|
||||
<div className="pl-1 space-y-1 flex-1">
|
||||
<p className={cn("font-semibold text-[#fafafa]", dmSans125ClassName())}>
|
||||
Delete space
|
||||
</p>
|
||||
<p className="text-[#737373] font-medium text-[16px] leading-[1.35]">
|
||||
What would you like to do with the documents and memories in{" "}
|
||||
<span className="text-[#fafafa] font-medium">
|
||||
"{deleteDialog.project?.name}"
|
||||
</span>
|
||||
?
|
||||
</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>
|
||||
|
||||
<div id="delete-dialog-content" className="space-y-3">
|
||||
<button
|
||||
id="move-option"
|
||||
type="button"
|
||||
onClick={() => setDeleteDialog((prev) => ({ ...prev, action: "move" }))}
|
||||
className={cn(
|
||||
"flex items-center gap-3 p-3 rounded-[12px] cursor-pointer transition-colors w-full text-left",
|
||||
deleteDialog.action === "move"
|
||||
? "bg-[#14161A] border border-[rgba(82,89,102,0.3)]"
|
||||
: "bg-[#14161A]/50 border border-transparent hover:border-[rgba(82,89,102,0.2)]",
|
||||
)}
|
||||
style={{
|
||||
boxShadow:
|
||||
deleteDialog.action === "move"
|
||||
? "0px 1px 2px 0px rgba(0,43,87,0.1), inset 0px 0px 0px 1px rgba(43,49,67,0.08), inset 0px 1px 1px 0px rgba(0,0,0,0.08)"
|
||||
: "none",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"w-4 h-4 rounded-full border-2 flex items-center justify-center shrink-0",
|
||||
deleteDialog.action === "move"
|
||||
? "border-blue-500"
|
||||
: "border-[#737373]",
|
||||
)}
|
||||
>
|
||||
{deleteDialog.action === "move" && (
|
||||
<div className="w-2 h-2 rounded-full bg-blue-500" />
|
||||
)}
|
||||
</div>
|
||||
<span className="text-[#fafafa] text-sm font-medium">
|
||||
Move to another space
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{deleteDialog.action === "move" && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{ opacity: 1, height: "auto" }}
|
||||
exit={{ opacity: 0, height: 0 }}
|
||||
className="ml-7"
|
||||
>
|
||||
<Select
|
||||
value={deleteDialog.targetProjectId}
|
||||
onValueChange={(val) =>
|
||||
setDeleteDialog((prev) => ({
|
||||
...prev,
|
||||
targetProjectId: val,
|
||||
}))
|
||||
}
|
||||
>
|
||||
<SelectTrigger
|
||||
className={cn(
|
||||
"bg-[#14161A] border border-[rgba(82,89,102,0.2)] rounded-[12px] text-[#fafafa] text-[14px] h-[45px]",
|
||||
dmSansClassName(),
|
||||
)}
|
||||
style={{
|
||||
boxShadow:
|
||||
"0px 1px 2px 0px rgba(0,43,87,0.1), inset 0px 0px 0px 1px rgba(43,49,67,0.08), inset 0px 1px 1px 0px rgba(0,0,0,0.08), inset 0px 2px 4px 0px rgba(0,0,0,0.02)",
|
||||
}}
|
||||
>
|
||||
<SelectValue placeholder="Select target space" />
|
||||
</SelectTrigger>
|
||||
<SelectContent
|
||||
className={cn(
|
||||
"bg-[#14161A] border border-[rgba(82,89,102,0.2)] rounded-[12px]",
|
||||
dmSansClassName(),
|
||||
)}
|
||||
style={{
|
||||
boxShadow:
|
||||
"0px 1px 2px 0px rgba(0,43,87,0.1), inset 0px 0px 0px 1px rgba(43,49,67,0.08)",
|
||||
}}
|
||||
>
|
||||
{availableTargetProjects.map((p: Project) => (
|
||||
<SelectItem
|
||||
key={p.id}
|
||||
value={p.id}
|
||||
className="text-[#fafafa] hover:bg-[#1B1F24] cursor-pointer rounded-md"
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<span>{p.emoji || "📁"}</span>
|
||||
<span>
|
||||
{p.containerTag === DEFAULT_PROJECT_ID ? "My Space" : p.name}
|
||||
</span>
|
||||
</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
<button
|
||||
id="delete-option"
|
||||
type="button"
|
||||
onClick={() => setDeleteDialog((prev) => ({ ...prev, action: "delete" }))}
|
||||
className={cn(
|
||||
"flex items-center gap-3 p-3 rounded-[12px] cursor-pointer transition-colors w-full text-left",
|
||||
deleteDialog.action === "delete"
|
||||
? "bg-[#14161A] border border-[rgba(220,38,38,0.3)]"
|
||||
: "bg-[#14161A]/50 border border-transparent hover:border-[rgba(82,89,102,0.2)]",
|
||||
)}
|
||||
style={{
|
||||
boxShadow:
|
||||
deleteDialog.action === "delete"
|
||||
? "0px 1px 2px 0px rgba(87,0,0,0.1), inset 0px 0px 0px 1px rgba(67,43,43,0.08), inset 0px 1px 1px 0px rgba(0,0,0,0.08)"
|
||||
: "none",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"w-4 h-4 rounded-full border-2 flex items-center justify-center shrink-0",
|
||||
deleteDialog.action === "delete"
|
||||
? "border-red-500"
|
||||
: "border-[#737373]",
|
||||
)}
|
||||
>
|
||||
{deleteDialog.action === "delete" && (
|
||||
<div className="w-2 h-2 rounded-full bg-red-500" />
|
||||
)}
|
||||
</div>
|
||||
<span className="text-[#fafafa] text-sm font-medium">
|
||||
Delete everything permanently
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{deleteDialog.action === "delete" && (
|
||||
<motion.p
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
className="text-xs text-red-400 ml-7"
|
||||
>
|
||||
All documents and memories will be permanently deleted.
|
||||
</motion.p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div id="delete-dialog-footer" className="flex items-center justify-end gap-[22px]">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDeleteCancel}
|
||||
disabled={deleteProjectMutation.isPending}
|
||||
className={cn(
|
||||
"text-[#737373] font-medium text-[14px] cursor-pointer transition-colors hover:text-[#999]",
|
||||
dmSansClassName(),
|
||||
)}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<Button
|
||||
variant="insideOut"
|
||||
onClick={handleDeleteConfirm}
|
||||
disabled={
|
||||
deleteProjectMutation.isPending ||
|
||||
(deleteDialog.action === "move" && !deleteDialog.targetProjectId)
|
||||
}
|
||||
className={cn(
|
||||
"px-4 py-[10px] rounded-full",
|
||||
deleteDialog.action === "delete" &&
|
||||
"bg-red-600 hover:bg-red-700 border-red-700",
|
||||
)}
|
||||
>
|
||||
{deleteProjectMutation.isPending ? (
|
||||
<>
|
||||
<Loader2 className="size-4 animate-spin mr-2" />
|
||||
{deleteDialog.action === "move" ? "Moving..." : "Deleting..."}
|
||||
</>
|
||||
) : deleteDialog.action === "move" ? (
|
||||
"Move & Delete"
|
||||
) : (
|
||||
"Delete Everything"
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -74,6 +74,11 @@ export function ProjectSelector() {
|
|||
staleTime: 30 * 1000,
|
||||
})
|
||||
|
||||
const selectedProjectData = projects.find(
|
||||
(p: Project) => p.containerTag === selectedProject,
|
||||
)
|
||||
const selectedEmoji = selectedProjectData?.emoji
|
||||
|
||||
const handleProjectSelect = (containerTag: string) => {
|
||||
switchProject(containerTag)
|
||||
setIsOpen(false)
|
||||
|
|
@ -92,7 +97,11 @@ export function ProjectSelector() {
|
|||
className="flex items-center gap-1.5 px-2 py-1.5 rounded-md transition-colors"
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
>
|
||||
<FolderIcon className="h-3.5 w-3.5" />
|
||||
{selectedEmoji ? (
|
||||
<span className="text-sm">{selectedEmoji}</span>
|
||||
) : (
|
||||
<FolderIcon className="h-3.5 w-3.5" />
|
||||
)}
|
||||
<span className="text-xs font-medium max-w-32 truncate">
|
||||
{isLoading ? "..." : projectName}
|
||||
</span>
|
||||
|
|
@ -157,7 +166,11 @@ export function ProjectSelector() {
|
|||
handleProjectSelect(project.containerTag)
|
||||
}
|
||||
>
|
||||
<FolderIcon className="h-3.5 w-3.5 opacity-70" />
|
||||
{project.emoji ? (
|
||||
<span className="text-sm">{project.emoji}</span>
|
||||
) : (
|
||||
<FolderIcon className="h-3.5 w-3.5 opacity-70" />
|
||||
)}
|
||||
<span className="text-xs font-medium truncate max-w-32">
|
||||
{project.name}
|
||||
</span>
|
||||
|
|
|
|||
|
|
@ -1,41 +1,44 @@
|
|||
"use client";
|
||||
"use client"
|
||||
|
||||
import { $fetch } from "@lib/api";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import { useProject } from "@/stores";
|
||||
import { $fetch } from "@lib/api"
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query"
|
||||
import { toast } from "sonner"
|
||||
import { useProject } from "@/stores"
|
||||
|
||||
export function useProjectMutations() {
|
||||
const queryClient = useQueryClient();
|
||||
const { selectedProject, setSelectedProject } = useProject();
|
||||
const queryClient = useQueryClient()
|
||||
const { selectedProject, setSelectedProject } = useProject()
|
||||
|
||||
const createProjectMutation = useMutation({
|
||||
mutationFn: async (name: string) => {
|
||||
mutationFn: async (input: string | { name: string; emoji?: string }) => {
|
||||
const { name, emoji } =
|
||||
typeof input === "string" ? { name: input, emoji: undefined } : input
|
||||
|
||||
const response = await $fetch("@post/projects", {
|
||||
body: { name },
|
||||
});
|
||||
body: { name, emoji },
|
||||
})
|
||||
|
||||
if (response.error) {
|
||||
throw new Error(response.error?.message || "Failed to create project");
|
||||
throw new Error(response.error?.message || "Failed to create project")
|
||||
}
|
||||
|
||||
return response.data;
|
||||
return response.data
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
toast.success("Project created successfully!");
|
||||
queryClient.invalidateQueries({ queryKey: ["projects"] });
|
||||
toast.success("Project created successfully!")
|
||||
queryClient.invalidateQueries({ queryKey: ["projects"] })
|
||||
|
||||
// Automatically switch to the newly created project
|
||||
if (data?.containerTag) {
|
||||
setSelectedProject(data.containerTag);
|
||||
setSelectedProject(data.containerTag)
|
||||
}
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error("Failed to create project", {
|
||||
description: error instanceof Error ? error.message : "Unknown error",
|
||||
});
|
||||
})
|
||||
},
|
||||
});
|
||||
})
|
||||
|
||||
const deleteProjectMutation = useMutation({
|
||||
mutationFn: async ({
|
||||
|
|
@ -43,47 +46,47 @@ export function useProjectMutations() {
|
|||
action,
|
||||
targetProjectId,
|
||||
}: {
|
||||
projectId: string;
|
||||
action: "move" | "delete";
|
||||
targetProjectId?: string;
|
||||
projectId: string
|
||||
action: "move" | "delete"
|
||||
targetProjectId?: string
|
||||
}) => {
|
||||
const response = await $fetch(`@delete/projects/${projectId}`, {
|
||||
body: { action, targetProjectId },
|
||||
});
|
||||
})
|
||||
|
||||
if (response.error) {
|
||||
throw new Error(response.error?.message || "Failed to delete project");
|
||||
throw new Error(response.error?.message || "Failed to delete project")
|
||||
}
|
||||
|
||||
return response.data;
|
||||
return response.data
|
||||
},
|
||||
onSuccess: (_, variables) => {
|
||||
toast.success("Project deleted successfully");
|
||||
queryClient.invalidateQueries({ queryKey: ["projects"] });
|
||||
toast.success("Project deleted successfully")
|
||||
queryClient.invalidateQueries({ queryKey: ["projects"] })
|
||||
|
||||
// If we deleted the selected project, switch to default
|
||||
const deletedProject = queryClient
|
||||
.getQueryData<any[]>(["projects"])
|
||||
?.find((p) => p.id === variables.projectId);
|
||||
?.find((p) => p.id === variables.projectId)
|
||||
if (deletedProject?.containerTag === selectedProject) {
|
||||
setSelectedProject("sm_project_default");
|
||||
setSelectedProject("sm_project_default")
|
||||
}
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error("Failed to delete project", {
|
||||
description: error instanceof Error ? error.message : "Unknown error",
|
||||
});
|
||||
})
|
||||
},
|
||||
});
|
||||
})
|
||||
|
||||
const switchProject = (containerTag: string) => {
|
||||
setSelectedProject(containerTag);
|
||||
toast.success("Project switched successfully");
|
||||
};
|
||||
setSelectedProject(containerTag)
|
||||
toast.success("Project switched successfully")
|
||||
}
|
||||
|
||||
return {
|
||||
createProjectMutation,
|
||||
deleteProjectMutation,
|
||||
switchProject,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,4 +9,5 @@ export interface Project {
|
|||
createdAt: string
|
||||
updatedAt: string
|
||||
isExperimental?: boolean
|
||||
emoji?: string
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1233,6 +1233,10 @@ export const ProjectSchema = z
|
|||
description: "Number of documents in this project",
|
||||
example: 42,
|
||||
}),
|
||||
emoji: z.string().optional().openapi({
|
||||
description: "Emoji icon for the project",
|
||||
example: "📁",
|
||||
}),
|
||||
})
|
||||
.openapi({
|
||||
description: "Project object for organizing memories",
|
||||
|
|
@ -1246,6 +1250,11 @@ export const CreateProjectSchema = z
|
|||
minLength: 1,
|
||||
maxLength: 100,
|
||||
}),
|
||||
emoji: z.string().max(10).optional().openapi({
|
||||
description: "Emoji icon for the project",
|
||||
example: "📁",
|
||||
maxLength: 10,
|
||||
}),
|
||||
})
|
||||
.openapi({
|
||||
description: "Request body for creating a new project",
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue