diff --git a/wwwroot/app.js b/wwwroot/app.js
index a6ecac9..06a6121 100644
--- a/wwwroot/app.js
+++ b/wwwroot/app.js
@@ -1,1070 +1,239 @@
import { api, adminApi } from "./js/api.js";
+import { t, setLanguage, getLanguage, initI18n, onLanguageChange } from "./js/i18n.js";
+import { state, clearUserState, getSavedUsername, setSavedUsername } from "./js/state.js";
+import { $, toast } from "./js/dom.js";
+import { setupBackgroundPan, triggerCelebration } from "./js/effects.js";
import {
- t,
- setLanguage,
- getLanguage,
- initI18n,
- onLanguageChange,
-} from "./js/i18n.js";
+ setAuthUI,
+ setAuthMode,
+ handleAuthError,
+ renderWelcome,
+ renderPhasePill,
+ renderCounts,
+ renderMySuggestions,
+ renderAllSuggestions,
+ renderVotes,
+ syncVoteScores,
+ renderResults,
+ renderPhaseTitles,
+ normalizeSuggestionForm,
+} from "./js/ui.js";
+import {
+ loadState,
+ loadSuggestData,
+ loadRevealData,
+ loadVoteData,
+ loadResults,
+ refreshPhaseData,
+} from "./js/data.js";
initI18n();
-setupBackgroundPan();
+setupBackgroundPan({ maxOffsetPx: 30, ease: 0.08, scaleFactor: 1.12 });
-const state = {
- isAuthenticated: false,
- authMode: "login",
- me: null,
- phase: null,
- prevPhase: null,
- counts: null,
- mySuggestions: [],
- allSuggestions: [],
- allSuggestionsSig: null,
- 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.dataset.userEditing &&
- !loginUser.value
- ) {
- loginUser.value = cachedUser;
- }
- }
-}
-
-function setAuthMode(mode) {
- state.authMode = mode;
- document.querySelectorAll(".auth-form").forEach((form) => {
- form.classList.toggle("hidden", form.dataset.mode !== mode);
+function setupHandlers() {
+ const toggleAuth = $("auth-toggle");
+ if (toggleAuth) {
+ toggleAuth.addEventListener("click", (e) => {
+ e.preventDefault();
+ setAuthMode(state.authMode === "login" ? "register" : "login");
});
- const title = $("auth-title");
- const toggleBtn = $("auth-toggle");
- if (title) {
- title.textContent =
- mode === "login"
- ? t("auth.loginHeading")
- : t("auth.registerHeading");
- }
- if (toggleBtn) {
- toggleBtn.textContent =
- mode === "login"
- ? t("auth.switchToRegister")
- : t("auth.switchToLogin");
- }
-}
+ }
+ setAuthMode(state.authMode);
-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");
-}
+ const loginUser = $("login-username");
+ if (loginUser) {
+ const markEditing = () => { loginUser.dataset.userEditing = "1"; };
+ ["focus", "input", "keydown"].forEach((evt) => loginUser.addEventListener(evt, markEditing));
+ loginUser.addEventListener("blur", () => { delete loginUser.dataset.userEditing; });
+ }
-function handleAuthError(err) {
- if (err?.status === 401) {
- clearUserState();
- state.isAuthenticated = false;
- setAuthUI(false);
- return true;
- }
- toast(err?.message || t("toast.unexpected"), true);
- return false;
-}
+ const langSelects = Array.from(document.querySelectorAll(".lang-select"));
+ const syncLanguageSelects = () => langSelects.forEach((sel) => (sel.value = getLanguage()));
+ syncLanguageSelects();
+ langSelects.forEach((sel) => sel.addEventListener("change", () => setLanguage(sel.value)));
-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);
+ onLanguageChange(() => {
+ syncLanguageSelects();
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"
- ) {
- const latest = await api.allSuggestions();
- const latestSig = signatureSuggestions(latest);
- const changed = latestSig !== state.allSuggestionsSig;
- state.allSuggestions = latest;
- state.allSuggestionsSig = latestSig;
- renderAllSuggestions();
- renderPhaseTitles();
- if (state.phase === "Vote" && changed) {
- state.votesRendered = false;
- }
- }
-}
-
-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() {
- const phaseKey =
- typeof state.phase === "string" ? state.phase.toLowerCase() : null;
- $("phase-pill").textContent = phaseKey ? "" : t("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 = t("counts.format", {
- 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 ||
- t("auth.defaultName");
- el.textContent = t("auth.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 }),
- ),
- );
renderPhaseTitles();
-}
-
-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 hasVote = Object.prototype.hasOwnProperty.call(votesMap, s.id);
- const current = hasVote ? votesMap[s.id] : 5; // start neutral when no prior vote
- const displayScore = hasVote ? current : "—";
- const displayEmoji = hasVote ? scoreToEmoji(current) : neutralEmoji();
- const footer = document.createElement("div");
- footer.className = "vote-controls";
- footer.innerHTML = `
-
- ${displayScore}
- ${displayEmoji}`;
- 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(t("vote.saved"));
- 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);
- }
- });
- document
- .querySelectorAll("input[type=range][data-id]")
- .forEach((slider) => {
- const id = slider.dataset.id;
- if (Object.prototype.hasOwnProperty.call(votesMap, Number(id)))
- return;
- const scoreLabel = $("score-" + id);
- const emoji = $("emoji-" + id);
- if (scoreLabel) scoreLabel.textContent = "—";
- if (emoji) emoji.textContent = neutralEmoji();
- });
-}
-
-function renderResults() {
- const container = $("results-list");
- container.innerHTML = "";
- const table = document.createElement("table");
- table.className = "results-table";
- table.innerHTML = `
-
-
- | ${t("results.rank")} |
- ${t("results.game")} |
- ${t("results.author")} |
- ${t("results.votes")} |
- ${t("results.avg")} |
- ${t("results.total")} |
- ${t("results.links")} |
-
-
-
- `;
- const tbody = table.querySelector("tbody");
- state.results.forEach((r, idx) => {
- const row = document.createElement("tr");
- row.innerHTML = `
- ${idx + 1} |
-
- ${r.screenshotUrl ? ` ` : ""}
-
- |
- ${r.author ?? "—"} |
- ${r.count} |
- ${r.average.toFixed(1)} |
- ${r.total} |
-
- ${r.gameUrl ? `${t("results.link.site")} ` : ""}
- ${r.youtubeUrl ? `${t("results.link.youtube")}` : ""}
- |
- `;
- tbody.appendChild(row);
- });
- const frame = document.createElement("div");
- frame.className = "results-frame";
- frame.appendChild(table);
- container.appendChild(frame);
- container.querySelectorAll(".clickable-thumb").forEach((img) => {
- img.addEventListener("click", () => openLightbox(img.src, img.alt));
- });
-}
-
-function renderPhaseTitles() {
- const revealTitle = $("reveal-title");
- const voteTitle = $("vote-title");
- const totalGames = state.allSuggestions?.length ?? 0;
- if (revealTitle) {
- revealTitle.textContent =
- totalGames > 0
- ? t("section.allSuggestions.count", { count: totalGames })
- : t("section.allSuggestions");
+ renderMySuggestions();
+ renderAllSuggestions();
+ if (state.phase === "Vote") {
+ renderVotes();
+ state.votesRendered = true;
+ syncVoteScores();
}
- if (voteTitle) {
- voteTitle.textContent =
- totalGames > 0
- ? t("section.vote.count", { count: totalGames })
- : t("section.vote");
+ if (state.phase === "Results") {
+ renderResults();
}
-}
+ });
-function setupHandlers() {
- const toggleAuth = $("auth-toggle");
- if (toggleAuth) {
- toggleAuth.addEventListener("click", (e) => {
- e.preventDefault();
- setAuthMode(state.authMode === "login" ? "register" : "login");
- });
- }
- setAuthMode(state.authMode);
-
- const loginUser = $("login-username");
- if (loginUser) {
- const markEditing = () => {
- loginUser.dataset.userEditing = "1";
- };
- ["focus", "input", "keydown"].forEach((evt) =>
- loginUser.addEventListener(evt, markEditing),
- );
- loginUser.addEventListener("blur", () => {
- delete loginUser.dataset.userEditing;
- });
- }
-
- const langSelects = Array.from(document.querySelectorAll(".lang-select"));
- const syncLanguageSelects = () =>
- langSelects.forEach((sel) => (sel.value = getLanguage()));
- syncLanguageSelects();
- langSelects.forEach((sel) =>
- sel.addEventListener("change", () => setLanguage(sel.value)),
- );
-
- onLanguageChange(() => {
- syncLanguageSelects();
- renderWelcome();
- renderPhasePill();
- renderCounts();
- renderPhaseTitles();
- renderMySuggestions();
- renderAllSuggestions();
- if (state.phase === "Vote") {
- renderVotes();
- state.votesRendered = true;
- syncVoteScores();
- }
- if (state.phase === "Results") {
- renderResults();
- }
+ 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.length > 24) return toast("Username must be 24 characters or fewer.", true);
+ if (!username || !password) return toast(t("auth.needCredentials"), true);
+ try {
+ await api.login({ username, password });
+ setSavedUsername(username);
+ state.isAuthenticated = true;
+ setAuthUI(true);
+ await refreshPhaseData();
+ toast(t("toast.loggedIn"));
+ } catch (err) {
+ if (err?.status === 401) return toast(t("auth.invalidCredentials"), true);
+ if (handleAuthError(err, clearUserState)) return;
+ }
});
+ }
- 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.length > 24)
- return toast("Username must be 24 characters or fewer.", true);
- if (!username || !password)
- return toast(t("auth.needCredentials"), true);
- try {
- await api.login({ username, password });
- setSavedUsername(username);
- state.isAuthenticated = true;
- setAuthUI(true);
- await refreshPhaseData();
- toast(t("toast.loggedIn"));
- } catch (err) {
- if (err?.status === 401)
- return toast(t("auth.invalidCredentials"), 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 (!displayName)
- return toast(
- t("toast.displayNameRequired") ||
- "Display name is required.",
- true,
- );
- if (username.length > 24)
- return toast("Username must be 24 characters or fewer.", true);
- if (displayName.length > 16)
- return toast(
- "Display name must be 16 characters or fewer.",
- true,
- );
- if (!username || !password)
- return toast(t("auth.needCredentials"), true);
- try {
- await api.register({
- username,
- password,
- displayName,
- adminKey,
- });
- setSavedUsername(username);
- state.isAuthenticated = true;
- setAuthUI(true);
- await refreshPhaseData();
- toast(t("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(t("toast.nameRequired"), true);
- if (data.screenshotUrl && !isValidImageUrl(data.screenshotUrl)) {
- return toast(t("toast.invalidImageUrl"), true);
- }
- try {
- await api.createSuggestion(data);
- form.reset();
- toast(t("toast.suggestionAdded"));
- triggerCelebration(form.querySelector("button[type=submit]"));
- await loadSuggestData();
- } catch (err) {
+ 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 (!displayName) return toast(t("toast.displayNameRequired") || "Display name is required.", true);
+ if (username.length > 24) return toast("Username must be 24 characters or fewer.", true);
+ if (displayName.length > 16) return toast("Display name must be 16 characters or fewer.", true);
+ if (!username || !password) return toast(t("auth.needCredentials"), true);
+ try {
+ await api.register({ username, password, displayName, adminKey });
+ setSavedUsername(username);
+ state.isAuthenticated = true;
+ setAuthUI(true);
+ await refreshPhaseData();
+ toast(t("toast.registered"));
+ } catch (err) {
+ if (handleAuthError(err, clearUserState)) return;
toast(err.message, true);
- }
-});
-
- $("set-phase").addEventListener("click", async () => {
- const phase = $("phase-select").value;
- try {
- await adminApi.setPhase(phase);
- toast(t("admin.phaseUpdated"));
- 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, t("admin.resetDone")),
- );
- $("factory-reset").addEventListener("click", () =>
- adminAction(adminApi.factoryReset, t("admin.factoryResetDone")),
- );
-
- 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 = "";
- }
- });
+ $("suggest-form").addEventListener("submit", async (e) => {
+ e.preventDefault();
+ const form = e.target;
+ const data = normalizeSuggestionForm(new FormData(form));
+ if (!data.name) return toast(t("toast.nameRequired"), true);
+ if (data.screenshotUrl && !isValidImageUrl(data.screenshotUrl)) {
+ return toast(t("toast.invalidImageUrl"), true);
}
-
- 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));
+ try {
+ await api.createSuggestion(data);
+ form.reset();
+ toast(t("toast.suggestionAdded"));
+ triggerCelebration(form.querySelector("button[type=submit]"));
+ await loadSuggestData();
+ } catch (err) {
+ toast(err.message, true);
}
+ });
+
+ $("set-phase").addEventListener("click", async () => {
+ const phase = $("phase-select").value;
+ try {
+ await adminApi.setPhase(phase);
+ toast(t("admin.phaseUpdated"));
+ 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, t("admin.resetDone")));
+ $("factory-reset").addEventListener("click", () => adminAction(adminApi.factoryReset, t("admin.factoryResetDone")));
+
+ 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);
- }
+ 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
- ? ``
- : ``;
- const hasPlayers = s.minPlayers || s.maxPlayers;
- const players = hasPlayers
- ? `${t("card.players", { min: s.minPlayers ?? "?", max: s.maxPlayers ?? "?" })}`
- : "";
- const genreAndPlayers = s.genre
- ? hasPlayers
- ? `${s.genre} • ${players}`
- : s.genre
- : hasPlayers
- ? players
- : undefined;
- const hasExtraInfo = genreAndPlayers || s.gameUrl || s.youtubeUrl;
- card.innerHTML = `
- ${visual}
-
-
-
${s.name}
-
- ${showAuthor && s.author ? `${s.author}` : ""}
- ${allowEdit ? `` : ""}
- ${allowDelete ? `` : ""}
-
-
- ${hasExtraInfo ? `
` : ""}
- ${genreAndPlayers ? genreAndPlayers : ""}
- ${s.gameUrl ? `${t("card.site")}` : ""}
- ${s.youtubeUrl ? `${t("card.youtube")}` : ""}
- ${hasExtraInfo ? `
` : ""}
- ${s.description ? `
${s.description}
` : ""}
-
- `;
- if (hasImage) {
- const btn = card.querySelector(".card-visual");
- setupCardVisualHover(btn, s.screenshotUrl);
- 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(t("toast.suggestionDeleted"));
- await loadSuggestData();
- } catch (err) {
- toast(err.message, true);
- }
- });
- }
- return card;
-}
-
-function openEditModal(s) {
- const overlay = document.createElement("div");
- overlay.className = "edit-modal";
- overlay.innerHTML = `
-
- `;
-
- 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(t("toast.invalidImageUrl"), true);
- }
- if (!data.name?.trim()) return toast(t("toast.nameRequired"), true);
- try {
- await api.updateSuggestion(s.id, data);
- toast(t("toast.savedChanges"));
- 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 = `
-
-
-

-
${title || ""}
-
- `;
- overlay.addEventListener("click", (e) => {
- if (
- e.target.classList.contains("lightbox") ||
- e.target.classList.contains("lightbox-close")
- ) {
- overlay.remove();
- }
- });
- document.body.appendChild(overlay);
-}
-
-function setupCardVisualHover(el, url) {
- if (!el || !url) return;
- const img = new Image();
- let naturalW = 0;
- let naturalH = 0;
- let loaded = false;
- img.src = url;
- img.onload = () => {
- naturalW = img.naturalWidth;
- naturalH = img.naturalHeight;
- loaded = true;
- };
-
- const reset = () => {
- el.classList.remove("hovering");
- el.style.backgroundSize = "";
- el.style.backgroundPosition = "";
- el.style.backgroundRepeat = "";
- };
-
- el.addEventListener("mouseenter", () => {
- el.classList.add("hovering");
- el.style.backgroundSize = "auto";
- el.style.backgroundRepeat = "no-repeat";
- el.style.backgroundPosition = "center";
- });
-
- el.addEventListener("mousemove", (e) => {
- if (!loaded) return;
- const rect = el.getBoundingClientRect();
- const overW = naturalW - rect.width;
- const overH = naturalH - rect.height;
- if (overW <= 0 && overH <= 0) {
- el.style.backgroundPosition = "center";
- return;
- }
- const xRatio = (e.clientX - rect.left) / rect.width;
- const yRatio = (e.clientY - rect.top) / rect.height;
- const xPercent = overW > 0 ? xRatio * 100 : 50;
- const yPercent = overH > 0 ? yRatio * 100 : 50;
- el.style.backgroundPosition = `${xPercent}% ${yPercent}%`;
- });
-
- ["mouseleave", "blur"].forEach((evt) => el.addEventListener(evt, reset));
-}
-
-function setupBackgroundPan(config = {}) {
- const root = document.documentElement;
- const maxOffset = config.maxOffsetPx ?? 5;
- const ease = config.ease ?? 0.03;
- const scaleFactor = config.scaleFactor ?? 1.02;
- if (scaleFactor) {
- root.style.setProperty("--bg-scale", scaleFactor);
- }
- let targetX = 0;
- let targetY = 0;
- let currX = 0;
- let currY = 0;
-
- const setTarget = (x, y) => {
- targetX = x;
- targetY = y;
- };
-
- const step = () => {
- currX += (targetX - currX) * ease;
- currY += (targetY - currY) * ease;
- root.style.setProperty("--bg-x", `${currX}px`);
- root.style.setProperty("--bg-y", `${currY}px`);
- requestAnimationFrame(step);
- };
-
- window.addEventListener("mousemove", (e) => {
- const nx = (e.clientX / window.innerWidth - 0.5) * 2;
- const ny = (e.clientY / window.innerHeight - 0.5) * 2;
- setTarget(-nx * maxOffset, -ny * maxOffset);
- });
-
- window.addEventListener("mouseleave", () => setTarget(0, 0));
- window.addEventListener("blur", () => setTarget(0, 0));
- step();
-}
-
-// Celebration FX -----------------------------------------------------
-let fxCanvas;
-let fxCtx;
-let fxParticles = [];
-let fxAnimating = false;
-
-function ensureFxCanvas() {
- if (fxCanvas) return;
- fxCanvas = document.createElement("canvas");
- fxCanvas.className = "fx-canvas";
- fxCanvas.style.position = "fixed";
- fxCanvas.style.inset = "0";
- fxCanvas.style.pointerEvents = "none";
- fxCanvas.style.zIndex = "120";
- fxCanvas.width = window.innerWidth;
- fxCanvas.height = window.innerHeight;
- fxCtx = fxCanvas.getContext("2d");
- document.body.appendChild(fxCanvas);
- window.addEventListener("resize", () => {
- fxCanvas.width = window.innerWidth;
- fxCanvas.height = window.innerHeight;
- });
-}
-
-function triggerCelebration(button) {
- ensureFxCanvas();
- const rect = (button || document.body).getBoundingClientRect();
- const x = rect.left + rect.width / 2;
- const y = rect.top + rect.height / 2;
- spawnConfetti(x, y, 80);
- spawnFirework(x, y, 40);
- if (!fxAnimating) {
- fxAnimating = true;
- requestAnimationFrame(fxStep);
- }
-}
-
-function spawnConfetti(x, y, count) {
- for (let i = 0; i < count; i++) {
- const speed = 0.5;
- fxParticles.push({
- x: x + (Math.random() - 0.5) * 20,
- y: y + (Math.random() - 0.5) * 200,
- vx: (Math.random() - 0.5) * 10 * speed,
- vy: (Math.random() * -6 - 2) * speed,
- size: 6 + Math.random() * 4,
- life: 600 + Math.random() * 200,
- color: randomColor(),
- type: "confetti",
- wobble: Math.random(),
- });
- }
-}
-
-function spawnFirework(x, y, count) {
- for (let i = 0; i < count; i++) {
- const angle = (Math.PI * 2 * i) / count + Math.random() * 0.3;
- const speed = (3 + Math.random() * 3) * 0.1;
- fxParticles.push({
- x,
- y,
- vx: Math.cos(angle) * speed,
- vy: (Math.sin(angle) - 1) * speed,
- size: 4 + Math.random() * 3,
- life: 500 + Math.random() * 200,
- color: randomColor(),
- type: "spark",
- });
- }
-}
-
-function fxStep() {
- if (!fxCtx || !fxCanvas) return;
- fxCtx.clearRect(0, 0, fxCanvas.width, fxCanvas.height);
- fxParticles = fxParticles.filter((p) => p.life > 0);
- let i = 0;
- for (const p of fxParticles) {
- i += 1;
- if (p.type === "confetti") {
- p.vy += 0.018;
- p.vx *= 0.999;
- p.wobble += 0.2;
- p.x += p.vx + Math.cos(p.wobble + i) * 0.5;
- p.y += p.vy;
- } else {
- p.vy += 0.008;
- p.vx *= 0.9995;
- p.x += p.vx;
- p.y += p.vy;
- }
- p.life -= 1;
- fxCtx.fillStyle = p.color;
- fxCtx.beginPath();
- if (p.type === "confetti") {
- fxCtx.fillRect(p.x, p.y, p.size, p.size * 0.6);
- } else {
- fxCtx.arc(p.x, p.y, p.size * 0.5, 0, Math.PI * 2);
- fxCtx.fill();
- }
- }
- if (fxParticles.length > 0) {
- requestAnimationFrame(fxStep);
- } else {
- fxCtx.clearRect(0, 0, fxCanvas.width, fxCanvas.height);
- fxAnimating = false;
- }
-}
-
-function randomColor() {
- const palette = ["#ff595e", "#ffca3a", "#8ac926", "#1982c4", "#6a4c93"];
- return palette[Math.floor(Math.random() * palette.length)];
-}
-// -------------------------------------------------------------------
-
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);
+ setupHandlers();
+ try {
+ await refreshPhaseData();
+ } catch (err) {
+ toast(err.message, true);
+ }
+ setInterval(() => {
+ refreshPhaseData().catch((err) => {
+ if (!handleAuthError(err, clearUserState)) 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 == null || Number.isNaN(score)) return neutralEmoji();
- if (score < 1) return "😡";
- if (score <= 3) return "😠";
- if (score <= 6) return "😐";
- if (score <= 8) return "🙂";
- if (score <= 9) return "😃";
- return "🤩";
-}
-
-function neutralEmoji() {
- return "⬅️";
-}
-
-function signatureSuggestions(list) {
- return JSON.stringify(
- list.map((s) => [
- s.id,
- s.name,
- s.genre,
- s.description,
- s.screenshotUrl,
- s.youtubeUrl,
- s.gameUrl,
- s.minPlayers,
- s.maxPlayers,
- ]),
- );
+ 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;
+ }
}
diff --git a/wwwroot/js/data.js b/wwwroot/js/data.js
new file mode 100644
index 0000000..8229723
--- /dev/null
+++ b/wwwroot/js/data.js
@@ -0,0 +1,96 @@
+import { api } from "./api.js";
+import { handleAuthError, renderAllSuggestions, renderCounts, renderMySuggestions, renderPhasePill, renderPhaseTitles, renderResults, renderVotes, renderWelcome, setAuthUI, syncVoteScores } from "./ui.js";
+import { state, clearUserState } from "./state.js";
+
+export 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();
+}
+
+export async function loadSuggestData() {
+ if (state.phase !== "Suggest") return;
+ state.mySuggestions = await api.mySuggestions();
+ renderMySuggestions();
+}
+
+export async function loadRevealData() {
+ if (state.phase === "Reveal" || state.phase === "Vote" || state.phase === "Results") {
+ const latest = await api.allSuggestions();
+ const latestSig = signatureSuggestions(latest);
+ const changed = latestSig !== state.allSuggestionsSig;
+ state.allSuggestions = latest;
+ state.allSuggestionsSig = latestSig;
+ renderAllSuggestions();
+ renderPhaseTitles();
+ if (state.phase === "Vote" && changed) {
+ state.votesRendered = false;
+ }
+ }
+}
+
+export 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();
+ }
+}
+
+export async function loadResults() {
+ if (state.phase !== "Results") return;
+ state.results = await api.results();
+ renderResults();
+}
+
+export 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, clearUserState)) return;
+ throw err;
+ }
+}
+
+export function signatureSuggestions(list) {
+ return JSON.stringify(
+ list.map((s) => [
+ s.id,
+ s.name,
+ s.genre,
+ s.description,
+ s.screenshotUrl,
+ s.youtubeUrl,
+ s.gameUrl,
+ s.minPlayers,
+ s.maxPlayers,
+ ]),
+ );
+}
+
+// expose for UI handlers that call back in
+window.refreshPhaseData = refreshPhaseData;
+window.loadSuggestData = loadSuggestData;
+window.loadVoteData = loadVoteData;
+window.handleAuthError = (err) => handleAuthError(err, clearUserState);
diff --git a/wwwroot/js/dom.js b/wwwroot/js/dom.js
new file mode 100644
index 0000000..fec73c3
--- /dev/null
+++ b/wwwroot/js/dom.js
@@ -0,0 +1,11 @@
+export const $ = (id) => document.getElementById(id);
+
+const toastEl = typeof document !== "undefined" ? document.getElementById("toast") : null;
+
+export 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);
+}
diff --git a/wwwroot/js/effects.js b/wwwroot/js/effects.js
new file mode 100644
index 0000000..a14de19
--- /dev/null
+++ b/wwwroot/js/effects.js
@@ -0,0 +1,197 @@
+import { toast } from "./dom.js";
+import { t } from "./i18n.js";
+
+// Background parallax ------------------------------------------------
+export function setupBackgroundPan(config = {}) {
+ const root = document.documentElement;
+ const maxOffset = config.maxOffsetPx ?? 30;
+ const ease = config.ease ?? 0.08;
+ const scaleFactor = config.scaleFactor ?? 1.12;
+ if (scaleFactor) {
+ root.style.setProperty("--bg-scale", scaleFactor);
+ }
+ let targetX = 0;
+ let targetY = 0;
+ let currX = 0;
+ let currY = 0;
+
+ const setTarget = (x, y) => {
+ targetX = x;
+ targetY = y;
+ };
+
+ const step = () => {
+ currX += (targetX - currX) * ease;
+ currY += (targetY - currY) * ease;
+ root.style.setProperty("--bg-x", `${currX}px`);
+ root.style.setProperty("--bg-y", `${currY}px`);
+ requestAnimationFrame(step);
+ };
+
+ window.addEventListener("mousemove", (e) => {
+ const nx = (e.clientX / window.innerWidth - 0.5) * 2;
+ const ny = (e.clientY / window.innerHeight - 0.5) * 2;
+ setTarget(-nx * maxOffset, -ny * maxOffset);
+ });
+
+ window.addEventListener("mouseleave", () => setTarget(0, 0));
+ window.addEventListener("blur", () => setTarget(0, 0));
+ step();
+}
+
+// Screenshot hover ---------------------------------------------------
+export function setupCardVisualHover(el, url) {
+ if (!el || !url) return;
+ const img = new Image();
+ let naturalW = 0;
+ let naturalH = 0;
+ let loaded = false;
+ img.src = url;
+ img.onload = () => {
+ naturalW = img.naturalWidth;
+ naturalH = img.naturalHeight;
+ loaded = true;
+ };
+
+ const reset = () => {
+ el.classList.remove("hovering");
+ el.style.backgroundSize = "";
+ el.style.backgroundPosition = "";
+ el.style.backgroundRepeat = "";
+ };
+
+ el.addEventListener("mouseenter", () => {
+ el.classList.add("hovering");
+ el.style.backgroundSize = "auto";
+ el.style.backgroundRepeat = "no-repeat";
+ el.style.backgroundPosition = "center";
+ });
+
+ el.addEventListener("mousemove", (e) => {
+ if (!loaded) return;
+ const rect = el.getBoundingClientRect();
+ const overW = naturalW - rect.width;
+ const overH = naturalH - rect.height;
+ if (overW <= 0 && overH <= 0) {
+ el.style.backgroundPosition = "center";
+ return;
+ }
+ const xRatio = (e.clientX - rect.left) / rect.width;
+ const yRatio = (e.clientY - rect.top) / rect.height;
+ const xPercent = overW > 0 ? xRatio * 100 : 50;
+ const yPercent = overH > 0 ? yRatio * 100 : 50;
+ el.style.backgroundPosition = `${xPercent}% ${yPercent}%`;
+ });
+
+ ["mouseleave", "blur"].forEach((evt) => el.addEventListener(evt, reset));
+}
+
+// Celebration FX -----------------------------------------------------
+let fxCanvas;
+let fxCtx;
+let fxParticles = [];
+let fxAnimating = false;
+
+function ensureFxCanvas() {
+ if (fxCanvas) return;
+ fxCanvas = document.createElement("canvas");
+ fxCanvas.className = "fx-canvas";
+ fxCanvas.style.position = "fixed";
+ fxCanvas.style.inset = "0";
+ fxCanvas.style.pointerEvents = "none";
+ fxCanvas.style.zIndex = "120";
+ fxCanvas.width = window.innerWidth;
+ fxCanvas.height = window.innerHeight;
+ fxCtx = fxCanvas.getContext("2d");
+ document.body.appendChild(fxCanvas);
+ window.addEventListener("resize", () => {
+ fxCanvas.width = window.innerWidth;
+ fxCanvas.height = window.innerHeight;
+ });
+}
+
+export function triggerCelebration(button) {
+ ensureFxCanvas();
+ const rect = (button || document.body).getBoundingClientRect();
+ const x = rect.left + rect.width / 2;
+ const y = rect.top + rect.height / 2;
+ spawnConfetti(x, y, 80);
+ spawnFirework(x, y, 40);
+ if (!fxAnimating) {
+ fxAnimating = true;
+ requestAnimationFrame(fxStep);
+ }
+}
+
+function spawnConfetti(x, y, count) {
+ for (let i = 0; i < count; i++) {
+ fxParticles.push({
+ x,
+ y,
+ vx: (Math.random() - 0.5) * 6,
+ vy: Math.random() * -6 - 2,
+ size: 6 + Math.random() * 4,
+ life: 60 + Math.random() * 20,
+ color: randomColor(),
+ type: "confetti",
+ wobble: Math.random() * Math.PI * 2,
+ });
+ }
+}
+
+function spawnFirework(x, y, count) {
+ for (let i = 0; i < count; i++) {
+ const angle = (Math.PI * 2 * i) / count + Math.random() * 0.3;
+ const speed = 3 + Math.random() * 3;
+ fxParticles.push({
+ x,
+ y,
+ vx: Math.cos(angle) * speed,
+ vy: Math.sin(angle) * speed,
+ size: 4 + Math.random() * 3,
+ life: 50 + Math.random() * 20,
+ color: randomColor(),
+ type: "spark",
+ });
+ }
+}
+
+function fxStep() {
+ if (!fxCtx || !fxCanvas) return;
+ fxCtx.clearRect(0, 0, fxCanvas.width, fxCanvas.height);
+ fxParticles = fxParticles.filter((p) => p.life > 0);
+ for (const p of fxParticles) {
+ if (p.type === "confetti") {
+ p.vy += 0.18;
+ p.vx *= 0.99;
+ p.wobble += 0.2;
+ p.x += p.vx + Math.cos(p.wobble) * 0.8;
+ p.y += p.vy;
+ } else {
+ p.vy += 0.08;
+ p.vx *= 0.995;
+ p.x += p.vx;
+ p.y += p.vy;
+ }
+ p.life -= 1;
+ fxCtx.fillStyle = p.color;
+ fxCtx.beginPath();
+ if (p.type === "confetti") {
+ fxCtx.fillRect(p.x, p.y, p.size, p.size * 0.6);
+ } else {
+ fxCtx.arc(p.x, p.y, p.size * 0.5, 0, Math.PI * 2);
+ fxCtx.fill();
+ }
+ }
+ if (fxParticles.length > 0) {
+ requestAnimationFrame(fxStep);
+ } else {
+ fxCtx.clearRect(0, 0, fxCanvas.width, fxCanvas.height);
+ fxAnimating = false;
+ }
+}
+
+function randomColor() {
+ const palette = ["#ff595e", "#ffca3a", "#8ac926", "#1982c4", "#6a4c93"];
+ return palette[Math.floor(Math.random() * palette.length)];
+}
diff --git a/wwwroot/js/state.js b/wwwroot/js/state.js
new file mode 100644
index 0000000..4e654fe
--- /dev/null
+++ b/wwwroot/js/state.js
@@ -0,0 +1,33 @@
+export const state = {
+ isAuthenticated: false,
+ authMode: "login",
+ me: null,
+ phase: null,
+ prevPhase: null,
+ counts: null,
+ mySuggestions: [],
+ allSuggestions: [],
+ allSuggestionsSig: null,
+ myVotes: [],
+ results: [],
+ votesRendered: false,
+};
+
+export 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 = document.getElementById("admin-card");
+ if (adminCard) adminCard.classList.add("hidden");
+}
+
+export const getSavedUsername = () =>
+ localStorage.getItem("last_username") || "";
+export const setSavedUsername = (name) =>
+ localStorage.setItem("last_username", name);
diff --git a/wwwroot/js/ui.js b/wwwroot/js/ui.js
new file mode 100644
index 0000000..fa32148
--- /dev/null
+++ b/wwwroot/js/ui.js
@@ -0,0 +1,503 @@
+import { api } from "./api.js";
+import { t } from "./i18n.js";
+import { state, getSavedUsername, setSavedUsername } from "./state.js";
+import { $, toast } from "./dom.js";
+import { setupCardVisualHover } from "./effects.js";
+
+export 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.dataset.userEditing &&
+ !loginUser.value
+ ) {
+ loginUser.value = cachedUser;
+ }
+ }
+}
+
+export function setAuthMode(mode) {
+ state.authMode = mode;
+ document.querySelectorAll(".auth-form").forEach((form) => {
+ form.classList.toggle("hidden", form.dataset.mode !== mode);
+ });
+ const title = $("auth-title");
+ const toggleBtn = $("auth-toggle");
+ if (title) {
+ title.textContent =
+ mode === "login"
+ ? t("auth.loginHeading")
+ : t("auth.registerHeading");
+ }
+ if (toggleBtn) {
+ toggleBtn.textContent =
+ mode === "login"
+ ? t("auth.switchToRegister")
+ : t("auth.switchToLogin");
+ }
+}
+
+export function handleAuthError(err, clearUserState) {
+ if (err?.status === 401) {
+ clearUserState();
+ state.isAuthenticated = false;
+ setAuthUI(false);
+ return true;
+ }
+ toast(err?.message || t("toast.unexpected"), true);
+ return false;
+}
+
+export function renderPhasePill() {
+ const phaseKey = typeof state.phase === "string" ? state.phase.toLowerCase() : null;
+ $("phase-pill").textContent = phaseKey ? "" : t("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";
+ }
+}
+
+export function renderCounts() {
+ if (!state.counts) return;
+ $("counts").textContent = t("counts.format", {
+ players: state.counts.players,
+ suggestions: state.counts.suggestions,
+ votes: state.counts.votes,
+ });
+}
+
+export function renderWelcome() {
+ const el = $("welcome-text");
+ if (!el) return;
+ const name =
+ state.me?.displayName?.trim() ||
+ state.me?.username ||
+ t("auth.defaultName");
+ el.textContent = t("auth.welcome", { name });
+}
+
+export 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 }),
+ ),
+ );
+}
+
+export 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 }),
+ ),
+ );
+ renderPhaseTitles();
+}
+
+export 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 hasVote = Object.prototype.hasOwnProperty.call(votesMap, s.id);
+ const current = hasVote ? votesMap[s.id] : 5; // start neutral when no prior vote
+ const displayScore = hasVote ? current : "—";
+ const displayEmoji = hasVote ? scoreToEmoji(current) : neutralEmoji();
+ const footer = document.createElement("div");
+ footer.className = "vote-controls";
+ footer.innerHTML = `
+
+ ${displayScore}
+ ${displayEmoji}`;
+ 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(t("vote.saved"));
+ await window.loadVoteData();
+ } catch (err) {
+ toast(err.message, true);
+ }
+ });
+ });
+}
+
+export 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);
+ }
+ });
+ document
+ .querySelectorAll("input[type=range][data-id]")
+ .forEach((slider) => {
+ const id = slider.dataset.id;
+ if (Object.prototype.hasOwnProperty.call(votesMap, Number(id)))
+ return;
+ const scoreLabel = $("score-" + id);
+ const emoji = $("emoji-" + id);
+ if (scoreLabel) scoreLabel.textContent = "—";
+ if (emoji) emoji.textContent = neutralEmoji();
+ });
+}
+
+export function renderResults() {
+ const container = $("results-list");
+ container.innerHTML = "";
+ const table = document.createElement("table");
+ table.className = "results-table";
+ table.innerHTML = `
+
+
+ | ${t("results.rank")} |
+ ${t("results.game")} |
+ ${t("results.author")} |
+ ${t("results.votes")} |
+ ${t("results.avg")} |
+ ${t("results.total")} |
+ ${t("results.links")} |
+
+
+
+ `;
+ const tbody = table.querySelector("tbody");
+ state.results.forEach((r, idx) => {
+ const row = document.createElement("tr");
+ row.innerHTML = `
+ ${idx + 1} |
+
+ ${r.screenshotUrl ? ` ` : ''}
+
+ |
+ ${r.author ?? "—"} |
+ ${r.count} |
+ ${r.average.toFixed(1)} |
+ ${r.total} |
+
+ ${r.gameUrl ? `${t("results.link.site")} ` : ''}
+ ${r.youtubeUrl ? `${t("results.link.youtube")}` : ''}
+ |
+ `;
+ tbody.appendChild(row);
+ });
+ const frame = document.createElement("div");
+ frame.className = "results-frame";
+ frame.appendChild(table);
+ container.appendChild(frame);
+ container.querySelectorAll(".clickable-thumb").forEach((img) => {
+ img.addEventListener("click", () => openLightbox(img.src, img.alt));
+ });
+}
+
+export function renderPhaseTitles() {
+ const revealTitle = $("reveal-title");
+ const voteTitle = $("vote-title");
+ const totalGames = state.allSuggestions?.length ?? 0;
+ if (revealTitle) {
+ revealTitle.textContent =
+ totalGames > 0
+ ? t("section.allSuggestions.count", { count: totalGames })
+ : t("section.allSuggestions");
+ }
+ if (voteTitle) {
+ voteTitle.textContent =
+ totalGames > 0
+ ? t("section.vote.count", { count: totalGames })
+ : t("section.vote");
+ }
+}
+
+export 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
+ ? ``
+ : ``;
+ const hasPlayers = s.minPlayers || s.maxPlayers;
+ const players = hasPlayers
+ ? `${t("card.players", {
+ min: s.minPlayers ?? "?",
+ max: s.maxPlayers ?? "?",
+ })}`
+ : "";
+ const genreAndPlayers = s.genre
+ ? hasPlayers
+ ? `${s.genre} • ${players}`
+ : s.genre
+ : hasPlayers
+ ? players
+ : undefined;
+ const hasExtraInfo = genreAndPlayers || s.gameUrl || s.youtubeUrl;
+ card.innerHTML = `
+ ${visual}
+
+
+
${s.name}
+
+ ${showAuthor && s.author ? `${s.author}` : ""}
+ ${allowEdit ? `` : ""}
+ ${allowDelete ? `` : ""}
+
+
+ ${hasExtraInfo ? `
` : ""}
+ ${genreAndPlayers ? genreAndPlayers : ""}
+ ${s.gameUrl ? `${t("card.site")}` : ""}
+ ${s.youtubeUrl ? `${t("card.youtube")}` : ""}
+ ${hasExtraInfo ? `
` : ""}
+ ${s.description ? `
${s.description}
` : ""}
+
+ `;
+ if (hasImage) {
+ const btn = card.querySelector(".card-visual");
+ setupCardVisualHover(btn, s.screenshotUrl);
+ 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(t("toast.suggestionDeleted"));
+ await window.loadSuggestData();
+ } catch (err) {
+ toast(err.message, true);
+ }
+ });
+ }
+ return card;
+}
+
+function openEditModal(s) {
+ const overlay = document.createElement("div");
+ overlay.className = "edit-modal";
+ overlay.innerHTML = `
+
+ `;
+
+ 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(t("toast.invalidImageUrl"), true);
+ }
+ if (!data.name?.trim()) return toast(t("toast.nameRequired"), true);
+ try {
+ await api.updateSuggestion(s.id, data);
+ toast(t("toast.savedChanges"));
+ close();
+ await window.refreshPhaseData();
+ } catch (err) {
+ if (window.handleAuthError(err)) return;
+ toast(err.message, true);
+ }
+ });
+
+ document.body.appendChild(overlay);
+}
+
+export function openLightbox(url, title) {
+ const overlay = document.createElement("div");
+ overlay.className = "lightbox";
+ overlay.innerHTML = `
+
+
+

+
${title || ""}
+
+ `;
+ overlay.addEventListener("click", (e) => {
+ if (
+ e.target.classList.contains("lightbox") ||
+ e.target.classList.contains("lightbox-close")
+ ) {
+ overlay.remove();
+ }
+ });
+ document.body.appendChild(overlay);
+}
+
+export 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),
+ };
+}
+
+export function scoreToEmoji(score) {
+ if (score == null || Number.isNaN(score)) return neutralEmoji();
+ if (score < 1) return "😡";
+ if (score <= 3) return "😠";
+ if (score <= 6) return "😐";
+ if (score <= 8) return "🙂";
+ if (score <= 9) return "😃";
+ return "🤩";
+}
+
+export function neutralEmoji() {
+ return "😐";
+}
+
+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;
+ }
+}