amerc/src/Scene2D.jsx
artheru c6127747ed vividness pass: agent avatars + online pulse + getting-started empty state (0.27.0)
- 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)
2026-06-10 04:58:49 +08:00

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>
);
}