diff --git a/package.json b/package.json index d739d6b..6c1b0d3 100644 --- a/package.json +++ b/package.json @@ -4,8 +4,8 @@ "type": "module", "scripts": { "lint": "eslint \"wwwroot/**/*.js\"", - "format": "prettier --write \"eslint.config.js\" \"wwwroot/js/i18n.js\" \"wwwroot/js/{admin-ui,auth-ui,modals-ui,results-ui,suggestions-ui,ui-runtime,ui-utils,ui,votes-ui}.js\"", - "format:check": "prettier --check \"eslint.config.js\" \"wwwroot/js/i18n.js\" \"wwwroot/js/{admin-ui,auth-ui,modals-ui,results-ui,suggestions-ui,ui-runtime,ui-utils,ui,votes-ui}.js\"" + "format": "prettier --write \"eslint.config.js\" \"wwwroot/js/i18n.js\" \"wwwroot/js/{admin-ui,app-admin-handlers,app-auth-handlers,app-vote-nav-handlers,auth-ui,modals-ui,results-ui,suggestions-ui,ui-runtime,ui-utils,ui,votes-ui}.js\"", + "format:check": "prettier --check \"eslint.config.js\" \"wwwroot/js/i18n.js\" \"wwwroot/js/{admin-ui,app-admin-handlers,app-auth-handlers,app-vote-nav-handlers,auth-ui,modals-ui,results-ui,suggestions-ui,ui-runtime,ui-utils,ui,votes-ui}.js\"" }, "devDependencies": { "@eslint/js": "9.21.0", diff --git a/wwwroot/app.js b/wwwroot/app.js index 1dc4b4c..8c993c6 100644 --- a/wwwroot/app.js +++ b/wwwroot/app.js @@ -1,10 +1,7 @@ -import { api, adminApi } from "./js/api.js"; import { t, setLanguage, getLanguage, initI18n, onLanguageChange, faqMarkdown } from "./js/i18n.js"; -import { state, clearUserState, setSavedUsername } from "./js/state.js"; -import { $, toast } from "./js/dom.js"; +import { state, clearUserState } from "./js/state.js"; +import { toast } from "./js/dom.js"; import { - setAuthUI, - setAuthMode, handleAuthError, renderWelcome, renderPhasePill, @@ -15,10 +12,7 @@ import { syncVoteScores, renderResults, renderPhaseTitles, - openNewSuggestionModal, updatePhaseNav, - openConfirmModal, - openResultsRelockModal, configureUiRuntime, } from "./js/ui.js"; import { @@ -26,6 +20,9 @@ import { loadVoteData, refreshPhaseData, } from "./js/data.js"; +import { setupAuthHandlers } from "./js/app-auth-handlers.js"; +import { setupAdminHandlers } from "./js/app-admin-handlers.js"; +import { setupVoteNavigationHandlers } from "./js/app-vote-nav-handlers.js"; const REFRESH_INTERVAL_MS = 4000; let refreshInFlight = null; @@ -81,37 +78,9 @@ configureUiRuntime({ }); function setupHandlers() { - const toggleAuth = $("auth-toggle"); - if (toggleAuth) { - toggleAuth.addEventListener("click", (e) => { - e.preventDefault(); - setAuthMode(state.authMode === "login" ? "register" : "login"); - }); - } - setAuthMode(state.authMode); - - const hasConsent = () => document.cookie.split(";").some((c) => c.trim().startsWith("cookie_consent=1")); - const setConsent = () => { document.cookie = "cookie_consent=1; path=/; max-age=31536000; SameSite=Lax"; }; - const consentRows = document.querySelectorAll(".consent-row"); - const toggleConsentRows = () => { - const hide = hasConsent(); - consentRows.forEach((row) => row.classList.toggle("hidden", hide)); - }; - toggleConsentRows(); - ["login-consent", "register-consent"].forEach((id) => { - const box = $(id); - if (box) { - box.checked = hasConsent(); - } - }); - - 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; }); - } - + setupAuthHandlers({ runSerializedRefresh }); + setupAdminHandlers({ runSerializedRefresh }); + setupVoteNavigationHandlers({ runSerializedRefresh }); setupLanguageSwitchers(); onLanguageChange(() => { @@ -133,201 +102,9 @@ function setupHandlers() { updatePhaseNav(); }); - 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); - if (!hasConsent() && !$("login-consent")?.checked) return toast(t("auth.cookieRequired"), true); - try { - await api.login({ username, password }); - setConsent(); - toggleConsentRows(); - setSavedUsername(username); - state.isAuthenticated = true; - setAuthUI(true); - await runSerializedRefresh(); - toast(t("toast.loggedIn")); - } catch (err) { - if (err?.status === 401) return toast(t("auth.invalidCredentials"), true); - if (handleAuthError(err, clearUserState)) 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 || !password) return toast(t("auth.needCredentials"), true); - if (!hasConsent() && !$("register-consent")?.checked) return toast(t("auth.cookieRequired"), true); - try { - await api.register({ username, password, displayName, adminKey }); - setConsent(); - toggleConsentRows(); - setSavedUsername(username); - state.isAuthenticated = true; - setAuthUI(true); - await runSerializedRefresh(); - toast(t("toast.registered")); - } catch (err) { - if (handleAuthError(err, clearUserState)) return; - toast(err.message, true); - } - }); - } - - const openSuggestBtn = $("open-suggest-modal"); - if (openSuggestBtn) { - openSuggestBtn.addEventListener("click", (e) => { - e.preventDefault(); - if (openSuggestBtn.disabled) return; - if (state.phase !== "Suggest") return; - openNewSuggestionModal(); - }); - } - const openJokerBtn = $("open-joker-modal"); - if (openJokerBtn) { - openJokerBtn.addEventListener("click", (e) => { - e.preventDefault(); - if (state.phase !== "Vote" || !state.hasJoker) return; - openNewSuggestionModal(); - }); - } - - bindNavButtons(); - - $("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)); - } - document.querySelectorAll(".help-chip").forEach((chip) => { chip.addEventListener("click", () => openFaqModal()); }); - - const resultsToggle = $("results-open"); - if (resultsToggle) { - resultsToggle.addEventListener("change", async (e) => { - const desired = !!e.target.checked; - try { - const resp = await adminApi.setResultsOpen(desired); - const wasResultsOpen = state.resultsOpen; - const wasPhase = state.phase; - state.resultsOpen = resp.resultsOpen; - if (wasResultsOpen && !resp.resultsOpen && wasPhase === "Results") { - openResultsRelockModal(); - } - renderPhasePill(); - toast(t("admin.resultsUpdated")); - await runSerializedRefresh(); - } catch (err) { - e.target.checked = !desired; - toast(err.message, true); - } - }); - } - - const linkApply = $("link-apply"); - if (linkApply) { - linkApply.addEventListener("click", async () => { - const source = Number($("link-source")?.value); - const target = Number($("link-target")?.value); - if (!source || !target || source === target) { - return toast(t("admin.linkValidation"), true); - } - try { - await adminApi.linkSuggestions(source, target); - toast(t("admin.linkDone")); - await runSerializedRefresh(); - } catch (err) { - toast(err.message, true); - } - }); - } - - const playerTable = $("admin-player-table"); - if (playerTable) { - playerTable.addEventListener("click", async (e) => { - const grantBtn = e.target.closest("[data-grant-joker]"); - const deleteBtn = e.target.closest("[data-delete-player]"); - if (grantBtn) { - const playerId = grantBtn.dataset.grantJoker; - try { - await adminApi.grantJoker(playerId); - toast(t("admin.jokerGranted")); - await runSerializedRefresh(); - } catch (err) { - toast(err.message, true); - } - } else if (deleteBtn) { - const playerId = deleteBtn.dataset.deletePlayer; - const name = deleteBtn.dataset.name || ""; - openConfirmModal({ - title: t("admin.deleteTitle"), - body: t("admin.deleteBody", { name }), - confirmLabel: t("admin.deleteConfirm"), - onConfirm: async (close) => { - try { - await adminApi.deletePlayer(playerId); - toast(t("admin.deleteDone")); - close(); - await runSerializedRefresh(); - } catch (err) { - toast(err.message, true); - } - }, - }); - } - }); - } -} - -async function adminAction(fn, successMessage) { - try { - await fn(); - toast(successMessage); - await runSerializedRefresh(); - } catch (err) { - toast(err.message, true); - } } async function main() { @@ -378,103 +155,6 @@ function setupLanguageSwitchers() { updateLanguageButtons(); } -function bindNavButtons() { - const makeForward = (id, before) => { - const btn = $(id); - if (!btn) return; - btn.addEventListener("click", async () => { - try { - if (before) { - const proceed = await before(); - if (!proceed) return; - } - const resp = await api.nextPhase(); - state.prevPhase = state.phase; - state.phase = resp.currentPhase; - state.resultsOpen = resp.resultsOpen ?? state.resultsOpen; - state.votesRendered = false; - renderPhasePill(); - await runSerializedRefresh(); - } catch (err) { - toast(err.message, true); - } - }); - }; - - const makeBack = (id) => { - const btn = $(id); - if (!btn) return; - btn.addEventListener("click", async () => { - try { - const resp = await api.prevPhase(); - state.prevPhase = state.phase; - state.phase = resp.currentPhase; - state.resultsOpen = resp.resultsOpen ?? state.resultsOpen; - state.votesRendered = false; - renderPhasePill(); - await runSerializedRefresh(); - } catch (err) { - toast(err.message, true); - } - }); - }; - - makeForward("nav-suggest-next", async () => { - return await new Promise((resolve) => { - openConfirmModal({ - title: t("nav.freezeModalTitle"), - body: t("nav.freezeModalBody"), - confirmLabel: t("nav.next"), - onConfirm: (close) => { - close(); - resolve(true); - }, - }); - }); - }); - makeBack("nav-vote-prev"); - - const finalizeBtn = $("finalize-votes"); - if (finalizeBtn) { - const finalizeVotes = async (desired) => { - await api.finalizeVotes(desired); - state.votesFinal = desired; - renderPhasePill(); - renderVotes(); - toast(desired ? t("vote.finalize") : t("vote.unfinalize")); - }; - - const missingVotes = () => { - const votedIds = new Set((state.myVotes ?? []).map((v) => v.suggestionId)); - return (state.allSuggestions ?? []).filter((s) => !votedIds.has(s.id)); - }; - - finalizeBtn.addEventListener("click", async () => { - try { - const desired = !state.votesFinal; - if (desired) { - const missing = missingVotes(); - if (missing.length > 0) { - openConfirmModal({ - title: t("vote.finalizeMissingTitle"), - body: t("vote.finalizeMissingBody", { count: missing.length }), - confirmLabel: t("vote.finalizeMissingConfirm"), - onConfirm: async (close) => { - await finalizeVotes(desired); - close(); - }, - }); - return; - } - } - await finalizeVotes(desired); - } catch (err) { - toast(err.message, true); - } - }); - } -} - function markdownToHtml(md) { const lines = md.trim().split(/\r?\n/); const html = []; diff --git a/wwwroot/js/app-admin-handlers.js b/wwwroot/js/app-admin-handlers.js new file mode 100644 index 0000000..8a38c19 --- /dev/null +++ b/wwwroot/js/app-admin-handlers.js @@ -0,0 +1,135 @@ +import { adminApi } from "./api.js"; +import { t } from "./i18n.js"; +import { state } from "./state.js"; +import { $, toast } from "./dom.js"; +import { + openConfirmModal, + openResultsRelockModal, + renderPhasePill, +} from "./ui.js"; + +async function adminAction(fn, successMessage, runSerializedRefresh) { + try { + await fn(); + toast(successMessage); + await runSerializedRefresh(); + } catch (err) { + toast(err.message, true); + } +} + +function setupAdminPanelToggle() { + const adminToggle = $("admin-toggle"); + const adminCard = $("admin-card"); + const adminClose = $("admin-close"); + if (!adminToggle || !adminCard || !adminClose) return; + + const togglePanel = (show) => adminCard.classList.toggle("hidden", !show); + adminToggle.addEventListener("click", () => + togglePanel(adminCard.classList.contains("hidden")), + ); + adminClose.addEventListener("click", () => togglePanel(false)); +} + +function setupResetButtons(runSerializedRefresh) { + $("reset").addEventListener("click", () => + adminAction(adminApi.reset, t("admin.resetDone"), runSerializedRefresh), + ); + $("factory-reset").addEventListener("click", () => + adminAction( + adminApi.factoryReset, + t("admin.factoryResetDone"), + runSerializedRefresh, + ), + ); +} + +function setupResultsToggle(runSerializedRefresh) { + const resultsToggle = $("results-open"); + if (!resultsToggle) return; + + resultsToggle.addEventListener("change", async (e) => { + const desired = !!e.target.checked; + try { + const resp = await adminApi.setResultsOpen(desired); + const wasResultsOpen = state.resultsOpen; + const wasPhase = state.phase; + state.resultsOpen = resp.resultsOpen; + if (wasResultsOpen && !resp.resultsOpen && wasPhase === "Results") { + openResultsRelockModal(); + } + renderPhasePill(); + toast(t("admin.resultsUpdated")); + await runSerializedRefresh(); + } catch (err) { + e.target.checked = !desired; + toast(err.message, true); + } + }); +} + +function setupLinkApply(runSerializedRefresh) { + const linkApply = $("link-apply"); + if (!linkApply) return; + + linkApply.addEventListener("click", async () => { + const source = Number($("link-source")?.value); + const target = Number($("link-target")?.value); + if (!source || !target || source === target) { + return toast(t("admin.linkValidation"), true); + } + try { + await adminApi.linkSuggestions(source, target); + toast(t("admin.linkDone")); + await runSerializedRefresh(); + } catch (err) { + toast(err.message, true); + } + }); +} + +function setupPlayerTableActions(runSerializedRefresh) { + const playerTable = $("admin-player-table"); + if (!playerTable) return; + + playerTable.addEventListener("click", async (e) => { + const grantBtn = e.target.closest("[data-grant-joker]"); + const deleteBtn = e.target.closest("[data-delete-player]"); + if (grantBtn) { + const playerId = grantBtn.dataset.grantJoker; + try { + await adminApi.grantJoker(playerId); + toast(t("admin.jokerGranted")); + await runSerializedRefresh(); + } catch (err) { + toast(err.message, true); + } + } else if (deleteBtn) { + const playerId = deleteBtn.dataset.deletePlayer; + const name = deleteBtn.dataset.name || ""; + openConfirmModal({ + title: t("admin.deleteTitle"), + body: t("admin.deleteBody", { name }), + confirmLabel: t("admin.deleteConfirm"), + onConfirm: async (close) => { + try { + await adminApi.deletePlayer(playerId); + toast(t("admin.deleteDone")); + close(); + await runSerializedRefresh(); + } catch (err) { + toast(err.message, true); + } + }, + }); + } + }); +} + +export function setupAdminHandlers({ runSerializedRefresh }) { + setupResetButtons(runSerializedRefresh); + setupAdminPanelToggle(); + setupResultsToggle(runSerializedRefresh); + setupLinkApply(runSerializedRefresh); + setupPlayerTableActions(runSerializedRefresh); +} diff --git a/wwwroot/js/app-auth-handlers.js b/wwwroot/js/app-auth-handlers.js new file mode 100644 index 0000000..70fdf31 --- /dev/null +++ b/wwwroot/js/app-auth-handlers.js @@ -0,0 +1,192 @@ +import { api } from "./api.js"; +import { t } from "./i18n.js"; +import { state, clearUserState, setSavedUsername } from "./state.js"; +import { $, toast } from "./dom.js"; +import { + handleAuthError, + openNewSuggestionModal, + setAuthMode, + setAuthUI, +} from "./ui.js"; + +function setupConsentRows() { + const hasConsent = () => + document.cookie + .split(";") + .some((c) => c.trim().startsWith("cookie_consent=1")); + const setConsent = () => { + document.cookie = + "cookie_consent=1; path=/; max-age=31536000; SameSite=Lax"; + }; + const consentRows = document.querySelectorAll(".consent-row"); + const toggleConsentRows = () => { + const hide = hasConsent(); + consentRows.forEach((row) => row.classList.toggle("hidden", hide)); + }; + + toggleConsentRows(); + ["login-consent", "register-consent"].forEach((id) => { + const box = $(id); + if (box) { + box.checked = hasConsent(); + } + }); + + return { hasConsent, setConsent, toggleConsentRows }; +} + +function setupAuthModeToggle() { + const toggleAuth = $("auth-toggle"); + if (toggleAuth) { + toggleAuth.addEventListener("click", (e) => { + e.preventDefault(); + setAuthMode(state.authMode === "login" ? "register" : "login"); + }); + } + setAuthMode(state.authMode); +} + +function setupLoginUserEditingHint() { + const loginUser = $("login-username"); + if (!loginUser) return; + + const markEditing = () => { + loginUser.dataset.userEditing = "1"; + }; + ["focus", "input", "keydown"].forEach((evt) => + loginUser.addEventListener(evt, markEditing), + ); + loginUser.addEventListener("blur", () => { + delete loginUser.dataset.userEditing; + }); +} + +function setupLoginFormHandlers({ + hasConsent, + setConsent, + toggleConsentRows, + runSerializedRefresh, +}) { + const loginForm = $("login-form"); + if (!loginForm) return; + + 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); + if (!hasConsent() && !$("login-consent")?.checked) + return toast(t("auth.cookieRequired"), true); + try { + await api.login({ username, password }); + setConsent(); + toggleConsentRows(); + setSavedUsername(username); + state.isAuthenticated = true; + setAuthUI(true); + await runSerializedRefresh(); + toast(t("toast.loggedIn")); + } catch (err) { + if (err?.status === 401) + return toast(t("auth.invalidCredentials"), true); + if (handleAuthError(err, clearUserState)) return; + } + }); +} + +function setupRegisterFormHandlers({ + hasConsent, + setConsent, + toggleConsentRows, + runSerializedRefresh, +}) { + const registerForm = $("register-form"); + if (!registerForm) return; + + 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 || !password) + return toast(t("auth.needCredentials"), true); + if (!hasConsent() && !$("register-consent")?.checked) + return toast(t("auth.cookieRequired"), true); + try { + await api.register({ username, password, displayName, adminKey }); + setConsent(); + toggleConsentRows(); + setSavedUsername(username); + state.isAuthenticated = true; + setAuthUI(true); + await runSerializedRefresh(); + toast(t("toast.registered")); + } catch (err) { + if (handleAuthError(err, clearUserState)) return; + toast(err.message, true); + } + }); +} + +function setupLogoutHandler() { + const logoutBtn = $("logout"); + if (!logoutBtn) return; + + 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 = ""; + } + }); +} + +function setupSuggestionEntryButtons() { + const openSuggestBtn = $("open-suggest-modal"); + if (openSuggestBtn) { + openSuggestBtn.addEventListener("click", (e) => { + e.preventDefault(); + if (openSuggestBtn.disabled) return; + if (state.phase !== "Suggest") return; + openNewSuggestionModal(); + }); + } + + const openJokerBtn = $("open-joker-modal"); + if (openJokerBtn) { + openJokerBtn.addEventListener("click", (e) => { + e.preventDefault(); + if (state.phase !== "Vote" || !state.hasJoker) return; + openNewSuggestionModal(); + }); + } +} + +export function setupAuthHandlers({ runSerializedRefresh }) { + setupAuthModeToggle(); + const consent = setupConsentRows(); + setupLoginUserEditingHint(); + setupLoginFormHandlers({ ...consent, runSerializedRefresh }); + setupRegisterFormHandlers({ ...consent, runSerializedRefresh }); + setupSuggestionEntryButtons(); + setupLogoutHandler(); +} diff --git a/wwwroot/js/app-vote-nav-handlers.js b/wwwroot/js/app-vote-nav-handlers.js new file mode 100644 index 0000000..13b3c24 --- /dev/null +++ b/wwwroot/js/app-vote-nav-handlers.js @@ -0,0 +1,113 @@ +import { api } from "./api.js"; +import { t } from "./i18n.js"; +import { state } from "./state.js"; +import { $, toast } from "./dom.js"; +import { openConfirmModal, renderPhasePill, renderVotes } from "./ui.js"; + +function bindPhaseAdvanceButtons(runSerializedRefresh) { + const makeForward = (id, before) => { + const btn = $(id); + if (!btn) return; + btn.addEventListener("click", async () => { + try { + if (before) { + const proceed = await before(); + if (!proceed) return; + } + const resp = await api.nextPhase(); + state.prevPhase = state.phase; + state.phase = resp.currentPhase; + state.resultsOpen = resp.resultsOpen ?? state.resultsOpen; + state.votesRendered = false; + renderPhasePill(); + await runSerializedRefresh(); + } catch (err) { + toast(err.message, true); + } + }); + }; + + const makeBack = (id) => { + const btn = $(id); + if (!btn) return; + btn.addEventListener("click", async () => { + try { + const resp = await api.prevPhase(); + state.prevPhase = state.phase; + state.phase = resp.currentPhase; + state.resultsOpen = resp.resultsOpen ?? state.resultsOpen; + state.votesRendered = false; + renderPhasePill(); + await runSerializedRefresh(); + } catch (err) { + toast(err.message, true); + } + }); + }; + + makeForward("nav-suggest-next", async () => { + return await new Promise((resolve) => { + openConfirmModal({ + title: t("nav.freezeModalTitle"), + body: t("nav.freezeModalBody"), + confirmLabel: t("nav.next"), + onConfirm: (close) => { + close(); + resolve(true); + }, + }); + }); + }); + makeBack("nav-vote-prev"); +} + +function bindVoteFinalizeButton() { + const finalizeBtn = $("finalize-votes"); + if (!finalizeBtn) return; + + const finalizeVotes = async (desired) => { + await api.finalizeVotes(desired); + state.votesFinal = desired; + renderPhasePill(); + renderVotes(); + toast(desired ? t("vote.finalize") : t("vote.unfinalize")); + }; + + const missingVotes = () => { + const votedIds = new Set( + (state.myVotes ?? []).map((v) => v.suggestionId), + ); + return (state.allSuggestions ?? []).filter((s) => !votedIds.has(s.id)); + }; + + finalizeBtn.addEventListener("click", async () => { + try { + const desired = !state.votesFinal; + if (desired) { + const missing = missingVotes(); + if (missing.length > 0) { + openConfirmModal({ + title: t("vote.finalizeMissingTitle"), + body: t("vote.finalizeMissingBody", { + count: missing.length, + }), + confirmLabel: t("vote.finalizeMissingConfirm"), + onConfirm: async (close) => { + await finalizeVotes(desired); + close(); + }, + }); + return; + } + } + await finalizeVotes(desired); + } catch (err) { + toast(err.message, true); + } + }); +} + +export function setupVoteNavigationHandlers({ runSerializedRefresh }) { + bindPhaseAdvanceButtons(runSerializedRefresh); + bindVoteFinalizeButton(); +}