Admin: tool registry, grouped nav, and an /admin/ hub
This commit is contained in:
1 parent
7fc2b02e6a
commit
7040d4f078
9 files changed
+412
-7
No files matched your search
@@ -42,5 +42,6 @@ volume, so live state carries over and rollback is one command.
|
|||||||
|
|
||||||
## Session Log
|
## Session Log
|
||||||
|
|
||||||
|
- 2026-10-12 — Admin Navigation Tidy-up — tool registry driving both the nav bar and a new /admin/ hub; unreachable A3 test rig and an unauthenticated calibrate page fixed.
|
||||||
- 2026-10-07 — Corner Precision and ROI Detection — sub-pixel corner refinement (own implementation; `cornerSubPix` is absent from the pinned build), ROI-first detection cropping from the native video, and an anchor-coplanarity check surfaced in the HUD.
|
- 2026-10-07 — Corner Precision and ROI Detection — sub-pixel corner refinement (own implementation; `cornerSubPix` is absent from the pinned build), ROI-first detection cropping from the native video, and an anchor-coplanarity check surfaced in the HUD.
|
||||||
- 2026-09-23 — AR Tracking Overhaul: Worker Offload, Camera Model, and Coplanar Pose Ambiguity — moved OpenCV off the main thread, fixed the focal/FOV model for portrait streams, deduped marker ids, gated the mirrored coplanar pose, and built a two-page A3 test rig with a foldable marker tower.
|
- 2026-09-23 — AR Tracking Overhaul: Worker Offload, Camera Model, and Coplanar Pose Ambiguity — moved OpenCV off the main thread, fixed the focal/FOV model for portrait streams, deduped marker ids, gated the mirrored coplanar pose, and built a two-page A3 test rig with a foldable marker tower.
|
||||||
@@ -111,6 +111,29 @@ across the table. A layout re-saved by an editor that does not understand wall
|
|||||||
anchors can silently become flat. Reference values: A3 rig with the tower ~3.5cm,
|
anchors can silently become flat. Reference values: A3 rig with the tower ~3.5cm,
|
||||||
a town layout with one wall crest at 8cm ~2.7cm, any all-tabletop layout 0.0cm.
|
a town layout with one wall crest at 8cm ~2.7cm, any all-tabletop layout 0.0cm.
|
||||||
|
|
||||||
|
## Admin chrome
|
||||||
|
|
||||||
|
`public/admin/admin.js` holds a `TOOLS` registry — id, display name, group and
|
||||||
|
a one-line blurb for every admin page. The nav bar (`nav()`) and the hub
|
||||||
|
(`/admin/index.html`) are both generated from it, so a new page is registered
|
||||||
|
once and cannot appear in one and not the other. `tools/check-admin-nav.mjs`
|
||||||
|
checks the other direction: every `.html` in `public/admin` except the hub and
|
||||||
|
the login screen must be registered.
|
||||||
|
|
||||||
|
This replaced a hand-kept array of filenames, under which `testsheet.html`
|
||||||
|
existed but was listed nowhere, and `calibrate.html` was listed but never
|
||||||
|
rendered the bar — and never called `requireAuth()`, so in final mode it was
|
||||||
|
the one admin page reachable without the password.
|
||||||
|
|
||||||
|
Two pages cannot host the bar: `calibrate.html` is a full-bleed camera view and
|
||||||
|
`testsheet.html` must print edge to edge. They carry `chrome: false` and get a
|
||||||
|
link home instead — a floating pill (`backLink()`, styled by the separately
|
||||||
|
exported `BACK_CSS`) for calibrate, an inline link in the existing toolbar for
|
||||||
|
testsheet, where a floating pill would cover the Print button.
|
||||||
|
|
||||||
|
`nav()`'s root element stays `<header>`: `print.html` hides `header` on print
|
||||||
|
and `plan.html` hides its `#hdr` wrapper, and both rules depend on it.
|
||||||
|
|
||||||
## Camera model
|
## Camera model
|
||||||
|
|
||||||
Everything derives from one assumption — `HFOV_DEG` (default 60, override via
|
Everything derives from one assumption — `HFOV_DEG` (default 60, override via
|
||||||
|
|||||||
@@ -0,0 +1,105 @@
|
|||||||
|
# Admin Navigation Tidy-up
|
||||||
|
|
||||||
|
**Date:** 2026-10-12
|
||||||
|
**Project:** newbury-exhibit-v3
|
||||||
|
**Status:** written and checked offline; not yet opened on the deployed server
|
||||||
|
**Tags:** admin, navigation, ux, auth
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
The admin section had grown to eleven pages behind a nav bar that was a flat,
|
||||||
|
hand-maintained array of nine lowercase filenames. Asked to make it easier to
|
||||||
|
navigate; reading it first turned up three things worse than untidiness.
|
||||||
|
|
||||||
|
## What was actually wrong
|
||||||
|
|
||||||
|
- **`testsheet.html` was unreachable.** It existed, it worked, it was in no
|
||||||
|
nav list. The only way in was typing the URL.
|
||||||
|
- **`calibrate.html` was listed in the nav but never rendered it** — it imports
|
||||||
|
nothing from `admin.js`, so the link led to a page with no way back.
|
||||||
|
- **`calibrate.html` never called `requireAuth()`.** Every other admin page
|
||||||
|
does. In `final` mode it was the one admin page openable without the
|
||||||
|
password. That is a hole, not a nav problem, and it was only visible because
|
||||||
|
the nav work meant reading every page's imports.
|
||||||
|
- **`/admin/` had no `index.html`**, so the bare path 404s and there is no
|
||||||
|
landing point.
|
||||||
|
- **Labels were filenames**: `landing`, `plan`, `print`, `preview` say nothing
|
||||||
|
about what the page does, and the four physical-setup tools sat
|
||||||
|
interleaved with the content editors.
|
||||||
|
|
||||||
|
## Key Decisions
|
||||||
|
|
||||||
|
- **One registry, two consumers.** `TOOLS` in `admin.js` carries id, display
|
||||||
|
name, group and a one-line blurb. `nav()` and the new hub both generate from
|
||||||
|
it. The old bug class — a page existing in the folder but not in the bar —
|
||||||
|
needs someone to forget the registry rather than forget one of two lists.
|
||||||
|
|
||||||
|
- **`tools/check-admin-nav.mjs` closes the loop the registry cannot.** The
|
||||||
|
registry guarantees the bar and the hub agree with each other; it cannot
|
||||||
|
notice a page nobody registered. The check walks `public/admin/*.html` and
|
||||||
|
fails on anything unregistered, plus asserts `nav()` marks exactly one link
|
||||||
|
active and still roots at `<header>`.
|
||||||
|
|
||||||
|
*Rejected:* a rule that a tool's label must differ from its filename. It
|
||||||
|
fired on Layout, Characters, Playlist and Calibrate, which are all correctly
|
||||||
|
named. The labels that were genuinely bad were bad because they did not
|
||||||
|
describe the page, which no mechanical rule can tell.
|
||||||
|
|
||||||
|
- **Four groups: World, Viewer, Setup, Diagnostics.** World is what is in the
|
||||||
|
exhibit (layout, characters, playlist), Viewer is what visitors get (landing
|
||||||
|
page, virtual viewport), Setup is the physical work (crests, plan, A3 rig,
|
||||||
|
calibrate), Diagnostics is the error log. The group label doubles as the
|
||||||
|
separator in the bar, so the grouping survives wrapping onto a second row on
|
||||||
|
a phone rather than becoming a run of orphaned pipes.
|
||||||
|
|
||||||
|
- **`nav()`'s root stays `<header>`.** `print.html` hides `header` on print and
|
||||||
|
`plan.html` hides its `#hdr` wrapper; changing the element would have
|
||||||
|
silently printed the nav bar onto the crest sheets.
|
||||||
|
|
||||||
|
- **Additive CSS only.** Existing selectors in `styles` are untouched except
|
||||||
|
for `flex-wrap` on `header`; ten pages inject `styles` plus their own rules
|
||||||
|
and several style `h1` indirectly through it. New chrome is namespaced under
|
||||||
|
`.anav`, `.hub`, `.card`, `.chip`.
|
||||||
|
|
||||||
|
- **Two pages cannot host the bar.** `calibrate.html` is a full-bleed camera
|
||||||
|
view and `testsheet.html` must print edge to edge. Both carry
|
||||||
|
`chrome: false`. Calibrate gets a floating pill (`backLink()`, styled from
|
||||||
|
`BACK_CSS`, exported separately so the page need not pull in the body and
|
||||||
|
background rules that would wreck it). Testsheet gets an inline link in its
|
||||||
|
existing toolbar — a floating pill would have landed on top of the Print A3
|
||||||
|
button.
|
||||||
|
|
||||||
|
- **Hub status chips never block.** Each endpoint is fetched in parallel and a
|
||||||
|
failure drops its chip rather than the page. The hub's job is to get you
|
||||||
|
somewhere else quickly; it must not sit spinning because the error log is
|
||||||
|
unhappy.
|
||||||
|
|
||||||
|
## Technical Details
|
||||||
|
|
||||||
|
`public/admin/admin.js` — added `GROUPS`, `TOOLS`, `toolById`, `href`,
|
||||||
|
`BACK_CSS`, `backLink()`; rewrote `nav()`; extended `styles` additively.
|
||||||
|
`api()` and `requireAuth()` unchanged.
|
||||||
|
|
||||||
|
`public/admin/index.html` — new. Mode banner (dev vs final), then grouped
|
||||||
|
cards. Chips: crest and spawn counts from `/api/scene`, tracks/residents/
|
||||||
|
windows from `/api/playlist`, built models from `/api/characters`, roster size
|
||||||
|
from `/api/ghosts`, error count from `/api/client-errors`.
|
||||||
|
|
||||||
|
`public/admin/calibrate.html` — added `requireAuth()` and `backLink()`.
|
||||||
|
`public/admin/testsheet.html` — added an inline `← Admin` link to `.bar`.
|
||||||
|
|
||||||
|
## Open Questions / Next Steps
|
||||||
|
|
||||||
|
- **Nothing here has been opened against a running server.** The checks are
|
||||||
|
static: registry/folder agreement, `nav()` output, HTML tag balance, module
|
||||||
|
syntax. Worth confirming the hub's chips match reality on first load, since
|
||||||
|
the exact shape of `/api/characters` and `/api/ghosts` (array vs wrapper
|
||||||
|
object) is handled defensively rather than known.
|
||||||
|
- `calibrate.html` gaining `requireAuth()` means it now redirects to the login
|
||||||
|
screen in final mode. Expected, but it is a behaviour change on a page that
|
||||||
|
used to open straight to the camera.
|
||||||
|
- The hub is a plain page; a tablet propped next to the table would want the
|
||||||
|
virtual viewport, not this. Possibly worth a "kiosk" entry later.
|
||||||
|
- Still outstanding from 2026-10-07: on-device validation of the sub-pixel and
|
||||||
|
ROI work, re-tuning the 1€ filter and `MAX_REPROJ_PX` if `reproj` has
|
||||||
|
dropped, and carrying a raised crest into the real town layout.
|
||||||
@@ -13,6 +13,7 @@ landing page (admin URLs must be typed in full).
|
|||||||
| Path | Page | What it does |
|
| Path | Page | What it does |
|
||||||
|------|------|--------------|
|
|------|------|--------------|
|
||||||
| `/` | Exhibit | Landing screen → camera AR view. The thing visitors use. Tracking HUD appears in dev mode. |
|
| `/` | Exhibit | Landing screen → camera AR view. The thing visitors use. Tracking HUD appears in dev mode. |
|
||||||
|
| `/admin/` | Admin hub | Every tool, grouped, with a one-line description and live counts. Generated from `TOOLS` in `admin.js`. |
|
||||||
|
|
||||||
## Admin
|
## Admin
|
||||||
|
|
||||||
|
|||||||
+125
-7
@@ -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) {
|
export async function api(path, method = 'GET', body) {
|
||||||
const res = await fetch(path, {
|
const res = await fetch(path, {
|
||||||
method,
|
method,
|
||||||
@@ -22,7 +23,63 @@ export async function requireAuth() {
|
|||||||
return info;
|
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; }
|
:root { color-scheme: dark; }
|
||||||
body { margin:0; background:#0a0d18; color:#e8ecff; font-family:system-ui,sans-serif; }
|
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; }
|
header { display:flex; gap:14px; align-items:center; padding:10px 16px; background:#111730; }
|
||||||
@@ -34,13 +91,74 @@ export const styles = `
|
|||||||
button.danger { background:#7a2437; }
|
button.danger { background:#7a2437; }
|
||||||
input, select { background:#141a33; color:#fff; border:1px solid #2a3a6e; border-radius:5px; padding:4px 8px; }
|
input, select { background:#141a33; color:#fff; border:1px solid #2a3a6e; border-radius:5px; padding:4px 8px; }
|
||||||
label { font-size:.8rem; opacity:.85; }
|
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) {
|
export function nav(active) {
|
||||||
return `<header>
|
const esc = s => String(s).replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]));
|
||||||
<h1>Newbury Exhibit — Admin</h1>
|
const groups = GROUPS.map(g => {
|
||||||
${['preview', 'layout', 'plan', 'playlist', 'characters', 'landing', 'print', 'calibrate', 'errors'].map(p =>
|
const links = TOOLS.filter(t => t.group === g).map(t =>
|
||||||
`<a href="/admin/${p}.html" class="${p === active ? 'active' : ''}">${p}</a>`).join('')}
|
`<a href="${href(t)}" class="${t.id === active ? 'active' : ''}"${t.id === active ? ' aria-current="page"' : ''}>${esc(t.name)}</a>`
|
||||||
<a href="/">viewer ↗</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>`;
|
</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;
|
||||||
|
}
|
||||||
@@ -29,6 +29,12 @@ import * as THREE from 'three';
|
|||||||
import { createDetector, detectMarkers, quadArea } from '/js/ar/detect.js';
|
import { createDetector, detectMarkers, quadArea } from '/js/ar/detect.js';
|
||||||
import { PoseEstimator } from '/js/ar/pose.js';
|
import { PoseEstimator } from '/js/ar/pose.js';
|
||||||
import { WorldFuser } from '/js/ar/fuse.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 scene = await (await fetch('/api/scene')).json();
|
||||||
const fuser = new WorldFuser(); fuser.setScene(scene);
|
const fuser = new WorldFuser(); fuser.setScene(scene);
|
||||||
|
|||||||
@@ -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 => ({ '&':'&','<':'<','>':'>','"':'"' }[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>
|
||||||
@@ -7,6 +7,8 @@
|
|||||||
.bar button { padding:9px 16px; border-radius:8px; border:0; cursor:pointer; font-weight:600; }
|
.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 .go { background:linear-gradient(135deg,#51eaf1,#529eff); color:#04121a; }
|
||||||
.bar .ghost { background:transparent; color:#e8ecff; border:1px solid rgba(255,255,255,.35); }
|
.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 { 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; }
|
.note code { background:rgba(255,255,255,.1); padding:1px 5px; border-radius:4px; }
|
||||||
#status { font-size:.85rem; opacity:.9; }
|
#status { font-size:.85rem; opacity:.9; }
|
||||||
@@ -27,6 +29,7 @@
|
|||||||
<body>
|
<body>
|
||||||
|
|
||||||
<div class="bar">
|
<div class="bar">
|
||||||
|
<a class="home" href="/admin/">← Admin</a>
|
||||||
<button class="go" onclick="window.print()">Print A3</button>
|
<button class="go" onclick="window.print()">Print A3</button>
|
||||||
<button class="ghost" id="load">Load this layout onto the server</button>
|
<button class="ghost" id="load">Load this layout onto the server</button>
|
||||||
<span id="status"></span>
|
<span id="status"></span>
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
/* check-admin-nav.mjs — every admin page must be reachable from the nav.
|
||||||
|
*
|
||||||
|
* node tools/check-admin-nav.mjs
|
||||||
|
*
|
||||||
|
* The A3 test rig shipped unreachable: the nav was a hand-maintained array of
|
||||||
|
* filenames and adding testsheet.html to the folder did not add it to the bar.
|
||||||
|
* Now the bar and the hub are both generated from TOOLS in admin.js, and this
|
||||||
|
* checks the other direction — that the folder and the registry agree.
|
||||||
|
*/
|
||||||
|
import { readdirSync } from 'node:fs';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { dirname, join } from 'node:path';
|
||||||
|
import { TOOLS, GROUPS, nav, href } from '../public/admin/admin.js';
|
||||||
|
|
||||||
|
const adminDir = join(dirname(fileURLToPath(import.meta.url)), '..', 'public', 'admin');
|
||||||
|
|
||||||
|
/* Pages that are intentionally not tools: the hub itself and the login screen. */
|
||||||
|
const NOT_TOOLS = new Set(['index.html', 'login.html']);
|
||||||
|
|
||||||
|
const pages = readdirSync(adminDir).filter(f => f.endsWith('.html') && !NOT_TOOLS.has(f));
|
||||||
|
const ids = new Set(TOOLS.map(t => t.id));
|
||||||
|
|
||||||
|
let bad = 0;
|
||||||
|
const fail = m => { console.log(' FAIL ' + m); bad++; };
|
||||||
|
|
||||||
|
console.log('admin nav / page registry\n');
|
||||||
|
|
||||||
|
for (const f of pages) {
|
||||||
|
const id = f.replace(/\.html$/, '');
|
||||||
|
if (!ids.has(id)) fail(`${f} exists but is not in TOOLS — unreachable from the nav and the hub`);
|
||||||
|
}
|
||||||
|
for (const t of TOOLS) {
|
||||||
|
if (!pages.includes(`${t.id}.html`)) fail(`TOOLS lists "${t.id}" but ${t.id}.html does not exist`);
|
||||||
|
if (!t.name || !t.blurb) fail(`"${t.id}" is missing a name or blurb`);
|
||||||
|
if (!GROUPS.includes(t.group)) fail(`"${t.id}" is in group "${t.group}", which is not in GROUPS`);
|
||||||
|
}
|
||||||
|
/* No check that a label differs from its filename: "Layout" and "Playlist" are
|
||||||
|
the right names and happen to match. The labels that were actually wrong
|
||||||
|
("landing", "plan", "print") were wrong because they did not say what the
|
||||||
|
page does, which no rule here can tell. */
|
||||||
|
|
||||||
|
/* The bar must actually contain every tool, and mark exactly one active. */
|
||||||
|
const bar = nav('playlist');
|
||||||
|
for (const t of TOOLS) {
|
||||||
|
if (!bar.includes(href(t))) fail(`nav() output is missing a link to ${href(t)}`);
|
||||||
|
if (!bar.includes(`>${t.name}<`)) fail(`nav() output is missing the label "${t.name}"`);
|
||||||
|
}
|
||||||
|
const actives = (bar.match(/class="active"/g) || []).length;
|
||||||
|
if (actives !== 1) fail(`nav('playlist') marked ${actives} links active, expected 1`);
|
||||||
|
if ((nav('').match(/class="active"/g) || []).length !== 0) fail(`nav('') marked a link active`);
|
||||||
|
if (!bar.startsWith('<header')) fail('nav() root is not <header> — print.html hides "header" on print');
|
||||||
|
|
||||||
|
console.log(` ${pages.length} pages, ${TOOLS.length} registered, ${GROUPS.length} groups`);
|
||||||
|
for (const g of GROUPS) {
|
||||||
|
console.log(` ${g.padEnd(12)} ${TOOLS.filter(t => t.group === g).map(t => t.name).join(', ')}`);
|
||||||
|
}
|
||||||
|
console.log(bad ? `\n${bad} problem(s)` : '\nall pages reachable');
|
||||||
|
process.exit(bad ? 1 : 0);
|
||||||
Reference in new issue
Block a user