diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f0438ac --- /dev/null +++ b/.gitignore @@ -0,0 +1,25 @@ +# Build outputs +bin/ +obj/ +out/ +publish/ + +# IDE +.vs/ +.vscode/ + +# User secrets / configs +appsettings.Development.json +*.user +*.suo + +# Logs +*.log + +# SQLite data +App_Data/ +*.db + +# OS cruft +Thumbs.db +Desktop.ini diff --git a/AGENTS.md b/AGENTS.md index 7fa581b..e859503 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -7,7 +7,7 @@ This repo is a tiny, purpose-built web app for a closed Discord group to: 4) reveal totals sorted by score Tech constraints: -- .NET 8 +- .NET 10 - ASP.NET Core Minimal API - Static HTML/CSS/JS (no Razor Pages, no Blazor, no HTMX) - SQLite via EF Core @@ -76,3 +76,4 @@ Do not introduce MVC controllers, Razor Pages, Blazor, or SPA frameworks. - Implement API first, UI second - Keep changes small and testable - Prefer clarity over abstraction +- After every iteration, do a git commit with a brief summary of the changes as a commit message. \ No newline at end of file diff --git a/Data/AppDbContext.cs b/Data/AppDbContext.cs new file mode 100644 index 0000000..8d01707 --- /dev/null +++ b/Data/AppDbContext.cs @@ -0,0 +1,61 @@ +using GameList.Domain; +using Microsoft.EntityFrameworkCore; + +namespace GameList.Data; + +public class AppDbContext : DbContext +{ + public AppDbContext(DbContextOptions options) : base(options) + { + } + + public DbSet Players => Set(); + public DbSet Suggestions => Set(); + public DbSet Votes => Set(); + public DbSet AppState => Set(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(builder => + { + builder.HasKey(p => p.Id); + builder.Property(p => p.DisplayName).HasMaxLength(64); + builder.HasMany(p => p.Suggestions) + .WithOne(s => s.Player!) + .HasForeignKey(s => s.PlayerId) + .OnDelete(DeleteBehavior.Cascade); + builder.HasMany(p => p.Votes) + .WithOne(v => v.Player!) + .HasForeignKey(v => v.PlayerId) + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity(builder => + { + builder.HasKey(s => s.Id); + builder.Property(s => s.Name).IsRequired().HasMaxLength(100); + builder.Property(s => s.Genre).HasMaxLength(50); + builder.Property(s => s.Description).HasMaxLength(500); + builder.Property(s => s.ScreenshotUrl).HasMaxLength(2048); + builder.Property(s => s.YoutubeUrl).HasMaxLength(2048); + }); + + modelBuilder.Entity(builder => + { + builder.HasKey(v => v.Id); + builder.Property(v => v.Score).IsRequired(); + builder.HasIndex(v => new { v.PlayerId, v.SuggestionId }).IsUnique(); + }); + + modelBuilder.Entity(builder => + { + builder.HasKey(s => s.Id); + builder.HasData(new AppState + { + Id = 1, + CurrentPhase = Phase.Suggest, + UpdatedAt = DateTimeOffset.UtcNow + }); + }); + } +} diff --git a/Domain/AppState.cs b/Domain/AppState.cs new file mode 100644 index 0000000..3363e53 --- /dev/null +++ b/Domain/AppState.cs @@ -0,0 +1,8 @@ +namespace GameList.Domain; + +public class AppState +{ + public int Id { get; set; } = 1; + public Phase CurrentPhase { get; set; } = Phase.Suggest; + public DateTimeOffset UpdatedAt { get; set; } = DateTimeOffset.UtcNow; +} diff --git a/Domain/Phase.cs b/Domain/Phase.cs new file mode 100644 index 0000000..d611688 --- /dev/null +++ b/Domain/Phase.cs @@ -0,0 +1,9 @@ +namespace GameList.Domain; + +public enum Phase +{ + Suggest = 0, + Reveal = 1, + Vote = 2, + Results = 3 +} diff --git a/Domain/Player.cs b/Domain/Player.cs new file mode 100644 index 0000000..b13a95b --- /dev/null +++ b/Domain/Player.cs @@ -0,0 +1,16 @@ +using System.ComponentModel.DataAnnotations; + +namespace GameList.Domain; + +public class Player +{ + public Guid Id { get; set; } = Guid.NewGuid(); + + [MaxLength(64)] + public string? DisplayName { get; set; } + + public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow; + + public ICollection Suggestions { get; set; } = new List(); + public ICollection Votes { get; set; } = new List(); +} diff --git a/Domain/Suggestion.cs b/Domain/Suggestion.cs new file mode 100644 index 0000000..4d3f925 --- /dev/null +++ b/Domain/Suggestion.cs @@ -0,0 +1,32 @@ +using System.ComponentModel.DataAnnotations; + +namespace GameList.Domain; + +public class Suggestion +{ + public int Id { get; set; } + + [Required] + public Guid PlayerId { get; set; } + public Player? Player { get; set; } + + [Required] + [MaxLength(100)] + public string Name { get; set; } = string.Empty; + + [MaxLength(50)] + public string? Genre { get; set; } + + [MaxLength(500)] + public string? Description { get; set; } + + [MaxLength(2048)] + public string? ScreenshotUrl { get; set; } + + [MaxLength(2048)] + public string? YoutubeUrl { get; set; } + + public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow; + + public ICollection Votes { get; set; } = new List(); +} diff --git a/Domain/Vote.cs b/Domain/Vote.cs new file mode 100644 index 0000000..cf263aa --- /dev/null +++ b/Domain/Vote.cs @@ -0,0 +1,21 @@ +using System.ComponentModel.DataAnnotations; + +namespace GameList.Domain; + +public class Vote +{ + public int Id { get; set; } + + [Required] + public Guid PlayerId { get; set; } + public Player? Player { get; set; } + + [Required] + public int SuggestionId { get; set; } + public Suggestion? Suggestion { get; set; } + + [Range(0, 10)] + public int Score { get; set; } + + public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow; +} diff --git a/GameList.csproj b/GameList.csproj new file mode 100644 index 0000000..3e5e966 --- /dev/null +++ b/GameList.csproj @@ -0,0 +1,17 @@ + + + + net10.0 + enable + enable + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + diff --git a/Program.cs b/Program.cs new file mode 100644 index 0000000..7cbdc20 --- /dev/null +++ b/Program.cs @@ -0,0 +1,45 @@ +using GameList.Data; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; + +var builder = WebApplication.CreateBuilder(args); + +var dataDirectory = Path.Combine(builder.Environment.ContentRootPath, "App_Data"); +Directory.CreateDirectory(dataDirectory); + +var configuredConnection = builder.Configuration.GetConnectionString("Default"); +var dbPath = Path.Combine(dataDirectory, "gamelist.db"); +var connectionBuilder = new SqliteConnectionStringBuilder(); + +if (string.IsNullOrWhiteSpace(configuredConnection)) +{ + connectionBuilder.DataSource = dbPath; +} +else +{ + connectionBuilder = new SqliteConnectionStringBuilder(configuredConnection); + + if (connectionBuilder.DataSource.Contains("App_Data", StringComparison.OrdinalIgnoreCase)) + { + var fileName = Path.GetFileName(connectionBuilder.DataSource); + connectionBuilder.DataSource = Path.Combine(dataDirectory, fileName); + } + else if (!Path.IsPathRooted(connectionBuilder.DataSource)) + { + connectionBuilder.DataSource = Path.GetFullPath(connectionBuilder.DataSource, dataDirectory); + } +} + +var connectionString = connectionBuilder.ToString(); + +builder.Services.AddDbContext(options => + options.UseSqlite(connectionString)); + +var app = builder.Build(); + +app.UseDefaultFiles(); +app.UseStaticFiles(); + +app.MapGet("/health", () => Results.Ok(new { status = "ok" })); + +app.Run(); diff --git a/Properties/launchSettings.json b/Properties/launchSettings.json new file mode 100644 index 0000000..66312e8 --- /dev/null +++ b/Properties/launchSettings.json @@ -0,0 +1,38 @@ +{ + "$schema": "http://json.schemastore.org/launchsettings.json", + "iisSettings": { + "windowsAuthentication": false, + "anonymousAuthentication": true, + "iisExpress": { + "applicationUrl": "http://localhost:42257", + "sslPort": 44362 + } + }, + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "http://localhost:5116", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "https://localhost:7103;http://localhost:5116", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "IIS Express": { + "commandName": "IISExpress", + "launchBrowser": true, + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/TASKS.md b/TASKS.md index 96e9e8d..6bfb556 100644 --- a/TASKS.md +++ b/TASKS.md @@ -3,11 +3,11 @@ - [x] Initialize git repository. ## Foundation -- [ ] Add `.gitignore` for .NET / IIS publish artifacts. -- [ ] Scaffold .NET 8 minimal API project with static file hosting (`wwwroot`). -- [ ] Configure SQLite connection pointing to `App_Data` with EF Core migrations folder under `Data/`. -- [ ] Define domain models in `Domain/`: `Player`, `Suggestion`, `Vote`, `AppState`, `Phase` enum. -- [ ] Implement `AppDbContext` in `Data/` with DbSets and simple seeding of `AppState`. +- [x] Add `.gitignore` for .NET / IIS publish artifacts. +- [x] Scaffold .NET 10 minimal API project with static file hosting (`wwwroot`). +- [x] Configure SQLite connection pointing to `App_Data` with EF Core migrations folder under `Data/`. +- [x] Define domain models in `Domain/`: `Player`, `Suggestion`, `Vote`, `AppState`, `Phase` enum. +- [x] Implement `AppDbContext` in `Data/` with DbSets and simple seeding of `AppState`. ## Identity & Middleware - [ ] Middleware to issue/read HttpOnly `player` cookie with Guid; SameSite=Strict; secure in production. diff --git a/appsettings.json b/appsettings.json new file mode 100644 index 0000000..3ffe25c --- /dev/null +++ b/appsettings.json @@ -0,0 +1,12 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*", + "ConnectionStrings": { + "Default": "Data Source=App_Data/gamelist.db" + } +} diff --git a/wwwroot/app.js b/wwwroot/app.js new file mode 100644 index 0000000..fcde689 --- /dev/null +++ b/wwwroot/app.js @@ -0,0 +1 @@ +console.log("CoopGameChooser client placeholder"); diff --git a/wwwroot/index.html b/wwwroot/index.html new file mode 100644 index 0000000..fb5c2cc --- /dev/null +++ b/wwwroot/index.html @@ -0,0 +1,16 @@ + + + + + + CoopGameChooser + + + +
+

CoopGameChooser

+

MVP is on the way. API and UI will land here.

+
+ + + diff --git a/wwwroot/styles.css b/wwwroot/styles.css new file mode 100644 index 0000000..9fd4e90 --- /dev/null +++ b/wwwroot/styles.css @@ -0,0 +1,20 @@ +:root { + font-family: "Segoe UI", system-ui, -apple-system, sans-serif; + background: #0f172a; + color: #e2e8f0; +} + +.container { + max-width: 720px; + margin: 10vh auto; + padding: 24px; + background: rgba(15, 23, 42, 0.7); + border-radius: 12px; + border: 1px solid #1e293b; + box-shadow: 0 10px 40px rgba(0, 0, 0, 0.35); +} + +h1 { + margin-top: 0; + letter-spacing: 0.5px; +}