Add player count fields with validation and labeled UX
This commit is contained in:
@@ -1,7 +1,7 @@
|
|||||||
namespace GameList.Contracts;
|
namespace GameList.Contracts;
|
||||||
|
|
||||||
public record SetNameRequest(string Name);
|
public record SetNameRequest(string Name);
|
||||||
public record SuggestionRequest(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);
|
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 PhaseRequest(GameList.Domain.Phase Phase);
|
||||||
|
|||||||
@@ -45,6 +45,8 @@ public class AppDbContext : DbContext
|
|||||||
builder.Property(s => s.ScreenshotUrl).HasMaxLength(2048);
|
builder.Property(s => s.ScreenshotUrl).HasMaxLength(2048);
|
||||||
builder.Property(s => s.YoutubeUrl).HasMaxLength(2048);
|
builder.Property(s => s.YoutubeUrl).HasMaxLength(2048);
|
||||||
builder.Property(s => s.GameUrl).HasMaxLength(2048);
|
builder.Property(s => s.GameUrl).HasMaxLength(2048);
|
||||||
|
builder.Property(s => s.MinPlayers);
|
||||||
|
builder.Property(s => s.MaxPlayers);
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity<Vote>(builder =>
|
modelBuilder.Entity<Vote>(builder =>
|
||||||
|
|||||||
217
Data/Migrations/20260129004926_SyncPlayers.Designer.cs
generated
Normal file
217
Data/Migrations/20260129004926_SyncPlayers.Designer.cs
generated
Normal file
@@ -0,0 +1,217 @@
|
|||||||
|
// <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("20260129004926_SyncPlayers")]
|
||||||
|
partial class SyncPlayers
|
||||||
|
{
|
||||||
|
/// <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<int>("CurrentPhase")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("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<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("CreatedAt")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("DisplayName")
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<bool>("IsAdmin")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasDefaultValue(false);
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset?>("LastLoginAt")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("NormalizedUsername")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<byte[]>("PasswordHash")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("BLOB");
|
||||||
|
|
||||||
|
b.Property<byte[]>("PasswordSalt")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("BLOB");
|
||||||
|
|
||||||
|
b.Property<string>("Username")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
38
Data/Migrations/20260129004926_SyncPlayers.cs
Normal file
38
Data/Migrations/20260129004926_SyncPlayers.cs
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace GameList.Data.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class SyncPlayers : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<int>(
|
||||||
|
name: "MaxPlayers",
|
||||||
|
table: "Suggestions",
|
||||||
|
type: "INTEGER",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<int>(
|
||||||
|
name: "MinPlayers",
|
||||||
|
table: "Suggestions",
|
||||||
|
type: "INTEGER",
|
||||||
|
nullable: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "MaxPlayers",
|
||||||
|
table: "Suggestions");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "MinPlayers",
|
||||||
|
table: "Suggestions");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -31,7 +31,7 @@ namespace GameList.Data.Migrations
|
|||||||
|
|
||||||
b.HasKey("Id");
|
b.HasKey("Id");
|
||||||
|
|
||||||
b.ToTable("AppState");
|
b.ToTable("AppState", (string)null);
|
||||||
|
|
||||||
b.HasData(
|
b.HasData(
|
||||||
new
|
new
|
||||||
@@ -86,7 +86,7 @@ namespace GameList.Data.Migrations
|
|||||||
b.HasIndex("NormalizedUsername")
|
b.HasIndex("NormalizedUsername")
|
||||||
.IsUnique();
|
.IsUnique();
|
||||||
|
|
||||||
b.ToTable("Players");
|
b.ToTable("Players", (string)null);
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("GameList.Domain.Suggestion", b =>
|
modelBuilder.Entity("GameList.Domain.Suggestion", b =>
|
||||||
@@ -110,6 +110,12 @@ namespace GameList.Data.Migrations
|
|||||||
.HasMaxLength(50)
|
.HasMaxLength(50)
|
||||||
.HasColumnType("TEXT");
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<int?>("MaxPlayers")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int?>("MinPlayers")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
b.Property<string>("Name")
|
b.Property<string>("Name")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasMaxLength(100)
|
.HasMaxLength(100)
|
||||||
@@ -130,7 +136,7 @@ namespace GameList.Data.Migrations
|
|||||||
|
|
||||||
b.HasIndex("PlayerId");
|
b.HasIndex("PlayerId");
|
||||||
|
|
||||||
b.ToTable("Suggestions");
|
b.ToTable("Suggestions", (string)null);
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("GameList.Domain.Vote", b =>
|
modelBuilder.Entity("GameList.Domain.Vote", b =>
|
||||||
@@ -158,7 +164,7 @@ namespace GameList.Data.Migrations
|
|||||||
b.HasIndex("PlayerId", "SuggestionId")
|
b.HasIndex("PlayerId", "SuggestionId")
|
||||||
.IsUnique();
|
.IsUnique();
|
||||||
|
|
||||||
b.ToTable("Votes");
|
b.ToTable("Votes", (string)null);
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("GameList.Domain.Suggestion", b =>
|
modelBuilder.Entity("GameList.Domain.Suggestion", b =>
|
||||||
|
|||||||
@@ -29,6 +29,9 @@ public class Suggestion
|
|||||||
[MaxLength(2048)]
|
[MaxLength(2048)]
|
||||||
public string? GameUrl { get; set; }
|
public string? GameUrl { get; set; }
|
||||||
|
|
||||||
|
public int? MinPlayers { get; set; }
|
||||||
|
public int? MaxPlayers { get; set; }
|
||||||
|
|
||||||
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
|
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
|
||||||
|
|
||||||
public ICollection<Vote> Votes { get; set; } = new List<Vote>();
|
public ICollection<Vote> Votes { get; set; } = new List<Vote>();
|
||||||
|
|||||||
@@ -80,9 +80,9 @@ internal static class EndpointHelpers
|
|||||||
return true;
|
return true;
|
||||||
|
|
||||||
await using var stream = await resp.Content.ReadAsStreamAsync(cts.Token);
|
await using var stream = await resp.Content.ReadAsStreamAsync(cts.Token);
|
||||||
Span<byte> buffer = stackalloc byte[12];
|
var rented = new byte[12];
|
||||||
var read = await stream.ReadAsync(buffer, cts.Token);
|
var read = await stream.ReadAsync(rented, 0, rented.Length, cts.Token);
|
||||||
var sig = buffer[..read];
|
var sig = new ReadOnlySpan<byte>(rented, 0, read);
|
||||||
|
|
||||||
if (IsMagic(sig, "PNG")) return true;
|
if (IsMagic(sig, "PNG")) return true;
|
||||||
if (IsMagic(sig, new byte[] { 0xFF, 0xD8 })) return true; // JPEG
|
if (IsMagic(sig, new byte[] { 0xFF, 0xD8 })) return true; // JPEG
|
||||||
|
|||||||
@@ -29,13 +29,15 @@ public static class SuggestEndpoints
|
|||||||
s.ScreenshotUrl,
|
s.ScreenshotUrl,
|
||||||
s.YoutubeUrl,
|
s.YoutubeUrl,
|
||||||
s.GameUrl,
|
s.GameUrl,
|
||||||
s.CreatedAt
|
s.CreatedAt,
|
||||||
|
s.MinPlayers,
|
||||||
|
s.MaxPlayers
|
||||||
})
|
})
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
|
|
||||||
var ordered = mine
|
var ordered = mine
|
||||||
.OrderBy(s => s.CreatedAt)
|
.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);
|
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." });
|
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);
|
var player = await EndpointHelpers.GetAuthenticatedPlayer(ctx, db);
|
||||||
if (player is null) return Results.Unauthorized();
|
if (player is null) return Results.Unauthorized();
|
||||||
|
|
||||||
@@ -82,7 +87,9 @@ public static class SuggestEndpoints
|
|||||||
Description = EndpointHelpers.TrimTo(request.Description, 500),
|
Description = EndpointHelpers.TrimTo(request.Description, 500),
|
||||||
ScreenshotUrl = EndpointHelpers.TrimTo(request.ScreenshotUrl, 2048),
|
ScreenshotUrl = EndpointHelpers.TrimTo(request.ScreenshotUrl, 2048),
|
||||||
YoutubeUrl = EndpointHelpers.TrimTo(request.YoutubeUrl, 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);
|
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." });
|
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);
|
var suggestion = await db.Suggestions.FirstOrDefaultAsync(s => s.Id == id);
|
||||||
if (suggestion == null) return Results.NotFound(new { error = "Suggestion not found." });
|
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.ScreenshotUrl = EndpointHelpers.TrimTo(request.ScreenshotUrl, 2048);
|
||||||
suggestion.YoutubeUrl = EndpointHelpers.TrimTo(request.YoutubeUrl, 2048);
|
suggestion.YoutubeUrl = EndpointHelpers.TrimTo(request.YoutubeUrl, 2048);
|
||||||
suggestion.GameUrl = EndpointHelpers.TrimTo(request.GameUrl, 2048);
|
suggestion.GameUrl = EndpointHelpers.TrimTo(request.GameUrl, 2048);
|
||||||
|
suggestion.MinPlayers = request.MinPlayers;
|
||||||
|
suggestion.MaxPlayers = request.MaxPlayers;
|
||||||
|
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
@@ -162,7 +174,9 @@ public static class SuggestEndpoints
|
|||||||
suggestion.Description,
|
suggestion.Description,
|
||||||
suggestion.ScreenshotUrl,
|
suggestion.ScreenshotUrl,
|
||||||
suggestion.YoutubeUrl,
|
suggestion.YoutubeUrl,
|
||||||
suggestion.GameUrl
|
suggestion.GameUrl,
|
||||||
|
suggestion.MinPlayers,
|
||||||
|
suggestion.MaxPlayers
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -186,6 +200,8 @@ public static class SuggestEndpoints
|
|||||||
s.ScreenshotUrl,
|
s.ScreenshotUrl,
|
||||||
s.YoutubeUrl,
|
s.YoutubeUrl,
|
||||||
s.GameUrl,
|
s.GameUrl,
|
||||||
|
s.MinPlayers,
|
||||||
|
s.MaxPlayers,
|
||||||
Author = s.Player!.DisplayName,
|
Author = s.Player!.DisplayName,
|
||||||
s.CreatedAt
|
s.CreatedAt
|
||||||
})
|
})
|
||||||
@@ -202,10 +218,44 @@ public static class SuggestEndpoints
|
|||||||
s.ScreenshotUrl,
|
s.ScreenshotUrl,
|
||||||
s.YoutubeUrl,
|
s.YoutubeUrl,
|
||||||
s.GameUrl,
|
s.GameUrl,
|
||||||
|
s.MinPlayers,
|
||||||
|
s.MaxPlayers,
|
||||||
s.Author
|
s.Author
|
||||||
});
|
});
|
||||||
|
|
||||||
return Results.Ok(ordered);
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -294,7 +294,7 @@ function setupHandlers() {
|
|||||||
$("suggest-form").addEventListener("submit", async (e) => {
|
$("suggest-form").addEventListener("submit", async (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const form = e.target;
|
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.name) return toast("Name required", true);
|
||||||
if (data.screenshotUrl && !isValidImageUrl(data.screenshotUrl)) {
|
if (data.screenshotUrl && !isValidImageUrl(data.screenshotUrl)) {
|
||||||
return toast("Screenshot URL must be http(s) and end with an image file.", true);
|
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
|
|||||||
</div>
|
</div>
|
||||||
${s.genre ? `<p class="muted">${s.genre}</p>` : ""}
|
${s.genre ? `<p class="muted">${s.genre}</p>` : ""}
|
||||||
${s.description ? `<p>${s.description}</p>` : ""}
|
${s.description ? `<p>${s.description}</p>` : ""}
|
||||||
|
${(s.minPlayers || s.maxPlayers) ? `<p class="muted">Players: ${s.minPlayers ?? "?"}–${s.maxPlayers ?? "?"}</p>` : ""}
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
if (hasImage) {
|
if (hasImage) {
|
||||||
@@ -470,7 +471,7 @@ function openEditModal(s) {
|
|||||||
const form = overlay.querySelector("#edit-form");
|
const form = overlay.querySelector("#edit-form");
|
||||||
form?.addEventListener("submit", async (e) => {
|
form?.addEventListener("submit", async (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const data = Object.fromEntries(new FormData(form).entries());
|
const data = normalizeSuggestionForm(new FormData(form));
|
||||||
if (data.screenshotUrl && !isValidImageUrl(data.screenshotUrl)) {
|
if (data.screenshotUrl && !isValidImageUrl(data.screenshotUrl)) {
|
||||||
return toast("Screenshot URL must be http(s) and end with an image file.", true);
|
return toast("Screenshot URL must be http(s) and end with an image file.", true);
|
||||||
}
|
}
|
||||||
@@ -535,3 +536,22 @@ function isValidImageUrl(url) {
|
|||||||
return false;
|
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),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -15,15 +15,33 @@
|
|||||||
<button class="ghost" data-auth-tab="register" type="button">Register</button>
|
<button class="ghost" data-auth-tab="register" type="button">Register</button>
|
||||||
</div>
|
</div>
|
||||||
<form id="login-form" class="stack auth-form" data-mode="login">
|
<form id="login-form" class="stack auth-form" data-mode="login">
|
||||||
<input id="login-username" name="username" maxlength="64" placeholder="Username" autocomplete="username" required />
|
<label class="stack">
|
||||||
<input id="login-password" name="password" type="password" placeholder="Password" autocomplete="current-password" required />
|
<span class="label">Username</span>
|
||||||
|
<input id="login-username" name="username" maxlength="64" autocomplete="username" required />
|
||||||
|
</label>
|
||||||
|
<label class="stack">
|
||||||
|
<span class="label">Password</span>
|
||||||
|
<input id="login-password" name="password" type="password" autocomplete="current-password" required />
|
||||||
|
</label>
|
||||||
<button type="submit">Log in</button>
|
<button type="submit">Log in</button>
|
||||||
</form>
|
</form>
|
||||||
<form id="register-form" class="stack auth-form hidden" data-mode="register">
|
<form id="register-form" class="stack auth-form hidden" data-mode="register">
|
||||||
<input id="register-username" name="username" maxlength="64" placeholder="Username" autocomplete="username" required />
|
<label class="stack">
|
||||||
<input id="register-password" name="password" type="password" placeholder="Password" autocomplete="new-password" required />
|
<span class="label">Username</span>
|
||||||
<input id="register-displayName" name="displayName" maxlength="64" placeholder="Display name (shows to group)" required />
|
<input id="register-username" name="username" maxlength="64" autocomplete="username" required />
|
||||||
<input id="register-adminkey" name="adminKey" type="password" maxlength="128" placeholder="Admin key (optional)" />
|
</label>
|
||||||
|
<label class="stack">
|
||||||
|
<span class="label">Password</span>
|
||||||
|
<input id="register-password" name="password" type="password" autocomplete="new-password" required />
|
||||||
|
</label>
|
||||||
|
<label class="stack">
|
||||||
|
<span class="label">Display name (shows to group)</span>
|
||||||
|
<input id="register-displayName" name="displayName" maxlength="64" required />
|
||||||
|
</label>
|
||||||
|
<label class="stack">
|
||||||
|
<span class="label">Admin key (optional)</span>
|
||||||
|
<input id="register-adminkey" name="adminKey" type="password" maxlength="128" />
|
||||||
|
</label>
|
||||||
<button type="submit">Create account</button>
|
<button type="submit">Create account</button>
|
||||||
</form>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
@@ -47,17 +65,46 @@
|
|||||||
<h2>Suggest (up to 3)</h2>
|
<h2>Suggest (up to 3)</h2>
|
||||||
<p class="hint">Only you can see your suggestions until Reveal.</p>
|
<p class="hint">Only you can see your suggestions until Reveal.</p>
|
||||||
<form id="suggest-form" class="stack">
|
<form id="suggest-form" class="stack">
|
||||||
<input name="name" required maxlength="100" placeholder="Game name *" />
|
<label class="stack">
|
||||||
<input name="genre" maxlength="50" placeholder="Genre" />
|
<span class="label">Game name *</span>
|
||||||
<textarea name="description" maxlength="500" placeholder="Short description"></textarea>
|
<input name="name" required maxlength="100" />
|
||||||
<div class="stack horizontal">
|
</label>
|
||||||
<input name="screenshotUrl" maxlength="2048" placeholder="Screenshot URL" />
|
<label class="stack">
|
||||||
<input name="youtubeUrl" maxlength="2048" placeholder="YouTube URL" />
|
<span class="label">Genre</span>
|
||||||
<input name="gameUrl" maxlength="2048" placeholder="Game website URL" />
|
<input name="genre" maxlength="50" />
|
||||||
</div>
|
</label>
|
||||||
<button type="submit">Submit</button>
|
<label class="stack">
|
||||||
</form>
|
<span class="label">Description</span>
|
||||||
</div>
|
<textarea name="description" maxlength="500"></textarea>
|
||||||
|
</label>
|
||||||
|
<div class="stack">
|
||||||
|
<span class="label">Players</span>
|
||||||
|
<div class="stack horizontal">
|
||||||
|
<label class="stack">
|
||||||
|
<span class="label">Min</span>
|
||||||
|
<input name="minPlayers" type="number" min="1" max="32" inputmode="numeric" />
|
||||||
|
</label>
|
||||||
|
<label class="stack">
|
||||||
|
<span class="label">Max</span>
|
||||||
|
<input name="maxPlayers" type="number" min="1" max="32" inputmode="numeric" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<label class="stack">
|
||||||
|
<span class="label">Screenshot URL</span>
|
||||||
|
<input name="screenshotUrl" maxlength="2048" />
|
||||||
|
</label>
|
||||||
|
<label class="stack">
|
||||||
|
<span class="label">YouTube URL</span>
|
||||||
|
<input name="youtubeUrl" maxlength="2048" />
|
||||||
|
</label>
|
||||||
|
<label class="stack">
|
||||||
|
<span class="label">Game website URL</span>
|
||||||
|
<input name="gameUrl" maxlength="2048" />
|
||||||
|
</label>
|
||||||
|
<button type="submit">Submit</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
<div class="card subcard">
|
<div class="card subcard">
|
||||||
<h3>Your suggestions</h3>
|
<h3>Your suggestions</h3>
|
||||||
<div id="my-suggestions" class="card-grid"></div>
|
<div id="my-suggestions" class="card-grid"></div>
|
||||||
|
|||||||
Reference in New Issue
Block a user