- on phones the docs kept the desktop sidebar+content row, squeezing the prose into ~240px (sidebar ate the rest) — poor reading on a public docs surface - stack on mobile (<=760px): sidebar becomes a compact scrollable doc-picker on top (max 210px), content goes full-width below with comfortable padding - verified live (390px): layout column, content full-width (390 vs old 240), no horizontal overflow
480 lines
27 KiB
JavaScript
480 lines
27 KiB
JavaScript
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||
import { useAuth, AuthPanel, AdminConsole } from './Auth.jsx';
|
||
import { AgentBrowse, BoothDashboard } from './AgentPlatform.jsx';
|
||
import Mansion from './Mansion.jsx';
|
||
import Account from './Account.jsx';
|
||
import TavernGame from './TavernGame.jsx';
|
||
import { api } from './api.js';
|
||
|
||
// Inline hamburger + close icons (was lucide-react — dropped for one fewer dep).
|
||
const Menu = ({ size = 20 }) => (<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" aria-hidden="true"><line x1="3" y1="6" x2="21" y2="6" /><line x1="3" y1="12" x2="21" y2="12" /><line x1="3" y1="18" x2="21" y2="18" /></svg>);
|
||
const X = ({ size = 20 }) => (<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" aria-hidden="true"><line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" /></svg>);
|
||
|
||
const RELEASE = '0.77.0-docsmobile';
|
||
|
||
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', 'account', 'admin', 'signin', 'status', 'changelog'];
|
||
const ROUTE_TITLES = {
|
||
home: 'amerc — hire agents like mercenaries', agents: 'Browse Agents · amerc', mansion: 'The Mansion · amerc',
|
||
booth: 'My Booth · amerc', account: 'Account · amerc', signin: 'Sign in · amerc', status: 'System status · amerc',
|
||
changelog: 'Changelog · amerc', admin: 'Quartermaster · amerc',
|
||
};
|
||
|
||
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>
|
||
))}
|
||
<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${route === 'account' ? ' active' : ''}`} onClick={() => go('account')} type="button" title="Account">{auth.user.handle}</button>
|
||
<button className="px-nav-btn" onClick={() => auth.logout()} type="button" title="Log out">⏻</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>
|
||
</button>
|
||
<button className="px-ver" onClick={() => go('changelog')} type="button" title="What's new — changelog">v{RELEASE}</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-backdrop" onClick={() => setOpen(false)} aria-hidden="true" /><div className="px-mobile">{nav}</div></>}
|
||
</header>
|
||
);
|
||
}
|
||
|
||
const prefersReducedMotion = () => typeof window !== 'undefined' && window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||
|
||
// Count up from 0 to n with an ease-out curve (delightful stat reveals).
|
||
function Count({ n }) {
|
||
const [v, setV] = useState(prefersReducedMotion() ? n : 0);
|
||
useEffect(() => {
|
||
if (prefersReducedMotion()) { setV(n); return; }
|
||
let raf, start; const dur = 750;
|
||
const tick = (t) => { if (!start) start = t; const p = Math.min(1, (t - start) / dur); setV(Math.round(n * (1 - Math.pow(1 - p, 3)))); if (p < 1) raf = requestAnimationFrame(tick); };
|
||
raf = requestAnimationFrame(tick); return () => cancelAnimationFrame(raf);
|
||
}, [n]);
|
||
return <>{v}</>;
|
||
}
|
||
|
||
// Fade-up as the element scrolls into view; always-visible fallback if no IO / reduced motion.
|
||
function Reveal({ children, className = '', delay = 0, tag: Tag = 'div' }) {
|
||
const ref = useRef(null);
|
||
const [armed] = useState(() => typeof window !== 'undefined' && 'IntersectionObserver' in window && !prefersReducedMotion());
|
||
const [shown, setShown] = useState(false);
|
||
useEffect(() => {
|
||
if (!armed || !ref.current) return;
|
||
const io = new IntersectionObserver((entries) => { entries.forEach((e) => { if (e.isIntersecting) { setShown(true); io.disconnect(); } }); }, { threshold: 0.12, rootMargin: '0px 0px -6% 0px' });
|
||
io.observe(ref.current); return () => io.disconnect();
|
||
}, [armed]);
|
||
return <Tag ref={ref} className={`${className} ${armed ? 'reveal' : ''} ${shown ? 'in' : ''}`.replace(/\s+/g, ' ').trim()} style={delay && armed ? { transitionDelay: `${delay}ms` } : undefined}>{children}</Tag>;
|
||
}
|
||
|
||
function Home({ setRoute }) {
|
||
const [stats, setStats] = useState(null);
|
||
const [scrolled, setScrolled] = useState(false);
|
||
const homeRef = useRef(null);
|
||
useEffect(() => { api('/agents/stats').then(setStats).catch(() => {}); }, []);
|
||
useEffect(() => {
|
||
const el = homeRef.current; if (!el) return;
|
||
const onScroll = () => setScrolled(el.scrollTop > 500);
|
||
el.addEventListener('scroll', onScroll, { passive: true });
|
||
return () => el.removeEventListener('scroll', onScroll);
|
||
}, []);
|
||
return (
|
||
<div className="gm-home" ref={homeRef}>
|
||
<div className="gm-hero">
|
||
<div className="gm-stage"><TavernGame onHotspot={setRoute} stats={stats} /></div>
|
||
<div className="gm-caption">
|
||
<h1>Hire agents like mercenaries.</h1>
|
||
<p>Hire, run, and deliver AI agents across any software boundary — as an embedded chatbox, a live web terminal, or a public URL. <b>No infrastructure, just skills.</b></p>
|
||
<div className="gm-cta">
|
||
<button className="gm-cta-btn gm-cta-primary" onClick={() => setRoute('agents')}>Browse Agents →</button>
|
||
<button className="gm-cta-btn gm-cta-secondary" onClick={() => setRoute('booth')}>Publish yours</button>
|
||
</div>
|
||
{stats && (
|
||
<div className="hero-live">
|
||
<span><b><Count n={stats.classes} /></b> classes</span>
|
||
<span><b><Count n={stats.online} /></b> online</span>
|
||
<span><b><Count n={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} />
|
||
<button className={`gm-totop${scrolled ? ' show' : ''}`} onClick={() => homeRef.current?.scrollTo({ top: 0, behavior: 'smooth' })} aria-label="Back to top" type="button">↑</button>
|
||
</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) => (
|
||
<Reveal tag="li" key={i} delay={Math.min(i, 6) * 50}><span className="act-ico">{ACT_ICON[a.kind] || '•'}</span><span className="act-text">{a.text}</span><span className="act-time">{relTime(a.at)}</span></Reveal>
|
||
))}
|
||
</ul>
|
||
</div>
|
||
);
|
||
}
|
||
const FLOW = [
|
||
{ ico: '💻', title: 'You', sub: 'broker + agent', body: 'Your broker runs the agent on your own machine — codex, claude, any CLI. It dials out over WebSocket, so there are no inbound ports to open.' },
|
||
{ ico: '💎', title: 'amerc edge', sub: 'relay · proxy · auth', body: 'amerc authenticates each session, relays the agent, and proxies traffic — all on the public edge at amerc.ai.' },
|
||
{ ico: '👥', title: 'Your users', sub: 'chatbox · terminal · url', body: 'They reach your agent as an embedded chatbox, a live web terminal, or a public Showcase subdomain.' },
|
||
];
|
||
function FlowDiagram() {
|
||
return (
|
||
<div className="fd">
|
||
{FLOW.map((n, i) => (
|
||
<React.Fragment key={i}>
|
||
<div className="fd-node">
|
||
<span className="fd-ico" aria-hidden="true">{n.ico}</span>
|
||
<strong>{n.title}</strong>
|
||
<span className="fd-sub">{n.sub}</span>
|
||
<p>{n.body}</p>
|
||
</div>
|
||
{i < FLOW.length - 1 && <div className="fd-link" aria-hidden="true"><i /></div>}
|
||
</React.Fragment>
|
||
))}
|
||
</div>
|
||
);
|
||
}
|
||
const DEMO_SCRIPT = [
|
||
{ who: 'user', text: 'Add a CSV export button to the orders table.' },
|
||
{ who: 'agent', text: 'Added an “Export CSV” button to the orders toolbar — it streams /api/orders.csv. Change pushed; your preview is rebuilding now.' },
|
||
{ who: 'user', text: 'Nice. Expose my local dev server so my client can review it.' },
|
||
{ who: 'agent', text: 'Live at demo1.you.amerc.ai over HTTPS — tunneled from localhost:3000 through Showcase. Share the link.' },
|
||
];
|
||
function ChatDemo() {
|
||
const [n, setN] = useState(prefersReducedMotion() ? DEMO_SCRIPT.length : 0);
|
||
const [typing, setTyping] = useState(false);
|
||
useEffect(() => {
|
||
if (prefersReducedMotion()) return;
|
||
const timers = []; let i = 0;
|
||
const at = (fn, ms) => timers.push(setTimeout(fn, ms));
|
||
const next = () => {
|
||
if (i >= DEMO_SCRIPT.length) { at(() => { setN(0); i = 0; next(); }, 4200); return; }
|
||
if (DEMO_SCRIPT[i].who === 'agent') { setTyping(true); at(() => { setTyping(false); i += 1; setN(i); at(next, 1000); }, 1400); }
|
||
else { i += 1; setN(i); at(next, 1200); }
|
||
};
|
||
at(next, 700);
|
||
return () => timers.forEach(clearTimeout);
|
||
}, []);
|
||
return (
|
||
<div className="cd" role="img" aria-label="Preview of an agent chat session">
|
||
<div className="cd-head"><span className="cd-ava" aria-hidden="true">💬</span><div className="cd-head-t"><strong>amerc agent</strong><span className="cd-status"><i />online · preview</span></div></div>
|
||
<div className="cd-body">
|
||
{DEMO_SCRIPT.slice(0, n).map((m, idx) => <div key={idx} className={`cd-msg ${m.who}`}>{m.text}</div>)}
|
||
{typing && <div className="cd-msg agent cd-typing" aria-hidden="true"><span /><span /><span /></div>}
|
||
</div>
|
||
<div className="cd-foot"><span className="cd-input">Ask the agent…</span><span className="cd-send" aria-hidden="true">➤</span></div>
|
||
</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>
|
||
<Reveal><FlowDiagram /></Reveal>
|
||
<div className="hf-grid">
|
||
{HF_STEPS.map((s, i) => (
|
||
<Reveal key={i} className="hf-card" delay={i * 90}>
|
||
<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>
|
||
</Reveal>
|
||
))}
|
||
</div>
|
||
<h3 className="hf-sub">Three ways to use an agent</h3>
|
||
<div className="hf-uses">
|
||
{HF_USES.map((u, i) => (
|
||
<Reveal key={i} className="hf-use" delay={i * 80}><span className="hf-ico-sm">{u.icon}</span><div><strong>{u.title}</strong><p>{u.body}</p></div></Reveal>
|
||
))}
|
||
</div>
|
||
<h3 className="hf-sub">See a session</h3>
|
||
<div className="hf-demo">
|
||
<Reveal className="hf-demo-stage"><ChatDemo /></Reveal>
|
||
<div className="hf-demo-side">
|
||
<h4>A hired agent, working for your users.</h4>
|
||
<p>An embedded chatbox that does real work — ships a code change, then exposes the preview on a public URL through Showcase. Drop it on any page with one <code><script></code> tag.</p>
|
||
<button className="px-action" onClick={() => setRoute('agents')}>Browse real agents →</button>
|
||
<span className="hf-demo-note">↑ a scripted preview of a session</span>
|
||
</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>
|
||
<button className={`ft-status${ok === false ? ' down' : ''}`} onClick={() => setRoute('status')} title="View system status">
|
||
<i /> {ok === null ? 'checking…' : ok ? 'All systems operational' : 'Service degraded'}{stats ? ` · ${stats.online} online · ${stats.sessions} live` : ''}
|
||
</button>
|
||
</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>
|
||
<button onClick={() => setRoute('changelog')}>Changelog</button>
|
||
</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>
|
||
<button onClick={() => setRoute('status')}>Status</button>
|
||
</div>
|
||
</div>
|
||
<div className="ft-base">© 2026 amerc · agent mercenary tavern · v{RELEASE}</div>
|
||
</footer>
|
||
);
|
||
}
|
||
|
||
const GATE_BENEFITS = [
|
||
{ icon: '🍺', title: 'Hire agents like mercenaries', body: 'Browse the roster and put a skilled agent to work in seconds.' },
|
||
{ icon: '🗡️', title: 'Publish & run your own', body: 'Register an agent class — your broker brings the runtime, no servers to rent.' },
|
||
{ icon: '🔌', title: 'Deliver anywhere', body: 'Embed a chatbox, drive a live web terminal, or expose a local port to the internet.' },
|
||
];
|
||
function Gatehouse({ auth, setRoute }) {
|
||
const [stats, setStats] = useState(null);
|
||
useEffect(() => { api('/agents/stats').then(setStats).catch(() => {}); }, []);
|
||
return (
|
||
<div className="gate">
|
||
<div className="gate-pitch">
|
||
<span className="gate-tag">⛩ THE GATEHOUSE</span>
|
||
<h2>Join the tavern.</h2>
|
||
<p className="gate-lead">One amerc account opens every door — hire mercenaries, run your own, and deliver them across software boundaries. No infrastructure, just skills.</p>
|
||
<ul className="gate-benefits">
|
||
{GATE_BENEFITS.map((b) => (
|
||
<li key={b.title}><span className="gate-bi" aria-hidden="true">{b.icon}</span><div><strong>{b.title}</strong><p>{b.body}</p></div></li>
|
||
))}
|
||
</ul>
|
||
<div className="gate-sso"><span>🔓</span> One sign-in works across <b>tavern</b>, <b>docs</b>, <b>PM</b> and <b>git</b>.</div>
|
||
{stats && (
|
||
<div className="gate-live">
|
||
<span><b><Count n={stats.classes} /></b> classes</span><i />
|
||
<span><b><Count n={stats.online} /></b> online</span><i />
|
||
<span><b><Count n={stats.sessions} /></b> sessions</span>
|
||
</div>
|
||
)}
|
||
</div>
|
||
<div className="gate-form">
|
||
<div className="px-board gate-board">
|
||
<header className="px-board-head"><span className="px-board-tag">OPEN BOOTH</span><h2>Welcome, mercenary</h2><p>Log in, or create your account in seconds.</p></header>
|
||
<AuthPanel auth={auth} onDone={() => setRoute('booth')} />
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
const STATUS_TARGETS = [
|
||
{ key: 'web', name: 'Tavern (web)', url: 'https://amerc.ai/', mode: 'reach' },
|
||
{ key: 'api', name: 'amerc API', url: '/api/health', mode: 'json' },
|
||
{ key: 'docs', name: 'Docs', url: 'https://docs.amerc.ai/', mode: 'reach' },
|
||
{ key: 'git', name: 'Git', url: 'https://git.amerc.ai/', mode: 'reach' },
|
||
{ key: 'agent', name: 'Webagent relay', url: 'https://webagent.amerc.ai/health', mode: 'reach' },
|
||
];
|
||
async function probe(t) {
|
||
const ctrl = new AbortController(); const to = setTimeout(() => ctrl.abort(), 6000); const t0 = performance.now();
|
||
try {
|
||
if (t.mode === 'json') { const r = await fetch(t.url, { signal: ctrl.signal, cache: 'no-store' }); return { ok: r.ok, ms: Math.round(performance.now() - t0) }; }
|
||
await fetch(t.url, { mode: 'no-cors', signal: ctrl.signal, cache: 'no-store' }); return { ok: true, ms: Math.round(performance.now() - t0) };
|
||
} catch { return { ok: false, ms: Math.round(performance.now() - t0) }; }
|
||
finally { clearTimeout(to); }
|
||
}
|
||
function StatusPage() {
|
||
const [res, setRes] = useState({});
|
||
const run = useCallback(() => {
|
||
STATUS_TARGETS.forEach(async (t) => {
|
||
setRes((r) => ({ ...r, [t.key]: { ...r[t.key], loading: true } }));
|
||
const o = await probe(t);
|
||
setRes((r) => ({ ...r, [t.key]: { ...o, loading: false } }));
|
||
});
|
||
}, []);
|
||
useEffect(() => { run(); const id = setInterval(run, 20000); return () => clearInterval(id); }, [run]);
|
||
const known = STATUS_TARGETS.map((t) => res[t.key]).filter((x) => x && !x.loading);
|
||
const allUp = known.length === STATUS_TARGETS.length && known.every((x) => x.ok);
|
||
const anyDown = known.some((x) => !x.ok);
|
||
return (
|
||
<div className="st">
|
||
<h2 className="st-title">System status</h2>
|
||
<div className={`st-banner${allUp ? ' up' : anyDown ? ' down' : ''}`}>
|
||
<span className="st-beacon" />
|
||
<div className="st-banner-txt">
|
||
<strong>{!known.length ? 'Checking services…' : allUp ? 'All systems operational' : anyDown ? 'Some services are degraded' : 'Checking…'}</strong>
|
||
<p>Live health of the amerc edge and its services · re-checks every 20s.</p>
|
||
</div>
|
||
<button className="px-action" onClick={run}>Re-check</button>
|
||
</div>
|
||
<div className="st-list">
|
||
{STATUS_TARGETS.map((t) => {
|
||
const o = res[t.key] || {};
|
||
const state = o.loading || o.ok == null ? 'checking' : o.ok ? 'up' : 'down';
|
||
return (
|
||
<div key={t.key} className="st-row">
|
||
<span className={`st-pill ${state}`} />
|
||
<strong className="st-name">{t.name}</strong>
|
||
<span className="st-host">{t.url.replace('https://', '').replace(/\/$/, '')}</span>
|
||
<span className="st-ms">{o.ms != null && !o.loading ? `${o.ms} ms` : '·'}</span>
|
||
<span className={`st-state ${state}`}>{state === 'up' ? 'operational' : state === 'down' ? 'unreachable' : 'checking…'}</span>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
<p className="st-foot">amerc runs on a single edge node — nginx, a zero-dependency Node API, Gitea, and the webagent + showcase relays. Cross-origin checks measure reachability from your browser, so transient network blips can show as degraded.</p>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
const CHANGELOG = [
|
||
{ v: '0.51', date: 'Jun 2026', title: 'Loading skeletons', notes: ['Browse shows shimmer skeletons while the roster loads', 'Fixed an empty-state flash on cold load'] },
|
||
{ v: '0.50', date: 'Jun 2026', title: 'Sharper hero', notes: ['Clear value proposition and Browse / Publish calls-to-action on the home hero'] },
|
||
{ v: '0.49', date: 'Jun 2026', title: 'Live session preview', notes: ['An animated chatbox on the home page shows what a hired agent does'] },
|
||
{ v: '0.48', date: 'Jun 2026', title: 'Findable', notes: ['robots.txt, sitemap.xml and Organization / WebSite structured data'] },
|
||
{ v: '0.47', date: 'Jun 2026', title: 'Architecture flow', notes: ['Animated You → amerc edge → your users diagram on the home page'] },
|
||
{ v: '0.46', date: 'Jun 2026', title: 'System status', notes: ['A live status page probing the tavern, API, docs, git and webagent'] },
|
||
{ v: '0.45', date: 'Jun 2026', title: 'Motion', notes: ['Scroll-reveal feature cards and count-up statistics'] },
|
||
{ v: '0.44', date: 'Jun 2026', title: 'Roster storefront', notes: ['Browse became a storefront with category chips and a publish-your-own card'] },
|
||
{ v: '0.43', date: 'Jun 2026', title: 'Lean + onboarding', notes: ['Dropped 68 MB of dead assets and moved to atomic deploys', 'First-run onboarding in My Booth'] },
|
||
{ v: '0.42', date: 'Jun 2026', title: 'The Gatehouse', notes: ['A split join page that leads with the value proposition'] },
|
||
{ v: '0.41', date: 'Jun 2026', title: 'The Mansion', notes: ['A vivid services hall with the Showcase reverse proxy front and centre'] },
|
||
{ v: '0.40', date: 'Jun 2026', title: 'Installable', notes: ['amerc is a PWA — branded app icons, offline shell, add to home screen'] },
|
||
{ v: '0.39', date: 'Jun 2026', title: 'Public docs', notes: ['docs.amerc.ai opened to everyone with quickstart and reference guides'] },
|
||
{ v: '0.30', date: 'Jun 2026', title: 'The tavern opens', notes: ['The 2D RPG tavern, the agent platform, the Showcase reverse proxy, and single sign-on across every *.amerc.ai'] },
|
||
];
|
||
function ChangelogPage() {
|
||
return (
|
||
<div className="cl">
|
||
<h2 className="cl-title">Changelog</h2>
|
||
<p className="cl-lead">amerc ships continuously — one focused improvement at a time. Here's the trail.</p>
|
||
<div className="cl-timeline">
|
||
{CHANGELOG.map((r, i) => (
|
||
<Reveal key={r.v} className="cl-item" delay={Math.min(i, 7) * 55}>
|
||
<span className="cl-dot" aria-hidden="true" />
|
||
<div className="cl-card">
|
||
<div className="cl-card-head"><span className="cl-ver">v{r.v}</span><strong>{r.title}</strong><span className="cl-date">{r.date}</span></div>
|
||
<ul>{r.notes.map((n, j) => <li key={j}>{n}</li>)}</ul>
|
||
</div>
|
||
</Reveal>
|
||
))}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
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 === 'account') return <Page setRoute={setRoute}><Account auth={auth} setRoute={setRoute} /></Page>;
|
||
if (route === 'status') return <Page setRoute={setRoute}><StatusPage /></Page>;
|
||
if (route === 'changelog') return <Page setRoute={setRoute}><ChangelogPage /></Page>;
|
||
if (route === 'signin') return <Page setRoute={setRoute}><Gatehouse auth={auth} setRoute={setRoute} /></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();
|
||
const isHome = route === 'home';
|
||
useEffect(() => { document.title = ROUTE_TITLES[route] || 'amerc'; }, [route]);
|
||
const skipToContent = (e) => {
|
||
e.preventDefault();
|
||
const target = document.querySelector('.route-enter') || document.querySelector('.route-host');
|
||
if (target) { target.setAttribute('tabindex', '-1'); target.focus(); }
|
||
};
|
||
return (
|
||
<main className="px-app td-app">
|
||
<a className="skip-link" href="#content" onClick={skipToContent}>Skip to content</a>
|
||
<h1 className="sr-only">amerc agent mercenary tavern</h1>
|
||
<PixelTopbar route={route} setRoute={setRoute} auth={auth} />
|
||
{/* Home stays mounted (hidden off-route) so returning to the tavern is instant —
|
||
the canvas pauses while hidden, so it costs nothing. */}
|
||
<div className="route-host" style={{ display: isHome ? 'flex' : 'none' }}><Home setRoute={setRoute} auth={auth} /></div>
|
||
{!isHome && <div key={route} className="route-enter"><Scene route={route} setRoute={setRoute} auth={auth} /></div>}
|
||
</main>
|
||
);
|
||
}
|