Add linked suggestions with synced voting
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 } }),
|
||||
};
|
||||
|
||||
@@ -91,6 +91,7 @@ export function signatureSuggestions(list) {
|
||||
s.gameUrl,
|
||||
s.minPlayers,
|
||||
s.maxPlayers,
|
||||
s.parentSuggestionId,
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -75,6 +75,8 @@ const translations = {
|
||||
"card.site": "Site ↗",
|
||||
"card.youtube": "YouTube ↗",
|
||||
"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 ↗",
|
||||
"card.youtube": "YouTube ↗",
|
||||
"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",
|
||||
|
||||
141
wwwroot/js/ui.js
141
wwwroot/js/ui.js
@@ -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"];
|
||||
|
||||
Reference in New Issue
Block a user