diff --git a/apps/desktop/app/(app)/layout.tsx b/apps/desktop/app/(app)/layout.tsx new file mode 100644 index 00000000..a4559e17 --- /dev/null +++ b/apps/desktop/app/(app)/layout.tsx @@ -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 ( + +
+ +
+ +
{children}
+
+
+
+ ) +} diff --git a/apps/desktop/app/(app)/page.tsx b/apps/desktop/app/(app)/page.tsx new file mode 100644 index 00000000..ef0ff82d --- /dev/null +++ b/apps/desktop/app/(app)/page.tsx @@ -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 ( +
+
+
+

Your memories

+

+ Everything you have saved, in one place. +

+
+ +
+ +
+ {MOCK_MEMORIES.map((memory) => ( + + + {memory.title} + {memory.desc} + + + ))} +
+ + +
+ ) +} diff --git a/apps/desktop/app/(app)/settings/page.tsx b/apps/desktop/app/(app)/settings/page.tsx new file mode 100644 index 00000000..2ee7013d --- /dev/null +++ b/apps/desktop/app/(app)/settings/page.tsx @@ -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(null) + + useEffect(() => { + invoke("app_info") + .then(setInfo) + .catch(() => setInfo(null)) + }, []) + + return ( +
+

Settings

+ + + + About + + Native runtime details, read from the Rust core over IPC. + + + + + + + + + +

+ Not signed in?{" "} + + Go to login + +

+
+ ) +} + +function Row({ label, value }: { label: string; value: string }) { + return ( +
+ {label} + {value} +
+ ) +} diff --git a/apps/desktop/app/globals.css b/apps/desktop/app/globals.css index d8b6f430..2903e893 100644 --- a/apps/desktop/app/globals.css +++ b/apps/desktop/app/globals.css @@ -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; } diff --git a/apps/desktop/app/layout.tsx b/apps/desktop/app/layout.tsx index 5752c0f8..a8c52ddb 100644 --- a/apps/desktop/app/layout.tsx +++ b/apps/desktop/app/layout.tsx @@ -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 ( - - {children} + + + {children} + ) } diff --git a/apps/desktop/app/login/page.tsx b/apps/desktop/app/login/page.tsx new file mode 100644 index 00000000..349baea1 --- /dev/null +++ b/apps/desktop/app/login/page.tsx @@ -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 ( +
+ {/* Keep the frameless window draggable from the top edge. */} +
+
+
+

Supermemory

+

+ Sign in to access your memories. +

+ +

+ Browser-based sign-in arrives in a later phase. +

+
+
+
+ ) +} diff --git a/apps/desktop/app/page.tsx b/apps/desktop/app/page.tsx deleted file mode 100644 index 1ddcb967..00000000 --- a/apps/desktop/app/page.tsx +++ /dev/null @@ -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(null) - const [error, setError] = useState(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("app_info") - .then(setInfo) - .catch((err) => setError(String(err))) - }, []) - - return ( -
-
-

Supermemory

-

Desktop · Phase 0

- - {info ? ( -
-
-
App
-
{info.name}
-
-
-
Version
-
{info.version}
-
-
-
Platform
-
{info.platform}
-
-
- ) : error ? ( -

Native bridge error: {error}

- ) : ( -

Connecting to the native core…

- )} -
-
- ) -} diff --git a/apps/desktop/app/providers.tsx b/apps/desktop/app/providers.tsx new file mode 100644 index 00000000..6ade08dc --- /dev/null +++ b/apps/desktop/app/providers.tsx @@ -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 ( + + + {children} + + + + ) +} diff --git a/apps/desktop/app/spotlight/page.tsx b/apps/desktop/app/spotlight/page.tsx new file mode 100644 index 00000000..b7eaead1 --- /dev/null +++ b/apps/desktop/app/spotlight/page.tsx @@ -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 ( +
+
+
+ + +
+
+
+ ) +} diff --git a/apps/desktop/components/app-nav.tsx b/apps/desktop/components/app-nav.tsx new file mode 100644 index 00000000..a1969d84 --- /dev/null +++ b/apps/desktop/components/app-nav.tsx @@ -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 ( + + ) +} diff --git a/apps/desktop/components/auth-guard.tsx b/apps/desktop/components/auth-guard.tsx new file mode 100644 index 00000000..a7d43269 --- /dev/null +++ b/apps/desktop/components/auth-guard.tsx @@ -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 ( +
+ Loading… +
+ ) + } + + return children +} diff --git a/apps/desktop/components/search-command.tsx b/apps/desktop/components/search-command.tsx new file mode 100644 index 00000000..8f9941ef --- /dev/null +++ b/apps/desktop/components/search-command.tsx @@ -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 ( + + + + No results found. + + {MOCK_RESULTS.map((result) => { + const Icon = ICONS[result.kind] + return ( + onOpenChange(false)} + > + + {result.title} + + ) + })} + + + + ) +} + +// 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 } +} diff --git a/apps/desktop/components/titlebar.tsx b/apps/desktop/components/titlebar.tsx new file mode 100644 index 00000000..4b493c28 --- /dev/null +++ b/apps/desktop/components/titlebar.tsx @@ -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 ( +
+ {title} +
+ ) +} diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 23dcba71..59dd4fbe 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -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" } } diff --git a/apps/desktop/postcss.config.mjs b/apps/desktop/postcss.config.mjs new file mode 100644 index 00000000..78452aad --- /dev/null +++ b/apps/desktop/postcss.config.mjs @@ -0,0 +1,5 @@ +const config = { + plugins: ["@tailwindcss/postcss"], +} + +export default config