Match desktop shell to web dashboard

This commit is contained in:
Sreeram Sreedhar 2026-06-22 16:24:41 -07:00
parent 9cd894a8f2
commit 5d7dc85886
6 changed files with 331 additions and 117 deletions

View file

@ -9,12 +9,12 @@ import { Titlebar } from "@/components/titlebar"
export default function AppLayout({ children }: { children: ReactNode }) {
return (
<AuthGuard>
<div className="flex h-screen flex-col">
<div className="desktop-shell flex h-screen flex-col overflow-hidden">
<Titlebar />
<div className="flex min-h-0 flex-1">
<AppNav />
<main className="min-w-0 flex-1 overflow-auto">{children}</main>
</div>
<AppNav />
<main className="relative z-10 min-h-0 flex-1 overflow-auto">
{children}
</main>
</div>
</AuthGuard>
)

View file

@ -1,7 +1,6 @@
"use client"
import { useQuery } from "@tanstack/react-query"
import { Button } from "@ui/components/button"
import {
Card,
CardContent,
@ -9,7 +8,16 @@ import {
CardHeader,
CardTitle,
} from "@ui/components/card"
import { Clock, FileText, RefreshCcw, Search } from "lucide-react"
import {
ArrowRight,
Clock,
FileText,
Link2,
Lightbulb,
MessageCircle,
RefreshCcw,
Search,
} from "lucide-react"
import { useMemo, useState } from "react"
import { listDocuments, type DocumentWithMemories } from "@/lib/api"
import type { SearchResult } from "@/lib/search"
@ -43,91 +51,163 @@ export default function DashboardPage() {
const subtitle = useMemo(() => {
if (documentsQuery.isPending) return "Loading your recent memories."
if (typeof totalCount === "number") return `${totalCount} memories indexed.`
return "Your recent memories."
return "Your memory is ready."
}, [documentsQuery.isPending, totalCount])
return (
<div className="mx-auto flex h-full w-full max-w-6xl flex-col p-8">
<div className="mb-8 flex items-center justify-between gap-4">
<div>
<h1 className="font-semibold text-2xl">Your memories</h1>
<p className="text-muted-foreground text-sm">{subtitle}</p>
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="icon"
aria-label="Refresh memories"
<div className="min-h-full px-4 pt-2 pb-16 md:px-6">
<div className="mx-auto w-full max-w-4xl space-y-5">
<header className="flex items-end justify-between gap-4 border-surface-border border-b pb-4">
<div className="space-y-0.5">
<p className="font-medium text-[10px] text-fg-faint uppercase tracking-[0.12em]">
Home
</p>
<h1 className="max-w-2xl font-medium text-2xl text-white leading-tight tracking-tight">
Welcome back, your saved context is ready.
</h1>
<p className="text-fg-subtle text-sm">{subtitle}</p>
</div>
<button
type="button"
onClick={() => documentsQuery.refetch()}
disabled={documentsQuery.isFetching}
className="group relative hidden h-14 w-36 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="Refresh memories"
>
<div className="absolute inset-0 bg-[radial-gradient(circle_at_28%_50%,rgba(75,160,250,0.35),transparent_22%),radial-gradient(circle_at_58%_42%,rgba(139,198,255,0.22),transparent_18%),radial-gradient(circle_at_78%_62%,rgba(15,240,210,0.16),transparent_16%)]" />
<div className="absolute inset-x-0 bottom-0 h-10 bg-gradient-to-t from-black/45 to-transparent" />
<RefreshCcw
className={documentsQuery.isFetching ? "animate-spin" : undefined}
className={[
"absolute right-3 bottom-3 size-4 text-[#8BC6FF]",
documentsQuery.isFetching ? "animate-spin" : "",
].join(" ")}
/>
</Button>
<Button variant="outline" onClick={() => setOpen(true)}>
<Search />
Search
<kbd className="ml-1 rounded bg-muted px-1.5 py-0.5 text-xs">
⌘K
</kbd>
</Button>
</div>
</div>
</button>
</header>
<div className="grid min-h-0 flex-1 grid-cols-1 gap-4 lg:grid-cols-[minmax(320px,420px)_1fr]">
<Card className="min-h-0 gap-0 overflow-hidden py-0">
<CardHeader className="border-border/60 border-b py-4">
<CardTitle className="text-base">Recent</CardTitle>
<CardDescription>
Latest documents from Supermemory.
</CardDescription>
</CardHeader>
<CardContent className="min-h-0 overflow-auto p-2">
{documentsQuery.isPending ? (
<div className="p-4 text-muted-foreground text-sm">
Loading memories...
<section className="space-y-2">
<p className="font-medium text-[10px] text-fg-faint uppercase tracking-[0.12em]">
Daily brief
</p>
<div className="grid gap-3 sm:grid-cols-[1fr_220px]">
<div className="rounded-[18px] bg-surface-card/60 p-3 shadow-[0_12px_40px_rgba(0,0,0,0.22)] backdrop-blur-md">
<div className="flex min-h-[132px] flex-col justify-between gap-4">
<div className="space-y-2">
<p className="font-medium text-fg-primary text-sm">
{activeMemory?.title ?? "No memory selected yet"}
</p>
<p className="line-clamp-3 text-[12px] text-fg-subtle leading-relaxed">
{activeMemory?.summary ??
activeMemory?.content ??
"Save or search memories and Nova will surface the most useful context here."}
</p>
</div>
<div className="flex items-center justify-between">
<button
type="button"
onClick={() => setOpen(true)}
className="text-[11px] text-fg-subtle transition-colors hover:text-fg-primary"
>
Ask or search related →
</button>
<MessageCircle className="size-4 text-[#4BA0FA]" />
</div>
</div>
) : null}
{documentsQuery.isError ? (
<div className="p-4 text-destructive text-sm">
{documentsQuery.error.message}
</div>
) : null}
{!documentsQuery.isPending && documents.length === 0 ? (
<div className="p-4 text-muted-foreground text-sm">
No memories found.
</div>
) : null}
<div className="space-y-1">
{documents.map((document) => (
<button
type="button"
key={document.id}
onClick={() => setSelectedMemory(toMemoryPreview(document))}
className={[
"flex w-full items-start gap-3 rounded-md px-3 py-2 text-left transition-colors",
activeMemory?.id === document.id
? "bg-accent text-accent-foreground"
: "hover:bg-accent/50",
].join(" ")}
>
<FileText className="mt-0.5 size-4 shrink-0 text-muted-foreground" />
<span className="min-w-0">
<span className="block truncate font-medium text-sm">
{document.title ?? document.url ?? "Untitled memory"}
</span>
<span className="mt-1 line-clamp-2 block text-muted-foreground text-xs">
{document.summary ?? document.content ?? document.type}
</span>
</span>
</button>
))}
</div>
</CardContent>
</Card>
<div className="hidden rounded-[18px] bg-surface-card/60 p-3 shadow-[0_12px_40px_rgba(0,0,0,0.22)] backdrop-blur-md sm:flex sm:flex-col sm:justify-between">
<span className="self-start rounded-full bg-[#4BA0FA]/16 px-2 py-0.5 font-semibold text-[#8BC6FF] text-[9px] uppercase tracking-[0.12em]">
Desktop
</span>
<p className="text-[12px] text-fg-secondary leading-relaxed">
Use Command-K to search your memory without leaving the native
shell.
</p>
<span className="text-[10px] text-fg-faint">Nova-ready</span>
</div>
</div>
</section>
<DocumentPreview memory={activeMemory} />
<section className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="grid grid-cols-3 gap-1 sm:-mx-2.5 sm:flex sm:items-center sm:gap-0.5">
<QuickAction icon={Link2} label="Save link" />
<span className="hidden text-[#3A4455] sm:inline">·</span>
<QuickAction icon={FileText} label="Write note" />
<span className="hidden text-[#3A4455] sm:inline">·</span>
<QuickAction
icon={Search}
label="Search"
onClick={() => setOpen(true)}
/>
</div>
<p className="hidden min-w-0 items-center gap-1.5 overflow-hidden text-[11px] text-fg-subtle sm:flex">
<Lightbulb className="size-3 shrink-0 text-[#3374FF]" />
<span className="truncate">
Search by meaning, not just exact titles.
</span>
</p>
</section>
<section className="space-y-2">
<div className="flex gap-4">
<div className="min-w-0 flex-[4]">
<p className="font-medium text-[10px] text-fg-faint uppercase tracking-[0.12em]">
Recents
</p>
</div>
<div className="hidden min-w-0 flex-[2] sm:block">
<p className="font-medium text-[10px] text-fg-faint uppercase tracking-[0.12em]">
Preview
</p>
</div>
</div>
<div className="flex items-start gap-4">
<div className="min-w-0 flex-[4]">
{documentsQuery.isPending ? (
<div className="px-2.5 py-2 text-fg-subtle text-sm">
Loading memories...
</div>
) : null}
{documentsQuery.isError ? (
<div className="px-2.5 py-2 text-destructive text-sm">
{documentsQuery.error.message}
</div>
) : null}
{!documentsQuery.isPending && documents.length === 0 ? (
<div className="px-2.5 py-2 text-fg-subtle text-sm">
No memories found.
</div>
) : null}
<ul className="space-y-0.5">
{documents.map((document) => (
<li key={document.id}>
<button
type="button"
onClick={() =>
setSelectedMemory(toMemoryPreview(document))
}
className="group flex w-full items-center gap-3 rounded-lg px-2.5 py-2 text-left transition-colors hover:bg-surface-hover"
>
<span className="flex size-6 shrink-0 items-center justify-center rounded-md bg-surface-card ring-1 ring-surface-border transition-colors group-hover:bg-[#182333]">
<FileText className="size-3 text-fg-subtle" />
</span>
<span className="min-w-0 flex-1 truncate text-fg-muted text-sm transition-colors group-hover:text-white">
{document.title?.trim() ||
document.url ||
"Untitled memory"}
</span>
<ArrowRight className="size-3.5 shrink-0 text-fg-faint transition-colors group-hover:text-fg-muted" />
</button>
</li>
))}
</ul>
</div>
<div className="hidden min-w-0 flex-[2] sm:block">
<DocumentPreview memory={activeMemory} />
</div>
</div>
</section>
</div>
<SearchCommand
@ -141,11 +221,32 @@ export default function DashboardPage() {
)
}
function QuickAction({
icon: Icon,
label,
onClick,
}: {
icon: typeof Link2
label: string
onClick?: () => void
}) {
return (
<button
type="button"
onClick={onClick}
className="flex min-w-0 items-center justify-center gap-1 rounded-lg px-1 py-1.5 text-[11px] text-fg-subtle leading-none transition-colors hover:bg-surface-hover hover:text-white sm:gap-1.5 sm:px-2.5 sm:text-sm"
>
<Icon className="size-3.5 shrink-0" />
<span className="min-w-0 truncate whitespace-nowrap">{label}</span>
</button>
)
}
function DocumentPreview({ memory }: { memory: MemoryPreview | null }) {
if (!memory) {
return (
<Card className="min-h-0">
<CardContent className="flex h-full min-h-[360px] items-center justify-center text-muted-foreground text-sm">
<Card className="min-h-0 rounded-lg border-white/[0.08] bg-[#0D121A]/74 shadow-[0_24px_80px_rgba(0,0,0,0.24),inset_1px_1px_1px_rgba(255,255,255,0.05)] backdrop-blur-xl">
<CardContent className="flex h-full min-h-[360px] items-center justify-center text-[#8A929E] text-sm">
Select a memory to preview it.
</CardContent>
</Card>
@ -153,46 +254,46 @@ function DocumentPreview({ memory }: { memory: MemoryPreview | null }) {
}
return (
<Card className="min-h-0 overflow-hidden">
<CardHeader className="border-border/60 border-b">
<Card className="border-0 bg-surface-card/60 shadow-[0_12px_40px_rgba(0,0,0,0.22)] backdrop-blur-md">
<CardHeader className="p-3 pb-2">
<div className="flex items-start justify-between gap-4">
<div className="min-w-0">
<CardTitle className="truncate text-lg">
<CardTitle className="truncate text-fg-primary text-sm">
{memory.title ?? "Untitled memory"}
</CardTitle>
<CardDescription className="mt-1 flex items-center gap-2">
<CardDescription className="mt-1 flex items-center gap-1.5 text-fg-faint text-[10px]">
<Clock className="size-3.5" />
{formatDate(memory.createdAt)}
</CardDescription>
</div>
{memory.type ? (
<span className="rounded-md bg-muted px-2 py-1 text-muted-foreground text-xs">
<span className="rounded-full bg-[#4BA0FA]/16 px-2 py-0.5 font-semibold text-[#8BC6FF] text-[9px] uppercase tracking-[0.08em]">
{memory.type}
</span>
) : null}
</div>
</CardHeader>
<CardContent className="min-h-0 overflow-auto p-6">
<CardContent className="px-3 pt-0 pb-3">
{memory.url ? (
<a
href={memory.url}
target="_blank"
rel="noreferrer"
className="mb-4 block truncate text-primary text-sm underline-offset-4 hover:underline"
className="mb-3 block truncate text-[#8BC6FF] text-[11px] underline-offset-4 hover:underline"
>
{memory.url}
</a>
) : null}
<div className="space-y-4 text-sm leading-6">
<div className="space-y-3 text-[12px] leading-relaxed">
{memory.summary ? (
<section>
<h2 className="mb-2 font-medium">Summary</h2>
<p className="text-muted-foreground">{memory.summary}</p>
<h2 className="mb-1 font-medium text-fg-primary">Summary</h2>
<p className="line-clamp-4 text-fg-subtle">{memory.summary}</p>
</section>
) : null}
<section>
<h2 className="mb-2 font-medium">Content</h2>
<p className="whitespace-pre-wrap text-muted-foreground">
<h2 className="mb-1 font-medium text-fg-primary">Content</h2>
<p className="line-clamp-6 whitespace-pre-wrap text-fg-subtle">
{memory.content ?? memory.raw ?? "No content available."}
</p>
</section>

View file

@ -12,6 +12,72 @@ body {
overscroll-behavior: none;
}
.desktop-shell {
position: relative;
isolation: isolate;
background:
radial-gradient(
ellipse 90% 46% at 50% -4%,
rgba(75, 160, 250, 0.22),
transparent 60%
),
radial-gradient(
ellipse 52% 32% at 78% 12%,
rgba(127, 108, 255, 0.14),
transparent 70%
),
radial-gradient(
ellipse 42% 30% at 18% 20%,
rgba(15, 240, 210, 0.08),
transparent 74%
),
linear-gradient(180deg, #05080d 0%, #070b12 44%, #05070a 100%);
color: #fafafa;
}
.desktop-shell::before {
position: absolute;
inset: 0;
z-index: -2;
content: "";
background-image: radial-gradient(
circle,
rgba(255, 255, 255, 0.13) 1px,
transparent 1px
);
background-size: 18px 18px;
mask-image: linear-gradient(
to bottom,
rgba(255, 255, 255, 0.72),
rgba(255, 255, 255, 0.2) 44%,
transparent 88%
);
-webkit-mask-image: linear-gradient(
to bottom,
rgba(255, 255, 255, 0.72),
rgba(255, 255, 255, 0.2) 44%,
transparent 88%
);
pointer-events: none;
}
.desktop-shell::after {
position: absolute;
inset: 0;
z-index: -1;
content: "";
background:
radial-gradient(
ellipse 86% 32% at 50% 100%,
rgba(65, 115, 255, 0.1),
transparent 70%
),
linear-gradient(90deg, transparent, rgba(75, 160, 250, 0.08), transparent)
50% 0 / 60% 1px no-repeat,
linear-gradient(180deg, rgba(5, 8, 13, 0), #05070a 92%);
pointer-events: none;
}
/* The draggable title bar should feel like native chrome, not selectable text. */
[data-tauri-drag-region] {
cursor: default;

View file

@ -1,38 +1,80 @@
"use client"
import { Logo } from "@ui/assets/Logo"
import { cn } from "@lib/utils"
import { Cog, House } from "lucide-react"
import { ChevronRight, Cog, Home, LayoutGrid, SearchIcon } from "lucide-react"
import Link from "next/link"
import { usePathname } from "next/navigation"
const LINKS = [
{ href: "/", label: "Home", icon: House },
{ href: "/", label: "Home", icon: Home },
{ href: "/settings", label: "Settings", icon: Cog },
] as const
export function AppNav() {
const pathname = usePathname()
const openSearch = () => {
window.dispatchEvent(new Event("supermemory:open-search"))
}
return (
<nav className="flex w-48 shrink-0 flex-col gap-1 border-border/60 border-r p-3">
{LINKS.map(({ href, label, icon: Icon }) => {
const active = pathname === href
return (
<Link
key={href}
href={href}
className={cn(
"flex items-center gap-2 rounded-md px-3 py-2 text-sm transition-colors",
active
? "bg-accent text-accent-foreground"
: "text-muted-foreground hover:bg-accent/50 hover:text-foreground",
)}
<header className="relative z-10 flex shrink-0 items-center justify-between gap-1.5 p-2.5 md:gap-2 md:p-3">
<div className="flex min-w-0 items-center gap-3">
<Link
href="/"
className="relative flex shrink-0 items-center rounded-lg px-1.5 py-1 transition-colors hover:bg-white/[0.05]"
>
<Logo className="h-6 shrink-0" />
<div className="ml-2 min-w-0">
<div className="truncate text-[10px] leading-tight text-[#6B6B6B]">
My
</div>
<div className="-mt-0.5 truncate font-medium text-lg text-white/90 leading-none">
supermemory
</div>
</div>
</Link>
<ChevronRight className="hidden size-4 shrink-0 text-[#3F4853] md:block" />
<div className="hidden items-center gap-1 rounded-full border border-[#161F2C] bg-[#0D121A]/80 p-1 md:flex">
{LINKS.map(({ href, label, icon: Icon }) => {
const active = pathname === href
return (
<Link
key={href}
href={href}
className={cn(
"inline-flex h-8 items-center gap-1.5 rounded-full border border-transparent px-3 font-medium text-sm transition-colors",
active
? "border-[#2261CA33] bg-[#00173C] text-white"
: "text-[#D0DAE7] hover:bg-white/[0.05] hover:text-white",
)}
>
<Icon className="size-4" />
{label}
</Link>
)
})}
<button
type="button"
className="inline-flex h-8 items-center gap-1.5 rounded-full border border-transparent px-3 font-medium text-[#D0DAE7] text-sm transition-colors hover:bg-white/[0.05] hover:text-white"
>
<Icon className="size-4" />
{label}
</Link>
)
})}
</nav>
<LayoutGrid className="size-4" />
Memories
</button>
</div>
</div>
<div className="flex shrink-0 items-center gap-1.5">
<button
type="button"
onClick={openSearch}
className="flex size-9 items-center justify-center rounded-full border border-[#161F2C] bg-[#0D121A]/80 text-[#D0DAE7] transition-colors hover:bg-white/[0.05] hover:text-white"
aria-label="Search"
>
<SearchIcon className="size-4" />
</button>
</div>
</header>
)
}

View file

@ -111,8 +111,13 @@ export function useCommandK() {
setOpen((prev) => !prev)
}
}
const onOpenSearch = () => setOpen(true)
document.addEventListener("keydown", onKeyDown)
return () => document.removeEventListener("keydown", onKeyDown)
window.addEventListener("supermemory:open-search", onOpenSearch)
return () => {
document.removeEventListener("keydown", onKeyDown)
window.removeEventListener("supermemory:open-search", onOpenSearch)
}
}, [])
return { open, setOpen }

View file

@ -8,7 +8,7 @@ export function Titlebar({ title = "Supermemory" }: { title?: string }) {
return (
<header
data-tauri-drag-region
className="flex h-10 shrink-0 items-center justify-center border-border/60 border-b bg-background/80 pl-20 font-medium text-muted-foreground text-xs backdrop-blur"
className="flex h-10 shrink-0 items-center justify-center border-white/[0.06] border-b bg-[#05080D]/70 pl-20 font-medium text-[#737B86] text-xs backdrop-blur-md"
>
{title}
</header>