amerc/src/Scene2D.jsx
artheru 0076e57448 social share meta + SEO (0.34.0)
- 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)
2026-06-10 05:33:44 +08:00

217 lines
11 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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