routing & app shell

This commit is contained in:
Sreeram Sreedhar 2026-06-21 23:27:52 -07:00
parent 6a4d59ccf5
commit 4def158714
15 changed files with 453 additions and 121 deletions

View file

@ -0,0 +1,21 @@
import type { ReactNode } from "react"
import { AppNav } from "@/components/app-nav"
import { AuthGuard } from "@/components/auth-guard"
import { Titlebar } from "@/components/titlebar"
// Shell for the authenticated app (dashboard, settings). The login/ and
// spotlight/ routes live OUTSIDE this group, so they don't inherit the nav +
// guard chrome.
export default function AppLayout({ children }: { children: ReactNode }) {
return (
<AuthGuard>
<div className="flex h-screen flex-col">
<Titlebar />
<div className="flex min-h-0 flex-1">
<AppNav />
<main className="min-w-0 flex-1 overflow-auto">{children}</main>
</div>
</div>
</AuthGuard>
)
}

View file

@ -0,0 +1,68 @@
"use client"
import { Button } from "@ui/components/button"
import {
Card,
CardDescription,
CardHeader,
CardTitle,
} from "@ui/components/card"
import { Search } from "lucide-react"
import { SearchCommand, useCommandK } from "@/components/search-command"
const MOCK_MEMORIES = [
{ id: "1", title: "Q3 planning notes", desc: "Roadmap, OKRs, hiring plan" },
{
id: "2",
title: "Tauri vs Electron",
desc: "Why we picked Tauri for the desktop app",
},
{
id: "3",
title: "Onboarding flow",
desc: "Detect installed AI tools, one-click connect",
},
{
id: "4",
title: "SMFS notes",
desc: "One container, two interfaces (API + folder)",
},
]
export default function DashboardPage() {
const { open, setOpen } = useCommandK()
return (
<div className="mx-auto w-full max-w-4xl 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">
Everything you have saved, in one place.
</p>
</div>
<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 className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
{MOCK_MEMORIES.map((memory) => (
<Card
key={memory.id}
className="cursor-pointer gap-0 py-5 transition-colors hover:bg-accent/40"
>
<CardHeader>
<CardTitle className="text-base">{memory.title}</CardTitle>
<CardDescription>{memory.desc}</CardDescription>
</CardHeader>
</Card>
))}
</div>
<SearchCommand open={open} onOpenChange={setOpen} />
</div>
)
}

View file

@ -0,0 +1,67 @@
"use client"
import { invoke } from "@tauri-apps/api/core"
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@ui/components/card"
import Link from "next/link"
import { useEffect, useState } from "react"
type AppInfo = {
name: string
version: string
platform: string
}
export default function SettingsPage() {
const [info, setInfo] = useState<AppInfo | null>(null)
useEffect(() => {
invoke<AppInfo>("app_info")
.then(setInfo)
.catch(() => setInfo(null))
}, [])
return (
<div className="mx-auto w-full max-w-2xl p-8">
<h1 className="mb-6 font-semibold text-2xl">Settings</h1>
<Card>
<CardHeader>
<CardTitle className="text-base">About</CardTitle>
<CardDescription>
Native runtime details, read from the Rust core over IPC.
</CardDescription>
</CardHeader>
<CardContent className="space-y-2 text-sm">
<Row label="App" value={info?.name ?? "…"} />
<Row label="Version" value={info?.version ?? "…"} />
<Row label="Platform" value={info?.platform ?? "…"} />
</CardContent>
</Card>
<p className="mt-6 text-muted-foreground text-sm">
Not signed in?{" "}
<Link
href="/login"
className="text-primary underline-offset-4 hover:underline"
>
Go to login
</Link>
</p>
</div>
)
}
function Row({ label, value }: { label: string; value: string }) {
return (
<div className="flex justify-between">
<span className="text-muted-foreground">{label}</span>
<span className="font-medium tabular-nums">{value}</span>
</div>
)
}

View file

@ -1,71 +1,20 @@
:root {
color-scheme: light dark;
font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto,
sans-serif;
}
* {
box-sizing: border-box;
}
/*
* Desktop window chrome supplements the shared @repo/ui theme, which is
* imported first in app/layout.tsx (`@ui/globals.css`) and provides the
* Tailwind layer + design tokens. Keep this file plain CSS (no @apply / no
* tailwind import) so there is a single Tailwind entry point.
*/
html,
body {
height: 100%;
margin: 0;
/* No rubber-band overscroll inside the native window. */
overscroll-behavior: none;
}
.screen {
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
background: radial-gradient(125% 125% at 50% 0%, #1b1b2f 0%, #0c0c12 60%);
color: #ececf1;
}
.card {
width: 360px;
padding: 2rem;
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 1rem;
background: rgba(255, 255, 255, 0.04);
text-align: center;
}
.title {
margin: 0;
font-size: 1.5rem;
font-weight: 600;
}
.muted {
margin: 0.25rem 0 1.5rem;
color: #9a9ab0;
font-size: 0.875rem;
}
.info {
display: grid;
gap: 0.5rem;
margin: 0;
}
.row {
display: flex;
justify-content: space-between;
font-size: 0.875rem;
}
.row dt {
color: #9a9ab0;
}
.row dd {
margin: 0;
font-variant-numeric: tabular-nums;
}
.error {
color: #ff6b6b;
font-size: 0.875rem;
/* The draggable title bar should feel like native chrome, not selectable text. */
[data-tauri-drag-region] {
cursor: default;
user-select: none;
-webkit-user-select: none;
}

View file

@ -1,15 +1,26 @@
import type { Metadata } from "next"
import { Space_Grotesk } from "next/font/google"
import type { ReactNode } from "react"
import "@ui/globals.css"
import "./globals.css"
import { Providers } from "./providers"
export const metadata = {
const font = Space_Grotesk({
subsets: ["latin"],
variable: "--font-sans",
})
export const metadata: Metadata = {
title: "Supermemory",
description: "Supermemory Desktop",
description: "Your memories, wherever you are",
}
export default function RootLayout({ children }: { children: ReactNode }) {
return (
<html lang="en">
<body>{children}</body>
<html lang="en" suppressHydrationWarning>
<body className={`${font.variable} antialiased`} suppressHydrationWarning>
<Providers>{children}</Providers>
</body>
</html>
)
}

View file

@ -0,0 +1,31 @@
"use client"
import { Button } from "@ui/components/button"
import { useRouter } from "next/navigation"
// Phase 1 stub. Phase 3 wires "Sign in" to the system-browser OAuth flow
// (invoke('auth_begin_oauth')) + the session-token deep-link handoff.
export default function LoginPage() {
const router = useRouter()
return (
<div className="flex h-screen flex-col">
{/* Keep the frameless window draggable from the top edge. */}
<div data-tauri-drag-region className="h-10 shrink-0" />
<div className="flex flex-1 items-center justify-center p-8">
<div className="w-full max-w-sm text-center">
<h1 className="font-semibold text-2xl">Supermemory</h1>
<p className="mt-2 text-muted-foreground text-sm">
Sign in to access your memories.
</p>
<Button className="mt-6 w-full" onClick={() => router.replace("/")}>
Sign in with browser
</Button>
<p className="mt-3 text-muted-foreground text-xs">
Browser-based sign-in arrives in a later phase.
</p>
</div>
</div>
</div>
)
}

View file

@ -1,53 +0,0 @@
"use client"
import { invoke } from "@tauri-apps/api/core"
import { useEffect, useState } from "react"
type AppInfo = {
name: string
version: string
platform: string
}
export default function Home() {
const [info, setInfo] = useState<AppInfo | null>(null)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
// Call into the Rust core over Tauri's IPC bridge. If this resolves, the
// native layer is alive and wired to the webview — the Phase 0 goal.
invoke<AppInfo>("app_info")
.then(setInfo)
.catch((err) => setError(String(err)))
}, [])
return (
<main className="screen">
<section className="card">
<h1 className="title">Supermemory</h1>
<p className="muted">Desktop · Phase 0</p>
{info ? (
<dl className="info">
<div className="row">
<dt>App</dt>
<dd>{info.name}</dd>
</div>
<div className="row">
<dt>Version</dt>
<dd>{info.version}</dd>
</div>
<div className="row">
<dt>Platform</dt>
<dd>{info.platform}</dd>
</div>
</dl>
) : error ? (
<p className="error">Native bridge error: {error}</p>
) : (
<p className="muted">Connecting to the native core</p>
)}
</section>
</main>
)
}

View file

@ -0,0 +1,38 @@
"use client"
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
import { Toaster } from "@ui/components/sonner"
import { ThemeProvider } from "next-themes"
import { type ReactNode, useState } from "react"
export function Providers({ children }: { children: ReactNode }) {
// One QueryClient for the app's lifetime; lazy init avoids re-creating it on
// every render (and on Fast Refresh in dev).
const [queryClient] = useState(
() =>
new QueryClient({
defaultOptions: {
queries: {
refetchOnWindowFocus: false,
staleTime: 60 * 1000,
},
},
}),
)
// The app is dark-only, matching apps/web (forcedTheme="dark").
return (
<ThemeProvider
attribute="class"
defaultTheme="dark"
enableSystem={false}
forcedTheme="dark"
disableTransitionOnChange
>
<QueryClientProvider client={queryClient}>
{children}
<Toaster />
</QueryClientProvider>
</ThemeProvider>
)
}

View file

@ -0,0 +1,23 @@
"use client"
import { Search } from "lucide-react"
// Phase 1 stub for the frameless spotlight window. Phase 5 wires the Rust
// global-shortcut, window show/hide on blur/Esc, input focus on show, and real
// /v3/search results.
export default function SpotlightPage() {
return (
<div className="flex h-screen items-start justify-center bg-transparent p-3">
<div className="w-full overflow-hidden rounded-xl border border-border/60 bg-popover/95 shadow-2xl backdrop-blur">
<div className="flex items-center gap-3 px-4 py-3">
<Search className="size-4 shrink-0 text-muted-foreground" />
<input
aria-label="Search your memories"
placeholder="Search your memories…"
className="w-full bg-transparent text-sm outline-none placeholder:text-muted-foreground"
/>
</div>
</div>
</div>
)
}

View file

@ -0,0 +1,38 @@
"use client"
import { cn } from "@lib/utils"
import { Cog, House } from "lucide-react"
import Link from "next/link"
import { usePathname } from "next/navigation"
const LINKS = [
{ href: "/", label: "Home", icon: House },
{ href: "/settings", label: "Settings", icon: Cog },
] as const
export function AppNav() {
const pathname = usePathname()
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",
)}
>
<Icon className="size-4" />
{label}
</Link>
)
})}
</nav>
)
}

View file

@ -0,0 +1,35 @@
"use client"
import { useRouter } from "next/navigation"
import { type ReactNode, useEffect } from "react"
// Phase 1 stub. Phase 2/3 replaces `useAuthStatus` with a real check of the
// keychain-backed token (invoke('auth_get_token')) and returns "unauthenticated"
// when it is missing. The state machine + redirect are wired now so later phases
// only swap the source of truth, not the guard's shape.
type AuthStatus = "loading" | "authenticated" | "unauthenticated"
function useAuthStatus(): AuthStatus {
return "authenticated"
}
export function AuthGuard({ children }: { children: ReactNode }) {
const status = useAuthStatus()
const router = useRouter()
useEffect(() => {
if (status === "unauthenticated") {
router.replace("/login")
}
}, [status, router])
if (status !== "authenticated") {
return (
<div className="flex min-h-screen items-center justify-center text-muted-foreground text-sm">
Loading
</div>
)
}
return children
}

View file

@ -0,0 +1,76 @@
"use client"
import {
CommandDialog,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@ui/components/command"
import { FileText, Hash, MessageSquare } from "lucide-react"
import { useEffect, useState } from "react"
// Mock data for Phase 1. Phase 5 swaps this for /v3/search results and wires the
// OS-global hotkey via the Rust global-shortcut plugin. The point here is to
// mount the real command-palette UI (cmdk + shared theme) behind an in-app
// Cmd/Ctrl+K — the precursor to the spotlight window.
const MOCK_RESULTS = [
{ id: "1", title: "Q3 planning notes", kind: "doc" },
{ id: "2", title: "Tauri vs Electron — research", kind: "doc" },
{ id: "3", title: "engineering", kind: "space" },
{ id: "4", title: "Desktop app architecture", kind: "chat" },
] as const
const ICONS = { doc: FileText, space: Hash, chat: MessageSquare }
export function SearchCommand({
open,
onOpenChange,
}: {
open: boolean
onOpenChange: (open: boolean) => void
}) {
return (
<CommandDialog open={open} onOpenChange={onOpenChange}>
<CommandInput placeholder="Search your memories…" />
<CommandList>
<CommandEmpty>No results found.</CommandEmpty>
<CommandGroup heading="Memories">
{MOCK_RESULTS.map((result) => {
const Icon = ICONS[result.kind]
return (
<CommandItem
key={result.id}
value={result.title}
onSelect={() => onOpenChange(false)}
>
<Icon />
{result.title}
</CommandItem>
)
})}
</CommandGroup>
</CommandList>
</CommandDialog>
)
}
// In-app Cmd/Ctrl+K toggles the palette. (The OS-global hotkey arrives in Phase 5
// from the Rust side; this is the in-window shortcut.)
export function useCommandK() {
const [open, setOpen] = useState(false)
useEffect(() => {
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === "k" && (event.metaKey || event.ctrlKey)) {
event.preventDefault()
setOpen((prev) => !prev)
}
}
document.addEventListener("keydown", onKeyDown)
return () => document.removeEventListener("keydown", onKeyDown)
}, [])
return { open, setOpen }
}

View file

@ -0,0 +1,16 @@
"use client"
// Draggable strip for the frameless main window. On macOS the native traffic
// lights overlay the top-left (tauri.conf.json -> titleBarStyle: "Overlay"),
// so we pad left to clear them. `data-tauri-drag-region` is the hook that lets
// Tauri move the OS window when this strip is dragged.
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"
>
{title}
</header>
)
}

View file

@ -9,16 +9,23 @@
"tauri": "tauri"
},
"dependencies": {
"@repo/lib": "workspace:*",
"@repo/ui": "workspace:*",
"@tanstack/react-query": "^5.90.14",
"@tauri-apps/api": "^2",
"lucide-react": "^0.525.0",
"next": "^16.0.11",
"next-themes": "^0.4.6",
"react": "^19.2.4",
"react-dom": "^19.2.4"
},
"devDependencies": {
"@tailwindcss/postcss": "^4.1.11",
"@tauri-apps/cli": "^2",
"@types/node": "^24.0.4",
"@types/react": "^19.2.9",
"@types/react-dom": "^19.2.3",
"tailwindcss": "^4.1.11",
"typescript": "^5.8.3"
}
}

View file

@ -0,0 +1,5 @@
const config = {
plugins: ["@tailwindcss/postcss"],
}
export default config