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:00 -08:00
parent ea0ffda8a9
commit 5672bc1c61
4 changed files with 59 additions and 38 deletions

View File

@ -32,6 +32,7 @@ export default function HomePage() {
return (
<div className="min-h-screen bg-[#0a0a0a]">
<Header
current="notes"
actions={
<>
<div className="hidden md:block w-64">

View File

@ -1,6 +1,5 @@
'use client';
import Link from 'next/link';
import { AppSwitcher } from './AppSwitcher';
import { SpaceSwitcher } from './SpaceSwitcher';
import { UserMenu } from './UserMenu';
@ -11,7 +10,9 @@ export interface BreadcrumbItem {
}
interface HeaderProps {
/** Breadcrumb trail after the switchers (e.g. [{label: 'Notebooks', href: '/notebooks'}, {label: 'My Notebook'}]) */
/** 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;
@ -19,13 +20,13 @@ interface HeaderProps {
maxWidth?: string;
}
export function Header({ breadcrumbs, actions, maxWidth = 'max-w-6xl' }: HeaderProps) {
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="notes" />
<AppSwitcher current={current} />
<SpaceSwitcher />
{breadcrumbs && breadcrumbs.length > 0 && (
<>
@ -33,12 +34,12 @@ export function Header({ breadcrumbs, actions, maxWidth = 'max-w-6xl' }: HeaderP
<div key={i} className="flex items-center gap-1 min-w-0">
<span className="text-slate-600 hidden sm:inline">/</span>
{crumb.href ? (
<Link
<a
href={crumb.href}
className="text-slate-400 hover:text-white transition-colors text-sm hidden sm:inline truncate max-w-[140px]"
>
{crumb.label}
</Link>
</a>
) : (
<span className="text-white text-sm truncate max-w-[140px] md:max-w-[200px]">{crumb.label}</span>
)}

View File

@ -1,7 +1,6 @@
'use client';
import { useState, useRef, useEffect } from 'react';
import { useEncryptID } from '@encryptid/sdk/ui/react';
interface SpaceInfo {
slug: string;
@ -10,17 +9,12 @@ interface SpaceInfo {
role?: string;
}
interface SpaceSwitcherProps {
current?: string;
name?: string;
}
export function SpaceSwitcher({ current = 'personal', name }: SpaceSwitcherProps) {
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);
const { isAuthenticated } = useEncryptID();
useEffect(() => {
function handleClick(e: MouseEvent) {
@ -32,6 +26,16 @@ export function SpaceSwitcher({ current = 'personal', name }: SpaceSwitcherProps
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 {
@ -41,7 +45,7 @@ export function SpaceSwitcher({ current = 'personal', name }: SpaceSwitcherProps
setSpaces(data.spaces || []);
}
} catch {
// API not available — show empty
// API not available
}
setLoaded(true);
};
@ -54,8 +58,6 @@ export function SpaceSwitcher({ current = 'personal', name }: SpaceSwitcherProps
}
};
const displayName = name || current;
const mySpaces = spaces.filter((s) => s.role);
const publicSpaces = spaces.filter((s) => !s.role);
@ -66,7 +68,7 @@ export function SpaceSwitcher({ current = 'personal', name }: SpaceSwitcherProps
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">{displayName}</span>
<span className="max-w-[160px] truncate">personal</span>
<span className="text-[0.7em] opacity-50">&#9662;</span>
</button>
@ -97,10 +99,8 @@ export function SpaceSwitcher({ current = 'personal', name }: SpaceSwitcherProps
{mySpaces.map((s) => (
<a
key={s.slug}
href={`https://rspace.online/${s.slug}/notes`}
className={`flex items-center gap-2.5 px-3.5 py-2.5 text-slate-200 no-underline transition-colors ${
s.slug === current ? 'bg-cyan-500/10' : 'hover:bg-white/[0.05]'
}`}
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>
@ -124,10 +124,8 @@ export function SpaceSwitcher({ current = 'personal', name }: SpaceSwitcherProps
{publicSpaces.map((s) => (
<a
key={s.slug}
href={`https://rspace.online/${s.slug}/notes`}
className={`flex items-center gap-2.5 px-3.5 py-2.5 text-slate-200 no-underline transition-colors ${
s.slug === current ? 'bg-cyan-500/10' : 'hover:bg-white/[0.05]'
}`}
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>

View File

@ -1,10 +1,27 @@
'use client';
import { useEncryptID } from '@encryptid/sdk/ui/react';
import Link from 'next/link';
import { useState, useEffect } from 'react';
interface UserInfo {
username?: string;
did?: string;
}
export function UserMenu() {
const { isAuthenticated, username, did, loading, logout } = useEncryptID();
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 (
@ -12,29 +29,33 @@ export function UserMenu() {
);
}
if (!isAuthenticated) {
if (!user) {
return (
<Link
href="/auth/signin"
className="px-3 py-1.5 text-sm bg-amber-500 hover:bg-amber-400 text-black font-medium rounded-lg transition-colors"
<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
</Link>
</a>
);
}
const displayName = username || (did ? `${did.slice(0, 12)}...` : 'User');
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-amber-400 to-orange-500 flex items-center justify-center text-xs font-bold text-black">
{(username || 'U')[0].toUpperCase()}
<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={logout}
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