From d375b942ff0ec301065bb1eed141566af942edea Mon Sep 17 00:00:00 2001 From: Frank Tovar Date: Sun, 8 Feb 2026 21:57:47 +0100 Subject: [PATCH] Reduce frontend polling load and clean stale UI hooks --- Endpoints/SuggestionValidator.cs | 25 +- Endpoints/SuggestionWorkflowService.cs | 15 +- GameList.Tests/SuggestionTests.cs | 39 +++ TESTS.md | 2 +- package.json | 4 +- wwwroot/app.js | 418 ++++++++++++++----------- wwwroot/index.html | 1 + wwwroot/js/api.js | 150 +++++---- wwwroot/js/app-auth-handlers.js | 1 + wwwroot/js/data.js | 51 ++- wwwroot/js/dom.js | 3 +- wwwroot/js/suggestions-ui.js | 10 - wwwroot/js/votes-ui.js | 9 - 13 files changed, 447 insertions(+), 281 deletions(-) diff --git a/Endpoints/SuggestionValidator.cs b/Endpoints/SuggestionValidator.cs index 84ea3bf..6641788 100644 --- a/Endpoints/SuggestionValidator.cs +++ b/Endpoints/SuggestionValidator.cs @@ -1,8 +1,14 @@ +using System.Collections.Concurrent; + namespace GameList.Endpoints; internal static class SuggestionValidator { - public static async Task ValidateAsync(SuggestionInput input, IHttpClientFactory httpFactory) + private static readonly ConcurrentDictionary ImageReachabilityCache = new(StringComparer.OrdinalIgnoreCase); + private static readonly TimeSpan ReachableCacheTtl = TimeSpan.FromMinutes(15); + private static readonly TimeSpan UnreachableCacheTtl = TimeSpan.FromMinutes(2); + + public static async Task ValidateAsync(SuggestionInput input, IHttpClientFactory httpFactory, bool shouldValidateImageReachability = true) { if (string.IsNullOrWhiteSpace(input.Name) || input.Name.Length > 100) return "Name is required and must be <= 100 characters."; @@ -10,7 +16,7 @@ internal static class SuggestionValidator if (!EndpointHelpers.IsValidImageUrl(input.ScreenshotUrl)) return "Screenshot URL must be http(s) and end with an image file extension."; - if (!await EndpointHelpers.IsReachableImageAsync(input.ScreenshotUrl, httpFactory)) + if (shouldValidateImageReachability && !await IsReachableImageCachedAsync(input.ScreenshotUrl, httpFactory)) return "Screenshot URL could not be validated as an image. Use a public image link (http/https, no redirects, max 5 MB)."; if (!EndpointHelpers.IsValidHttpUrl(input.GameUrl)) @@ -22,6 +28,21 @@ internal static class SuggestionValidator return ValidatePlayers(input.MinPlayers, input.MaxPlayers); } + private static async Task IsReachableImageCachedAsync(string? url, IHttpClientFactory httpFactory) + { + if (string.IsNullOrWhiteSpace(url)) + return true; + + var normalized = url.Trim(); + if (ImageReachabilityCache.TryGetValue(normalized, out var cached) && cached.ExpiresAt > DateTimeOffset.UtcNow) + return cached.Reachable; + + var reachable = await EndpointHelpers.IsReachableImageAsync(normalized, httpFactory); + var ttl = reachable ? ReachableCacheTtl : UnreachableCacheTtl; + ImageReachabilityCache[normalized] = (reachable, DateTimeOffset.UtcNow.Add(ttl)); + return reachable; + } + private static string? ValidatePlayers(int? minPlayers, int? maxPlayers) { if (minPlayers is null && maxPlayers is null) diff --git a/Endpoints/SuggestionWorkflowService.cs b/Endpoints/SuggestionWorkflowService.cs index dd846f7..08dc54b 100644 --- a/Endpoints/SuggestionWorkflowService.cs +++ b/Endpoints/SuggestionWorkflowService.cs @@ -145,10 +145,6 @@ internal sealed class SuggestionWorkflowService(AppDbContext db, IHttpClientFact public async Task> UpdateAsync(Guid playerId, int suggestionId, SuggestionInput input) { - var validationError = await SuggestionValidator.ValidateAsync(input, httpFactory); - if (validationError is not null) - return ServiceResult.Failure(ServiceError.BadRequest(validationError)); - var actor = await db.Players .AsNoTracking() .Where(p => p.Id == playerId) @@ -162,6 +158,11 @@ internal sealed class SuggestionWorkflowService(AppDbContext db, IHttpClientFact if (suggestion == null) return ServiceResult.Failure(ServiceError.NotFound("Suggestion not found.")); + var shouldValidateScreenshot = ShouldValidateScreenshotReachability(input.ScreenshotUrl, suggestion.ScreenshotUrl); + var validationError = await SuggestionValidator.ValidateAsync(input, httpFactory, shouldValidateScreenshot); + if (validationError is not null) + return ServiceResult.Failure(ServiceError.BadRequest(validationError)); + var isAdmin = actor.IsAdmin; if (!isAdmin) { @@ -269,4 +270,10 @@ internal sealed class SuggestionWorkflowService(AppDbContext db, IHttpClientFact suggestion.MinPlayers = input.MinPlayers; suggestion.MaxPlayers = input.MaxPlayers; } + + private static bool ShouldValidateScreenshotReachability(string? requestedScreenshotUrl, string? existingScreenshotUrl) + { + var normalizedRequested = EndpointHelpers.TrimTo(requestedScreenshotUrl, 2048); + return !string.Equals(normalizedRequested, existingScreenshotUrl, StringComparison.Ordinal); + } } diff --git a/GameList.Tests/SuggestionTests.cs b/GameList.Tests/SuggestionTests.cs index c107b6e..19cb02a 100644 --- a/GameList.Tests/SuggestionTests.cs +++ b/GameList.Tests/SuggestionTests.cs @@ -348,6 +348,45 @@ public class SuggestionTests Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); } + [Fact] + public async Task Update_does_not_revalidate_unchanged_screenshot_url() + { + await using var factory = new TestWebApplicationFactory(); + var client = factory.CreateClientWithCookies(); + await client.RegisterAsync("reval"); + + var create = await client.PostAsJsonAsync("/api/suggestions", new + { + Name = "Reachable once", + Genre = (string?)null, + Description = (string?)null, + ScreenshotUrl = "http://example.com/shot.png", + YoutubeUrl = (string?)null, + GameUrl = (string?)null, + MinPlayers = (int?)null, + MaxPlayers = (int?)null + }); + create.EnsureSuccessStatusCode(); + var createdPayload = await create.Content.ReadFromJsonAsync(); + var suggestionId = createdPayload.GetProperty("id").GetInt32(); + + factory.HttpHandler.SetResponder(_ => new HttpResponseMessage(HttpStatusCode.BadRequest)); + + var update = await client.PutAsJsonAsync($"/api/suggestions/{suggestionId}", new + { + Name = "Reachable once", + Genre = "Updated", + Description = (string?)null, + ScreenshotUrl = "http://example.com/shot.png", + YoutubeUrl = (string?)null, + GameUrl = (string?)null, + MinPlayers = (int?)null, + MaxPlayers = (int?)null + }); + + update.EnsureSuccessStatusCode(); + } + [Fact] public async Task Get_all_requires_vote_phase() { diff --git a/TESTS.md b/TESTS.md index 0c12478..ad39e4a 100644 --- a/TESTS.md +++ b/TESTS.md @@ -55,7 +55,7 @@ stateDiagram-v2 - DB trigger also enforces suggestion cap for non-joker inserts, protecting against concurrent over-limit writes. - Joker path: when phase=Vote and HasJoker=true allows creation, consumes joker, resets VotesFinal for all players. - Phase gating: non-admin cannot create/update/delete outside Suggest (except joker create); admin bypasses phase checks for update/delete. -- PUT /{id}: player can edit own in Suggest; name locked outside Suggest; admin can edit any time; validation mirrors create. +- PUT /{id}: player can edit own in Suggest; name locked outside Suggest; admin can edit any time; screenshot reachability check is skipped when screenshot URL is unchanged. - DELETE /{id}: player deletes own in Suggest; admin any time; also breaks child links and deletes related votes. - GET /all: accessible from Vote+, orders by CreatedAt, includes link metadata, enforces phase mismatch before Vote. diff --git a/package.json b/package.json index 6c1b0d3..1eb02fc 100644 --- a/package.json +++ b/package.json @@ -4,8 +4,8 @@ "type": "module", "scripts": { "lint": "eslint \"wwwroot/**/*.js\"", - "format": "prettier --write \"eslint.config.js\" \"wwwroot/js/i18n.js\" \"wwwroot/js/{admin-ui,app-admin-handlers,app-auth-handlers,app-vote-nav-handlers,auth-ui,modals-ui,results-ui,suggestions-ui,ui-runtime,ui-utils,ui,votes-ui}.js\"", - "format:check": "prettier --check \"eslint.config.js\" \"wwwroot/js/i18n.js\" \"wwwroot/js/{admin-ui,app-admin-handlers,app-auth-handlers,app-vote-nav-handlers,auth-ui,modals-ui,results-ui,suggestions-ui,ui-runtime,ui-utils,ui,votes-ui}.js\"" + "format": "prettier --write \"eslint.config.js\" \"wwwroot/**/*.js\"", + "format:check": "prettier --check \"eslint.config.js\" \"wwwroot/**/*.js\"" }, "devDependencies": { "@eslint/js": "9.21.0", diff --git a/wwwroot/app.js b/wwwroot/app.js index 1a94be1..473e22f 100644 --- a/wwwroot/app.js +++ b/wwwroot/app.js @@ -1,246 +1,282 @@ -import { t, setLanguage, getLanguage, initI18n, onLanguageChange, faqMarkdown } from "./js/i18n.js"; +import { + t, + setLanguage, + getLanguage, + initI18n, + onLanguageChange, + faqMarkdown, +} from "./js/i18n.js"; import { state, clearUserState } from "./js/state.js"; import { toast } from "./js/dom.js"; import { - handleAuthError, - renderWelcome, - renderPhasePill, - renderCounts, - renderMySuggestions, - renderAllSuggestions, - renderVotes, - syncVoteScores, - renderResults, - renderPhaseTitles, - updatePhaseNav, - configureUiRuntime, + handleAuthError, + renderWelcome, + renderPhasePill, + renderCounts, + renderMySuggestions, + renderAllSuggestions, + renderVotes, + syncVoteScores, + renderResults, + renderPhaseTitles, + updatePhaseNav, + configureUiRuntime, } from "./js/ui.js"; -import { - loadSuggestData, - loadVoteData, - refreshPhaseData, -} from "./js/data.js"; +import { loadSuggestData, loadVoteData, refreshPhaseData } from "./js/data.js"; import { setupAuthHandlers } from "./js/app-auth-handlers.js"; import { setupAdminHandlers } from "./js/app-admin-handlers.js"; import { setupVoteNavigationHandlers } from "./js/app-vote-nav-handlers.js"; -const REFRESH_INTERVAL_MS = 4000; +const REFRESH_MIN_MS = 3000; +const REFRESH_MAX_MS = 20000; let refreshInFlight = null; let refreshTimerId = null; let refreshSchedulerStarted = false; +let unchangedRefreshCycles = 0; +let nextRefreshDelayMs = REFRESH_MIN_MS; async function runSerializedRefresh() { - if (refreshInFlight) return refreshInFlight; - refreshInFlight = refreshPhaseData().finally(() => { - refreshInFlight = null; - }); - return refreshInFlight; + if (refreshInFlight) return refreshInFlight; + refreshInFlight = refreshPhaseData().finally(() => { + refreshInFlight = null; + }); + return refreshInFlight; } async function refreshWithUiErrorHandling() { - try { - await runSerializedRefresh(); - } catch (err) { - if (!handleAuthError(err, clearUserState)) toast(err.message, true); - } + try { + const changed = await runSerializedRefresh(); + updateRefreshCadence(changed === true); + } catch (err) { + // Back off after transient failures to avoid hammering server/dependencies. + nextRefreshDelayMs = Math.min(nextRefreshDelayMs * 2, REFRESH_MAX_MS); + if (!handleAuthError(err, clearUserState)) toast(err.message, true); + } } function scheduleNextRefresh() { - refreshTimerId = window.setTimeout(async () => { - if (!document.hidden && !state.adminStatusSelectActive) { - await refreshWithUiErrorHandling(); - } - scheduleNextRefresh(); - }, REFRESH_INTERVAL_MS); + refreshTimerId = window.setTimeout(async () => { + if (!document.hidden && !state.adminStatusSelectActive) { + await refreshWithUiErrorHandling(); + } + scheduleNextRefresh(); + }, nextRefreshDelayMs); } function startRefreshScheduler() { - if (refreshSchedulerStarted) return; - refreshSchedulerStarted = true; + if (refreshSchedulerStarted) return; + refreshSchedulerStarted = true; - document.addEventListener("visibilitychange", () => { - if (!document.hidden && !state.adminStatusSelectActive) { - refreshWithUiErrorHandling(); + document.addEventListener("visibilitychange", () => { + if (!document.hidden && !state.adminStatusSelectActive) { + unchangedRefreshCycles = 0; + nextRefreshDelayMs = baseRefreshDelayForPhase(); + refreshWithUiErrorHandling(); + } + }); + + if (refreshTimerId !== null) { + window.clearTimeout(refreshTimerId); } - }); + scheduleNextRefresh(); +} - if (refreshTimerId !== null) { - window.clearTimeout(refreshTimerId); - } - scheduleNextRefresh(); +function updateRefreshCadence(changed) { + const base = baseRefreshDelayForPhase(); + if (changed) { + unchangedRefreshCycles = 0; + nextRefreshDelayMs = base; + return; + } + + unchangedRefreshCycles = Math.min(unchangedRefreshCycles + 1, 8); + const growth = Math.pow(1.35, unchangedRefreshCycles); + nextRefreshDelayMs = Math.min(Math.round(base * growth), REFRESH_MAX_MS); +} + +function baseRefreshDelayForPhase() { + switch (state.phase) { + case "Vote": + return REFRESH_MIN_MS; + case "Suggest": + return 5000; + case "Results": + return 7000; + default: + return 5000; + } } configureUiRuntime({ - refreshPhaseData: runSerializedRefresh, - loadSuggestData, - loadVoteData, - handleAuthError: (err) => handleAuthError(err, clearUserState), + refreshPhaseData: runSerializedRefresh, + loadSuggestData, + loadVoteData, + handleAuthError: (err) => handleAuthError(err, clearUserState), }); function setupHandlers() { - setupAuthHandlers({ runSerializedRefresh }); - setupAdminHandlers({ runSerializedRefresh }); - setupVoteNavigationHandlers({ runSerializedRefresh }); - setupLanguageSwitchers(); + setupAuthHandlers({ runSerializedRefresh }); + setupAdminHandlers({ runSerializedRefresh }); + setupVoteNavigationHandlers({ runSerializedRefresh }); + setupLanguageSwitchers(); - onLanguageChange(() => { - updateLanguageButtons(); - renderWelcome(); - renderPhasePill(); - renderCounts(); - renderPhaseTitles(); - renderMySuggestions(); - renderAllSuggestions(); - if (state.phase === "Vote") { - renderVotes(); - state.votesRendered = true; - syncVoteScores(); - } - if (state.phase === "Results") { - renderResults(); - } - updatePhaseNav(); - }); + onLanguageChange(() => { + updateLanguageButtons(); + renderWelcome(); + renderPhasePill(); + renderCounts(); + renderPhaseTitles(); + renderMySuggestions(); + renderAllSuggestions(); + if (state.phase === "Vote") { + renderVotes(); + state.votesRendered = true; + syncVoteScores(); + } + if (state.phase === "Results") { + renderResults(); + } + updatePhaseNav(); + }); - document.querySelectorAll(".help-chip").forEach((chip) => { - chip.addEventListener("click", () => openFaqModal()); - }); + document.querySelectorAll(".help-chip").forEach((chip) => { + chip.addEventListener("click", () => openFaqModal()); + }); } async function main() { - await initI18n(); - setupHandlers(); - await refreshWithUiErrorHandling(); - startRefreshScheduler(); + await initI18n(); + setupHandlers(); + await refreshWithUiErrorHandling(); + startRefreshScheduler(); } main(); function updateLanguageButtons() { - document.querySelectorAll(".lang-button").forEach((btn) => { - btn.textContent = "🌐"; - btn.title = t("lang.label"); - btn.setAttribute("aria-label", t("lang.label")); - }); + document.querySelectorAll(".lang-button").forEach((btn) => { + btn.textContent = "🌐"; + btn.title = t("lang.label"); + btn.setAttribute("aria-label", t("lang.label")); + }); } function setupLanguageSwitchers() { - const switches = document.querySelectorAll(".lang-switch"); - const closeAll = () => - switches.forEach((wrap) => wrap.querySelector(".lang-menu")?.classList.add("hidden")); + const switches = document.querySelectorAll(".lang-switch"); + const closeAll = () => + switches.forEach((wrap) => + wrap.querySelector(".lang-menu")?.classList.add("hidden"), + ); - switches.forEach((wrap) => { - const btn = wrap.querySelector(".lang-button"); - const menu = wrap.querySelector(".lang-menu"); - if (!btn || !menu) return; - btn.addEventListener("click", (e) => { - e.preventDefault(); - const isHidden = menu.classList.contains("hidden"); - closeAll(); - if (isHidden) menu.classList.remove("hidden"); + switches.forEach((wrap) => { + const btn = wrap.querySelector(".lang-button"); + const menu = wrap.querySelector(".lang-menu"); + if (!btn || !menu) return; + btn.addEventListener("click", (e) => { + e.preventDefault(); + const isHidden = menu.classList.contains("hidden"); + closeAll(); + if (isHidden) menu.classList.remove("hidden"); + }); + menu.querySelectorAll("[data-lang]").forEach((item) => + item.addEventListener("click", () => { + const lang = item.dataset.lang; + if (lang) setLanguage(lang); + closeAll(); + }), + ); }); - menu.querySelectorAll("[data-lang]").forEach((item) => - item.addEventListener("click", () => { - const lang = item.dataset.lang; - if (lang) setLanguage(lang); - closeAll(); - }), - ); - }); - document.addEventListener("click", (e) => { - if (!e.target.closest(".lang-switch")) closeAll(); - }); + document.addEventListener("click", (e) => { + if (!e.target.closest(".lang-switch")) closeAll(); + }); - updateLanguageButtons(); + updateLanguageButtons(); } function markdownToHtml(md) { - const lines = md.trim().split(/\r?\n/); - const html = []; - let inList = false; - let inParagraph = false; + const lines = md.trim().split(/\r?\n/); + const html = []; + let inList = false; + let inParagraph = false; - const escapeHtml = (text) => - text - .replace(/&/g, "&") - .replace(//g, ">"); + const escapeHtml = (text) => + text.replace(/&/g, "&").replace(//g, ">"); - const formatInline = (text) => - escapeHtml(text) - .replace(/\*\*(.+?)\*\*/g, "$1") - .replace(/`([^`]+)`/g, "$1"); + const formatInline = (text) => + escapeHtml(text) + .replace(/\*\*(.+?)\*\*/g, "$1") + .replace(/`([^`]+)`/g, "$1"); - const closeParagraph = () => { - if (inParagraph) { - html.push("

"); - inParagraph = false; - } - }; + const closeParagraph = () => { + if (inParagraph) { + html.push("

"); + inParagraph = false; + } + }; - const closeList = () => { - if (inList) { - html.push(""); - inList = false; - } - }; + const closeList = () => { + if (inList) { + html.push(""); + inList = false; + } + }; - lines.forEach((rawLine) => { - const line = rawLine.trimEnd(); - const trimmed = line.trim(); - if (!trimmed) { - closeParagraph(); - closeList(); - return; - } + lines.forEach((rawLine) => { + const line = rawLine.trimEnd(); + const trimmed = line.trim(); + if (!trimmed) { + closeParagraph(); + closeList(); + return; + } - if (/^-{5,}$/.test(trimmed)) { - closeParagraph(); - closeList(); - html.push('
'); - return; - } + if (/^-{5,}$/.test(trimmed)) { + closeParagraph(); + closeList(); + html.push('
'); + return; + } - const heading = trimmed.match(/^(#{1,3})\s+(.*)$/); - if (heading) { - closeParagraph(); - closeList(); - const level = heading[1].length; - const tag = level === 1 ? "h2" : level === 2 ? "h3" : "h4"; - html.push(`<${tag}>${formatInline(heading[2].trim())}`); - return; - } + const heading = trimmed.match(/^(#{1,3})\s+(.*)$/); + if (heading) { + closeParagraph(); + closeList(); + const level = heading[1].length; + const tag = level === 1 ? "h2" : level === 2 ? "h3" : "h4"; + html.push(`<${tag}>${formatInline(heading[2].trim())}`); + return; + } - if (/^[*-]\s+/.test(trimmed)) { - closeParagraph(); - if (!inList) { - html.push("
    "); - inList = true; - } - const text = trimmed.replace(/^[*-]\s+/, ""); - html.push(`
  • ${formatInline(text)}
  • `); - return; - } + if (/^[*-]\s+/.test(trimmed)) { + closeParagraph(); + if (!inList) { + html.push("
      "); + inList = true; + } + const text = trimmed.replace(/^[*-]\s+/, ""); + html.push(`
    • ${formatInline(text)}
    • `); + return; + } - if (!inParagraph) { - html.push("

      "); - inParagraph = true; - } - html.push(formatInline(trimmed)); - }); + if (!inParagraph) { + html.push("

      "); + inParagraph = true; + } + html.push(formatInline(trimmed)); + }); - closeParagraph(); - closeList(); - return html.join("\n"); + closeParagraph(); + closeList(); + return html.join("\n"); } function openFaqModal() { - const overlay = document.createElement("div"); - overlay.className = "edit-modal"; - const panel = document.createElement("div"); - panel.className = "edit-panel faq-panel"; - panel.innerHTML = ` + const overlay = document.createElement("div"); + overlay.className = "edit-modal"; + const panel = document.createElement("div"); + panel.className = "edit-panel faq-panel"; + panel.innerHTML = `

      ${t("help.title")}

      @@ -250,16 +286,20 @@ function openFaqModal() {
      `; - const list = panel.querySelector(".faq-list"); - const lang = getLanguage(); - const md = faqMarkdown[lang] ?? faqMarkdown.en; - list.innerHTML = markdownToHtml(md); + const list = panel.querySelector(".faq-list"); + const lang = getLanguage(); + const md = faqMarkdown[lang] ?? faqMarkdown.en; + list.innerHTML = markdownToHtml(md); - const close = () => overlay.remove(); - overlay.addEventListener("click", (e) => { - if (e.target.classList.contains("edit-modal") || e.target.classList.contains("lightbox-close")) close(); - }); + const close = () => overlay.remove(); + overlay.addEventListener("click", (e) => { + if ( + e.target.classList.contains("edit-modal") || + e.target.classList.contains("lightbox-close") + ) + close(); + }); - overlay.appendChild(panel); - document.body.appendChild(overlay); + overlay.appendChild(panel); + document.body.appendChild(overlay); } diff --git a/wwwroot/index.html b/wwwroot/index.html index ab63a91..dea666c 100644 --- a/wwwroot/index.html +++ b/wwwroot/index.html @@ -99,6 +99,7 @@ +
      diff --git a/wwwroot/js/api.js b/wwwroot/js/api.js index e42f9bd..34b712f 100644 --- a/wwwroot/js/api.js +++ b/wwwroot/js/api.js @@ -5,77 +5,107 @@ const basePath = normalizeBase(rawBase); const withBase = (path) => `${basePath}${path}`; function normalizeBase(value) { - if (!value) return ""; - if (!value.startsWith("/")) return `/${value}`; - return value.endsWith("/") ? value.slice(0, -1) : value; + if (!value) return ""; + if (!value.startsWith("/")) return `/${value}`; + return value.endsWith("/") ? value.slice(0, -1) : value; } async function request(path, { method = "GET", body } = {}) { - const res = await fetch(withBase(path), { - method, - credentials: "same-origin", - headers: defaultHeaders, - body: body ? JSON.stringify(body) : undefined, - }); + const res = await fetch(withBase(path), { + method, + credentials: "same-origin", + headers: defaultHeaders, + body: body ? JSON.stringify(body) : undefined, + }); - if (!res.ok) { - let msg = `${res.status}`; - try { - const data = await res.json(); - msg = data.error || data.detail || data.title || JSON.stringify(data); - } catch { /* ignore */ } - const err = new Error(msg); - err.status = res.status; - throw err; - } - return res.status === 204 ? null : res.json(); + if (!res.ok) { + let msg = `${res.status}`; + try { + const data = await res.json(); + msg = + data.error || data.detail || data.title || JSON.stringify(data); + } catch { + /* ignore */ + } + const err = new Error(msg); + err.status = res.status; + throw err; + } + return res.status === 204 ? null : res.json(); } export const api = { - state: () => request("/api/state"), - me: () => request("/api/me"), - authOptions: () => request("/api/auth/options"), - register: (payload) => request("/api/auth/register", { method: "POST", body: payload }), - login: (payload) => request("/api/auth/login", { method: "POST", body: payload }), - logout: () => request("/api/auth/logout", { method: "POST" }), + state: () => request("/api/state"), + me: () => request("/api/me"), + authOptions: () => request("/api/auth/options"), + register: (payload) => + request("/api/auth/register", { method: "POST", body: payload }), + login: (payload) => + request("/api/auth/login", { method: "POST", body: payload }), + logout: () => request("/api/auth/logout", { method: "POST" }), - mySuggestions: () => request("/api/suggestions/mine"), - createSuggestion: (payload) => request("/api/suggestions", { method: "POST", body: payload }), - deleteSuggestion: (id) => request(`/api/suggestions/${id}`, { method: "DELETE" }), - updateSuggestion: (id, payload) => request(`/api/suggestions/${id}`, { method: "PUT", body: payload }), - allSuggestions: () => request("/api/suggestions/all"), + mySuggestions: () => request("/api/suggestions/mine"), + createSuggestion: (payload) => + request("/api/suggestions", { method: "POST", body: payload }), + deleteSuggestion: (id) => + request(`/api/suggestions/${id}`, { method: "DELETE" }), + updateSuggestion: (id, payload) => + request(`/api/suggestions/${id}`, { method: "PUT", body: payload }), + allSuggestions: () => request("/api/suggestions/all"), - myVotes: () => request("/api/votes/mine"), - vote: (suggestionId, score) => request("/api/votes", { method: "POST", body: { suggestionId, score } }), - finalizeVotes: (final) => request("/api/votes/finalize", { method: "POST", body: { final } }), + myVotes: () => request("/api/votes/mine"), + vote: (suggestionId, score) => + request("/api/votes", { + method: "POST", + body: { suggestionId, score }, + }), + finalizeVotes: (final) => + request("/api/votes/finalize", { method: "POST", body: { final } }), - results: () => request("/api/results"), - nextPhase: () => request("/api/me/phase/next", { method: "POST" }), - prevPhase: () => request("/api/me/phase/prev", { method: "POST" }), + results: () => request("/api/results"), + nextPhase: () => request("/api/me/phase/next", { method: "POST" }), + prevPhase: () => request("/api/me/phase/prev", { method: "POST" }), }; export const adminApi = { - setResultsOpen: (resultsOpen) => request("/api/admin/results", { method: "POST", body: { resultsOpen } }), - voteStatus: () => request("/api/admin/vote-status"), - reset: (password) => - request("/api/admin/reset", { method: "POST", body: { password } }), - factoryReset: (password) => - request("/api/admin/factory-reset", { method: "POST", body: { password } }), - grantJoker: (playerId) => request("/api/admin/joker", { method: "POST", body: { playerId } }), - setPlayerAdmin: (playerId, isAdmin) => - request("/api/admin/player-admin", { - method: "POST", - body: { playerId, isAdmin }, - }), - setPlayerPhase: (playerId, phase) => - request("/api/admin/player-phase", { method: "POST", body: { playerId, phase } }), - deletePlayer: (playerId, password) => - request(`/api/admin/players/${playerId}`, { - method: "DELETE", - body: { password }, - }), - linkSuggestions: (sourceSuggestionId, targetSuggestionId) => - request("/api/admin/link-suggestions", { method: "POST", body: { sourceSuggestionId, targetSuggestionId } }), - unlinkSuggestions: (suggestionId) => - request("/api/admin/unlink-suggestions", { method: "POST", body: { suggestionId } }), + setResultsOpen: (resultsOpen) => + request("/api/admin/results", { + method: "POST", + body: { resultsOpen }, + }), + voteStatus: () => request("/api/admin/vote-status"), + reset: (password) => + request("/api/admin/reset", { method: "POST", body: { password } }), + factoryReset: (password) => + request("/api/admin/factory-reset", { + method: "POST", + body: { password }, + }), + grantJoker: (playerId) => + request("/api/admin/joker", { method: "POST", body: { playerId } }), + setPlayerAdmin: (playerId, isAdmin) => + request("/api/admin/player-admin", { + method: "POST", + body: { playerId, isAdmin }, + }), + setPlayerPhase: (playerId, phase) => + request("/api/admin/player-phase", { + method: "POST", + body: { playerId, phase }, + }), + deletePlayer: (playerId, password) => + request(`/api/admin/players/${playerId}`, { + method: "DELETE", + body: { password }, + }), + linkSuggestions: (sourceSuggestionId, targetSuggestionId) => + request("/api/admin/link-suggestions", { + method: "POST", + body: { sourceSuggestionId, targetSuggestionId }, + }), + unlinkSuggestions: (suggestionId) => + request("/api/admin/unlink-suggestions", { + method: "POST", + body: { suggestionId }, + }), }; diff --git a/wwwroot/js/app-auth-handlers.js b/wwwroot/js/app-auth-handlers.js index 86402bf..03a8327 100644 --- a/wwwroot/js/app-auth-handlers.js +++ b/wwwroot/js/app-auth-handlers.js @@ -114,6 +114,7 @@ function setupLoginFormHandlers({ if (err?.status === 401) return toast(t("auth.invalidCredentials"), true); if (handleAuthError(err, clearUserState)) return; + toast(err?.message || t("toast.unexpected"), true); } }); } diff --git a/wwwroot/js/data.js b/wwwroot/js/data.js index 5d8284c..652ff30 100644 --- a/wwwroot/js/data.js +++ b/wwwroot/js/data.js @@ -1,5 +1,20 @@ import { api, adminApi } from "./api.js"; -import { handleAuthError, renderAllSuggestions, renderCounts, renderMySuggestions, renderPhasePill, renderPhaseTitles, renderResults, renderVotes, renderWelcome, setAuthUI, syncVoteScores, updatePhaseNav, openResultsRelockModal, openSuggestionsChangedModal } from "./ui.js"; +import { + handleAuthError, + renderAllSuggestions, + renderCounts, + renderMySuggestions, + renderPhasePill, + renderPhaseTitles, + renderResults, + renderVotes, + renderWelcome, + setAuthUI, + syncVoteScores, + updatePhaseNav, + openResultsRelockModal, + openSuggestionsChangedModal, +} from "./ui.js"; import { state, clearUserState } from "./state.js"; export async function loadState() { @@ -86,18 +101,26 @@ export async function loadResults() { } export async function refreshPhaseData() { + const before = buildRefreshSnapshot(); try { const prevPhase = state.phase; const prevResultsOpen = state.resultsOpen; await loadState(); - await Promise.all([loadSuggestData(), loadSuggestionsData(), loadResults()]); + await Promise.all([ + loadSuggestData(), + loadSuggestionsData(), + loadResults(), + ]); if (state.phase === "Vote") { if (!state.votesRendered) await loadVoteData(); } else { state.votesRendered = false; await loadVoteData(); } - if (state.me?.isAdmin) { + const adminCard = document.getElementById("admin-card"); + const adminPanelVisible = + !!adminCard && !adminCard.classList.contains("hidden"); + if (state.me?.isAdmin && adminPanelVisible) { state.adminVoteStatus = await adminApi.voteStatus(); } if ( @@ -109,12 +132,34 @@ export async function refreshPhaseData() { openResultsRelockModal(); } updatePhaseNav(); + const after = buildRefreshSnapshot(); + return before !== after; } catch (err) { if (handleAuthError(err, clearUserState)) return; throw err; } } +function buildRefreshSnapshot() { + return JSON.stringify({ + phase: state.phase, + resultsOpen: state.resultsOpen, + votesFinal: state.votesFinal, + hasJoker: state.hasJoker, + counts: state.counts + ? [ + state.counts.players, + state.counts.suggestions, + state.counts.votes, + ] + : null, + mineCount: state.mySuggestions?.length ?? 0, + allSig: state.allSuggestionsSig ?? "", + voteCount: state.myVotes?.length ?? 0, + resultsCount: state.results?.length ?? 0, + }); +} + export function signatureSuggestions(list) { return JSON.stringify( list.map((s) => [ diff --git a/wwwroot/js/dom.js b/wwwroot/js/dom.js index fec73c3..53f0474 100644 --- a/wwwroot/js/dom.js +++ b/wwwroot/js/dom.js @@ -1,6 +1,7 @@ export const $ = (id) => document.getElementById(id); -const toastEl = typeof document !== "undefined" ? document.getElementById("toast") : null; +const toastEl = + typeof document !== "undefined" ? document.getElementById("toast") : null; export function toast(msg, isError = false) { if (!toastEl) return; diff --git a/wwwroot/js/suggestions-ui.js b/wwwroot/js/suggestions-ui.js index 8b17bdd..2b9b640 100644 --- a/wwwroot/js/suggestions-ui.js +++ b/wwwroot/js/suggestions-ui.js @@ -49,16 +49,6 @@ export function renderMySuggestions() { 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(); } diff --git a/wwwroot/js/votes-ui.js b/wwwroot/js/votes-ui.js index 838fb2e..7f719d1 100644 --- a/wwwroot/js/votes-ui.js +++ b/wwwroot/js/votes-ui.js @@ -261,15 +261,6 @@ export function updatePhaseNav() { } } - 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.textContent = state.resultsOpen