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 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-02-24 23:03:05 -08:00
parent dc83da4909
commit 5018426380
4 changed files with 288 additions and 31 deletions

View File

@ -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 (
<div className="min-h-screen bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900">
{/* ── Header Nav ─────────────────────────────────────────── */}
<nav className="border-b border-slate-700/50 backdrop-blur-sm sticky top-0 z-50 bg-slate-900/90">
<div className="max-w-5xl mx-auto px-6 py-4 flex items-center justify-between">
<div className="flex items-center gap-3">
<AppSwitcher current="maps" />
<Link href="/" className="flex items-center gap-2">
<div className="w-8 h-8 bg-gradient-to-br from-emerald-400 to-emerald-600 rounded-lg flex items-center justify-center font-bold text-slate-900 text-sm">
rM
</div>
<span className="font-semibold text-lg">
<span className="text-rmaps-primary">r</span>Maps
</span>
</Link>
</div>
<div className="flex items-center gap-4">
<Link
href="/demo"
className="text-sm text-slate-300 hover:text-white transition-colors"
>
Demo
</Link>
<a
href="#get-started"
className="text-sm px-4 py-2 bg-emerald-600 hover:bg-emerald-500 rounded-lg transition-colors font-medium"
>
Create Map
</a>
<AuthButton />
</div>
</div>
</nav>
<Header
current="maps"
maxWidth="max-w-5xl"
actions={
<>
<Link href="/demo" className="text-sm text-slate-300 hover:text-white transition-colors">Demo</Link>
<a href="#get-started" className="text-sm px-4 py-2 bg-emerald-600 hover:bg-emerald-500 rounded-lg transition-colors font-medium">Create Map</a>
</>
}
/>
{/* ── Hero Section ─────────────────────────────────────────── */}
<section className="relative overflow-hidden">

60
src/components/Header.tsx Normal file
View File

@ -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 (
<nav className="border-b border-slate-800 backdrop-blur-sm bg-[#0a0a0a]/90 sticky top-0 z-50">
<div className={`${maxWidth} mx-auto px-4 md:px-6 py-3 flex items-center justify-between gap-2`}>
{/* Left: App switcher + Space switcher + Breadcrumbs */}
<div className="flex items-center gap-1 min-w-0">
<AppSwitcher current={current} />
<SpaceSwitcher />
{breadcrumbs && breadcrumbs.length > 0 && (
<>
{breadcrumbs.map((crumb, i) => (
<div key={i} className="flex items-center gap-1 min-w-0">
<span className="text-slate-600 hidden sm:inline">/</span>
{crumb.href ? (
<a
href={crumb.href}
className="text-slate-400 hover:text-white transition-colors text-sm hidden sm:inline truncate max-w-[140px]"
>
{crumb.label}
</a>
) : (
<span className="text-white text-sm truncate max-w-[140px] md:max-w-[200px]">{crumb.label}</span>
)}
</div>
))}
</>
)}
</div>
{/* Right: Actions + UserMenu */}
<div className="flex items-center gap-2 md:gap-3 flex-shrink-0">
{actions}
<UserMenu />
</div>
</div>
</nav>
);
}

View File

@ -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<SpaceInfo[]>([]);
const [loaded, setLoaded] = useState(false);
const [isAuthenticated, setIsAuthenticated] = useState(false);
const ref = useRef<HTMLDivElement>(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 (
<div className="relative" ref={ref}>
<button
onClick={(e) => { e.stopPropagation(); handleOpen(); }}
className="flex items-center gap-1 px-3 py-1.5 rounded-lg text-sm font-medium text-slate-400 hover:bg-white/[0.05] transition-colors"
>
<span className="opacity-40 font-light mr-0.5">/</span>
<span className="max-w-[160px] truncate">personal</span>
<span className="text-[0.7em] opacity-50">&#9662;</span>
</button>
{open && (
<div className="absolute top-full left-0 mt-1.5 min-w-[240px] max-h-[400px] overflow-y-auto rounded-xl bg-slate-800 border border-white/10 shadow-xl shadow-black/30 z-[200]">
{!loaded ? (
<div className="px-4 py-4 text-center text-sm text-slate-400">Loading spaces...</div>
) : spaces.length === 0 ? (
<>
<div className="px-4 py-4 text-center text-sm text-slate-400">
{isAuthenticated ? 'No spaces yet' : 'Sign in to see your spaces'}
</div>
<a
href="https://rspace.online/new"
className="flex items-center px-3.5 py-2.5 text-sm font-semibold text-cyan-400 hover:bg-cyan-500/[0.08] transition-colors no-underline"
onClick={() => setOpen(false)}
>
+ Create new space
</a>
</>
) : (
<>
{mySpaces.length > 0 && (
<>
<div className="px-3.5 pt-2.5 pb-1 text-[0.65rem] font-bold uppercase tracking-wider text-slate-500 select-none">
Your spaces
</div>
{mySpaces.map((s) => (
<a
key={s.slug}
href={`https://rspace.online/${s.slug}`}
className="flex items-center gap-2.5 px-3.5 py-2.5 text-slate-200 no-underline transition-colors hover:bg-white/[0.05]"
onClick={() => setOpen(false)}
>
<span className="text-base">{s.icon || '🌐'}</span>
<span className="text-sm font-medium flex-1">{s.name}</span>
{s.role && (
<span className="text-[0.6rem] font-bold uppercase bg-cyan-500/15 text-cyan-300 px-1.5 py-0.5 rounded tracking-wide">
{s.role}
</span>
)}
</a>
))}
</>
)}
{publicSpaces.length > 0 && (
<>
{mySpaces.length > 0 && <div className="h-px bg-white/[0.08] my-1" />}
<div className="px-3.5 pt-2.5 pb-1 text-[0.65rem] font-bold uppercase tracking-wider text-slate-500 select-none">
Public spaces
</div>
{publicSpaces.map((s) => (
<a
key={s.slug}
href={`https://rspace.online/${s.slug}`}
className="flex items-center gap-2.5 px-3.5 py-2.5 text-slate-200 no-underline transition-colors hover:bg-white/[0.05]"
onClick={() => setOpen(false)}
>
<span className="text-base">{s.icon || '🌐'}</span>
<span className="text-sm font-medium flex-1">{s.name}</span>
</a>
))}
</>
)}
<div className="h-px bg-white/[0.08] my-1" />
<a
href="https://rspace.online/new"
className="flex items-center px-3.5 py-2.5 text-sm font-semibold text-cyan-400 hover:bg-cyan-500/[0.08] transition-colors no-underline"
onClick={() => setOpen(false)}
>
+ Create new space
</a>
</>
)}
</div>
)}
</div>
);
}

View File

@ -0,0 +1,65 @@
'use client';
import { useState, useEffect } from 'react';
interface UserInfo {
username?: string;
did?: string;
}
export function UserMenu() {
const [user, setUser] = useState<UserInfo | null>(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 (
<div className="w-6 h-6 rounded-full bg-slate-700 animate-pulse" />
);
}
if (!user) {
return (
<a
href="https://auth.ridentity.online"
className="px-3 py-1.5 text-sm bg-cyan-500 hover:bg-cyan-400 text-black font-medium rounded-lg transition-colors no-underline"
>
Sign In
</a>
);
}
const displayName = user.username || (user.did ? `${user.did.slice(0, 12)}...` : 'User');
return (
<div className="flex items-center gap-3">
<div className="flex items-center gap-2">
<div className="w-7 h-7 rounded-full bg-gradient-to-br from-cyan-400 to-violet-500 flex items-center justify-center text-xs font-bold text-white">
{(user.username || 'U')[0].toUpperCase()}
</div>
<span className="text-sm text-slate-300 hidden sm:inline">{displayName}</span>
</div>
<button
onClick={() => {
fetch('/api/auth/logout', { method: 'POST' })
.catch(() => {})
.finally(() => window.location.reload());
}}
className="px-2 py-1 text-xs text-slate-500 hover:text-slate-300 border border-slate-700 hover:border-slate-600 rounded transition-colors"
>
Sign Out
</button>
</div>
);
}