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;
}