From e5e27af0af7216a4090df14ecdf25fa7f81a2748 Mon Sep 17 00:00:00 2001 From: Frank Tovar Date: Wed, 4 Feb 2026 21:43:12 +0100 Subject: [PATCH] Add per-user phase navigation with results toggle --- AGENTS.md | 1 + Contracts/Dtos.cs | 2 +- Data/AppDbContext.cs | 3 +- .../20260204203441_PerUserPhases.Designer.cs | 222 ++++++++++++++++++ .../20260204203441_PerUserPhases.cs | 51 ++++ Data/Migrations/AppDbContextModelSnapshot.cs | 9 +- Domain/AppState.cs | 2 +- Domain/Player.cs | 1 + Endpoints/AdminEndpoints.cs | 15 +- Endpoints/EndpointHelpers.cs | 10 +- Endpoints/ResultsEndpoints.cs | 10 +- Endpoints/StateEndpoints.cs | 56 ++++- Endpoints/SuggestEndpoints.cs | 25 +- Endpoints/VoteEndpoints.cs | 14 +- GameList.csproj | 1 + wwwroot/app.js | 72 ++++-- wwwroot/css/components.css | 27 +++ wwwroot/css/layout.css | 4 + wwwroot/index.html | 16 +- wwwroot/js/api.js | 4 +- wwwroot/js/data.js | 3 +- wwwroot/js/i18n.js | 18 +- wwwroot/js/state.js | 2 + wwwroot/js/ui.js | 27 ++- 24 files changed, 507 insertions(+), 88 deletions(-) create mode 100644 Data/Migrations/20260204203441_PerUserPhases.Designer.cs create mode 100644 Data/Migrations/20260204203441_PerUserPhases.cs diff --git a/AGENTS.md b/AGENTS.md index 6a8d944..202197c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -6,6 +6,7 @@ Also see the other related files: API.md, IIS.md, SPEC.md - After every iteration, do a git commit with a brief summary of the changes as a commit message. - 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 to build the project and migrate the dn. If this is blocked by a running dotnet process, feel free to kill the process and retry the operation 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. diff --git a/Contracts/Dtos.cs b/Contracts/Dtos.cs index 06e46e9..16a9384 100644 --- a/Contracts/Dtos.cs +++ b/Contracts/Dtos.cs @@ -4,4 +4,4 @@ public record SetNameRequest(string Name); public record SuggestionRequest(string Name, string? Genre, string? Description, string? ScreenshotUrl, string? YoutubeUrl, string? GameUrl, int? MinPlayers, int? MaxPlayers); public record SuggestionDto(int Id, string Name, string? Genre, string? Description, string? ScreenshotUrl, string? YoutubeUrl, string? GameUrl, int? MinPlayers, int? MaxPlayers); public record VoteRequest(int SuggestionId, int Score); -public record PhaseRequest(GameList.Domain.Phase Phase); +public record ResultsOpenRequest(bool ResultsOpen); diff --git a/Data/AppDbContext.cs b/Data/AppDbContext.cs index 9e69392..93ffc27 100644 --- a/Data/AppDbContext.cs +++ b/Data/AppDbContext.cs @@ -26,6 +26,7 @@ public class AppDbContext : DbContext builder.Property(p => p.PasswordHash).IsRequired(); builder.Property(p => p.PasswordSalt).IsRequired(); builder.Property(p => p.IsAdmin).HasDefaultValue(false); + builder.Property(p => p.CurrentPhase).HasDefaultValue(Phase.Suggest); builder.HasMany(p => p.Suggestions) .WithOne(s => s.Player!) .HasForeignKey(s => s.PlayerId) @@ -62,7 +63,7 @@ public class AppDbContext : DbContext builder.HasData(new AppState { Id = 1, - CurrentPhase = Phase.Suggest, + ResultsOpen = false, UpdatedAt = DateTimeOffset.UnixEpoch }); }); diff --git a/Data/Migrations/20260204203441_PerUserPhases.Designer.cs b/Data/Migrations/20260204203441_PerUserPhases.Designer.cs new file mode 100644 index 0000000..20553bf --- /dev/null +++ b/Data/Migrations/20260204203441_PerUserPhases.Designer.cs @@ -0,0 +1,222 @@ +// +using System; +using GameList.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace GameList.Data.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20260204203441_PerUserPhases")] + partial class PerUserPhases + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.2"); + + modelBuilder.Entity("GameList.Domain.AppState", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ResultsOpen") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("AppState"); + + b.HasData( + new + { + Id = 1, + ResultsOpen = false, + UpdatedAt = new DateTimeOffset(new DateTime(1970, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)) + }); + }); + + modelBuilder.Entity("GameList.Domain.Player", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CurrentPhase") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.Property("DisplayName") + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("IsAdmin") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(false); + + b.Property("LastLoginAt") + .HasColumnType("TEXT"); + + b.Property("NormalizedUsername") + .IsRequired() + .HasMaxLength(24) + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .IsRequired() + .HasColumnType("BLOB"); + + b.Property("PasswordSalt") + .IsRequired() + .HasColumnType("BLOB"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(24) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedUsername") + .IsUnique(); + + b.ToTable("Players"); + }); + + modelBuilder.Entity("GameList.Domain.Suggestion", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("GameUrl") + .HasMaxLength(2048) + .HasColumnType("TEXT"); + + b.Property("Genre") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("MaxPlayers") + .HasColumnType("INTEGER"); + + b.Property("MinPlayers") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("PlayerId") + .HasColumnType("TEXT"); + + b.Property("ScreenshotUrl") + .HasMaxLength(2048) + .HasColumnType("TEXT"); + + b.Property("YoutubeUrl") + .HasMaxLength(2048) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId"); + + b.ToTable("Suggestions"); + }); + + modelBuilder.Entity("GameList.Domain.Vote", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("PlayerId") + .HasColumnType("TEXT"); + + b.Property("Score") + .HasColumnType("INTEGER"); + + b.Property("SuggestionId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SuggestionId"); + + b.HasIndex("PlayerId", "SuggestionId") + .IsUnique(); + + b.ToTable("Votes"); + }); + + modelBuilder.Entity("GameList.Domain.Suggestion", b => + { + b.HasOne("GameList.Domain.Player", "Player") + .WithMany("Suggestions") + .HasForeignKey("PlayerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Player"); + }); + + modelBuilder.Entity("GameList.Domain.Vote", b => + { + b.HasOne("GameList.Domain.Player", "Player") + .WithMany("Votes") + .HasForeignKey("PlayerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("GameList.Domain.Suggestion", "Suggestion") + .WithMany("Votes") + .HasForeignKey("SuggestionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Player"); + + b.Navigation("Suggestion"); + }); + + modelBuilder.Entity("GameList.Domain.Player", b => + { + b.Navigation("Suggestions"); + + b.Navigation("Votes"); + }); + + modelBuilder.Entity("GameList.Domain.Suggestion", b => + { + b.Navigation("Votes"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Data/Migrations/20260204203441_PerUserPhases.cs b/Data/Migrations/20260204203441_PerUserPhases.cs new file mode 100644 index 0000000..d911d1f --- /dev/null +++ b/Data/Migrations/20260204203441_PerUserPhases.cs @@ -0,0 +1,51 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace GameList.Data.Migrations +{ + /// + public partial class PerUserPhases : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "CurrentPhase", + table: "AppState"); + + migrationBuilder.AddColumn( + name: "CurrentPhase", + table: "Players", + type: "INTEGER", + nullable: false, + defaultValue: 0); + + migrationBuilder.AddColumn( + name: "ResultsOpen", + table: "AppState", + type: "INTEGER", + nullable: false, + defaultValue: false); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "ResultsOpen", + table: "AppState"); + + migrationBuilder.DropColumn( + name: "CurrentPhase", + table: "Players"); + + migrationBuilder.AddColumn( + name: "CurrentPhase", + table: "AppState", + type: "INTEGER", + nullable: false, + defaultValue: 0); + } + } +} diff --git a/Data/Migrations/AppDbContextModelSnapshot.cs b/Data/Migrations/AppDbContextModelSnapshot.cs index 41134f6..fbe5c70 100644 --- a/Data/Migrations/AppDbContextModelSnapshot.cs +++ b/Data/Migrations/AppDbContextModelSnapshot.cs @@ -23,7 +23,7 @@ namespace GameList.Data.Migrations .ValueGeneratedOnAdd() .HasColumnType("INTEGER"); - b.Property("CurrentPhase") + b.Property("ResultsOpen") .HasColumnType("INTEGER"); b.Property("UpdatedAt") @@ -37,7 +37,7 @@ namespace GameList.Data.Migrations new { Id = 1, - CurrentPhase = 0, + ResultsOpen = false, UpdatedAt = new DateTimeOffset(new DateTime(1970, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)) }); }); @@ -51,6 +51,11 @@ namespace GameList.Data.Migrations b.Property("CreatedAt") .HasColumnType("TEXT"); + b.Property("CurrentPhase") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + b.Property("DisplayName") .HasMaxLength(16) .HasColumnType("TEXT"); diff --git a/Domain/AppState.cs b/Domain/AppState.cs index 7caca35..ef6439b 100644 --- a/Domain/AppState.cs +++ b/Domain/AppState.cs @@ -3,6 +3,6 @@ namespace GameList.Domain; public class AppState { public int Id { get; set; } = 1; - public Phase CurrentPhase { get; set; } = Phase.Suggest; + public bool ResultsOpen { get; set; } public DateTimeOffset UpdatedAt { get; set; } = DateTimeOffset.UnixEpoch; } diff --git a/Domain/Player.cs b/Domain/Player.cs index a71d44a..67442b9 100644 --- a/Domain/Player.cs +++ b/Domain/Player.cs @@ -20,6 +20,7 @@ public class Player public DateTimeOffset? LastLoginAt { get; set; } public bool IsAdmin { get; set; } + public Phase CurrentPhase { get; set; } = Phase.Suggest; public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow; diff --git a/Endpoints/AdminEndpoints.cs b/Endpoints/AdminEndpoints.cs index bb57e4d..33032fc 100644 --- a/Endpoints/AdminEndpoints.cs +++ b/Endpoints/AdminEndpoints.cs @@ -11,15 +11,17 @@ public static class AdminEndpoints { var admin = app.MapGroup("/api/admin"); - admin.MapPost("/phase", async ([FromBody] Contracts.PhaseRequest request, HttpContext ctx, AppDbContext db, IConfiguration config) => + admin.MapPost("/results", async ([FromBody] Contracts.ResultsOpenRequest request, HttpContext ctx, AppDbContext db, IConfiguration config) => { if (!await EndpointHelpers.IsAdmin(ctx, db, config)) return Results.Unauthorized(); var state = await db.AppState.FirstAsync(); - state.CurrentPhase = request.Phase; + state.ResultsOpen = request.ResultsOpen; state.UpdatedAt = DateTimeOffset.UtcNow; + await db.SaveChangesAsync(); - return Results.Ok(new { state.CurrentPhase, state.UpdatedAt }); + var currentState = await db.AppState.AsNoTracking().FirstAsync(); + return Results.Ok(new { currentState.ResultsOpen, currentState.UpdatedAt }); }); admin.MapPost("/reset", async (HttpContext ctx, AppDbContext db, IConfiguration config) => @@ -29,12 +31,13 @@ public static class AdminEndpoints await db.Votes.ExecuteDeleteAsync(); await db.Suggestions.ExecuteDeleteAsync(); + await db.Players.ExecuteUpdateAsync(p => p.SetProperty(x => x.CurrentPhase, Phase.Suggest)); var state = await db.AppState.FirstAsync(); - state.CurrentPhase = Phase.Suggest; + state.ResultsOpen = false; state.UpdatedAt = DateTimeOffset.UtcNow; await db.SaveChangesAsync(); - return Results.Ok(new { state.CurrentPhase, state.UpdatedAt }); + return Results.Ok(new { Phase = Phase.Suggest, state.ResultsOpen, state.UpdatedAt }); }); admin.MapPost("/factory-reset", async (HttpContext ctx, AppDbContext db, IConfiguration config) => @@ -54,7 +57,7 @@ public static class AdminEndpoints await tx.CommitAsync(); - return Results.Ok(new { fresh.CurrentPhase, fresh.UpdatedAt }); + return Results.Ok(new { Phase = Phase.Suggest, fresh.ResultsOpen, fresh.UpdatedAt }); }); } } diff --git a/Endpoints/EndpointHelpers.cs b/Endpoints/EndpointHelpers.cs index 95cf720..965a456 100644 --- a/Endpoints/EndpointHelpers.cs +++ b/Endpoints/EndpointHelpers.cs @@ -18,14 +18,14 @@ internal static class EndpointHelpers return existing; } - public static async Task GetPhase(AppDbContext db) + public static async Task GetPhase(AppDbContext db, Guid playerId) { - var state = await db.AppState.AsNoTracking().FirstAsync(); - return state.CurrentPhase; + var player = await db.Players.AsNoTracking().FirstOrDefaultAsync(p => p.Id == playerId); + return player?.CurrentPhase ?? Phase.Suggest; } public static IResult PhaseMismatch(Phase required, Phase current) => - Results.BadRequest(new { error = $"This endpoint is available in the {required} phase. Current phase is {current}." }); + Results.BadRequest(new { error = $"This endpoint is available in the {required} phase. Your current phase is {current}." }); public static string? TrimTo(string? input, int max) => string.IsNullOrWhiteSpace(input) @@ -138,7 +138,7 @@ internal static class EndpointHelpers public static AppState NewAppState() => new() { Id = 1, - CurrentPhase = Phase.Suggest, + ResultsOpen = false, UpdatedAt = DateTimeOffset.UnixEpoch }; } diff --git a/Endpoints/ResultsEndpoints.cs b/Endpoints/ResultsEndpoints.cs index 16d0903..fe99d02 100644 --- a/Endpoints/ResultsEndpoints.cs +++ b/Endpoints/ResultsEndpoints.cs @@ -12,13 +12,15 @@ public static class ResultsEndpoints "/api/results", async (HttpContext ctx, AppDbContext db) => { - var phase = await EndpointHelpers.GetPhase(db); - if (phase != Phase.Results) - return EndpointHelpers.PhaseMismatch(Phase.Results, phase); - var player = await EndpointHelpers.GetAuthenticatedPlayer(ctx, db); 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.GetPhase(db, player.Id); + if (phase != Phase.Results) + return EndpointHelpers.PhaseMismatch(Phase.Results, phase); var results = await db .Suggestions.AsNoTracking() diff --git a/Endpoints/StateEndpoints.cs b/Endpoints/StateEndpoints.cs index 7abd9c5..e74c1a4 100644 --- a/Endpoints/StateEndpoints.cs +++ b/Endpoints/StateEndpoints.cs @@ -1,5 +1,6 @@ using GameList.Contracts; using GameList.Data; +using GameList.Domain; using Microsoft.EntityFrameworkCore; using Microsoft.AspNetCore.Mvc; @@ -9,12 +10,16 @@ public static class StateEndpoints { public static void MapStateEndpoints(this IEndpointRouteBuilder app) { - app.MapGet("/api/state", async (AppDbContext db) => + app.MapGet("/api/state", async (HttpContext ctx, AppDbContext db) => { + var player = await EndpointHelpers.GetAuthenticatedPlayer(ctx, db); + if (player is null) return Results.Unauthorized(); + var state = await db.AppState.AsNoTracking().FirstAsync(); var summary = new { - state.CurrentPhase, + CurrentPhase = player.CurrentPhase, + state.ResultsOpen, state.UpdatedAt, Players = await db.Players.CountAsync(), Suggestions = await db.Suggestions.CountAsync(), @@ -27,7 +32,36 @@ public static class StateEndpoints { var player = await EndpointHelpers.GetAuthenticatedPlayer(ctx, db); if (player is null) return Results.Unauthorized(); - return Results.Ok(new { player.Id, player.DisplayName, player.Username, player.IsAdmin }); + return Results.Ok(new { player.Id, player.DisplayName, player.Username, player.IsAdmin, player.CurrentPhase }); + }); + + app.MapPost("/api/me/phase/next", async (HttpContext ctx, AppDbContext db) => + { + var player = await EndpointHelpers.GetAuthenticatedPlayer(ctx, db); + if (player is null) return Results.Unauthorized(); + + var next = NextPhase(player.CurrentPhase); + var appState = await db.AppState.FirstAsync(); + + if (next == Phase.Results && !appState.ResultsOpen) + { + return Results.BadRequest(new { error = "Results are locked until the admin enables them." }); + } + + player.CurrentPhase = next; + await db.SaveChangesAsync(); + return Results.Ok(new { player.CurrentPhase, appState.ResultsOpen }); + }); + + app.MapPost("/api/me/phase/prev", async (HttpContext ctx, AppDbContext db) => + { + var player = await EndpointHelpers.GetAuthenticatedPlayer(ctx, db); + if (player is null) return Results.Unauthorized(); + + player.CurrentPhase = PrevPhase(player.CurrentPhase); + await db.SaveChangesAsync(); + var appState = await db.AppState.AsNoTracking().FirstAsync(); + return Results.Ok(new { player.CurrentPhase, appState.ResultsOpen }); }); app.MapPost("/api/me/name", async ([FromBody] SetNameRequest request, HttpContext ctx, AppDbContext db) => @@ -46,4 +80,20 @@ public static class StateEndpoints return Results.Ok(new { player.Id, player.DisplayName }); }); } + + private static Phase NextPhase(Phase current) => current switch + { + Phase.Suggest => Phase.Reveal, + Phase.Reveal => Phase.Vote, + Phase.Vote => Phase.Results, + _ => Phase.Results + }; + + private static Phase PrevPhase(Phase current) => current switch + { + Phase.Results => Phase.Vote, + Phase.Vote => Phase.Reveal, + Phase.Reveal => Phase.Suggest, + _ => Phase.Suggest + }; } diff --git a/Endpoints/SuggestEndpoints.cs b/Endpoints/SuggestEndpoints.cs index b2c5ec7..e34a863 100644 --- a/Endpoints/SuggestEndpoints.cs +++ b/Endpoints/SuggestEndpoints.cs @@ -12,12 +12,11 @@ public static class SuggestEndpoints { app.MapGet("/api/suggestions/mine", async (HttpContext ctx, AppDbContext db) => { - var phase = await EndpointHelpers.GetPhase(db); - if (phase != Phase.Suggest) - return EndpointHelpers.PhaseMismatch(Phase.Suggest, phase); - var player = await EndpointHelpers.GetAuthenticatedPlayer(ctx, db); if (player is null) return Results.Unauthorized(); + var phase = await EndpointHelpers.GetPhase(db, player.Id); + if (phase != Phase.Suggest) + return EndpointHelpers.PhaseMismatch(Phase.Suggest, phase); var mine = await db.Suggestions.AsNoTracking() .Where(s => s.PlayerId == player.Id) .Select(s => new @@ -44,10 +43,6 @@ public static class SuggestEndpoints app.MapPost("/api/suggestions", async ([FromBody] SuggestionRequest request, HttpContext ctx, AppDbContext db, IHttpClientFactory http) => { - var phase = await EndpointHelpers.GetPhase(db); - if (phase != Phase.Suggest) - return EndpointHelpers.PhaseMismatch(Phase.Suggest, phase); - if (string.IsNullOrWhiteSpace(request.Name) || request.Name.Length > 100) { return Results.BadRequest(new { error = "Name is required and must be <= 100 characters." }); @@ -67,6 +62,9 @@ public static class SuggestEndpoints var player = await EndpointHelpers.GetAuthenticatedPlayer(ctx, db); if (player is null) return Results.Unauthorized(); + var phase = await EndpointHelpers.GetPhase(db, player.Id); + if (phase != Phase.Suggest) + return EndpointHelpers.PhaseMismatch(Phase.Suggest, phase); if (string.IsNullOrWhiteSpace(player.DisplayName)) { @@ -104,7 +102,7 @@ public static class SuggestEndpoints if (player is null) return Results.Unauthorized(); var isAdmin = await EndpointHelpers.IsAdmin(ctx, db, config); - var phase = await EndpointHelpers.GetPhase(db); + var phase = await EndpointHelpers.GetPhase(db, player.Id); if (!isAdmin && phase != Phase.Suggest) return EndpointHelpers.PhaseMismatch(Phase.Suggest, phase); @@ -128,7 +126,7 @@ public static class SuggestEndpoints { if (player is null) return Results.Unauthorized(); - var phase = await EndpointHelpers.GetPhase(db); + var phase = await EndpointHelpers.GetPhase(db, player.Id); if (phase != Phase.Suggest) return EndpointHelpers.PhaseMismatch(Phase.Suggest, phase); } @@ -186,12 +184,11 @@ public static class SuggestEndpoints app.MapGet("/api/suggestions/all", async (HttpContext ctx, AppDbContext db) => { - var phase = await EndpointHelpers.GetPhase(db); - if (phase < Phase.Reveal) - return EndpointHelpers.PhaseMismatch(Phase.Reveal, phase); - var player = await EndpointHelpers.GetAuthenticatedPlayer(ctx, db); if (player is null) return Results.Unauthorized(); + var phase = await EndpointHelpers.GetPhase(db, player.Id); + if (phase < Phase.Reveal) + return EndpointHelpers.PhaseMismatch(Phase.Reveal, phase); var all = await db.Suggestions.AsNoTracking() .Include(s => s.Player) diff --git a/Endpoints/VoteEndpoints.cs b/Endpoints/VoteEndpoints.cs index 18c41ae..d0c2ce0 100644 --- a/Endpoints/VoteEndpoints.cs +++ b/Endpoints/VoteEndpoints.cs @@ -12,12 +12,11 @@ public static class VoteEndpoints { app.MapGet("/api/votes/mine", async (HttpContext ctx, AppDbContext db) => { - var phase = await EndpointHelpers.GetPhase(db); - if (phase != Phase.Vote) - return EndpointHelpers.PhaseMismatch(Phase.Vote, phase); - var player = await EndpointHelpers.GetAuthenticatedPlayer(ctx, db); if (player is null) return Results.Unauthorized(); + var phase = await EndpointHelpers.GetPhase(db, 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 }) @@ -28,15 +27,14 @@ public static class VoteEndpoints app.MapPost("/api/votes", async ([FromBody] VoteRequest request, HttpContext ctx, AppDbContext db) => { - var phase = await EndpointHelpers.GetPhase(db); - if (phase != Phase.Vote) - return EndpointHelpers.PhaseMismatch(Phase.Vote, phase); - 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); if (player is null) return Results.Unauthorized(); + var phase = await EndpointHelpers.GetPhase(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." }); diff --git a/GameList.csproj b/GameList.csproj index 3e5e966..289b6b3 100644 --- a/GameList.csproj +++ b/GameList.csproj @@ -4,6 +4,7 @@ net10.0 enable enable + false diff --git a/wwwroot/app.js b/wwwroot/app.js index 97b0d78..feb28b9 100644 --- a/wwwroot/app.js +++ b/wwwroot/app.js @@ -122,31 +122,39 @@ function setupHandlers() { }); } - $("set-phase").addEventListener("click", async () => { - const phase = $("phase-select").value; - try { - await adminApi.setPhase(phase); - toast(t("admin.phaseUpdated")); - state.prevPhase = state.phase; - state.phase = phase; - state.votesRendered = false; - renderPhasePill(); - $("phase-select").dataset.userEditing = ""; - await refreshPhaseData(); - } catch (err) { - toast(err.message, true); - } - }); - - const phaseSelect = $("phase-select"); - ["focus", "input", "click"].forEach((evt) => { - phaseSelect.addEventListener(evt, () => { - phaseSelect.dataset.userEditing = "1"; + const prevPhaseBtn = $("prev-phase"); + if (prevPhaseBtn) { + prevPhaseBtn.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); + } }); - }); - phaseSelect.addEventListener("blur", () => { - phaseSelect.dataset.userEditing = ""; - }); + } + + const nextPhaseBtn = $("next-phase"); + if (nextPhaseBtn) { + nextPhaseBtn.addEventListener("click", async () => { + try { + 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); + } + }); + } $("reset").addEventListener("click", () => adminAction(adminApi.reset, t("admin.resetDone"))); $("factory-reset").addEventListener("click", () => adminAction(adminApi.factoryReset, t("admin.factoryResetDone"))); @@ -182,6 +190,22 @@ function setupHandlers() { adminToggle.addEventListener("click", () => togglePanel(adminCard.classList.contains("hidden"))); adminClose.addEventListener("click", () => togglePanel(false)); } + + const resultsToggle = $("results-open"); + if (resultsToggle) { + resultsToggle.addEventListener("change", async (e) => { + const desired = !!e.target.checked; + try { + const resp = await adminApi.setResultsOpen(desired); + state.resultsOpen = resp.resultsOpen; + renderPhasePill(); + toast(t("admin.resultsUpdated")); + } catch (err) { + e.target.checked = !desired; + toast(err.message, true); + } + }); + } } async function adminAction(fn, successMessage) { diff --git a/wwwroot/css/components.css b/wwwroot/css/components.css index 68d28fb..20e573c 100644 --- a/wwwroot/css/components.css +++ b/wwwroot/css/components.css @@ -108,6 +108,33 @@ button .chip { border-color: #a83a35; } +.nav-btn { + min-width: 64px; + font-weight: 700; +} + +.badge { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 4px 8px; + border-radius: 999px; + font-size: 12px; + border: 1px solid transparent; +} + +.badge.warning { + background: #fff0d6; + color: #7a4a00; + border-color: #f0c66b; +} + +.toggle-row { + display: flex; + gap: 8px; + align-items: center; + font-weight: 600; +} .vote-controls { display: flex; gap: 10px; diff --git a/wwwroot/css/layout.css b/wwwroot/css/layout.css index 5a12bcb..9867fb9 100644 --- a/wwwroot/css/layout.css +++ b/wwwroot/css/layout.css @@ -83,6 +83,10 @@ align-items: center; gap: 10px; } +.status-center { + flex-wrap: wrap; + justify-content: center; +} .logo-mark { height: 65px; margin: -10px; diff --git a/wwwroot/index.html b/wwwroot/index.html index 10e9492..efbf914 100644 --- a/wwwroot/index.html +++ b/wwwroot/index.html @@ -71,7 +71,10 @@ Logout
+ Loading… + +
@@ -130,15 +133,10 @@

Admin

-
- - -
+
diff --git a/wwwroot/js/api.js b/wwwroot/js/api.js index b078dcf..f0596ba 100644 --- a/wwwroot/js/api.js +++ b/wwwroot/js/api.js @@ -46,10 +46,12 @@ export const api = { vote: (suggestionId, score) => request("/api/votes", { method: "POST", body: { suggestionId, score } }), results: () => request("/api/results"), + nextPhase: () => request("/api/me/phase/next", { method: "POST" }), + prevPhase: () => request("/api/me/phase/prev", { method: "POST" }), }; export const adminApi = { - setPhase: (phase) => request("/api/admin/phase", { method: "POST", body: { phase } }), + setResultsOpen: (resultsOpen) => request("/api/admin/results", { method: "POST", body: { resultsOpen } }), reset: () => request("/api/admin/reset", { method: "POST" }), factoryReset: () => request("/api/admin/factory-reset", { method: "POST" }), }; diff --git a/wwwroot/js/data.js b/wwwroot/js/data.js index 8229723..55f53c5 100644 --- a/wwwroot/js/data.js +++ b/wwwroot/js/data.js @@ -8,6 +8,7 @@ export async function loadState() { state.me = me; state.prevPhase = state.phase; state.phase = stateData.currentPhase; + state.resultsOpen = stateData.resultsOpen; state.counts = stateData; if (state.prevPhase !== state.phase && state.phase === "Vote") { state.votesRendered = false; @@ -52,7 +53,7 @@ export async function loadVoteData() { } export async function loadResults() { - if (state.phase !== "Results") return; + if (state.phase !== "Results" || !state.resultsOpen) return; state.results = await api.results(); renderResults(); } diff --git a/wwwroot/js/i18n.js b/wwwroot/js/i18n.js index 8e01ad2..d35cbe4 100644 --- a/wwwroot/js/i18n.js +++ b/wwwroot/js/i18n.js @@ -31,6 +31,10 @@ const translations = { "counts.format": "Players: {players} • Suggestions: {suggestions} • Votes: {votes}", + "nav.prev": "Back", + "nav.next": "Next", + "nav.waitingForResults": "Waiting…", + "suggest.title": "Suggest games (up to 5)", "suggest.new": "Add new suggestion", "suggest.addButton": "Suggest a game", @@ -79,10 +83,11 @@ const translations = { "admin.title": "Admin", "admin.tools": "Admin tools", - "admin.setPhase": "Set phase", + "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.phaseUpdated": "Phase updated", "admin.resetDone": "Reset complete", "admin.factoryResetDone": "Factory reset complete", @@ -138,6 +143,10 @@ const translations = { "counts.format": "Spieler: {players} • Vorschläge: {suggestions} • Stimmen: {votes}", + "nav.prev": "Zurück", + "nav.next": "Weiter", + "nav.waitingForResults": "Warten…", + "suggest.title": "Schlage Spiele vor (bis zu 5)", "suggest.new": "Neuen Vorschlag hinzufügen", "suggest.addButton": "Spiel vorschlagen", @@ -186,10 +195,11 @@ const translations = { "admin.title": "Admin", "admin.tools": "Admin-Werkzeuge", - "admin.setPhase": "Phase setzen", + "admin.resultsOpenToggle": "Ergebnisse freigeben", + "admin.resultsLocked": "Ergebnisse vom Admin gesperrt", + "admin.resultsUpdated": "Ergebnisfreigabe aktualisiert", "admin.reset": "Zurücksetzen (Spieler behalten)", "admin.factoryReset": "Werkseinstellung", - "admin.phaseUpdated": "Phase aktualisiert", "admin.resetDone": "Zurücksetzen abgeschlossen", "admin.factoryResetDone": "Werkseinstellung abgeschlossen", diff --git a/wwwroot/js/state.js b/wwwroot/js/state.js index 4e654fe..34ee56f 100644 --- a/wwwroot/js/state.js +++ b/wwwroot/js/state.js @@ -4,6 +4,7 @@ export const state = { me: null, phase: null, prevPhase: null, + resultsOpen: false, counts: null, mySuggestions: [], allSuggestions: [], @@ -17,6 +18,7 @@ export function clearUserState() { state.me = null; state.phase = null; state.prevPhase = null; + state.resultsOpen = false; state.counts = null; state.mySuggestions = []; state.allSuggestions = []; diff --git a/wwwroot/js/ui.js b/wwwroot/js/ui.js index d632cb5..6cf53b4 100644 --- a/wwwroot/js/ui.js +++ b/wwwroot/js/ui.js @@ -65,7 +65,8 @@ export function handleAuthError(err, clearUserState) { export function renderPhasePill() { const phaseKey = typeof state.phase === "string" ? state.phase.toLowerCase() : null; - $("phase-pill").textContent = phaseKey ? "" : t("phase.loading"); + const pill = $("phase-pill"); + if (pill) pill.textContent = phaseKey ? t(`phase.${phaseKey}`) : t("phase.loading"); document.querySelectorAll(".phase-view").forEach((el) => el.classList.add("hidden"), ); @@ -77,9 +78,27 @@ export function renderPhasePill() { }; const id = viewMap[state.phase]; if (id) $(id).classList.remove("hidden"); - const phaseSelect = $("phase-select"); - if (phaseSelect && !phaseSelect.dataset.userEditing) { - phaseSelect.value = state.phase || "Suggest"; + + const prevBtn = $("prev-phase"); + if (prevBtn) prevBtn.disabled = state.phase === "Suggest"; + + const nextBtn = $("next-phase"); + if (nextBtn) { + const atResults = state.phase === "Results"; + const locked = !state.resultsOpen && state.phase === "Vote"; + nextBtn.disabled = atResults || locked; + nextBtn.textContent = locked ? t("nav.waitingForResults") : t("nav.next"); + } + + const resultsLock = $("results-lock"); + if (resultsLock) { + resultsLock.classList.toggle("hidden", state.resultsOpen); + resultsLock.textContent = t("admin.resultsLocked"); + } + + const adminResultsToggle = $("results-open"); + if (adminResultsToggle) { + adminResultsToggle.checked = !!state.resultsOpen; } }