import { ReactNode, memo, useEffect, useMemo, useRef, useState } from 'react'; import { NavLink, useLocation } from 'react-router-dom'; import AmbientBackground from '../Ambient/AmbientBackground'; import SystemStatusBar from '../Visual/SystemStatusBar'; import { useWebSocket } from '../../hooks/useWebSocket'; import { useIsMobileLayout } from '../../hooks/useMediaQuery'; import MatrixRain from './MatrixRain'; import afLogo from '../../assets/af-logo.png'; import CursorFire from '../Visual/CursorFire'; import SacredGeometryLayer from '../Visual/sacredGeometry/SacredGeometryLayer'; import { SacredMotif } from '../Visual/sacredGeometry/motifs'; import SetupBanner from '../SetupBanner'; import { getSetupStatus } from '../../help/setupStatus'; import { resolvePageWeather } from '../../help/pageWeather'; import { mergeScoutBiomeWeather, type ScoutConstellationSnapshot } from '../../help/scoutBiomeWeather'; import { isDashboardRoute } from '../../help/routeEffects'; import { api } from '../../api/client'; import { usePresence } from '../../context/PresenceContext'; import { useVisualEffects } from '../../context/VisualEffectsContext'; import ComradeAvatar from '../Presence/ComradeAvatar'; import { formatAppVersion } from '../../help/appVersion'; import type { ServerConfig, ServerInfo } from '../../types'; import '../Presence/Presence.css'; import './Layout.css'; import './MobileNav.css'; import '../../styles/operatorDeck.css'; interface LayoutProps { children: ReactNode; } function operatorDeckId(pathname: string): string { const path = pathname.split('?')[0].replace(/\/$/, '') || '/'; if (path.startsWith('/mission-deck')) return 'mission-deck'; if (path.startsWith('/forge') || path.startsWith('/builder')) return 'forge'; if (path.startsWith('/crucible')) return 'crucible'; if (path.startsWith('/emberwake') || path.startsWith('/spread')) return 'emberwake'; if (path.startsWith('/builds')) return 'builds'; if (path.startsWith('/settings')) return 'settings'; if (path.startsWith('/pathtracer')) return 'pathtracer'; if (path.startsWith('/lotl-timeline') || path.startsWith('/onion')) return 'lotl-timeline'; if (path.startsWith('/roi')) return 'roi'; if (path.startsWith('/activity')) return 'activity'; if (path.startsWith('/seer')) return 'seer'; return 'dashboard'; } type NavItem = { readonly to: string; readonly label: string; readonly icon: string; readonly glow?: boolean }; const NAV_BASE: readonly NavItem[] = [ { to: '/dashboard', label: 'Command Deck', icon: 'deck' }, { to: '/crucible', label: 'Crucible', icon: 'crucible' }, { to: '/activity', label: 'Activity Feed', icon: 'activity' }, { to: '/roi', label: 'ROI Intelligence', icon: 'roi' }, { to: '/lotl-timeline', label: 'Onion', icon: 'onion' }, { to: '/pathtracer', label: 'Path Tracer', icon: 'trace' }, { to: '/forge', label: 'Forge', icon: 'forge' }, { to: '/mission-deck', label: 'Mission Deck', icon: 'mission', glow: true }, { to: '/builds', label: 'Builds', icon: 'builds' }, { to: '/emberwake', label: 'Emberwake', icon: 'ember' }, { to: '/settings', label: 'Calibrate', icon: 'gear' }, ]; const SEER_NAV: NavItem = { to: '/seer', label: 'Seer', icon: 'seer' }; function buildNav(aiControlEnabled: boolean): NavItem[] { if (!aiControlEnabled) { return [...NAV_BASE]; } const items: NavItem[] = [...NAV_BASE]; const calibrateIdx = items.findIndex((i) => i.to === '/settings'); items.splice(calibrateIdx, 0, SEER_NAV); return items; } const NAV = NAV_BASE; const DOCS_HREF = '/docs/'; /** Primary tabs on mobile bottom bar — Deck, Crucible, Activity, ROI, Onion */ const MOBILE_PRIMARY = NAV.slice(0, 5); /** Path Tracer, Forge, Mission Deck, Builds, Emberwake, Calibrate — "More" sheet */ const MOBILE_MORE = NAV.slice(5); function NavIcon({ type }: { type: string }) { switch (type) { case 'deck': return ( ); case 'roi': return ( ); case 'activity': return ( ); case 'fleet': return ( ); case 'forge': return ( ); case 'mission': return ( ); case 'builds': return ( ); case 'crucible': return ( ); case 'onion': return ( ); case 'ember': return ( ); case 'trace': return ( ); case 'seer': return ( ); case 'docs': return ( ); default: return ( ); } } function formatHashrate(hs: number): string { if (hs >= 1_000_000) return `${(hs / 1_000_000).toFixed(2)} MH/s`; if (hs >= 1_000) return `${(hs / 1_000).toFixed(1)} KH/s`; return `${hs.toFixed(0)} H/s`; } const FleetReadout = memo(function FleetReadout() { const { agents } = useWebSocket(); const { online, total, totalHashrate, fillPct } = useMemo(() => { const on = agents.filter((a) => a.status === 'online').length; const tot = agents.length; const hr = agents.reduce((sum, a) => sum + (a.hashrate_15s ?? a.hashrate_15m ?? 0), 0); return { online: on, total: tot, totalHashrate: hr, fillPct: tot > 0 ? Math.round((on / tot) * 100) : 0, }; }, [agents]); // Tick animation: flash hashrate value whenever it meaningfully changes const [flash, setFlash] = useState(false); const prevHash = useRef(0); useEffect(() => { if (Math.abs(totalHashrate - prevHash.current) > 1) { prevHash.current = totalHashrate; setFlash(true); const t = setTimeout(() => setFlash(false), 600); return () => clearTimeout(t); } }, [totalHashrate]); return (
0 ? 'readout-dot-online' : 'readout-dot-idle'}`} /> MACHINES {online}/{total}
HASHRATE {totalHashrate > 0 ? formatHashrate(totalHashrate) : IDLE}
0 ? 'readout-bar-active' : ''}`} style={{ width: `${fillPct}%` }} />
{fillPct}%
); }); const MobileTopStats = memo(function MobileTopStats() { const { agents } = useWebSocket(); const { online, total, hr } = useMemo(() => { const on = agents.filter((a) => a.status === 'online').length; return { online: on, total: agents.length, hr: agents.reduce((s, a) => s + (a.hashrate_15s ?? a.hashrate_15m ?? 0), 0), }; }, [agents]); return (
{online}/{total} online {hr > 0 ? formatHashrate(hr) : 'IDLE'}
); }); export default function Layout({ children }: LayoutProps) { const location = useLocation(); const isMobile = useIsMobileLayout(); const { othersOnline, comrades } = usePresence(); const { glowParticles } = useVisualEffects(); const [serverConfig, setServerConfig] = useState(null); const [serverInfo, setServerInfo] = useState(null); const [moreOpen, setMoreOpen] = useState(false); useEffect(() => { Promise.all([api.getConfig(), api.getServerInfo()]) .then(([cfg, info]) => { setServerConfig(cfg); setServerInfo(info); }) .catch(() => {}); }, []); useEffect(() => { setMoreOpen(false); }, [location.pathname]); useEffect(() => { if (!moreOpen) return; const prev = document.body.style.overflow; document.body.style.overflow = 'hidden'; return () => { document.body.style.overflow = prev; }; }, [moreOpen]); const setupStatus = getSetupStatus(serverConfig, serverInfo); const { latestMessage } = useWebSocket(); const scoutBiome = useMemo(() => { if (latestMessage?.type !== 'scout_constellations') return null; return latestMessage.payload as ScoutConstellationSnapshot; }, [latestMessage]); const pageWeather = useMemo(() => { const base = resolvePageWeather(location.pathname); const path = location.pathname.split('?')[0].replace(/\/$/, '') || '/'; if (path === '/emberwake' || path === '/spread' || path === '/dashboard' || path === '/agents') { return mergeScoutBiomeWeather(base, scoutBiome); } return base; }, [location.pathname, scoutBiome]); const showDeckEffects = isDashboardRoute(location.pathname); const moreActive = MOBILE_MORE.some((item) => location.pathname === item.to); const mobileShortLabel: Record = { '/dashboard': 'Deck', '/crucible': 'Ops', '/activity': 'Feed', '/roi': 'ROI', '/lotl-timeline': 'Onion', '/pathtracer': 'Tracer', '/forge': 'Forge', }; return (
{!isMobile && glowParticles && showDeckEffects && } {glowParticles && } {isMobile && (
AetherForge
)}
{children}
{isMobile && ( <> {moreOpen && (
setMoreOpen(false)} /> )}
{MOBILE_MORE.map((item) => ( `mobile-more-link${isActive ? ' active' : ''}`} onClick={() => setMoreOpen(false)} > {item.label} ))} setMoreOpen(false)} > Documentation
)}
); }