Landing page editor: logo/background/subtitle, markdown details page, editable disclaimer, photo shutter

This commit is contained in:
2026-07-24 16:35:00 +10:00
parent dfa2ae12e8
commit a1234fb851
8 changed files with 373 additions and 31 deletions
+1 -1
View File
@@ -39,7 +39,7 @@ export const styles = `
export function nav(active) {
return `<header>
<h1>Newbury Exhibit — Admin</h1>
${['preview', 'layout', 'plan', 'playlist', 'characters', 'print', 'calibrate', 'errors'].map(p =>
${['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>
</header>`;
+181
View File
@@ -0,0 +1,181 @@
<!doctype html>
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
<title>Landing Page — Newbury Exhibit</title></head>
<body>
<div id="app"></div>
<script type="module">
import { api, requireAuth, styles, nav } from './admin.js';
import { renderMarkdown } from '/js/md.js';
document.head.insertAdjacentHTML('beforeend', `<style>${styles}
#wrap { display:grid; grid-template-columns: 1fr 420px; height:calc(100vh - 44px); }
#form { padding:14px 18px; overflow:auto; }
#form h2 { font-size:.9rem; color:#8fb8ff; margin:16px 0 6px; }
#form h2:first-child { margin-top:0; }
.row { display:flex; gap:8px; align-items:center; margin:6px 0; }
.row label { width:120px; font-size:.78rem; }
.row input, .row select, .row textarea { flex:1; min-width:0; font-family:inherit; }
.row input[type=color] { padding:1px; height:28px; max-width:60px; }
textarea { min-height:190px; background:#141a33; color:#fff; border:1px solid #2a3a6e;
border-radius:5px; padding:8px; line-height:1.5; resize:vertical; }
.badge { font-size:.72rem; opacity:.62; line-height:1.5; }
.imgpick { display:flex; gap:8px; align-items:center; }
.imgpick img { width:44px; height:44px; object-fit:contain; background:#0d1226; border-radius:5px; }
#preview { background:#080b16; border-left:1px solid #1e2748; overflow:auto; padding:14px; }
.phone { width:340px; height:680px; margin:0 auto; border-radius:26px; border:8px solid #222a44;
overflow:hidden; position:relative; background:#06080f; }
.scr { position:absolute; inset:0; display:flex; flex-direction:column; align-items:center;
justify-content:center; text-align:center; padding:22px; background-size:cover; background-position:center; }
.scr::before { content:''; position:absolute; inset:0; background:#06080f; }
.scr > * { position:relative; z-index:1; }
.scr img.logo { max-width:74%; max-height:30%; margin-bottom:14px; }
.scr h1 { font-size:1.6rem; margin:0 0 8px; letter-spacing:.05em; }
.scr p.sub { font-size:.85rem; opacity:.85; margin:0 0 22px; line-height:1.5; }
.scr .b1 { padding:11px 30px; border-radius:999px; border:0; font-weight:700; font-size:.95rem; }
.scr .b2 { margin-top:11px; padding:7px 18px; border-radius:999px; background:transparent;
border:1px solid rgba(255,255,255,.35); font-size:.8rem; }
.scr .disc { position:absolute; bottom:12px; left:14px; right:14px; font-size:.58rem; opacity:.55; line-height:1.35; z-index:1; }
.dscr { position:absolute; inset:0; overflow:auto; padding:18px; background:#080b16; }
.dscr h1 { font-size:1.25rem; margin:0 0 10px; }
.dscr p, .dscr li { font-size:.82rem; line-height:1.6; opacity:.9; }
.tabs { display:flex; gap:6px; justify-content:center; margin-bottom:10px; }
</style>`);
await requireAuth();
const app = document.getElementById('app');
let L = await api('/api/landing');
let assets = await api('/api/assets');
let tab = 'start';
const images = () => assets.assets.filter(a => a.kind === 'texture');
function render() {
app.innerHTML = `${nav('landing')}
<div id="wrap">
<div id="form">
<h2>Artwork</h2>
<div class="row"><label>logo</label>
<div class="imgpick" style="flex:1">
<img src="${L.logoUrl || ''}" onerror="this.style.visibility='hidden'">
<select id="logoUrl" style="flex:1"><option value="">— none (show text title) —</option>
${images().map(a => `<option value="${a.url}" ${L.logoUrl === a.url ? 'selected' : ''}>${a.name}</option>`).join('')}
</select></div></div>
<div class="row"><label>background</label>
<div class="imgpick" style="flex:1">
<img src="${L.backgroundUrl || ''}" onerror="this.style.visibility='hidden'">
<select id="backgroundUrl" style="flex:1"><option value="">— none —</option>
${images().map(a => `<option value="${a.url}" ${L.backgroundUrl === a.url ? 'selected' : ''}>${a.name}</option>`).join('')}
</select></div></div>
<div class="row"><label>upload</label><input type="file" id="file" accept=".png,.jpg,.jpeg,.webp" multiple></div>
<div class="badge">Uploads share the asset library with the character manager.</div>
<h2>Landing page</h2>
<div class="row"><label>title</label><input id="title" value="${esc(L.title)}"></div>
<div class="badge">Shown only when no logo is selected.</div>
<div class="row"><label>subtitle</label><input id="subtitle" value="${esc(L.subtitle)}"></div>
<div class="row"><label>start button</label><input id="startButton" value="${esc(L.startButton)}"></div>
<div class="row"><label>about button</label><input id="detailsButton" value="${esc(L.detailsButton)}"></div>
<h2>Details page</h2>
<div class="row"><label>heading</label><input id="detailsTitle" value="${esc(L.detailsTitle)}"></div>
<div class="row" style="align-items:flex-start"><label>body</label>
<textarea id="detailsMarkdown">${esc(L.detailsMarkdown)}</textarea></div>
<div class="badge">Markdown: <code>#</code> heading · <code>**bold**</code> · <code>*italic*</code> ·
<code>- list</code> · <code>[text](https://…)</code> · blank line = new paragraph.
Leave empty to hide the About button entirely.</div>
<h2>Theme</h2>
<div class="row"><label>accent</label><input type="color" id="accent" value="${L.accent}">
<label style="width:auto">text</label><input type="color" id="textColor" value="${L.textColor}"></div>
<div class="row"><label>bg dimming</label>
<input type="range" min="0" max="1" step="0.05" id="overlay" value="${L.overlay}">
<span class="badge" id="ovv">${L.overlay}</span></div>
<h2>Disclaimer</h2>
<div class="row" style="align-items:flex-start"><label>text</label>
<textarea id="disclaimer" style="min-height:80px">${esc(L.disclaimer)}</textarea></div>
<div class="badge">Always shown on both screens — wording is yours to edit, but it can't be removed.
If left blank it reverts to the default wording.</div>
<button id="save" class="primary" style="width:100%;padding:11px;margin:16px 0 6px">Save landing page</button>
<div id="status" class="badge"></div>
</div>
<div id="preview">
<div class="tabs">
<button data-tab="start" class="${tab === 'start' ? 'primary' : ''}">Landing</button>
<button data-tab="details" class="${tab === 'details' ? 'primary' : ''}">Details</button>
</div>
<div class="phone">${tab === 'start' ? startPreview() : detailsPreview()}</div>
<div class="badge" style="text-align:center;margin-top:8px">Live preview · approximate phone size</div>
</div>
</div>`;
wire();
}
function esc(s) {
return String(s == null ? '' : s).replace(/&/g, '&amp;').replace(/</g, '&lt;')
.replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
function startPreview() {
return `<div class="scr" style="background-image:${L.backgroundUrl ? `url('${L.backgroundUrl}')` : 'none'};color:${L.textColor}">
<style>.phone .scr::before{opacity:${L.overlay}}</style>
${L.logoUrl ? `<img class="logo" src="${L.logoUrl}">` : `<h1>${esc(L.title)}</h1>`}
<p class="sub">${esc(L.subtitle)}</p>
<button class="b1" style="background:linear-gradient(135deg,${L.accent},#529eff);color:#04121a">${esc(L.startButton)}</button>
${L.detailsMarkdown ? `<button class="b2" style="color:${L.textColor}">${esc(L.detailsButton)}</button>` : ''}
<div class="disc">${esc(L.disclaimer)}</div>
</div>`;
}
function detailsPreview() {
return `<div class="dscr" style="color:${L.textColor}">
<h1 style="color:${L.accent}">${esc(L.detailsTitle)}</h1>
${renderMarkdown(L.detailsMarkdown)}
<div class="disc" style="position:static;margin-top:24px;font-size:.6rem;opacity:.55">${esc(L.disclaimer)}</div>
</div>`;
}
function wire() {
const live = (id, key) => {
const el = document.getElementById(id);
if (!el) return;
el.oninput = () => {
L[key] = el.type === 'range' ? parseFloat(el.value) : el.value;
if (id === 'overlay') document.getElementById('ovv').textContent = L.overlay;
// repaint preview only — keeps focus/caret in the field being edited
document.querySelector('.phone').innerHTML = tab === 'start' ? startPreview() : detailsPreview();
};
el.onchange = () => { if (['logoUrl', 'backgroundUrl'].includes(id)) render(); };
};
['title', 'subtitle', 'startButton', 'detailsButton', 'detailsTitle', 'detailsMarkdown',
'disclaimer', 'accent', 'textColor', 'overlay', 'logoUrl', 'backgroundUrl'].forEach(k => live(k, k));
document.querySelectorAll('[data-tab]').forEach(b => b.onclick = () => { tab = b.dataset.tab; render(); });
document.getElementById('file').onchange = async e => {
for (const f of [...e.target.files]) {
const dataBase64 = await new Promise((res, rej) => {
const r = new FileReader();
r.onload = () => res(String(r.result).split(',')[1]);
r.onerror = () => rej(new Error('read failed'));
r.readAsDataURL(f);
});
try { await api('/api/assets', 'POST', { name: f.name, kind: 'texture', dataBase64 }); }
catch (err) { alert(`${f.name}: ${err.message}`); }
}
assets = await api('/api/assets');
render();
};
document.getElementById('save').onclick = async () => {
const st = document.getElementById('status');
try { L = await api('/api/landing', 'PUT', L); render();
document.getElementById('status').textContent = 'Saved ' + new Date().toLocaleTimeString() +
' — reload the viewer to see it.';
} catch (e) { st.textContent = 'Save failed: ' + e.message; }
};
}
render();
</script>
</body></html>
+50 -15
View File
@@ -3,29 +3,52 @@
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">
<title>Newbury Exhibit</title>
<title>Newbury Nights</title>
<style>
:root { color-scheme: dark; }
html, body { margin:0; height:100%; overflow:hidden; background:#06080f; font-family:system-ui, sans-serif; color:#e8ecff; }
:root { color-scheme: dark; --accent:#51eaf1; --text:#e8ecff; }
html, body { margin:0; height:100%; overflow:hidden; background:#06080f; font-family:system-ui, sans-serif; color:var(--text); }
#cam { position:fixed; inset:0; width:100%; height:100%; object-fit:cover; }
#gl { position:fixed; inset:0; width:100%; height:100%; }
.hidden { display:none !important; }
#startScreen { position:fixed; inset:0; display:flex; flex-direction:column; align-items:center; justify-content:center;
background:radial-gradient(ellipse at 50% 30%, #12203f 0%, #06080f 70%); text-align:center; padding:24px; z-index:10; }
#startScreen h1 { font-size:2rem; margin:0 0 4px; letter-spacing:.06em; }
#startScreen .sub { opacity:.7; margin-bottom:28px; }
#start { font-size:1.2rem; padding:14px 42px; border-radius:999px; border:0; cursor:pointer;
background:linear-gradient(135deg,#51eaf1,#529eff); color:#04121a; font-weight:700; }
text-align:center; padding:24px; z-index:10; background-size:cover; background-position:center; }
#startScreen::before { content:''; position:absolute; inset:0; background:#06080f; opacity:var(--ov,.55); }
#startScreen > * { position:relative; }
#logo { max-width:min(78vw, 460px); max-height:34vh; margin-bottom:18px; }
#title { font-size:clamp(1.8rem,7vw,2.8rem); margin:0 0 10px; letter-spacing:.06em; }
#subtitle { opacity:.85; margin:0 0 30px; max-width:34rem; line-height:1.5; }
.btn { font-size:1.15rem; padding:14px 40px; border-radius:999px; border:0; cursor:pointer;
background:linear-gradient(135deg, var(--accent), #529eff); color:#04121a; font-weight:700; }
.btn.ghost { background:transparent; color:var(--text); border:1px solid rgba(255,255,255,.35);
font-size:.92rem; padding:9px 22px; font-weight:500; margin-top:14px; }
.disclaimer { position:absolute; bottom:14px; left:16px; right:16px; font-size:.68rem; opacity:.55; line-height:1.4; }
/* details page */
#details { position:fixed; inset:0; z-index:20; overflow:auto; background:#080b16; padding:0 0 90px; }
#details .inner { max-width:44rem; margin:0 auto; padding:24px 20px 0; }
#details h1 { font-size:1.6rem; color:var(--accent); }
#details h2, #details h3 { color:var(--accent); margin-top:1.4em; }
#details p, #details li { line-height:1.65; opacity:.9; }
#details a { color:var(--accent); }
#backBtn { position:fixed; bottom:0; left:0; right:0; padding:14px; border:0; font-size:1rem;
background:#111730; color:var(--text); cursor:pointer; border-top:1px solid #1e2748; }
#hud { position:fixed; top:0; left:0; right:0; display:flex; justify-content:space-between; padding:10px 14px;
pointer-events:none; z-index:5; text-shadow:0 1px 3px #000; }
#hud span { background:rgba(6,8,15,.55); border-radius:8px; padding:5px 10px; font-size:.85rem; }
#trk.warn { color:#ffd166; }
#toast { position:fixed; bottom:32px; left:50%; transform:translateX(-50%) translateY(20px);
background:rgba(20,28,55,.9); padding:10px 20px; border-radius:999px; opacity:0; transition:all .3s; z-index:6; }
/* shutter */
#shutter { position:fixed; bottom:28px; left:50%; transform:translateX(-50%); z-index:6;
width:68px; height:68px; border-radius:50%; border:4px solid rgba(255,255,255,.85);
background:rgba(255,255,255,.22); cursor:pointer; backdrop-filter:blur(2px); }
#shutter:active { transform:translateX(-50%) scale(.92); background:rgba(255,255,255,.5); }
#flash { position:fixed; inset:0; background:#fff; opacity:0; pointer-events:none; z-index:30; transition:opacity .35s; }
#flash.on { opacity:.85; transition:opacity .05s; }
#toast { position:fixed; bottom:110px; left:50%; transform:translateX(-50%) translateY(20px);
background:rgba(20,28,55,.92); padding:10px 20px; border-radius:999px; opacity:0; transition:all .3s; z-index:6; }
#toast.show { opacity:1; transform:translateX(-50%) translateY(0); }
</style>
</head>
@@ -34,17 +57,29 @@
<canvas id="gl"></canvas>
<div id="startScreen">
<h1>NEWBURY EXHIBIT</h1>
<div class="sub">Point your phone at the Newbury Crests to reveal the hidden side of the town.</div>
<button id="start">Start Ghost Watching</button>
<div class="disclaimer">Fan-made tribute experience. Not affiliated with, sponsored, or endorsed by the LEGO Group.
LEGO® and Hidden Side™ are trademarks of the LEGO Group.</div>
<img id="logo" class="hidden" alt="">
<h1 id="title"></h1>
<p id="subtitle"></p>
<button id="start" class="btn"></button>
<button id="detailsBtn" class="btn ghost"></button>
<div class="disclaimer" id="disc1"></div>
</div>
<div id="details" class="hidden">
<div class="inner">
<h1 id="detailsTitle"></h1>
<div id="detailsBody"></div>
<div class="disclaimer" style="position:static;margin-top:32px" id="disc2"></div>
</div>
<button id="backBtn">← Back</button>
</div>
<div id="hud" class="hidden">
<span id="trk"></span>
<span id="cnt"></span>
</div>
<button id="shutter" class="hidden" aria-label="Take photo"></button>
<div id="flash"></div>
<div id="toast"></div>
<!-- js-aruco2 vendor chain — load order matters: cv -> svd -> posit -> aruco -> dictionary -->
+83 -1
View File
@@ -10,6 +10,7 @@ import { WorldFuser } from './ar/fuse.js';
import { buildGhost } from './ghosts/loader.js';
import { ghostTransform } from './ghosts/behavior.js';
import { ExhibitNet, installErrorReporter } from './net.js';
import { renderMarkdown } from './md.js';
const $ = (s) => document.querySelector(s);
let info = { mode: 'dev' };
@@ -26,17 +27,54 @@ async function boot() {
info = await (await fetch('/api/info')).json();
installErrorReporter(info.mode === 'dev');
await applyLanding();
const g = await (await fetch('/api/ghosts')).json();
gradients = g.gradients.gradients || g.gradients;
modelManifest = await (await fetch('/api/models')).json();
characters = await (await fetch('/api/characters')).json();
$('#start').addEventListener('click', start, { once: true });
$('#detailsBtn').addEventListener('click', () => $('#details').classList.remove('hidden'));
$('#backBtn').addEventListener('click', () => $('#details').classList.add('hidden'));
}
/* Paint the operator-authored landing page + details page. */
async function applyLanding() {
let L;
try { L = await (await fetch('/api/landing')).json(); } catch { return; }
const set = (sel, txt) => { const el = $(sel); if (el) el.textContent = txt || ''; };
document.documentElement.style.setProperty('--accent', L.accent || '#51eaf1');
document.documentElement.style.setProperty('--text', L.textColor || '#e8ecff');
document.documentElement.style.setProperty('--ov', L.overlay != null ? L.overlay : 0.55);
if (L.backgroundUrl) $('#startScreen').style.backgroundImage = `url("${L.backgroundUrl}")`;
if (L.logoUrl) {
const img = $('#logo');
img.src = L.logoUrl; img.alt = L.title || 'logo';
img.classList.remove('hidden');
$('#title').classList.add('hidden'); // logo art replaces the text title
} else {
set('#title', L.title);
}
set('#subtitle', L.subtitle);
set('#start', L.startButton || 'Start');
set('#detailsBtn', L.detailsButton || 'About');
set('#detailsTitle', L.detailsTitle);
$('#detailsBody').innerHTML = renderMarkdown(L.detailsMarkdown);
// disclaimer is always rendered on both screens
set('#disc1', L.disclaimer);
set('#disc2', L.disclaimer);
if (!L.detailsMarkdown) $('#detailsBtn').classList.add('hidden');
document.title = L.title || 'Newbury Nights';
}
async function start() {
$('#startScreen').classList.add('hidden');
$('#hud').classList.remove('hidden');
$('#shutter').classList.remove('hidden');
$('#shutter').addEventListener('click', capturePhoto);
// camera
video = $('#cam');
@@ -52,7 +90,7 @@ async function start() {
ctx2d = canvas2d.getContext('2d', { willReadFrequently: true });
// three.js
renderer = new THREE.WebGLRenderer({ canvas: $('#gl'), alpha: true, antialias: true });
renderer = new THREE.WebGLRenderer({ canvas: $('#gl'), alpha: true, antialias: true, preserveDrawingBuffer: true });
renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
scene3 = new THREE.Scene();
camera3 = new THREE.PerspectiveCamera(60, innerWidth / innerHeight, 2, 5000);
@@ -195,6 +233,50 @@ function loop(t) {
renderer.render(scene3, camera3);
}
/* Shutter: composite the live camera frame with the rendered ghosts and save a PNG.
* The WebGL canvas is drawn on top of a video frame at full video resolution. */
async function capturePhoto() {
try {
const flash = $('#flash');
flash.classList.add('on');
setTimeout(() => flash.classList.remove('on'), 60);
const vw = video.videoWidth, vh = video.videoHeight;
const out = document.createElement('canvas');
out.width = vw; out.height = vh;
const c = out.getContext('2d');
c.drawImage(video, 0, 0, vw, vh);
// renderer canvas is CSS-sized to the viewport with object-fit:cover on the video,
// so replicate that cover mapping when overlaying the 3D layer
const gl = $('#gl');
const scale = Math.max(vw / gl.width, vh / gl.height);
const dw = gl.width * scale, dh = gl.height * scale;
renderer.render(scene3, camera3); // ensure the buffer is current
c.drawImage(gl, (vw - dw) / 2, (vh - dh) / 2, dw, dh);
const stamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
const blob = await new Promise(r => out.toBlob(r, 'image/png'));
const file = new File([blob], `newbury-${stamp}.png`, { type: 'image/png' });
// native share sheet on mobile (lets users save to camera roll), download elsewhere
if (navigator.canShare && navigator.canShare({ files: [file] })) {
await navigator.share({ files: [file], title: 'Newbury Nights' });
toast('Photo ready to share');
} else {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url; a.download = file.name; a.click();
setTimeout(() => URL.revokeObjectURL(url), 10000);
toast('Photo saved');
}
} catch (e) {
if (e && e.name === 'AbortError') return; // user dismissed the share sheet
toast('Could not save photo');
console.warn('capture failed', e);
}
}
let toastTimer = null;
function toast(msg) {
const el = $('#toast');
+4 -13
View File
@@ -1,16 +1,7 @@
@echo off
REM push-characters.bat - commit and push the character manager changes to Gitea.
REM Run from anywhere; it always works on K:\newbury-exhibit-v2
cd /d K:\newbury-exhibit-v2
if not exist .git (
echo No git repo here - initialising and linking to Gitea...
git init -b main
git remote add origin https://gitea.hideawaygaming.com.au/jessikitty/newbury-exhibit-v2.git
git fetch origin
git reset --soft origin/main
)
@echo off
REM push-characters.bat commit and push the character manager changes to Gitea.
REM Extract the zip over Y:\newbury-exhibit-v2 first, then run this.
cd /d Y:\newbury-exhibit-v2
git add -A
git commit -m "Character manager: asset uploads, per-ghost model/colour/opacity overrides, face+torso texture decals"
git push origin main
+16
View File
@@ -0,0 +1,16 @@
@echo off
REM push-landing.bat - commit and push the landing page editor + shutter button.
cd /d K:\newbury-exhibit-v2
if not exist .git (
echo No git repo here - initialising and linking to Gitea...
git init -b main
git remote add origin https://gitea.hideawaygaming.com.au/jessikitty/newbury-exhibit-v2.git
git fetch origin
git reset --soft origin/main
)
git add -A
git commit -m "Landing page editor: logo/background/subtitle, markdown details page, editable disclaimer, photo shutter"
git push origin main
pause
+7
View File
@@ -38,6 +38,13 @@ app.delete('/api/assets/:id', guard, (req, res) => {
catch (e) { res.status(400).json({ error: String(e.message || e) }); }
});
// ---------- landing page ----------
app.get('/api/landing', (req, res) => res.json(state.getLanding()));
app.put('/api/landing', guard, (req, res) => {
try { res.json(state.putLanding(req.body)); }
catch (e) { res.status(400).json({ error: String(e.message || e) }); }
});
// ---------- character overrides ----------
app.get('/api/characters', (req, res) => res.json(state.getCharacters()));
app.put('/api/characters', guard, (req, res) => {
+30
View File
@@ -13,6 +13,7 @@ const PLAYLIST_LIVE = path.join(LIVE_DIR, 'playlist.live.json');
const MODELS_LIVE = path.join(LIVE_DIR, 'models.live.json');
const MODELS_SEED = path.join(DATA, 'models.json');
const CHARS_LIVE = path.join(LIVE_DIR, 'characters.live.json');
const LANDING_LIVE = path.join(LIVE_DIR, 'landing.live.json');
fs.mkdirSync(LIVE_DIR, { recursive: true });
@@ -32,6 +33,24 @@ let models = readJSON(MODELS_LIVE, null) || readJSON(MODELS_SEED, { models: [] }
* faceTextureUrl, torsoTextureUrl, heightCm, scale } } } */
let characters = readJSON(CHARS_LIVE, null) || { defaults: {}, byId: {} };
/* landing: end-user facing start screen + details page. All text is editable except
* that the disclaimer is ALWAYS rendered (wording editable, presence is not). */
const LANDING_DEFAULTS = {
title: 'NEWBURY NIGHTS',
subtitle: 'Point your phone at the Newbury Crests to reveal the hidden side of the town.',
logoUrl: '',
backgroundUrl: '',
startButton: 'Start Ghost Watching',
detailsButton: 'About this build',
detailsTitle: 'About Newbury Nights',
detailsMarkdown: 'Write about your build here.\n\nUse **bold**, *italic*, and blank lines for paragraphs.',
disclaimer: 'Fan-made tribute experience. Not affiliated with, sponsored, or endorsed by the LEGO Group. LEGO® and Hidden Side™ are trademarks of the LEGO Group.',
accent: '#51eaf1',
textColor: '#e8ecff',
overlay: 0.55,
};
let landing = Object.assign({}, LANDING_DEFAULTS, readJSON(LANDING_LIVE, null) || {});
const listeners = [];
function validateScene(s) {
@@ -76,6 +95,17 @@ module.exports = {
return scene;
},
getGhosts: () => ({ ghosts, gradients }),
getLanding: () => landing,
putLanding(l) {
if (!l || typeof l !== 'object') throw new Error('landing must be object');
const next = Object.assign({}, LANDING_DEFAULTS, l);
// disclaimer may be reworded but never emptied — it protects the project
if (!String(next.disclaimer || '').trim()) next.disclaimer = LANDING_DEFAULTS.disclaimer;
landing = next;
writeJSON(LANDING_LIVE, landing);
listeners.forEach(f => f());
return landing;
},
getCharacters: () => characters,
putCharacters(c) {
if (!c || typeof c !== 'object') throw new Error('characters must be object');