Modularize frontend: API helper module and cleaner app.js

This commit is contained in:
2026-01-28 17:16:41 +01:00
parent fe8ba137e0
commit d74deb1696
3 changed files with 77 additions and 56 deletions

View File

@@ -1,3 +1,5 @@
import { api, adminApi } from "./js/api.js";
const state = {
me: null,
phase: null,
@@ -5,44 +7,22 @@ const state = {
mySuggestions: [],
allSuggestions: [],
myVotes: [],
results: [],
adminKey: ""
results: []
};
const $ = (id) => document.getElementById(id);
const toastEl = $("toast");
function toast(msg, isError = false) {
if (!toastEl) return;
toastEl.textContent = msg;
toastEl.classList.remove("hidden");
toastEl.classList.toggle("error", isError);
setTimeout(() => toastEl.classList.add("hidden"), 2400);
}
async function api(path, options = {}) {
const res = await fetch(path, {
headers: {
"Content-Type": "application/json",
...(options.adminKey ? { "X-Admin-Key": options.adminKey } : {})
},
...options
});
if (!res.ok) {
let msg = `${res.status}`;
try {
const body = await res.json();
msg = body.error || JSON.stringify(body);
} catch (_) { /* ignore */ }
throw new Error(msg);
}
return res.status === 204 ? null : res.json();
setTimeout(() => toastEl.classList.add("hidden"), 2000);
}
async function loadState() {
const [me, stateData] = await Promise.all([
api("/api/me"),
api("/api/state")
]);
const [me, stateData] = await Promise.all([api.me(), api.state()]);
state.me = me;
state.phase = stateData.currentPhase;
state.counts = stateData;
@@ -57,26 +37,26 @@ async function loadState() {
async function loadSuggestData() {
if (state.phase !== "Suggest") return;
state.mySuggestions = await api("/api/suggestions/mine");
state.mySuggestions = await api.mySuggestions();
renderMySuggestions();
}
async function loadRevealData() {
if (state.phase === "Reveal" || state.phase === "Vote" || state.phase === "Results") {
state.allSuggestions = await api("/api/suggestions/all");
state.allSuggestions = await api.allSuggestions();
renderAllSuggestions();
}
}
async function loadVoteData() {
if (state.phase !== "Vote") return;
state.myVotes = await api("/api/votes/mine");
state.myVotes = await api.myVotes();
renderVotes();
}
async function loadResults() {
if (state.phase !== "Results") return;
state.results = await api("/api/results");
state.results = await api.results();
renderResults();
}
@@ -119,6 +99,7 @@ function renderAllSuggestions() {
function renderVotes() {
const list = $("vote-list");
if (!list) return;
list.innerHTML = "";
const votesMap = Object.fromEntries(state.myVotes.map((v) => [v.suggestionId, v.score]));
state.allSuggestions.forEach((s) => {
@@ -141,7 +122,7 @@ function renderVotes() {
const suggestionId = Number(e.target.dataset.id);
const score = Number(e.target.value);
try {
await api("/api/votes", { method: "POST", body: JSON.stringify({ suggestionId, score }) });
await api.vote(suggestionId, score);
toast("Saved vote");
await loadVoteData();
} catch (err) {
@@ -211,7 +192,7 @@ function setupHandlers() {
const name = nameInput.value.trim();
if (!name) return toast("Name required", true);
try {
const me = await api("/api/me/name", { method: "POST", body: JSON.stringify({ name }) });
const me = await api.setName(name);
state.me = me;
nameInput.dataset.userEditing = "";
toast("Saved name");
@@ -227,7 +208,7 @@ function setupHandlers() {
const data = Object.fromEntries(new FormData(form).entries());
if (!data.name) return toast("Name required", true);
try {
await api("/api/suggestions", { method: "POST", body: JSON.stringify(data) });
await api.createSuggestion(data);
form.reset();
toast("Suggestion added");
await loadSuggestData();
@@ -240,11 +221,7 @@ function setupHandlers() {
const phase = $("phase-select").value;
const adminKey = $("admin-key").value;
try {
await api("/api/admin/phase", {
method: "POST",
body: JSON.stringify({ phase }),
adminKey
});
await adminApi.setPhase(phase, adminKey);
toast("Phase updated");
state.phase = phase;
$("phase-select").dataset.userEditing = "";
@@ -260,8 +237,8 @@ function setupHandlers() {
});
phaseSelect.addEventListener("blur", () => { phaseSelect.dataset.userEditing = ""; });
$("reset").addEventListener("click", () => adminAction("/api/admin/reset", "Reset complete"));
$("factory-reset").addEventListener("click", () => adminAction("/api/admin/factory-reset", "Factory reset complete"));
$("reset").addEventListener("click", () => adminAction(adminApi.reset, "Reset complete"));
$("factory-reset").addEventListener("click", () => adminAction(adminApi.factoryReset, "Factory reset complete"));
const adminToggle = $("admin-toggle");
const adminCard = $("admin-card");
@@ -273,10 +250,10 @@ function setupHandlers() {
}
}
async function adminAction(path, successMessage) {
async function adminAction(fn, successMessage) {
const adminKey = $("admin-key").value;
try {
await api(path, { method: "POST", adminKey });
await fn(adminKey);
toast(successMessage);
await refreshPhaseData();
} catch (err) {
@@ -301,8 +278,8 @@ function buildCard(s, { showAuthor = false, allowDelete = false }) {
<div class="card-body">
<div class="card-title-row">
<h3>${s.name}</h3>
${s.youtubeUrl ? `<a class="link compact" href="${s.youtubeUrl}" target="_blank" rel="noopener">YouTube ↗</a>` : ""}
<div class="title-meta">
${s.youtubeUrl ? `<a class="link compact" href="${s.youtubeUrl}" target="_blank" rel="noopener">YouTube ↗</a>` : ""}
${showAuthor && s.author ? `<span class="chip">${s.author}</span>` : ""}
${allowDelete ? `<button class="chip danger-chip" data-delete="${s.id}" type="button">Delete</button>` : ""}
</div>
@@ -315,18 +292,18 @@ function buildCard(s, { showAuthor = false, allowDelete = false }) {
const btn = card.querySelector(".card-visual");
btn.addEventListener("click", () => openLightbox(s.screenshotUrl, s.name));
}
if (allowDelete) {
const del = card.querySelector("[data-delete]");
del.addEventListener("click", async () => {
try {
await api(`/api/suggestions/${s.id}`, { method: "DELETE" });
toast("Suggestion deleted");
await loadSuggestData();
} catch (err) {
toast(err.message, true);
}
});
}
if (allowDelete) {
const del = card.querySelector("[data-delete]");
del.addEventListener("click", async () => {
try {
await api.deleteSuggestion(s.id);
toast("Suggestion deleted");
await loadSuggestData();
} catch (err) {
toast(err.message, true);
}
});
}
return card;
}

View File

@@ -88,6 +88,6 @@
<div id="toast" class="toast hidden"></div>
<script src="app.js"></script>
<script type="module" src="app.js"></script>
</body>
</html>

44
wwwroot/js/api.js Normal file
View File

@@ -0,0 +1,44 @@
const defaultHeaders = { "Content-Type": "application/json" };
async function request(path, { method = "GET", body, adminKey } = {}) {
const res = await fetch(path, {
method,
headers: {
...defaultHeaders,
...(adminKey ? { "X-Admin-Key": adminKey } : {})
},
body: body ? JSON.stringify(body) : undefined,
});
if (!res.ok) {
let msg = `${res.status}`;
try {
const data = await res.json();
msg = data.error || JSON.stringify(data);
} catch { /* ignore */ }
throw new Error(msg);
}
return res.status === 204 ? null : res.json();
}
export const api = {
state: () => request("/api/state"),
me: () => request("/api/me"),
setName: (name) => request("/api/me/name", { method: "POST", body: { name } }),
mySuggestions: () => request("/api/suggestions/mine"),
createSuggestion: (payload) => request("/api/suggestions", { method: "POST", body: payload }),
deleteSuggestion: (id) => request(`/api/suggestions/${id}`, { method: "DELETE" }),
allSuggestions: () => request("/api/suggestions/all"),
myVotes: () => request("/api/votes/mine"),
vote: (suggestionId, score) => request("/api/votes", { method: "POST", body: { suggestionId, score } }),
results: () => request("/api/results"),
};
export const adminApi = {
setPhase: (phase, adminKey) => request("/api/admin/phase", { method: "POST", body: { phase }, adminKey }),
reset: (adminKey) => request("/api/admin/reset", { method: "POST", adminKey }),
factoryReset: (adminKey) => request("/api/admin/factory-reset", { method: "POST", adminKey }),
};