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.name}` : ""} -
-
${r.name}
- ${r.genre ? `
${r.genre}
` : ""} -
- - ${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 = ` -
-
-

${t("modal.editTitle")}

- -
-
-
- - - -
- ${t("form.players")} -
- - -
-
- - - -
- - -
-
-
-
- `; - - 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 = ` - - `; - 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.name}` : ''} +
+
${r.name}
+ ${r.genre ? `
${r.genre}
` : ''} +
+ + ${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 = ` +
+
+

${t("modal.editTitle")}

+ +
+
+
+ + + +
+ ${t("form.players")} +
+ + +
+
+ + + +
+ + +
+
+
+
+ `; + + 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 = ` + + `; + 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; + } +}