- generated gradient avatars w/ tag-based category emoji on agent cards - pulsing online-status indicator - actionable 3-step 'getting started' empty state on Browse (publish -> run -> use)
121 lines
5.5 KiB
JavaScript
121 lines
5.5 KiB
JavaScript
import React, { useCallback, useEffect, useState } from 'react';
|
|
import { Menu, X } from 'lucide-react';
|
|
import { useAuth, AuthPanel, AdminConsole } from './Auth.jsx';
|
|
import { AgentBrowse, BoothDashboard } from './AgentPlatform.jsx';
|
|
import Mansion from './Mansion.jsx';
|
|
import TavernGame from './TavernGame.jsx';
|
|
import { api } from './api.js';
|
|
|
|
const RELEASE = '0.27.0-vivid';
|
|
|
|
const NAV = [
|
|
{ id: 'home', label: 'Tavern' },
|
|
{ id: 'agents', label: 'Browse Agents' },
|
|
{ id: 'mansion', label: 'Mansion' },
|
|
{ id: 'booth', label: 'My Booth' },
|
|
];
|
|
const VALID_ROUTES = ['home', 'agents', 'mansion', 'booth', 'admin', 'signin'];
|
|
|
|
function useHashRoute() {
|
|
const normalize = () => {
|
|
const raw = window.location.hash.replace(/^#\/?/, '');
|
|
return VALID_ROUTES.includes(raw) ? raw : 'home';
|
|
};
|
|
const [route, setRouteState] = useState(normalize);
|
|
useEffect(() => {
|
|
const onHash = () => setRouteState(normalize());
|
|
window.addEventListener('hashchange', onHash);
|
|
window.addEventListener('popstate', onHash);
|
|
return () => { window.removeEventListener('hashchange', onHash); window.removeEventListener('popstate', onHash); };
|
|
}, []);
|
|
const setRoute = useCallback((next) => {
|
|
const nextHash = `/${next}`;
|
|
if (window.location.hash !== `#${nextHash}`) window.history.pushState(null, '', `${window.location.pathname}${window.location.search}#${nextHash}`);
|
|
setRouteState(next);
|
|
}, []);
|
|
return [route, setRoute];
|
|
}
|
|
|
|
function PixelTopbar({ route, setRoute, auth }) {
|
|
const [open, setOpen] = useState(false);
|
|
const go = (id) => { setRoute(id); setOpen(false); };
|
|
const loggedIn = !!auth?.user;
|
|
const isAdmin = auth?.user?.role === 'admin';
|
|
const nav = (
|
|
<>
|
|
{NAV.map((item) => (
|
|
<button key={item.id} className={`px-nav-btn${route === item.id ? ' active' : ''}`} onClick={() => go(item.id)} type="button">{item.label}</button>
|
|
))}
|
|
{loggedIn && <a className="px-nav-btn" href="https://docs.amerc.ai/">Docs</a>}
|
|
{isAdmin && <a className="px-nav-btn" href="https://pm.amerc.ai/">PM</a>}
|
|
{isAdmin && <button className={`px-nav-btn quartermaster${route === 'admin' ? ' active' : ''}`} onClick={() => go('admin')} type="button">Quartermaster</button>}
|
|
{loggedIn
|
|
? <button className="px-nav-btn px-nav-user" onClick={() => auth.logout()} type="button" title="Log out">{auth.user.handle} ⏻</button>
|
|
: <button className={`px-nav-btn${route === 'signin' ? ' active' : ''}`} onClick={() => go('signin')} type="button">Login</button>}
|
|
</>
|
|
);
|
|
return (
|
|
<header className="px-topbar">
|
|
<button className="px-logo" onClick={() => go('home')} type="button" aria-label="amerc home">
|
|
<span className="px-logo-gem" aria-hidden="true" /><strong>amerc</strong><small>agent mercenary tavern</small><em>v{RELEASE}</em>
|
|
</button>
|
|
<nav className="px-nav" aria-label="Primary">{nav}</nav>
|
|
<button className="px-menu" onClick={() => setOpen((v) => !v)} type="button" aria-label="Toggle menu">{open ? <X size={20} /> : <Menu size={20} />}</button>
|
|
{open && <div className="px-mobile">{nav}</div>}
|
|
</header>
|
|
);
|
|
}
|
|
|
|
function Home({ setRoute }) {
|
|
const [stats, setStats] = useState(null);
|
|
useEffect(() => { api('/agents/stats').then(setStats).catch(() => {}); }, []);
|
|
return (
|
|
<div className="gm-home">
|
|
<div className="gm-stage">
|
|
<TavernGame onHotspot={setRoute} />
|
|
</div>
|
|
<div className="gm-caption">
|
|
<h1>Hire agents like mercenaries.</h1>
|
|
<p>Walk into the tavern — talk to the <b>screen</b> to browse staff, or step to the floor to open <b>your booth</b>.</p>
|
|
{stats && (
|
|
<div className="hero-live">
|
|
<span><b>{stats.classes}</b> classes</span>
|
|
<span><b>{stats.online}</b> online</span>
|
|
<span><b>{stats.sessions}</b> sessions</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function Page({ children }) { return <div className="td-page">{children}</div>; }
|
|
|
|
function Scene({ route, setRoute, auth }) {
|
|
if (route === 'home') return <Home setRoute={setRoute} auth={auth} />;
|
|
if (route === 'agents') return <Page><AgentBrowse auth={auth} setRoute={setRoute} /></Page>;
|
|
if (route === 'mansion') return <Page><Mansion auth={auth} setRoute={setRoute} /></Page>;
|
|
if (route === 'booth') return <Page><BoothDashboard auth={auth} setRoute={setRoute} /></Page>;
|
|
if (route === 'signin') return <Page><div className="td-signin"><div className="px-board" style={{ position: 'static', transform: 'none', width: 'min(380px,92vw)', margin: '0 auto' }}>
|
|
<header className="px-board-head"><span className="px-board-tag">GATEHOUSE</span><h2>Open Booth</h2><p>One amerc account across tavern, docs, PM and git.</p></header>
|
|
<AuthPanel auth={auth} onDone={() => setRoute('booth')} />
|
|
</div></div></Page>;
|
|
if (route === 'admin') return <Page><div className="px-board" style={{ position: 'static', transform: 'none', width: 'min(720px,94vw)', margin: '0 auto' }}>
|
|
<header className="px-board-head"><span className="px-board-tag">BACKDOOR</span><h2>Quartermaster</h2><p>Tenant, key, session and risk controls.</p></header>
|
|
<AdminConsole auth={auth} />
|
|
</div></Page>;
|
|
return <Home setRoute={setRoute} />;
|
|
}
|
|
|
|
export default function Scene2DApp() {
|
|
const [route, setRoute] = useHashRoute();
|
|
const auth = useAuth();
|
|
return (
|
|
<main className="px-app td-app">
|
|
<h1 className="sr-only">amerc agent mercenary tavern</h1>
|
|
<PixelTopbar route={route} setRoute={setRoute} auth={auth} />
|
|
<Scene route={route} setRoute={setRoute} auth={auth} />
|
|
</main>
|
|
);
|
|
}
|