chore : move all to root
This commit is contained in:
150
nest-front/src/components/layout/IntranetLayout.tsx
Normal file
150
nest-front/src/components/layout/IntranetLayout.tsx
Normal file
@@ -0,0 +1,150 @@
|
||||
import { NavLink, Outlet, useNavigate } from 'react-router-dom';
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
import { useCallback } from 'react';
|
||||
|
||||
const INTRANET_LINKS = [
|
||||
{ to: '/intranet', label: 'Dashboard', icon: '[>]', end: true },
|
||||
{ to: '/intranet/bugs', label: 'Bug Reports', icon: '[!]', end: false },
|
||||
{ to: '/intranet/feed', label: 'Team Feed', icon: '[~]', end: false },
|
||||
{ to: '/intranet/events', label: 'Events', icon: '[E]', end: false },
|
||||
{ to: '/intranet/users', label: 'Users', icon: '[U]', end: false },
|
||||
{ to: '/intranet/moderation', label: 'Moderation', icon: '[M]', end: false },
|
||||
];
|
||||
|
||||
export function IntranetLayout() {
|
||||
const { user, logout } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleLogout = useCallback(() => {
|
||||
logout();
|
||||
navigate('/');
|
||||
}, [logout, navigate]);
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', minHeight: '100vh', background: 'var(--color-bg)' }}>
|
||||
{/* Sidebar */}
|
||||
<aside
|
||||
style={{
|
||||
width: '220px',
|
||||
flexShrink: 0,
|
||||
background: 'var(--color-bg-alt)',
|
||||
borderRight: '2px solid var(--color-border)',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
padding: '1.5rem 0',
|
||||
}}
|
||||
>
|
||||
{/* Logo */}
|
||||
<div style={{ padding: '0 1.25rem 1.25rem', borderBottom: '1px solid var(--color-border)' }}>
|
||||
<div
|
||||
style={{
|
||||
fontFamily: 'var(--font-heading)',
|
||||
color: 'var(--color-yellow)',
|
||||
fontSize: '1.3rem',
|
||||
letterSpacing: '0.08em',
|
||||
}}
|
||||
>
|
||||
INTRANET
|
||||
</div>
|
||||
<div style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-text-muted)', fontSize: '0.62rem', marginTop: '2px' }}>
|
||||
CROWMATE STUDIO
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Nav links */}
|
||||
<nav style={{ flex: 1, padding: '0.75rem 0' }}>
|
||||
{INTRANET_LINKS.map(({ to, label, icon, end }) => (
|
||||
<NavLink
|
||||
key={to}
|
||||
to={to}
|
||||
end={end}
|
||||
style={({ isActive }) => ({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '0.6rem',
|
||||
padding: '0.55rem 1.25rem',
|
||||
fontFamily: 'var(--font-mono)',
|
||||
fontSize: '0.78rem',
|
||||
color: isActive ? 'var(--color-yellow)' : 'var(--color-text-muted)',
|
||||
background: isActive ? 'rgba(37,99,235,0.08)' : 'transparent',
|
||||
borderLeft: isActive ? '3px solid var(--color-yellow)' : '3px solid transparent',
|
||||
textDecoration: 'none',
|
||||
transition: 'color 0.1s, background 0.1s',
|
||||
letterSpacing: '0.05em',
|
||||
})}
|
||||
>
|
||||
<span style={{ opacity: 0.6, fontSize: '0.68rem' }}>{icon}</span>
|
||||
{label}
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
{/* User info */}
|
||||
<div
|
||||
style={{
|
||||
padding: '1rem 1.25rem',
|
||||
borderTop: '1px solid var(--color-border)',
|
||||
fontFamily: 'var(--font-mono)',
|
||||
}}
|
||||
>
|
||||
<div style={{ color: 'var(--color-text-dim)', fontSize: '0.75rem', marginBottom: '0.4rem' }}>
|
||||
{user?.username}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
display: 'inline-block',
|
||||
background: 'rgba(255,255,0,0.08)',
|
||||
border: '1px solid var(--color-yellow)',
|
||||
color: 'var(--color-yellow)',
|
||||
fontSize: '0.6rem',
|
||||
padding: '0.1rem 0.4rem',
|
||||
letterSpacing: '0.1em',
|
||||
textTransform: 'uppercase',
|
||||
marginBottom: '0.75rem',
|
||||
}}
|
||||
>
|
||||
{user?.role}
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '0.5rem' }}>
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
style={{
|
||||
background: 'transparent',
|
||||
border: '1px solid var(--color-red)',
|
||||
color: 'var(--color-red)',
|
||||
fontFamily: 'var(--font-mono)',
|
||||
fontSize: '0.63rem',
|
||||
padding: '0.2rem 0.5rem',
|
||||
cursor: 'pointer',
|
||||
letterSpacing: '0.05em',
|
||||
}}
|
||||
>
|
||||
Logout
|
||||
</button>
|
||||
<NavLink
|
||||
to="/"
|
||||
style={{
|
||||
background: 'transparent',
|
||||
border: '1px solid var(--color-border)',
|
||||
color: 'var(--color-text-muted)',
|
||||
fontFamily: 'var(--font-mono)',
|
||||
fontSize: '0.63rem',
|
||||
padding: '0.2rem 0.5rem',
|
||||
letterSpacing: '0.05em',
|
||||
textDecoration: 'none',
|
||||
display: 'inline-block',
|
||||
}}
|
||||
>
|
||||
Public
|
||||
</NavLink>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* Main content */}
|
||||
<main style={{ flex: 1, overflowY: 'auto', padding: '2rem', background: 'var(--color-bg)' }}>
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
31
nest-front/src/components/layout/PublicLayout.tsx
Normal file
31
nest-front/src/components/layout/PublicLayout.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import { Outlet, useLocation } from 'react-router-dom';
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { Navbar } from '../shared/Navbar';
|
||||
import { Footer } from '../shared/Footer';
|
||||
import { DevRoleSwitcher } from '../shared/DevRoleSwitcher';
|
||||
|
||||
export function PublicLayout() {
|
||||
const location = useLocation();
|
||||
const mainRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Scroll to top and add page-enter animation on route change
|
||||
useEffect(() => {
|
||||
window.scrollTo({ top: 0, behavior: 'instant' });
|
||||
const el = mainRef.current;
|
||||
if (!el) return;
|
||||
el.classList.remove('page-enter');
|
||||
void el.offsetWidth; // reflow to restart animation
|
||||
el.classList.add('page-enter');
|
||||
}, [location.pathname]);
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', minHeight: '100vh' }}>
|
||||
<Navbar />
|
||||
<main ref={mainRef} style={{ flex: 1 }}>
|
||||
<Outlet />
|
||||
</main>
|
||||
<Footer />
|
||||
<DevRoleSwitcher />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
118
nest-front/src/components/shared/DevRoleSwitcher.tsx
Normal file
118
nest-front/src/components/shared/DevRoleSwitcher.tsx
Normal file
@@ -0,0 +1,118 @@
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
import type { UserRole } from '../../types';
|
||||
|
||||
/**
|
||||
* Developer-only overlay to quickly switch user roles for testing.
|
||||
* Only visible in development mode.
|
||||
*/
|
||||
export function DevRoleSwitcher() {
|
||||
if (import.meta.env.PROD) return null;
|
||||
|
||||
return <DevRoleSwitcherInner />;
|
||||
}
|
||||
|
||||
function DevRoleSwitcherInner() {
|
||||
const { user, isAuthenticated, devSetRole, login, logout } = useAuth();
|
||||
|
||||
const ROLES: UserRole[] = ['user', 'dev', 'com'];
|
||||
const DEV_ACCOUNTS = [
|
||||
{ label: 'Dev/Admin (Kestrel)', email: 'kestrel@crowmate.dev' },
|
||||
{ label: 'Com Staff (Vesper)', email: 'vesper@crowmate.dev' },
|
||||
{ label: 'User (GlitchHunter)', email: 'glitch@mail.com' },
|
||||
];
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
bottom: '1rem',
|
||||
right: '1rem',
|
||||
background: 'var(--color-surface)',
|
||||
border: '2px solid var(--color-yellow)',
|
||||
padding: '0.75rem',
|
||||
zIndex: 9999,
|
||||
fontSize: '0.7rem',
|
||||
fontFamily: 'var(--font-mono)',
|
||||
color: 'var(--color-text)',
|
||||
maxWidth: '220px',
|
||||
borderRadius: '8px',
|
||||
boxShadow: '0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -2px rgba(0, 0, 0, 0.1)',
|
||||
}}
|
||||
>
|
||||
<div style={{ marginBottom: '0.5rem', fontWeight: 'bold', letterSpacing: '0.1em' }}>
|
||||
[DEV] Auth Switcher
|
||||
</div>
|
||||
|
||||
{isAuthenticated ? (
|
||||
<>
|
||||
<div style={{ color: 'var(--color-text-muted)', marginBottom: '0.4rem' }}>
|
||||
Logged as: <strong style={{ color: 'var(--color-text)' }}>{user?.username}</strong>
|
||||
</div>
|
||||
<div style={{ color: 'var(--color-text-muted)', marginBottom: '0.5rem' }}>
|
||||
Role: <strong style={{ color: 'var(--color-green)' }}>{user?.role}</strong>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: '0.3rem', marginBottom: '0.5rem', flexWrap: 'wrap' }}>
|
||||
{ROLES.map((r) => (
|
||||
<button
|
||||
key={r}
|
||||
onClick={() => devSetRole(r)}
|
||||
style={{
|
||||
background: user?.role === r ? 'var(--color-amber)' : 'transparent',
|
||||
border: '1px solid var(--color-amber)',
|
||||
color: user?.role === r ? '#000' : 'var(--color-amber)',
|
||||
padding: '0.1rem 0.4rem',
|
||||
cursor: 'pointer',
|
||||
fontFamily: 'var(--font-mono)',
|
||||
fontSize: '0.65rem',
|
||||
}}
|
||||
>
|
||||
{r}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={logout}
|
||||
style={{
|
||||
background: 'transparent',
|
||||
border: '1px solid var(--color-red)',
|
||||
color: 'var(--color-red)',
|
||||
padding: '0.2rem 0.5rem',
|
||||
cursor: 'pointer',
|
||||
fontFamily: 'var(--font-mono)',
|
||||
fontSize: '0.65rem',
|
||||
width: '100%',
|
||||
}}
|
||||
>
|
||||
Logout
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div style={{ color: 'var(--color-text-muted)', marginBottom: '0.5rem' }}>Quick login:</div>
|
||||
{DEV_ACCOUNTS.map(({ label, email }) => (
|
||||
<button
|
||||
key={email}
|
||||
onClick={() => login(email, 'password')}
|
||||
style={{
|
||||
background: 'transparent',
|
||||
border: '1px solid var(--color-border)',
|
||||
color: 'var(--color-text-dim)',
|
||||
padding: '0.2rem 0.4rem',
|
||||
cursor: 'pointer',
|
||||
fontFamily: 'var(--font-mono)',
|
||||
fontSize: '0.63rem',
|
||||
width: '100%',
|
||||
marginBottom: '0.2rem',
|
||||
textAlign: 'left',
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
103
nest-front/src/components/shared/Footer.tsx
Normal file
103
nest-front/src/components/shared/Footer.tsx
Normal file
@@ -0,0 +1,103 @@
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
export function Footer() {
|
||||
const year = new Date().getFullYear();
|
||||
|
||||
return (
|
||||
<footer
|
||||
style={{
|
||||
background: 'var(--color-surface-alt)',
|
||||
borderTop: '1px solid var(--color-border)',
|
||||
padding: '2.5rem 1.5rem 2rem',
|
||||
marginTop: 'auto',
|
||||
}}
|
||||
>
|
||||
<div className="max-w-7xl mx-auto">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-8 mb-8">
|
||||
{/* Brand */}
|
||||
<div>
|
||||
<div
|
||||
style={{
|
||||
fontFamily: 'var(--font-heading)',
|
||||
color: 'var(--color-green)',
|
||||
fontSize: '1.1rem',
|
||||
letterSpacing: '0.1em',
|
||||
marginBottom: '0.5rem',
|
||||
}}
|
||||
>
|
||||
CROWMATE STUDIO
|
||||
</div>
|
||||
<p style={{ color: 'var(--color-text-muted)', fontSize: '0.75rem', lineHeight: 1.7 }}>
|
||||
Building strange worlds for strange people.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Navigation */}
|
||||
<div>
|
||||
<div className="section-label" style={{ marginBottom: '0.75rem' }}>Navigate</div>
|
||||
<ul style={{ listStyle: 'none', margin: 0, padding: 0, display: 'flex', flexDirection: 'column', gap: '0.4rem' }}>
|
||||
{[
|
||||
{ to: '/', label: 'Home' },
|
||||
{ to: '/studio', label: 'Studio' },
|
||||
{ to: '/forum', label: 'Forum' },
|
||||
{ to: '/bugs', label: 'Bug Reports' },
|
||||
].map(({ to, label }) => (
|
||||
<li key={to}>
|
||||
<Link
|
||||
to={to}
|
||||
style={{ color: 'var(--color-text-muted)', fontSize: '0.8rem' }}
|
||||
>
|
||||
> {label}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Social */}
|
||||
<div>
|
||||
<div className="section-label" style={{ marginBottom: '0.75rem' }}>Connect</div>
|
||||
<ul style={{ listStyle: 'none', margin: 0, padding: 0, display: 'flex', flexDirection: 'column', gap: '0.4rem' }}>
|
||||
{[
|
||||
{ href: '#', label: 'Twitter / X' },
|
||||
{ href: '#', label: 'Discord' },
|
||||
{ href: '#', label: 'YouTube' },
|
||||
{ href: '#', label: 'Steam Page' },
|
||||
].map(({ href, label }) => (
|
||||
<li key={label}>
|
||||
<a
|
||||
href={href}
|
||||
style={{ color: 'var(--color-text-muted)', fontSize: '0.8rem' }}
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
> {label}
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bottom bar */}
|
||||
<div
|
||||
style={{
|
||||
borderTop: '1px solid var(--color-border)',
|
||||
paddingTop: '1.25rem',
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
gap: '0.5rem',
|
||||
}}
|
||||
>
|
||||
<span style={{ color: 'var(--color-text-muted)', fontSize: '0.7rem', fontFamily: 'var(--font-mono)' }}>
|
||||
© {year} CrowMate Studio. All rights reserved.
|
||||
</span>
|
||||
<span style={{ color: 'var(--color-text-muted)', fontSize: '0.7rem', fontFamily: 'var(--font-mono)', opacity: 0.5 }}>
|
||||
HEADLESS HAZARD v0.9.3-alpha
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
264
nest-front/src/components/shared/Navbar.tsx
Normal file
264
nest-front/src/components/shared/Navbar.tsx
Normal file
@@ -0,0 +1,264 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import { Link, NavLink, useNavigate } from 'react-router-dom';
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
|
||||
const NAV_LINKS = [
|
||||
{ to: '/', label: 'Home', end: true },
|
||||
{ to: '/studio', label: 'Studio', end: false },
|
||||
{ to: '/events', label: 'Events', end: false },
|
||||
{ to: '/forum', label: 'Forum', end: false },
|
||||
{ to: '/bugs', label: 'Bugs', end: false },
|
||||
];
|
||||
|
||||
export function Navbar() {
|
||||
const { user, isAuthenticated, isStaff, logout } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
|
||||
const handleLogout = useCallback(() => {
|
||||
logout();
|
||||
setMenuOpen(false);
|
||||
navigate('/');
|
||||
}, [logout, navigate]);
|
||||
|
||||
const closeMenu = useCallback(() => setMenuOpen(false), []);
|
||||
|
||||
const navLinkStyle = ({ isActive }: { isActive: boolean }): React.CSSProperties => ({
|
||||
fontFamily: 'var(--font-mono)',
|
||||
fontSize: '0.82rem',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.12em',
|
||||
textDecoration: 'none',
|
||||
color: isActive ? 'var(--color-yellow)' : 'var(--color-text-dim)',
|
||||
borderBottom: isActive ? '2px solid var(--color-yellow)' : '2px solid transparent',
|
||||
paddingBottom: '2px',
|
||||
transition: 'color 0.1s, border-color 0.1s',
|
||||
});
|
||||
|
||||
return (
|
||||
<header
|
||||
style={{
|
||||
background: 'var(--color-bg)',
|
||||
borderBottom: '2px solid var(--color-border)',
|
||||
position: 'sticky',
|
||||
top: 0,
|
||||
zIndex: 50,
|
||||
}}
|
||||
>
|
||||
<nav
|
||||
className="max-w-7xl mx-auto px-4 sm:px-6 h-14 flex items-center justify-between"
|
||||
role="navigation"
|
||||
aria-label="Main navigation"
|
||||
>
|
||||
{/* Logo */}
|
||||
<Link
|
||||
to="/"
|
||||
onClick={closeMenu}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'baseline',
|
||||
gap: '0.5rem',
|
||||
textDecoration: 'none',
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
fontFamily: 'var(--font-heading)',
|
||||
color: 'var(--color-yellow)',
|
||||
fontSize: '1.5rem',
|
||||
letterSpacing: '0.08em',
|
||||
}}
|
||||
>
|
||||
CROWMATE
|
||||
</span>
|
||||
<span
|
||||
style={{
|
||||
fontFamily: 'var(--font-mono)',
|
||||
color: 'var(--color-text-muted)',
|
||||
fontSize: '0.62rem',
|
||||
letterSpacing: '0.15em',
|
||||
}}
|
||||
>
|
||||
STUDIO
|
||||
</span>
|
||||
</Link>
|
||||
|
||||
{/* Desktop Nav */}
|
||||
<div className="hidden md:flex items-center gap-6">
|
||||
{NAV_LINKS.map(({ to, label, end }) => (
|
||||
<NavLink key={to} to={to} end={end} style={navLinkStyle}>
|
||||
{label}
|
||||
</NavLink>
|
||||
))}
|
||||
|
||||
{/* Intranet — visually separated, highlighted button */}
|
||||
{isStaff && (
|
||||
<>
|
||||
{/* Vertical divider */}
|
||||
<span
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
display: 'inline-block',
|
||||
width: '1px',
|
||||
height: '18px',
|
||||
background: 'var(--color-border)',
|
||||
margin: '0 0.25rem',
|
||||
}}
|
||||
/>
|
||||
<NavLink
|
||||
to="/intranet"
|
||||
style={({ isActive }) => ({
|
||||
fontFamily: 'var(--font-mono)',
|
||||
fontSize: '0.75rem',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.1em',
|
||||
textDecoration: 'none',
|
||||
background: isActive ? 'var(--color-yellow)' : 'var(--color-yellow)',
|
||||
color: 'var(--color-bg)',
|
||||
border: '2px solid var(--color-yellow)',
|
||||
padding: '0.2rem 0.65rem',
|
||||
fontWeight: 'bold',
|
||||
opacity: isActive ? 1 : 0.85,
|
||||
transition: 'opacity 0.1s',
|
||||
})}
|
||||
>
|
||||
▮ INTRANET
|
||||
</NavLink>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Desktop Auth */}
|
||||
<div className="hidden md:flex items-center gap-3">
|
||||
{isAuthenticated ? (
|
||||
<>
|
||||
<Link
|
||||
to="/account"
|
||||
style={{
|
||||
color: 'var(--color-text-dim)',
|
||||
fontSize: '0.78rem',
|
||||
fontFamily: 'var(--font-mono)',
|
||||
textDecoration: 'none',
|
||||
}}
|
||||
>
|
||||
[{user?.username}]
|
||||
</Link>
|
||||
<button className="btn-terminal btn-danger" onClick={handleLogout} style={{ padding: '0.3rem 0.85rem', fontSize: '0.75rem' }}>
|
||||
Logout
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Link to="/login" className="btn-terminal" style={{ padding: '0.3rem 0.85rem', fontSize: '0.75rem' }}>
|
||||
Login
|
||||
</Link>
|
||||
<Link to="/register" className="btn-terminal btn-amber" style={{ padding: '0.3rem 0.85rem', fontSize: '0.75rem' }}>
|
||||
Register
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Mobile hamburger */}
|
||||
<button
|
||||
className="md:hidden"
|
||||
style={{ background: 'transparent', border: 'none', cursor: 'pointer', padding: '4px' }}
|
||||
onClick={() => setMenuOpen((v) => !v)}
|
||||
aria-label={menuOpen ? 'Close menu' : 'Open menu'}
|
||||
aria-expanded={menuOpen}
|
||||
>
|
||||
{/* Three-bar icon using block chars */}
|
||||
<div style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-cyan)', fontSize: '1.2rem', lineHeight: 1 }}>
|
||||
{menuOpen ? '✕' : '≡'}
|
||||
</div>
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
{/* Mobile menu */}
|
||||
{menuOpen && (
|
||||
<div
|
||||
style={{
|
||||
background: 'var(--color-bg)',
|
||||
borderTop: '2px solid var(--color-border)',
|
||||
padding: '1rem 1.25rem',
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.85rem' }}>
|
||||
{NAV_LINKS.map(({ to, label, end }) => (
|
||||
<NavLink
|
||||
key={to}
|
||||
to={to}
|
||||
end={end}
|
||||
onClick={closeMenu}
|
||||
style={navLinkStyle}
|
||||
>
|
||||
{label}
|
||||
</NavLink>
|
||||
))}
|
||||
|
||||
{/* Auth section */}
|
||||
<div
|
||||
style={{
|
||||
borderTop: '1px solid var(--color-border)',
|
||||
paddingTop: '0.85rem',
|
||||
display: 'flex',
|
||||
gap: '0.6rem',
|
||||
flexWrap: 'wrap',
|
||||
}}
|
||||
>
|
||||
{isAuthenticated ? (
|
||||
<>
|
||||
<Link
|
||||
to="/account"
|
||||
style={{ color: 'var(--color-text-dim)', fontSize: '0.78rem', textDecoration: 'none', fontFamily: 'var(--font-mono)' }}
|
||||
onClick={closeMenu}
|
||||
>
|
||||
[{user?.username}]
|
||||
</Link>
|
||||
<button className="btn-terminal btn-danger" onClick={handleLogout} style={{ padding: '0.25rem 0.7rem', fontSize: '0.73rem' }}>
|
||||
Logout
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Link to="/login" className="btn-terminal" onClick={closeMenu} style={{ padding: '0.25rem 0.7rem', fontSize: '0.73rem' }}>
|
||||
Login
|
||||
</Link>
|
||||
<Link to="/register" className="btn-terminal btn-amber" onClick={closeMenu} style={{ padding: '0.25rem 0.7rem', fontSize: '0.73rem' }}>
|
||||
Register
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Intranet button — mobile: separated at the bottom */}
|
||||
{isStaff && (
|
||||
<div style={{ borderTop: '2px solid var(--color-yellow)', paddingTop: '0.85rem' }}>
|
||||
<NavLink
|
||||
to="/intranet"
|
||||
onClick={closeMenu}
|
||||
style={({ isActive }) => ({
|
||||
display: 'inline-block',
|
||||
fontFamily: 'var(--font-mono)',
|
||||
fontSize: '0.8rem',
|
||||
textTransform: 'uppercase' as const,
|
||||
letterSpacing: '0.1em',
|
||||
textDecoration: 'none',
|
||||
background: 'var(--color-yellow)',
|
||||
color: 'var(--color-bg)',
|
||||
border: '2px solid var(--color-yellow)',
|
||||
padding: '0.3rem 0.85rem',
|
||||
fontWeight: 'bold',
|
||||
opacity: isActive ? 0.85 : 1,
|
||||
})}
|
||||
>
|
||||
▮ INTRANET
|
||||
</NavLink>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</header>
|
||||
);
|
||||
}
|
||||
25
nest-front/src/components/shared/PageLoader.tsx
Normal file
25
nest-front/src/components/shared/PageLoader.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
export function PageLoader() {
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 flex items-center justify-center"
|
||||
style={{ background: 'var(--color-bg)' }}
|
||||
role="status"
|
||||
aria-label="Loading"
|
||||
>
|
||||
<div className="text-center">
|
||||
<div
|
||||
className="text-4xl font-bold mb-4 cursor-blink"
|
||||
style={{
|
||||
fontFamily: 'var(--font-mono)',
|
||||
color: 'var(--color-green)',
|
||||
}}
|
||||
>
|
||||
LOADING
|
||||
</div>
|
||||
<div style={{ color: 'var(--color-text-muted)', fontSize: '0.75rem', letterSpacing: '0.2em' }}>
|
||||
CROWMATE STUDIO / HEADLESS HAZARD
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
29
nest-front/src/components/shared/ProtectedRoute.tsx
Normal file
29
nest-front/src/components/shared/ProtectedRoute.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import { Navigate, useLocation } from 'react-router-dom';
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
|
||||
interface ProtectedRouteProps {
|
||||
children: React.ReactNode;
|
||||
/** If true, requires staff role (dev or com). */
|
||||
staffOnly?: boolean;
|
||||
/** Redirect destination when access is denied. Defaults to /login. */
|
||||
redirectTo?: string;
|
||||
}
|
||||
|
||||
export function ProtectedRoute({
|
||||
children,
|
||||
staffOnly = false,
|
||||
redirectTo = '/login',
|
||||
}: ProtectedRouteProps) {
|
||||
const { isAuthenticated, isStaff } = useAuth();
|
||||
const location = useLocation();
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return <Navigate to={redirectTo} state={{ from: location }} replace />;
|
||||
}
|
||||
|
||||
if (staffOnly && !isStaff) {
|
||||
return <Navigate to="/" replace />;
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
Reference in New Issue
Block a user