/* 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