Lock suggestions after reveal and move per-phase navigation

This commit is contained in:
2026-02-04 21:59:26 +01:00
parent e5e27af0af
commit ea0f8f2e27
8 changed files with 252 additions and 73 deletions

View File

@@ -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");
}

View File

@@ -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;

View File

@@ -22,3 +22,9 @@
justify-content: space-between;
gap: 12px;
}
input[readonly].readonly {
background: #f7f3eb;
color: #6f6353;
cursor: not-allowed;
}

View File

@@ -71,10 +71,7 @@
<a id="logout" href="#" class="link inline-link" data-i18n="auth.logout">Logout</a>
</div>
<div class="status-center">
<button id="prev-phase" class="chip nav-btn" type="button" data-i18n="nav.prev">Back</button>
<span id="phase-pill" data-i18n="phase.loading">Loading…</span>
<button id="next-phase" class="chip nav-btn" type="button" data-i18n="nav.next">Next</button>
<span class="badge warning hidden" id="results-lock" data-i18n="admin.resultsLocked">Results locked by admin</span>
<span class="counts" id="counts"></span>
</div>
<div class="status-right">
@@ -102,6 +99,15 @@
<h3 data-i18n="section.mySuggestions">Your suggestions</h3>
<div id="my-suggestions" class="card-grid"></div>
</div>
<div class="card subcard phase-nav" id="nav-suggest">
<div class="nav-text">
<strong data-i18n="nav.freezeTitle">Ready to reveal?</strong>
<p data-i18n="nav.freezeHint">Moving forward will freeze your suggestions. Titles become locked; only extra details stay editable.</p>
</div>
<div class="nav-actions">
<button id="nav-suggest-next" class="primary" data-i18n="nav.next">Next</button>
</div>
</div>
</div>
<div id="reveal-view" class="phase-view hidden">
@@ -109,6 +115,15 @@
<h2 id="reveal-title" data-i18n="section.allSuggestions">All Suggestions</h2>
</div>
<div id="all-suggestions" class="card-grid"></div>
<div class="card subcard phase-nav" id="nav-reveal">
<div class="nav-text">
<p data-i18n="nav.revealHint">Review all games, then advance to voting when ready.</p>
</div>
<div class="nav-actions">
<button id="nav-reveal-prev" class="ghost" data-i18n="nav.prev">Back</button>
<button id="nav-reveal-next" class="primary" data-i18n="nav.next">Next</button>
</div>
</div>
</div>
<div id="vote-view" class="phase-view hidden">
@@ -116,6 +131,16 @@
<h2 id="vote-title" data-i18n="section.vote">Vote 010</h2>
</div>
<div id="vote-list" class="card-grid"></div>
<div class="card subcard phase-nav" id="nav-vote">
<div class="nav-text">
<p data-i18n="nav.voteHint">Cast votes for every game to unlock results.</p>
<span class="badge warning hidden" id="results-lock" data-i18n="admin.resultsLocked">Results locked by admin</span>
</div>
<div class="nav-actions">
<button id="nav-vote-prev" class="ghost" data-i18n="nav.prev">Back</button>
<button id="nav-vote-next" class="primary" data-i18n="nav.next">Next</button>
</div>
</div>
</div>
<div id="results-view" class="phase-view hidden">

View File

@@ -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",

View File

@@ -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 }) {
<div class="edit-body"></div>
`;
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 = `
<div class="edit-header">
<h3>${title}</h3>
<button class="lightbox-close" aria-label="${t("modal.close")}">x</button>
</div>
<div class="edit-body">
<p>${body}</p>
</div>
`;
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;
}
}