Add per-user phase navigation with results toggle

This commit is contained in:
2026-02-04 21:43:12 +01:00
parent b64a33d833
commit e5e27af0af
24 changed files with 507 additions and 88 deletions

View File

@@ -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.

View File

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

View File

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

View File

@@ -0,0 +1,222 @@
// <auto-generated />
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
{
/// <inheritdoc />
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<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<bool>("ResultsOpen")
.HasColumnType("INTEGER");
b.Property<DateTimeOffset>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("TEXT");
b.Property<int>("CurrentPhase")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(0);
b.Property<string>("DisplayName")
.HasMaxLength(16)
.HasColumnType("TEXT");
b.Property<bool>("IsAdmin")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false);
b.Property<DateTimeOffset?>("LastLoginAt")
.HasColumnType("TEXT");
b.Property<string>("NormalizedUsername")
.IsRequired()
.HasMaxLength(24)
.HasColumnType("TEXT");
b.Property<byte[]>("PasswordHash")
.IsRequired()
.HasColumnType("BLOB");
b.Property<byte[]>("PasswordSalt")
.IsRequired()
.HasColumnType("BLOB");
b.Property<string>("Username")
.IsRequired()
.HasMaxLength(24)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("NormalizedUsername")
.IsUnique();
b.ToTable("Players");
});
modelBuilder.Entity("GameList.Domain.Suggestion", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("Description")
.HasMaxLength(500)
.HasColumnType("TEXT");
b.Property<string>("GameUrl")
.HasMaxLength(2048)
.HasColumnType("TEXT");
b.Property<string>("Genre")
.HasMaxLength(50)
.HasColumnType("TEXT");
b.Property<int?>("MaxPlayers")
.HasColumnType("INTEGER");
b.Property<int?>("MinPlayers")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("TEXT");
b.Property<Guid>("PlayerId")
.HasColumnType("TEXT");
b.Property<string>("ScreenshotUrl")
.HasMaxLength(2048)
.HasColumnType("TEXT");
b.Property<string>("YoutubeUrl")
.HasMaxLength(2048)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("PlayerId");
b.ToTable("Suggestions");
});
modelBuilder.Entity("GameList.Domain.Vote", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("TEXT");
b.Property<Guid>("PlayerId")
.HasColumnType("TEXT");
b.Property<int>("Score")
.HasColumnType("INTEGER");
b.Property<int>("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
}
}
}

View File

@@ -0,0 +1,51 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace GameList.Data.Migrations
{
/// <inheritdoc />
public partial class PerUserPhases : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "CurrentPhase",
table: "AppState");
migrationBuilder.AddColumn<int>(
name: "CurrentPhase",
table: "Players",
type: "INTEGER",
nullable: false,
defaultValue: 0);
migrationBuilder.AddColumn<bool>(
name: "ResultsOpen",
table: "AppState",
type: "INTEGER",
nullable: false,
defaultValue: false);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "ResultsOpen",
table: "AppState");
migrationBuilder.DropColumn(
name: "CurrentPhase",
table: "Players");
migrationBuilder.AddColumn<int>(
name: "CurrentPhase",
table: "AppState",
type: "INTEGER",
nullable: false,
defaultValue: 0);
}
}
}

View File

@@ -23,7 +23,7 @@ namespace GameList.Data.Migrations
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("CurrentPhase")
b.Property<bool>("ResultsOpen")
.HasColumnType("INTEGER");
b.Property<DateTimeOffset>("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<DateTimeOffset>("CreatedAt")
.HasColumnType("TEXT");
b.Property<int>("CurrentPhase")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(0);
b.Property<string>("DisplayName")
.HasMaxLength(16)
.HasColumnType("TEXT");

View File

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

View File

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

View File

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

View File

@@ -18,14 +18,14 @@ internal static class EndpointHelpers
return existing;
}
public static async Task<Phase> GetPhase(AppDbContext db)
public static async Task<Phase> 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
};
}

View File

@@ -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()

View File

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

View File

@@ -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)

View File

@@ -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." });

View File

@@ -4,6 +4,7 @@
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<UseAppHost>false</UseAppHost>
</PropertyGroup>
<ItemGroup>

View File

@@ -122,31 +122,39 @@ function setupHandlers() {
});
}
$("set-phase").addEventListener("click", async () => {
const phase = $("phase-select").value;
const prevPhaseBtn = $("prev-phase");
if (prevPhaseBtn) {
prevPhaseBtn.addEventListener("click", async () => {
try {
await adminApi.setPhase(phase);
toast(t("admin.phaseUpdated"));
const resp = await api.prevPhase();
state.prevPhase = state.phase;
state.phase = phase;
state.phase = resp.currentPhase;
state.resultsOpen = resp.resultsOpen ?? state.resultsOpen;
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";
});
});
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) {

View File

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

View File

@@ -83,6 +83,10 @@
align-items: center;
gap: 10px;
}
.status-center {
flex-wrap: wrap;
justify-content: center;
}
.logo-mark {
height: 65px;
margin: -10px;

View File

@@ -71,7 +71,10 @@
<a id="logout" href="#" class="link inline-link" data-i18n="auth.logout">Logout</a>
</div>
<div class="status-center">
<button id="prev-phase" class="chip nav-btn" type="button" data-i18n="nav.prev">Back</button>
<span id="phase-pill" data-i18n="phase.loading">Loading…</span>
<button id="next-phase" class="chip nav-btn" type="button" data-i18n="nav.next">Next</button>
<span class="badge warning hidden" id="results-lock" data-i18n="admin.resultsLocked">Results locked by admin</span>
<span class="counts" id="counts"></span>
</div>
<div class="status-right">
@@ -130,15 +133,10 @@
<h3 data-i18n="admin.title">Admin</h3>
<button id="admin-close" class="ghost"></button>
</div>
<div class="stack horizontal">
<select id="phase-select">
<option value="Suggest" data-i18n="phase.suggest">Suggest</option>
<option value="Reveal" data-i18n="phase.reveal">Reveal</option>
<option value="Vote" data-i18n="phase.vote">Vote</option>
<option value="Results" data-i18n="phase.results">Results</option>
</select>
<button id="set-phase" data-i18n="admin.setPhase">Set phase</button>
</div>
<label class="stack toggle-row">
<input type="checkbox" id="results-open" />
<span data-i18n="admin.resultsOpenToggle">Allow results phase</span>
</label>
<div class="stack horizontal">
<button id="reset" class="danger" data-i18n="admin.reset">Reset (keep players)</button>
<button id="factory-reset" class="danger" data-i18n="admin.factoryReset">Factory reset</button>

View File

@@ -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" }),
};

View File

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

View File

@@ -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",

View File

@@ -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 = [];

View File

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