From 34d274d24460dd739bd9c9e3ed769d68c39dedd6 Mon Sep 17 00:00:00 2001 From: Frank Tovar Date: Sat, 7 Feb 2026 02:07:29 +0100 Subject: [PATCH] Split frontend UI into feature modules --- REVIEW.md | 20 +- wwwroot/js/admin-ui.js | 103 ++++ wwwroot/js/modals-ui.js | 97 +++ wwwroot/js/results-ui.js | 98 +++ wwwroot/js/suggestions-ui.js | 470 ++++++++++++++ wwwroot/js/ui-runtime.js | 14 + wwwroot/js/ui-utils.js | 83 +++ wwwroot/js/ui.js | 1108 +--------------------------------- wwwroot/js/votes-ui.js | 253 ++++++++ 9 files changed, 1153 insertions(+), 1093 deletions(-) create mode 100644 wwwroot/js/admin-ui.js create mode 100644 wwwroot/js/modals-ui.js create mode 100644 wwwroot/js/results-ui.js create mode 100644 wwwroot/js/suggestions-ui.js create mode 100644 wwwroot/js/ui-runtime.js create mode 100644 wwwroot/js/ui-utils.js create mode 100644 wwwroot/js/votes-ui.js 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 = ` + + `; + overlay.addEventListener("click", (e) => { + if ( + e.target.classList.contains("lightbox") || + e.target.classList.contains("lightbox-close") + ) { + overlay.remove(); + } + }); + document.body.appendChild(overlay); +} + +export function openConfirmModal({ title, body, confirmLabel, cancelLabel = t("modal.cancel"), onConfirm }) { + const overlay = document.createElement("div"); + overlay.className = "edit-modal"; + const panel = document.createElement("div"); + panel.className = "edit-panel"; + panel.innerHTML = ` +
+

${title}

+ +
+
+

${body}

+
+ `; + const 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 ? `${safeName}` : ""} +
+
+ ${safeName} + ${renderLinkBadge(r)} +
+ ${buildResultMeta(r)} +
+ + ${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 = ` +
+

${title}

+ +
+
+ `; + + const form = buildSuggestionForm(initial, lockTitle); + const actions = document.createElement("div"); + actions.className = "stack horizontal"; + const submitBtn = document.createElement("button"); + submitBtn.type = "submit"; + submitBtn.textContent = submitLabel; + const cancelBtn = document.createElement("button"); + cancelBtn.type = "button"; + cancelBtn.className = "ghost"; + cancelBtn.textContent = t("modal.cancel"); + actions.append(submitBtn, cancelBtn); + form.appendChild(actions); + + const close = () => overlay.remove(); + overlay.addEventListener("click", (e) => { + if ( + e.target.classList.contains("edit-modal") || + e.target.classList.contains("lightbox-close") + ) { + close(); + } + }); + cancelBtn.addEventListener("click", close); + + form.addEventListener("submit", async (e) => { + e.preventDefault(); + const data = normalizeSuggestionForm(new FormData(form)); + 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 = ` +
+

${t("modal.confirmDeleteTitle")}

+ +
+
+ `; + + 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 ? `${safeName}` : ''} -
-
- ${safeName} - ${renderLinkBadge(r)} -
- ${buildResultMeta(r)} -
- - ${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 = ` -
-

${title}

- -
-
- `; - - const form = buildSuggestionForm(initial, lockTitle); - const actions = document.createElement("div"); - actions.className = "stack horizontal"; - const submitBtn = document.createElement("button"); - submitBtn.type = "submit"; - submitBtn.textContent = submitLabel; - const cancelBtn = document.createElement("button"); - cancelBtn.type = "button"; - cancelBtn.className = "ghost"; - cancelBtn.textContent = t("modal.cancel"); - actions.append(submitBtn, cancelBtn); - form.appendChild(actions); - - const close = () => overlay.remove(); - overlay.addEventListener("click", (e) => { - if ( - e.target.classList.contains("edit-modal") || - e.target.classList.contains("lightbox-close") - ) - close(); - }); - cancelBtn.addEventListener("click", close); - - form.addEventListener("submit", async (e) => { - e.preventDefault(); - const data = normalizeSuggestionForm(new FormData(form)); - 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 = ` - - `; - 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 = ` -
-

${title}

- -
-
-

${body}

-
- `; - 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 = ` -
-

${t("modal.confirmDeleteTitle")}

- -
-
- `; - - const preview = buildCard( - { ...s, id: s.id }, // shallow copy to avoid mutations - { showAuthor: true, allowDelete: false, allowEdit: false }, - ); - preview.classList.add("preview-card"); - - const actions = document.createElement("div"); - actions.className = "stack horizontal"; - const confirmBtn = document.createElement("button"); - confirmBtn.className = "danger"; - confirmBtn.textContent = t("modal.confirmDelete"); - const cancelBtn = document.createElement("button"); - cancelBtn.type = "button"; - cancelBtn.className = "ghost"; - cancelBtn.textContent = t("modal.cancel"); - actions.append(confirmBtn, cancelBtn); - - const body = panel.querySelector(".delete-body"); - body?.append(preview, actions); - - const close = () => overlay.remove(); - overlay.addEventListener("click", (e) => { - if ( - e.target.classList.contains("edit-modal") || - e.target.classList.contains("lightbox-close") - ) - close(); - }); - cancelBtn.addEventListener("click", close); - - confirmBtn.addEventListener("click", async () => { - try { - await api.deleteSuggestion(s.id); - toast(t("toast.suggestionDeleted")); - close(); - await 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; + } +}