/* 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, headers: { 'Content-Type': 'application/json', 'x-exhibit-token': localStorage.getItem('exhibitToken') || '', }, body: body === undefined ? undefined : JSON.stringify(body), }); if (res.status === 401) { location.href = '/admin/login.html?next=' + encodeURIComponent(location.pathname); throw new Error('unauthorized'); } if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || res.statusText); return res.json(); } export async function requireAuth() { const info = await (await fetch('/api/info')).json(); if (!info.authRequired) return info; // probe a guarded endpoint const res = await fetch('/api/client-errors', { headers: { 'x-exhibit-token': localStorage.getItem('exhibitToken') || '' } }); if (res.status === 401) location.href = '/admin/login.html?next=' + encodeURIComponent(location.pathname); return info; } /* --------------------------------------------------------------------------- * 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; } header a { color:#8fb8ff; text-decoration:none; font-size:.9rem; } header a.active { color:#51eaf1; } h1 { font-size:1.05rem; margin:0 auto 0 0; } button { background:#2a3a6e; color:#fff; border:0; border-radius:6px; padding:6px 14px; cursor:pointer; } button.primary { background:linear-gradient(135deg,#51eaf1,#529eff); color:#04121a; font-weight:600; } 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
so the print rules in print.html and plan.html keep hiding it. */ export function nav(active) { const esc = s => String(s).replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c])); const groups = GROUPS.map(g => { const links = TOOLS.filter(t => t.group === g).map(t => `${esc(t.name)}` ).join(''); return links ? `${esc(g)}${links}` : ''; }).join(''); return `
Newbury Exhibit Admin Viewer ↗
`; } /* 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; }