diff --git a/Endpoints/StateEndpoints.cs b/Endpoints/StateEndpoints.cs
index e74c1a4..843cb42 100644
--- a/Endpoints/StateEndpoints.cs
+++ b/Endpoints/StateEndpoints.cs
@@ -35,10 +35,11 @@ public static class StateEndpoints
return Results.Ok(new { player.Id, player.DisplayName, player.Username, player.IsAdmin, player.CurrentPhase });
});
- app.MapPost("/api/me/phase/next", async (HttpContext ctx, AppDbContext db) =>
+ app.MapPost("/api/me/phase/next", async (HttpContext ctx, AppDbContext db, IConfiguration config) =>
{
var player = await EndpointHelpers.GetAuthenticatedPlayer(ctx, db);
if (player is null) return Results.Unauthorized();
+ var isAdmin = await EndpointHelpers.IsAdmin(ctx, db, config);
var next = NextPhase(player.CurrentPhase);
var appState = await db.AppState.FirstAsync();
@@ -48,15 +49,21 @@ public static class StateEndpoints
return Results.BadRequest(new { error = "Results are locked until the admin enables them." });
}
+ // Non-admins can only move forward
player.CurrentPhase = next;
await db.SaveChangesAsync();
return Results.Ok(new { player.CurrentPhase, appState.ResultsOpen });
});
- app.MapPost("/api/me/phase/prev", async (HttpContext ctx, AppDbContext db) =>
+ app.MapPost("/api/me/phase/prev", async (HttpContext ctx, AppDbContext db, IConfiguration config) =>
{
var player = await EndpointHelpers.GetAuthenticatedPlayer(ctx, db);
if (player is null) return Results.Unauthorized();
+ var isAdmin = await EndpointHelpers.IsAdmin(ctx, db, config);
+ if (!isAdmin)
+ {
+ return Results.BadRequest(new { error = "Only admins can move backward." });
+ }
player.CurrentPhase = PrevPhase(player.CurrentPhase);
await db.SaveChangesAsync();
diff --git a/Endpoints/SuggestEndpoints.cs b/Endpoints/SuggestEndpoints.cs
index e34a863..fe4fa64 100644
--- a/Endpoints/SuggestEndpoints.cs
+++ b/Endpoints/SuggestEndpoints.cs
@@ -14,9 +14,6 @@ public static class SuggestEndpoints
{
var player = await EndpointHelpers.GetAuthenticatedPlayer(ctx, db);
if (player is null) return Results.Unauthorized();
- var phase = await EndpointHelpers.GetPhase(db, player.Id);
- if (phase != Phase.Suggest)
- return EndpointHelpers.PhaseMismatch(Phase.Suggest, phase);
var mine = await db.Suggestions.AsNoTracking()
.Where(s => s.PlayerId == player.Id)
.Select(s => new
@@ -104,7 +101,7 @@ public static class SuggestEndpoints
var phase = await EndpointHelpers.GetPhase(db, player.Id);
if (!isAdmin && phase != Phase.Suggest)
- return EndpointHelpers.PhaseMismatch(Phase.Suggest, phase);
+ return Results.BadRequest(new { error = "Suggestions are frozen; you can no longer delete them." });
var suggestion = isAdmin
? await db.Suggestions.FirstOrDefaultAsync(s => s.Id == id)
@@ -127,8 +124,7 @@ public static class SuggestEndpoints
if (player is null) return Results.Unauthorized();
var phase = await EndpointHelpers.GetPhase(db, player.Id);
- if (phase != Phase.Suggest)
- return EndpointHelpers.PhaseMismatch(Phase.Suggest, phase);
+ // Non-admins can edit optional fields after Suggest, but not the name
}
if (string.IsNullOrWhiteSpace(request.Name) || request.Name.Length > 100)
@@ -157,7 +153,11 @@ public static class SuggestEndpoints
return Results.Unauthorized();
}
- suggestion.Name = request.Name.Trim();
+ var isSuggestPhase = isAdmin ? true : await EndpointHelpers.GetPhase(db, player?.Id ?? Guid.Empty) == Phase.Suggest;
+ if (isSuggestPhase || isAdmin)
+ {
+ suggestion.Name = request.Name.Trim();
+ }
suggestion.Genre = EndpointHelpers.TrimTo(request.Genre, 50);
suggestion.Description = EndpointHelpers.TrimTo(request.Description, 500);
suggestion.ScreenshotUrl = EndpointHelpers.TrimTo(request.ScreenshotUrl, 2048);
diff --git a/wwwroot/app.js b/wwwroot/app.js
index feb28b9..a21dcef 100644
--- a/wwwroot/app.js
+++ b/wwwroot/app.js
@@ -16,6 +16,8 @@ import {
renderResults,
renderPhaseTitles,
openNewSuggestionModal,
+ updatePhaseNav,
+ openConfirmModal,
} from "./js/ui.js";
import {
loadState,
@@ -63,6 +65,7 @@ function setupHandlers() {
if (state.phase === "Results") {
renderResults();
}
+ updatePhaseNav();
});
const loginForm = $("login-form");
@@ -122,39 +125,7 @@ function setupHandlers() {
});
}
- const prevPhaseBtn = $("prev-phase");
- if (prevPhaseBtn) {
- prevPhaseBtn.addEventListener("click", async () => {
- try {
- const resp = await api.prevPhase();
- state.prevPhase = state.phase;
- state.phase = resp.currentPhase;
- state.resultsOpen = resp.resultsOpen ?? state.resultsOpen;
- state.votesRendered = false;
- renderPhasePill();
- await refreshPhaseData();
- } catch (err) {
- toast(err.message, true);
- }
- });
- }
-
- const nextPhaseBtn = $("next-phase");
- if (nextPhaseBtn) {
- nextPhaseBtn.addEventListener("click", async () => {
- try {
- const resp = await api.nextPhase();
- state.prevPhase = state.phase;
- state.phase = resp.currentPhase;
- state.resultsOpen = resp.resultsOpen ?? state.resultsOpen;
- state.votesRendered = false;
- renderPhasePill();
- await refreshPhaseData();
- } catch (err) {
- toast(err.message, true);
- }
- });
- }
+ bindNavButtons();
$("reset").addEventListener("click", () => adminAction(adminApi.reset, t("admin.resetDone")));
$("factory-reset").addEventListener("click", () => adminAction(adminApi.factoryReset, t("admin.factoryResetDone")));
@@ -272,3 +243,64 @@ function setupLanguageSwitchers() {
updateLanguageButtons();
}
+
+function bindNavButtons() {
+ const makeForward = (id, before) => {
+ const btn = $(id);
+ if (!btn) return;
+ btn.addEventListener("click", async () => {
+ try {
+ if (before) {
+ const proceed = await before();
+ if (!proceed) return;
+ }
+ const resp = await api.nextPhase();
+ state.prevPhase = state.phase;
+ state.phase = resp.currentPhase;
+ state.resultsOpen = resp.resultsOpen ?? state.resultsOpen;
+ state.votesRendered = false;
+ renderPhasePill();
+ await refreshPhaseData();
+ } catch (err) {
+ toast(err.message, true);
+ }
+ });
+ };
+
+ const makeBack = (id) => {
+ const btn = $(id);
+ if (!btn) return;
+ btn.addEventListener("click", async () => {
+ try {
+ const resp = await api.prevPhase();
+ state.prevPhase = state.phase;
+ state.phase = resp.currentPhase;
+ state.resultsOpen = resp.resultsOpen ?? state.resultsOpen;
+ state.votesRendered = false;
+ renderPhasePill();
+ await refreshPhaseData();
+ } catch (err) {
+ toast(err.message, true);
+ }
+ });
+ };
+
+ makeForward("nav-suggest-next", async () => {
+ return await new Promise((resolve) => {
+ openConfirmModal({
+ title: t("nav.freezeModalTitle"),
+ body: t("nav.freezeModalBody"),
+ confirmLabel: t("nav.next"),
+ onConfirm: (close) => {
+ close();
+ resolve(true);
+ },
+ });
+ });
+ });
+ makeForward("nav-reveal-next");
+ makeForward("nav-vote-next");
+
+ makeBack("nav-reveal-prev");
+ makeBack("nav-vote-prev");
+}
diff --git a/wwwroot/css/components.css b/wwwroot/css/components.css
index 20e573c..d4a5eb5 100644
--- a/wwwroot/css/components.css
+++ b/wwwroot/css/components.css
@@ -135,6 +135,27 @@ button .chip {
align-items: center;
font-weight: 600;
}
+
+.phase-nav {
+ display: flex;
+ flex-direction: row;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+}
+
+.phase-nav .nav-text {
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+ color: #4b3a26;
+}
+
+.phase-nav .nav-actions {
+ display: flex;
+ gap: 8px;
+ align-items: center;
+}
.vote-controls {
display: flex;
gap: 10px;
diff --git a/wwwroot/css/forms-and-auth.css b/wwwroot/css/forms-and-auth.css
index 8a12ad4..b841d39 100644
--- a/wwwroot/css/forms-and-auth.css
+++ b/wwwroot/css/forms-and-auth.css
@@ -22,3 +22,9 @@
justify-content: space-between;
gap: 12px;
}
+
+input[readonly].readonly {
+ background: #f7f3eb;
+ color: #6f6353;
+ cursor: not-allowed;
+}
diff --git a/wwwroot/index.html b/wwwroot/index.html
index efbf914..e218f20 100644
--- a/wwwroot/index.html
+++ b/wwwroot/index.html
@@ -71,10 +71,7 @@
Logout
-
Loading…
-
- Results locked by admin
—
@@ -102,6 +99,15 @@
Your suggestions
+
+
+
Ready to reveal?
+
Moving forward will freeze your suggestions. Titles become locked; only extra details stay editable.
+
+
+
+
+
@@ -109,6 +115,15 @@
All Suggestions
+
+
+
Review all games, then advance to voting when ready.
+
+
+
+
+
+
@@ -116,6 +131,16 @@
Vote 0–10
+
+
+
Cast votes for every game to unlock results.
+
Results locked by admin
+
+
+
+
+
+
diff --git a/wwwroot/js/i18n.js b/wwwroot/js/i18n.js
index d35cbe4..45bb45c 100644
--- a/wwwroot/js/i18n.js
+++ b/wwwroot/js/i18n.js
@@ -34,6 +34,12 @@ const translations = {
"nav.prev": "Back",
"nav.next": "Next",
"nav.waitingForResults": "Waiting…",
+ "nav.freezeTitle": "Ready to reveal?",
+ "nav.freezeHint": "Moving forward will freeze your suggestions. Titles become locked; only extra details stay editable.",
+ "nav.freezeModalTitle": "Freeze suggestions?",
+ "nav.freezeModalBody": "Once you leave Suggest, your games are locked: titles cannot be changed or deleted. Only optional details (description, links, players, artwork) remain editable. Continue?",
+ "nav.revealHint": "Review all games, then advance to voting when ready.",
+ "nav.voteHint": "Cast votes for every game to unlock results.",
"suggest.title": "Suggest games (up to 5)",
"suggest.new": "Add new suggestion",
@@ -146,6 +152,12 @@ const translations = {
"nav.prev": "Zurück",
"nav.next": "Weiter",
"nav.waitingForResults": "Warten…",
+ "nav.freezeTitle": "Bereit zum Aufdecken?",
+ "nav.freezeHint": "Beim Weitergehen werden deine Vorschläge eingefroren. Titel bleiben gesperrt; nur Zusatzinfos bleiben bearbeitbar.",
+ "nav.freezeModalTitle": "Vorschläge einfrieren?",
+ "nav.freezeModalBody": "Sobald du die Vorschlagsphase verlässt, sind deine Spiele gesperrt: Titel können nicht mehr geändert oder gelöscht werden. Nur optionale Angaben (Beschreibung, Links, Spielerzahlen, Bilder) bleiben bearbeitbar. Fortfahren?",
+ "nav.revealHint": "Sieh dir alle Spiele an und gehe dann zur Abstimmung weiter.",
+ "nav.voteHint": "Bewerte alle Spiele, um die Ergebnisse freizuschalten.",
"suggest.title": "Schlage Spiele vor (bis zu 5)",
"suggest.new": "Neuen Vorschlag hinzufügen",
diff --git a/wwwroot/js/ui.js b/wwwroot/js/ui.js
index 6cf53b4..d760482 100644
--- a/wwwroot/js/ui.js
+++ b/wwwroot/js/ui.js
@@ -79,27 +79,7 @@ export function renderPhasePill() {
const id = viewMap[state.phase];
if (id) $(id).classList.remove("hidden");
- const prevBtn = $("prev-phase");
- if (prevBtn) prevBtn.disabled = state.phase === "Suggest";
-
- const nextBtn = $("next-phase");
- if (nextBtn) {
- const atResults = state.phase === "Results";
- const locked = !state.resultsOpen && state.phase === "Vote";
- nextBtn.disabled = atResults || locked;
- nextBtn.textContent = locked ? t("nav.waitingForResults") : t("nav.next");
- }
-
- const resultsLock = $("results-lock");
- if (resultsLock) {
- resultsLock.classList.toggle("hidden", state.resultsOpen);
- resultsLock.textContent = t("admin.resultsLocked");
- }
-
- const adminResultsToggle = $("results-open");
- if (adminResultsToggle) {
- adminResultsToggle.checked = !!state.resultsOpen;
- }
+ updatePhaseNav();
}
export function renderCounts() {
@@ -125,10 +105,12 @@ export function renderMySuggestions() {
const wrap = $("my-suggestions");
if (!wrap) return;
wrap.innerHTML = "";
- const allowEdit = state.phase === "Suggest" || state.me?.isAdmin;
+ 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;
state.mySuggestions.forEach((s) =>
wrap.appendChild(
- buildCard(s, { showAuthor: false, allowDelete: true, allowEdit }),
+ buildCard(s, { showAuthor: false, allowDelete, allowEdit, lockTitle }),
),
);
}
@@ -306,7 +288,7 @@ export function renderPhaseTitles() {
export function buildCard(
s,
- { showAuthor = false, allowDelete = false, allowEdit = false },
+ { showAuthor = false, allowDelete = false, allowEdit = false, lockTitle = false },
) {
const card = document.createElement("article");
card.className = "game-card";
@@ -366,6 +348,7 @@ export function buildCard(
close();
await window.refreshPhaseData();
},
+ lockTitle,
}),
);
}
@@ -378,7 +361,7 @@ export function buildCard(
return card;
}
-function buildSuggestionForm(initial = {}) {
+function buildSuggestionForm(initial = {}, lockTitle = false) {
const form = document.createElement("form");
form.className = "stack suggestion-form";
form.innerHTML = `
@@ -441,7 +424,13 @@ function buildSuggestionForm(initial = {}) {
const setVal = (name, value) => {
const input = form.querySelector(`[name="${name}"]`);
- if (input) input.value = value ?? "";
+ if (input) {
+ input.value = value ?? "";
+ if (name === "name" && lockTitle) {
+ input.readOnly = true;
+ input.classList.add("readonly");
+ }
+ }
};
setVal("name", initial.name ?? "");
setVal("genre", initial.genre ?? "");
@@ -472,7 +461,7 @@ function buildSuggestionForm(initial = {}) {
}
}
-function openSuggestionModal({ title, submitLabel, initial = {}, onSubmit }) {
+function openSuggestionModal({ title, submitLabel, initial = {}, onSubmit, lockTitle = false }) {
const overlay = document.createElement("div");
overlay.className = "edit-modal";
const panel = document.createElement("div");
@@ -485,7 +474,7 @@ function openSuggestionModal({ title, submitLabel, initial = {}, onSubmit }) {
`;
- const form = buildSuggestionForm(initial);
+ const form = buildSuggestionForm(initial, lockTitle);
const actions = document.createElement("div");
actions.className = "stack horizontal";
const submitBtn = document.createElement("button");
@@ -565,6 +554,52 @@ export function openLightbox(url, title) {
document.body.appendChild(overlay);
}
+export function openConfirmModal({ title, body, confirmLabel, cancelLabel = t("modal.cancel"), onConfirm }) {
+ const overlay = document.createElement("div");
+ overlay.className = "edit-modal";
+ const panel = document.createElement("div");
+ panel.className = "edit-panel";
+ panel.innerHTML = `
+
+
+ `;
+ const actions = document.createElement("div");
+ actions.className = "stack horizontal";
+ const confirmBtn = document.createElement("button");
+ confirmBtn.textContent = confirmLabel ?? t("modal.confirm");
+ const cancelBtn = document.createElement("button");
+ cancelBtn.className = "ghost";
+ cancelBtn.type = "button";
+ cancelBtn.textContent = cancelLabel;
+ actions.append(confirmBtn, cancelBtn);
+ panel.querySelector(".edit-body")?.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);
+ 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) => {
@@ -681,3 +716,44 @@ function isValidImageUrl(url) {
return false;
}
}
+
+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-reveal", phase === "Reveal");
+ showNav("nav-vote", phase === "Vote");
+
+ const lockBadge = $("results-lock");
+ if (lockBadge) {
+ const locked = !state.resultsOpen && phase === "Vote";
+ lockBadge.classList.toggle("hidden", !locked);
+ lockBadge.textContent = t("admin.resultsLocked");
+ }
+
+ // Toggle admin-only back buttons
+ const backButtons = ["nav-reveal-prev", "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;
+ }
+}