Files
newbury-nights/public/js/admin.js
T
2026-06-19 01:21:26 +00:00

358 lines
13 KiB
JavaScript

/* 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
? `<img src="/uploads/${g.image_path}" alt="">`
: (g.webm_path
? `<video src="/uploads/${g.webm_path}" muted loop autoplay playsinline></video>`
: '');
const setRef = g.set_number ? `${g.set_number}` : '—';
return `<tr>
<td class="thumb-cell">${img}</td>
<td><div>${esc(g.name)}${g.is_boss ? ' <span class="pill">boss</span>' : ''}</div>
${g.display_name && g.display_name !== g.name ? `<div class="sub">↳ ${esc(g.display_name)}</div>` : ''}</td>
<td><span class="dot ${g.type}"></span> ${g.type}</td>
<td class="stars">${'★'.repeat(g.rarity)}</td>
<td>${g.health}</td><td>${g.damage}</td>
<td>${esc(g.ability || '—')}</td>
<td class="mono">${setRef}</td>
<td><span class="toggle ${g.enabled ? 'on' : 'off'}" data-id="${g.id}">${g.enabled ? '◉' : '◯'}</span></td>
<td><div class="row-actions">
<button class="edit" data-id="${g.id}">Edit</button>
<button class="danger del" data-id="${g.id}">Del</button>
</div></td>
</tr>`;
}
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 <img> and a <video> depending on media kind.
function showPreview(src, kind) {
const img = $('#gm-preview');
const vid = $('#gm-preview-vid');
if (kind === 'video') {
img.classList.add('hidden'); img.removeAttribute('src');
vid.src = src; vid.classList.remove('hidden');
vid.play?.().catch(() => {});
} else {
vid.classList.add('hidden'); vid.pause?.(); vid.removeAttribute('src');
img.src = src; img.classList.remove('hidden');
}
}
function hidePreview() {
const img = $('#gm-preview');
const vid = $('#gm-preview-vid');
img.classList.add('hidden'); img.removeAttribute('src');
vid.classList.add('hidden'); vid.pause?.(); vid.removeAttribute('src');
}
// Live local preview when a file is chosen (before upload).
$('#gm-file').addEventListener('change', () => {
const file = $('#gm-file').files[0];
if (!file) return;
const url = URL.createObjectURL(file);
const isVideo = /\.(mp4|webm)$/i.test(file.name) || file.type.startsWith('video/');
showPreview(url, isVideo ? 'video' : 'image');
});
$('#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('') ||
'<p class="muted">No sets yet. Create one to wire a scan code to a ghost roster.</p>';
$$('#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) =>
`<span class="rchip"><span class="dot ${r.type}"></span>${esc(r.name)}${r.is_boss ? ' ★' : ''}</span>`
).join('');
return `<div class="set-card ${s.enabled ? '' : 'off'}">
<div class="set-head">
<span class="code">${esc(s.code)}</span>
<span class="sname">${esc(s.set_name)}</span>
${s.set_number ? `<span class="mono muted">#${esc(s.set_number)}</span>` : ''}
<span class="grow"></span>
<button class="edit-set" data-id="${s.id}">Edit</button>
<button class="danger del-set" data-id="${s.id}">Delete</button>
</div>
<div class="set-roster">${chips || '<span class="muted">no roster</span>'}</div>
</div>`;
}
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 = '<option value="">— none —</option>' +
GHOSTS.filter((g) => g.is_boss).map((g) =>
`<option value="${g.id}">${esc(g.name)}</option>`).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) =>
`<label class="rcheck" data-name="${esc(g.name.toLowerCase())}">
<input type="checkbox" value="${g.id}" ${selected.has(g.id) ? 'checked' : ''}>
<span class="dot ${g.type}"></span> ${esc(g.name)} ${'★'.repeat(g.rarity)}
</label>`).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) =>
({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
}