diff --git a/public/js/admin.js b/public/js/admin.js new file mode 100644 index 0000000..0629857 --- /dev/null +++ b/public/js/admin.js @@ -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 ? `` : ''; + 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 = ''; + 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('') || + '

No sets yet. Create one to wire a scan code to a ghost roster.

'; + $$('#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) => + `${esc(r.name)}${r.is_boss ? ' ★' : ''}` + ).join(''); + return `
+
+ ${esc(s.code)} + ${esc(s.set_name)} + ${s.set_number ? `#${esc(s.set_number)}` : ''} + + + +
+
${chips || 'no roster'}
+
`; +} + +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 = '' + + GHOSTS.filter((g) => g.is_boss).map((g) => + ``).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) => + ``).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])); +}