Link games
Use during voting to merge duplicates. Linking clears votes and unfinalizes voters.
diff --git a/wwwroot/js/api.js b/wwwroot/js/api.js
index adbb3f6..6a84f82 100644
--- a/wwwroot/js/api.js
+++ b/wwwroot/js/api.js
@@ -56,6 +56,7 @@ export const adminApi = {
voteStatus: () => request("/api/admin/vote-status"),
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 } }),
linkSuggestions: (sourceSuggestionId, targetSuggestionId) =>
request("/api/admin/link-suggestions", { method: "POST", body: { sourceSuggestionId, targetSuggestionId } }),
unlinkSuggestions: (suggestionId) =>
diff --git a/wwwroot/js/data.js b/wwwroot/js/data.js
index 4cfd9cc..037556b 100644
--- a/wwwroot/js/data.js
+++ b/wwwroot/js/data.js
@@ -6,6 +6,7 @@ export async function loadState() {
const [me, stateData] = await Promise.all([api.me(), api.state()]);
state.isAuthenticated = true;
state.me = me;
+ state.hasJoker = me.hasJoker ?? false;
state.prevPhase = state.phase;
state.phase = stateData.currentPhase;
state.resultsOpen = stateData.resultsOpen;
diff --git a/wwwroot/js/i18n.js b/wwwroot/js/i18n.js
index 59ba3f7..2e36b95 100644
--- a/wwwroot/js/i18n.js
+++ b/wwwroot/js/i18n.js
@@ -44,6 +44,7 @@ const translations = {
"suggest.title": "Suggest games (up to 5)",
"suggest.new": "Add new suggestion",
"suggest.addButton": "Suggest a game",
+ "suggest.jokerAddButton": "Use joker: suggest a game",
"suggest.hint": "Only you can see your suggestions until voting starts.",
"form.gameName": "Game name *",
"form.genre": "Genre",
@@ -109,6 +110,13 @@ const translations = {
"admin.factoryResetDone": "Factory reset complete",
"admin.readyForResults": "Ready for results",
"admin.waitingForPlayers": "Waiting for players: {names}",
+ "admin.jokerTitle": "Jokers",
+ "admin.jokerHint": "Grant a player one extra suggestion during voting.",
+ "admin.jokerSelect": "Player",
+ "admin.jokerGive": "Grant joker",
+ "admin.jokerGranted": "Joker granted",
+ "admin.jokerSelectFirst": "Pick a player first.",
+ "admin.jokerPlaceholder": "Pick a player",
"admin.linkTitle": "Link games",
"admin.linkHint": "Use during voting to merge duplicates. Linking clears votes and unfinalizes voters.",
"admin.linkSource": "Game to link",
@@ -189,6 +197,7 @@ const translations = {
"suggest.title": "Schlage Spiele vor (bis zu 5)",
"suggest.new": "Neuen Vorschlag hinzufügen",
"suggest.addButton": "Spiel vorschlagen",
+ "suggest.jokerAddButton": "Joker nutzen: Spiel vorschlagen",
"suggest.hint": "Nur du siehst deine Vorschläge bis zum Start der Abstimmung.",
"form.gameName": "Spielname *",
"form.genre": "Genre",
@@ -254,6 +263,13 @@ const translations = {
"admin.factoryResetDone": "Werkseinstellung abgeschlossen",
"admin.readyForResults": "Bereit für Ergebnisse",
"admin.waitingForPlayers": "Warten auf: {names}",
+ "admin.jokerTitle": "Joker",
+ "admin.jokerHint": "Gib einem Spieler einen Joker für einen zusätzlichen Vorschlag in der Bewertungsphase.",
+ "admin.jokerSelect": "Spieler",
+ "admin.jokerGive": "Joker vergeben",
+ "admin.jokerGranted": "Joker vergeben",
+ "admin.jokerSelectFirst": "Wähle zuerst einen Spieler.",
+ "admin.jokerPlaceholder": "Spieler wählen",
"admin.linkTitle": "Spiele verknüpfen",
"admin.linkHint": "Nutze dies in der Bewertungsphase, um Duplikate zu verbinden. Das löscht die Stimmen der verknüpften Spiele und hebt Finalisierungen auf.",
"admin.linkSource": "Spiel verknüpfen",
diff --git a/wwwroot/js/state.js b/wwwroot/js/state.js
index 2fedc9c..98d7ded 100644
--- a/wwwroot/js/state.js
+++ b/wwwroot/js/state.js
@@ -6,6 +6,7 @@ export const state = {
prevPhase: null,
resultsOpen: false,
votesFinal: false,
+ hasJoker: false,
counts: null,
mySuggestions: [],
allSuggestions: [],
@@ -22,6 +23,7 @@ export function clearUserState() {
state.prevPhase = null;
state.resultsOpen = false;
state.votesFinal = false;
+ state.hasJoker = false;
state.counts = null;
state.mySuggestions = [];
state.allSuggestions = [];
diff --git a/wwwroot/js/ui.js b/wwwroot/js/ui.js
index 06e7abd..2f40c84 100644
--- a/wwwroot/js/ui.js
+++ b/wwwroot/js/ui.js
@@ -565,11 +565,16 @@ export function openNewSuggestionModal() {
submitLabel: t("form.submit"),
initial: {},
onSubmit: async (data, close, submitBtn) => {
+ const wasVotePhase = state.phase === "Vote";
await api.createSuggestion(data);
toast(t("toast.suggestionAdded"));
if (submitBtn) triggerCelebration(submitBtn);
close();
- await window.loadSuggestData();
+ if (wasVotePhase) {
+ await window.refreshPhaseData();
+ } else {
+ await window.loadSuggestData();
+ }
},
});
}
@@ -712,13 +717,16 @@ function renderAdminVoteStatus() {
if (!state.me?.isAdmin) return;
const list = $("admin-voter-list");
const status = $("admin-ready-status");
+ const jokerWrap = $("admin-joker");
+ const jokerSelect = $("joker-player");
if (!state.adminVoteStatus || !list || !status) return;
list.innerHTML = "";
state.adminVoteStatus.voters.forEach((v) => {
const li = document.createElement("li");
const name = v.name?.length > 24 ? `${v.name.slice(0, 21)}…` : v.name;
- li.textContent = `${name} — ${v.finalized ? "✅" : "⏳"}`;
+ const jokerMark = v.hasJoker ? " 🎟" : "";
+ li.textContent = `${name}${jokerMark} — ${v.finalized ? "✅" : "⏳"}`;
li.title = v.name;
list.appendChild(li);
});
@@ -732,6 +740,29 @@ function renderAdminVoteStatus() {
? t("admin.readyForResults")
: t("admin.waitingForPlayers", { names: waitingDisplay.join(", ") });
status.className = ready ? "badge" : "badge warning";
+
+ if (jokerWrap) jokerWrap.classList.toggle("hidden", state.phase !== "Vote");
+ if (jokerSelect && state.phase === "Vote") {
+ const previous = jokerSelect.value;
+ jokerSelect.innerHTML = "";
+ const placeholder = document.createElement("option");
+ placeholder.value = "";
+ placeholder.disabled = true;
+ placeholder.selected = true;
+ placeholder.textContent = t("admin.jokerPlaceholder");
+ jokerSelect.appendChild(placeholder);
+
+ state.adminVoteStatus.voters.forEach((v) => {
+ const opt = document.createElement("option");
+ opt.value = v.playerId;
+ opt.textContent = v.hasJoker ? `${v.name} — 🎟` : v.name;
+ jokerSelect.appendChild(opt);
+ });
+
+ if (previous && Array.from(jokerSelect.options).some((o) => o.value === previous)) {
+ jokerSelect.value = previous;
+ }
+ }
}
function renderAdminLinker() {
@@ -961,6 +992,12 @@ export function updatePhaseNav() {
showNav("nav-suggest", phase === "Suggest");
showNav("nav-vote", phase === "Vote");
+ const jokerBtn = $("open-joker-modal");
+ if (jokerBtn) {
+ const showJoker = phase === "Vote" && state.hasJoker;
+ jokerBtn.classList.toggle("hidden", !showJoker);
+ jokerBtn.disabled = !showJoker;
+ }
const finalizeBtn = $("finalize-votes");
if (finalizeBtn) {