From 501842638073ec85d30dcfcf8a90d387789430df Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Tue, 24 Feb 2026 23:03:05 -0800 Subject: [PATCH] feat: standardize header with AppSwitcher, SpaceSwitcher, and UserMenu - Replace inline nav/Navbar with shared Header component - Header pattern: AppSwitcher dropdown / SpaceSwitcher / actions / Sign In - SpaceSwitcher and UserMenu work without SDK dependency - Consistent across all r*Apps Co-Authored-By: Claude Opus 4.6 --- src/app/page.tsx | 42 +++------ src/components/Header.tsx | 60 ++++++++++++ src/components/SpaceSwitcher.tsx | 152 +++++++++++++++++++++++++++++++ src/components/UserMenu.tsx | 65 +++++++++++++ 4 files changed, 288 insertions(+), 31 deletions(-) create mode 100644 src/components/Header.tsx create mode 100644 src/components/SpaceSwitcher.tsx create mode 100644 src/components/UserMenu.tsx diff --git a/src/app/page.tsx b/src/app/page.tsx index cded5b1..a1fe3f5 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -5,7 +5,7 @@ import { useRouter } from 'next/navigation'; import Link from 'next/link'; import { nanoid } from 'nanoid'; import { AuthButton } from '@/components/AuthButton'; -import { AppSwitcher } from '@/components/AppSwitcher'; +import { Header } from '@/components/Header'; import { EcosystemFooter } from '@/components/EcosystemFooter'; import { useAuthStore } from '@/stores/auth'; @@ -125,36 +125,16 @@ export default function HomePage() { return (
{/* ── Header Nav ─────────────────────────────────────────── */} - +
+ Demo + Create Map + + } + /> {/* ── Hero Section ─────────────────────────────────────────── */}
diff --git a/src/components/Header.tsx b/src/components/Header.tsx new file mode 100644 index 0000000..0d4599d --- /dev/null +++ b/src/components/Header.tsx @@ -0,0 +1,60 @@ +'use client'; + +import { AppSwitcher } from './AppSwitcher'; +import { SpaceSwitcher } from './SpaceSwitcher'; +import { UserMenu } from './UserMenu'; + +export interface BreadcrumbItem { + label: string; + href?: string; +} + +interface HeaderProps { + /** Which r*App is current (e.g. 'notes', 'vote', 'funds') */ + current?: string; + /** Breadcrumb trail after the switchers */ + breadcrumbs?: BreadcrumbItem[]; + /** Right-side actions (rendered between breadcrumbs and UserMenu) */ + actions?: React.ReactNode; + /** Max width class for the inner container */ + maxWidth?: string; +} + +export function Header({ current = 'notes', breadcrumbs, actions, maxWidth = 'max-w-6xl' }: HeaderProps) { + return ( + + ); +} diff --git a/src/components/SpaceSwitcher.tsx b/src/components/SpaceSwitcher.tsx new file mode 100644 index 0000000..33db21a --- /dev/null +++ b/src/components/SpaceSwitcher.tsx @@ -0,0 +1,152 @@ +'use client'; + +import { useState, useRef, useEffect } from 'react'; + +interface SpaceInfo { + slug: string; + name: string; + icon?: string; + role?: string; +} + +export function SpaceSwitcher() { + const [open, setOpen] = useState(false); + const [spaces, setSpaces] = useState([]); + const [loaded, setLoaded] = useState(false); + const [isAuthenticated, setIsAuthenticated] = useState(false); + const ref = useRef(null); + + useEffect(() => { + function handleClick(e: MouseEvent) { + if (ref.current && !ref.current.contains(e.target as Node)) { + setOpen(false); + } + } + document.addEventListener('click', handleClick); + return () => document.removeEventListener('click', handleClick); + }, []); + + // Check auth status on mount + useEffect(() => { + fetch('/api/me') + .then((r) => r.json()) + .then((data) => { + if (data.authenticated) setIsAuthenticated(true); + }) + .catch(() => {}); + }, []); + + const loadSpaces = async () => { + if (loaded) return; + try { + const res = await fetch('/api/spaces'); + if (res.ok) { + const data = await res.json(); + setSpaces(data.spaces || []); + } + } catch { + // API not available + } + setLoaded(true); + }; + + const handleOpen = async () => { + const nowOpen = !open; + setOpen(nowOpen); + if (nowOpen && !loaded) { + await loadSpaces(); + } + }; + + const mySpaces = spaces.filter((s) => s.role); + const publicSpaces = spaces.filter((s) => !s.role); + + return ( +
+ + + {open && ( +
+ {!loaded ? ( +
Loading spaces...
+ ) : spaces.length === 0 ? ( + <> +
+ {isAuthenticated ? 'No spaces yet' : 'Sign in to see your spaces'} +
+ setOpen(false)} + > + + Create new space + + + ) : ( + <> + {mySpaces.length > 0 && ( + <> +
+ Your spaces +
+ {mySpaces.map((s) => ( + setOpen(false)} + > + {s.icon || '🌐'} + {s.name} + {s.role && ( + + {s.role} + + )} + + ))} + + )} + + {publicSpaces.length > 0 && ( + <> + {mySpaces.length > 0 &&
} +
+ Public spaces +
+ {publicSpaces.map((s) => ( + setOpen(false)} + > + {s.icon || '🌐'} + {s.name} + + ))} + + )} + + + )} +
+ ); +} diff --git a/src/components/UserMenu.tsx b/src/components/UserMenu.tsx new file mode 100644 index 0000000..3a31e75 --- /dev/null +++ b/src/components/UserMenu.tsx @@ -0,0 +1,65 @@ +'use client'; + +import { useState, useEffect } from 'react'; + +interface UserInfo { + username?: string; + did?: string; +} + +export function UserMenu() { + const [user, setUser] = useState(null); + const [loading, setLoading] = useState(true); + + useEffect(() => { + fetch('/api/me') + .then((r) => r.json()) + .then((data) => { + if (data.authenticated && data.user) { + setUser(data.user); + } + }) + .catch(() => {}) + .finally(() => setLoading(false)); + }, []); + + if (loading) { + return ( +
+ ); + } + + if (!user) { + return ( + + Sign In + + ); + } + + const displayName = user.username || (user.did ? `${user.did.slice(0, 12)}...` : 'User'); + + return ( +
+
+
+ {(user.username || 'U')[0].toUpperCase()} +
+ {displayName} +
+ +
+ ); +}