Files
newbury-exhibit-v2/public/admin/landing.html
T

182 lines
9.6 KiB
HTML

<!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>