162 lines
9.8 KiB
HTML
162 lines
9.8 KiB
HTML
<!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 (0–1)</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 (0–1)</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 & 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>
|