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

100 lines
5.5 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 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 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 === 'shuffle' ? 'selected' : ''}>shuffle</option><option ${cfg.order === 'roster' ? 'selected' : ''}>roster</option></select></div>
</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 (m)</label><input id="wanderRadius" type="number" step="0.05" 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>
<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('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,
};
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>