Add per-user phase navigation with results toggle
This commit is contained in:
@@ -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.
|
- 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.
|
- 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
|
- Keep changes small and testable
|
||||||
- Avoid introducing new dependencies unless they remove complexity.
|
- 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 endpoint logic in `Endpoints/` and shared helpers/DTOs in their folders to avoid Program.cs bloat.
|
||||||
|
|||||||
@@ -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 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 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 VoteRequest(int SuggestionId, int Score);
|
||||||
public record PhaseRequest(GameList.Domain.Phase Phase);
|
public record ResultsOpenRequest(bool ResultsOpen);
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ public class AppDbContext : DbContext
|
|||||||
builder.Property(p => p.PasswordHash).IsRequired();
|
builder.Property(p => p.PasswordHash).IsRequired();
|
||||||
builder.Property(p => p.PasswordSalt).IsRequired();
|
builder.Property(p => p.PasswordSalt).IsRequired();
|
||||||
builder.Property(p => p.IsAdmin).HasDefaultValue(false);
|
builder.Property(p => p.IsAdmin).HasDefaultValue(false);
|
||||||
|
builder.Property(p => p.CurrentPhase).HasDefaultValue(Phase.Suggest);
|
||||||
builder.HasMany(p => p.Suggestions)
|
builder.HasMany(p => p.Suggestions)
|
||||||
.WithOne(s => s.Player!)
|
.WithOne(s => s.Player!)
|
||||||
.HasForeignKey(s => s.PlayerId)
|
.HasForeignKey(s => s.PlayerId)
|
||||||
@@ -62,7 +63,7 @@ public class AppDbContext : DbContext
|
|||||||
builder.HasData(new AppState
|
builder.HasData(new AppState
|
||||||
{
|
{
|
||||||
Id = 1,
|
Id = 1,
|
||||||
CurrentPhase = Phase.Suggest,
|
ResultsOpen = false,
|
||||||
UpdatedAt = DateTimeOffset.UnixEpoch
|
UpdatedAt = DateTimeOffset.UnixEpoch
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
222
Data/Migrations/20260204203441_PerUserPhases.Designer.cs
generated
Normal file
222
Data/Migrations/20260204203441_PerUserPhases.Designer.cs
generated
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
51
Data/Migrations/20260204203441_PerUserPhases.cs
Normal file
51
Data/Migrations/20260204203441_PerUserPhases.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -23,7 +23,7 @@ namespace GameList.Data.Migrations
|
|||||||
.ValueGeneratedOnAdd()
|
.ValueGeneratedOnAdd()
|
||||||
.HasColumnType("INTEGER");
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
b.Property<int>("CurrentPhase")
|
b.Property<bool>("ResultsOpen")
|
||||||
.HasColumnType("INTEGER");
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
b.Property<DateTimeOffset>("UpdatedAt")
|
b.Property<DateTimeOffset>("UpdatedAt")
|
||||||
@@ -37,7 +37,7 @@ namespace GameList.Data.Migrations
|
|||||||
new
|
new
|
||||||
{
|
{
|
||||||
Id = 1,
|
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))
|
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")
|
b.Property<DateTimeOffset>("CreatedAt")
|
||||||
.HasColumnType("TEXT");
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<int>("CurrentPhase")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasDefaultValue(0);
|
||||||
|
|
||||||
b.Property<string>("DisplayName")
|
b.Property<string>("DisplayName")
|
||||||
.HasMaxLength(16)
|
.HasMaxLength(16)
|
||||||
.HasColumnType("TEXT");
|
.HasColumnType("TEXT");
|
||||||
|
|||||||
@@ -3,6 +3,6 @@ namespace GameList.Domain;
|
|||||||
public class AppState
|
public class AppState
|
||||||
{
|
{
|
||||||
public int Id { get; set; } = 1;
|
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;
|
public DateTimeOffset UpdatedAt { get; set; } = DateTimeOffset.UnixEpoch;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ public class Player
|
|||||||
|
|
||||||
public DateTimeOffset? LastLoginAt { get; set; }
|
public DateTimeOffset? LastLoginAt { get; set; }
|
||||||
public bool IsAdmin { get; set; }
|
public bool IsAdmin { get; set; }
|
||||||
|
public Phase CurrentPhase { get; set; } = Phase.Suggest;
|
||||||
|
|
||||||
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
|
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
|
||||||
|
|
||||||
|
|||||||
@@ -11,15 +11,17 @@ public static class AdminEndpoints
|
|||||||
{
|
{
|
||||||
var admin = app.MapGroup("/api/admin");
|
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();
|
if (!await EndpointHelpers.IsAdmin(ctx, db, config)) return Results.Unauthorized();
|
||||||
|
|
||||||
var state = await db.AppState.FirstAsync();
|
var state = await db.AppState.FirstAsync();
|
||||||
state.CurrentPhase = request.Phase;
|
state.ResultsOpen = request.ResultsOpen;
|
||||||
state.UpdatedAt = DateTimeOffset.UtcNow;
|
state.UpdatedAt = DateTimeOffset.UtcNow;
|
||||||
|
|
||||||
await db.SaveChangesAsync();
|
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) =>
|
admin.MapPost("/reset", async (HttpContext ctx, AppDbContext db, IConfiguration config) =>
|
||||||
@@ -29,12 +31,13 @@ public static class AdminEndpoints
|
|||||||
await db.Votes.ExecuteDeleteAsync();
|
await db.Votes.ExecuteDeleteAsync();
|
||||||
await db.Suggestions.ExecuteDeleteAsync();
|
await db.Suggestions.ExecuteDeleteAsync();
|
||||||
|
|
||||||
|
await db.Players.ExecuteUpdateAsync(p => p.SetProperty(x => x.CurrentPhase, Phase.Suggest));
|
||||||
var state = await db.AppState.FirstAsync();
|
var state = await db.AppState.FirstAsync();
|
||||||
state.CurrentPhase = Phase.Suggest;
|
state.ResultsOpen = false;
|
||||||
state.UpdatedAt = DateTimeOffset.UtcNow;
|
state.UpdatedAt = DateTimeOffset.UtcNow;
|
||||||
await db.SaveChangesAsync();
|
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) =>
|
admin.MapPost("/factory-reset", async (HttpContext ctx, AppDbContext db, IConfiguration config) =>
|
||||||
@@ -54,7 +57,7 @@ public static class AdminEndpoints
|
|||||||
|
|
||||||
await tx.CommitAsync();
|
await tx.CommitAsync();
|
||||||
|
|
||||||
return Results.Ok(new { fresh.CurrentPhase, fresh.UpdatedAt });
|
return Results.Ok(new { Phase = Phase.Suggest, fresh.ResultsOpen, fresh.UpdatedAt });
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,14 +18,14 @@ internal static class EndpointHelpers
|
|||||||
return existing;
|
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();
|
var player = await db.Players.AsNoTracking().FirstOrDefaultAsync(p => p.Id == playerId);
|
||||||
return state.CurrentPhase;
|
return player?.CurrentPhase ?? Phase.Suggest;
|
||||||
}
|
}
|
||||||
|
|
||||||
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. 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) =>
|
public static string? TrimTo(string? input, int max) =>
|
||||||
string.IsNullOrWhiteSpace(input)
|
string.IsNullOrWhiteSpace(input)
|
||||||
@@ -138,7 +138,7 @@ internal static class EndpointHelpers
|
|||||||
public static AppState NewAppState() => new()
|
public static AppState NewAppState() => new()
|
||||||
{
|
{
|
||||||
Id = 1,
|
Id = 1,
|
||||||
CurrentPhase = Phase.Suggest,
|
ResultsOpen = false,
|
||||||
UpdatedAt = DateTimeOffset.UnixEpoch
|
UpdatedAt = DateTimeOffset.UnixEpoch
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,13 +12,15 @@ public static class ResultsEndpoints
|
|||||||
"/api/results",
|
"/api/results",
|
||||||
async (HttpContext ctx, AppDbContext db) =>
|
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);
|
var player = await EndpointHelpers.GetAuthenticatedPlayer(ctx, db);
|
||||||
if (player is null)
|
if (player is null)
|
||||||
return Results.Unauthorized();
|
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
|
var results = await db
|
||||||
.Suggestions.AsNoTracking()
|
.Suggestions.AsNoTracking()
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
using GameList.Contracts;
|
using GameList.Contracts;
|
||||||
using GameList.Data;
|
using GameList.Data;
|
||||||
|
using GameList.Domain;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
@@ -9,12 +10,16 @@ public static class StateEndpoints
|
|||||||
{
|
{
|
||||||
public static void MapStateEndpoints(this IEndpointRouteBuilder app)
|
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 state = await db.AppState.AsNoTracking().FirstAsync();
|
||||||
var summary = new
|
var summary = new
|
||||||
{
|
{
|
||||||
state.CurrentPhase,
|
CurrentPhase = player.CurrentPhase,
|
||||||
|
state.ResultsOpen,
|
||||||
state.UpdatedAt,
|
state.UpdatedAt,
|
||||||
Players = await db.Players.CountAsync(),
|
Players = await db.Players.CountAsync(),
|
||||||
Suggestions = await db.Suggestions.CountAsync(),
|
Suggestions = await db.Suggestions.CountAsync(),
|
||||||
@@ -27,7 +32,36 @@ public static class StateEndpoints
|
|||||||
{
|
{
|
||||||
var player = await EndpointHelpers.GetAuthenticatedPlayer(ctx, db);
|
var player = await EndpointHelpers.GetAuthenticatedPlayer(ctx, db);
|
||||||
if (player is null) return Results.Unauthorized();
|
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) =>
|
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 });
|
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
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,12 +12,11 @@ public static class SuggestEndpoints
|
|||||||
{
|
{
|
||||||
app.MapGet("/api/suggestions/mine", async (HttpContext ctx, AppDbContext db) =>
|
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);
|
var player = await EndpointHelpers.GetAuthenticatedPlayer(ctx, db);
|
||||||
if (player is null) return Results.Unauthorized();
|
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()
|
var mine = await db.Suggestions.AsNoTracking()
|
||||||
.Where(s => s.PlayerId == player.Id)
|
.Where(s => s.PlayerId == player.Id)
|
||||||
.Select(s => new
|
.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) =>
|
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)
|
if (string.IsNullOrWhiteSpace(request.Name) || request.Name.Length > 100)
|
||||||
{
|
{
|
||||||
return Results.BadRequest(new { error = "Name is required and must be <= 100 characters." });
|
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);
|
var player = await EndpointHelpers.GetAuthenticatedPlayer(ctx, db);
|
||||||
if (player is null) return Results.Unauthorized();
|
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))
|
if (string.IsNullOrWhiteSpace(player.DisplayName))
|
||||||
{
|
{
|
||||||
@@ -104,7 +102,7 @@ public static class SuggestEndpoints
|
|||||||
if (player is null) return Results.Unauthorized();
|
if (player is null) return Results.Unauthorized();
|
||||||
var isAdmin = await EndpointHelpers.IsAdmin(ctx, db, config);
|
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)
|
if (!isAdmin && phase != Phase.Suggest)
|
||||||
return EndpointHelpers.PhaseMismatch(Phase.Suggest, phase);
|
return EndpointHelpers.PhaseMismatch(Phase.Suggest, phase);
|
||||||
|
|
||||||
@@ -128,7 +126,7 @@ public static class SuggestEndpoints
|
|||||||
{
|
{
|
||||||
if (player is null) return Results.Unauthorized();
|
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)
|
if (phase != Phase.Suggest)
|
||||||
return EndpointHelpers.PhaseMismatch(Phase.Suggest, phase);
|
return EndpointHelpers.PhaseMismatch(Phase.Suggest, phase);
|
||||||
}
|
}
|
||||||
@@ -186,12 +184,11 @@ public static class SuggestEndpoints
|
|||||||
|
|
||||||
app.MapGet("/api/suggestions/all", async (HttpContext ctx, AppDbContext db) =>
|
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);
|
var player = await EndpointHelpers.GetAuthenticatedPlayer(ctx, db);
|
||||||
if (player is null) return Results.Unauthorized();
|
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()
|
var all = await db.Suggestions.AsNoTracking()
|
||||||
.Include(s => s.Player)
|
.Include(s => s.Player)
|
||||||
|
|||||||
@@ -12,12 +12,11 @@ public static class VoteEndpoints
|
|||||||
{
|
{
|
||||||
app.MapGet("/api/votes/mine", async (HttpContext ctx, AppDbContext db) =>
|
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);
|
var player = await EndpointHelpers.GetAuthenticatedPlayer(ctx, db);
|
||||||
if (player is null) return Results.Unauthorized();
|
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()
|
var votes = await db.Votes.AsNoTracking()
|
||||||
.Where(v => v.PlayerId == player.Id)
|
.Where(v => v.PlayerId == player.Id)
|
||||||
.Select(v => new { v.SuggestionId, v.Score })
|
.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) =>
|
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)
|
if (request.Score is < 0 or > 10)
|
||||||
return Results.BadRequest(new { error = "Score must be between 0 and 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) return Results.Unauthorized();
|
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))
|
if (string.IsNullOrWhiteSpace(player.DisplayName))
|
||||||
return Results.BadRequest(new { error = "Set a display name before voting." });
|
return Results.BadRequest(new { error = "Set a display name before voting." });
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
<TargetFramework>net10.0</TargetFramework>
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<UseAppHost>false</UseAppHost>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
@@ -122,31 +122,39 @@ function setupHandlers() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
$("set-phase").addEventListener("click", async () => {
|
const prevPhaseBtn = $("prev-phase");
|
||||||
const phase = $("phase-select").value;
|
if (prevPhaseBtn) {
|
||||||
try {
|
prevPhaseBtn.addEventListener("click", async () => {
|
||||||
await adminApi.setPhase(phase);
|
try {
|
||||||
toast(t("admin.phaseUpdated"));
|
const resp = await api.prevPhase();
|
||||||
state.prevPhase = state.phase;
|
state.prevPhase = state.phase;
|
||||||
state.phase = phase;
|
state.phase = resp.currentPhase;
|
||||||
state.votesRendered = false;
|
state.resultsOpen = resp.resultsOpen ?? state.resultsOpen;
|
||||||
renderPhasePill();
|
state.votesRendered = false;
|
||||||
$("phase-select").dataset.userEditing = "";
|
renderPhasePill();
|
||||||
await refreshPhaseData();
|
await refreshPhaseData();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast(err.message, true);
|
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")));
|
$("reset").addEventListener("click", () => adminAction(adminApi.reset, t("admin.resetDone")));
|
||||||
$("factory-reset").addEventListener("click", () => adminAction(adminApi.factoryReset, t("admin.factoryResetDone")));
|
$("factory-reset").addEventListener("click", () => adminAction(adminApi.factoryReset, t("admin.factoryResetDone")));
|
||||||
@@ -182,6 +190,22 @@ function setupHandlers() {
|
|||||||
adminToggle.addEventListener("click", () => togglePanel(adminCard.classList.contains("hidden")));
|
adminToggle.addEventListener("click", () => togglePanel(adminCard.classList.contains("hidden")));
|
||||||
adminClose.addEventListener("click", () => togglePanel(false));
|
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) {
|
async function adminAction(fn, successMessage) {
|
||||||
|
|||||||
@@ -108,6 +108,33 @@ button .chip {
|
|||||||
border-color: #a83a35;
|
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 {
|
.vote-controls {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
|
|||||||
@@ -83,6 +83,10 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
}
|
}
|
||||||
|
.status-center {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
.logo-mark {
|
.logo-mark {
|
||||||
height: 65px;
|
height: 65px;
|
||||||
margin: -10px;
|
margin: -10px;
|
||||||
|
|||||||
@@ -71,7 +71,10 @@
|
|||||||
<a id="logout" href="#" class="link inline-link" data-i18n="auth.logout">Logout</a>
|
<a id="logout" href="#" class="link inline-link" data-i18n="auth.logout">Logout</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="status-center">
|
<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>
|
<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>
|
<span class="counts" id="counts">—</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="status-right">
|
<div class="status-right">
|
||||||
@@ -130,15 +133,10 @@
|
|||||||
<h3 data-i18n="admin.title">Admin</h3>
|
<h3 data-i18n="admin.title">Admin</h3>
|
||||||
<button id="admin-close" class="ghost">✕</button>
|
<button id="admin-close" class="ghost">✕</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="stack horizontal">
|
<label class="stack toggle-row">
|
||||||
<select id="phase-select">
|
<input type="checkbox" id="results-open" />
|
||||||
<option value="Suggest" data-i18n="phase.suggest">Suggest</option>
|
<span data-i18n="admin.resultsOpenToggle">Allow results phase</span>
|
||||||
<option value="Reveal" data-i18n="phase.reveal">Reveal</option>
|
</label>
|
||||||
<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>
|
|
||||||
<div class="stack horizontal">
|
<div class="stack horizontal">
|
||||||
<button id="reset" class="danger" data-i18n="admin.reset">Reset (keep players)</button>
|
<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>
|
<button id="factory-reset" class="danger" data-i18n="admin.factoryReset">Factory reset</button>
|
||||||
|
|||||||
@@ -46,10 +46,12 @@ export const api = {
|
|||||||
vote: (suggestionId, score) => request("/api/votes", { method: "POST", body: { suggestionId, score } }),
|
vote: (suggestionId, score) => request("/api/votes", { method: "POST", body: { suggestionId, score } }),
|
||||||
|
|
||||||
results: () => request("/api/results"),
|
results: () => request("/api/results"),
|
||||||
|
nextPhase: () => request("/api/me/phase/next", { method: "POST" }),
|
||||||
|
prevPhase: () => request("/api/me/phase/prev", { method: "POST" }),
|
||||||
};
|
};
|
||||||
|
|
||||||
export const adminApi = {
|
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" }),
|
reset: () => request("/api/admin/reset", { method: "POST" }),
|
||||||
factoryReset: () => request("/api/admin/factory-reset", { method: "POST" }),
|
factoryReset: () => request("/api/admin/factory-reset", { method: "POST" }),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ export async function loadState() {
|
|||||||
state.me = me;
|
state.me = me;
|
||||||
state.prevPhase = state.phase;
|
state.prevPhase = state.phase;
|
||||||
state.phase = stateData.currentPhase;
|
state.phase = stateData.currentPhase;
|
||||||
|
state.resultsOpen = stateData.resultsOpen;
|
||||||
state.counts = stateData;
|
state.counts = stateData;
|
||||||
if (state.prevPhase !== state.phase && state.phase === "Vote") {
|
if (state.prevPhase !== state.phase && state.phase === "Vote") {
|
||||||
state.votesRendered = false;
|
state.votesRendered = false;
|
||||||
@@ -52,7 +53,7 @@ export async function loadVoteData() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function loadResults() {
|
export async function loadResults() {
|
||||||
if (state.phase !== "Results") return;
|
if (state.phase !== "Results" || !state.resultsOpen) return;
|
||||||
state.results = await api.results();
|
state.results = await api.results();
|
||||||
renderResults();
|
renderResults();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,6 +31,10 @@ const translations = {
|
|||||||
|
|
||||||
"counts.format": "Players: {players} • Suggestions: {suggestions} • Votes: {votes}",
|
"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.title": "Suggest games (up to 5)",
|
||||||
"suggest.new": "Add new suggestion",
|
"suggest.new": "Add new suggestion",
|
||||||
"suggest.addButton": "Suggest a game",
|
"suggest.addButton": "Suggest a game",
|
||||||
@@ -79,10 +83,11 @@ const translations = {
|
|||||||
|
|
||||||
"admin.title": "Admin",
|
"admin.title": "Admin",
|
||||||
"admin.tools": "Admin tools",
|
"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.reset": "Reset (keep players)",
|
||||||
"admin.factoryReset": "Factory reset",
|
"admin.factoryReset": "Factory reset",
|
||||||
"admin.phaseUpdated": "Phase updated",
|
|
||||||
"admin.resetDone": "Reset complete",
|
"admin.resetDone": "Reset complete",
|
||||||
"admin.factoryResetDone": "Factory reset complete",
|
"admin.factoryResetDone": "Factory reset complete",
|
||||||
|
|
||||||
@@ -138,6 +143,10 @@ const translations = {
|
|||||||
|
|
||||||
"counts.format": "Spieler: {players} • Vorschläge: {suggestions} • Stimmen: {votes}",
|
"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.title": "Schlage Spiele vor (bis zu 5)",
|
||||||
"suggest.new": "Neuen Vorschlag hinzufügen",
|
"suggest.new": "Neuen Vorschlag hinzufügen",
|
||||||
"suggest.addButton": "Spiel vorschlagen",
|
"suggest.addButton": "Spiel vorschlagen",
|
||||||
@@ -186,10 +195,11 @@ const translations = {
|
|||||||
|
|
||||||
"admin.title": "Admin",
|
"admin.title": "Admin",
|
||||||
"admin.tools": "Admin-Werkzeuge",
|
"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.reset": "Zurücksetzen (Spieler behalten)",
|
||||||
"admin.factoryReset": "Werkseinstellung",
|
"admin.factoryReset": "Werkseinstellung",
|
||||||
"admin.phaseUpdated": "Phase aktualisiert",
|
|
||||||
"admin.resetDone": "Zurücksetzen abgeschlossen",
|
"admin.resetDone": "Zurücksetzen abgeschlossen",
|
||||||
"admin.factoryResetDone": "Werkseinstellung abgeschlossen",
|
"admin.factoryResetDone": "Werkseinstellung abgeschlossen",
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ export const state = {
|
|||||||
me: null,
|
me: null,
|
||||||
phase: null,
|
phase: null,
|
||||||
prevPhase: null,
|
prevPhase: null,
|
||||||
|
resultsOpen: false,
|
||||||
counts: null,
|
counts: null,
|
||||||
mySuggestions: [],
|
mySuggestions: [],
|
||||||
allSuggestions: [],
|
allSuggestions: [],
|
||||||
@@ -17,6 +18,7 @@ export function clearUserState() {
|
|||||||
state.me = null;
|
state.me = null;
|
||||||
state.phase = null;
|
state.phase = null;
|
||||||
state.prevPhase = null;
|
state.prevPhase = null;
|
||||||
|
state.resultsOpen = false;
|
||||||
state.counts = null;
|
state.counts = null;
|
||||||
state.mySuggestions = [];
|
state.mySuggestions = [];
|
||||||
state.allSuggestions = [];
|
state.allSuggestions = [];
|
||||||
|
|||||||
@@ -65,7 +65,8 @@ export function handleAuthError(err, clearUserState) {
|
|||||||
|
|
||||||
export function renderPhasePill() {
|
export function renderPhasePill() {
|
||||||
const phaseKey = typeof state.phase === "string" ? state.phase.toLowerCase() : null;
|
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) =>
|
document.querySelectorAll(".phase-view").forEach((el) =>
|
||||||
el.classList.add("hidden"),
|
el.classList.add("hidden"),
|
||||||
);
|
);
|
||||||
@@ -77,9 +78,27 @@ export function renderPhasePill() {
|
|||||||
};
|
};
|
||||||
const id = viewMap[state.phase];
|
const id = viewMap[state.phase];
|
||||||
if (id) $(id).classList.remove("hidden");
|
if (id) $(id).classList.remove("hidden");
|
||||||
const phaseSelect = $("phase-select");
|
|
||||||
if (phaseSelect && !phaseSelect.dataset.userEditing) {
|
const prevBtn = $("prev-phase");
|
||||||
phaseSelect.value = state.phase || "Suggest";
|
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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user