/* 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();