Upgrade to .NET 10 and finalize foundation scaffold

This commit is contained in:
2026-01-28 14:29:42 +01:00
parent 71f61bb122
commit 257b473253
16 changed files with 328 additions and 6 deletions

25
.gitignore vendored Normal file
View File

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

View File

@@ -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 4) reveal totals sorted by score
Tech constraints: Tech constraints:
- .NET 8 - .NET 10
- ASP.NET Core Minimal API - ASP.NET Core Minimal API
- Static HTML/CSS/JS (no Razor Pages, no Blazor, no HTMX) - Static HTML/CSS/JS (no Razor Pages, no Blazor, no HTMX)
- SQLite via EF Core - SQLite via EF Core
@@ -76,3 +76,4 @@ Do not introduce MVC controllers, Razor Pages, Blazor, or SPA frameworks.
- Implement API first, UI second - Implement API first, UI second
- Keep changes small and testable - Keep changes small and testable
- Prefer clarity over abstraction - Prefer clarity over abstraction
- After every iteration, do a git commit with a brief summary of the changes as a commit message.

61
Data/AppDbContext.cs Normal file
View File

@@ -0,0 +1,61 @@
using GameList.Domain;
using Microsoft.EntityFrameworkCore;
namespace GameList.Data;
public class AppDbContext : DbContext
{
public AppDbContext(DbContextOptions<AppDbContext> options) : base(options)
{
}
public DbSet<Player> Players => Set<Player>();
public DbSet<Suggestion> Suggestions => Set<Suggestion>();
public DbSet<Vote> Votes => Set<Vote>();
public DbSet<AppState> AppState => Set<AppState>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Player>(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<Suggestion>(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<Vote>(builder =>
{
builder.HasKey(v => v.Id);
builder.Property(v => v.Score).IsRequired();
builder.HasIndex(v => new { v.PlayerId, v.SuggestionId }).IsUnique();
});
modelBuilder.Entity<AppState>(builder =>
{
builder.HasKey(s => s.Id);
builder.HasData(new AppState
{
Id = 1,
CurrentPhase = Phase.Suggest,
UpdatedAt = DateTimeOffset.UtcNow
});
});
}
}

8
Domain/AppState.cs Normal file
View File

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

9
Domain/Phase.cs Normal file
View File

@@ -0,0 +1,9 @@
namespace GameList.Domain;
public enum Phase
{
Suggest = 0,
Reveal = 1,
Vote = 2,
Results = 3
}

16
Domain/Player.cs Normal file
View File

@@ -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<Suggestion> Suggestions { get; set; } = new List<Suggestion>();
public ICollection<Vote> Votes { get; set; } = new List<Vote>();
}

32
Domain/Suggestion.cs Normal file
View File

@@ -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<Vote> Votes { get; set; } = new List<Vote>();
}

21
Domain/Vote.cs Normal file
View File

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

17
GameList.csproj Normal file
View File

@@ -0,0 +1,17 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.2">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.2" />
</ItemGroup>
</Project>

45
Program.cs Normal file
View File

@@ -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<AppDbContext>(options =>
options.UseSqlite(connectionString));
var app = builder.Build();
app.UseDefaultFiles();
app.UseStaticFiles();
app.MapGet("/health", () => Results.Ok(new { status = "ok" }));
app.Run();

View File

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

View File

@@ -3,11 +3,11 @@
- [x] Initialize git repository. - [x] Initialize git repository.
## Foundation ## Foundation
- [ ] Add `.gitignore` for .NET / IIS publish artifacts. - [x] Add `.gitignore` for .NET / IIS publish artifacts.
- [ ] Scaffold .NET 8 minimal API project with static file hosting (`wwwroot`). - [x] Scaffold .NET 10 minimal API project with static file hosting (`wwwroot`).
- [ ] Configure SQLite connection pointing to `App_Data` with EF Core migrations folder under `Data/`. - [x] 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. - [x] Define domain models in `Domain/`: `Player`, `Suggestion`, `Vote`, `AppState`, `Phase` enum.
- [ ] Implement `AppDbContext` in `Data/` with DbSets and simple seeding of `AppState`. - [x] Implement `AppDbContext` in `Data/` with DbSets and simple seeding of `AppState`.
## Identity & Middleware ## Identity & Middleware
- [ ] Middleware to issue/read HttpOnly `player` cookie with Guid; SameSite=Strict; secure in production. - [ ] Middleware to issue/read HttpOnly `player` cookie with Guid; SameSite=Strict; secure in production.

12
appsettings.json Normal file
View File

@@ -0,0 +1,12 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*",
"ConnectionStrings": {
"Default": "Data Source=App_Data/gamelist.db"
}
}

1
wwwroot/app.js Normal file
View File

@@ -0,0 +1 @@
console.log("CoopGameChooser client placeholder");

16
wwwroot/index.html Normal file
View File

@@ -0,0 +1,16 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>CoopGameChooser</title>
<link rel="stylesheet" href="styles.css">
</head>
<body>
<main class="container">
<h1>CoopGameChooser</h1>
<p>MVP is on the way. API and UI will land here.</p>
</main>
<script src="app.js"></script>
</body>
</html>

20
wwwroot/styles.css Normal file
View File

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