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
+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');