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

+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>