- OpenGraph + Twitter card tags with the concept-art tavern as the preview image - sharper title/description, theme-color for mobile browser chrome - removed unused Press Start 2P font request (perf) - (verified mobile nav/hamburger + layout already responsive — no fix needed)
217 lines
11 KiB
JavaScript
217 lines
11 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.34.0-share';
|
||
|
||
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-hero">
|
||
<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 className="gm-scrollcue" onClick={() => document.querySelector('.hf')?.scrollIntoView({ behavior: 'smooth' })}>how it works ↓</div>
|
||
</div>
|
||
</div>
|
||
<HomeFeatures setRoute={setRoute} />
|
||
<Footer setRoute={setRoute} />
|
||
</div>
|
||
);
|
||
}
|
||
|
||
const HF_STEPS = [
|
||
{ icon: '🍺', title: 'Browse & hire', body: 'Walk the tavern floor and pick a mercenary by skill and tag. See runtime, online status, and what each agent can do.', cta: ['Browse Agents', 'agents'] },
|
||
{ icon: '🗡️', title: 'Publish & run', body: 'Register your own agent class in My Booth and run instances — your broker brings the runtime. No infrastructure to manage.', cta: ['Open My Booth', 'booth'] },
|
||
{ icon: '🔌', title: 'Deliver anywhere', body: 'Serve users as an embedded chatbox, drive a live web terminal, or expose a local port to the internet via Showcase.', cta: ['The Mansion', 'mansion'] },
|
||
];
|
||
const HF_USES = [
|
||
{ icon: '💬', title: 'Chatbox', body: 'Drop one <script> tag on any site; the agent appears as a chat widget with your page skills injected.' },
|
||
{ icon: '🖥️', title: 'Web terminal', body: 'Open a relayed PTY to the agent’s terminal — watch and drive it right from the browser.' },
|
||
{ icon: '🌐', title: 'Reverse proxy', body: 'Map a local port to your own subdomain — your service goes live on the internet through your broker.' },
|
||
];
|
||
function relTime(at) { const s = Math.floor((Date.now() - at) / 1000); if (s < 60) return `${s}s ago`; const m = Math.floor(s / 60); if (m < 60) return `${m}m ago`; const h = Math.floor(m / 60); if (h < 24) return `${h}h ago`; return `${Math.floor(h / 24)}d ago`; }
|
||
const ACT_ICON = { class: '🗡️', instance: '⚙️', session: '💬', showcase: '🌐' };
|
||
function ActivityFeed() {
|
||
const [items, setItems] = useState([]);
|
||
useEffect(() => { const f = () => api('/agents/activity').then((d) => setItems(d.activity || [])).catch(() => {}); f(); const t = setInterval(f, 15000); return () => clearInterval(t); }, []);
|
||
if (!items.length) return null;
|
||
return (
|
||
<div className="hf-activity">
|
||
<h3 className="hf-sub">Live in the tavern</h3>
|
||
<ul className="act-list">
|
||
{items.map((a, i) => (
|
||
<li key={i}><span className="act-ico">{ACT_ICON[a.kind] || '•'}</span><span className="act-text">{a.text}</span><span className="act-time">{relTime(a.at)}</span></li>
|
||
))}
|
||
</ul>
|
||
</div>
|
||
);
|
||
}
|
||
function HomeFeatures({ setRoute }) {
|
||
return (
|
||
<section className="hf">
|
||
<h2 className="hf-title">How <span>amerc</span> works</h2>
|
||
<p className="hf-lead">An agent mercenary layer for embedding, bringing, and hosting AI agents across vertical software boundaries.</p>
|
||
<div className="hf-grid">
|
||
{HF_STEPS.map((s, i) => (
|
||
<div key={i} className="hf-card">
|
||
<span className="hf-ico">{s.icon}</span>
|
||
<h3>{s.title}</h3>
|
||
<p>{s.body}</p>
|
||
<button className="hf-link" onClick={() => setRoute(s.cta[1])}>{s.cta[0]} →</button>
|
||
</div>
|
||
))}
|
||
</div>
|
||
<h3 className="hf-sub">Three ways to use an agent</h3>
|
||
<div className="hf-uses">
|
||
{HF_USES.map((u, i) => (
|
||
<div key={i} className="hf-use"><span className="hf-ico-sm">{u.icon}</span><div><strong>{u.title}</strong><p>{u.body}</p></div></div>
|
||
))}
|
||
</div>
|
||
<ActivityFeed />
|
||
<div className="hf-cta">
|
||
<button className="px-action" onClick={() => setRoute('agents')}>Browse Agents</button>
|
||
<button className="px-action" onClick={() => setRoute('booth')}>Publish an Agent</button>
|
||
</div>
|
||
</section>
|
||
);
|
||
}
|
||
|
||
function Footer({ setRoute }) {
|
||
const [ok, setOk] = useState(null);
|
||
const [stats, setStats] = useState(null);
|
||
useEffect(() => {
|
||
api('/health').then(() => setOk(true)).catch(() => setOk(false));
|
||
api('/agents/stats').then(setStats).catch(() => {});
|
||
}, []);
|
||
return (
|
||
<footer className="ft">
|
||
<div className="ft-cols">
|
||
<div className="ft-brand">
|
||
<div className="ft-logo"><span className="px-logo-gem" aria-hidden="true" /><strong>amerc</strong></div>
|
||
<p>Hire, run, and deliver AI agents across software boundaries — no infrastructure, just skills.</p>
|
||
<span className={`ft-status${ok === false ? ' down' : ''}`}>
|
||
<i /> {ok === null ? 'checking…' : ok ? 'All systems operational' : 'Service degraded'}{stats ? ` · ${stats.online} online · ${stats.sessions} live` : ''}
|
||
</span>
|
||
</div>
|
||
<div className="ft-col"><h5>Product</h5>
|
||
<button onClick={() => setRoute('agents')}>Browse Agents</button>
|
||
<button onClick={() => setRoute('mansion')}>Mansion</button>
|
||
<button onClick={() => setRoute('booth')}>My Booth</button>
|
||
</div>
|
||
<div className="ft-col"><h5>Resources</h5>
|
||
<a href="https://docs.amerc.ai/">Docs</a>
|
||
<a href="https://docs.amerc.ai/">Roadmap</a>
|
||
</div>
|
||
<div className="ft-col"><h5>Platform</h5>
|
||
<a href="https://git.amerc.ai/">Git</a>
|
||
<a href="https://webagent.amerc.ai/health">Webagent</a>
|
||
</div>
|
||
</div>
|
||
<div className="ft-base">© 2026 amerc · agent mercenary tavern · v{RELEASE}</div>
|
||
</footer>
|
||
);
|
||
}
|
||
|
||
function Page({ children, setRoute }) { return <div className="td-page"><div className="td-page-inner">{children}</div><Footer setRoute={setRoute} /></div>; }
|
||
|
||
function Scene({ route, setRoute, auth }) {
|
||
if (route === 'home') return <Home setRoute={setRoute} auth={auth} />;
|
||
if (route === 'agents') return <Page setRoute={setRoute}><AgentBrowse auth={auth} setRoute={setRoute} /></Page>;
|
||
if (route === 'mansion') return <Page setRoute={setRoute}><Mansion auth={auth} setRoute={setRoute} /></Page>;
|
||
if (route === 'booth') return <Page setRoute={setRoute}><BoothDashboard auth={auth} setRoute={setRoute} /></Page>;
|
||
if (route === 'signin') return <Page setRoute={setRoute}><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 setRoute={setRoute}><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>
|
||
);
|
||
}
|