- Scene2D pixel tavern (replaces three.js 3D scene) - amerc-api: node:http+node:sqlite+node:crypto, auth/admin/docs/files/boards - docs.amerc.ai + pm.amerc.ai (whiteboard mindmap + netdisk) apps - agent API keys for fleet read/write Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
309 lines
12 KiB
JavaScript
309 lines
12 KiB
JavaScript
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
import { Menu, X } from 'lucide-react';
|
|
import { useAuth, AuthPanel, AdminConsole } from './Auth.jsx';
|
|
|
|
const RELEASE = '0.20.0-suite';
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Routing
|
|
// ---------------------------------------------------------------------------
|
|
const NAV = [
|
|
{ id: 'home', label: 'Solution' },
|
|
{ id: 'agents', label: 'Staff' },
|
|
{ id: 'contracts', label: 'Pricing' },
|
|
{ id: 'signin', label: 'Login' },
|
|
{ id: 'booth', label: 'My Booth' },
|
|
];
|
|
|
|
const VALID_ROUTES = ['home', 'agents', 'booth', 'contracts', '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];
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Route panel copy (carried over from the 3D scene so nothing regresses)
|
|
// ---------------------------------------------------------------------------
|
|
function panelData(route) {
|
|
const panels = {
|
|
home: {
|
|
title: 'Browse Agents',
|
|
subtitle: 'Walk the tavern floor and inspect the mercenary roster.',
|
|
rows: ['Vertical site embeds a command booth', 'Customer backend mints a scoped session', 'Agent acts through supplied skills in tinybox'],
|
|
stats: ['12,458 sessions', '7,326 deliveries', '+23.7% usage'],
|
|
actions: ['Inspect Roster', 'Publish Agent', 'Remote Use', 'Port Mapping', 'Run Directly'],
|
|
tint: '#27d8ff',
|
|
},
|
|
agents: {
|
|
title: 'Mercenary Staff',
|
|
subtitle: 'Specialists with scoped tools, runtime limits, and visible feedback.',
|
|
rows: ['Iron Tusk / site ops / 18 coin per hour', 'Velvet Hex / user consent / 14 coin per hour', 'Socket Wraith / tinybox bridge / 16 coin per hour'],
|
|
stats: ['6 active ranks', 'SLA visible', 'skills scoped'],
|
|
actions: ['Filter by Skill', 'Inspect Runtime', 'Hire Specialist', 'Watch Feedback', 'Swap Agent'],
|
|
tint: '#27d8ff',
|
|
},
|
|
booth: {
|
|
title: 'My Booth',
|
|
subtitle: 'Mount, message, inspect, and carry personal agents into websites.',
|
|
rows: ['Iron Tusk / running / cart.update / 00:13', 'Socket Wraith / waiting / claim.lookup / 02:41', 'Nightforge / paused / terminal.chat / 18:09'],
|
|
stats: ['3 mounted', '4 callbacks', 'healthy'],
|
|
actions: ['Mount Agent', 'Open TUI', 'Send Message', 'Review Logs', 'Detach Booth'],
|
|
tint: '#36f0b0',
|
|
},
|
|
contracts: {
|
|
title: 'Contract Pricing',
|
|
subtitle: 'Three paths: embedded agent, bring-your-own agent, or hosted agent.',
|
|
rows: ['Site hires agent / API key to session', 'User brings agent / connect amerc', 'Hosted mercenary / TUI operations'],
|
|
stats: ['session', 'skills', 'runtime'],
|
|
actions: ['Create API Key', 'Bind Skills', 'Set Budget', 'Publish Terms', 'Start Session'],
|
|
tint: '#f2b85f',
|
|
},
|
|
admin: {
|
|
title: 'Quartermaster',
|
|
subtitle: 'Tenant, key, session, skill, and risk controls for operators.',
|
|
rows: ['northstar-retail / embedded / green', 'claims-lab / byoa / amber', 'sandbox / hosted / red'],
|
|
stats: ['tenants', 'keys', 'risk queue'],
|
|
actions: ['Audit Tenants', 'Rotate Keys', 'Trace Sessions', 'Block Skill', 'Review Risk'],
|
|
tint: '#f2b85f',
|
|
},
|
|
signin: {
|
|
title: 'Open Booth',
|
|
subtitle: 'Sign in to the mercenary control surface.',
|
|
rows: ['Guild handle / operator@vertical-site.ai', 'Access sigil / ************', `Release / ${RELEASE}`],
|
|
stats: ['preview', 'amerc.ai', 'frontend'],
|
|
actions: ['Choose Account', 'Link Agent', 'Grant Scope', 'Enter Booth', 'Return to Site'],
|
|
tint: '#9d73ff',
|
|
},
|
|
};
|
|
return panels[route] || panels.home;
|
|
}
|
|
|
|
// Characters that live on the tavern floor. Clicking one routes you.
|
|
const CHARACTERS = [
|
|
{
|
|
id: 'orc', sprite: '/scene2d/orc.png', route: 'agents',
|
|
name: 'Grommash', role: 'Bartender', say: 'Pick a mercenary, friend.',
|
|
left: '72%', h: 460, z: 6, flip: false, bob: 3.4,
|
|
},
|
|
{
|
|
id: 'elf', sprite: '/scene2d/elf.png', route: 'home',
|
|
name: 'Aelis', role: 'Guide', say: 'Welcome to amerc. This way.',
|
|
left: '46%', h: 420, z: 5, flip: false, bob: 4.2,
|
|
},
|
|
{
|
|
id: 'dwarf', sprite: '/scene2d/dwarf.png', route: 'booth',
|
|
name: 'Durn', role: 'Patron', say: 'My booth runs three agents.',
|
|
left: '20%', h: 360, z: 4, flip: true, bob: 3.0,
|
|
},
|
|
];
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Top bar
|
|
// ---------------------------------------------------------------------------
|
|
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.filter((item) => !(item.id === 'signin' && loggedIn)).map((item) => (
|
|
<button key={item.id} className={`px-nav-btn${route === item.id ? ' active' : ''}`} onClick={() => go(item.id)} type="button">
|
|
{item.label}
|
|
</button>
|
|
))}
|
|
{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>
|
|
)}
|
|
</>
|
|
);
|
|
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>
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Scene
|
|
// ---------------------------------------------------------------------------
|
|
function TavernFloor({ route, setRoute, auth }) {
|
|
const stageRef = useRef(null);
|
|
const [parallax, setParallax] = useState({ x: 0, y: 0 });
|
|
const [active, setActive] = useState(null); // hovered/selected character id
|
|
|
|
const onMove = useCallback((e) => {
|
|
const el = stageRef.current;
|
|
if (!el) return;
|
|
const r = el.getBoundingClientRect();
|
|
const px = (e.clientX - r.left) / r.width - 0.5;
|
|
const py = (e.clientY - r.top) / r.height - 0.5;
|
|
setParallax({ x: px, y: py });
|
|
}, []);
|
|
|
|
const embers = useMemo(
|
|
() => Array.from({ length: 26 }, (_, i) => ({
|
|
id: i,
|
|
left: `${(i * 37) % 100}%`,
|
|
delay: `${(i % 13) * 0.9}s`,
|
|
dur: `${7 + (i % 6)}s`,
|
|
size: 2 + (i % 3),
|
|
})),
|
|
[],
|
|
);
|
|
|
|
return (
|
|
<div className="px-stage" ref={stageRef} onMouseMove={onMove} onMouseLeave={() => setParallax({ x: 0, y: 0 })}>
|
|
{/* parallax tavern backdrop */}
|
|
<div
|
|
className="px-bg"
|
|
style={{ transform: `translate3d(${parallax.x * -14}px, ${parallax.y * -8}px, 0) scale(1.08)` }}
|
|
/>
|
|
<div className="px-bg-haze" />
|
|
|
|
{/* floating embers */}
|
|
<div className="px-embers" aria-hidden="true">
|
|
{embers.map((e) => (
|
|
<span key={e.id} style={{ left: e.left, animationDelay: e.delay, animationDuration: e.dur, width: e.size, height: e.size }} />
|
|
))}
|
|
</div>
|
|
|
|
{/* characters on the floor */}
|
|
<div
|
|
className="px-floor"
|
|
style={{ transform: `translate3d(${parallax.x * 10}px, 0, 0)` }}
|
|
>
|
|
{CHARACTERS.map((c) => (
|
|
<button
|
|
key={c.id}
|
|
type="button"
|
|
className={`px-char${active === c.id ? ' active' : ''}${route === c.route ? ' here' : ''}`}
|
|
style={{ left: c.left, zIndex: c.z, '--bob': `${c.bob}s` }}
|
|
onMouseEnter={() => setActive(c.id)}
|
|
onMouseLeave={() => setActive((p) => (p === c.id ? null : p))}
|
|
onClick={() => setRoute(c.route)}
|
|
aria-label={`${c.name} the ${c.role}`}
|
|
>
|
|
{(active === c.id || route === c.route) && (
|
|
<span className="px-speech">
|
|
<strong>{c.name}</strong> · {c.role}
|
|
<em>{c.say}</em>
|
|
</span>
|
|
)}
|
|
<img
|
|
src={c.sprite}
|
|
alt=""
|
|
draggable={false}
|
|
style={{ height: `min(${c.h}px, 58vh)`, transform: c.flip ? 'scaleX(-1)' : 'none' }}
|
|
/>
|
|
<span className="px-char-shadow" />
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
{/* warm light + vignette */}
|
|
<div className="px-vignette" aria-hidden="true" />
|
|
|
|
{/* foreground holo board with route content */}
|
|
<RoutePanel route={route} setRoute={setRoute} auth={auth} />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function RoutePanel({ route, setRoute, auth }) {
|
|
const data = panelData(route);
|
|
const wide = route === 'admin' && auth.user?.role === 'admin';
|
|
return (
|
|
<aside className={`px-board${wide ? ' px-board-wide' : ''}`} style={{ '--tint': data.tint }}>
|
|
<div className="px-board-rivets" aria-hidden="true" />
|
|
<header className="px-board-head">
|
|
<span className="px-board-tag">{route === 'admin' ? 'BACKDOOR' : route === 'signin' ? 'GATEHOUSE' : 'WANTED BOARD'}</span>
|
|
<h2>{data.title}</h2>
|
|
<p>{data.subtitle}</p>
|
|
</header>
|
|
|
|
{route === 'signin' && <AuthPanel auth={auth} onDone={() => setRoute('booth')} />}
|
|
{route === 'admin' && <AdminConsole auth={auth} />}
|
|
{route !== 'signin' && route !== 'admin' && (
|
|
<>
|
|
<RouteBody data={data} route={route} setRoute={setRoute} />
|
|
</>
|
|
)}
|
|
</aside>
|
|
);
|
|
}
|
|
|
|
function RouteBody({ data, route, setRoute }) {
|
|
return (
|
|
<>
|
|
<ul className="px-board-rows">
|
|
{data.rows.map((r, i) => (
|
|
<li key={i}><span className="px-bullet" />{r}</li>
|
|
))}
|
|
</ul>
|
|
<div className="px-board-stats">
|
|
{data.stats.map((s, i) => (
|
|
<span key={i} className="px-stat">{s}</span>
|
|
))}
|
|
</div>
|
|
<div className="px-board-actions">
|
|
{data.actions.map((a, i) => (
|
|
<button key={i} type="button" className="px-action" onClick={() => setRoute(route === 'home' ? 'agents' : route)}>
|
|
{a}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</>
|
|
);
|
|
}
|
|
|
|
export default function Scene2DApp() {
|
|
const [route, setRoute] = useHashRoute();
|
|
const auth = useAuth();
|
|
return (
|
|
<main className="px-app">
|
|
<h1 className="sr-only">amerc agent mercenary tavern</h1>
|
|
<PixelTopbar route={route} setRoute={setRoute} auth={auth} />
|
|
<TavernFloor route={route} setRoute={setRoute} auth={auth} />
|
|
</main>
|
|
);
|
|
}
|