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:
parent
ea0ffda8a9
commit
5672bc1c61
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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">▾</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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue