44 Commits

Author SHA1 Message Date
46cb1dcb1e Include AGENTS workflow guidance update 2026-02-08 16:07:28 +01:00
6a5f1c5890 Add spacing above confirmation modal action buttons 2026-02-08 16:07:21 +01:00
d9466d9194 Show suggest-phase hint until first submission 2026-02-08 16:06:24 +01:00
d534fc256b Replace results-phase checkbox with stateful button 2026-02-08 15:06:39 +01:00
e666e7c603 Require admin password for destructive admin actions 2026-02-08 15:05:10 +01:00
96a47020d8 Add admin status combobox to move voters back to suggest 2026-02-08 15:00:09 +01:00
fadd72d5c4 Fix vote list update popup on first vote entry 2026-02-08 14:55:39 +01:00
02d15e9c50 Restrict results-close rollback to players with suggestions 2026-02-08 14:54:12 +01:00
0c888e5a5d Fix logout to reset auth forms to login 2026-02-08 14:52:56 +01:00
5ec18d20ea Revert "Implement admin back-pass flow and guarded admin actions"
This reverts commit 5595bfd3b1.
2026-02-08 14:43:26 +01:00
5595bfd3b1 Implement admin back-pass flow and guarded admin actions 2026-02-08 14:20:38 +01:00
4ee327fb4e loca 2026-02-08 14:07:31 +01:00
ddd26369dd Updated FAQ 2026-02-07 14:02:29 +01:00
41d9a3b571 Style blocked suggest-next button as red 2026-02-07 13:53:06 +01:00
3104ddc601 Updated Agents 2026-02-07 13:49:21 +01:00
47fbec4512 Remove EF query warnings from test runs 2026-02-07 13:46:46 +01:00
86310804fa Silenced info logs 2026-02-07 13:36:09 +01:00
83cea11c64 Add local PowerShell CI check script 2026-02-07 13:33:47 +01:00
b67753ff9e Code cleanup 2026-02-07 13:32:49 +01:00
abb9874c98 Refactor state transitions into workflow service 2026-02-07 13:27:02 +01:00
260dd5ab17 Adjusted faq 2026-02-07 13:23:32 +01:00
9d3947714a Require suggestion before entering vote phase 2026-02-07 13:18:55 +01:00
c3951b95ac Updated Agents 2026-02-07 13:14:57 +01:00
08163a7ee2 Agents updated 2026-02-07 13:14:02 +01:00
a281f4acaf Code cleanup 2026-02-07 02:51:01 +01:00
ced72ccd84 Remove obsolete faq.json after markdown migration 2026-02-07 02:45:31 +01:00
d4072da430 Extract auth admin and vote handlers from app entry 2026-02-07 02:45:10 +01:00
124fb62657 Code format 2026-02-07 02:42:33 +01:00
536e6392f0 Split auth UI module and load FAQ from markdown assets 2026-02-07 02:28:21 +01:00
5f31455651 Externalize i18n and FAQ frontend assets 2026-02-07 02:17:28 +01:00
c765dd322b Refactor endpoint services to accept narrow inputs 2026-02-07 02:17:01 +01:00
5b06e279f3 Add analyzer and frontend lint guardrails 2026-02-07 02:12:00 +01:00
34d274d244 Split frontend UI into feature modules 2026-02-07 02:07:29 +01:00
b16bf8007f Standardize API auth challenge responses as ProblemDetails 2026-02-07 01:51:09 +01:00
567502d665 Remove legacy reveal phase paths and rename reveal data loader 2026-02-07 01:49:38 +01:00
5e84686678 Serialize refresh scheduling and remove overlap polling 2026-02-07 01:47:36 +01:00
78701cebf2 Remove UI window hooks and wire explicit runtime callbacks 2026-02-07 01:45:52 +01:00
37db70e67e Prune REVIEW to active backlog only 2026-02-07 01:41:31 +01:00
20daecd3eb Finalize API envelopes and close validation drift tasks 2026-02-07 01:35:56 +01:00
f615ef3a4a Standardize service errors with ProblemDetails envelope 2026-02-07 01:23:54 +01:00
79dc8f899f Introduce typed API responses and align workflow outputs 2026-02-07 01:19:51 +01:00
35d842d6ee Add explicit write transactions and deterministic ordering tests 2026-02-07 01:16:07 +01:00
0d60108036 Extract admin and results workflows into services 2026-02-07 01:06:22 +01:00
5d40d555d1 Extract suggestion and vote workflows into services 2026-02-07 01:01:10 +01:00
71 changed files with 5558 additions and 3350 deletions

7
.editorconfig Normal file
View File

@@ -0,0 +1,7 @@
root = true
[*.cs]
dotnet_diagnostic.CA1707.severity = none
dotnet_diagnostic.CA1852.severity = none
dotnet_diagnostic.CA1825.severity = none
dotnet_diagnostic.CA1861.severity = none

View File

@@ -14,6 +14,20 @@ jobs:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: 22
- name: Install frontend tooling
run: npm install
- name: Lint frontend
run: npm run lint
- name: Check frontend formatting
run: npm run format:check
- name: Setup .NET - name: Setup .NET
uses: actions/setup-dotnet@v4 uses: actions/setup-dotnet@v4
with: with:

1
.gitignore vendored
View File

@@ -7,6 +7,7 @@ artifacts/
# IDE # IDE
.vs/ .vs/
.vscode/ .vscode/
node_modules/
# User secrets / configs # User secrets / configs
appsettings.Development.json appsettings.Development.json

View File

@@ -1,21 +1,20 @@
# Agent Guide — Pick'n'Play # Agent Guide — Pick'n'Play
Also see the other related files: API.md, IIS.md, SPEC.md Also see the other related technical documentation: API.md, IIS.md, SPEC.md, TESTS.md and README.md.
Also see the user-facing documentation: per-language md files in wwwroot/data/i18n/faq
## Rules ## Rules
- This is a Windows environment, WSL is not installed (i.e. sed is not available). You're running under PowerShell 7.5.4. Due to platform restrictions, file deletions are not possible. Replacing the entire file content via a context diff is a viable alternative. PowerShell doesn't support bash-style heredocs, run Python code using python -c with inline commands instead of python - <<'PY'. - This is a Windows environment, WSL is not installed (i.e. sed is not available). You're running under PowerShell 7.5.4. Due to platform restrictions, file deletions are not possible. Replacing the entire file content via a context diff is a viable alternative.
- If complex scripts need to be executed, consider using python. It's installed. - PowerShell doesn't support bash-style heredocs. If complex scripts need to be executed, consider using python. Run Python code using python -c with inline commands instead of python - <<'PY'.
- web.config in the server is different than locally, it must be exluded from deployment. - web.config in the server is different than locally, it must be exluded from deployment.
- After every iteration, evaluate if the test coverage would fall below 100%, and write tests if necessary. - After every iteration, evaluate if the test coverage would fall below 100%, and write tests if necessary.
- After every iteration, run "dotnet test GameList.Tests/GameList.Tests.csproj" and make sure that nothing broke. - After every iteration, run "scripts/ci-local.ps1" and ensure that nothing broke.
- After every iteration, update all related documentation according to the change, and evaluate if a FAQ entry would help the users, serving as public documentation for this project.
- After every iteration, do a git commit with a brief summary of the changes as a commit message. - After every iteration, do a git commit with a brief summary of the changes as a commit message.
- Keep changes small and commit often. If one iteration encompasses many smaller tasks, create a git branch and do the commits there. Let me review the branch before merging it back to master.
- If you find unexpected changes in the code (deletions, changes, diff results that were not communicated.), never revert them and never restore the old state. Assume that those changes happened with intent. - If you find unexpected changes in the code (deletions, changes, diff results that were not communicated.), never revert them and never restore the old state. Assume that those changes happened with intent.
- After changing the backend, feel free run "dotnet build" and "dotnet ef database update". If this is blocked by a running dotnet process, feel free to kill the process and retry the operations once. - After changing the database, run "dotnet ef database update". If this is blocked by a running dotnet process, feel free to kill the process and retry the operations once.
- Keep changes small and testable
- Avoid introducing new dependencies unless they remove complexity.
- Keep endpoint logic in `Endpoints/` and shared helpers/DTOs in their folders to avoid Program.cs bloat.
- Keep css and js files diversified to avoid styles.css or app.js bloat.
## Tech constraints: ## Tech constraints:
- .NET 10 - .NET 10

9
API.md
View File

@@ -13,7 +13,7 @@ GET /api/state — returns currentPhase (for caller), votesFinal, resultsOpen, u
GET /api/me — id, displayName, username, isAdmin, currentPhase, votesFinal GET /api/me — id, displayName, username, isAdmin, currentPhase, votesFinal
## Player (requires auth) ## Player (requires auth)
POST /api/me/phase/next — advance caller to next phase (Suggest→Vote→Results; Results gated by resultsOpen) POST /api/me/phase/next — advance caller to next phase (Suggest→Vote requires at least one own suggestion; Vote→Results is gated by resultsOpen)
POST /api/me/phase/prev — admin-only move caller backward (Results→Vote→Suggest) POST /api/me/phase/prev — admin-only move caller backward (Results→Vote→Suggest)
## Suggestions (requires auth + phase gating) ## Suggestions (requires auth + phase gating)
@@ -34,7 +34,10 @@ GET /api/results — leaderboard with totals, counts, averages, callers vote,
## Admin (requires authenticated admin user) ## Admin (requires authenticated admin user)
POST /api/admin/results — `{ resultsOpen: bool }` locks/unlocks results and aligns player phases POST /api/admin/results — `{ resultsOpen: bool }` locks/unlocks results and aligns player phases
GET /api/admin/vote-status — readiness overview (who finalized) GET /api/admin/vote-status — readiness overview (who finalized)
POST /api/admin/joker — `{ playerId }` grants a vote-phase joker to the target player
POST /api/admin/player-phase — `{ playerId, phase }`; currently supports Vote→Suggest transitions only
DELETE /api/admin/players/{playerId} — `{ password }`; deletes player account plus their suggestions/votes
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/reset — clear suggestions/votes; keep players; reset phases/vote-final flags POST /api/admin/reset — `{ password }`; 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 — `{ password }`; wipe players, suggestions, votes, state

View File

@@ -19,3 +19,7 @@ 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);
public record AdminPasswordRequest(string Password);

83
Contracts/Responses.cs Normal file
View File

@@ -0,0 +1,83 @@
using GameList.Domain;
namespace GameList.Contracts;
public record SuggestionCreatedResponse(int Id);
public record SuggestionUpdatedResponse(
int Id,
string Name,
string? Genre,
string? Description,
string? ScreenshotUrl,
string? YoutubeUrl,
string? GameUrl,
int? MinPlayers,
int? MaxPlayers
);
public record VoteUpsertResponse(IReadOnlyList<int> SuggestionIds, int Score);
public record VoteFinalizeResponse(bool VotesFinal);
public record AdminResultsStateResponse(bool ResultsOpen, DateTimeOffset UpdatedAt);
public record AdminGrantJokerResponse(Guid Id, bool HasJoker);
public record AdminSetPlayerPhaseResponse(Guid PlayerId, Phase CurrentPhase, bool VotesFinal);
public record AdminDeletePlayerResponse(Guid DeletedPlayerId);
public record AdminLinkSuggestionsResponse(int RootId, IReadOnlyList<int> LinkedSuggestionIds, int UnfinalizedPlayers);
public record AdminUnlinkSuggestionsResponse(IReadOnlyList<int> UnlinkedSuggestionIds, int UnfinalizedPlayers);
public record AdminResetStateResponse(Phase Phase, bool ResultsOpen, DateTimeOffset UpdatedAt);
public record VoteStatusResponse(IReadOnlyList<VoteStatusDto> Voters, bool Ready, IReadOnlyList<string> Waiting);
public record ResultItemDto(
int Id,
string Name,
string? Author,
int? MinPlayers,
int? MaxPlayers,
int Total,
int Count,
double Average,
IReadOnlyList<int> Votes,
int? MyVote,
string? ScreenshotUrl,
string? YoutubeUrl,
string? GameUrl,
string? Description,
string? Genre,
int? ParentSuggestionId,
IReadOnlyList<int> LinkedIds,
IReadOnlyList<string> LinkedTitles
);
public record AuthSessionResponse(Guid Id, string Username, string? DisplayName, bool IsAdmin);
public record StateSummaryResponse(
Phase CurrentPhase,
bool VotesFinal,
bool HasJoker,
bool ResultsOpen,
DateTimeOffset UpdatedAt,
int Players,
int Suggestions,
int Votes
);
public record MeResponse(
Guid Id,
string Username,
string? DisplayName,
bool IsAdmin,
Phase CurrentPhase,
bool VotesFinal,
bool HasJoker
);
public record PhaseTransitionResponse(Phase CurrentPhase, bool ResultsOpen);

7
Directory.Build.props Normal file
View File

@@ -0,0 +1,7 @@
<Project>
<PropertyGroup>
<EnableNETAnalyzers>true</EnableNETAnalyzers>
<AnalysisLevel>latest-recommended</AnalysisLevel>
<EnforceCodeStyleInBuild>true</EnforceCodeStyleInBuild>
</PropertyGroup>
</Project>

View File

@@ -3,7 +3,6 @@ namespace GameList.Domain;
public enum Phase public enum Phase
{ {
Suggest = 0, Suggest = 0,
Reveal = 1,
Vote = 2, Vote = 2,
Results = 3 Results = 3
} }

View File

@@ -1,8 +1,6 @@
using GameList.Data; using GameList.Data;
using GameList.Domain;
using GameList.Contracts; using GameList.Contracts;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using GameList.Infrastructure; using GameList.Infrastructure;
namespace GameList.Endpoints; namespace GameList.Endpoints;
@@ -13,250 +11,57 @@ public static class AdminEndpoints
{ {
var admin = app.MapGroup("/api/admin").RequireAuthorization().AddEndpointFilter<AdminOnlyFilter>(); var admin = app.MapGroup("/api/admin").RequireAuthorization().AddEndpointFilter<AdminOnlyFilter>();
admin.MapPost("/results", async ([FromBody] ResultsOpenRequest request, HttpContext _, AppDbContext db) => admin.MapPost("/results", async ([FromBody] ResultsOpenRequest request, AdminWorkflowService service) => await service.SetResultsOpenAsync(request.ResultsOpen));
{
var state = await db.AppState.FirstAsync();
state.ResultsOpen = request.ResultsOpen;
state.UpdatedAt = DateTimeOffset.UtcNow;
if (request.ResultsOpen) admin.MapGet("/vote-status", async (AdminWorkflowService service) => await service.GetVoteStatusAsync());
{
await db.Players.ExecuteUpdateAsync(p => p.SetProperty(x => x.CurrentPhase, Phase.Results));
}
else
{
await db.Players.ExecuteUpdateAsync(p => p.SetProperty(x => x.CurrentPhase, Phase.Vote).SetProperty(x => x.VotesFinal, false));
}
await db.SaveChangesAsync(); admin.MapPost("/joker", async ([FromBody] GrantJokerRequest request, AdminWorkflowService service) => await service.GrantJokerAsync(request.PlayerId));
var currentState = await db.AppState.AsNoTracking().FirstAsync();
return Results.Ok(new
{
currentState.ResultsOpen,
currentState.UpdatedAt
});
});
admin.MapGet("/vote-status", async (HttpContext _, AppDbContext db) => admin.MapPost("/player-phase", async ([FromBody] SetPlayerPhaseRequest request, AdminWorkflowService service) => await service.SetPlayerPhaseAsync(request.PlayerId, request.Phase));
{
var voters = await db.Players.AsNoTracking().Include(p => p.Suggestions).OrderBy(p => p.DisplayName ?? p.Username).Select(p => new VoteStatusDto(p.Id, p.DisplayName ?? p.Username, p.Username, p.CurrentPhase, p.VotesFinal, p.HasJoker, p.Suggestions.Count, p.Suggestions.Select(s => s.Name).ToList())).ToListAsync();
var waiting = voters.Where(v => !v.Finalized).Select(v => v.Name).ToList(); admin.MapDelete("/players/{playerId:guid}", async (Guid playerId, [FromBody] AdminPasswordRequest request, HttpContext ctx, AppDbContext db, AdminWorkflowService service) =>
var ready = waiting.Count == 0;
return Results.Ok(new
{
voters,
ready,
waiting
});
});
admin.MapPost("/joker", async ([FromBody] GrantJokerRequest request, HttpContext _, AppDbContext db) =>
{
var player = await db.Players.FirstOrDefaultAsync(p => p.Id == request.PlayerId);
if (player is null)
return Results.NotFound(new { error = "Player not found." });
var phase = await EndpointHelpers.GetCurrentPhaseAsync(db, player.Id);
if (phase != Phase.Vote)
return Results.BadRequest(new { error = "Player must be in the Vote phase to receive a joker." });
player.HasJoker = true;
player.VotesFinal = false;
await db.SaveChangesAsync();
return Results.Ok(new
{
player.Id,
player.HasJoker
});
});
admin.MapDelete("/players/{playerId:guid}", async (Guid playerId, HttpContext _, AppDbContext db) =>
{
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) =>
{ {
var player = await EndpointHelpers.GetAuthenticatedPlayer(ctx, db); var player = await EndpointHelpers.GetAuthenticatedPlayer(ctx, db);
if (player is null) if (player is null)
return Results.Unauthorized(); return EndpointHelpers.UnauthorizedError();
var phase = await EndpointHelpers.GetCurrentPhaseAsync(db, player.Id); return await service.DeletePlayerAsync(playerId, player.Id, request.Password);
if (phase != Phase.Vote)
return EndpointHelpers.PhaseMismatch(Phase.Vote, phase);
if (request.SourceSuggestionId == request.TargetSuggestionId)
return Results.BadRequest(new { error = "Pick two different games to link." });
var suggestions = await db.Suggestions.ToListAsync();
var source = suggestions.FirstOrDefault(s => s.Id == request.SourceSuggestionId);
var target = suggestions.FirstOrDefault(s => s.Id == request.TargetSuggestionId);
if (source is null || 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(source.Id, out var sourceRoot) || !rootIndex.TryGetValue(target.Id, out var targetRoot))
return Results.NotFound(new { error = "Suggestion not found." });
if (sourceRoot == targetRoot)
return Results.BadRequest(new { error = "These games are already linked." });
var affectedRootIds = new HashSet<int>
{
sourceRoot,
targetRoot
};
var affectedIds = rootIndex.Where(kv => affectedRootIds.Contains(kv.Value)).Select(kv => kv.Key).ToList();
await using var tx = await db.Database.BeginTransactionAsync();
foreach (var suggestion in suggestions)
{
var root = rootIndex.GetValueOrDefault(suggestion.Id);
if (root == targetRoot)
{
suggestion.ParentSuggestionId = suggestion.Id == targetRoot ? null : targetRoot;
}
else if (root == sourceRoot)
{
suggestion.ParentSuggestionId = targetRoot;
}
}
await db.SaveChangesAsync();
await db.Votes.Where(v => affectedIds.Contains(v.SuggestionId)).ExecuteDeleteAsync();
await db.Players.ExecuteUpdateAsync(p => p.SetProperty(x => x.VotesFinal, false));
await tx.CommitAsync();
return Results.Ok(new
{
RootId = targetRoot,
LinkedSuggestionIds = affectedIds,
UnfinalizedPlayers = await db.Players.CountAsync()
});
}); });
admin.MapPost("/unlink-suggestions", async ([FromBody] UnlinkSuggestionsRequest request, HttpContext ctx, AppDbContext db) => admin.MapPost("/link-suggestions", async ([FromBody] LinkSuggestionsRequest request, HttpContext ctx, AppDbContext db, AdminWorkflowService service) =>
{ {
var player = await EndpointHelpers.GetAuthenticatedPlayer(ctx, db); var player = await EndpointHelpers.GetAuthenticatedPlayer(ctx, db);
if (player is null) if (player is null)
return Results.Unauthorized(); return EndpointHelpers.UnauthorizedError();
var phase = await EndpointHelpers.GetCurrentPhaseAsync(db, player.Id); return await service.LinkSuggestionsAsync(player.Id, request.SourceSuggestionId, request.TargetSuggestionId);
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.Ok(new
{
UnlinkedSuggestionIds = Array.Empty<int>(),
UnfinalizedPlayers = 0
});
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();
await db.Votes.Where(v => groupIds.Contains(v.SuggestionId)).ExecuteDeleteAsync();
await db.Players.ExecuteUpdateAsync(p => p.SetProperty(x => x.VotesFinal, false));
await tx.CommitAsync();
return Results.Ok(new
{
UnlinkedSuggestionIds = groupIds,
UnfinalizedPlayers = await db.Players.CountAsync()
});
}); });
admin.MapPost("/reset", async (HttpContext _, AppDbContext db) => admin.MapPost("/unlink-suggestions", async ([FromBody] UnlinkSuggestionsRequest request, HttpContext ctx, AppDbContext db, AdminWorkflowService service) =>
{ {
await db.Votes.ExecuteDeleteAsync(); var player = await EndpointHelpers.GetAuthenticatedPlayer(ctx, db);
await db.Suggestions.ExecuteDeleteAsync(); if (player is null)
return EndpointHelpers.UnauthorizedError();
await db.Players.ExecuteUpdateAsync(p => p.SetProperty(x => x.CurrentPhase, Phase.Suggest).SetProperty(x => x.VotesFinal, false).SetProperty(x => x.HasJoker, false)); return await service.UnlinkSuggestionsAsync(player.Id, request.SuggestionId);
var state = await db.AppState.FirstAsync();
state.ResultsOpen = false;
state.UpdatedAt = DateTimeOffset.UtcNow;
await db.SaveChangesAsync();
return Results.Ok(new
{
Phase = Phase.Suggest,
state.ResultsOpen,
state.UpdatedAt
});
}); });
admin.MapPost("/factory-reset", async (HttpContext _, AppDbContext db) => admin.MapPost("/reset", async ([FromBody] AdminPasswordRequest request, HttpContext ctx, AppDbContext db, AdminWorkflowService service) =>
{ {
await using var tx = await db.Database.BeginTransactionAsync(); var player = await EndpointHelpers.GetAuthenticatedPlayer(ctx, db);
if (player is null)
return EndpointHelpers.UnauthorizedError();
await db.Votes.ExecuteDeleteAsync(); return await service.ResetAsync(player.Id, request.Password);
await db.Suggestions.ExecuteDeleteAsync(); });
await db.Players.ExecuteDeleteAsync();
await db.AppState.ExecuteDeleteAsync();
var fresh = EndpointHelpers.NewAppState(); admin.MapPost("/factory-reset", async ([FromBody] AdminPasswordRequest request, HttpContext ctx, AppDbContext db, AdminWorkflowService service) =>
db.AppState.Add(fresh); {
await db.SaveChangesAsync(); var player = await EndpointHelpers.GetAuthenticatedPlayer(ctx, db);
if (player is null)
return EndpointHelpers.UnauthorizedError();
await tx.CommitAsync(); return await service.FactoryResetAsync(player.Id, request.Password);
return Results.Ok(new
{
Phase = Phase.Suggest,
fresh.ResultsOpen,
fresh.UpdatedAt
});
}); });
} }
} }

View File

@@ -0,0 +1,267 @@
using GameList.Contracts;
using GameList.Data;
using GameList.Domain;
using GameList.Infrastructure;
using Microsoft.EntityFrameworkCore;
namespace GameList.Endpoints;
internal sealed class AdminWorkflowService(AppDbContext db)
{
public async Task<IResult> SetResultsOpenAsync(bool resultsOpen)
{
var state = await db.AppState.SingleAsync();
state.ResultsOpen = resultsOpen;
state.UpdatedAt = DateTimeOffset.UtcNow;
await using var tx = await db.Database.BeginTransactionAsync();
if (resultsOpen)
{
await db.Players.ExecuteUpdateAsync(p => p.SetProperty(x => x.CurrentPhase, Phase.Results));
}
else
{
await db.Players
.Where(p => p.Suggestions.Any())
.ExecuteUpdateAsync(p => p.SetProperty(x => x.CurrentPhase, Phase.Vote).SetProperty(x => x.VotesFinal, false));
await db.Players
.Where(p => !p.Suggestions.Any())
.ExecuteUpdateAsync(p => p.SetProperty(x => x.CurrentPhase, Phase.Suggest).SetProperty(x => x.VotesFinal, false));
}
await db.SaveChangesAsync();
await tx.CommitAsync();
var currentState = await db.AppState.AsNoTracking().SingleAsync();
return Results.Ok(new AdminResultsStateResponse(currentState.ResultsOpen, currentState.UpdatedAt));
}
public async Task<IResult> GetVoteStatusAsync()
{
var voters = await db.Players
.AsNoTracking()
.Include(p => p.Suggestions)
.OrderBy(p => p.DisplayName ?? p.Username)
.Select(p => new VoteStatusDto(p.Id, p.DisplayName ?? p.Username, p.Username, p.CurrentPhase, p.VotesFinal, p.HasJoker, p.Suggestions.Count, p.Suggestions.Select(s => s.Name).ToList()))
.ToListAsync();
var waiting = voters.Where(v => !v.Finalized).Select(v => v.Name).ToList();
var ready = waiting.Count == 0;
return Results.Ok(new VoteStatusResponse(voters, ready, waiting));
}
public async Task<IResult> GrantJokerAsync(Guid playerId)
{
var player = await db.Players.FirstOrDefaultAsync(p => p.Id == playerId);
if (player is null)
return EndpointHelpers.NotFoundError("Player not found.");
var phase = await EndpointHelpers.GetCurrentPhaseAsync(db, player.Id);
if (phase != Phase.Vote)
return EndpointHelpers.BadRequestError("Player must be in the Vote phase to receive a joker.");
player.HasJoker = true;
player.VotesFinal = false;
await db.SaveChangesAsync();
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, Guid adminPlayerId, string password)
{
var passwordError = await ValidateAdminPasswordAsync(adminPlayerId, password);
if (passwordError is not null)
return passwordError;
var player = await db.Players.Include(p => p.Suggestions).FirstOrDefaultAsync(p => p.Id == playerId);
if (player is null)
return EndpointHelpers.NotFoundError("Player not found.");
await using var tx = await db.Database.BeginTransactionAsync();
await db.Votes.Where(v => v.PlayerId == playerId).ExecuteDeleteAsync();
var suggestionIds = player.Suggestions.Select(s => s.Id).ToList();
if (suggestionIds.Count > 0)
{
await db.Suggestions
.Where(s => s.ParentSuggestionId != null && suggestionIds.Contains(s.ParentSuggestionId.Value))
.ExecuteUpdateAsync(s => s.SetProperty(x => x.ParentSuggestionId, (int?)null));
await db.Votes.Where(v => suggestionIds.Contains(v.SuggestionId)).ExecuteDeleteAsync();
}
db.Players.Remove(player);
await db.SaveChangesAsync();
await tx.CommitAsync();
return Results.Ok(new AdminDeletePlayerResponse(playerId));
}
public async Task<IResult> LinkSuggestionsAsync(Guid adminPlayerId, int sourceSuggestionId, int targetSuggestionId)
{
var phase = await EndpointHelpers.GetCurrentPhaseAsync(db, adminPlayerId);
if (phase != Phase.Vote)
return EndpointHelpers.PhaseMismatch(Phase.Vote, phase);
if (sourceSuggestionId == targetSuggestionId)
return EndpointHelpers.BadRequestError("Pick two different games to link.");
var suggestions = await db.Suggestions.ToListAsync();
var source = suggestions.FirstOrDefault(s => s.Id == sourceSuggestionId);
var target = suggestions.FirstOrDefault(s => s.Id == targetSuggestionId);
if (source is null || target is null)
return EndpointHelpers.NotFoundError("Suggestion not found.");
var rootIndex = EndpointHelpers.BuildLinkRoots(suggestions.Select(s => (s.Id, s.ParentSuggestionId)));
if (!rootIndex.TryGetValue(source.Id, out var sourceRoot) || !rootIndex.TryGetValue(target.Id, out var targetRoot))
return EndpointHelpers.NotFoundError("Suggestion not found.");
if (sourceRoot == targetRoot)
return EndpointHelpers.BadRequestError("These games are already linked.");
var affectedRootIds = new HashSet<int>
{
sourceRoot,
targetRoot
};
var affectedIds = rootIndex.Where(kv => affectedRootIds.Contains(kv.Value)).Select(kv => kv.Key).ToList();
await using var tx = await db.Database.BeginTransactionAsync();
foreach (var suggestion in suggestions)
{
var root = rootIndex.GetValueOrDefault(suggestion.Id);
if (root == targetRoot)
{
suggestion.ParentSuggestionId = suggestion.Id == targetRoot ? null : targetRoot;
}
else if (root == sourceRoot)
{
suggestion.ParentSuggestionId = targetRoot;
}
}
await db.SaveChangesAsync();
await db.Votes.Where(v => affectedIds.Contains(v.SuggestionId)).ExecuteDeleteAsync();
await db.Players.ExecuteUpdateAsync(p => p.SetProperty(x => x.VotesFinal, false));
await tx.CommitAsync();
return Results.Ok(new AdminLinkSuggestionsResponse(targetRoot, affectedIds, await db.Players.CountAsync()));
}
public async Task<IResult> UnlinkSuggestionsAsync(Guid adminPlayerId, int suggestionId)
{
var phase = await EndpointHelpers.GetCurrentPhaseAsync(db, adminPlayerId);
if (phase != Phase.Vote)
return EndpointHelpers.PhaseMismatch(Phase.Vote, phase);
var suggestions = await db.Suggestions.ToListAsync();
var target = suggestions.FirstOrDefault(s => s.Id == suggestionId);
if (target is null)
return Results.Ok(new AdminUnlinkSuggestionsResponse(Array.Empty<int>(), 0));
var rootIndex = EndpointHelpers.BuildLinkRoots(suggestions.Select(s => (s.Id, s.ParentSuggestionId)));
if (!rootIndex.TryGetValue(target.Id, out var rootId))
return Results.Ok(new AdminUnlinkSuggestionsResponse(Array.Empty<int>(), 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();
await db.Votes.Where(v => groupIds.Contains(v.SuggestionId)).ExecuteDeleteAsync();
await db.Players.ExecuteUpdateAsync(p => p.SetProperty(x => x.VotesFinal, false));
await tx.CommitAsync();
return Results.Ok(new AdminUnlinkSuggestionsResponse(groupIds, await db.Players.CountAsync()));
}
public async Task<IResult> ResetAsync(Guid adminPlayerId, string password)
{
var passwordError = await ValidateAdminPasswordAsync(adminPlayerId, password);
if (passwordError is not null)
return passwordError;
await using var tx = await db.Database.BeginTransactionAsync();
await db.Votes.ExecuteDeleteAsync();
await db.Suggestions.ExecuteDeleteAsync();
await db.Players.ExecuteUpdateAsync(p => p.SetProperty(x => x.CurrentPhase, Phase.Suggest).SetProperty(x => x.VotesFinal, false).SetProperty(x => x.HasJoker, false));
var state = await db.AppState.SingleAsync();
state.ResultsOpen = false;
state.UpdatedAt = DateTimeOffset.UtcNow;
await db.SaveChangesAsync();
await tx.CommitAsync();
return Results.Ok(new AdminResetStateResponse(Phase.Suggest, state.ResultsOpen, state.UpdatedAt));
}
public async Task<IResult> FactoryResetAsync(Guid adminPlayerId, string password)
{
var passwordError = await ValidateAdminPasswordAsync(adminPlayerId, password);
if (passwordError is not null)
return passwordError;
await using var tx = await db.Database.BeginTransactionAsync();
await db.Votes.ExecuteDeleteAsync();
await db.Suggestions.ExecuteDeleteAsync();
await db.Players.ExecuteDeleteAsync();
await db.AppState.ExecuteDeleteAsync();
var fresh = EndpointHelpers.NewAppState();
db.AppState.Add(fresh);
await db.SaveChangesAsync();
await tx.CommitAsync();
return Results.Ok(new AdminResetStateResponse(Phase.Suggest, fresh.ResultsOpen, fresh.UpdatedAt));
}
private async Task<IResult?> ValidateAdminPasswordAsync(Guid adminPlayerId, string password)
{
if (string.IsNullOrWhiteSpace(password))
return EndpointHelpers.BadRequestError("Admin password is required.");
var admin = await db.Players.AsNoTracking().FirstOrDefaultAsync(p => p.Id == adminPlayerId && p.IsAdmin);
if (admin is null)
return EndpointHelpers.UnauthorizedError();
return PasswordHasher.Verify(password, admin.PasswordHash, admin.PasswordSalt)
? null
: EndpointHelpers.BadRequestError("Invalid admin password.");
}
}

View File

@@ -16,11 +16,11 @@ public static class AuthEndpoints
group.MapPost("/register", async ([FromBody] RegisterRequest request, HttpContext ctx, AppDbContext db, IConfiguration config) => group.MapPost("/register", async ([FromBody] RegisterRequest request, HttpContext ctx, AppDbContext db, IConfiguration config) =>
{ {
if (!AuthValidator.TryValidateRegistration(request, out var validated, out var registrationError)) if (!AuthValidator.TryValidateRegistration(request, out var validated, out var registrationError))
return Results.BadRequest(new { error = registrationError }); return EndpointHelpers.BadRequestError(registrationError);
var exists = await db.Players.AnyAsync(p => p.NormalizedUsername == validated.NormalizedUsername); var exists = await db.Players.AnyAsync(p => p.NormalizedUsername == validated.NormalizedUsername);
if (exists) if (exists)
return Results.Conflict(new { error = "Username already taken." }); return EndpointHelpers.ConflictError("Username already taken.");
var (hash, salt) = PasswordHasher.HashPassword(request.Password); var (hash, salt) = PasswordHasher.HashPassword(request.Password);
var expectedAdminKey = config["ADMIN_PASSWORD"]; var expectedAdminKey = config["ADMIN_PASSWORD"];
@@ -28,7 +28,7 @@ public static class AuthEndpoints
if (wantsAdmin) if (wantsAdmin)
{ {
if (string.IsNullOrWhiteSpace(expectedAdminKey) || validated.AdminKey != expectedAdminKey) if (string.IsNullOrWhiteSpace(expectedAdminKey) || validated.AdminKey != expectedAdminKey)
return Results.BadRequest(new { error = "Invalid admin key." }); return EndpointHelpers.BadRequestError("Invalid admin key.");
} }
var isAdmin = wantsAdmin; var isAdmin = wantsAdmin;
@@ -51,23 +51,22 @@ public static class AuthEndpoints
await PlayerIdentityExtensions.SignInPlayerAsync(ctx, player); await PlayerIdentityExtensions.SignInPlayerAsync(ctx, player);
return Results.Ok(new return Results.Ok(new AuthSessionResponse(
{
player.Id, player.Id,
player.Username, player.Username,
player.DisplayName, player.DisplayName,
player.IsAdmin player.IsAdmin
}); ));
}); });
group.MapPost("/login", async ([FromBody] LoginRequest request, HttpContext ctx, AppDbContext db) => group.MapPost("/login", async ([FromBody] LoginRequest request, HttpContext ctx, AppDbContext db) =>
{ {
if (!AuthValidator.TryValidateLogin(request, out _, out var normalizedUsername, out var loginError)) if (!AuthValidator.TryValidateLogin(request, out _, out var normalizedUsername, out var loginError))
return Results.BadRequest(new { error = loginError }); return EndpointHelpers.BadRequestError(loginError);
var player = await db.Players.FirstOrDefaultAsync(p => p.NormalizedUsername == normalizedUsername); var player = await db.Players.FirstOrDefaultAsync(p => p.NormalizedUsername == normalizedUsername);
if (player == null || !PasswordHasher.Verify(request.Password, player.PasswordHash, player.PasswordSalt)) if (player == null || !PasswordHasher.Verify(request.Password, player.PasswordHash, player.PasswordSalt))
return Results.Json(new { error = "Invalid username or password." }, statusCode: StatusCodes.Status401Unauthorized); return EndpointHelpers.UnauthorizedError("Invalid username or password.");
if (string.IsNullOrWhiteSpace(player.DisplayName)) if (string.IsNullOrWhiteSpace(player.DisplayName))
{ {
@@ -79,13 +78,12 @@ public static class AuthEndpoints
await PlayerIdentityExtensions.SignInPlayerAsync(ctx, player); await PlayerIdentityExtensions.SignInPlayerAsync(ctx, player);
return Results.Ok(new return Results.Ok(new AuthSessionResponse(
{
player.Id, player.Id,
player.Username, player.Username,
player.DisplayName, player.DisplayName,
player.IsAdmin player.IsAdmin
}); ));
}); });
group.MapPost("/logout", async (HttpContext ctx) => group.MapPost("/logout", async (HttpContext ctx) =>

View File

@@ -10,7 +10,7 @@ internal static class AuthValidator
public static bool TryValidateRegistration(RegisterRequest request, out ValidatedRegistration validated, out string error) public static bool TryValidateRegistration(RegisterRequest request, out ValidatedRegistration validated, out string error)
{ {
var username = (request.Username ?? string.Empty).Trim(); var username = (request.Username).Trim();
if (string.IsNullOrWhiteSpace(username) || username.Length > MaxUsernameLength) if (string.IsNullOrWhiteSpace(username) || username.Length > MaxUsernameLength)
{ {
validated = default; validated = default;
@@ -48,7 +48,7 @@ internal static class AuthValidator
public static bool TryValidateLogin(LoginRequest request, out string username, out string normalizedUsername, out string error) public static bool TryValidateLogin(LoginRequest request, out string username, out string normalizedUsername, out string error)
{ {
username = (request.Username ?? string.Empty).Trim(); username = (request.Username).Trim();
normalizedUsername = string.Empty; normalizedUsername = string.Empty;
if (string.IsNullOrWhiteSpace(username) || string.IsNullOrWhiteSpace(request.Password)) if (string.IsNullOrWhiteSpace(username) || string.IsNullOrWhiteSpace(request.Password))

View File

@@ -44,13 +44,13 @@ internal static class EndpointHelpers
if (playerPhase is null) if (playerPhase is null)
return Phase.Suggest; return Phase.Suggest;
var resultsOpen = await db.AppState.AsNoTracking().Select(s => s.ResultsOpen).FirstAsync(); var resultsOpen = await db.AppState.AsNoTracking().Select(s => s.ResultsOpen).SingleAsync();
return GetCurrentPhase(playerPhase.Value, resultsOpen); return GetCurrentPhase(playerPhase.Value, resultsOpen);
} }
public static Phase GetCurrentPhase(Phase phase, bool resultsOpen) public static Phase GetCurrentPhase(Phase phase, bool resultsOpen)
{ {
var normalized = phase == Phase.Reveal ? Phase.Vote : phase; var normalized = NormalizePhase(phase);
if (resultsOpen) if (resultsOpen)
return Phase.Results; return Phase.Results;
@@ -62,9 +62,10 @@ internal static class EndpointHelpers
{ {
var changed = false; var changed = false;
if (player.CurrentPhase == Phase.Reveal) var normalized = NormalizePhase(player.CurrentPhase);
if (player.CurrentPhase != normalized)
{ {
player.CurrentPhase = Phase.Vote; player.CurrentPhase = normalized;
changed = true; changed = true;
} }
@@ -83,8 +84,40 @@ internal static class EndpointHelpers
return changed; return changed;
} }
private static Phase NormalizePhase(Phase phase)
{
return phase switch
{
Phase.Suggest => Phase.Suggest,
Phase.Vote => Phase.Vote,
Phase.Results => Phase.Results,
_ => Phase.Vote // legacy/invalid phase fallback
};
}
public static IResult PhaseMismatch(Phase required, Phase current) => public static IResult PhaseMismatch(Phase required, Phase current) =>
Results.BadRequest(new { error = $"This endpoint is available in the {required} phase. Your current phase is {current}." }); BadRequestError($"This endpoint is available in the {required} phase. Your current phase is {current}.");
public static IResult BadRequestError(string detail) => Problem(StatusCodes.Status400BadRequest, "Bad Request", detail);
public static IResult NotFoundError(string detail) => Problem(StatusCodes.Status404NotFound, "Not Found", detail);
public static IResult ConflictError(string detail) => Problem(StatusCodes.Status409Conflict, "Conflict", detail);
public static IResult UnauthorizedError(string detail = "Unauthorized") => Problem(StatusCodes.Status401Unauthorized, "Unauthorized", detail);
private static IResult Problem(int statusCode, string title, string detail)
{
return Results.Problem(
statusCode: statusCode,
title: title,
detail: detail,
extensions: new Dictionary<string, object?>
{
["error"] = detail
}
);
}
public static string? TrimTo(string? input, int max) => public static string? TrimTo(string? input, int max) =>
string.IsNullOrWhiteSpace(input) ? null : input.Trim() is { Length: > 0 } t ? t[..Math.Min(t.Length, max)] : null; string.IsNullOrWhiteSpace(input) ? null : input.Trim() is { Length: > 0 } t ? t[..Math.Min(t.Length, max)] : null;
@@ -99,7 +132,12 @@ internal static class EndpointHelpers
return false; return false;
var path = uri.AbsolutePath.ToLowerInvariant(); var path = uri.AbsolutePath.ToLowerInvariant();
return path.EndsWith(".png") || path.EndsWith(".jpg") || path.EndsWith(".jpeg") || path.EndsWith(".gif") || path.EndsWith(".webp") || path.EndsWith(".avif"); return path.EndsWith(".png", StringComparison.Ordinal)
|| path.EndsWith(".jpg", StringComparison.Ordinal)
|| path.EndsWith(".jpeg", StringComparison.Ordinal)
|| path.EndsWith(".gif", StringComparison.Ordinal)
|| path.EndsWith(".webp", StringComparison.Ordinal)
|| path.EndsWith(".avif", StringComparison.Ordinal);
} }
public static async Task<bool> IsReachableImageAsync(string? url, IHttpClientFactory httpFactory, HttpMessageHandler? handler = null, CancellationToken ct = default) public static async Task<bool> IsReachableImageAsync(string? url, IHttpClientFactory httpFactory, HttpMessageHandler? handler = null, CancellationToken ct = default)
@@ -159,7 +197,7 @@ internal static class EndpointHelpers
await using var stream = await resp.Content.ReadAsStreamAsync(cts.Token); await using var stream = await resp.Content.ReadAsStreamAsync(cts.Token);
var rented = new byte[12]; var rented = new byte[12];
var read = await stream.ReadAsync(rented, 0, rented.Length, cts.Token); var read = await stream.ReadAsync(rented.AsMemory(0, rented.Length), cts.Token);
var sig = new ReadOnlySpan<byte>(rented, 0, read); var sig = new ReadOnlySpan<byte>(rented, 0, read);
if (IsMagic(sig, "PNG")) if (IsMagic(sig, "PNG"))

View File

@@ -1,7 +1,6 @@
using GameList.Data; using GameList.Data;
using GameList.Domain;
using Microsoft.EntityFrameworkCore;
using GameList.Infrastructure; using GameList.Infrastructure;
using GameList.Domain;
namespace GameList.Endpoints; namespace GameList.Endpoints;
@@ -13,87 +12,14 @@ public static class ResultsEndpoints
.RequireAuthorization() .RequireAuthorization()
.AddEndpointFilter(new PhaseRequirementFilter(Phase.Results)); .AddEndpointFilter(new PhaseRequirementFilter(Phase.Results));
group.MapGet( group.MapGet("/", async (HttpContext ctx, AppDbContext db, ResultsWorkflowService service) =>
"/", {
async (HttpContext ctx, AppDbContext db) => var player = await EndpointHelpers.GetAuthenticatedPlayer(ctx, db);
{ if (player is null)
var player = await EndpointHelpers.GetAuthenticatedPlayer(ctx, db); return EndpointHelpers.UnauthorizedError();
if (player is null)
return Results.Unauthorized();
var appState = await db.AppState.AsNoTracking().FirstAsync();
if (!appState.ResultsOpen)
return Results.BadRequest(new { error = "Results are locked until the admin enables them." });
var phase = await EndpointHelpers.GetCurrentPhaseAsync(db, player.Id);
if (phase != Phase.Results)
return EndpointHelpers.PhaseMismatch(Phase.Results, phase);
var results = await db return await service.GetResultsAsync(player.Id);
.Suggestions.AsNoTracking() });
.Include(s => s.Player)
.Include(s => s.Votes)
.Select(s => new
{
s.Id,
s.Name,
Author = s.Player!.DisplayName,
s.MinPlayers,
s.MaxPlayers,
Total = s.Votes.Sum(v => v.Score),
s.Votes.Count,
Average = s.Votes.Count == 0 ? 0 : s.Votes.Average(v => v.Score),
Votes = s.Votes.Select(v => v.Score).ToList(),
MyVote = s.Votes
.Where(v => v.PlayerId == player.Id)
.Select(v => (int?)v.Score)
.FirstOrDefault(),
s.ScreenshotUrl,
s.YoutubeUrl,
s.GameUrl,
s.Description,
s.Genre,
s.ParentSuggestionId
})
.OrderByDescending(r => r.Average)
.ToListAsync();
var rootIndex = EndpointHelpers.BuildLinkRoots(results.Select(r => (r.Id, r.ParentSuggestionId)));
var nameLookup = results.ToDictionary(r => r.Id, r => r.Name);
var shaped = results.Select(r =>
{
var linkedIds = EndpointHelpers.LinkedIdsFor(r.Id, rootIndex)
.Where(id => id != r.Id)
.ToList();
return new
{
r.Id,
r.Name,
r.Author,
r.MinPlayers,
r.MaxPlayers,
r.Total,
r.Count,
r.Average,
r.Votes,
r.MyVote,
r.ScreenshotUrl,
r.YoutubeUrl,
r.GameUrl,
r.Description,
r.Genre,
r.ParentSuggestionId,
LinkedIds = linkedIds,
LinkedTitles = linkedIds
.Where(nameLookup.ContainsKey)
.Select(id => nameLookup[id])
.ToList()
};
});
return Results.Ok(shaped);
}
);
} }
} }

View File

@@ -0,0 +1,87 @@
using GameList.Contracts;
using GameList.Data;
using GameList.Domain;
using Microsoft.EntityFrameworkCore;
namespace GameList.Endpoints;
internal sealed class ResultsWorkflowService(AppDbContext db)
{
public async Task<IResult> GetResultsAsync(Guid playerId)
{
var appState = await db.AppState.AsNoTracking().SingleAsync();
if (!appState.ResultsOpen)
return EndpointHelpers.BadRequestError("Results are locked until the admin enables them.");
var phase = await EndpointHelpers.GetCurrentPhaseAsync(db, playerId);
if (phase != Phase.Results)
return EndpointHelpers.PhaseMismatch(Phase.Results, phase);
var results = await db
.Suggestions.AsNoTracking()
.Include(s => s.Player)
.Include(s => s.Votes)
.Select(s => new
{
s.Id,
s.Name,
Author = s.Player!.DisplayName,
s.MinPlayers,
s.MaxPlayers,
Total = s.Votes.Sum(v => v.Score),
s.Votes.Count,
Average = s.Votes.Count == 0 ? 0 : s.Votes.Average(v => v.Score),
Votes = s.Votes.Select(v => v.Score).ToList(),
MyVote = s.Votes
.Where(v => v.PlayerId == playerId)
.Select(v => (int?)v.Score)
.FirstOrDefault(),
s.ScreenshotUrl,
s.YoutubeUrl,
s.GameUrl,
s.Description,
s.Genre,
s.ParentSuggestionId
})
.OrderByDescending(r => r.Average)
.ToListAsync();
var rootIndex = EndpointHelpers.BuildLinkRoots(results.Select(r => (r.Id, r.ParentSuggestionId)));
var nameLookup = results.ToDictionary(r => r.Id, r => r.Name);
var shaped = results.Select(r =>
{
var linkedIds = EndpointHelpers.LinkedIdsFor(r.Id, rootIndex)
.Where(id => id != r.Id)
.ToList();
var linkedTitles = linkedIds
.Where(nameLookup.ContainsKey)
.Select(id => nameLookup[id])
.ToList();
return new ResultItemDto(
r.Id,
r.Name,
r.Author,
r.MinPlayers,
r.MaxPlayers,
r.Total,
r.Count,
r.Average,
r.Votes,
r.MyVote,
r.ScreenshotUrl,
r.YoutubeUrl,
r.GameUrl,
r.Description,
r.Genre,
r.ParentSuggestionId,
linkedIds,
linkedTitles
);
});
return Results.Ok(shaped);
}
}

View File

@@ -1,6 +1,4 @@
using GameList.Data; using GameList.Data;
using GameList.Domain;
using Microsoft.EntityFrameworkCore;
namespace GameList.Endpoints; namespace GameList.Endpoints;
@@ -10,115 +8,41 @@ public static class StateEndpoints
{ {
var group = app.MapGroup("/api").RequireAuthorization(); var group = app.MapGroup("/api").RequireAuthorization();
group.MapGet("/state", async (HttpContext ctx, AppDbContext db) => group.MapGet("/state", async (HttpContext ctx, AppDbContext db, StateWorkflowService service) =>
{ {
var player = await EndpointHelpers.GetAuthenticatedPlayer(ctx, db); var player = await EndpointHelpers.GetAuthenticatedPlayer(ctx, db);
if (player is null) if (player is null)
return Results.Unauthorized(); return EndpointHelpers.UnauthorizedError();
var state = await db.AppState.AsNoTracking().FirstAsync(); return await service.GetStateAsync(player);
var phase = EndpointHelpers.GetCurrentPhase(player.CurrentPhase, state.ResultsOpen);
var summary = new
{
CurrentPhase = phase,
player.VotesFinal,
player.HasJoker,
state.ResultsOpen,
state.UpdatedAt,
Players = await db.Players.CountAsync(),
Suggestions = await db.Suggestions.CountAsync(),
Votes = await db.Votes.CountAsync()
};
return Results.Ok(summary);
}); });
group.MapGet("/me", async (HttpContext ctx, AppDbContext db) => group.MapGet("/me", async (HttpContext ctx, AppDbContext db, StateWorkflowService service) =>
{ {
var player = await EndpointHelpers.GetAuthenticatedPlayer(ctx, db); var player = await EndpointHelpers.GetAuthenticatedPlayer(ctx, db);
if (player is null) if (player is null)
return Results.Unauthorized(); return EndpointHelpers.UnauthorizedError();
var state = await db.AppState.AsNoTracking().FirstAsync(); return await service.GetMeAsync(player);
var phase = EndpointHelpers.GetCurrentPhase(player.CurrentPhase, state.ResultsOpen);
return Results.Ok(new
{
player.Id,
player.DisplayName,
player.Username,
player.IsAdmin,
CurrentPhase = phase,
player.VotesFinal,
player.HasJoker
});
}); });
group.MapPost("/me/phase/next", async (HttpContext ctx, AppDbContext db) => group.MapPost("/me/phase/next", async (HttpContext ctx, AppDbContext db, StateWorkflowService service) =>
{ {
var player = await EndpointHelpers.GetAuthenticatedPlayer(ctx, db); var player = await EndpointHelpers.GetAuthenticatedPlayer(ctx, db);
if (player is null) if (player is null)
return Results.Unauthorized(); return EndpointHelpers.UnauthorizedError();
var appState = await db.AppState.FirstAsync(); return await service.NextPhaseAsync(player);
var reconciled = EndpointHelpers.ReconcilePlayerPhase(player, appState.ResultsOpen);
var next = NextPhase(player.CurrentPhase);
if (next == Phase.Results && !appState.ResultsOpen)
{
if (reconciled)
await db.SaveChangesAsync();
return Results.BadRequest(new { error = "Results are locked until the admin enables them." });
}
player.CurrentPhase = next;
player.VotesFinal = false; // moving forward clears any prior finalize
await db.SaveChangesAsync();
return Results.Ok(new
{
player.CurrentPhase,
appState.ResultsOpen
});
}); });
group.MapPost("/me/phase/prev", async (HttpContext ctx, AppDbContext db) => group.MapPost("/me/phase/prev", async (HttpContext ctx, AppDbContext db, StateWorkflowService service) =>
{ {
var player = await EndpointHelpers.GetAuthenticatedPlayer(ctx, db); var player = await EndpointHelpers.GetAuthenticatedPlayer(ctx, db);
if (player is null) if (player is null)
return Results.Unauthorized(); return EndpointHelpers.UnauthorizedError();
var isAdmin = await EndpointHelpers.IsAdmin(ctx, db); return await service.PrevPhaseAsync(player);
if (!isAdmin)
{
return Results.BadRequest(new { error = "Only admins can move backward." });
}
var appState = await db.AppState.FirstAsync();
EndpointHelpers.ReconcilePlayerPhase(player, appState.ResultsOpen);
player.CurrentPhase = PrevPhase(player.CurrentPhase);
player.VotesFinal = false;
await db.SaveChangesAsync();
return Results.Ok(new
{
player.CurrentPhase,
appState.ResultsOpen
});
}); });
} }
private static Phase NextPhase(Phase current) => current switch
{
Phase.Suggest => Phase.Vote,
Phase.Reveal => Phase.Vote, // legacy safety
Phase.Vote => Phase.Results,
_ => Phase.Results
};
private static Phase PrevPhase(Phase current) => current switch
{
Phase.Results => Phase.Vote,
Phase.Vote => Phase.Suggest,
Phase.Reveal => Phase.Suggest, // legacy safety
_ => Phase.Suggest
};
} }

View File

@@ -0,0 +1,98 @@
using GameList.Contracts;
using GameList.Data;
using GameList.Domain;
using Microsoft.EntityFrameworkCore;
namespace GameList.Endpoints;
internal sealed class StateWorkflowService(AppDbContext db)
{
public async Task<IResult> GetStateAsync(Player player)
{
var state = await db.AppState.AsNoTracking().SingleAsync();
var phase = EndpointHelpers.GetCurrentPhase(player.CurrentPhase, state.ResultsOpen);
var summary = new StateSummaryResponse(
phase,
player.VotesFinal,
player.HasJoker,
state.ResultsOpen,
state.UpdatedAt,
await db.Players.CountAsync(),
await db.Suggestions.CountAsync(),
await db.Votes.CountAsync()
);
return Results.Ok(summary);
}
public async Task<IResult> GetMeAsync(Player player)
{
var state = await db.AppState.AsNoTracking().SingleAsync();
var phase = EndpointHelpers.GetCurrentPhase(player.CurrentPhase, state.ResultsOpen);
return Results.Ok(new MeResponse(
player.Id,
player.Username,
player.DisplayName,
player.IsAdmin,
phase,
player.VotesFinal,
player.HasJoker
));
}
public async Task<IResult> NextPhaseAsync(Player player)
{
var appState = await db.AppState.SingleAsync();
var shouldSave = EndpointHelpers.ReconcilePlayerPhase(player, appState.ResultsOpen);
try
{
var next = NextPhase(player.CurrentPhase);
if (next == Phase.Vote)
{
var hasSuggestions = await db.Suggestions.AnyAsync(s => s.PlayerId == player.Id);
if (!hasSuggestions)
return EndpointHelpers.BadRequestError("Add at least one suggestion before entering the Vote phase.");
}
if (next == Phase.Results && !appState.ResultsOpen)
return EndpointHelpers.BadRequestError("Results are locked until the admin enables them.");
player.CurrentPhase = next;
player.VotesFinal = false; // moving forward clears any prior finalize
shouldSave = true;
return Results.Ok(new PhaseTransitionResponse(player.CurrentPhase, appState.ResultsOpen));
}
finally
{
if (shouldSave)
await db.SaveChangesAsync();
}
}
public async Task<IResult> PrevPhaseAsync(Player player)
{
if (!player.IsAdmin)
return EndpointHelpers.BadRequestError("Only admins can move backward.");
var appState = await db.AppState.SingleAsync();
_ = EndpointHelpers.ReconcilePlayerPhase(player, appState.ResultsOpen);
player.CurrentPhase = PrevPhase(player.CurrentPhase);
player.VotesFinal = false;
await db.SaveChangesAsync();
return Results.Ok(new PhaseTransitionResponse(player.CurrentPhase, appState.ResultsOpen));
}
private static Phase NextPhase(Phase current) => current switch
{
Phase.Suggest => Phase.Vote,
_ => Phase.Results
};
private static Phase PrevPhase(Phase current) => current switch
{
Phase.Results => Phase.Vote,
_ => Phase.Suggest
};
}

View File

@@ -1,8 +1,6 @@
using GameList.Contracts; using GameList.Contracts;
using GameList.Data; using GameList.Data;
using GameList.Domain;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using GameList.Infrastructure; using GameList.Infrastructure;
namespace GameList.Endpoints; namespace GameList.Endpoints;
@@ -13,247 +11,74 @@ public static class SuggestEndpoints
{ {
var group = app.MapGroup("/api/suggestions").RequireAuthorization(); var group = app.MapGroup("/api/suggestions").RequireAuthorization();
group.MapGet("/mine", async (HttpContext ctx, AppDbContext db) => group.MapGet("/mine", async (HttpContext ctx, AppDbContext db, SuggestionWorkflowService service) =>
{ {
var player = await EndpointHelpers.GetAuthenticatedPlayer(ctx, db); var player = await EndpointHelpers.GetAuthenticatedPlayer(ctx, db);
if (player is null) if (player is null)
return Results.Unauthorized(); return EndpointHelpers.UnauthorizedError();
var mine = await db.Suggestions.AsNoTracking().Where(s => s.PlayerId == player.Id).Select(s => new return await service.GetMineAsync(player.Id);
{
s.Id,
s.PlayerId,
s.Name,
s.Genre,
s.Description,
s.ScreenshotUrl,
s.YoutubeUrl,
s.GameUrl,
s.CreatedAt,
s.MinPlayers,
s.MaxPlayers,
s.ParentSuggestionId
}).ToListAsync();
var ordered = mine.OrderBy(s => s.CreatedAt).Select(s => new SuggestionDto(s.Id, s.Name, s.Genre, s.Description, s.ScreenshotUrl, s.YoutubeUrl, s.GameUrl, s.MinPlayers, s.MaxPlayers, s.ParentSuggestionId));
return Results.Ok(ordered);
}); });
group.MapPost("/", async ([FromBody] SuggestionRequest request, HttpContext ctx, AppDbContext db, IHttpClientFactory http) => group.MapPost("/", async ([FromBody] SuggestionRequest request, HttpContext ctx, AppDbContext db, SuggestionWorkflowService service) =>
{ {
var validationError = await SuggestionValidator.ValidateAsync(request, http);
if (validationError is not null)
return Results.BadRequest(new { error = validationError });
var player = await EndpointHelpers.GetAuthenticatedPlayer(ctx, db); var player = await EndpointHelpers.GetAuthenticatedPlayer(ctx, db);
if (player is null) if (player is null)
return Results.Unauthorized(); return EndpointHelpers.UnauthorizedError();
var phase = await EndpointHelpers.GetCurrentPhaseAsync(db, player.Id); return await service.CreateAsync(
var usingJoker = phase == Phase.Vote && player.HasJoker; player.Id,
if (phase != Phase.Suggest && !usingJoker) new SuggestionInput(
return EndpointHelpers.PhaseMismatch(Phase.Suggest, phase); request.Name,
request.Genre,
if (string.IsNullOrWhiteSpace(player.DisplayName)) request.Description,
{ request.ScreenshotUrl,
return Results.BadRequest(new { error = "Set a display name before submitting suggestions." }); request.YoutubeUrl,
} request.GameUrl,
request.MinPlayers,
var existingCount = await db.Suggestions.CountAsync(s => s.PlayerId == player.Id); request.MaxPlayers
if (!usingJoker && existingCount >= 5) )
{ );
return Results.BadRequest(new { error = "You have reached the 5 suggestion limit." });
}
var suggestion = new Suggestion
{
PlayerId = player.Id,
Name = request.Name.Trim(),
Genre = EndpointHelpers.TrimTo(request.Genre, 50),
Description = EndpointHelpers.TrimTo(request.Description, 500),
ScreenshotUrl = EndpointHelpers.TrimTo(request.ScreenshotUrl, 2048),
YoutubeUrl = EndpointHelpers.TrimTo(request.YoutubeUrl, 2048),
GameUrl = EndpointHelpers.TrimTo(request.GameUrl, 2048),
MinPlayers = request.MinPlayers,
MaxPlayers = request.MaxPlayers
};
db.Suggestions.Add(suggestion);
if (usingJoker)
{
player.HasJoker = false;
await db.Players.ExecuteUpdateAsync(p => p.SetProperty(x => x.VotesFinal, false));
}
await db.SaveChangesAsync();
return Results.Created($"/api/suggestions/{suggestion.Id}", new { suggestion.Id });
}).AddEndpointFilter(new PhaseOrJokerFilter()); }).AddEndpointFilter(new PhaseOrJokerFilter());
group.MapDelete("/{id:int}", async (int id, HttpContext ctx, AppDbContext db) => group.MapDelete("/{id:int}", async (int id, HttpContext ctx, AppDbContext db, SuggestionWorkflowService service) =>
{ {
var player = await EndpointHelpers.GetAuthenticatedPlayer(ctx, db); var player = await EndpointHelpers.GetAuthenticatedPlayer(ctx, db);
if (player is null) if (player is null)
return Results.Unauthorized(); return EndpointHelpers.UnauthorizedError();
var isAdmin = await EndpointHelpers.IsAdmin(ctx, db); return await service.DeleteAsync(player.Id, id);
if (!isAdmin)
{
var phase = await EndpointHelpers.GetCurrentPhaseAsync(db, player.Id);
if (phase != Phase.Suggest)
return EndpointHelpers.PhaseMismatch(Phase.Suggest, phase);
}
var suggestion = isAdmin ? await db.Suggestions.FirstOrDefaultAsync(s => s.Id == id) : await db.Suggestions.FirstOrDefaultAsync(s => s.Id == id && s.PlayerId == player.Id);
if (suggestion == null)
return Results.NotFound(new { error = "Suggestion not found." });
// Break any links that pointed at this suggestion
await db.Suggestions.Where(s => s.ParentSuggestionId == suggestion.Id).ExecuteUpdateAsync(s => s.SetProperty(x => x.ParentSuggestionId, (int?)null));
// Remove votes for this suggestion to avoid orphaned vote rows or FK errors
await db.Votes.Where(v => v.SuggestionId == suggestion.Id).ExecuteDeleteAsync();
db.Suggestions.Remove(suggestion);
await db.SaveChangesAsync();
return Results.NoContent();
}); });
group.MapPut("/{id:int}", async (int id, [FromBody] SuggestionRequest request, HttpContext ctx, AppDbContext db, IHttpClientFactory http) => group.MapPut("/{id:int}", async (int id, [FromBody] SuggestionRequest request, HttpContext ctx, AppDbContext db, SuggestionWorkflowService service) =>
{
var player = await EndpointHelpers.GetAuthenticatedPlayer(ctx, db);
var isAdmin = await EndpointHelpers.IsAdmin(ctx, db);
if (!isAdmin && player is null)
return Results.Unauthorized();
var validationError = await SuggestionValidator.ValidateAsync(request, http);
if (validationError is not null)
return Results.BadRequest(new { error = validationError });
var suggestion = await db.Suggestions.FirstOrDefaultAsync(s => s.Id == id);
if (suggestion == null)
return Results.NotFound(new { error = "Suggestion not found." });
if (!isAdmin)
{
if (suggestion.PlayerId != player!.Id)
return Results.Unauthorized();
var phase = await EndpointHelpers.GetCurrentPhaseAsync(db, player.Id);
if (phase == Phase.Results)
return EndpointHelpers.PhaseMismatch(Phase.Suggest, phase);
var inSuggest = phase == Phase.Suggest;
var inVote = phase == Phase.Vote;
if (inSuggest)
{
suggestion.Name = request.Name.Trim();
}
else if (inVote)
{
// Title locked in vote; allow other fields
}
else
{
return EndpointHelpers.PhaseMismatch(Phase.Suggest, phase);
}
suggestion.Genre = EndpointHelpers.TrimTo(request.Genre, 50);
suggestion.Description = EndpointHelpers.TrimTo(request.Description, 500);
suggestion.ScreenshotUrl = EndpointHelpers.TrimTo(request.ScreenshotUrl, 2048);
suggestion.YoutubeUrl = EndpointHelpers.TrimTo(request.YoutubeUrl, 2048);
suggestion.GameUrl = EndpointHelpers.TrimTo(request.GameUrl, 2048);
suggestion.MinPlayers = request.MinPlayers;
suggestion.MaxPlayers = request.MaxPlayers;
}
else
{
// Admins can edit anytime
suggestion.Name = request.Name.Trim();
suggestion.Genre = EndpointHelpers.TrimTo(request.Genre, 50);
suggestion.Description = EndpointHelpers.TrimTo(request.Description, 500);
suggestion.ScreenshotUrl = EndpointHelpers.TrimTo(request.ScreenshotUrl, 2048);
suggestion.YoutubeUrl = EndpointHelpers.TrimTo(request.YoutubeUrl, 2048);
suggestion.GameUrl = EndpointHelpers.TrimTo(request.GameUrl, 2048);
suggestion.MinPlayers = request.MinPlayers;
suggestion.MaxPlayers = request.MaxPlayers;
}
await db.SaveChangesAsync();
return Results.Ok(new
{
suggestion.Id,
suggestion.Name,
suggestion.Genre,
suggestion.Description,
suggestion.ScreenshotUrl,
suggestion.YoutubeUrl,
suggestion.GameUrl,
suggestion.MinPlayers,
suggestion.MaxPlayers
});
});
group.MapGet("/all", async (HttpContext ctx, AppDbContext db) =>
{ {
var player = await EndpointHelpers.GetAuthenticatedPlayer(ctx, db); var player = await EndpointHelpers.GetAuthenticatedPlayer(ctx, db);
if (player is null) if (player is null)
return Results.Unauthorized(); return EndpointHelpers.UnauthorizedError();
var phase = await EndpointHelpers.GetCurrentPhaseAsync(db, player.Id); return await service.UpdateAsync(
if (phase < Phase.Vote) player.Id,
return EndpointHelpers.PhaseMismatch(Phase.Vote, phase); id,
new SuggestionInput(
request.Name,
request.Genre,
request.Description,
request.ScreenshotUrl,
request.YoutubeUrl,
request.GameUrl,
request.MinPlayers,
request.MaxPlayers
)
);
});
var all = await db.Suggestions.AsNoTracking().Include(s => s.Player).Select(s => new group.MapGet("/all", async (HttpContext ctx, AppDbContext db, SuggestionWorkflowService service) =>
{ {
s.Id, var player = await EndpointHelpers.GetAuthenticatedPlayer(ctx, db);
s.Name, if (player is null)
s.Genre, return EndpointHelpers.UnauthorizedError();
s.Description,
s.ScreenshotUrl,
s.YoutubeUrl,
s.GameUrl,
s.MinPlayers,
s.MaxPlayers,
Author = s.Player!.DisplayName,
s.CreatedAt,
s.ParentSuggestionId,
IsOwner = s.PlayerId == player.Id
}).ToListAsync();
var rootIndex = EndpointHelpers.BuildLinkRoots(all.Select(s => (s.Id, s.ParentSuggestionId))); return await service.GetAllAsync(player.Id);
var nameLookup = all.ToDictionary(s => s.Id, s => s.Name);
var ordered = all.OrderBy(s => s.CreatedAt).Select(s =>
{
var linkedIds = EndpointHelpers.LinkedIdsFor(s.Id, rootIndex).Where(id => id != s.Id).ToList();
return new
{
s.Id,
s.Name,
s.Genre,
s.Description,
s.ScreenshotUrl,
s.YoutubeUrl,
s.GameUrl,
s.MinPlayers,
s.MaxPlayers,
s.Author,
s.ParentSuggestionId,
s.IsOwner,
LinkedIds = linkedIds,
LinkedTitles = linkedIds.Where(nameLookup.ContainsKey).Select(id => nameLookup[id]).ToList()
};
});
return Results.Ok(ordered);
}); });
} }
} }

View File

@@ -0,0 +1,12 @@
namespace GameList.Endpoints;
internal readonly record struct SuggestionInput(
string Name,
string? Genre,
string? Description,
string? ScreenshotUrl,
string? YoutubeUrl,
string? GameUrl,
int? MinPlayers,
int? MaxPlayers
);

View File

@@ -1,27 +1,25 @@
using GameList.Contracts;
namespace GameList.Endpoints; namespace GameList.Endpoints;
internal static class SuggestionValidator internal static class SuggestionValidator
{ {
public static async Task<string?> ValidateAsync(SuggestionRequest request, IHttpClientFactory httpFactory) public static async Task<string?> ValidateAsync(SuggestionInput input, IHttpClientFactory httpFactory)
{ {
if (string.IsNullOrWhiteSpace(request.Name) || request.Name.Length > 100) if (string.IsNullOrWhiteSpace(input.Name) || input.Name.Length > 100)
return "Name is required and must be <= 100 characters."; return "Name is required and must be <= 100 characters.";
if (!EndpointHelpers.IsValidImageUrl(request.ScreenshotUrl)) if (!EndpointHelpers.IsValidImageUrl(input.ScreenshotUrl))
return "Screenshot URL must be http(s) and end with an image file extension."; return "Screenshot URL must be http(s) and end with an image file extension.";
if (!await EndpointHelpers.IsReachableImageAsync(request.ScreenshotUrl, httpFactory)) if (!await EndpointHelpers.IsReachableImageAsync(input.ScreenshotUrl, httpFactory))
return "Screenshot URL could not be validated as an image. Use a public image link (http/https, no redirects, max 5 MB)."; return "Screenshot URL could not be validated as an image. Use a public image link (http/https, no redirects, max 5 MB).";
if (!EndpointHelpers.IsValidHttpUrl(request.GameUrl)) if (!EndpointHelpers.IsValidHttpUrl(input.GameUrl))
return "Game URL must be http or https."; return "Game URL must be http or https.";
if (!EndpointHelpers.IsValidHttpUrl(request.YoutubeUrl)) if (!EndpointHelpers.IsValidHttpUrl(input.YoutubeUrl))
return "YouTube URL must be http or https."; return "YouTube URL must be http or https.";
return ValidatePlayers(request.MinPlayers, request.MaxPlayers); return ValidatePlayers(input.MinPlayers, input.MaxPlayers);
} }
private static string? ValidatePlayers(int? minPlayers, int? maxPlayers) private static string? ValidatePlayers(int? minPlayers, int? maxPlayers)

View File

@@ -0,0 +1,264 @@
using GameList.Contracts;
using GameList.Data;
using GameList.Domain;
using Microsoft.EntityFrameworkCore;
namespace GameList.Endpoints;
internal sealed class SuggestionWorkflowService(AppDbContext db, IHttpClientFactory httpFactory)
{
public async Task<IResult> GetMineAsync(Guid playerId)
{
var mine = await db.Suggestions
.AsNoTracking()
.Where(s => s.PlayerId == playerId)
.Select(s => new
{
s.Id,
s.PlayerId,
s.Name,
s.Genre,
s.Description,
s.ScreenshotUrl,
s.YoutubeUrl,
s.GameUrl,
s.CreatedAt,
s.MinPlayers,
s.MaxPlayers,
s.ParentSuggestionId
})
.ToListAsync();
var ordered = mine
.OrderBy(s => s.CreatedAt)
.Select(s => new SuggestionDto(s.Id, s.Name, s.Genre, s.Description, s.ScreenshotUrl, s.YoutubeUrl, s.GameUrl, s.MinPlayers, s.MaxPlayers, s.ParentSuggestionId));
return Results.Ok(ordered);
}
public async Task<IResult> CreateAsync(Guid playerId, SuggestionInput input)
{
var validationError = await SuggestionValidator.ValidateAsync(input, httpFactory);
if (validationError is not null)
return EndpointHelpers.BadRequestError(validationError);
var playerState = await db.Players
.AsNoTracking()
.Where(p => p.Id == playerId)
.Select(p => new
{
p.DisplayName,
p.HasJoker
})
.FirstAsync();
var phase = await EndpointHelpers.GetCurrentPhaseAsync(db, playerId);
var usingJoker = phase == Phase.Vote && playerState.HasJoker;
if (phase != Phase.Suggest && !usingJoker)
return EndpointHelpers.PhaseMismatch(Phase.Suggest, phase);
if (string.IsNullOrWhiteSpace(playerState.DisplayName))
return EndpointHelpers.BadRequestError("Set a display name before submitting suggestions.");
var existingCount = await db.Suggestions.CountAsync(s => s.PlayerId == playerId);
if (!usingJoker && existingCount >= 5)
return EndpointHelpers.BadRequestError("You have reached the 5 suggestion limit.");
var suggestion = new Suggestion
{
PlayerId = playerId,
Name = input.Name.Trim(),
Genre = EndpointHelpers.TrimTo(input.Genre, 50),
Description = EndpointHelpers.TrimTo(input.Description, 500),
ScreenshotUrl = EndpointHelpers.TrimTo(input.ScreenshotUrl, 2048),
YoutubeUrl = EndpointHelpers.TrimTo(input.YoutubeUrl, 2048),
GameUrl = EndpointHelpers.TrimTo(input.GameUrl, 2048),
MinPlayers = input.MinPlayers,
MaxPlayers = input.MaxPlayers
};
await using var tx = await db.Database.BeginTransactionAsync();
db.Suggestions.Add(suggestion);
if (usingJoker)
{
await db.Players
.Where(p => p.Id == playerId)
.ExecuteUpdateAsync(p => p.SetProperty(x => x.HasJoker, false));
await db.Players.ExecuteUpdateAsync(p => p.SetProperty(x => x.VotesFinal, false));
}
await db.SaveChangesAsync();
await tx.CommitAsync();
return Results.Created($"/api/suggestions/{suggestion.Id}", new SuggestionCreatedResponse(suggestion.Id));
}
public async Task<IResult> DeleteAsync(Guid playerId, int suggestionId)
{
var actor = await db.Players
.AsNoTracking()
.Where(p => p.Id == playerId)
.Select(p => new
{
p.IsAdmin
})
.FirstAsync();
var isAdmin = actor.IsAdmin;
if (!isAdmin)
{
var phase = await EndpointHelpers.GetCurrentPhaseAsync(db, playerId);
if (phase != Phase.Suggest)
return EndpointHelpers.PhaseMismatch(Phase.Suggest, phase);
}
var suggestion = isAdmin
? await db.Suggestions.FirstOrDefaultAsync(s => s.Id == suggestionId)
: await db.Suggestions.FirstOrDefaultAsync(s => s.Id == suggestionId && s.PlayerId == playerId);
if (suggestion == null)
return EndpointHelpers.NotFoundError("Suggestion not found.");
await using var tx = await db.Database.BeginTransactionAsync();
await db.Suggestions
.Where(s => s.ParentSuggestionId == suggestion.Id)
.ExecuteUpdateAsync(s => s.SetProperty(x => x.ParentSuggestionId, (int?)null));
await db.Votes.Where(v => v.SuggestionId == suggestion.Id).ExecuteDeleteAsync();
db.Suggestions.Remove(suggestion);
await db.SaveChangesAsync();
await tx.CommitAsync();
return Results.NoContent();
}
public async Task<IResult> UpdateAsync(Guid playerId, int suggestionId, SuggestionInput input)
{
var validationError = await SuggestionValidator.ValidateAsync(input, httpFactory);
if (validationError is not null)
return EndpointHelpers.BadRequestError(validationError);
var actor = await db.Players
.AsNoTracking()
.Where(p => p.Id == playerId)
.Select(p => new
{
p.IsAdmin
})
.FirstAsync();
var suggestion = await db.Suggestions.FirstOrDefaultAsync(s => s.Id == suggestionId);
if (suggestion == null)
return EndpointHelpers.NotFoundError("Suggestion not found.");
var isAdmin = actor.IsAdmin;
if (!isAdmin)
{
if (suggestion.PlayerId != playerId)
return EndpointHelpers.UnauthorizedError();
var phase = await EndpointHelpers.GetCurrentPhaseAsync(db, playerId);
if (phase == Phase.Results)
return EndpointHelpers.PhaseMismatch(Phase.Suggest, phase);
if (phase == Phase.Suggest)
{
suggestion.Name = input.Name.Trim();
}
else if (phase != Phase.Vote)
{
return EndpointHelpers.PhaseMismatch(Phase.Suggest, phase);
}
ApplyEditableFields(suggestion, input);
}
else
{
suggestion.Name = input.Name.Trim();
ApplyEditableFields(suggestion, input);
}
await db.SaveChangesAsync();
return Results.Ok(new SuggestionUpdatedResponse(
suggestion.Id,
suggestion.Name,
suggestion.Genre,
suggestion.Description,
suggestion.ScreenshotUrl,
suggestion.YoutubeUrl,
suggestion.GameUrl,
suggestion.MinPlayers,
suggestion.MaxPlayers
));
}
public async Task<IResult> GetAllAsync(Guid playerId)
{
var phase = await EndpointHelpers.GetCurrentPhaseAsync(db, playerId);
if (phase < Phase.Vote)
return EndpointHelpers.PhaseMismatch(Phase.Vote, phase);
var all = await db.Suggestions
.AsNoTracking()
.Include(s => s.Player)
.Select(s => new
{
s.Id,
s.Name,
s.Genre,
s.Description,
s.ScreenshotUrl,
s.YoutubeUrl,
s.GameUrl,
s.MinPlayers,
s.MaxPlayers,
Author = s.Player!.DisplayName,
s.CreatedAt,
s.ParentSuggestionId,
IsOwner = s.PlayerId == playerId
})
.ToListAsync();
var rootIndex = EndpointHelpers.BuildLinkRoots(all.Select(s => (s.Id, s.ParentSuggestionId)));
var nameLookup = all.ToDictionary(s => s.Id, s => s.Name);
var ordered = all.OrderBy(s => s.CreatedAt).Select(s =>
{
var linkedIds = EndpointHelpers.LinkedIdsFor(s.Id, rootIndex).Where(id => id != s.Id).ToList();
return new
{
s.Id,
s.Name,
s.Genre,
s.Description,
s.ScreenshotUrl,
s.YoutubeUrl,
s.GameUrl,
s.MinPlayers,
s.MaxPlayers,
s.Author,
s.ParentSuggestionId,
s.IsOwner,
LinkedIds = linkedIds,
LinkedTitles = linkedIds.Where(nameLookup.ContainsKey).Select(id => nameLookup[id]).ToList()
};
});
return Results.Ok(ordered);
}
private static void ApplyEditableFields(Suggestion suggestion, SuggestionInput input)
{
suggestion.Genre = EndpointHelpers.TrimTo(input.Genre, 50);
suggestion.Description = EndpointHelpers.TrimTo(input.Description, 500);
suggestion.ScreenshotUrl = EndpointHelpers.TrimTo(input.ScreenshotUrl, 2048);
suggestion.YoutubeUrl = EndpointHelpers.TrimTo(input.YoutubeUrl, 2048);
suggestion.GameUrl = EndpointHelpers.TrimTo(input.GameUrl, 2048);
suggestion.MinPlayers = input.MinPlayers;
suggestion.MaxPlayers = input.MaxPlayers;
}
}

View File

@@ -1,9 +1,7 @@
using GameList.Contracts; using GameList.Contracts;
using GameList.Data; using GameList.Data;
using GameList.Domain;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using GameList.Infrastructure; using GameList.Infrastructure;
using GameList.Domain;
namespace GameList.Endpoints; namespace GameList.Endpoints;
@@ -13,97 +11,30 @@ public static class VoteEndpoints
{ {
var group = app.MapGroup("/api/votes").RequireAuthorization().AddEndpointFilter(new PhaseRequirementFilter(Phase.Vote)); var group = app.MapGroup("/api/votes").RequireAuthorization().AddEndpointFilter(new PhaseRequirementFilter(Phase.Vote));
group.MapGet("/mine", async (HttpContext ctx, AppDbContext db) => group.MapGet("/mine", async (HttpContext ctx, AppDbContext db, VoteWorkflowService service) =>
{ {
var player = await EndpointHelpers.GetAuthenticatedPlayer(ctx, db); var player = await EndpointHelpers.GetAuthenticatedPlayer(ctx, db);
if (player is null) if (player is null)
return Results.Unauthorized(); return EndpointHelpers.UnauthorizedError();
var phase = await EndpointHelpers.GetCurrentPhaseAsync(db, player.Id); return await service.GetMineAsync(player.Id);
if (phase != Phase.Vote)
return EndpointHelpers.PhaseMismatch(Phase.Vote, phase);
var votes = await db.Votes.AsNoTracking().Where(v => v.PlayerId == player.Id).Select(v => new
{
v.SuggestionId,
v.Score
}).ToListAsync();
return Results.Ok(votes);
}); });
group.MapPost("/", async ([FromBody] VoteRequest request, HttpContext ctx, AppDbContext db) => group.MapPost("/", async (VoteRequest request, HttpContext ctx, AppDbContext db, VoteWorkflowService service) =>
{ {
if (request.Score is < 0 or > 10)
return Results.BadRequest(new { error = "Score must be between 0 and 10." });
var player = await EndpointHelpers.GetAuthenticatedPlayer(ctx, db); var player = await EndpointHelpers.GetAuthenticatedPlayer(ctx, db);
if (player is null) if (player is null)
return Results.Unauthorized(); return EndpointHelpers.UnauthorizedError();
if (player.VotesFinal) return await service.UpsertAsync(player.Id, request.SuggestionId, request.Score);
return Results.BadRequest(new { error = "Votes are finalized. Unfinalize before changing scores." });
var phase = await EndpointHelpers.GetCurrentPhaseAsync(db, player.Id);
if (phase != Phase.Vote)
return EndpointHelpers.PhaseMismatch(Phase.Vote, phase);
if (string.IsNullOrWhiteSpace(player.DisplayName))
return Results.BadRequest(new { error = "Set a display name before voting." });
var linkMap = await db.Suggestions.AsNoTracking().Select(s => new
{
s.Id,
s.ParentSuggestionId
}).ToListAsync();
var rootIndex = EndpointHelpers.BuildLinkRoots(linkMap.Select(s => (s.Id, s.ParentSuggestionId)));
if (!rootIndex.ContainsKey(request.SuggestionId))
return Results.BadRequest(new { error = "Suggestion not found." });
var linkedIds = EndpointHelpers.LinkedIdsFor(request.SuggestionId, rootIndex);
if (linkedIds.Count == 0)
linkedIds.Add(request.SuggestionId);
var existingVotes = await db.Votes.Where(v => v.PlayerId == player.Id && linkedIds.Contains(v.SuggestionId)).ToListAsync();
foreach (var suggestionId in linkedIds)
{
var vote = existingVotes.FirstOrDefault(v => v.SuggestionId == suggestionId);
if (vote == null)
{
db.Votes.Add(new Vote
{
PlayerId = player.Id,
SuggestionId = suggestionId,
Score = request.Score
});
}
else
{
vote.Score = request.Score;
}
}
await db.SaveChangesAsync();
return Results.Ok(new
{
SuggestionIds = linkedIds,
request.Score
});
}); });
group.MapPost("/finalize", async ([FromBody] VoteFinalizeRequest request, HttpContext ctx, AppDbContext db) => group.MapPost("/finalize", async (VoteFinalizeRequest request, HttpContext ctx, AppDbContext db, VoteWorkflowService service) =>
{ {
var player = await EndpointHelpers.GetAuthenticatedPlayer(ctx, db); var player = await EndpointHelpers.GetAuthenticatedPlayer(ctx, db);
if (player is null) if (player is null)
return Results.Unauthorized(); return EndpointHelpers.UnauthorizedError();
var phase = await EndpointHelpers.GetCurrentPhaseAsync(db, player.Id); return await service.SetFinalizeAsync(player.Id, request.Final);
if (phase != Phase.Vote)
return EndpointHelpers.PhaseMismatch(Phase.Vote, phase);
player.VotesFinal = request.Final;
await db.SaveChangesAsync();
return Results.Ok(new { player.VotesFinal });
}); });
} }
} }

View File

@@ -0,0 +1,108 @@
using GameList.Contracts;
using GameList.Data;
using GameList.Domain;
using Microsoft.EntityFrameworkCore;
namespace GameList.Endpoints;
internal sealed class VoteWorkflowService(AppDbContext db)
{
public async Task<IResult> GetMineAsync(Guid playerId)
{
var phase = await EndpointHelpers.GetCurrentPhaseAsync(db, playerId);
if (phase != Phase.Vote)
return EndpointHelpers.PhaseMismatch(Phase.Vote, phase);
var votes = await db.Votes
.AsNoTracking()
.Where(v => v.PlayerId == playerId)
.Select(v => new
{
v.SuggestionId,
v.Score
})
.ToListAsync();
return Results.Ok(votes);
}
public async Task<IResult> UpsertAsync(Guid playerId, int suggestionId, int score)
{
if (score is < 0 or > 10)
return EndpointHelpers.BadRequestError("Score must be between 0 and 10.");
var playerState = await db.Players
.AsNoTracking()
.Where(p => p.Id == playerId)
.Select(p => new
{
p.VotesFinal,
p.DisplayName
})
.FirstAsync();
if (playerState.VotesFinal)
return EndpointHelpers.BadRequestError("Votes are finalized. Unfinalize before changing scores.");
var phase = await EndpointHelpers.GetCurrentPhaseAsync(db, playerId);
if (phase != Phase.Vote)
return EndpointHelpers.PhaseMismatch(Phase.Vote, phase);
if (string.IsNullOrWhiteSpace(playerState.DisplayName))
return EndpointHelpers.BadRequestError("Set a display name before voting.");
var linkMap = await db.Suggestions
.AsNoTracking()
.Select(s => new
{
s.Id,
s.ParentSuggestionId
})
.ToListAsync();
var rootIndex = EndpointHelpers.BuildLinkRoots(linkMap.Select(s => (s.Id, s.ParentSuggestionId)));
if (!rootIndex.ContainsKey(suggestionId))
return EndpointHelpers.BadRequestError("Suggestion not found.");
var linkedIds = EndpointHelpers.LinkedIdsFor(suggestionId, rootIndex);
if (linkedIds.Count == 0)
linkedIds.Add(suggestionId);
var existingVotes = await db.Votes
.Where(v => v.PlayerId == playerId && linkedIds.Contains(v.SuggestionId))
.ToListAsync();
foreach (var linkedSuggestionId in linkedIds)
{
var vote = existingVotes.FirstOrDefault(v => v.SuggestionId == linkedSuggestionId);
if (vote == null)
{
db.Votes.Add(new Vote
{
PlayerId = playerId,
SuggestionId = linkedSuggestionId,
Score = score
});
}
else
{
vote.Score = score;
}
}
await db.SaveChangesAsync();
return Results.Ok(new VoteUpsertResponse(linkedIds, score));
}
public async Task<IResult> SetFinalizeAsync(Guid playerId, bool final)
{
var phase = await EndpointHelpers.GetCurrentPhaseAsync(db, playerId);
if (phase != Phase.Vote)
return EndpointHelpers.PhaseMismatch(Phase.Vote, phase);
var player = await db.Players.FirstAsync(p => p.Id == playerId);
player.VotesFinal = final;
await db.SaveChangesAsync();
return Results.Ok(new VoteFinalizeResponse(player.VotesFinal));
}
}

View File

@@ -9,19 +9,21 @@ namespace GameList.Tests;
public class AdminTests public class AdminTests
{ {
private const string AdminPassword = "Pass123!";
[Fact] [Fact]
public async Task Admin_vote_status_marks_ready_when_all_finalized() public async Task Admin_vote_status_marks_ready_when_all_finalized()
{ {
await using var factory = new TestWebApplicationFactory(); await using var factory = new TestWebApplicationFactory();
var admin = factory.CreateClientWithCookies(); var admin = factory.CreateClientWithCookies();
await admin.RegisterAsync("admin", admin: true); await admin.RegisterAsync("admin", admin: true);
await admin.PostAsJsonAsync("/api/me/phase/next", new { }); // move to Vote await admin.AdvanceToVoteAsync("Admin seed"); // move to Vote
var p1 = factory.CreateClientWithCookies(); var p1 = factory.CreateClientWithCookies();
await p1.RegisterAsync("alice"); await p1.RegisterAsync("alice");
var p2 = factory.CreateClientWithCookies(); var p2 = factory.CreateClientWithCookies();
await p2.RegisterAsync("bob"); await p2.RegisterAsync("bob");
await p2.PostAsJsonAsync("/api/me/phase/next", new { }); await p2.AdvanceToVoteAsync("Bob seed");
var s1 = await p1.CreateSuggestionAsync("A"); var s1 = await p1.CreateSuggestionAsync("A");
await p1.PostAsJsonAsync("/api/me/phase/next", new { }); await p1.PostAsJsonAsync("/api/me/phase/next", new { });
@@ -59,6 +61,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()
{ {
@@ -77,7 +136,10 @@ public class AdminTests
Score = 8 Score = 8
}); });
var resp = await admin.DeleteAsync($"/api/admin/players/{await player.GetProfileIdAsync()}"); var resp = await admin.SendAsync(new HttpRequestMessage(HttpMethod.Delete, $"/api/admin/players/{await player.GetProfileIdAsync()}")
{
Content = JsonContent.Create(new { password = AdminPassword })
});
resp.EnsureSuccessStatusCode(); resp.EnsureSuccessStatusCode();
await factory.WithDbContextAsync(db => await factory.WithDbContextAsync(db =>
@@ -111,7 +173,7 @@ public class AdminTests
var b = await player.CreateSuggestionAsync("Game B"); var b = await player.CreateSuggestionAsync("Game B");
await player.PostAsJsonAsync("/api/me/phase/next", new { }); await player.PostAsJsonAsync("/api/me/phase/next", new { });
await admin.PostAsJsonAsync("/api/me/phase/next", new { }); await admin.AdvanceToVoteAsync("Admin link seed");
var same = await admin.PostAsJsonAsync("/api/admin/link-suggestions", new var same = await admin.PostAsJsonAsync("/api/admin/link-suggestions", new
{ {
@@ -147,7 +209,7 @@ public class AdminTests
var a = await player.CreateSuggestionAsync("Game A"); var a = await player.CreateSuggestionAsync("Game A");
var b = await player.CreateSuggestionAsync("Game B"); var b = await player.CreateSuggestionAsync("Game B");
await player.PostAsJsonAsync("/api/me/phase/next", new { }); await player.PostAsJsonAsync("/api/me/phase/next", new { });
await admin.PostAsJsonAsync("/api/me/phase/next", new { }); await admin.AdvanceToVoteAsync("Admin unlink seed");
await admin.PostAsJsonAsync("/api/admin/link-suggestions", new await admin.PostAsJsonAsync("/api/admin/link-suggestions", new
{ {
SourceSuggestionId = a, SourceSuggestionId = a,
@@ -189,7 +251,7 @@ public class AdminTests
await player.RegisterAsync("player"); await player.RegisterAsync("player");
await player.CreateSuggestionAsync("Keep"); await player.CreateSuggestionAsync("Keep");
var reset = await admin.PostAsJsonAsync("/api/admin/reset", new { }); var reset = await admin.PostAsJsonAsync("/api/admin/reset", new { password = AdminPassword });
reset.EnsureSuccessStatusCode(); reset.EnsureSuccessStatusCode();
await factory.WithDbContextAsync(db => await factory.WithDbContextAsync(db =>
@@ -209,7 +271,7 @@ public class AdminTests
} }
}); });
var factoryReset = await admin.PostAsJsonAsync("/api/admin/factory-reset", new { }); var factoryReset = await admin.PostAsJsonAsync("/api/admin/factory-reset", new { password = AdminPassword });
factoryReset.EnsureSuccessStatusCode(); factoryReset.EnsureSuccessStatusCode();
await factory.WithDbContextAsync(db => await factory.WithDbContextAsync(db =>
@@ -236,6 +298,7 @@ public class AdminTests
await admin.RegisterAsync("admin", admin: true); await admin.RegisterAsync("admin", admin: true);
var player = factory.CreateClientWithCookies(); var player = factory.CreateClientWithCookies();
await player.RegisterAsync("player"); await player.RegisterAsync("player");
await player.CreateSuggestionAsync("Player game");
var open = await admin.PostAsJsonAsync("/api/admin/results", new { resultsOpen = true }); var open = await admin.PostAsJsonAsync("/api/admin/results", new { resultsOpen = true });
open.EnsureSuccessStatusCode(); open.EnsureSuccessStatusCode();
@@ -244,11 +307,11 @@ public class AdminTests
{ {
var p = await db.Players.FirstAsync(x => !x.IsAdmin); var p = await db.Players.FirstAsync(x => !x.IsAdmin);
p.VotesFinal = true; p.VotesFinal = true;
var state = await db.AppState.SingleAsync();
state.UpdatedAt = DateTimeOffset.UnixEpoch;
await db.SaveChangesAsync(); await db.SaveChangesAsync();
}); });
var beforeState = await factory.WithDbContextAsync(async db => await db.AppState.AsNoTracking().FirstAsync());
await Task.Delay(5);
var close = await admin.PostAsJsonAsync("/api/admin/results", new { resultsOpen = false }); var close = await admin.PostAsJsonAsync("/api/admin/results", new { resultsOpen = false });
close.EnsureSuccessStatusCode(); close.EnsureSuccessStatusCode();
@@ -257,9 +320,40 @@ public class AdminTests
var p = await db.Players.FirstAsync(x => !x.IsAdmin); var p = await db.Players.FirstAsync(x => !x.IsAdmin);
Assert.Equal(Phase.Vote, p.CurrentPhase); Assert.Equal(Phase.Vote, p.CurrentPhase);
Assert.False(p.VotesFinal); Assert.False(p.VotesFinal);
var state = await db.AppState.AsNoTracking().FirstAsync(); var state = await db.AppState.AsNoTracking().SingleAsync();
Assert.False(state.ResultsOpen); Assert.False(state.ResultsOpen);
Assert.True(state.UpdatedAt > beforeState.UpdatedAt); Assert.True(state.UpdatedAt > DateTimeOffset.UnixEpoch);
});
}
[Fact]
public async Task Admin_results_closing_sends_players_without_suggestions_to_suggest_phase()
{
await using var factory = new TestWebApplicationFactory();
var admin = factory.CreateClientWithCookies();
await admin.RegisterAsync("admin", admin: true);
var voter = factory.CreateClientWithCookies();
await voter.RegisterAsync("voter");
await voter.CreateSuggestionAsync("Voter game");
var open = await admin.PostAsJsonAsync("/api/admin/results", new { resultsOpen = true });
open.EnsureSuccessStatusCode();
var lateJoiner = factory.CreateClientWithCookies();
await lateJoiner.RegisterAsync("late");
var close = await admin.PostAsJsonAsync("/api/admin/results", new { resultsOpen = false });
close.EnsureSuccessStatusCode();
await factory.WithDbContextAsync(async db =>
{
var voterPlayer = await db.Players.SingleAsync(p => p.Username == "voter");
var latePlayer = await db.Players.SingleAsync(p => p.Username == "late");
Assert.Equal(Phase.Vote, voterPlayer.CurrentPhase);
Assert.Equal(Phase.Suggest, latePlayer.CurrentPhase);
Assert.False(voterPlayer.VotesFinal);
Assert.False(latePlayer.VotesFinal);
}); });
} }
@@ -269,7 +363,7 @@ public class AdminTests
await using var factory = new TestWebApplicationFactory(); await using var factory = new TestWebApplicationFactory();
var admin = factory.CreateClientWithCookies(); var admin = factory.CreateClientWithCookies();
await admin.RegisterAsync("admin", admin: true); await admin.RegisterAsync("admin", admin: true);
await admin.PostAsJsonAsync("/api/me/phase/next", new { }); await admin.AdvanceToVoteAsync("Admin vote status seed");
var p1 = factory.CreateClientWithCookies(); var p1 = factory.CreateClientWithCookies();
await p1.RegisterAsync("alice"); await p1.RegisterAsync("alice");
@@ -277,7 +371,7 @@ public class AdminTests
await p2.RegisterAsync("bob"); await p2.RegisterAsync("bob");
var s = await p1.CreateSuggestionAsync("Game"); var s = await p1.CreateSuggestionAsync("Game");
await p1.PostAsJsonAsync("/api/me/phase/next", new { }); await p1.PostAsJsonAsync("/api/me/phase/next", new { });
await p2.PostAsJsonAsync("/api/me/phase/next", new { }); await p2.AdvanceToVoteAsync("Bob vote seed");
await p1.PostAsJsonAsync("/api/votes", new await p1.PostAsJsonAsync("/api/votes", new
{ {
SuggestionId = s, SuggestionId = s,
@@ -300,7 +394,7 @@ public class AdminTests
var p = factory.CreateClientWithCookies(); var p = factory.CreateClientWithCookies();
await p.RegisterAsync("player"); await p.RegisterAsync("player");
await p.PostAsJsonAsync("/api/me/phase/next", new { }); await p.AdvanceToVoteAsync("Player joker seed");
await p.PostAsJsonAsync("/api/votes/finalize", new { Final = true }); await p.PostAsJsonAsync("/api/votes/finalize", new { Final = true });
var give = await admin.PostAsJsonAsync("/api/admin/joker", new { playerId = (await p.GetProfileIdAsync()) }); var give = await admin.PostAsJsonAsync("/api/admin/joker", new { playerId = (await p.GetProfileIdAsync()) });
@@ -333,7 +427,7 @@ public class AdminTests
}); });
Assert.Equal(HttpStatusCode.BadRequest, beforeVotePhase.StatusCode); Assert.Equal(HttpStatusCode.BadRequest, beforeVotePhase.StatusCode);
await admin.PostAsJsonAsync("/api/me/phase/next", new { }); await admin.AdvanceToVoteAsync("Admin link-phase seed");
await player.PostAsJsonAsync("/api/me/phase/next", new { }); await player.PostAsJsonAsync("/api/me/phase/next", new { });
await player.PostAsJsonAsync("/api/votes", new await player.PostAsJsonAsync("/api/votes", new
@@ -373,9 +467,9 @@ public class AdminTests
var a = await p1.CreateSuggestionAsync("A"); var a = await p1.CreateSuggestionAsync("A");
var b = await p1.CreateSuggestionAsync("B"); var b = await p1.CreateSuggestionAsync("B");
await admin.PostAsJsonAsync("/api/me/phase/next", new { }); await admin.AdvanceToVoteAsync("Admin unfinalize seed");
await p1.PostAsJsonAsync("/api/me/phase/next", new { }); await p1.PostAsJsonAsync("/api/me/phase/next", new { });
await p2.PostAsJsonAsync("/api/me/phase/next", new { }); await p2.AdvanceToVoteAsync("P2 unfinalize seed");
await p1.PostAsJsonAsync("/api/votes/finalize", new { Final = true }); await p1.PostAsJsonAsync("/api/votes/finalize", new { Final = true });
await p2.PostAsJsonAsync("/api/votes/finalize", new { Final = true }); await p2.PostAsJsonAsync("/api/votes/finalize", new { Final = true });
@@ -400,7 +494,7 @@ public class AdminTests
await using var factory = new TestWebApplicationFactory(); await using var factory = new TestWebApplicationFactory();
var admin = factory.CreateClientWithCookies(); var admin = factory.CreateClientWithCookies();
await admin.RegisterAsync("admin", admin: true); await admin.RegisterAsync("admin", admin: true);
await admin.PostAsJsonAsync("/api/me/phase/next", new { }); await admin.AdvanceToVoteAsync("Admin unlink not-found seed");
var resp = await admin.PostAsJsonAsync("/api/admin/unlink-suggestions", new { suggestionId = 9999 }); var resp = await admin.PostAsJsonAsync("/api/admin/unlink-suggestions", new { suggestionId = 9999 });
resp.EnsureSuccessStatusCode(); resp.EnsureSuccessStatusCode();
@@ -425,7 +519,7 @@ public class AdminTests
await db.SaveChangesAsync(); await db.SaveChangesAsync();
}); });
var reset = await admin.PostAsJsonAsync("/api/admin/reset", new { }); var reset = await admin.PostAsJsonAsync("/api/admin/reset", new { password = AdminPassword });
reset.EnsureSuccessStatusCode(); reset.EnsureSuccessStatusCode();
await factory.WithDbContextAsync(async db => await factory.WithDbContextAsync(async db =>
@@ -433,16 +527,37 @@ public class AdminTests
var player = await db.Players.SingleAsync(x => x.Username == "flags"); var player = await db.Players.SingleAsync(x => x.Username == "flags");
Assert.False(player.HasJoker); Assert.False(player.HasJoker);
Assert.False(player.VotesFinal); Assert.False(player.VotesFinal);
var state = await db.AppState.AsNoTracking().FirstAsync(); var state = await db.AppState.AsNoTracking().SingleAsync();
Assert.False(state.ResultsOpen); Assert.False(state.ResultsOpen);
}); });
var factoryReset = await admin.PostAsJsonAsync("/api/admin/factory-reset", new { }); var factoryReset = await admin.PostAsJsonAsync("/api/admin/factory-reset", new { password = AdminPassword });
factoryReset.EnsureSuccessStatusCode(); factoryReset.EnsureSuccessStatusCode();
await factory.WithDbContextAsync(async db => await factory.WithDbContextAsync(async db =>
{ {
var state = await db.AppState.AsNoTracking().FirstAsync(); var state = await db.AppState.AsNoTracking().SingleAsync();
Assert.False(state.ResultsOpen); Assert.False(state.ResultsOpen);
}); });
} }
[Fact]
public async Task Destructive_admin_actions_require_valid_admin_password()
{
await using var factory = new TestWebApplicationFactory();
var admin = factory.CreateClientWithCookies();
await admin.RegisterAsync("admin", admin: true);
var player = factory.CreateClientWithCookies();
await player.RegisterAsync("target");
var resetWrongPassword = await admin.PostAsJsonAsync("/api/admin/reset", new { password = "wrong" });
Assert.Equal(HttpStatusCode.BadRequest, resetWrongPassword.StatusCode);
var playerId = await factory.WithDbContextAsync(async db => await db.Players.Where(p => p.Username == "target").Select(p => p.Id).SingleAsync());
var deleteWrongPassword = await admin.SendAsync(new HttpRequestMessage(HttpMethod.Delete, $"/api/admin/players/{playerId}")
{
Content = JsonContent.Create(new { password = "wrong" })
});
Assert.Equal(HttpStatusCode.BadRequest, deleteWrongPassword.StatusCode);
}
} }

View File

@@ -71,7 +71,7 @@ public class AuthTests
await factory.WithDbContextAsync(async db => await factory.WithDbContextAsync(async db =>
{ {
var player = await db.Players.FirstAsync(); var player = await db.Players.SingleAsync();
player.DisplayName = null; player.DisplayName = null;
player.LastLoginAt = DateTimeOffset.UnixEpoch; player.LastLoginAt = DateTimeOffset.UnixEpoch;
await db.SaveChangesAsync(); await db.SaveChangesAsync();
@@ -161,6 +161,10 @@ public class AuthTests
var resp = await player.GetAsync("/api/admin/vote-status"); var resp = await player.GetAsync("/api/admin/vote-status");
Assert.Equal(HttpStatusCode.Unauthorized, resp.StatusCode); Assert.Equal(HttpStatusCode.Unauthorized, resp.StatusCode);
var json = await resp.Content.ReadFromJsonAsync<JsonElement>();
Assert.Equal("Unauthorized", json.GetProperty("title").GetString());
Assert.Equal("Unauthorized", json.GetProperty("detail").GetString());
Assert.Equal("Unauthorized", json.GetProperty("error").GetString());
} }
[Fact] [Fact]

View File

@@ -91,7 +91,7 @@ public class FiltersTests
DisplayName = "User" DisplayName = "User"
}; };
db.Players.Add(player); db.Players.Add(player);
var state = await db.AppState.FirstAsync(); var state = await db.AppState.SingleAsync();
state.ResultsOpen = resultsOpen; state.ResultsOpen = resultsOpen;
await db.SaveChangesAsync(); await db.SaveChangesAsync();

View File

@@ -43,7 +43,7 @@ public class ResultsTests
await using var factory = new TestWebApplicationFactory(); await using var factory = new TestWebApplicationFactory();
var client = factory.CreateClientWithCookies(); var client = factory.CreateClientWithCookies();
await client.RegisterAsync("user"); await client.RegisterAsync("user");
await client.PostAsJsonAsync("/api/me/phase/next", new { }); await client.AdvanceToVoteAsync("Results locked seed");
var resp = await client.GetAsync("/api/results"); var resp = await client.GetAsync("/api/results");
Assert.Equal(System.Net.HttpStatusCode.BadRequest, resp.StatusCode); Assert.Equal(System.Net.HttpStatusCode.BadRequest, resp.StatusCode);
} }

View File

@@ -19,7 +19,7 @@ public class StateTests
await client.RegisterAsync("payload"); await client.RegisterAsync("payload");
await factory.WithDbContextAsync(async db => await factory.WithDbContextAsync(async db =>
{ {
var player = await db.Players.FirstAsync(); var player = await db.Players.SingleAsync();
player.HasJoker = true; player.HasJoker = true;
await db.SaveChangesAsync(); await db.SaveChangesAsync();
}); });
@@ -50,12 +50,12 @@ public class StateTests
PasswordHash = [1], PasswordHash = [1],
PasswordSalt = [1], PasswordSalt = [1],
DisplayName = "Legacy", DisplayName = "Legacy",
CurrentPhase = Phase.Reveal, CurrentPhase = (Phase)1,
VotesFinal = true VotesFinal = true
}; };
playerId = player.Id; playerId = player.Id;
db.Players.Add(player); db.Players.Add(player);
var state = await db.AppState.FirstAsync(); var state = await db.AppState.SingleAsync();
state.ResultsOpen = true; state.ResultsOpen = true;
await db.SaveChangesAsync(); await db.SaveChangesAsync();
}); });
@@ -69,7 +69,7 @@ public class StateTests
await factory.WithDbContextAsync(async db => await factory.WithDbContextAsync(async db =>
{ {
var state = await db.AppState.FirstAsync(); var state = await db.AppState.SingleAsync();
state.ResultsOpen = false; state.ResultsOpen = false;
await db.SaveChangesAsync(); await db.SaveChangesAsync();
}); });
@@ -80,7 +80,7 @@ public class StateTests
var phase = await Endpoints.EndpointHelpers.GetCurrentPhaseAsync(db, playerId); var phase = await Endpoints.EndpointHelpers.GetCurrentPhaseAsync(db, playerId);
var player = await db.Players.FindAsync(playerId); var player = await db.Players.FindAsync(playerId);
Assert.Equal(Phase.Vote, phase); Assert.Equal(Phase.Vote, phase);
Assert.Equal(Phase.Reveal, player!.CurrentPhase); Assert.Equal((Phase)1, player!.CurrentPhase);
Assert.True(player.VotesFinal); Assert.True(player.VotesFinal);
} }
} }
@@ -91,10 +91,11 @@ public class StateTests
await using var factory = new TestWebApplicationFactory(); await using var factory = new TestWebApplicationFactory();
var client = factory.CreateClientWithCookies(); var client = factory.CreateClientWithCookies();
await client.RegisterAsync("advance"); await client.RegisterAsync("advance");
await client.CreateSuggestionAsync("Advance game");
await factory.WithDbContextAsync(async db => await factory.WithDbContextAsync(async db =>
{ {
var player = await db.Players.FirstAsync(); var player = await db.Players.SingleAsync();
player.VotesFinal = true; player.VotesFinal = true;
await db.SaveChangesAsync(); await db.SaveChangesAsync();
}); });
@@ -121,11 +122,12 @@ public class StateTests
await using var factory = new TestWebApplicationFactory(); await using var factory = new TestWebApplicationFactory();
var admin = factory.CreateClientWithCookies(); var admin = factory.CreateClientWithCookies();
await admin.RegisterAsync("admin", admin: true); await admin.RegisterAsync("admin", admin: true);
await admin.CreateSuggestionAsync("Admin game");
await admin.PostAsJsonAsync("/api/me/phase/next", new { }); // Vote await admin.PostAsJsonAsync("/api/me/phase/next", new { }); // Vote
await factory.WithDbContextAsync(async db => await factory.WithDbContextAsync(async db =>
{ {
var player = await db.Players.FirstAsync(); var player = await db.Players.SingleAsync();
player.VotesFinal = true; player.VotesFinal = true;
await db.SaveChangesAsync(); await db.SaveChangesAsync();
}); });
@@ -143,6 +145,7 @@ public class StateTests
await using var factory = new TestWebApplicationFactory(); await using var factory = new TestWebApplicationFactory();
var client = factory.CreateClientWithCookies(); var client = factory.CreateClientWithCookies();
await client.RegisterAsync("player"); await client.RegisterAsync("player");
await client.CreateSuggestionAsync("Player game");
var toVote = await client.PostAsync("/api/me/phase/next", JsonContent.Create(new { })); var toVote = await client.PostAsync("/api/me/phase/next", JsonContent.Create(new { }));
toVote.EnsureSuccessStatusCode(); toVote.EnsureSuccessStatusCode();
@@ -152,6 +155,20 @@ public class StateTests
Assert.Equal(HttpStatusCode.BadRequest, toResults.StatusCode); Assert.Equal(HttpStatusCode.BadRequest, toResults.StatusCode);
} }
[Fact]
public async Task Phase_next_from_suggest_requires_at_least_one_suggestion()
{
await using var factory = new TestWebApplicationFactory();
var client = factory.CreateClientWithCookies();
await client.RegisterAsync("nosuggest");
var response = await client.PostAsJsonAsync("/api/me/phase/next", new { });
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
var me = await client.GetFromJsonAsync<JsonElement>("/api/me");
Assert.Equal(nameof(Phase.Suggest), me.GetProperty("currentPhase").GetString());
}
[Fact] [Fact]
public async Task Admin_opening_results_moves_players_to_results_phase() public async Task Admin_opening_results_moves_players_to_results_phase()
{ {
@@ -199,6 +216,7 @@ public class StateTests
var admin = factory.CreateClientWithCookies(); var admin = factory.CreateClientWithCookies();
await admin.RegisterAsync("admin", admin: true); await admin.RegisterAsync("admin", admin: true);
await admin.CreateSuggestionAsync("Admin phase game");
await admin.PostAsJsonAsync("/api/me/phase/next", new { }); // to Vote await admin.PostAsJsonAsync("/api/me/phase/next", new { }); // to Vote
var back = await admin.PostAsJsonAsync("/api/me/phase/prev", new { }); var back = await admin.PostAsJsonAsync("/api/me/phase/prev", new { });
back.EnsureSuccessStatusCode(); back.EnsureSuccessStatusCode();
@@ -212,7 +230,11 @@ public class StateTests
await using var factory = new TestWebApplicationFactory(); await using var factory = new TestWebApplicationFactory();
var anon = factory.CreateClient(); var anon = factory.CreateClient();
var unauthorized = await anon.GetAsync("/api/state"); var unauthorized = await anon.GetAsync("/api/state");
Assert.NotEqual(HttpStatusCode.OK, unauthorized.StatusCode); Assert.Equal(HttpStatusCode.Unauthorized, unauthorized.StatusCode);
var unauthorizedJson = await unauthorized.Content.ReadFromJsonAsync<JsonElement>();
Assert.Equal("Unauthorized", unauthorizedJson.GetProperty("title").GetString());
Assert.Equal("Unauthorized", unauthorizedJson.GetProperty("detail").GetString());
Assert.Equal("Unauthorized", unauthorizedJson.GetProperty("error").GetString());
var client = factory.CreateClientWithCookies(); var client = factory.CreateClientWithCookies();
await client.RegisterAsync("counting"); await client.RegisterAsync("counting");
@@ -224,6 +246,27 @@ public class StateTests
Assert.True(suggestions.GetInt32() >= 1); Assert.True(suggestions.GetInt32() >= 1);
} }
[Fact]
public async Task State_endpoint_with_stale_cookie_returns_unauthorized_and_clears_cookie()
{
await using var factory = new TestWebApplicationFactory();
var client = factory.CreateClientWithCookies();
await client.RegisterAsync("stale");
await factory.WithDbContextAsync(async db =>
{
var player = await db.Players.SingleAsync();
db.Players.Remove(player);
await db.SaveChangesAsync();
});
var resp = await client.GetAsync("/api/state");
Assert.Equal(HttpStatusCode.Unauthorized, resp.StatusCode);
Assert.True(resp.Headers.TryGetValues("Set-Cookie", out var cookies));
Assert.Contains(cookies, c => c.Contains("player=", StringComparison.OrdinalIgnoreCase));
}
[Fact] [Fact]
public async Task Health_endpoint_ok() public async Task Health_endpoint_ok()
{ {
@@ -241,10 +284,10 @@ public class StateTests
await factory.WithDbContextAsync(async db => await factory.WithDbContextAsync(async db =>
{ {
var player = await db.Players.FirstAsync(); var player = await db.Players.SingleAsync();
player.CurrentPhase = Phase.Results; player.CurrentPhase = Phase.Results;
player.VotesFinal = true; player.VotesFinal = true;
var state = await db.AppState.FirstAsync(); var state = await db.AppState.SingleAsync();
state.ResultsOpen = false; state.ResultsOpen = false;
await db.SaveChangesAsync(); await db.SaveChangesAsync();
}); });
@@ -257,7 +300,7 @@ public class StateTests
await factory.WithDbContextAsync(async db => await factory.WithDbContextAsync(async db =>
{ {
var player = await db.Players.AsNoTracking().FirstAsync(); var player = await db.Players.AsNoTracking().SingleAsync();
Assert.Equal(Phase.Results, player.CurrentPhase); Assert.Equal(Phase.Results, player.CurrentPhase);
Assert.True(player.VotesFinal); Assert.True(player.VotesFinal);
}); });
@@ -280,17 +323,18 @@ public class StateTests
CurrentPhase = Phase.Vote CurrentPhase = Phase.Vote
}; };
db.Players.Add(player); db.Players.Add(player);
var state = await db.AppState.FirstAsync(); var state = await db.AppState.SingleAsync();
state.ResultsOpen = true; state.ResultsOpen = true;
await db.SaveChangesAsync(); await db.SaveChangesAsync();
}); });
using var scope = factory.Services.CreateScope(); using var scope = factory.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>(); var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
var playerId = await db.Players.Select(p => p.Id).FirstAsync(); var playerId = await db.Players.Select(p => p.Id).SingleAsync();
var phase = await Endpoints.EndpointHelpers.GetCurrentPhaseAsync(db, playerId); var phase = await Endpoints.EndpointHelpers.GetCurrentPhaseAsync(db, playerId);
Assert.Equal(Phase.Results, phase); Assert.Equal(Phase.Results, phase);
} }
} }

View File

@@ -91,7 +91,7 @@ public class SuggestionTests
await factory.WithDbContextAsync(async db => await factory.WithDbContextAsync(async db =>
{ {
var p = await db.Players.FirstAsync(); var p = await db.Players.SingleAsync(x => x.Username == "joker");
p.HasJoker = true; p.HasJoker = true;
p.CurrentPhase = Domain.Phase.Vote; p.CurrentPhase = Domain.Phase.Vote;
var o = await db.Players.SingleAsync(x => x.Username == "other"); var o = await db.Players.SingleAsync(x => x.Username == "other");
@@ -114,7 +114,7 @@ public class SuggestionTests
await factory.WithDbContextAsync(async db => await factory.WithDbContextAsync(async db =>
{ {
var p = await db.Players.FirstAsync(); var p = await db.Players.SingleAsync(x => x.Username == "joker");
Assert.False(p.HasJoker); Assert.False(p.HasJoker);
Assert.False(p.VotesFinal); Assert.False(p.VotesFinal);
var o = await db.Players.SingleAsync(x => x.Username == "other"); var o = await db.Players.SingleAsync(x => x.Username == "other");
@@ -187,9 +187,9 @@ public class SuggestionTests
// Move everyone to Results // Move everyone to Results
await factory.WithDbContextAsync(async db => await factory.WithDbContextAsync(async db =>
{ {
var state = await db.AppState.FirstAsync(); var state = await db.AppState.SingleAsync();
state.ResultsOpen = true; state.ResultsOpen = true;
var p = await db.Players.FirstAsync(); var p = await db.Players.SingleAsync();
p.CurrentPhase = Domain.Phase.Results; p.CurrentPhase = Domain.Phase.Results;
await db.SaveChangesAsync(); await db.SaveChangesAsync();
}); });
@@ -267,7 +267,7 @@ public class SuggestionTests
await client.PostAsJsonAsync("/api/me/phase/next", new { }); await client.PostAsJsonAsync("/api/me/phase/next", new { });
await factory.WithDbContextAsync(async db => await factory.WithDbContextAsync(async db =>
{ {
var p = await db.Players.FirstAsync(); var p = await db.Players.SingleAsync();
p.HasJoker = true; p.HasJoker = true;
await db.SaveChangesAsync(); await db.SaveChangesAsync();
}); });
@@ -288,7 +288,7 @@ public class SuggestionTests
// Grant another joker and add a seventh suggestion // Grant another joker and add a seventh suggestion
await factory.WithDbContextAsync(async db => await factory.WithDbContextAsync(async db =>
{ {
var p = await db.Players.FirstAsync(); var p = await db.Players.SingleAsync();
p.HasJoker = true; p.HasJoker = true;
await db.SaveChangesAsync(); await db.SaveChangesAsync();
}); });
@@ -365,28 +365,15 @@ public class SuggestionTests
var client = factory.CreateClientWithCookies(); var client = factory.CreateClientWithCookies();
await client.RegisterAsync("mine"); await client.RegisterAsync("mine");
await client.PostAsJsonAsync("/api/suggestions", new var secondId = await client.CreateSuggestionAsync("Second");
var thirdId = await client.CreateSuggestionAsync("Third");
await factory.WithDbContextAsync(async db =>
{ {
Name = "Second", var second = await db.Suggestions.FindAsync(secondId);
Genre = (string?)null, var third = await db.Suggestions.FindAsync(thirdId);
Description = (string?)null, second!.CreatedAt = DateTimeOffset.UtcNow.AddMinutes(-1);
ScreenshotUrl = (string?)null, third!.CreatedAt = DateTimeOffset.UtcNow;
YoutubeUrl = (string?)null, await db.SaveChangesAsync();
GameUrl = (string?)null,
MinPlayers = (int?)null,
MaxPlayers = (int?)null
});
await Task.Delay(10);
await client.PostAsJsonAsync("/api/suggestions", new
{
Name = "Third",
Genre = (string?)null,
Description = (string?)null,
ScreenshotUrl = (string?)null,
YoutubeUrl = (string?)null,
GameUrl = (string?)null,
MinPlayers = (int?)null,
MaxPlayers = (int?)null
}); });
var mine = await client.GetFromJsonAsync<List<JsonElement>>("/api/suggestions/mine"); var mine = await client.GetFromJsonAsync<List<JsonElement>>("/api/suggestions/mine");
@@ -402,7 +389,7 @@ public class SuggestionTests
await factory.WithDbContextAsync(async db => await factory.WithDbContextAsync(async db =>
{ {
var p = await db.Players.FirstAsync(); var p = await db.Players.SingleAsync();
p.CurrentPhase = Domain.Phase.Vote; p.CurrentPhase = Domain.Phase.Vote;
p.DisplayName = null; p.DisplayName = null;
await db.SaveChangesAsync(); await db.SaveChangesAsync();
@@ -423,7 +410,7 @@ public class SuggestionTests
await factory.WithDbContextAsync(async db => await factory.WithDbContextAsync(async db =>
{ {
var p = await db.Players.FirstAsync(); var p = await db.Players.SingleAsync();
p.CurrentPhase = Domain.Phase.Suggest; p.CurrentPhase = Domain.Phase.Suggest;
await db.SaveChangesAsync(); await db.SaveChangesAsync();
}); });
@@ -539,7 +526,7 @@ public class SuggestionTests
await factory.WithDbContextAsync(async db => await factory.WithDbContextAsync(async db =>
{ {
var s = await db.Suggestions.AsNoTracking().FirstAsync(); var s = await db.Suggestions.AsNoTracking().SingleAsync();
Assert.Equal(50, s.Genre!.Length); Assert.Equal(50, s.Genre!.Length);
Assert.Equal(500, s.Description!.Length); Assert.Equal(500, s.Description!.Length);
Assert.Equal("http://example.com/img.png", s.ScreenshotUrl); Assert.Equal("http://example.com/img.png", s.ScreenshotUrl);
@@ -572,11 +559,13 @@ public class SuggestionTests
await client.RegisterAsync("owner"); await client.RegisterAsync("owner");
var id1 = await client.CreateSuggestionAsync("Alpha"); var id1 = await client.CreateSuggestionAsync("Alpha");
await Task.Delay(10);
var id2 = await client.CreateSuggestionAsync("Beta"); var id2 = await client.CreateSuggestionAsync("Beta");
await factory.WithDbContextAsync(async db => await factory.WithDbContextAsync(async db =>
{ {
var alpha = await db.Suggestions.FindAsync(id1);
var beta = await db.Suggestions.FindAsync(id2); var beta = await db.Suggestions.FindAsync(id2);
alpha!.CreatedAt = DateTimeOffset.UtcNow.AddMinutes(-1);
beta!.CreatedAt = DateTimeOffset.UtcNow;
beta!.ParentSuggestionId = id1; beta!.ParentSuggestionId = id1;
await db.SaveChangesAsync(); await db.SaveChangesAsync();
}); });
@@ -613,6 +602,7 @@ public class SuggestionTests
}); });
await owner.PostAsJsonAsync("/api/me/phase/next", new { }); // Vote await owner.PostAsJsonAsync("/api/me/phase/next", new { }); // Vote
await other.CreateSuggestionAsync("Other vote seed");
await other.PostAsJsonAsync("/api/me/phase/next", new { }); await other.PostAsJsonAsync("/api/me/phase/next", new { });
await other.PostAsJsonAsync("/api/votes", new await other.PostAsJsonAsync("/api/votes", new
{ {

View File

@@ -49,4 +49,11 @@ internal static class TestClientExtensions
var me = await client.GetFromJsonAsync<JsonElement>("/api/me"); var me = await client.GetFromJsonAsync<JsonElement>("/api/me");
return Guid.Parse(me.GetProperty("id").GetString()!); return Guid.Parse(me.GetProperty("id").GetString()!);
} }
public static async Task AdvanceToVoteAsync(this HttpClient client, string suggestionName = "Seed game")
{
await client.CreateSuggestionAsync(suggestionName);
var response = await client.PostAsJsonAsync("/api/me/phase/next", new { });
response.EnsureSuccessStatusCode();
}
} }

View File

@@ -78,7 +78,7 @@ public class VoteTests
await using var factory = new TestWebApplicationFactory(); await using var factory = new TestWebApplicationFactory();
var client = factory.CreateClientWithCookies(); var client = factory.CreateClientWithCookies();
await client.RegisterAsync("invalid"); await client.RegisterAsync("invalid");
await client.PostAsJsonAsync("/api/me/phase/next", new { }); await client.AdvanceToVoteAsync("Invalid seed");
var resp = await client.PostAsJsonAsync("/api/votes", new var resp = await client.PostAsJsonAsync("/api/votes", new
{ {
@@ -98,7 +98,7 @@ public class VoteTests
await factory.WithDbContextAsync(async db => await factory.WithDbContextAsync(async db =>
{ {
var p = await db.Players.FirstAsync(); var p = await db.Players.SingleAsync();
p.DisplayName = null; p.DisplayName = null;
await db.SaveChangesAsync(); await db.SaveChangesAsync();
}); });
@@ -152,7 +152,7 @@ public class VoteTests
await using var factory = new TestWebApplicationFactory(); await using var factory = new TestWebApplicationFactory();
var admin = factory.CreateClientWithCookies(); var admin = factory.CreateClientWithCookies();
await admin.RegisterAsync("admin", admin: true); await admin.RegisterAsync("admin", admin: true);
await admin.PostAsJsonAsync("/api/me/phase/next", new { }); await admin.AdvanceToVoteAsync("Admin link seed");
var player = factory.CreateClientWithCookies(); var player = factory.CreateClientWithCookies();
await player.RegisterAsync("linker"); await player.RegisterAsync("linker");
@@ -189,7 +189,7 @@ public class VoteTests
await using var factory = new TestWebApplicationFactory(); await using var factory = new TestWebApplicationFactory();
var admin = factory.CreateClientWithCookies(); var admin = factory.CreateClientWithCookies();
await admin.RegisterAsync("admin", admin: true); await admin.RegisterAsync("admin", admin: true);
await admin.PostAsJsonAsync("/api/me/phase/next", new { }); await admin.AdvanceToVoteAsync("Admin chain seed");
var player = factory.CreateClientWithCookies(); var player = factory.CreateClientWithCookies();
await player.RegisterAsync("chain"); await player.RegisterAsync("chain");

View File

@@ -12,7 +12,7 @@ public class AdminOnlyFilter : IEndpointFilter
var player = await EndpointHelpers.GetAuthenticatedPlayer(httpContext, db); var player = await EndpointHelpers.GetAuthenticatedPlayer(httpContext, db);
if (player?.IsAdmin != true) if (player?.IsAdmin != true)
{ {
return Results.Unauthorized(); return EndpointHelpers.UnauthorizedError();
} }
return await next(context); return await next(context);

View File

@@ -16,7 +16,7 @@ public class PhaseOrJokerFilter : IEndpointFilter
var db = httpContext.RequestServices.GetRequiredService<AppDbContext>(); var db = httpContext.RequestServices.GetRequiredService<AppDbContext>();
var player = await EndpointHelpers.GetAuthenticatedPlayer(httpContext, db); var player = await EndpointHelpers.GetAuthenticatedPlayer(httpContext, db);
if (player is null) if (player is null)
return Results.Unauthorized(); return EndpointHelpers.UnauthorizedError();
var phase = await EndpointHelpers.GetCurrentPhaseAsync(db, player.Id); var phase = await EndpointHelpers.GetCurrentPhaseAsync(db, player.Id);
var allow = phase == Phase.Suggest || (phase == Phase.Vote && player.HasJoker); var allow = phase == Phase.Suggest || (phase == Phase.Vote && player.HasJoker);

View File

@@ -12,7 +12,7 @@ public class PhaseRequirementFilter(Phase required, bool allowAdminOverride = fa
var db = httpContext.RequestServices.GetRequiredService<AppDbContext>(); var db = httpContext.RequestServices.GetRequiredService<AppDbContext>();
var player = await EndpointHelpers.GetAuthenticatedPlayer(httpContext, db); var player = await EndpointHelpers.GetAuthenticatedPlayer(httpContext, db);
if (player is null) if (player is null)
return Results.Unauthorized(); return EndpointHelpers.UnauthorizedError();
var phase = await EndpointHelpers.GetCurrentPhaseAsync(db, player.Id); var phase = await EndpointHelpers.GetCurrentPhaseAsync(db, player.Id);
if (phase != required && !(allowAdminOverride && player.IsAdmin)) if (phase != required && !(allowAdminOverride && player.IsAdmin))

View File

@@ -3,6 +3,7 @@ using GameList.Domain;
using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.Cookies; using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Diagnostics; using Microsoft.AspNetCore.Diagnostics;
using Microsoft.AspNetCore.Mvc;
namespace GameList.Infrastructure; namespace GameList.Infrastructure;
@@ -11,6 +12,11 @@ public static class PlayerIdentityExtensions
public const string PlayerCookieName = "player"; public const string PlayerCookieName = "player";
public const string AdminClaim = "is_admin"; public const string AdminClaim = "is_admin";
public const string AdminPolicy = "AdminOnly"; public const string AdminPolicy = "AdminOnly";
private static readonly Action<ILogger, Exception?> LogUnhandledException =
LoggerMessage.Define(
LogLevel.Error,
new EventId(1001, nameof(LogUnhandledException)),
"Unhandled exception");
public static async Task SignInPlayerAsync(HttpContext ctx, Player player) public static async Task SignInPlayerAsync(HttpContext ctx, Player player)
{ {
@@ -39,12 +45,19 @@ public static class PlayerIdentityExtensions
var logger = context.RequestServices.GetRequiredService<ILoggerFactory>().CreateLogger("GlobalException"); var logger = context.RequestServices.GetRequiredService<ILoggerFactory>().CreateLogger("GlobalException");
if (feature?.Error != null) if (feature?.Error != null)
{ {
logger.LogError(feature.Error, "Unhandled exception"); LogUnhandledException(logger, feature.Error);
} }
context.Response.StatusCode = StatusCodes.Status500InternalServerError; context.Response.StatusCode = StatusCodes.Status500InternalServerError;
context.Response.ContentType = "application/json"; context.Response.ContentType = "application/json";
await context.Response.WriteAsJsonAsync(new { error = "Unexpected server error" }); var problem = new ProblemDetails
{
Status = StatusCodes.Status500InternalServerError,
Title = "Internal Server Error",
Detail = "Unexpected server error"
};
problem.Extensions["error"] = "Unexpected server error";
await context.Response.WriteAsJsonAsync(problem);
}); });
}); });
return app; return app;

View File

@@ -2,6 +2,7 @@ using GameList.Data;
using GameList.Endpoints; using GameList.Endpoints;
using GameList.Infrastructure; using GameList.Infrastructure;
using Microsoft.AspNetCore.Authentication.Cookies; using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.DataProtection; using Microsoft.AspNetCore.DataProtection;
using Microsoft.AspNetCore.HttpOverrides; using Microsoft.AspNetCore.HttpOverrides;
using Microsoft.Data.Sqlite; using Microsoft.Data.Sqlite;
@@ -34,6 +35,11 @@ else if (!Path.IsPathRooted(connectionBuilder.DataSource))
var connectionString = connectionBuilder.ToString(); var connectionString = connectionBuilder.ToString();
builder.Services.AddDbContext<AppDbContext>(options => options.UseSqlite(connectionString)); builder.Services.AddDbContext<AppDbContext>(options => options.UseSqlite(connectionString));
builder.Services.AddScoped<SuggestionWorkflowService>();
builder.Services.AddScoped<VoteWorkflowService>();
builder.Services.AddScoped<AdminWorkflowService>();
builder.Services.AddScoped<ResultsWorkflowService>();
builder.Services.AddScoped<StateWorkflowService>();
builder.Services.ConfigureHttpJsonOptions(options => { options.SerializerOptions.Converters.Add(new JsonStringEnumConverter()); }); builder.Services.ConfigureHttpJsonOptions(options => { options.SerializerOptions.Converters.Add(new JsonStringEnumConverter()); });
@@ -50,16 +56,8 @@ builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationSc
options.ExpireTimeSpan = TimeSpan.FromDays(30); options.ExpireTimeSpan = TimeSpan.FromDays(30);
options.Events = new CookieAuthenticationEvents options.Events = new CookieAuthenticationEvents
{ {
OnRedirectToLogin = ctx => OnRedirectToLogin = ctx => WriteUnauthorizedChallengeAsync(ctx.HttpContext),
{ OnRedirectToAccessDenied = ctx => WriteUnauthorizedChallengeAsync(ctx.HttpContext)
ctx.Response.StatusCode = StatusCodes.Status401Unauthorized;
return Task.CompletedTask;
},
OnRedirectToAccessDenied = ctx =>
{
ctx.Response.StatusCode = StatusCodes.Status401Unauthorized;
return Task.CompletedTask;
}
}; };
}); });
@@ -135,6 +133,26 @@ static ForwardedHeadersOptions BuildForwardedHeadersOptions(IConfiguration confi
return options; return options;
} }
static Task WriteUnauthorizedChallengeAsync(HttpContext context)
{
context.Response.StatusCode = StatusCodes.Status401Unauthorized;
if (!context.Request.Path.StartsWithSegments("/api"))
return Task.CompletedTask;
if (context.Response.HasStarted)
return Task.CompletedTask;
context.Response.ContentType = "application/problem+json";
var problem = new ProblemDetails
{
Status = StatusCodes.Status401Unauthorized,
Title = "Unauthorized",
Detail = "Unauthorized",
Extensions = { ["error"] = "Unauthorized" }
};
return context.Response.WriteAsJsonAsync(problem);
}
static void UpdateIndexMetaBase(IWebHostEnvironment env, string basePath) static void UpdateIndexMetaBase(IWebHostEnvironment env, string basePath)
{ {
try try

View File

@@ -13,6 +13,13 @@ Pick'n'Play is a .NET 10 ASP.NET Core Minimal API app with a static HTML/CSS/JS
4. Open: 4. Open:
`http://localhost:5000` (or the URL shown by `dotnet run`) `http://localhost:5000` (or the URL shown by `dotnet run`)
## Frontend Tooling
- Install tooling: `npm install`
- Lint JS: `npm run lint`
- Check formatting: `npm run format:check`
- Apply formatting: `npm run format`
## Core Behavior ## Core Behavior
- Authentication: username/password with HttpOnly `player` cookie. - Authentication: username/password with HttpOnly `player` cookie.
@@ -44,5 +51,6 @@ Pick'n'Play is a .NET 10 ASP.NET Core Minimal API app with a static HTML/CSS/JS
GitHub Actions workflow: `.github/workflows/ci.yml` GitHub Actions workflow: `.github/workflows/ci.yml`
- Restores dependencies - Restores dependencies
- Runs frontend lint and format checks
- Builds with warnings treated as errors - Builds with warnings treated as errors
- Runs `GameList.Tests` - Runs `GameList.Tests`

233
REVIEW.md
View File

@@ -1,231 +1,24 @@
# Maintainability Review - Pick'n'Play # Maintainability Review - Pick'n'Play
## A) Executive summary ## A) Current focus
This codebase is functional and reasonably tested on backend behavior, but long-term change safety is currently limited by concentration risk (god modules), hidden side effects in read paths, and drifting operational contracts. This document tracks only active work. Completed work is intentionally omitted and can be reviewed in git history.
Progress update (as of February 6, 2026): Active maintainability risks (priority order):
- Completed: phase reads are side-effect free with explicit reconciliation on write routes (`Endpoints/EndpointHelpers.cs:37`, `Endpoints/EndpointHelpers.cs:61`, `Endpoints/StateEndpoints.cs:62`).
- Completed: admin auth docs aligned to account-based admin sessions (`API.md:3`).
- Completed: build/test guardrails added (`.github/workflows/ci.yml`) and root ownership/setup docs added (`README.md:1`).
- Completed: backend validators centralized for suggestions and auth (`Endpoints/SuggestionValidator.cs:7`, `Endpoints/AuthValidator.cs:11`).
- Completed: request safety hardened for redirects and forwarded headers (`Program.cs:40`, `Program.cs:104`, `Endpoints/EndpointHelpers.cs:105`, `GameList.Tests/HelperTests.cs:121`, `GameList.Tests/HelperTests.cs:219`).
Top 5 maintainability risks (priority order): - None at the moment.
1. Frontend module concentration and global coupling (Critical) ## B) Active task list
- `wwwroot/js/ui.js` is still the dominant hotspot and owns rendering, validation, modal orchestration, admin flows, and vote logic.
- Hidden module coupling through globals: `wwwroot/js/data.js:131`-`wwwroot/js/data.js:134`, plus `window` callbacks consumed in `wwwroot/js/ui.js:473`, `wwwroot/js/ui.js:696`, `wwwroot/js/ui.js:1009`.
- Impact: hard-to-debug regressions and fragile refactors in UI workflows.
2. Rule duplication still present between backend/frontend validations (High) - None.
- Suggestion validation is centralized on the backend (`Endpoints/SuggestEndpoints.cs:45`, `Endpoints/SuggestEndpoints.cs:133`, `Endpoints/SuggestionValidator.cs:7`) but frontend still duplicates parts (`wwwroot/js/ui.js:648`, `wwwroot/js/ui.js:1019`).
- Auth validation is centralized on the backend (`Endpoints/AuthEndpoints.cs:18`, `Endpoints/AuthEndpoints.cs:65`, `Endpoints/AuthValidator.cs:11`) while frontend length checks remain duplicated (`wwwroot/app.js:92`, `wwwroot/app.js:121`).
- Impact: inconsistent behavior and repeated fixes across server/client.
3. High-change, high-complexity frontend hotspots (High) ## C) Suggested execution order
- Git churn: `wwwroot/app.js` (76 changes), `wwwroot/js/ui.js` (55), `wwwroot/js/i18n.js` (50).
- `wwwroot/js/ui.js` is 1123 lines and owns rendering, validation, modal orchestration, admin flows, and vote logic.
- Hidden module coupling through globals: `wwwroot/js/data.js:131`-`wwwroot/js/data.js:134`, plus `window` callbacks consumed in `wwwroot/js/ui.js:473`, `wwwroot/js/ui.js:696`, `wwwroot/js/ui.js:1009`.
- Impact: every UI change risks regressions outside its feature area.
4. Service-layer extraction is still pending in large endpoint files (High) 1. Add new items when fresh risks are identified.
- Endpoint lambdas still own orchestration and persistence logic in `Endpoints/SuggestEndpoints.cs`, `Endpoints/AdminEndpoints.cs`, `Endpoints/VoteEndpoints.cs`, and `Endpoints/ResultsEndpoints.cs`.
- Impact: high cognitive load and slower, riskier feature changes.
5. Static-analysis and frontend lint guardrails remain incomplete (Medium) ## D) Guardrails
- Build/test CI exists (`.github/workflows/ci.yml`) and project content rules are fixed (`GameList.csproj:17`-`GameList.csproj:21`), but analyzers/lint/format gates are still absent.
- Impact: regressions and style drift can still slip through.
Where to start (recommended sequence): - Keep endpoint handlers transport-focused and move business rules into services/validators.
- Start with P0 tasks that reduce surprise and operational risk: phase-read side effects, auth contract drift, build hygiene, and validation centralization. - Keep reads side-effect free and isolate all persistence changes to explicit command paths.
- Apply these guiding principles: - Maintain one source of truth per validation rule (backend authoritative, frontend UX hints only).
- Keep reads pure and writes explicit. - Prefer typed DTOs over anonymous response shapes for non-trivial API payloads.
- Consolidate business rules in one place per rule.
- Reduce module fan-in/fan-out before adding features.
- Add small guardrails (build warnings, CI gates) before larger refactors.
Assumptions and validation:
- Assumption A1: local git history reflects meaningful churn/risk. Validate by comparing with remote `main`/PR stats if different branch practices exist.
- Assumption A2: build warnings are reproducible environment signals, not one-off file locks. Validate by running clean build in CI and on a fresh clone.
## B) Maintainability map
Major modules/components and responsibilities:
- `Program.cs`: app bootstrap, infrastructure wiring, middleware order, DB migration on startup, route registration.
- `Endpoints/*.cs`: HTTP endpoint definitions and most business logic (auth, phase, suggestions, votes, results, admin).
- `Endpoints/EndpointHelpers.cs`: cross-cutting helper hub (auth lookup, phase alignment, URL checks, image probing, link graph utilities).
- `Infrastructure/*.cs`: filters/middleware/auth helpers (`AdminOnlyFilter`, phase filters, cookie identity helpers, global exception wrapper).
- `Data/AppDbContext.cs`: EF Core model and persistence mappings.
- `Domain/*.cs`: entity data structures (`Player`, `Suggestion`, `Vote`, `AppState`, `Phase`).
- `wwwroot/app.js` + `wwwroot/js/*.js`: frontend orchestration, shared state, API calls, rendering, i18n, effects.
- `GameList.Tests/*.cs`: integration-heavy endpoint tests plus helper/unit tests.
- `scripts/*.ps1`: deployment automation.
Boundary quality:
- Backend boundaries are leaky: endpoint layer owns domain rules, persistence orchestration, and security checks directly.
- `Infrastructure` depends on `Endpoints` (`Infrastructure/PhaseRequirementFilter.cs:3`) while `Endpoints` depend back on `Infrastructure` (`Endpoints/EndpointHelpers.cs:22`), creating conceptual circular ownership.
- Frontend boundaries are leaky: `ui.js` mixes rendering, form validation, and server mutation calls; shared mutable state is directly mutated across modules.
Worst coupling points:
- `Endpoints/EndpointHelpers.cs` (84 call sites): de facto god module.
- `wwwroot/js/ui.js` + global `state` object (131 direct state references across JS modules).
- Suggestion and phase workflows span endpoint functions, helper functions, filters, and duplicated client-side checks.
- Operational behavior is split across `API.md` and runtime filters with contradictory contracts.
## C) Critical task list
[P0][Done] Make phase reads side-effect free and move reconciliation to explicit writes
- Problem: Severity `Critical`, Category `Architecture`. Read endpoints/filters previously relied on mutating phase reads. Impact: unsafe refactors and non-deterministic behavior.
- Evidence: `Endpoints/EndpointHelpers.cs:37`, `Endpoints/EndpointHelpers.cs:61`, `Endpoints/StateEndpoints.cs:20`, `Infrastructure/PhaseRequirementFilter.cs:17`, `Endpoints/ResultsEndpoints.cs:26`, `GameList.Tests/StateTests.cs:236`, `GameList.Tests/FiltersTests.cs:55`.
- Recommendation: Split into `GetCurrentPhaseAsync` (pure read) and explicit `ReconcilePhaseAsync` (write command). Run reconciliation only on intentional transition points (admin toggle, phase change commands, migration job), not on GET paths.
- Acceptance criteria (testable): GET `/api/state` and GET `/api/me` never call `SaveChangesAsync`; integration tests verify no phase mutations occur during read-only requests; filters perform one phase check path without side effects.
- Effort / Risk: `M / Med`.
- Dependencies (if any): none.
[P0][Done] Repair admin auth contract drift across code, docs, and smoke automation
- Problem: Severity `High`, Category `Documentation/Tooling`. Documentation and smoke script specify header-based admin auth, runtime requires authenticated admin cookie. Impact: runbooks are misleading during incidents/releases.
- Evidence: `API.md:3`, `Infrastructure/AdminOnlyFilter.cs:12`.
- Recommendation: Make one contract authoritative (account-based admin role), update docs to follow it, and add one integration smoke test path that validates real auth flow.
- Acceptance criteria (testable): `API.md` no longer references `X-Admin-Key`; one automated test verifies admin route rejection/acceptance behavior.
- Effort / Risk: `S / Low`.
- Dependencies (if any): none.
[P0][Done] Eliminate build instability and warning noise from project layout/content rules
- Problem: Severity `High`, Category `Tooling`. Current build emits MSB3026 warnings and recursive copy paths under test output, reducing trust in build outputs and masking real issues.
- Evidence: content exclusions now include `Content` in `GameList.csproj:17`-`GameList.csproj:21`; CI gate added in `.github/workflows/ci.yml`.
- Recommendation: Ensure test assets are fully excluded from web content pipeline (or move tests outside web project root), then enforce clean build (`0 warnings`) in CI.
- Acceptance criteria (testable): `dotnet build GameList.sln` emits zero warnings; publish output contains only expected runtime artifacts; CI fails on warning regressions.
- Effort / Risk: `M / Med`.
- Dependencies (if any): none.
[P0][Partial] Centralize validation rules to stop backend/frontend drift
- Problem: Severity `High`, Category `Complexity/Duplication`. Validation rules are duplicated in multiple backend endpoints and frontend forms. Impact: inconsistent behavior and repeated fixes.
- Evidence: backend centralized in `Endpoints/SuggestEndpoints.cs:45`, `Endpoints/SuggestEndpoints.cs:133`, `Endpoints/SuggestionValidator.cs:7`, `Endpoints/AuthEndpoints.cs:18`, `Endpoints/AuthEndpoints.cs:65`, `Endpoints/AuthValidator.cs:11`; frontend duplicates remain in `wwwroot/js/ui.js:648`, `wwwroot/js/ui.js:1019`, `wwwroot/app.js:92`.
- Recommendation: Extract backend validators (e.g., `SuggestionValidator`, `AuthValidator`) and reuse in create/update paths; simplify frontend to UX-only prechecks and rely on server responses for source-of-truth.
- Acceptance criteria (testable): create/update share one backend validator path; tests cover validator once and both endpoints; frontend no longer re-implements server-only security rules.
- Effort / Risk: `M / Med`.
- Dependencies (if any): none.
[P0][Done] Harden request safety defaults (forwarded headers and redirect handling)
- Problem: Severity `High`, Category `Security`. Forwarded headers are trusted without explicit proxy/network allowlist, and image validation likely follows redirects despite "no redirects" policy.
- Evidence: `Program.cs:40`, `Program.cs:70`, `Program.cs:104`, `Endpoints/EndpointHelpers.cs:105`, `GameList.Tests/HelperTests.cs:121`, `GameList.Tests/HelperTests.cs:219`, `IIS.md:17`.
- Recommendation: Configure known proxies/networks for forwarded headers; enforce `AllowAutoRedirect = false` in image validation client and add tests for redirect-chain and private-host edge cases.
- Acceptance criteria (testable): integration tests prove redirected URLs are rejected; forwarded header spoofing test fails when source is untrusted; documentation updated with trusted proxy requirements.
- Effort / Risk: `M / Med`.
- Dependencies (if any): none.
[P1] Extract service-layer workflows from endpoint lambdas
- Problem: Severity `High`, Category `Architecture`. Endpoint files contain business orchestration, persistence, and policy logic inline; large lambdas are hard to reason about and reuse.
- Evidence: `Endpoints/SuggestEndpoints.cs:43`, `Endpoints/AdminEndpoints.cs:105`, `Endpoints/VoteEndpoints.cs:35`, `Endpoints/ResultsEndpoints.cs:30`.
- Recommendation: Introduce focused application services (`SuggestionService`, `VoteService`, `AdminWorkflowService`) and keep endpoints as transport adapters.
- Acceptance criteria (testable): endpoint handlers reduced to routing + DTO mapping + service calls; domain rule tests target service methods directly; endpoint tests remain green.
- Effort / Risk: `L / Med`.
- Dependencies (if any): P0 phase-read cleanup recommended first.
[P1] Decompose frontend UI monolith and remove `window` cross-module hooks
- Problem: Severity `High`, Category `Architecture/Complexity`. UI logic is concentrated in `ui.js`; global `window` callbacks hide dependencies and increase accidental complexity.
- Evidence: `wwwroot/js/ui.js:390`, `wwwroot/js/data.js:131`, `wwwroot/js/data.js:134`, `wwwroot/js/ui.js:473`, `wwwroot/js/ui.js:1009`.
- Recommendation: Split UI by feature (`suggestions-ui`, `votes-ui`, `admin-ui`, `modals-ui`) and use explicit imports/events instead of `window` globals.
- Acceptance criteria (testable): no feature code depends on `window.refreshPhaseData`/`window.loadVoteData`; module boundaries documented; smoke behavior unchanged.
- Effort / Risk: `L / Med`.
- Dependencies (if any): none.
[P1] Replace uncontrolled polling with serialized refresh scheduling
- Problem: Severity `Medium`, Category `Reliability/Complexity`. A fixed 4-second interval can overlap async refreshes and race state updates.
- Evidence: `wwwroot/app.js:293`-`wwwroot/app.js:297`, `wwwroot/js/data.js:82`.
- Recommendation: Add a scheduler that avoids overlapping refreshes (single-flight), pauses when tab hidden, and supports explicit event-triggered refresh.
- Acceptance criteria (testable): at most one in-flight refresh at any time; no duplicate toasts/state flicker during slow network simulation; tests for scheduler behavior.
- Effort / Risk: `M / Low`.
- Dependencies (if any): none.
[P1] Remove legacy/dead paths to reduce cognitive load
- Problem: Severity `Medium`, Category `Other`. Legacy `Reveal` phase and dead UI hooks remain in active code, increasing confusion.
- Evidence: `Domain/Phase.cs:6`, `Endpoints/StateEndpoints.cs:107`, `wwwroot/js/data.js:30`, `wwwroot/js/ui.js:156`, `wwwroot/js/ui.js:1191`.
- Recommendation: Remove obsolete phase enum/value handling and dead UI references (`all-suggestions`, `nav-vote-next`).
- Acceptance criteria (testable): no references to removed phase/UI ids remain; tests validate expected phase transitions only (`Suggest`, `Vote`, `Results`).
- Effort / Risk: `S / Low`.
- Dependencies (if any): P0 phase cleanup.
[P1] Make write workflows transaction-consistent and explicit
- Problem: Severity `Medium`, Category `Correctness/Architecture`. Several multi-step state changes rely on multiple DB commands without explicit transaction grouping.
- Evidence: `Endpoints/SuggestEndpoints.cs:103`-`Endpoints/SuggestEndpoints.cs:109`, `Endpoints/AdminEndpoints.cs:16`-`Endpoints/AdminEndpoints.cs:31`, `Endpoints/AdminEndpoints.cs:220`-`Endpoints/AdminEndpoints.cs:229`.
- Recommendation: Wrap multi-entity updates in explicit transactions where consistency matters, or refactor into idempotent command handlers with compensating behavior.
- Acceptance criteria (testable): fault-injection tests prove no partial state after exceptions; transaction boundaries documented per workflow.
- Effort / Risk: `M / Med`.
- Dependencies (if any): service-layer extraction helpful but not required.
[P1] Strengthen test quality for flaky/time-sensitive cases and security edges
- Problem: Severity `Medium`, Category `Testing`. Some tests depend on sleeps and do not cover realistic redirect behavior or overlapping refresh flows.
- Evidence: `GameList.Tests/SuggestionTests.cs:379`, `GameList.Tests/SuggestionTests.cs:575`, `GameList.Tests/HelperTests.cs:121`.
- Recommendation: replace `Task.Delay` ordering checks with deterministic seeded timestamps where feasible; add explicit redirect-follow tests and concurrency-path tests.
- Acceptance criteria (testable): no timing sleeps in endpoint tests for ordering; new tests cover redirect-chain rejection and race-sensitive refresh logic.
- Effort / Risk: `M / Low`.
- Dependencies (if any): P0 redirect-hardening task.
[P2] Externalize i18n/FAQ content from executable JS modules
- Problem: Severity `Low`, Category `Complexity/Documentation`. Translation and FAQ payloads are embedded in code, making review and localization hard.
- Evidence: `wwwroot/js/i18n.js:1`-`wwwroot/js/i18n.js:799`.
- Recommendation: move translation dictionaries and FAQ markdown into versioned JSON/MD assets, load via data module, keep code focused on i18n mechanics.
- Acceptance criteria (testable): `i18n.js` contains behavior only; language assets are schema-validated; app renders identical strings.
- Effort / Risk: `M / Low`.
- Dependencies (if any): frontend module split helps.
[P2] Improve repository-level engineering docs and ownership map
- Problem: Severity `Low`, Category `Documentation`. There is no root README/architecture map/runbook tying module ownership, local setup, and deployment flow together.
- Evidence: root README absent; operational docs fragmented across `API.md`, `SPEC.md`, `IIS.md`, `scripts/deploy-ftp.ps1`.
- Recommendation: add concise `README.md` with architecture diagram, local run/test commands, operational boundaries, and links to deeper docs.
- Acceptance criteria (testable): new contributors can run app + tests using README only; docs align with live auth/deploy behavior.
- Effort / Risk: `S / Low`.
- Dependencies (if any): P0 auth contract and build hygiene fixes.
## D) Quick wins vs strategic refactors
Quick wins (hours to 1 day each):
- Remove stale admin key wording in `API.md` and align with actual auth behavior.
- Add `Content Remove="GameList.Tests\**\*"` (or equivalent) and verify clean build output.
- Add a build check script that fails on warnings and run it locally/CI.
- Completed: removed dead `DeletePlayerRequest` from `Contracts/Dtos.cs`.
- Completed: removed unused endpoint handler parameters in `Endpoints/StateEndpoints.cs`.
- Remove dead UI references (`all-suggestions`, `nav-vote-next`) or add explicit TODO with owner/date.
- Replace `Task.Delay` test ordering hacks with deterministic setup in affected tests.
- Completed: added module ownership section in `README.md`.
Strategic refactors (multi-day/week), staged:
1) Phase and policy workflow refactor
- Stage 1: introduce pure phase reader API and separate reconciliation command.
- Stage 2: migrate filters/endpoints to pure reads; remove write side effects from GET.
- Stage 3: enforce via tests that read endpoints do not mutate persistence.
2) Backend application service extraction
- Stage 1: extract suggestion create/update/delete logic to one service.
- Stage 2: extract vote/link/unlink workflows with explicit transaction semantics.
- Stage 3: standardize endpoint responses with typed DTOs/ProblemDetails.
3) Frontend architecture split
- Stage 1: split `ui.js` into modal/forms/vote/admin render modules without behavior changes.
- Stage 2: remove `window.*` bridges and introduce explicit orchestration module.
- Stage 3: add lightweight frontend tests for state transitions and API error handling.
4) Engineering guardrail rollout
- Stage 1: introduce analyzer/lint config and formatting scripts.
- Stage 2: add CI pipeline gates (build, test, coverage, vulnerability check).
- Stage 3: enable warning budgets and file-size complexity thresholds for hotspot files.
## E) Suggested guardrails
Recommended tooling and standards:
- C# static analysis: enable .NET analyzers (`AnalysisLevel latest`), nullable warnings strict, and treat warnings as errors in CI.
- JS quality: add ESLint + Prettier for `wwwroot/**/*.js` and fail CI on lint errors.
- Contract safety: standardize error responses using RFC7807 `ProblemDetails` and validate API DTO schema.
- CI gates: run `dotnet build`, `dotnet test GameList.Tests/GameList.Tests.csproj`, coverage collection, and `dotnet list ... --vulnerable`.
- Churn guardrails: add a simple check warning when files exceed thresholds (e.g., >500 LOC for JS UI modules, >250 LOC endpoint files).
- Documentation gate: PR checklist requires updates to `API.md`/runbooks when auth/endpoints/deploy scripts change.
- Security defaults: require known proxy config for forwarded headers and explicit no-redirect HTTP client behavior for external URL validation.
Pragmatic coding standards to prevent backsliding:
- Keep endpoint handlers transport-focused; place business rules in services/validators.
- Keep reads side-effect free; state changes only in explicit commands.
- Avoid duplicate rule definitions across server/client; one source of truth per rule.
- Prefer typed DTOs over anonymous response shapes for non-trivial payloads.

10
SPEC.md
View File

@@ -10,12 +10,16 @@ Help a small Discord group (48 players) pick a co-op game via phased flow:
- Single shared instance - Single shared instance
- Username/password login (cookie auth) - Username/password login (cookie auth)
- Admins flagged via admin key at registration - Admins flagged via admin key at registration
- Per-user phase tracking; admins can move themselves backward, everyone can move forward (subject to admin “results open” toggle) - Logout returns to the login form and clears all auth form fields
- Destructive admin actions (player delete, reset, factory reset) require admin password confirmation
- Per-user phase tracking; admins can move themselves backward, everyone can move forward (subject to admin “results open” toggle and Suggest→Vote requiring at least one own suggestion)
## Suggest Phase ## Suggest Phase
- Up to **5 suggestions** per player - Up to **5 suggestions** per player
- Name required; optional genre, description, screenshot URL, YouTube URL, external game link, min/max players - Name required; optional genre, description, screenshot URL, YouTube URL, external game link, min/max players
- Players see only their own suggestions until voting - Players see only their own suggestions until voting
- A player can enter Vote only after submitting at least one own suggestion
- The Suggest phase shows a non-interactive “add a game first” hint until the first successful suggestion, then immediately shows the `Next` button
- Screenshots validated as reachable images - Screenshots validated as reachable images
## Vote Phase ## Vote Phase
@@ -24,10 +28,14 @@ Help a small Discord group (48 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
## Results Phase ## Results Phase
- Visible only after admin enables results; players auto-advance when opened - Visible only after admin enables results; players auto-advance when opened
- Admin controls results availability with a single toggle button whose label reflects enabled/disabled state
- Leaderboard sorted by average score; shows totals, counts, players own vote, and links/media - Leaderboard sorted by average score; shows totals, counts, players own vote, and links/media
- When results are closed again, only accounts with at least one suggestion return to Vote; accounts without suggestions return to Suggest
## Non-functional ## Non-functional
- Desktop + mobile friendly - Desktop + mobile friendly

View File

@@ -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 010, 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 010, 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,11 +68,12 @@ 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.
- DELETE /admin/players/{id}: removes player, cascades suggestions, breaks links to their suggestions, deletes related votes, wrapped in transaction. - POST /admin/player-phase allows Vote->Suggest transitions only; rejects other targets/current phases; clears target VotesFinal.
- DELETE /admin/players/{id}: requires valid admin password; 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.
- POST /admin/reset: wipes suggestions/votes, resets phases to Suggest, clears votesFinal/hasJoker, closes results, updates timestamp. - POST /admin/reset: requires valid admin password; wipes suggestions/votes, resets phases to Suggest, clears votesFinal/hasJoker, closes results, updates timestamp.
- POST /admin/factory-reset: wipes all players/suggestions/votes/state; reseeds AppState with defaults; transactional. - POST /admin/factory-reset: requires valid admin password; wipes all players/suggestions/votes/state; reseeds AppState with defaults; transactional.
### 7) Infrastructure/Helpers ### 7) Infrastructure/Helpers
- PasswordHasher: hash+verify roundtrip, rejects empty password, constant-time compare (FixedTimeEquals usage). - PasswordHasher: hash+verify roundtrip, rejects empty password, constant-time compare (FixedTimeEquals usage).

View File

@@ -1,7 +1,7 @@
{ {
"Logging": { "Logging": {
"LogLevel": { "LogLevel": {
"Default": "Information", "Default": "Warning",
"Microsoft.AspNetCore": "Warning" "Microsoft.AspNetCore": "Warning"
} }
}, },

21
eslint.config.js Normal file
View File

@@ -0,0 +1,21 @@
import js from "@eslint/js";
import globals from "globals";
export default [
{
files: ["wwwroot/**/*.js"],
...js.configs.recommended,
languageOptions: {
...js.configs.recommended.languageOptions,
ecmaVersion: 2024,
sourceType: "module",
globals: {
...globals.browser,
},
},
rules: {
...js.configs.recommended.rules,
"no-unused-vars": ["error", { argsIgnorePattern: "^_" }],
},
},
];

1101
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

16
package.json Normal file
View File

@@ -0,0 +1,16 @@
{
"name": "picknplay-frontend",
"private": true,
"type": "module",
"scripts": {
"lint": "eslint \"wwwroot/**/*.js\"",
"format": "prettier --write \"eslint.config.js\" \"wwwroot/js/i18n.js\" \"wwwroot/js/{admin-ui,app-admin-handlers,app-auth-handlers,app-vote-nav-handlers,auth-ui,modals-ui,results-ui,suggestions-ui,ui-runtime,ui-utils,ui,votes-ui}.js\"",
"format:check": "prettier --check \"eslint.config.js\" \"wwwroot/js/i18n.js\" \"wwwroot/js/{admin-ui,app-admin-handlers,app-auth-handlers,app-vote-nav-handlers,auth-ui,modals-ui,results-ui,suggestions-ui,ui-runtime,ui-utils,ui,votes-ui}.js\""
},
"devDependencies": {
"@eslint/js": "9.21.0",
"eslint": "9.21.0",
"globals": "15.15.0",
"prettier": "3.5.0"
}
}

67
scripts/ci-local.ps1 Normal file
View File

@@ -0,0 +1,67 @@
param(
[switch]$SkipNpmInstall,
[switch]$SkipDotnetRestore,
[switch]$SkipBuild
)
Set-StrictMode -Version Latest
$ErrorActionPreference = "Stop"
function Invoke-Step {
param(
[Parameter(Mandatory = $true)][string]$Name,
[Parameter(Mandatory = $true)][scriptblock]$Action
)
Write-Host "==> $Name"
& $Action
if ($LASTEXITCODE -ne 0) {
throw "Step failed: $Name"
}
}
$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
$repoRoot = Split-Path -Parent $scriptDir
Push-Location $repoRoot
try {
if (-not $SkipNpmInstall) {
Invoke-Step -Name "Install frontend tooling (npm install)" -Action {
npm install
}
}
Invoke-Step -Name "Lint frontend" -Action {
npm run lint
}
Invoke-Step -Name "Check frontend formatting" -Action {
npm run format:check
}
if (-not $SkipDotnetRestore) {
Invoke-Step -Name "Restore .NET solution" -Action {
dotnet restore GameList.sln
}
}
if (-not $SkipBuild) {
Invoke-Step -Name "Build .NET solution (warnings as errors)" -Action {
dotnet build GameList.sln --no-restore -warnaserror
}
}
Invoke-Step -Name "Run tests" -Action {
if ($SkipBuild) {
dotnet test GameList.Tests/GameList.Tests.csproj --verbosity normal
}
else {
dotnet test GameList.Tests/GameList.Tests.csproj --no-build --verbosity normal
}
}
Write-Host "CI checks passed."
}
finally {
Pop-Location
}

View File

@@ -1,10 +1,7 @@
import { api, adminApi } from "./js/api.js";
import { t, setLanguage, getLanguage, initI18n, onLanguageChange, faqMarkdown } from "./js/i18n.js"; import { t, setLanguage, getLanguage, initI18n, onLanguageChange, faqMarkdown } from "./js/i18n.js";
import { state, clearUserState, getSavedUsername, setSavedUsername } from "./js/state.js"; import { state, clearUserState } from "./js/state.js";
import { $, toast } from "./js/dom.js"; import { toast } from "./js/dom.js";
import { import {
setAuthUI,
setAuthMode,
handleAuthError, handleAuthError,
renderWelcome, renderWelcome,
renderPhasePill, renderPhasePill,
@@ -15,53 +12,75 @@ import {
syncVoteScores, syncVoteScores,
renderResults, renderResults,
renderPhaseTitles, renderPhaseTitles,
openNewSuggestionModal,
updatePhaseNav, updatePhaseNav,
openConfirmModal, configureUiRuntime,
openResultsRelockModal,
} from "./js/ui.js"; } from "./js/ui.js";
import { import {
loadState,
loadSuggestData, loadSuggestData,
loadRevealData,
loadVoteData, loadVoteData,
loadResults,
refreshPhaseData, refreshPhaseData,
} from "./js/data.js"; } from "./js/data.js";
initI18n(); import { setupAuthHandlers } from "./js/app-auth-handlers.js";
import { setupAdminHandlers } from "./js/app-admin-handlers.js";
import { setupVoteNavigationHandlers } from "./js/app-vote-nav-handlers.js";
function setupHandlers() { const REFRESH_INTERVAL_MS = 4000;
const toggleAuth = $("auth-toggle"); let refreshInFlight = null;
if (toggleAuth) { let refreshTimerId = null;
toggleAuth.addEventListener("click", (e) => { let refreshSchedulerStarted = false;
e.preventDefault();
setAuthMode(state.authMode === "login" ? "register" : "login"); async function runSerializedRefresh() {
}); if (refreshInFlight) return refreshInFlight;
refreshInFlight = refreshPhaseData().finally(() => {
refreshInFlight = null;
});
return refreshInFlight;
}
async function refreshWithUiErrorHandling() {
try {
await runSerializedRefresh();
} catch (err) {
if (!handleAuthError(err, clearUserState)) toast(err.message, true);
} }
setAuthMode(state.authMode); }
const hasConsent = () => document.cookie.split(";").some((c) => c.trim().startsWith("cookie_consent=1")); function scheduleNextRefresh() {
const setConsent = () => { document.cookie = "cookie_consent=1; path=/; max-age=31536000; SameSite=Lax"; }; refreshTimerId = window.setTimeout(async () => {
const consentRows = document.querySelectorAll(".consent-row"); if (!document.hidden && !state.adminStatusSelectActive) {
const toggleConsentRows = () => { await refreshWithUiErrorHandling();
const hide = hasConsent(); }
consentRows.forEach((row) => row.classList.toggle("hidden", hide)); scheduleNextRefresh();
}; }, REFRESH_INTERVAL_MS);
toggleConsentRows(); }
["login-consent", "register-consent"].forEach((id) => {
const box = $(id); function startRefreshScheduler() {
if (box) { if (refreshSchedulerStarted) return;
box.checked = hasConsent(); refreshSchedulerStarted = true;
document.addEventListener("visibilitychange", () => {
if (!document.hidden && !state.adminStatusSelectActive) {
refreshWithUiErrorHandling();
} }
}); });
const loginUser = $("login-username"); if (refreshTimerId !== null) {
if (loginUser) { window.clearTimeout(refreshTimerId);
const markEditing = () => { loginUser.dataset.userEditing = "1"; };
["focus", "input", "keydown"].forEach((evt) => loginUser.addEventListener(evt, markEditing));
loginUser.addEventListener("blur", () => { delete loginUser.dataset.userEditing; });
} }
scheduleNextRefresh();
}
configureUiRuntime({
refreshPhaseData: runSerializedRefresh,
loadSuggestData,
loadVoteData,
handleAuthError: (err) => handleAuthError(err, clearUserState),
});
function setupHandlers() {
setupAuthHandlers({ runSerializedRefresh });
setupAdminHandlers({ runSerializedRefresh });
setupVoteNavigationHandlers({ runSerializedRefresh });
setupLanguageSwitchers(); setupLanguageSwitchers();
onLanguageChange(() => { onLanguageChange(() => {
@@ -83,218 +102,16 @@ function setupHandlers() {
updatePhaseNav(); updatePhaseNav();
}); });
const loginForm = $("login-form");
if (loginForm) {
loginForm.addEventListener("submit", async (e) => {
e.preventDefault();
const username = $("login-username").value.trim();
const password = $("login-password").value;
if (username.length > 24) return toast("Username must be 24 characters or fewer.", true);
if (!username || !password) return toast(t("auth.needCredentials"), true);
if (!hasConsent() && !$("login-consent")?.checked) return toast(t("auth.cookieRequired"), true);
try {
await api.login({ username, password });
setConsent();
toggleConsentRows();
setSavedUsername(username);
state.isAuthenticated = true;
setAuthUI(true);
await refreshPhaseData();
toast(t("toast.loggedIn"));
} catch (err) {
if (err?.status === 401) return toast(t("auth.invalidCredentials"), true);
if (handleAuthError(err, clearUserState)) return;
}
});
}
const registerForm = $("register-form");
if (registerForm) {
registerForm.addEventListener("submit", async (e) => {
e.preventDefault();
const username = $("register-username").value.trim();
const password = $("register-password").value;
const displayName = $("register-displayName").value.trim();
const adminKey = $("register-adminkey").value.trim();
if (!displayName) return toast(t("toast.displayNameRequired") || "Display name is required.", true);
if (username.length > 24) return toast("Username must be 24 characters or fewer.", true);
if (displayName.length > 16) return toast("Display name must be 16 characters or fewer.", true);
if (!username || !password) return toast(t("auth.needCredentials"), true);
if (!hasConsent() && !$("register-consent")?.checked) return toast(t("auth.cookieRequired"), true);
try {
await api.register({ username, password, displayName, adminKey });
setConsent();
toggleConsentRows();
setSavedUsername(username);
state.isAuthenticated = true;
setAuthUI(true);
await refreshPhaseData();
toast(t("toast.registered"));
} catch (err) {
if (handleAuthError(err, clearUserState)) return;
toast(err.message, true);
}
});
}
const openSuggestBtn = $("open-suggest-modal");
if (openSuggestBtn) {
openSuggestBtn.addEventListener("click", (e) => {
e.preventDefault();
if (openSuggestBtn.disabled) return;
if (state.phase !== "Suggest") return;
openNewSuggestionModal();
});
}
const openJokerBtn = $("open-joker-modal");
if (openJokerBtn) {
openJokerBtn.addEventListener("click", (e) => {
e.preventDefault();
if (state.phase !== "Vote" || !state.hasJoker) return;
openNewSuggestionModal();
});
}
bindNavButtons();
$("reset").addEventListener("click", () => adminAction(adminApi.reset, t("admin.resetDone")));
$("factory-reset").addEventListener("click", () => adminAction(adminApi.factoryReset, t("admin.factoryResetDone")));
const logoutBtn = $("logout");
if (logoutBtn) {
logoutBtn.addEventListener("click", async (e) => {
e.preventDefault();
const lastUser = state.me?.username;
try {
await api.logout();
} catch (err) {
toast(err.message, true);
}
clearUserState();
state.isAuthenticated = false;
setAuthUI(false);
if (lastUser) {
setSavedUsername(lastUser);
const loginUser = $("login-username");
if (loginUser) loginUser.value = lastUser;
const loginPass = $("login-password");
if (loginPass) loginPass.value = "";
}
});
}
const adminToggle = $("admin-toggle");
const adminCard = $("admin-card");
const adminClose = $("admin-close");
if (adminToggle && adminCard && adminClose) {
const togglePanel = (show) => adminCard.classList.toggle("hidden", !show);
adminToggle.addEventListener("click", () => togglePanel(adminCard.classList.contains("hidden")));
adminClose.addEventListener("click", () => togglePanel(false));
}
document.querySelectorAll(".help-chip").forEach((chip) => { document.querySelectorAll(".help-chip").forEach((chip) => {
chip.addEventListener("click", () => openFaqModal()); chip.addEventListener("click", () => openFaqModal());
}); });
const resultsToggle = $("results-open");
if (resultsToggle) {
resultsToggle.addEventListener("change", async (e) => {
const desired = !!e.target.checked;
try {
const resp = await adminApi.setResultsOpen(desired);
const wasResultsOpen = state.resultsOpen;
const wasPhase = state.phase;
state.resultsOpen = resp.resultsOpen;
if (wasResultsOpen && !resp.resultsOpen && wasPhase === "Results") {
openResultsRelockModal();
}
renderPhasePill();
toast(t("admin.resultsUpdated"));
await refreshPhaseData();
} catch (err) {
e.target.checked = !desired;
toast(err.message, true);
}
});
}
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);
}
});
}
const playerTable = $("admin-player-table");
if (playerTable) {
playerTable.addEventListener("click", async (e) => {
const grantBtn = e.target.closest("[data-grant-joker]");
const deleteBtn = e.target.closest("[data-delete-player]");
if (grantBtn) {
const playerId = grantBtn.dataset.grantJoker;
try {
await adminApi.grantJoker(playerId);
toast(t("admin.jokerGranted"));
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);
}
},
});
}
});
}
}
async function adminAction(fn, successMessage) {
try {
await fn();
toast(successMessage);
await refreshPhaseData();
} catch (err) {
toast(err.message, true);
}
} }
async function main() { async function main() {
await initI18n();
setupHandlers(); setupHandlers();
try { await refreshWithUiErrorHandling();
await refreshPhaseData(); startRefreshScheduler();
} catch (err) {
toast(err.message, true);
}
setInterval(() => {
refreshPhaseData().catch((err) => {
if (!handleAuthError(err, clearUserState)) toast(err.message, true);
});
}, 4000);
} }
main(); main();
@@ -338,103 +155,6 @@ function setupLanguageSwitchers() {
updateLanguageButtons(); updateLanguageButtons();
} }
function bindNavButtons() {
const makeForward = (id, before) => {
const btn = $(id);
if (!btn) return;
btn.addEventListener("click", async () => {
try {
if (before) {
const proceed = await before();
if (!proceed) return;
}
const resp = await api.nextPhase();
state.prevPhase = state.phase;
state.phase = resp.currentPhase;
state.resultsOpen = resp.resultsOpen ?? state.resultsOpen;
state.votesRendered = false;
renderPhasePill();
await refreshPhaseData();
} catch (err) {
toast(err.message, true);
}
});
};
const makeBack = (id) => {
const btn = $(id);
if (!btn) return;
btn.addEventListener("click", async () => {
try {
const resp = await api.prevPhase();
state.prevPhase = state.phase;
state.phase = resp.currentPhase;
state.resultsOpen = resp.resultsOpen ?? state.resultsOpen;
state.votesRendered = false;
renderPhasePill();
await refreshPhaseData();
} catch (err) {
toast(err.message, true);
}
});
};
makeForward("nav-suggest-next", async () => {
return await new Promise((resolve) => {
openConfirmModal({
title: t("nav.freezeModalTitle"),
body: t("nav.freezeModalBody"),
confirmLabel: t("nav.next"),
onConfirm: (close) => {
close();
resolve(true);
},
});
});
});
makeBack("nav-vote-prev");
const finalizeBtn = $("finalize-votes");
if (finalizeBtn) {
const finalizeVotes = async (desired) => {
await api.finalizeVotes(desired);
state.votesFinal = desired;
renderPhasePill();
renderVotes();
toast(desired ? t("vote.finalize") : t("vote.unfinalize"));
};
const missingVotes = () => {
const votedIds = new Set((state.myVotes ?? []).map((v) => v.suggestionId));
return (state.allSuggestions ?? []).filter((s) => !votedIds.has(s.id));
};
finalizeBtn.addEventListener("click", async () => {
try {
const desired = !state.votesFinal;
if (desired) {
const missing = missingVotes();
if (missing.length > 0) {
openConfirmModal({
title: t("vote.finalizeMissingTitle"),
body: t("vote.finalizeMissingBody", { count: missing.length }),
confirmLabel: t("vote.finalizeMissingConfirm"),
onConfirm: async (close) => {
await finalizeVotes(desired);
close();
},
});
return;
}
}
await finalizeVotes(desired);
} catch (err) {
toast(err.message, true);
}
});
}
}
function markdownToHtml(md) { function markdownToHtml(md) {
const lines = md.trim().split(/\r?\n/); const lines = md.trim().split(/\r?\n/);
const html = []; const html = [];

View File

@@ -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;
}

View File

@@ -84,6 +84,17 @@ button.ghost:hover {
border-color: #b4d9f3; border-color: #b4d9f3;
color: #1a3d64; color: #1a3d64;
} }
button.needs-suggestion,
button.needs-suggestion:disabled {
background: #e0564f;
border-color: #c54740;
color: #fffaf3;
opacity: 1;
}
button.needs-suggestion:hover {
background: #c9473f;
border-color: #a83a35;
}
.label { .label {
color: #6c5a42; color: #6c5a42;

View File

@@ -173,6 +173,10 @@ button .chip {
align-items: center; align-items: center;
} }
.nav-hint {
font-weight: 600;
}
.warning-text { .warning-text {
color: #b23b3b; color: #b23b3b;
font-weight: 600; font-weight: 600;
@@ -219,6 +223,7 @@ button .chip {
/* Slider */ /* Slider */
input[type="range"].full-slider { input[type="range"].full-slider {
-webkit-appearance: none; -webkit-appearance: none;
appearance: none;
width: 100%; width: 100%;
height: 20px; height: 20px;
border-radius: 999px; border-radius: 999px;

View File

@@ -68,6 +68,9 @@
overflow: auto; overflow: auto;
max-height: 70vh; max-height: 70vh;
} }
.edit-modal .edit-body .confirm-actions {
margin-top: 12px;
}
.edit-modal .delete-body { .edit-modal .delete-body {
display: flex; display: flex;
flex-direction: column; flex-direction: column;

197
wwwroot/data/i18n/faq/de.md Normal file
View File

@@ -0,0 +1,197 @@
Pick'n'play hilft Gruppen dabei, fair und transparent zu entscheiden, welches Spiel als Nächstes gespielt wird. Spieler können Vorschläge einreichen, diese unabhängig bewerten und durch strukturierte Phasen gehen, die den Prozess organisiert und anonym halten. Es löst das klassische „Was sollen wir spielen?"-Chaos, indem es Gruppenentscheidungen in einen klaren, ausgewogenen und stressfreien Ablauf verwandelt.
## Konten & Anmeldung
### Wie erstelle ich ein Konto?
Registriere dich mit:
- Einem **eindeutigen Benutzernamen** (max. 24 Zeichen)
- Einem **Passwort**
- Einem **Anzeigenamen** (max. 16 Zeichen)
Dein Anzeigename ist erforderlich er erscheint neben all deinen Vorschlägen und Bewertungen.
### Brauche ich Admin-Rechte?
Wenn du einen **Admin-Schlüssel** erhalten hast, gib ihn bei der Registrierung ein. Ist der Schlüssel ungültig, wird die Anfrage abgelehnt. Admin-Rechte können später nicht hinzugefügt werden. Um Admin zu werden, musst du dich mit dem korrekten Schlüssel neu registrieren.
## Phasen im Überblick
### Persönliche Phasen
Jeder Spieler durchläuft die Phasen unabhängig voneinander:
**Vorschlagen → Abstimmen → Ergebnisse**
Klicke auf **„Weiter"**, um fortzufahren. Admins können sich bei Bedarf auch wieder zurücksetzen.
In der **Vorschlagsphase** bleibt **„Weiter"** deaktiviert, bis dein Konto mindestens einen eigenen Spielvorschlag hat.
## Spiele vorschlagen
### Wie viele Spiele kann ich vorschlagen?
Bis zu **5 Vorschläge pro Spieler**.
### Pflichtfelder und Grenzen
- **Name** erforderlich (max. 100 Zeichen)
- **Genre** optional (max. 50 Zeichen)
- **Beschreibung** optional (max. 500 Zeichen)
- **Links** optional (URLs bis zu 2048 Zeichen)
### Min./Max. Spieleranzahl
- Müssen gemeinsam ausgefüllt werden (oder beide leer bleiben)
- Werte müssen zwischen **1 und 32** liegen
- Minimum muss ≤ Maximum sein
### Screenshot-Regeln
Wenn du eine Screenshot-URL angibst, muss sie:
- **http oder https** verwenden
- Mit einer gültigen Bilddateiendung enden (`png`, `jpg`, `jpeg`, `gif`, `webp`, `avif`)
- Direkt erreichbar sein (keine Weiterleitungen)
- Innerhalb von ~3 Sekunden laden
- Unter **5 MB**groß sein
- Nicht auf lokale oder private Hosts verweisen
Screenshots sind optional.
### Weitere Links
Spiel-Links und YouTube-Links müssen **http oder https** verwenden. Andere URL-Schemata werden abgelehnt.
### Vorschlag bearbeiten
Klicke auf das **Bearbeiten-Symbol (Stift)** auf einer Spielkarte, um Felder jederzeit zu aktualisieren.
### Vorschlag löschen
Klicke auf das **Löschen-Symbol (Kreuz)** auf einer Spielkarte, um sie zu entfernen außer du befindest dich bereits in der Abstimmungsphase (siehe unten).
### Warum wurde mein Vorschlag blockiert?
Häufige Gründe:
- Fehlender Anzeigename
- Das Limit von 5 Vorschlägen wurde erreicht
- Name überschreitet das Zeichenlimit
- Screenshot-URL ist ungültig, nicht erreichbar oder zu groß
- Min./Max.-Spieler fehlen oder sind ungültig
- Versuch, in der falschen Phase einen Vorschlag hinzuzufügen
Überprüfe Fehlermeldungen unten rechts auf dem Bildschirm.
## Abstimmung
### Wer darf abstimmen?
Authentifizierte Spieler während der **Abstimmungsphase**, welche mindestens ein Vorschlag hinzugefügt haben.
### Wie vergebe ich Punkte?
Nutze den Schieberegler, um eine ganze Zahl von **0 bis 10** zu vergeben.
### Bearbeiten während der Abstimmung
- Die meisten Spieldetails können weiterhin bearbeitet werden
- Der **Spielname ist während der Abstimmungsphase gesperrt**
- Eigene Vorschläge können nicht mehr gelöscht werden
- Admins können Vorschläge bei Bedarf löschen
### Verknüpfte Duplikate
Wenn ein Admin doppelte Spiele verknüpft:
- Eine Punkteänderung wirkt sich auf alle verknüpften Einträge aus
- Punkte werden pro Gruppe gespeichert, nicht pro Einzeleintrag
### Abstimmung finalisieren
Mit **„Finalisieren"** werden deine Bewertungen gesperrt. Deaktiviere es, um erneut zu bearbeiten.
„Finalisieren" ist nur während der Abstimmungsphase verfügbar und wird automatisch zurückgesetzt, wenn:
- Ein Joker ein neues Spiel hinzufügt
- Ein Admin Spiele verknüpft oder trennt
### Abstimmen nach Änderungen
Wenn neue Spiele hinzugefügt oder Verknüpfungen geändert werden:
- Betroffene Stimmen werden gelöscht
- Deine Abstimmung wird automatisch zurückgesetzt
- Das Update-Popup erscheint nur, wenn sich deine bereits sichtbare Abstimmungsliste verändert
Überprüfe deine Liste und bewerte erneut, bevor du wieder finalisierst.
## Joker (Späte Ergänzungen)
### Was ist ein Joker?
Ein **Joker** ist ein einmaliger zusätzlicher Vorschlags-Slot, der nur während der **Abstimmungsphase** verfügbar ist. Ein Admin muss ihn dir gewähren.
### So funktioniert es
Wenn du einen Joker erhältst:
- Erscheint ein Button in der oberen Leiste, mit dem du ein weiteres Spiel hinzufügen kannst
- Nach der Nutzung wird der Joker sofort verbraucht
- Die Finalisierung aller Abstimmungen werden automatisch zurückgesetzt, damit das neue Spiel bewertet werden kann
Admins können bei Bedarf zusätzliche Joker vergeben.
## Ergebnisse
### Wann sind die Ergebnisse sichtbar?
Die Ergebnisse bleiben verborgen, bis ein Admin sie freigibt. Danach werden alle Spieler automatisch in die **Ergebnisphase** verschoben. Falls nötig, kann ein Admin die Ergebnisse wieder schließen: Konten mit mindestens einem eigenen Vorschlag kehren in die Abstimmungsphase zurück, Konten ohne Vorschläge in die Vorschlagsphase, und alle Abstimmungen werden zur Anpassung zurückgesetzt.
### Kann ich in der Ergebnisphase etwas bearbeiten?
Nein. Vorschläge und Bewertungen sind schreibgeschützt. Wende dich bei Bedarf an einen Admin.
## Admin-Tools (Für Hosts)
### Was können Admin-Konten tun?
- Joker während der Abstimmung vergeben
- Einen Bewerter zurück in die Vorschlagsphase setzen (stärker als ein Joker; sparsam einsetzen)
- Ergebniszugriff mit einem einzelnen Button umschalten (Beschriftung wechselt je nach Zustand)
- Doppelte Vorschläge verknüpfen oder trennen
- Vorschläge löschen
- Abstimmungsstatus einsehen (wer finalisiert hat)
- Einen Spieler löschen (inklusive dessen Vorschläge und Stimmen)
- Für Konto-Löschung, Zurücksetzen und Werkseinstellung das Admin-Passwort bestätigen
- Die Datenbank auf Werkseinstellungen zurücksetzen
- Zu vorherigen Phasen zurückkehren
### Was können Admin-Konten nicht tun?
- Einzelne Spielerbewertungen einsehen
Die Abstimmung bleibt anonym und fair.
## Häufige Fehler & Lösungen
### „Screenshot-URL muss http(s) verwenden und mit einer Bilddateiendung enden."
Stelle sicher:
- Der Link ist direkt (keine HTML-Seite)
- Er endet mit einer gültigen Bilddateiendung
- Die Datei ist unter 5 MB groß
- Es gibt keine Weiterleitungen
### „Du hast das Limit von 5 Vorschlägen erreicht."
Warte auf die Abstimmungsphase und bitte bei Bedarf um einen Joker.
### „Füge mindestens einen Vorschlag hinzu, bevor du in die Abstimmungsphase wechselst."
Füge mit deinem aktuellen Konto mindestens einen Spielvorschlag hinzu. Erst dann kannst du von der Vorschlagsphase in die Abstimmungsphase wechseln. Diesese Verhalten erschwert die Abgabe von mehreren Stimmen pro Benutzer.
Bis dahin zeigt die Navigation in der Vorschlagsphase einen Hinweis statt eines Weiter-Buttons und wechselt direkt nach der ersten erfolgreichen Einreichung.
### „Ungültiger Admin-Schlüssel."
Registriere dich erneut mit dem korrekten Schlüssel vom Host oder lasse das Feld leer, um ein normales Konto zu erstellen.
## Daten & Datenschutz
- Vorschläge, Stimmen und Phasenstatus werden in einer gemeinsamen **SQLite-Datenbank** gespeichert.
- Passwörtwer werden mit einer SHA256 Verschlüsselung gespeichert.
- Beim Abmelden wird dein Authentifizierungs-Cookie gelöscht und die Eingaben in Login/Registrierung werden zurückgesetzt.
- Wenn ein Admin dein Spielerkonto löscht, werden auch deine Vorschläge und Stimmen entfernt.

201
wwwroot/data/i18n/faq/en.md Normal file
View File

@@ -0,0 +1,201 @@
Pick'n'play helps groups fairly and transparently choose what game to play next. Players can suggest options, score them independently, and move through structured phases that keep the process organized and anonymous. It solves the classic “what should we play?” chaos by turning group decision-making into a clear, balanced, and drama-free flow.
## Accounts & Login
### How do I create an account?
Register with:
- A **unique username** (max 24 characters)
- A **password**
- A **display name** (max 16 characters)
Your display name is required it appears next to all of your suggestions and scores.
### Do I need admin privileges?
If you've been given an **admin key**, enter it during registration. If the key is invalid, the request is rejected.
Admin access cannot be added later. To become an admin, you must re-register with the correct key.
## Phases at a Glance
### Personal phases
Each player progresses independently through the phases:
**Suggest → Vote → Results**
Click **"Next"** to move forward. Admins can move themselves backward if needed.
In the **Suggest** phase, **Next** stays disabled until your account has at least one own game suggestion.
## Suggesting Games
### How many games can I suggest?
Up to **5 suggestions per player**.
### Required fields and limits
- **Name** required (max 100 characters)
- **Genre** optional (max 50 characters)
- **Description** optional (max 500 characters)
- **Links** optional (URLs up to 2048 characters)
### Min/Max players
- Must be filled together (or both left empty)
- Values must be between **1 and 32**
- Minimum must be ≤ maximum
### Screenshot rules
If you include a screenshot URL, it must:
- Use **http or https**
- End with a valid image extension (`png`, `jpg`, `jpeg`, `gif`, `webp`, `avif`)
- Be directly accessible (no redirects)
- Load within ~3 seconds
- Be under **5 MB**
- Not point to local or private hosts
Screenshots are optional.
### Other links
Game links and YouTube links must use **http or https**. Other URL schemes are rejected.
### Editing a suggestion
Click the **edit (pencil) icon** on a game card to update any field at any time.
### Deleting a suggestion
Click the **delete (cross) icon** on a game card to remove it unless you're already in the Vote phase (see below).
### Why was my suggestion blocked?
Common reasons:
- Missing display name
- Already reached the 5-suggestion limit
- Name exceeds character limit
- Screenshot URL is invalid, unreachable, or too large
- Min/max players missing or invalid
- Attempting to add a suggestion in the wrong phase
Check the bottom-right corner of the screen for error messages.
## Jokers (Late Additions)
### What is a joker?
A **joker** is a one-time extra suggestion slot available only during the **Vote phase**. An admin must grant it to you.
### How it works
If you receive a joker:
- A button appears in the top bar allowing you to add one more game.
- Once used, the joker is consumed immediately.
- Your ballot becomes unfinalized.
- All players are unfinalized so the new game can be scored.
Admins may grant additional jokers if necessary.
## Voting
### Who can vote?
Authenticated players during the **Vote phase**, who submitted at least one suggestion.
### How do I score games?
Use the slider to assign a whole number from **0 to 10**.
### Editing during Vote
- You can still edit most game details.
- The **game name becomes locked** during the Vote phase.
- You can no longer delete your own suggestions.
- Admins may delete suggestions if necessary.
### Linked duplicates
If an admin links duplicate games:
- Changing the score for one updates all linked entries.
- Scores are stored per group, not per individual entry.
### Finalizing your ballot
Toggling **"Finalize"** locks your scores. Toggle it off to edit again.
Finalize is only available during the Vote phase and will automatically reset if:
- A joker adds a new game
- An admin links or unlinks games
### Voting after changes
If new games are added or links are modified:
- Affected votes are cleared
- You are automatically unfinalized
- The update popup appears only when your already-visible Vote list changes
Review your list and rescore before finalizing again.
## Results
### When are results visible?
Results are hidden until an admin opens them. When opened, all players are automatically moved to the **Results phase**.
If needed, an admin can close the Results: players with at least one own suggestion return to the Vote phase, accounts without suggestions return to Suggest, and all ballots are unfinalized for adjustments.
### Can I edit anything in Results?
No. Suggestions and votes are read-only. Contact an admin for assistance.
## Admin Tools (For Hosts)
### What can admin accounts do?
- Grant jokers during Vote
- Move a voter back to Suggest (stronger than a joker; use sparingly)
- Toggle results access with a single button (label switches by current state)
- Link or unlink duplicate suggestions
- Delete suggestions
- View vote readiness (who has finalized)
- Delete a player (removes their suggestions and votes)
- Confirm admin password for account deletion, reset, and factory reset
- Reset the database to factory defaults
- Move backward to previous phases
### What can't admin accounts do?
- View individual player votes
Voting remains anonymous and fair.
## Common Errors & Fixes
### "Screenshot URL must be http(s) and end with an image file extension."
Make sure:
- The link is direct (not a page or html content)
- It ends with a valid image extension
- The file is under 5 MB
- There are no redirects
### "You have reached the 5 suggestion limit."
Wait for the Vote phase and request a joker if needed.
### "Add at least one suggestion before entering the Vote phase."
Add at least one game suggestion with your current account. Only then can you move from Suggest to Vote. This behavior hinders the submission of multiple votes per user.
Until then, the Suggest navigation shows a hint instead of a Next button, and switches immediately after your first successful submission.
### "Invalid admin key."
Register again using the correct key from the host or leave it blank to create a regular account.
## Data & Privacy
- Suggestions, votes, and phase states are stored in a shared **SQLite database**.
- Passwords are stored with a SHA256 encryption.
- Logging out clears your authentication cookie and resets login/register form inputs.
- If an admin deletes your player account, your suggestions and votes are removed as well.

View File

@@ -0,0 +1,342 @@
{
"en": {
"lang.label": "Language",
"lang.en": "English",
"lang.de": "Deutsch",
"auth.loginTab": "Log in",
"auth.registerTab": "Register",
"auth.username": "Username",
"auth.password": "Password",
"auth.displayName": "Display name (shows to group)",
"auth.adminKey": "Admin key (optional)",
"auth.loginSubmit": "Log in",
"auth.registerSubmit": "Create account",
"auth.loginHeading": "Log in",
"auth.registerHeading": "Create account",
"auth.switchToRegister": "Need an account? Register",
"auth.switchToLogin": "Have an account? Log in",
"auth.cookieLabel": "I agree to the use of essential cookies.",
"auth.cookieRequired": "Please agree to essential cookies to continue.",
"auth.logout": "Logout",
"auth.welcome": "Welcome, {name}!",
"auth.defaultName": "Player",
"auth.loading": "Loading…",
"auth.needCredentials": "Username and password required",
"auth.invalidCredentials": "Invalid username or password",
"counts.format": "Players: {players} • Suggestions: {suggestions} • Votes: {votes}",
"nav.prev": "Back",
"nav.next": "Next",
"nav.addSuggestionFirst": "Add a game first",
"nav.waitingForResults": "Waiting…",
"nav.freezeTitle": "Ready to reveal?",
"nav.freezeHint": "Moving forward will freeze your suggestions. The suggested game names become locked and can't be edited or deleted anymore, only the optional extra details stay editable.",
"nav.freezeModalTitle": "Freeze suggestions?",
"nav.freezeModalBody": "Once you leave Suggest, your games are locked: game names cannot be changed or deleted. Only optional details (description, links, players, artwork) remain editable. Continue?",
"nav.voteHint": "Cast votes for every game to unlock results.",
"nav.voteFinalized": "✅ You finalized your votes. Sit back and relax while the other players finalize their votes.",
"suggest.title": "Suggest games (up to 5)",
"suggest.new": "Add new suggestion",
"suggest.addButton": "Suggest a game",
"suggest.maxReached": "max limit reached",
"suggest.jokerAddButton": "🃏 Joker: add another game",
"suggest.hint": "Only you can see your suggestions until voting starts.",
"form.gameName": "Game name *",
"form.genre": "Genre",
"form.description": "Description",
"form.players": "Players",
"form.min": "Min",
"form.max": "Max",
"form.screenshot": "Screenshot URL",
"form.youtube": "YouTube URL",
"form.gameUrl": "Game website URL",
"form.submit": "Submit",
"form.placeholder.description": "Short description",
"form.placeholder.gameName": "Game name *",
"form.placeholder.genre": "Genre",
"form.placeholder.screenshot": "Screenshot URL",
"form.placeholder.youtube": "YouTube URL",
"form.placeholder.gameUrl": "Game website URL",
"form.playersInvalid": "Players must be between 1 and 32, and min cannot exceed max.",
"form.screenshotHint": "Use a public direct image link (http/https), max 5 MB. Avoid shortlinks/redirects.",
"form.screenshotInvalid": "Screenshot must be a direct http/https image URL (png, jpg, jpeg, gif, webp, avif) under 5 MB and not a redirect/shortlink.",
"section.mySuggestions": "Your suggestions",
"section.allSuggestions": "All suggestions",
"section.allSuggestions.count": "All {count} suggestions",
"section.vote": "Vote 0-10",
"section.vote.count": "Vote for all {count} games",
"section.results": "Results",
"card.edit": "Edit",
"card.delete": "Delete",
"card.players": "Players: {min}{max}",
"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",
"vote.missingWarn": "You havent voted yet.",
"vote.missingFinalWarn": "You didn't vote for this game.",
"vote.missingFooter": "At least one game is missing a score. Check before finalizing.",
"vote.finalize": "Finalize votes",
"vote.unfinalize": "Edit votes",
"vote.finalHint": "Finalize when youre done. You can unfinalize to change scores.",
"vote.waitAdmin": "Waiting for admin to unlock results.",
"vote.finalizeMissingTitle": "Finalize with missing votes?",
"vote.finalizeMissingBody": "You still have {count} game(s) without a score. Finalizing will mark you as done while those stay unrated.",
"vote.finalizeMissingConfirm": "Finalize anyway",
"results.rank": "Rank",
"results.game": "Game",
"results.author": "Author",
"results.average": "Ø",
"results.votesList": "All votes",
"results.myVote": "Your vote",
"results.links": "Links",
"results.link.site": "Site&nbsp;↗",
"results.link.youtube": "YouTube&nbsp;↗",
"results.relockedTitle": "Results closed",
"results.relockedBody": "Results have been locked again. Youre back in the voting phase and your finalized status was cleared. Adjust scores and re-finalize when ready.",
"results.relockedConfirm": "Got it",
"vote.listUpdatedTitle": "Vote list updated",
"vote.listUpdatedBody": "New or linked games: {names}",
"vote.listUpdatedConfirm": "OK",
"admin.title": "Admin",
"admin.tools": "Admin tools",
"admin.resultsOpenToggle": "Allow results phase",
"admin.resultsOpenEnable": "Enable results phase",
"admin.resultsOpenDisable": "Disable results phase",
"admin.resultsLocked": "Results locked by admin",
"admin.resultsUpdated": "Results availability updated",
"admin.reset": "Reset (keep players)",
"admin.factoryReset": "Factory reset",
"admin.resetConfirmTitle": "Reset round data?",
"admin.resetConfirmBody": "This clears suggestions and votes while keeping accounts. Enter your admin password to continue.",
"admin.factoryResetConfirmTitle": "Factory reset everything?",
"admin.factoryResetConfirmBody": "This removes all players, suggestions, and votes. Enter your admin password to continue.",
"admin.confirmPasswordLabel": "Admin password",
"admin.confirmPasswordRequired": "Admin password is required.",
"admin.resetDone": "Reset complete",
"admin.factoryResetDone": "Factory reset complete",
"admin.readyForResults": "Ready for results",
"admin.waitingForPlayers": "Waiting for players: {names}",
"admin.playerName": "Name",
"admin.playerUsername": "Username",
"admin.playerStatus": "Status",
"admin.playerGames": "Games",
"admin.playerJoker": "Joker",
"admin.playerDelete": "Delete",
"admin.grantJokerChip": "Grant",
"admin.statusSuggesting": "Suggesting",
"admin.statusVoting": "Voting",
"admin.statusFinished": "Finished",
"admin.statusMoveToSuggest": "Move to Suggest",
"admin.statusUpdated": "Player phase updated",
"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.linkTitle": "Link games",
"admin.linkSource": "Game to link",
"admin.linkTarget": "Link to (parent)",
"admin.linkAction": "Link & clear votes",
"admin.linkSourcePlaceholder": "Select source",
"admin.linkTargetPlaceholder": "Select target",
"admin.linkValidation": "Choose two different games to link.",
"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.registered": "Registered",
"toast.loggedIn": "Logged in",
"toast.suggestionAdded": "Suggestion added",
"toast.suggestionDeleted": "Suggestion deleted",
"toast.savedChanges": "Saved changes",
"toast.nameRequired": "Name required",
"toast.displayNameRequired": "Display name is required",
"toast.invalidImageUrl": "Screenshot URL must be http(s) and end with an image file.",
"modal.editTitle": "Edit game",
"modal.addTitle": "Suggest a game",
"modal.confirmDeleteTitle": "Are you sure?",
"modal.confirmDelete": "Confirm delete",
"modal.save": "Save changes",
"modal.cancel": "Cancel",
"modal.close": "Close",
"lightbox.close": "Close",
"help.label": "Help",
"help.title": "FAQ & tips"
},
"de": {
"lang.label": "Sprache",
"lang.en": "Englisch",
"lang.de": "Deutsch",
"auth.loginTab": "Anmelden",
"auth.registerTab": "Registrieren",
"auth.username": "Benutzername",
"auth.password": "Passwort",
"auth.displayName": "Anzeigename (für die Gruppe sichtbar)",
"auth.adminKey": "Admin-Schlüssel (optional)",
"auth.loginSubmit": "Anmelden",
"auth.registerSubmit": "Konto erstellen",
"auth.loginHeading": "Anmelden",
"auth.registerHeading": "Konto erstellen",
"auth.switchToRegister": "Noch kein Konto? Registrieren",
"auth.switchToLogin": "Schon ein Konto? Anmelden",
"auth.cookieLabel": "Ich stimme der Nutzung erforderlicher Cookies zu.",
"auth.cookieRequired": "Bitte stimme den erforderlichen Cookies zu.",
"auth.logout": "Abmelden",
"auth.welcome": "Willkommen, {name}!",
"auth.defaultName": "Spieler",
"auth.loading": "Lädt…",
"auth.needCredentials": "Benutzername und Passwort erforderlich",
"auth.invalidCredentials": "Ungültiger Benutzername oder Passwort",
"counts.format": "Spieler: {players} • Vorschläge: {suggestions} • Stimmen: {votes}",
"nav.prev": "Zurück",
"nav.next": "Weiter",
"nav.addSuggestionFirst": "Zuerst ein Spiel vorschlagen",
"nav.waitingForResults": "Warten…",
"nav.freezeTitle": "Bereit zum Aufdecken?",
"nav.freezeHint": "Beim Weitergehen werden deine Vorschläge eingefroren. Die vorgeschlagene Spiele werden gesperrt und können hinterher nicht mehr abgeändert werden; abgesehen von den Zusatzinfos, diese bleiben bearbeitbar.",
"nav.freezeModalTitle": "Vorschläge einfrieren?",
"nav.freezeModalBody": "Sobald du die Vorschlagsphase verlässt, sind deine Spiele gesperrt: Die Namen von deinen Spielen können nicht mehr geändert oder gelöscht werden. Nur optionale Angaben (Beschreibung, Links, Spielerzahlen, Bilder) bleiben bearbeitbar. Fortfahren?",
"nav.voteHint": "Bewerte alle Spiele, um die Ergebnisse freizuschalten.",
"nav.voteFinalized": "✅ Du hast deine Abstimmung abgeschlossen. Lehn dich zurück, bis die anderen fertig sind.",
"suggest.title": "Schlage Spiele vor (bis zu 5)",
"suggest.new": "Neuen Vorschlag hinzufügen",
"suggest.addButton": "Spiel vorschlagen",
"suggest.maxReached": "Limit erreicht",
"suggest.jokerAddButton": "🃏 Joker: Weiteres Spiel hinzufügen",
"suggest.hint": "Nur du siehst deine Vorschläge bis zum Start der Abstimmung.",
"form.gameName": "Spielname *",
"form.genre": "Genre",
"form.description": "Beschreibung",
"form.players": "Spieler",
"form.min": "Min",
"form.max": "Max",
"form.screenshot": "Screenshot-URL",
"form.youtube": "YouTube-URL",
"form.gameUrl": "Spiel-Webseite",
"form.submit": "Absenden",
"form.placeholder.description": "Kurze Beschreibung",
"form.placeholder.gameName": "Spielname *",
"form.placeholder.genre": "Genre",
"form.placeholder.screenshot": "Screenshot-URL",
"form.placeholder.youtube": "YouTube-URL",
"form.placeholder.gameUrl": "Spiel-Webseite",
"form.playersInvalid": "Spielerzahl muss zwischen 1 und 32 liegen, und Min darf Max nicht überschreiten.",
"form.screenshotHint": "Nutze einen öffentlichen Bildlink (http/https), max. 5 MB. Keine Kurzlinks/Weiterleitungen.",
"form.screenshotInvalid": "Screenshot muss eine direkte http/https-Bild-URL sein (png, jpg, jpeg, gif, webp, avif), unter 5 MB und ohne Weiterleitung/Kurzlink.",
"section.mySuggestions": "Deine Vorschläge",
"section.allSuggestions": "Alle Vorschläge",
"section.allSuggestions.count": "Alle {count} Vorschläge",
"section.vote": "Bewerten 0-10",
"section.vote.count": "Bewerte alle {count} Spiele",
"section.results": "Ergebnisse",
"card.edit": "Bearbeiten",
"card.delete": "Löschen",
"card.players": "Spieler: {min}{max}",
"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",
"vote.missingWarn": "Du hast hier noch nicht abgestimmt.",
"vote.missingFinalWarn": "Du hast für dieses Spiel nicht abgestimmt.",
"vote.missingFooter": "Für mindestens einen Spiel fehlt noch eine Wertung. Prüfe vor dem Abschließen.",
"vote.finalize": "Abstimmung abschließen",
"vote.unfinalize": "Abstimmung bearbeiten",
"vote.finalHint": "Schließe ab, wenn du fertig bist. Zum Ändern wieder öffnen.",
"vote.waitAdmin": "Warten, bis der Admin die Ergebnisse freigibt.",
"vote.finalizeMissingTitle": "Mit fehlenden Stimmen abschließen?",
"vote.finalizeMissingBody": "Für {count} Spiel(e) fehlt noch eine Wertung. Beim Abschließen gilt deine Abstimmung als fertig, obwohl diese Spiele unbewertet bleiben.",
"vote.finalizeMissingConfirm": "Trotzdem abschließen",
"results.rank": "Rang",
"results.game": "Spiel",
"results.author": "Autor",
"results.average": "Ø",
"results.votesList": "Alle Stimmen",
"results.myVote": "Deine Stimme",
"results.links": "Links",
"results.link.site": "Webseite&nbsp;↗",
"results.link.youtube": "YouTube&nbsp;↗",
"results.relockedTitle": "Ergebnisse geschlossen",
"results.relockedBody": "Die Ergebnisse wurden wieder gesperrt. Du bist zurück in der Bewertungsphase und deine Finalisierung wurde zurückgesetzt. Passe deine Bewertungen an und schließe erneut ab, wenn du bereit bist.",
"results.relockedConfirm": "Verstanden",
"vote.listUpdatedTitle": "Liste aktualisiert",
"vote.listUpdatedBody": "Neue oder verknüpfte Spiele: {names}",
"vote.listUpdatedConfirm": "OK",
"admin.title": "Admin",
"admin.tools": "Admin-Werkzeuge",
"admin.resultsOpenToggle": "Ergebnisse freigeben",
"admin.resultsOpenEnable": "Ergebnisse freigeben",
"admin.resultsOpenDisable": "Ergebnisse sperren",
"admin.resultsLocked": "Ergebnisse vom Admin gesperrt",
"admin.resultsUpdated": "Ergebnisfreigabe aktualisiert",
"admin.reset": "Zurücksetzen (Spieler behalten)",
"admin.factoryReset": "Werkseinstellung",
"admin.resetConfirmTitle": "Rundendaten zurücksetzen?",
"admin.resetConfirmBody": "Dadurch werden Vorschläge und Stimmen gelöscht, die Konten bleiben erhalten. Gib dein Admin-Passwort ein, um fortzufahren.",
"admin.factoryResetConfirmTitle": "Alles auf Werkseinstellung setzen?",
"admin.factoryResetConfirmBody": "Dadurch werden alle Spieler, Vorschläge und Stimmen gelöscht. Gib dein Admin-Passwort ein, um fortzufahren.",
"admin.confirmPasswordLabel": "Admin-Passwort",
"admin.confirmPasswordRequired": "Admin-Passwort ist erforderlich.",
"admin.resetDone": "Zurücksetzen abgeschlossen",
"admin.factoryResetDone": "Werkseinstellung abgeschlossen",
"admin.readyForResults": "Bereit für Ergebnisse",
"admin.waitingForPlayers": "Warten auf: {names}",
"admin.playerName": "Name",
"admin.playerUsername": "Benutzername",
"admin.playerStatus": "Status",
"admin.playerGames": "Spiele",
"admin.playerJoker": "Joker",
"admin.playerDelete": "Löschen",
"admin.grantJokerChip": "Joker",
"admin.statusSuggesting": "Vorschlagen",
"admin.statusVoting": "Bewerten",
"admin.statusFinished": "Fertig",
"admin.statusMoveToSuggest": "Zur Vorschlagsphase",
"admin.statusUpdated": "Spielerphase aktualisiert",
"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.linkTitle": "Spiele verknüpfen",
"admin.linkSource": "Spiel verknüpfen",
"admin.linkTarget": "Verknüpfen mit",
"admin.linkAction": "Verknüpfen & Stimmen löschen",
"admin.linkSourcePlaceholder": "Quelle wählen",
"admin.linkTargetPlaceholder": "Ziel wählen",
"admin.linkValidation": "Wähle zwei verschiedene Spiele aus.",
"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.registered": "Registriert",
"toast.loggedIn": "Angemeldet",
"toast.suggestionAdded": "Vorschlag hinzugefügt",
"toast.suggestionDeleted": "Vorschlag gelöscht",
"toast.savedChanges": "Änderungen gespeichert",
"toast.nameRequired": "Name erforderlich",
"toast.displayNameRequired": "Anzeigename ist erforderlich",
"toast.invalidImageUrl": "Screenshot-URL muss mit http(s) beginnen und auf eine Bilddatei enden.",
"modal.editTitle": "Spiel bearbeiten",
"modal.addTitle": "Spiel vorschlagen",
"modal.confirmDeleteTitle": "Bist du sicher?",
"modal.confirmDelete": "Löschen bestätigen",
"modal.save": "Änderungen speichern",
"modal.cancel": "Abbrechen",
"modal.close": "Schließen",
"lightbox.close": "Schließen",
"help.label": "Hilfe",
"help.title": "FAQ & Tipps"
}
}

View File

@@ -121,6 +121,7 @@
<p data-i18n="nav.freezeHint">Moving forward will freeze your suggestions. Titles become locked; only extra details stay editable.</p> <p data-i18n="nav.freezeHint">Moving forward will freeze your suggestions. Titles become locked; only extra details stay editable.</p>
</div> </div>
<div class="nav-actions"> <div class="nav-actions">
<span id="nav-suggest-hint" class="muted nav-hint" data-i18n="nav.addSuggestionFirst">Add a game first</span>
<button id="nav-suggest-next" class="primary" data-i18n="nav.next">Next</button> <button id="nav-suggest-next" class="primary" data-i18n="nav.next">Next</button>
</div> </div>
</div> </div>
@@ -178,10 +179,7 @@
</table> </table>
</div> </div>
</div> </div>
<label class="stack toggle-row"> <button id="results-open" class="secondary" type="button" data-i18n="admin.resultsOpenEnable">Enable results phase</button>
<input type="checkbox" id="results-open" />
<span data-i18n="admin.resultsOpenToggle">Allow results phase</span>
</label>
<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">

121
wwwroot/js/admin-ui.js Normal file
View File

@@ -0,0 +1,121 @@
import { t } from "./i18n.js";
import { state } from "./state.js";
import { $ } from "./dom.js";
import { buildLinkOptionLabel, escapeHtml, truncate } from "./ui-utils.js";
function displayPlayerStatus(player) {
if (!player) return "";
const phase = player.phase;
if (phase === "Suggest") return t("admin.statusSuggesting");
if (phase === "Vote")
return player.finalized
? t("admin.statusFinished")
: t("admin.statusVoting");
if (phase === "Results") return t("admin.statusFinished");
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() {
if (!state.me?.isAdmin) return;
if (state.adminStatusSelectActive) return;
const statusBadge = $("admin-ready-status");
const table = $("admin-player-table")?.querySelector("tbody");
if (!state.adminVoteStatus || !statusBadge || !table) return;
table.innerHTML = "";
state.adminVoteStatus.voters.forEach((v) => {
const tr = document.createElement("tr");
const gamesTooltip = escapeHtml((v.suggestionTitles || []).join(", "));
const nameText = escapeHtml(truncate(v.name, 28));
const userText = escapeHtml(truncate(v.username, 24));
tr.innerHTML = `
<td title="${escapeHtml(v.name)}">${nameText}</td>
<td class="muted small" title="${escapeHtml(v.username)}">${userText}</td>
<td>${buildStatusSelect(v)}</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 danger-chip" data-delete-player="${v.playerId}" data-name="${v.name}" type="button">✕</button></td>
`;
table.appendChild(tr);
});
const waiting = state.adminVoteStatus.waiting;
const ready = waiting.length === 0;
const waitingDisplay = waiting.map((name) =>
name?.length > 24 ? `${name.slice(0, 21)}...` : name,
);
statusBadge.textContent = ready
? t("admin.readyForResults")
: t("admin.waitingForPlayers", { names: waitingDisplay.join(", ") });
statusBadge.className = ready ? "badge" : "badge warning";
}
export 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);
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();
}

View File

@@ -22,7 +22,7 @@ async function request(path, { method = "GET", body } = {}) {
let msg = `${res.status}`; let msg = `${res.status}`;
try { try {
const data = await res.json(); const data = await res.json();
msg = data.error || JSON.stringify(data); msg = data.error || data.detail || data.title || JSON.stringify(data);
} catch { /* ignore */ } } catch { /* ignore */ }
const err = new Error(msg); const err = new Error(msg);
err.status = res.status; err.status = res.status;
@@ -56,10 +56,18 @@ export const api = {
export const adminApi = { export const adminApi = {
setResultsOpen: (resultsOpen) => request("/api/admin/results", { method: "POST", body: { resultsOpen } }), setResultsOpen: (resultsOpen) => request("/api/admin/results", { method: "POST", body: { resultsOpen } }),
voteStatus: () => request("/api/admin/vote-status"), voteStatus: () => request("/api/admin/vote-status"),
reset: () => request("/api/admin/reset", { method: "POST" }), reset: (password) =>
factoryReset: () => request("/api/admin/factory-reset", { method: "POST" }), request("/api/admin/reset", { method: "POST", body: { password } }),
factoryReset: (password) =>
request("/api/admin/factory-reset", { method: "POST", body: { password } }),
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" }), setPlayerPhase: (playerId, phase) =>
request("/api/admin/player-phase", { method: "POST", body: { playerId, phase } }),
deletePlayer: (playerId, password) =>
request(`/api/admin/players/${playerId}`, {
method: "DELETE",
body: { password },
}),
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) =>

View File

@@ -0,0 +1,208 @@
import { adminApi } from "./api.js";
import { t } from "./i18n.js";
import { state } from "./state.js";
import { $, toast } from "./dom.js";
import {
openConfirmModal,
openResultsRelockModal,
renderPhasePill,
} from "./ui.js";
function openAdminPasswordModal({ title, body, confirmLabel, onConfirm }) {
openConfirmModal({
title,
body,
confirmLabel,
confirmClass: "danger",
requirePassword: true,
passwordLabel: t("admin.confirmPasswordLabel"),
onConfirm: async (close, payload) => {
const password = (payload?.password || "").trim();
if (!password) {
toast(t("admin.confirmPasswordRequired"), true);
return;
}
await onConfirm(password, close);
},
});
}
function setupAdminPanelToggle() {
const adminToggle = $("admin-toggle");
const adminCard = $("admin-card");
const adminClose = $("admin-close");
if (!adminToggle || !adminCard || !adminClose) return;
const togglePanel = (show) => adminCard.classList.toggle("hidden", !show);
adminToggle.addEventListener("click", () =>
togglePanel(adminCard.classList.contains("hidden")),
);
adminClose.addEventListener("click", () => togglePanel(false));
}
function setupResetButtons(runSerializedRefresh) {
$("reset").addEventListener("click", () => {
openAdminPasswordModal({
title: t("admin.resetConfirmTitle"),
body: t("admin.resetConfirmBody"),
confirmLabel: t("admin.reset"),
onConfirm: async (password, close) => {
try {
await adminApi.reset(password);
toast(t("admin.resetDone"));
close();
await runSerializedRefresh();
} catch (err) {
toast(err.message, true);
}
},
});
});
$("factory-reset").addEventListener("click", () => {
openAdminPasswordModal({
title: t("admin.factoryResetConfirmTitle"),
body: t("admin.factoryResetConfirmBody"),
confirmLabel: t("admin.factoryReset"),
onConfirm: async (password, close) => {
try {
await adminApi.factoryReset(password);
toast(t("admin.factoryResetDone"));
close();
await runSerializedRefresh();
} catch (err) {
toast(err.message, true);
}
},
});
});
}
function setupResultsToggle(runSerializedRefresh) {
const resultsToggle = $("results-open");
if (!resultsToggle) return;
resultsToggle.addEventListener("click", async () => {
const desired = !state.resultsOpen;
resultsToggle.disabled = true;
try {
const resp = await adminApi.setResultsOpen(desired);
const wasResultsOpen = state.resultsOpen;
const wasPhase = state.phase;
state.resultsOpen = resp.resultsOpen;
if (wasResultsOpen && !resp.resultsOpen && wasPhase === "Results") {
openResultsRelockModal();
}
renderPhasePill();
toast(t("admin.resultsUpdated"));
await runSerializedRefresh();
} catch (err) {
toast(err.message, true);
} finally {
resultsToggle.disabled = false;
}
});
}
function setupLinkApply(runSerializedRefresh) {
const linkApply = $("link-apply");
if (!linkApply) return;
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 runSerializedRefresh();
} catch (err) {
toast(err.message, true);
}
});
}
function setupPlayerTableActions(runSerializedRefresh) {
const playerTable = $("admin-player-table");
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) => {
const grantBtn = e.target.closest("[data-grant-joker]");
const deleteBtn = e.target.closest("[data-delete-player]");
if (grantBtn) {
const playerId = grantBtn.dataset.grantJoker;
try {
await adminApi.grantJoker(playerId);
toast(t("admin.jokerGranted"));
await runSerializedRefresh();
} catch (err) {
toast(err.message, true);
}
} else if (deleteBtn) {
const playerId = deleteBtn.dataset.deletePlayer;
const name = deleteBtn.dataset.name || "";
openAdminPasswordModal({
title: t("admin.deleteTitle"),
body: t("admin.deleteBody", { name }),
confirmLabel: t("admin.deleteConfirm"),
onConfirm: async (password, close) => {
try {
await adminApi.deletePlayer(playerId, password);
toast(t("admin.deleteDone"));
close();
await runSerializedRefresh();
} catch (err) {
toast(err.message, true);
}
},
});
}
});
}
export function setupAdminHandlers({ runSerializedRefresh }) {
setupResetButtons(runSerializedRefresh);
setupAdminPanelToggle();
setupResultsToggle(runSerializedRefresh);
setupLinkApply(runSerializedRefresh);
setupPlayerTableActions(runSerializedRefresh);
}

View File

@@ -0,0 +1,187 @@
import { api } from "./api.js";
import { t } from "./i18n.js";
import { state, clearUserState, setSavedUsername } from "./state.js";
import { $, toast } from "./dom.js";
import {
handleAuthError,
openNewSuggestionModal,
setAuthMode,
setAuthUI,
} from "./ui.js";
function setupConsentRows() {
const hasConsent = () =>
document.cookie
.split(";")
.some((c) => c.trim().startsWith("cookie_consent=1"));
const setConsent = () => {
document.cookie =
"cookie_consent=1; path=/; max-age=31536000; SameSite=Lax";
};
const consentRows = document.querySelectorAll(".consent-row");
const toggleConsentRows = () => {
const hide = hasConsent();
consentRows.forEach((row) => row.classList.toggle("hidden", hide));
};
toggleConsentRows();
["login-consent", "register-consent"].forEach((id) => {
const box = $(id);
if (box) {
box.checked = hasConsent();
}
});
return { hasConsent, setConsent, toggleConsentRows };
}
function setupAuthModeToggle() {
const toggleAuth = $("auth-toggle");
if (toggleAuth) {
toggleAuth.addEventListener("click", (e) => {
e.preventDefault();
setAuthMode(state.authMode === "login" ? "register" : "login");
});
}
setAuthMode(state.authMode);
}
function setupLoginUserEditingHint() {
const loginUser = $("login-username");
if (!loginUser) return;
const markEditing = () => {
loginUser.dataset.userEditing = "1";
};
["focus", "input", "keydown"].forEach((evt) =>
loginUser.addEventListener(evt, markEditing),
);
loginUser.addEventListener("blur", () => {
delete loginUser.dataset.userEditing;
});
}
function setupLoginFormHandlers({
hasConsent,
setConsent,
toggleConsentRows,
runSerializedRefresh,
}) {
const loginForm = $("login-form");
if (!loginForm) return;
loginForm.addEventListener("submit", async (e) => {
e.preventDefault();
const username = $("login-username").value.trim();
const password = $("login-password").value;
if (!username || !password)
return toast(t("auth.needCredentials"), true);
if (!hasConsent() && !$("login-consent")?.checked)
return toast(t("auth.cookieRequired"), true);
try {
await api.login({ username, password });
setConsent();
toggleConsentRows();
setSavedUsername(username);
state.isAuthenticated = true;
setAuthUI(true);
await runSerializedRefresh();
toast(t("toast.loggedIn"));
} catch (err) {
if (err?.status === 401)
return toast(t("auth.invalidCredentials"), true);
if (handleAuthError(err, clearUserState)) return;
}
});
}
function setupRegisterFormHandlers({
hasConsent,
setConsent,
toggleConsentRows,
runSerializedRefresh,
}) {
const registerForm = $("register-form");
if (!registerForm) return;
registerForm.addEventListener("submit", async (e) => {
e.preventDefault();
const username = $("register-username").value.trim();
const password = $("register-password").value;
const displayName = $("register-displayName").value.trim();
const adminKey = $("register-adminkey").value.trim();
if (!displayName)
return toast(
t("toast.displayNameRequired") || "Display name is required.",
true,
);
if (!username || !password)
return toast(t("auth.needCredentials"), true);
if (!hasConsent() && !$("register-consent")?.checked)
return toast(t("auth.cookieRequired"), true);
try {
await api.register({ username, password, displayName, adminKey });
setConsent();
toggleConsentRows();
setSavedUsername(username);
state.isAuthenticated = true;
setAuthUI(true);
await runSerializedRefresh();
toast(t("toast.registered"));
} catch (err) {
if (handleAuthError(err, clearUserState)) return;
toast(err.message, true);
}
});
}
function setupLogoutHandler() {
const logoutBtn = $("logout");
if (!logoutBtn) return;
logoutBtn.addEventListener("click", async (e) => {
e.preventDefault();
try {
await api.logout();
} catch (err) {
toast(err.message, true);
}
document.querySelectorAll(".auth-form").forEach((form) => form.reset());
setAuthMode("login");
setSavedUsername("");
clearUserState();
state.isAuthenticated = false;
setAuthUI(false);
});
}
function setupSuggestionEntryButtons() {
const openSuggestBtn = $("open-suggest-modal");
if (openSuggestBtn) {
openSuggestBtn.addEventListener("click", (e) => {
e.preventDefault();
if (openSuggestBtn.disabled) return;
if (state.phase !== "Suggest") return;
openNewSuggestionModal();
});
}
const openJokerBtn = $("open-joker-modal");
if (openJokerBtn) {
openJokerBtn.addEventListener("click", (e) => {
e.preventDefault();
if (state.phase !== "Vote" || !state.hasJoker) return;
openNewSuggestionModal();
});
}
}
export function setupAuthHandlers({ runSerializedRefresh }) {
setupAuthModeToggle();
const consent = setupConsentRows();
setupLoginUserEditingHint();
setupLoginFormHandlers({ ...consent, runSerializedRefresh });
setupRegisterFormHandlers({ ...consent, runSerializedRefresh });
setupSuggestionEntryButtons();
setupLogoutHandler();
}

View File

@@ -0,0 +1,113 @@
import { api } from "./api.js";
import { t } from "./i18n.js";
import { state } from "./state.js";
import { $, toast } from "./dom.js";
import { openConfirmModal, renderPhasePill, renderVotes } from "./ui.js";
function bindPhaseAdvanceButtons(runSerializedRefresh) {
const makeForward = (id, before) => {
const btn = $(id);
if (!btn) return;
btn.addEventListener("click", async () => {
try {
if (before) {
const proceed = await before();
if (!proceed) return;
}
const resp = await api.nextPhase();
state.prevPhase = state.phase;
state.phase = resp.currentPhase;
state.resultsOpen = resp.resultsOpen ?? state.resultsOpen;
state.votesRendered = false;
renderPhasePill();
await runSerializedRefresh();
} catch (err) {
toast(err.message, true);
}
});
};
const makeBack = (id) => {
const btn = $(id);
if (!btn) return;
btn.addEventListener("click", async () => {
try {
const resp = await api.prevPhase();
state.prevPhase = state.phase;
state.phase = resp.currentPhase;
state.resultsOpen = resp.resultsOpen ?? state.resultsOpen;
state.votesRendered = false;
renderPhasePill();
await runSerializedRefresh();
} catch (err) {
toast(err.message, true);
}
});
};
makeForward("nav-suggest-next", async () => {
return await new Promise((resolve) => {
openConfirmModal({
title: t("nav.freezeModalTitle"),
body: t("nav.freezeModalBody"),
confirmLabel: t("nav.next"),
onConfirm: (close) => {
close();
resolve(true);
},
});
});
});
makeBack("nav-vote-prev");
}
function bindVoteFinalizeButton() {
const finalizeBtn = $("finalize-votes");
if (!finalizeBtn) return;
const finalizeVotes = async (desired) => {
await api.finalizeVotes(desired);
state.votesFinal = desired;
renderPhasePill();
renderVotes();
toast(desired ? t("vote.finalize") : t("vote.unfinalize"));
};
const missingVotes = () => {
const votedIds = new Set(
(state.myVotes ?? []).map((v) => v.suggestionId),
);
return (state.allSuggestions ?? []).filter((s) => !votedIds.has(s.id));
};
finalizeBtn.addEventListener("click", async () => {
try {
const desired = !state.votesFinal;
if (desired) {
const missing = missingVotes();
if (missing.length > 0) {
openConfirmModal({
title: t("vote.finalizeMissingTitle"),
body: t("vote.finalizeMissingBody", {
count: missing.length,
}),
confirmLabel: t("vote.finalizeMissingConfirm"),
onConfirm: async (close) => {
await finalizeVotes(desired);
close();
},
});
return;
}
}
await finalizeVotes(desired);
} catch (err) {
toast(err.message, true);
}
});
}
export function setupVoteNavigationHandlers({ runSerializedRefresh }) {
bindPhaseAdvanceButtons(runSerializedRefresh);
bindVoteFinalizeButton();
}

72
wwwroot/js/auth-ui.js Normal file
View File

@@ -0,0 +1,72 @@
import { t } from "./i18n.js";
import { state, getSavedUsername } from "./state.js";
import { $, toast } from "./dom.js";
export function setAuthUI(isAuthed) {
const main = document.querySelector("main");
const statusBar = document.querySelector(".status-bar");
const authCard = $("auth-card");
[main, statusBar].forEach((el) =>
el?.classList.toggle("hidden", !isAuthed),
);
if (authCard) authCard.classList.toggle("hidden", isAuthed);
const adminToggle = $("admin-toggle");
if (adminToggle)
adminToggle.classList.toggle("hidden", !isAuthed || !state.me?.isAdmin);
if (!isAuthed) {
const adminCard = $("admin-card");
if (adminCard) adminCard.classList.add("hidden");
const loginUser = $("login-username");
const cachedUser = getSavedUsername();
if (
loginUser &&
cachedUser &&
!loginUser.dataset.userEditing &&
!loginUser.value
) {
loginUser.value = cachedUser;
}
}
}
export function setAuthMode(mode) {
state.authMode = mode;
document.querySelectorAll(".auth-form").forEach((form) => {
form.classList.toggle("hidden", form.dataset.mode !== mode);
});
const title = $("auth-title");
const toggleBtn = $("auth-toggle");
if (title) {
title.textContent =
mode === "login"
? t("auth.loginHeading")
: t("auth.registerHeading");
}
if (toggleBtn) {
toggleBtn.textContent =
mode === "login"
? t("auth.switchToRegister")
: t("auth.switchToLogin");
}
}
export function handleAuthError(err, clearUserState) {
if (err?.status === 401) {
clearUserState();
state.isAuthenticated = false;
setAuthUI(false);
return true;
}
toast(err?.message || t("toast.unexpected"), true);
return false;
}
export function renderWelcome() {
const el = $("welcome-text");
if (!el) return;
const name =
state.me?.displayName?.trim() ||
state.me?.username ||
t("auth.defaultName");
el.textContent = t("auth.welcome", { name });
}

View File

@@ -25,16 +25,22 @@ export async function loadSuggestData() {
if (state.phase !== "Suggest") return; if (state.phase !== "Suggest") return;
state.mySuggestions = await api.mySuggestions(); state.mySuggestions = await api.mySuggestions();
renderMySuggestions(); renderMySuggestions();
updatePhaseNav();
} }
export async function loadRevealData() { export async function loadSuggestionsData() {
if (state.phase === "Vote" || state.phase === "Results") { if (state.phase === "Vote" || state.phase === "Results") {
const prev = state.allSuggestions ?? []; const prev = state.allSuggestions ?? [];
const prevById = Object.fromEntries(prev.map((s) => [s.id, s])); const prevById = Object.fromEntries(prev.map((s) => [s.id, s]));
const latest = await api.allSuggestions(); const latest = await api.allSuggestions();
const latestSig = signatureSuggestions(latest); const latestSig = signatureSuggestions(latest);
const changed = latestSig !== state.allSuggestionsSig; const changed = latestSig !== state.allSuggestionsSig;
if (changed && state.phase === "Vote" && state.allSuggestionsSig) { if (
changed &&
state.phase === "Vote" &&
state.votesRendered &&
state.allSuggestionsSig
) {
const added = latest const added = latest
.filter((s) => !prevById[s.id]) .filter((s) => !prevById[s.id])
.map((s) => s.name); .map((s) => s.name);
@@ -84,7 +90,7 @@ export async function refreshPhaseData() {
const prevPhase = state.phase; const prevPhase = state.phase;
const prevResultsOpen = state.resultsOpen; const prevResultsOpen = state.resultsOpen;
await loadState(); await loadState();
await Promise.all([loadSuggestData(), loadRevealData(), loadResults()]); await Promise.all([loadSuggestData(), loadSuggestionsData(), loadResults()]);
if (state.phase === "Vote") { if (state.phase === "Vote") {
if (!state.votesRendered) await loadVoteData(); if (!state.votesRendered) await loadVoteData();
} else { } else {
@@ -126,9 +132,3 @@ export function signatureSuggestions(list) {
]), ]),
); );
} }
// expose for UI handlers that call back in
window.refreshPhaseData = refreshPhaseData;
window.loadSuggestData = loadSuggestData;
window.loadVoteData = loadVoteData;
window.handleAuthError = (err) => handleAuthError(err, clearUserState);

View File

@@ -1,799 +1,211 @@
const translations = {
en: {
"lang.label": "Language",
"lang.en": "English",
"lang.de": "Deutsch",
"auth.loginTab": "Log in",
"auth.registerTab": "Register",
"auth.username": "Username",
"auth.password": "Password",
"auth.displayName": "Display name (shows to group)",
"auth.adminKey": "Admin key (optional)",
"auth.loginSubmit": "Log in",
"auth.registerSubmit": "Create account",
"auth.loginHeading": "Log in",
"auth.registerHeading": "Create account",
"auth.switchToRegister": "Need an account? Register",
"auth.switchToLogin": "Have an account? Log in",
"auth.cookieLabel": "I agree to the use of essential cookies.",
"auth.cookieRequired": "Please agree to essential cookies to continue.",
"auth.logout": "Logout",
"auth.welcome": "Welcome, {name}!",
"auth.defaultName": "Player",
"auth.loading": "Loading…",
"auth.needCredentials": "Username and password required",
"auth.invalidCredentials": "Invalid username or password",
"counts.format": "Players: {players} • Suggestions: {suggestions} • Votes: {votes}",
"nav.prev": "Back",
"nav.next": "Next",
"nav.waitingForResults": "Waiting…",
"nav.freezeTitle": "Ready to reveal?",
"nav.freezeHint": "Moving forward will freeze your suggestions. Game names become locked; only extra details stay editable.",
"nav.freezeModalTitle": "Freeze suggestions?",
"nav.freezeModalBody": "Once you leave Suggest, your games are locked: game names cannot be changed or deleted. Only optional details (description, links, players, artwork) remain editable. Continue?",
"nav.voteHint": "Cast votes for every game to unlock results.",
"nav.voteFinalized": "✅ You finalized your votes. Sit back and relax while the other players finalize their votes.",
"suggest.title": "Suggest games (up to 5)",
"suggest.new": "Add new suggestion",
"suggest.addButton": "Suggest a game",
"suggest.maxReached": "max limit reached",
"suggest.jokerAddButton": "🃏 Joker: add another game",
"suggest.hint": "Only you can see your suggestions until voting starts.",
"form.gameName": "Game name *",
"form.genre": "Genre",
"form.description": "Description",
"form.players": "Players",
"form.min": "Min",
"form.max": "Max",
"form.screenshot": "Screenshot URL",
"form.youtube": "YouTube URL",
"form.gameUrl": "Game website URL",
"form.submit": "Submit",
"form.placeholder.description": "Short description",
"form.placeholder.gameName": "Game name *",
"form.placeholder.genre": "Genre",
"form.placeholder.screenshot": "Screenshot URL",
"form.placeholder.youtube": "YouTube URL",
"form.placeholder.gameUrl": "Game website URL",
"form.playersInvalid": "Players must be between 1 and 32, and min cannot exceed max.",
"form.screenshotHint": "Use a public direct image link (http/https), max 5 MB. Avoid shortlinks/redirects.",
"form.screenshotInvalid": "Screenshot must be a direct http/https image URL (png, jpg, jpeg, gif, webp, avif) under 5 MB and not a redirect/shortlink.",
"section.mySuggestions": "Your suggestions",
"section.allSuggestions": "All suggestions",
"section.allSuggestions.count": "All {count} suggestions",
"section.vote": "Vote 0-10",
"section.vote.count": "Vote for all {count} games",
"section.results": "Results",
"card.edit": "Edit",
"card.delete": "Delete",
"card.players": "Players: {min}{max}",
"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",
"vote.missingWarn": "You havent voted yet.",
"vote.missingFinalWarn": "You didn't vote for this game.",
"vote.missingFooter": "At least one game is missing a score. Check before finalizing.",
"vote.finalize": "Finalize votes",
"vote.unfinalize": "Edit votes",
"vote.finalHint": "Finalize when youre done. You can unfinalize to change scores.",
"vote.waitAdmin": "Waiting for admin to unlock results.",
"vote.finalizeMissingTitle": "Finalize with missing votes?",
"vote.finalizeMissingBody": "You still have {count} game(s) without a score. Finalizing will mark you as done while those stay unrated.",
"vote.finalizeMissingConfirm": "Finalize anyway",
"results.rank": "Rank",
"results.game": "Game",
"results.author": "Author",
"results.average": "Ø",
"results.votesList": "All votes",
"results.myVote": "Your vote",
"results.links": "Links",
"results.link.site": "Site&nbsp;↗",
"results.link.youtube": "YouTube&nbsp;↗",
"results.relockedTitle": "Results closed",
"results.relockedBody": "Results have been locked again. Youre back in the voting phase and your finalized status was cleared. Adjust scores and re-finalize when ready.",
"results.relockedConfirm": "Got it",
"vote.listUpdatedTitle": "Vote list updated",
"vote.listUpdatedBody": "New or linked games: {names}",
"vote.listUpdatedConfirm": "OK",
"admin.title": "Admin",
"admin.tools": "Admin tools",
"admin.resultsOpenToggle": "Allow results phase",
"admin.resultsLocked": "Results locked by admin",
"admin.resultsUpdated": "Results availability updated",
"admin.reset": "Reset (keep players)",
"admin.factoryReset": "Factory reset",
"admin.resetDone": "Reset complete",
"admin.factoryResetDone": "Factory reset complete",
"admin.readyForResults": "Ready for results",
"admin.waitingForPlayers": "Waiting for players: {names}",
"admin.playerName": "Name",
"admin.playerUsername": "Username",
"admin.playerStatus": "Status",
"admin.playerGames": "Games",
"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.linkTitle": "Link games",
"admin.linkSource": "Game to link",
"admin.linkTarget": "Link to (parent)",
"admin.linkAction": "Link & clear votes",
"admin.linkSourcePlaceholder": "Select source",
"admin.linkTargetPlaceholder": "Select target",
"admin.linkValidation": "Choose two different games to link.",
"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.registered": "Registered",
"toast.loggedIn": "Logged in",
"toast.suggestionAdded": "Suggestion added",
"toast.suggestionDeleted": "Suggestion deleted",
"toast.savedChanges": "Saved changes",
"toast.nameRequired": "Name required",
"toast.displayNameRequired": "Display name is required",
"toast.invalidImageUrl": "Screenshot URL must be http(s) and end with an image file.",
"modal.editTitle": "Edit game",
"modal.addTitle": "Suggest a game",
"modal.confirmDeleteTitle": "Are you sure?",
"modal.confirmDelete": "Confirm delete",
"modal.save": "Save changes",
"modal.cancel": "Cancel",
"modal.close": "Close",
"lightbox.close": "Close",
"help.label": "Help",
"help.title": "FAQ & tips",
},
de: {
"lang.label": "Sprache",
"lang.en": "Englisch",
"lang.de": "Deutsch",
"auth.loginTab": "Anmelden",
"auth.registerTab": "Registrieren",
"auth.username": "Benutzername",
"auth.password": "Passwort",
"auth.displayName": "Anzeigename (für die Gruppe sichtbar)",
"auth.adminKey": "Admin-Schlüssel (optional)",
"auth.loginSubmit": "Anmelden",
"auth.registerSubmit": "Konto erstellen",
"auth.loginHeading": "Anmelden",
"auth.registerHeading": "Konto erstellen",
"auth.switchToRegister": "Noch kein Konto? Registrieren",
"auth.switchToLogin": "Schon ein Konto? Anmelden",
"auth.cookieLabel": "Ich stimme der Nutzung erforderlicher Cookies zu.",
"auth.cookieRequired": "Bitte stimme den erforderlichen Cookies zu.",
"auth.logout": "Abmelden",
"auth.welcome": "Willkommen, {name}!",
"auth.defaultName": "Spieler",
"auth.loading": "Lädt…",
"auth.needCredentials": "Benutzername und Passwort erforderlich",
"auth.invalidCredentials": "Ungültiger Benutzername oder Passwort",
"counts.format": "Spieler: {players} • Vorschläge: {suggestions} • Stimmen: {votes}",
"nav.prev": "Zurück",
"nav.next": "Weiter",
"nav.waitingForResults": "Warten…",
"nav.freezeTitle": "Bereit zum Aufdecken?",
"nav.freezeHint": "Beim Weitergehen werden deine Vorschläge eingefroren. Spielnamen werden gesperrt; nur Zusatzinfos bleiben bearbeitbar.",
"nav.freezeModalTitle": "Vorschläge einfrieren?",
"nav.freezeModalBody": "Sobald du die Vorschlagsphase verlässt, sind deine Spiele gesperrt: Die Namen von deinen Spielen können nicht mehr geändert oder gelöscht werden. Nur optionale Angaben (Beschreibung, Links, Spielerzahlen, Bilder) bleiben bearbeitbar. Fortfahren?",
"nav.voteHint": "Bewerte alle Spiele, um die Ergebnisse freizuschalten.",
"nav.voteFinalized": "✅ Du hast deine Abstimmung abgeschlossen. Lehn dich zurück, bis die anderen fertig sind.",
"suggest.title": "Schlage Spiele vor (bis zu 5)",
"suggest.new": "Neuen Vorschlag hinzufügen",
"suggest.addButton": "Spiel vorschlagen",
"suggest.maxReached": "Limit erreicht",
"suggest.jokerAddButton": "🃏 Joker: Weiteres Spiel hinzufügen",
"suggest.hint": "Nur du siehst deine Vorschläge bis zum Start der Abstimmung.",
"form.gameName": "Spielname *",
"form.genre": "Genre",
"form.description": "Beschreibung",
"form.players": "Spieler",
"form.min": "Min",
"form.max": "Max",
"form.screenshot": "Screenshot-URL",
"form.youtube": "YouTube-URL",
"form.gameUrl": "Spiel-Webseite",
"form.submit": "Absenden",
"form.placeholder.description": "Kurze Beschreibung",
"form.placeholder.gameName": "Spielname *",
"form.placeholder.genre": "Genre",
"form.placeholder.screenshot": "Screenshot-URL",
"form.placeholder.youtube": "YouTube-URL",
"form.placeholder.gameUrl": "Spiel-Webseite",
"form.playersInvalid": "Spielerzahl muss zwischen 1 und 32 liegen, und Min darf Max nicht überschreiten.",
"form.screenshotHint": "Nutze einen öffentlichen Bildlink (http/https), max. 5 MB. Keine Kurzlinks/Weiterleitungen.",
"form.screenshotInvalid": "Screenshot muss eine direkte http/https-Bild-URL sein (png, jpg, jpeg, gif, webp, avif), unter 5 MB und ohne Weiterleitung/Kurzlink.",
"section.mySuggestions": "Deine Vorschläge",
"section.allSuggestions": "Alle Vorschläge",
"section.allSuggestions.count": "Alle {count} Vorschläge",
"section.vote": "Bewerten 0-10",
"section.vote.count": "Bewerte alle {count} Spiele",
"section.results": "Ergebnisse",
"card.edit": "Bearbeiten",
"card.delete": "Löschen",
"card.players": "Spieler: {min}{max}",
"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",
"vote.missingWarn": "Du hast hier noch nicht abgestimmt.",
"vote.missingFinalWarn": "Du hast für dieses Spiel nicht abgestimmt.",
"vote.missingFooter": "Für mindestens einen Spiel fehlt noch eine Wertung. Prüfe vor dem Abschließen.",
"vote.finalize": "Abstimmung abschließen",
"vote.unfinalize": "Abstimmung bearbeiten",
"vote.finalHint": "Schließe ab, wenn du fertig bist. Zum Ändern wieder öffnen.",
"vote.waitAdmin": "Warten, bis der Admin die Ergebnisse freigibt.",
"vote.finalizeMissingTitle": "Mit fehlenden Stimmen abschließen?",
"vote.finalizeMissingBody": "Für {count} Spiel(e) fehlt noch eine Wertung. Beim Abschließen gilt deine Abstimmung als fertig, obwohl diese Spiele unbewertet bleiben.",
"vote.finalizeMissingConfirm": "Trotzdem abschließen",
"results.rank": "Rang",
"results.game": "Spiel",
"results.author": "Autor",
"results.average": "Ø",
"results.votesList": "Alle Stimmen",
"results.myVote": "Deine Stimme",
"results.links": "Links",
"results.link.site": "Webseite&nbsp;↗",
"results.link.youtube": "YouTube&nbsp;↗",
"results.relockedTitle": "Ergebnisse geschlossen",
"results.relockedBody": "Die Ergebnisse wurden wieder gesperrt. Du bist zurück in der Bewertungsphase und deine Finalisierung wurde zurückgesetzt. Passe deine Bewertungen an und schließe erneut ab, wenn du bereit bist.",
"results.relockedConfirm": "Verstanden",
"vote.listUpdatedTitle": "Liste aktualisiert",
"vote.listUpdatedBody": "Neue oder verknüpfte Spiele: {names}",
"vote.listUpdatedConfirm": "OK",
"admin.title": "Admin",
"admin.tools": "Admin-Werkzeuge",
"admin.resultsOpenToggle": "Ergebnisse freigeben",
"admin.resultsLocked": "Ergebnisse vom Admin gesperrt",
"admin.resultsUpdated": "Ergebnisfreigabe aktualisiert",
"admin.reset": "Zurücksetzen (Spieler behalten)",
"admin.factoryReset": "Werkseinstellung",
"admin.resetDone": "Zurücksetzen abgeschlossen",
"admin.factoryResetDone": "Werkseinstellung abgeschlossen",
"admin.readyForResults": "Bereit für Ergebnisse",
"admin.waitingForPlayers": "Warten auf: {names}",
"admin.playerName": "Name",
"admin.playerUsername": "Benutzername",
"admin.playerStatus": "Status",
"admin.playerGames": "Spiele",
"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.linkTitle": "Spiele verknüpfen",
"admin.linkSource": "Spiel verknüpfen",
"admin.linkTarget": "Verknüpfen mit",
"admin.linkAction": "Verknüpfen & Stimmen löschen",
"admin.linkSourcePlaceholder": "Quelle wählen",
"admin.linkTargetPlaceholder": "Ziel wählen",
"admin.linkValidation": "Wähle zwei verschiedene Spiele aus.",
"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.registered": "Registriert",
"toast.loggedIn": "Angemeldet",
"toast.suggestionAdded": "Vorschlag hinzugefügt",
"toast.suggestionDeleted": "Vorschlag gelöscht",
"toast.savedChanges": "Änderungen gespeichert",
"toast.nameRequired": "Name erforderlich",
"toast.displayNameRequired": "Anzeigename ist erforderlich",
"toast.invalidImageUrl": "Screenshot-URL muss mit http(s) beginnen und auf eine Bilddatei enden.",
"modal.editTitle": "Spiel bearbeiten",
"modal.addTitle": "Spiel vorschlagen",
"modal.confirmDeleteTitle": "Bist du sicher?",
"modal.confirmDelete": "Löschen bestätigen",
"modal.save": "Änderungen speichern",
"modal.cancel": "Abbrechen",
"modal.close": "Schließen",
"lightbox.close": "Schließen",
"help.label": "Hilfe",
"help.title": "FAQ & Tipps",
}
};
const faqMarkdown = {
en: `
Pick'n'play helps groups fairly and transparently choose what game to play next. Players can suggest options, score them independently, and move through structured phases that keep the process organized and anonymous. It solves the classic “what should we play?” chaos by turning group decision-making into a clear, balanced, and drama-free flow.
## Accounts & Login
### How do I create an account?
Register with:
- A **unique username** (max 24 characters)
- A **password**
- A **display name** (max 16 characters)
Your display name is required it appears next to all of your suggestions and scores.
### Do I need admin privileges?
If you've been given an **admin key**, enter it during registration. If the key is invalid, the request is rejected.
Admin access cannot be added later. To become an admin, you must re-register with the correct key.
## Phases at a Glance
### Personal phases
Each player progresses independently through the phases:
**Suggest → Vote → Results**
Click **"Next"** to move forward. Admins can move themselves backward if needed.
## Suggesting Games
### How many games can I suggest?
Up to **5 suggestions per player**.
### Required fields and limits
- **Name** required (max 100 characters)
- **Genre** optional (max 50 characters)
- **Description** optional (max 500 characters)
- **Links** optional (URLs up to 2048 characters)
### Min/Max players
- Must be filled together (or both left empty)
- Values must be between **1 and 32**
- Minimum must be ≤ maximum
### Screenshot rules
If you include a screenshot URL, it must:
- Use **http or https**
- End with a valid image extension (\`png\`, \`jpg\`, \`jpeg\`, \`gif\`, \`webp\`, \`avif\`)
- Be directly accessible (no redirects)
- Load within ~3 seconds
- Be under **5 MB**
- Not point to local or private hosts
Screenshots are optional.
### Other links
Game links and YouTube links must use **http or https**. Other URL schemes are rejected.
### Editing a suggestion
Click the **edit (pencil) icon** on a game card to update any field at any time.
### Deleting a suggestion
Click the **delete (cross) icon** on a game card to remove it unless you're already in the Vote phase (see below).
### Why was my suggestion blocked?
Common reasons:
- Missing display name
- Already reached the 5-suggestion limit
- Name exceeds character limit
- Screenshot URL is invalid, unreachable, or too large
- Min/max players missing or invalid
- Attempting to add a suggestion in the wrong phase
Check the bottom-right corner of the screen for error messages.
## Jokers (Late Additions)
### What is a joker?
A **joker** is a one-time extra suggestion slot available only during the **Vote phase**. An admin must grant it to you.
### How it works
If you receive a joker:
- A button appears in the top bar allowing you to add one more game.
- Once used, the joker is consumed immediately.
- Your ballot becomes unfinalized.
- All players are unfinalized so the new game can be scored.
Admins may grant additional jokers if necessary.
## Voting
### Who can vote?
Authenticated players during the **Vote phase**.
### How do I score games?
Use the slider to assign a whole number from **0 to 10**.
### Editing during Vote
- You can still edit most game details.
- The **game name becomes locked** during the Vote phase.
- You can no longer delete your own suggestions.
- Admins may delete suggestions if necessary.
### Linked duplicates
If an admin links duplicate games:
- Changing the score for one updates all linked entries.
- Scores are stored per group, not per individual entry.
### Finalizing your ballot
Toggling **"Finalize"** locks your scores. Toggle it off to edit again.
Finalize is only available during the Vote phase and will automatically reset if:
- A joker adds a new game
- An admin links or unlinks games
### Voting after changes
If new games are added or links are modified:
- Affected votes are cleared
- You are automatically unfinalized
Review your list and rescore before finalizing again.
## Results
### When are results visible?
Results are hidden until an admin opens them. When opened, all players are automatically moved to the **Results phase**.
If needed, an admin can close the Results: everyone returns to the Vote phase, and all ballots are unfinalized for adjustments.
### Can I edit anything in Results?
No. Suggestions and votes are read-only. Contact an admin for assistance.
## Admin Tools (For Hosts)
### What can admin accounts do?
- Grant jokers during Vote
- Link or unlink duplicate suggestions
- Delete suggestions
- View vote readiness (who has finalized)
- Delete a player (removes their suggestions and votes)
- Reset the database to factory defaults
- Move backward to previous phases
### What can't admin accounts do?
- View individual player votes
Voting remains anonymous and fair.
## Common Errors & Fixes
### "Screenshot URL must be http(s) and end with an image file extension."
Make sure:
- The link is direct (not a page or html content)
- It ends with a valid image extension
- The file is under 5 MB
- There are no redirects
### "You have reached the 5 suggestion limit."
Wait for the Vote phase and request a joker if needed.
### "Invalid admin key."
Register again using the correct key from the host or leave it blank to create a regular account.
## Data & Privacy
- Suggestions, votes, and phase states are stored in a shared **SQLite database**.
- Passwords are stored with a SHA256 encryption.
- Logging out clears your authentication cookie.
- If an admin deletes your player account, your suggestions and votes are removed as well.
`,
de: `
Pick'n'play hilft Gruppen dabei, fair und transparent zu entscheiden, welches Spiel als Nächstes gespielt wird. Spieler können Vorschläge einreichen, diese unabhängig bewerten und durch strukturierte Phasen gehen, die den Prozess organisiert und anonym halten. Es löst das klassische „Was sollen wir spielen?"-Chaos, indem es Gruppenentscheidungen in einen klaren, ausgewogenen und stressfreien Ablauf verwandelt.
## Konten & Anmeldung
### Wie erstelle ich ein Konto?
Registriere dich mit:
- Einem **eindeutigen Benutzernamen** (max. 24 Zeichen)
- Einem **Passwort**
- Einem **Anzeigenamen** (max. 16 Zeichen)
Dein Anzeigename ist erforderlich er erscheint neben all deinen Vorschlägen und Bewertungen.
### Brauche ich Admin-Rechte?
Wenn du einen **Admin-Schlüssel** erhalten hast, gib ihn bei der Registrierung ein. Ist der Schlüssel ungültig, wird die Anfrage abgelehnt. Admin-Rechte können später nicht hinzugefügt werden. Um Admin zu werden, musst du dich mit dem korrekten Schlüssel neu registrieren.
## Phasen im Überblick
### Persönliche Phasen
Jeder Spieler durchläuft die Phasen unabhängig voneinander:
**Vorschlagen → Abstimmen → Ergebnisse**
Klicke auf **„Weiter"**, um fortzufahren. Admins können sich bei Bedarf auch wieder zurücksetzen.
## Spiele vorschlagen
### Wie viele Spiele kann ich vorschlagen?
Bis zu **5 Vorschläge pro Spieler**.
### Pflichtfelder und Grenzen
- **Name** erforderlich (max. 100 Zeichen)
- **Genre** optional (max. 50 Zeichen)
- **Beschreibung** optional (max. 500 Zeichen)
- **Links** optional (URLs bis zu 2048 Zeichen)
### Min./Max. Spieleranzahl
- Müssen gemeinsam ausgefüllt werden (oder beide leer bleiben)
- Werte müssen zwischen **1 und 32** liegen
- Minimum muss ≤ Maximum sein
### Screenshot-Regeln
Wenn du eine Screenshot-URL angibst, muss sie:
- **http oder https** verwenden
- Mit einer gültigen Bilddateiendung enden (\`png\`, \`jpg\`, \`jpeg\`, \`gif\`, \`webp\`, \`avif\`)
- Direkt erreichbar sein (keine Weiterleitungen)
- Innerhalb von ~3 Sekunden laden
- Unter **5 MB**groß sein
- Nicht auf lokale oder private Hosts verweisen
Screenshots sind optional.
### Weitere Links
Spiel-Links und YouTube-Links müssen **http oder https** verwenden. Andere URL-Schemata werden abgelehnt.
### Vorschlag bearbeiten
Klicke auf das **Bearbeiten-Symbol (Stift)** auf einer Spielkarte, um Felder jederzeit zu aktualisieren.
### Vorschlag löschen
Klicke auf das **Löschen-Symbol (Kreuz)** auf einer Spielkarte, um sie zu entfernen außer du befindest dich bereits in der Abstimmungsphase (siehe unten).
### Warum wurde mein Vorschlag blockiert?
Häufige Gründe:
- Fehlender Anzeigename
- Das Limit von 5 Vorschlägen wurde erreicht
- Name überschreitet das Zeichenlimit
- Screenshot-URL ist ungültig, nicht erreichbar oder zu groß
- Min./Max.-Spieler fehlen oder sind ungültig
- Versuch, im falschen Phase einen Vorschlag hinzuzufügen
Überprüfe Fehlermeldungen unten rechts auf dem Bildschirm.
## Abstimmung
### Wer darf abstimmen?
Authentifizierte Spieler während der **Abstimmungsphase**.
### Wie vergebe ich Punkte?
Nutze den Schieberegler, um eine ganze Zahl von **0 bis 10** zu vergeben.
### Bearbeiten während der Abstimmung
- Die meisten Spieldetails können weiterhin bearbeitet werden
- Der **Spielname ist während der Abstimmungsphase gesperrt**
- Eigene Vorschläge können nicht mehr gelöscht werden
- Admins können Vorschläge bei Bedarf löschen
### Verknüpfte Duplikate
Wenn ein Admin doppelte Spiele verknüpft:
- Eine Punkteänderung wirkt sich auf alle verknüpften Einträge aus
- Punkte werden pro Gruppe gespeichert, nicht pro Einzeleintrag
### Abstimmung finalisieren
Mit **„Finalisieren"** werden deine Bewertungen gesperrt. Deaktiviere es, um erneut zu bearbeiten.
„Finalisieren" ist nur während der Abstimmungsphase verfügbar und wird automatisch zurückgesetzt, wenn:
- Ein Joker ein neues Spiel hinzufügt
- Ein Admin Spiele verknüpft oder trennt
### Abstimmen nach Änderungen
Wenn neue Spiele hinzugefügt oder Verknüpfungen geändert werden:
- Betroffene Stimmen werden gelöscht
- Deine Abstimmung wird automatisch zurückgesetzt
Überprüfe deine Liste und bewerte erneut, bevor du wieder finalisierst.
## Joker (Späte Ergänzungen)
### Was ist ein Joker?
Ein **Joker** ist ein einmaliger zusätzlicher Vorschlags-Slot, der nur während der **Abstimmungsphase** verfügbar ist. Ein Admin muss ihn dir gewähren.
### So funktioniert es
Wenn du einen Joker erhältst:
- Erscheint ein Button in der oberen Leiste, mit dem du ein weiteres Spiel hinzufügen kannst
- Nach der Nutzung wird der Joker sofort verbraucht
- Die Finalisierung aller Abstimmungen werden automatisch zurückgesetzt, damit das neue Spiel bewertet werden kann
Admins können bei Bedarf zusätzliche Joker vergeben.
## Ergebnisse
### Wann sind die Ergebnisse sichtbar?
Die Ergebnisse bleiben verborgen, bis ein Admin sie freigibt. Danach werden alle Spieler automatisch in die **Ergebnisphase** verschoben. Falls nötig, kann ein Admin die Ergebnisse wieder schließen: Alle kehren in die Abstimmungsphase zurück und alle Abstimmungen werden zur Anpassung zurückgesetzt.
### Kann ich in der Ergebnisphase etwas bearbeiten?
Nein. Vorschläge und Bewertungen sind schreibgeschützt. Wende dich bei Bedarf an einen Admin.
## Admin-Tools (Für Hosts)
### Was können Admin-Konten tun?
- Joker während der Abstimmung vergeben
- Doppelte Vorschläge verknüpfen oder trennen
- Vorschläge löschen
- Abstimmungsstatus einsehen (wer finalisiert hat)
- Einen Spieler löschen (inklusive dessen Vorschläge und Stimmen)
- Die Datenbank auf Werkseinstellungen zurücksetzen
- Zu vorherigen Phasen zurückkehren
### Was können Admin-Konten nicht tun?
- Einzelne Spielerbewertungen einsehen
Die Abstimmung bleibt anonym und fair.
## Häufige Fehler & Lösungen
### „Screenshot-URL muss http(s) verwenden und mit einer Bilddateiendung enden."
Stelle sicher:
- Der Link ist direkt (keine HTML-Seite)
- Er endet mit einer gültigen Bilddateiendung
- Die Datei ist unter 5 MB groß
- Es gibt keine Weiterleitungen
### „Du hast das Limit von 5 Vorschlägen erreicht."
Warte auf die Abstimmungsphase und bitte bei Bedarf um einen Joker.
### „Ungültiger Admin-Schlüssel."
Registriere dich erneut mit dem korrekten Schlüssel vom Host oder lasse das Feld leer, um ein normales Konto zu erstellen.
## Daten & Datenschutz
- Vorschläge, Stimmen und Phasenstatus werden in einer gemeinsamen **SQLite-Datenbank** gespeichert.
- Passwörtwer werden mit einer SHA256 Verschlüsselung gespeichert.
- Beim Abmelden wird dein Authentifizierungs-Cookie gelöscht.
- Wenn ein Admin dein Spielerkonto löscht, werden auch deine Vorschläge und Stimmen entfernt.
`,
};
const storageKey = "app_lang"; const storageKey = "app_lang";
const defaultLang = "en"; const defaultLang = "en";
const translationsAssetUrl = new URL(
"../data/i18n/translations.json",
import.meta.url,
);
const faqBaseUrl = new URL("../data/i18n/faq/", import.meta.url);
let currentLang = defaultLang; let currentLang = defaultLang;
const listeners = []; const listeners = [];
let assetsLoadingPromise = null;
let assetsLoaded = false;
export let translations = {};
export let faqMarkdown = {};
function isRecord(value) {
return value !== null && typeof value === "object" && !Array.isArray(value);
}
function validateTranslations(raw) {
if (!isRecord(raw)) {
throw new Error("Invalid i18n translations payload.");
}
const languages = Object.keys(raw);
if (languages.length === 0 || !isRecord(raw[defaultLang])) {
throw new Error(
`Missing default translation language "${defaultLang}".`,
);
}
const defaultDict = raw[defaultLang];
const defaultKeys = Object.keys(defaultDict);
languages.forEach((lang) => {
const dict = raw[lang];
if (!isRecord(dict)) {
throw new Error(
`Invalid translation dictionary for language "${lang}".`,
);
}
Object.entries(dict).forEach(([key, value]) => {
if (typeof value !== "string") {
throw new Error(
`Invalid translation value for "${lang}.${key}".`,
);
}
});
const missing = defaultKeys.filter(
(key) => typeof dict[key] !== "string",
);
if (missing.length > 0) {
throw new Error(
`Missing translation keys for "${lang}": ${missing.join(", ")}`,
);
}
});
return raw;
}
async function loadJson(url, label) {
const response = await fetch(url, { cache: "no-store" });
if (!response.ok) {
throw new Error(`Failed to load ${label}: ${response.status}`);
}
return response.json();
}
async function loadText(url, label) {
const response = await fetch(url, { cache: "no-store" });
if (!response.ok) {
throw new Error(`Failed to load ${label}: ${response.status}`);
}
return response.text();
}
async function loadFaqMarkdownForLanguages(languages) {
if (!Array.isArray(languages) || languages.length === 0) {
throw new Error("No i18n languages available for FAQ loading.");
}
const faqByLanguage = {};
for (const lang of languages) {
const faqUrl = new URL(`${lang}.md`, faqBaseUrl);
try {
faqByLanguage[lang] = await loadText(faqUrl, `faq.${lang}`);
} catch (err) {
if (lang === defaultLang) {
throw err;
}
}
}
const fallback = faqByLanguage[defaultLang];
if (typeof fallback !== "string" || fallback.trim().length === 0) {
throw new Error(`Missing default FAQ language "${defaultLang}".`);
}
languages.forEach((lang) => {
const value = faqByLanguage[lang];
faqByLanguage[lang] =
typeof value === "string" && value.trim().length > 0
? value
: fallback;
});
return faqByLanguage;
}
async function ensureAssetsLoaded() {
if (assetsLoaded) return;
if (assetsLoadingPromise) return assetsLoadingPromise;
assetsLoadingPromise = (async () => {
const translationsRaw = await loadJson(
translationsAssetUrl,
"translations",
);
translations = validateTranslations(translationsRaw);
faqMarkdown = await loadFaqMarkdownForLanguages(
Object.keys(translations),
);
assetsLoaded = true;
})().finally(() => {
assetsLoadingPromise = null;
});
return assetsLoadingPromise;
}
function interpolate(template, params = {}) { function interpolate(template, params = {}) {
return template.replace(/\{(\w+)\}/g, (_, key) => (params[key] ?? `{${key}}`)); return template.replace(
/\{(\w+)\}/g,
(_, key) => params[key] ?? `{${key}}`,
);
} }
function t(key, params) { function t(key, params) {
const fallback = translations[defaultLang][key] ?? key; const fallback = translations[defaultLang]?.[key] ?? key;
const phrase = translations[currentLang]?.[key] ?? fallback; const phrase = translations[currentLang]?.[key] ?? fallback;
return interpolate(phrase, params); return interpolate(phrase, params);
} }
function detectLanguage() { function detectLanguage() {
const stored = localStorage.getItem(storageKey); const stored = localStorage.getItem(storageKey);
if (stored && translations[stored]) return stored; if (stored && translations[stored]) return stored;
const nav = navigator.language?.slice(0, 2); const nav = navigator.language?.slice(0, 2);
if (nav && translations[nav]) return nav; if (nav && translations[nav]) return nav;
return defaultLang; return defaultLang;
} }
function applyTranslations(root = document) { function applyTranslations(root = document) {
root.querySelectorAll("[data-i18n]").forEach((el) => { root.querySelectorAll("[data-i18n]").forEach((el) => {
const key = el.dataset.i18n; const key = el.dataset.i18n;
const attrs = (el.dataset.i18nAttr || "") const attrs = (el.dataset.i18nAttr || "")
.split(",") .split(",")
.map((a) => a.trim()) .map((a) => a.trim())
.filter(Boolean); .filter(Boolean);
const text = t(key); const text = t(key);
if (attrs.length === 0) { if (attrs.length === 0) {
el.textContent = text; el.textContent = text;
} else { } else {
attrs.forEach((attr) => el.setAttribute(attr, text)); attrs.forEach((attr) => el.setAttribute(attr, text));
} }
}); });
} }
function notify() { function notify() {
listeners.forEach((fn) => fn(currentLang)); listeners.forEach((fn) => fn(currentLang));
} }
function setLanguage(lang) { function setLanguage(lang) {
if (!translations[lang]) lang = defaultLang; if (!translations[lang]) lang = defaultLang;
currentLang = lang; currentLang = lang;
localStorage.setItem(storageKey, lang); localStorage.setItem(storageKey, lang);
document.documentElement.lang = lang; document.documentElement.lang = lang;
applyTranslations(); applyTranslations();
notify(); notify();
} }
function getLanguage() { function getLanguage() {
return currentLang; return currentLang;
} }
function initI18n() { async function initI18n() {
currentLang = detectLanguage(); await ensureAssetsLoaded();
document.documentElement.lang = currentLang; currentLang = detectLanguage();
applyTranslations(); document.documentElement.lang = currentLang;
notify(); applyTranslations();
return currentLang; notify();
return currentLang;
} }
function onLanguageChange(fn) { function onLanguageChange(fn) {
listeners.push(fn); listeners.push(fn);
} }
export { t, setLanguage, getLanguage, initI18n, applyTranslations, onLanguageChange, translations, faqMarkdown }; export {
t,
setLanguage,
getLanguage,
initI18n,
applyTranslations,
onLanguageChange,
};

125
wwwroot/js/modals-ui.js Normal file
View File

@@ -0,0 +1,125 @@
import { t } from "./i18n.js";
import { toast } from "./dom.js";
import { escapeHtml } from "./ui-utils.js";
export function openLightbox(url, title) {
const overlay = document.createElement("div");
overlay.className = "lightbox";
const safeTitle = escapeHtml(title || "");
overlay.innerHTML = `
<div class="lightbox-content">
<button class="lightbox-close" aria-label="${t("lightbox.close")}">✕</button>
<img src="${url}" alt="${safeTitle}" />
<p>${safeTitle}</p>
</div>
`;
overlay.addEventListener("click", (e) => {
if (
e.target.classList.contains("lightbox") ||
e.target.classList.contains("lightbox-close")
) {
overlay.remove();
}
});
document.body.appendChild(overlay);
}
export function openConfirmModal({
title,
body,
confirmLabel,
cancelLabel = t("modal.cancel"),
confirmClass = null,
requirePassword = false,
passwordLabel = t("auth.password"),
onConfirm,
}) {
const overlay = document.createElement("div");
overlay.className = "edit-modal";
const panel = document.createElement("div");
panel.className = "edit-panel";
panel.innerHTML = `
<div class="edit-header">
<h3>${title}</h3>
<button class="lightbox-close" aria-label="${t("modal.close")}">x</button>
</div>
<div class="edit-body">
<p>${body}</p>
</div>
`;
const close = () => overlay.remove();
const actions = document.createElement("div");
actions.className = "stack horizontal confirm-actions";
const confirmBtn = document.createElement("button");
if (confirmClass) confirmBtn.className = confirmClass;
confirmBtn.textContent = confirmLabel ?? t("modal.confirm");
confirmBtn.disabled = requirePassword;
actions.append(confirmBtn);
if (cancelLabel !== null && cancelLabel !== undefined) {
const cancelBtn = document.createElement("button");
cancelBtn.className = "ghost";
cancelBtn.type = "button";
cancelBtn.textContent = cancelLabel;
actions.append(cancelBtn);
cancelBtn.addEventListener("click", close);
}
const bodyContainer = panel.querySelector(".edit-body");
let passwordInput = null;
if (requirePassword && bodyContainer) {
const field = document.createElement("label");
field.className = "stack";
const label = document.createElement("span");
label.className = "label";
label.textContent = passwordLabel;
passwordInput = document.createElement("input");
passwordInput.type = "password";
passwordInput.autocomplete = "current-password";
field.append(label, passwordInput);
bodyContainer.appendChild(field);
passwordInput.addEventListener("input", () => {
confirmBtn.disabled = !(passwordInput.value || "").trim();
});
}
bodyContainer?.appendChild(actions);
overlay.addEventListener("click", (e) => {
if (
e.target.classList.contains("edit-modal") ||
e.target.classList.contains("lightbox-close")
) {
close();
}
});
confirmBtn.addEventListener("click", async () => {
try {
await onConfirm?.(close, { password: passwordInput?.value ?? "" });
} catch (err) {
toast(err.message, true);
}
});
overlay.appendChild(panel);
document.body.appendChild(overlay);
}
export function openResultsRelockModal() {
openConfirmModal({
title: t("results.relockedTitle"),
body: t("results.relockedBody"),
confirmLabel: t("results.relockedConfirm"),
cancelLabel: null,
onConfirm: (close) => close(),
});
}
export function openSuggestionsChangedModal(names) {
const uniq = Array.from(new Set(names)).filter(Boolean);
if (uniq.length === 0) return;
openConfirmModal({
title: t("vote.listUpdatedTitle"),
body: t("vote.listUpdatedBody", { names: uniq.join(", ") }),
confirmLabel: t("vote.listUpdatedConfirm"),
cancelLabel: null,
onConfirm: (close) => close(),
});
}

122
wwwroot/js/results-ui.js Normal file
View File

@@ -0,0 +1,122 @@
import { t } from "./i18n.js";
import { state } from "./state.js";
import { $ } from "./dom.js";
import {
linkRootId,
renderLinkBadge,
escapeHtml,
safeUrl,
} from "./ui-utils.js";
import { scoreToEmoji } from "./votes-ui.js";
import { openLightbox } from "./modals-ui.js";
export function renderResults() {
const container = $("results-list");
if (!container) return;
container.innerHTML = "";
const table = document.createElement("table");
table.className = "results-table";
table.innerHTML = `
<thead>
<tr>
<th>${t("results.rank")}</th>
<th>${t("results.game")}</th>
<th>${t("results.author")}</th>
<th>${t("results.average")}</th>
<th>${t("results.votesList")}</th>
<th>${t("results.myVote")}</th>
<th>${t("results.links")}</th>
</tr>
</thead>
<tbody></tbody>
`;
const tbody = table.querySelector("tbody");
const rankByRoot = new Map();
let nextRank = 1;
state.results.forEach((r) => {
const root = linkRootId(r);
let rank = rankByRoot.get(root);
if (!rank) {
rank = nextRank++;
rankByRoot.set(root, rank);
}
const medal =
rank === 1
? "🥇"
: rank === 2
? "🥈"
: rank === 3
? "🥉"
: `${rank}`;
const row = document.createElement("tr");
const podiumClass =
rank === 1
? "podium podium-1"
: rank === 2
? "podium podium-2"
: rank === 3
? "podium podium-3"
: "";
row.className = podiumClass;
const safeName = escapeHtml(r.name);
const safeAuthor = escapeHtml(r.author ?? "—");
const safeShot = safeUrl(r.screenshotUrl);
const safeGameUrl = safeUrl(r.gameUrl);
const safeYoutubeUrl = safeUrl(r.youtubeUrl);
row.innerHTML = `
<td class="rank-cell"><span class="medal">${medal}</span></td>
<td class="game-cell">
${safeShot ? `<img class="thumb clickable-thumb" src="${safeShot}" alt="${safeName}">` : ""}
<div class="game-meta">
<div class="title-line">
<span class="title-text">${safeName}</span>
${renderLinkBadge(r)}
</div>
${buildResultMeta(r)}
</div>
</td>
<td class="author-cell">${safeAuthor || "—"}</td>
<td>${r.average?.toFixed ? r.average.toFixed(1) : r.average}</td>
<td>${formatVotes(r.votes)}</td>
<td>${formatMyVote(r.myVote)}</td>
<td>
${safeGameUrl ? `<a class="link compact" href="${safeGameUrl}" target="_blank" rel="noopener">${t("results.link.site")}</a><br>` : ""}
${safeYoutubeUrl ? `<a class="link compact" href="${safeYoutubeUrl}" target="_blank" rel="noopener">${t("results.link.youtube")}</a>` : ""}
</td>
`;
tbody.appendChild(row);
});
const frame = document.createElement("div");
frame.className = "results-frame";
frame.appendChild(table);
container.appendChild(frame);
container.querySelectorAll(".clickable-thumb").forEach((img) => {
img.addEventListener("click", () => openLightbox(img.src, img.alt));
});
}
function buildResultMeta(r) {
const hasPlayers = r.minPlayers || r.maxPlayers;
const players = hasPlayers
? t("card.players", {
min: r.minPlayers ?? "?",
max: r.maxPlayers ?? "?",
})
: null;
const bits = [r.genre ? escapeHtml(r.genre) : null, players].filter(
Boolean,
);
if (bits.length === 0) return "";
return `<div class="muted small">${bits.join(" • ")}</div>`;
}
function formatVotes(votes) {
if (!Array.isArray(votes) || votes.length === 0) return "⚠️";
const sorted = [...votes].sort((a, b) => a - b);
return sorted.map((v) => scoreToEmoji(v)).join("");
}
function formatMyVote(score) {
if (score == null || Number.isNaN(score)) return "—";
return `${score} ${scoreToEmoji(score)}`;
}

View File

@@ -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() {
@@ -27,9 +28,11 @@ export function clearUserState() {
state.counts = null; state.counts = null;
state.mySuggestions = []; state.mySuggestions = [];
state.allSuggestions = []; state.allSuggestions = [];
state.allSuggestionsSig = null;
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");
} }

View File

@@ -0,0 +1,495 @@
import { api, adminApi } from "./api.js";
import { t } from "./i18n.js";
import { state } from "./state.js";
import { $, toast } from "./dom.js";
import { setupCardVisualHover, triggerCelebration } from "./effects.js";
import { renderAdminLinker } from "./admin-ui.js";
import { getUiRuntime } from "./ui-runtime.js";
import {
cssEscapeUrl,
escapeHtml,
isLinked,
linkedPeerTitles,
safeUrl,
sortByName,
} from "./ui-utils.js";
import { openConfirmModal, openLightbox } from "./modals-ui.js";
function updateSuggestButtonState() {
const btn = $("open-suggest-modal");
if (!btn) return;
const limit = 5;
const count = state.mySuggestions?.length ?? 0;
const blocked = count >= limit;
btn.disabled = blocked || state.phase !== "Suggest";
btn.textContent = blocked
? t("suggest.maxReached")
: t("suggest.addButton");
}
export function renderMySuggestions() {
const wrap = $("my-suggestions");
if (!wrap) return;
wrap.innerHTML = "";
const allowEdit = true;
const lockTitle = state.phase !== "Suggest" && !state.me?.isAdmin;
const allowDelete = state.phase === "Suggest" || state.me?.isAdmin;
sortByName(state.mySuggestions).forEach((s) =>
wrap.appendChild(
buildCard(s, {
showAuthor: false,
allowDelete,
allowEdit,
lockTitle,
}),
),
);
updateSuggestButtonState();
}
export function renderAllSuggestions() {
renderAdminLinker();
const list = $("all-suggestions");
if (!list) return;
list.innerHTML = "";
const allowEdit = true;
const allowDelete = !!state.me?.isAdmin;
sortByName(state.allSuggestions).forEach((s) =>
list.appendChild(
buildCard(s, { showAuthor: true, allowEdit, allowDelete }),
),
);
renderPhaseTitles();
}
export function renderPhaseTitles() {
const voteTitle = $("vote-title");
const totalGames = state.allSuggestions?.length ?? 0;
if (voteTitle) {
voteTitle.textContent =
totalGames > 0
? t("section.vote.count", { count: totalGames })
: t("section.vote");
}
}
export function buildCard(
s,
{
showAuthor = false,
allowDelete = false,
allowEdit = false,
lockTitle = false,
},
) {
const card = document.createElement("article");
card.className = "game-card";
const hasImage = !!s.screenshotUrl;
const safeShot = safeUrl(s.screenshotUrl);
const nameText = escapeHtml(s.name);
const genreText = escapeHtml(s.genre);
const descText = escapeHtml(s.description);
const authorText = escapeHtml(s.author);
const safeGameUrl = safeUrl(s.gameUrl);
const safeYoutubeUrl = safeUrl(s.youtubeUrl);
const linkedTitles = linkedPeerTitles(s);
const linked = isLinked(s);
const linkTooltipText = linked
? linkedTitles.length > 0
? t("card.linkedWith", { names: linkedTitles.join(", ") })
: t("card.linked")
: "";
const linkTooltipSafe = escapeHtml(linkTooltipText);
const linkChip = linked
? `<button class="chip icon link-chip${state.me?.isAdmin ? " link-chip-action" : ""}" data-unlink="${s.id}" type="button" title="${linkTooltipSafe}">🔗</button>`
: "";
const visual =
hasImage && safeShot
? `<button class="card-visual" data-img="${safeShot}" aria-label="${t("card.openScreenshot")}" style="background-image:url('${cssEscapeUrl(safeShot)}')"></button>`
: `<div class="card-visual"></div>`;
const hasPlayers = s.minPlayers || s.maxPlayers;
const players = hasPlayers
? `${t("card.players", {
min: s.minPlayers ?? "?",
max: s.maxPlayers ?? "?",
})}`
: "";
const genreAndPlayers = s.genre
? hasPlayers
? `${genreText}${players}`
: genreText
: hasPlayers
? players
: undefined;
const hasExtraInfo = genreAndPlayers || safeGameUrl || safeYoutubeUrl;
card.innerHTML = `
${visual}
<div class="card-body">
<div class="card-title-row">
<h3 class="card-title" title="${nameText}">${nameText}</h3>
<div class="title-meta">
${linkChip}
${showAuthor && s.author ? `<span class="chip">${authorText}</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>` : ""}
</div>
</div>
${hasExtraInfo ? `<p class="muted">` : ""}
${genreAndPlayers ? genreAndPlayers : ""}
${safeGameUrl ? `<a class="link compact" href="${safeGameUrl}" target="_blank" rel="noopener">${t("card.site")}</a>` : ""}
${safeYoutubeUrl ? `<a class="link compact" href="${safeYoutubeUrl}" target="_blank" rel="noopener">${t("card.youtube")}</a>` : ""}
${hasExtraInfo ? `</p>` : ""}
${s.description ? `<p>${descText}</p>` : ""}
</div>
`;
if (hasImage) {
const btn = card.querySelector(".card-visual");
setupCardVisualHover(btn, safeShot);
btn.addEventListener("click", () => openLightbox(safeShot, s.name));
}
if (linked && state.me?.isAdmin) {
const unlinkBtn = card.querySelector("[data-unlink]");
unlinkBtn?.addEventListener("click", () => openUnlinkConfirm(s));
}
if (allowEdit) {
const editBtn = card.querySelector("[data-edit]");
editBtn?.addEventListener("click", () =>
openSuggestionModal({
title: t("modal.editTitle"),
submitLabel: t("modal.save"),
initial: s,
onSubmit: async (data, close) => {
await api.updateSuggestion(s.id, data);
toast(t("toast.savedChanges"));
close();
await getUiRuntime().refreshPhaseData();
},
lockTitle,
}),
);
}
if (allowDelete) {
const del = card.querySelector("[data-delete]");
del.addEventListener("click", async () => {
openDeleteConfirmModal(s);
});
}
return card;
}
function buildSuggestionForm(initial = {}, lockTitle = false) {
const form = document.createElement("form");
form.className = "stack suggestion-form";
form.innerHTML = `
<label class="stack">
<span class="label-row">
<span class="label" data-i18n="form.gameName">${t("form.gameName")}</span>
<span class="char-counter" data-for="name"></span>
</span>
<input name="name" required maxlength="100" />
</label>
<label class="stack">
<span class="label-row">
<span class="label" data-i18n="form.genre">${t("form.genre")}</span>
<span class="char-counter" data-for="genre"></span>
</span>
<input name="genre" maxlength="50" />
</label>
<label class="stack">
<span class="label-row">
<span class="label" data-i18n="form.description">${t("form.description")}</span>
<span class="char-counter" data-for="description"></span>
</span>
<textarea name="description" maxlength="500"></textarea>
</label>
<div class="stack">
<span class="label">${t("form.players")}</span>
<div class="stack horizontal">
<label class="stack">
<span class="label">${t("form.min")}</span>
<input name="minPlayers" type="number" min="1" max="32" inputmode="numeric" />
</label>
<label class="stack">
<span class="label">${t("form.max")}</span>
<input name="maxPlayers" type="number" min="1" max="32" inputmode="numeric" />
</label>
</div>
<div class="form-error hidden" data-error="players"></div>
</div>
<label class="stack">
<span class="label-row">
<span class="label" data-i18n="form.screenshot">${t("form.screenshot")}</span>
<span class="char-counter" data-for="screenshotUrl"></span>
</span>
<input name="screenshotUrl" maxlength="2048" />
<p class="hint" data-i18n="form.screenshotHint">${t("form.screenshotHint")}</p>
<div class="form-error hidden" data-error="screenshot"></div>
</label>
<label class="stack">
<span class="label-row">
<span class="label" data-i18n="form.youtube">${t("form.youtube")}</span>
<span class="char-counter" data-for="youtubeUrl"></span>
</span>
<input name="youtubeUrl" maxlength="2048" />
</label>
<label class="stack">
<span class="label-row">
<span class="label" data-i18n="form.gameUrl">${t("form.gameUrl")}</span>
<span class="char-counter" data-for="gameUrl"></span>
</span>
<input name="gameUrl" maxlength="2048" />
</label>
`;
const setVal = (name, value) => {
const input = form.querySelector(`[name="${name}"]`);
if (input) {
input.value = value ?? "";
if (name === "name" && lockTitle) {
input.readOnly = true;
input.classList.add("readonly");
}
}
};
setVal("name", initial.name ?? "");
setVal("genre", initial.genre ?? "");
const desc = form.querySelector("textarea[name=description]");
if (desc) desc.value = initial.description ?? "";
setVal("minPlayers", initial.minPlayers ?? "");
setVal("maxPlayers", initial.maxPlayers ?? "");
setVal("screenshotUrl", initial.screenshotUrl ?? "");
setVal("youtubeUrl", initial.youtubeUrl ?? "");
setVal("gameUrl", initial.gameUrl ?? "");
initCharCounters(form);
return form;
function initCharCounters(formEl) {
const inputs = formEl.querySelectorAll(
"input[maxlength], textarea[maxlength]",
);
inputs.forEach((input) => {
const counter = formEl.querySelector(
`.char-counter[data-for="${input.name}"]`,
);
if (!counter) return;
const update = () => {
const max = input.maxLength;
if (!max || max < 0) return;
const used = input.value?.length ?? 0;
counter.textContent = `${used}/${max}`;
};
input.addEventListener("input", update);
update();
});
}
}
function openSuggestionModal({
title,
submitLabel,
initial = {},
onSubmit,
lockTitle = false,
}) {
const overlay = document.createElement("div");
overlay.className = "edit-modal";
const panel = document.createElement("div");
panel.className = "edit-panel";
panel.innerHTML = `
<div class="edit-header">
<h3>${title}</h3>
<button class="lightbox-close" aria-label="${t("modal.close")}">x</button>
</div>
<div class="edit-body"></div>
`;
const form = buildSuggestionForm(initial, lockTitle);
const actions = document.createElement("div");
actions.className = "stack horizontal";
const submitBtn = document.createElement("button");
submitBtn.type = "submit";
submitBtn.textContent = submitLabel;
const cancelBtn = document.createElement("button");
cancelBtn.type = "button";
cancelBtn.className = "ghost";
cancelBtn.textContent = t("modal.cancel");
actions.append(submitBtn, cancelBtn);
form.appendChild(actions);
const close = () => overlay.remove();
overlay.addEventListener("click", (e) => {
if (
e.target.classList.contains("edit-modal") ||
e.target.classList.contains("lightbox-close")
) {
close();
}
});
cancelBtn.addEventListener("click", close);
form.addEventListener("submit", async (e) => {
e.preventDefault();
const data = normalizeSuggestionForm(new FormData(form));
const errorBox = form.querySelector('[data-error="players"]');
const minInput = form.querySelector('input[name="minPlayers"]');
const maxInput = form.querySelector('input[name="maxPlayers"]');
const markError = (msg) => {
if (errorBox) {
errorBox.textContent = msg;
errorBox.classList.remove("hidden");
}
};
const clearError = () => {
if (errorBox) errorBox.classList.add("hidden");
};
clearError();
const min = data.minPlayers;
const max = data.maxPlayers;
const inRange = (v) =>
v == null || (Number.isInteger(v) && v >= 1 && v <= 32);
const valid =
inRange(min) &&
inRange(max) &&
(min == null || max == null || min <= max);
[minInput, maxInput].forEach((el) =>
el?.classList.toggle("input-error", !valid),
);
if (!valid) {
markError(t("form.playersInvalid"));
return;
}
if (!data.name?.trim()) return toast(t("toast.nameRequired"), true);
try {
await onSubmit(data, close, submitBtn);
} catch (err) {
if (getUiRuntime().handleAuthError?.(err)) return;
toast(err.message, true);
}
});
panel.querySelector(".edit-body")?.appendChild(form);
overlay.appendChild(panel);
document.body.appendChild(overlay);
return overlay;
}
export function openNewSuggestionModal() {
openSuggestionModal({
title: t("modal.addTitle") || t("suggest.new"),
submitLabel: t("form.submit"),
initial: {},
onSubmit: async (data, close, submitBtn) => {
const wasVotePhase = state.phase === "Vote";
await api.createSuggestion(data);
toast(t("toast.suggestionAdded"));
if (submitBtn) triggerCelebration(submitBtn);
close();
if (wasVotePhase) {
await getUiRuntime().refreshPhaseData();
} else {
await getUiRuntime().loadSuggestData();
}
},
});
}
export function normalizeSuggestionForm(formData) {
const obj = Object.fromEntries(formData.entries());
const parseNum = (v) => {
if (v === undefined || v === null || v === "") return null;
const n = Number(v);
return Number.isFinite(n) ? n : null;
};
return {
name: obj.name?.trim(),
genre: obj.genre?.trim() || null,
description: obj.description?.trim() || null,
screenshotUrl: obj.screenshotUrl?.trim() || null,
youtubeUrl: obj.youtubeUrl?.trim() || null,
gameUrl: obj.gameUrl?.trim() || null,
minPlayers: parseNum(obj.minPlayers),
maxPlayers: parseNum(obj.maxPlayers),
};
}
function openDeleteConfirmModal(s) {
const overlay = document.createElement("div");
overlay.className = "edit-modal";
const panel = document.createElement("div");
panel.className = "edit-panel";
panel.innerHTML = `
<div class="edit-header">
<h3>${t("modal.confirmDeleteTitle")}</h3>
<button class="lightbox-close" aria-label="${t("modal.close")}">x</button>
</div>
<div class="edit-body delete-body"></div>
`;
const preview = buildCard(
{ ...s, id: s.id },
{ showAuthor: true, allowDelete: false, allowEdit: false },
);
preview.classList.add("preview-card");
const actions = document.createElement("div");
actions.className = "stack horizontal";
const confirmBtn = document.createElement("button");
confirmBtn.className = "danger";
confirmBtn.textContent = t("modal.confirmDelete");
const cancelBtn = document.createElement("button");
cancelBtn.type = "button";
cancelBtn.className = "ghost";
cancelBtn.textContent = t("modal.cancel");
actions.append(confirmBtn, cancelBtn);
const body = panel.querySelector(".delete-body");
body?.append(preview, actions);
const close = () => overlay.remove();
overlay.addEventListener("click", (e) => {
if (
e.target.classList.contains("edit-modal") ||
e.target.classList.contains("lightbox-close")
) {
close();
}
});
cancelBtn.addEventListener("click", close);
confirmBtn.addEventListener("click", async () => {
try {
await api.deleteSuggestion(s.id);
toast(t("toast.suggestionDeleted"));
close();
await getUiRuntime().loadSuggestData();
} catch (err) {
toast(err.message, true);
}
});
overlay.appendChild(panel);
document.body.appendChild(overlay);
}
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 getUiRuntime().refreshPhaseData();
} catch (err) {
toast(err.message, true);
}
},
});
}

14
wwwroot/js/ui-runtime.js Normal file
View File

@@ -0,0 +1,14 @@
const runtime = {
refreshPhaseData: async () => {},
loadSuggestData: async () => {},
loadVoteData: async () => {},
handleAuthError: () => false,
};
export function configureUiRuntime(deps) {
Object.assign(runtime, deps ?? {});
}
export function getUiRuntime() {
return runtime;
}

85
wwwroot/js/ui-utils.js Normal file
View File

@@ -0,0 +1,85 @@
import { t } from "./i18n.js";
import { state } from "./state.js";
export const sortByName = (items) =>
(items ?? [])
.slice()
.sort((a, b) =>
a.name.localeCompare(b.name, undefined, { sensitivity: "base" }),
);
export const truncate = (text, max) => {
if (!text) return "";
return text.length > max ? `${text.slice(0, max - 1)}...` : text;
};
export const escapeHtml = (value) =>
(value ?? "")
.toString()
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
export const safeUrl = (url) => {
if (!url) return null;
try {
const u = new URL(url);
if (u.protocol === "http:" || u.protocol === "https:") return u.href;
} catch {
return null;
}
return null;
};
export const cssEscapeUrl = (url) => url.replace(/['")\\]/g, "\\$&");
export function linkRootId(s) {
return s?.parentSuggestionId ?? s?.id;
}
export 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);
}
export 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);
}
export function isLinked(s) {
return !!s?.parentSuggestionId || linkedPeerIds(s).length > 0;
}
export function linkTooltip(s) {
const peers = linkedPeerTitles(s);
if (peers.length === 0) return t("card.linked");
return t("card.linkedWith", { names: peers.join(", ") });
}
export function renderLinkBadge(s) {
if (!isLinked(s)) return "";
return `<span class="chip icon link-chip" title="${escapeHtml(linkTooltip(s))}">🔗</span>`;
}
export function buildLinkOptionLabel(s) {
const author = s.author ? ` - ${s.author}` : "";
const linked = isLinked(s) ? " 🔗" : "";
return `${s.name}${author}${linked}`;
}

File diff suppressed because it is too large Load Diff

279
wwwroot/js/votes-ui.js Normal file
View File

@@ -0,0 +1,279 @@
import { api } from "./api.js";
import { t } from "./i18n.js";
import { state } from "./state.js";
import { $, toast } from "./dom.js";
import { renderAdminLinker, renderAdminVoteStatus } from "./admin-ui.js";
import { getUiRuntime } from "./ui-runtime.js";
import { linkedPeerIds, linkRootId, sortByName } from "./ui-utils.js";
import { buildCard } from "./suggestions-ui.js";
export function renderVotes() {
const list = $("vote-list");
if (!list) return;
const prevScroll = list.scrollTop;
list.innerHTML = "";
const votesMap = Object.fromEntries(
state.myVotes.map((v) => [v.suggestionId, v.score]),
);
sortByName(state.allSuggestions).forEach((s) => {
const canEdit = !!state.me?.isAdmin || s.isOwner;
const lockTitle = state.phase !== "Suggest" && !state.me?.isAdmin;
const li = buildCard(s, {
showAuthor: true,
allowEdit: canEdit,
allowDelete: !!state.me?.isAdmin,
lockTitle,
});
const hasVote = Object.prototype.hasOwnProperty.call(votesMap, s.id);
const current = hasVote ? votesMap[s.id] : 5;
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}" 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>`;
li.querySelector(".card-body").appendChild(footer);
list.appendChild(li);
});
updatePhaseNav();
updateMissingBadgeFromDom();
list.scrollTop = prevScroll;
list.querySelectorAll("input[type=range]").forEach((input) => {
input.addEventListener("input", (e) => {
if (state.votesFinal) return;
const val = Number(e.target.value);
const id = e.target.dataset.id;
$("score-" + id).textContent = val;
const emojiEl = $("emoji-" + id);
if (emojiEl) emojiEl.textContent = scoreToEmoji(val);
const warn = $("warn-" + id);
if (warn) warn.classList.remove("hidden");
e.target.dataset.pending = "1";
syncLinkedSliders(e.target, val);
updateMissingBadgeFromDom();
});
input.addEventListener("change", async (e) => {
if (state.votesFinal) return;
const suggestionId = Number(e.target.dataset.id);
const score = Number(e.target.value);
const prevScore = votesMap[suggestionId];
const linkedIds = (e.target.dataset.linked || "")
.split(",")
.filter(Boolean)
.map((x) => Number(x));
const resetUi = () => {
const label = $("score-" + suggestionId);
const emoji = $("emoji-" + suggestionId);
const warn = $("warn-" + suggestionId);
const fallbackValue = prevScore ?? 5;
const fallbackDisplay = prevScore ?? "—";
const fallbackEmoji =
prevScore != null ? scoreToEmoji(prevScore) : "⚠️";
e.target.value = fallbackValue;
if (label) label.textContent = fallbackDisplay;
if (emoji) emoji.textContent = fallbackEmoji;
if (warn) warn.classList.remove("hidden");
};
try {
await api.vote(suggestionId, score);
toast(t("vote.saved"));
delete e.target.dataset.pending;
const warn = $("warn-" + suggestionId);
if (warn) warn.classList.add("hidden");
linkedIds.forEach((id) => {
const peerWarn = $("warn-" + id);
if (peerWarn) peerWarn.classList.add("hidden");
const peerSlider = document.querySelector(
`input[type=range][data-id="${id}"]`,
);
if (peerSlider) delete peerSlider.dataset.pending;
});
await getUiRuntime().loadVoteData();
updateMissingBadgeFromDom();
} catch (err) {
delete e.target.dataset.pending;
resetUi();
toast(err.message, true);
}
});
});
}
export function syncVoteScores() {
const votesMap = Object.fromEntries(
state.myVotes.map((v) => [v.suggestionId, v.score]),
);
Object.entries(votesMap).forEach(([id, score]) => {
const slider = document.querySelector(
`input[type=range][data-id="${id}"]`,
);
const scoreLabel = $("score-" + id);
const emoji = $("emoji-" + id);
const warn = $("warn-" + id);
if (slider && score != null) {
slider.value = score;
if (scoreLabel) scoreLabel.textContent = score;
if (emoji) emoji.textContent = scoreToEmoji(score);
if (warn) warn.classList.add("hidden");
delete slider.dataset.pending;
}
});
document
.querySelectorAll("input[type=range][data-id]")
.forEach((slider) => {
const id = slider.dataset.id;
if (Object.prototype.hasOwnProperty.call(votesMap, Number(id)))
return;
const scoreLabel = $("score-" + id);
const emoji = $("emoji-" + id);
const warn = $("warn-" + id);
if (scoreLabel) scoreLabel.textContent = "—";
if (emoji) emoji.textContent = neutralEmoji();
if (warn) warn.classList.remove("hidden");
});
}
export function scoreToEmoji(score) {
if (score == null || Number.isNaN(score)) return neutralEmoji();
if (score < 1) return "😡";
if (score <= 3) return "😠";
if (score <= 6) return "😐";
if (score <= 8) return "🙂";
if (score <= 9) return "😃";
return "🤩";
}
export function neutralEmoji() {
return "😐";
}
function missingVotesCount() {
const total = state.allSuggestions?.length ?? 0;
const votedIds = new Set(state.myVotes?.map((v) => v.suggestionId));
const missing = total - votedIds.size;
return missing < 0 ? 0 : missing;
}
function updateMissingBadgeFromDom() {
const badge = $("vote-missing");
if (!badge) return;
if (state.votesFinal || state.phase !== "Vote") {
badge.classList.add("hidden");
return;
}
const missing = missingVotesCount();
badge.classList.toggle("hidden", missing === 0);
}
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.remove("hidden");
slider.dataset.pending = "1";
});
}
export function updatePhaseNav() {
const isAdmin = !!state.me?.isAdmin;
const phase = state.phase;
const showNav = (id, visible) => {
const el = $(id);
if (el) el.classList.toggle("hidden", !visible);
};
showNav("nav-suggest", phase === "Suggest");
showNav("nav-vote", phase === "Vote");
const jokerBtn = $("open-joker-modal");
if (jokerBtn) {
const showJoker = phase === "Vote" && state.hasJoker;
jokerBtn.classList.toggle("hidden", !showJoker);
jokerBtn.disabled = !showJoker;
}
const finalizeBtn = $("finalize-votes");
if (finalizeBtn) {
finalizeBtn.textContent = state.votesFinal
? t("vote.unfinalize")
: t("vote.finalize");
}
const voteMissingBadge = $("vote-missing");
if (voteMissingBadge) {
const missing = missingVotesCount();
const showMissing = !state.votesFinal && missing > 0;
voteMissingBadge.classList.toggle("hidden", !showMissing);
voteMissingBadge.textContent = t("vote.missingFooter");
}
const waitAdmin = $("vote-wait-admin");
if (waitAdmin) {
const show = state.votesFinal && phase === "Vote" && !state.resultsOpen;
waitAdmin.classList.toggle("hidden", !show);
waitAdmin.textContent = t("vote.waitAdmin");
}
const voteStatusText = $("vote-status-text");
if (voteStatusText) {
voteStatusText.textContent = state.votesFinal
? t("nav.voteFinalized")
: t("nav.voteHint");
}
renderAdminVoteStatus();
renderAdminLinker();
updateMissingBadgeFromDom();
const backButtons = ["nav-vote-prev"];
backButtons.forEach((id) => {
const btn = $(id);
if (btn) btn.classList.toggle("hidden", !isAdmin);
});
const suggestNext = $("nav-suggest-next");
const suggestHint = $("nav-suggest-hint");
if (suggestNext) {
const hasSuggestions = (state.mySuggestions?.length ?? 0) > 0;
const needsSuggestion = phase === "Suggest" && !hasSuggestions;
suggestNext.classList.toggle("hidden", needsSuggestion);
suggestNext.textContent = t("nav.next");
if (suggestHint) {
suggestHint.classList.toggle("hidden", !needsSuggestion);
suggestHint.textContent = t("nav.addSuggestionFirst");
}
}
const voteNext = $("nav-vote-next");
if (voteNext) {
const locked = !state.resultsOpen && !isAdmin;
voteNext.disabled = locked;
voteNext.textContent = locked
? t("nav.waitingForResults")
: t("nav.next");
}
const adminResultsToggle = $("results-open");
if (adminResultsToggle) {
adminResultsToggle.textContent = state.resultsOpen
? t("admin.resultsOpenDisable")
: t("admin.resultsOpenEnable");
}
}