Add admin panel JS (login, ghost/set CRUD against the API)

This commit is contained in:
2026-06-03 10:00:33 +10:00
parent d3d752c8a2
commit feb2247cd4
+187
View File
@@ -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 = `
<img src="/uploads/${g.gif_file}" alt="${escapeHtml(g.name)}" />
<div class="gname">${escapeHtml(g.name)}</div>
<div class="meta"><span class="rarity ${g.rarity}">${g.rarity}</span> · scale ${g.scale}</div>
<div class="row">
<button data-act="toggle">${g.enabled ? "Disable" : "Enable"}</button>
<button class="danger-btn" data-act="del">Delete</button>
</div>`;
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 = `
<div>
<div class="stitle">${escapeHtml(s.title)}</div>
<div class="code">${escapeHtml(s.code)}</div>
<div class="scount">${s.ghost_ids.length} ghost(s) linked</div>
</div>
<div class="row">
<button data-act="toggle">${s.enabled ? "Disable" : "Enable"}</button>
<button class="danger-btn" data-act="del">Delete</button>
</div>`;
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) =>
({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c]));
}
/* ---------- Boot ---------- */
if (token && me) enterDash();