aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--apps/web/app/new/onboarding/layout.tsx5
-rw-r--r--apps/web/app/new/onboarding/welcome/page.tsx10
-rw-r--r--apps/web/components/create-project-dialog.tsx11
-rw-r--r--apps/web/components/new/add-space-modal.tsx200
-rw-r--r--apps/web/components/new/header.tsx18
-rw-r--r--apps/web/components/new/onboarding/setup/header.tsx17
-rw-r--r--apps/web/components/new/space-selector.tsx376
-rw-r--r--apps/web/components/project-selector.tsx17
-rw-r--r--apps/web/hooks/use-project-mutations.ts69
-rw-r--r--packages/lib/types.ts1
-rw-r--r--packages/validation/api.ts9
11 files changed, 657 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,
- };
+ }
}
diff --git a/packages/lib/types.ts b/packages/lib/types.ts
index 024c4811..02469b11 100644
--- a/packages/lib/types.ts
+++ b/packages/lib/types.ts
@@ -9,4 +9,5 @@ export interface Project {
createdAt: string
updatedAt: string
isExperimental?: boolean
+ emoji?: string
}
diff --git a/packages/validation/api.ts b/packages/validation/api.ts
index 9d8c1983..3669c9eb 100644
--- a/packages/validation/api.ts
+++ b/packages/validation/api.ts
@@ -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",