Navigation links now change based on authentication state: - Logged out: Home, Runs, Genlockes, Stats - Logged in: New Run, My Runs, Genlockes, Stats - Admin: adds Admin link Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
258 lines
8.2 KiB
TypeScript
258 lines
8.2 KiB
TypeScript
import { useState, useMemo } from 'react'
|
|
import { Link, Outlet, useLocation } from 'react-router-dom'
|
|
import { useTheme } from '../hooks/useTheme'
|
|
import { useAuth } from '../contexts/AuthContext'
|
|
|
|
function NavLink({
|
|
to,
|
|
active,
|
|
children,
|
|
onClick,
|
|
className = '',
|
|
}: {
|
|
to: string
|
|
active: boolean
|
|
children: React.ReactNode
|
|
onClick?: () => void
|
|
className?: string
|
|
}) {
|
|
return (
|
|
<Link
|
|
to={to}
|
|
onClick={onClick}
|
|
className={`${className} px-3 py-2 rounded-md text-sm font-medium transition-colors ${
|
|
active
|
|
? 'bg-accent-600/20 text-accent-300'
|
|
: 'text-text-secondary hover:text-text-primary hover:bg-surface-3'
|
|
}`}
|
|
>
|
|
{children}
|
|
</Link>
|
|
)
|
|
}
|
|
|
|
function ThemeToggle() {
|
|
const { theme, toggle } = useTheme()
|
|
|
|
return (
|
|
<button
|
|
type="button"
|
|
onClick={toggle}
|
|
className="p-2 rounded-md text-text-secondary hover:text-text-primary hover:bg-surface-3 transition-colors"
|
|
aria-label={`Switch to ${theme === 'dark' ? 'light' : 'dark'} mode`}
|
|
>
|
|
{theme === 'dark' ? (
|
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
strokeWidth={2}
|
|
d="M12 3v1m0 16v1m8.66-13.66l-.71.71M4.05 19.95l-.71.71M21 12h-1M4 12H3m16.66 7.66l-.71-.71M4.05 4.05l-.71-.71M16 12a4 4 0 11-8 0 4 4 0 018 0z"
|
|
/>
|
|
</svg>
|
|
) : (
|
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
strokeWidth={2}
|
|
d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z"
|
|
/>
|
|
</svg>
|
|
)}
|
|
</button>
|
|
)
|
|
}
|
|
|
|
function UserMenu({ onAction }: { onAction?: () => void }) {
|
|
const { user, loading, signOut } = useAuth()
|
|
const [open, setOpen] = useState(false)
|
|
|
|
if (loading) {
|
|
return <div className="w-8 h-8 rounded-full bg-surface-3 animate-pulse" />
|
|
}
|
|
|
|
if (!user) {
|
|
return (
|
|
<Link
|
|
to="/login"
|
|
onClick={onAction}
|
|
className="px-3 py-2 rounded-md text-sm font-medium text-text-secondary hover:text-text-primary hover:bg-surface-3 transition-colors"
|
|
>
|
|
Sign in
|
|
</Link>
|
|
)
|
|
}
|
|
|
|
const email = user.email ?? ''
|
|
const initials = email.charAt(0).toUpperCase()
|
|
|
|
return (
|
|
<div className="relative">
|
|
<button
|
|
type="button"
|
|
onClick={() => setOpen(!open)}
|
|
className="flex items-center gap-2 p-1 rounded-full hover:bg-surface-3 transition-colors"
|
|
>
|
|
<div className="w-8 h-8 rounded-full bg-accent-600 flex items-center justify-center text-white text-sm font-medium">
|
|
{initials}
|
|
</div>
|
|
</button>
|
|
{open && (
|
|
<>
|
|
<div className="fixed inset-0 z-40" onClick={() => setOpen(false)} />
|
|
<div className="absolute right-0 mt-2 w-48 bg-surface-2 border border-border-default rounded-lg shadow-lg z-50">
|
|
<div className="px-4 py-3 border-b border-border-default">
|
|
<p className="text-sm text-text-primary truncate">{email}</p>
|
|
</div>
|
|
<div className="py-1">
|
|
<button
|
|
type="button"
|
|
onClick={async () => {
|
|
setOpen(false)
|
|
onAction?.()
|
|
await signOut()
|
|
}}
|
|
className="w-full text-left px-4 py-2 text-sm text-text-secondary hover:text-text-primary hover:bg-surface-3 transition-colors"
|
|
>
|
|
Sign out
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export function Layout() {
|
|
const [menuOpen, setMenuOpen] = useState(false)
|
|
const location = useLocation()
|
|
const { user, isAdmin } = useAuth()
|
|
|
|
const navLinks = useMemo(() => {
|
|
if (!user) {
|
|
// Logged out: Home, Runs, Genlockes, Stats
|
|
return [
|
|
{ to: '/', label: 'Home' },
|
|
{ to: '/runs', label: 'Runs' },
|
|
{ to: '/genlockes', label: 'Genlockes' },
|
|
{ to: '/stats', label: 'Stats' },
|
|
]
|
|
}
|
|
// Logged in: New Run, My Runs, Genlockes, Stats
|
|
const links = [
|
|
{ to: '/runs/new', label: 'New Run' },
|
|
{ to: '/runs', label: 'My Runs' },
|
|
{ to: '/genlockes', label: 'Genlockes' },
|
|
{ to: '/stats', label: 'Stats' },
|
|
]
|
|
// Admin gets Admin link
|
|
if (isAdmin) {
|
|
links.push({ to: '/admin', label: 'Admin' })
|
|
}
|
|
return links
|
|
}, [user, isAdmin])
|
|
|
|
function isActive(to: string) {
|
|
if (to === '/' || to === '/runs/new') return location.pathname === to
|
|
return location.pathname.startsWith(to)
|
|
}
|
|
|
|
return (
|
|
<div className="min-h-screen flex flex-col bg-surface-0 text-text-primary">
|
|
<nav className="sticky top-0 z-40 bg-surface-1/80 backdrop-blur-lg border-b border-border-default">
|
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
|
<div className="flex justify-between h-14">
|
|
<div className="flex items-center gap-2">
|
|
<Link to="/" className="flex items-center gap-2 group">
|
|
<img
|
|
src="/favicon.svg"
|
|
alt=""
|
|
className="w-7 h-7 transition-transform group-hover:scale-110"
|
|
/>
|
|
<span className="text-lg font-bold tracking-tight text-text-primary">ANT</span>
|
|
</Link>
|
|
</div>
|
|
{/* Desktop nav */}
|
|
<div className="hidden sm:flex items-center gap-1">
|
|
{navLinks.map((link) => (
|
|
<NavLink key={link.to} to={link.to} active={isActive(link.to)}>
|
|
{link.label}
|
|
</NavLink>
|
|
))}
|
|
<ThemeToggle />
|
|
<UserMenu />
|
|
</div>
|
|
{/* Mobile hamburger */}
|
|
<div className="flex items-center gap-1 sm:hidden">
|
|
<ThemeToggle />
|
|
<button
|
|
type="button"
|
|
onClick={() => setMenuOpen(!menuOpen)}
|
|
className="p-2 rounded-md text-text-secondary hover:text-text-primary hover:bg-surface-3 transition-colors"
|
|
aria-label="Toggle menu"
|
|
>
|
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
{menuOpen ? (
|
|
<path
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
strokeWidth={2}
|
|
d="M6 18L18 6M6 6l12 12"
|
|
/>
|
|
) : (
|
|
<path
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
strokeWidth={2}
|
|
d="M4 6h16M4 12h16M4 18h16"
|
|
/>
|
|
)}
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{/* Mobile dropdown */}
|
|
{menuOpen && (
|
|
<div className="sm:hidden border-t border-border-default">
|
|
<div className="px-2 pt-2 pb-3 space-y-1">
|
|
{navLinks.map((link) => (
|
|
<NavLink
|
|
key={link.to}
|
|
to={link.to}
|
|
active={isActive(link.to)}
|
|
onClick={() => setMenuOpen(false)}
|
|
className="block"
|
|
>
|
|
{link.label}
|
|
</NavLink>
|
|
))}
|
|
<div className="pt-2 border-t border-border-default mt-2">
|
|
<UserMenu onAction={() => setMenuOpen(false)} />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</nav>
|
|
<main className="flex-1">
|
|
<Outlet />
|
|
</main>
|
|
<footer className="border-t border-border-default bg-surface-1/50">
|
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4 text-center text-xs text-text-tertiary">
|
|
Encounter data from{' '}
|
|
<a
|
|
href="https://pokedb.org"
|
|
className="underline hover:text-text-secondary transition-colors"
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
>
|
|
PokeDB.org
|
|
</a>
|
|
</div>
|
|
</footer>
|
|
</div>
|
|
)
|
|
}
|