/* 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 ? `` : (g.webm_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 = ''; // Show the existing stored media: prefer the WebM video, else a still image // (image_path is the GIF/PNG, or the WebP thumbnail for converted ghosts). if (g?.webm_path) showPreview(`/uploads/${g.webm_path}`, 'video'); else if (g?.image_path) showPreview(`/uploads/${g.image_path}`, 'image'); else if (g?.webp_path) showPreview(`/uploads/${g.webp_path}`, 'image'); else hidePreview(); $('#ghost-modal').classList.remove('hidden'); } // Swap the modal preview between an and a