Showcase reverse-proxy + amerc Mansion UI (0.26.0)
- amerc-api /api/showcase: register subdomain + GoDaddy per-user wildcard DNS automation - server/showcase-edge: ngrok-style HTTP+WS reverse tunnel (ws), broker-token verified - nginx: apex /showcase/ws (broker) + wildcard vhost for *.<user>.amerc.ai showcases - Mansion page: services hub, Showcase registration + kebab delivery-center instructions - verified on the public internet (http://demo1.artheru.amerc.ai/ via local-port tunnel) - reference broker sent to kebab for their delivery center + ws/http demo
This commit is contained in:
parent
ae4455ea34
commit
099fd7616f
@ -7,7 +7,7 @@
|
|||||||
name="description"
|
name="description"
|
||||||
content="amerc is an agent mercenary layer for embedding, bringing, and hosting agents across vertical software boundaries."
|
content="amerc is an agent mercenary layer for embedding, bringing, and hosting agents across vertical software boundaries."
|
||||||
/>
|
/>
|
||||||
<meta name="version" content="0.25.0-lpc" />
|
<meta name="version" content="0.26.0-mansion" />
|
||||||
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
|
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "amerc-site",
|
"name": "amerc-site",
|
||||||
"version": "0.25.0-lpc",
|
"version": "0.26.0-mansion",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@ -60,8 +60,29 @@ db.exec(`
|
|||||||
CREATE TABLE IF NOT EXISTS class_subscriptions (
|
CREATE TABLE IF NOT EXISTS class_subscriptions (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT, class_id INTEGER NOT NULL, party TEXT NOT NULL, token TEXT NOT NULL,
|
id INTEGER PRIMARY KEY AUTOINCREMENT, class_id INTEGER NOT NULL, party TEXT NOT NULL, token TEXT NOT NULL,
|
||||||
created_at INTEGER NOT NULL);
|
created_at INTEGER NOT NULL);
|
||||||
|
CREATE TABLE IF NOT EXISTS showcases (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT, owner TEXT NOT NULL, name TEXT NOT NULL, host TEXT UNIQUE NOT NULL,
|
||||||
|
created_at INTEGER NOT NULL);
|
||||||
`);
|
`);
|
||||||
|
|
||||||
|
const SERVER_IP = process.env.AMERC_SERVER_IP || '104.168.145.233';
|
||||||
|
// edge token: signed {host} so the showcase-edge can verify a broker may bind that host
|
||||||
|
function showcaseToken(host) {
|
||||||
|
const body = b64url(JSON.stringify({ host, exp: Math.floor(Date.now() / 1000) + 365 * DAY }));
|
||||||
|
return `${body}.${crypto.createHmac('sha256', SECRET).update(body).digest('base64url')}`;
|
||||||
|
}
|
||||||
|
async function godaddyEnsureWildcard(handle) {
|
||||||
|
const key = process.env.GODADDY_KEY, secret = process.env.GODADDY_SECRET;
|
||||||
|
if (!key || !secret) return { ok: false, error: 'godaddy not configured' };
|
||||||
|
try {
|
||||||
|
const r = await fetch(`https://api.godaddy.com/v1/domains/amerc.ai/records/A/${encodeURIComponent('*.' + handle)}`, {
|
||||||
|
method: 'PUT', headers: { Authorization: `sso-key ${key}:${secret}`, 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify([{ data: SERVER_IP, ttl: 600 }]),
|
||||||
|
});
|
||||||
|
return { ok: r.ok, status: r.status };
|
||||||
|
} catch (e) { return { ok: false, error: String(e && e.message || e) }; }
|
||||||
|
}
|
||||||
|
|
||||||
// instance lifecycle thresholds
|
// instance lifecycle thresholds
|
||||||
const INSTANCE_LOST_MS = 30 * 1000; // no heartbeat -> lost
|
const INSTANCE_LOST_MS = 30 * 1000; // no heartbeat -> lost
|
||||||
const INSTANCE_DOWN_MS = 5 * 60 * 1000; // lost too long -> auto shut down
|
const INSTANCE_DOWN_MS = 5 * 60 * 1000; // lost too long -> auto shut down
|
||||||
@ -426,6 +447,30 @@ const server = http.createServer(async (req, res) => {
|
|||||||
db.prepare("UPDATE agent_sessions SET status='closed', ended_at=? WHERE id=?").run(Date.now(), Number(m[1])); return send(res, 200, { ok: true });
|
db.prepare("UPDATE agent_sessions SET status='closed', ended_at=? WHERE id=?").run(Date.now(), Number(m[1])); return send(res, 200, { ok: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------- SHOWCASE (reverse-proxy subdomains) — amerc mansion service ----------
|
||||||
|
if (path === '/showcase' && method === 'GET') {
|
||||||
|
if (id?.kind !== 'user') return send(res, 401, { ok: false, error: 'login required' });
|
||||||
|
return send(res, 200, { ok: true, showcases: db.prepare('SELECT id,name,host,created_at FROM showcases WHERE owner=? ORDER BY id DESC').all(id.user.handle) });
|
||||||
|
}
|
||||||
|
if (path === '/showcase' && method === 'POST') {
|
||||||
|
if (id?.kind !== 'user') return send(res, 401, { ok: false, error: 'login required' });
|
||||||
|
const b = await readBody(req) || {};
|
||||||
|
const handle = id.user.handle.toLowerCase().replace(/[^a-z0-9]/g, '');
|
||||||
|
const name = String(b.name || '').toLowerCase().replace(/[^a-z0-9-]/g, '').slice(0, 30);
|
||||||
|
if (!name || !handle) return send(res, 400, { ok: false, error: 'name required (letters/numbers)' });
|
||||||
|
const host = `${name}.${handle}.amerc.ai`;
|
||||||
|
if (db.prepare('SELECT id FROM showcases WHERE host=?').get(host)) return send(res, 409, { ok: false, error: 'showcase already registered' });
|
||||||
|
const dns = await godaddyEnsureWildcard(handle);
|
||||||
|
db.prepare('INSERT INTO showcases(owner,name,host,created_at) VALUES(?,?,?,?)').run(id.user.handle, name, host, Date.now());
|
||||||
|
return send(res, 200, { ok: true, host, url: `http://${host}/`, edgeToken: showcaseToken(host), edgeWs: 'wss://amerc.ai/showcase/ws', dns: dns.ok ? 'configured' : (dns.error || 'pending') });
|
||||||
|
}
|
||||||
|
if ((m = path.match(/^\/showcase\/(\d+)$/)) && method === 'DELETE') {
|
||||||
|
if (id?.kind !== 'user') return send(res, 401, { ok: false, error: 'login required' });
|
||||||
|
const s = db.prepare('SELECT * FROM showcases WHERE id=?').get(Number(m[1]));
|
||||||
|
if (s && (s.owner === id.user.handle || id.user.role === 'admin')) db.prepare('DELETE FROM showcases WHERE id=?').run(s.id);
|
||||||
|
return send(res, 200, { ok: true });
|
||||||
|
}
|
||||||
|
|
||||||
return send(res, 404, { ok: false, error: 'not found' });
|
return send(res, 404, { ok: false, error: 'not found' });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return send(res, 500, { ok: false, error: 'server error', detail: String(err && err.message || err) });
|
return send(res, 500, { ok: false, error: 'server error', detail: String(err && err.message || err) });
|
||||||
|
|||||||
78
server/showcase-edge/index.mjs
Normal file
78
server/showcase-edge/index.mjs
Normal file
@ -0,0 +1,78 @@
|
|||||||
|
// amerc showcase-edge — reverse proxy (ngrok-style). Inbound HTTP/WS from the internet on
|
||||||
|
// <show>.<user>.amerc.ai is tunneled over a broker WS to the user's local port.
|
||||||
|
// Node 22 + ws (pure JS). env: PORT, AMERC_SECRET.
|
||||||
|
import http from 'node:http';
|
||||||
|
import crypto from 'node:crypto';
|
||||||
|
import { WebSocketServer, WebSocket } from 'ws';
|
||||||
|
|
||||||
|
const PORT = Number(process.env.PORT || 5191);
|
||||||
|
const SECRET = process.env.AMERC_SECRET || 'dev-insecure-secret-change-me';
|
||||||
|
|
||||||
|
const binds = new Map(); // host -> { ws (broker), port }
|
||||||
|
const pendingHttp = new Map(); // reqId -> res
|
||||||
|
const browserWs = new Map(); // connId -> client ws
|
||||||
|
|
||||||
|
function verifyEdgeToken(token) {
|
||||||
|
if (!token || !token.includes('.')) return null;
|
||||||
|
const [body, sig] = token.split('.');
|
||||||
|
const expect = crypto.createHmac('sha256', SECRET).update(body).digest('base64url');
|
||||||
|
if (sig.length !== expect.length || !crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expect))) return null;
|
||||||
|
try { const d = JSON.parse(Buffer.from(body, 'base64url').toString()); return d.exp < Math.floor(Date.now() / 1000) ? null : d; }
|
||||||
|
catch { return null; }
|
||||||
|
}
|
||||||
|
const hostOf = (req) => String(req.headers.host || '').split(':')[0].toLowerCase();
|
||||||
|
|
||||||
|
const server = http.createServer((req, res) => {
|
||||||
|
const host = hostOf(req);
|
||||||
|
if (host === 'amerc.ai' || req.url === '/health') { res.writeHead(200, { 'Content-Type': 'application/json' }); return res.end(JSON.stringify({ ok: true, service: 'showcase-edge', binds: binds.size })); }
|
||||||
|
const bind = binds.get(host);
|
||||||
|
if (!bind || bind.ws.readyState !== WebSocket.OPEN) {
|
||||||
|
res.writeHead(502, { 'Content-Type': 'text/html; charset=utf-8' });
|
||||||
|
return res.end(`<!doctype html><meta charset=utf-8><title>amerc showcase</title><body style="font-family:Georgia,serif;background:#0e0a14;color:#e7d4b6;text-align:center;padding:60px"><h1>🏰 amerc showcase</h1><p>No service is bound to <b>${host}</b> yet.</p><p>Open the kebab <b>delivery center (交付中心)</b> and map a local port to this showcase.</p></body>`);
|
||||||
|
}
|
||||||
|
const chunks = [];
|
||||||
|
req.on('data', (c) => chunks.push(c));
|
||||||
|
req.on('end', () => {
|
||||||
|
const reqId = crypto.randomUUID();
|
||||||
|
pendingHttp.set(reqId, res);
|
||||||
|
try { bind.ws.send(JSON.stringify({ type: 'http_req', reqId, method: req.method, url: req.url, headers: req.headers, body: Buffer.concat(chunks).toString('base64') })); }
|
||||||
|
catch { pendingHttp.delete(reqId); try { res.writeHead(502); res.end('broker gone'); } catch {} }
|
||||||
|
setTimeout(() => { if (pendingHttp.has(reqId)) { pendingHttp.delete(reqId); try { res.writeHead(504); res.end('upstream timeout'); } catch {} } }, 30000);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const wss = new WebSocketServer({ noServer: true });
|
||||||
|
server.on('upgrade', (req, socket, head) => {
|
||||||
|
const url = new URL(req.url, 'http://x'); const host = hostOf(req);
|
||||||
|
if (url.pathname === '/showcase/ws' && url.searchParams.get('role') === 'edge') {
|
||||||
|
wss.handleUpgrade(req, socket, head, (ws) => onBroker(ws, url));
|
||||||
|
} else {
|
||||||
|
const bind = binds.get(host);
|
||||||
|
if (!bind || bind.ws.readyState !== WebSocket.OPEN) { socket.destroy(); return; }
|
||||||
|
wss.handleUpgrade(req, socket, head, (ws) => onBrowserWs(ws, req, bind));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function onBroker(ws, url) {
|
||||||
|
const tok = verifyEdgeToken(url.searchParams.get('token'));
|
||||||
|
if (!tok) { try { ws.close(4001, 'bad token'); } catch {} return; }
|
||||||
|
ws.on('message', (data) => {
|
||||||
|
let m; try { m = JSON.parse(data); } catch { return; }
|
||||||
|
if (m.type === 'bind') { if (m.host === tok.host) { binds.set(m.host, { ws, port: m.port }); ws.send(JSON.stringify({ type: 'bound', host: m.host })); } else ws.send(JSON.stringify({ type: 'error', error: 'token not valid for ' + m.host })); }
|
||||||
|
else if (m.type === 'http_res') { const res = pendingHttp.get(m.reqId); if (res) { pendingHttp.delete(m.reqId); try { const h = m.headers || {}; delete h['content-length']; delete h['transfer-encoding']; res.writeHead(m.status || 200, h); res.end(Buffer.from(m.body || '', 'base64')); } catch {} } }
|
||||||
|
else if (m.type === 'ws_msg') { const c = browserWs.get(m.connId); if (c && c.readyState === WebSocket.OPEN) c.send(m.binary ? Buffer.from(m.data, 'base64') : m.data); }
|
||||||
|
else if (m.type === 'ws_close') { const c = browserWs.get(m.connId); if (c) try { c.close(); } catch {} browserWs.delete(m.connId); }
|
||||||
|
});
|
||||||
|
ws.on('close', () => { for (const [h, b] of binds) if (b.ws === ws) binds.delete(h); });
|
||||||
|
ws.on('error', () => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
function onBrowserWs(ws, req, bind) {
|
||||||
|
const connId = crypto.randomUUID(); browserWs.set(connId, ws);
|
||||||
|
try { bind.ws.send(JSON.stringify({ type: 'ws_open', connId, url: req.url, headers: req.headers })); } catch {}
|
||||||
|
ws.on('message', (data, isBinary) => { try { bind.ws.send(JSON.stringify({ type: 'ws_msg', connId, data: isBinary ? data.toString('base64') : data.toString(), binary: !!isBinary })); } catch {} });
|
||||||
|
ws.on('close', () => { try { bind.ws.send(JSON.stringify({ type: 'ws_close', connId })); } catch {} browserWs.delete(connId); });
|
||||||
|
ws.on('error', () => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
server.listen(PORT, '127.0.0.1', () => console.log(`showcase-edge on 127.0.0.1:${PORT}`));
|
||||||
34
server/showcase-edge/package-lock.json
generated
Normal file
34
server/showcase-edge/package-lock.json
generated
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
{
|
||||||
|
"name": "amerc-showcase-edge",
|
||||||
|
"lockfileVersion": 3,
|
||||||
|
"requires": true,
|
||||||
|
"packages": {
|
||||||
|
"": {
|
||||||
|
"name": "amerc-showcase-edge",
|
||||||
|
"dependencies": {
|
||||||
|
"ws": "^8.21.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/ws": {
|
||||||
|
"version": "8.21.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz",
|
||||||
|
"integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"bufferutil": "^4.0.1",
|
||||||
|
"utf-8-validate": ">=5.0.2"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"bufferutil": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"utf-8-validate": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
1
server/showcase-edge/package.json
Normal file
1
server/showcase-edge/package.json
Normal file
@ -0,0 +1 @@
|
|||||||
|
{"name":"amerc-showcase-edge","private":true,"type":"module","dependencies":{"ws":"^8.21.0"}}
|
||||||
69
src/Mansion.jsx
Normal file
69
src/Mansion.jsx
Normal file
@ -0,0 +1,69 @@
|
|||||||
|
import React, { useCallback, useEffect, useState } from 'react';
|
||||||
|
import { api } from './api.js';
|
||||||
|
|
||||||
|
// amerc Mansion — a hub of amerc services. First service: Showcase (reverse proxy).
|
||||||
|
export default function Mansion({ auth, setRoute }) {
|
||||||
|
if (!auth.ready) return <div className="ap"><p>Loading…</p></div>;
|
||||||
|
if (!auth.user) return (
|
||||||
|
<div className="ap ap-gate"><h2>The Mansion</h2><p>Log in to use amerc services.</p><button className="px-action" onClick={() => setRoute('signin')}>Go to Login</button></div>
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<div className="ap">
|
||||||
|
<div className="ap-head"><h2>The Mansion</h2><span className="ap-by">{auth.user.handle}'s services</span></div>
|
||||||
|
<p className="ap-hint" style={{ marginBottom: 18 }}>amerc hosts your services behind its edge. Each one lives in a room of the mansion.</p>
|
||||||
|
<div className="mn-grid">
|
||||||
|
<Showcase handle={auth.user.handle.toLowerCase().replace(/[^a-z0-9]/g, '')} />
|
||||||
|
<div className="mn-soon"><div className="mn-soon-ico">🗄️</div><strong>Netdisk relay</strong><span>coming soon</span></div>
|
||||||
|
<div className="mn-soon"><div className="mn-soon-ico">🤖</div><strong>Hosted webagent</strong><span>coming soon</span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Showcase({ handle }) {
|
||||||
|
const [list, setList] = useState([]);
|
||||||
|
const [name, setName] = useState('');
|
||||||
|
const [created, setCreated] = useState(null);
|
||||||
|
const [err, setErr] = useState('');
|
||||||
|
const load = useCallback(async () => { try { const d = await api('/showcase'); setList(d.showcases || []); } catch (e) { setErr(e.message); } }, []);
|
||||||
|
useEffect(() => { load(); }, [load]);
|
||||||
|
const register = async (e) => {
|
||||||
|
e.preventDefault(); setErr('');
|
||||||
|
try { const r = await api('/showcase', { method: 'POST', body: { name } }); setCreated(r); setName(''); load(); }
|
||||||
|
catch (e2) { setErr(e2.message); }
|
||||||
|
};
|
||||||
|
const del = async (id) => { if (!window.confirm('Delete this showcase?')) return; try { await api(`/showcase/${id}`, { method: 'DELETE' }); load(); } catch (e2) { setErr(e2.message); } };
|
||||||
|
const copy = (t) => navigator.clipboard?.writeText(t);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mn-card mn-card-wide">
|
||||||
|
<div className="mn-card-head"><div className="mn-soon-ico">🌐</div><div><strong>Showcase</strong><span className="mn-tag">reverse proxy</span></div></div>
|
||||||
|
<p className="ap-hint">Publish a web service from a local port to a public subdomain. Register a name, then in kebab's <b>delivery center (交付中心)</b> map your local port — requests to the public URL tunnel through your broker.</p>
|
||||||
|
<form className="mn-reg" onSubmit={register}>
|
||||||
|
<span className="mn-prefix">register</span>
|
||||||
|
<input placeholder="show1" value={name} onChange={(e) => setName(e.target.value.toLowerCase().replace(/[^a-z0-9-]/g, ''))} required />
|
||||||
|
<span className="mn-suffix">.{handle}.amerc.ai</span>
|
||||||
|
<button className="px-action px-auth-submit" type="submit">Register</button>
|
||||||
|
</form>
|
||||||
|
{err && <p className="px-auth-err">{err}</p>}
|
||||||
|
{created && (
|
||||||
|
<div className="mn-created">
|
||||||
|
<p>✅ <b>{created.host}</b> reserved · DNS {created.dns}</p>
|
||||||
|
<label className="ap-field">public URL<code className="ap-token" onClick={() => copy(created.url)}>{created.url} ⧉</code></label>
|
||||||
|
<label className="ap-field">broker WS (kebab connects here)<code className="ap-token" onClick={() => copy(created.edgeWs)}>{created.edgeWs} ⧉</code></label>
|
||||||
|
<label className="ap-field">edge token (bind {created.host})<code className="ap-token" onClick={() => copy(created.edgeToken)}>{created.edgeToken.slice(0, 28)}… ⧉</code></label>
|
||||||
|
<p className="ap-hint">In the kebab delivery center: connect a broker to the WS with this token, send <code>{`{type:"bind",host:"${created.host}",port:<localPort>}`}</code>, and your local service is live at the public URL.</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<h4 className="ap-h4">My showcases</h4>
|
||||||
|
{list.map((s) => (
|
||||||
|
<div key={s.id} className="mn-row">
|
||||||
|
<a href={`http://${s.host}/`} target="_blank" rel="noreferrer">{s.host}</a>
|
||||||
|
<span>registered {new Date(s.created_at).toLocaleDateString()}</span>
|
||||||
|
<button className="px-action" onClick={() => del(s.id)}>delete</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{!list.length && <p className="ap-hint">No showcases yet — register one above.</p>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -2,17 +2,19 @@ import React, { useCallback, useEffect, useState } from 'react';
|
|||||||
import { Menu, X } from 'lucide-react';
|
import { Menu, X } from 'lucide-react';
|
||||||
import { useAuth, AuthPanel, AdminConsole } from './Auth.jsx';
|
import { useAuth, AuthPanel, AdminConsole } from './Auth.jsx';
|
||||||
import { AgentBrowse, BoothDashboard } from './AgentPlatform.jsx';
|
import { AgentBrowse, BoothDashboard } from './AgentPlatform.jsx';
|
||||||
|
import Mansion from './Mansion.jsx';
|
||||||
import TavernGame from './TavernGame.jsx';
|
import TavernGame from './TavernGame.jsx';
|
||||||
import { api } from './api.js';
|
import { api } from './api.js';
|
||||||
|
|
||||||
const RELEASE = '0.25.0-lpc';
|
const RELEASE = '0.26.0-mansion';
|
||||||
|
|
||||||
const NAV = [
|
const NAV = [
|
||||||
{ id: 'home', label: 'Tavern' },
|
{ id: 'home', label: 'Tavern' },
|
||||||
{ id: 'agents', label: 'Browse Agents' },
|
{ id: 'agents', label: 'Browse Agents' },
|
||||||
|
{ id: 'mansion', label: 'Mansion' },
|
||||||
{ id: 'booth', label: 'My Booth' },
|
{ id: 'booth', label: 'My Booth' },
|
||||||
];
|
];
|
||||||
const VALID_ROUTES = ['home', 'agents', 'booth', 'admin', 'signin'];
|
const VALID_ROUTES = ['home', 'agents', 'mansion', 'booth', 'admin', 'signin'];
|
||||||
|
|
||||||
function useHashRoute() {
|
function useHashRoute() {
|
||||||
const normalize = () => {
|
const normalize = () => {
|
||||||
@ -92,6 +94,7 @@ function Page({ children }) { return <div className="td-page">{children}</div>;
|
|||||||
function Scene({ route, setRoute, auth }) {
|
function Scene({ route, setRoute, auth }) {
|
||||||
if (route === 'home') return <Home setRoute={setRoute} auth={auth} />;
|
if (route === 'home') return <Home setRoute={setRoute} auth={auth} />;
|
||||||
if (route === 'agents') return <Page><AgentBrowse auth={auth} setRoute={setRoute} /></Page>;
|
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 === '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' }}>
|
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>
|
<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>
|
||||||
|
|||||||
@ -1055,3 +1055,21 @@ button {
|
|||||||
outline: 2px solid #7fe3ff; outline-offset: 1px; filter: brightness(1.18);
|
outline: 2px solid #7fe3ff; outline-offset: 1px; filter: brightness(1.18);
|
||||||
}
|
}
|
||||||
.hero-hot:hover { background: rgba(80,210,255,0.2) !important; box-shadow: 0 0 0 3px rgba(120,225,255,0.85), 0 0 26px rgba(80,210,255,0.6) !important; }
|
.hero-hot:hover { background: rgba(80,210,255,0.2) !important; box-shadow: 0 0 0 3px rgba(120,225,255,0.85), 0 0 26px rgba(80,210,255,0.6) !important; }
|
||||||
|
|
||||||
|
/* mansion (services hub) */
|
||||||
|
.mn-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 16px; }
|
||||||
|
.mn-card { grid-column: 1 / -1; padding: 18px; border-radius: 14px; background: #130d20; border: 1px solid #2a1f3e; }
|
||||||
|
.mn-card-wide { max-width: 720px; }
|
||||||
|
.mn-card-head { display: flex; align-items: center; gap: 12px; margin-bottom: 8px; }
|
||||||
|
.mn-card-head strong { font-size: 17px; color: #fff; display: block; }
|
||||||
|
.mn-soon-ico { font-size: 26px; }
|
||||||
|
.mn-tag { font-size: 11px; color: #27d8ff; }
|
||||||
|
.mn-soon { padding: 18px; border-radius: 14px; background: #0e0a18; border: 1px dashed #2a1f3e; opacity: 0.6; display: flex; flex-direction: column; gap: 4px; align-items: flex-start; }
|
||||||
|
.mn-soon strong { color: #c9b8e6; } .mn-soon span { font-size: 12px; color: #7a6a98; }
|
||||||
|
.mn-reg { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin: 12px 0; }
|
||||||
|
.mn-reg input { padding: 9px 11px; border-radius: 8px; background: #0a0f1a; border: 1px solid #2e2440; color: #eaf2ff; font-family: inherit; width: 130px; }
|
||||||
|
.mn-prefix, .mn-suffix { color: #8a7aa8; font-size: 13px; }
|
||||||
|
.mn-created { background: #0e0a18; border: 1px solid #241a36; border-radius: 10px; padding: 12px; margin: 10px 0; }
|
||||||
|
.mn-created p { margin: 0 0 8px; font-size: 13px; color: #d8c4a0; } .mn-created code { background: #07060c; padding: 1px 5px; border-radius: 4px; color: #7fe9ff; }
|
||||||
|
.mn-row { display: flex; align-items: center; gap: 10px; padding: 9px 0; border-bottom: 1px solid #241a36; font-size: 13px; }
|
||||||
|
.mn-row a { color: #27d8ff; } .mn-row span { color: #8a7aa8; margin-left: auto; }
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user