From feb2247cd411cd222f7995554b1a2bef4adb2db8 Mon Sep 17 00:00:00 2001 From: jessikitty Date: Wed, 3 Jun 2026 10:00:33 +1000 Subject: [PATCH] Add admin panel JS (login, ghost/set CRUD against the API) --- public/js/admin.js | 187 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 187 insertions(+) create mode 100644 public/js/admin.js diff --git a/public/js/admin.js b/public/js/admin.js new file mode 100644 index 0000000..d050f38 --- /dev/null +++ b/public/js/admin.js @@ -0,0 +1,187 @@ +/* Token kept in memory + sessionStorage so a refresh keeps you logged in + but closing the tab clears it. */ +let token = sessionStorage.getItem("spectre_token") || null; +let me = sessionStorage.getItem("spectre_user") || null; +let ghostCache = []; + +const $ = (id) => document.getElementById(id); +const loginView = $("login-view"); +const dashView = $("dash-view"); + +/* ---------- API helper ---------- */ +async function api(method, path, body, isForm = false) { + const headers = {}; + if (token) headers.Authorization = "Bearer " + token; + let payload; + if (isForm) { + payload = body; // FormData + } else if (body) { + headers["Content-Type"] = "application/json"; + payload = JSON.stringify(body); + } + const res = await fetch("/api" + path, { method, headers, body: payload }); + if (res.status === 401) { doLogout(); throw new Error("Session expired"); } + const data = await res.json().catch(() => ({})); + if (!res.ok) throw new Error(data.error || "Request failed"); + return data; +} + +/* ---------- Auth ---------- */ +$("login-btn").addEventListener("click", async () => { + $("login-error").textContent = ""; + try { + const data = await api("POST", "/login", { + username: $("username").value.trim(), + password: $("password").value, + }); + token = data.token; me = data.username; + sessionStorage.setItem("spectre_token", token); + sessionStorage.setItem("spectre_user", me); + enterDash(); + } catch (e) { + $("login-error").textContent = e.message; + } +}); +$("password").addEventListener("keydown", (e) => { if (e.key === "Enter") $("login-btn").click(); }); + +$("logout-btn").addEventListener("click", doLogout); +function doLogout() { + token = null; me = null; + sessionStorage.removeItem("spectre_token"); + sessionStorage.removeItem("spectre_user"); + dashView.classList.remove("active"); + loginView.classList.add("active"); +} + +function enterDash() { + loginView.classList.remove("active"); + dashView.classList.add("active"); + $("whoami").textContent = "@" + me; + loadGhosts(); + loadSets(); +} + +/* ---------- Tabs ---------- */ +document.querySelectorAll(".tab").forEach((t) => + t.addEventListener("click", () => { + document.querySelectorAll(".tab").forEach((x) => x.classList.remove("active")); + document.querySelectorAll(".tab-panel").forEach((x) => x.classList.remove("active")); + t.classList.add("active"); + $("tab-" + t.dataset.tab).classList.add("active"); + }) +); + +/* ---------- Ghosts ---------- */ +async function loadGhosts() { + ghostCache = await api("GET", "/ghosts"); + const grid = $("ghost-grid"); + grid.innerHTML = ""; + for (const g of ghostCache) { + const card = document.createElement("div"); + card.className = "ghost-card" + (g.enabled ? "" : " disabled"); + card.innerHTML = ` + ${escapeHtml(g.name)} +
${escapeHtml(g.name)}
+
${g.rarity} ยท scale ${g.scale}
+
+ + +
`; + card.querySelector('[data-act="toggle"]').onclick = async () => { + await api("PATCH", "/ghosts/" + g.id, { enabled: !g.enabled }); + loadGhosts(); + }; + card.querySelector('[data-act="del"]').onclick = async () => { + if (!confirm(`Delete "${g.name}"? This removes the GIF too.`)) return; + await api("DELETE", "/ghosts/" + g.id); + loadGhosts(); loadSets(); + }; + grid.appendChild(card); + } + renderGhostPicker(); +} + +$("g-add").addEventListener("click", async () => { + const file = $("g-file").files[0]; + const name = $("g-name").value.trim(); + if (!name || !file) return alert("Name and a file are required."); + const fd = new FormData(); + fd.append("name", name); + fd.append("rarity", $("g-rarity").value); + fd.append("scale", $("g-scale").value || "1.0"); + fd.append("gif", file); + try { + await api("POST", "/ghosts", fd, true); + $("g-name").value = ""; $("g-file").value = ""; + loadGhosts(); + } catch (e) { alert(e.message); } +}); + +/* ---------- Sets ---------- */ +let pickedGhosts = new Set(); +function renderGhostPicker() { + const picker = $("s-ghost-picker"); + picker.innerHTML = ""; + for (const g of ghostCache) { + const chip = document.createElement("span"); + chip.className = "chip" + (pickedGhosts.has(g.id) ? " on" : ""); + chip.textContent = g.name; + chip.onclick = () => { + if (pickedGhosts.has(g.id)) pickedGhosts.delete(g.id); + else pickedGhosts.add(g.id); + chip.classList.toggle("on"); + }; + picker.appendChild(chip); + } +} + +$("s-add").addEventListener("click", async () => { + const code = $("s-code").value.trim(); + const title = $("s-title").value.trim(); + if (!code || !title) return alert("Code and title are required."); + try { + await api("POST", "/sets", { code, title, ghost_ids: [...pickedGhosts] }); + $("s-code").value = ""; $("s-title").value = ""; + pickedGhosts = new Set(); + renderGhostPicker(); + loadSets(); + } catch (e) { alert(e.message); } +}); + +async function loadSets() { + const sets = await api("GET", "/sets"); + const list = $("set-list"); + list.innerHTML = ""; + for (const s of sets) { + const row = document.createElement("div"); + row.className = "set-row" + (s.enabled ? "" : " disabled"); + row.innerHTML = ` +
+
${escapeHtml(s.title)}
+
${escapeHtml(s.code)}
+
${s.ghost_ids.length} ghost(s) linked
+
+
+ + +
`; + row.querySelector('[data-act="toggle"]').onclick = async () => { + await api("PATCH", "/sets/" + s.id, { enabled: !s.enabled }); + loadSets(); + }; + row.querySelector('[data-act="del"]').onclick = async () => { + if (!confirm(`Delete set "${s.title}"?`)) return; + await api("DELETE", "/sets/" + s.id); + loadSets(); + }; + list.appendChild(row); + } +} + +function escapeHtml(s) { + return String(s).replace(/[&<>"']/g, (c) => + ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c])); +} + +/* ---------- Boot ---------- */ +if (token && me) enterDash();