Add unlink flow for linked games
This commit is contained in:
@@ -8,3 +8,4 @@ public record ResultsOpenRequest(bool ResultsOpen);
|
|||||||
public record VoteFinalizeRequest(bool Final);
|
public record VoteFinalizeRequest(bool Final);
|
||||||
public record VoteStatusDto(Guid PlayerId, string Name, bool Finalized);
|
public record VoteStatusDto(Guid PlayerId, string Name, bool Finalized);
|
||||||
public record LinkSuggestionsRequest(int SourceSuggestionId, int TargetSuggestionId);
|
public record LinkSuggestionsRequest(int SourceSuggestionId, int TargetSuggestionId);
|
||||||
|
public record UnlinkSuggestionsRequest(int SuggestionId);
|
||||||
|
|||||||
@@ -124,6 +124,61 @@ public static class AdminEndpoints
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
admin.MapPost("/unlink-suggestions", async ([FromBody] UnlinkSuggestionsRequest request, HttpContext ctx, AppDbContext db, IConfiguration config) =>
|
||||||
|
{
|
||||||
|
var player = await EndpointHelpers.GetAuthenticatedPlayer(ctx, db);
|
||||||
|
if (player is null || !await EndpointHelpers.IsAdmin(ctx, db, config)) return Results.Unauthorized();
|
||||||
|
|
||||||
|
var phase = await EndpointHelpers.GetPhase(db, player.Id);
|
||||||
|
if (phase != Phase.Vote)
|
||||||
|
return EndpointHelpers.PhaseMismatch(Phase.Vote, phase);
|
||||||
|
|
||||||
|
var suggestions = await db.Suggestions.ToListAsync();
|
||||||
|
var target = suggestions.FirstOrDefault(s => s.Id == request.SuggestionId);
|
||||||
|
if (target is null)
|
||||||
|
return Results.NotFound(new { error = "Suggestion not found." });
|
||||||
|
|
||||||
|
var rootIndex = EndpointHelpers.BuildLinkRoots(suggestions.Select(s => (s.Id, s.ParentSuggestionId)));
|
||||||
|
if (!rootIndex.TryGetValue(target.Id, out var rootId))
|
||||||
|
return Results.Ok(new { UnlinkedSuggestionIds = Array.Empty<int>(), UnfinalizedPlayers = 0 });
|
||||||
|
|
||||||
|
var groupIds = rootIndex
|
||||||
|
.Where(kv => kv.Value == rootId)
|
||||||
|
.Select(kv => kv.Key)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
await using var tx = await db.Database.BeginTransactionAsync();
|
||||||
|
|
||||||
|
foreach (var suggestion in suggestions.Where(s => groupIds.Contains(s.Id)))
|
||||||
|
{
|
||||||
|
suggestion.ParentSuggestionId = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
var affectedPlayerIds = await db.Votes
|
||||||
|
.Where(v => groupIds.Contains(v.SuggestionId))
|
||||||
|
.Select(v => v.PlayerId)
|
||||||
|
.Distinct()
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
await db.Votes.Where(v => groupIds.Contains(v.SuggestionId)).ExecuteDeleteAsync();
|
||||||
|
|
||||||
|
if (affectedPlayerIds.Count > 0)
|
||||||
|
{
|
||||||
|
await db.Players.Where(p => affectedPlayerIds.Contains(p.Id))
|
||||||
|
.ExecuteUpdateAsync(p => p.SetProperty(x => x.VotesFinal, false));
|
||||||
|
}
|
||||||
|
|
||||||
|
await tx.CommitAsync();
|
||||||
|
|
||||||
|
return Results.Ok(new
|
||||||
|
{
|
||||||
|
UnlinkedSuggestionIds = groupIds,
|
||||||
|
UnfinalizedPlayers = affectedPlayerIds.Count
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
admin.MapPost("/reset", async (HttpContext ctx, AppDbContext db, IConfiguration config) =>
|
admin.MapPost("/reset", async (HttpContext ctx, AppDbContext db, IConfiguration config) =>
|
||||||
{
|
{
|
||||||
if (!await EndpointHelpers.IsAdmin(ctx, db, config)) return Results.Unauthorized();
|
if (!await EndpointHelpers.IsAdmin(ctx, db, config)) return Results.Unauthorized();
|
||||||
|
|||||||
@@ -58,4 +58,6 @@ export const adminApi = {
|
|||||||
factoryReset: () => request("/api/admin/factory-reset", { method: "POST" }),
|
factoryReset: () => request("/api/admin/factory-reset", { method: "POST" }),
|
||||||
linkSuggestions: (sourceSuggestionId, targetSuggestionId) =>
|
linkSuggestions: (sourceSuggestionId, targetSuggestionId) =>
|
||||||
request("/api/admin/link-suggestions", { method: "POST", body: { sourceSuggestionId, targetSuggestionId } }),
|
request("/api/admin/link-suggestions", { method: "POST", body: { sourceSuggestionId, targetSuggestionId } }),
|
||||||
|
unlinkSuggestions: (suggestionId) =>
|
||||||
|
request("/api/admin/unlink-suggestions", { method: "POST", body: { suggestionId } }),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -118,6 +118,11 @@ const translations = {
|
|||||||
"admin.linkTargetPlaceholder": "Select game B (parent)",
|
"admin.linkTargetPlaceholder": "Select game B (parent)",
|
||||||
"admin.linkValidation": "Choose two different games to link.",
|
"admin.linkValidation": "Choose two different games to link.",
|
||||||
"admin.linkDone": "Games linked. Votes cleared.",
|
"admin.linkDone": "Games linked. Votes cleared.",
|
||||||
|
"admin.unlinkTitle": "Remove links?",
|
||||||
|
"admin.unlinkBody": "Remove all links involving \"{name}\"? This clears votes and unfinalizes voters in this group: {peers}.",
|
||||||
|
"admin.unlinkConfirm": "Remove links",
|
||||||
|
"admin.unlinkDone": "Links removed. Votes cleared.",
|
||||||
|
"admin.unlinkUnknownPeers": "linked games",
|
||||||
|
|
||||||
"toast.unexpected": "Unexpected error",
|
"toast.unexpected": "Unexpected error",
|
||||||
"toast.registered": "Registered",
|
"toast.registered": "Registered",
|
||||||
@@ -258,6 +263,11 @@ const translations = {
|
|||||||
"admin.linkTargetPlaceholder": "Spiel B (Eltern) wählen",
|
"admin.linkTargetPlaceholder": "Spiel B (Eltern) wählen",
|
||||||
"admin.linkValidation": "Wähle zwei verschiedene Spiele aus.",
|
"admin.linkValidation": "Wähle zwei verschiedene Spiele aus.",
|
||||||
"admin.linkDone": "Spiele verknüpft. Stimmen gelöscht.",
|
"admin.linkDone": "Spiele verknüpft. Stimmen gelöscht.",
|
||||||
|
"admin.unlinkTitle": "Links entfernen?",
|
||||||
|
"admin.unlinkBody": "Alle Links zu \"{name}\" entfernen? Dadurch werden Stimmen gelöscht und Finalisierungen aufgehoben für: {peers}.",
|
||||||
|
"admin.unlinkConfirm": "Links entfernen",
|
||||||
|
"admin.unlinkDone": "Links entfernt. Stimmen gelöscht.",
|
||||||
|
"admin.unlinkUnknownPeers": "verknüpfte Spiele",
|
||||||
|
|
||||||
"toast.unexpected": "Unerwarteter Fehler",
|
"toast.unexpected": "Unerwarteter Fehler",
|
||||||
"toast.registered": "Registriert",
|
"toast.registered": "Registriert",
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { t } from "./i18n.js";
|
|||||||
import { state, getSavedUsername, setSavedUsername } from "./state.js";
|
import { state, getSavedUsername, setSavedUsername } from "./state.js";
|
||||||
import { $, toast } from "./dom.js";
|
import { $, toast } from "./dom.js";
|
||||||
import { setupCardVisualHover, triggerCelebration } from "./effects.js";
|
import { setupCardVisualHover, triggerCelebration } from "./effects.js";
|
||||||
|
import { adminApi } from "./api.js";
|
||||||
|
|
||||||
export function setAuthUI(isAuthed) {
|
export function setAuthUI(isAuthed) {
|
||||||
const main = document.querySelector("main");
|
const main = document.querySelector("main");
|
||||||
@@ -307,7 +308,9 @@ export function buildCard(
|
|||||||
? t("card.linkedWith", { names: linkedTitles.join(", ") })
|
? t("card.linkedWith", { names: linkedTitles.join(", ") })
|
||||||
: t("card.linked")
|
: t("card.linked")
|
||||||
: "";
|
: "";
|
||||||
const linkChip = linked ? `<span class="chip icon link-chip" title="${linkTooltip}">🔗</span>` : "";
|
const linkChip = linked
|
||||||
|
? `<button class="chip icon link-chip${state.me?.isAdmin ? " link-chip-action" : ""}" data-unlink="${s.id}" type="button" title="${linkTooltip}">🔗</button>`
|
||||||
|
: "";
|
||||||
const visual = hasImage
|
const visual = hasImage
|
||||||
? `<button class="card-visual" data-img="${s.screenshotUrl}" aria-label="${t("card.openScreenshot")}" style="background-image:url('${s.screenshotUrl}')"></button>`
|
? `<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>`;
|
: `<div class="card-visual"></div>`;
|
||||||
@@ -351,6 +354,10 @@ export function buildCard(
|
|||||||
setupCardVisualHover(btn, s.screenshotUrl);
|
setupCardVisualHover(btn, s.screenshotUrl);
|
||||||
btn.addEventListener("click", () => openLightbox(s.screenshotUrl, s.name));
|
btn.addEventListener("click", () => openLightbox(s.screenshotUrl, s.name));
|
||||||
}
|
}
|
||||||
|
if (linked && state.me?.isAdmin) {
|
||||||
|
const unlinkBtn = card.querySelector("[data-unlink]");
|
||||||
|
unlinkBtn?.addEventListener("click", () => openUnlinkConfirm(s));
|
||||||
|
}
|
||||||
if (allowEdit) {
|
if (allowEdit) {
|
||||||
const editBtn = card.querySelector("[data-edit]");
|
const editBtn = card.querySelector("[data-edit]");
|
||||||
editBtn?.addEventListener("click", () =>
|
editBtn?.addEventListener("click", () =>
|
||||||
@@ -890,6 +897,27 @@ function syncLinkedSliders(sourceEl, value) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function openUnlinkConfirm(s) {
|
||||||
|
const peers = linkedPeerTitles(s);
|
||||||
|
const names = peers.length ? peers.join(", ") : t("admin.unlinkUnknownPeers");
|
||||||
|
openConfirmModal({
|
||||||
|
title: t("admin.unlinkTitle"),
|
||||||
|
body: t("admin.unlinkBody", { name: s.name, peers: names }),
|
||||||
|
confirmLabel: t("admin.unlinkConfirm"),
|
||||||
|
cancelLabel: t("modal.cancel"),
|
||||||
|
onConfirm: async (close) => {
|
||||||
|
try {
|
||||||
|
await adminApi.unlinkSuggestions(s.id);
|
||||||
|
toast(t("admin.unlinkDone"));
|
||||||
|
close();
|
||||||
|
await window.refreshPhaseData();
|
||||||
|
} catch (err) {
|
||||||
|
toast(err.message, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function updatePhaseNav() {
|
export function updatePhaseNav() {
|
||||||
const isAdmin = !!state.me?.isAdmin;
|
const isAdmin = !!state.me?.isAdmin;
|
||||||
const phase = state.phase;
|
const phase = state.phase;
|
||||||
|
|||||||
Reference in New Issue
Block a user