import { api, adminApi } from "./js/api.js"; import { t, setLanguage, getLanguage, initI18n, onLanguageChange } from "./js/i18n.js"; initI18n(); const state = { isAuthenticated: false, authMode: "login", me: null, phase: null, prevPhase: null, counts: null, mySuggestions: [], allSuggestions: [], myVotes: [], results: [], votesRendered: false }; const $ = (id) => document.getElementById(id); const toastEl = $("toast"); function toast(msg, isError = false) { if (!toastEl) return; toastEl.textContent = msg; toastEl.classList.remove("hidden"); toastEl.classList.toggle("error", isError); setTimeout(() => toastEl.classList.add("hidden"), 2000); } const getSavedUsername = () => localStorage.getItem("last_username") || ""; const setSavedUsername = (name) => localStorage.setItem("last_username", name); function setAuthUI(isAuthed) { const main = document.querySelector("main"); const statusBar = document.querySelector(".status-bar"); const authCard = $("auth-card"); [main, statusBar].forEach(el => el?.classList.toggle("hidden", !isAuthed)); if (authCard) authCard.classList.toggle("hidden", isAuthed); const adminToggle = $("admin-toggle"); if (adminToggle) adminToggle.classList.toggle("hidden", !isAuthed || !state.me?.isAdmin); if (!isAuthed) { const adminCard = $("admin-card"); if (adminCard) adminCard.classList.add("hidden"); const loginUser = $("login-username"); const cachedUser = getSavedUsername(); if (loginUser && cachedUser) loginUser.value = cachedUser; } } function setAuthMode(mode) { state.authMode = mode; document.querySelectorAll(".auth-form").forEach(form => { form.classList.toggle("hidden", form.dataset.mode !== mode); }); document.querySelectorAll("[data-auth-tab]").forEach(btn => { btn.classList.toggle("active", btn.dataset.authTab === mode); }); } function clearUserState() { state.me = null; state.phase = null; state.prevPhase = null; state.counts = null; state.mySuggestions = []; state.allSuggestions = []; state.myVotes = []; state.results = []; state.votesRendered = false; const adminCard = $("admin-card"); if (adminCard) adminCard.classList.add("hidden"); } function handleAuthError(err) { if (err?.status === 401) { clearUserState(); state.isAuthenticated = false; setAuthUI(false); return true; } toast(err?.message || t("toast.unexpected"), true); return false; } async function loadState() { const [me, stateData] = await Promise.all([api.me(), api.state()]); state.isAuthenticated = true; state.me = me; state.prevPhase = state.phase; state.phase = stateData.currentPhase; state.counts = stateData; if (state.prevPhase !== state.phase && state.phase === "Vote") { state.votesRendered = false; } setAuthUI(true); renderWelcome(); renderPhasePill(); renderCounts(); } async function loadSuggestData() { if (state.phase !== "Suggest") return; state.mySuggestions = await api.mySuggestions(); renderMySuggestions(); } async function loadRevealData() { if (state.phase === "Reveal" || state.phase === "Vote" || state.phase === "Results") { state.allSuggestions = await api.allSuggestions(); renderAllSuggestions(); } } async function loadVoteData() { if (state.phase !== "Vote") return; const votes = await api.myVotes(); state.myVotes = votes; if (!state.votesRendered) { renderVotes(); state.votesRendered = true; } else { syncVoteScores(); } } async function loadResults() { if (state.phase !== "Results") return; state.results = await api.results(); renderResults(); } function renderPhasePill() { const phaseKey = typeof state.phase === "string" ? state.phase.toLowerCase() : null; $("phase-pill").textContent = phaseKey ? t(`phase.${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 }))); } function renderVotes() { const list = $("vote-list"); if (!list) return; list.innerHTML = ""; const votesMap = Object.fromEntries(state.myVotes.map((v) => [v.suggestionId, v.score])); state.allSuggestions.forEach((s) => { const li = buildCard(s, { showAuthor: true, allowEdit: !!state.me?.isAdmin }); const current = votesMap[s.id] ?? 0; const footer = document.createElement("div"); footer.className = "vote-controls"; footer.innerHTML = ` ${current} ${scoreToEmoji(current)}`; 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); } }); } 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); }); container.appendChild(table); container.querySelectorAll(".clickable-thumb").forEach(img => { img.addEventListener("click", () => openLightbox(img.src, img.alt)); }); } function setupHandlers() { document.querySelectorAll("[data-auth-tab]").forEach(btn => { btn.addEventListener("click", () => setAuthMode(btn.dataset.authTab)); }); setAuthMode(state.authMode); const langSelect = $("language-select"); if (langSelect) { langSelect.value = getLanguage(); langSelect.addEventListener("change", () => setLanguage(langSelect.value)); } onLanguageChange(() => { if (langSelect) langSelect.value = getLanguage(); renderWelcome(); renderPhasePill(); renderCounts(); 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 || !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 (!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")); 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); } } 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 ? `` : `
`; card.innerHTML = ` ${visual}

${s.name}

${s.gameUrl ? `${t("card.site")}` : ""} ${s.youtubeUrl ? `${t("card.youtube")}` : ""} ${showAuthor && s.author ? `${s.author}` : ""} ${allowEdit ? `` : ""} ${allowDelete ? `` : ""}
${s.genre ? `

${s.genre}

` : ""} ${s.description ? `

${s.description}

` : ""} ${(s.minPlayers || s.maxPlayers) ? `

${t("card.players", { min: s.minPlayers ?? "?", max: s.maxPlayers ?? "?" })}

` : ""}
`; if (hasImage) { const btn = card.querySelector(".card-visual"); btn.addEventListener("click", () => openLightbox(s.screenshotUrl, s.name)); } if (allowEdit) { const editBtn = card.querySelector("[data-edit]"); editBtn?.addEventListener("click", () => openEditModal(s)); } if (allowDelete) { const del = card.querySelector("[data-delete]"); del.addEventListener("click", async () => { try { await api.deleteSuggestion(s.id); toast(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); } async function main() { setupHandlers(); try { await refreshPhaseData(); } catch (err) { toast(err.message, true); } setInterval(() => { refreshPhaseData().catch(err => { if (!handleAuthError(err)) toast(err.message, true); }); }, 4000); } main(); function isValidImageUrl(url) { if (!url) return true; try { const u = new URL(url); const allowed = ["http:", "https:"]; if (!allowed.includes(u.protocol)) return false; const path = u.pathname.toLowerCase(); return [".png", ".jpg", ".jpeg", ".gif", ".webp", ".avif"].some(ext => path.endsWith(ext)); } catch { return false; } } function normalizeSuggestionForm(formData) { const obj = Object.fromEntries(formData.entries()); const parseNum = (v) => { if (v === undefined || v === null || v === "") return null; const n = Number(v); return Number.isFinite(n) ? n : null; }; return { name: obj.name?.trim(), genre: obj.genre?.trim() || null, description: obj.description?.trim() || null, screenshotUrl: obj.screenshotUrl?.trim() || null, youtubeUrl: obj.youtubeUrl?.trim() || null, gameUrl: obj.gameUrl?.trim() || null, minPlayers: parseNum(obj.minPlayers), maxPlayers: parseNum(obj.maxPlayers), }; } function scoreToEmoji(score) { if (score <= 1) return "😡"; if (score <= 3) return "😠"; if (score <= 5) return "😐"; if (score <= 7) return "🙂"; if (score <= 8) return "😃"; return "🤩"; }