mirror of
https://github.com/supermemoryai/supermemory.git
synced 2026-08-28 05:25:33 +00:00
feat(web): mobile UX for dashboard, nav, and spaces (#1000)
This commit is contained in:
parent
9b310e9538
commit
f646f1a80f
10 changed files with 637 additions and 44 deletions
|
|
@ -11,6 +11,7 @@ import {
|
|||
import { AnimatePresence, motion } from "motion/react"
|
||||
import { useQueryState } from "nuqs"
|
||||
import { Header, PublicHeader } from "@/components/header"
|
||||
import { MobileBottomNav } from "@/components/bottom-nav"
|
||||
import { ChatSidebar, HomeChatComposer } from "@/components/chat"
|
||||
import { DashboardView } from "@/components/dashboard-view"
|
||||
import { MemoriesGrid } from "@/components/memories-grid"
|
||||
|
|
@ -561,6 +562,7 @@ export default function NewPage() {
|
|||
const isDashboardShell =
|
||||
viewMode === "dashboard" || (viewMode === "graph" && isMobile)
|
||||
const isGraphMode = viewMode === "graph"
|
||||
const showBottomNav = isMobile && !isChatView && !!session
|
||||
|
||||
return (
|
||||
<HotkeysProvider>
|
||||
|
|
@ -568,6 +570,9 @@ export default function NewPage() {
|
|||
className={cn(
|
||||
"relative flex min-h-dvh flex-col bg-[#05080D]",
|
||||
isGraphMode && "h-dvh overflow-hidden",
|
||||
showBottomNav &&
|
||||
!isGraphMode &&
|
||||
"pb-[calc(5.5rem+env(safe-area-inset-bottom))]",
|
||||
)}
|
||||
>
|
||||
{showNovaBackdrop && (
|
||||
|
|
@ -737,14 +742,37 @@ export default function NewPage() {
|
|||
</motion.main>
|
||||
</AnimatePresence>
|
||||
|
||||
{isDashboardShell && showBottomNav && (
|
||||
<div className="pointer-events-none fixed inset-x-0 bottom-0 z-20 h-64 bg-gradient-to-t from-[#05080D] via-[#05080D]/95 to-transparent" />
|
||||
)}
|
||||
{isDashboardShell && (
|
||||
<div className="pointer-events-none fixed inset-x-0 bottom-0 z-30 bg-gradient-to-t from-black via-black/40 to-transparent pt-12">
|
||||
<div
|
||||
className={cn(
|
||||
"pointer-events-none fixed inset-x-0 z-30",
|
||||
showBottomNav
|
||||
? "bottom-[4.25rem]"
|
||||
: "bottom-0 bg-gradient-to-t from-black via-black/40 to-transparent pt-12",
|
||||
)}
|
||||
>
|
||||
<div className="pointer-events-auto">
|
||||
<HomeChatComposer onStartChat={handleHomeChatStart} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showBottomNav && (
|
||||
<MobileBottomNav
|
||||
onAddMemory={() => {
|
||||
analytics.addDocumentModalOpened()
|
||||
setAddDoc("note")
|
||||
}}
|
||||
onOpenSearch={() => {
|
||||
analytics.searchOpened({ source: "header" })
|
||||
setIsSearchOpen(true)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<AddDocumentModal
|
||||
isOpen={addDoc !== null}
|
||||
onClose={() => setAddDoc(null)}
|
||||
|
|
|
|||
202
apps/web/components/bottom-nav.tsx
Normal file
202
apps/web/components/bottom-nav.tsx
Normal file
|
|
@ -0,0 +1,202 @@
|
|||
"use client"
|
||||
|
||||
import {
|
||||
Home,
|
||||
LayoutGrid,
|
||||
Plus,
|
||||
MessageCircleIcon,
|
||||
MoreHorizontal,
|
||||
SearchIcon,
|
||||
Sun,
|
||||
LifeBuoy,
|
||||
Settings,
|
||||
} from "lucide-react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { useQueryState } from "nuqs"
|
||||
import { cn } from "@lib/utils"
|
||||
import { dmSansClassName } from "@/lib/fonts"
|
||||
import { GraphIcon } from "@/components/integration-icons"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@ui/components/dropdown-menu"
|
||||
import { useViewMode, type ViewMode } from "@/lib/view-mode-context"
|
||||
import { feedbackParam } from "@/lib/search-params"
|
||||
|
||||
const INTEGRATION_VIEWS: ViewMode[] = [
|
||||
"integrations",
|
||||
"mcp",
|
||||
"plugins",
|
||||
"chrome",
|
||||
"connections",
|
||||
"shortcuts",
|
||||
"raycast",
|
||||
"import",
|
||||
]
|
||||
|
||||
interface BottomNavProps {
|
||||
onAddMemory?: () => void
|
||||
onOpenSearch?: () => void
|
||||
}
|
||||
|
||||
export function MobileBottomNav({ onAddMemory, onOpenSearch }: BottomNavProps) {
|
||||
const router = useRouter()
|
||||
const { viewMode, setViewMode } = useViewMode()
|
||||
const [, setFeedbackOpen] = useQueryState("feedback", feedbackParam)
|
||||
|
||||
const isHome = viewMode === "dashboard"
|
||||
const isMemories = viewMode === "list" || viewMode === "graph"
|
||||
const isChat = viewMode === "chat"
|
||||
const isMore = INTEGRATION_VIEWS.includes(viewMode)
|
||||
|
||||
return (
|
||||
<nav
|
||||
aria-label="Primary"
|
||||
className={cn(
|
||||
"fixed inset-x-0 bottom-[calc(0.75rem+env(safe-area-inset-bottom))] z-40 flex justify-center px-3 md:hidden",
|
||||
dmSansClassName(),
|
||||
)}
|
||||
>
|
||||
<div className="flex w-full items-center justify-around rounded-full border border-[#161F2C] bg-muted/95 px-2.5 py-2 shadow-[0_10px_30px_rgba(0,0,0,0.55)] backdrop-blur-xl">
|
||||
<NavTab
|
||||
label="Home"
|
||||
icon={Home}
|
||||
active={isHome}
|
||||
onClick={() => void setViewMode("dashboard")}
|
||||
/>
|
||||
<NavTab
|
||||
label="Memories"
|
||||
icon={LayoutGrid}
|
||||
active={isMemories}
|
||||
onClick={() => void setViewMode("list")}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Add memory"
|
||||
onClick={onAddMemory}
|
||||
className="flex size-11 shrink-0 items-center justify-center self-center rounded-full text-white outline-none transition-colors hover:bg-white/5"
|
||||
>
|
||||
<Plus className="size-7" strokeWidth={2.25} />
|
||||
</button>
|
||||
<NavTab
|
||||
label="Chat"
|
||||
icon={MessageCircleIcon}
|
||||
active={isChat}
|
||||
onClick={() => void setViewMode("chat")}
|
||||
/>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<NavTabButton label="More" active={isMore}>
|
||||
<MoreHorizontal className="size-6" />
|
||||
</NavTabButton>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
side="top"
|
||||
align="end"
|
||||
sideOffset={12}
|
||||
className={cn(
|
||||
"min-w-[200px] rounded-2xl border border-[#263348]/60 p-1.5 shadow-[0px_1.5px_20px_0px_rgba(0,0,0,0.65)]",
|
||||
dmSansClassName(),
|
||||
)}
|
||||
style={{
|
||||
background: "linear-gradient(180deg, #101822 0%, #0A0E14 100%)",
|
||||
}}
|
||||
>
|
||||
<MoreItem icon={SearchIcon} label="Search" onClick={onOpenSearch} />
|
||||
<MoreItem
|
||||
icon={GraphIcon}
|
||||
label="Graph"
|
||||
onClick={() => void setViewMode("graph")}
|
||||
/>
|
||||
<MoreItem
|
||||
icon={Sun}
|
||||
label="Integrations"
|
||||
onClick={() => void setViewMode("integrations")}
|
||||
/>
|
||||
<DropdownMenuSeparator className="bg-[#263348]/50" />
|
||||
<MoreItem
|
||||
icon={LifeBuoy}
|
||||
label="Feedback"
|
||||
onClick={() => setFeedbackOpen(true)}
|
||||
/>
|
||||
<MoreItem
|
||||
icon={Settings}
|
||||
label="Settings"
|
||||
onClick={() => router.push("/settings")}
|
||||
/>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
|
||||
function NavTab({
|
||||
label,
|
||||
icon: Icon,
|
||||
active,
|
||||
onClick,
|
||||
}: {
|
||||
label: string
|
||||
icon: React.ComponentType<{ className?: string }>
|
||||
active: boolean
|
||||
onClick: () => void
|
||||
}) {
|
||||
return (
|
||||
<NavTabButton label={label} active={active} onClick={onClick}>
|
||||
<Icon className="size-6" />
|
||||
</NavTabButton>
|
||||
)
|
||||
}
|
||||
|
||||
function NavTabButton({
|
||||
label,
|
||||
active,
|
||||
onClick,
|
||||
children,
|
||||
...props
|
||||
}: {
|
||||
label: string
|
||||
active: boolean
|
||||
onClick?: () => void
|
||||
children: React.ReactNode
|
||||
} & React.ComponentProps<"button">) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
aria-current={active ? "page" : undefined}
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
"flex shrink-0 flex-col items-center gap-1 rounded-full px-3 py-1.5 outline-none transition-colors",
|
||||
active ? "text-white" : "text-[#737373] hover:text-white",
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<span className="text-[10px] font-medium leading-none">{label}</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function MoreItem({
|
||||
icon: Icon,
|
||||
label,
|
||||
onClick,
|
||||
}: {
|
||||
icon: React.ComponentType<{ className?: string }>
|
||||
label: string
|
||||
onClick?: () => void
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
onClick={onClick}
|
||||
className="gap-2 rounded-md px-3 py-2.5 text-sm font-medium text-white hover:bg-[#293952]/40"
|
||||
>
|
||||
<Icon className="size-4 text-[#737373]" />
|
||||
{label}
|
||||
</DropdownMenuItem>
|
||||
)
|
||||
}
|
||||
|
|
@ -1297,7 +1297,7 @@ export function DashboardView({
|
|||
Home
|
||||
</p>
|
||||
<h1
|
||||
className="max-w-2xl text-xl font-medium tracking-tight text-white md:text-2xl"
|
||||
className="max-w-2xl text-lg font-medium leading-snug tracking-tight text-white md:text-2xl md:leading-tight"
|
||||
title={spaceLabel}
|
||||
>
|
||||
{homeHeadline}
|
||||
|
|
@ -1309,7 +1309,7 @@ export function DashboardView({
|
|||
<button
|
||||
type="button"
|
||||
onClick={onNavigateToGraph}
|
||||
className="group relative shrink-0 w-[140px] h-[56px] rounded-xl overflow-hidden border border-surface-border hover:border-[#3A4A63] transition-all bg-surface-card hover:scale-[1.02]"
|
||||
className="group relative hidden h-[56px] w-[140px] shrink-0 overflow-hidden rounded-xl border border-surface-border bg-surface-card transition-all hover:scale-[1.02] hover:border-[#3A4A63] md:block"
|
||||
aria-label="Open graph view"
|
||||
>
|
||||
<StaticGraphPreview
|
||||
|
|
|
|||
|
|
@ -76,12 +76,12 @@ export function Header({ onAddMemory, onOpenSearch }: HeaderProps) {
|
|||
className="flex shrink-0 cursor-pointer items-center rounded-lg px-1.5 py-1 transition-colors hover:bg-white/5 focus-visible:ring-2 focus-visible:ring-ring/50 focus-visible:outline-none md:-ml-2"
|
||||
>
|
||||
<Logo className="h-6 md:h-7" />
|
||||
{!isMobile && userName && (
|
||||
{userName && (
|
||||
<div className="ml-1.5 flex flex-col items-start justify-center sm:ml-2">
|
||||
<p className="text-[10px] leading-tight text-[#6B6B6B] sm:text-[11px]">
|
||||
{userName}
|
||||
</p>
|
||||
<p className="-mt-0.5 text-base leading-none font-medium text-white/90 sm:text-lg">
|
||||
<p className="-mt-0.5 text-sm leading-none font-medium text-white/90 sm:text-lg">
|
||||
supermemory
|
||||
</p>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import {
|
|||
X,
|
||||
} from "lucide-react"
|
||||
import { Logo } from "@ui/assets/Logo"
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@ui/components/popover"
|
||||
import { analytics } from "@/lib/analytics"
|
||||
|
||||
export type HighlightFormat = "paragraph" | "bullets" | "quote" | "one_liner"
|
||||
|
|
@ -213,7 +214,37 @@ export function HighlightsCard({
|
|||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<Info className="size-[14px] text-fg-subtle" />
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="About the Daily Brief"
|
||||
className="shrink-0 rounded-full p-0.5 text-fg-subtle transition-colors hover:text-fg-primary focus-visible:outline-none"
|
||||
>
|
||||
<Info className="size-[14px]" />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
align="end"
|
||||
side="bottom"
|
||||
className={cn(
|
||||
"w-64 rounded-xl border border-[#263348]/60 p-3 shadow-[0px_1.5px_20px_0px_rgba(0,0,0,0.65)]",
|
||||
dmSansClassName(),
|
||||
)}
|
||||
style={{
|
||||
background: "linear-gradient(180deg, #101822 0%, #0A0E14 100%)",
|
||||
}}
|
||||
>
|
||||
<p className="mb-1 text-[12px] font-semibold text-fg-primary">
|
||||
Daily Brief
|
||||
</p>
|
||||
<p className="text-[12px] leading-relaxed text-fg-subtle">
|
||||
AI-generated highlights and questions drawn from your memories. It
|
||||
refreshes automatically every few hours — tap the refresh icon to
|
||||
update it now.
|
||||
</p>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
|
||||
<div id="highlights-body" className="flex flex-col gap-1.5">
|
||||
|
|
|
|||
|
|
@ -547,11 +547,11 @@ export function MemoriesGrid({
|
|||
id="filter-pills"
|
||||
className="mb-3 flex flex-col gap-2 pr-2 sm:flex-row sm:items-start sm:justify-between sm:gap-4"
|
||||
>
|
||||
<div className="order-2 flex w-full min-w-0 flex-wrap items-center gap-1.5 sm:order-1">
|
||||
<div className="order-2 flex w-full min-w-0 items-center gap-1.5 overflow-x-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden sm:order-1 sm:flex-wrap sm:overflow-visible">
|
||||
<Button
|
||||
className={cn(
|
||||
dmSansClassName(),
|
||||
"rounded-full border border-[#161F2C] bg-[#0D121A] px-2.5 py-1 text-xs h-auto hover:bg-[#00173C] hover:border-[#2261CA33]",
|
||||
"shrink-0 whitespace-nowrap rounded-full border border-[#161F2C] bg-[#0D121A] px-2.5 py-1 text-xs h-auto hover:bg-[#00173C] hover:border-[#2261CA33]",
|
||||
selectedCategories.length === 0 &&
|
||||
"bg-[#00173C] border-[#2261CA33]",
|
||||
)}
|
||||
|
|
@ -569,7 +569,7 @@ export function MemoriesGrid({
|
|||
key={facet.category}
|
||||
className={cn(
|
||||
dmSansClassName(),
|
||||
"rounded-full border border-[#161F2C] bg-[#0D121A] px-2.5 py-1 text-xs h-auto hover:bg-[#00173C] hover:border-[#2261CA33]",
|
||||
"shrink-0 whitespace-nowrap rounded-full border border-[#161F2C] bg-[#0D121A] px-2.5 py-1 text-xs h-auto hover:bg-[#00173C] hover:border-[#2261CA33]",
|
||||
selectedCategoriesSet.has(facet.category) &&
|
||||
"bg-[#00173C] border-[#2261CA33]",
|
||||
)}
|
||||
|
|
@ -580,7 +580,7 @@ export function MemoriesGrid({
|
|||
</Button>
|
||||
))}
|
||||
</div>
|
||||
<div className="order-1 flex shrink-0 items-center gap-2 self-end sm:order-2 sm:self-start">
|
||||
<div className="order-1 flex w-full items-center justify-between gap-2 sm:order-2 sm:w-auto sm:justify-start sm:self-start">
|
||||
{/* View mode toggle — segmented control */}
|
||||
<div
|
||||
role="tablist"
|
||||
|
|
|
|||
|
|
@ -4,7 +4,9 @@ import { useState, useMemo, useEffect, useCallback, useRef } from "react"
|
|||
import Image from "next/image"
|
||||
import { dmSans125ClassName, dmSansClassName } from "@/lib/fonts"
|
||||
import { Dialog, DialogContent } from "@repo/ui/components/dialog"
|
||||
import { Drawer, DrawerContent, DrawerTitle } from "@repo/ui/components/drawer"
|
||||
import { cn } from "@lib/utils"
|
||||
import { useIsMobile } from "@hooks/use-mobile"
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog"
|
||||
import {
|
||||
XIcon,
|
||||
|
|
@ -47,6 +49,7 @@ import { useProjectMutations } from "@/hooks/use-project-mutations"
|
|||
import { AUTO_CHAT_SPACE_ID } from "@/lib/chat-auto-space"
|
||||
import NovaOrb from "@/components/nova/nova-orb"
|
||||
import { AutoSpaceIcon } from "@/components/nova/auto-space-icon"
|
||||
import { SpaceGlyph } from "./space-glyph"
|
||||
|
||||
interface SelectSpacesModalProps {
|
||||
isOpen: boolean
|
||||
|
|
@ -116,6 +119,7 @@ export function SelectSpacesModal({
|
|||
const editInputRef = useRef<HTMLInputElement | null>(null)
|
||||
const editingContainerTag = editingProject?.containerTag
|
||||
const currentSelection = selectedProjects[0] ?? ""
|
||||
const isMobile = useIsMobile()
|
||||
|
||||
const pluginTags = useMemo(
|
||||
() =>
|
||||
|
|
@ -645,7 +649,11 @@ export function SelectSpacesModal({
|
|||
</button>
|
||||
{isEditing ? (
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2">
|
||||
<span className="shrink-0 text-lg">{project.emoji || "📁"}</span>
|
||||
<SpaceGlyph
|
||||
emoji={project.emoji}
|
||||
size={18}
|
||||
className="shrink-0"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={editingProject.name}
|
||||
|
|
@ -709,9 +717,11 @@ export function SelectSpacesModal({
|
|||
) : isOwnSpace ? (
|
||||
<NovaOrb size={20} className="shrink-0 blur-[0.55px]!" />
|
||||
) : (
|
||||
<span className="shrink-0 text-lg">
|
||||
{project.emoji || "📁"}
|
||||
</span>
|
||||
<SpaceGlyph
|
||||
emoji={project.emoji}
|
||||
size={20}
|
||||
className="shrink-0"
|
||||
/>
|
||||
)}
|
||||
<span
|
||||
className="min-w-0 flex-1 truncate text-[#fafafa] text-sm font-medium"
|
||||
|
|
@ -825,6 +835,283 @@ export function SelectSpacesModal({
|
|||
)
|
||||
}, [currentSelection, handleSelectAuto])
|
||||
|
||||
const renderCategoryChip = (
|
||||
category: {
|
||||
id: string
|
||||
label: string
|
||||
count?: number
|
||||
iconSrc?: string
|
||||
emoji?: string
|
||||
},
|
||||
isDiscover: boolean,
|
||||
) => {
|
||||
const isActive = activeCategory === category.id
|
||||
return (
|
||||
<button
|
||||
key={category.id}
|
||||
type="button"
|
||||
onClick={() => setActiveCategory(category.id)}
|
||||
className={cn(
|
||||
"flex shrink-0 items-center gap-2 whitespace-nowrap rounded-full border px-3 py-2 transition-colors",
|
||||
isActive
|
||||
? "border-[#2261CA33] bg-[#00173C] text-[#fafafa]"
|
||||
: "border-[#161F2C] bg-[#0D121A] text-[#A1A1AA]",
|
||||
isDiscover && !isActive && "opacity-60",
|
||||
)}
|
||||
>
|
||||
<span className="flex size-[18px] shrink-0 items-center justify-center">
|
||||
{category.id === "all" ? (
|
||||
<LayoutGrid
|
||||
className={cn(
|
||||
"size-4",
|
||||
isActive ? "text-[#fafafa]" : "text-[#737373]",
|
||||
)}
|
||||
/>
|
||||
) : category.iconSrc ? (
|
||||
<Image
|
||||
src={category.iconSrc}
|
||||
alt=""
|
||||
width={18}
|
||||
height={18}
|
||||
className="rounded-[3px]"
|
||||
aria-hidden
|
||||
/>
|
||||
) : category.emoji ? (
|
||||
<SpaceGlyph emoji={category.emoji} size={16} />
|
||||
) : category.id.startsWith("plugin:") ? (
|
||||
<span
|
||||
className="flex h-[18px] w-[18px] items-center justify-center rounded-[3px] bg-[#1E232B] text-[10px] font-semibold uppercase text-[#FAFAFA]"
|
||||
aria-hidden
|
||||
>
|
||||
{pluginInitial(category.label)}
|
||||
</span>
|
||||
) : (
|
||||
<FolderIcon
|
||||
className={cn(
|
||||
"size-4",
|
||||
isActive ? "text-[#fafafa]" : "text-[#737373]",
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
<span className="text-[13px] font-medium">{category.label}</span>
|
||||
{isDiscover ? (
|
||||
<ArrowRight className="size-3.5 text-[#737373]" />
|
||||
) : (
|
||||
<span className="text-[11px] tabular-nums text-[#737373]">
|
||||
{category.count}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
const rightPanelContent = activeCategory.startsWith("discover:") ? (
|
||||
<DiscoverPanel
|
||||
catalogId={activeDiscoverId ?? ""}
|
||||
isConnecting={connectingPluginId === activeDiscoverId}
|
||||
newKey={newKey?.pluginId === activeDiscoverId ? newKey.key : null}
|
||||
onConnect={() => {
|
||||
if (activeDiscoverId) connectMutation.mutate(activeDiscoverId)
|
||||
}}
|
||||
onDismissKey={() => setNewKey(null)}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<div className="relative shrink-0">
|
||||
<Search className="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-[#737373]" />
|
||||
<input
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder="Search spaces..."
|
||||
className={cn(
|
||||
"w-full rounded-[12px] bg-[#14161A] py-2.5 pl-10 pr-4 text-[14px] text-[#fafafa] shadow-inside-out placeholder:text-[#737373] focus:outline-none",
|
||||
dmSansClassName(),
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="min-h-0 flex-1 overflow-x-hidden overflow-y-auto scrollbar-thin pr-1">
|
||||
{filteredProjects.length === 0 ? (
|
||||
<p className="py-8 text-center text-sm text-[#737373]">
|
||||
No spaces found
|
||||
</p>
|
||||
) : (
|
||||
<div className="flex flex-col gap-1">
|
||||
{showAutoRow && (
|
||||
<>
|
||||
<div className="px-3 pt-1 pb-0.5 text-[10px] uppercase tracking-[0.08em] text-[#737373]">
|
||||
Mode
|
||||
</div>
|
||||
{renderAutoRow()}
|
||||
<div className="my-1.5 h-px bg-[rgba(82,89,102,0.18)]" />
|
||||
</>
|
||||
)}
|
||||
{recentProjects.length > 0 && (
|
||||
<>
|
||||
<div className="flex items-center gap-1.5 px-3 pt-1 pb-0.5 text-[10px] uppercase tracking-[0.08em] text-[#737373]">
|
||||
<Clock className="size-3" />
|
||||
Recently used
|
||||
</div>
|
||||
{recentProjects.map(renderRow)}
|
||||
<div className="my-1.5 h-px bg-[rgba(82,89,102,0.18)]" />
|
||||
<div className="px-3 pt-0.5 pb-0.5 text-[10px] uppercase tracking-[0.08em] text-[#737373]">
|
||||
All spaces
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{mainList.map(renderRow)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
|
||||
const footerContent = !activeCategory.startsWith("discover:") &&
|
||||
(isBulkDeleteMode || (showNewSpace && onNewSpace)) && (
|
||||
<div className="flex shrink-0 items-center justify-between gap-3 border-t border-[rgba(82,89,102,0.18)] px-4 py-3">
|
||||
{isBulkDeleteMode ? (
|
||||
<>
|
||||
<p className="min-w-0 text-[13px] font-medium text-[#737373]">
|
||||
{bulkDeleteCount === 0
|
||||
? "No spaces selected"
|
||||
: `${bulkDeleteCount} ${
|
||||
bulkDeleteCount === 1 ? "space" : "spaces"
|
||||
} selected`}
|
||||
</p>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleBulkModeToggle}
|
||||
className={cn(
|
||||
"px-3 py-2 text-[13px] font-medium text-[#737373] transition-colors hover:text-[#fafafa]",
|
||||
dmSansClassName(),
|
||||
)}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={bulkDeleteCount === 0}
|
||||
onClick={() => {
|
||||
if (bulkDeleteCount === 0) return
|
||||
onBulkDeleteRequest?.(bulkDeleteProjects)
|
||||
setIsBulkDeleteMode(false)
|
||||
setBulkDeleteTags(new Set())
|
||||
setLastBulkDeleteTag(null)
|
||||
}}
|
||||
className={cn(
|
||||
"flex items-center gap-2 rounded-full bg-red-600 px-4 py-2 text-[13px] font-medium text-white transition-colors hover:bg-red-700 disabled:cursor-not-allowed disabled:opacity-40",
|
||||
dmSansClassName(),
|
||||
)}
|
||||
>
|
||||
<Trash2 className="size-4" />
|
||||
Delete selected
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span />
|
||||
{showNewSpace && onNewSpace && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onNewSpace}
|
||||
className={cn(
|
||||
"flex items-center gap-2 rounded-full bg-[#14161A] px-4 py-2 text-[13px] font-medium text-[#fafafa] shadow-inside-out transition-colors hover:bg-[#121820] focus:outline-none focus:ring-0",
|
||||
dmSansClassName(),
|
||||
)}
|
||||
>
|
||||
<Plus className="size-4" />
|
||||
New space
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
if (isMobile) {
|
||||
return (
|
||||
<Drawer open={isOpen} onOpenChange={handleOpenChange}>
|
||||
<DrawerContent
|
||||
className={cn(
|
||||
"flex h-[85dvh] flex-col gap-0 overflow-hidden border-none bg-[#1B1F24] p-0",
|
||||
dmSansClassName(),
|
||||
)}
|
||||
>
|
||||
<DrawerTitle className="sr-only">Select Space</DrawerTitle>
|
||||
<div className="flex shrink-0 items-start justify-between gap-3 px-4 pt-1">
|
||||
<div className="space-y-1">
|
||||
<p
|
||||
className={cn(
|
||||
"font-semibold text-[#fafafa]",
|
||||
dmSans125ClassName(),
|
||||
)}
|
||||
>
|
||||
Select Space
|
||||
</p>
|
||||
<p className="text-[13px] font-medium leading-[1.35] text-[#737373]">
|
||||
{isBulkDeleteMode
|
||||
? "Choose spaces to permanently delete"
|
||||
: "Filter your memories by space"}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
{enableDelete && onBulkDeleteRequest && !activeDiscoverId && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleBulkModeToggle}
|
||||
className={cn(
|
||||
"flex h-8 items-center gap-1.5 rounded-full bg-[#0D121A] px-2.5 text-[12px] font-medium transition-colors",
|
||||
isBulkDeleteMode ? "text-[#fafafa]" : "text-[#737373]",
|
||||
)}
|
||||
style={{
|
||||
boxShadow:
|
||||
"inset 1.313px 1.313px 3.938px 0px rgba(0,0,0,0.7)",
|
||||
}}
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
{isBulkDeleteMode ? "Cancel" : "Bulk delete"}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleOpenChange(false)}
|
||||
aria-label="Close"
|
||||
className="flex size-8 shrink-0 items-center justify-center rounded-full border border-[rgba(115,115,115,0.2)] bg-[#0D121A]"
|
||||
style={{
|
||||
boxShadow:
|
||||
"inset 1.313px 1.313px 3.938px 0px rgba(0,0,0,0.7)",
|
||||
}}
|
||||
>
|
||||
<XIcon stroke="#737373" className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex shrink-0 gap-1.5 overflow-x-auto px-4 pb-2 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
|
||||
{categories.map((category) => renderCategoryChip(category, false))}
|
||||
{discoverCategories.length > 0 && (
|
||||
<>
|
||||
<div className="mx-0.5 my-1 w-px shrink-0 bg-[rgba(82,89,102,0.25)]" />
|
||||
{discoverCategories.map((category) =>
|
||||
renderCategoryChip(category, true),
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex min-h-0 flex-1 flex-col gap-3 overflow-hidden px-4 pb-2">
|
||||
{rightPanelContent}
|
||||
</div>
|
||||
|
||||
{footerContent}
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={handleOpenChange}>
|
||||
<DialogContent
|
||||
|
|
@ -921,7 +1208,7 @@ export function SelectSpacesModal({
|
|||
aria-hidden
|
||||
/>
|
||||
) : category.emoji ? (
|
||||
<span className="text-base">{category.emoji}</span>
|
||||
<SpaceGlyph emoji={category.emoji} size={16} />
|
||||
) : category.id.startsWith("plugin:") ? (
|
||||
<span
|
||||
className="w-[18px] h-[18px] flex items-center justify-center rounded-[3px] bg-[#1E232B] text-[#FAFAFA] text-[10px] font-semibold uppercase"
|
||||
|
|
|
|||
34
apps/web/components/space-folder-icon.tsx
Normal file
34
apps/web/components/space-folder-icon.tsx
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
export function SpaceFolderIcon({
|
||||
size = 16,
|
||||
className,
|
||||
}: {
|
||||
size?: number
|
||||
className?: string
|
||||
}) {
|
||||
return (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
className={className}
|
||||
aria-hidden
|
||||
>
|
||||
<title>Space</title>
|
||||
{/* paper tab peeking above the folder */}
|
||||
<rect x="7" y="3.2" width="10" height="5" rx="1.6" fill="#F5C518" />
|
||||
{/* folder back with tab */}
|
||||
<path
|
||||
d="M2.6 8.8a2 2 0 0 1 2-2h4.2a2 2 0 0 1 1.5.68l1 1.13a2 2 0 0 0 1.5.69h6.6a2 2 0 0 1 2 2V17a2 2 0 0 1-2 2H4.6a2 2 0 0 1-2-2Z"
|
||||
fill="#161D29"
|
||||
stroke="#3C4658"
|
||||
strokeWidth="1.3"
|
||||
/>
|
||||
{/* lighter front flap for depth */}
|
||||
<path
|
||||
d="M2.6 11.6h18.8V17a2 2 0 0 1-2 2H4.6a2 2 0 0 1-2-2Z"
|
||||
fill="#27313F"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
24
apps/web/components/space-glyph.tsx
Normal file
24
apps/web/components/space-glyph.tsx
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
import { cn } from "@lib/utils"
|
||||
import { SpaceFolderIcon } from "./space-folder-icon"
|
||||
|
||||
export function SpaceGlyph({
|
||||
emoji,
|
||||
size = 16,
|
||||
className,
|
||||
}: {
|
||||
emoji?: string | null
|
||||
size?: number
|
||||
className?: string
|
||||
}) {
|
||||
if (!emoji || emoji === "📁") {
|
||||
return <SpaceFolderIcon size={size} className={cn("shrink-0", className)} />
|
||||
}
|
||||
return (
|
||||
<span
|
||||
className={cn("shrink-0 leading-none", className)}
|
||||
style={{ fontSize: size }}
|
||||
>
|
||||
{emoji}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
|
@ -12,6 +12,7 @@ import type { ContainerTagListType } from "@lib/types"
|
|||
import { AUTO_CHAT_SPACE_ID } from "@/lib/chat-auto-space"
|
||||
import { AddSpaceModal } from "./add-space-modal"
|
||||
import { SelectSpacesModal } from "./select-spaces-modal"
|
||||
import { SpaceGlyph } from "./space-glyph"
|
||||
import { useProjectMutations } from "@/hooks/use-project-mutations"
|
||||
import { useContainerTags } from "@/hooks/use-container-tags"
|
||||
import { motion } from "motion/react"
|
||||
|
|
@ -418,40 +419,26 @@ export function SpaceSelector({
|
|||
</span>
|
||||
)
|
||||
) : (
|
||||
<span
|
||||
className="shrink-0 text-sm font-bold tracking-[-0.98px]"
|
||||
aria-hidden
|
||||
>
|
||||
{displayInfo.emoji}
|
||||
</span>
|
||||
)}
|
||||
{!compact && (
|
||||
<span
|
||||
className={cn(
|
||||
"min-w-0 truncate text-sm font-medium text-white",
|
||||
"max-w-[10rem] md:max-w-[15rem]",
|
||||
)}
|
||||
title={isLoading ? undefined : displayInfo.name}
|
||||
>
|
||||
{isLoading ? "…" : displayInfo.name}
|
||||
</span>
|
||||
<SpaceGlyph emoji={displayInfo.emoji} size={compact ? 16 : 18} />
|
||||
)}
|
||||
<span
|
||||
className={cn(
|
||||
"min-w-0 truncate text-sm font-medium text-white",
|
||||
compact ? "max-w-[7rem]" : "max-w-[10rem] md:max-w-[15rem]",
|
||||
)}
|
||||
title={isLoading ? undefined : displayInfo.name}
|
||||
>
|
||||
{isLoading ? "…" : displayInfo.name}
|
||||
</span>
|
||||
{!compact && spaceCountData !== undefined && spaceCountData > 0 && (
|
||||
<span className="shrink-0 text-[11px] text-[#737373] tabular-nums">
|
||||
· {formatCount(spaceCountData)}
|
||||
</span>
|
||||
)}
|
||||
{!compact && (
|
||||
<ChevronDownIcon
|
||||
className="size-3.5 shrink-0 text-[#737373]"
|
||||
aria-hidden
|
||||
/>
|
||||
)}
|
||||
{compact && (
|
||||
<span className="sr-only">
|
||||
{isLoading ? "Loading" : displayInfo.name}
|
||||
</span>
|
||||
)}
|
||||
<ChevronDownIcon
|
||||
className="size-3.5 shrink-0 text-[#737373]"
|
||||
aria-hidden
|
||||
/>
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" className={dmSansClassName()}>
|
||||
|
|
@ -633,7 +620,7 @@ export function SpaceSelector({
|
|||
className="shrink-0 blur-[0.45px]!"
|
||||
/>
|
||||
) : (
|
||||
<span>{p.emoji || "📁"}</span>
|
||||
<SpaceGlyph emoji={p.emoji} size={16} />
|
||||
)}
|
||||
<span className="truncate">
|
||||
{p.containerTag === DEFAULT_PROJECT_ID ? (
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue