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]));
+}