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())}${tag}>`);
- 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())}${tag}>`);
+ 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 = `
`;
- 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