Add per-user phase navigation with results toggle

This commit is contained in:
2026-02-04 21:43:12 +01:00
parent b64a33d833
commit e5e27af0af
24 changed files with 507 additions and 88 deletions

View File

@@ -122,31 +122,39 @@ function setupHandlers() {
});
}
$("set-phase").addEventListener("click", async () => {
const phase = $("phase-select").value;
try {
await adminApi.setPhase(phase);
toast(t("admin.phaseUpdated"));
state.prevPhase = state.phase;
state.phase = phase;
state.votesRendered = false;
renderPhasePill();
$("phase-select").dataset.userEditing = "";
await refreshPhaseData();
} catch (err) {
toast(err.message, true);
}
});
const phaseSelect = $("phase-select");
["focus", "input", "click"].forEach((evt) => {
phaseSelect.addEventListener(evt, () => {
phaseSelect.dataset.userEditing = "1";
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);
}
});
});
phaseSelect.addEventListener("blur", () => {
phaseSelect.dataset.userEditing = "";
});
}
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);
}
});
}
$("reset").addEventListener("click", () => adminAction(adminApi.reset, t("admin.resetDone")));
$("factory-reset").addEventListener("click", () => adminAction(adminApi.factoryReset, t("admin.factoryResetDone")));
@@ -182,6 +190,22 @@ function setupHandlers() {
adminToggle.addEventListener("click", () => togglePanel(adminCard.classList.contains("hidden")));
adminClose.addEventListener("click", () => togglePanel(false));
}
const resultsToggle = $("results-open");
if (resultsToggle) {
resultsToggle.addEventListener("change", async (e) => {
const desired = !!e.target.checked;
try {
const resp = await adminApi.setResultsOpen(desired);
state.resultsOpen = resp.resultsOpen;
renderPhasePill();
toast(t("admin.resultsUpdated"));
} catch (err) {
e.target.checked = !desired;
toast(err.message, true);
}
});
}
}
async function adminAction(fn, successMessage) {

View File

@@ -108,6 +108,33 @@ button .chip {
border-color: #a83a35;
}
.nav-btn {
min-width: 64px;
font-weight: 700;
}
.badge {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 8px;
border-radius: 999px;
font-size: 12px;
border: 1px solid transparent;
}
.badge.warning {
background: #fff0d6;
color: #7a4a00;
border-color: #f0c66b;
}
.toggle-row {
display: flex;
gap: 8px;
align-items: center;
font-weight: 600;
}
.vote-controls {
display: flex;
gap: 10px;

View File

@@ -83,6 +83,10 @@
align-items: center;
gap: 10px;
}
.status-center {
flex-wrap: wrap;
justify-content: center;
}
.logo-mark {
height: 65px;
margin: -10px;

View File

@@ -71,7 +71,10 @@
<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">
@@ -130,15 +133,10 @@
<h3 data-i18n="admin.title">Admin</h3>
<button id="admin-close" class="ghost"></button>
</div>
<div class="stack horizontal">
<select id="phase-select">
<option value="Suggest" data-i18n="phase.suggest">Suggest</option>
<option value="Reveal" data-i18n="phase.reveal">Reveal</option>
<option value="Vote" data-i18n="phase.vote">Vote</option>
<option value="Results" data-i18n="phase.results">Results</option>
</select>
<button id="set-phase" data-i18n="admin.setPhase">Set phase</button>
</div>
<label class="stack toggle-row">
<input type="checkbox" id="results-open" />
<span data-i18n="admin.resultsOpenToggle">Allow results phase</span>
</label>
<div class="stack horizontal">
<button id="reset" class="danger" data-i18n="admin.reset">Reset (keep players)</button>
<button id="factory-reset" class="danger" data-i18n="admin.factoryReset">Factory reset</button>

View File

@@ -46,10 +46,12 @@ export const api = {
vote: (suggestionId, score) => request("/api/votes", { method: "POST", body: { suggestionId, score } }),
results: () => request("/api/results"),
nextPhase: () => request("/api/me/phase/next", { method: "POST" }),
prevPhase: () => request("/api/me/phase/prev", { method: "POST" }),
};
export const adminApi = {
setPhase: (phase) => request("/api/admin/phase", { method: "POST", body: { phase } }),
setResultsOpen: (resultsOpen) => request("/api/admin/results", { method: "POST", body: { resultsOpen } }),
reset: () => request("/api/admin/reset", { method: "POST" }),
factoryReset: () => request("/api/admin/factory-reset", { method: "POST" }),
};

View File

@@ -8,6 +8,7 @@ export async function loadState() {
state.me = me;
state.prevPhase = state.phase;
state.phase = stateData.currentPhase;
state.resultsOpen = stateData.resultsOpen;
state.counts = stateData;
if (state.prevPhase !== state.phase && state.phase === "Vote") {
state.votesRendered = false;
@@ -52,7 +53,7 @@ export async function loadVoteData() {
}
export async function loadResults() {
if (state.phase !== "Results") return;
if (state.phase !== "Results" || !state.resultsOpen) return;
state.results = await api.results();
renderResults();
}

View File

@@ -31,6 +31,10 @@ const translations = {
"counts.format": "Players: {players} • Suggestions: {suggestions} • Votes: {votes}",
"nav.prev": "Back",
"nav.next": "Next",
"nav.waitingForResults": "Waiting…",
"suggest.title": "Suggest games (up to 5)",
"suggest.new": "Add new suggestion",
"suggest.addButton": "Suggest a game",
@@ -79,10 +83,11 @@ const translations = {
"admin.title": "Admin",
"admin.tools": "Admin tools",
"admin.setPhase": "Set phase",
"admin.resultsOpenToggle": "Allow results phase",
"admin.resultsLocked": "Results locked by admin",
"admin.resultsUpdated": "Results availability updated",
"admin.reset": "Reset (keep players)",
"admin.factoryReset": "Factory reset",
"admin.phaseUpdated": "Phase updated",
"admin.resetDone": "Reset complete",
"admin.factoryResetDone": "Factory reset complete",
@@ -138,6 +143,10 @@ const translations = {
"counts.format": "Spieler: {players} • Vorschläge: {suggestions} • Stimmen: {votes}",
"nav.prev": "Zurück",
"nav.next": "Weiter",
"nav.waitingForResults": "Warten…",
"suggest.title": "Schlage Spiele vor (bis zu 5)",
"suggest.new": "Neuen Vorschlag hinzufügen",
"suggest.addButton": "Spiel vorschlagen",
@@ -186,10 +195,11 @@ const translations = {
"admin.title": "Admin",
"admin.tools": "Admin-Werkzeuge",
"admin.setPhase": "Phase setzen",
"admin.resultsOpenToggle": "Ergebnisse freigeben",
"admin.resultsLocked": "Ergebnisse vom Admin gesperrt",
"admin.resultsUpdated": "Ergebnisfreigabe aktualisiert",
"admin.reset": "Zurücksetzen (Spieler behalten)",
"admin.factoryReset": "Werkseinstellung",
"admin.phaseUpdated": "Phase aktualisiert",
"admin.resetDone": "Zurücksetzen abgeschlossen",
"admin.factoryResetDone": "Werkseinstellung abgeschlossen",

View File

@@ -4,6 +4,7 @@ export const state = {
me: null,
phase: null,
prevPhase: null,
resultsOpen: false,
counts: null,
mySuggestions: [],
allSuggestions: [],
@@ -17,6 +18,7 @@ export function clearUserState() {
state.me = null;
state.phase = null;
state.prevPhase = null;
state.resultsOpen = false;
state.counts = null;
state.mySuggestions = [];
state.allSuggestions = [];

View File

@@ -65,7 +65,8 @@ export function handleAuthError(err, clearUserState) {
export function renderPhasePill() {
const phaseKey = typeof state.phase === "string" ? state.phase.toLowerCase() : null;
$("phase-pill").textContent = phaseKey ? "" : t("phase.loading");
const pill = $("phase-pill");
if (pill) pill.textContent = phaseKey ? t(`phase.${phaseKey}`) : t("phase.loading");
document.querySelectorAll(".phase-view").forEach((el) =>
el.classList.add("hidden"),
);
@@ -77,9 +78,27 @@ export function renderPhasePill() {
};
const id = viewMap[state.phase];
if (id) $(id).classList.remove("hidden");
const phaseSelect = $("phase-select");
if (phaseSelect && !phaseSelect.dataset.userEditing) {
phaseSelect.value = state.phase || "Suggest";
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;
}
}