/* Newbury Nights — admin console */ const $ = (s, r = document) => r.querySelector(s); const $$ = (s, r = document) => [...r.querySelectorAll(s)]; let TOKEN = null; let GHOSTS = []; let SETS = []; const api = (path, opts = {}) => fetch(path, { ...opts, headers: { ...(opts.body && !(opts.body instanceof FormData) ? { 'Content-Type': 'application/json' } : {}), ...(TOKEN ? { Authorization: `Bearer ${TOKEN}` } : {}), ...(opts.headers || {}), }, }); /* ---------- Auth ---------- */ $('#lg-go').addEventListener('click', login); $('#lg-pass').addEventListener('keydown', (e) => { if (e.key === 'Enter') login(); }); async function login() { const username = $('#lg-user').value.trim(); const password = $('#lg-pass').value; const err = $('#lg-error'); err.classList.add('hidden'); const res = await api('/auth/login', { method: 'POST', body: JSON.stringify({ username, password }) }); if (!res.ok) { err.textContent = 'Invalid username or password.'; err.classList.remove('hidden'); return; } const data = await res.json(); TOKEN = data.token; $('#acct-user').textContent = data.user.username; $('#login').classList.add('hidden'); $('#app').classList.remove('hidden'); await Promise.all([loadGhosts(), loadSets()]); } $('#logout').addEventListener('click', async () => { await api('/auth/logout', { method: 'POST' }); location.reload(); }); /* ---------- Tabs ---------- */ $$('.tab').forEach((t) => t.addEventListener('click', () => { $$('.tab').forEach((x) => x.classList.remove('active')); t.classList.add('active'); $$('.tab-panel').forEach((p) => p.classList.add('hidden')); $(`#tab-${t.dataset.tab}`).classList.remove('hidden'); }) ); /* ---------- Ghosts ---------- */ async function loadGhosts() { const res = await api('/api/admin/ghosts'); GHOSTS = (await res.json()).ghosts; renderGhosts(); } $('#ghost-search').addEventListener('input', renderGhosts); function renderGhosts() { const q = $('#ghost-search').value.toLowerCase(); const rows = GHOSTS.filter( (g) => !q || g.name.toLowerCase().includes(q) || (g.ability || '').toLowerCase().includes(q) ).map(ghostRow).join(''); $('#ghosts-table tbody').innerHTML = rows; $$('#ghosts-table .edit').forEach((b) => b.addEventListener('click', () => openGhost(+b.dataset.id))); $$('#ghosts-table .del').forEach((b) => b.addEventListener('click', () => deleteGhost(+b.dataset.id))); $$('#ghosts-table .toggle').forEach((b) => b.addEventListener('click', () => toggleGhost(+b.dataset.id))); } function ghostRow(g) { const img = g.image_path ? `` : ''; const setRef = g.set_number ? `${g.set_number}` : '—'; return ` ${img}
${esc(g.name)}${g.is_boss ? ' boss' : ''}
${g.display_name && g.display_name !== g.name ? `
↳ ${esc(g.display_name)}
` : ''} ${g.type} ${'★'.repeat(g.rarity)} ${g.health}${g.damage} ${esc(g.ability || '—')} ${setRef} ${g.enabled ? '◉' : '◯'}
`; } async function toggleGhost(id) { const g = GHOSTS.find((x) => x.id === id); await api(`/api/admin/ghosts/${id}`, { method: 'PATCH', body: JSON.stringify({ enabled: !g.enabled }) }); await loadGhosts(); } async function deleteGhost(id) { const g = GHOSTS.find((x) => x.id === id); if (!confirm(`Delete "${g.name}"? This also removes it from any set rosters.`)) return; await api(`/api/admin/ghosts/${id}`, { method: 'DELETE' }); await Promise.all([loadGhosts(), loadSets()]); } /* ghost modal */ let editingGhostId = null; $('#new-ghost').addEventListener('click', () => openGhost(null)); function openGhost(id) { editingGhostId = id; const g = id ? GHOSTS.find((x) => x.id === id) : null; $('#gm-title').textContent = g ? 'Edit ghost' : 'New ghost'; $('#gm-name').value = g?.name || ''; $('#gm-display').value = g?.display_name || ''; $('#gm-type').value = g?.type || 'red'; $('#gm-rarity').value = g?.rarity ?? 1; $('#gm-health').value = g?.health ?? 300; $('#gm-damage').value = g?.damage ?? 150; $('#gm-speed').value = g?.speed ?? 3; $('#gm-range').value = g?.range ?? 3; $('#gm-charge').value = g?.charge_shot ?? 2; $('#gm-ability').value = g?.ability || ''; $('#gm-setnum').value = g?.set_number || ''; $('#gm-setname').value = g?.set_name || ''; $('#gm-boss').checked = !!g?.is_boss; $('#gm-enabled').checked = g ? !!g.enabled : true; $('#gm-file').value = ''; const prev = $('#gm-preview'); if (g?.image_path) { prev.src = `/uploads/${g.image_path}`; prev.classList.remove('hidden'); } else prev.classList.add('hidden'); $('#ghost-modal').classList.remove('hidden'); } $('#gm-save').addEventListener('click', saveGhost); async function saveGhost() { const payload = { name: $('#gm-name').value.trim(), displayName: $('#gm-display').value.trim() || $('#gm-name').value.trim(), type: $('#gm-type').value, rarity: +$('#gm-rarity').value, health: +$('#gm-health').value, damage: +$('#gm-damage').value, speed: +$('#gm-speed').value, range: +$('#gm-range').value, chargeShot: +$('#gm-charge').value, ability: $('#gm-ability').value.trim() || null, setNumber: $('#gm-setnum').value.trim() || null, setName: $('#gm-setname').value.trim() || null, isBoss: $('#gm-boss').checked, enabled: $('#gm-enabled').checked, }; if (!payload.name) { alert('Name is required.'); return; } let id = editingGhostId; if (id) { await api(`/api/admin/ghosts/${id}`, { method: 'PATCH', body: JSON.stringify(payload) }); } else { const res = await api('/api/admin/ghosts', { method: 'POST', body: JSON.stringify(payload) }); id = (await res.json()).id; } const file = $('#gm-file').files[0]; if (file && id) { const fd = new FormData(); fd.append('image', file); await api(`/api/admin/ghosts/${id}/image`, { method: 'POST', body: fd }); } $('#ghost-modal').classList.add('hidden'); await Promise.all([loadGhosts(), loadSets()]); } /* ---------- Sets ---------- */ async function loadSets() { const res = await api('/api/admin/sets'); SETS = (await res.json()).sets; renderSets(); } function renderSets() { $('#sets-list').innerHTML = SETS.map(setCard).join('') || '

No sets yet. Create one to wire a scan code to a ghost roster.

'; $$('#sets-list .edit-set').forEach((b) => b.addEventListener('click', () => openSet(+b.dataset.id))); $$('#sets-list .del-set').forEach((b) => b.addEventListener('click', () => deleteSet(+b.dataset.id))); } function setCard(s) { const chips = s.roster.map((r) => `${esc(r.name)}${r.is_boss ? ' ★' : ''}` ).join(''); return `
${esc(s.code)} ${esc(s.set_name)} ${s.set_number ? `#${esc(s.set_number)}` : ''}
${chips || 'no roster'}
`; } async function deleteSet(id) { const s = SETS.find((x) => x.id === id); if (!confirm(`Delete set "${s.set_name}" (${s.code})?`)) return; await api(`/api/admin/sets/${id}`, { method: 'DELETE' }); await loadSets(); } /* set modal */ let editingSetId = null; $('#new-set').addEventListener('click', () => openSet(null)); $('#sm-roster-search').addEventListener('input', filterRosterChecklist); function openSet(id) { editingSetId = id; const s = id ? SETS.find((x) => x.id === id) : null; $('#sm-title').textContent = s ? 'Edit set' : 'New set'; $('#sm-code').value = s?.code || ''; $('#sm-setnum').value = s?.set_number || ''; $('#sm-setname').value = s?.set_name || ''; $('#sm-enabled').checked = s ? !!s.enabled : true; // boss dropdown const bossSel = $('#sm-boss'); bossSel.innerHTML = '' + GHOSTS.filter((g) => g.is_boss).map((g) => ``).join(''); bossSel.value = s?.boss_ghost_id || ''; // roster checklist const selected = new Set((s?.roster || []).map((r) => r.id)); $('#sm-roster').innerHTML = GHOSTS.map((g) => ``).join(''); $('#sm-roster-search').value = ''; $('#set-modal').classList.remove('hidden'); } function filterRosterChecklist() { const q = $('#sm-roster-search').value.toLowerCase(); $$('#sm-roster .rcheck').forEach((el) => { el.style.display = !q || el.dataset.name.includes(q) ? '' : 'none'; }); } $('#sm-save').addEventListener('click', saveSet); async function saveSet() { const payload = { code: $('#sm-code').value.trim(), setNumber: $('#sm-setnum').value.trim() || null, setName: $('#sm-setname').value.trim(), bossGhostId: $('#sm-boss').value ? +$('#sm-boss').value : null, enabled: $('#sm-enabled').checked, }; if (!payload.code || !payload.setName) { alert('Scan code and set name are required.'); return; } let id = editingSetId; if (id) { await api(`/api/admin/sets/${id}`, { method: 'PATCH', body: JSON.stringify(payload) }); } else { const res = await api('/api/admin/sets', { method: 'POST', body: JSON.stringify(payload) }); if (res.status === 409) { alert('That scan code already exists.'); return; } id = (await res.json()).id; } const ghostIds = $$('#sm-roster input:checked').map((i) => +i.value); await api(`/api/admin/sets/${id}/roster`, { method: 'PUT', body: JSON.stringify({ ghostIds }) }); $('#set-modal').classList.add('hidden'); await loadSets(); } /* ---------- Account ---------- */ $('#cp-go').addEventListener('click', async () => { const msg = $('#cp-msg'); msg.classList.add('hidden'); msg.classList.remove('ok'); const res = await api('/auth/change-password', { method: 'POST', body: JSON.stringify({ currentPassword: $('#cp-current').value, newPassword: $('#cp-new').value, }), }); const data = await res.json().catch(() => ({})); if (res.ok) { msg.textContent = 'Password updated.'; msg.classList.add('ok'); } else { msg.textContent = data.error || 'Could not update password.'; } msg.classList.remove('hidden'); $('#cp-current').value = ''; $('#cp-new').value = ''; }); /* ---------- modal close ---------- */ $$('[data-close]').forEach((b) => b.addEventListener('click', () => $$('.modal').forEach((m) => m.classList.add('hidden')))); $$('.modal').forEach((m) => m.addEventListener('click', (e) => { if (e.target === m) m.classList.add('hidden'); })); function esc(s) { return String(s ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); }