Files
GameList/wwwroot/app.js

610 lines
20 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { api, adminApi } from "./js/api.js";
const state = {
isAuthenticated: false,
authMode: "login",
me: null,
phase: null,
prevPhase: null,
counts: null,
mySuggestions: [],
allSuggestions: [],
myVotes: [],
results: [],
votesRendered: false
};
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"), 2000);
}
const getSavedUsername = () => localStorage.getItem("last_username") || "";
const setSavedUsername = (name) => localStorage.setItem("last_username", name);
function setAuthUI(isAuthed) {
const main = document.querySelector("main");
const statusBar = document.querySelector(".status-bar");
const authCard = $("auth-card");
[main, statusBar].forEach(el => el?.classList.toggle("hidden", !isAuthed));
if (authCard) authCard.classList.toggle("hidden", isAuthed);
const adminToggle = $("admin-toggle");
if (adminToggle) adminToggle.classList.toggle("hidden", !isAuthed || !state.me?.isAdmin);
if (!isAuthed) {
const adminCard = $("admin-card");
if (adminCard) adminCard.classList.add("hidden");
const loginUser = $("login-username");
const cachedUser = getSavedUsername();
if (loginUser && cachedUser) loginUser.value = cachedUser;
}
}
function setAuthMode(mode) {
state.authMode = mode;
document.querySelectorAll(".auth-form").forEach(form => {
form.classList.toggle("hidden", form.dataset.mode !== mode);
});
document.querySelectorAll("[data-auth-tab]").forEach(btn => {
btn.classList.toggle("active", btn.dataset.authTab === mode);
});
}
function clearUserState() {
state.me = null;
state.phase = null;
state.prevPhase = null;
state.counts = null;
state.mySuggestions = [];
state.allSuggestions = [];
state.myVotes = [];
state.results = [];
state.votesRendered = false;
const adminCard = $("admin-card");
if (adminCard) adminCard.classList.add("hidden");
}
function handleAuthError(err) {
if (err?.status === 401) {
clearUserState();
state.isAuthenticated = false;
setAuthUI(false);
return true;
}
toast(err?.message || "Unexpected error", true);
return false;
}
async function loadState() {
const [me, stateData] = await Promise.all([api.me(), api.state()]);
state.isAuthenticated = true;
state.me = me;
state.prevPhase = state.phase;
state.phase = stateData.currentPhase;
state.counts = stateData;
if (state.prevPhase !== state.phase && state.phase === "Vote") {
state.votesRendered = false;
}
setAuthUI(true);
renderWelcome();
renderPhasePill();
renderCounts();
}
async function loadSuggestData() {
if (state.phase !== "Suggest") return;
state.mySuggestions = await api.mySuggestions();
renderMySuggestions();
}
async function loadRevealData() {
if (state.phase === "Reveal" || state.phase === "Vote" || state.phase === "Results") {
state.allSuggestions = await api.allSuggestions();
renderAllSuggestions();
}
}
async function loadVoteData() {
if (state.phase !== "Vote") return;
const votes = await api.myVotes();
state.myVotes = votes;
if (!state.votesRendered) {
renderVotes();
state.votesRendered = true;
} else {
syncVoteScores();
}
}
async function loadResults() {
if (state.phase !== "Results") return;
state.results = await api.results();
renderResults();
}
function renderPhasePill() {
$("phase-pill").textContent = state.phase || "Loading…";
document.querySelectorAll(".phase-view").forEach((el) => el.classList.add("hidden"));
const viewMap = {
Suggest: "suggest-view",
Reveal: "reveal-view",
Vote: "vote-view",
Results: "results-view"
};
const id = viewMap[state.phase];
if (id) $(id).classList.remove("hidden");
const phaseSelect = $("phase-select");
if (phaseSelect && !phaseSelect.dataset.userEditing) {
phaseSelect.value = state.phase || "Suggest";
}
}
function renderCounts() {
if (!state.counts) return;
$("counts").textContent = `Players: ${state.counts.players} • Suggestions: ${state.counts.suggestions} • Votes: ${state.counts.votes}`;
}
function renderWelcome() {
const el = $("welcome-text");
if (!el) return;
const name = state.me?.displayName?.trim() || state.me?.username || "Player";
el.textContent = `Welcome, ${name}!`;
}
function renderMySuggestions() {
const wrap = $("my-suggestions");
if (!wrap) return;
wrap.innerHTML = "";
const allowEdit = state.phase === "Suggest" || state.me?.isAdmin;
state.mySuggestions.forEach((s) => wrap.appendChild(buildCard(s, { showAuthor: false, allowDelete: true, allowEdit })));
}
function renderAllSuggestions() {
const list = $("all-suggestions");
if (!list) return;
list.innerHTML = "";
const allowEdit = !!state.me?.isAdmin;
const allowDelete = !!state.me?.isAdmin && (state.phase === "Reveal" || state.phase === "Suggest");
state.allSuggestions.forEach((s) => list.appendChild(buildCard(s, { showAuthor: true, allowEdit, allowDelete })));
}
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) => {
const li = buildCard(s, { showAuthor: true, allowEdit: !!state.me?.isAdmin });
const current = votesMap[s.id] ?? 0;
const footer = document.createElement("div");
footer.className = "vote-controls";
footer.innerHTML = `
<input class="full-slider" type="range" min="0" max="10" value="${current}" data-id="${s.id}">
<span class="score" id="score-${s.id}">${current}</span>
<span class="score-emoji" id="emoji-${s.id}">${scoreToEmoji(current)}</span>`;
li.querySelector(".card-body").appendChild(footer);
list.appendChild(li);
});
list.querySelectorAll("input[type=range]").forEach((input) => {
input.addEventListener("input", (e) => {
const val = Number(e.target.value);
$("score-" + e.target.dataset.id).textContent = val;
const emojiEl = $("emoji-" + e.target.dataset.id);
if (emojiEl) emojiEl.textContent = scoreToEmoji(val);
});
input.addEventListener("change", async (e) => {
const suggestionId = Number(e.target.dataset.id);
const score = Number(e.target.value);
try {
await api.vote(suggestionId, score);
toast("Saved vote");
await loadVoteData();
} catch (err) {
toast(err.message, true);
}
});
});
}
function syncVoteScores() {
const votesMap = Object.fromEntries(state.myVotes.map((v) => [v.suggestionId, v.score]));
Object.entries(votesMap).forEach(([id, score]) => {
const slider = document.querySelector(`input[type=range][data-id="${id}"]`);
const scoreLabel = $("score-" + id);
const emoji = $("emoji-" + id);
if (slider && score != null) {
slider.value = score;
if (scoreLabel) scoreLabel.textContent = score;
if (emoji) emoji.textContent = scoreToEmoji(score);
}
});
}
function renderResults() {
const container = $("results-list");
container.innerHTML = "";
const table = document.createElement("table");
table.className = "results-table";
table.innerHTML = `
<thead>
<tr>
<th>Rank</th>
<th>Game</th>
<th>Author</th>
<th>Votes</th>
<th>Avg</th>
<th>Total</th>
<th>Links</th>
</tr>
</thead>
<tbody></tbody>
`;
const tbody = table.querySelector("tbody");
state.results.forEach((r, idx) => {
const row = document.createElement("tr");
row.innerHTML = `
<td>${idx + 1}</td>
<td class="game-cell">
${r.screenshotUrl ? `<img class="thumb clickable-thumb" src="${r.screenshotUrl}" alt="${r.name}">` : ''}
<div class="game-meta">
<div class="title-line">${r.name}</div>
${r.genre ? `<div class="muted small">${r.genre}</div>` : ''}
</div>
</td>
<td>${r.author ?? "—"}</td>
<td>${r.count}</td>
<td>${r.average.toFixed(1)}</td>
<td>${r.total}</td>
<td>
${r.gameUrl ? `<a class="link compact" href="${r.gameUrl}" target="_blank" rel="noopener">Site ↗</a><br>` : ''}
${r.youtubeUrl ? `<a class="link compact" href="${r.youtubeUrl}" target="_blank" rel="noopener">YouTube ↗</a>` : ''}
</td>
`;
tbody.appendChild(row);
});
container.appendChild(table);
container.querySelectorAll(".clickable-thumb").forEach(img => {
img.addEventListener("click", () => openLightbox(img.src, img.alt));
});
}
function setupHandlers() {
document.querySelectorAll("[data-auth-tab]").forEach(btn => {
btn.addEventListener("click", () => setAuthMode(btn.dataset.authTab));
});
setAuthMode(state.authMode);
const loginForm = $("login-form");
if (loginForm) {
loginForm.addEventListener("submit", async (e) => {
e.preventDefault();
const username = $("login-username").value.trim();
const password = $("login-password").value;
if (!username || !password) return toast("Username and password required", true);
try {
await api.login({ username, password });
setSavedUsername(username);
state.isAuthenticated = true;
setAuthUI(true);
await refreshPhaseData();
toast("Logged in");
} catch (err) {
if (err?.status === 401) return toast("Invalid username or password", true);
if (handleAuthError(err)) return;
}
});
}
const registerForm = $("register-form");
if (registerForm) {
registerForm.addEventListener("submit", async (e) => {
e.preventDefault();
const username = $("register-username").value.trim();
const password = $("register-password").value;
const displayName = $("register-displayName").value.trim();
const adminKey = $("register-adminkey").value.trim();
if (!username || !password) return toast("Username and password required", true);
try {
await api.register({ username, password, displayName, adminKey });
setSavedUsername(username);
state.isAuthenticated = true;
setAuthUI(true);
await refreshPhaseData();
toast("Registered");
} catch (err) {
if (handleAuthError(err)) return;
toast(err.message, true);
}
});
}
$("suggest-form").addEventListener("submit", async (e) => {
e.preventDefault();
const form = e.target;
const data = normalizeSuggestionForm(new FormData(form));
if (!data.name) return toast("Name required", true);
if (data.screenshotUrl && !isValidImageUrl(data.screenshotUrl)) {
return toast("Screenshot URL must be http(s) and end with an image file.", true);
}
try {
await api.createSuggestion(data);
form.reset();
toast("Suggestion added");
await loadSuggestData();
} catch (err) {
toast(err.message, true);
}
});
$("set-phase").addEventListener("click", async () => {
const phase = $("phase-select").value;
try {
await adminApi.setPhase(phase);
toast("Phase updated");
state.prevPhase = state.phase;
state.phase = phase;
state.votesRendered = false;
renderPhasePill();
$("phase-select").dataset.userEditing = "";
await refreshPhaseData();
} catch (err) {
toast(err.message, true);
}
});
const phaseSelect = $("phase-select");
["focus", "input", "click"].forEach(evt => {
phaseSelect.addEventListener(evt, () => { phaseSelect.dataset.userEditing = "1"; });
});
phaseSelect.addEventListener("blur", () => { phaseSelect.dataset.userEditing = ""; });
$("reset").addEventListener("click", () => adminAction(adminApi.reset, "Reset complete"));
$("factory-reset").addEventListener("click", () => adminAction(adminApi.factoryReset, "Factory reset complete"));
const logoutBtn = $("logout");
if (logoutBtn) {
logoutBtn.addEventListener("click", async (e) => {
e.preventDefault();
const lastUser = state.me?.username;
try {
await api.logout();
} catch (err) {
toast(err.message, true);
}
clearUserState();
state.isAuthenticated = false;
setAuthUI(false);
if (lastUser) {
setSavedUsername(lastUser);
const loginUser = $("login-username");
if (loginUser) loginUser.value = lastUser;
const loginPass = $("login-password");
if (loginPass) loginPass.value = "";
}
});
}
const adminToggle = $("admin-toggle");
const adminCard = $("admin-card");
const adminClose = $("admin-close");
if (adminToggle && adminCard && adminClose) {
const togglePanel = (show) => adminCard.classList.toggle("hidden", !show);
adminToggle.addEventListener("click", () => togglePanel(adminCard.classList.contains("hidden")));
adminClose.addEventListener("click", () => togglePanel(false));
}
}
async function adminAction(fn, successMessage) {
try {
await fn();
toast(successMessage);
await refreshPhaseData();
} catch (err) {
toast(err.message, true);
}
}
async function refreshPhaseData() {
try {
await loadState();
await Promise.all([loadSuggestData(), loadRevealData(), loadResults()]);
if (state.phase === "Vote") {
if (!state.votesRendered) {
await loadVoteData();
}
} else {
state.votesRendered = false;
await loadVoteData();
}
} catch (err) {
if (handleAuthError(err)) return;
throw err;
}
}
function buildCard(s, { showAuthor = false, allowDelete = false, allowEdit = false }) {
const card = document.createElement("article");
card.className = "game-card";
const hasImage = !!s.screenshotUrl;
const visual = hasImage
? `<button class="card-visual" data-img="${s.screenshotUrl}" aria-label="Open screenshot" style="background-image:url('${s.screenshotUrl}')"></button>`
: `<div class="card-visual"></div>`;
card.innerHTML = `
${visual}
<div class="card-body">
<div class="card-title-row">
<h3>${s.name}</h3>
<div class="title-meta">
${s.gameUrl ? `<a class="link compact" href="${s.gameUrl}" target="_blank" rel="noopener">Site ↗</a>` : ""}
${s.youtubeUrl ? `<a class="link compact" href="${s.youtubeUrl}" target="_blank" rel="noopener">YouTube ↗</a>` : ""}
${showAuthor && s.author ? `<span class="chip">${s.author}</span>` : ""}
${allowEdit ? `<button class="chip" data-edit="${s.id}" type="button">Edit</button>` : ""}
${allowDelete ? `<button class="chip danger-chip" data-delete="${s.id}" type="button">Delete</button>` : ""}
</div>
</div>
${s.genre ? `<p class="muted">${s.genre}</p>` : ""}
${s.description ? `<p>${s.description}</p>` : ""}
${(s.minPlayers || s.maxPlayers) ? `<p class="muted">Players: ${s.minPlayers ?? "?"}${s.maxPlayers ?? "?"}</p>` : ""}
</div>
`;
if (hasImage) {
const btn = card.querySelector(".card-visual");
btn.addEventListener("click", () => openLightbox(s.screenshotUrl, s.name));
}
if (allowEdit) {
const editBtn = card.querySelector("[data-edit]");
editBtn?.addEventListener("click", () => openEditModal(s));
}
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;
}
function openEditModal(s) {
const overlay = document.createElement("div");
overlay.className = "edit-modal";
overlay.innerHTML = `
<div class="edit-panel">
<div class="edit-header">
<h3>Edit game</h3>
<button class="lightbox-close" aria-label="Close">×</button>
</div>
<div class="edit-body">
<form class="stack" id="edit-form">
<input name="name" required maxlength="100" placeholder="Game name *" value="${s.name ?? ""}" />
<input name="genre" maxlength="50" placeholder="Genre" value="${s.genre ?? ""}" />
<textarea name="description" maxlength="500" placeholder="Short description">${s.description ?? ""}</textarea>
<div class="stack horizontal">
<input name="screenshotUrl" maxlength="2048" placeholder="Screenshot URL" value="${s.screenshotUrl ?? ""}" />
<input name="youtubeUrl" maxlength="2048" placeholder="YouTube URL" value="${s.youtubeUrl ?? ""}" />
<input name="gameUrl" maxlength="2048" placeholder="Game website URL" value="${s.gameUrl ?? ""}" />
</div>
<div class="stack horizontal">
<button type="submit">Save changes</button>
<button type="button" class="ghost" id="edit-cancel">Cancel</button>
</div>
</form>
</div>
</div>
`;
const close = () => overlay.remove();
overlay.addEventListener("click", (e) => {
if (e.target.classList.contains("edit-modal") || e.target.classList.contains("lightbox-close")) close();
});
const cancelBtn = overlay.querySelector("#edit-cancel");
cancelBtn?.addEventListener("click", close);
const form = overlay.querySelector("#edit-form");
form?.addEventListener("submit", async (e) => {
e.preventDefault();
const data = normalizeSuggestionForm(new FormData(form));
if (data.screenshotUrl && !isValidImageUrl(data.screenshotUrl)) {
return toast("Screenshot URL must be http(s) and end with an image file.", true);
}
if (!data.name?.trim()) return toast("Name required", true);
try {
await api.updateSuggestion(s.id, data);
toast("Saved changes");
close();
await refreshPhaseData();
} catch (err) {
if (handleAuthError(err)) return;
toast(err.message, true);
}
});
document.body.appendChild(overlay);
}
function openLightbox(url, title) {
const overlay = document.createElement("div");
overlay.className = "lightbox";
overlay.innerHTML = `
<div class="lightbox-content">
<button class="lightbox-close" aria-label="Close">✕</button>
<img src="${url}" alt="${title}" />
<p>${title || ""}</p>
</div>
`;
overlay.addEventListener("click", (e) => {
if (e.target.classList.contains("lightbox") || e.target.classList.contains("lightbox-close")) {
overlay.remove();
}
});
document.body.appendChild(overlay);
}
async function main() {
setupHandlers();
try {
await refreshPhaseData();
} catch (err) {
toast(err.message, true);
}
setInterval(() => {
refreshPhaseData().catch(err => {
if (!handleAuthError(err)) toast(err.message, true);
});
}, 4000);
}
main();
function isValidImageUrl(url) {
if (!url) return true;
try {
const u = new URL(url);
const allowed = ["http:", "https:"];
if (!allowed.includes(u.protocol)) return false;
const path = u.pathname.toLowerCase();
return [".png", ".jpg", ".jpeg", ".gif", ".webp", ".avif"].some(ext => path.endsWith(ext));
} catch {
return false;
}
}
function normalizeSuggestionForm(formData) {
const obj = Object.fromEntries(formData.entries());
const parseNum = (v) => {
if (v === undefined || v === null || v === "") return null;
const n = Number(v);
return Number.isFinite(n) ? n : null;
};
return {
name: obj.name?.trim(),
genre: obj.genre?.trim() || null,
description: obj.description?.trim() || null,
screenshotUrl: obj.screenshotUrl?.trim() || null,
youtubeUrl: obj.youtubeUrl?.trim() || null,
gameUrl: obj.gameUrl?.trim() || null,
minPlayers: parseNum(obj.minPlayers),
maxPlayers: parseNum(obj.maxPlayers),
};
}
function scoreToEmoji(score) {
if (score <= 1) return "😡";
if (score <= 3) return "😠";
if (score <= 5) return "😐";
if (score <= 7) return "🙂";
if (score <= 8) return "😃";
return "🤩";
}