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