Add admin status combobox to move voters back to suggest
This commit is contained in:
1
API.md
1
API.md
@@ -36,5 +36,6 @@ POST /api/admin/results — `{ resultsOpen: bool }` locks/unlocks results and al
|
|||||||
GET /api/admin/vote-status — readiness overview (who finalized)
|
GET /api/admin/vote-status — readiness overview (who finalized)
|
||||||
POST /api/admin/link-suggestions — `{ sourceSuggestionId, targetSuggestionId }`; merges vote groups during Vote, clears votes in the linked group, unfinalizes **all** players
|
POST /api/admin/link-suggestions — `{ sourceSuggestionId, targetSuggestionId }`; merges vote groups during Vote, clears votes in the linked group, unfinalizes **all** players
|
||||||
POST /api/admin/unlink-suggestions — `{ suggestionId }`; breaks links, clears votes for that group, unfinalizes **all** players
|
POST /api/admin/unlink-suggestions — `{ suggestionId }`; breaks links, clears votes for that group, unfinalizes **all** players
|
||||||
|
POST /api/admin/player-phase — `{ playerId, phase }`; currently supports Vote→Suggest transitions only
|
||||||
POST /api/admin/reset — clear suggestions/votes; keep players; reset phases/vote-final flags
|
POST /api/admin/reset — clear suggestions/votes; keep players; reset phases/vote-final flags
|
||||||
POST /api/admin/factory-reset — wipe players, suggestions, votes, state
|
POST /api/admin/factory-reset — wipe players, suggestions, votes, state
|
||||||
|
|||||||
@@ -19,3 +19,5 @@ public record LinkSuggestionsRequest(int SourceSuggestionId, int TargetSuggestio
|
|||||||
public record UnlinkSuggestionsRequest(int SuggestionId);
|
public record UnlinkSuggestionsRequest(int SuggestionId);
|
||||||
|
|
||||||
public record GrantJokerRequest(Guid PlayerId);
|
public record GrantJokerRequest(Guid PlayerId);
|
||||||
|
|
||||||
|
public record SetPlayerPhaseRequest(Guid PlayerId, Phase Phase);
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ public record AdminResultsStateResponse(bool ResultsOpen, DateTimeOffset Updated
|
|||||||
|
|
||||||
public record AdminGrantJokerResponse(Guid Id, bool HasJoker);
|
public record AdminGrantJokerResponse(Guid Id, bool HasJoker);
|
||||||
|
|
||||||
|
public record AdminSetPlayerPhaseResponse(Guid PlayerId, Phase CurrentPhase, bool VotesFinal);
|
||||||
|
|
||||||
public record AdminDeletePlayerResponse(Guid DeletedPlayerId);
|
public record AdminDeletePlayerResponse(Guid DeletedPlayerId);
|
||||||
|
|
||||||
public record AdminLinkSuggestionsResponse(int RootId, IReadOnlyList<int> LinkedSuggestionIds, int UnfinalizedPlayers);
|
public record AdminLinkSuggestionsResponse(int RootId, IReadOnlyList<int> LinkedSuggestionIds, int UnfinalizedPlayers);
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ public static class AdminEndpoints
|
|||||||
|
|
||||||
admin.MapPost("/joker", async ([FromBody] GrantJokerRequest request, AdminWorkflowService service) => await service.GrantJokerAsync(request.PlayerId));
|
admin.MapPost("/joker", async ([FromBody] GrantJokerRequest request, AdminWorkflowService service) => await service.GrantJokerAsync(request.PlayerId));
|
||||||
|
|
||||||
|
admin.MapPost("/player-phase", async ([FromBody] SetPlayerPhaseRequest request, AdminWorkflowService service) => await service.SetPlayerPhaseAsync(request.PlayerId, request.Phase));
|
||||||
|
|
||||||
admin.MapDelete("/players/{playerId:guid}", async (Guid playerId, AdminWorkflowService service) => await service.DeletePlayerAsync(playerId));
|
admin.MapDelete("/players/{playerId:guid}", async (Guid playerId, AdminWorkflowService service) => await service.DeletePlayerAsync(playerId));
|
||||||
|
|
||||||
admin.MapPost("/link-suggestions", async ([FromBody] LinkSuggestionsRequest request, HttpContext ctx, AppDbContext db, AdminWorkflowService service) =>
|
admin.MapPost("/link-suggestions", async ([FromBody] LinkSuggestionsRequest request, HttpContext ctx, AppDbContext db, AdminWorkflowService service) =>
|
||||||
|
|||||||
@@ -66,6 +66,26 @@ internal sealed class AdminWorkflowService(AppDbContext db)
|
|||||||
return Results.Ok(new AdminGrantJokerResponse(player.Id, player.HasJoker));
|
return Results.Ok(new AdminGrantJokerResponse(player.Id, player.HasJoker));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<IResult> SetPlayerPhaseAsync(Guid playerId, Phase phase)
|
||||||
|
{
|
||||||
|
if (phase != Phase.Suggest)
|
||||||
|
return EndpointHelpers.BadRequestError("Only transition to Suggest is supported.");
|
||||||
|
|
||||||
|
var player = await db.Players.FirstOrDefaultAsync(p => p.Id == playerId);
|
||||||
|
if (player is null)
|
||||||
|
return EndpointHelpers.NotFoundError("Player not found.");
|
||||||
|
|
||||||
|
var currentPhase = await EndpointHelpers.GetCurrentPhaseAsync(db, player.Id);
|
||||||
|
if (currentPhase != Phase.Vote)
|
||||||
|
return EndpointHelpers.BadRequestError("Player must currently be in the Vote phase.");
|
||||||
|
|
||||||
|
player.CurrentPhase = Phase.Suggest;
|
||||||
|
player.VotesFinal = false;
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
return Results.Ok(new AdminSetPlayerPhaseResponse(player.Id, player.CurrentPhase, player.VotesFinal));
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<IResult> DeletePlayerAsync(Guid playerId)
|
public async Task<IResult> DeletePlayerAsync(Guid playerId)
|
||||||
{
|
{
|
||||||
var player = await db.Players.Include(p => p.Suggestions).FirstOrDefaultAsync(p => p.Id == playerId);
|
var player = await db.Players.Include(p => p.Suggestions).FirstOrDefaultAsync(p => p.Id == playerId);
|
||||||
|
|||||||
@@ -59,6 +59,63 @@ public class AdminTests
|
|||||||
Assert.Equal(HttpStatusCode.BadRequest, give.StatusCode);
|
Assert.Equal(HttpStatusCode.BadRequest, give.StatusCode);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Admin_can_move_vote_player_back_to_suggest()
|
||||||
|
{
|
||||||
|
await using var factory = new TestWebApplicationFactory();
|
||||||
|
var admin = factory.CreateClientWithCookies();
|
||||||
|
await admin.RegisterAsync("admin", admin: true);
|
||||||
|
var player = factory.CreateClientWithCookies();
|
||||||
|
await player.RegisterAsync("player");
|
||||||
|
await player.CreateSuggestionAsync("Game");
|
||||||
|
await player.PostAsJsonAsync("/api/me/phase/next", new { });
|
||||||
|
|
||||||
|
await factory.WithDbContextAsync(async db =>
|
||||||
|
{
|
||||||
|
var p = await db.Players.SingleAsync(x => x.Username == "player");
|
||||||
|
p.VotesFinal = true;
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
});
|
||||||
|
|
||||||
|
var resp = await admin.PostAsJsonAsync("/api/admin/player-phase", new
|
||||||
|
{
|
||||||
|
playerId = await player.GetProfileIdAsync(),
|
||||||
|
phase = nameof(Phase.Suggest)
|
||||||
|
});
|
||||||
|
resp.EnsureSuccessStatusCode();
|
||||||
|
|
||||||
|
await factory.WithDbContextAsync(async db =>
|
||||||
|
{
|
||||||
|
var p = await db.Players.SingleAsync(x => x.Username == "player");
|
||||||
|
Assert.Equal(Phase.Suggest, p.CurrentPhase);
|
||||||
|
Assert.False(p.VotesFinal);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Admin_player_phase_requires_vote_phase_and_suggest_target()
|
||||||
|
{
|
||||||
|
await using var factory = new TestWebApplicationFactory();
|
||||||
|
var admin = factory.CreateClientWithCookies();
|
||||||
|
await admin.RegisterAsync("admin", admin: true);
|
||||||
|
var player = factory.CreateClientWithCookies();
|
||||||
|
await player.RegisterAsync("player");
|
||||||
|
|
||||||
|
var wrongTarget = await admin.PostAsJsonAsync("/api/admin/player-phase", new
|
||||||
|
{
|
||||||
|
playerId = await player.GetProfileIdAsync(),
|
||||||
|
phase = nameof(Phase.Results)
|
||||||
|
});
|
||||||
|
Assert.Equal(HttpStatusCode.BadRequest, wrongTarget.StatusCode);
|
||||||
|
|
||||||
|
var wrongCurrentPhase = await admin.PostAsJsonAsync("/api/admin/player-phase", new
|
||||||
|
{
|
||||||
|
playerId = await player.GetProfileIdAsync(),
|
||||||
|
phase = nameof(Phase.Suggest)
|
||||||
|
});
|
||||||
|
Assert.Equal(HttpStatusCode.BadRequest, wrongCurrentPhase.StatusCode);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Delete_player_cascades_suggestions_and_votes()
|
public async Task Delete_player_cascades_suggestions_and_votes()
|
||||||
{
|
{
|
||||||
|
|||||||
1
SPEC.md
1
SPEC.md
@@ -26,6 +26,7 @@ Help a small Discord group (4–8 players) pick a co-op game via phased flow:
|
|||||||
- Players see only their own votes; can finalize/unfinalize their ballot
|
- Players see only their own votes; can finalize/unfinalize their ballot
|
||||||
- **Linked games**: admins can link duplicates; linked games share a vote group. Moving a slider on one updates all linked siblings.
|
- **Linked games**: admins can link duplicates; linked games share a vote group. Moving a slider on one updates all linked siblings.
|
||||||
- Linking or unlinking games clears votes for the linked group and unfinalizes **all** players so ballots can be reviewed again
|
- Linking or unlinking games clears votes for the linked group and unfinalizes **all** players so ballots can be reviewed again
|
||||||
|
- Admin status controls can move a player from Vote back to Suggest for exceptional cases
|
||||||
- The “new/linked games” vote popup appears only when the vote list changes after the player has already seen that vote list
|
- The “new/linked games” vote popup appears only when the vote list changes after the player has already seen that vote list
|
||||||
|
|
||||||
## Results Phase
|
## Results Phase
|
||||||
|
|||||||
3
TESTS.md
3
TESTS.md
@@ -8,7 +8,7 @@ Purpose: full coverage of backend + critical UI flows using a mock (in-memory) S
|
|||||||
| --- | --- | --- | --- | --- |
|
| --- | --- | --- | --- | --- |
|
||||||
| Unauthenticated visitor | No API access; only static assets | — | — | Health check only |
|
| Unauthenticated visitor | No API access; only static assets | — | — | Health check only |
|
||||||
| Player (non-admin) | Create/see own suggestions (≤5), edit all fields, delete own; can advance to Vote; title locks after leaving phase | View all suggestions, vote 0–10, finalize/unfinalize, use joker once to add a game; cannot go backward | Read leaderboard only when resultsOpen=true; no writes | Login/logout, read /state and /me |
|
| Player (non-admin) | Create/see own suggestions (≤5), edit all fields, delete own; can advance to Vote; title locks after leaving phase | View all suggestions, vote 0–10, finalize/unfinalize, use joker once to add a game; cannot go backward | Read leaderboard only when resultsOpen=true; no writes | Login/logout, read /state and /me |
|
||||||
| Admin (isAdmin=true) | Same as player; may edit/delete any suggestion | All player actions; may grant jokers, link/unlink games, delete players | Open/close results; sees leaderboard like player | Toggle results, reset/factory-reset DB, fetch vote status, move self backward |
|
| Admin (isAdmin=true) | Same as player; may edit/delete any suggestion | All player actions; may grant jokers, link/unlink games, delete players, move a voter back to Suggest | Open/close results; sees leaderboard like player | Toggle results, reset/factory-reset DB, fetch vote status, move self backward |
|
||||||
|
|
||||||
## Phase/Permission Chart (for tests)
|
## Phase/Permission Chart (for tests)
|
||||||
```mermaid
|
```mermaid
|
||||||
@@ -68,6 +68,7 @@ stateDiagram-v2
|
|||||||
- POST /admin/results toggles resultsOpen and aligns all player phases (to Results or back to Vote clearing votesFinal); updates UpdatedAt.
|
- POST /admin/results toggles resultsOpen and aligns all player phases (to Results or back to Vote clearing votesFinal); updates UpdatedAt.
|
||||||
- GET /admin/vote-status returns list ordered by display/username with suggestion counts, finalized flag, joker flag; ready/waiting derived correctly.
|
- GET /admin/vote-status returns list ordered by display/username with suggestion counts, finalized flag, joker flag; ready/waiting derived correctly.
|
||||||
- POST /admin/joker grants joker only when target in Vote; resets VotesFinal for target.
|
- POST /admin/joker grants joker only when target in Vote; resets VotesFinal for target.
|
||||||
|
- POST /admin/player-phase allows Vote->Suggest transitions only; rejects other targets/current phases; clears target VotesFinal.
|
||||||
- DELETE /admin/players/{id}: removes player, cascades suggestions, breaks links to their suggestions, deletes related votes, wrapped in transaction.
|
- DELETE /admin/players/{id}: removes player, cascades suggestions, breaks links to their suggestions, deletes related votes, wrapped in transaction.
|
||||||
- POST /admin/link-suggestions: only in Vote; errors on same ids/already linked/not found; re-parents groups correctly; deletes votes for affected group and unfinalizes affected players.
|
- POST /admin/link-suggestions: only in Vote; errors on same ids/already linked/not found; re-parents groups correctly; deletes votes for affected group and unfinalizes affected players.
|
||||||
- POST /admin/unlink-suggestions: only in Vote; clears parents for group, deletes votes in group, unfinalizes affected players; no-op safe when missing.
|
- POST /admin/unlink-suggestions: only in Vote; clears parents for group, deletes votes in group, unfinalizes affected players; no-op safe when missing.
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ async function refreshWithUiErrorHandling() {
|
|||||||
|
|
||||||
function scheduleNextRefresh() {
|
function scheduleNextRefresh() {
|
||||||
refreshTimerId = window.setTimeout(async () => {
|
refreshTimerId = window.setTimeout(async () => {
|
||||||
if (!document.hidden) {
|
if (!document.hidden && !state.adminStatusSelectActive) {
|
||||||
await refreshWithUiErrorHandling();
|
await refreshWithUiErrorHandling();
|
||||||
}
|
}
|
||||||
scheduleNextRefresh();
|
scheduleNextRefresh();
|
||||||
@@ -59,7 +59,7 @@ function startRefreshScheduler() {
|
|||||||
refreshSchedulerStarted = true;
|
refreshSchedulerStarted = true;
|
||||||
|
|
||||||
document.addEventListener("visibilitychange", () => {
|
document.addEventListener("visibilitychange", () => {
|
||||||
if (!document.hidden) {
|
if (!document.hidden && !state.adminStatusSelectActive) {
|
||||||
refreshWithUiErrorHandling();
|
refreshWithUiErrorHandling();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -55,3 +55,9 @@
|
|||||||
border: 1px solid #e3d4bd;
|
border: 1px solid #e3d4bd;
|
||||||
background: #fffaf3;
|
background: #fffaf3;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.admin-status-select {
|
||||||
|
width: 100%;
|
||||||
|
min-width: 140px;
|
||||||
|
background: #fffaf3;
|
||||||
|
}
|
||||||
|
|||||||
@@ -150,6 +150,7 @@ Nein. Vorschläge und Bewertungen sind schreibgeschützt. Wende dich bei Bedarf
|
|||||||
### Was können Admin-Konten tun?
|
### Was können Admin-Konten tun?
|
||||||
|
|
||||||
- Joker während der Abstimmung vergeben
|
- Joker während der Abstimmung vergeben
|
||||||
|
- Einen Bewerter zurück in die Vorschlagsphase setzen (stärker als ein Joker; sparsam einsetzen)
|
||||||
- Doppelte Vorschläge verknüpfen oder trennen
|
- Doppelte Vorschläge verknüpfen oder trennen
|
||||||
- Vorschläge löschen
|
- Vorschläge löschen
|
||||||
- Abstimmungsstatus einsehen (wer finalisiert hat)
|
- Abstimmungsstatus einsehen (wer finalisiert hat)
|
||||||
|
|||||||
@@ -154,6 +154,7 @@ No. Suggestions and votes are read-only. Contact an admin for assistance.
|
|||||||
### What can admin accounts do?
|
### What can admin accounts do?
|
||||||
|
|
||||||
- Grant jokers during Vote
|
- Grant jokers during Vote
|
||||||
|
- Move a voter back to Suggest (stronger than a joker; use sparingly)
|
||||||
- Link or unlink duplicate suggestions
|
- Link or unlink duplicate suggestions
|
||||||
- Delete suggestions
|
- Delete suggestions
|
||||||
- View vote readiness (who has finalized)
|
- View vote readiness (who has finalized)
|
||||||
|
|||||||
@@ -121,6 +121,8 @@
|
|||||||
"admin.statusSuggesting": "Suggesting",
|
"admin.statusSuggesting": "Suggesting",
|
||||||
"admin.statusVoting": "Voting",
|
"admin.statusVoting": "Voting",
|
||||||
"admin.statusFinished": "Finished",
|
"admin.statusFinished": "Finished",
|
||||||
|
"admin.statusMoveToSuggest": "Move to Suggest",
|
||||||
|
"admin.statusUpdated": "Player phase updated",
|
||||||
"admin.deleteTitle": "Delete account?",
|
"admin.deleteTitle": "Delete account?",
|
||||||
"admin.deleteBody": "Delete player \"{name}\" and all their games and votes? This cannot be undone.",
|
"admin.deleteBody": "Delete player \"{name}\" and all their games and votes? This cannot be undone.",
|
||||||
"admin.deleteConfirm": "Delete",
|
"admin.deleteConfirm": "Delete",
|
||||||
@@ -281,6 +283,8 @@
|
|||||||
"admin.statusSuggesting": "Vorschlagen",
|
"admin.statusSuggesting": "Vorschlagen",
|
||||||
"admin.statusVoting": "Bewerten",
|
"admin.statusVoting": "Bewerten",
|
||||||
"admin.statusFinished": "Fertig",
|
"admin.statusFinished": "Fertig",
|
||||||
|
"admin.statusMoveToSuggest": "Zur Vorschlagsphase",
|
||||||
|
"admin.statusUpdated": "Spielerphase aktualisiert",
|
||||||
"admin.deleteTitle": "Konto löschen?",
|
"admin.deleteTitle": "Konto löschen?",
|
||||||
"admin.deleteBody": "Spieler \"{name}\" samt Spielen und Stimmen löschen? Dies kann nicht rückgängig gemacht werden.",
|
"admin.deleteBody": "Spieler \"{name}\" samt Spielen und Stimmen löschen? Dies kann nicht rückgängig gemacht werden.",
|
||||||
"admin.deleteConfirm": "Löschen",
|
"admin.deleteConfirm": "Löschen",
|
||||||
|
|||||||
@@ -15,8 +15,20 @@ function displayPlayerStatus(player) {
|
|||||||
return phase;
|
return phase;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildStatusSelect(player) {
|
||||||
|
const statusText = displayPlayerStatus(player);
|
||||||
|
const canMoveToSuggest = player.phase === "Vote";
|
||||||
|
return `
|
||||||
|
<select class="chip admin-status-select" data-set-player-phase="${player.playerId}" aria-label="${t("admin.playerStatus")}">
|
||||||
|
<option value="" selected>${statusText}</option>
|
||||||
|
<option value="Suggest" ${canMoveToSuggest ? "" : "disabled"}>${t("admin.statusMoveToSuggest")}</option>
|
||||||
|
</select>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
export function renderAdminVoteStatus() {
|
export function renderAdminVoteStatus() {
|
||||||
if (!state.me?.isAdmin) return;
|
if (!state.me?.isAdmin) return;
|
||||||
|
if (state.adminStatusSelectActive) return;
|
||||||
const statusBadge = $("admin-ready-status");
|
const statusBadge = $("admin-ready-status");
|
||||||
const table = $("admin-player-table")?.querySelector("tbody");
|
const table = $("admin-player-table")?.querySelector("tbody");
|
||||||
if (!state.adminVoteStatus || !statusBadge || !table) return;
|
if (!state.adminVoteStatus || !statusBadge || !table) return;
|
||||||
@@ -24,14 +36,13 @@ export function renderAdminVoteStatus() {
|
|||||||
table.innerHTML = "";
|
table.innerHTML = "";
|
||||||
state.adminVoteStatus.voters.forEach((v) => {
|
state.adminVoteStatus.voters.forEach((v) => {
|
||||||
const tr = document.createElement("tr");
|
const tr = document.createElement("tr");
|
||||||
const statusText = displayPlayerStatus(v);
|
|
||||||
const gamesTooltip = escapeHtml((v.suggestionTitles || []).join(", "));
|
const gamesTooltip = escapeHtml((v.suggestionTitles || []).join(", "));
|
||||||
const nameText = escapeHtml(truncate(v.name, 28));
|
const nameText = escapeHtml(truncate(v.name, 28));
|
||||||
const userText = escapeHtml(truncate(v.username, 24));
|
const userText = escapeHtml(truncate(v.username, 24));
|
||||||
tr.innerHTML = `
|
tr.innerHTML = `
|
||||||
<td title="${escapeHtml(v.name)}">${nameText}</td>
|
<td title="${escapeHtml(v.name)}">${nameText}</td>
|
||||||
<td class="muted small" title="${escapeHtml(v.username)}">${userText}</td>
|
<td class="muted small" title="${escapeHtml(v.username)}">${userText}</td>
|
||||||
<td>${statusText}</td>
|
<td>${buildStatusSelect(v)}</td>
|
||||||
<td title="${gamesTooltip}">${v.suggestionCount ?? 0}</td>
|
<td title="${gamesTooltip}">${v.suggestionCount ?? 0}</td>
|
||||||
<td><button class="chip" data-grant-joker="${v.playerId}" type="button">${v.hasJoker ? "🎟" : t("admin.grantJokerChip")}</button></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>
|
<td><button class="chip danger-chip" data-delete-player="${v.playerId}" data-name="${v.name}" type="button">✕</button></td>
|
||||||
|
|||||||
@@ -59,6 +59,8 @@ 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 } }),
|
||||||
|
setPlayerPhase: (playerId, phase) =>
|
||||||
|
request("/api/admin/player-phase", { method: "POST", body: { playerId, phase } }),
|
||||||
deletePlayer: (playerId) => request(`/api/admin/players/${playerId}`, { method: "DELETE" }),
|
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 } }),
|
||||||
|
|||||||
@@ -91,6 +91,44 @@ function setupLinkApply(runSerializedRefresh) {
|
|||||||
function setupPlayerTableActions(runSerializedRefresh) {
|
function setupPlayerTableActions(runSerializedRefresh) {
|
||||||
const playerTable = $("admin-player-table");
|
const playerTable = $("admin-player-table");
|
||||||
if (!playerTable) return;
|
if (!playerTable) return;
|
||||||
|
const phaseSelectSelector = "[data-set-player-phase]";
|
||||||
|
|
||||||
|
playerTable.addEventListener("focusin", (e) => {
|
||||||
|
if (e.target.matches?.(phaseSelectSelector)) {
|
||||||
|
state.adminStatusSelectActive = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
playerTable.addEventListener("focusout", (e) => {
|
||||||
|
if (!e.target.matches?.(phaseSelectSelector)) return;
|
||||||
|
window.setTimeout(() => {
|
||||||
|
const focused = document.activeElement;
|
||||||
|
state.adminStatusSelectActive =
|
||||||
|
!!focused?.matches?.(phaseSelectSelector);
|
||||||
|
}, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
playerTable.addEventListener("change", async (e) => {
|
||||||
|
const select = e.target.closest(phaseSelectSelector);
|
||||||
|
if (!select) return;
|
||||||
|
const playerId = select.dataset.setPlayerPhase;
|
||||||
|
const phase = select.value;
|
||||||
|
if (!playerId || !phase) return;
|
||||||
|
select.disabled = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await adminApi.setPlayerPhase(playerId, phase);
|
||||||
|
toast(t("admin.statusUpdated"));
|
||||||
|
state.adminStatusSelectActive = false;
|
||||||
|
await runSerializedRefresh();
|
||||||
|
} catch (err) {
|
||||||
|
select.value = "";
|
||||||
|
toast(err.message, true);
|
||||||
|
} finally {
|
||||||
|
select.disabled = false;
|
||||||
|
state.adminStatusSelectActive = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
playerTable.addEventListener("click", async (e) => {
|
playerTable.addEventListener("click", async (e) => {
|
||||||
const grantBtn = e.target.closest("[data-grant-joker]");
|
const grantBtn = e.target.closest("[data-grant-joker]");
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ export const state = {
|
|||||||
results: [],
|
results: [],
|
||||||
votesRendered: false,
|
votesRendered: false,
|
||||||
adminVoteStatus: null,
|
adminVoteStatus: null,
|
||||||
|
adminStatusSelectActive: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
export function clearUserState() {
|
export function clearUserState() {
|
||||||
@@ -31,6 +32,7 @@ export function clearUserState() {
|
|||||||
state.myVotes = [];
|
state.myVotes = [];
|
||||||
state.results = [];
|
state.results = [];
|
||||||
state.votesRendered = false;
|
state.votesRendered = false;
|
||||||
|
state.adminStatusSelectActive = false;
|
||||||
const adminCard = document.getElementById("admin-card");
|
const adminCard = document.getElementById("admin-card");
|
||||||
if (adminCard) adminCard.classList.add("hidden");
|
if (adminCard) adminCard.classList.add("hidden");
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user