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

162 lines
9.8 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!doctype html>
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
<title>Playlist — Newbury Exhibit</title></head>
<body>
<div id="app"></div>
<script type="module">
import { api, requireAuth, styles, nav } from './admin.js';
document.head.insertAdjacentHTML('beforeend', `<style>${styles}
main { max-width:640px; margin:20px auto; padding:0 16px; }
.card { background:#111730; border-radius:10px; padding:16px; margin-bottom:16px; }
.row { display:flex; gap:10px; align-items:center; margin:8px 0; }
.row label { width:170px; }
.chips { display:flex; flex-wrap:wrap; gap:6px; }
.chip { padding:4px 12px; border-radius:999px; background:#1a2244; cursor:pointer; font-size:.85rem; user-select:none; }
.chip.on { background:#2a6ea0; }
table { width:100%; font-size:.85rem; }
</style>`);
await requireAuth();
const app = document.getElementById('app');
let cfg = await api('/api/playlist');
const { ghosts } = await api('/api/ghosts');
const sceneData = await api('/api/scene');
const colors = ['Red', 'Yellow', 'Blue'];
const rarities = ['Common', 'Rare', 'Epic', 'Legendary'];
function chips(list, selected, onToggle) {
return list.map(v => `<span class="chip ${selected.includes(v) ? 'on' : ''}" data-v="${v}">${v}</span>`).join('');
}
function rosterCount() {
const inc = cfg.include || {};
return ghosts.filter(g =>
(!inc.colors?.length || inc.colors.includes(g.color)) &&
(!inc.rarities?.length || inc.rarities.includes(g.rarity))).length;
}
function residentRow(r, i) {
const gOpts = ghosts.map(g => `<option value="${g.id}" ${g.id === r.id ? 'selected' : ''}>${g.name} (${g.rarity} ${g.color})</option>`).join('');
const locOpts = ['<option value="">auto</option>']
.concat((sceneData.spawns || []).map(sp => `<option value="s:${sp.id}" ${r.spawnId === sp.id ? 'selected' : ''}>spawn ${sp.id}</option>`))
.concat((sceneData.paths || []).map(pt => `<option value="p:${pt.id}" ${r.pathId === pt.id ? 'selected' : ''}>path ${pt.id}</option>`)).join('');
const behOpts = ['static', 'wander', 'path'].map(b => `<option ${(r.behavior || 'static') === b ? 'selected' : ''}>${b}</option>`).join('');
return `<div class="row" data-res="${i}">
<select data-rk="id" style="flex:2;min-width:0">${gOpts}</select>
<select data-rk="loc" style="flex:1;min-width:0">${locOpts}</select>
<select data-rk="behavior" style="width:86px">${behOpts}</select>
<button data-resdel="${i}" class="danger">✕</button></div>`;
}
function render() {
app.innerHTML = `${nav('playlist')}<main>
<div class="card">
<h2 style="margin-top:0">Rotation</h2>
<div class="row"><label>Concurrent ghosts (slots)</label><input id="slots" type="number" min="1" max="20" value="${cfg.slots}"></div>
<div class="row"><label>Dwell time (seconds)</label><input id="dwell" type="number" min="10" value="${cfg.dwellSeconds}"></div>
<div class="row"><label>Crossfade (seconds)</label><input id="fade" type="number" min="0" value="${cfg.crossfadeSeconds}"></div>
<div class="row"><label>Order</label>
<select id="order"><option ${cfg.order === 'weighted' ? 'selected' : ''}>weighted</option><option ${cfg.order === 'shuffle' ? 'selected' : ''}>shuffle</option><option ${cfg.order === 'roster' ? 'selected' : ''}>roster</option></select></div>
<p style="opacity:.6;font-size:.78rem;margin:4px 0 0">weighted = rarity odds below decide who shows up · shuffle/roster = everyone equally, in random/fixed order</p>
</div>
<div class="card">
<h2 style="margin-top:0">Roster filter <span style="opacity:.6;font-size:.8rem">(${rosterCount()} ghosts match — empty = all)</span></h2>
<div class="row"><label>Colors</label><div class="chips" id="colors">${chips(colors, cfg.include?.colors || [])}</div></div>
<div class="row"><label>Rarities</label><div class="chips" id="rarities">${chips(rarities, cfg.include?.rarities || [])}</div></div>
</div>
<div class="card">
<h2 style="margin-top:0">Behavior</h2>
<div class="row"><label>Static chance (01)</label><input id="staticChance" type="number" step="0.05" min="0" max="1" value="${cfg.behaviors.staticChance}"></div>
<div class="row"><label>Wander radius (cm)</label><input id="wanderRadius" type="number" step="1" value="${cfg.behaviors.wanderRadius}"></div>
<div class="row"><label>Wander speed</label><input id="wanderSpeed" type="number" step="0.05" value="${cfg.behaviors.wanderSpeed}"></div>
<div class="row"><label>Vertical wander (cm)</label><input id="wanderVertical" type="number" step="1" value="${cfg.behaviors.wanderVertical ?? 10}"></div>
<div class="row"><label>Path walk speed (cm/s)</label><input id="pathSpeed" type="number" step="0.5" value="${cfg.behaviors.pathSpeed ?? 8}"></div>
<div class="row"><label>Path chance (01)</label><input id="pathChance" type="number" step="0.05" min="0" max="1" value="${cfg.behaviors.pathChance ?? 0.4}"></div>
<p style="opacity:.6;font-size:.78rem;margin:4px 0 0">Path chance = odds a new ghost takes a free path instead of a spawn point.</p>
</div>
<div class="card">
<h2 style="margin-top:0">Rarity: appearances &amp; movement</h2>
<table>
<tr style="opacity:.6"><td></td><td>appearance weight</td><td>movement scale</td></tr>
${rarities.map(r => `<tr>
<td style="padding:4px 8px 4px 0">${r}</td>
<td><input data-rw="${r}" type="number" step="0.05" min="0" style="width:90px" value="${cfg.rarity?.weights?.[r] ?? 1}"></td>
<td><input data-rm="${r}" type="number" step="0.05" min="0" style="width:90px" value="${cfg.rarity?.movementScale?.[r] ?? 1}"></td>
</tr>`).join('')}
</table>
<p style="opacity:.6;font-size:.78rem">Weight: how often they appear in <b>weighted</b> order (Legendary 0.1 = a tenth as often as Common 1.0).
Movement scale shrinks wander radius/speed and path speed — 0.4 means rare ghosts drift only a little.</p>
</div>
<div class="card">
<h2 style="margin-top:0">Residents <span style="opacity:.6;font-size:.8rem">(permanently in their place — never rotate out)</span></h2>
<div id="residents">${(cfg.residents || []).map((r, i) => residentRow(r, i)).join('') || '<em style="opacity:.6">No residents yet</em>'}</div>
<button id="addRes" style="margin-top:8px">+ Add resident</button>
</div>
<div class="card">
<h2 style="margin-top:0">Time windows <span style="opacity:.6;font-size:.8rem">(ghosts only appear inside these; empty = always on)</span></h2>
<div id="windows">${(cfg.timeWindows || []).map((w, i) =>
`<div class="row"><input type="time" value="${w.start}" data-i="${i}" data-k="start"> →
<input type="time" value="${w.end}" data-i="${i}" data-k="end">
<button data-del="${i}" class="danger">✕</button></div>`).join('') || '<em style="opacity:.6">Always on</em>'}</div>
<button id="addWin" style="margin-top:8px">+ Add window</button>
</div>
<button id="save" class="primary" style="width:100%;padding:12px;font-size:1rem">Apply playlist (restarts rotation)</button>
<div id="status" style="margin:10px 0;opacity:.7;font-size:.85rem"></div>
</main>`;
for (const grp of ['colors', 'rarities']) {
document.getElementById(grp).querySelectorAll('.chip').forEach(c => c.onclick = () => {
const arr = cfg.include[grp] = cfg.include[grp] || [];
const i = arr.indexOf(c.dataset.v);
i >= 0 ? arr.splice(i, 1) : arr.push(c.dataset.v);
render();
});
}
document.getElementById('addWin').onclick = () => { (cfg.timeWindows ||= []).push({ start: '09:00', end: '17:00' }); render(); };
document.querySelectorAll('[data-del]').forEach(b => b.onclick = () => { cfg.timeWindows.splice(+b.dataset.del, 1); render(); });
document.querySelectorAll('#windows input').forEach(inp => inp.onchange = () => cfg.timeWindows[+inp.dataset.i][inp.dataset.k] = inp.value);
document.getElementById('addRes').onclick = () => {
(cfg.residents ||= []).push({ id: ghosts[0].id, behavior: 'static' });
render();
};
document.querySelectorAll('[data-resdel]').forEach(b => b.onclick = () => { cfg.residents.splice(+b.dataset.resdel, 1); render(); });
document.querySelectorAll('#residents [data-rk]').forEach(el => el.onchange = () => {
const i = +el.closest('[data-res]').dataset.res, r = cfg.residents[i];
if (el.dataset.rk === 'id') r.id = el.value;
else if (el.dataset.rk === 'behavior') r.behavior = el.value;
else {
delete r.spawnId; delete r.pathId;
if (el.value.startsWith('s:')) r.spawnId = el.value.slice(2);
if (el.value.startsWith('p:')) { r.pathId = el.value.slice(2); r.behavior = 'path'; }
render(); return;
}
});
document.querySelectorAll('[data-rw],[data-rm]').forEach(el => el.onchange = () => {
cfg.rarity ||= { weights: {}, movementScale: {} };
if (el.dataset.rw) (cfg.rarity.weights ||= {})[el.dataset.rw] = +el.value;
if (el.dataset.rm) (cfg.rarity.movementScale ||= {})[el.dataset.rm] = +el.value;
});
document.getElementById('save').onclick = async () => {
cfg.slots = +document.getElementById('slots').value;
cfg.dwellSeconds = +document.getElementById('dwell').value;
cfg.crossfadeSeconds = +document.getElementById('fade').value;
cfg.order = document.getElementById('order').value;
cfg.behaviors = {
staticChance: +document.getElementById('staticChance').value,
wanderRadius: +document.getElementById('wanderRadius').value,
wanderSpeed: +document.getElementById('wanderSpeed').value,
wanderVertical: +document.getElementById('wanderVertical').value,
pathSpeed: +document.getElementById('pathSpeed').value,
pathChance: +document.getElementById('pathChance').value,
};
try { cfg = await api('/api/playlist', 'PUT', cfg); document.getElementById('status').textContent = 'Applied ' + new Date().toLocaleTimeString(); }
catch (e) { document.getElementById('status').textContent = 'Failed: ' + e.message; }
};
}
render();
</script>
</body></html>