amerc suite: 2D pixel tavern + zero-dep auth/admin/docs/pm backend
- 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>
This commit is contained in:
commit
b055663372
10
.gitignore
vendored
Normal file
10
.gitignore
vendored
Normal file
@ -0,0 +1,10 @@
|
||||
node_modules/
|
||||
dist/
|
||||
npm-debug.log*
|
||||
|
||||
# Heavy non-source artifacts (kept out of the source repo)
|
||||
ai-deck/
|
||||
public/models/
|
||||
assets/
|
||||
server/data/
|
||||
*.log
|
||||
49
README.md
Normal file
49
README.md
Normal file
@ -0,0 +1,49 @@
|
||||
# amerc
|
||||
|
||||
**amerc** — the agent mercenary tavern. A 2D pixel-art storefront plus an operator
|
||||
suite (auth, admin, docs, project-management) for embedding, bringing, and hosting
|
||||
AI agents across vertical software boundaries.
|
||||
|
||||
Live: <https://amerc.ai>
|
||||
|
||||
## The suite (all on the amerc.ai server)
|
||||
|
||||
| Site | What |
|
||||
|------|------|
|
||||
| `amerc.ai` | 2D Starbound-style pixel tavern (Vite + React, no 3D). Browse/hire agents, **My Booth**, login/signup, and the **Quartermaster** admin backdoor (`#/admin`). |
|
||||
| `docs.amerc.ai` | Markdown documentation space for humans and agents. |
|
||||
| `pm.amerc.ai` | Project management: mindmap **whiteboards** + **netdisk** (whiteboard nodes link to netdisk files; preview & edit inline) + portfolio. |
|
||||
| `git.amerc.ai` | Source hosting (Gitea). |
|
||||
|
||||
One amerc account (cookie `Domain=.amerc.ai`) signs you in across all four.
|
||||
|
||||
## Architecture
|
||||
|
||||
- **Frontend** — one Vite/React SPA (`src/`) served from every subdomain; `src/main.jsx`
|
||||
picks the app by hostname (`Scene2D` / `DocsApp` / `PmApp`). Pixel sprites in
|
||||
`public/scene2d/`.
|
||||
- **Backend** — `server/amerc-api.mjs`: a **zero-dependency** Node service
|
||||
(`node:http` + `node:sqlite` + `node:crypto`). scrypt password hashing, HMAC
|
||||
HttpOnly session cookies. Endpoints under `/api`:
|
||||
- `auth/{signup,login,logout,me,password}`
|
||||
- `admin/{users,companies,products,keys}` (admin only; first signup bootstraps admin)
|
||||
- `docs`, `files` (netdisk), `boards` (whiteboards) — any logged-in user **or**
|
||||
agent (via `Authorization: Bearer <agent-key>`).
|
||||
|
||||
### Agents
|
||||
Admins mint **agent keys** in the Quartermaster console. Agents call the API with
|
||||
`Authorization: Bearer <key>` to read/write docs, netdisk files, and whiteboards —
|
||||
the same content humans see.
|
||||
|
||||
## Develop
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run dev # vite dev server; /api proxies to AMERC_API (default 127.0.0.1:5180)
|
||||
# backend:
|
||||
node --experimental-sqlite server/amerc-api.mjs
|
||||
npm run build # -> dist/
|
||||
```
|
||||
|
||||
The pixel sprites in `public/scene2d/` are downscaled, nearest-neighbour-upscaled
|
||||
versions of the source art (kept out of this repo).
|
||||
21
index.html
Normal file
21
index.html
Normal file
@ -0,0 +1,21 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta
|
||||
name="description"
|
||||
content="amerc is an agent mercenary layer for embedding, bringing, and hosting agents across vertical software boundaries."
|
||||
/>
|
||||
<meta name="version" content="0.20.0-suite" />
|
||||
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Press+Start+2P&display=swap" rel="stylesheet" />
|
||||
<title>amerc | 2D pixel mercenary tavern</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
1696
package-lock.json
generated
Normal file
1696
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
20
package.json
Normal file
20
package.json
Normal file
@ -0,0 +1,20 @@
|
||||
{
|
||||
"name": "amerc-site",
|
||||
"version": "0.20.0-suite",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --host 0.0.0.0",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview --host 0.0.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@vitejs/plugin-react": "^5.0.0",
|
||||
"lucide-react": "^0.468.0",
|
||||
"marked": "^14.1.4",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"three": "^0.184.0",
|
||||
"vite": "^7.0.0"
|
||||
}
|
||||
}
|
||||
7
public/favicon.svg
Normal file
7
public/favicon.svg
Normal file
@ -0,0 +1,7 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||
<rect width="64" height="64" rx="10" fill="#170704"/>
|
||||
<path d="M12 48h40l-6-30H18z" fill="#8b4a22"/>
|
||||
<path d="M20 18h24l4 22H16z" fill="#321207"/>
|
||||
<path d="M27 10h10l9 9-14 14-14-14z" fill="#42dcff"/>
|
||||
<path d="M22 47c4-11 16-11 20 0" fill="none" stroke="#f7bc5d" stroke-width="5" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 383 B |
BIN
public/scene2d/bg.png
Normal file
BIN
public/scene2d/bg.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 115 KiB |
BIN
public/scene2d/dwarf.png
Normal file
BIN
public/scene2d/dwarf.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 35 KiB |
BIN
public/scene2d/elf.png
Normal file
BIN
public/scene2d/elf.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 24 KiB |
BIN
public/scene2d/orc.png
Normal file
BIN
public/scene2d/orc.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 43 KiB |
258
server/amerc-api.mjs
Normal file
258
server/amerc-api.mjs
Normal file
@ -0,0 +1,258 @@
|
||||
// amerc-api — zero-dependency backend: auth + admin + docs + netdisk + whiteboards.
|
||||
// Node 22+ built-ins only: node:http, node:sqlite, node:crypto, node:fs.
|
||||
// Run: node --experimental-sqlite amerc-api.mjs
|
||||
// env: PORT, AMERC_DB, AMERC_SECRET, AMERC_COOKIE_DOMAIN, AMERC_FILES
|
||||
import http from 'node:http';
|
||||
import { DatabaseSync } from 'node:sqlite';
|
||||
import crypto from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
import { existsSync, mkdirSync, createReadStream } from 'node:fs';
|
||||
import { dirname, join, basename } from 'node:path';
|
||||
|
||||
const PORT = Number(process.env.PORT || 5180);
|
||||
const DB_PATH = process.env.AMERC_DB || './data/amerc.db';
|
||||
const FILES_DIR = process.env.AMERC_FILES || './data/netdisk';
|
||||
const SECRET = process.env.AMERC_SECRET || 'dev-insecure-secret-change-me';
|
||||
const COOKIE_DOMAIN = process.env.AMERC_COOKIE_DOMAIN || ''; // e.g. .amerc.ai in prod
|
||||
const COOKIE = 'amerc_session';
|
||||
const DAY = 86400;
|
||||
const MAX_UPLOAD = 16 * 1024 * 1024;
|
||||
|
||||
for (const d of [dirname(DB_PATH), FILES_DIR]) if (!existsSync(d)) mkdirSync(d, { recursive: true });
|
||||
const db = new DatabaseSync(DB_PATH);
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT, email TEXT UNIQUE NOT NULL, handle TEXT NOT NULL,
|
||||
pass TEXT NOT NULL, role TEXT NOT NULL DEFAULT 'member', status TEXT NOT NULL DEFAULT 'active', created_at INTEGER NOT NULL);
|
||||
CREATE TABLE IF NOT EXISTS companies (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, tier TEXT NOT NULL DEFAULT 'embedded',
|
||||
risk TEXT NOT NULL DEFAULT 'green', notes TEXT DEFAULT '', created_at INTEGER NOT NULL);
|
||||
CREATE TABLE IF NOT EXISTS products (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, company_id INTEGER, status TEXT NOT NULL DEFAULT 'draft',
|
||||
price TEXT DEFAULT '', notes TEXT DEFAULT '', created_at INTEGER NOT NULL);
|
||||
CREATE TABLE IF NOT EXISTS api_keys (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, key_hash TEXT NOT NULL, prefix TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL, last_used INTEGER);
|
||||
CREATE TABLE IF NOT EXISTS documents (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT NOT NULL, folder TEXT DEFAULT '', body TEXT DEFAULT '',
|
||||
updated_by TEXT DEFAULT '', created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL);
|
||||
CREATE TABLE IF NOT EXISTS files (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, folder TEXT DEFAULT '', mime TEXT DEFAULT 'application/octet-stream',
|
||||
size INTEGER DEFAULT 0, store TEXT NOT NULL, kind TEXT DEFAULT 'binary', text_body TEXT DEFAULT '',
|
||||
created_by TEXT DEFAULT '', created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL);
|
||||
CREATE TABLE IF NOT EXISTS boards (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, data TEXT DEFAULT '', links TEXT DEFAULT '[]',
|
||||
updated_by TEXT DEFAULT '', created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL);
|
||||
`);
|
||||
|
||||
// ---- crypto -----------------------------------------------------------------
|
||||
function hashPassword(pw) {
|
||||
const salt = crypto.randomBytes(16);
|
||||
return `scrypt$${salt.toString('hex')}$${crypto.scryptSync(pw, salt, 32).toString('hex')}`;
|
||||
}
|
||||
function verifyPassword(pw, stored) {
|
||||
const [scheme, saltHex, hashHex] = String(stored).split('$');
|
||||
if (scheme !== 'scrypt') return false;
|
||||
const hash = crypto.scryptSync(pw, Buffer.from(saltHex, 'hex'), 32);
|
||||
const want = Buffer.from(hashHex, 'hex');
|
||||
return hash.length === want.length && crypto.timingSafeEqual(hash, want);
|
||||
}
|
||||
const b64url = (buf) => Buffer.from(buf).toString('base64url');
|
||||
function signToken(payload) {
|
||||
const body = b64url(JSON.stringify({ ...payload, exp: Math.floor(Date.now() / 1000) + 30 * DAY }));
|
||||
const sig = crypto.createHmac('sha256', SECRET).update(body).digest('base64url');
|
||||
return `${body}.${sig}`;
|
||||
}
|
||||
function verifyToken(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 hashKey = (k) => crypto.createHash('sha256').update(k).digest('hex');
|
||||
|
||||
// ---- http helpers -----------------------------------------------------------
|
||||
function send(res, code, obj, extra = {}) {
|
||||
const data = JSON.stringify(obj);
|
||||
res.writeHead(code, { 'Content-Type': 'application/json', ...extra });
|
||||
res.end(data);
|
||||
}
|
||||
function readBody(req, limit = 1e6) {
|
||||
return new Promise((resolve) => {
|
||||
let raw = ''; let over = false;
|
||||
req.on('data', (c) => { raw += c; if (raw.length > limit) { over = true; req.destroy(); } });
|
||||
req.on('end', () => { if (over) return resolve(null); try { resolve(raw ? JSON.parse(raw) : {}); } catch { resolve({}); } });
|
||||
req.on('error', () => resolve(null));
|
||||
});
|
||||
}
|
||||
function parseCookies(req) {
|
||||
const out = {};
|
||||
(req.headers.cookie || '').split(';').forEach((p) => { const i = p.indexOf('='); if (i > -1) out[p.slice(0, i).trim()] = decodeURIComponent(p.slice(i + 1).trim()); });
|
||||
return out;
|
||||
}
|
||||
const publicUser = (u) => u && { id: u.id, email: u.email, handle: u.handle, role: u.role, status: u.status };
|
||||
function sessionCookie(token) {
|
||||
const dom = COOKIE_DOMAIN ? `; Domain=${COOKIE_DOMAIN}` : '';
|
||||
return `${COOKIE}=${token}; Path=/; HttpOnly; SameSite=Lax${dom}; Max-Age=${30 * DAY}`;
|
||||
}
|
||||
const clearCookie = `${COOKIE}=; Path=/; HttpOnly; SameSite=Lax${COOKIE_DOMAIN ? `; Domain=${COOKIE_DOMAIN}` : ''}; Max-Age=0`;
|
||||
|
||||
// identity: returns { kind:'user', user } | { kind:'agent', name } | null
|
||||
function identify(req) {
|
||||
const auth = req.headers.authorization || '';
|
||||
if (auth.startsWith('Bearer ')) {
|
||||
const key = auth.slice(7).trim();
|
||||
const row = db.prepare('SELECT * FROM api_keys WHERE key_hash=?').get(hashKey(key));
|
||||
if (row) { db.prepare('UPDATE api_keys SET last_used=? WHERE id=?').run(Date.now(), row.id); return { kind: 'agent', name: row.name }; }
|
||||
}
|
||||
const data = verifyToken(parseCookies(req)[COOKIE]);
|
||||
if (data) { const u = db.prepare('SELECT id,email,handle,role,status FROM users WHERE id=?').get(data.uid); if (u) return { kind: 'user', user: u }; }
|
||||
return null;
|
||||
}
|
||||
const who = (id) => (id.kind === 'agent' ? `agent:${id.name}` : id.user.handle);
|
||||
|
||||
// ---- generic collection CRUD (documents / boards) ---------------------------
|
||||
function listRows(table, extraOrder = 'updated_at DESC') {
|
||||
return db.prepare(`SELECT * FROM ${table} ORDER BY ${extraOrder}`).all();
|
||||
}
|
||||
|
||||
const server = http.createServer(async (req, res) => {
|
||||
const url = new URL(req.url, 'http://x');
|
||||
const path = url.pathname.replace(/^\/api/, '') || '/';
|
||||
const method = req.method;
|
||||
let m;
|
||||
try {
|
||||
if (path === '/health') return send(res, 200, { ok: true, service: 'amerc-api', version: 2 });
|
||||
|
||||
// ---------- AUTH ----------
|
||||
if (path === '/auth/signup' && method === 'POST') {
|
||||
const b = await readBody(req); if (!b) return send(res, 400, { ok: false, error: 'bad body' });
|
||||
const { email, password, handle } = b;
|
||||
if (!email || !password || password.length < 6) return send(res, 400, { ok: false, error: 'email and password (min 6) required' });
|
||||
if (db.prepare('SELECT id FROM users WHERE email=?').get(String(email).toLowerCase())) return send(res, 409, { ok: false, error: 'email already registered' });
|
||||
const role = db.prepare('SELECT COUNT(*) n FROM users').get().n === 0 ? 'admin' : 'member';
|
||||
const info = db.prepare('INSERT INTO users(email,handle,pass,role,status,created_at) VALUES(?,?,?,?,?,?)')
|
||||
.run(String(email).toLowerCase(), handle || String(email).split('@')[0], hashPassword(password), role, 'active', Date.now());
|
||||
const u = db.prepare('SELECT id,email,handle,role,status FROM users WHERE id=?').get(Number(info.lastInsertRowid));
|
||||
return send(res, 200, { ok: true, user: publicUser(u) }, { 'Set-Cookie': sessionCookie(signToken({ uid: u.id })) });
|
||||
}
|
||||
if (path === '/auth/login' && method === 'POST') {
|
||||
const b = await readBody(req) || {};
|
||||
const u = db.prepare('SELECT * FROM users WHERE email=?').get(String(b.email || '').toLowerCase());
|
||||
if (!u || !verifyPassword(b.password || '', u.pass)) return send(res, 401, { ok: false, error: 'invalid credentials' });
|
||||
if (u.status !== 'active') return send(res, 403, { ok: false, error: 'account suspended' });
|
||||
return send(res, 200, { ok: true, user: publicUser(u) }, { 'Set-Cookie': sessionCookie(signToken({ uid: u.id })) });
|
||||
}
|
||||
if (path === '/auth/logout' && method === 'POST') return send(res, 200, { ok: true }, { 'Set-Cookie': clearCookie });
|
||||
if (path === '/auth/me' && method === 'GET') { const id = identify(req); return send(res, 200, { ok: true, user: id?.kind === 'user' ? publicUser(id.user) : null, agent: id?.kind === 'agent' ? id.name : null }); }
|
||||
if (path === '/auth/password' && method === 'POST') {
|
||||
const id = identify(req); if (id?.kind !== 'user') return send(res, 401, { ok: false, error: 'login required' });
|
||||
const b = await readBody(req) || {};
|
||||
const u = db.prepare('SELECT * FROM users WHERE id=?').get(id.user.id);
|
||||
if (!verifyPassword(b.currentPassword || '', u.pass)) return send(res, 401, { ok: false, error: 'current password incorrect' });
|
||||
if (!b.newPassword || b.newPassword.length < 6) return send(res, 400, { ok: false, error: 'new password min 6 chars' });
|
||||
db.prepare('UPDATE users SET pass=? WHERE id=?').run(hashPassword(b.newPassword), u.id);
|
||||
return send(res, 200, { ok: true });
|
||||
}
|
||||
|
||||
// ---------- ADMIN (user role=admin) ----------
|
||||
if (path.startsWith('/admin/')) {
|
||||
const id = identify(req);
|
||||
if (id?.kind !== 'user') return send(res, 401, { ok: false, error: 'login required' });
|
||||
if (id.user.role !== 'admin') return send(res, 403, { ok: false, error: 'admin only' });
|
||||
const me = id.user;
|
||||
|
||||
if (path === '/admin/users' && method === 'GET')
|
||||
return send(res, 200, { ok: true, users: db.prepare('SELECT id,email,handle,role,status,created_at FROM users ORDER BY id DESC').all() });
|
||||
if ((m = path.match(/^\/admin\/users\/(\d+)$/)) && method === 'PATCH') {
|
||||
const b = await readBody(req) || {}; const u = db.prepare('SELECT * FROM users WHERE id=?').get(Number(m[1]));
|
||||
if (!u) return send(res, 404, { ok: false, error: 'not found' });
|
||||
db.prepare('UPDATE users SET role=?,status=?,handle=? WHERE id=?').run(b.role || u.role, b.status || u.status, b.handle || u.handle, u.id);
|
||||
return send(res, 200, { ok: true });
|
||||
}
|
||||
if ((m = path.match(/^\/admin\/users\/(\d+)$/)) && method === 'DELETE') {
|
||||
if (Number(m[1]) === me.id) return send(res, 400, { ok: false, error: 'cannot delete yourself' });
|
||||
db.prepare('DELETE FROM users WHERE id=?').run(Number(m[1])); return send(res, 200, { ok: true });
|
||||
}
|
||||
// api keys (agent tokens)
|
||||
if (path === '/admin/keys' && method === 'GET')
|
||||
return send(res, 200, { ok: true, keys: db.prepare('SELECT id,name,prefix,created_at,last_used FROM api_keys ORDER BY id DESC').all() });
|
||||
if (path === '/admin/keys' && method === 'POST') {
|
||||
const b = await readBody(req) || {}; const raw = `amk_${crypto.randomBytes(24).toString('base64url')}`;
|
||||
const info = db.prepare('INSERT INTO api_keys(name,key_hash,prefix,created_at) VALUES(?,?,?,?)').run(b.name || 'agent', hashKey(raw), raw.slice(0, 12), Date.now());
|
||||
return send(res, 200, { ok: true, id: Number(info.lastInsertRowid), key: raw, note: 'store this key now; it is not shown again' });
|
||||
}
|
||||
if ((m = path.match(/^\/admin\/keys\/(\d+)$/)) && method === 'DELETE') { db.prepare('DELETE FROM api_keys WHERE id=?').run(Number(m[1])); return send(res, 200, { ok: true }); }
|
||||
// companies + products generic CRUD
|
||||
for (const [coll, cols] of [['companies', ['name', 'tier', 'risk', 'notes']], ['products', ['name', 'company_id', 'status', 'price', 'notes']]]) {
|
||||
if (path === `/admin/${coll}` && method === 'GET') return send(res, 200, { ok: true, [coll]: db.prepare(`SELECT * FROM ${coll} ORDER BY id DESC`).all() });
|
||||
if (path === `/admin/${coll}` && method === 'POST') {
|
||||
const b = await readBody(req) || {}; const vals = cols.map((c) => b[c] ?? (c === 'company_id' ? null : ''));
|
||||
const info = db.prepare(`INSERT INTO ${coll}(${cols.join(',')},created_at) VALUES(${cols.map(() => '?').join(',')},?)`).run(...vals, Date.now());
|
||||
return send(res, 200, { ok: true, id: Number(info.lastInsertRowid) });
|
||||
}
|
||||
if ((m = path.match(new RegExp(`^/admin/${coll}/(\\d+)$`))) && method === 'PATCH') {
|
||||
const b = await readBody(req) || {}; const row = db.prepare(`SELECT * FROM ${coll} WHERE id=?`).get(Number(m[1]));
|
||||
if (!row) return send(res, 404, { ok: false, error: 'not found' });
|
||||
db.prepare(`UPDATE ${coll} SET ${cols.map((c) => `${c}=?`).join(',')} WHERE id=?`).run(...cols.map((c) => b[c] ?? row[c]), row.id);
|
||||
return send(res, 200, { ok: true });
|
||||
}
|
||||
if ((m = path.match(new RegExp(`^/admin/${coll}/(\\d+)$`))) && method === 'DELETE') { db.prepare(`DELETE FROM ${coll} WHERE id=?`).run(Number(m[1])); return send(res, 200, { ok: true }); }
|
||||
}
|
||||
return send(res, 404, { ok: false, error: 'not found' });
|
||||
}
|
||||
|
||||
// ---------- CONTENT (docs / files / boards): any authenticated identity ----------
|
||||
const id = identify(req);
|
||||
const needAuth = () => send(res, 401, { ok: false, error: 'login or api key required' });
|
||||
|
||||
// DOCUMENTS
|
||||
if (path === '/docs' && method === 'GET') { if (!id) return needAuth(); return send(res, 200, { ok: true, docs: db.prepare('SELECT id,title,folder,updated_by,created_at,updated_at FROM documents ORDER BY folder,title').all() }); }
|
||||
if (path === '/docs' && method === 'POST') { if (!id) return needAuth(); const b = await readBody(req) || {}; const now = Date.now();
|
||||
const info = db.prepare('INSERT INTO documents(title,folder,body,updated_by,created_at,updated_at) VALUES(?,?,?,?,?,?)').run(b.title || 'Untitled', b.folder || '', b.body || '', who(id), now, now);
|
||||
return send(res, 200, { ok: true, id: Number(info.lastInsertRowid) }); }
|
||||
if ((m = path.match(/^\/docs\/(\d+)$/)) && method === 'GET') { if (!id) return needAuth(); const d = db.prepare('SELECT * FROM documents WHERE id=?').get(Number(m[1])); return d ? send(res, 200, { ok: true, doc: d }) : send(res, 404, { ok: false, error: 'not found' }); }
|
||||
if ((m = path.match(/^\/docs\/(\d+)$/)) && method === 'PATCH') { if (!id) return needAuth(); const d = db.prepare('SELECT * FROM documents WHERE id=?').get(Number(m[1])); if (!d) return send(res, 404, { ok: false, error: 'not found' }); const b = await readBody(req) || {};
|
||||
db.prepare('UPDATE documents SET title=?,folder=?,body=?,updated_by=?,updated_at=? WHERE id=?').run(b.title ?? d.title, b.folder ?? d.folder, b.body ?? d.body, who(id), Date.now(), d.id); return send(res, 200, { ok: true }); }
|
||||
if ((m = path.match(/^\/docs\/(\d+)$/)) && method === 'DELETE') { if (id?.kind !== 'user') return send(res, 403, { ok: false, error: 'human login required to delete' }); db.prepare('DELETE FROM documents WHERE id=?').run(Number(m[1])); return send(res, 200, { ok: true }); }
|
||||
|
||||
// FILES (netdisk)
|
||||
if (path === '/files' && method === 'GET') { if (!id) return needAuth(); const folder = url.searchParams.get('folder'); const rows = folder != null ? db.prepare('SELECT id,name,folder,mime,size,kind,created_by,created_at,updated_at FROM files WHERE folder=? ORDER BY name').all(folder) : db.prepare('SELECT id,name,folder,mime,size,kind,created_by,created_at,updated_at FROM files ORDER BY folder,name').all(); return send(res, 200, { ok: true, files: rows }); }
|
||||
if (path === '/files' && method === 'POST') { if (!id) return needAuth(); const b = await readBody(req, MAX_UPLOAD); if (!b) return send(res, 413, { ok: false, error: 'file too large (max 16MB)' });
|
||||
const now = Date.now(); const name = String(b.name || 'file').replace(/[/\\]/g, '_');
|
||||
if (b.kind === 'text' || (typeof b.text === 'string' && b.data == null)) {
|
||||
const text = b.text || ''; const info = db.prepare('INSERT INTO files(name,folder,mime,size,store,kind,text_body,created_by,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?)').run(name, b.folder || '', b.mime || 'text/plain', Buffer.byteLength(text), '', 'text', text, who(id), now, now);
|
||||
return send(res, 200, { ok: true, id: Number(info.lastInsertRowid) });
|
||||
}
|
||||
const buf = Buffer.from(String(b.data || ''), 'base64'); const store = `${now}-${crypto.randomBytes(4).toString('hex')}-${name}`;
|
||||
fs.writeFileSync(join(FILES_DIR, store), buf);
|
||||
const info = db.prepare('INSERT INTO files(name,folder,mime,size,store,kind,created_by,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?)').run(name, b.folder || '', b.mime || 'application/octet-stream', buf.length, store, 'binary', who(id), now, now);
|
||||
return send(res, 200, { ok: true, id: Number(info.lastInsertRowid) }); }
|
||||
if ((m = path.match(/^\/files\/(\d+)\/raw$/)) && method === 'GET') { if (!id) return needAuth(); const f = db.prepare('SELECT * FROM files WHERE id=?').get(Number(m[1])); if (!f) return send(res, 404, { ok: false, error: 'not found' });
|
||||
if (f.kind === 'text') { res.writeHead(200, { 'Content-Type': f.mime || 'text/plain; charset=utf-8' }); return res.end(f.text_body || ''); }
|
||||
const p = join(FILES_DIR, f.store); if (!existsSync(p)) return send(res, 410, { ok: false, error: 'blob missing' });
|
||||
res.writeHead(200, { 'Content-Type': f.mime, 'Content-Length': f.size, 'Content-Disposition': `inline; filename="${basename(f.name)}"` }); return createReadStream(p).pipe(res); }
|
||||
if ((m = path.match(/^\/files\/(\d+)$/)) && method === 'GET') { if (!id) return needAuth(); const f = db.prepare('SELECT * FROM files WHERE id=?').get(Number(m[1])); return f ? send(res, 200, { ok: true, file: f }) : send(res, 404, { ok: false, error: 'not found' }); }
|
||||
if ((m = path.match(/^\/files\/(\d+)$/)) && method === 'PATCH') { if (!id) return needAuth(); const f = db.prepare('SELECT * FROM files WHERE id=?').get(Number(m[1])); if (!f) return send(res, 404, { ok: false, error: 'not found' }); const b = await readBody(req, MAX_UPLOAD) || {};
|
||||
const text = b.text != null && f.kind === 'text' ? b.text : f.text_body;
|
||||
db.prepare('UPDATE files SET name=?,folder=?,text_body=?,size=?,updated_at=? WHERE id=?').run(b.name ?? f.name, b.folder ?? f.folder, text, f.kind === 'text' ? Buffer.byteLength(text) : f.size, Date.now(), f.id); return send(res, 200, { ok: true }); }
|
||||
if ((m = path.match(/^\/files\/(\d+)$/)) && method === 'DELETE') { if (!id) return needAuth(); const f = db.prepare('SELECT * FROM files WHERE id=?').get(Number(m[1])); if (f) { if (f.store) try { fs.unlinkSync(join(FILES_DIR, f.store)); } catch {} db.prepare('DELETE FROM files WHERE id=?').run(f.id); } return send(res, 200, { ok: true }); }
|
||||
|
||||
// BOARDS (whiteboards)
|
||||
if (path === '/boards' && method === 'GET') { if (!id) return needAuth(); return send(res, 200, { ok: true, boards: db.prepare('SELECT id,name,links,updated_by,created_at,updated_at FROM boards ORDER BY updated_at DESC').all() }); }
|
||||
if (path === '/boards' && method === 'POST') { if (!id) return needAuth(); const b = await readBody(req, MAX_UPLOAD) || {}; const now = Date.now();
|
||||
const info = db.prepare('INSERT INTO boards(name,data,links,updated_by,created_at,updated_at) VALUES(?,?,?,?,?,?)').run(b.name || 'Untitled board', b.data || '', JSON.stringify(b.links || []), who(id), now, now); return send(res, 200, { ok: true, id: Number(info.lastInsertRowid) }); }
|
||||
if ((m = path.match(/^\/boards\/(\d+)$/)) && method === 'GET') { if (!id) return needAuth(); const bd = db.prepare('SELECT * FROM boards WHERE id=?').get(Number(m[1])); return bd ? send(res, 200, { ok: true, board: bd }) : send(res, 404, { ok: false, error: 'not found' }); }
|
||||
if ((m = path.match(/^\/boards\/(\d+)$/)) && method === 'PATCH') { if (!id) return needAuth(); const bd = db.prepare('SELECT * FROM boards WHERE id=?').get(Number(m[1])); if (!bd) return send(res, 404, { ok: false, error: 'not found' }); const b = await readBody(req, MAX_UPLOAD) || {};
|
||||
db.prepare('UPDATE boards SET name=?,data=?,links=?,updated_by=?,updated_at=? WHERE id=?').run(b.name ?? bd.name, b.data ?? bd.data, b.links != null ? JSON.stringify(b.links) : bd.links, who(id), Date.now(), bd.id); return send(res, 200, { ok: true }); }
|
||||
if ((m = path.match(/^\/boards\/(\d+)$/)) && method === 'DELETE') { if (!id) return needAuth(); db.prepare('DELETE FROM boards WHERE id=?').run(Number(m[1])); return send(res, 200, { ok: true }); }
|
||||
|
||||
return send(res, 404, { ok: false, error: 'not found' });
|
||||
} catch (err) {
|
||||
return send(res, 500, { ok: false, error: 'server error', detail: String(err && err.message || err) });
|
||||
}
|
||||
});
|
||||
|
||||
server.listen(PORT, '127.0.0.1', () => console.log(`amerc-api v2 listening on 127.0.0.1:${PORT}, db=${DB_PATH}, files=${FILES_DIR}`));
|
||||
3
src/App.jsx
Normal file
3
src/App.jsx
Normal file
@ -0,0 +1,3 @@
|
||||
import Scene2DApp from './Scene2D.jsx';
|
||||
|
||||
export default Scene2DApp;
|
||||
200
src/Auth.jsx
Normal file
200
src/Auth.jsx
Normal file
@ -0,0 +1,200 @@
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { api } from './api.js';
|
||||
|
||||
// Session hook --------------------------------------------------------------
|
||||
export function useAuth() {
|
||||
const [user, setUser] = useState(null);
|
||||
const [ready, setReady] = useState(false);
|
||||
const refresh = useCallback(async () => {
|
||||
try { const d = await api('/auth/me'); setUser(d.user || null); }
|
||||
catch { setUser(null); }
|
||||
finally { setReady(true); }
|
||||
}, []);
|
||||
useEffect(() => { refresh(); }, [refresh]);
|
||||
const logout = useCallback(async () => { try { await api('/auth/logout', { method: 'POST' }); } catch {} setUser(null); }, []);
|
||||
return { user, ready, setUser, refresh, logout };
|
||||
}
|
||||
|
||||
// Login / Signup ------------------------------------------------------------
|
||||
export function AuthPanel({ auth, onDone }) {
|
||||
const [mode, setMode] = useState('login');
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [handle, setHandle] = useState('');
|
||||
const [err, setErr] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
if (auth.user) return <SignedInPanel auth={auth} />;
|
||||
|
||||
const submit = async (e) => {
|
||||
e.preventDefault();
|
||||
setErr(''); setBusy(true);
|
||||
try {
|
||||
const path = mode === 'login' ? '/auth/login' : '/auth/signup';
|
||||
const d = await api(path, { method: 'POST', body: { email, password, handle } });
|
||||
auth.setUser(d.user);
|
||||
onDone && onDone(d.user);
|
||||
} catch (e2) { setErr(e2.message); }
|
||||
finally { setBusy(false); }
|
||||
};
|
||||
|
||||
return (
|
||||
<form className="px-auth" onSubmit={submit}>
|
||||
<div className="px-auth-tabs">
|
||||
<button type="button" className={mode === 'login' ? 'active' : ''} onClick={() => setMode('login')}>Login</button>
|
||||
<button type="button" className={mode === 'signup' ? 'active' : ''} onClick={() => setMode('signup')}>Sign Up</button>
|
||||
</div>
|
||||
<label className="px-field"><span>Guild handle email</span>
|
||||
<input type="email" autoComplete="email" value={email} onChange={(e) => setEmail(e.target.value)} placeholder="operator@vertical-site.ai" required />
|
||||
</label>
|
||||
{mode === 'signup' && (
|
||||
<label className="px-field"><span>Display name</span>
|
||||
<input value={handle} onChange={(e) => setHandle(e.target.value)} placeholder="ironworks" />
|
||||
</label>
|
||||
)}
|
||||
<label className="px-field"><span>Access sigil</span>
|
||||
<input type="password" autoComplete={mode === 'login' ? 'current-password' : 'new-password'} value={password} onChange={(e) => setPassword(e.target.value)} placeholder="min 6 chars" required minLength={6} />
|
||||
</label>
|
||||
{err && <p className="px-auth-err">{err}</p>}
|
||||
<button type="submit" className="px-action px-auth-submit" disabled={busy}>
|
||||
{busy ? '…' : mode === 'login' ? 'Enter Booth' : 'Create Account'}
|
||||
</button>
|
||||
<p className="px-auth-hint">{mode === 'login' ? 'New mercenary? Switch to Sign Up.' : 'The first account becomes Quartermaster (admin).'}</p>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
function SignedInPanel({ auth }) {
|
||||
const [pw, setPw] = useState(false);
|
||||
const [cur, setCur] = useState('');
|
||||
const [nw, setNw] = useState('');
|
||||
const [msg, setMsg] = useState('');
|
||||
const [err, setErr] = useState('');
|
||||
const change = async (e) => {
|
||||
e.preventDefault(); setErr(''); setMsg('');
|
||||
try { await api('/auth/password', { method: 'POST', body: { currentPassword: cur, newPassword: nw } }); setMsg('Password updated.'); setCur(''); setNw(''); setPw(false); }
|
||||
catch (e2) { setErr(e2.message); }
|
||||
};
|
||||
return (
|
||||
<div className="px-auth">
|
||||
<p className="px-auth-hi">Signed in as <strong>{auth.user.handle}</strong> <em>({auth.user.role})</em></p>
|
||||
{msg && <p className="px-auth-hint" style={{ color: '#36f0b0' }}>{msg}</p>}
|
||||
<div className="px-auth-actions">
|
||||
{auth.user.role === 'admin' && <button type="button" className="px-action" onClick={() => { window.location.hash = '#/admin'; }}>Quartermaster</button>}
|
||||
<a className="px-action" href={`https://pm.amerc.ai/`}>PM Site</a>
|
||||
<a className="px-action" href={`https://docs.amerc.ai/`}>Docs</a>
|
||||
<button type="button" className="px-action" onClick={() => setPw((v) => !v)}>Change Password</button>
|
||||
<button type="button" className="px-action" onClick={() => auth.logout()}>Log Out</button>
|
||||
</div>
|
||||
{pw && (
|
||||
<form className="px-auth" onSubmit={change} style={{ marginTop: 8 }}>
|
||||
<label className="px-field"><span>Current password</span>
|
||||
<input type="password" value={cur} onChange={(e) => setCur(e.target.value)} required /></label>
|
||||
<label className="px-field"><span>New password (min 6)</span>
|
||||
<input type="password" value={nw} onChange={(e) => setNw(e.target.value)} required minLength={6} /></label>
|
||||
{err && <p className="px-auth-err">{err}</p>}
|
||||
<button type="submit" className="px-action px-auth-submit">Update Password</button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Admin console (Quartermaster backdoor) ------------------------------------
|
||||
const TABS = [
|
||||
{ key: 'users', label: 'Users', cols: ['handle', 'email', 'role', 'status'] },
|
||||
{ key: 'companies', label: 'Companies', cols: ['name', 'tier', 'risk', 'notes'] },
|
||||
{ key: 'products', label: 'Products', cols: ['name', 'company_id', 'status', 'price', 'notes'] },
|
||||
{ key: 'keys', label: 'Agent Keys', cols: ['name', 'prefix', 'last_used'] },
|
||||
];
|
||||
|
||||
export function AdminConsole({ auth }) {
|
||||
const [tab, setTab] = useState('users');
|
||||
const [rows, setRows] = useState([]);
|
||||
const [err, setErr] = useState('');
|
||||
const [draft, setDraft] = useState({});
|
||||
|
||||
const load = useCallback(async (key) => {
|
||||
setErr('');
|
||||
try { const d = await api(`/admin/${key}`); setRows(d[key] || []); }
|
||||
catch (e) { setErr(e.message); setRows([]); }
|
||||
}, []);
|
||||
useEffect(() => { if (auth.user?.role === 'admin') load(tab); }, [tab, auth.user, load]);
|
||||
|
||||
if (!auth.ready) return <div className="px-admin"><p>Loading…</p></div>;
|
||||
if (!auth.user) {
|
||||
return (
|
||||
<div className="px-admin px-admin-gate">
|
||||
<h3>Backdoor sealed</h3>
|
||||
<p>Quartermaster credentials required.</p>
|
||||
<button className="px-action" onClick={() => { window.location.hash = '#/signin'; }}>Go to Login</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (auth.user.role !== 'admin') {
|
||||
return <div className="px-admin px-admin-gate"><h3>Not authorised</h3><p>{auth.user.handle} is a member, not Quartermaster.</p></div>;
|
||||
}
|
||||
|
||||
const meta = TABS.find((t) => t.key === tab);
|
||||
const editable = meta.cols.filter((c) => tab !== 'users' || true);
|
||||
|
||||
const create = async () => {
|
||||
try {
|
||||
const r = await api(`/admin/${tab}`, { method: 'POST', body: draft });
|
||||
if (tab === 'keys' && r.key) window.alert(`Agent key (copy now, shown once):\n\n${r.key}`);
|
||||
setDraft({}); load(tab);
|
||||
} catch (e) { setErr(e.message); }
|
||||
};
|
||||
const patch = async (id, patchBody) => {
|
||||
try { await api(`/admin/${tab}/${id}`, { method: 'PATCH', body: patchBody }); load(tab); }
|
||||
catch (e) { setErr(e.message); }
|
||||
};
|
||||
const del = async (id) => {
|
||||
if (!window.confirm('Delete this record?')) return;
|
||||
try { await api(`/admin/${tab}/${id}`, { method: 'DELETE' }); load(tab); }
|
||||
catch (e) { setErr(e.message); }
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="px-admin">
|
||||
<div className="px-admin-tabs">
|
||||
{TABS.map((t) => (
|
||||
<button key={t.key} className={tab === t.key ? 'active' : ''} onClick={() => setTab(t.key)} type="button">{t.label}</button>
|
||||
))}
|
||||
<span className="px-admin-who">{auth.user.handle} · admin</span>
|
||||
</div>
|
||||
{err && <p className="px-auth-err">{err}</p>}
|
||||
<div className="px-admin-table">
|
||||
<div className="px-admin-row px-admin-head">
|
||||
{meta.cols.map((c) => <span key={c}>{c}</span>)}
|
||||
<span>actions</span>
|
||||
</div>
|
||||
{rows.map((r) => (
|
||||
<div className="px-admin-row" key={r.id}>
|
||||
{meta.cols.map((c) => {
|
||||
if (tab === 'users' && c === 'role') return (
|
||||
<select key={c} value={r.role} onChange={(e) => patch(r.id, { role: e.target.value })}>
|
||||
<option value="member">member</option><option value="admin">admin</option>
|
||||
</select>);
|
||||
if (tab === 'users' && c === 'status') return (
|
||||
<select key={c} value={r.status} onChange={(e) => patch(r.id, { status: e.target.value })}>
|
||||
<option value="active">active</option><option value="suspended">suspended</option>
|
||||
</select>);
|
||||
return <span key={c} title={String(r[c] ?? '')}>{String(r[c] ?? '')}</span>;
|
||||
})}
|
||||
<span className="px-admin-acts"><button type="button" onClick={() => del(r.id)}>✕</button></span>
|
||||
</div>
|
||||
))}
|
||||
{!rows.length && <div className="px-admin-row px-admin-empty">no records</div>}
|
||||
</div>
|
||||
{tab !== 'users' && (
|
||||
<div className="px-admin-new">
|
||||
{(tab === 'keys' ? ['name'] : editable).map((c) => (
|
||||
<input key={c} placeholder={tab === 'keys' ? 'agent key name' : c} value={draft[c] ?? ''} onChange={(e) => setDraft((d) => ({ ...d, [c]: e.target.value }))} />
|
||||
))}
|
||||
<button type="button" className="px-action" onClick={create}>{tab === 'keys' ? '+ Mint Key' : '+ Add'}</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
64
src/ConsoleShell.jsx
Normal file
64
src/ConsoleShell.jsx
Normal file
@ -0,0 +1,64 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useAuth } from './Auth.jsx';
|
||||
|
||||
const APPS = [
|
||||
{ label: 'Tavern', href: 'https://amerc.ai/' },
|
||||
{ label: 'Docs', href: 'https://docs.amerc.ai/' },
|
||||
{ label: 'PM', href: 'https://pm.amerc.ai/' },
|
||||
{ label: 'Git', href: 'https://git.amerc.ai/' },
|
||||
];
|
||||
|
||||
function ConsoleLogin({ auth }) {
|
||||
const [mode, setMode] = useState('login');
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [handle, setHandle] = useState('');
|
||||
const [err, setErr] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const submit = async (e) => {
|
||||
e.preventDefault(); setErr(''); setBusy(true);
|
||||
try {
|
||||
const { api } = await import('./api.js');
|
||||
const d = await api(mode === 'login' ? '/auth/login' : '/auth/signup', { method: 'POST', body: { email, password, handle } });
|
||||
auth.setUser(d.user);
|
||||
} catch (e2) { setErr(e2.message); } finally { setBusy(false); }
|
||||
};
|
||||
return (
|
||||
<div className="cs-login-wrap">
|
||||
<form className="cs-login" onSubmit={submit}>
|
||||
<div className="cs-brand"><span className="cs-gem" />amerc</div>
|
||||
<div className="cs-tabs">
|
||||
<button type="button" className={mode === 'login' ? 'on' : ''} onClick={() => setMode('login')}>Login</button>
|
||||
<button type="button" className={mode === 'signup' ? 'on' : ''} onClick={() => setMode('signup')}>Sign Up</button>
|
||||
</div>
|
||||
<input type="email" placeholder="email" value={email} onChange={(e) => setEmail(e.target.value)} required />
|
||||
{mode === 'signup' && <input placeholder="display name" value={handle} onChange={(e) => setHandle(e.target.value)} />}
|
||||
<input type="password" placeholder="password (min 6)" value={password} onChange={(e) => setPassword(e.target.value)} required minLength={6} />
|
||||
{err && <p className="cs-err">{err}</p>}
|
||||
<button type="submit" className="cs-btn cs-primary" disabled={busy}>{busy ? '…' : mode === 'login' ? 'Enter' : 'Create account'}</button>
|
||||
<p className="cs-hint">One amerc account across tavern, docs, PM and git.</p>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ConsoleShell({ title, accent = '#27d8ff', current, children }) {
|
||||
const auth = useAuth();
|
||||
if (!auth.ready) return <div className="cs-app"><div className="cs-loading">Loading…</div></div>;
|
||||
if (!auth.user) return <div className="cs-app" style={{ '--accent': accent }}><ConsoleLogin auth={auth} /></div>;
|
||||
return (
|
||||
<div className="cs-app" style={{ '--accent': accent }}>
|
||||
<header className="cs-top">
|
||||
<div className="cs-brand"><span className="cs-gem" />amerc<small>{title}</small></div>
|
||||
<nav className="cs-appnav">
|
||||
{APPS.map((a) => <a key={a.label} href={a.href} className={a.label.toLowerCase() === current ? 'on' : ''}>{a.label}</a>)}
|
||||
</nav>
|
||||
<div className="cs-user">
|
||||
<span>{auth.user.handle}{auth.user.role === 'admin' ? ' · admin' : ''}</span>
|
||||
<button type="button" onClick={() => auth.logout()}>Log out</button>
|
||||
</div>
|
||||
</header>
|
||||
<main className="cs-main">{typeof children === 'function' ? children(auth) : children}</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
86
src/DocsApp.jsx
Normal file
86
src/DocsApp.jsx
Normal file
@ -0,0 +1,86 @@
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { marked } from 'marked';
|
||||
import ConsoleShell from './ConsoleShell.jsx';
|
||||
import { api } from './api.js';
|
||||
|
||||
marked.setOptions({ breaks: true });
|
||||
|
||||
function DocsWorkspace() {
|
||||
const [docs, setDocs] = useState([]);
|
||||
const [sel, setSel] = useState(null);
|
||||
const [doc, setDoc] = useState(null);
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [draft, setDraft] = useState({ title: '', folder: '', body: '' });
|
||||
const [err, setErr] = useState('');
|
||||
const [q, setQ] = useState('');
|
||||
|
||||
const loadList = useCallback(async () => {
|
||||
try { const d = await api('/docs'); setDocs(d.docs || []); } catch (e) { setErr(e.message); }
|
||||
}, []);
|
||||
useEffect(() => { loadList(); }, [loadList]);
|
||||
useEffect(() => {
|
||||
if (sel == null) { setDoc(null); return; }
|
||||
api(`/docs/${sel}`).then((d) => { setDoc(d.doc); setDraft({ title: d.doc.title, folder: d.doc.folder, body: d.doc.body }); setEditing(false); }).catch((e) => setErr(e.message));
|
||||
}, [sel]);
|
||||
|
||||
const create = async () => {
|
||||
const title = window.prompt('Document title', 'New document'); if (!title) return;
|
||||
try { const r = await api('/docs', { method: 'POST', body: { title, folder: '', body: `# ${title}\n\n` } }); await loadList(); setSel(r.id); setEditing(true); } catch (e) { setErr(e.message); }
|
||||
};
|
||||
const save = async () => { try { await api(`/docs/${sel}`, { method: 'PATCH', body: draft }); await loadList(); setDoc({ ...doc, ...draft }); setEditing(false); } catch (e) { setErr(e.message); } };
|
||||
const del = async () => { if (!window.confirm('Delete document?')) return; try { await api(`/docs/${sel}`, { method: 'DELETE' }); setSel(null); loadList(); } catch (e) { setErr(e.message); } };
|
||||
|
||||
const filtered = docs.filter((d) => (d.title + ' ' + (d.folder || '')).toLowerCase().includes(q.toLowerCase()));
|
||||
const folders = {};
|
||||
filtered.forEach((d) => { (folders[d.folder || ''] = folders[d.folder || ''] || []).push(d); });
|
||||
|
||||
return (
|
||||
<div className="docs">
|
||||
<aside className="docs-side">
|
||||
<div className="docs-side-top">
|
||||
<input placeholder="search docs…" value={q} onChange={(e) => setQ(e.target.value)} />
|
||||
<button className="cs-btn cs-primary" onClick={create}>+ New</button>
|
||||
</div>
|
||||
<div className="docs-tree">
|
||||
{Object.keys(folders).sort().map((f) => (
|
||||
<div key={f} className="docs-folder">
|
||||
<div className="docs-folder-name">{f || '📁 root'}</div>
|
||||
{folders[f].map((d) => (
|
||||
<button key={d.id} className={`docs-item${sel === d.id ? ' on' : ''}`} onClick={() => setSel(d.id)}>{d.title}</button>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
{!filtered.length && <p className="cs-hint">No documents yet. Create one — humans and agents (via API key) can both read & write here.</p>}
|
||||
</div>
|
||||
</aside>
|
||||
<section className="docs-main">
|
||||
{err && <p className="cs-err">{err}</p>}
|
||||
{!doc && <div className="docs-empty">Select or create a document.</div>}
|
||||
{doc && (
|
||||
<>
|
||||
<div className="docs-head">
|
||||
{editing
|
||||
? <input className="docs-title-input" value={draft.title} onChange={(e) => setDraft({ ...draft, title: e.target.value })} />
|
||||
: <h2>{doc.title}</h2>}
|
||||
<div className="docs-actions">
|
||||
{editing && <input className="docs-folder-input" placeholder="folder" value={draft.folder} onChange={(e) => setDraft({ ...draft, folder: e.target.value })} />}
|
||||
{!editing && <button className="cs-btn" onClick={() => setEditing(true)}>Edit</button>}
|
||||
{editing && <button className="cs-btn cs-primary" onClick={save}>Save</button>}
|
||||
{editing && <button className="cs-btn" onClick={() => { setEditing(false); setDraft({ title: doc.title, folder: doc.folder, body: doc.body }); }}>Cancel</button>}
|
||||
<button className="cs-btn" onClick={del}>Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
<p className="docs-byline">updated by {doc.updated_by || '—'}</p>
|
||||
{editing
|
||||
? <textarea className="docs-editor" value={draft.body} onChange={(e) => setDraft({ ...draft, body: e.target.value })} />
|
||||
: <article className="docs-render" dangerouslySetInnerHTML={{ __html: marked.parse(doc.body || '') }} />}
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function DocsApp() {
|
||||
return <ConsoleShell title="docs" accent="#27d8ff" current="docs">{() => <DocsWorkspace />}</ConsoleShell>;
|
||||
}
|
||||
1500
src/GemhallScene.jsx
Normal file
1500
src/GemhallScene.jsx
Normal file
File diff suppressed because it is too large
Load Diff
2926
src/ModelScene.jsx
Normal file
2926
src/ModelScene.jsx
Normal file
File diff suppressed because it is too large
Load Diff
104
src/Netdisk.jsx
Normal file
104
src/Netdisk.jsx
Normal file
@ -0,0 +1,104 @@
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { api, fileUrl, fileToBase64 } from './api.js';
|
||||
|
||||
const isImage = (m) => /^image\//.test(m || '');
|
||||
const isText = (f) => f.kind === 'text' || /^text\/|json|markdown|javascript|xml|csv/.test(f.mime || '');
|
||||
const human = (n) => (n < 1024 ? `${n} B` : n < 1048576 ? `${(n / 1024).toFixed(1)} KB` : `${(n / 1048576).toFixed(1)} MB`);
|
||||
|
||||
// Netdisk file browser. `onPick` (optional) turns rows into a picker for the whiteboard.
|
||||
export default function Netdisk({ onPick, compact }) {
|
||||
const [files, setFiles] = useState([]);
|
||||
const [folder, setFolder] = useState('');
|
||||
const [sel, setSel] = useState(null);
|
||||
const [err, setErr] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const fileInput = useRef(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setErr('');
|
||||
try { const d = await api('/files'); setFiles(d.files || []); } catch (e) { setErr(e.message); }
|
||||
}, []);
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const folders = Array.from(new Set(['', ...files.map((f) => f.folder || '')])).sort();
|
||||
const shown = files.filter((f) => (folder ? (f.folder || '') === folder : true));
|
||||
|
||||
const upload = async (fileList) => {
|
||||
setBusy(true); setErr('');
|
||||
try {
|
||||
for (const file of fileList) {
|
||||
const data = await fileToBase64(file);
|
||||
await api('/files', { method: 'POST', body: { name: file.name, folder, mime: file.type || 'application/octet-stream', data } });
|
||||
}
|
||||
await load();
|
||||
} catch (e) { setErr(e.message); } finally { setBusy(false); }
|
||||
};
|
||||
const newTextFile = async () => {
|
||||
const name = window.prompt('New text file name', 'note.md'); if (!name) return;
|
||||
try { await api('/files', { method: 'POST', body: { name, folder, kind: 'text', text: '', mime: name.endsWith('.md') ? 'text/markdown' : 'text/plain' } }); load(); }
|
||||
catch (e) { setErr(e.message); }
|
||||
};
|
||||
const del = async (id) => { if (!window.confirm('Delete file?')) return; try { await api(`/files/${id}`, { method: 'DELETE' }); setSel(null); load(); } catch (e) { setErr(e.message); } };
|
||||
|
||||
return (
|
||||
<div className={`nd ${compact ? 'nd-compact' : ''}`}>
|
||||
<div className="nd-bar">
|
||||
<select value={folder} onChange={(e) => setFolder(e.target.value)}>
|
||||
{folders.map((f) => <option key={f} value={f}>{f || '— all / root —'}</option>)}
|
||||
</select>
|
||||
<input className="nd-folder-input" placeholder="folder for new uploads" value={folder} onChange={(e) => setFolder(e.target.value)} />
|
||||
<button className="cs-btn" onClick={() => fileInput.current?.click()} disabled={busy}>{busy ? '…' : 'Upload'}</button>
|
||||
<button className="cs-btn" onClick={newTextFile}>New text</button>
|
||||
<input ref={fileInput} type="file" multiple hidden onChange={(e) => { upload([...e.target.files]); e.target.value = ''; }} />
|
||||
</div>
|
||||
{err && <p className="cs-err">{err}</p>}
|
||||
<div className="nd-grid">
|
||||
<div className="nd-list">
|
||||
{shown.map((f) => (
|
||||
<div key={f.id} className={`nd-row${sel?.id === f.id ? ' on' : ''}`} onClick={() => setSel(f)}>
|
||||
<span className="nd-icon">{isImage(f.mime) ? '🖼' : isText(f) ? '📄' : '📦'}</span>
|
||||
<span className="nd-name" title={f.name}>{f.name}</span>
|
||||
<span className="nd-meta">{f.folder || '/'} · {human(f.size)}</span>
|
||||
{onPick && <button className="cs-btn nd-pick" onClick={(e) => { e.stopPropagation(); onPick(f); }}>link</button>}
|
||||
<button className="nd-del" onClick={(e) => { e.stopPropagation(); del(f.id); }}>✕</button>
|
||||
</div>
|
||||
))}
|
||||
{!shown.length && <div className="nd-empty">no files — upload or create one</div>}
|
||||
</div>
|
||||
{!compact && <FilePreview file={sel} onChange={load} />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function FilePreview({ file, onChange }) {
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [text, setText] = useState('');
|
||||
const [err, setErr] = useState('');
|
||||
useEffect(() => {
|
||||
setEditing(false); setErr('');
|
||||
if (file && (file.kind === 'text')) {
|
||||
api(`/files/${file.id}`).then((d) => setText(d.file.text_body || '')).catch((e) => setErr(e.message));
|
||||
}
|
||||
}, [file]);
|
||||
if (!file) return <div className="nd-preview nd-preview-empty">select a file</div>;
|
||||
const save = async () => { try { await api(`/files/${file.id}`, { method: 'PATCH', body: { text } }); setEditing(false); onChange && onChange(); } catch (e) { setErr(e.message); } };
|
||||
return (
|
||||
<div className="nd-preview">
|
||||
<div className="nd-preview-head">
|
||||
<strong>{file.name}</strong>
|
||||
<span>{file.mime} · {human(file.size)}</span>
|
||||
<a className="cs-btn" href={fileUrl(file.id)} target="_blank" rel="noreferrer">Open ↗</a>
|
||||
{file.kind === 'text' && !editing && <button className="cs-btn" onClick={() => setEditing(true)}>Edit</button>}
|
||||
{editing && <button className="cs-btn cs-primary" onClick={save}>Save</button>}
|
||||
</div>
|
||||
{err && <p className="cs-err">{err}</p>}
|
||||
<div className="nd-preview-body">
|
||||
{isImage(file.mime) && <img src={fileUrl(file.id)} alt={file.name} className="nd-img" />}
|
||||
{file.kind === 'text' && editing && <textarea className="nd-text" value={text} onChange={(e) => setText(e.target.value)} />}
|
||||
{file.kind === 'text' && !editing && <pre className="nd-textview">{text}</pre>}
|
||||
{!isImage(file.mime) && file.kind !== 'text' && <div className="nd-binary">Binary file — <a href={fileUrl(file.id)} target="_blank" rel="noreferrer">download / open</a></div>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
83
src/PmApp.jsx
Normal file
83
src/PmApp.jsx
Normal file
@ -0,0 +1,83 @@
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import ConsoleShell from './ConsoleShell.jsx';
|
||||
import Netdisk from './Netdisk.jsx';
|
||||
import Whiteboard from './Whiteboard.jsx';
|
||||
import { api } from './api.js';
|
||||
|
||||
function BoardsTab() {
|
||||
const [boards, setBoards] = useState([]);
|
||||
const [open, setOpen] = useState(null);
|
||||
const [err, setErr] = useState('');
|
||||
const load = useCallback(async () => { try { const d = await api('/boards'); setBoards(d.boards || []); } catch (e) { setErr(e.message); } }, []);
|
||||
useEffect(() => { load(); }, [load]);
|
||||
const create = async () => {
|
||||
const name = window.prompt('Whiteboard name', 'New mindmap'); if (!name) return;
|
||||
try { const r = await api('/boards', { method: 'POST', body: { name, data: JSON.stringify({ nodes: [], edges: [] }) } }); await load(); setOpen(r.id); } catch (e) { setErr(e.message); }
|
||||
};
|
||||
const del = async (id) => { if (!window.confirm('Delete board?')) return; try { await api(`/boards/${id}`, { method: 'DELETE' }); load(); } catch (e) { setErr(e.message); } };
|
||||
if (open) return <Whiteboard boardId={open} onClose={() => { setOpen(null); load(); }} />;
|
||||
return (
|
||||
<div className="pm-cards">
|
||||
<div className="pm-cards-head"><h2>Whiteboards</h2><button className="cs-btn cs-primary" onClick={create}>+ New mindmap</button></div>
|
||||
{err && <p className="cs-err">{err}</p>}
|
||||
<div className="pm-grid">
|
||||
{boards.map((b) => (
|
||||
<div key={b.id} className="pm-card" onClick={() => setOpen(b.id)}>
|
||||
<div className="pm-card-ico">🧠</div>
|
||||
<strong>{b.name}</strong>
|
||||
<span>{(JSON.parse(b.links || '[]') || []).length} linked files</span>
|
||||
<small>updated {new Date(b.updated_at).toLocaleString()} · {b.updated_by}</small>
|
||||
<button className="pm-card-del" onClick={(e) => { e.stopPropagation(); del(b.id); }}>✕</button>
|
||||
</div>
|
||||
))}
|
||||
{!boards.length && <p className="cs-hint">No whiteboards yet. Create a mindmap and link netdisk files to its nodes.</p>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PortfolioTab() {
|
||||
const [companies, setCompanies] = useState([]);
|
||||
const [products, setProducts] = useState([]);
|
||||
const [err, setErr] = useState('');
|
||||
useEffect(() => {
|
||||
Promise.allSettled([api('/admin/companies'), api('/admin/products')]).then(([c, p]) => {
|
||||
if (c.status === 'fulfilled') setCompanies(c.value.companies || []);
|
||||
if (p.status === 'fulfilled') setProducts(p.value.products || []);
|
||||
if (c.status === 'rejected') setErr('Portfolio is admin-only. Ask an admin for access.');
|
||||
});
|
||||
}, []);
|
||||
return (
|
||||
<div className="pm-portfolio">
|
||||
<h2>Portfolio</h2>
|
||||
{err && <p className="cs-hint">{err}</p>}
|
||||
<div className="pm-port-cols">
|
||||
<div><h3>Companies</h3>{companies.map((c) => <div key={c.id} className="pm-port-row"><strong>{c.name}</strong><span>{c.tier}</span><em className={`risk-${c.risk}`}>{c.risk}</em></div>)}{!companies.length && <p className="cs-hint">—</p>}</div>
|
||||
<div><h3>Products</h3>{products.map((p) => <div key={p.id} className="pm-port-row"><strong>{p.name}</strong><span>{p.status}</span><em>{p.price}</em></div>)}{!products.length && <p className="cs-hint">—</p>}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const TABS = [
|
||||
{ key: 'boards', label: 'Whiteboards', el: <BoardsTab /> },
|
||||
{ key: 'disk', label: 'Netdisk', el: <div className="pm-disk"><h2>Netdisk</h2><Netdisk /></div> },
|
||||
{ key: 'portfolio', label: 'Portfolio', el: <PortfolioTab /> },
|
||||
];
|
||||
|
||||
function PmWorkspace() {
|
||||
const [tab, setTab] = useState('boards');
|
||||
return (
|
||||
<div className="pm">
|
||||
<nav className="pm-nav">
|
||||
{TABS.map((t) => <button key={t.key} className={tab === t.key ? 'on' : ''} onClick={() => setTab(t.key)}>{t.label}</button>)}
|
||||
<a className="pm-nav-ext" href="https://docs.amerc.ai/">Docs ↗</a>
|
||||
</nav>
|
||||
<div className="pm-body">{TABS.find((t) => t.key === tab)?.el}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function PmApp() {
|
||||
return <ConsoleShell title="pm" accent="#36f0b0" current="pm">{() => <PmWorkspace />}</ConsoleShell>;
|
||||
}
|
||||
308
src/Scene2D.jsx
Normal file
308
src/Scene2D.jsx
Normal file
@ -0,0 +1,308 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
958
src/SplashScene.jsx
Normal file
958
src/SplashScene.jsx
Normal file
@ -0,0 +1,958 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import * as THREE from 'three';
|
||||
import { Menu, X } from 'lucide-react';
|
||||
import tavernBackdropUrl from '../assets/scene2d/tavern-bg-039.png';
|
||||
import orcBartenderUrl from '../assets/scene2d/orc-bartender-trim-081.png';
|
||||
import elfGuideUrl from '../assets/scene2d/elf-guide-trim-081.png';
|
||||
import dwarfPatronUrl from '../assets/scene2d/dwarf-patron-trim-081.png';
|
||||
|
||||
const RELEASE = '0.8.4-splash';
|
||||
const BACKDROP_ASPECT = 1672 / 941;
|
||||
const ORC_ASPECT = 1024 / 1387;
|
||||
const ELF_ASPECT = 777 / 1276;
|
||||
const DWARF_ASPECT = 939 / 1332;
|
||||
const SCREEN_ASPECT = 1220 / 520;
|
||||
const BOOTH_ASPECT = 980 / 250;
|
||||
const SPEECH_ASPECT = 820 / 520;
|
||||
|
||||
const NAV_ROUTES = [
|
||||
{ id: 'home', label: 'Solution' },
|
||||
{ id: 'agents', label: 'Staff' },
|
||||
{ id: 'contracts', label: 'Pricing' },
|
||||
{ id: 'signin', label: 'Sign Up' },
|
||||
{ id: 'signin', label: 'Login' },
|
||||
{ id: 'booth', label: 'My Booth' },
|
||||
];
|
||||
|
||||
const WANTED_AGENTS = [
|
||||
{ name: 'Velvet Hex', role: 'consent guide', skill: 'BYOA links', bounty: '14c/h', initials: 'VH', tint: '#b989ff' },
|
||||
{ name: 'Socket Wraith', role: 'tinybox bridge', skill: 'port mapping', bounty: '16c/h', initials: 'SW', tint: '#54d9ff' },
|
||||
{ name: 'Iron Tusk', role: 'ops bartender', skill: 'publish agent', bounty: '18c/h', initials: 'IT', tint: '#7be36d' },
|
||||
{ name: 'Amber Ledger', role: 'contract clerk', skill: 'pricing audit', bounty: '12c/h', initials: 'AL', tint: '#ffc45f' },
|
||||
{ name: 'Northstar Fixer', role: 'merchant ops', skill: 'session scope', bounty: '20c/h', initials: 'NF', tint: '#a7ecff' },
|
||||
{ name: 'Cinder Relay', role: 'delivery relay', skill: 'remote use', bounty: '15c/h', initials: 'CR', tint: '#ff8564' },
|
||||
];
|
||||
|
||||
const SPEECH_STEPS = {
|
||||
home: ['Publish Agent', 'Remote Use', 'Port Mapping', 'Delivery Relay', 'Run Directly'],
|
||||
agents: ['Inspect Roster', 'Check Skills', 'Open Contract', 'Hire Session', 'Audit Scope'],
|
||||
booth: ['Attach Skills', 'Set Runtime', 'Approve Budget', 'Launch Booth', 'Watch Output'],
|
||||
contracts: ['Scope Bounty', 'Limit Spend', 'Rotate Keys', 'Review Logs', 'Renew Term'],
|
||||
signin: ['Create Pass', 'Link Agent', 'Bring Token', 'Confirm Consent', 'Enter Booth'],
|
||||
admin: ['Tenant Keys', 'Session Health', 'Runtime Gate', 'Audit Queue', 'Deploy Static'],
|
||||
};
|
||||
|
||||
const ASSET_AUDIT = [
|
||||
{ area: 'tavern backdrop', asset: 'assets/scene2d/tavern-bg-039.png', source: 'imagegen reference-guided game splash art', rating: '8.8/10' },
|
||||
{ area: 'orc bartender', asset: 'assets/scene2d/orc-bartender-trim-081.png', source: 'imagegen character sprite with chroma-key alpha, alpha-trimmed for framing', rating: '8.6/10' },
|
||||
{ area: 'elf guide', asset: 'assets/scene2d/elf-guide-trim-081.png', source: 'imagegen character sprite with chroma-key alpha, alpha-trimmed for framing', rating: '8.7/10' },
|
||||
{ area: 'dwarf patron', asset: 'assets/scene2d/dwarf-patron-trim-081.png', source: 'imagegen character sprite with chroma-key alpha, alpha-trimmed for framing', rating: '8.5/10' },
|
||||
{ area: 'browse agents screen', asset: 'animated CanvasTexture wanted board', source: 'repo-generated canvas UI', rating: '8.3/10' },
|
||||
{ area: 'enter booth sign', asset: 'animated CanvasTexture neon sign', source: 'repo-generated canvas UI', rating: '8.2/10' },
|
||||
{ area: 'topbar', asset: 'CSS carved wood/gem nav', source: 'repo-generated CSS material', rating: '8.0/10' },
|
||||
];
|
||||
|
||||
const IDLE_ANIMATIONS = [
|
||||
'orc sprite: breathing scale, shoulder sway, mug glow pulse, cyan armor flicker',
|
||||
'elf sprite: cape sway, hand presentation bob, gem pulse, speech bubble pulse',
|
||||
'dwarf sprite: seated toast bob, mug lift beat, backpack core glow',
|
||||
'browse agents screen: wanted roster scroll, badge cycling, coin burst motion',
|
||||
'enter booth sign: neon tube flicker, scanline sweep, floor ring pulse',
|
||||
'environment: lantern glow pulses, crystal glints, ember drift, drag parallax',
|
||||
];
|
||||
|
||||
export default function SplashSceneApp() {
|
||||
const [route, setRoute] = useHashRoute();
|
||||
|
||||
return (
|
||||
<main className="amerc-app gemhall-app">
|
||||
<h1 className="sr-only">amerc agent mercenary tavern</h1>
|
||||
<WoodTopbar route={route} setRoute={setRoute} />
|
||||
<SplashScene route={route} setRoute={setRoute} />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
function useHashRoute() {
|
||||
const normalize = () => {
|
||||
const raw = window.location.hash.replace(/^#\/?/, '');
|
||||
return ['home', 'agents', 'booth', 'contracts', 'admin', 'signin'].includes(raw) ? raw : 'home';
|
||||
};
|
||||
const [route, setRouteState] = useState(normalize);
|
||||
|
||||
useEffect(() => {
|
||||
const onHash = () => setRouteState(normalize());
|
||||
window.addEventListener('hashchange', onHash);
|
||||
return () => window.removeEventListener('hashchange', onHash);
|
||||
}, []);
|
||||
|
||||
const setRoute = (nextRoute) => {
|
||||
window.location.hash = `/${nextRoute}`;
|
||||
setRouteState(nextRoute);
|
||||
};
|
||||
|
||||
return [route, setRoute];
|
||||
}
|
||||
|
||||
function WoodTopbar({ route, setRoute }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const nav = (
|
||||
<>
|
||||
{NAV_ROUTES.map((item, index) => (
|
||||
<button
|
||||
className={route === item.id ? 'active' : ''}
|
||||
key={`${item.id}-${item.label}-${index}`}
|
||||
onClick={() => {
|
||||
setRoute(item.id);
|
||||
setOpen(false);
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
{item.label}
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
className={route === 'admin' ? 'active quartermaster-link' : 'quartermaster-link'}
|
||||
onClick={() => {
|
||||
setRoute('admin');
|
||||
setOpen(false);
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
Quartermaster
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<header className="wood-topbar">
|
||||
<button className="logo-plaque" onClick={() => setRoute('home')} type="button" aria-label="amerc home">
|
||||
<span className="logo-gem" aria-hidden="true" />
|
||||
<strong>amerc</strong>
|
||||
<small>agent mercenary</small>
|
||||
<em>v{RELEASE}</em>
|
||||
</button>
|
||||
<nav className="wood-nav" aria-label="Primary navigation">
|
||||
{nav}
|
||||
</nav>
|
||||
<button className="wood-menu" onClick={() => setOpen((value) => !value)} type="button" aria-label="Toggle menu">
|
||||
{open ? <X size={21} /> : <Menu size={21} />}
|
||||
</button>
|
||||
{open && <div className="wood-mobile">{nav}</div>}
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
function SplashScene({ route, setRoute }) {
|
||||
const mountRef = useRef(null);
|
||||
const [fallback, setFallback] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const mount = mountRef.current;
|
||||
if (!mount) return undefined;
|
||||
|
||||
let renderer;
|
||||
try {
|
||||
renderer = new THREE.WebGLRenderer({
|
||||
antialias: true,
|
||||
alpha: false,
|
||||
preserveDrawingBuffer: true,
|
||||
powerPreference: 'high-performance',
|
||||
});
|
||||
} catch {
|
||||
setFallback(true);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
setFallback(false);
|
||||
renderer.outputColorSpace = THREE.SRGBColorSpace;
|
||||
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
|
||||
renderer.domElement.setAttribute('data-version', RELEASE);
|
||||
renderer.domElement.style.cursor = 'grab';
|
||||
renderer.domElement.style.touchAction = 'none';
|
||||
mount.appendChild(renderer.domElement);
|
||||
|
||||
const scene = new THREE.Scene();
|
||||
scene.background = new THREE.Color(0x020101);
|
||||
const camera = new THREE.PerspectiveCamera(35, 16 / 9, 0.1, 80);
|
||||
const target = new THREE.Vector3(0, -0.15, -2);
|
||||
|
||||
const stage = new THREE.Group();
|
||||
scene.add(stage);
|
||||
|
||||
const built = buildSplashScene(route);
|
||||
stage.add(built.root);
|
||||
|
||||
const interactives = [built.dashboard, built.boothSign, built.speech];
|
||||
const tracked = {
|
||||
dashboard: built.dashboard,
|
||||
booth: built.boothSign,
|
||||
speech: built.speech,
|
||||
orc: built.orc,
|
||||
elf: built.elf,
|
||||
dwarf: built.dwarf,
|
||||
};
|
||||
|
||||
const raycaster = new THREE.Raycaster();
|
||||
const pointer = new THREE.Vector2();
|
||||
const drag = { active: false, moved: false, startX: 0, startY: 0, pointerId: null };
|
||||
let hoverRoute = '';
|
||||
let frame = 0;
|
||||
let disposed = false;
|
||||
let layout = 'desktop';
|
||||
let currentYaw = 0;
|
||||
let currentPitch = 0;
|
||||
let targetYaw = 0;
|
||||
let targetPitch = 0;
|
||||
|
||||
const metrics = () => ({
|
||||
assetMode: 'imagegen bitmap asset pack plus Three.js layered parallax and animated canvas UI',
|
||||
assetAudit: ASSET_AUDIT,
|
||||
idleAnimations: IDLE_ANIMATIONS,
|
||||
generatedAssets: [tavernBackdropUrl, orcBartenderUrl, elfGuideUrl, dwarfPatronUrl],
|
||||
dynamicFrames: {
|
||||
wantedList: built.dashboard.material.map?.userData?.frame || 0,
|
||||
speech: built.speech.material.map?.userData?.frame || 0,
|
||||
boothNeon: built.boothSign.material.map?.userData?.frame || 0,
|
||||
},
|
||||
rosterScroll: round(built.dashboard.material.map?.userData?.scroll || 0),
|
||||
rosterActiveAgent: built.dashboard.material.map?.userData?.activeAgent || WANTED_AGENTS[0].name,
|
||||
visualTarget: 'reference-matched polished fantasy sci-fi tavern splash art',
|
||||
spriteAssets: true,
|
||||
lowPoly: true,
|
||||
draggable: true,
|
||||
});
|
||||
|
||||
const setLayout = () => {
|
||||
const rect = mount.getBoundingClientRect();
|
||||
const width = Math.max(1, Math.floor(rect.width || window.innerWidth || 1));
|
||||
const height = Math.max(1, Math.floor(rect.height || window.innerHeight || 1));
|
||||
const aspect = width / height;
|
||||
const portrait = height > width * 1.05;
|
||||
layout = width < 760 && portrait ? 'mobile' : width < 1080 || aspect < 1.22 ? 'tablet' : 'desktop';
|
||||
renderer.setSize(width, height, false);
|
||||
camera.aspect = aspect;
|
||||
|
||||
if (layout === 'mobile') {
|
||||
camera.fov = 44;
|
||||
camera.position.set(0, 1.58, 7.45);
|
||||
target.set(0, -0.18, -1.95);
|
||||
stage.scale.setScalar(1.08);
|
||||
stage.position.set(0, 0.24, 0);
|
||||
placeMobile(built);
|
||||
} else if (layout === 'tablet') {
|
||||
camera.fov = 42;
|
||||
camera.position.set(0, 1.72, 8.55);
|
||||
target.set(0, -0.22, -2);
|
||||
stage.scale.setScalar(1);
|
||||
stage.position.set(0, -0.03, 0);
|
||||
placeTablet(built);
|
||||
} else {
|
||||
camera.fov = 38;
|
||||
camera.position.set(0, 1.56, 8.65);
|
||||
target.set(0, -0.18, -2.05);
|
||||
stage.scale.setScalar(1);
|
||||
stage.position.set(0, 0, 0);
|
||||
placeDesktop(built);
|
||||
}
|
||||
camera.updateProjectionMatrix();
|
||||
camera.lookAt(target);
|
||||
publishStageDebug({ width, height, layout, dragYaw: currentYaw, dragPitch: currentPitch, meshCount: countMeshes(stage), ...metrics() }, tracked, camera);
|
||||
};
|
||||
|
||||
const resizeObserver = new ResizeObserver(setLayout);
|
||||
resizeObserver.observe(mount);
|
||||
setLayout();
|
||||
|
||||
const pickRoute = (event) => {
|
||||
const rect = renderer.domElement.getBoundingClientRect();
|
||||
pointer.x = ((event.clientX - rect.left) / Math.max(rect.width, 1)) * 2 - 1;
|
||||
pointer.y = -((event.clientY - rect.top) / Math.max(rect.height, 1)) * 2 + 1;
|
||||
raycaster.setFromCamera(pointer, camera);
|
||||
const hit = raycaster.intersectObjects(interactives, false)[0];
|
||||
return hit?.object?.userData?.route || '';
|
||||
};
|
||||
|
||||
const onPointerDown = (event) => {
|
||||
drag.active = true;
|
||||
drag.moved = false;
|
||||
drag.startX = event.clientX;
|
||||
drag.startY = event.clientY;
|
||||
drag.pointerId = event.pointerId;
|
||||
renderer.domElement.setPointerCapture?.(event.pointerId);
|
||||
renderer.domElement.style.cursor = 'grabbing';
|
||||
};
|
||||
|
||||
const onPointerMove = (event) => {
|
||||
if (drag.active) {
|
||||
const dx = event.clientX - drag.startX;
|
||||
const dy = event.clientY - drag.startY;
|
||||
if (Math.hypot(dx, dy) > 4) drag.moved = true;
|
||||
targetYaw = clamp(dx * 0.0012, -0.16, 0.16);
|
||||
targetPitch = clamp(dy * 0.00075, -0.06, 0.06);
|
||||
return;
|
||||
}
|
||||
hoverRoute = pickRoute(event);
|
||||
renderer.domElement.style.cursor = hoverRoute ? 'pointer' : 'grab';
|
||||
};
|
||||
|
||||
const onPointerUp = (event) => {
|
||||
renderer.domElement.releasePointerCapture?.(event.pointerId);
|
||||
drag.active = false;
|
||||
drag.pointerId = null;
|
||||
if (!drag.moved) {
|
||||
const nextRoute = pickRoute(event);
|
||||
if (nextRoute) setRoute(nextRoute);
|
||||
}
|
||||
hoverRoute = pickRoute(event);
|
||||
renderer.domElement.style.cursor = hoverRoute ? 'pointer' : 'grab';
|
||||
};
|
||||
|
||||
const onPointerLeave = () => {
|
||||
if (!drag.active) {
|
||||
hoverRoute = '';
|
||||
renderer.domElement.style.cursor = 'grab';
|
||||
}
|
||||
};
|
||||
|
||||
renderer.domElement.addEventListener('pointerdown', onPointerDown);
|
||||
renderer.domElement.addEventListener('pointermove', onPointerMove);
|
||||
renderer.domElement.addEventListener('pointerup', onPointerUp);
|
||||
renderer.domElement.addEventListener('pointercancel', onPointerUp);
|
||||
renderer.domElement.addEventListener('pointerleave', onPointerLeave);
|
||||
|
||||
const start = performance.now();
|
||||
const animate = () => {
|
||||
if (disposed) return;
|
||||
frame = requestAnimationFrame(animate);
|
||||
const t = (performance.now() - start) / 1000;
|
||||
currentYaw += (targetYaw - currentYaw) * 0.12;
|
||||
currentPitch += (targetPitch - currentPitch) * 0.12;
|
||||
stage.rotation.y = currentYaw + Math.sin(t * 0.28) * 0.004;
|
||||
stage.rotation.x = currentPitch;
|
||||
|
||||
animateSprites(built, t);
|
||||
built.updaters.forEach((update) => update(t));
|
||||
interactives.forEach((object) => {
|
||||
const base = object.userData.baseScale || object.scale;
|
||||
const targetScale = object.userData.route === hoverRoute ? 1.035 : 1;
|
||||
object.scale.lerp(new THREE.Vector3(base.x * targetScale, base.y * targetScale, base.z), 0.16);
|
||||
});
|
||||
|
||||
if (Math.floor(t * 10) % 3 === 0) {
|
||||
publishStageDebug(
|
||||
{ width: renderer.domElement.clientWidth, height: renderer.domElement.clientHeight, layout, dragYaw: currentYaw, dragPitch: currentPitch, meshCount: countMeshes(stage), ...metrics() },
|
||||
tracked,
|
||||
camera,
|
||||
);
|
||||
}
|
||||
renderer.render(scene, camera);
|
||||
};
|
||||
animate();
|
||||
|
||||
return () => {
|
||||
disposed = true;
|
||||
resizeObserver.disconnect();
|
||||
renderer.domElement.removeEventListener('pointerdown', onPointerDown);
|
||||
renderer.domElement.removeEventListener('pointermove', onPointerMove);
|
||||
renderer.domElement.removeEventListener('pointerup', onPointerUp);
|
||||
renderer.domElement.removeEventListener('pointercancel', onPointerUp);
|
||||
renderer.domElement.removeEventListener('pointerleave', onPointerLeave);
|
||||
cancelAnimationFrame(frame);
|
||||
disposeScene(scene);
|
||||
renderer.dispose();
|
||||
if (renderer.domElement.parentNode === mount) mount.removeChild(renderer.domElement);
|
||||
};
|
||||
}, [route, setRoute]);
|
||||
|
||||
return (
|
||||
<div className="tavern-stage gemhall-stage" ref={mountRef} aria-label="Draggable polished bitmap-asset AI mercenary tavern">
|
||||
{fallback && <div className="webgl-fallback" aria-hidden="true" />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function buildSplashScene(route) {
|
||||
const root = new THREE.Group();
|
||||
const bg = makeImagePlane(tavernBackdropUrl, BACKDROP_ASPECT, { renderOrder: -20, transparent: false });
|
||||
bg.userData.asset = 'imagegen tavern background';
|
||||
root.add(bg);
|
||||
|
||||
const title = makeCanvasPlane(makeTitleTexture(route).texture, { renderOrder: 24, depthTest: false });
|
||||
const wantedTexture = makeWantedTexture(route);
|
||||
const speechTexture = makeSpeechTexture(route);
|
||||
const boothTexture = makeBoothTexture();
|
||||
const dashboard = makeCanvasPlane(wantedTexture.texture, { renderOrder: 28, depthTest: true, route: 'agents' });
|
||||
const boothSign = makeCanvasPlane(boothTexture.texture, { renderOrder: 34, depthTest: true, route: 'booth' });
|
||||
const speech = makeCanvasPlane(speechTexture.texture, { renderOrder: 36, depthTest: true, route: route === 'signin' ? 'booth' : 'agents' });
|
||||
|
||||
const dwarf = makeSpriteGroup(dwarfPatronUrl, DWARF_ASPECT, 'dwarf patron sprite');
|
||||
const orc = makeSpriteGroup(orcBartenderUrl, ORC_ASPECT, 'orc bartender sprite');
|
||||
const elf = makeSpriteGroup(elfGuideUrl, ELF_ASPECT, 'elf guide sprite');
|
||||
const particles = makeParticles();
|
||||
const boothRing = makeRingSet();
|
||||
|
||||
root.add(dashboard, boothSign, speech, dwarf, orc, elf, particles, boothRing);
|
||||
return {
|
||||
root,
|
||||
bg,
|
||||
title,
|
||||
dashboard,
|
||||
boothSign,
|
||||
speech,
|
||||
dwarf,
|
||||
orc,
|
||||
elf,
|
||||
particles,
|
||||
boothRing,
|
||||
updaters: [wantedTexture.update, speechTexture.update, boothTexture.update],
|
||||
};
|
||||
}
|
||||
|
||||
function makeImagePlane(url, aspect, options = {}) {
|
||||
const texture = new THREE.TextureLoader().load(url);
|
||||
prepareTexture(texture);
|
||||
const material = new THREE.MeshBasicMaterial({
|
||||
map: texture,
|
||||
transparent: options.transparent ?? true,
|
||||
depthTest: options.depthTest ?? true,
|
||||
depthWrite: options.depthWrite ?? false,
|
||||
alphaTest: options.transparent === false ? 0 : 0.02,
|
||||
});
|
||||
const mesh = new THREE.Mesh(new THREE.PlaneGeometry(aspect, 1), material);
|
||||
mesh.renderOrder = options.renderOrder ?? 0;
|
||||
return mesh;
|
||||
}
|
||||
|
||||
function makeSpriteGroup(url, aspect, label) {
|
||||
const group = new THREE.Group();
|
||||
const sprite = makeImagePlane(url, aspect, { renderOrder: 20, transparent: true, depthWrite: false });
|
||||
sprite.userData.baseScale = sprite.scale.clone();
|
||||
group.add(sprite);
|
||||
group.userData.sprite = sprite;
|
||||
group.userData.asset = label;
|
||||
group.userData.baseY = 0;
|
||||
group.userData.baseRotY = 0;
|
||||
group.userData.baseScale = 1;
|
||||
return group;
|
||||
}
|
||||
|
||||
function makeCanvasPlane(texture, { renderOrder = 10, route = '', depthTest = true } = {}) {
|
||||
const material = new THREE.MeshBasicMaterial({
|
||||
map: texture,
|
||||
transparent: true,
|
||||
depthTest,
|
||||
depthWrite: false,
|
||||
side: THREE.DoubleSide,
|
||||
});
|
||||
const mesh = new THREE.Mesh(new THREE.PlaneGeometry(1, 1), material);
|
||||
mesh.renderOrder = renderOrder;
|
||||
mesh.userData.route = route;
|
||||
mesh.userData.baseScale = mesh.scale.clone();
|
||||
return mesh;
|
||||
}
|
||||
|
||||
function makeTitleTexture(route) {
|
||||
return makeAnimatedTexture(1240, 260, (ctx, width, height, time) => {
|
||||
const routeLabel = route === 'home' ? 'Hire agents like mercenaries.' : routeTitle(route);
|
||||
ctx.clearRect(0, 0, width, height);
|
||||
ctx.save();
|
||||
ctx.shadowColor = 'rgba(0,0,0,.68)';
|
||||
ctx.shadowBlur = 18;
|
||||
ctx.fillStyle = 'rgba(22, 8, 4, .78)';
|
||||
ctx.strokeStyle = 'rgba(255, 196, 93, .72)';
|
||||
ctx.lineWidth = 5;
|
||||
drawRound(ctx, 190, 36, width - 380, height - 70, 34);
|
||||
ctx.fill();
|
||||
ctx.stroke();
|
||||
ctx.restore();
|
||||
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.shadowColor = 'rgba(0,0,0,.7)';
|
||||
ctx.shadowBlur = 14;
|
||||
ctx.fillStyle = '#fff0d7';
|
||||
ctx.font = '900 48px Georgia, Times New Roman, serif';
|
||||
fitText(ctx, routeLabel, width / 2, 96, width - 500, 48);
|
||||
ctx.font = '900 21px Inter, Arial, sans-serif';
|
||||
ctx.fillStyle = '#ffc96e';
|
||||
fitText(ctx, 'live contracts / agent roster / private booth', width / 2, 150, width - 560, 21);
|
||||
ctx.fillStyle = `rgba(80, 225, 255, ${0.5 + Math.sin(time * 2.2) * 0.16})`;
|
||||
ctx.fillRect(390, 188, 460 + Math.sin(time * 0.9) * 60, 4);
|
||||
return {};
|
||||
});
|
||||
}
|
||||
|
||||
function makeWantedTexture(route) {
|
||||
return makeAnimatedTexture(1220, 520, (ctx, width, height, time) => {
|
||||
const scroll = positiveModulo(time * 56, 118);
|
||||
const active = WANTED_AGENTS[Math.floor(time / 2.25) % WANTED_AGENTS.length];
|
||||
ctx.clearRect(0, 0, width, height);
|
||||
|
||||
ctx.save();
|
||||
ctx.shadowColor = '#1fe7ff';
|
||||
ctx.shadowBlur = 24;
|
||||
ctx.fillStyle = 'rgba(5, 21, 36, .9)';
|
||||
ctx.strokeStyle = 'rgba(54, 226, 255, .92)';
|
||||
ctx.lineWidth = 8;
|
||||
drawRound(ctx, 22, 20, width - 44, height - 40, 34);
|
||||
ctx.fill();
|
||||
ctx.stroke();
|
||||
ctx.restore();
|
||||
|
||||
ctx.strokeStyle = 'rgba(65, 230, 255, .12)';
|
||||
ctx.lineWidth = 1;
|
||||
for (let x = 60; x < width - 60; x += 48) {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x, 36);
|
||||
ctx.lineTo(x, height - 42);
|
||||
ctx.stroke();
|
||||
}
|
||||
for (let y = 72; y < height - 48; y += 42) {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(44, y);
|
||||
ctx.lineTo(width - 44, y);
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
ctx.textAlign = 'left';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.shadowColor = '#33eaff';
|
||||
ctx.shadowBlur = 18;
|
||||
ctx.fillStyle = '#bff8ff';
|
||||
ctx.font = '900 56px Georgia, Times New Roman, serif';
|
||||
fitText(ctx, 'BROWSE AGENTS', 64, 62, 520, 56, 'left');
|
||||
ctx.shadowBlur = 0;
|
||||
ctx.fillStyle = '#ffd36a';
|
||||
ctx.font = '900 23px Inter, Arial, sans-serif';
|
||||
fitText(ctx, 'WANTED LIST / SCOPED MERCENARIES', 68, 112, 570, 23, 'left');
|
||||
|
||||
ctx.save();
|
||||
ctx.beginPath();
|
||||
drawRound(ctx, 52, 132, 674, 292, 20);
|
||||
ctx.clip();
|
||||
for (let i = -1; i < WANTED_AGENTS.length + 3; i += 1) {
|
||||
const agent = WANTED_AGENTS[positiveModulo(i, WANTED_AGENTS.length)];
|
||||
drawWantedRow(ctx, agent, 72, 144 + i * 86 - scroll, time + i);
|
||||
}
|
||||
ctx.restore();
|
||||
|
||||
drawActiveAgent(ctx, active, 770, 126, 360, 285, time);
|
||||
|
||||
for (let i = 0; i < 24; i += 1) {
|
||||
const x = 600 + Math.cos(time * 1.8 + i * 0.7) * (60 + (i % 5) * 16);
|
||||
const y = 258 + Math.sin(time * 2.1 + i * 1.3) * (38 + (i % 4) * 9);
|
||||
ctx.globalAlpha = 0.45 + (i % 3) * 0.14;
|
||||
ctx.fillStyle = '#ffc45f';
|
||||
ctx.beginPath();
|
||||
ctx.ellipse(x, y, 9, 14, time + i, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
}
|
||||
ctx.globalAlpha = 1;
|
||||
|
||||
return { scroll, activeAgent: active.name };
|
||||
});
|
||||
}
|
||||
|
||||
function drawWantedRow(ctx, agent, x, y, time) {
|
||||
ctx.save();
|
||||
ctx.shadowColor = agent.tint;
|
||||
ctx.shadowBlur = 10;
|
||||
ctx.fillStyle = 'rgba(7, 31, 45, .82)';
|
||||
ctx.strokeStyle = agent.tint;
|
||||
ctx.lineWidth = 2;
|
||||
drawRound(ctx, x, y, 620, 58, 16);
|
||||
ctx.fill();
|
||||
ctx.stroke();
|
||||
|
||||
ctx.fillStyle = 'rgba(0,0,0,.38)';
|
||||
ctx.beginPath();
|
||||
ctx.arc(x + 34, y + 29, 20, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
ctx.stroke();
|
||||
ctx.fillStyle = agent.tint;
|
||||
ctx.font = '900 15px Inter, Arial, sans-serif';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText(agent.initials, x + 34, y + 29);
|
||||
|
||||
ctx.textAlign = 'left';
|
||||
ctx.shadowBlur = 0;
|
||||
ctx.fillStyle = '#f2fbff';
|
||||
ctx.font = '900 20px Georgia, Times New Roman, serif';
|
||||
fitText(ctx, agent.name, x + 74, y + 21, 250, 20, 'left');
|
||||
ctx.fillStyle = '#b8d7df';
|
||||
ctx.font = '800 14px Inter, Arial, sans-serif';
|
||||
fitText(ctx, agent.role, x + 74, y + 42, 250, 14, 'left');
|
||||
ctx.fillStyle = '#c4f3ff';
|
||||
fitText(ctx, agent.skill, x + 395, y + 27, 130, 14, 'left');
|
||||
ctx.fillStyle = '#ffd36a';
|
||||
ctx.font = '900 18px Inter, Arial, sans-serif';
|
||||
fitText(ctx, agent.bounty, x + 545, y + 28 + Math.sin(time * 2) * 1.5, 70, 18, 'left');
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
function drawActiveAgent(ctx, agent, x, y, width, height, time) {
|
||||
ctx.save();
|
||||
ctx.shadowColor = agent.tint;
|
||||
ctx.shadowBlur = 24;
|
||||
ctx.fillStyle = 'rgba(4, 20, 32, .86)';
|
||||
ctx.strokeStyle = agent.tint;
|
||||
ctx.lineWidth = 5;
|
||||
drawRound(ctx, x, y, width, height, 26);
|
||||
ctx.fill();
|
||||
ctx.stroke();
|
||||
ctx.translate(x + width / 2, y + 86);
|
||||
ctx.rotate(Math.sin(time * 0.9) * 0.08);
|
||||
ctx.strokeStyle = agent.tint;
|
||||
ctx.lineWidth = 7;
|
||||
drawHex(ctx, 0, 0, 56 + Math.sin(time * 3) * 5);
|
||||
ctx.stroke();
|
||||
ctx.fillStyle = agent.tint;
|
||||
ctx.font = '900 35px Inter, Arial, sans-serif';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText(agent.initials, 0, 7);
|
||||
ctx.setTransform(1, 0, 0, 1, 0, 0);
|
||||
ctx.shadowBlur = 0;
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillStyle = '#f3fbff';
|
||||
ctx.font = '900 27px Georgia, Times New Roman, serif';
|
||||
fitText(ctx, agent.name, x + width / 2, y + 176, width - 60, 27);
|
||||
ctx.fillStyle = '#c8f7ff';
|
||||
ctx.font = '800 18px Inter, Arial, sans-serif';
|
||||
fitText(ctx, agent.role, x + width / 2, y + 210, width - 70, 18);
|
||||
ctx.fillStyle = '#ffd36a';
|
||||
ctx.font = '900 22px Inter, Arial, sans-serif';
|
||||
fitText(ctx, `Bounty ${agent.bounty}`, x + width / 2, y + 250, width - 80, 22);
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
function makeSpeechTexture(route) {
|
||||
const lines = SPEECH_STEPS[route] || SPEECH_STEPS.home;
|
||||
return makeAnimatedTexture(820, 520, (ctx, width, height, time) => {
|
||||
const active = Math.floor(time / 1.45) % lines.length;
|
||||
const pulse = 0.74 + Math.sin(time * 3.8) * 0.14;
|
||||
ctx.clearRect(0, 0, width, height);
|
||||
ctx.save();
|
||||
ctx.shadowColor = '#35dfff';
|
||||
ctx.shadowBlur = 24 * pulse;
|
||||
ctx.fillStyle = 'rgba(3, 22, 39, .88)';
|
||||
ctx.strokeStyle = `rgba(58, 222, 255, ${0.72 + pulse * 0.2})`;
|
||||
ctx.lineWidth = 7;
|
||||
drawRound(ctx, 28, 26, width - 80, height - 92, 28);
|
||||
ctx.fill();
|
||||
ctx.stroke();
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(width - 150, height - 72);
|
||||
ctx.lineTo(width - 44, height - 24);
|
||||
ctx.lineTo(width - 104, height - 118);
|
||||
ctx.closePath();
|
||||
ctx.fill();
|
||||
ctx.stroke();
|
||||
ctx.restore();
|
||||
|
||||
ctx.textAlign = 'left';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.fillStyle = '#bff8ff';
|
||||
ctx.font = '900 34px Georgia, Times New Roman, serif';
|
||||
fitText(ctx, 'Velvet Hex offers', 70, 76, width - 160, 34, 'left');
|
||||
ctx.font = '900 27px Inter, Arial, sans-serif';
|
||||
lines.forEach((line, index) => {
|
||||
const y = 145 + index * 58;
|
||||
const selected = index === active;
|
||||
ctx.globalAlpha = selected ? 1 : 0.44;
|
||||
ctx.strokeStyle = selected ? '#5ee8ff' : 'rgba(94,232,255,.6)';
|
||||
ctx.lineWidth = selected ? 5 : 3;
|
||||
ctx.beginPath();
|
||||
ctx.arc(78, y, selected ? 14 + Math.sin(time * 6) * 2 : 9, 0, Math.PI * 2);
|
||||
ctx.stroke();
|
||||
ctx.fillStyle = selected ? '#5ee8ff' : '#c9f5ff';
|
||||
fitText(ctx, line, 112, y, width - 190, 27, 'left');
|
||||
});
|
||||
ctx.globalAlpha = 1;
|
||||
return {};
|
||||
});
|
||||
}
|
||||
|
||||
function makeBoothTexture() {
|
||||
return makeAnimatedTexture(980, 250, (ctx, width, height, time) => {
|
||||
const flicker = 0.72 + Math.sin(time * 9.5) * 0.08 + (Math.sin(time * 27) > 0.94 ? 0.18 : 0);
|
||||
ctx.clearRect(0, 0, width, height);
|
||||
ctx.save();
|
||||
ctx.shadowColor = '#24e6ff';
|
||||
ctx.shadowBlur = 28 * flicker;
|
||||
ctx.fillStyle = 'rgba(1, 19, 32, .82)';
|
||||
ctx.strokeStyle = `rgba(44, 230, 255, ${0.75 * flicker})`;
|
||||
ctx.lineWidth = 8;
|
||||
drawRound(ctx, 38, 38, width - 76, height - 76, 28);
|
||||
ctx.fill();
|
||||
ctx.stroke();
|
||||
ctx.font = '900 66px Georgia, Times New Roman, serif';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.strokeStyle = `rgba(38, 225, 255, ${0.95 * flicker})`;
|
||||
ctx.lineWidth = 7;
|
||||
ctx.strokeText('Enter My Booth', width / 2, height / 2 - 6, width - 130);
|
||||
ctx.fillStyle = `rgba(232, 255, 255, ${0.96 * flicker})`;
|
||||
ctx.fillText('Enter My Booth', width / 2, height / 2 - 6, width - 130);
|
||||
ctx.fillStyle = `rgba(255, 210, 107, ${0.84 * flicker})`;
|
||||
ctx.font = '900 18px Inter, Arial, sans-serif';
|
||||
fitText(ctx, 'private agent command booth', width / 2, height - 60, width - 180, 18);
|
||||
ctx.fillStyle = `rgba(49, 229, 255, ${0.26 * flicker})`;
|
||||
ctx.fillRect(74 + positiveModulo(time * 180, width - 230), 54, 120, 5);
|
||||
ctx.restore();
|
||||
return {};
|
||||
});
|
||||
}
|
||||
|
||||
function makeParticles() {
|
||||
const group = new THREE.Group();
|
||||
const material = new THREE.MeshBasicMaterial({ color: 0xffc36e, transparent: true, opacity: 0.85 });
|
||||
for (let i = 0; i < 48; i += 1) {
|
||||
const dot = new THREE.Mesh(new THREE.CircleGeometry(0.012 + (i % 3) * 0.004, 6), material.clone());
|
||||
dot.position.set(-5.8 + ((i * 1.83) % 11.6), -1.35 + ((i * 0.43) % 4.1), -0.4 - (i % 5) * 0.34);
|
||||
dot.userData.seed = i * 0.47;
|
||||
dot.renderOrder = 38;
|
||||
group.add(dot);
|
||||
}
|
||||
return group;
|
||||
}
|
||||
|
||||
function makeRingSet() {
|
||||
const group = new THREE.Group();
|
||||
const material = new THREE.MeshBasicMaterial({ color: 0x1ee6ff, transparent: true, opacity: 0.62, side: THREE.DoubleSide, depthWrite: false });
|
||||
[0.56, 0.78, 1.0].forEach((radius) => {
|
||||
const ring = new THREE.Mesh(new THREE.RingGeometry(radius, radius + 0.012, 96), material.clone());
|
||||
ring.rotation.x = -Math.PI / 2;
|
||||
ring.renderOrder = 31;
|
||||
group.add(ring);
|
||||
});
|
||||
return group;
|
||||
}
|
||||
|
||||
function animateSprites(built, time) {
|
||||
const orc = built.orc;
|
||||
orc.position.y = orc.userData.baseY + Math.sin(time * 1.15) * 0.035;
|
||||
orc.rotation.z = Math.sin(time * 0.8) * 0.006;
|
||||
orc.scale.setScalar(orc.userData.baseScale * (1 + Math.sin(time * 1.25) * 0.012));
|
||||
|
||||
const elf = built.elf;
|
||||
elf.position.y = elf.userData.baseY + Math.sin(time * 1.45 + 0.9) * 0.045;
|
||||
elf.rotation.z = Math.sin(time * 1.1) * 0.01;
|
||||
elf.scale.setScalar(elf.userData.baseScale * (1 + Math.sin(time * 1.8) * 0.008));
|
||||
|
||||
const dwarf = built.dwarf;
|
||||
dwarf.position.y = dwarf.userData.baseY + Math.sin(time * 1.8 + 1.9) * 0.028;
|
||||
dwarf.rotation.z = Math.sin(time * 1.6) * 0.012;
|
||||
dwarf.scale.setScalar(dwarf.userData.baseScale * (1 + Math.sin(time * 2.0 + 0.5) * 0.01));
|
||||
|
||||
built.boothRing.rotation.z = time * 0.12;
|
||||
built.boothRing.children.forEach((ring, index) => {
|
||||
ring.material.opacity = 0.32 + Math.sin(time * 2.4 + index * 0.8) * 0.14;
|
||||
});
|
||||
|
||||
built.particles.children.forEach((dot) => {
|
||||
const seed = dot.userData.seed || 0;
|
||||
dot.position.y += Math.sin(time * 1.3 + seed) * 0.00055;
|
||||
dot.material.opacity = 0.42 + Math.sin(time * 2.2 + seed) * 0.28;
|
||||
});
|
||||
}
|
||||
|
||||
function placeDesktop(built) {
|
||||
placePlane(built.bg, 14.4, 14.4 / BACKDROP_ASPECT, 0, -0.18, -4.3, 0);
|
||||
placePlane(built.title, 4.45, 0.72, -0.32, 1.22, -1.62, 0);
|
||||
placePlane(built.dashboard, 4.55, 4.55 / SCREEN_ASPECT, -0.36, 0.43, -1.54, -0.02);
|
||||
placePlane(built.boothSign, 2.8, 2.8 / BOOTH_ASPECT, -0.05, -1.68, 0.1, 0);
|
||||
placePlane(built.speech, 1.88, 1.88 / SPEECH_ASPECT, 2.92, 0.04, 0.32, -0.1);
|
||||
placeSprite(built.orc, 2.34, 2.34 / ORC_ASPECT, 0.02, -1.38, 0.26, 0.02, 1);
|
||||
placeSprite(built.elf, 1.46, 1.46 / ELF_ASPECT, 3.04, -1.42, 0.5, -0.12, 1);
|
||||
placeSprite(built.dwarf, 1.18, 1.18 / DWARF_ASPECT, -3.04, -1.46, 0.58, 0.14, 1);
|
||||
placeFlat(built.boothRing, -0.1, -2, 0.9, 1.05, 0);
|
||||
}
|
||||
|
||||
function placeTablet(built) {
|
||||
placePlane(built.bg, 13.0, 13.0 / BACKDROP_ASPECT, 0, -0.18, -4.1, 0);
|
||||
placePlane(built.title, 3.7, 0.62, 0, 1.08, -1.35, 0);
|
||||
placePlane(built.dashboard, 3.85, 3.85 / SCREEN_ASPECT, -0.22, 0.34, -1.48, -0.02);
|
||||
placePlane(built.boothSign, 2.22, 2.22 / BOOTH_ASPECT, -0.06, -1.58, 0.08, 0);
|
||||
placePlane(built.speech, 1.46, 1.46 / SPEECH_ASPECT, 1.88, -0.2, 0.24, -0.1);
|
||||
placeSprite(built.orc, 1.95, 1.95 / ORC_ASPECT, 0, -1.34, 0.28, 0, 1);
|
||||
placeSprite(built.elf, 1.22, 1.22 / ELF_ASPECT, 2.05, -1.52, 0.46, -0.1, 1);
|
||||
placeSprite(built.dwarf, 0.98, 0.98 / DWARF_ASPECT, -2.02, -1.54, 0.58, 0.1, 1);
|
||||
placeFlat(built.boothRing, -0.08, -1.92, 0.92, 0.92, 0);
|
||||
}
|
||||
|
||||
function placeMobile(built) {
|
||||
placePlane(built.bg, 10.7, 10.7 / BACKDROP_ASPECT, 0, 0.16, -4.0, 0);
|
||||
placePlane(built.title, 2.8, 0.48, 0, 1.02, -1.24, 0);
|
||||
placePlane(built.dashboard, 3.05, 3.05 / SCREEN_ASPECT, 0, 0.5, -1.42, 0);
|
||||
placePlane(built.boothSign, 1.84, 1.84 / BOOTH_ASPECT, -0.12, -1.24, 0.2, 0);
|
||||
placePlane(built.speech, 1.08, 1.08 / SPEECH_ASPECT, 0.58, -0.44, 0.38, -0.06);
|
||||
placeSprite(built.orc, 1.68, 1.68 / ORC_ASPECT, -0.18, -1.0, 0.36, 0, 1);
|
||||
placeSprite(built.elf, 0.84, 0.84 / ELF_ASPECT, 0.84, -1.22, 0.54, -0.06, 1);
|
||||
placeSprite(built.dwarf, 0.7, 0.7 / DWARF_ASPECT, -0.86, -1.22, 0.62, 0.06, 1);
|
||||
placeFlat(built.boothRing, -0.2, -1.5, 0.86, 0.68, 0);
|
||||
}
|
||||
|
||||
function placePlane(mesh, width, height, x, y, z, rotY = 0) {
|
||||
mesh.scale.set(width, height, 1);
|
||||
mesh.position.set(x, y, z);
|
||||
mesh.rotation.set(0, rotY, 0);
|
||||
mesh.userData.baseScale = mesh.scale.clone();
|
||||
}
|
||||
|
||||
function placeSprite(group, width, height, x, y, z, rotY = 0, baseScale = 1) {
|
||||
const sprite = group.userData.sprite;
|
||||
sprite.scale.set(width, height, 1);
|
||||
group.position.set(x, y, z);
|
||||
group.rotation.set(0, rotY, 0);
|
||||
group.userData.baseY = y;
|
||||
group.userData.baseRotY = rotY;
|
||||
group.userData.baseScale = baseScale;
|
||||
}
|
||||
|
||||
function placeFlat(group, x, y, z, scale, rotY) {
|
||||
group.position.set(x, y, z);
|
||||
group.scale.setScalar(scale);
|
||||
group.rotation.set(0, rotY, 0);
|
||||
}
|
||||
|
||||
function makeAnimatedTexture(width, height, draw) {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
const ctx = canvas.getContext('2d');
|
||||
const texture = new THREE.CanvasTexture(canvas);
|
||||
prepareTexture(texture);
|
||||
const update = (time = 0) => {
|
||||
const data = draw(ctx, width, height, time) || {};
|
||||
texture.userData.frame = (texture.userData.frame || 0) + 1;
|
||||
Object.assign(texture.userData, data);
|
||||
texture.needsUpdate = true;
|
||||
};
|
||||
update(0);
|
||||
return { texture, update };
|
||||
}
|
||||
|
||||
function prepareTexture(texture) {
|
||||
texture.colorSpace = THREE.SRGBColorSpace;
|
||||
texture.anisotropy = 8;
|
||||
}
|
||||
|
||||
function drawRound(ctx, x, y, width, height, radius) {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x + radius, y);
|
||||
ctx.lineTo(x + width - radius, y);
|
||||
ctx.quadraticCurveTo(x + width, y, x + width, y + radius);
|
||||
ctx.lineTo(x + width, y + height - radius);
|
||||
ctx.quadraticCurveTo(x + width, y + height, x + width - radius, y + height);
|
||||
ctx.lineTo(x + radius, y + height);
|
||||
ctx.quadraticCurveTo(x, y + height, x, y + height - radius);
|
||||
ctx.lineTo(x, y + radius);
|
||||
ctx.quadraticCurveTo(x, y, x + radius, y);
|
||||
ctx.closePath();
|
||||
}
|
||||
|
||||
function drawHex(ctx, x, y, radius) {
|
||||
ctx.beginPath();
|
||||
for (let i = 0; i < 6; i += 1) {
|
||||
const angle = Math.PI / 6 + i * Math.PI / 3;
|
||||
const px = x + Math.cos(angle) * radius;
|
||||
const py = y + Math.sin(angle) * radius;
|
||||
if (i === 0) ctx.moveTo(px, py);
|
||||
else ctx.lineTo(px, py);
|
||||
}
|
||||
ctx.closePath();
|
||||
}
|
||||
|
||||
function fitText(ctx, text, x, y, maxWidth, size, align = 'center') {
|
||||
let fontSize = size;
|
||||
const family = ctx.font.replace(/^[^ ]+ /, '');
|
||||
ctx.textAlign = align;
|
||||
while (fontSize > 9) {
|
||||
ctx.font = `${fontSize}px ${family}`;
|
||||
if (ctx.measureText(text).width <= maxWidth) break;
|
||||
fontSize -= 1;
|
||||
}
|
||||
ctx.fillText(text, x, y);
|
||||
}
|
||||
|
||||
function routeTitle(route) {
|
||||
return {
|
||||
agents: 'Browse agent mercenaries.',
|
||||
booth: 'Enter your private booth.',
|
||||
contracts: 'Contract every run.',
|
||||
signin: 'Claim a tavern pass.',
|
||||
admin: 'Quartermaster control.',
|
||||
}[route] || 'Hire agents like mercenaries.';
|
||||
}
|
||||
|
||||
function positiveModulo(value, modulo) {
|
||||
return ((value % modulo) + modulo) % modulo;
|
||||
}
|
||||
|
||||
function clamp(value, min, max) {
|
||||
return Math.max(min, Math.min(max, value));
|
||||
}
|
||||
|
||||
function round(value) {
|
||||
return Math.round(value * 1000) / 1000;
|
||||
}
|
||||
|
||||
function countMeshes(root) {
|
||||
let count = 0;
|
||||
root.traverse((child) => {
|
||||
if (child.isMesh || child.isPoints) count += 1;
|
||||
});
|
||||
return count;
|
||||
}
|
||||
|
||||
function publishStageDebug(metrics, tracked, camera) {
|
||||
if (typeof window === 'undefined') return;
|
||||
const placements = Object.fromEntries(
|
||||
Object.entries(tracked).map(([name, object]) => {
|
||||
const bounds = projectedBounds(object, camera);
|
||||
const visible = bounds.left >= -1.08 && bounds.right <= 1.08 && bounds.bottom >= -1.08 && bounds.top <= 1.08;
|
||||
return [name, { bounds, visible }];
|
||||
}),
|
||||
);
|
||||
window.__AMERC_STAGE = {
|
||||
version: RELEASE,
|
||||
release: RELEASE,
|
||||
...metrics,
|
||||
placements,
|
||||
allVisible: Object.values(placements).every((item) => item.visible),
|
||||
};
|
||||
}
|
||||
|
||||
function projectedBounds(object, camera) {
|
||||
object.updateWorldMatrix(true, true);
|
||||
const box = new THREE.Box3().setFromObject(object);
|
||||
if (box.isEmpty()) return { left: 0, right: 0, top: 0, bottom: 0 };
|
||||
const points = [
|
||||
new THREE.Vector3(box.min.x, box.min.y, box.min.z),
|
||||
new THREE.Vector3(box.min.x, box.min.y, box.max.z),
|
||||
new THREE.Vector3(box.min.x, box.max.y, box.min.z),
|
||||
new THREE.Vector3(box.min.x, box.max.y, box.max.z),
|
||||
new THREE.Vector3(box.max.x, box.min.y, box.min.z),
|
||||
new THREE.Vector3(box.max.x, box.min.y, box.max.z),
|
||||
new THREE.Vector3(box.max.x, box.max.y, box.min.z),
|
||||
new THREE.Vector3(box.max.x, box.max.y, box.max.z),
|
||||
].map((point) => point.project(camera));
|
||||
return {
|
||||
left: round(Math.min(...points.map((point) => point.x))),
|
||||
right: round(Math.max(...points.map((point) => point.x))),
|
||||
top: round(Math.max(...points.map((point) => point.y))),
|
||||
bottom: round(Math.min(...points.map((point) => point.y))),
|
||||
};
|
||||
}
|
||||
|
||||
function disposeScene(scene) {
|
||||
scene.traverse((object) => {
|
||||
if (object.geometry) object.geometry.dispose();
|
||||
const materials = Array.isArray(object.material) ? object.material : [object.material];
|
||||
materials.filter(Boolean).forEach((material) => {
|
||||
if (material.map) material.map.dispose();
|
||||
material.dispose?.();
|
||||
});
|
||||
});
|
||||
}
|
||||
141
src/Whiteboard.jsx
Normal file
141
src/Whiteboard.jsx
Normal file
@ -0,0 +1,141 @@
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { api, fileUrl } from './api.js';
|
||||
import Netdisk, { FilePreview } from './Netdisk.jsx';
|
||||
|
||||
const COLORS = ['#27d8ff', '#36f0b0', '#f2b85f', '#9d73ff', '#ff7a9c'];
|
||||
const uid = () => `n${Math.random().toString(36).slice(2, 8)}`;
|
||||
|
||||
// Mind-map whiteboard. Nodes are draggable; nodes can link to a netdisk file,
|
||||
// which is previewed (and openable to edit) in the side panel.
|
||||
export default function Whiteboard({ boardId, onClose }) {
|
||||
const [name, setName] = useState('');
|
||||
const [nodes, setNodes] = useState([]);
|
||||
const [edges, setEdges] = useState([]);
|
||||
const [sel, setSel] = useState(null);
|
||||
const [connectFrom, setConnectFrom] = useState(null);
|
||||
const [picking, setPicking] = useState(false);
|
||||
const [previewFile, setPreviewFile] = useState(null);
|
||||
const [dirty, setDirty] = useState(false);
|
||||
const [saved, setSaved] = useState('');
|
||||
const svgRef = useRef(null);
|
||||
const drag = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
api(`/boards/${boardId}`).then((d) => {
|
||||
setName(d.board.name);
|
||||
let data = {}; try { data = JSON.parse(d.board.data || '{}'); } catch {}
|
||||
setNodes(data.nodes || []); setEdges(data.edges || []);
|
||||
}).catch(() => {});
|
||||
}, [boardId]);
|
||||
|
||||
const save = useCallback(async () => {
|
||||
const links = nodes.filter((n) => n.link).map((n) => ({ nodeId: n.id, ...n.link }));
|
||||
await api(`/boards/${boardId}`, { method: 'PATCH', body: { name, data: JSON.stringify({ nodes, edges }), links } });
|
||||
setDirty(false); setSaved('saved ' + new Date().toLocaleTimeString()); setTimeout(() => setSaved(''), 2500);
|
||||
}, [boardId, name, nodes, edges]);
|
||||
|
||||
// autosave when dirty
|
||||
useEffect(() => { if (!dirty) return; const t = setTimeout(save, 1200); return () => clearTimeout(t); }, [dirty, save]);
|
||||
const mutate = (fn) => { fn(); setDirty(true); };
|
||||
|
||||
const svgPoint = (e) => {
|
||||
const r = svgRef.current.getBoundingClientRect();
|
||||
return { x: e.clientX - r.left + svgRef.current.parentNode.scrollLeft, y: e.clientY - r.top + svgRef.current.parentNode.scrollTop };
|
||||
};
|
||||
const addNode = (e) => {
|
||||
if (e.target !== svgRef.current) return;
|
||||
const p = svgPoint(e);
|
||||
const n = { id: uid(), x: p.x, y: p.y, text: 'idea', color: COLORS[nodes.length % COLORS.length] };
|
||||
mutate(() => setNodes((ns) => [...ns, n])); setSel(n.id);
|
||||
};
|
||||
const onNodeDown = (e, n) => {
|
||||
e.stopPropagation();
|
||||
if (connectFrom && connectFrom !== n.id) {
|
||||
mutate(() => setEdges((es) => es.some((x) => x.from === connectFrom && x.to === n.id) ? es : [...es, { from: connectFrom, to: n.id }]));
|
||||
setConnectFrom(null); return;
|
||||
}
|
||||
setSel(n.id);
|
||||
const p = svgPoint(e);
|
||||
drag.current = { id: n.id, dx: p.x - n.x, dy: p.y - n.y, moved: false };
|
||||
};
|
||||
const onMove = (e) => {
|
||||
if (!drag.current) return;
|
||||
const p = svgPoint(e); drag.current.moved = true;
|
||||
setNodes((ns) => ns.map((n) => n.id === drag.current.id ? { ...n, x: p.x - drag.current.dx, y: p.y - drag.current.dy } : n));
|
||||
};
|
||||
const onUp = () => { if (drag.current?.moved) setDirty(true); drag.current = null; };
|
||||
|
||||
const selNode = nodes.find((n) => n.id === sel) || null;
|
||||
const setSelField = (k, v) => mutate(() => setNodes((ns) => ns.map((n) => n.id === sel ? { ...n, [k]: v } : n)));
|
||||
const delSel = () => { if (!sel) return; mutate(() => { setNodes((ns) => ns.filter((n) => n.id !== sel)); setEdges((es) => es.filter((x) => x.from !== sel && x.to !== sel)); }); setSel(null); };
|
||||
const linkFile = (f) => { setSelField('link', { type: 'file', id: f.id, label: f.name, mime: f.mime, kind: f.kind }); setPicking(false); };
|
||||
const openLinked = (n) => { if (n.link?.type === 'file') api(`/files/${n.link.id}`).then((d) => setPreviewFile(d.file)).catch(() => {}); };
|
||||
|
||||
return (
|
||||
<div className="wb">
|
||||
<div className="wb-toolbar">
|
||||
<button className="cs-btn" onClick={onClose}>← Boards</button>
|
||||
<input className="wb-name" value={name} onChange={(e) => { setName(e.target.value); setDirty(true); }} />
|
||||
<span className="wb-hint">double-click canvas = new node · drag to move</span>
|
||||
<button className={`cs-btn${connectFrom ? ' cs-primary' : ''}`} onClick={() => setConnectFrom(connectFrom ? null : sel)} disabled={!sel && !connectFrom}>{connectFrom ? 'click target…' : 'Connect'}</button>
|
||||
<button className="cs-btn" onClick={delSel} disabled={!sel}>Delete</button>
|
||||
<button className="cs-btn" onClick={() => setPicking(true)} disabled={!sel}>Link file</button>
|
||||
<button className="cs-btn cs-primary" onClick={save}>Save</button>
|
||||
<span className="wb-saved">{saved || (dirty ? 'unsaved…' : '')}</span>
|
||||
</div>
|
||||
<div className="wb-body">
|
||||
<div className="wb-canvas-wrap">
|
||||
<svg ref={svgRef} className="wb-canvas" width="2400" height="1600" onDoubleClick={addNode} onMouseMove={onMove} onMouseUp={onUp} onMouseLeave={onUp}>
|
||||
{edges.map((ed, i) => {
|
||||
const a = nodes.find((n) => n.id === ed.from), b = nodes.find((n) => n.id === ed.to);
|
||||
if (!a || !b) return null;
|
||||
return <line key={i} x1={a.x} y1={a.y} x2={b.x} y2={b.y} stroke="#3a4a6a" strokeWidth="2" />;
|
||||
})}
|
||||
{nodes.map((n) => (
|
||||
<g key={n.id} transform={`translate(${n.x},${n.y})`} onMouseDown={(e) => onNodeDown(e, n)} onDoubleClick={(e) => { e.stopPropagation(); openLinked(n); }} style={{ cursor: 'grab' }}>
|
||||
<rect x={-Math.max(46, n.text.length * 4.6)} y={-20} width={Math.max(92, n.text.length * 9.2)} height={40} rx={10}
|
||||
fill="#101626" stroke={sel === n.id ? '#fff' : n.color} strokeWidth={sel === n.id ? 3 : 2} />
|
||||
<text textAnchor="middle" y={n.link ? -2 : 5} fill="#eaf2ff" fontSize="13" style={{ pointerEvents: 'none' }}>{n.text}</text>
|
||||
{n.link && <text textAnchor="middle" y={13} fill={n.color} fontSize="9" style={{ pointerEvents: 'none' }}>🔗 {n.link.label.slice(0, 22)}</text>}
|
||||
</g>
|
||||
))}
|
||||
</svg>
|
||||
</div>
|
||||
<aside className="wb-side">
|
||||
{selNode ? (
|
||||
<>
|
||||
<h4>Node</h4>
|
||||
<label className="cs-field"><span>Text</span><input value={selNode.text} onChange={(e) => setSelField('text', e.target.value)} /></label>
|
||||
<label className="cs-field"><span>Color</span>
|
||||
<div className="wb-colors">{COLORS.map((c) => <button key={c} className={selNode.color === c ? 'on' : ''} style={{ background: c }} onClick={() => setSelField('color', c)} />)}</div>
|
||||
</label>
|
||||
{selNode.link ? (
|
||||
<div className="wb-link">
|
||||
<span>🔗 {selNode.link.label}</span>
|
||||
<button className="cs-btn" onClick={() => openLinked(selNode)}>Preview / edit</button>
|
||||
<button className="cs-btn" onClick={() => setSelField('link', undefined)}>Unlink</button>
|
||||
</div>
|
||||
) : <button className="cs-btn" onClick={() => setPicking(true)}>Link netdisk file</button>}
|
||||
</>
|
||||
) : <p className="cs-hint">Select a node, or double-click the canvas to add one. Link nodes to netdisk files to build a visual map of your docs and assets.</p>}
|
||||
</aside>
|
||||
</div>
|
||||
{picking && (
|
||||
<div className="cs-modal" onClick={() => setPicking(false)}>
|
||||
<div className="cs-modal-box" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="cs-modal-head"><strong>Link a netdisk file to this node</strong><button onClick={() => setPicking(false)}>✕</button></div>
|
||||
<Netdisk compact onPick={linkFile} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{previewFile && (
|
||||
<div className="cs-modal" onClick={() => setPreviewFile(null)}>
|
||||
<div className="cs-modal-box cs-modal-wide" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="cs-modal-head"><strong>{previewFile.name}</strong><button onClick={() => setPreviewFile(null)}>✕</button></div>
|
||||
<FilePreview file={previewFile} onChange={() => {}} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
29
src/api.js
Normal file
29
src/api.js
Normal file
@ -0,0 +1,29 @@
|
||||
// Shared amerc API client. Same-origin /api on every *.amerc.ai subdomain
|
||||
// (each nginx vhost proxies /api -> the backend; session cookie is Domain=.amerc.ai).
|
||||
export const API = '/api';
|
||||
|
||||
export async function api(path, { method = 'GET', body, raw = false } = {}) {
|
||||
const res = await fetch(API + path, {
|
||||
method,
|
||||
headers: body ? { 'Content-Type': 'application/json' } : undefined,
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
credentials: 'same-origin',
|
||||
});
|
||||
if (raw) return res;
|
||||
let data = {};
|
||||
try { data = await res.json(); } catch { /* ignore */ }
|
||||
if (!res.ok || data.ok === false) throw new Error(data.error || `request failed (${res.status})`);
|
||||
return data;
|
||||
}
|
||||
|
||||
export const fileUrl = (id) => `${API}/files/${id}/raw`;
|
||||
|
||||
// Read a File object as base64 (for netdisk uploads).
|
||||
export function fileToBase64(file) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const r = new FileReader();
|
||||
r.onload = () => resolve(String(r.result).split(',')[1] || '');
|
||||
r.onerror = reject;
|
||||
r.readAsDataURL(file);
|
||||
});
|
||||
}
|
||||
21
src/main.jsx
Normal file
21
src/main.jsx
Normal file
@ -0,0 +1,21 @@
|
||||
import React, { Suspense, lazy } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import './styles.css';
|
||||
|
||||
// One SPA, served from every *.amerc.ai docroot; pick the app by hostname.
|
||||
const host = window.location.hostname;
|
||||
const DocsApp = lazy(() => import('./DocsApp.jsx'));
|
||||
const PmApp = lazy(() => import('./PmApp.jsx'));
|
||||
const Scene2DApp = lazy(() => import('./Scene2D.jsx'));
|
||||
|
||||
let App = Scene2DApp;
|
||||
if (host.startsWith('docs.')) App = DocsApp;
|
||||
else if (host.startsWith('pm.')) App = PmApp;
|
||||
|
||||
createRoot(document.getElementById('root')).render(
|
||||
<React.StrictMode>
|
||||
<Suspense fallback={<div style={{ color: '#9fb0d0', fontFamily: 'monospace', padding: 24 }}>Loading amerc…</div>}>
|
||||
<App />
|
||||
</Suspense>
|
||||
</React.StrictMode>,
|
||||
);
|
||||
907
src/styles.css
Normal file
907
src/styles.css
Normal file
@ -0,0 +1,907 @@
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
font-family:
|
||||
Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
--cream: #fff0d7;
|
||||
--gold: #f7bc5d;
|
||||
--cyan: #42dcff;
|
||||
--wood-a: #8b4a22;
|
||||
--wood-b: #170704;
|
||||
background: #050303;
|
||||
color: var(--cream);
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body,
|
||||
#root {
|
||||
width: 100%;
|
||||
min-width: 320px;
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
background: #050303;
|
||||
}
|
||||
|
||||
button {
|
||||
border: 0;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.amerc-app,
|
||||
.tavern-stage {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.tavern-stage {
|
||||
z-index: 0;
|
||||
height: 100svh;
|
||||
background: #050303;
|
||||
}
|
||||
|
||||
.tavern-stage canvas,
|
||||
.webgl-fallback {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.tavern-stage canvas {
|
||||
cursor: grab;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.webgl-fallback {
|
||||
background:
|
||||
radial-gradient(circle at 50% 28%, rgba(44, 214, 255, 0.22), transparent 30%),
|
||||
linear-gradient(180deg, #170c07, #050303 70%);
|
||||
}
|
||||
|
||||
.sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.wood-topbar {
|
||||
position: fixed;
|
||||
z-index: 20;
|
||||
top: 0;
|
||||
left: 0;
|
||||
display: grid;
|
||||
width: 100%;
|
||||
min-height: 74px;
|
||||
grid-template-columns: minmax(220px, 306px) 1fr auto;
|
||||
align-items: start;
|
||||
padding: 7px 28px 0 12px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.wood-topbar::before {
|
||||
position: absolute;
|
||||
top: 16px;
|
||||
right: 28px;
|
||||
left: 224px;
|
||||
height: 50px;
|
||||
border: 3px solid #3f1b0b;
|
||||
border-radius: 7px;
|
||||
background:
|
||||
radial-gradient(ellipse at 18% 40%, rgba(31, 9, 3, 0.54) 0 9px, rgba(144, 70, 26, 0.18) 10px 18px, transparent 20px),
|
||||
radial-gradient(ellipse at 66% 58%, rgba(22, 7, 2, 0.5) 0 7px, rgba(167, 86, 32, 0.18) 8px 17px, transparent 19px),
|
||||
repeating-linear-gradient(0deg, rgba(255, 226, 160, 0.09) 0 2px, transparent 2px 15px),
|
||||
repeating-linear-gradient(90deg, rgba(18, 6, 2, 0.18) 0 4px, transparent 4px 112px),
|
||||
linear-gradient(180deg, #8a471f 0%, #5a2a12 36%, #2d1207 72%, #130602 100%);
|
||||
box-shadow:
|
||||
inset 0 4px 0 rgba(255, 224, 150, 0.24),
|
||||
inset 0 -7px 0 rgba(0, 0, 0, 0.54),
|
||||
inset 0 0 0 1px rgba(255, 206, 112, 0.13),
|
||||
0 16px 32px rgba(0, 0, 0, 0.62),
|
||||
0 2px 0 rgba(255, 210, 126, 0.1);
|
||||
content: "";
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.wood-topbar::after {
|
||||
position: absolute;
|
||||
top: 5px;
|
||||
left: 50%;
|
||||
width: 48px;
|
||||
height: 64px;
|
||||
border: 3px solid #4b1e0b;
|
||||
border-radius: 5px;
|
||||
background:
|
||||
radial-gradient(circle at 50% 22%, #8ff5ff 0 12px, #006cff 13px 20px, transparent 21px),
|
||||
radial-gradient(ellipse at 36% 68%, rgba(18, 5, 1, 0.42), transparent 24px),
|
||||
linear-gradient(180deg, #8a451d, #2f1307);
|
||||
box-shadow:
|
||||
inset 0 2px 0 rgba(255, 220, 145, 0.2),
|
||||
0 0 22px rgba(32, 170, 255, 0.42),
|
||||
0 10px 28px rgba(0, 0, 0, 0.48);
|
||||
content: "";
|
||||
transform: translateX(-50%) rotate(45deg);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.logo-plaque,
|
||||
.wood-nav,
|
||||
.wood-menu,
|
||||
.wood-mobile {
|
||||
position: relative;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.logo-plaque {
|
||||
display: grid;
|
||||
width: min(286px, calc(100vw - 84px));
|
||||
min-height: 76px;
|
||||
place-items: center;
|
||||
padding: 6px 18px 9px;
|
||||
border: 4px solid #3f1a09;
|
||||
border-radius: 9px;
|
||||
background:
|
||||
radial-gradient(ellipse at 24% 42%, rgba(22, 6, 2, 0.52) 0 10px, rgba(143, 70, 25, 0.22) 11px 21px, transparent 23px),
|
||||
radial-gradient(ellipse at 72% 63%, rgba(20, 6, 2, 0.5) 0 8px, rgba(178, 92, 34, 0.18) 9px 20px, transparent 22px),
|
||||
repeating-linear-gradient(0deg, rgba(255, 228, 166, 0.11) 0 2px, transparent 2px 17px),
|
||||
repeating-linear-gradient(90deg, rgba(15, 5, 1, 0.18) 0 4px, transparent 4px 92px),
|
||||
linear-gradient(180deg, #8c4a22 0%, #5c2a12 42%, #2a1107 74%, #120602 100%);
|
||||
box-shadow:
|
||||
inset 0 5px 0 rgba(255, 228, 156, 0.26),
|
||||
inset 0 -8px 0 rgba(0, 0, 0, 0.46),
|
||||
inset 0 0 0 1px rgba(255, 202, 110, 0.14),
|
||||
0 18px 34px rgba(0, 0, 0, 0.48);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.logo-plaque::before,
|
||||
.logo-plaque::after,
|
||||
.wood-nav::before,
|
||||
.wood-nav::after {
|
||||
position: absolute;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border: 2px solid #331305;
|
||||
border-radius: 4px;
|
||||
background:
|
||||
radial-gradient(circle at 50% 35%, rgba(255, 200, 104, 0.25), transparent 42%),
|
||||
linear-gradient(180deg, #a15a24, #4c210e);
|
||||
box-shadow:
|
||||
inset 0 2px 0 rgba(255, 215, 143, 0.24),
|
||||
0 2px 0 rgba(0, 0, 0, 0.38);
|
||||
content: "";
|
||||
}
|
||||
|
||||
.logo-plaque::before {
|
||||
top: -8px;
|
||||
left: -8px;
|
||||
}
|
||||
|
||||
.logo-plaque::after {
|
||||
right: -8px;
|
||||
bottom: -8px;
|
||||
}
|
||||
|
||||
.logo-gem {
|
||||
position: absolute;
|
||||
top: -16px;
|
||||
left: 50%;
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
border: 5px solid #6f3516;
|
||||
background: linear-gradient(135deg, #8ff4ff, #0059ff 58%, #03246c);
|
||||
box-shadow:
|
||||
0 0 20px rgba(32, 180, 255, 0.72),
|
||||
0 12px 20px rgba(0, 0, 0, 0.4);
|
||||
transform: translateX(-50%) rotate(45deg);
|
||||
}
|
||||
|
||||
.logo-plaque strong {
|
||||
color: var(--gold);
|
||||
font-family: Georgia, "Times New Roman", serif;
|
||||
font-size: clamp(2.24rem, 4.8vw, 3.72rem);
|
||||
font-weight: 900;
|
||||
line-height: 0.82;
|
||||
text-shadow:
|
||||
0 4px 0 #4d210c,
|
||||
0 0 22px rgba(255, 186, 84, 0.35);
|
||||
}
|
||||
|
||||
.logo-plaque small {
|
||||
color: #ffc96e;
|
||||
font-family: Georgia, "Times New Roman", serif;
|
||||
font-size: clamp(0.8rem, 1.05vw, 1rem);
|
||||
font-weight: 800;
|
||||
text-shadow: 0 2px 0 #4d210c;
|
||||
}
|
||||
|
||||
.logo-plaque em {
|
||||
margin-top: 1px;
|
||||
color: rgba(255, 238, 201, 0.82);
|
||||
font-size: 0.56rem;
|
||||
font-style: normal;
|
||||
font-weight: 900;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.wood-nav {
|
||||
z-index: 2;
|
||||
display: flex;
|
||||
min-height: 50px;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: clamp(10px, 2.6vw, 40px);
|
||||
padding: 14px 32px 0 10px;
|
||||
}
|
||||
|
||||
.wood-nav::before {
|
||||
top: 13px;
|
||||
left: 14px;
|
||||
}
|
||||
|
||||
.wood-nav::after {
|
||||
top: 13px;
|
||||
right: 2px;
|
||||
}
|
||||
|
||||
.wood-nav button,
|
||||
.wood-mobile button {
|
||||
padding: 5px 4px;
|
||||
background: transparent;
|
||||
color: #f4e3c5;
|
||||
font-family: Georgia, "Times New Roman", serif;
|
||||
font-size: clamp(1rem, 1.35vw, 1.38rem);
|
||||
font-weight: 800;
|
||||
text-shadow: 0 2px 0 #1b0b05;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.wood-nav button:hover,
|
||||
.wood-nav button.active,
|
||||
.wood-mobile button:hover,
|
||||
.wood-mobile button.active {
|
||||
color: var(--cyan);
|
||||
text-shadow:
|
||||
0 2px 0 #06131c,
|
||||
0 0 16px rgba(53, 213, 255, 0.72);
|
||||
}
|
||||
|
||||
.quartermaster-link {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.wood-menu {
|
||||
display: none;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
place-items: center;
|
||||
margin-top: 28px;
|
||||
border: 2px solid #673018;
|
||||
border-radius: 7px;
|
||||
background: linear-gradient(180deg, #6a3518, #251007);
|
||||
color: #ffe4bb;
|
||||
box-shadow: inset 0 2px 0 rgba(255, 221, 145, 0.18);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.wood-mobile {
|
||||
position: absolute;
|
||||
top: 92px;
|
||||
right: 16px;
|
||||
display: grid;
|
||||
min-width: 220px;
|
||||
gap: 10px;
|
||||
padding: 16px;
|
||||
border: 2px solid #5e2b13;
|
||||
border-radius: 8px;
|
||||
background:
|
||||
repeating-linear-gradient(0deg, rgba(255, 222, 159, 0.06) 0 2px, transparent 2px 15px),
|
||||
linear-gradient(180deg, #522613, #180b05);
|
||||
box-shadow: 0 20px 38px rgba(0, 0, 0, 0.54);
|
||||
}
|
||||
|
||||
@media (max-width: 1040px) {
|
||||
.wood-topbar {
|
||||
grid-template-columns: minmax(210px, 278px) 1fr auto;
|
||||
padding-right: 18px;
|
||||
}
|
||||
|
||||
.wood-topbar::before {
|
||||
left: 218px;
|
||||
}
|
||||
|
||||
.wood-nav {
|
||||
gap: 16px;
|
||||
padding-right: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 820px) {
|
||||
.wood-topbar {
|
||||
min-height: 88px;
|
||||
grid-template-columns: 1fr auto;
|
||||
padding: 8px 10px 0;
|
||||
}
|
||||
|
||||
.wood-topbar::before,
|
||||
.wood-topbar::after,
|
||||
.wood-nav {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.logo-plaque {
|
||||
width: min(228px, calc(100vw - 72px));
|
||||
min-height: 72px;
|
||||
padding: 8px 14px 12px;
|
||||
}
|
||||
|
||||
.logo-plaque strong {
|
||||
font-size: clamp(2.08rem, 10vw, 2.85rem);
|
||||
}
|
||||
|
||||
.logo-plaque small {
|
||||
font-size: 0.84rem;
|
||||
}
|
||||
|
||||
.logo-plaque em {
|
||||
font-size: 0.62rem;
|
||||
}
|
||||
|
||||
.logo-gem {
|
||||
top: -13px;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-width: 4px;
|
||||
}
|
||||
|
||||
.wood-menu {
|
||||
display: grid;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.quartermaster-link {
|
||||
display: inline-block;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 430px) {
|
||||
.wood-mobile {
|
||||
right: 10px;
|
||||
left: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
.gemhall-app .wood-topbar {
|
||||
min-height: 96px;
|
||||
grid-template-columns: minmax(238px, 330px) 1fr auto;
|
||||
padding: 14px 28px 0 14px;
|
||||
}
|
||||
|
||||
.gemhall-app .wood-topbar::before {
|
||||
top: 21px;
|
||||
right: 26px;
|
||||
left: 262px;
|
||||
height: 58px;
|
||||
border: 2px solid #2a0e04;
|
||||
border-radius: 6px;
|
||||
background:
|
||||
radial-gradient(ellipse at 12% 28%, rgba(255, 187, 89, 0.2) 0 6px, rgba(42, 13, 4, 0.62) 7px 16px, transparent 18px),
|
||||
radial-gradient(ellipse at 39% 72%, rgba(255, 181, 73, 0.14) 0 5px, rgba(28, 7, 2, 0.62) 6px 14px, transparent 16px),
|
||||
radial-gradient(ellipse at 78% 36%, rgba(255, 205, 119, 0.15) 0 7px, rgba(24, 7, 2, 0.58) 8px 19px, transparent 21px),
|
||||
repeating-linear-gradient(0deg, rgba(255, 219, 141, 0.14) 0 2px, transparent 2px 12px),
|
||||
repeating-linear-gradient(90deg, rgba(12, 4, 1, 0.26) 0 3px, transparent 3px 84px),
|
||||
linear-gradient(180deg, #a45b27 0%, #743616 34%, #321207 72%, #0c0301 100%);
|
||||
box-shadow:
|
||||
inset 0 3px 0 rgba(255, 227, 160, 0.3),
|
||||
inset 0 -6px 0 rgba(0, 0, 0, 0.58),
|
||||
inset 0 0 0 1px rgba(255, 207, 112, 0.16),
|
||||
0 15px 28px rgba(0, 0, 0, 0.66);
|
||||
}
|
||||
|
||||
.gemhall-app .wood-topbar::after {
|
||||
top: 5px;
|
||||
width: 46px;
|
||||
height: 66px;
|
||||
background:
|
||||
radial-gradient(circle at 50% 22%, #b5fbff 0 10px, #0079ff 11px 18px, transparent 19px),
|
||||
linear-gradient(180deg, #a75c27, #311307);
|
||||
}
|
||||
|
||||
.gemhall-app .logo-plaque {
|
||||
width: min(318px, calc(100vw - 78px));
|
||||
min-height: 102px;
|
||||
padding: 10px 18px 14px;
|
||||
border: 3px solid #2a0e04;
|
||||
background:
|
||||
radial-gradient(ellipse at 17% 38%, rgba(255, 189, 89, 0.2) 0 7px, rgba(35, 10, 3, 0.66) 8px 19px, transparent 21px),
|
||||
radial-gradient(ellipse at 78% 64%, rgba(255, 204, 114, 0.16) 0 7px, rgba(29, 8, 2, 0.62) 8px 20px, transparent 22px),
|
||||
repeating-linear-gradient(0deg, rgba(255, 228, 159, 0.16) 0 2px, transparent 2px 13px),
|
||||
repeating-linear-gradient(90deg, rgba(15, 5, 1, 0.24) 0 4px, transparent 4px 78px),
|
||||
linear-gradient(180deg, #a65b29 0%, #713415 40%, #2b1006 74%, #0b0301 100%);
|
||||
box-shadow:
|
||||
inset 0 4px 0 rgba(255, 233, 168, 0.32),
|
||||
inset 0 -7px 0 rgba(0, 0, 0, 0.52),
|
||||
0 16px 32px rgba(0, 0, 0, 0.58);
|
||||
}
|
||||
|
||||
.gemhall-app .logo-plaque strong {
|
||||
font-size: clamp(2.9rem, 5.8vw, 4.85rem);
|
||||
}
|
||||
|
||||
.gemhall-app .wood-nav {
|
||||
min-height: 52px;
|
||||
gap: clamp(12px, 2.35vw, 34px);
|
||||
padding-top: 22px;
|
||||
}
|
||||
|
||||
.gemhall-app .wood-menu {
|
||||
border-color: #2a0e04;
|
||||
background:
|
||||
radial-gradient(ellipse at 48% 28%, rgba(255, 202, 115, 0.22), transparent 32%),
|
||||
linear-gradient(180deg, #8c481e, #271006);
|
||||
}
|
||||
|
||||
@media (max-width: 820px) {
|
||||
.gemhall-app .wood-topbar {
|
||||
min-height: 74px;
|
||||
padding: 7px 8px 0;
|
||||
}
|
||||
|
||||
.gemhall-app .logo-plaque {
|
||||
width: min(210px, calc(100vw - 66px));
|
||||
min-height: 66px;
|
||||
padding: 6px 12px 10px;
|
||||
}
|
||||
|
||||
.gemhall-app .logo-plaque strong {
|
||||
font-size: clamp(2.05rem, 9.4vw, 2.85rem);
|
||||
}
|
||||
|
||||
.gemhall-app .logo-plaque small {
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.gemhall-app .logo-plaque em {
|
||||
font-size: 0.56rem;
|
||||
}
|
||||
|
||||
.gemhall-app .wood-menu {
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
margin-top: 15px;
|
||||
}
|
||||
}
|
||||
|
||||
/* ===================================================================== */
|
||||
/* 0.18.0-pixel — 2D Starbound-style tavern */
|
||||
/* ===================================================================== */
|
||||
.px-app {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
font-family: "Press Start 2P", ui-monospace, "Courier New", monospace;
|
||||
background: #07060c;
|
||||
overflow: hidden;
|
||||
}
|
||||
.px-app img { image-rendering: pixelated; image-rendering: crisp-edges; }
|
||||
|
||||
/* ---- top bar ---- */
|
||||
.px-topbar {
|
||||
position: relative;
|
||||
z-index: 40;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
padding: 10px 16px;
|
||||
background: linear-gradient(180deg, #2a1608, #140803);
|
||||
border-bottom: 4px solid #000;
|
||||
box-shadow: 0 4px 0 rgba(0, 0, 0, 0.6), inset 0 2px 0 #6b3a1c;
|
||||
}
|
||||
.px-logo {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
color: var(--gold);
|
||||
}
|
||||
.px-logo-gem {
|
||||
width: 14px; height: 14px;
|
||||
background: var(--cyan);
|
||||
box-shadow: 0 0 0 3px #07334a, 2px 2px 0 #000;
|
||||
transform: rotate(45deg);
|
||||
align-self: center;
|
||||
}
|
||||
.px-logo strong { font-size: 18px; letter-spacing: 1px; color: var(--cream); text-shadow: 2px 2px 0 #000; }
|
||||
.px-logo small { font-size: 7px; color: #c79a5c; letter-spacing: 1px; }
|
||||
.px-logo em { font-size: 7px; color: #6f5736; font-style: normal; }
|
||||
.px-nav { display: flex; gap: 8px; margin-left: auto; flex-wrap: wrap; }
|
||||
.px-nav-btn {
|
||||
font-family: inherit;
|
||||
font-size: 9px;
|
||||
padding: 9px 11px;
|
||||
color: var(--cream);
|
||||
background: #3a2110;
|
||||
border: 2px solid #000;
|
||||
box-shadow: inset -2px -2px 0 #1c0f06, inset 2px 2px 0 #6b3a1c, 2px 2px 0 #000;
|
||||
cursor: pointer;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
.px-nav-btn:hover { background: #50300f; color: #fff; }
|
||||
.px-nav-btn.active { background: var(--gold); color: #2a1303; box-shadow: inset -2px -2px 0 #b07a26, inset 2px 2px 0 #ffe6a8, 2px 2px 0 #000; }
|
||||
.px-nav-btn.quartermaster { color: var(--cyan); }
|
||||
.px-nav-btn.quartermaster.active { color: #06222f; background: var(--cyan); }
|
||||
.px-menu { display: none; margin-left: auto; color: var(--cream); background: #3a2110; border: 2px solid #000; padding: 8px; cursor: pointer; }
|
||||
.px-mobile { display: none; }
|
||||
|
||||
/* ---- stage ---- */
|
||||
.px-stage { position: relative; flex: 1; overflow: hidden; cursor: crosshair; }
|
||||
.px-bg {
|
||||
position: absolute;
|
||||
inset: -6%;
|
||||
background-image: url('/scene2d/bg.png');
|
||||
background-size: cover;
|
||||
background-position: center 38%;
|
||||
image-rendering: pixelated;
|
||||
filter: saturate(1.05) brightness(0.82) contrast(1.08);
|
||||
will-change: transform;
|
||||
}
|
||||
.px-bg-haze {
|
||||
position: absolute; inset: 0;
|
||||
background:
|
||||
radial-gradient(120% 80% at 66% 40%, rgba(255, 159, 56, 0.28), transparent 55%),
|
||||
radial-gradient(90% 70% at 30% 55%, rgba(39, 216, 255, 0.16), transparent 60%);
|
||||
mix-blend-mode: screen;
|
||||
pointer-events: none;
|
||||
}
|
||||
.px-vignette {
|
||||
position: absolute; inset: 0; pointer-events: none;
|
||||
background: radial-gradient(130% 100% at 50% 42%, transparent 42%, rgba(5, 3, 8, 0.85) 100%);
|
||||
}
|
||||
|
||||
/* ---- embers ---- */
|
||||
.px-embers { position: absolute; inset: 0; pointer-events: none; overflow: hidden; }
|
||||
.px-embers span {
|
||||
position: absolute;
|
||||
bottom: -10px;
|
||||
background: #ffb451;
|
||||
box-shadow: 0 0 6px 1px rgba(255, 180, 81, 0.8);
|
||||
opacity: 0;
|
||||
animation-name: px-rise;
|
||||
animation-iteration-count: infinite;
|
||||
animation-timing-function: linear;
|
||||
}
|
||||
@keyframes px-rise {
|
||||
0% { transform: translateY(0) translateX(0); opacity: 0; }
|
||||
12% { opacity: 0.9; }
|
||||
80% { opacity: 0.5; }
|
||||
100% { transform: translateY(-78vh) translateX(18px); opacity: 0; }
|
||||
}
|
||||
|
||||
/* ---- floor + characters ---- */
|
||||
.px-floor {
|
||||
position: absolute;
|
||||
left: 0; right: 0;
|
||||
bottom: 6%;
|
||||
height: 0;
|
||||
will-change: transform;
|
||||
}
|
||||
.px-char {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
transform: translateX(-50%);
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
filter: drop-shadow(3px 4px 0 rgba(0, 0, 0, 0.55));
|
||||
transition: filter 0.15s;
|
||||
}
|
||||
.px-char img {
|
||||
display: block;
|
||||
animation: px-bob var(--bob, 3.6s) ease-in-out infinite;
|
||||
}
|
||||
.px-char:hover { filter: drop-shadow(0 0 10px rgba(255, 200, 120, 0.7)) drop-shadow(3px 4px 0 rgba(0, 0, 0, 0.55)); }
|
||||
.px-char.here img { animation: px-bob var(--bob, 3.6s) ease-in-out infinite, px-glow 1.6s ease-in-out infinite; }
|
||||
@keyframes px-bob {
|
||||
0%, 100% { transform: translateY(0); }
|
||||
50% { transform: translateY(-5px); }
|
||||
}
|
||||
@keyframes px-glow {
|
||||
0%, 100% { filter: brightness(1); }
|
||||
50% { filter: brightness(1.18); }
|
||||
}
|
||||
.px-char-shadow {
|
||||
position: absolute;
|
||||
left: 50%; bottom: -6px;
|
||||
width: 60%; height: 12px;
|
||||
transform: translateX(-50%);
|
||||
background: radial-gradient(ellipse, rgba(0, 0, 0, 0.55), transparent 70%);
|
||||
pointer-events: none;
|
||||
}
|
||||
.px-speech {
|
||||
position: absolute;
|
||||
bottom: calc(100% + 10px);
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: max-content;
|
||||
max-width: 230px;
|
||||
padding: 8px 10px;
|
||||
font-size: 8px;
|
||||
line-height: 1.7;
|
||||
color: #1c1206;
|
||||
background: var(--cream);
|
||||
border: 2px solid #000;
|
||||
box-shadow: 3px 3px 0 #000;
|
||||
text-align: center;
|
||||
}
|
||||
.px-speech strong { color: #7a3d0c; }
|
||||
.px-speech em { display: block; margin-top: 4px; font-style: normal; color: #3a2a14; }
|
||||
.px-speech::after {
|
||||
content: '';
|
||||
position: absolute; top: 100%; left: 50%;
|
||||
transform: translateX(-50%);
|
||||
border: 7px solid transparent;
|
||||
border-top-color: #000;
|
||||
}
|
||||
|
||||
/* ---- route board ---- */
|
||||
.px-board {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 28px;
|
||||
transform: translateY(-50%);
|
||||
width: min(340px, 42vw);
|
||||
padding: 18px 18px 16px;
|
||||
z-index: 20;
|
||||
color: var(--cream);
|
||||
background: linear-gradient(180deg, rgba(20, 14, 30, 0.95), rgba(10, 7, 16, 0.96));
|
||||
border: 3px solid #000;
|
||||
box-shadow: inset 0 0 0 2px var(--tint), inset 0 0 24px rgba(0, 0, 0, 0.7), 5px 5px 0 #000;
|
||||
animation: px-board-in 0.25s ease-out;
|
||||
}
|
||||
@keyframes px-board-in {
|
||||
from { opacity: 0; transform: translateY(-50%) translateX(-12px); }
|
||||
to { opacity: 1; transform: translateY(-50%) translateX(0); }
|
||||
}
|
||||
.px-board-rivets {
|
||||
position: absolute; inset: 6px; pointer-events: none;
|
||||
background-image: radial-gradient(circle, var(--tint) 1.5px, transparent 2px);
|
||||
background-position: 0 0, 100% 0, 0 100%, 100% 100%;
|
||||
background-size: 8px 8px;
|
||||
background-repeat: no-repeat;
|
||||
opacity: 0.5;
|
||||
}
|
||||
.px-board-tag {
|
||||
display: inline-block;
|
||||
font-size: 7px;
|
||||
letter-spacing: 2px;
|
||||
padding: 4px 6px;
|
||||
color: #07060c;
|
||||
background: var(--tint);
|
||||
}
|
||||
.px-board-head h2 { margin: 10px 0 6px; font-size: 14px; color: #fff; text-shadow: 2px 2px 0 #000; line-height: 1.4; }
|
||||
.px-board-head p { margin: 0 0 12px; font-size: 8px; line-height: 1.8; color: #b9a6cf; }
|
||||
.px-board-rows { list-style: none; margin: 0 0 12px; padding: 0; display: flex; flex-direction: column; gap: 8px; }
|
||||
.px-board-rows li { display: flex; align-items: center; gap: 8px; font-size: 8px; line-height: 1.7; color: var(--cream); }
|
||||
.px-bullet { flex: none; width: 7px; height: 7px; background: var(--tint); box-shadow: 1px 1px 0 #000; transform: rotate(45deg); }
|
||||
.px-board-stats { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 12px; }
|
||||
.px-stat { font-size: 7px; padding: 5px 6px; color: var(--tint); border: 1px solid var(--tint); background: rgba(0, 0, 0, 0.4); }
|
||||
.px-board-actions { display: flex; flex-wrap: wrap; gap: 6px; }
|
||||
.px-action {
|
||||
font-family: inherit;
|
||||
font-size: 7px;
|
||||
padding: 7px 8px;
|
||||
color: var(--cream);
|
||||
background: #2a1c12;
|
||||
border: 2px solid #000;
|
||||
box-shadow: inset -1px -1px 0 #140b06, inset 1px 1px 0 #4a2f18, 2px 2px 0 #000;
|
||||
cursor: pointer;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
.px-action:hover { background: var(--tint); color: #07060c; }
|
||||
|
||||
/* ---- mobile ---- */
|
||||
@media (max-width: 760px) {
|
||||
.px-nav { display: none; }
|
||||
.px-menu { display: inline-flex; }
|
||||
.px-mobile {
|
||||
display: flex; flex-direction: column; gap: 6px;
|
||||
position: absolute; top: 100%; right: 8px;
|
||||
padding: 8px; background: #1a0d05; border: 3px solid #000; z-index: 50;
|
||||
}
|
||||
.px-board { left: 50%; transform: translate(-50%, 0); top: auto; bottom: 12px; width: min(94vw, 420px); }
|
||||
@keyframes px-board-in { from { opacity: 0; } to { opacity: 1; } }
|
||||
.px-char img { height: auto; }
|
||||
}
|
||||
|
||||
/* ---- auth + admin (0.18 backend wiring) ---- */
|
||||
.px-nav-user { color: #36f0b0; }
|
||||
.px-board-wide { width: min(680px, 88vw); left: 50%; transform: translate(-50%, -50%); }
|
||||
@keyframes px-board-in-wide { from { opacity: 0; } to { opacity: 1; } }
|
||||
|
||||
.px-auth { display: flex; flex-direction: column; gap: 10px; }
|
||||
.px-auth-tabs { display: flex; gap: 6px; margin-bottom: 4px; }
|
||||
.px-auth-tabs button {
|
||||
flex: 1; font-family: inherit; font-size: 9px; padding: 8px; cursor: pointer; letter-spacing: 1px;
|
||||
color: var(--cream); background: #2a1c12; border: 2px solid #000; box-shadow: inset 1px 1px 0 #4a2f18;
|
||||
}
|
||||
.px-auth-tabs button.active { background: var(--tint); color: #07060c; }
|
||||
.px-field { display: flex; flex-direction: column; gap: 5px; font-size: 7px; color: #b9a6cf; letter-spacing: 1px; }
|
||||
.px-field input {
|
||||
font-family: inherit; font-size: 9px; padding: 9px; color: var(--cream);
|
||||
background: #0c0814; border: 2px solid #000; box-shadow: inset 1px 1px 0 #251a3a;
|
||||
}
|
||||
.px-field input:focus { outline: none; box-shadow: inset 0 0 0 2px var(--tint); }
|
||||
.px-auth-submit { align-self: stretch; text-align: center; padding: 10px; font-size: 9px; }
|
||||
.px-auth-err { color: #ff7a7a; font-size: 8px; line-height: 1.6; margin: 0; }
|
||||
.px-auth-hint { color: #6f5b8c; font-size: 7px; line-height: 1.7; margin: 2px 0 0; }
|
||||
.px-auth-hi { font-size: 9px; color: var(--cream); line-height: 1.7; margin: 0; }
|
||||
.px-auth-hi em { color: #36f0b0; font-style: normal; }
|
||||
.px-auth-actions { display: flex; gap: 6px; flex-wrap: wrap; margin-top: 8px; }
|
||||
|
||||
.px-admin { display: flex; flex-direction: column; gap: 10px; }
|
||||
.px-admin-gate { text-align: center; padding: 12px 0; }
|
||||
.px-admin-gate h3 { font-size: 12px; color: #ff9f9f; margin: 0 0 8px; }
|
||||
.px-admin-gate p { font-size: 8px; color: #b9a6cf; margin: 0 0 12px; line-height: 1.7; }
|
||||
.px-admin-tabs { display: flex; gap: 6px; align-items: center; flex-wrap: wrap; }
|
||||
.px-admin-tabs button {
|
||||
font-family: inherit; font-size: 8px; padding: 7px 9px; cursor: pointer; letter-spacing: 1px;
|
||||
color: var(--cream); background: #2a1c12; border: 2px solid #000;
|
||||
}
|
||||
.px-admin-tabs button.active { background: var(--tint); color: #07060c; }
|
||||
.px-admin-who { margin-left: auto; font-size: 7px; color: #36f0b0; }
|
||||
.px-admin-table { display: flex; flex-direction: column; border: 2px solid #000; background: rgba(0,0,0,0.35); max-height: 38vh; overflow: auto; }
|
||||
.px-admin-row { display: grid; grid-template-columns: repeat(var(--cols, 5), 1fr) 40px; gap: 6px; align-items: center; padding: 7px 8px; border-bottom: 1px solid #1c1230; font-size: 8px; }
|
||||
.px-admin-row span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.px-admin-head { position: sticky; top: 0; background: #1a1030; color: var(--tint); font-size: 7px; letter-spacing: 1px; z-index: 1; }
|
||||
.px-admin-row select { font-family: inherit; font-size: 8px; background: #0c0814; color: var(--cream); border: 1px solid #000; padding: 3px; }
|
||||
.px-admin-acts button { color: #ff7a7a; background: none; cursor: pointer; font-size: 10px; padding: 2px 6px; }
|
||||
.px-admin-empty { color: #6f5b8c; grid-template-columns: 1fr; }
|
||||
.px-admin-new { display: flex; gap: 6px; flex-wrap: wrap; }
|
||||
.px-admin-new input { font-family: inherit; font-size: 8px; padding: 7px; flex: 1 1 90px; min-width: 70px; color: var(--cream); background: #0c0814; border: 2px solid #000; }
|
||||
/* users table = 4 cols, others vary; set per-tab via attribute fallback */
|
||||
.px-admin-table .px-admin-row { --cols: 4; }
|
||||
|
||||
/* ===================================================================== */
|
||||
/* Console theme — docs.amerc.ai / pm.amerc.ai (operator tools) */
|
||||
/* ===================================================================== */
|
||||
.cs-app { position: fixed; inset: 0; display: flex; flex-direction: column;
|
||||
background: #0a0e17; color: #dce6f5; font-family: Inter, ui-sans-serif, system-ui, sans-serif; --accent: #27d8ff; }
|
||||
.cs-loading { margin: auto; color: #6c7b96; font-family: ui-monospace, monospace; }
|
||||
.cs-brand { display: flex; align-items: center; gap: 8px; font-family: "Press Start 2P", monospace; font-size: 15px; color: #fff; }
|
||||
.cs-brand small { font-family: Inter, sans-serif; font-size: 11px; color: var(--accent); letter-spacing: 1px; }
|
||||
.cs-gem { width: 12px; height: 12px; background: var(--accent); transform: rotate(45deg); box-shadow: 0 0 0 2px #07273a; }
|
||||
|
||||
.cs-top { display: flex; align-items: center; gap: 18px; padding: 12px 18px; background: #0d1320; border-bottom: 1px solid #1b2536; }
|
||||
.cs-appnav { display: flex; gap: 4px; margin-left: 8px; }
|
||||
.cs-appnav a { padding: 6px 12px; border-radius: 7px; color: #aab8d0; text-decoration: none; font-size: 13px; }
|
||||
.cs-appnav a:hover { background: #16203200; background: #162032; color: #fff; }
|
||||
.cs-appnav a.on { background: var(--accent); color: #061018; font-weight: 600; }
|
||||
.cs-user { margin-left: auto; display: flex; align-items: center; gap: 10px; font-size: 13px; color: #aab8d0; }
|
||||
.cs-user button { padding: 6px 12px; border-radius: 7px; background: #1b2536; color: #dce6f5; cursor: pointer; font-size: 12px; }
|
||||
.cs-main { flex: 1; min-height: 0; overflow: hidden; display: flex; }
|
||||
|
||||
.cs-login-wrap { margin: auto; }
|
||||
.cs-login { display: flex; flex-direction: column; gap: 12px; width: 320px; padding: 28px; background: #0e1422; border: 1px solid #1f2a3d; border-radius: 14px; box-shadow: 0 20px 60px rgba(0,0,0,0.5); }
|
||||
.cs-login .cs-brand { justify-content: center; margin-bottom: 6px; }
|
||||
.cs-tabs { display: flex; gap: 6px; }
|
||||
.cs-tabs button { flex: 1; padding: 8px; border-radius: 8px; background: #18213200; background: #182132; color: #aab8d0; cursor: pointer; font-size: 13px; }
|
||||
.cs-tabs button.on { background: var(--accent); color: #061018; font-weight: 600; }
|
||||
.cs-login input, .cs-field input, .nd-folder-input, .nd-bar select, .wb-name, .docs-side-top input, .docs-title-input, .docs-folder-input, .pm-nav + * input { font-family: inherit; }
|
||||
.cs-login input { padding: 11px; border-radius: 8px; background: #0a0f1a; border: 1px solid #233044; color: #eaf2ff; font-size: 14px; }
|
||||
.cs-login input:focus, textarea:focus, .nd-text:focus { outline: 2px solid var(--accent); border-color: transparent; }
|
||||
.cs-err { color: #ff7a8c; font-size: 13px; margin: 0; }
|
||||
.cs-hint { color: #6c7b96; font-size: 12px; line-height: 1.6; margin: 6px 0; }
|
||||
.cs-btn { padding: 8px 12px; border-radius: 8px; background: #1b2536; color: #dce6f5; cursor: pointer; font-size: 13px; border: 1px solid #26334a; text-decoration: none; display: inline-flex; align-items: center; gap: 6px; }
|
||||
.cs-btn:hover { background: #233044; }
|
||||
.cs-btn:disabled { opacity: 0.45; cursor: default; }
|
||||
.cs-primary { background: var(--accent); color: #061018; border-color: transparent; font-weight: 600; }
|
||||
.cs-primary:hover { filter: brightness(1.08); }
|
||||
.cs-field { display: flex; flex-direction: column; gap: 6px; font-size: 12px; color: #8a99b5; margin-bottom: 10px; }
|
||||
.cs-field input { padding: 9px; border-radius: 7px; background: #0a0f1a; border: 1px solid #233044; color: #eaf2ff; font-size: 13px; }
|
||||
|
||||
.cs-modal { position: fixed; inset: 0; background: rgba(4,7,12,0.7); display: flex; align-items: center; justify-content: center; z-index: 100; }
|
||||
.cs-modal-box { width: min(560px, 92vw); max-height: 86vh; overflow: auto; background: #0e1422; border: 1px solid #233044; border-radius: 14px; padding: 16px; }
|
||||
.cs-modal-wide { width: min(880px, 94vw); }
|
||||
.cs-modal-head { display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; }
|
||||
.cs-modal-head button { background: #1b2536; color: #dce6f5; padding: 4px 10px; border-radius: 6px; cursor: pointer; }
|
||||
|
||||
/* docs */
|
||||
.docs { display: flex; flex: 1; min-height: 0; width: 100%; }
|
||||
.docs-side { width: 280px; border-right: 1px solid #1b2536; display: flex; flex-direction: column; background: #0c111c; }
|
||||
.docs-side-top { display: flex; gap: 8px; padding: 12px; border-bottom: 1px solid #1b2536; }
|
||||
.docs-side-top input { flex: 1; padding: 8px; border-radius: 7px; background: #0a0f1a; border: 1px solid #233044; color: #eaf2ff; }
|
||||
.docs-tree { overflow: auto; padding: 8px; }
|
||||
.docs-folder-name { font-size: 11px; text-transform: uppercase; letter-spacing: 1px; color: #5f6e88; margin: 10px 6px 4px; }
|
||||
.docs-item { display: block; width: 100%; text-align: left; padding: 7px 10px; border-radius: 7px; color: #c3d0e6; cursor: pointer; font-size: 13px; }
|
||||
.docs-item:hover { background: #141d2e; }
|
||||
.docs-item.on { background: var(--accent); color: #061018; }
|
||||
.docs-main { flex: 1; min-width: 0; overflow: auto; padding: 26px 32px; }
|
||||
.docs-empty { color: #5f6e88; margin-top: 40px; text-align: center; }
|
||||
.docs-head { display: flex; align-items: center; gap: 12px; }
|
||||
.docs-head h2 { margin: 0; font-size: 24px; }
|
||||
.docs-title-input { flex: 1; font-size: 22px; padding: 8px; border-radius: 8px; background: #0a0f1a; border: 1px solid #233044; color: #fff; }
|
||||
.docs-actions { margin-left: auto; display: flex; gap: 8px; align-items: center; }
|
||||
.docs-folder-input { padding: 7px; border-radius: 7px; background: #0a0f1a; border: 1px solid #233044; color: #eaf2ff; width: 120px; }
|
||||
.docs-byline { color: #5f6e88; font-size: 12px; margin: 4px 0 18px; }
|
||||
.docs-editor { width: 100%; min-height: 60vh; padding: 16px; border-radius: 10px; background: #0a0f1a; border: 1px solid #233044; color: #eaf2ff; font-family: ui-monospace, monospace; font-size: 14px; line-height: 1.6; resize: vertical; }
|
||||
.docs-render { line-height: 1.7; font-size: 15px; max-width: 760px; }
|
||||
.docs-render h1, .docs-render h2, .docs-render h3 { color: #fff; margin-top: 1.4em; }
|
||||
.docs-render a { color: var(--accent); }
|
||||
.docs-render code { background: #16203200; background: #162032; padding: 2px 6px; border-radius: 5px; font-size: 0.9em; }
|
||||
.docs-render pre { background: #0a0f1a; padding: 14px; border-radius: 10px; overflow: auto; border: 1px solid #1b2536; }
|
||||
.docs-render blockquote { border-left: 3px solid var(--accent); margin: 0; padding-left: 14px; color: #aab8d0; }
|
||||
.docs-render table { border-collapse: collapse; } .docs-render td, .docs-render th { border: 1px solid #233044; padding: 6px 10px; }
|
||||
|
||||
/* pm */
|
||||
.pm { display: flex; flex: 1; min-height: 0; width: 100%; }
|
||||
.pm-nav { width: 180px; border-right: 1px solid #1b2536; padding: 14px 10px; display: flex; flex-direction: column; gap: 4px; background: #0c111c; }
|
||||
.pm-nav button { text-align: left; padding: 10px 12px; border-radius: 8px; color: #c3d0e6; cursor: pointer; font-size: 14px; }
|
||||
.pm-nav button:hover { background: #141d2e; }
|
||||
.pm-nav button.on { background: var(--accent); color: #061018; font-weight: 600; }
|
||||
.pm-nav-ext { margin-top: auto; padding: 10px 12px; color: #8a99b5; font-size: 13px; text-decoration: none; }
|
||||
.pm-body { flex: 1; min-width: 0; overflow: auto; padding: 22px 26px; display: flex; flex-direction: column; }
|
||||
.pm-cards-head, .pm-disk h2, .pm-portfolio h2 { display: flex; align-items: center; gap: 12px; }
|
||||
.pm-cards-head { justify-content: space-between; }
|
||||
.pm-cards-head h2 { margin: 0; }
|
||||
.pm-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 14px; margin-top: 16px; }
|
||||
.pm-card { position: relative; padding: 16px; border-radius: 12px; background: #0e1422; border: 1px solid #1f2a3d; cursor: pointer; display: flex; flex-direction: column; gap: 5px; }
|
||||
.pm-card:hover { border-color: var(--accent); }
|
||||
.pm-card-ico { font-size: 28px; } .pm-card strong { font-size: 15px; } .pm-card span { font-size: 12px; color: var(--accent); } .pm-card small { font-size: 11px; color: #5f6e88; }
|
||||
.pm-card-del { position: absolute; top: 8px; right: 8px; background: #1b2536; color: #ff8c9c; border-radius: 6px; padding: 2px 7px; cursor: pointer; }
|
||||
.pm-portfolio h2 { margin-top: 0; } .pm-port-cols { display: grid; grid-template-columns: 1fr 1fr; gap: 24px; margin-top: 12px; }
|
||||
.pm-port-row { display: flex; gap: 10px; padding: 9px 0; border-bottom: 1px solid #1b2536; font-size: 14px; } .pm-port-row span { color: #8a99b5; } .pm-port-row em { margin-left: auto; font-style: normal; color: #aab8d0; }
|
||||
.risk-green { color: #36f0b0 !important; } .risk-amber { color: #f2b85f !important; } .risk-red { color: #ff7a8c !important; }
|
||||
.pm-disk { flex: 1; display: flex; flex-direction: column; min-height: 0; } .pm-disk h2 { margin: 0 0 12px; }
|
||||
|
||||
/* netdisk */
|
||||
.nd { display: flex; flex-direction: column; flex: 1; min-height: 0; }
|
||||
.nd-bar { display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 12px; }
|
||||
.nd-bar select, .nd-folder-input { padding: 8px; border-radius: 7px; background: #0a0f1a; border: 1px solid #233044; color: #eaf2ff; }
|
||||
.nd-grid { display: flex; gap: 16px; flex: 1; min-height: 0; }
|
||||
.nd-compact .nd-grid { display: block; }
|
||||
.nd-list { width: 100%; max-width: 420px; overflow: auto; border: 1px solid #1b2536; border-radius: 10px; }
|
||||
.nd-compact .nd-list { max-width: none; max-height: 50vh; }
|
||||
.nd-row { display: flex; align-items: center; gap: 10px; padding: 9px 12px; border-bottom: 1px solid #141d2e; cursor: pointer; font-size: 13px; }
|
||||
.nd-row:hover { background: #111a29; } .nd-row.on { background: #14223a; }
|
||||
.nd-icon { font-size: 16px; } .nd-name { font-weight: 500; } .nd-meta { margin-left: auto; color: #5f6e88; font-size: 11px; }
|
||||
.nd-pick { padding: 3px 8px !important; font-size: 11px !important; }
|
||||
.nd-del { background: none; color: #6c7b96; cursor: pointer; padding: 2px 6px; } .nd-del:hover { color: #ff8c9c; }
|
||||
.nd-empty, .docs-empty { color: #5f6e88; padding: 20px; text-align: center; font-size: 13px; }
|
||||
.nd-preview { flex: 1; min-width: 0; border: 1px solid #1b2536; border-radius: 10px; padding: 14px; overflow: auto; }
|
||||
.nd-preview-empty { color: #5f6e88; display: flex; align-items: center; justify-content: center; }
|
||||
.nd-preview-head { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin-bottom: 12px; } .nd-preview-head span { color: #5f6e88; font-size: 12px; margin-right: auto; }
|
||||
.nd-img { max-width: 100%; image-rendering: auto; border-radius: 8px; }
|
||||
.nd-text { width: 100%; min-height: 50vh; background: #0a0f1a; border: 1px solid #233044; border-radius: 8px; color: #eaf2ff; padding: 12px; font-family: ui-monospace, monospace; }
|
||||
.nd-textview { white-space: pre-wrap; background: #0a0f1a; padding: 12px; border-radius: 8px; font-family: ui-monospace, monospace; font-size: 13px; }
|
||||
.nd-binary { color: #8a99b5; } .nd-binary a, .nd-textview a { color: var(--accent); }
|
||||
|
||||
/* whiteboard */
|
||||
.wb { display: flex; flex-direction: column; flex: 1; min-height: 0; width: 100%; }
|
||||
.wb-toolbar { display: flex; align-items: center; gap: 8px; padding: 10px 14px; border-bottom: 1px solid #1b2536; flex-wrap: wrap; }
|
||||
.wb-name { font-size: 16px; font-weight: 600; padding: 6px 10px; border-radius: 7px; background: #0a0f1a; border: 1px solid #233044; color: #fff; }
|
||||
.wb-hint { color: #5f6e88; font-size: 12px; }
|
||||
.wb-saved { color: #36f0b0; font-size: 12px; margin-left: auto; }
|
||||
.wb-body { flex: 1; display: flex; min-height: 0; }
|
||||
.wb-canvas-wrap { flex: 1; overflow: auto; background: #0a0e17 radial-gradient(circle, #131c2c 1px, transparent 1px); background-size: 26px 26px; }
|
||||
.wb-canvas { display: block; background: transparent; }
|
||||
.wb-side { width: 250px; border-left: 1px solid #1b2536; padding: 16px; background: #0c111c; overflow: auto; }
|
||||
.wb-side h4 { margin: 0 0 12px; }
|
||||
.wb-colors { display: flex; gap: 6px; } .wb-colors button { width: 22px; height: 22px; border-radius: 6px; cursor: pointer; border: 2px solid transparent; } .wb-colors button.on { border-color: #fff; }
|
||||
.wb-link { display: flex; flex-direction: column; gap: 8px; margin-top: 10px; font-size: 13px; color: #aab8d0; }
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.docs-side, .pm-nav { width: 150px; }
|
||||
.nd-grid { flex-direction: column; } .nd-list { max-width: none; }
|
||||
.cs-appnav { display: none; }
|
||||
}
|
||||
20
vite.config.js
Normal file
20
vite.config.js
Normal file
@ -0,0 +1,20 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
|
||||
const apiProxy = { '/api': { target: process.env.AMERC_API || 'http://127.0.0.1:5180', changeOrigin: true } };
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: { proxy: apiProxy },
|
||||
preview: { proxy: apiProxy },
|
||||
build: {
|
||||
chunkSizeWarningLimit: 800,
|
||||
rollupOptions: {
|
||||
output: {
|
||||
manualChunks: {
|
||||
three: ['three'],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user