diff --git a/Contracts/Dtos.cs b/Contracts/Dtos.cs index 6f6438c..06e46e9 100644 --- a/Contracts/Dtos.cs +++ b/Contracts/Dtos.cs @@ -1,7 +1,7 @@ namespace GameList.Contracts; public record SetNameRequest(string Name); -public record SuggestionRequest(string Name, string? Genre, string? Description, string? ScreenshotUrl, string? YoutubeUrl, string? GameUrl); -public record SuggestionDto(int Id, string Name, string? Genre, string? Description, string? ScreenshotUrl, string? YoutubeUrl, string? GameUrl); +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); diff --git a/Data/AppDbContext.cs b/Data/AppDbContext.cs index 70f68ea..4fcf0b9 100644 --- a/Data/AppDbContext.cs +++ b/Data/AppDbContext.cs @@ -45,6 +45,8 @@ public class AppDbContext : DbContext builder.Property(s => s.ScreenshotUrl).HasMaxLength(2048); builder.Property(s => s.YoutubeUrl).HasMaxLength(2048); builder.Property(s => s.GameUrl).HasMaxLength(2048); + builder.Property(s => s.MinPlayers); + builder.Property(s => s.MaxPlayers); }); modelBuilder.Entity(builder => diff --git a/Data/Migrations/20260129004926_SyncPlayers.Designer.cs b/Data/Migrations/20260129004926_SyncPlayers.Designer.cs new file mode 100644 index 0000000..c31871f --- /dev/null +++ b/Data/Migrations/20260129004926_SyncPlayers.Designer.cs @@ -0,0 +1,217 @@ +// +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("20260129004926_SyncPlayers")] + partial class SyncPlayers + { + /// + 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("CurrentPhase") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("AppState"); + + b.HasData( + new + { + Id = 1, + CurrentPhase = 0, + 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("DisplayName") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("IsAdmin") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(false); + + b.Property("LastLoginAt") + .HasColumnType("TEXT"); + + b.Property("NormalizedUsername") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .IsRequired() + .HasColumnType("BLOB"); + + b.Property("PasswordSalt") + .IsRequired() + .HasColumnType("BLOB"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(64) + .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/20260129004926_SyncPlayers.cs b/Data/Migrations/20260129004926_SyncPlayers.cs new file mode 100644 index 0000000..6e94372 --- /dev/null +++ b/Data/Migrations/20260129004926_SyncPlayers.cs @@ -0,0 +1,38 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace GameList.Data.Migrations +{ + /// + public partial class SyncPlayers : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "MaxPlayers", + table: "Suggestions", + type: "INTEGER", + nullable: true); + + migrationBuilder.AddColumn( + name: "MinPlayers", + table: "Suggestions", + type: "INTEGER", + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "MaxPlayers", + table: "Suggestions"); + + migrationBuilder.DropColumn( + name: "MinPlayers", + table: "Suggestions"); + } + } +} diff --git a/Data/Migrations/AppDbContextModelSnapshot.cs b/Data/Migrations/AppDbContextModelSnapshot.cs index c84ec05..7e5df74 100644 --- a/Data/Migrations/AppDbContextModelSnapshot.cs +++ b/Data/Migrations/AppDbContextModelSnapshot.cs @@ -31,7 +31,7 @@ namespace GameList.Data.Migrations b.HasKey("Id"); - b.ToTable("AppState"); + b.ToTable("AppState", (string)null); b.HasData( new @@ -86,7 +86,7 @@ namespace GameList.Data.Migrations b.HasIndex("NormalizedUsername") .IsUnique(); - b.ToTable("Players"); + b.ToTable("Players", (string)null); }); modelBuilder.Entity("GameList.Domain.Suggestion", b => @@ -110,6 +110,12 @@ namespace GameList.Data.Migrations .HasMaxLength(50) .HasColumnType("TEXT"); + b.Property("MaxPlayers") + .HasColumnType("INTEGER"); + + b.Property("MinPlayers") + .HasColumnType("INTEGER"); + b.Property("Name") .IsRequired() .HasMaxLength(100) @@ -130,7 +136,7 @@ namespace GameList.Data.Migrations b.HasIndex("PlayerId"); - b.ToTable("Suggestions"); + b.ToTable("Suggestions", (string)null); }); modelBuilder.Entity("GameList.Domain.Vote", b => @@ -158,7 +164,7 @@ namespace GameList.Data.Migrations b.HasIndex("PlayerId", "SuggestionId") .IsUnique(); - b.ToTable("Votes"); + b.ToTable("Votes", (string)null); }); modelBuilder.Entity("GameList.Domain.Suggestion", b => diff --git a/Domain/Suggestion.cs b/Domain/Suggestion.cs index c3f3831..1083aa3 100644 --- a/Domain/Suggestion.cs +++ b/Domain/Suggestion.cs @@ -29,6 +29,9 @@ public class Suggestion [MaxLength(2048)] public string? GameUrl { get; set; } + public int? MinPlayers { get; set; } + public int? MaxPlayers { get; set; } + public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow; public ICollection Votes { get; set; } = new List(); diff --git a/Endpoints/EndpointHelpers.cs b/Endpoints/EndpointHelpers.cs index 9f74f02..95cf720 100644 --- a/Endpoints/EndpointHelpers.cs +++ b/Endpoints/EndpointHelpers.cs @@ -80,9 +80,9 @@ internal static class EndpointHelpers return true; await using var stream = await resp.Content.ReadAsStreamAsync(cts.Token); - Span buffer = stackalloc byte[12]; - var read = await stream.ReadAsync(buffer, cts.Token); - var sig = buffer[..read]; + var rented = new byte[12]; + var read = await stream.ReadAsync(rented, 0, rented.Length, cts.Token); + var sig = new ReadOnlySpan(rented, 0, read); if (IsMagic(sig, "PNG")) return true; if (IsMagic(sig, new byte[] { 0xFF, 0xD8 })) return true; // JPEG diff --git a/Endpoints/SuggestEndpoints.cs b/Endpoints/SuggestEndpoints.cs index 36b9791..bfe483c 100644 --- a/Endpoints/SuggestEndpoints.cs +++ b/Endpoints/SuggestEndpoints.cs @@ -29,13 +29,15 @@ public static class SuggestEndpoints s.ScreenshotUrl, s.YoutubeUrl, s.GameUrl, - s.CreatedAt + s.CreatedAt, + s.MinPlayers, + s.MaxPlayers }) .ToListAsync(); var ordered = mine .OrderBy(s => s.CreatedAt) - .Select(s => new SuggestionDto(s.Id, s.Name, s.Genre, s.Description, s.ScreenshotUrl, s.YoutubeUrl, s.GameUrl)); + .Select(s => new SuggestionDto(s.Id, s.Name, s.Genre, s.Description, s.ScreenshotUrl, s.YoutubeUrl, s.GameUrl, s.MinPlayers, s.MaxPlayers)); return Results.Ok(ordered); }); @@ -60,6 +62,9 @@ public static class SuggestEndpoints return Results.BadRequest(new { error = "Screenshot URL could not be validated as an image." }); } + if (!ValidatePlayers(request.MinPlayers, request.MaxPlayers, out var playersError)) + return Results.BadRequest(new { error = playersError }); + var player = await EndpointHelpers.GetAuthenticatedPlayer(ctx, db); if (player is null) return Results.Unauthorized(); @@ -82,7 +87,9 @@ public static class SuggestEndpoints Description = EndpointHelpers.TrimTo(request.Description, 500), ScreenshotUrl = EndpointHelpers.TrimTo(request.ScreenshotUrl, 2048), YoutubeUrl = EndpointHelpers.TrimTo(request.YoutubeUrl, 2048), - GameUrl = EndpointHelpers.TrimTo(request.GameUrl, 2048) + GameUrl = EndpointHelpers.TrimTo(request.GameUrl, 2048), + MinPlayers = request.MinPlayers, + MaxPlayers = request.MaxPlayers }; db.Suggestions.Add(suggestion); @@ -136,6 +143,9 @@ public static class SuggestEndpoints return Results.BadRequest(new { error = "Screenshot URL could not be validated as an image." }); } + if (!ValidatePlayers(request.MinPlayers, request.MaxPlayers, out var playersError)) + return Results.BadRequest(new { error = playersError }); + var suggestion = await db.Suggestions.FirstOrDefaultAsync(s => s.Id == id); if (suggestion == null) return Results.NotFound(new { error = "Suggestion not found." }); @@ -151,6 +161,8 @@ public static class SuggestEndpoints suggestion.ScreenshotUrl = EndpointHelpers.TrimTo(request.ScreenshotUrl, 2048); suggestion.YoutubeUrl = EndpointHelpers.TrimTo(request.YoutubeUrl, 2048); suggestion.GameUrl = EndpointHelpers.TrimTo(request.GameUrl, 2048); + suggestion.MinPlayers = request.MinPlayers; + suggestion.MaxPlayers = request.MaxPlayers; await db.SaveChangesAsync(); @@ -162,7 +174,9 @@ public static class SuggestEndpoints suggestion.Description, suggestion.ScreenshotUrl, suggestion.YoutubeUrl, - suggestion.GameUrl + suggestion.GameUrl, + suggestion.MinPlayers, + suggestion.MaxPlayers }); }); @@ -186,6 +200,8 @@ public static class SuggestEndpoints s.ScreenshotUrl, s.YoutubeUrl, s.GameUrl, + s.MinPlayers, + s.MaxPlayers, Author = s.Player!.DisplayName, s.CreatedAt }) @@ -202,10 +218,44 @@ public static class SuggestEndpoints s.ScreenshotUrl, s.YoutubeUrl, s.GameUrl, + s.MinPlayers, + s.MaxPlayers, s.Author }); return Results.Ok(ordered); }); } + + private static bool ValidatePlayers(int? minPlayers, int? maxPlayers, out string? error) + { + error = null; + if (minPlayers is null && maxPlayers is null) return true; + + if (minPlayers is not null && (minPlayers < 1 || minPlayers > 32)) + { + error = "Min players must be between 1 and 32."; + return false; + } + + if (maxPlayers is not null && (maxPlayers < 1 || maxPlayers > 32)) + { + error = "Max players must be between 1 and 32."; + return false; + } + + if (minPlayers is null || maxPlayers is null) + { + error = "Provide both min and max players."; + return false; + } + + if (minPlayers > maxPlayers) + { + error = "Min players cannot exceed max players."; + return false; + } + + return true; + } } diff --git a/wwwroot/app.js b/wwwroot/app.js index 1be9eef..8fe2c98 100644 --- a/wwwroot/app.js +++ b/wwwroot/app.js @@ -294,7 +294,7 @@ function setupHandlers() { $("suggest-form").addEventListener("submit", async (e) => { e.preventDefault(); const form = e.target; - const data = Object.fromEntries(new FormData(form).entries()); + const data = normalizeSuggestionForm(new FormData(form)); if (!data.name) return toast("Name required", true); if (data.screenshotUrl && !isValidImageUrl(data.screenshotUrl)) { return toast("Screenshot URL must be http(s) and end with an image file.", true); @@ -406,6 +406,7 @@ function buildCard(s, { showAuthor = false, allowDelete = false, allowEdit = fal ${s.genre ? `

${s.genre}

` : ""} ${s.description ? `

${s.description}

` : ""} + ${(s.minPlayers || s.maxPlayers) ? `

Players: ${s.minPlayers ?? "?"}–${s.maxPlayers ?? "?"}

` : ""} `; if (hasImage) { @@ -470,7 +471,7 @@ function openEditModal(s) { const form = overlay.querySelector("#edit-form"); form?.addEventListener("submit", async (e) => { e.preventDefault(); - const data = Object.fromEntries(new FormData(form).entries()); + const data = normalizeSuggestionForm(new FormData(form)); if (data.screenshotUrl && !isValidImageUrl(data.screenshotUrl)) { return toast("Screenshot URL must be http(s) and end with an image file.", true); } @@ -535,3 +536,22 @@ function isValidImageUrl(url) { return false; } } + +function normalizeSuggestionForm(formData) { + const obj = Object.fromEntries(formData.entries()); + const parseNum = (v) => { + if (v === undefined || v === null || v === "") return null; + const n = Number(v); + return Number.isFinite(n) ? n : null; + }; + return { + name: obj.name?.trim(), + genre: obj.genre?.trim() || null, + description: obj.description?.trim() || null, + screenshotUrl: obj.screenshotUrl?.trim() || null, + youtubeUrl: obj.youtubeUrl?.trim() || null, + gameUrl: obj.gameUrl?.trim() || null, + minPlayers: parseNum(obj.minPlayers), + maxPlayers: parseNum(obj.maxPlayers), + }; +} diff --git a/wwwroot/index.html b/wwwroot/index.html index 9922946..b3504fe 100644 --- a/wwwroot/index.html +++ b/wwwroot/index.html @@ -15,15 +15,33 @@
- - + +
@@ -47,17 +65,46 @@

Suggest (up to 3)

Only you can see your suggestions until Reveal.

- - - -
- - - -
- -
- + + + +
+ Players +
+ + +
+
+ + + + + +

Your suggestions