Add admin panel JS (login, ghost/set CRUD against the API)
This commit is contained in:
@@ -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) =>
|
||||
({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c]));
|
||||
}
|
||||
|
||||
/* ---------- Boot ---------- */
|
||||
if (token && me) enterDash();
|
||||
Reference in New Issue
Block a user