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

View File

@@ -88,6 +88,6 @@
<div id="toast" class="toast hidden"></div> <div id="toast" class="toast hidden"></div>
<script src="app.js"></script> <script type="module" src="app.js"></script>
</body> </body>
</html> </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 }),
};