Add admin status combobox to move voters back to suggest

This commit is contained in:
2026-02-08 15:00:09 +01:00
parent fadd72d5c4
commit 96a47020d8
17 changed files with 156 additions and 5 deletions

View File

@@ -47,7 +47,7 @@ async function refreshWithUiErrorHandling() {
function scheduleNextRefresh() {
refreshTimerId = window.setTimeout(async () => {
if (!document.hidden) {
if (!document.hidden && !state.adminStatusSelectActive) {
await refreshWithUiErrorHandling();
}
scheduleNextRefresh();
@@ -59,7 +59,7 @@ function startRefreshScheduler() {
refreshSchedulerStarted = true;
document.addEventListener("visibilitychange", () => {
if (!document.hidden) {
if (!document.hidden && !state.adminStatusSelectActive) {
refreshWithUiErrorHandling();
}
});

View File

@@ -55,3 +55,9 @@
border: 1px solid #e3d4bd;
background: #fffaf3;
}
.admin-status-select {
width: 100%;
min-width: 140px;
background: #fffaf3;
}

View File

@@ -150,6 +150,7 @@ Nein. Vorschläge und Bewertungen sind schreibgeschützt. Wende dich bei Bedarf
### Was können Admin-Konten tun?
- Joker während der Abstimmung vergeben
- Einen Bewerter zurück in die Vorschlagsphase setzen (stärker als ein Joker; sparsam einsetzen)
- Doppelte Vorschläge verknüpfen oder trennen
- Vorschläge löschen
- Abstimmungsstatus einsehen (wer finalisiert hat)

View File

@@ -154,6 +154,7 @@ No. Suggestions and votes are read-only. Contact an admin for assistance.
### What can admin accounts do?
- Grant jokers during Vote
- Move a voter back to Suggest (stronger than a joker; use sparingly)
- Link or unlink duplicate suggestions
- Delete suggestions
- View vote readiness (who has finalized)

View File

@@ -121,6 +121,8 @@
"admin.statusSuggesting": "Suggesting",
"admin.statusVoting": "Voting",
"admin.statusFinished": "Finished",
"admin.statusMoveToSuggest": "Move to Suggest",
"admin.statusUpdated": "Player phase updated",
"admin.deleteTitle": "Delete account?",
"admin.deleteBody": "Delete player \"{name}\" and all their games and votes? This cannot be undone.",
"admin.deleteConfirm": "Delete",
@@ -281,6 +283,8 @@
"admin.statusSuggesting": "Vorschlagen",
"admin.statusVoting": "Bewerten",
"admin.statusFinished": "Fertig",
"admin.statusMoveToSuggest": "Zur Vorschlagsphase",
"admin.statusUpdated": "Spielerphase aktualisiert",
"admin.deleteTitle": "Konto löschen?",
"admin.deleteBody": "Spieler \"{name}\" samt Spielen und Stimmen löschen? Dies kann nicht rückgängig gemacht werden.",
"admin.deleteConfirm": "Löschen",

View File

@@ -15,8 +15,20 @@ function displayPlayerStatus(player) {
return phase;
}
function buildStatusSelect(player) {
const statusText = displayPlayerStatus(player);
const canMoveToSuggest = player.phase === "Vote";
return `
<select class="chip admin-status-select" data-set-player-phase="${player.playerId}" aria-label="${t("admin.playerStatus")}">
<option value="" selected>${statusText}</option>
<option value="Suggest" ${canMoveToSuggest ? "" : "disabled"}>${t("admin.statusMoveToSuggest")}</option>
</select>
`;
}
export function renderAdminVoteStatus() {
if (!state.me?.isAdmin) return;
if (state.adminStatusSelectActive) return;
const statusBadge = $("admin-ready-status");
const table = $("admin-player-table")?.querySelector("tbody");
if (!state.adminVoteStatus || !statusBadge || !table) return;
@@ -24,14 +36,13 @@ export function renderAdminVoteStatus() {
table.innerHTML = "";
state.adminVoteStatus.voters.forEach((v) => {
const tr = document.createElement("tr");
const statusText = displayPlayerStatus(v);
const gamesTooltip = escapeHtml((v.suggestionTitles || []).join(", "));
const nameText = escapeHtml(truncate(v.name, 28));
const userText = escapeHtml(truncate(v.username, 24));
tr.innerHTML = `
<td title="${escapeHtml(v.name)}">${nameText}</td>
<td class="muted small" title="${escapeHtml(v.username)}">${userText}</td>
<td>${statusText}</td>
<td>${buildStatusSelect(v)}</td>
<td title="${gamesTooltip}">${v.suggestionCount ?? 0}</td>
<td><button class="chip" data-grant-joker="${v.playerId}" type="button">${v.hasJoker ? "🎟" : t("admin.grantJokerChip")}</button></td>
<td><button class="chip danger-chip" data-delete-player="${v.playerId}" data-name="${v.name}" type="button">✕</button></td>

View File

@@ -59,6 +59,8 @@ export const adminApi = {
reset: () => request("/api/admin/reset", { method: "POST" }),
factoryReset: () => request("/api/admin/factory-reset", { method: "POST" }),
grantJoker: (playerId) => request("/api/admin/joker", { method: "POST", body: { playerId } }),
setPlayerPhase: (playerId, phase) =>
request("/api/admin/player-phase", { method: "POST", body: { playerId, phase } }),
deletePlayer: (playerId) => request(`/api/admin/players/${playerId}`, { method: "DELETE" }),
linkSuggestions: (sourceSuggestionId, targetSuggestionId) =>
request("/api/admin/link-suggestions", { method: "POST", body: { sourceSuggestionId, targetSuggestionId } }),

View File

@@ -91,6 +91,44 @@ function setupLinkApply(runSerializedRefresh) {
function setupPlayerTableActions(runSerializedRefresh) {
const playerTable = $("admin-player-table");
if (!playerTable) return;
const phaseSelectSelector = "[data-set-player-phase]";
playerTable.addEventListener("focusin", (e) => {
if (e.target.matches?.(phaseSelectSelector)) {
state.adminStatusSelectActive = true;
}
});
playerTable.addEventListener("focusout", (e) => {
if (!e.target.matches?.(phaseSelectSelector)) return;
window.setTimeout(() => {
const focused = document.activeElement;
state.adminStatusSelectActive =
!!focused?.matches?.(phaseSelectSelector);
}, 0);
});
playerTable.addEventListener("change", async (e) => {
const select = e.target.closest(phaseSelectSelector);
if (!select) return;
const playerId = select.dataset.setPlayerPhase;
const phase = select.value;
if (!playerId || !phase) return;
select.disabled = true;
try {
await adminApi.setPlayerPhase(playerId, phase);
toast(t("admin.statusUpdated"));
state.adminStatusSelectActive = false;
await runSerializedRefresh();
} catch (err) {
select.value = "";
toast(err.message, true);
} finally {
select.disabled = false;
state.adminStatusSelectActive = false;
}
});
playerTable.addEventListener("click", async (e) => {
const grantBtn = e.target.closest("[data-grant-joker]");

View File

@@ -15,6 +15,7 @@ export const state = {
results: [],
votesRendered: false,
adminVoteStatus: null,
adminStatusSelectActive: false,
};
export function clearUserState() {
@@ -31,6 +32,7 @@ export function clearUserState() {
state.myVotes = [];
state.results = [];
state.votesRendered = false;
state.adminStatusSelectActive = false;
const adminCard = document.getElementById("admin-card");
if (adminCard) adminCard.classList.add("hidden");
}