Admin: tool registry, grouped nav, and an /admin/ hub

This commit is contained in:
jessikitty committed 2026-10-12 13:18:59 +11:00
1 parent 7fc2b02e6a
commit 7040d4f078
9 files changed
+412 -7

No files matched your search

+125 -7
View File
@@ -1,4 +1,5 @@
/* admin.js — shared auth + API helpers for admin pages. */
/* admin.js — shared auth, API helpers, tool registry and chrome for admin pages. */
export async function api(path, method = 'GET', body) {
const res = await fetch(path, {
method,
@@ -22,7 +23,63 @@ export async function requireAuth() {
return info;
}
export const styles = `
/* ---------------------------------------------------------------------------
* TOOL REGISTRY — the single source of truth for what the admin section
* contains. The nav bar and the /admin/ hub are both generated from it, so a
* new page is added in one place and cannot go missing from one and not the
* other. That is how `testsheet` ended up unreachable: the nav was a hand-kept
* array of filenames and nobody remembered to extend it.
*
* `name` is what the thing IS, not what its file is called. `blurb` is one
* line, written for someone who has not opened the page in a month.
* `chrome: false` marks a page whose layout cannot host the nav bar — a
* full-bleed camera view, or a sheet that must print edge to edge. Those get a
* small floating back link instead (see backLink()).
* ------------------------------------------------------------------------- */
export const GROUPS = ['World', 'Viewer', 'Setup', 'Diagnostics'];
export const TOOLS = [
{ id: 'layout', group: 'World', name: 'Layout',
blurb: 'Place crests, buildings, occluders and ghost spawn points on the table in 3D.' },
{ id: 'characters', group: 'World', name: 'Characters',
blurb: 'Build ghosts from parts — model, appearance, textures, part placement.' },
{ id: 'playlist', group: 'World', name: 'Playlist',
blurb: 'Which ghosts appear, when, for how long, and how they move.' },
{ id: 'landing', group: 'Viewer', name: 'Landing page',
blurb: 'What visitors see before the camera starts — artwork, theme, disclaimer.' },
{ id: 'preview', group: 'Viewer', name: 'Virtual viewport',
blurb: 'Watch the live scene play out from a chosen viewpoint, without a phone.' },
{ id: 'print', group: 'Setup', name: 'Print crests',
blurb: 'Printable sheet of the crests this layout uses, at true size.' },
{ id: 'plan', group: 'Setup', name: 'Placement plan',
blurb: 'Printable overhead plan showing where each crest goes on the table.' },
{ id: 'testsheet', group: 'Setup', name: 'A3 test rig', chrome: false,
blurb: 'Self-contained A3 sheet with a raised marker tower, for testing tracking.' },
{ id: 'calibrate', group: 'Setup', name: 'Calibrate', chrome: false,
blurb: 'Point a phone at a crest and read pose, FOV and reprojection error live.' },
{ id: 'errors', group: 'Diagnostics', name: 'Client errors',
blurb: 'Problems reported by viewer devices, newest first.' },
];
export const toolById = id => TOOLS.find(t => t.id === id);
export const href = t => `/admin/${t.id}.html`;
/* Kept separate so a full-bleed tool can inject just this, without pulling in
the body/background rules that would wreck its layout. */
export const BACK_CSS = `
/* Back link for full-bleed tools that cannot host the bar. */
.aback { position:fixed; top:8px; left:8px; z-index:9999; background:rgba(10,13,24,.82);
color:#8fb8ff; text-decoration:none; font:500 .8rem system-ui,sans-serif;
padding:6px 11px; border-radius:999px; border:1px solid rgba(143,184,255,.3);
backdrop-filter:blur(6px); }
.aback:hover { color:#51eaf1; border-color:#51eaf1; }
@media print { .aback { display:none !important; } }
`;
export const styles = BACK_CSS + `
:root { color-scheme: dark; }
body { margin:0; background:#0a0d18; color:#e8ecff; font-family:system-ui,sans-serif; }
header { display:flex; gap:14px; align-items:center; padding:10px 16px; background:#111730; }
@@ -34,13 +91,74 @@ export const styles = `
button.danger { background:#7a2437; }
input, select { background:#141a33; color:#fff; border:1px solid #2a3a6e; border-radius:5px; padding:4px 8px; }
label { font-size:.8rem; opacity:.85; }
/* ---- admin chrome (nav bar) ---------------------------------------- */
header.anav { flex-wrap:wrap; row-gap:8px; padding:9px 16px; border-bottom:1px solid #1e2748; }
.anav .brand { font-weight:600; color:#e8ecff; font-size:.95rem; letter-spacing:.01em; white-space:nowrap; }
.anav .brand b { color:#51eaf1; font-weight:600; }
.anav .grp { display:flex; align-items:center; gap:10px; }
/* Group label doubles as the separator — no pipes to line up, and the
grouping survives wrapping onto a second row on a phone. */
.anav .grp > .lbl { font-size:.62rem; text-transform:uppercase; letter-spacing:.09em;
opacity:.45; white-space:nowrap; }
.anav nav { display:flex; flex-wrap:wrap; align-items:center; gap:10px 16px; flex:1 1 auto; }
.anav nav a { padding:3px 0; border-bottom:2px solid transparent; white-space:nowrap; }
.anav nav a:hover { color:#cfe0ff; }
.anav nav a.active { border-bottom-color:#51eaf1; }
.anav .viewer { white-space:nowrap; opacity:.8; }
@media (max-width:640px) {
.anav { padding:8px 12px; }
.anav nav { gap:8px 12px; }
.anav nav a { font-size:.85rem; }
}
/* ---- hub ------------------------------------------------------------ */
.hub { padding:22px 18px 40px; max-width:1100px; margin:0 auto; }
.hub section { margin:0 0 26px; }
.hub section > h2 { font-size:.7rem; text-transform:uppercase; letter-spacing:.11em;
opacity:.5; margin:0 0 10px; font-weight:600; }
.cards { display:grid; gap:12px; grid-template-columns:repeat(auto-fill,minmax(265px,1fr)); }
.card { display:block; text-decoration:none; color:inherit; background:#111730;
border:1px solid #1e2748; border-radius:10px; padding:13px 15px;
transition:border-color .12s, background .12s; }
.card:hover { border-color:#51eaf1; background:#141c3a; }
.card .t { font-weight:600; font-size:.98rem; margin:0 0 4px; display:flex;
align-items:center; gap:8px; }
.card .b { font-size:.82rem; line-height:1.45; opacity:.68; margin:0; }
.chip { font-size:.68rem; font-weight:600; padding:2px 7px; border-radius:999px;
background:#1c2550; color:#8fb8ff; white-space:nowrap; }
.chip.warn { background:#4a2030; color:#ff9db4; }
.chip.ok { background:#133a3d; color:#51eaf1; }
`;
/* The bar. Root stays <header> so the print rules in print.html and plan.html
keep hiding it. */
export function nav(active) {
return `<header>
<h1>Newbury Exhibit — Admin</h1>
${['preview', 'layout', 'plan', 'playlist', 'characters', 'landing', 'print', 'calibrate', 'errors'].map(p =>
`<a href="/admin/${p}.html" class="${p === active ? 'active' : ''}">${p}</a>`).join('')}
<a href="/">viewer ↗</a>
const esc = s => String(s).replace(/[&<>"]/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]));
const groups = GROUPS.map(g => {
const links = TOOLS.filter(t => t.group === g).map(t =>
`<a href="${href(t)}" class="${t.id === active ? 'active' : ''}"${t.id === active ? ' aria-current="page"' : ''}>${esc(t.name)}</a>`
).join('');
return links ? `<span class="grp"><span class="lbl">${esc(g)}</span>${links}</span>` : '';
}).join('');
return `<header class="anav">
<a class="brand" href="/admin/">Newbury Exhibit <b>Admin</b></a>
<nav aria-label="Admin tools">${groups}</nav>
<a class="viewer" href="/" target="_blank" rel="noopener">Viewer ↗</a>
</header>`;
}
/* For pages whose layout cannot host the bar (full-bleed camera, print sheets).
Call once; it injects its own style so the page need not import `styles`. */
export function backLink(label = 'Admin') {
const el = document.createElement('a');
el.className = 'aback';
el.href = '/admin/';
el.textContent = '← ' + label;
const css = document.createElement('style');
css.textContent = BACK_CSS;
document.head.appendChild(css);
document.body.appendChild(el);
return el;
}
+6
View File
@@ -29,6 +29,12 @@ import * as THREE from 'three';
import { createDetector, detectMarkers, quadArea } from '/js/ar/detect.js';
import { PoseEstimator } from '/js/ar/pose.js';
import { WorldFuser } from '/js/ar/fuse.js';
import { requireAuth, backLink } from './admin.js';
/* This page used to skip requireAuth entirely — in final mode it was the one
admin page you could open without the password. */
await requireAuth();
backLink();
const scene = await (await fetch('/api/scene')).json();
const fuser = new WorldFuser(); fuser.setScene(scene);
+90
View File
@@ -0,0 +1,90 @@
<!doctype html>
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
<title>Admin — Newbury Exhibit</title></head>
<body><div id="app"></div>
<script type="module">
/* The admin home. Everything here is generated from the TOOLS registry in
* admin.js, so adding a page to that array is the whole job — no risk of a
* tool existing but being reachable from nowhere, which is how the A3 test rig
* spent its first weeks.
*
* The status chips are deliberately cheap: one parallel burst of GETs that the
* pages themselves already call. Anything that fails is simply left off rather
* than blocking the page — the point of this screen is to get you somewhere
* else quickly, so it must never sit there spinning. */
import { requireAuth, styles, nav, TOOLS, GROUPS, href } from './admin.js';
document.head.insertAdjacentHTML('beforeend', `<style>${styles}
.mode { display:flex; align-items:center; gap:9px; flex-wrap:wrap;
margin:0 0 20px; font-size:.84rem; opacity:.8; }
.hub .empty { font-size:.8rem; opacity:.5; }
</style>`);
const info = await requireAuth();
const esc = s => String(s).replace(/[&<>"]/g, c => ({ '&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;' }[c]));
const n = v => Array.isArray(v) ? v.length : 0;
/* Never let one dead endpoint take the page down. */
const get = async (p) => {
try {
const r = await fetch(p, { headers: { 'x-exhibit-token': localStorage.getItem('exhibitToken') || '' } });
return r.ok ? await r.json() : null;
} catch { return null; }
};
const [scene, playlist, characters, errors, ghosts] = await Promise.all([
get('/api/scene'), get('/api/playlist'), get('/api/characters'),
get('/api/client-errors'), get('/api/ghosts'),
]);
/* id -> [{text, cls}]. Only facts worth knowing before you click. */
const chips = {};
const add = (id, text, cls = '') => { (chips[id] ||= []).push({ text, cls }); };
if (scene) {
add('layout', `${n(scene.anchors)} crest${n(scene.anchors) === 1 ? '' : 's'}`);
if (n(scene.spawns)) add('layout', `${n(scene.spawns)} spawns`);
if (!n(scene.anchors)) add('layout', 'no crests yet', 'warn');
add('print', `${n(scene.anchors)} to print`);
}
if (playlist) {
const tracks = n(playlist.tracks), res = n(playlist.residents);
add('playlist', `${tracks} track${tracks === 1 ? '' : 's'}`);
if (res) add('playlist', `${res} resident${res === 1 ? '' : 's'}`);
if (n(playlist.windows)) add('playlist', `${n(playlist.windows)} time window${n(playlist.windows) === 1 ? '' : 's'}`);
}
if (characters) {
const c = Array.isArray(characters) ? characters.length : n(characters.characters);
if (c) add('characters', `${c} built`);
}
if (ghosts) {
const g = Array.isArray(ghosts) ? ghosts.length : n(ghosts.ghosts);
if (g) add('characters', `${g} in roster`);
}
if (Array.isArray(errors)) {
add('errors', errors.length ? `${errors.length} logged` : 'none', errors.length ? 'warn' : 'ok');
}
const card = t => `<a class="card" href="${href(t)}">
<p class="t">${esc(t.name)}${(chips[t.id] || []).map(c => `<span class="chip ${c.cls}">${esc(c.text)}</span>`).join('')}</p>
<p class="b">${esc(t.blurb)}</p>
</a>`;
const section = g => {
const ts = TOOLS.filter(t => t.group === g);
if (!ts.length) return '';
return `<section><h2>${esc(g)}</h2><div class="cards">${ts.map(card).join('')}</div></section>`;
};
const modeChip = info && info.mode === 'final'
? '<span class="chip ok">final — admin password protected</span>'
: '<span class="chip warn">dev mode — admin is open, no password</span>';
document.getElementById('app').innerHTML = `${nav('')}
<div class="hub">
<div class="mode">${modeChip}<span>Changes save to the live volume and push to viewers immediately.</span></div>
${GROUPS.map(section).join('')}
</div>`;
</script>
</body></html>
+3
View File
@@ -7,6 +7,8 @@
.bar button { padding:9px 16px; border-radius:8px; border:0; cursor:pointer; font-weight:600; }
.bar .go { background:linear-gradient(135deg,#51eaf1,#529eff); color:#04121a; }
.bar .ghost { background:transparent; color:#e8ecff; border:1px solid rgba(255,255,255,.35); }
.bar .home { color:#8fb8ff; text-decoration:none; font-size:.85rem; margin-right:4px; }
.bar .home:hover { color:#51eaf1; }
.note { padding:0 18px 14px; font-size:.85rem; line-height:1.6; opacity:.85; max-width:70rem; }
.note code { background:rgba(255,255,255,.1); padding:1px 5px; border-radius:4px; }
#status { font-size:.85rem; opacity:.9; }
@@ -27,6 +29,7 @@
<body>
<div class="bar">
<a class="home" href="/admin/">&larr; Admin</a>
<button class="go" onclick="window.print()">Print A3</button>
<button class="ghost" id="load">Load this layout onto the server</button>
<span id="status"></span>