import { useEffect, useState, type ReactNode } from 'react';
import {
Activity,
Ban,
Blocks,
CalendarClock,
ChevronRight,
ClipboardList,
Coins,
Command,
Languages,
Menu,
PanelsTopLeft,
Workflow,
RotateCcw,
ScrollText,
Settings,
ShieldCheck,
Users,
X,
} from 'lucide-react';
import { useI18n } from '../lib/i18n';
import { canAccessRoute } from '../lib/permissions';
import { useServer } from '../context/server-context';
import { Badge, Button } from './ui';
export type RouteId = 'overview' | 'players' | 'economy' | 'triggers' | 'menus' | 'policies' | 'audit' | 'console' | 'settings' | 'moderation' | 'maintenance' | 'claims' | 'rollback';
const routeSet = new Set<RouteId>(['overview', 'players', 'economy', 'triggers', 'menus', 'policies', 'audit', 'console', 'settings', 'moderation', 'maintenance', 'claims', 'rollback']);
export function currentRoute(): RouteId {
const value = typeof location === 'undefined' ? '' : location.hash.replace(/^#\/?/, '').split('/')[0];
if (value === 'messages') return 'triggers'; // Preserve bookmarks from the retired message-settings page.
return routeSet.has(value as RouteId) ? value as RouteId : 'overview';
}
export function useRoute(): [RouteId, (route: RouteId) => void] {
const [route, setRoute] = useState<RouteId>(currentRoute);
useEffect(() => {
const change = () => setRoute(currentRoute());
window.addEventListener('hashchange', change);
return () => window.removeEventListener('hashchange', change);
}, []);
return [route, (next) => {
if (next === currentRoute()) setRoute(next);
else location.hash = `/${next}`;
}];
}
const coreNavigation = [
{ id: 'overview', label: 'nav.overview', icon: Activity },
{ id: 'players', label: 'nav.players', icon: Users },
{ id: 'economy', label: 'nav.economy', icon: Coins },
{ id: 'triggers', label: 'nav.messages', icon: Workflow },
{ id: 'menus', label: 'nav.menus', icon: PanelsTopLeft },
{ id: 'policies', label: 'nav.policies', icon: ShieldCheck },
{ id: 'audit', label: 'nav.audit', icon: ScrollText },
{ id: 'console', label: 'nav.console', icon: Command },
{ id: 'settings', label: 'nav.settings', icon: Settings },
] as const;
const governanceNavigation = [
{ id: 'moderation', label: 'nav.moderation', icon: Ban },
{ id: 'maintenance', label: 'nav.maintenance', icon: CalendarClock },
{ id: 'claims', label: 'nav.claims', icon: Blocks },
{ id: 'rollback', label: 'nav.rollback', icon: RotateCcw },
] as const;
function ConnectionPill() {
const { connection, lastEventAt } = useServer();
const { t, locale } = useI18n();
const labels = {
online: t('connection.online'),
connecting: t('connection.connecting'),
offline: t('connection.offline'),
degraded: t('connection.degraded'),
};
return (
<div className={`connection-pill connection-pill--${connection}`} title={lastEventAt?.toLocaleString(locale)}>
<span className="connection-pill__dot" />
<span>{labels[connection]}</span>
</div>
);
}
function Navigation({ route, navigate, onNavigate }: {
route: RouteId;
navigate: (route: RouteId) => void;
onNavigate?: () => void;
}) {
const { t } = useI18n();
const { session } = useServer();
const section = (label: 'nav.core' | 'nav.governance', entries: typeof coreNavigation | typeof governanceNavigation) => {
const permitted = entries.filter((entry) => canAccessRoute(session?.actor, entry.id));
if (permitted.length === 0) return null;
return (
<div className="nav-section">
<span className="nav-section__label">{t(label)}</span>
<nav aria-label={t(label)}>
{permitted.map((entry) => {
const Icon = entry.icon;
const active = route === entry.id;
return (
<button
className={`nav-link ${active ? 'nav-link--active' : ''}`}
key={entry.id}
onClick={() => { navigate(entry.id); onNavigate?.(); }}
aria-current={active ? 'page' : undefined}
>
<Icon size={18} />
<span>{t(entry.label)}</span>
{active && <ChevronRight className="nav-link__arrow" size={15} />}
</button>
);
})}
</nav>
</div>
);
};
return <>{section('nav.core', coreNavigation)}{section('nav.governance', governanceNavigation)}</>;
}
export function AppShell({ route, navigate, children }: {
route: RouteId;
navigate: (route: RouteId) => void;
children: ReactNode;
}) {
const [mobileOpen, setMobileOpen] = useState(false);
const { locale, setLocale, t } = useI18n();
const { session, connection, lastError, refresh, logout, operations } = useServer();
return (
<div className="app-shell">
<aside className={`sidebar ${mobileOpen ? 'sidebar--open' : ''}`}>
<div className="brand">
<div className="brand__mark" aria-hidden="true"><span /><span /><span /></div>
<div><strong>{t('app.name')}</strong><small>{t('app.subtitle')}</small></div>
<button className="sidebar__close" onClick={() => setMobileOpen(false)} aria-label={t('common.close')}><X /></button>
</div>
<ConnectionPill />
<div className="sidebar__navigation"><Navigation route={route} navigate={navigate} onNavigate={() => setMobileOpen(false)} /></div>
<div className="sidebar__footer">
{session?.actor && (
<div className="actor">
<div className="actor__avatar">{session.actor.displayName.slice(0, 2).toUpperCase()}</div>
<div><strong>{session.actor.displayName}</strong><small>{session.actor.roles.join(' · ')}</small></div>
</div>
)}
<button className="language-button" onClick={() => setLocale(locale === 'zh-CN' ? 'en-US' : 'zh-CN')}>
<Languages size={16} /> {locale === 'zh-CN' ? 'English' : '简体中文'}
</button>
</div>
</aside>
{mobileOpen && <button className="sidebar-backdrop" aria-label={t('common.close')} onClick={() => setMobileOpen(false)} />}
<div className="workspace">
<header className="topbar">
<button className="menu-button" onClick={() => setMobileOpen(true)} aria-label="Menu"><Menu /></button>
<div className="topbar__server">
<span className="topbar__pulse" />
<span>{connection === 'online' ? t('connection.online') : t(`connection.${connection}`)}</span>
</div>
<div className="topbar__actions">
{operations.some((item) => item.state === 'QUEUED' || item.state === 'RUNNING') && (
<Badge tone="info"><ClipboardList size={13} />{operations.filter((item) => item.state === 'QUEUED' || item.state === 'RUNNING').length}</Badge>
)}
<button className="language-button language-button--top" onClick={() => setLocale(locale === 'zh-CN' ? 'en-US' : 'zh-CN')}>
<Languages size={16} /><span>{locale === 'zh-CN' ? 'EN' : '中文'}</span>
</button>
{session?.authenticated && <Button variant="ghost" onClick={() => void logout()}>{t('common.signOut')}</Button>}
</div>
</header>
{connection === 'offline' && (
<div className="offline-banner" role="alert">
<div><strong>{t('connection.offline')}</strong><span>{lastError ?? t('connection.offlineDetail')}</span></div>
<Button variant="secondary" onClick={() => void refresh()}>{t('connection.retry')}</Button>
</div>
)}
<main className="main-content">{children}</main>
</div>
</div>
);
}
import { useEffect, useState, type ReactNode } from 'react';
import {
Activity,
Ban,
Blocks,
CalendarClock,
ChevronRight,
ClipboardList,
Coins,
Command,
Languages,
Menu,
PanelsTopLeft,
Workflow,
RotateCcw,
ScrollText,
Settings,
ShieldCheck,
Users,
X,
} from 'lucide-react';
import { useI18n } from '../lib/i18n';
import { canAccessRoute } from '../lib/permissions';
import { useServer } from '../context/server-context';
import { Badge, Button } from './ui';
export type RouteId = 'overview' | 'players' | 'economy' | 'triggers' | 'menus' | 'policies' | 'audit' | 'console' | 'settings' | 'moderation' | 'maintenance' | 'claims' | 'rollback';
const routeSet = new Set<RouteId>(['overview', 'players', 'economy', 'triggers', 'menus', 'policies', 'audit', 'console', 'settings', 'moderation', 'maintenance', 'claims', 'rollback']);
export function currentRoute(): RouteId {
const value = typeof location === 'undefined' ? '' : location.hash.replace(/^#\/?/, '').split('/')[0];
if (value === 'messages') return 'triggers'; // Preserve bookmarks from the retired message-settings page.
return routeSet.has(value as RouteId) ? value as RouteId : 'overview';
}
export function useRoute(): [RouteId, (route: RouteId) => void] {
const [route, setRoute] = useState<RouteId>(currentRoute);
useEffect(() => {
const change = () => setRoute(currentRoute());
window.addEventListener('hashchange', change);
return () => window.removeEventListener('hashchange', change);
}, []);
return [route, (next) => {
if (next === currentRoute()) setRoute(next);
else location.hash = `/${next}`;
}];
}
const coreNavigation = [
{ id: 'overview', label: 'nav.overview', icon: Activity },
{ id: 'players', label: 'nav.players', icon: Users },
{ id: 'economy', label: 'nav.economy', icon: Coins },
{ id: 'triggers', label: 'nav.messages', icon: Workflow },
{ id: 'menus', label: 'nav.menus', icon: PanelsTopLeft },
{ id: 'policies', label: 'nav.policies', icon: ShieldCheck },
{ id: 'audit', label: 'nav.audit', icon: ScrollText },
{ id: 'console', label: 'nav.console', icon: Command },
{ id: 'settings', label: 'nav.settings', icon: Settings },
] as const;
const governanceNavigation = [
{ id: 'moderation', label: 'nav.moderation', icon: Ban },
{ id: 'maintenance', label: 'nav.maintenance', icon: CalendarClock },
{ id: 'claims', label: 'nav.claims', icon: Blocks },
{ id: 'rollback', label: 'nav.rollback', icon: RotateCcw },
] as const;
function ConnectionPill() {
const { connection, lastEventAt } = useServer();
const { t, locale } = useI18n();
const labels = {
online: t('connection.online'),
connecting: t('connection.connecting'),
offline: t('connection.offline'),
degraded: t('connection.degraded'),
};
return (
<div className={`connection-pill connection-pill--${connection}`} title={lastEventAt?.toLocaleString(locale)}>
<span className="connection-pill__dot" />
<span>{labels[connection]}</span>
</div>
);
}
function Navigation({ route, navigate, onNavigate }: {
route: RouteId;
navigate: (route: RouteId) => void;
onNavigate?: () => void;
}) {
const { t } = useI18n();
const { session } = useServer();
const section = (label: 'nav.core' | 'nav.governance', entries: typeof coreNavigation | typeof governanceNavigation) => {
const permitted = entries.filter((entry) => canAccessRoute(session?.actor, entry.id));
if (permitted.length === 0) return null;
return (
<div className="nav-section">
<span className="nav-section__label">{t(label)}</span>
<nav aria-label={t(label)}>
{permitted.map((entry) => {
const Icon = entry.icon;
const active = route === entry.id;
return (
<button
className={`nav-link ${active ? 'nav-link--active' : ''}`}
key={entry.id}
onClick={() => { navigate(entry.id); onNavigate?.(); }}
aria-current={active ? 'page' : undefined}
>
<Icon size={18} />
<span>{t(entry.label)}</span>
{active && <ChevronRight className="nav-link__arrow" size={15} />}
</button>
);
})}
</nav>
</div>
);
};
return <>{section('nav.core', coreNavigation)}{section('nav.governance', governanceNavigation)}</>;
}
export function AppShell({ route, navigate, children }: {
route: RouteId;
navigate: (route: RouteId) => void;
children: ReactNode;
}) {
const [mobileOpen, setMobileOpen] = useState(false);
const { locale, setLocale, t } = useI18n();
const { session, connection, lastError, refresh, logout, operations } = useServer();
return (
<div className="app-shell">
<aside className={`sidebar ${mobileOpen ? 'sidebar--open' : ''}`}>
<div className="brand">
<div className="brand__mark" aria-hidden="true"><span /><span /><span /></div>
<div><strong>{t('app.name')}</strong><small>{t('app.subtitle')}</small></div>
<button className="sidebar__close" onClick={() => setMobileOpen(false)} aria-label={t('common.close')}><X /></button>
</div>
<ConnectionPill />
<div className="sidebar__navigation"><Navigation route={route} navigate={navigate} onNavigate={() => setMobileOpen(false)} /></div>
<div className="sidebar__footer">
{session?.actor && (
<div className="actor">
<div className="actor__avatar">{session.actor.displayName.slice(0, 2).toUpperCase()}</div>
<div><strong>{session.actor.displayName}</strong><small>{session.actor.roles.join(' · ')}</small></div>
</div>
)}
<button className="language-button" onClick={() => setLocale(locale === 'zh-CN' ? 'en-US' : 'zh-CN')}>
<Languages size={16} /> {locale === 'zh-CN' ? 'English' : '简体中文'}
</button>
</div>
</aside>
{mobileOpen && <button className="sidebar-backdrop" aria-label={t('common.close')} onClick={() => setMobileOpen(false)} />}
<div className="workspace">
<header className="topbar">
<button className="menu-button" onClick={() => setMobileOpen(true)} aria-label="Menu"><Menu /></button>
<div className="topbar__server">
<span className="topbar__pulse" />
<span>{connection === 'online' ? t('connection.online') : t(`connection.${connection}`)}</span>
</div>
<div className="topbar__actions">
{operations.some((item) => item.state === 'QUEUED' || item.state === 'RUNNING') && (
<Badge tone="info"><ClipboardList size={13} />{operations.filter((item) => item.state === 'QUEUED' || item.state === 'RUNNING').length}</Badge>
)}
<button className="language-button language-button--top" onClick={() => setLocale(locale === 'zh-CN' ? 'en-US' : 'zh-CN')}>
<Languages size={16} /><span>{locale === 'zh-CN' ? 'EN' : '中文'}</span>
</button>
{session?.authenticated && <Button variant="ghost" onClick={() => void logout()}>{t('common.signOut')}</Button>}
</div>
</header>
{connection === 'offline' && (
<div className="offline-banner" role="alert">
<div><strong>{t('connection.offline')}</strong><span>{lastError ?? t('connection.offlineDetail')}</span></div>
<Button variant="secondary" onClick={() => void refresh()}>{t('connection.retry')}</Button>
</div>
)}
<main className="main-content">{children}</main>
</div>
</div>
);
}