Rework admin player table and account deletion
This commit is contained in:
@@ -1,3 +1,5 @@
|
|||||||
|
using GameList.Domain;
|
||||||
|
|
||||||
namespace GameList.Contracts;
|
namespace GameList.Contracts;
|
||||||
|
|
||||||
public record SetNameRequest(string Name);
|
public record SetNameRequest(string Name);
|
||||||
@@ -6,7 +8,8 @@ public record SuggestionDto(int Id, string Name, string? Genre, string? Descript
|
|||||||
public record VoteRequest(int SuggestionId, int Score);
|
public record VoteRequest(int SuggestionId, int Score);
|
||||||
public record ResultsOpenRequest(bool ResultsOpen);
|
public record ResultsOpenRequest(bool ResultsOpen);
|
||||||
public record VoteFinalizeRequest(bool Final);
|
public record VoteFinalizeRequest(bool Final);
|
||||||
public record VoteStatusDto(Guid PlayerId, string Name, bool Finalized, bool HasJoker);
|
public record VoteStatusDto(Guid PlayerId, string Name, string Username, Phase Phase, bool Finalized, bool HasJoker);
|
||||||
public record LinkSuggestionsRequest(int SourceSuggestionId, int TargetSuggestionId);
|
public record LinkSuggestionsRequest(int SourceSuggestionId, int TargetSuggestionId);
|
||||||
public record UnlinkSuggestionsRequest(int SuggestionId);
|
public record UnlinkSuggestionsRequest(int SuggestionId);
|
||||||
public record GrantJokerRequest(Guid PlayerId);
|
public record GrantJokerRequest(Guid PlayerId);
|
||||||
|
public record DeletePlayerRequest(Guid PlayerId);
|
||||||
|
|||||||
@@ -42,9 +42,13 @@ public static class AdminEndpoints
|
|||||||
|
|
||||||
var voters = await db.Players
|
var voters = await db.Players
|
||||||
.AsNoTracking()
|
.AsNoTracking()
|
||||||
.Where(p => p.CurrentPhase == Phase.Vote || p.Suggestions.Any())
|
|
||||||
.OrderBy(p => p.DisplayName ?? p.Username)
|
.OrderBy(p => p.DisplayName ?? p.Username)
|
||||||
.Select(p => new VoteStatusDto(p.Id, p.DisplayName ?? p.Username, p.VotesFinal, p.HasJoker))
|
.Select(p => new VoteStatusDto(p.Id,
|
||||||
|
p.DisplayName ?? p.Username,
|
||||||
|
p.Username,
|
||||||
|
p.CurrentPhase,
|
||||||
|
p.VotesFinal,
|
||||||
|
p.HasJoker))
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
|
|
||||||
var waiting = voters.Where(v => !v.Finalized).Select(v => v.Name).ToList();
|
var waiting = voters.Where(v => !v.Finalized).Select(v => v.Name).ToList();
|
||||||
@@ -69,6 +73,41 @@ public static class AdminEndpoints
|
|||||||
return Results.Ok(new { player.Id, player.HasJoker });
|
return Results.Ok(new { player.Id, player.HasJoker });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
admin.MapDelete("/players/{playerId:guid}", async (Guid playerId, HttpContext ctx, AppDbContext db, IConfiguration config) =>
|
||||||
|
{
|
||||||
|
if (!await EndpointHelpers.IsAdmin(ctx, db, config)) return Results.Unauthorized();
|
||||||
|
|
||||||
|
var player = await db.Players
|
||||||
|
.Include(p => p.Suggestions)
|
||||||
|
.FirstOrDefaultAsync(p => p.Id == playerId);
|
||||||
|
if (player is null) return Results.NotFound(new { error = "Player not found." });
|
||||||
|
|
||||||
|
await using var tx = await db.Database.BeginTransactionAsync();
|
||||||
|
|
||||||
|
// Remove votes cast by the player
|
||||||
|
await db.Votes.Where(v => v.PlayerId == playerId).ExecuteDeleteAsync();
|
||||||
|
|
||||||
|
// Collect suggestions authored by the player
|
||||||
|
var suggestionIds = player.Suggestions.Select(s => s.Id).ToList();
|
||||||
|
if (suggestionIds.Count > 0)
|
||||||
|
{
|
||||||
|
// Break links pointing to these suggestions
|
||||||
|
await db.Suggestions
|
||||||
|
.Where(s => s.ParentSuggestionId != null && suggestionIds.Contains(s.ParentSuggestionId.Value))
|
||||||
|
.ExecuteUpdateAsync(s => s.SetProperty(x => x.ParentSuggestionId, (int?)null));
|
||||||
|
|
||||||
|
// Remove votes for these suggestions to avoid orphaned rows
|
||||||
|
await db.Votes.Where(v => suggestionIds.Contains(v.SuggestionId)).ExecuteDeleteAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete player (cascades suggestions)
|
||||||
|
db.Players.Remove(player);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
await tx.CommitAsync();
|
||||||
|
|
||||||
|
return Results.Ok(new { DeletedPlayerId = playerId });
|
||||||
|
});
|
||||||
|
|
||||||
admin.MapPost("/link-suggestions", async ([FromBody] LinkSuggestionsRequest request, HttpContext ctx, AppDbContext db, IConfiguration config) =>
|
admin.MapPost("/link-suggestions", async ([FromBody] LinkSuggestionsRequest request, HttpContext ctx, AppDbContext db, IConfiguration config) =>
|
||||||
{
|
{
|
||||||
var player = await EndpointHelpers.GetAuthenticatedPlayer(ctx, db);
|
var player = await EndpointHelpers.GetAuthenticatedPlayer(ctx, db);
|
||||||
|
|||||||
@@ -204,17 +204,38 @@ function setupHandlers() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const grantJokerBtn = $("grant-joker");
|
const playerTable = $("admin-player-table");
|
||||||
if (grantJokerBtn) {
|
if (playerTable) {
|
||||||
grantJokerBtn.addEventListener("click", async () => {
|
playerTable.addEventListener("click", async (e) => {
|
||||||
const playerId = $("joker-player")?.value;
|
const grantBtn = e.target.closest("[data-grant-joker]");
|
||||||
if (!playerId) return toast(t("admin.jokerSelectFirst"), true);
|
const deleteBtn = e.target.closest("[data-delete-player]");
|
||||||
try {
|
if (grantBtn) {
|
||||||
await adminApi.grantJoker(playerId);
|
const playerId = grantBtn.dataset.grantJoker;
|
||||||
toast(t("admin.jokerGranted"));
|
try {
|
||||||
await refreshPhaseData();
|
await adminApi.grantJoker(playerId);
|
||||||
} catch (err) {
|
toast(t("admin.jokerGranted"));
|
||||||
toast(err.message, true);
|
await refreshPhaseData();
|
||||||
|
} catch (err) {
|
||||||
|
toast(err.message, true);
|
||||||
|
}
|
||||||
|
} else if (deleteBtn) {
|
||||||
|
const playerId = deleteBtn.dataset.deletePlayer;
|
||||||
|
const name = deleteBtn.dataset.name || "";
|
||||||
|
openConfirmModal({
|
||||||
|
title: t("admin.deleteTitle"),
|
||||||
|
body: t("admin.deleteBody", { name }),
|
||||||
|
confirmLabel: t("admin.deleteConfirm"),
|
||||||
|
onConfirm: async (close) => {
|
||||||
|
try {
|
||||||
|
await adminApi.deletePlayer(playerId);
|
||||||
|
toast(t("admin.deleteDone"));
|
||||||
|
close();
|
||||||
|
await refreshPhaseData();
|
||||||
|
} catch (err) {
|
||||||
|
toast(err.message, true);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -144,22 +144,27 @@
|
|||||||
<h3 data-i18n="admin.title">Admin</h3>
|
<h3 data-i18n="admin.title">Admin</h3>
|
||||||
<button id="admin-close" class="ghost">✕</button>
|
<button id="admin-close" class="ghost">✕</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="stack" id="admin-vote-status">
|
<div class="stack" id="admin-players">
|
||||||
<div class="badge" id="admin-ready-status"></div>
|
<div class="badge" id="admin-ready-status"></div>
|
||||||
<ul class="status-list" id="admin-voter-list"></ul>
|
<div class="table-scroll">
|
||||||
|
<table class="compact" id="admin-player-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th data-i18n="admin.playerName">Name</th>
|
||||||
|
<th data-i18n="admin.playerUsername">Username</th>
|
||||||
|
<th data-i18n="admin.playerStatus">Status</th>
|
||||||
|
<th data-i18n="admin.playerJoker">Joker</th>
|
||||||
|
<th data-i18n="admin.playerDelete">Delete</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<label class="stack toggle-row">
|
<label class="stack toggle-row">
|
||||||
<input type="checkbox" id="results-open" />
|
<input type="checkbox" id="results-open" />
|
||||||
<span data-i18n="admin.resultsOpenToggle">Allow results phase</span>
|
<span data-i18n="admin.resultsOpenToggle">Allow results phase</span>
|
||||||
</label>
|
</label>
|
||||||
<div class="stack hidden" id="admin-joker">
|
|
||||||
<h4 data-i18n="admin.jokerTitle">Jokers</h4>
|
|
||||||
<label class="stack">
|
|
||||||
<span class="label" data-i18n="admin.jokerSelect">Player</span>
|
|
||||||
<select id="joker-player"></select>
|
|
||||||
</label>
|
|
||||||
<button id="grant-joker" class="secondary" type="button" data-i18n="admin.jokerGive">Grant joker</button>
|
|
||||||
</div>
|
|
||||||
<div class="stack hidden" id="admin-linker">
|
<div class="stack hidden" id="admin-linker">
|
||||||
<h4 data-i18n="admin.linkTitle">Link games</h4>
|
<h4 data-i18n="admin.linkTitle">Link games</h4>
|
||||||
<label class="stack">
|
<label class="stack">
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ export const adminApi = {
|
|||||||
reset: () => request("/api/admin/reset", { method: "POST" }),
|
reset: () => request("/api/admin/reset", { method: "POST" }),
|
||||||
factoryReset: () => request("/api/admin/factory-reset", { method: "POST" }),
|
factoryReset: () => request("/api/admin/factory-reset", { method: "POST" }),
|
||||||
grantJoker: (playerId) => request("/api/admin/joker", { method: "POST", body: { playerId } }),
|
grantJoker: (playerId) => request("/api/admin/joker", { method: "POST", body: { playerId } }),
|
||||||
|
deletePlayer: (playerId) => request(`/api/admin/players/${playerId}`, { method: "DELETE" }),
|
||||||
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) =>
|
unlinkSuggestions: (suggestionId) =>
|
||||||
|
|||||||
@@ -110,12 +110,20 @@ const translations = {
|
|||||||
"admin.factoryResetDone": "Factory reset complete",
|
"admin.factoryResetDone": "Factory reset complete",
|
||||||
"admin.readyForResults": "Ready for results",
|
"admin.readyForResults": "Ready for results",
|
||||||
"admin.waitingForPlayers": "Waiting for players: {names}",
|
"admin.waitingForPlayers": "Waiting for players: {names}",
|
||||||
"admin.jokerTitle": "Jokers",
|
"admin.playerName": "Name",
|
||||||
"admin.jokerSelect": "Player",
|
"admin.playerUsername": "Username",
|
||||||
"admin.jokerGive": "Grant joker",
|
"admin.playerStatus": "Status",
|
||||||
|
"admin.playerJoker": "Joker",
|
||||||
|
"admin.playerDelete": "Delete",
|
||||||
|
"admin.grantJokerChip": "Grant",
|
||||||
|
"admin.statusSuggesting": "Suggesting",
|
||||||
|
"admin.statusVoting": "Voting",
|
||||||
|
"admin.statusFinished": "Finished",
|
||||||
|
"admin.deleteTitle": "Delete account?",
|
||||||
|
"admin.deleteBody": "Delete player \"{name}\" and all their games and votes? This cannot be undone.",
|
||||||
|
"admin.deleteConfirm": "Delete",
|
||||||
|
"admin.deleteDone": "Player deleted",
|
||||||
"admin.jokerGranted": "Joker granted",
|
"admin.jokerGranted": "Joker granted",
|
||||||
"admin.jokerSelectFirst": "Pick a player first.",
|
|
||||||
"admin.jokerPlaceholder": "Pick a player",
|
|
||||||
"admin.linkTitle": "Link games",
|
"admin.linkTitle": "Link games",
|
||||||
"admin.linkSource": "Game to link",
|
"admin.linkSource": "Game to link",
|
||||||
"admin.linkTarget": "Link to (parent)",
|
"admin.linkTarget": "Link to (parent)",
|
||||||
@@ -261,12 +269,20 @@ const translations = {
|
|||||||
"admin.factoryResetDone": "Werkseinstellung abgeschlossen",
|
"admin.factoryResetDone": "Werkseinstellung abgeschlossen",
|
||||||
"admin.readyForResults": "Bereit für Ergebnisse",
|
"admin.readyForResults": "Bereit für Ergebnisse",
|
||||||
"admin.waitingForPlayers": "Warten auf: {names}",
|
"admin.waitingForPlayers": "Warten auf: {names}",
|
||||||
"admin.jokerTitle": "Joker",
|
"admin.playerName": "Name",
|
||||||
"admin.jokerSelect": "Spieler",
|
"admin.playerUsername": "Benutzername",
|
||||||
"admin.jokerGive": "Joker vergeben",
|
"admin.playerStatus": "Status",
|
||||||
|
"admin.playerJoker": "Joker",
|
||||||
|
"admin.playerDelete": "Löschen",
|
||||||
|
"admin.grantJokerChip": "Joker",
|
||||||
|
"admin.statusSuggesting": "Vorschlagen",
|
||||||
|
"admin.statusVoting": "Bewerten",
|
||||||
|
"admin.statusFinished": "Fertig",
|
||||||
|
"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",
|
||||||
|
"admin.deleteDone": "Spieler gelöscht",
|
||||||
"admin.jokerGranted": "Joker vergeben",
|
"admin.jokerGranted": "Joker vergeben",
|
||||||
"admin.jokerSelectFirst": "Wähle zuerst einen Spieler.",
|
|
||||||
"admin.jokerPlaceholder": "Spieler wählen",
|
|
||||||
"admin.linkTitle": "Spiele verknüpfen",
|
"admin.linkTitle": "Spiele verknüpfen",
|
||||||
"admin.linkSource": "Spiel verknüpfen",
|
"admin.linkSource": "Spiel verknüpfen",
|
||||||
"admin.linkTarget": "Verknüpfen mit",
|
"admin.linkTarget": "Verknüpfen mit",
|
||||||
|
|||||||
@@ -9,6 +9,10 @@ const sortByName = (items) =>
|
|||||||
(items ?? [])
|
(items ?? [])
|
||||||
.slice()
|
.slice()
|
||||||
.sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: "base" }));
|
.sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: "base" }));
|
||||||
|
const truncate = (text, max) => {
|
||||||
|
if (!text) return "";
|
||||||
|
return text.length > max ? `${text.slice(0, max - 1)}…` : text;
|
||||||
|
};
|
||||||
|
|
||||||
export function setAuthUI(isAuthed) {
|
export function setAuthUI(isAuthed) {
|
||||||
const main = document.querySelector("main");
|
const main = document.querySelector("main");
|
||||||
@@ -715,20 +719,22 @@ function updateMissingBadgeFromDom() {
|
|||||||
|
|
||||||
function renderAdminVoteStatus() {
|
function renderAdminVoteStatus() {
|
||||||
if (!state.me?.isAdmin) return;
|
if (!state.me?.isAdmin) return;
|
||||||
const list = $("admin-voter-list");
|
const statusBadge = $("admin-ready-status");
|
||||||
const status = $("admin-ready-status");
|
const table = $("admin-player-table")?.querySelector("tbody");
|
||||||
const jokerWrap = $("admin-joker");
|
if (!state.adminVoteStatus || !statusBadge || !table) return;
|
||||||
const jokerSelect = $("joker-player");
|
|
||||||
if (!state.adminVoteStatus || !list || !status) return;
|
|
||||||
|
|
||||||
list.innerHTML = "";
|
table.innerHTML = "";
|
||||||
state.adminVoteStatus.voters.forEach((v) => {
|
state.adminVoteStatus.voters.forEach((v) => {
|
||||||
const li = document.createElement("li");
|
const tr = document.createElement("tr");
|
||||||
const name = v.name?.length > 24 ? `${v.name.slice(0, 21)}…` : v.name;
|
const statusText = displayPlayerStatus(v);
|
||||||
const jokerMark = v.hasJoker ? " 🎟" : "";
|
tr.innerHTML = `
|
||||||
li.textContent = `${name}${jokerMark} — ${v.finalized ? "✅" : "⏳"}`;
|
<td title="${v.name}">${truncate(v.name, 28)}</td>
|
||||||
li.title = v.name;
|
<td class="muted small" title="${v.username}">${truncate(v.username, 24)}</td>
|
||||||
list.appendChild(li);
|
<td>${statusText}</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>
|
||||||
|
`;
|
||||||
|
table.appendChild(tr);
|
||||||
});
|
});
|
||||||
|
|
||||||
const waiting = state.adminVoteStatus.waiting;
|
const waiting = state.adminVoteStatus.waiting;
|
||||||
@@ -736,33 +742,19 @@ function renderAdminVoteStatus() {
|
|||||||
const waitingDisplay = waiting.map((name) =>
|
const waitingDisplay = waiting.map((name) =>
|
||||||
name?.length > 24 ? `${name.slice(0, 21)}…` : name,
|
name?.length > 24 ? `${name.slice(0, 21)}…` : name,
|
||||||
);
|
);
|
||||||
status.textContent = ready
|
statusBadge.textContent = ready
|
||||||
? t("admin.readyForResults")
|
? t("admin.readyForResults")
|
||||||
: t("admin.waitingForPlayers", { names: waitingDisplay.join(", ") });
|
: t("admin.waitingForPlayers", { names: waitingDisplay.join(", ") });
|
||||||
status.className = ready ? "badge" : "badge warning";
|
statusBadge.className = ready ? "badge" : "badge warning";
|
||||||
|
}
|
||||||
|
|
||||||
if (jokerWrap) jokerWrap.classList.toggle("hidden", state.phase !== "Vote");
|
function displayPlayerStatus(player) {
|
||||||
if (jokerSelect && state.phase === "Vote") {
|
if (!player) return "";
|
||||||
const previous = jokerSelect.value;
|
const phase = player.phase;
|
||||||
jokerSelect.innerHTML = "";
|
if (phase === "Suggest") return t("admin.statusSuggesting");
|
||||||
const placeholder = document.createElement("option");
|
if (phase === "Vote") return player.finalized ? t("admin.statusFinished") : t("admin.statusVoting");
|
||||||
placeholder.value = "";
|
if (phase === "Results") return t("admin.statusFinished");
|
||||||
placeholder.disabled = true;
|
return phase;
|
||||||
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() {
|
function renderAdminLinker() {
|
||||||
|
|||||||
Reference in New Issue
Block a user