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"); const podiumClass = idx === 0 ? "podium podium-1" : idx === 1 ? "podium podium-2" : idx === 2 ? "podium podium-3" : ""; row.className = podiumClass; const medal = idx === 0 ? "🥇" : idx === 1 ? "🥈" : idx === 2 ? "🥉" : `${idx + 1}`; row.innerHTML = ` ${medal} ${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; } }