Add admin console client logic
This commit is contained in:
@@ -0,0 +1,320 @@
|
||||
/* 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="">` : '';
|
||||
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 = '';
|
||||
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('') ||
|
||||
'<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) =>
|
||||
({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
||||
}
|
||||
Reference in New Issue
Block a user