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