diff options
| author | MaheshtheDev <[email protected]> | 2026-01-20 17:03:22 +0000 |
|---|---|---|
| committer | MaheshtheDev <[email protected]> | 2026-01-20 17:03:22 +0000 |
| commit | 731808be6838f33aa649129434bbc5ce71800c38 (patch) | |
| tree | 1a127fe5a5c84c9c6f277d825c125ba0be3ec817 /apps | |
| parent | fix(tools): multi step agent prompt caching (#685) (diff) | |
| download | supermemory-731808be6838f33aa649129434bbc5ce71800c38.tar.xz supermemory-731808be6838f33aa649129434bbc5ce71800c38.zip | |
feat: create space, delete spaces and emoji picker (#687)01-19-feat_create_space_and_delete_spaces_and_emoji_picker
### 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
Diffstat (limited to 'apps')
| -rw-r--r-- | apps/web/app/new/onboarding/layout.tsx | 5 | ||||
| -rw-r--r-- | apps/web/app/new/onboarding/welcome/page.tsx | 10 | ||||
| -rw-r--r-- | apps/web/components/create-project-dialog.tsx | 11 | ||||
| -rw-r--r-- | apps/web/components/new/add-space-modal.tsx | 200 | ||||
| -rw-r--r-- | apps/web/components/new/header.tsx | 18 | ||||
| -rw-r--r-- | apps/web/components/new/onboarding/setup/header.tsx | 17 | ||||
| -rw-r--r-- | apps/web/components/new/space-selector.tsx | 376 | ||||
| -rw-r--r-- | apps/web/components/project-selector.tsx | 17 | ||||
| -rw-r--r-- | apps/web/hooks/use-project-mutations.ts | 69 |
9 files changed, 647 insertions, 76 deletions
diff --git a/apps/web/app/new/onboarding/layout.tsx b/apps/web/app/new/onboarding/layout.tsx index 0b077f1f..644b2018 100644 --- a/apps/web/app/new/onboarding/layout.tsx +++ b/apps/web/app/new/onboarding/layout.tsx @@ -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) diff --git a/apps/web/app/new/onboarding/welcome/page.tsx b/apps/web/app/new/onboarding/welcome/page.tsx index 40f0134b..5b579144 100644 --- a/apps/web/app/new/onboarding/welcome/page.tsx +++ b/apps/web/app/new/onboarding/welcome/page.tsx @@ -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) } diff --git a/apps/web/components/create-project-dialog.tsx b/apps/web/components/create-project-dialog.tsx index 468a07d2..5b6af4c8 100644 --- a/apps/web/components/create-project-dialog.tsx +++ b/apps/web/components/create-project-dialog.tsx @@ -34,11 +34,14 @@ export function CreateProjectDialog({ const handleCreate = () => { if (projectName.trim()) { - createProjectMutation.mutate(projectName, { - onSuccess: () => { - handleClose() + createProjectMutation.mutate( + { name: projectName }, + { + onSuccess: () => { + handleClose() + }, }, - }) + ) } } diff --git a/apps/web/components/new/add-space-modal.tsx b/apps/web/components/new/add-space-modal.tsx new file mode 100644 index 00000000..69e1563b --- /dev/null +++ b/apps/web/components/new/add-space-modal.tsx @@ -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> + ) +} diff --git a/apps/web/components/new/header.tsx b/apps/web/components/new/header.tsx index 77362dec..da84b5e2 100644 --- a/apps/web/components/new/header.tsx +++ b/apps/web/components/new/header.tsx @@ -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"> diff --git a/apps/web/components/new/onboarding/setup/header.tsx b/apps/web/components/new/onboarding/setup/header.tsx index 4981c6aa..f28b8fd4 100644 --- a/apps/web/components/new/onboarding/setup/header.tsx +++ b/apps/web/components/new/onboarding/setup/header.tsx @@ -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} diff --git a/apps/web/components/new/space-selector.tsx b/apps/web/components/new/space-selector.tsx index 68c615a5..adfbdee1 100644 --- a/apps/web/components/new/space-selector.tsx +++ b/apps/web/components/new/space-selector.tsx @@ -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> </> ) } diff --git a/apps/web/components/project-selector.tsx b/apps/web/components/project-selector.tsx index 9e613cc7..a55c351b 100644 --- a/apps/web/components/project-selector.tsx +++ b/apps/web/components/project-selector.tsx @@ -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> diff --git a/apps/web/hooks/use-project-mutations.ts b/apps/web/hooks/use-project-mutations.ts index a6766f4e..3ec24176 100644 --- a/apps/web/hooks/use-project-mutations.ts +++ b/apps/web/hooks/use-project-mutations.ts @@ -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, - }; + } } |