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
+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) {
|
||||
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 => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[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;
|
||||
}
|
||||
Reference in new issue
Block a user