Add linked suggestions with synced voting

This commit is contained in:
2026-02-05 09:07:46 +01:00
parent 431370ceb9
commit 5d432c9d17
19 changed files with 725 additions and 34 deletions

View File

@@ -177,6 +177,24 @@ function setupHandlers() {
}
});
}
const linkApply = $("link-apply");
if (linkApply) {
linkApply.addEventListener("click", async () => {
const source = Number($("link-source")?.value);
const target = Number($("link-target")?.value);
if (!source || !target || source === target) {
return toast(t("admin.linkValidation"), true);
}
try {
await adminApi.linkSuggestions(source, target);
toast(t("admin.linkDone"));
await refreshPhaseData();
} catch (err) {
toast(err.message, true);
}
});
}
}
async function adminAction(fn, successMessage) {

View File

@@ -29,3 +29,11 @@
justify-content: space-between;
align-items: center;
}
#admin-linker select {
width: 100%;
padding: 8px;
border-radius: 8px;
border: 1px solid #e3d4bd;
background: #fffaf3;
}

View File

@@ -99,6 +99,11 @@ button .chip {
width: 30px;
font-size: 18px;
}
.chip.link-chip {
background: #d7e7ff;
border: 1px solid #b9d1ff;
color: #1b3d75;
}
.chip.danger-chip {
background: #e0564f;
border: 1px solid #c54740;

View File

@@ -151,6 +151,19 @@
<input type="checkbox" id="results-open" />
<span data-i18n="admin.resultsOpenToggle">Allow results phase</span>
</label>
<div class="stack hidden" id="admin-linker">
<h4 data-i18n="admin.linkTitle">Link games</h4>
<p class="muted small" data-i18n="admin.linkHint">Use during voting to merge duplicates. Linking clears votes and unfinalizes voters.</p>
<label class="stack">
<span class="label" data-i18n="admin.linkSource">Game to link</span>
<select id="link-source"></select>
</label>
<label class="stack">
<span class="label" data-i18n="admin.linkTarget">Link to (parent)</span>
<select id="link-target"></select>
</label>
<button id="link-apply" class="secondary" type="button" data-i18n="admin.linkAction">Link & clear votes</button>
</div>
<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

@@ -56,4 +56,6 @@ export const adminApi = {
voteStatus: () => request("/api/admin/vote-status"),
reset: () => request("/api/admin/reset", { method: "POST" }),
factoryReset: () => request("/api/admin/factory-reset", { method: "POST" }),
linkSuggestions: (sourceSuggestionId, targetSuggestionId) =>
request("/api/admin/link-suggestions", { method: "POST", body: { sourceSuggestionId, targetSuggestionId } }),
};

View File

@@ -91,6 +91,7 @@ export function signatureSuggestions(list) {
s.gameUrl,
s.minPlayers,
s.maxPlayers,
s.parentSuggestionId,
]),
);
}

View File

@@ -75,6 +75,8 @@ const translations = {
"card.site": "Site&nbsp;↗",
"card.youtube": "YouTube&nbsp;↗",
"card.openScreenshot": "Open screenshot",
"card.linked": "Votes linked",
"card.linkedWith": "Linked with: {names}",
"vote.saved": "Saved vote",
"vote.missing": "Missing",
@@ -107,6 +109,15 @@ const translations = {
"admin.factoryResetDone": "Factory reset complete",
"admin.readyForResults": "Ready for results",
"admin.waitingForPlayers": "Waiting for players: {names}",
"admin.linkTitle": "Link games",
"admin.linkHint": "Use during voting to merge duplicates. Linking clears votes and unfinalizes voters.",
"admin.linkSource": "Game to link",
"admin.linkTarget": "Link to (parent)",
"admin.linkAction": "Link & clear votes",
"admin.linkSourcePlaceholder": "Select game A",
"admin.linkTargetPlaceholder": "Select game B (parent)",
"admin.linkValidation": "Choose two different games to link.",
"admin.linkDone": "Games linked. Votes cleared.",
"toast.unexpected": "Unexpected error",
"toast.registered": "Registered",
@@ -204,6 +215,8 @@ const translations = {
"card.site": "Webseite&nbsp;↗",
"card.youtube": "YouTube&nbsp;↗",
"card.openScreenshot": "Screenshot öffnen",
"card.linked": "Verknüpfte Stimmen",
"card.linkedWith": "Verknüpft mit: {names}",
"vote.saved": "Stimme gespeichert",
"vote.missing": "Fehlt",
@@ -236,6 +249,15 @@ const translations = {
"admin.factoryResetDone": "Werkseinstellung abgeschlossen",
"admin.readyForResults": "Bereit für Ergebnisse",
"admin.waitingForPlayers": "Warten auf: {names}",
"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",
"admin.linkTarget": "Verknüpfen mit (Eltern)",
"admin.linkAction": "Verknüpfen & Stimmen löschen",
"admin.linkSourcePlaceholder": "Spiel A wählen",
"admin.linkTargetPlaceholder": "Spiel B (Eltern) wählen",
"admin.linkValidation": "Wähle zwei verschiedene Spiele aus.",
"admin.linkDone": "Spiele verknüpft. Stimmen gelöscht.",
"toast.unexpected": "Unerwarteter Fehler",
"toast.registered": "Registriert",

View File

@@ -115,6 +115,7 @@ export function renderMySuggestions() {
}
export function renderAllSuggestions() {
renderAdminLinker();
const list = $("all-suggestions");
if (!list) return;
list.innerHTML = "";
@@ -147,12 +148,14 @@ export function renderVotes() {
const current = hasVote ? votesMap[s.id] : 5; // start neutral when no prior vote
const displayScore = hasVote ? current : "—";
const displayEmoji = hasVote ? scoreToEmoji(current) : "⚠️";
const linkedIds = linkedPeerIds(s);
const rootId = linkRootId(s);
const footer = document.createElement("div");
footer.className = "vote-controls";
footer.innerHTML = `
<div class="warning-text ${hasVote ? "hidden" : ""}" id="warn-${s.id}">${state.votesFinal ? t("vote.missingFinalWarn") : t("vote.missingWarn")}</div>
<div class="vote-row">
<input class="full-slider" type="range" min="0" max="10" value="${current}" data-id="${s.id}" ${state.votesFinal ? "disabled" : ""}>
<input class="full-slider" type="range" min="0" max="10" value="${current}" data-id="${s.id}" data-root="${rootId}" data-linked="${linkedIds.join(",")}" ${state.votesFinal ? "disabled" : ""}>
<span class="score" id="score-${s.id}">${displayScore}</span>
<span class="score-emoji" id="emoji-${s.id}">${displayEmoji}</span>
</div>`;
@@ -169,6 +172,7 @@ export function renderVotes() {
if (emojiEl) emojiEl.textContent = scoreToEmoji(val);
const warn = $("warn-" + e.target.dataset.id);
if (warn) warn.classList.add("hidden");
syncLinkedSliders(e.target, val);
});
input.addEventListener("change", async (e) => {
if (state.votesFinal) return;
@@ -244,7 +248,7 @@ export function renderResults() {
<td class="game-cell">
${r.screenshotUrl ? `<img class="thumb clickable-thumb" src="${r.screenshotUrl}" alt="${r.name}">` : ''}
<div class="game-meta">
<div class="title-line">${r.name}</div>
<div class="title-line">${r.name} ${renderLinkBadge(r)}</div>
${buildResultMeta(r)}
</div>
</td>
@@ -296,6 +300,14 @@ export function buildCard(
const card = document.createElement("article");
card.className = "game-card";
const hasImage = !!s.screenshotUrl;
const linkedTitles = linkedPeerTitles(s);
const linked = isLinked(s);
const linkTooltip = linked
? linkedTitles.length > 0
? t("card.linkedWith", { names: linkedTitles.join(", ") })
: t("card.linked")
: "";
const linkChip = linked ? `<span class="chip icon link-chip" title="${linkTooltip}">🔗</span>` : "";
const visual = hasImage
? `<button class="card-visual" data-img="${s.screenshotUrl}" aria-label="${t("card.openScreenshot")}" style="background-image:url('${s.screenshotUrl}')"></button>`
: `<div class="card-visual"></div>`;
@@ -320,6 +332,7 @@ export function buildCard(
<div class="card-title-row">
<h3 class="card-title" title="${s.name}">${s.name}</h3>
<div class="title-meta">
${linkChip}
${showAuthor && s.author ? `<span class="chip">${s.author}</span>` : ""}
${allowEdit ? `<button class="chip icon" data-edit="${s.id}" type="button" title="${t("card.edit")}">✏️</button>` : ""}
${allowDelete ? `<button class="chip icon danger-chip" data-delete="${s.id}" type="button" title="${t("card.delete")}">✕</button>` : ""}
@@ -681,6 +694,63 @@ function renderAdminVoteStatus() {
status.className = ready ? "badge" : "badge warning";
}
function renderAdminLinker() {
const wrap = $("admin-linker");
const source = $("link-source");
const target = $("link-target");
if (!wrap || !source || !target) return;
const visible = state.me?.isAdmin && state.phase === "Vote";
wrap.classList.toggle("hidden", !visible);
if (!visible) return;
const previousSource = source.value;
const previousTarget = target.value;
const options = (state.allSuggestions ?? []).slice().sort((a, b) => a.name.localeCompare(b.name));
const fillSelect = (select, placeholderKey) => {
select.innerHTML = "";
const placeholder = document.createElement("option");
placeholder.value = "";
placeholder.textContent = t(placeholderKey);
placeholder.disabled = true;
placeholder.selected = true;
select.appendChild(placeholder);
options.forEach((s) => {
const opt = document.createElement("option");
opt.value = s.id;
opt.textContent = buildLinkOptionLabel(s);
opt.dataset.root = linkRootId(s);
select.appendChild(opt);
});
};
fillSelect(source, "admin.linkSourcePlaceholder");
fillSelect(target, "admin.linkTargetPlaceholder");
if (previousSource && options.some((s) => String(s.id) === previousSource)) source.value = previousSource;
if (previousTarget && options.some((s) => String(s.id) === previousTarget)) target.value = previousTarget;
const preventSameSelection = () => {
const sourceVal = source.value;
const targetVal = target.value;
Array.from(target.options).forEach((opt) => {
if (!opt.value) return;
opt.disabled = opt.value === sourceVal;
});
Array.from(source.options).forEach((opt) => {
if (!opt.value) return;
opt.disabled = opt.value === targetVal;
});
};
source.onchange = preventSameSelection;
target.onchange = preventSameSelection;
preventSameSelection();
}
function openDeleteConfirmModal(s) {
const overlay = document.createElement("div");
overlay.className = "edit-modal";
@@ -754,6 +824,72 @@ function isValidImageUrl(url) {
}
}
function linkRootId(s) {
return s?.parentSuggestionId ?? s?.id;
}
function linkedPeerIds(s) {
if (!s) return [];
if (Array.isArray(s.linkedIds) && s.linkedIds.length > 0) {
return s.linkedIds.filter((id) => id !== s.id);
}
if (!state.allSuggestions?.length) return [];
const root = linkRootId(s);
return state.allSuggestions
.filter((other) => linkRootId(other) === root && other.id !== s.id)
.map((other) => other.id);
}
function linkedPeerTitles(s) {
if (!s) return [];
if (Array.isArray(s.linkedTitles) && s.linkedTitles.length > 0) {
return s.linkedTitles;
}
if (!state.allSuggestions?.length) return [];
const root = linkRootId(s);
return state.allSuggestions
.filter((other) => linkRootId(other) === root && other.id !== s.id)
.map((other) => other.name);
}
function isLinked(s) {
return !!s?.parentSuggestionId || linkedPeerIds(s).length > 0;
}
function linkTooltip(s) {
const peers = linkedPeerTitles(s);
if (peers.length === 0) return t("card.linked");
return t("card.linkedWith", { names: peers.join(", ") });
}
function renderLinkBadge(s) {
if (!isLinked(s)) return "";
return `<span class="chip icon link-chip" title="${linkTooltip(s)}">🔗</span>`;
}
function buildLinkOptionLabel(s) {
const author = s.author ? `${s.author}` : "";
const linked = isLinked(s) ? " 🔗" : "";
return `${s.name}${author}${linked}`;
}
function syncLinkedSliders(sourceEl, value) {
const linkedAttr = sourceEl?.dataset?.linked;
if (!linkedAttr) return;
const ids = linkedAttr.split(",").filter(Boolean);
ids.forEach((id) => {
const slider = document.querySelector(`input[type=range][data-id="${id}"]`);
if (!slider || slider === sourceEl) return;
slider.value = value;
const scoreLabel = $("score-" + id);
if (scoreLabel) scoreLabel.textContent = value;
const emojiEl = $("emoji-" + id);
if (emojiEl) emojiEl.textContent = scoreToEmoji(Number(value));
const warn = $("warn-" + id);
if (warn) warn.classList.add("hidden");
});
}
export function updatePhaseNav() {
const isAdmin = !!state.me?.isAdmin;
const phase = state.phase;
@@ -791,6 +927,7 @@ export function updatePhaseNav() {
}
renderAdminVoteStatus();
renderAdminLinker();
// Toggle admin-only back buttons
const backButtons = ["nav-vote-prev"];