diff --git a/REVIEW.md b/REVIEW.md
index c04dc6c..5656582 100644
--- a/REVIEW.md
+++ b/REVIEW.md
@@ -6,25 +6,12 @@ This document tracks only active work. Completed work is intentionally omitted a
Active maintainability risks (priority order):
-1. Frontend module concentration and global coupling (Critical)
-- `wwwroot/js/ui.js` remains the dominant hotspot and owns rendering, modal flows, admin flows, and vote logic.
-- Cross-feature coupling still exists via shared mutable state usage across UI/data modules (`wwwroot/js/ui.js:180`, `wwwroot/js/ui.js:401`, `wwwroot/js/ui.js:622`, `wwwroot/js/data.js:82`).
-- Impact: high regression surface and expensive refactors even after removing global `window` bridges.
-
-2. Static analysis and frontend lint guardrails are still missing (Medium)
+1. Static analysis and frontend lint guardrails are still missing (Medium)
- CI currently gates restore/build/test only (`.github/workflows/ci.yml:23`-`.github/workflows/ci.yml:29`).
- Impact: style drift and low-signal warnings can enter the codebase undetected.
## B) Active task list
-[P1] Decompose frontend UI monolith by feature
-- Problem: Severity `High`, Category `Architecture/Complexity`. `ui.js` still mixes rendering, form behavior, and mutation flows.
-- Evidence: `wwwroot/js/ui.js:180`, `wwwroot/js/ui.js:401`, `wwwroot/js/ui.js:622`, `wwwroot/js/ui.js:903`, `wwwroot/js/data.js:82`.
-- Recommendation: split by feature (`suggestions-ui`, `votes-ui`, `admin-ui`, `modals-ui`) and keep orchestration in a thin composition layer.
-- Acceptance criteria (testable): UI behavior is preserved while feature modules own isolated responsibilities and no single file coordinates all vote/admin/modal/suggestion interactions.
-- Effort / Risk: `L / Med`.
-- Dependencies (if any): none.
-
[P2] Add static analysis and JS lint/format guardrails
- Problem: Severity `Medium`, Category `Tooling`. CI does not enforce analyzers or JS lint/format checks.
- Evidence: `.github/workflows/ci.yml:23`-`.github/workflows/ci.yml:29`.
@@ -43,9 +30,8 @@ Active maintainability risks (priority order):
## C) Suggested execution order
-1. Decompose `ui.js` by feature and keep orchestration thin.
-2. Add analyzers + JS lint gates in CI.
-3. Externalize i18n/FAQ assets.
+1. Add analyzers + JS lint gates in CI.
+2. Externalize i18n/FAQ assets.
## D) Guardrails
diff --git a/wwwroot/js/admin-ui.js b/wwwroot/js/admin-ui.js
new file mode 100644
index 0000000..734b033
--- /dev/null
+++ b/wwwroot/js/admin-ui.js
@@ -0,0 +1,103 @@
+import { t } from "./i18n.js";
+import { state } from "./state.js";
+import { $ } from "./dom.js";
+import { buildLinkOptionLabel, escapeHtml, truncate } from "./ui-utils.js";
+
+function displayPlayerStatus(player) {
+ if (!player) return "";
+ const phase = player.phase;
+ if (phase === "Suggest") return t("admin.statusSuggesting");
+ if (phase === "Vote") return player.finalized ? t("admin.statusFinished") : t("admin.statusVoting");
+ if (phase === "Results") return t("admin.statusFinished");
+ return phase;
+}
+
+export function renderAdminVoteStatus() {
+ if (!state.me?.isAdmin) return;
+ const statusBadge = $("admin-ready-status");
+ const table = $("admin-player-table")?.querySelector("tbody");
+ if (!state.adminVoteStatus || !statusBadge || !table) return;
+
+ table.innerHTML = "";
+ state.adminVoteStatus.voters.forEach((v) => {
+ const tr = document.createElement("tr");
+ const statusText = displayPlayerStatus(v);
+ const gamesTooltip = escapeHtml((v.suggestionTitles || []).join(", "));
+ const nameText = escapeHtml(truncate(v.name, 28));
+ const userText = escapeHtml(truncate(v.username, 24));
+ tr.innerHTML = `
+
${nameText} |
+ ${userText} |
+ ${statusText} |
+ ${v.suggestionCount ?? 0} |
+ |
+ |
+ `;
+ table.appendChild(tr);
+ });
+
+ const waiting = state.adminVoteStatus.waiting;
+ const ready = waiting.length === 0;
+ const waitingDisplay = waiting.map((name) =>
+ name?.length > 24 ? `${name.slice(0, 21)}...` : name,
+ );
+ statusBadge.textContent = ready
+ ? t("admin.readyForResults")
+ : t("admin.waitingForPlayers", { names: waitingDisplay.join(", ") });
+ statusBadge.className = ready ? "badge" : "badge warning";
+}
+
+export function renderAdminLinker() {
+ const wrap = $("admin-linker");
+ const source = $("link-source");
+ const target = $("link-target");
+ if (!wrap || !source || !target) return;
+
+ const visible = state.me?.isAdmin && state.phase === "Vote";
+ wrap.classList.toggle("hidden", !visible);
+ if (!visible) return;
+
+ const previousSource = source.value;
+ const previousTarget = target.value;
+ const options = (state.allSuggestions ?? []).slice().sort((a, b) => a.name.localeCompare(b.name));
+
+ const fillSelect = (select, placeholderKey) => {
+ select.innerHTML = "";
+ const placeholder = document.createElement("option");
+ placeholder.value = "";
+ placeholder.textContent = t(placeholderKey);
+ placeholder.disabled = true;
+ placeholder.selected = true;
+ select.appendChild(placeholder);
+
+ options.forEach((s) => {
+ const opt = document.createElement("option");
+ opt.value = s.id;
+ opt.textContent = buildLinkOptionLabel(s);
+ select.appendChild(opt);
+ });
+ };
+
+ fillSelect(source, "admin.linkSourcePlaceholder");
+ fillSelect(target, "admin.linkTargetPlaceholder");
+
+ if (previousSource && options.some((s) => String(s.id) === previousSource)) source.value = previousSource;
+ if (previousTarget && options.some((s) => String(s.id) === previousTarget)) target.value = previousTarget;
+
+ const preventSameSelection = () => {
+ const sourceVal = source.value;
+ const targetVal = target.value;
+ Array.from(target.options).forEach((opt) => {
+ if (!opt.value) return;
+ opt.disabled = opt.value === sourceVal;
+ });
+ Array.from(source.options).forEach((opt) => {
+ if (!opt.value) return;
+ opt.disabled = opt.value === targetVal;
+ });
+ };
+
+ source.onchange = preventSameSelection;
+ target.onchange = preventSameSelection;
+ preventSameSelection();
+}
diff --git a/wwwroot/js/modals-ui.js b/wwwroot/js/modals-ui.js
new file mode 100644
index 0000000..91c3c06
--- /dev/null
+++ b/wwwroot/js/modals-ui.js
@@ -0,0 +1,97 @@
+import { t } from "./i18n.js";
+import { toast } from "./dom.js";
+import { escapeHtml } from "./ui-utils.js";
+
+export function openLightbox(url, title) {
+ const overlay = document.createElement("div");
+ overlay.className = "lightbox";
+ const safeTitle = escapeHtml(title || "");
+ overlay.innerHTML = `
+
+
+

+
${safeTitle}
+
+ `;
+ 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 = `
+
+
+ `;
+ const close = () => overlay.remove();
+ const actions = document.createElement("div");
+ actions.className = "stack horizontal";
+ const confirmBtn = document.createElement("button");
+ confirmBtn.textContent = confirmLabel ?? t("modal.confirm");
+ actions.append(confirmBtn);
+ if (cancelLabel !== null && cancelLabel !== undefined) {
+ const cancelBtn = document.createElement("button");
+ cancelBtn.className = "ghost";
+ cancelBtn.type = "button";
+ cancelBtn.textContent = cancelLabel;
+ actions.append(cancelBtn);
+ cancelBtn.addEventListener("click", close);
+ }
+ panel.querySelector(".edit-body")?.appendChild(actions);
+
+ overlay.addEventListener("click", (e) => {
+ if (
+ e.target.classList.contains("edit-modal") ||
+ e.target.classList.contains("lightbox-close")
+ ) {
+ close();
+ }
+ });
+ confirmBtn.addEventListener("click", async () => {
+ try {
+ await onConfirm?.(close);
+ } catch (err) {
+ toast(err.message, true);
+ }
+ });
+
+ overlay.appendChild(panel);
+ document.body.appendChild(overlay);
+}
+
+export function openResultsRelockModal() {
+ openConfirmModal({
+ title: t("results.relockedTitle"),
+ body: t("results.relockedBody"),
+ confirmLabel: t("results.relockedConfirm"),
+ cancelLabel: null,
+ onConfirm: (close) => close(),
+ });
+}
+
+export function openSuggestionsChangedModal(names) {
+ const uniq = Array.from(new Set(names)).filter(Boolean);
+ if (uniq.length === 0) return;
+ openConfirmModal({
+ title: t("vote.listUpdatedTitle"),
+ body: t("vote.listUpdatedBody", { names: uniq.join(", ") }),
+ confirmLabel: t("vote.listUpdatedConfirm"),
+ cancelLabel: null,
+ onConfirm: (close) => close(),
+ });
+}
diff --git a/wwwroot/js/results-ui.js b/wwwroot/js/results-ui.js
new file mode 100644
index 0000000..48051e9
--- /dev/null
+++ b/wwwroot/js/results-ui.js
@@ -0,0 +1,98 @@
+import { t } from "./i18n.js";
+import { state } from "./state.js";
+import { $ } from "./dom.js";
+import { linkRootId, renderLinkBadge, escapeHtml, safeUrl } from "./ui-utils.js";
+import { scoreToEmoji } from "./votes-ui.js";
+import { openLightbox } from "./modals-ui.js";
+
+export function renderResults() {
+ const container = $("results-list");
+ if (!container) return;
+ container.innerHTML = "";
+ const table = document.createElement("table");
+ table.className = "results-table";
+ table.innerHTML = `
+
+
+ | ${t("results.rank")} |
+ ${t("results.game")} |
+ ${t("results.author")} |
+ ${t("results.average")} |
+ ${t("results.votesList")} |
+ ${t("results.myVote")} |
+ ${t("results.links")} |
+
+
+
+ `;
+ const tbody = table.querySelector("tbody");
+ const rankByRoot = new Map();
+ let nextRank = 1;
+ state.results.forEach((r) => {
+ const root = linkRootId(r);
+ let rank = rankByRoot.get(root);
+ if (!rank) {
+ rank = nextRank++;
+ rankByRoot.set(root, rank);
+ }
+ const medal = rank === 1 ? "🥇" : rank === 2 ? "🥈" : rank === 3 ? "🥉" : `${rank}`;
+ const row = document.createElement("tr");
+ const podiumClass = rank === 1 ? "podium podium-1" : rank === 2 ? "podium podium-2" : rank === 3 ? "podium podium-3" : "";
+ row.className = podiumClass;
+ const safeName = escapeHtml(r.name);
+ const safeAuthor = escapeHtml(r.author ?? "—");
+ const safeShot = safeUrl(r.screenshotUrl);
+ const safeGameUrl = safeUrl(r.gameUrl);
+ const safeYoutubeUrl = safeUrl(r.youtubeUrl);
+ row.innerHTML = `
+ ${medal} |
+
+ ${safeShot ? ` ` : ""}
+
+ |
+ ${safeAuthor || "—"} |
+ ${r.average?.toFixed ? r.average.toFixed(1) : r.average} |
+ ${formatVotes(r.votes)} |
+ ${formatMyVote(r.myVote)} |
+
+ ${safeGameUrl ? `${t("results.link.site")} ` : ""}
+ ${safeYoutubeUrl ? `${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 ? escapeHtml(r.genre) : null, players].filter(Boolean);
+ if (bits.length === 0) return "";
+ return `${bits.join(" • ")}
`;
+}
+
+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)}`;
+}
diff --git a/wwwroot/js/suggestions-ui.js b/wwwroot/js/suggestions-ui.js
new file mode 100644
index 0000000..1fd1d3f
--- /dev/null
+++ b/wwwroot/js/suggestions-ui.js
@@ -0,0 +1,470 @@
+import { api, adminApi } from "./api.js";
+import { t } from "./i18n.js";
+import { state } from "./state.js";
+import { $, toast } from "./dom.js";
+import { setupCardVisualHover, triggerCelebration } from "./effects.js";
+import { renderAdminLinker } from "./admin-ui.js";
+import { getUiRuntime } from "./ui-runtime.js";
+import {
+ cssEscapeUrl,
+ escapeHtml,
+ isLinked,
+ linkedPeerTitles,
+ renderLinkBadge,
+ safeUrl,
+ sortByName,
+} from "./ui-utils.js";
+import { openConfirmModal, openLightbox } from "./modals-ui.js";
+
+function updateSuggestButtonState() {
+ const btn = $("open-suggest-modal");
+ if (!btn) return;
+ const limit = 5;
+ const count = state.mySuggestions?.length ?? 0;
+ const blocked = count >= limit;
+ btn.disabled = blocked || state.phase !== "Suggest";
+ btn.textContent = blocked ? t("suggest.maxReached") : t("suggest.addButton");
+}
+
+export function renderMySuggestions() {
+ const wrap = $("my-suggestions");
+ if (!wrap) return;
+ wrap.innerHTML = "";
+ const allowEdit = true;
+ const lockTitle = state.phase !== "Suggest" && !state.me?.isAdmin;
+ const allowDelete = state.phase === "Suggest" || state.me?.isAdmin;
+ sortByName(state.mySuggestions).forEach((s) =>
+ wrap.appendChild(
+ buildCard(s, { showAuthor: false, allowDelete, allowEdit, lockTitle }),
+ ),
+ );
+ updateSuggestButtonState();
+}
+
+export function renderAllSuggestions() {
+ renderAdminLinker();
+ const list = $("all-suggestions");
+ if (!list) return;
+ list.innerHTML = "";
+ const allowEdit = true;
+ const allowDelete = !!state.me?.isAdmin;
+ sortByName(state.allSuggestions).forEach((s) =>
+ list.appendChild(
+ buildCard(s, { showAuthor: true, allowEdit, allowDelete }),
+ ),
+ );
+ renderPhaseTitles();
+}
+
+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 safeShot = safeUrl(s.screenshotUrl);
+ const nameText = escapeHtml(s.name);
+ const genreText = escapeHtml(s.genre);
+ const descText = escapeHtml(s.description);
+ const authorText = escapeHtml(s.author);
+ const safeGameUrl = safeUrl(s.gameUrl);
+ const safeYoutubeUrl = safeUrl(s.youtubeUrl);
+ const linkedTitles = linkedPeerTitles(s);
+ const linked = isLinked(s);
+ const linkTooltipText = linked
+ ? linkedTitles.length > 0
+ ? t("card.linkedWith", { names: linkedTitles.join(", ") })
+ : t("card.linked")
+ : "";
+ const linkTooltipSafe = escapeHtml(linkTooltipText);
+ const linkChip = linked
+ ? ``
+ : "";
+ const visual = hasImage && safeShot
+ ? ``
+ : ``;
+ const hasPlayers = s.minPlayers || s.maxPlayers;
+ const players = hasPlayers
+ ? `${t("card.players", {
+ min: s.minPlayers ?? "?",
+ max: s.maxPlayers ?? "?",
+ })}`
+ : "";
+ const genreAndPlayers = s.genre
+ ? hasPlayers
+ ? `${genreText} • ${players}`
+ : genreText
+ : hasPlayers
+ ? players
+ : undefined;
+ const hasExtraInfo = genreAndPlayers || safeGameUrl || safeYoutubeUrl;
+ card.innerHTML = `
+ ${visual}
+
+
+
${nameText}
+
+ ${linkChip}
+ ${showAuthor && s.author ? `${authorText}` : ""}
+ ${allowEdit ? `` : ""}
+ ${allowDelete ? `` : ""}
+
+
+ ${hasExtraInfo ? `
` : ""}
+ ${genreAndPlayers ? genreAndPlayers : ""}
+ ${safeGameUrl ? `${t("card.site")}` : ""}
+ ${safeYoutubeUrl ? `${t("card.youtube")}` : ""}
+ ${hasExtraInfo ? `
` : ""}
+ ${s.description ? `
${descText}
` : ""}
+
+ `;
+ if (hasImage) {
+ const btn = card.querySelector(".card-visual");
+ setupCardVisualHover(btn, safeShot);
+ btn.addEventListener("click", () => openLightbox(safeShot, s.name));
+ }
+ if (linked && state.me?.isAdmin) {
+ const unlinkBtn = card.querySelector("[data-unlink]");
+ unlinkBtn?.addEventListener("click", () => openUnlinkConfirm(s));
+ }
+ 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 getUiRuntime().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 = `
+
+
+ `;
+
+ 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));
+ const errorBox = form.querySelector('[data-error="players"]');
+ const minInput = form.querySelector('input[name="minPlayers"]');
+ const maxInput = form.querySelector('input[name="maxPlayers"]');
+ const markError = (msg) => {
+ if (errorBox) {
+ errorBox.textContent = msg;
+ errorBox.classList.remove("hidden");
+ }
+ };
+ const clearError = () => {
+ if (errorBox) errorBox.classList.add("hidden");
+ };
+ clearError();
+ const min = data.minPlayers;
+ const max = data.maxPlayers;
+ const inRange = (v) => v == null || (Number.isInteger(v) && v >= 1 && v <= 32);
+ const valid =
+ inRange(min) &&
+ inRange(max) &&
+ (min == null || max == null || min <= max);
+ [minInput, maxInput].forEach((el) =>
+ el?.classList.toggle("input-error", !valid),
+ );
+ if (!valid) {
+ markError(t("form.playersInvalid"));
+ return;
+ }
+ if (!data.name?.trim()) return toast(t("toast.nameRequired"), true);
+ try {
+ await onSubmit(data, close, submitBtn);
+ } catch (err) {
+ if (getUiRuntime().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) => {
+ const wasVotePhase = state.phase === "Vote";
+ await api.createSuggestion(data);
+ toast(t("toast.suggestionAdded"));
+ if (submitBtn) triggerCelebration(submitBtn);
+ close();
+ if (wasVotePhase) {
+ await getUiRuntime().refreshPhaseData();
+ } else {
+ await getUiRuntime().loadSuggestData();
+ }
+ },
+ });
+}
+
+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),
+ };
+}
+
+function openDeleteConfirmModal(s) {
+ const overlay = document.createElement("div");
+ overlay.className = "edit-modal";
+ const panel = document.createElement("div");
+ panel.className = "edit-panel";
+ panel.innerHTML = `
+
+
+ `;
+
+ const preview = buildCard(
+ { ...s, id: s.id },
+ { 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 getUiRuntime().loadSuggestData();
+ } catch (err) {
+ toast(err.message, true);
+ }
+ });
+
+ overlay.appendChild(panel);
+ document.body.appendChild(overlay);
+}
+
+function openUnlinkConfirm(s) {
+ const peers = linkedPeerTitles(s);
+ const names = peers.length ? peers.join(", ") : t("admin.unlinkUnknownPeers");
+ openConfirmModal({
+ title: t("admin.unlinkTitle"),
+ body: t("admin.unlinkBody", { name: s.name, peers: names }),
+ confirmLabel: t("admin.unlinkConfirm"),
+ cancelLabel: t("modal.cancel"),
+ onConfirm: async (close) => {
+ try {
+ await adminApi.unlinkSuggestions(s.id);
+ toast(t("admin.unlinkDone"));
+ close();
+ await getUiRuntime().refreshPhaseData();
+ } catch (err) {
+ toast(err.message, true);
+ }
+ },
+ });
+}
diff --git a/wwwroot/js/ui-runtime.js b/wwwroot/js/ui-runtime.js
new file mode 100644
index 0000000..4f64259
--- /dev/null
+++ b/wwwroot/js/ui-runtime.js
@@ -0,0 +1,14 @@
+const runtime = {
+ refreshPhaseData: async () => {},
+ loadSuggestData: async () => {},
+ loadVoteData: async () => {},
+ handleAuthError: () => false,
+};
+
+export function configureUiRuntime(deps) {
+ Object.assign(runtime, deps ?? {});
+}
+
+export function getUiRuntime() {
+ return runtime;
+}
diff --git a/wwwroot/js/ui-utils.js b/wwwroot/js/ui-utils.js
new file mode 100644
index 0000000..cefbacf
--- /dev/null
+++ b/wwwroot/js/ui-utils.js
@@ -0,0 +1,83 @@
+import { t } from "./i18n.js";
+import { state } from "./state.js";
+
+export const sortByName = (items) =>
+ (items ?? [])
+ .slice()
+ .sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: "base" }));
+
+export const truncate = (text, max) => {
+ if (!text) return "";
+ return text.length > max ? `${text.slice(0, max - 1)}...` : text;
+};
+
+export const escapeHtml = (value) =>
+ (value ?? "")
+ .toString()
+ .replace(/&/g, "&")
+ .replace(//g, ">")
+ .replace(/"/g, """)
+ .replace(/'/g, "'");
+
+export const safeUrl = (url) => {
+ if (!url) return null;
+ try {
+ const u = new URL(url);
+ if (u.protocol === "http:" || u.protocol === "https:") return u.href;
+ } catch {
+ return null;
+ }
+ return null;
+};
+
+export const cssEscapeUrl = (url) => url.replace(/['")\\]/g, "\\$&");
+
+export function linkRootId(s) {
+ return s?.parentSuggestionId ?? s?.id;
+}
+
+export function linkedPeerIds(s) {
+ if (!s) return [];
+ if (Array.isArray(s.linkedIds) && s.linkedIds.length > 0) {
+ return s.linkedIds.filter((id) => id !== s.id);
+ }
+ if (!state.allSuggestions?.length) return [];
+ const root = linkRootId(s);
+ return state.allSuggestions
+ .filter((other) => linkRootId(other) === root && other.id !== s.id)
+ .map((other) => other.id);
+}
+
+export function linkedPeerTitles(s) {
+ if (!s) return [];
+ if (Array.isArray(s.linkedTitles) && s.linkedTitles.length > 0) {
+ return s.linkedTitles;
+ }
+ if (!state.allSuggestions?.length) return [];
+ const root = linkRootId(s);
+ return state.allSuggestions
+ .filter((other) => linkRootId(other) === root && other.id !== s.id)
+ .map((other) => other.name);
+}
+
+export function isLinked(s) {
+ return !!s?.parentSuggestionId || linkedPeerIds(s).length > 0;
+}
+
+export function linkTooltip(s) {
+ const peers = linkedPeerTitles(s);
+ if (peers.length === 0) return t("card.linked");
+ return t("card.linkedWith", { names: peers.join(", ") });
+}
+
+export function renderLinkBadge(s) {
+ if (!isLinked(s)) return "";
+ return `🔗`;
+}
+
+export function buildLinkOptionLabel(s) {
+ const author = s.author ? ` - ${s.author}` : "";
+ const linked = isLinked(s) ? " 🔗" : "";
+ return `${s.name}${author}${linked}`;
+}
diff --git a/wwwroot/js/ui.js b/wwwroot/js/ui.js
index a0b0c57..8d3dec9 100644
--- a/wwwroot/js/ui.js
+++ b/wwwroot/js/ui.js
@@ -1,48 +1,18 @@
-import { api } from "./api.js";
import { t } from "./i18n.js";
-import { state, getSavedUsername, setSavedUsername } from "./state.js";
+import { state, getSavedUsername } from "./state.js";
import { $, toast } from "./dom.js";
-import { setupCardVisualHover, triggerCelebration } from "./effects.js";
-import { adminApi } from "./api.js";
-
-const sortByName = (items) =>
- (items ?? [])
- .slice()
- .sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: "base" }));
-const truncate = (text, max) => {
- if (!text) return "";
- return text.length > max ? `${text.slice(0, max - 1)}…` : text;
-};
-const escapeHtml = (value) =>
- (value ?? "")
- .toString()
- .replace(/&/g, "&")
- .replace(//g, ">")
- .replace(/"/g, """)
- .replace(/'/g, "'");
-const safeUrl = (url) => {
- if (!url) return null;
- try {
- const u = new URL(url);
- if (u.protocol === "http:" || u.protocol === "https:") return u.href;
- } catch {
- return null;
- }
- return null;
-};
-const cssEscapeUrl = (url) => url.replace(/['")\\]/g, "\\$&");
-
-const runtime = {
- refreshPhaseData: async () => { },
- loadSuggestData: async () => { },
- loadVoteData: async () => { },
- handleAuthError: () => false,
-};
-
-export function configureUiRuntime(deps) {
- Object.assign(runtime, deps ?? {});
-}
+import { configureUiRuntime } from "./ui-runtime.js";
+import {
+ renderMySuggestions,
+ renderAllSuggestions,
+ renderPhaseTitles,
+ buildCard,
+ openNewSuggestionModal,
+ normalizeSuggestionForm,
+} from "./suggestions-ui.js";
+import { renderVotes, scoreToEmoji, syncVoteScores, neutralEmoji, updatePhaseNav } from "./votes-ui.js";
+import { renderResults } from "./results-ui.js";
+import { openConfirmModal, openLightbox, openResultsRelockModal, openSuggestionsChangedModal } from "./modals-ui.js";
export function setAuthUI(isAuthed) {
const main = document.querySelector("main");
@@ -137,1036 +107,22 @@ export function renderWelcome() {
el.textContent = t("auth.welcome", { name });
}
-function updateSuggestButtonState() {
- const btn = $("open-suggest-modal");
- if (!btn) return;
- const limit = 5;
- const count = state.mySuggestions?.length ?? 0;
- const blocked = count >= limit;
- btn.disabled = blocked || state.phase !== "Suggest";
- btn.textContent = blocked ? t("suggest.maxReached") : t("suggest.addButton");
-}
-
-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;
- sortByName(state.mySuggestions).forEach((s) =>
- wrap.appendChild(
- buildCard(s, { showAuthor: false, allowDelete, allowEdit, lockTitle }),
- ),
- );
- updateSuggestButtonState();
-}
-
-export function renderAllSuggestions() {
- renderAdminLinker();
- const list = $("all-suggestions");
- if (!list) return;
- list.innerHTML = "";
- const allowEdit = true; // allow own edits (optional fields) in vote
- const allowDelete = !!state.me?.isAdmin;
- sortByName(state.allSuggestions).forEach((s) =>
- list.appendChild(
- buildCard(s, { showAuthor: true, allowEdit, allowDelete }),
- ),
- );
- renderPhaseTitles();
-}
-
-export function renderVotes() {
- const list = $("vote-list");
- if (!list) return;
- const prevScroll = list.scrollTop;
- list.innerHTML = "";
- const votesMap = Object.fromEntries(
- state.myVotes.map((v) => [v.suggestionId, v.score]),
- );
- sortByName(state.allSuggestions).forEach((s) => {
- const canEdit = !!state.me?.isAdmin || s.isOwner;
- const lockTitle = state.phase !== "Suggest" && !state.me?.isAdmin;
- const li = buildCard(s, {
- showAuthor: true,
- allowEdit: canEdit,
- allowDelete: !!state.me?.isAdmin,
- lockTitle,
- });
- 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) : "⚠️";
- const linkedIds = linkedPeerIds(s);
- const rootId = linkRootId(s);
- const footer = document.createElement("div");
- footer.className = "vote-controls";
- footer.innerHTML = `
- ${state.votesFinal ? t("vote.missingFinalWarn") : t("vote.missingWarn")}
-
-
- ${displayScore}
- ${displayEmoji}
-
`;
- li.querySelector(".card-body").appendChild(footer);
- list.appendChild(li);
- });
- updatePhaseNav();
- updateMissingBadgeFromDom();
- list.scrollTop = prevScroll;
- list.querySelectorAll("input[type=range]").forEach((input) => {
- input.addEventListener("input", (e) => {
- if (state.votesFinal) return;
- const val = Number(e.target.value);
- const id = e.target.dataset.id;
- $("score-" + id).textContent = val;
- const emojiEl = $("emoji-" + id);
- if (emojiEl) emojiEl.textContent = scoreToEmoji(val);
- const warn = $("warn-" + id);
- if (warn) warn.classList.remove("hidden");
- e.target.dataset.pending = "1";
- syncLinkedSliders(e.target, val);
- updateMissingBadgeFromDom();
- });
- input.addEventListener("change", async (e) => {
- if (state.votesFinal) return;
- const suggestionId = Number(e.target.dataset.id);
- const score = Number(e.target.value);
- const prevScore = votesMap[suggestionId];
- const linkedIds = (e.target.dataset.linked || "")
- .split(",")
- .filter(Boolean)
- .map((x) => Number(x));
- const resetUi = () => {
- const label = $("score-" + suggestionId);
- const emoji = $("emoji-" + suggestionId);
- const warn = $("warn-" + suggestionId);
- const fallbackValue = prevScore ?? 5;
- const fallbackDisplay = prevScore ?? "—";
- const fallbackEmoji = prevScore != null ? scoreToEmoji(prevScore) : "⚠️";
- e.target.value = fallbackValue;
- if (label) label.textContent = fallbackDisplay;
- if (emoji) emoji.textContent = fallbackEmoji;
- if (warn) warn.classList.remove("hidden");
- };
- try {
- await api.vote(suggestionId, score);
- toast(t("vote.saved"));
- delete e.target.dataset.pending;
- const warn = $("warn-" + suggestionId);
- if (warn) warn.classList.add("hidden");
- linkedIds.forEach((id) => {
- const peerWarn = $("warn-" + id);
- if (peerWarn) peerWarn.classList.add("hidden");
- const peerSlider = document.querySelector(`input[type=range][data-id="${id}"]`);
- if (peerSlider) delete peerSlider.dataset.pending;
- });
- await runtime.loadVoteData();
- updateMissingBadgeFromDom();
- } catch (err) {
- delete e.target.dataset.pending;
- resetUi();
- 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);
- const warn = $("warn-" + id);
- if (slider && score != null) {
- slider.value = score;
- if (scoreLabel) scoreLabel.textContent = score;
- if (emoji) emoji.textContent = scoreToEmoji(score);
- if (warn) warn.classList.add("hidden");
- delete slider.dataset.pending;
- }
- });
- 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);
- const warn = $("warn-" + id);
- if (scoreLabel) scoreLabel.textContent = "—";
- if (emoji) emoji.textContent = neutralEmoji();
- if (warn) warn.classList.remove("hidden");
- });
-}
-
-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.average")} |
- ${t("results.votesList")} |
- ${t("results.myVote")} |
- ${t("results.links")} |
-
-
-
- `;
- const tbody = table.querySelector("tbody");
- const rankByRoot = new Map();
- let nextRank = 1;
- state.results.forEach((r) => {
- const root = linkRootId(r);
- let rank = rankByRoot.get(root);
- if (!rank) {
- rank = nextRank++;
- rankByRoot.set(root, rank);
- }
- const medal = rank === 1 ? "🥇" : rank === 2 ? "🥈" : rank === 3 ? "🥉" : `${rank}`;
- const row = document.createElement("tr");
- const podiumClass = rank === 1 ? "podium podium-1" : rank === 2 ? "podium podium-2" : rank === 3 ? "podium podium-3" : "";
- row.className = podiumClass;
- const safeName = escapeHtml(r.name);
- const safeAuthor = escapeHtml(r.author ?? "—");
- const safeShot = safeUrl(r.screenshotUrl);
- const safeGameUrl = safeUrl(r.gameUrl);
- const safeYoutubeUrl = safeUrl(r.youtubeUrl);
- row.innerHTML = `
- ${medal} |
-
- ${safeShot ? ` ` : ''}
-
- |
- ${safeAuthor || "—"} |
- ${r.average?.toFixed ? r.average.toFixed(1) : r.average} |
- ${formatVotes(r.votes)} |
- ${formatMyVote(r.myVote)} |
-
- ${safeGameUrl ? `${t("results.link.site")} ` : ''}
- ${safeYoutubeUrl ? `${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 ? escapeHtml(r.genre) : null, 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 safeShot = safeUrl(s.screenshotUrl);
- const nameText = escapeHtml(s.name);
- const genreText = escapeHtml(s.genre);
- const descText = escapeHtml(s.description);
- const authorText = escapeHtml(s.author);
- const safeGameUrl = safeUrl(s.gameUrl);
- const safeYoutubeUrl = safeUrl(s.youtubeUrl);
- const linkedTitles = linkedPeerTitles(s);
- const linked = isLinked(s);
- const linkTooltip = linked
- ? linkedTitles.length > 0
- ? t("card.linkedWith", { names: linkedTitles.join(", ") })
- : t("card.linked")
- : "";
- const linkTooltipSafe = escapeHtml(linkTooltip);
- const linkChip = linked
- ? ``
- : "";
- const visual = hasImage && safeShot
- ? ``
- : ``;
- const hasPlayers = s.minPlayers || s.maxPlayers;
- const players = hasPlayers
- ? `${t("card.players", {
- min: s.minPlayers ?? "?",
- max: s.maxPlayers ?? "?",
- })}`
- : "";
- const genreAndPlayers = s.genre
- ? hasPlayers
- ? `${genreText} • ${players}`
- : genreText
- : hasPlayers
- ? players
- : undefined;
- const hasExtraInfo = genreAndPlayers || safeGameUrl || safeYoutubeUrl;
- card.innerHTML = `
- ${visual}
-
-
-
${nameText}
-
- ${linkChip}
- ${showAuthor && s.author ? `${authorText}` : ""}
- ${allowEdit ? `` : ""}
- ${allowDelete ? `` : ""}
-
-
- ${hasExtraInfo ? `
` : ""}
- ${genreAndPlayers ? genreAndPlayers : ""}
- ${safeGameUrl ? `${t("card.site")}` : ""}
- ${safeYoutubeUrl ? `${t("card.youtube")}` : ""}
- ${hasExtraInfo ? `
` : ""}
- ${s.description ? `
${descText}
` : ""}
-
- `;
- if (hasImage) {
- const btn = card.querySelector(".card-visual");
- setupCardVisualHover(btn, safeShot);
- btn.addEventListener("click", () => openLightbox(safeShot, s.name));
- }
- if (linked && state.me?.isAdmin) {
- const unlinkBtn = card.querySelector("[data-unlink]");
- unlinkBtn?.addEventListener("click", () => openUnlinkConfirm(s));
- }
- 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 runtime.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 = `
-
-
- `;
-
- 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));
- const errorBox = form.querySelector('[data-error="players"]');
- const minInput = form.querySelector('input[name="minPlayers"]');
- const maxInput = form.querySelector('input[name="maxPlayers"]');
- const markError = (msg) => {
- if (errorBox) {
- errorBox.textContent = msg;
- errorBox.classList.remove("hidden");
- }
- };
- const clearError = () => {
- if (errorBox) errorBox.classList.add("hidden");
- };
- clearError();
- const min = data.minPlayers;
- const max = data.maxPlayers;
- const inRange = (v) => v == null || (Number.isInteger(v) && v >= 1 && v <= 32);
- const valid =
- inRange(min) &&
- inRange(max) &&
- (min == null || max == null || min <= max);
- [minInput, maxInput].forEach((el) =>
- el?.classList.toggle("input-error", !valid),
- );
- if (!valid) {
- markError(t("form.playersInvalid"));
- return;
- }
- if (!data.name?.trim()) return toast(t("toast.nameRequired"), true);
- try {
- await onSubmit(data, close, submitBtn);
- } catch (err) {
- if (runtime.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) => {
- const wasVotePhase = state.phase === "Vote";
- await api.createSuggestion(data);
- toast(t("toast.suggestionAdded"));
- if (submitBtn) triggerCelebration(submitBtn);
- close();
- if (wasVotePhase) {
- await runtime.refreshPhaseData();
- } else {
- await runtime.loadSuggestData();
- }
- },
- });
-}
-
-export function openLightbox(url, title) {
- const overlay = document.createElement("div");
- overlay.className = "lightbox";
- const safeTitle = escapeHtml(title || "");
- overlay.innerHTML = `
-
-
-

-
${safeTitle}
-
- `;
- overlay.addEventListener("click", (e) => {
- if (
- e.target.classList.contains("lightbox") ||
- e.target.classList.contains("lightbox-close")
- ) {
- overlay.remove();
- }
- });
- document.body.appendChild(overlay);
-}
-
-export function openResultsRelockModal() {
- openConfirmModal({
- title: t("results.relockedTitle"),
- body: t("results.relockedBody"),
- confirmLabel: t("results.relockedConfirm"),
- cancelLabel: null,
- onConfirm: (close) => close(),
- });
-}
-
-export function openSuggestionsChangedModal(names) {
- const uniq = Array.from(new Set(names)).filter(Boolean);
- if (uniq.length === 0) return;
- openConfirmModal({
- title: t("vote.listUpdatedTitle"),
- body: t("vote.listUpdatedBody", { names: uniq.join(", ") }),
- confirmLabel: t("vote.listUpdatedConfirm"),
- cancelLabel: null,
- onConfirm: (close) => close(),
- });
-}
-
-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 = `
-
-
- `;
- const close = () => overlay.remove();
- const actions = document.createElement("div");
- actions.className = "stack horizontal";
- const confirmBtn = document.createElement("button");
- confirmBtn.textContent = confirmLabel ?? t("modal.confirm");
- actions.append(confirmBtn);
- if (cancelLabel !== null && cancelLabel !== undefined) {
- const cancelBtn = document.createElement("button");
- cancelBtn.className = "ghost";
- cancelBtn.type = "button";
- cancelBtn.textContent = cancelLabel;
- actions.append(cancelBtn);
- cancelBtn.addEventListener("click", close);
- }
- panel.querySelector(".edit-body")?.appendChild(actions);
-
- overlay.addEventListener("click", (e) => {
- if (
- e.target.classList.contains("edit-modal") ||
- e.target.classList.contains("lightbox-close")
- )
- 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 missingVotesCount() {
- const total = state.allSuggestions?.length ?? 0;
- const mine = state.myVotes?.length ?? 0;
- const votedIds = new Set(state.myVotes?.map((v) => v.suggestionId));
- const missing = total - votedIds.size;
- return missing < 0 ? 0 : missing;
-}
-
-function updateMissingBadgeFromDom() {
- const badge = $("vote-missing");
- if (!badge) return;
- if (state.votesFinal || state.phase !== "Vote") {
- badge.classList.add("hidden");
- return;
- }
- const missing = missingVotesCount();
- badge.classList.toggle("hidden", missing === 0);
-}
-
-function renderAdminVoteStatus() {
- if (!state.me?.isAdmin) return;
- const statusBadge = $("admin-ready-status");
- const table = $("admin-player-table")?.querySelector("tbody");
- if (!state.adminVoteStatus || !statusBadge || !table) return;
-
- table.innerHTML = "";
- state.adminVoteStatus.voters.forEach((v) => {
- const tr = document.createElement("tr");
- const statusText = displayPlayerStatus(v);
- const gamesTooltip = escapeHtml((v.suggestionTitles || []).join(", "));
- const nameText = escapeHtml(truncate(v.name, 28));
- const userText = escapeHtml(truncate(v.username, 24));
- tr.innerHTML = `
- ${nameText} |
- ${userText} |
- ${statusText} |
- ${v.suggestionCount ?? 0} |
- |
- |
- `;
- table.appendChild(tr);
- });
-
- const waiting = state.adminVoteStatus.waiting;
- const ready = waiting.length === 0;
- const waitingDisplay = waiting.map((name) =>
- name?.length > 24 ? `${name.slice(0, 21)}…` : name,
- );
- statusBadge.textContent = ready
- ? t("admin.readyForResults")
- : t("admin.waitingForPlayers", { names: waitingDisplay.join(", ") });
- statusBadge.className = ready ? "badge" : "badge warning";
-}
-
-function displayPlayerStatus(player) {
- if (!player) return "";
- const phase = player.phase;
- if (phase === "Suggest") return t("admin.statusSuggesting");
- if (phase === "Vote") return player.finalized ? t("admin.statusFinished") : t("admin.statusVoting");
- if (phase === "Results") return t("admin.statusFinished");
- return phase;
-}
-
-function renderAdminLinker() {
- const wrap = $("admin-linker");
- const source = $("link-source");
- const target = $("link-target");
- if (!wrap || !source || !target) return;
-
- const visible = state.me?.isAdmin && state.phase === "Vote";
- wrap.classList.toggle("hidden", !visible);
- if (!visible) return;
-
- const previousSource = source.value;
- const previousTarget = target.value;
-
- const options = (state.allSuggestions ?? []).slice().sort((a, b) => a.name.localeCompare(b.name));
-
- const fillSelect = (select, placeholderKey) => {
- select.innerHTML = "";
- const placeholder = document.createElement("option");
- placeholder.value = "";
- placeholder.textContent = t(placeholderKey);
- placeholder.disabled = true;
- placeholder.selected = true;
- select.appendChild(placeholder);
-
- options.forEach((s) => {
- const opt = document.createElement("option");
- opt.value = s.id;
- opt.textContent = buildLinkOptionLabel(s);
- opt.dataset.root = linkRootId(s);
- select.appendChild(opt);
- });
- };
-
- fillSelect(source, "admin.linkSourcePlaceholder");
- fillSelect(target, "admin.linkTargetPlaceholder");
-
- if (previousSource && options.some((s) => String(s.id) === previousSource)) source.value = previousSource;
- if (previousTarget && options.some((s) => String(s.id) === previousTarget)) target.value = previousTarget;
-
- const preventSameSelection = () => {
- const sourceVal = source.value;
- const targetVal = target.value;
- Array.from(target.options).forEach((opt) => {
- if (!opt.value) return;
- opt.disabled = opt.value === sourceVal;
- });
- Array.from(source.options).forEach((opt) => {
- if (!opt.value) return;
- opt.disabled = opt.value === targetVal;
- });
- };
-
- source.onchange = preventSameSelection;
- target.onchange = preventSameSelection;
- preventSameSelection();
-}
-
-function openDeleteConfirmModal(s) {
- const overlay = document.createElement("div");
- overlay.className = "edit-modal";
- const panel = document.createElement("div");
- panel.className = "edit-panel";
- panel.innerHTML = `
-
-
- `;
-
- 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 runtime.loadSuggestData();
- } catch (err) {
- toast(err.message, true);
- }
- });
-
- overlay.appendChild(panel);
- document.body.appendChild(overlay);
-}
-
-function linkRootId(s) {
- return s?.parentSuggestionId ?? s?.id;
-}
-
-function linkedPeerIds(s) {
- if (!s) return [];
- if (Array.isArray(s.linkedIds) && s.linkedIds.length > 0) {
- return s.linkedIds.filter((id) => id !== s.id);
- }
- if (!state.allSuggestions?.length) return [];
- const root = linkRootId(s);
- return state.allSuggestions
- .filter((other) => linkRootId(other) === root && other.id !== s.id)
- .map((other) => other.id);
-}
-
-function linkedPeerTitles(s) {
- if (!s) return [];
- if (Array.isArray(s.linkedTitles) && s.linkedTitles.length > 0) {
- return s.linkedTitles;
- }
- if (!state.allSuggestions?.length) return [];
- const root = linkRootId(s);
- return state.allSuggestions
- .filter((other) => linkRootId(other) === root && other.id !== s.id)
- .map((other) => other.name);
-}
-
-function isLinked(s) {
- return !!s?.parentSuggestionId || linkedPeerIds(s).length > 0;
-}
-
-function linkTooltip(s) {
- const peers = linkedPeerTitles(s);
- if (peers.length === 0) return t("card.linked");
- return t("card.linkedWith", { names: peers.join(", ") });
-}
-
-function renderLinkBadge(s) {
- if (!isLinked(s)) return "";
- return `🔗`;
-}
-
-function buildLinkOptionLabel(s) {
- const author = s.author ? ` — ${s.author}` : "";
- const linked = isLinked(s) ? " 🔗" : "";
- return `${s.name}${author}${linked}`;
-}
-
-function syncLinkedSliders(sourceEl, value) {
- const linkedAttr = sourceEl?.dataset?.linked;
- if (!linkedAttr) return;
- const ids = linkedAttr.split(",").filter(Boolean);
- ids.forEach((id) => {
- const slider = document.querySelector(`input[type=range][data-id="${id}"]`);
- if (!slider || slider === sourceEl) return;
- slider.value = value;
- const scoreLabel = $("score-" + id);
- if (scoreLabel) scoreLabel.textContent = value;
- const emojiEl = $("emoji-" + id);
- if (emojiEl) emojiEl.textContent = scoreToEmoji(Number(value));
- const warn = $("warn-" + id);
- if (warn) warn.classList.remove("hidden");
- slider.dataset.pending = "1";
- });
-}
-
-function openUnlinkConfirm(s) {
- const peers = linkedPeerTitles(s);
- const names = peers.length ? peers.join(", ") : t("admin.unlinkUnknownPeers");
- openConfirmModal({
- title: t("admin.unlinkTitle"),
- body: t("admin.unlinkBody", { name: s.name, peers: names }),
- confirmLabel: t("admin.unlinkConfirm"),
- cancelLabel: t("modal.cancel"),
- onConfirm: async (close) => {
- try {
- await adminApi.unlinkSuggestions(s.id);
- toast(t("admin.unlinkDone"));
- close();
- await runtime.refreshPhaseData();
- } catch (err) {
- toast(err.message, true);
- }
- }
- });
-}
-
-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 jokerBtn = $("open-joker-modal");
- if (jokerBtn) {
- const showJoker = phase === "Vote" && state.hasJoker;
- jokerBtn.classList.toggle("hidden", !showJoker);
- jokerBtn.disabled = !showJoker;
- }
-
- const finalizeBtn = $("finalize-votes");
- if (finalizeBtn) {
- finalizeBtn.textContent = state.votesFinal ? t("vote.unfinalize") : t("vote.finalize");
- }
-
- const voteMissingBadge = $("vote-missing");
- if (voteMissingBadge) {
- const missing = missingVotesCount();
- const showMissing = !state.votesFinal && missing > 0;
- voteMissingBadge.classList.toggle("hidden", !showMissing);
- voteMissingBadge.textContent = t("vote.missingFooter");
- }
-
- const waitAdmin = $("vote-wait-admin");
- if (waitAdmin) {
- const show = state.votesFinal && phase === "Vote" && !state.resultsOpen;
- waitAdmin.classList.toggle("hidden", !show);
- waitAdmin.textContent = t("vote.waitAdmin");
- }
-
- const voteStatusText = $("vote-status-text");
- if (voteStatusText) {
- voteStatusText.textContent = state.votesFinal ? t("nav.voteFinalized") : t("nav.voteHint");
- }
-
- renderAdminVoteStatus();
- renderAdminLinker();
- updateMissingBadgeFromDom();
-
- // 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;
- }
-}
+export {
+ buildCard,
+ configureUiRuntime,
+ neutralEmoji,
+ normalizeSuggestionForm,
+ openConfirmModal,
+ openLightbox,
+ openNewSuggestionModal,
+ openResultsRelockModal,
+ openSuggestionsChangedModal,
+ renderAllSuggestions,
+ renderMySuggestions,
+ renderPhaseTitles,
+ renderResults,
+ renderVotes,
+ scoreToEmoji,
+ syncVoteScores,
+ updatePhaseNav,
+};
diff --git a/wwwroot/js/votes-ui.js b/wwwroot/js/votes-ui.js
new file mode 100644
index 0000000..a299cbf
--- /dev/null
+++ b/wwwroot/js/votes-ui.js
@@ -0,0 +1,253 @@
+import { api } from "./api.js";
+import { t } from "./i18n.js";
+import { state } from "./state.js";
+import { $, toast } from "./dom.js";
+import { renderAdminLinker, renderAdminVoteStatus } from "./admin-ui.js";
+import { getUiRuntime } from "./ui-runtime.js";
+import { linkedPeerIds, linkRootId, sortByName } from "./ui-utils.js";
+import { buildCard } from "./suggestions-ui.js";
+
+export function renderVotes() {
+ const list = $("vote-list");
+ if (!list) return;
+ const prevScroll = list.scrollTop;
+ list.innerHTML = "";
+ const votesMap = Object.fromEntries(
+ state.myVotes.map((v) => [v.suggestionId, v.score]),
+ );
+ sortByName(state.allSuggestions).forEach((s) => {
+ const canEdit = !!state.me?.isAdmin || s.isOwner;
+ const lockTitle = state.phase !== "Suggest" && !state.me?.isAdmin;
+ const li = buildCard(s, {
+ showAuthor: true,
+ allowEdit: canEdit,
+ allowDelete: !!state.me?.isAdmin,
+ lockTitle,
+ });
+ const hasVote = Object.prototype.hasOwnProperty.call(votesMap, s.id);
+ const current = hasVote ? votesMap[s.id] : 5;
+ const displayScore = hasVote ? current : "—";
+ const displayEmoji = hasVote ? scoreToEmoji(current) : "⚠️";
+ const linkedIds = linkedPeerIds(s);
+ const rootId = linkRootId(s);
+ const footer = document.createElement("div");
+ footer.className = "vote-controls";
+ footer.innerHTML = `
+ ${state.votesFinal ? t("vote.missingFinalWarn") : t("vote.missingWarn")}
+
+
+ ${displayScore}
+ ${displayEmoji}
+
`;
+ li.querySelector(".card-body").appendChild(footer);
+ list.appendChild(li);
+ });
+ updatePhaseNav();
+ updateMissingBadgeFromDom();
+ list.scrollTop = prevScroll;
+ list.querySelectorAll("input[type=range]").forEach((input) => {
+ input.addEventListener("input", (e) => {
+ if (state.votesFinal) return;
+ const val = Number(e.target.value);
+ const id = e.target.dataset.id;
+ $("score-" + id).textContent = val;
+ const emojiEl = $("emoji-" + id);
+ if (emojiEl) emojiEl.textContent = scoreToEmoji(val);
+ const warn = $("warn-" + id);
+ if (warn) warn.classList.remove("hidden");
+ e.target.dataset.pending = "1";
+ syncLinkedSliders(e.target, val);
+ updateMissingBadgeFromDom();
+ });
+ input.addEventListener("change", async (e) => {
+ if (state.votesFinal) return;
+ const suggestionId = Number(e.target.dataset.id);
+ const score = Number(e.target.value);
+ const prevScore = votesMap[suggestionId];
+ const linkedIds = (e.target.dataset.linked || "")
+ .split(",")
+ .filter(Boolean)
+ .map((x) => Number(x));
+ const resetUi = () => {
+ const label = $("score-" + suggestionId);
+ const emoji = $("emoji-" + suggestionId);
+ const warn = $("warn-" + suggestionId);
+ const fallbackValue = prevScore ?? 5;
+ const fallbackDisplay = prevScore ?? "—";
+ const fallbackEmoji = prevScore != null ? scoreToEmoji(prevScore) : "⚠️";
+ e.target.value = fallbackValue;
+ if (label) label.textContent = fallbackDisplay;
+ if (emoji) emoji.textContent = fallbackEmoji;
+ if (warn) warn.classList.remove("hidden");
+ };
+ try {
+ await api.vote(suggestionId, score);
+ toast(t("vote.saved"));
+ delete e.target.dataset.pending;
+ const warn = $("warn-" + suggestionId);
+ if (warn) warn.classList.add("hidden");
+ linkedIds.forEach((id) => {
+ const peerWarn = $("warn-" + id);
+ if (peerWarn) peerWarn.classList.add("hidden");
+ const peerSlider = document.querySelector(`input[type=range][data-id="${id}"]`);
+ if (peerSlider) delete peerSlider.dataset.pending;
+ });
+ await getUiRuntime().loadVoteData();
+ updateMissingBadgeFromDom();
+ } catch (err) {
+ delete e.target.dataset.pending;
+ resetUi();
+ 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);
+ const warn = $("warn-" + id);
+ if (slider && score != null) {
+ slider.value = score;
+ if (scoreLabel) scoreLabel.textContent = score;
+ if (emoji) emoji.textContent = scoreToEmoji(score);
+ if (warn) warn.classList.add("hidden");
+ delete slider.dataset.pending;
+ }
+ });
+ 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);
+ const warn = $("warn-" + id);
+ if (scoreLabel) scoreLabel.textContent = "—";
+ if (emoji) emoji.textContent = neutralEmoji();
+ if (warn) warn.classList.remove("hidden");
+ });
+}
+
+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 missingVotesCount() {
+ const total = state.allSuggestions?.length ?? 0;
+ const votedIds = new Set(state.myVotes?.map((v) => v.suggestionId));
+ const missing = total - votedIds.size;
+ return missing < 0 ? 0 : missing;
+}
+
+function updateMissingBadgeFromDom() {
+ const badge = $("vote-missing");
+ if (!badge) return;
+ if (state.votesFinal || state.phase !== "Vote") {
+ badge.classList.add("hidden");
+ return;
+ }
+ const missing = missingVotesCount();
+ badge.classList.toggle("hidden", missing === 0);
+}
+
+function syncLinkedSliders(sourceEl, value) {
+ const linkedAttr = sourceEl?.dataset?.linked;
+ if (!linkedAttr) return;
+ const ids = linkedAttr.split(",").filter(Boolean);
+ ids.forEach((id) => {
+ const slider = document.querySelector(`input[type=range][data-id="${id}"]`);
+ if (!slider || slider === sourceEl) return;
+ slider.value = value;
+ const scoreLabel = $("score-" + id);
+ if (scoreLabel) scoreLabel.textContent = value;
+ const emojiEl = $("emoji-" + id);
+ if (emojiEl) emojiEl.textContent = scoreToEmoji(Number(value));
+ const warn = $("warn-" + id);
+ if (warn) warn.classList.remove("hidden");
+ slider.dataset.pending = "1";
+ });
+}
+
+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 jokerBtn = $("open-joker-modal");
+ if (jokerBtn) {
+ const showJoker = phase === "Vote" && state.hasJoker;
+ jokerBtn.classList.toggle("hidden", !showJoker);
+ jokerBtn.disabled = !showJoker;
+ }
+
+ const finalizeBtn = $("finalize-votes");
+ if (finalizeBtn) {
+ finalizeBtn.textContent = state.votesFinal ? t("vote.unfinalize") : t("vote.finalize");
+ }
+
+ const voteMissingBadge = $("vote-missing");
+ if (voteMissingBadge) {
+ const missing = missingVotesCount();
+ const showMissing = !state.votesFinal && missing > 0;
+ voteMissingBadge.classList.toggle("hidden", !showMissing);
+ voteMissingBadge.textContent = t("vote.missingFooter");
+ }
+
+ const waitAdmin = $("vote-wait-admin");
+ if (waitAdmin) {
+ const show = state.votesFinal && phase === "Vote" && !state.resultsOpen;
+ waitAdmin.classList.toggle("hidden", !show);
+ waitAdmin.textContent = t("vote.waitAdmin");
+ }
+
+ const voteStatusText = $("vote-status-text");
+ if (voteStatusText) {
+ voteStatusText.textContent = state.votesFinal ? t("nav.voteFinalized") : t("nav.voteHint");
+ }
+
+ renderAdminVoteStatus();
+ renderAdminLinker();
+ updateMissingBadgeFromDom();
+
+ const backButtons = ["nav-vote-prev"];
+ backButtons.forEach((id) => {
+ const btn = $(id);
+ if (btn) btn.classList.toggle("hidden", !isAdmin);
+ });
+
+ const voteNext = $("nav-vote-next");
+ if (voteNext) {
+ const locked = !state.resultsOpen && !isAdmin;
+ voteNext.disabled = locked;
+ voteNext.textContent = locked ? t("nav.waitingForResults") : t("nav.next");
+ }
+
+ const adminResultsToggle = $("results-open");
+ if (adminResultsToggle) {
+ adminResultsToggle.checked = !!state.resultsOpen;
+ }
+}