import { api } from "./api.js"; import { t } from "./i18n.js"; import { state, getSavedUsername, setSavedUsername } from "./state.js"; import { $, toast } from "./dom.js"; import { setupCardVisualHover, triggerCelebration } 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; const pill = $("phase-pill"); if (pill) pill.textContent = phaseKey ? t(`phase.${phaseKey}`) : t("phase.loading"); document.querySelectorAll(".phase-view").forEach((el) => el.classList.add("hidden"), ); const viewMap = { Suggest: "suggest-view", Vote: "vote-view", Results: "results-view", }; const id = viewMap[state.phase]; if (id) $(id).classList.remove("hidden"); updatePhaseNav(); } 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 = true; // own suggestions can always adjust optional data const lockTitle = state.phase !== "Suggest" && !state.me?.isAdmin; const allowDelete = state.phase === "Suggest" || state.me?.isAdmin; state.mySuggestions.forEach((s) => wrap.appendChild( buildCard(s, { showAuthor: false, allowDelete, allowEdit, lockTitle }), ), ); } export function renderAllSuggestions() { const list = $("all-suggestions"); if (!list) return; list.innerHTML = ""; const allowEdit = !!state.me?.isAdmin; const allowDelete = !!state.me?.isAdmin; 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.votesList")} ${t("results.myVote")} ${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}
${buildResultMeta(r)}
${r.author ?? "—"} ${formatVotes(r.votes)} ${formatMyVote(r.myVote)} ${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 buildResultMeta(r) { const hasPlayers = r.minPlayers || r.maxPlayers; const players = hasPlayers ? t("card.players", { min: r.minPlayers ?? "?", max: r.maxPlayers ?? "?" }) : null; const bits = [r.genre, players].filter(Boolean); if (bits.length === 0) return ""; return `
${bits.join(" • ")}
`; } export function renderPhaseTitles() { const voteTitle = $("vote-title"); const totalGames = state.allSuggestions?.length ?? 0; 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, lockTitle = 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", () => openSuggestionModal({ title: t("modal.editTitle"), submitLabel: t("modal.save"), initial: s, onSubmit: async (data, close) => { await api.updateSuggestion(s.id, data); toast(t("toast.savedChanges")); close(); await window.refreshPhaseData(); }, lockTitle, }), ); } if (allowDelete) { const del = card.querySelector("[data-delete]"); del.addEventListener("click", async () => { openDeleteConfirmModal(s); }); } return card; } function buildSuggestionForm(initial = {}, lockTitle = false) { const form = document.createElement("form"); form.className = "stack suggestion-form"; form.innerHTML = `
${t("form.players")}
`; const setVal = (name, value) => { const input = form.querySelector(`[name="${name}"]`); if (input) { input.value = value ?? ""; if (name === "name" && lockTitle) { input.readOnly = true; input.classList.add("readonly"); } } }; setVal("name", initial.name ?? ""); setVal("genre", initial.genre ?? ""); const desc = form.querySelector("textarea[name=description]"); if (desc) desc.value = initial.description ?? ""; setVal("minPlayers", initial.minPlayers ?? ""); setVal("maxPlayers", initial.maxPlayers ?? ""); setVal("screenshotUrl", initial.screenshotUrl ?? ""); setVal("youtubeUrl", initial.youtubeUrl ?? ""); setVal("gameUrl", initial.gameUrl ?? ""); initCharCounters(form); return form; function initCharCounters(formEl) { const inputs = formEl.querySelectorAll("input[maxlength], textarea[maxlength]"); inputs.forEach((input) => { const counter = formEl.querySelector(`.char-counter[data-for="${input.name}"]`); if (!counter) return; const update = () => { const max = input.maxLength; if (!max || max < 0) return; const used = input.value?.length ?? 0; counter.textContent = `${used}/${max}`; }; input.addEventListener("input", update); update(); }); } } function openSuggestionModal({ title, submitLabel, initial = {}, onSubmit, lockTitle = false }) { const overlay = document.createElement("div"); overlay.className = "edit-modal"; const panel = document.createElement("div"); panel.className = "edit-panel"; panel.innerHTML = `

${title}

`; const form = buildSuggestionForm(initial, lockTitle); const actions = document.createElement("div"); actions.className = "stack horizontal"; const submitBtn = document.createElement("button"); submitBtn.type = "submit"; submitBtn.textContent = submitLabel; const cancelBtn = document.createElement("button"); cancelBtn.type = "button"; cancelBtn.className = "ghost"; cancelBtn.textContent = t("modal.cancel"); actions.append(submitBtn, cancelBtn); form.appendChild(actions); const close = () => overlay.remove(); overlay.addEventListener("click", (e) => { if ( e.target.classList.contains("edit-modal") || e.target.classList.contains("lightbox-close") ) close(); }); cancelBtn.addEventListener("click", close); 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 onSubmit(data, close, submitBtn); } catch (err) { if (window.handleAuthError?.(err)) return; toast(err.message, true); } }); panel.querySelector(".edit-body")?.appendChild(form); overlay.appendChild(panel); document.body.appendChild(overlay); return overlay; } export function openNewSuggestionModal() { openSuggestionModal({ title: t("modal.addTitle") || t("suggest.new"), submitLabel: t("form.submit"), initial: {}, onSubmit: async (data, close, submitBtn) => { await api.createSuggestion(data); toast(t("toast.suggestionAdded")); if (submitBtn) triggerCelebration(submitBtn); close(); await window.loadSuggestData(); }, }); } 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 openConfirmModal({ title, body, confirmLabel, cancelLabel = t("modal.cancel"), onConfirm }) { const overlay = document.createElement("div"); overlay.className = "edit-modal"; const panel = document.createElement("div"); panel.className = "edit-panel"; panel.innerHTML = `

${title}

${body}

`; const actions = document.createElement("div"); actions.className = "stack horizontal"; const confirmBtn = document.createElement("button"); confirmBtn.textContent = confirmLabel ?? t("modal.confirm"); const cancelBtn = document.createElement("button"); cancelBtn.className = "ghost"; cancelBtn.type = "button"; cancelBtn.textContent = cancelLabel; actions.append(confirmBtn, cancelBtn); panel.querySelector(".edit-body")?.appendChild(actions); const close = () => overlay.remove(); overlay.addEventListener("click", (e) => { if ( e.target.classList.contains("edit-modal") || e.target.classList.contains("lightbox-close") ) close(); }); cancelBtn.addEventListener("click", close); confirmBtn.addEventListener("click", async () => { try { await onConfirm?.(close); } catch (err) { toast(err.message, true); } }); overlay.appendChild(panel); 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 formatVotes(votes) { if (!Array.isArray(votes) || votes.length === 0) return "—"; const sorted = [...votes].sort((a, b) => a - b); return sorted.map((v) => scoreToEmoji(v)).join(""); } function formatMyVote(score) { if (score == null || Number.isNaN(score)) return "—"; return `${score} ${scoreToEmoji(score)}`; } function openDeleteConfirmModal(s) { const overlay = document.createElement("div"); overlay.className = "edit-modal"; const panel = document.createElement("div"); panel.className = "edit-panel"; panel.innerHTML = `

${t("modal.confirmDeleteTitle")}

`; const preview = buildCard( { ...s, id: s.id }, // shallow copy to avoid mutations { showAuthor: true, allowDelete: false, allowEdit: false }, ); preview.classList.add("preview-card"); const actions = document.createElement("div"); actions.className = "stack horizontal"; const confirmBtn = document.createElement("button"); confirmBtn.className = "danger"; confirmBtn.textContent = t("modal.confirmDelete"); const cancelBtn = document.createElement("button"); cancelBtn.type = "button"; cancelBtn.className = "ghost"; cancelBtn.textContent = t("modal.cancel"); actions.append(confirmBtn, cancelBtn); const body = panel.querySelector(".delete-body"); body?.append(preview, actions); const close = () => overlay.remove(); overlay.addEventListener("click", (e) => { if ( e.target.classList.contains("edit-modal") || e.target.classList.contains("lightbox-close") ) close(); }); cancelBtn.addEventListener("click", close); confirmBtn.addEventListener("click", async () => { try { await api.deleteSuggestion(s.id); toast(t("toast.suggestionDeleted")); close(); await window.loadSuggestData(); } catch (err) { toast(err.message, true); } }); overlay.appendChild(panel); document.body.appendChild(overlay); } 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; } } export function updatePhaseNav() { const isAdmin = !!state.me?.isAdmin; const phase = state.phase; const showNav = (id, visible) => { const el = $(id); if (el) el.classList.toggle("hidden", !visible); }; showNav("nav-suggest", phase === "Suggest"); showNav("nav-vote", phase === "Vote"); const lockBadge = $("results-lock"); if (lockBadge) { const locked = !state.resultsOpen && phase === "Vote"; lockBadge.classList.toggle("hidden", !locked); lockBadge.textContent = t("admin.resultsLocked"); } // Toggle admin-only back buttons const backButtons = ["nav-vote-prev"]; backButtons.forEach((id) => { const btn = $(id); if (btn) btn.classList.toggle("hidden", !isAdmin); }); // Disable vote->results next if locked (for non-admins) const voteNext = $("nav-vote-next"); if (voteNext) { const locked = !state.resultsOpen && !isAdmin; voteNext.disabled = locked; voteNext.textContent = locked ? t("nav.waitingForResults") : t("nav.next"); } // Admin toggle state const adminResultsToggle = $("results-open"); if (adminResultsToggle) { adminResultsToggle.checked = !!state.resultsOpen; } }