Author SHA1 Message Date
darkstack c9a3c252e1 Docker compose / Fix build ?
Build App / Build (push) Failing after 2m0s
2026-08-06 11:00:55 +02:00
darkstack a9860f4c94 Added this stupid vibecoded app as a test
Build App / Build (push) Waiting to run
2026-08-06 09:58:06 +02:00
darkstack d9e05fb487 Update to dotnet 9.0, add user roles, MatchStats and OpenApi/Scalar in dev 2026-08-06 09:56:07 +02:00
darkstack e10663c8c0 Update Readme
Build App / Build (push) Successful in 1m31s
2025-03-08 01:17:53 +01:00
darkstack cd03b39c20 Fix Discord Intents
Build App / Build (push) Successful in 2m14s
2025-03-07 15:14:38 +01:00
darkstack 9d8a7b3100 Fix DiscordBot
Build App / Build (push) Successful in 1m41s
2025-03-07 14:55:00 +01:00
87 changed files with 11274 additions and 224 deletions
+33
View File
@@ -0,0 +1,33 @@
# Copy to .env next to docker-compose.yml and edit. Compose reads it automatically;
# git ignores .env. Every value here is optional — the defaults in docker-compose.yml
# are what you get without it.
# --- Ports on your machine -----------------------------------------------------------
# Container-side ports are fixed (API 5000, nginx 80); only these move.
#LADOSE_WEB_PORT=8080
#LADOSE_API_PORT=5000
# --- Database ------------------------------------------------------------------------
# Overrides ConnectionStrings:DbContext from LaDOSE.Api/appsettings.json. The stack has
# no Postgres of its own, so this has to point at a reachable server.
#
# Postgres on the machine running the containers:
#LADOSE_DB_CONNECTION=Host=host.docker.internal;Username=tom;Password=tom;Database=ladoseapi
#
# Postgres elsewhere on the LAN — check the name resolves *inside* the container
# (`docker compose run --rm api getent hosts kafka.local`) and use the IP if it does not:
#LADOSE_DB_CONNECTION=Host=kafka.local;Username=tom;Password=tom;Database=ladoseapi
#
# Sql/dump_20240316.sql then Sql/2026-08-05_roles.sql populate an empty database.
# --- Secrets -------------------------------------------------------------------------
# appsettings.json carries placeholders for these. Any value works for JWT locally, but
# it must be at least 32 characters: Startup.cs feeds it to HMAC-SHA256 as raw ASCII.
#LADOSE_JWT_SECRET=dev-only-secret-not-for-any-deployed-environment
#LADOSE_SMASH_API_KEY=
#LADOSE_CHALLONGE_API_KEY=
# --- Frontend ------------------------------------------------------------------------
# Only needed if the API is not on http://localhost:${LADOSE_API_PORT}. Resolved by the
# browser, so container names like http://api:5000 will not work.
#LADOSE_API_BASE_URL=http://localhost:5000
+1 -1
View File
@@ -4,7 +4,7 @@ on: [push]
jobs:
Build:
runs-on: ubuntu-latest-real
runs-on: ubuntu-latest
steps:
- name: Update
run: |
+7
View File
@@ -328,3 +328,10 @@ ASALocalRun/
# MFractors (Xamarin productivity tool) working folder
.mfractor/
# Local docker-compose overrides: connection string, API keys, ports.
# .env.example is documentation and stays tracked.
.env
.env.*
!.env.example
docker-compose.override.yml
+22 -2
View File
@@ -1,2 +1,22 @@
*/*/bin*
*/*/obj*
# Context for LaDOSE.Src/Dockerfile.
#
# The previous patterns here were */*/bin* and */*/obj*, which matched nothing: this
# context is rooted at LaDOSE.Src, so build output sits one level down (LaDOSE.Api/bin),
# not two.
**/bin/
**/obj/
# The frontend is a separate image with its own context (LaDOSE.WebApp/Dockerfile).
# Its node_modules alone was adding ~118 MB to every API build.
LaDOSE.WebApp/node_modules/
LaDOSE.WebApp/build/
LaDOSE.WebApp/.svelte-kit/
# Local state and editor noise. Note Libraries/ is NOT excluded: LaDOSE.Business
# references ChallongeCSharpDriver.dll from there by HintPath.
.git
.vs/
.vscode/
.idea/
*.user
*.suo
+40 -11
View File
@@ -1,15 +1,44 @@
FROM microsoft/dotnet:sdk AS build-env
# Builds the LaDOSE.Api image. Context is LaDOSE.Src (see .dockerignore next to this file).
#
# Only LaDOSE.Api is published. LaDOSE.linux.sln also carries the Avalonia desktop app,
# the Discord bot and LinuxTest, and `dotnet publish <sln> -o out` flattens every project
# into that one directory — which is why the previous version of this file copied from
# /app/LaDOSE.Api/out/ and found nothing there.
ARG DOTNET_VERSION=9.0
FROM mcr.microsoft.com/dotnet/sdk:${DOTNET_VERSION} AS build
WORKDIR /src
# Debug is deliberate for local work, and docker-compose.yml passes it: the OpenAPI
# document and the Scalar UI are gated behind `#if DEBUG` in Startup.cs *and* behind
# Condition="'$(Configuration)' == 'Debug'" on their PackageReferences in
# LaDOSE.Api.csproj. A Release image therefore serves no /openapi/v1.json, which is
# exactly what LaDOSE.WebApp's `npm run api:sync` reads. Default stays Release.
ARG BUILD_CONFIGURATION=Release
# Project files first so this layer survives every .cs edit. Restore has to run under
# the same Configuration as the publish below, or the conditional PackageReferences
# above make the two disagree about which packages the assets file should contain.
COPY global.json ./
COPY LaDOSE.Api/LaDOSE.Api.csproj LaDOSE.Api/
COPY LaDOSE.DTO/LaDOSE.DTO.csproj LaDOSE.DTO/
COPY LaDOSE.Entity/LaDOSE.Entity.csproj LaDOSE.Entity/
COPY LaDOSE.Service/LaDOSE.Business.csproj LaDOSE.Service/
RUN dotnet restore LaDOSE.Api/LaDOSE.Api.csproj -p:Configuration=${BUILD_CONFIGURATION}
# Libraries/ChallongeCSharpDriver.dll is a HintPath reference from LaDOSE.Business,
# so the build needs the whole tree, not just the projects listed above.
COPY . .
RUN dotnet publish LaDOSE.Api/LaDOSE.Api.csproj -c ${BUILD_CONFIGURATION} --no-restore -o /app/out
FROM mcr.microsoft.com/dotnet/aspnet:${DOTNET_VERSION}
WORKDIR /app
COPY --from=build /app/out/ ./
# Copy everything else and build
COPY . ./
RUN dotnet publish LaDOSE.linux.sln -c Release -o out
# Build runtime image
FROM microsoft/dotnet:aspnetcore-runtime
WORKDIR /app
COPY --from=build-env /app/LaDOSE.Api/out/ .
# Fixed in the image on purpose. Program.cs binds Kestrel from appsettings.json's
# AllowedHosts/Port through a ConfigurationBuilder that reads *only* that file, so a
# Port env var would not move the listener — remap on the host side instead.
# Everything Startup.cs reads does honour env vars (ConnectionStrings__DbContext,
# ApiKey__SmashApiKey, ApiKey__ChallongeApiKey, JWTTokenSecret).
EXPOSE 5000
ENTRYPOINT ["dotnet", "LaDOSE.Api.dll"]
@@ -0,0 +1,38 @@
using System.Collections.Generic;
using System.Threading.Tasks;
using AutoMapper;
using LaDOSE.Business.Interface;
using LaDOSE.DTO;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace LaDOSE.Api.Controllers
{
[Authorize]
[Produces("application/json")]
[Route("api/[controller]")]
public class StatisticsController : Controller
{
private readonly IStatisticsService _service;
private readonly IMapper _mapper;
public StatisticsController(IMapper mapper, IStatisticsService service)
{
_mapper = mapper;
_service = service;
}
/// <summary>
/// Match statistics for the given Event ids. Body is a bare JSON array of ids,
/// like TournamentController.GetResults.
/// A null or empty array returns a zeroed, empty MatchStatsDTO rather than an error.
/// Read Coverage before trusting the numbers: many brackets have no set rows at all.
/// </summary>
[HttpPost("Matches")]
public async Task<MatchStatsDTO> GetMatchStats([FromBody] List<int> ids)
{
var stats = await _service.GetMatchStats(ids);
return _mapper.Map<MatchStatsDTO>(stats);
}
}
}
@@ -1,4 +1,4 @@
using System;
using System;
using System.Collections.Generic;
using System.IdentityModel.Tokens.Jwt;
using System.Linq;
@@ -9,6 +9,7 @@ using LaDOSE.Business.Interface;
using LaDOSE.DTO;
using LaDOSE.Entity;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Options;
@@ -33,12 +34,32 @@ namespace LaDOSE.Api.Controllers
_configuration = configuration;
}
/// <summary>Public view of a user: no password, no hash, no salt.</summary>
private static ApplicationUserDTO ToDto(ApplicationUser user)
{
return new ApplicationUserDTO
{
Id = user.Id,
Username = user.Username,
FirstName = user.FirstName,
LastName = user.LastName,
Roles = user.Names()
};
}
/// <summary>The id the JWT was issued for; null if the request is not authenticated.</summary>
private int? CurrentUserId()
{
return int.TryParse(User?.Identity?.Name, out var id) ? id : (int?)null;
}
[AllowAnonymous]
[HttpPost("auth")]
public IActionResult Authenticate([FromBody]ApplicationUser userDto)
[ProducesResponseType(typeof(ApplicationUserDTO), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
public IActionResult Authenticate([FromBody]ApplicationUserDTO userDto)
{
var user = _userService.Authenticate(userDto.Username, userDto.Password);
var user = _userService.Authenticate(userDto?.Username, userDto?.Password);
if (user == null)
return BadRequest(new { message = "Username or password is incorrect" });
@@ -47,6 +68,9 @@ namespace LaDOSE.Api.Controllers
var key = Encoding.ASCII.GetBytes(this._configuration["JWTTokenSecret"]);
var tokenDescriptor = new SecurityTokenDescriptor
{
// Only the user id goes in the token. Roles are read from the database on
// every request instead, so granting or revoking Admin takes effect at
// once rather than whenever the current token happens to expire.
Subject = new ClaimsIdentity(new Claim[]
{
new Claim(ClaimTypes.Name, user.Id.ToString()),
@@ -60,29 +84,62 @@ namespace LaDOSE.Api.Controllers
var tokenString = tokenHandler.WriteToken(token);
// return basic user info (without password) and token to store client side
return Ok(new ApplicationUserDTO
{
Id = user.Id,
Username = user.Username,
FirstName = user.FirstName,
LastName = user.LastName,
Token = tokenString,
Expire = token.ValidTo
});
var dto = ToDto(user);
dto.Token = tokenString;
dto.Expire = token.ValidTo;
return Ok(dto);
}
[AllowAnonymous]
[HttpPost("register")]
public IActionResult Register([FromBody]ApplicationUser userDto)
/// <summary>Every account, for the admin user-management screen.</summary>
[Authorize(Roles = Roles.Admin)]
[HttpGet]
[ProducesResponseType(typeof(List<ApplicationUserDTO>), StatusCodes.Status200OK)]
public IActionResult GetUsers()
{
// map dto to entity
var users = _userService.GetAll()
.OrderBy(user => user.Username)
.Select(ToDto)
.ToList();
return Ok(users);
}
/// <summary>The role names that may be assigned, from the applicationrole table.</summary>
[Authorize(Roles = Roles.Admin)]
[HttpGet("Roles")]
[ProducesResponseType(typeof(List<string>), StatusCodes.Status200OK)]
public IActionResult GetRoles()
{
return Ok(_userService.GetAllRoles().Select(role => role.Name).ToList());
}
/// <summary>
/// Creates an account. This replaces the old anonymous <c>register</c> endpoint —
/// only an admin may create users now, so the very first admin has to be promoted
/// directly in the database (see Sql/2026-08-05_roles.sql).
/// </summary>
[Authorize(Roles = Roles.Admin)]
[HttpPost("AddUser")]
[ProducesResponseType(typeof(ApplicationUserDTO), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
public IActionResult AddUser([FromBody]ApplicationUserDTO userDto)
{
if (userDto == null)
return BadRequest(new { message = "No user supplied" });
try
{
// save
_userService.Create(userDto, userDto.Password);
return Ok();
var created = _userService.Create(
new ApplicationUser
{
Username = userDto.Username?.Trim(),
FirstName = userDto.FirstName,
LastName = userDto.LastName
},
userDto.Password,
userDto.Roles);
return Ok(ToDto(created));
}
catch (Exception ex)
{
@@ -91,7 +148,35 @@ namespace LaDOSE.Api.Controllers
}
}
/// <summary>
/// Deletes an account. Refuses to delete the caller: since only an admin can reach
/// this, and an admin cannot remove themselves, at least one admin always survives
/// — which matters because there is no anonymous way back in any more.
/// </summary>
[Authorize(Roles = Roles.Admin)]
[HttpDelete("{id}")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public IActionResult DeleteUser(int id)
{
var user = _userService.GetById(id);
if (user == null)
return NotFound(new { message = "User not found" });
if (CurrentUserId() == id)
return BadRequest(new { message = "You cannot delete your own account" });
try
{
_userService.Delete(id);
return NoContent();
}
catch (Exception ex)
{
return BadRequest(new { message = ex.Message });
}
}
}
}
@@ -0,0 +1,41 @@
#if DEBUG
using System.Linq;
using Microsoft.AspNetCore.Mvc.ApplicationModels;
using Microsoft.AspNetCore.Mvc.Routing;
namespace LaDOSE.Api.Helpers
{
/// <summary>
/// The controllers in this project are attribute-routed but do not carry [ApiController].
/// Without it MVC never sets ApiExplorer visibility, so ApiExplorer yields no descriptions
/// and the generated OpenAPI document comes out with an empty "paths" object.
/// This convention opts the attribute-routed actions into ApiExplorer for the
/// OpenAPI/Scalar tooling only, without pulling in the [ApiController] behaviours
/// (automatic 400 responses, [FromBody] inference) that would change runtime binding.
/// </summary>
public class ApiExplorerVisibilityConvention : IControllerModelConvention
{
public void Apply(ControllerModel controller)
{
// Default the controller to hidden, then opt in action by action.
controller.ApiExplorer.IsVisible ??= false;
foreach (var action in controller.Actions)
{
if (action.ApiExplorer.IsVisible != null)
{
continue;
}
// An action with a [Route] but no verb attribute (e.g. BotEventController's
// CreateBotEvent) matches every HTTP method, so ApiExplorer reports an empty
// method and OpenAPI generation throws "Unsupported HTTP method".
// Only document actions that pin down a verb.
action.ApiExplorer.IsVisible = action.Attributes
.OfType<IActionHttpMethodProvider>()
.Any(provider => provider.HttpMethods?.Any() == true);
}
}
}
}
#endif
+4 -1
View File
@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<TargetFramework>net9.0</TargetFramework>
<Platforms>AnyCPU;x64</Platforms>
<LangVersion>12</LangVersion>
</PropertyGroup>
@@ -15,6 +15,9 @@
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.12" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.NewtonsoftJson" Version="8.0.12" />
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="8.0.1" />
<PackageReference Include="Microsoft.OpenApi" Version="1.6.17" Condition="'$(Configuration)' == 'Debug'" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.18" Condition="'$(Configuration)' == 'Debug'" />
<PackageReference Include="Scalar.AspNetCore" Version="2.16.17" Condition="'$(Configuration)' == 'Debug'" />
<PackageReference Include="Microsoft.VisualStudio.Web.CodeGeneration.Design" Version="8.0.7" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="8.0.11" />
+46 -2
View File
@@ -1,5 +1,6 @@
using System;
using System.Reflection;
using System.Security.Claims;
using System.Text;
using System.Threading.Tasks;
using LaDOSE.Business.Interface;
@@ -25,6 +26,9 @@ using Result = LaDOSE.Entity.Challonge.Result;
using LaDOSE.Entity.BotEvent;
using Microsoft.EntityFrameworkCore.Storage;
using Microsoft.Extensions.Hosting;
#if DEBUG
using Scalar.AspNetCore;
#endif
namespace LaDOSE.Api
{
@@ -55,11 +59,21 @@ namespace LaDOSE.Api
}
services.AddCors();
services.AddMvc().AddNewtonsoftJson(x =>
services.AddMvc(options =>
{
#if DEBUG
// Make the attribute-routed controllers visible to ApiExplorer so the
// OpenAPI document is actually populated. See ApiExplorerVisibilityConvention.
options.Conventions.Add(new ApiExplorerVisibilityConvention());
#endif
}).AddNewtonsoftJson(x =>
{
x.SerializerSettings.ReferenceLoopHandling = Newtonsoft.Json.ReferenceLoopHandling.Ignore;
x.SerializerSettings.MaxDepth= 4;
});
#if DEBUG
services.AddOpenApi();
#endif
// services.AddDbContextPool<LaDOSEDbContext>( // replace "YourDbContext" with the class name of your DbContext
//
// options => options.UseMySql($"Server={MySqlServer};Database={MySqlDatabase};User={MySqlUser};Password={MySqlPassword};", // replace with your Connection String
@@ -92,6 +106,18 @@ namespace LaDOSE.Api
{
// return unauthorized if user no longer exists
context.Fail("Unauthorized");
return Task.CompletedTask;
}
// Roles are attached here, from the database, rather than being
// signed into the token: a promotion or demotion then applies to
// the caller's very next request instead of waiting 16 minutes.
if (context.Principal.Identity is ClaimsIdentity identity)
{
foreach (var role in user.Names())
{
identity.AddClaim(new Claim(identity.RoleClaimType, role));
}
}
return Task.CompletedTask;
@@ -130,6 +156,13 @@ namespace LaDOSE.Api
cfg.CreateMapTwoWay<Game, LaDOSE.DTO.GameDTO>();
cfg.CreateMapTwoWay<Todo, LaDOSE.DTO.TodoDTO>();
// Match statistics: plain POCO aggregates computed by StatisticsService,
// mapped by name (same pattern as TournamentsResult above).
cfg.CreateMap<MatchStats, LaDOSE.DTO.MatchStatsDTO>();
cfg.CreateMap<MatchCoverage, LaDOSE.DTO.MatchCoverageDTO>();
cfg.CreateMap<PlayerMatchStats, LaDOSE.DTO.PlayerMatchStatsDTO>();
cfg.CreateMap<HeadToHead, LaDOSE.DTO.HeadToHeadDTO>();
});
IMapper mapper = mapperConfig.CreateMapper();
services.AddSingleton(mapper);
@@ -149,6 +182,7 @@ namespace LaDOSE.Api
services.AddScoped<IBotEventService, BotEventService>();
services.AddScoped<IPlayerService, PlayerService>();
services.AddScoped<IStatisticsService, StatisticsService>();
services.AddTransient<IChallongeProvider>(p => new ChallongeProvider( p.GetRequiredService<IGameService>(),
p.GetRequiredService<IEventService>(),
p.GetRequiredService<IPlayerService>(),
@@ -185,7 +219,17 @@ namespace LaDOSE.Api
app.UseRouting();
app.UseAuthentication();
app.UseAuthorization();
app.UseEndpoints(x => x.MapControllers());
app.UseEndpoints(x =>
{
x.MapControllers();
#if DEBUG
if (env.IsDevelopment())
{
x.MapOpenApi();
x.MapScalarApiReference();
}
#endif
});
}
}
}
+1 -7
View File
@@ -5,18 +5,12 @@
}
},
"ConnectionStrings": {
"DbContext":"Host=descartes.local;Username=tom;Password=tom;Database=ladoseapi"
"DbContext":"Host=kafka.local;Username=tom;Password=tom;Database=ladoseapi"
},
"CertificateSettings": {
"fileName": "localhost.pfx",
"password": "YourSecurePassword"
},
"MySql": {
"Server": "localhost",
"Database": "ladoseapi",
"User": "dev",
"Password": "dev"
},
"ApiKey": {
"ChallongeApiKey": "Challonge ApiKey",
"SmashApiKey": "Smash"
@@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
namespace LaDOSE.DTO
{
@@ -9,8 +10,13 @@ namespace LaDOSE.DTO
public string FirstName { get; set; }
public string LastName { get; set; }
public string Username { get; set; }
/// <summary>Only ever read from a request; never populated on a response.</summary>
public string Password { get; set; }
/// <summary>Role names held by the user, e.g. <c>["Admin"]</c>. Empty means a plain user.</summary>
public List<string> Roles { get; set; }
public string Token { get; set; }
public DateTime Expire { get; set; }
}
+6 -1
View File
@@ -1,8 +1,13 @@
namespace LaDOSE.DTO
using System;
namespace LaDOSE.DTO
{
public class EventDTO
{
public int Id { get; set; }
public string Name { get; set; }
/// <summary>Event date, mapped by convention from Event.Date. Used for time-series charts.</summary>
public DateTime Date { get; set; }
};
}
+1 -1
View File
@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<TargetFramework>net9.0</TargetFramework>
<Platforms>AnyCPU;x64</Platforms>
</PropertyGroup>
+55
View File
@@ -0,0 +1,55 @@
using System.Collections.Generic;
namespace LaDOSE.DTO
{
public class MatchStatsDTO
{
public MatchCoverageDTO Coverage { get; set; }
public List<PlayerMatchStatsDTO> Players { get; set; }
public List<HeadToHeadDTO> HeadToHead { get; set; }
}
public class MatchCoverageDTO
{
/// <summary>Requested event ids that actually exist.</summary>
public int Events { get; set; }
/// <summary>Tournaments (brackets) belonging to those events.</summary>
public int Brackets { get; set; }
/// <summary>Of those brackets, how many have at least one set row.</summary>
public int BracketsWithSets { get; set; }
/// <summary>Total set rows in scope, including unusable ones.</summary>
public int Sets { get; set; }
/// <summary>Sets with a determinable winner.</summary>
public int DecidedSets { get; set; }
}
public class PlayerMatchStatsDTO
{
public int PlayerId { get; set; }
/// <summary>Gamertag, falling back to Name, else "#&lt;id&gt;".</summary>
public string Player { get; set; }
/// <summary>Decided sets only. Always equals Wins + Losses.</summary>
public int Sets { get; set; }
public int Wins { get; set; }
public int Losses { get; set; }
public int GamesWon { get; set; }
public int GamesLost { get; set; }
}
public class HeadToHeadDTO
{
public int PlayerAId { get; set; }
public string PlayerA { get; set; }
public int PlayerBId { get; set; }
public string PlayerB { get; set; }
public int WinsA { get; set; }
public int WinsB { get; set; }
}
}
@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<TargetFramework>net9.0</TargetFramework>
<Nullable>enable</Nullable>
<BuiltInComInteropSupport>true</BuiltInComInteropSupport>
<ApplicationManifest>app.manifest</ApplicationManifest>
@@ -1,58 +1,55 @@
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using DSharpPlus.CommandsNext;
using DSharpPlus.CommandsNext.Attributes;
using LaDOSE.DiscordBot.Service;
using LaDOSE.DTO;
namespace LaDOSE.DiscordBot.Command
{
public class BotEvent : BaseCommandModule
{
private WebService dep;
public BotEvent(WebService d)
{
dep = d;
}
[RequireRolesAttribute(RoleCheckMode.Any, "Staff")]
[Command("newevent")]
public async Task NewEventAsync(CommandContext ctx, string command)
{
await ctx.RespondAsync(dep.RestService.CreateBotEvent(command).ToString());
}
[RequireRolesAttribute(RoleCheckMode.Any,"Staff")]
[Command("staffs")]
public async Task StaffAsync(CommandContext ctx)
{
BotEventDTO currentEvent = dep.RestService.GetLastBotEvent();
StringBuilder stringBuilder = new StringBuilder();
var present = currentEvent.Results.Where(x => x.Result).ToList();
var absent = currentEvent.Results.Where(x => !x.Result).ToList();
stringBuilder.AppendLine($"Pour {currentEvent.Name} : ");
present.ForEach(x => stringBuilder.AppendLine($":white_check_mark: {x.Name}"));
absent.ForEach(x => stringBuilder.AppendLine($":x: {x.Name}"));
await ctx.RespondAsync(stringBuilder.ToString());
}
[RequireRolesAttribute(RoleCheckMode.Any, "Staff")]
[Command("present")]
public async Task PresentAsync(CommandContext ctx)
{
await ctx.RespondAsync(dep.RestService.ResultBotEvent(new DTO.BotEventSendDTO() { DiscordId = ctx.Member.Id.ToString(), DiscordName = ctx.Member.DisplayName, Present = true }).ToString());
}
[RequireRolesAttribute(RoleCheckMode.Any, "Staff")]
[Command("absent")]
public async Task AbsentAsync(CommandContext ctx)
{
await ctx.RespondAsync(dep.RestService.ResultBotEvent(new DTO.BotEventSendDTO() { DiscordId = ctx.Member.Id.ToString(), DiscordName = ctx.Member.DisplayName, Present = false }).ToString());
}
}
// public class BotEvent : BaseCommandModule
// {
// private WebService dep;
// public BotEvent(WebService d)
// {
// dep = d;
// }
//
// [Command("newevent")]
// public async Task NewEventAsync(CommandContext ctx, string command)
// {
//
// await ctx.RespondAsync(dep.RestService.CreateBotEvent(command).ToString());
// }
// [RequireRolesAttribute(RoleCheckMode.Any,"Staff")]
// [Command("staffs")]
// public async Task StaffAsync(CommandContext ctx)
// {
// BotEventDTO currentEvent = dep.RestService.GetLastBotEvent();
// StringBuilder stringBuilder = new StringBuilder();
//
// var present = currentEvent.Results.Where(x => x.Result).ToList();
// var absent = currentEvent.Results.Where(x => !x.Result).ToList();
//
// stringBuilder.AppendLine($"Pour {currentEvent.Name} : ");
// present.ForEach(x => stringBuilder.AppendLine($":white_check_mark: {x.Name}"));
// absent.ForEach(x => stringBuilder.AppendLine($":x: {x.Name}"));
//
// await ctx.RespondAsync(stringBuilder.ToString());
//
// }
// [RequireRolesAttribute(RoleCheckMode.Any, "Staff")]
// [Command("present")]
// public async Task PresentAsync(CommandContext ctx)
// {
// await ctx.RespondAsync(dep.RestService.ResultBotEvent(new DTO.BotEventSendDTO() { DiscordId = ctx.Member.Id.ToString(), DiscordName = ctx.Member.DisplayName, Present = true }).ToString());
//
//
// }
// [RequireRolesAttribute(RoleCheckMode.Any, "Staff")]
// [Command("absent")]
// public async Task AbsentAsync(CommandContext ctx)
// {
// await ctx.RespondAsync(dep.RestService.ResultBotEvent(new DTO.BotEventSendDTO() { DiscordId = ctx.Member.Id.ToString(), DiscordName = ctx.Member.DisplayName, Present = false }).ToString());
// }
// }
}
+14 -6
View File
@@ -2,14 +2,15 @@
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using DSharpPlus.CommandsNext;
using DSharpPlus.CommandsNext.Attributes;
using DSharpPlus.Commands;
using DSharpPlus.Commands.ArgumentModifiers;
using DSharpPlus.Commands.Processors.TextCommands;
using DSharpPlus.Entities;
namespace LaDOSE.DiscordBot.Command
{
public class Hokuto : BaseCommandModule
public class Hokuto
{
private static List<string> Games = new List<string> { "2X", "3.3", "Karnov" };
@@ -21,14 +22,21 @@ namespace LaDOSE.DiscordBot.Command
[Command("hokuto")]
public async Task HokutoUserAsync(CommandContext ctx, params DiscordMember[] user)
public async ValueTask HokutoUserAsync(TextCommandContext ctx)
{
var i = r.Next(0, 3);
if (user!=null && user.Length>0)
if (ctx.Message.MentionedUsers is { Count: 1 } )
{
await ctx.RespondAsync(ctx.User?.Mention + " vs " + user[0].Mention + " : " + Games[i].ToString());
foreach (var arg in ctx.Message.MentionedUsers)
{
if (arg is DiscordUser member)
{
await ctx.RespondAsync(ctx.User?.Mention + " vs " + member.Mention + " : " + Games[i].ToString());
return;
}
}
}
else
{
@@ -4,12 +4,11 @@ using System.Globalization;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using DSharpPlus.CommandsNext;
using DSharpPlus.CommandsNext.Attributes;
using DSharpPlus.Commands;
namespace LaDOSE.DiscordBot.Command
{
public class Public : BaseCommandModule
public class Public
{
private static List<string> Quotes { get; set; }
@@ -2,16 +2,17 @@
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<TargetFramework>net9.0</TargetFramework>
<Platforms>AnyCPU;x64</Platforms>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="DSharpPlus" Version="4.5.0" />
<PackageReference Include="DSharpPlus.CommandsNext" Version="4.5.0" />
<PackageReference Include="DSharpPlus.Interactivity" Version="4.5.0" />
<PackageReference Include="DSharpPlus" Version="5.0.0-alpha.5" />
<PackageReference Include="DSharpPlus.Commands" Version="5.0.0-alpha.5" />
<PackageReference Include="DSharpPlus.Interactivity" Version="5.0.0-alpha.5" />
<PackageReference Include="Microsoft.Extensions.Configuration" Version="8.0.0" />
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="8.0.1" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="8.0.2" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
</ItemGroup>
+22 -89
View File
@@ -4,8 +4,12 @@ using System.IO;
using System.Threading;
using System.Threading.Tasks;
using DSharpPlus;
using DSharpPlus.Commands;
using DSharpPlus.Commands.Processors.SlashCommands;
using DSharpPlus.Commands.Processors.TextCommands;
using DSharpPlus.Commands.Processors.TextCommands.Parsing;
using DSharpPlus.Entities;
using DSharpPlus.Interactivity;
using DSharpPlus.CommandsNext;
using DSharpPlus.EventArgs;
using DSharpPlus.Interactivity.Extensions;
//using DSharpPlus.SlashCommands;
@@ -20,7 +24,6 @@ namespace LaDOSE.DiscordBot
{
class Program
{
static DiscordClient discord;
//static InteractivityModule Interactivity { get; set; }
static void Main(string[] args)
@@ -43,106 +46,36 @@ namespace LaDOSE.DiscordBot
var restUser = builder["REST:User"].ToString();
var restPassword = builder["REST:Password"].ToString();
var service = new ServiceCollection()
.AddSingleton(typeof(WebService), new WebService(new Uri(restUrl), restUser, restPassword))
.BuildServiceProvider();
Console.WriteLine($"LaDOSE.Net Discord Bot");
discord = new DiscordClient(new DiscordConfiguration
{
Token = discordToken,
TokenType = TokenType.Bot,
//AutoReconnect = true,
//MinimumLogLevel = LogLevel.Debug,
//MessageCacheSize = 0,
});
discord.UseInteractivity(new InteractivityConfiguration
{
// default pagination behaviour to just ignore the reactions
//PaginationBehaviour = TimeoutBehaviour.Ignore,
// default pagination timeout to 5 minutes
//PaginationTimeout = TimeSpan.FromMinutes(5),
// default timeout for other actions to 2 minutes
Timeout = TimeSpan.FromMinutes(2)
});
DiscordClientBuilder builder2 =
DiscordClientBuilder.CreateDefault(discordToken, DiscordIntents.AllUnprivileged | DiscordIntents.MessageContents | DiscordIntents.GuildMessages| TextCommandProcessor.RequiredIntents | SlashCommandProcessor.RequiredIntents);
var cts = new CancellationTokenSource();
var _cnext = discord.UseCommandsNext(new CommandsNextConfiguration()
// Setup the commands extension
builder2.UseCommands((IServiceProvider serviceProvider, CommandsExtension extension) =>
{
//CaseSensitive = false,
//EnableDefaultHelp = true,
//EnableDms = false,
//EnableMentionPrefix = true,
StringPrefixes = new List<string>() { "/", "!" },
//IgnoreExtraArguments = true,
Services = service
extension.AddCommands([typeof(Hokuto), typeof(Public)]);
TextCommandProcessor textCommandProcessor = new();
extension.AddProcessor(textCommandProcessor);
}, new CommandsConfiguration()
{
// The default value is true, however it's shown here for clarity
RegisterDefaultCommandProcessors = true,
UseDefaultCommandErrorHandler = false
// DebugGuildId = Environment.GetEnvironmentVariable("DEBUG_GUILD_ID") ?? 0,
});
DiscordClient client = builder2.Build();
//var slashCommands = discord.UseSlashCommands(new SlashCommandsConfiguration() {Services = service});
//slashCommands.RegisterCommands<SlashCommand>(guildId:null);
//_cnext.RegisterCommands<Result>();
_cnext.RegisterCommands<Public>();
//_cnext.RegisterCommands<Shutdown>();
//_cnext.RegisterCommands<Todo>();
_cnext.RegisterCommands<Hokuto>();
_cnext.RegisterCommands<BotEvent>();
foreach (var registeredCommandsKey in discord.GetCommandsNext().RegisteredCommands.Keys)
{
Console.WriteLine(registeredCommandsKey);
}
discord.Ready += (sender, eventArgs) =>
{
Console.WriteLine($"Bot READY.");
return Task.CompletedTask;
};
discord.GuildAvailable += (sender, eventArgs) =>
{
Console.WriteLine($"Joined Guild " + eventArgs.Guild.Name);
return Task.CompletedTask;
};
await discord.ConnectAsync();
// We can specify a status for our bot. Let's set it to "playing" and set the activity to "with fire".
DiscordActivity status = new("Street Fighter", DiscordActivityType.Playing);
await client.ConnectAsync(status,DiscordUserStatus.Online);
await Task.Delay(Timeout.Infinite);
//while (!cts.IsCancellationRequested)
//{
// await Task.Delay(200);
// //if(discord.GetConnectionsAsync().Result.Count)
//}
}
}
//internal class SlashCommand : ApplicationCommandModule
//{
// [SlashCommand("test", "A slash command made to test the DSharpPlusSlashCommands library!")]
// public async Task TestCommand(InteractionContext ctx)
// {
// await ctx.CreateResponseAsync("Lol");
// }
//}
}
@@ -0,0 +1,17 @@
using System.ComponentModel.DataAnnotations;
namespace LaDOSE.Entity
{
/// <summary>
/// A role a user can hold, stored in the pre-existing <c>applicationrole</c> table.
/// Rows are reference data seeded by Sql/2026-08-05_roles.sql, not created at runtime.
/// </summary>
public class ApplicationRole
{
public int Id { get; set; }
[Required]
[MaxLength(50)]
public string Name { get; set; }
}
}
+10 -1
View File
@@ -1,4 +1,5 @@
using System.ComponentModel.DataAnnotations;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
@@ -14,6 +15,14 @@ namespace LaDOSE.Entity
public string Password { get; set; }
public byte[] PasswordHash { get; set; }
public byte[] PasswordSalt { get; set; }
/// <summary>
/// Rows of the <c>applicationuserrole</c> join table for this user. Only populated
/// when the query asks for it — see UserService, which includes it (and the role
/// itself) everywhere the role matters; authorization reads this on every request.
/// Prefer the <see cref="Roles.Names"/> / <see cref="Roles.IsAdmin"/> helpers.
/// </summary>
public List<ApplicationUserRole> UserRoles { get; set; }
}
}
@@ -0,0 +1,16 @@
namespace LaDOSE.Entity
{
/// <summary>
/// The <c>applicationuserrole</c> join table. Declared explicitly rather than left
/// implicit so its columns match the table that already exists in the database
/// (<c>userid</c>, <c>roleid</c>, no surrogate key).
/// </summary>
public class ApplicationUserRole
{
public int UserId { get; set; }
public ApplicationUser User { get; set; }
public int RoleId { get; set; }
public ApplicationRole Role { get; set; }
}
}
@@ -9,6 +9,7 @@ namespace LaDOSE.Entity.Context
{
public DbSet<Game> Game { get; set; }
public DbSet<ApplicationUser> ApplicationUser { get; set; }
public DbSet<ApplicationRole> ApplicationRole { get; set; }
public DbSet<Todo> Todo { get; set; }
@@ -49,6 +50,27 @@ namespace LaDOSE.Entity.Context
base.OnModelCreating(modelBuilder);
#region Users and roles
// Maps onto the applicationrole / applicationuserrole tables that already exist
// in the schema. The join table is configured explicitly rather than as a
// many-to-many skip navigation so its columns are exactly userid + roleid,
// with the pair as the key and no surrogate id.
modelBuilder.Entity<ApplicationUserRole>(join =>
{
join.HasKey(ur => new { ur.UserId, ur.RoleId });
join.HasOne(ur => ur.User)
.WithMany(u => u.UserRoles)
.HasForeignKey(ur => ur.UserId);
join.HasOne(ur => ur.Role)
.WithMany()
.HasForeignKey(ur => ur.RoleId);
});
#endregion
modelBuilder.Entity<Event>()
.HasMany(s => s.Tournaments);
@@ -2,7 +2,7 @@
<PropertyGroup>
<Platforms>AnyCPU;x64</Platforms>
<TargetFramework>net8.0</TargetFramework>
<TargetFramework>net9.0</TargetFramework>
</PropertyGroup>
<ItemGroup>
+41
View File
@@ -0,0 +1,41 @@
using System;
using System.Collections.Generic;
using System.Linq;
namespace LaDOSE.Entity
{
/// <summary>
/// The role names the API knows about. They must exist as rows in
/// <c>applicationrole</c> — see Sql/2026-08-05_roles.sql.
/// </summary>
public static class Roles
{
/// <summary>May manage user accounts.</summary>
public const string Admin = "Admin";
/// <summary>May use everything else. The absence of a role means the same thing.</summary>
public const string User = "User";
public static readonly string[] All = { Admin, User };
/// <summary>Case-insensitive, so 'admin' typed into the SQL seed still counts.</summary>
public static bool IsAdmin(this ApplicationUser user)
{
return user.Names().Any(name => Admin.Equals(name, StringComparison.OrdinalIgnoreCase));
}
/// <summary>
/// The user's role names. Empty when the user holds no role, and also when the
/// query did not include them — callers that authorize must load them.
/// </summary>
public static List<string> Names(this ApplicationUser user)
{
return user?.UserRoles?
.Select(userRole => userRole.Role?.Name)
.Where(name => !string.IsNullOrWhiteSpace(name))
.OrderBy(name => name)
.ToList()
?? new List<string>();
}
}
}
@@ -0,0 +1,74 @@
using System.Collections.Generic;
namespace LaDOSE.Entity
{
/// <summary>
/// Aggregate over the persisted <see cref="Set"/> rows of one or more Events.
/// Not an entity: it is never mapped by EF, it is computed in memory by
/// StatisticsService and mapped to MatchStatsDTO by AutoMapper (same pattern as
/// <see cref="Challonge.TournamentsResult"/>).
/// Property names must stay identical to the DTO's, the mapping is by convention.
/// </summary>
public class MatchStats
{
/// <summary>How much of the requested scope actually has set data behind it.</summary>
public MatchCoverage Coverage { get; set; } = new MatchCoverage();
public List<PlayerMatchStats> Players { get; set; } = new List<PlayerMatchStats>();
public List<HeadToHead> HeadToHead { get; set; } = new List<HeadToHead>();
}
/// <summary>
/// Set coverage is sparse: Challonge-imported events have no sets at all, and events
/// imported before SmashProvider.GetSets existed were never backfilled. These counters
/// let a caller say "12 of 40 brackets have match data" instead of implying completeness.
/// </summary>
public class MatchCoverage
{
/// <summary>Requested event ids that actually exist.</summary>
public int Events { get; set; }
/// <summary>Tournaments (brackets) belonging to those events.</summary>
public int Brackets { get; set; }
/// <summary>Of those brackets, how many have at least one set row.</summary>
public int BracketsWithSets { get; set; }
/// <summary>Total set rows in scope, including the ones skipped as unusable.</summary>
public int Sets { get; set; }
/// <summary>Sets with a determinable winner (unequal scores, two distinct real players).</summary>
public int DecidedSets { get; set; }
}
public class PlayerMatchStats
{
public int PlayerId { get; set; }
/// <summary>Gamertag, falling back to Name, else "#&lt;id&gt;".</summary>
public string Player { get; set; }
/// <summary>Decided sets only. Always equals Wins + Losses.</summary>
public int Sets { get; set; }
public int Wins { get; set; }
public int Losses { get; set; }
public int GamesWon { get; set; }
public int GamesLost { get; set; }
}
/// <summary>
/// One row per unordered pair of players with at least one decided set.
/// A is always the lower PlayerId so WinsA / WinsB are unambiguous.
/// </summary>
public class HeadToHead
{
public int PlayerAId { get; set; }
public string PlayerA { get; set; }
public int PlayerBId { get; set; }
public string PlayerB { get; set; }
public int WinsA { get; set; }
public int WinsB { get; set; }
}
}
+1 -1
View File
@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<TargetFramework>net9.0</TargetFramework>
<Platforms>AnyCPU;x64</Platforms>
<LangVersion>12</LangVersion>
</PropertyGroup>
@@ -0,0 +1,16 @@
using System.Collections.Generic;
using System.Threading.Tasks;
using LaDOSE.Entity;
namespace LaDOSE.Business.Interface
{
public interface IStatisticsService
{
/// <summary>
/// Aggregate the persisted sets of the given Events into per-player records,
/// head-to-head records and a coverage report.
/// A null or empty id list yields a well-formed, zeroed <see cref="MatchStats"/>.
/// </summary>
Task<MatchStats> GetMatchStats(List<int> eventIds);
}
}
@@ -8,8 +8,11 @@ namespace LaDOSE.Business.Interface
ApplicationUser Authenticate(string username, string password);
IEnumerable<ApplicationUser> GetAll();
ApplicationUser GetById(int id);
ApplicationUser Create(ApplicationUser user, string password);
ApplicationUser Create(ApplicationUser user, string password, IEnumerable<string> roleNames = null);
void Update(ApplicationUser user, string password = null);
void Delete(int id);
/// <summary>The roles that exist in the database — reference data, not created at runtime.</summary>
IEnumerable<ApplicationRole> GetAllRoles();
}
}
@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<TargetFramework>net9.0</TargetFramework>
<AssemblyName>LaDOSE.Business</AssemblyName>
<RootNamespace>LaDOSE.Business</RootNamespace>
<Platforms>AnyCPU;x64</Platforms>
@@ -0,0 +1,267 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Threading.Tasks;
using LaDOSE.Business.Interface;
using LaDOSE.Entity;
using LaDOSE.Entity.Context;
namespace LaDOSE.Business.Service
{
/// <summary>
/// Match statistics over the persisted Set rows.
///
/// Two things to know about the data:
/// - There is no Set -> Player relationship in the EF model (the navigation properties
/// and their configuration are commented out), so player names are resolved with a
/// separate query plus an in-memory dictionary. Nothing here Includes a player.
/// - There is no winner column. The winner is inferred from the scores, and start.gg
/// encodes a DQ as -1, so games are clamped at 0.
///
/// The database work and the aggregation are deliberately separated: <see cref="GetMatchStats"/>
/// issues one query per table and hands the loaded lists to the pure static
/// <see cref="Aggregate"/>, which is unit-testable without a database.
/// </summary>
public class StatisticsService : IStatisticsService
{
protected LaDOSEDbContext _context;
public StatisticsService(LaDOSEDbContext context)
{
_context = context;
}
public Task<MatchStats> GetMatchStats(List<int> eventIds)
{
var requested = (eventIds ?? new List<int>()).Distinct().ToList();
if (requested.Count == 0)
{
// Well-formed and empty, never null and never a 500.
return Task.FromResult(new MatchStats());
}
// One query per table. No query per set and no query per player.
var events = _context.Event
.Where(e => requested.Contains(e.Id))
.ToList();
var existingEventIds = events.Select(e => e.Id).ToList();
var tournaments = existingEventIds.Count == 0
? new List<Tournament>()
: _context.Tournament
.Where(t => existingEventIds.Contains(t.EventId))
.ToList();
var tournamentIds = tournaments.Select(t => t.Id).Distinct().ToList();
// _context.Set is the DbSet<Set> property, not DbContext.Set<T>().
var sets = tournamentIds.Count == 0
? new List<Set>()
: _context.Set
.Where(s => tournamentIds.Contains(s.TournamentId))
.ToList();
var playerIds = sets.Select(s => s.Player1Id)
.Concat(sets.Select(s => s.Player2Id))
.Where(id => id != 0)
.Distinct()
.ToList();
var players = playerIds.Count == 0
? new List<Player>()
: _context.Player
.Where(p => playerIds.Contains(p.Id))
.ToList();
return Task.FromResult(Aggregate(requested, events, tournaments, sets, players));
}
/// <summary>
/// Pure aggregation over already-loaded rows. No database, no I/O, deterministic.
/// </summary>
/// <param name="requestedEventIds">The event ids the caller asked for.</param>
/// <param name="events">Events that exist (used for the coverage count).</param>
/// <param name="tournaments">Candidate tournaments; filtered here on EventId.</param>
/// <param name="sets">Candidate sets; filtered here on TournamentId.</param>
/// <param name="players">Players used to resolve display names; may be incomplete.</param>
public static MatchStats Aggregate(
IEnumerable<int> requestedEventIds,
IEnumerable<Event> events,
IEnumerable<Tournament> tournaments,
IEnumerable<Set> sets,
IEnumerable<Player> players)
{
var result = new MatchStats();
var requested = new HashSet<int>((requestedEventIds ?? Enumerable.Empty<int>()));
if (requested.Count == 0)
{
return result;
}
// Coverage: events -------------------------------------------------------
var existingEventIds = new HashSet<int>((events ?? Enumerable.Empty<Event>())
.Where(e => e != null && requested.Contains(e.Id))
.Select(e => e.Id));
result.Coverage.Events = existingEventIds.Count;
// Coverage: brackets ----------------------------------------------------
var scopedTournamentIds = new HashSet<int>((tournaments ?? Enumerable.Empty<Tournament>())
.Where(t => t != null && existingEventIds.Contains(t.EventId))
.Select(t => t.Id));
result.Coverage.Brackets = scopedTournamentIds.Count;
var scopedSets = (sets ?? Enumerable.Empty<Set>())
.Where(s => s != null && scopedTournamentIds.Contains(s.TournamentId))
.ToList();
result.Coverage.Sets = scopedSets.Count;
result.Coverage.BracketsWithSets = scopedSets.Select(s => s.TournamentId).Distinct().Count();
// Name resolution -------------------------------------------------------
var nameById = new Dictionary<int, string>();
foreach (var player in (players ?? Enumerable.Empty<Player>()).Where(p => p != null))
{
nameById[player.Id] = DisplayName(player);
}
var stats = new Dictionary<int, PlayerMatchStats>();
var h2h = new Dictionary<(int, int), HeadToHead>();
foreach (var set in scopedSets)
{
// Unusable rows still count in Coverage.Sets but nowhere else.
if (set.Player1Id == 0 || set.Player2Id == 0 || set.Player1Id == set.Player2Id)
{
continue;
}
// No winner column: equal scores (including 0-0 and -1 / -1) are undecided.
if (set.Player1Score == set.Player2Score)
{
continue;
}
result.Coverage.DecidedSets++;
var p1 = set.Player1Id;
var p2 = set.Player2Id;
// A DQ is stored as -1. Clamp so it never produces negative games.
var games1 = Math.Max(0, set.Player1Score);
var games2 = Math.Max(0, set.Player2Score);
var stat1 = GetOrAdd(stats, p1, nameById);
var stat2 = GetOrAdd(stats, p2, nameById);
stat1.GamesWon += games1;
stat1.GamesLost += games2;
stat2.GamesWon += games2;
stat2.GamesLost += games1;
var winnerId = set.Player1Score > set.Player2Score ? p1 : p2;
if (winnerId == p1)
{
stat1.Wins++;
stat2.Losses++;
}
else
{
stat2.Wins++;
stat1.Losses++;
}
// Lower PlayerId is always A, so WinsA / WinsB are unambiguous.
var aId = Math.Min(p1, p2);
var bId = Math.Max(p1, p2);
var key = (aId, bId);
if (!h2h.TryGetValue(key, out var pair))
{
pair = new HeadToHead
{
PlayerAId = aId,
PlayerA = ResolveName(aId, nameById),
PlayerBId = bId,
PlayerB = ResolveName(bId, nameById)
};
h2h[key] = pair;
}
if (winnerId == aId)
{
pair.WinsA++;
}
else
{
pair.WinsB++;
}
}
// Sets is derived, so Sets == Wins + Losses cannot drift.
foreach (var stat in stats.Values)
{
stat.Sets = stat.Wins + stat.Losses;
Debug.Assert(stat.Sets == stat.Wins + stat.Losses, "Sets must equal Wins + Losses");
}
result.Players = stats.Values
.OrderByDescending(p => p.Wins)
.ThenByDescending(p => p.Sets)
.ThenBy(p => p.Player, StringComparer.Ordinal)
.ThenBy(p => p.PlayerId)
.ToList();
// Not capped: bounded by the number of sets in scope.
result.HeadToHead = h2h.Values
.OrderByDescending(p => p.WinsA + p.WinsB)
.ThenBy(p => p.PlayerA, StringComparer.Ordinal)
.ThenBy(p => p.PlayerB, StringComparer.Ordinal)
.ThenBy(p => p.PlayerAId)
.ThenBy(p => p.PlayerBId)
.ToList();
return result;
}
private static PlayerMatchStats GetOrAdd(Dictionary<int, PlayerMatchStats> stats, int playerId,
Dictionary<int, string> nameById)
{
if (!stats.TryGetValue(playerId, out var stat))
{
stat = new PlayerMatchStats
{
PlayerId = playerId,
Player = ResolveName(playerId, nameById)
};
stats[playerId] = stat;
}
return stat;
}
/// <summary>
/// Gamertag, falling back to Name, else "#&lt;id&gt;" (a set can reference a player row
/// that no longer exists, and Gamertag is nullable in the database).
/// </summary>
private static string ResolveName(int playerId, Dictionary<int, string> nameById)
{
return nameById.TryGetValue(playerId, out var name) ? name : $"#{playerId}";
}
private static string DisplayName(Player player)
{
if (!string.IsNullOrWhiteSpace(player.Gamertag))
{
return player.Gamertag;
}
if (!string.IsNullOrWhiteSpace(player.Name))
{
return player.Name;
}
return $"#{player.Id}";
}
}
}
@@ -4,6 +4,7 @@ using System.Linq;
using LaDOSE.Business.Interface;
using LaDOSE.Entity;
using LaDOSE.Entity.Context;
using Microsoft.EntityFrameworkCore;
namespace LaDOSE.Business.Service
{
@@ -20,8 +21,9 @@ namespace LaDOSE.Business.Service
{
if (string.IsNullOrEmpty(username) || string.IsNullOrEmpty(password))
return null;
var p = _context.ApplicationUser.ToList();
var user = _context.ApplicationUser.SingleOrDefault(x => x.Username == username);
var user = _context.ApplicationUser
.Include(x => x.UserRoles).ThenInclude(ur => ur.Role)
.SingleOrDefault(x => x.Username == username);
// check if username exists
if (user == null)
@@ -37,23 +39,44 @@ namespace LaDOSE.Business.Service
public IEnumerable<ApplicationUser> GetAll()
{
return _context.ApplicationUser;
return _context.ApplicationUser
.Include(x => x.UserRoles).ThenInclude(ur => ur.Role)
.ToList();
}
/// <summary>
/// Roles are included because authorization reads them on every authenticated
/// request (see the OnTokenValidated handler in Startup).
/// </summary>
public ApplicationUser GetById(int id)
{
return _context.ApplicationUser.Find(id);
return _context.ApplicationUser
.Include(x => x.UserRoles).ThenInclude(ur => ur.Role)
.SingleOrDefault(x => x.Id == id);
}
public ApplicationUser Create(ApplicationUser user, string password)
public IEnumerable<ApplicationRole> GetAllRoles()
{
return _context.ApplicationRole.OrderBy(x => x.Name).ToList();
}
public ApplicationUser Create(ApplicationUser user, string password, IEnumerable<string> roleNames = null)
{
// validation
if (string.IsNullOrWhiteSpace(user?.Username))
throw new Exception("Username is required");
if (string.IsNullOrWhiteSpace(password))
throw new Exception("Password is required");
if (_context.ApplicationUser.Any(x => x.Username == user.Username))
throw new Exception("Username \"" + user.Username + "\" is already taken");
// EF fills userid/roleid on the join rows from these navigations when it saves.
user.UserRoles = ResolveRoles(roleNames)
.Select(role => new ApplicationUserRole { Role = role })
.ToList();
byte[] passwordHash, passwordSalt;
CreatePasswordHash(password, out passwordHash, out passwordSalt);
@@ -66,6 +89,35 @@ namespace LaDOSE.Business.Service
return user;
}
/// <summary>
/// Turns role names into the existing rows of <c>applicationrole</c>. An unknown
/// name is an error rather than a new role, so a typo cannot quietly produce an
/// account with no privileges — or, worse, a second spelling of "Admin".
/// </summary>
private List<ApplicationRole> ResolveRoles(IEnumerable<string> roleNames)
{
var wanted = (roleNames ?? Enumerable.Empty<string>())
.Where(name => !string.IsNullOrWhiteSpace(name))
.Select(name => name.Trim())
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
if (wanted.Count == 0)
return new List<ApplicationRole>();
var known = _context.ApplicationRole.ToList();
var resolved = new List<ApplicationRole>();
foreach (var name in wanted)
{
var role = known.FirstOrDefault(r => string.Equals(r.Name, name, StringComparison.OrdinalIgnoreCase));
if (role == null)
throw new Exception($"Unknown role \"{name}\"");
resolved.Add(role);
}
return resolved;
}
public void Update(ApplicationUser userParam, string password = null)
{
var user = _context.ApplicationUser.Find(userParam.Id);
@@ -101,12 +153,18 @@ namespace LaDOSE.Business.Service
public void Delete(int id)
{
var user = _context.ApplicationUser.Find(id);
if (user != null)
{
_context.ApplicationUser.Remove(user);
_context.SaveChanges();
}
// applicationuserrole's foreign keys are ON DELETE RESTRICT, so the join rows
// have to go first — clearing the collection makes EF delete them.
var user = _context.ApplicationUser
.Include(x => x.UserRoles).ThenInclude(ur => ur.Role)
.SingleOrDefault(x => x.Id == id);
if (user == null)
return;
user.UserRoles?.Clear();
_context.ApplicationUser.Remove(user);
_context.SaveChanges();
}
// private helper methods
+21
View File
@@ -0,0 +1,21 @@
# Keep the build context small and free of local state.
node_modules
build
.svelte-kit
.git
.vscode
# Real URLs live here and must never reach the image. .env.example is documentation.
.env
.env.*
!.env.example
# Consumed by `podman build` itself, never copied in.
Dockerfile
.dockerignore
# Local noise
.DS_Store
Thumbs.db
vite.config.js.timestamp-*
vite.config.ts.timestamp-*
+16
View File
@@ -0,0 +1,16 @@
# Base URL of LaDOSE.Api. Defaults to http://localhost:5000 when unset,
# which matches the Kestrel binding the API uses in development.
#
# Read by `vite dev` and inlined at build time (`npm run build`, or the Dockerfile's
# `--build-arg VITE_API_BASE_URL=...`). Baking it is optional.
VITE_API_BASE_URL=http://localhost:5000
# Container runtime only, and the reason one image serves every environment:
# docker-entrypoint.sh turns this into /config.js on start, which app.html loads
# before the bundle. Not a Vite variable, so it does NOT belong in a .env file.
#
# podman run -e LADOSE_API_BASE_URL=https://api.ladose.net ladose-webapp
#
# Precedence: /config.js > baked VITE_API_BASE_URL > http://localhost:5000.
# Leave it unset and /config.js is `{}`, so the baked value stays in charge.
# LADOSE_API_BASE_URL=https://api.ladose.net
+23
View File
@@ -0,0 +1,23 @@
node_modules
# Output
.output
.vercel
.netlify
.wrangler
/.svelte-kit
/build
# OS
.DS_Store
Thumbs.db
# Env
.env
.env.*
!.env.example
!.env.test
# Vite
vite.config.js.timestamp-*
vite.config.ts.timestamp-*
+1
View File
@@ -0,0 +1 @@
engine-strict=true
+1
View File
@@ -0,0 +1 @@
lts/*
+3
View File
@@ -0,0 +1,3 @@
{
"recommendations": ["svelte.svelte-vscode"]
}
+41
View File
@@ -0,0 +1,41 @@
# Build the SPA. adapter-static writes /app/build (pages == assets).
FROM node:24-alpine AS build
WORKDIR /app
# Lockfile first: this layer is reused until the dependencies actually change.
# Every build tool lives in devDependencies, so no --omit=dev here.
#
# --legacy-peer-deps works around a conflict that predates this Dockerfile:
# openapi-typescript@7.13.0 peer-requires typescript@^5.x while the project is on ^6.0.3,
# so plain `npm ci` fails ERESOLVE on every npm version tested (10.8, 10.9, 11.17).
# The flag only skips peer *validation* — the tree installed is still exactly
# package-lock.json (verified: typescript 6.0.3, lockfile unmodified).
# Drop the flag once package.json resolves that conflict.
COPY package.json package-lock.json ./
RUN npm ci --legacy-peer-deps
COPY . .
# Optional baked-in default. Precedence at runtime is
# /config.js > VITE_API_BASE_URL > the 'http://localhost:5000' in src/lib/api/client.ts.
# Declared after `npm ci` so passing it does not invalidate the dependency layer.
# client.ts uses `??`, so an empty string would beat its default: unset instead of exporting "".
ARG VITE_API_BASE_URL
# Same gate as the README's documented `npm run check`: a type regression fails the image.
RUN npm run check
RUN if [ -z "${VITE_API_BASE_URL:-}" ]; then unset VITE_API_BASE_URL; fi; npm run build
# Serve it. openapi.json / schema.d.ts are committed, so nothing here touches the live API.
FROM nginx:1.27-alpine
WORKDIR /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
COPY --from=build /app/build/ ./
EXPOSE 80
ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"]
CMD ["nginx", "-g", "daemon off;"]
+202
View File
@@ -0,0 +1,202 @@
# LaDOSE.WebApp
Svelte 5 + SvelteKit front-end for `LaDOSE.Api`, styled with Tailwind CSS v4.
It ships as a static SPA (`@sveltejs/adapter-static`) and talks to the API over
JWT bearer auth, so it can be served from any static host.
## Requirements
Node ≥ 22.12 (the toolchain uses Vite 8). An `.nvmrc` is checked in:
```bash
nvm use # resolves lts/*
npm install
```
## Running
The API must be up — it serves on `http://localhost:5000` in development:
```bash
cd ../LaDOSE.Api && dotnet run # terminal 1
npm run dev # terminal 2 -> http://localhost:5173
```
Point the app at a different API with `VITE_API_BASE_URL` (see `.env.example`).
`LaDOSE.Api` already allows any origin with credentials, so no dev proxy is needed.
## Typed API access
`src/lib/api/schema.d.ts` is **generated** from the API's OpenAPI document — never
edit it by hand. Regenerate whenever a C# controller or DTO changes:
```bash
npm run api:sync # fetch openapi.json from the running API, then re-emit types
```
That is `api:fetch` (curl `/openapi/v1.json`, override the host with `LADOSE_API_URL`)
followed by `api:types` (`openapi-typescript`). `openapi.json` is committed so the
types can be rebuilt without a running API.
Because paths and DTOs come from the generated types, a renamed route or a changed
DTO field surfaces as a TypeScript error rather than a runtime 404.
| Module | Purpose |
| --- | --- |
| `src/lib/api/schema.d.ts` | Generated types — all 23 API paths and every DTO |
| `src/lib/api/schema-helpers.ts` | Friendly aliases (`ApplicationUserDTO`, `LoginRequest`, …) |
| `src/lib/api/client.ts` | `apiRequest` — bearer auth, JSON, `ApiError`; paths constrained to real routes |
| `src/lib/api/users.ts` | `login` / `register` against `/Users/auth` and `/Users/register` |
| `src/lib/api/errors.ts` | `toErrorMessage` — message to show, or redirect to `/login` on a 401 |
| `src/lib/api/tournaments.ts` | `listEvents` / `importSmashTournament` / `getResults`, authenticated from the session |
| `src/lib/api/games.ts` | `listGames` / `saveGame` / `deleteGame` / `searchSmashGames` |
| `src/lib/api/admin-users.ts` | `listUsers` / `listRoles` / `addUser` / `deleteUser` — all Admin-only |
| `src/lib/api/statistics.ts` | `getMatchStats` against `POST /api/Statistics/Matches` — set-level win/loss and head to head |
| `src/lib/tournaments/results.ts` | Pure reshaping of `TournamentsResultDTO`: ranking grid, per-game placements, WordPress HTML, CSV |
| `src/lib/statistics/aggregate.ts` | Pure aggregation for `/statistiques`: standings, per-game and per-event summaries, CSV |
| `src/lib/statistics/load.ts` | `loadEventResults` — per-event `GetResults` fan-out with progress, partial failure and abort |
| `src/lib/games/draft.ts` | `GameDTO` ⇄ editor form, including the blank-to-NULL rules |
| `src/lib/ui/classes.ts` | The Tailwind class strings shared by the pages |
| `src/lib/stores/session.svelte.ts` | Signed-in user, persisted to `localStorage`, drops expired JWTs |
Calling another endpoint takes one line, and the path is checked at compile time:
```ts
import { apiRequest, buildPath, session } from '$lib';
import type { GameDTO } from '$lib';
const games = await apiRequest<GameDTO[]>('/api/Game', { token: session.token });
const one = await apiRequest<GameDTO>(buildPath('/api/Game/{id}', { id: 3 }));
```
## Routes
- `/login` — username + password, posts to `POST /Users/auth`, stores the returned JWT
- `/` — guarded; greets the signed-in user with **Hello, \<name\>.** and offers sign-out
- `/tournaments` — guarded; the start.gg half of the old Avalonia `TournamentResultView`
- `/statistiques` — guarded; standings, attendance and match statistics over a chosen scope
- `/games` — guarded; the game catalogue editor, from the Avalonia `GamesView`
- `/users`**Admin only**; add and remove accounts
`session.displayName` prefers `firstName lastName` and falls back to `username`.
### `/tournaments`
Ports the Smash.gg (start.gg) column of `LaDOSE.DesktopApp.Avalonia`:
1. **Import** — a slug (`start.gg/tournament/<slug>`) is sent to
`GET /api/Tournament/ParseSmash/{slug}`, which pulls the brackets, placements and
sets into the database. Every bracket must be `COMPLETED` or the API throws.
2. **Events**`GET /api/Event`, newest first. Tick one event for a single
tournament, or several to aggregate a ranking season. The regex box replaces the
selection with every matching event name (e.g. `Ranking #13\d{2}`).
3. **Generate results**`POST /api/Tournament/GetResults` with the selected ids;
the API applies the point rules in `ExternalProviderService`. Three views:
- *Ranking* — players × games with totals, highest first, plus CSV export
- *By game* — placements and points for one game
- *HTML* — the podium table for the WordPress recap, with copy-to-clipboard
The Challonge half of the Avalonia view (date range, Challonge tournament list,
`ParseChallonge`) is deliberately not ported.
### `/statistiques`
Pick a scope — everything, the last 6/12 events, or a regex over event names — then
**Compute statistics**. Two independent sources feed the page, and they are kept apart
on purpose:
- **Points, placements, attendance** come from `POST /api/Tournament/GetResults`, called
**one event at a time**. The endpoint merges everything it is given and never says
which event a row came from, so per-event calls are the only way to get a time series
— and they contain the damage, because it throws a 500 on any bracket missing a rank-1
or rank-2 row. One broken import is reported as a skipped event instead of taking the
whole scope down with it.
- **Set-level win rates and head to head** come from `POST /api/Statistics/Matches`, in
a single call for the whole scope.
The **Matches** tab leads with its coverage line, and it matters: brackets imported
before set rows were persisted contribute placements but no matches, so those figures
can describe a fraction of the scope while the standings above cover all of it. Win
rates count **decided sets only**, so a player with no resolvable set is left out rather
than shown at 0%.
Other things worth knowing:
- Rank `999` is the service's "unplaced" sentinel (the participation bucket in
`ExternalProviderService`), so it never counts as a placement or a podium, and
**Best** shows `—`.
- Player names are merged case-insensitively, as everywhere else in the app.
- Brackets are counted per event, so a bracket name reused every month counts once
per event rather than once overall.
- Undated events sort last in the chart and the Events tab — `GET /api/Event` returns
newest first, which is the least misleading place to put them.
- `aggregate.ts` is pure, so it can be exercised under plain Node with fixtures, the
same way `src/lib/tournaments/results.ts` is.
### `/games`
Ports `GamesView`: the list on the left (ordered by `Order`), an editor on the right.
- **Save** — `POST /api/Game`. `AddOrUpdate` inserts when `id` is 0 and otherwise
**replaces every column**, so the form always sends a complete `GameDTO`; blank
text fields are sent as `null`.
- **New game** — starts an empty draft with `id` 0 and the next free `Order`. The
desktop app instead posted a blank row immediately and let you fill it in after.
- **Delete** — `DELETE /api/Game/{id}`, behind a confirm.
- **Find on start.gg** — `GET /api/Game/smash/{name}` searches start.gg's videogame
catalogue using the long name (falling back to the name); picking a match fills
`smashId`. The ids listed are **start.gg** videogame ids, not LaDOSE game ids.
`smashId` is what bracket imports match on: a game without one collects its results
under a synthetic "GAME NOT FOUND" entry.
Unlike the desktop form, `imgUrl` is editable here — it is part of `GameDTO` and was
otherwise only reachable through the database.
### `/users` (Admin only)
Lists every account with its roles, creates accounts, and deletes them.
`POST /Users/register` used to be `[AllowAnonymous]` so that the first account could be
created. It is now `POST /Users/AddUser` and requires the **Admin** role, so before this
page is reachable at all, one account has to be promoted directly in the database:
```bash
# edit the username in the file first, then:
psql "$LADOSE_DB" -v ON_ERROR_STOP=1 -f ../../Sql/2026-08-05_roles.sql
```
How roles work:
- They live in the pre-existing `applicationrole` / `applicationuserrole` tables. The
script seeds `Admin` and `User`; no schema change was needed.
- Only user management checks a role. **Every other endpoint is unchanged** — a plain
or role-less account can still use tournaments, games and the rest.
- The JWT carries only the user id. Roles are read from the database on every request
(`OnTokenValidated` in `Startup.cs`), so granting or revoking Admin applies to the
caller's next request instead of whenever their 16-minute token expires.
- `session.isAdmin` hides the link and the page, but that is cosmetic — the API is
what enforces access, and a non-admin calling these endpoints gets a 403.
- The API refuses to delete the caller's own account. Since only an admin can reach the
endpoint, that is what guarantees at least one admin always remains.
## Notes
- The API issues 16-minute tokens. A lapsed token is treated as signed out on load;
there is no refresh flow yet, so long sessions will need a re-login. The guarded
pages turn a 401 into a redirect back to `/login` (see `toErrorMessage`).
- The API has no exception middleware, so an unhandled server error arrives as an
HTML developer page. `ApiError` then carries only the status, which is why each
call site supplies its own fallback message.
- `GetResults` only fills `slug` when **one** event id is requested, so the
"Voir le Bracket" links appear only for a single-event export.
- Player names are merged case-insensitively across brackets, matching the desktop app.
- `src/routes/+layout.ts` sets `ssr = false`: the JWT lives in the browser, so there
is nothing meaningful to render on the server.
## Checks
```bash
npm run check # svelte-check (types + template diagnostics)
npm run build # static build into ./build
```
@@ -0,0 +1,24 @@
#!/bin/sh
# Vite inlines import.meta.env.VITE_* at build time, so runtime configuration cannot
# come from an env var the app reads directly. Instead we write /config.js here, which
# src/app.html loads synchronously ahead of the bundle. One image, any environment.
set -eu
config_file=/usr/share/nginx/html/config.js
# Drop control characters (newlines included) so the value cannot break out of the
# string literal, then escape backslashes before double quotes. A URL containing
# & ? " or a trailing slash stays inert data.
value=$(printf '%s' "${LADOSE_API_BASE_URL:-}" | tr -d '\001-\037')
if [ -z "$value" ]; then
# Empty object, not an empty string: lets the app keep its baked-in default.
printf 'window.__LADOSE_CONFIG__ = {};\n' >"$config_file"
echo "config.js: LADOSE_API_BASE_URL unset, deferring to the built-in default" >&2
else
escaped=$(printf '%s' "$value" | sed -e 's/\\/\\\\/g' -e 's/"/\\"/g')
printf 'window.__LADOSE_CONFIG__ = { apiBaseUrl: "%s" };\n' "$escaped" >"$config_file"
echo "config.js: apiBaseUrl=$value" >&2
fi
exec "$@"
+58
View File
@@ -0,0 +1,58 @@
# Copied to /etc/nginx/conf.d/default.conf, replacing the image's default server.
server {
listen 80;
listen [::]:80;
server_name _;
root /usr/share/nginx/html;
index index.html;
server_tokens off;
# The build emits no .gz/.br, so compress on the fly.
gzip on;
gzip_vary on;
gzip_min_length 256;
gzip_types
application/javascript
application/json
application/manifest+json
application/wasm
image/svg+xml
text/css
text/javascript
text/plain
text/xml;
# Rewritten by docker-entrypoint.sh on every container start. Exact match, so the
# _app/immutable rule below can never apply to it.
location = /config.js {
add_header Cache-Control "no-store" always;
try_files $uri =404;
}
# Content-hashed filenames: safe forever.
location /_app/immutable/ {
add_header Cache-Control "public, max-age=31536000, immutable" always;
try_files $uri =404;
}
# Deploy marker the client polls. Stale copies mean updates are never noticed.
location = /_app/version.json {
add_header Cache-Control "no-cache" always;
try_files $uri =404;
}
# The shell. Same reason as version.json: it names the current immutable bundle.
location = /index.html {
add_header Cache-Control "no-cache" always;
}
# Nothing is prerendered (+layout.ts sets ssr = false, prerender = false) and the
# adapter's fallback is index.html, so every unknown path belongs to the client
# router. Without this, /login, /games, /tournaments, /users and /statistiques 404
# on deep-link or hard refresh. The internal redirect re-enters `location =
# /index.html`, so fallback responses pick up no-cache too.
location / {
try_files $uri $uri/ /index.html;
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+30
View File
@@ -0,0 +1,30 @@
{
"name": "ladose.webapp",
"private": true,
"version": "0.0.1",
"type": "module",
"scripts": {
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview",
"prepare": "svelte-kit sync || echo ''",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"api:fetch": "curl -fsS ${LADOSE_API_URL:-http://localhost:5000}/openapi/v1.json -o openapi.json",
"api:types": "openapi-typescript openapi.json -o src/lib/api/schema.d.ts",
"api:sync": "npm run api:fetch && npm run api:types"
},
"devDependencies": {
"@sveltejs/adapter-auto": "^7.0.1",
"@sveltejs/adapter-static": "^3.0.10",
"@sveltejs/kit": "^2.63.0",
"@sveltejs/vite-plugin-svelte": "^7.1.2",
"@tailwindcss/vite": "^4.3.3",
"openapi-typescript": "^7.13.0",
"svelte": "^5.56.1",
"svelte-check": "^4.6.0",
"tailwindcss": "^4.3.3",
"typescript": "^6.0.3",
"vite": "^8.0.16"
}
}
+218
View File
@@ -0,0 +1,218 @@
@import 'tailwindcss';
/*
* Colour is expressed twice over:
*
* 1. the LaDOSE brand ramp below — fixed values, theme-independent;
* 2. semantic tokens (canvas, surface, ink, accent, …) that every component
* actually uses, and that swap wholesale between light and dark.
*
* Components must only ever reach for the semantic layer. Adding `dark:` next to
* a hundred hard-coded shades would double every future edit; one set of tokens
* that changes underneath them does not.
*/
@theme {
--color-ladose-50: #f2f6ff;
--color-ladose-100: #e4ebff;
--color-ladose-200: #c3d4ff;
--color-ladose-300: #9db4ff;
--color-ladose-400: #6f8fff;
--color-ladose-500: #4b6cff;
--color-ladose-600: #3450e6;
--color-ladose-700: #2a3fb8;
--color-ladose-800: #1e2d80;
--color-ladose-900: #131a3d;
--color-ladose-950: #0b1027;
}
/*
* `inline` is load-bearing. Without it Tailwind emits `--color-surface:
* var(--app-surface)` into :root and the utility resolves through that copy, so
* redefining --app-surface in a more specific selector does nothing. `inline`
* substitutes the reference straight into the utility, which is what makes
* runtime theme switching work at all in Tailwind v4.
*/
@theme inline {
--color-canvas: var(--app-canvas);
--color-surface: var(--app-surface);
--color-overlay: var(--app-overlay);
--color-inset: var(--app-inset);
--color-line: var(--app-line);
--color-line-strong: var(--app-line-strong);
--color-ink: var(--app-ink);
--color-muted: var(--app-muted);
--color-subtle: var(--app-subtle);
--color-accent: var(--app-accent);
--color-accent-hover: var(--app-accent-hover);
--color-on-accent: var(--app-on-accent);
--color-danger: var(--app-danger);
--color-danger-soft: var(--app-danger-soft);
--color-success: var(--app-success);
--color-success-soft: var(--app-success-soft);
--color-warning: var(--app-warning);
--color-warning-soft: var(--app-warning-soft);
--shadow-card: var(--app-shadow);
}
/*
* Palette values live here once each. The three blocks that follow only
* re-point the --app-* aliases, so a colour is never written twice.
*/
:root {
--light-canvas: #f6f7fb;
--light-surface: #ffffff;
--light-overlay: #ffffff;
--light-inset: #f1f3f9;
--light-line: #dfe3ee;
--light-line-strong: #c6cddf;
--light-ink: #101430;
--light-muted: #4c5470;
--light-subtle: #767e99;
--light-accent: var(--color-ladose-600);
--light-accent-hover: var(--color-ladose-700);
--light-on-accent: #ffffff;
--light-danger: #b42318;
--light-danger-soft: rgb(180 35 24 / 0.09);
--light-success: #067647;
--light-success-soft: rgb(6 118 71 / 0.09);
--light-warning: #b54708;
--light-warning-soft: rgb(181 71 8 / 0.09);
--light-shadow: 0 1px 2px rgb(16 24 40 / 0.06), 0 10px 28px rgb(16 24 40 / 0.08);
--light-grad-from: #ffffff;
--light-grad-to: #eaeef8;
/* Translucent surfaces on purpose: they sit over the canvas gradient and
keep the depth the app had before tokens existed. */
--dark-canvas: var(--color-ladose-950);
--dark-surface: rgb(255 255 255 / 0.05);
/* Menus and popovers sit over content, so they need to be opaque. */
--dark-overlay: #171e3d;
--dark-inset: rgb(11 16 39 / 0.6);
--dark-line: rgb(255 255 255 / 0.1);
--dark-line-strong: rgb(255 255 255 / 0.16);
--dark-ink: var(--color-ladose-50);
--dark-muted: rgb(195 212 255 / 0.7);
--dark-subtle: rgb(195 212 255 / 0.45);
--dark-accent: var(--color-ladose-500);
--dark-accent-hover: var(--color-ladose-600);
--dark-on-accent: #ffffff;
--dark-danger: #fca5a5;
--dark-danger-soft: rgb(239 68 68 / 0.12);
--dark-success: #6ee7b7;
--dark-success-soft: rgb(16 185 129 / 0.12);
--dark-warning: #fcd34d;
--dark-warning-soft: rgb(245 158 11 / 0.12);
--dark-shadow: 0 1px 2px rgb(0 0 0 / 0.3), 0 16px 40px rgb(0 0 0 / 0.35);
--dark-grad-from: var(--color-ladose-900);
--dark-grad-to: var(--color-ladose-950);
}
/* Layer 1 — light is the unconditional default. */
:root {
--app-color-scheme: light;
--app-canvas: var(--light-canvas);
--app-surface: var(--light-surface);
--app-overlay: var(--light-overlay);
--app-inset: var(--light-inset);
--app-line: var(--light-line);
--app-line-strong: var(--light-line-strong);
--app-ink: var(--light-ink);
--app-muted: var(--light-muted);
--app-subtle: var(--light-subtle);
--app-accent: var(--light-accent);
--app-accent-hover: var(--light-accent-hover);
--app-on-accent: var(--light-on-accent);
--app-danger: var(--light-danger);
--app-danger-soft: var(--light-danger-soft);
--app-success: var(--light-success);
--app-success-soft: var(--light-success-soft);
--app-warning: var(--light-warning);
--app-warning-soft: var(--light-warning-soft);
--app-shadow: var(--light-shadow);
--app-grad-from: var(--light-grad-from);
--app-grad-to: var(--light-grad-to);
}
/*
* Layer 2 — follow the OS to dark, unless the user explicitly chose light.
* The :not() is what lets an explicit light choice win on a dark machine: a bare
* :root in layer 1 could never outrank a later media block on its own.
*/
@media (prefers-color-scheme: dark) {
:root:not([data-theme='light']) {
--app-color-scheme: dark;
--app-canvas: var(--dark-canvas);
--app-surface: var(--dark-surface);
--app-overlay: var(--dark-overlay);
--app-inset: var(--dark-inset);
--app-line: var(--dark-line);
--app-line-strong: var(--dark-line-strong);
--app-ink: var(--dark-ink);
--app-muted: var(--dark-muted);
--app-subtle: var(--dark-subtle);
--app-accent: var(--dark-accent);
--app-accent-hover: var(--dark-accent-hover);
--app-on-accent: var(--dark-on-accent);
--app-danger: var(--dark-danger);
--app-danger-soft: var(--dark-danger-soft);
--app-success: var(--dark-success);
--app-success-soft: var(--dark-success-soft);
--app-warning: var(--dark-warning);
--app-warning-soft: var(--dark-warning-soft);
--app-shadow: var(--dark-shadow);
--app-grad-from: var(--dark-grad-from);
--app-grad-to: var(--dark-grad-to);
}
}
/* Layer 3 — an explicit dark choice beats an OS light preference. */
:root[data-theme='dark'] {
--app-color-scheme: dark;
--app-canvas: var(--dark-canvas);
--app-surface: var(--dark-surface);
--app-overlay: var(--dark-overlay);
--app-inset: var(--dark-inset);
--app-line: var(--dark-line);
--app-line-strong: var(--dark-line-strong);
--app-ink: var(--dark-ink);
--app-muted: var(--dark-muted);
--app-subtle: var(--dark-subtle);
--app-accent: var(--dark-accent);
--app-accent-hover: var(--dark-accent-hover);
--app-on-accent: var(--dark-on-accent);
--app-danger: var(--dark-danger);
--app-danger-soft: var(--dark-danger-soft);
--app-success: var(--dark-success);
--app-success-soft: var(--dark-success-soft);
--app-warning: var(--dark-warning);
--app-warning-soft: var(--dark-warning-soft);
--app-shadow: var(--dark-shadow);
--app-grad-from: var(--dark-grad-from);
--app-grad-to: var(--dark-grad-to);
}
/*
* The canvas has to be painted by CSS on html/body, not by a class on a Svelte
* element: ssr is off, so that element does not exist until the bundle hydrates.
* Anything that relies on hydration to paint the background flashes white on
* every cold load of every route.
*/
html {
color-scheme: var(--app-color-scheme);
background-color: var(--app-canvas);
}
body {
min-height: 100svh;
background-image: radial-gradient(ellipse at top, var(--app-grad-from), var(--app-grad-to));
background-repeat: no-repeat;
background-attachment: fixed;
color: var(--app-ink);
}
/* Height of the fixed navbar, so pages that centre themselves can subtract it. */
:root {
--nav-h: 3.5rem;
}
+13
View File
@@ -0,0 +1,13 @@
// See https://svelte.dev/docs/kit/types#app.d.ts
// for information about these interfaces
declare global {
namespace App {
// interface Error {}
// interface Locals {}
// interface PageData {}
// interface PageState {}
// interface Platform {}
}
}
export {};
+37
View File
@@ -0,0 +1,37 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="text-scale" content="scale" />
<!--
Runtime configuration, written by the container entrypoint from
LADOSE_API_BASE_URL. Synchronous and ahead of the app bundle so
src/lib/api/client.ts can read it at module scope. Absent in `vite dev`,
which is why the client falls back to VITE_API_BASE_URL.
-->
<script src="%sveltekit.assets%/config.js"></script>
<!--
Theme, before %sveltekit.head% emits the render-blocking stylesheet, so
data-theme is already on <html> when its selectors are first matched.
Must stay a plain synchronous inline script: type=module, defer, async or
an external file all run after first paint, which is the flash we are
avoiding. Only ever writes 'light' or 'dark' — the CSS keys off those two
exact values, so a literal data-theme="system" would match neither.
-->
<script>
try {
var t = localStorage.getItem('ladose.theme');
if (t === 'light' || t === 'dark') document.documentElement.dataset.theme = t;
} catch (e) {
/* localStorage throws in Safari private mode and with cookies blocked */
}
</script>
<!--
%sveltekit.head%
-->
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>
@@ -0,0 +1,53 @@
import { session } from '$lib/stores/session.svelte';
import { apiRequest, buildPath, type RequestOptions } from './client';
import type { ApplicationUserDTO, NewUserRequest } from './schema-helpers';
/**
* User administration. Every endpoint here is `[Authorize(Roles = "Admin")]`, so a
* non-admin gets a 403 — the UI hides the page, but the API is what enforces it.
*
* `login` and the session live in `users.ts`; this module is only the admin screen.
*/
function authed(options: RequestOptions): RequestOptions {
return { ...options, token: options.token ?? session.token };
}
/** GET /Users — every account, ordered by username. Never includes password material. */
export function listUsers(options: RequestOptions = {}): Promise<ApplicationUserDTO[]> {
return apiRequest<ApplicationUserDTO[]>('/Users', authed(options));
}
/**
* GET /Users/Roles — the role names that exist in `applicationrole`. Assigning a name
* that is not in this list is rejected by the API rather than creating a new role.
*/
export function listRoles(options: RequestOptions = {}): Promise<string[]> {
return apiRequest<string[]>('/Users/Roles', authed(options));
}
/**
* POST /Users/AddUser — replaces the old anonymous `register`. Returns the created
* user. A duplicate username or an unknown role name comes back as a 400 carrying
* the reason in `message`.
*/
export function addUser(
user: NewUserRequest,
options: RequestOptions = {}
): Promise<ApplicationUserDTO> {
return apiRequest<ApplicationUserDTO>('/Users/AddUser', {
...authed(options),
method: 'POST',
body: user
});
}
/**
* DELETE /Users/{id} — 204 on success. The API refuses to delete the caller's own
* account (400), which is what guarantees an admin always remains.
*/
export async function deleteUser(id: number, options: RequestOptions = {}): Promise<void> {
await apiRequest<void>(buildPath('/Users/{id}', { id }), {
...authed(options),
method: 'DELETE'
});
}
@@ -0,0 +1,113 @@
import type { ApiPath } from './schema-helpers';
/** Shape of the object `static/config.js` defines, rewritten at container start. */
declare global {
interface Window {
__LADOSE_CONFIG__?: { apiBaseUrl?: string };
}
}
/**
* Base URL of LaDOSE.Api, resolved in this order:
*
* 1. `window.__LADOSE_CONFIG__.apiBaseUrl` — written by the container entrypoint
* from `LADOSE_API_BASE_URL` (see Dockerfile). This is what lets one image
* serve several environments: Vite inlines `import.meta.env` at *build* time,
* so without it the URL would be frozen into the bundle.
* 2. `VITE_API_BASE_URL` — baked at build time, for `vite dev` and for anyone
* who prefers one image per environment.
* 3. the Kestrel binding the API uses in development (LaDOSE.Api/appsettings.json).
*/
function resolveBaseUrl(): string {
// `typeof window` rather than a browser guard: this runs at module scope, and
// the static build still evaluates this module while generating the shell.
const runtime =
typeof window !== 'undefined' ? window.__LADOSE_CONFIG__?.apiBaseUrl : undefined;
const configured = runtime?.trim() || import.meta.env.VITE_API_BASE_URL || 'http://localhost:5000';
return configured.replace(/\/$/, '');
}
export const API_BASE_URL = resolveBaseUrl();
/** Thrown for any non-2xx response, carrying the API's message when it sent one. */
export class ApiError extends Error {
constructor(
readonly status: number,
message: string
) {
super(message);
this.name = 'ApiError';
}
}
/** The shape LaDOSE.Api uses for its error payloads: `new { message = "..." }`. */
function extractMessage(body: unknown, status: number): string {
if (body && typeof body === 'object' && 'message' in body) {
const { message } = body as { message?: unknown };
if (typeof message === 'string' && message.length > 0) return message;
}
return `Request failed with status ${status}`;
}
export interface RequestOptions {
method?: 'GET' | 'POST' | 'DELETE';
body?: unknown;
/** JWT from `POST /Users/auth`; sent as `Authorization: Bearer ...`. */
token?: string | null;
fetch?: typeof globalThis.fetch;
signal?: AbortSignal;
}
/**
* Calls LaDOSE.Api. `path` is constrained to the paths in the generated OpenAPI
* types, so a typo or a route removed on the server is a compile error.
* Templated paths (e.g. `/api/Game/{id}`) are built with `buildPath`.
*/
export async function apiRequest<TResponse>(
path: ApiPath | (string & {}),
options: RequestOptions = {}
): Promise<TResponse> {
const { method = 'GET', body, token, fetch: fetchImpl = globalThis.fetch, signal } = options;
const headers: Record<string, string> = { Accept: 'application/json' };
if (body !== undefined) headers['Content-Type'] = 'application/json';
if (token) headers['Authorization'] = `Bearer ${token}`;
let response: Response;
try {
response = await fetchImpl(`${API_BASE_URL}${path}`, {
method,
headers,
body: body === undefined ? undefined : JSON.stringify(body),
signal
});
} catch (cause) {
if (cause instanceof DOMException && cause.name === 'AbortError') throw cause;
// fetch only rejects on transport failures — the API being down, DNS, CORS.
const error = new ApiError(0, `Could not reach LaDOSE.Api at ${API_BASE_URL}`);
error.cause = cause;
throw error;
}
const isJson = response.headers.get('content-type')?.includes('json') ?? false;
const payload = isJson ? await response.json().catch(() => null) : null;
if (!response.ok) {
throw new ApiError(response.status, extractMessage(payload, response.status));
}
return payload as TResponse;
}
/** Fills a templated OpenAPI path, e.g. buildPath('/api/Game/{id}', { id: 3 }). */
export function buildPath(
template: ApiPath,
params: Record<string, string | number>
): string {
return template.replace(/\{(\w+)\}/g, (_, key: string) => {
const value = params[key];
if (value === undefined) throw new Error(`Missing route parameter "${key}" for ${template}`);
return encodeURIComponent(String(value));
});
}
@@ -0,0 +1,21 @@
import { goto } from '$app/navigation';
import { session } from '$lib/stores/session.svelte';
import { ApiError } from './client';
/**
* Turns a failed call into something to show the user.
*
* A 401 means the 16-minute JWT lapsed mid-session: there is nothing useful to
* display, so the session is dropped and the user is sent back to `/login`, and
* this returns null.
*/
export function toErrorMessage(cause: unknown, fallback: string): string | null {
if (cause instanceof ApiError && cause.status === 401) {
session.clear();
void goto('/login', { replaceState: true });
return null;
}
// ApiError already carries the API's own `message`, or "unreachable" for status 0.
return cause instanceof ApiError ? cause.message : fallback;
}
@@ -0,0 +1,45 @@
import { session } from '$lib/stores/session.svelte';
import { apiRequest, buildPath, type RequestOptions } from './client';
import type { GameDTO } from './schema-helpers';
/** GameController is `[Authorize]`; reuse the session JWT unless one is passed in. */
function authed(options: RequestOptions): RequestOptions {
return { ...options, token: options.token ?? session.token };
}
/** GET /api/Game — every game, in database order (sort by `order` for display). */
export function listGames(options: RequestOptions = {}): Promise<GameDTO[]> {
return apiRequest<GameDTO[]>('/api/Game', authed(options));
}
/**
* POST /api/Game — `AddOrUpdate`: an `id` of 0 inserts, anything else updates.
* The update replaces every column, so send a full DTO rather than a patch.
* Returns the saved game with its assigned id.
*/
export function saveGame(game: GameDTO, options: RequestOptions = {}): Promise<GameDTO> {
return apiRequest<GameDTO>('/api/Game', { ...authed(options), method: 'POST', body: game });
}
/**
* DELETE /api/Game/{id} — 204 on success. The service swallows `DbUpdateException`,
* so a game still referenced by tournaments comes back as a 404 rather than a 409.
*/
export async function deleteGame(id: number, options: RequestOptions = {}): Promise<void> {
await apiRequest<void>(buildPath('/api/Game/{id}', { id }), {
...authed(options),
method: 'DELETE'
});
}
/**
* GET /api/Game/smash/{name} — searches start.gg's videogame catalogue by name.
* The `id` of each match is a **start.gg** videogame id, i.e. a candidate value for
* `GameDTO.smashId`, not a LaDOSE game id.
*/
export function searchSmashGames(
name: string,
options: RequestOptions = {}
): Promise<GameDTO[]> {
return apiRequest<GameDTO[]>(buildPath('/api/Game/smash/{name}', { name }), authed(options));
}
@@ -0,0 +1,43 @@
import type { components, paths } from './schema';
/**
* Re-exports of the DTOs generated from LaDOSE.Api's OpenAPI document
* (see `npm run api:types`). Import these instead of hand-writing shapes so the
* client breaks at compile time when the C# DTOs change.
*/
export type Schemas = components['schemas'];
export type ApplicationUserDTO = Schemas['ApplicationUserDTO'];
export type GameDTO = Schemas['GameDTO'];
export type EventDTO = Schemas['EventDTO'];
export type TodoDTO = Schemas['TodoDTO'];
export type WPEventDTO = Schemas['WPEventDTO'];
export type TournamentDTO = Schemas['TournamentDTO'];
export type TournamentsResultDTO = Schemas['TournamentsResultDTO'];
export type ResultDTO = Schemas['ResultDTO'];
export type ParticipentDTO = Schemas['ParticipentDTO'];
export type MatchStatsDTO = Schemas['MatchStatsDTO'];
export type MatchCoverageDTO = Schemas['MatchCoverageDTO'];
export type PlayerMatchStatsDTO = Schemas['PlayerMatchStatsDTO'];
export type HeadToHeadDTO = Schemas['HeadToHeadDTO'];
/** The body `UsersController.Authenticate` binds — only these two fields are read. */
export type LoginRequest = Pick<ApplicationUserDTO, 'username' | 'password'>;
/** The body `UsersController.AddUser` binds. `roles` must name rows of `applicationrole`. */
export type NewUserRequest = Pick<
ApplicationUserDTO,
'username' | 'password' | 'firstName' | 'lastName' | 'roles'
>;
/**
* A logged-in user is the auth response with the fields the API always fills in
* narrowed to non-optional, so pages don't have to null-check `token`/`username`.
*/
export type AuthenticatedUser = ApplicationUserDTO & {
username: string;
token: string;
};
/** Every path exposed by LaDOSE.Api, e.g. `'/Users/auth'` or `'/api/Game'`. */
export type ApiPath = keyof paths;
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,29 @@
import { session } from '$lib/stores/session.svelte';
import { apiRequest, type RequestOptions } from './client';
import type { MatchStatsDTO } from './schema-helpers';
/** StatisticsController is `[Authorize]`, like the tournament endpoints. */
function authed(options: RequestOptions): RequestOptions {
return { ...options, token: options.token ?? session.token };
}
/**
* POST /api/Statistics/Matches set-level statistics (win/loss, games, head to
* head) for the given events, read from the `set` rows an import persisted.
*
* Unlike `GetResults` this takes every event in one call: the service tolerates
* brackets with no set rows instead of throwing, and reports what it could see in
* `coverage`. Always show that coverage many older brackets were imported
* without sets, so a low `bracketsWithSets` means these numbers describe only a
* slice of the scope.
*/
export function getMatchStats(
eventIds: number[],
options: RequestOptions = {}
): Promise<MatchStatsDTO> {
return apiRequest<MatchStatsDTO>('/api/Statistics/Matches', {
...authed(options),
method: 'POST',
body: eventIds
});
}
@@ -0,0 +1,53 @@
import { session } from '$lib/stores/session.svelte';
import { apiRequest, buildPath, type RequestOptions } from './client';
import type { EventDTO, TournamentsResultDTO } from './schema-helpers';
/**
* TournamentController and EventController are both `[Authorize]`, so every call
* here carries the JWT held by the session store unless one is passed explicitly.
*/
function authed(options: RequestOptions): RequestOptions {
return { ...options, token: options.token ?? session.token };
}
/**
* GET /api/Event every imported event, newest first (the controller orders by
* `Date` descending). A start.gg tournament becomes one Event holding one
* Tournament per bracket.
*/
export function listEvents(options: RequestOptions = {}): Promise<EventDTO[]> {
return apiRequest<EventDTO[]>('/api/Event', authed(options));
}
/**
* GET /api/Tournament/ParseSmash/{slug} pulls a start.gg tournament (brackets,
* placements, sets) into the database. Returns false only for an empty slug;
* re-importing an already-known slug is a no-op that still returns true.
* The API throws (500) when the tournament has an unfinished bracket.
*/
export function importSmashTournament(
slug: string,
options: RequestOptions = {}
): Promise<boolean> {
const path = buildPath('/api/Tournament/ParseSmash/{tournamentSlug}', {
tournamentSlug: slug
});
return apiRequest<boolean>(path, authed(options));
}
/**
* POST /api/Tournament/GetResults scores the given events with the point rules
* in ExternalProviderService and returns the merged participants/games/results.
* Pass one id for a single tournament or several to aggregate a ranking season.
* Note: the API only fills `slug` when exactly one id is requested.
*/
export function getResults(
eventIds: number[],
options: RequestOptions = {}
): Promise<TournamentsResultDTO> {
return apiRequest<TournamentsResultDTO>('/api/Tournament/GetResults', {
...authed(options),
method: 'POST',
body: eventIds
});
}
@@ -0,0 +1,26 @@
import { apiRequest } from './client';
import type { ApplicationUserDTO, AuthenticatedUser, LoginRequest } from './schema-helpers';
/**
* POST /Users/auth returns the user plus a JWT valid for 16 minutes
* (see UsersController.Authenticate). Throws ApiError(400) on bad credentials.
*/
export async function login(credentials: LoginRequest): Promise<AuthenticatedUser> {
const user = await apiRequest<ApplicationUserDTO>('/Users/auth', {
method: 'POST',
body: credentials
});
// The generated DTO marks every field optional because the C# properties are
// nullable reference types; the auth path always populates these two.
if (!user?.token || !user.username) {
throw new Error('LaDOSE.Api returned an authentication response without a token.');
}
return user as AuthenticatedUser;
}
/** POST /Users/register */
export async function register(credentials: LoginRequest): Promise<void> {
await apiRequest<void>('/Users/register', { method: 'POST', body: credentials });
}
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="107" height="128" viewBox="0 0 107 128"><title>svelte-logo</title><path d="M94.157 22.819c-10.4-14.885-30.94-19.297-45.792-9.835L22.282 29.608A29.92 29.92 0 0 0 8.764 49.65a31.5 31.5 0 0 0 3.108 20.231 30 30 0 0 0-4.477 11.183 31.9 31.9 0 0 0 5.448 24.116c10.402 14.887 30.942 19.297 45.791 9.835l26.083-16.624A29.92 29.92 0 0 0 98.235 78.35a31.53 31.53 0 0 0-3.105-20.232 30 30 0 0 0 4.474-11.182 31.88 31.88 0 0 0-5.447-24.116" style="fill:#ff3e00"/><path d="M45.817 106.582a20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.503 18 18 0 0 1 .624-2.435l.49-1.498 1.337.981a33.6 33.6 0 0 0 10.203 5.098l.97.294-.09.968a5.85 5.85 0 0 0 1.052 3.878 6.24 6.24 0 0 0 6.695 2.485 5.8 5.8 0 0 0 1.603-.704L69.27 76.28a5.43 5.43 0 0 0 2.45-3.631 5.8 5.8 0 0 0-.987-4.371 6.24 6.24 0 0 0-6.698-2.487 5.7 5.7 0 0 0-1.6.704l-9.953 6.345a19 19 0 0 1-5.296 2.326 20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.502 17.99 17.99 0 0 1 8.13-12.052l26.081-16.623a19 19 0 0 1 5.3-2.329 20.72 20.72 0 0 1 22.237 8.243 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-.624 2.435l-.49 1.498-1.337-.98a33.6 33.6 0 0 0-10.203-5.1l-.97-.294.09-.968a5.86 5.86 0 0 0-1.052-3.878 6.24 6.24 0 0 0-6.696-2.485 5.8 5.8 0 0 0-1.602.704L37.73 51.72a5.42 5.42 0 0 0-2.449 3.63 5.79 5.79 0 0 0 .986 4.372 6.24 6.24 0 0 0 6.698 2.486 5.8 5.8 0 0 0 1.602-.704l9.952-6.342a19 19 0 0 1 5.295-2.328 20.72 20.72 0 0 1 22.237 8.242 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-8.13 12.053l-26.081 16.622a19 19 0 0 1-5.3 2.328" style="fill:#fff"/></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

@@ -0,0 +1,65 @@
import type { GameDTO } from '$lib/api/schema-helpers';
/**
* The shape the game editor binds to. Every field is present and non-null so the
* inputs never see `undefined`, and `POST /api/Game` always receives a whole DTO
* `AddOrUpdate` replaces every column, so a partial body would blank the rest.
*
* `id` 0 marks an unsaved game: the API inserts on 0 and updates otherwise.
*/
export interface Draft {
id: number;
name: string;
longName: string;
/** Null while the number input sits empty; `Game.Order` is a non-nullable int. */
order: number | null;
imgUrl: string;
wordPressTag: string;
wordPressTagOs: string;
smashId: number | null;
}
export const blankDraft: Draft = {
id: 0,
name: '',
longName: '',
order: 0,
imgUrl: '',
wordPressTag: '',
wordPressTagOs: '',
smashId: null
};
export function toDraft(game: GameDTO): Draft {
return {
id: game.id ?? 0,
name: game.name ?? '',
longName: game.longName ?? '',
order: game.order ?? 0,
imgUrl: game.imgUrl ?? '',
wordPressTag: game.wordPressTag ?? '',
wordPressTagOs: game.wordPressTagOs ?? '',
smashId: game.smashId ?? null
};
}
/** Blank text is stored as NULL rather than as an empty string. */
export function toDto(draft: Draft): GameDTO {
const text = (value: string) => (value.trim() === '' ? null : value.trim());
return {
id: draft.id,
name: text(draft.name),
longName: text(draft.longName),
order: draft.order ?? 0,
imgUrl: text(draft.imgUrl),
wordPressTag: text(draft.wordPressTag),
wordPressTagOs: text(draft.wordPressTagOs),
smashId: draft.smashId
};
}
/** A new game sorts after the current last one. */
export function nextOrder(games: GameDTO[]): number {
return games.reduce((max, game) => Math.max(max, game.order ?? 0), 0) + 1;
}
+31
View File
@@ -0,0 +1,31 @@
// Re-export the API surface so pages can `import { login, session } from '$lib'`.
export { API_BASE_URL, ApiError, apiRequest, buildPath } from './api/client';
export { login, register } from './api/users';
export { toErrorMessage } from './api/errors';
export { addUser, deleteUser, listRoles, listUsers } from './api/admin-users';
export { deleteGame, listGames, saveGame, searchSmashGames } from './api/games';
export { getResults, importSmashTournament, listEvents } from './api/tournaments';
export { getMatchStats } from './api/statistics';
export { aggregate, formatMonth, standingsCsv } from './statistics/aggregate';
export type {
Aggregate,
EventAttendance,
EventResult,
GameSummary,
PlayerStanding,
Totals
} from './statistics/aggregate';
export { loadEventResults } from './statistics/load';
export type { FailedEvent, LoadOptions, LoadOutcome } from './statistics/load';
export { blankDraft, nextOrder, toDraft, toDto } from './games/draft';
export type { Draft } from './games/draft';
export { session } from './stores/session.svelte';
export {
buildCsv,
buildHtml,
buildRanking,
playedGames,
resultsForGame
} from './tournaments/results';
export type { RankingRow, RankingTable } from './tournaments/results';
export type * from './api/schema-helpers';
@@ -0,0 +1,328 @@
import type { EventDTO, GameDTO, TournamentsResultDTO } from '$lib/api/schema-helpers';
/**
* Aggregation for the Statistiques page. Pure: it takes results already fetched
* per event and reshapes them, so it can be exercised under plain Node the same
* way `$lib/tournaments/results.ts` is.
*
* Why per event rather than one batched call: `POST /api/Tournament/GetResults`
* merges everything it is given and never says which event a row came from, so
* per-event scoping is the only way to get a time series. It also contains the
* blast radius the endpoint throws on a bracket with no rank-1 row, and one bad
* event would otherwise take the whole batch with it.
*/
/** One event's scored results, as returned for that event alone. */
export interface EventResult {
event: EventDTO;
result: TournamentsResultDTO;
}
export interface PlayerStanding {
player: string;
points: number;
/** Bracket entries, i.e. result rows — a player counts once per bracket entered. */
entries: number;
events: number;
games: number;
firsts: number;
seconds: number;
thirds: number;
podiums: number;
/** Best placement seen, ignoring the 999 "rank unknown" sentinel. */
bestRank: number | null;
}
export interface GameSummary {
gameId: number;
name: string;
longName: string;
entries: number;
players: number;
brackets: number;
/** Highest scoring player in this game over the scope. */
topPlayer: string | null;
topPoints: number;
/** Mean entries per bracket — 0 when the game has no bracket in scope. */
averageField: number;
}
export interface EventAttendance {
eventId: number;
name: string;
/** ISO date from EventDTO.date, or null when the API did not supply one. */
date: string | null;
players: number;
entries: number;
games: number;
brackets: number;
}
export interface Totals {
events: number;
brackets: number;
entries: number;
players: number;
points: number;
}
export interface Aggregate {
standings: PlayerStanding[];
games: GameSummary[];
attendance: EventAttendance[];
totals: Totals;
}
/**
* The service emits 999 for players it could not place (the participation bucket
* in ExternalProviderService), so it must never be read as a placement.
*/
const UNRANKED = 999;
/** Names come from different brackets and only ever match case-insensitively. */
function key(name: string): string {
return name.trim().toUpperCase();
}
function displayName(name: string): string {
return name.trim();
}
interface PlayerAccumulator {
player: string;
points: number;
entries: number;
events: Set<number>;
games: Set<number>;
firsts: number;
seconds: number;
thirds: number;
bestRank: number | null;
}
interface GameAccumulator {
game: GameDTO;
entries: number;
players: Set<string>;
brackets: Set<string>;
pointsByPlayer: Map<string, { player: string; points: number }>;
}
export function aggregate(loaded: EventResult[]): Aggregate {
const players = new Map<string, PlayerAccumulator>();
const games = new Map<number, GameAccumulator>();
const attendance: EventAttendance[] = [];
let totalBrackets = 0;
let totalEntries = 0;
let totalPoints = 0;
for (const { event, result } of loaded) {
const eventId = event.id ?? 0;
const rows = result.results ?? [];
const eventPlayers = new Set<string>();
const eventGames = new Set<number>();
const eventBrackets = new Set<string>();
for (const row of rows) {
const name = row.player?.trim();
if (!name) continue;
const gameId = row.gameId ?? 0;
const points = row.point ?? 0;
const rank = row.rank ?? UNRANKED;
const bracket = `${gameId}::${row.tournamentUrl ?? ''}`;
eventPlayers.add(key(name));
eventGames.add(gameId);
eventBrackets.add(bracket);
totalEntries++;
totalPoints += points;
let player = players.get(key(name));
if (!player) {
player = {
player: displayName(name),
points: 0,
entries: 0,
events: new Set(),
games: new Set(),
firsts: 0,
seconds: 0,
thirds: 0,
bestRank: null
};
players.set(key(name), player);
}
player.points += points;
player.entries++;
player.events.add(eventId);
player.games.add(gameId);
if (rank === 1) player.firsts++;
else if (rank === 2) player.seconds++;
else if (rank === 3) player.thirds++;
if (rank !== UNRANKED && (player.bestRank === null || rank < player.bestRank)) {
player.bestRank = rank;
}
let game = games.get(gameId);
if (!game) {
const meta = (result.games ?? []).find((candidate) => candidate.id === gameId);
game = {
game: meta ?? { id: gameId, name: `#${gameId}`, longName: `#${gameId}` },
entries: 0,
players: new Set(),
brackets: new Set(),
pointsByPlayer: new Map()
};
games.set(gameId, game);
}
game.entries++;
game.players.add(key(name));
// Bracket names repeat every month, so scope them to the event.
game.brackets.add(`${eventId}::${bracket}`);
const forPlayer = game.pointsByPlayer.get(key(name)) ?? {
player: displayName(name),
points: 0
};
forPlayer.points += points;
game.pointsByPlayer.set(key(name), forPlayer);
}
totalBrackets += eventBrackets.size;
attendance.push({
eventId,
name: event.name ?? `#${eventId}`,
date: event.date ?? null,
players: eventPlayers.size,
entries: rows.length,
games: eventGames.size,
brackets: eventBrackets.size
});
}
return {
standings: buildStandings(players),
games: buildGameSummaries(games),
attendance: sortByDate(attendance),
totals: {
events: loaded.length,
brackets: totalBrackets,
entries: totalEntries,
players: players.size,
points: totalPoints
}
};
}
function buildStandings(players: Map<string, PlayerAccumulator>): PlayerStanding[] {
return [...players.values()]
.map((p) => ({
player: p.player,
points: p.points,
entries: p.entries,
events: p.events.size,
games: p.games.size,
firsts: p.firsts,
seconds: p.seconds,
thirds: p.thirds,
podiums: p.firsts + p.seconds + p.thirds,
bestRank: p.bestRank
}))
.sort(
(a, b) =>
b.points - a.points ||
b.firsts - a.firsts ||
b.podiums - a.podiums ||
a.player.localeCompare(b.player)
);
}
function buildGameSummaries(games: Map<number, GameAccumulator>): GameSummary[] {
return [...games.values()]
.map((g) => {
const top = [...g.pointsByPlayer.values()].sort(
(a, b) => b.points - a.points || a.player.localeCompare(b.player)
)[0];
return {
gameId: g.game.id ?? 0,
name: g.game.name ?? `#${g.game.id ?? 0}`,
longName: g.game.longName ?? g.game.name ?? `#${g.game.id ?? 0}`,
entries: g.entries,
players: g.players.size,
brackets: g.brackets.size,
topPlayer: top?.player ?? null,
topPoints: top?.points ?? 0,
averageField: g.brackets.size === 0 ? 0 : g.entries / g.brackets.size
};
})
.sort((a, b) => b.entries - a.entries || a.name.localeCompare(b.name));
}
/**
* Oldest first, so a chart reads left to right. Events the API gave no date for
* keep their incoming order and sort last `GET /api/Event` returns newest
* first, so that is still the least misleading placement for them.
*/
function sortByDate(attendance: EventAttendance[]): EventAttendance[] {
return attendance
.map((entry, index) => ({ entry, index }))
.sort((a, b) => {
const aTime = a.entry.date ? Date.parse(a.entry.date) : NaN;
const bTime = b.entry.date ? Date.parse(b.entry.date) : NaN;
const aOk = Number.isFinite(aTime);
const bOk = Number.isFinite(bTime);
if (aOk && bOk) return aTime - bTime || a.index - b.index;
if (aOk) return -1;
if (bOk) return 1;
return a.index - b.index;
})
.map(({ entry }) => entry);
}
/** `2026-08-05T00:00:00` → `Aug 2026`. Falls back to the event name's own text. */
export function formatMonth(date: string | null): string {
if (!date) return '';
const parsed = Date.parse(date);
if (!Number.isFinite(parsed)) return '';
return new Date(parsed).toLocaleDateString(undefined, { month: 'short', year: 'numeric' });
}
/** Semicolon-separated, quoted — same dialect as the tournaments CSV export. */
export function standingsCsv(standings: PlayerStanding[]): string {
const quote = (value: string | number) => `"${String(value).replaceAll('"', '""')}"`;
const header = [
'Player',
'Points',
'Entries',
'Events',
'Games',
'1st',
'2nd',
'3rd',
'Podiums',
'Best rank'
];
const lines = [header.map(quote).join(';')];
for (const row of standings) {
lines.push(
[
row.player,
row.points,
row.entries,
row.events,
row.games,
row.firsts,
row.seconds,
row.thirds,
row.podiums,
row.bestRank ?? ''
]
.map(quote)
.join(';')
);
}
return lines.join('\r\n') + '\r\n';
}
@@ -0,0 +1,72 @@
import { ApiError } from '$lib/api/client';
import type { EventDTO } from '$lib/api/schema-helpers';
import { getResults } from '$lib/api/tournaments';
import type { EventResult } from './aggregate';
/**
* Fetches scored results one event at a time.
*
* `POST /api/Tournament/GetResults` throws a 500 when any bracket in the request
* is missing a rank-1 or rank-2 row, so asking for thirty events in one call means
* one broken import hides all thirty. Per-event requests cost more round trips but
* degrade to "we skipped event #97" instead of "statistics are unavailable".
*/
export interface FailedEvent {
event: EventDTO;
message: string;
}
export interface LoadOutcome {
loaded: EventResult[];
failed: FailedEvent[];
}
export interface LoadOptions {
/** Called after each event settles, for a progress indicator. */
onProgress?: (done: number, total: number) => void;
/** Abort further requests, e.g. when the user changes scope mid-load. */
signal?: AbortSignal;
/** Parallel requests. Enough to be quick, few enough to be polite to the API. */
concurrency?: number;
}
export async function loadEventResults(
events: EventDTO[],
options: LoadOptions = {}
): Promise<LoadOutcome> {
const { onProgress, signal, concurrency = 6 } = options;
const targets = events.filter((event) => event.id !== undefined);
const loaded: EventResult[] = [];
const failed: FailedEvent[] = [];
let done = 0;
let next = 0;
async function worker() {
while (next < targets.length) {
if (signal?.aborted) return;
const event = targets[next++];
try {
const result = await getResults([event.id as number], { signal });
loaded.push({ event, result });
} catch (cause) {
if (cause instanceof DOMException && cause.name === 'AbortError') throw cause;
// A 401 has to stop everything: retrying N times just burns requests.
if (cause instanceof ApiError && cause.status === 401) throw cause;
failed.push({
event,
message: cause instanceof ApiError ? cause.message : 'Request failed'
});
}
onProgress?.(++done, targets.length);
}
}
const workers = Array.from({ length: Math.min(concurrency, targets.length) }, worker);
await Promise.all(workers);
return { loaded, failed };
}
@@ -0,0 +1,86 @@
import { browser } from '$app/environment';
import type { AuthenticatedUser } from '$lib/api/schema-helpers';
const STORAGE_KEY = 'ladose.session';
/** Restores the session written by a previous visit, discarding it if the JWT expired. */
function restore(): AuthenticatedUser | null {
if (!browser) return null;
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return null;
try {
const user = JSON.parse(raw) as AuthenticatedUser;
if (!user?.token || !user.username) return null;
if (isExpired(user)) {
localStorage.removeItem(STORAGE_KEY);
return null;
}
return user;
} catch {
localStorage.removeItem(STORAGE_KEY);
return null;
}
}
/** The API issues short-lived tokens (16 min), so treat a lapsed one as logged out. */
function isExpired(user: AuthenticatedUser): boolean {
if (!user.expire) return false;
const expiresAt = Date.parse(user.expire);
return Number.isFinite(expiresAt) && expiresAt <= Date.now();
}
/**
* Holds the signed-in user for the lifetime of the tab and mirrors it into
* localStorage so a refresh doesn't bounce the user back to /login.
*/
class Session {
#user = $state<AuthenticatedUser | null>(restore());
get user(): AuthenticatedUser | null {
return this.#user;
}
get isLoggedIn(): boolean {
return this.#user !== null;
}
/** Bearer token for `apiRequest`, or null when signed out. */
get token(): string | null {
return this.#user?.token ?? null;
}
/** Role names the API reported at sign-in, e.g. `['Admin']`. */
get roles(): string[] {
return this.#user?.roles ?? [];
}
/**
* Whether to offer the admin-only screens. This is presentation only the API
* re-reads the caller's roles from the database on every request, so hiding a link
* is a convenience and never the thing that keeps a non-admin out.
*/
get isAdmin(): boolean {
return this.roles.some((role) => role.toLowerCase() === 'admin');
}
/** Name to greet the user with; falls back to the username. */
get displayName(): string {
if (!this.#user) return '';
const full = [this.#user.firstName, this.#user.lastName].filter(Boolean).join(' ').trim();
return full.length > 0 ? full : this.#user.username;
}
start(user: AuthenticatedUser): void {
this.#user = user;
if (browser) localStorage.setItem(STORAGE_KEY, JSON.stringify(user));
}
clear(): void {
this.#user = null;
if (browser) localStorage.removeItem(STORAGE_KEY);
}
}
export const session = new Session();
@@ -0,0 +1,91 @@
import { browser } from '$app/environment';
/** Must match the key read by the inline script in src/app.html, byte for byte. */
const STORAGE_KEY = 'ladose.theme';
export type Theme = 'light' | 'dark' | 'system';
/** The two themes the CSS actually defines; 'system' resolves to one of these. */
export type ResolvedTheme = 'light' | 'dark';
function readStored(): Theme {
if (!browser) return 'system';
try {
const raw = localStorage.getItem(STORAGE_KEY);
return raw === 'light' || raw === 'dark' ? raw : 'system';
} catch {
// Safari private mode, or cookies blocked.
return 'system';
}
}
function systemPrefersDark(): boolean {
return browser && window.matchMedia('(prefers-color-scheme: dark)').matches;
}
/**
* Light/dark preference.
*
* The initial `data-theme` attribute is stamped by the inline script in
* `src/app.html` before first paint; this store only mirrors that state and takes
* over once the user touches the toggle. It deliberately does not re-stamp on
* construction that would cost a style recalc on every boot to no effect.
*
* `app.css` keys off `[data-theme='light']` and `[data-theme='dark']` only, so
* 'system' is represented by the *absence* of the attribute and never written as
* a literal value.
*/
class ThemeStore {
#preference = $state<Theme>(readStored());
#systemDark = $state(systemPrefersDark());
constructor() {
if (!browser) return;
// Reflect a live OS switch while the preference is 'system'.
const query = window.matchMedia('(prefers-color-scheme: dark)');
query.addEventListener('change', (event) => {
this.#systemDark = event.matches;
});
}
/** What the user chose: an explicit theme, or 'system' to follow the OS. */
get preference(): Theme {
return this.#preference;
}
/** What is actually on screen. Drives the toggle's icon and label. */
get resolved(): ResolvedTheme {
if (this.#preference !== 'system') return this.#preference;
return this.#systemDark ? 'dark' : 'light';
}
set(preference: Theme): void {
this.#preference = preference;
if (!browser) return;
const root = document.documentElement;
if (preference === 'system') {
delete root.dataset.theme;
} else {
root.dataset.theme = preference;
}
try {
if (preference === 'system') localStorage.removeItem(STORAGE_KEY);
else localStorage.setItem(STORAGE_KEY, preference);
} catch {
// Preference is still applied for this tab; it just will not persist.
}
}
/**
* Flips to the opposite of what is currently on screen. Starting from 'system'
* this pins an explicit choice, which is what someone clicking a toggle means.
*/
toggle(): void {
this.set(this.resolved === 'dark' ? 'light' : 'dark');
}
}
export const theme = new ThemeStore();
@@ -0,0 +1,159 @@
import type { GameDTO, ResultDTO, TournamentsResultDTO } from '$lib/api/schema-helpers';
/**
* Scoring lives on the server (ExternalProviderService applies the point rules);
* everything here only reshapes `TournamentsResultDTO` for display, mirroring the
* Avalonia TournamentResultViewModel: a players x games ranking grid, a per-game
* breakdown, the WordPress HTML summary and a CSV export.
*/
export interface RankingRow {
player: string;
/** Points per game, index-aligned with `RankingTable.games`. */
points: number[];
total: number;
}
export interface RankingTable {
games: GameDTO[];
rows: RankingRow[];
}
/** Player names come from different brackets, so they only match case-insensitively. */
function sameName(a: string, b: string): boolean {
return a.toUpperCase() === b.toUpperCase();
}
/** Games that actually have results, in the display order configured on Game.Order. */
export function playedGames(result: TournamentsResultDTO | null): GameDTO[] {
if (!result?.games) return [];
const scored = new Set((result.results ?? []).map((r) => r.gameId));
const byId = new Map<number, GameDTO>();
for (const game of result.games) {
if (game.id !== undefined && scored.has(game.id) && !byId.has(game.id)) byId.set(game.id, game);
}
return [...byId.values()].sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
}
/**
* Builds the ranking grid: one row per participant with their points in each game
* and a total, highest total first. Duplicate spellings of a name are merged.
*/
export function buildRanking(result: TournamentsResultDTO | null): RankingTable {
const games = playedGames(result);
if (!result) return { games, rows: [] };
const players: string[] = [];
for (const participant of result.participents ?? []) {
const name = participant.name?.trim();
if (name && !players.some((known) => sameName(known, name))) players.push(name);
}
players.sort((a, b) => a.localeCompare(b));
const results = result.results ?? [];
const rows = players.map((player) => {
const points = games.map((game) =>
results
.filter((r) => r.gameId === game.id && r.player && sameName(r.player, player))
.reduce((sum, r) => sum + (r.point ?? 0), 0)
);
return { player, points, total: points.reduce((sum, p) => sum + p, 0) };
});
rows.sort((a, b) => b.total - a.total || a.player.localeCompare(b.player));
return { games, rows };
}
/** The placements of a single game, best rank first — the "By game" view. */
export function resultsForGame(
result: TournamentsResultDTO | null,
gameId: number | null
): ResultDTO[] {
if (!result?.results || gameId === null) return [];
return result.results
.filter((r) => r.gameId === gameId)
.sort((a, b) => (a.rank ?? 0) - (b.rank ?? 0) || (b.point ?? 0) - (a.point ?? 0));
}
/** start.gg event slugs: the bracket name with spaces and dots turned into dashes. */
function bracketSlug(tournamentUrl: string): string {
return tournamentUrl.replaceAll(' ', '-').replaceAll('.', '-');
}
function escapeHtml(value: string): string {
return value
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;');
}
/**
* The podium table pasted into the WordPress recap post: two games per row, each
* cell listing the top 3 and linking to the start.gg bracket. The bracket link
* needs `slug`, which the API only returns for a single-event request.
*/
export function buildHtml(result: TournamentsResultDTO | null): string {
if (!result) return '';
const games = playedGames(result);
const results = result.results ?? [];
const parts: string[] = [
'<table class="table table-responsive-md table-dark table-striped mt-lg-4 mt-3">'
];
let columns = 0;
for (const game of games) {
const forGame = results.filter((r) => r.gameId === game.id);
const top3 = [...forGame]
.sort((a, b) => (a.rank ?? 0) - (b.rank ?? 0))
.slice(0, 3)
.map((r) => r.player ?? '');
if (top3.length === 0) continue;
if (columns % 2 === 0) parts.push('<tr>');
columns++;
// A lone game on the last row spans both columns.
const span = columns === games.length && columns % 2 !== 0 ? 2 : 1;
parts.push(
`<td colspan="${span}" width="50%">` +
'<span style="color: #ff0000;">' +
`<strong>${escapeHtml(game.longName ?? game.name ?? '')} (${forGame.length} participants) :</strong>` +
'</span>'
);
parts.push(
'<br>' + top3.map((player, i) => ` ${i + 1}/ ${escapeHtml(player)}<br>`).join('')
);
const tournamentUrl = forGame[0]?.tournamentUrl;
if (result.slug && tournamentUrl) {
const href = `https://start.gg/tournament/${result.slug}/event/${bracketSlug(tournamentUrl)}`;
parts.push(`<a href="${escapeHtml(href)}" target="_blank">Voir le Bracket</a>`);
}
parts.push('</td>');
if (columns % 2 === 0) parts.push('</tr>');
}
if (columns % 2 !== 0) parts.push('</tr>');
parts.push('</table>');
return parts.join('');
}
/** Excel is picky: semicolon separated, every field quoted, inner quotes doubled. */
export function buildCsv(table: RankingTable): string {
const quote = (value: string | number) => `"${String(value).replaceAll('"', '""')}"`;
const header = ['Players', ...table.games.map((g) => g.name ?? ''), 'Total'];
const lines = [header.map(quote).join(';')];
for (const row of table.rows) {
lines.push([row.player, ...row.points, row.total].map(quote).join(';'));
}
return lines.join('\r\n') + '\r\n';
}
@@ -0,0 +1,220 @@
<script lang="ts">
import type { EventAttendance } from '$lib/statistics/aggregate';
import { formatMonth } from '$lib/statistics/aggregate';
interface Props {
data: EventAttendance[];
}
let { data }: Props = $props();
/*
* One series — unique players per event — so there is no legend: the heading
* above the chart already says what is plotted, and a one-swatch legend would
* just restate it. Columns rather than a line because the events are discrete
* monthly rankings; a line would imply attendance existed between them.
*
* Marks are painted with the theme's own accent token, which is a different
* (validated) step in light and dark, so the chart is not a flipped light chart.
* Axes and labels wear text/line tokens, never the data colour.
*/
// Rendered 1:1 against the measured container width: a viewBox that scales would
// shrink the tick labels on a phone and blur the hairlines.
let width = $state(880);
const height = 260;
const pad = { top: 14, right: 10, bottom: 28, left: 40 };
let host: HTMLDivElement | undefined = $state();
$effect(() => {
if (!host || typeof ResizeObserver === 'undefined') return;
const observer = new ResizeObserver((entries) => {
const measured = entries[0]?.contentRect.width ?? 0;
if (measured > 0) width = Math.max(320, measured);
});
observer.observe(host);
return () => observer.disconnect();
});
const plot = $derived({
x: pad.left,
y: pad.top,
w: Math.max(10, width - pad.left - pad.right),
h: Math.max(10, height - pad.top - pad.bottom)
});
/** Clean axis ceiling — 0 / 20 / 40 rather than 0 / 17 / 34. */
function niceTicks(max: number, wanted = 4): number[] {
if (max <= 0) return [0, 1];
const raw = max / wanted;
const magnitude = 10 ** Math.floor(Math.log10(raw));
const step = [1, 2, 2.5, 5, 10].map((m) => m * magnitude).find((s) => s >= raw) ?? magnitude * 10;
const ticks: number[] = [];
for (let value = 0; value <= max + step / 2; value += step) ticks.push(Math.round(value));
return ticks;
}
const peak = $derived(Math.max(0, ...data.map((entry) => entry.players)));
const ticks = $derived(niceTicks(peak));
const scaleMax = $derived(ticks[ticks.length - 1] || 1);
const slot = $derived(data.length > 0 ? plot.w / data.length : plot.w);
/** Capped at 24px, and 2px of the gap is the surface showing between neighbours. */
const barWidth = $derived(Math.max(2, Math.min(24, slot - 2)));
interface Bar {
entry: EventAttendance;
x: number;
y: number;
w: number;
h: number;
slotX: number;
label: string;
}
const bars = $derived<Bar[]>(
data.map((entry, index) => {
const barHeight = scaleMax === 0 ? 0 : (entry.players / scaleMax) * plot.h;
const slotX = plot.x + index * slot;
return {
entry,
x: slotX + (slot - barWidth) / 2,
y: plot.y + plot.h - barHeight,
w: barWidth,
h: barHeight,
slotX,
label: formatMonth(entry.date) || entry.name
};
})
);
/** Square at the baseline, 4px rounded at the data end. */
function columnPath(bar: Bar): string {
const r = Math.min(4, bar.w / 2, bar.h);
const base = bar.y + bar.h;
if (r <= 0.5) return `M${bar.x} ${bar.y}h${bar.w}v${bar.h}h${-bar.w}Z`;
return [
`M${bar.x} ${base}`,
`V${bar.y + r}`,
`a${r} ${r} 0 0 1 ${r} ${-r}`,
`h${bar.w - 2 * r}`,
`a${r} ${r} 0 0 1 ${r} ${r}`,
`V${base}`,
'Z'
].join(' ');
}
/** Label the ends plus a sparse interior: a label on every column is unreadable. */
const labelEvery = $derived(Math.max(1, Math.ceil(data.length / Math.max(1, Math.floor(width / 90)))));
function showLabel(index: number): boolean {
if (data.length <= 1) return true;
if (index === 0 || index === data.length - 1) return true;
return index % labelEvery === 0 && index > 0 && index < data.length - 1;
}
let hovered = $state<number | null>(null);
const active = $derived(hovered === null ? null : bars[hovered]);
</script>
<div bind:this={host} class="relative w-full">
{#if data.length === 0}
<p class="py-10 text-center text-sm text-subtle">No event in this scope.</p>
{:else}
<svg
{width}
{height}
role="img"
aria-label="Unique players per event, oldest first. The same numbers are in the table below."
class="block"
>
<!-- Gridlines: hairline, solid, one step off the surface. -->
{#each ticks as tick (tick)}
{@const y = plot.y + plot.h - (scaleMax === 0 ? 0 : (tick / scaleMax) * plot.h)}
<line
x1={plot.x}
x2={plot.x + plot.w}
y1={y}
y2={y}
stroke="var(--app-line)"
stroke-width="1"
shape-rendering="crispEdges"
/>
<text
x={plot.x - 8}
{y}
dy="0.32em"
text-anchor="end"
font-size="11"
fill="var(--app-subtle)"
>
{tick}
</text>
{/each}
{#each bars as bar, index (bar.entry.eventId)}
<path
d={columnPath(bar)}
fill="var(--app-accent)"
opacity={hovered === null || hovered === index ? 1 : 0.45}
/>
{/each}
{#each bars as bar, index (bar.entry.eventId)}
{#if showLabel(index)}
<text
x={bar.x + bar.w / 2}
y={plot.y + plot.h + 18}
text-anchor="middle"
font-size="11"
fill="var(--app-subtle)"
>
{bar.label}
</text>
{/if}
{/each}
<!-- Hit targets are the full slot height, so thin columns stay hoverable. -->
{#each bars as bar, index (bar.entry.eventId)}
<rect
x={bar.slotX}
y={plot.y}
width={Math.max(slot, 6)}
height={plot.h}
fill="transparent"
onmouseenter={() => (hovered = index)}
onmouseleave={() => (hovered = null)}
role="presentation"
/>
{/each}
</svg>
{#if active}
<div
class="pointer-events-none absolute z-10 w-44 rounded-lg border border-line bg-overlay p-2 text-xs shadow-card"
style="left: {Math.min(
Math.max(active.x + active.w / 2 - 88, 0),
Math.max(width - 176, 0)
)}px; top: {Math.max(active.y - 92, 0)}px"
>
<p class="font-semibold text-ink">{active.entry.name}</p>
{#if formatMonth(active.entry.date)}
<p class="text-subtle">{formatMonth(active.entry.date)}</p>
{/if}
<dl class="mt-1 space-y-0.5 text-muted">
<div class="flex justify-between gap-2">
<dt>Players</dt>
<dd class="font-semibold text-ink">{active.entry.players}</dd>
</div>
<div class="flex justify-between gap-2">
<dt>Entries</dt>
<dd>{active.entry.entries}</dd>
</div>
<div class="flex justify-between gap-2">
<dt>Brackets</dt>
<dd>{active.entry.brackets}</dd>
</div>
</dl>
</div>
{/if}
{/if}
</div>
@@ -0,0 +1,227 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { page } from '$app/state';
import { session } from '$lib/stores/session.svelte';
import ThemeToggle from '$lib/ui/ThemeToggle.svelte';
import {
iconButton,
menuItem,
menuItemActive,
menuPanel,
navLink,
navLinkActive
} from '$lib/ui/classes';
interface NavItem {
href: string;
label: string;
}
/** Tournaments is the app's reason to exist, so it leads. */
const mainLinks: NavItem[] = [
{ href: '/tournaments', label: 'Tournaments' },
{ href: '/statistiques', label: 'Statistiques' }
];
/** Administration, tucked away: not what anyone opens the app to do. */
const settingsLinks = $derived<NavItem[]>([
{ href: '/games', label: 'Games' },
...(session.isAdmin ? [{ href: '/users', label: 'Users' }] : [])
]);
const path = $derived(page.url.pathname);
const isLoginPage = $derived(path === '/login');
const showNav = $derived(!isLoginPage && session.isLoggedIn);
function isActive(href: string): boolean {
return path === href || path.startsWith(`${href}/`);
}
const settingsActive = $derived(settingsLinks.some((link) => isActive(link.href)));
let openMenu = $state<'settings' | 'mobile' | null>(null);
/** Close on outside click, on Escape, and whenever the route changes. */
$effect(() => {
if (openMenu === null) return;
const onPointerDown = (event: PointerEvent) => {
if (!(event.target instanceof Element)) return;
if (!event.target.closest('[data-menu-root]')) openMenu = null;
};
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') openMenu = null;
};
document.addEventListener('pointerdown', onPointerDown, true);
document.addEventListener('keydown', onKeyDown);
return () => {
document.removeEventListener('pointerdown', onPointerDown, true);
document.removeEventListener('keydown', onKeyDown);
};
});
$effect(() => {
// Reading `path` subscribes this effect to navigation.
void path;
openMenu = null;
});
async function signOut() {
openMenu = null;
session.clear();
await goto('/login', { replaceState: true });
}
</script>
<a
href="#main"
class="sr-only focus:not-sr-only focus:absolute focus:top-2 focus:left-2 focus:z-50 focus:rounded-lg focus:bg-accent focus:px-3 focus:py-2 focus:text-sm focus:font-semibold focus:text-on-accent"
>
Skip to content
</a>
<header
class="sticky top-0 z-40 h-(--nav-h) border-b border-line bg-canvas/80 backdrop-blur-md"
>
<div class="mx-auto flex h-full max-w-6xl items-center gap-2 px-4">
{#if showNav}
<a
href="/"
class="mr-1 shrink-0 rounded-lg px-1 text-base font-semibold tracking-tight text-ink focus:outline-none focus-visible:ring-2 focus-visible:ring-accent"
aria-current={path === '/' ? 'page' : undefined}
>
LaDOSE
</a>
<!-- Desktop navigation -->
<nav class="hidden items-center gap-1 sm:flex" aria-label="Main">
{#each mainLinks as link (link.href)}
<a
href={link.href}
class={isActive(link.href) ? navLinkActive : navLink}
aria-current={isActive(link.href) ? 'page' : undefined}
>
{link.label}
</a>
{/each}
<div class="relative" data-menu-root>
<button
type="button"
onclick={() => (openMenu = openMenu === 'settings' ? null : 'settings')}
class="{settingsActive ? navLinkActive : navLink} inline-flex items-center gap-1"
aria-expanded={openMenu === 'settings'}
aria-haspopup="menu"
>
Settings
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="size-3 transition-transform {openMenu === 'settings' ? 'rotate-180' : ''}"
aria-hidden="true"
>
<path d="m6 9 6 6 6-6" />
</svg>
</button>
{#if openMenu === 'settings'}
<div class={menuPanel} role="menu">
{#each settingsLinks as link (link.href)}
<a
href={link.href}
role="menuitem"
class={isActive(link.href) ? menuItemActive : menuItem}
aria-current={isActive(link.href) ? 'page' : undefined}
>
{link.label}
</a>
{/each}
</div>
{/if}
</div>
</nav>
<div class="ml-auto flex items-center gap-2">
<span class="hidden text-sm text-muted md:inline">{session.displayName}</span>
<ThemeToggle />
<button type="button" onclick={signOut} class="hidden {navLink} sm:inline-block">
Sign out
</button>
<!-- Mobile menu -->
<div class="relative sm:hidden" data-menu-root>
<button
type="button"
onclick={() => (openMenu = openMenu === 'mobile' ? null : 'mobile')}
class={iconButton}
aria-expanded={openMenu === 'mobile'}
aria-haspopup="menu"
aria-label="Menu"
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
class="size-4"
aria-hidden="true"
>
{#if openMenu === 'mobile'}
<path d="M6 6l12 12M18 6 6 18" />
{:else}
<path d="M4 7h16M4 12h16M4 17h16" />
{/if}
</svg>
</button>
{#if openMenu === 'mobile'}
<div class={menuPanel} role="menu">
{#each mainLinks as link (link.href)}
<a
href={link.href}
role="menuitem"
class={isActive(link.href) ? menuItemActive : menuItem}
aria-current={isActive(link.href) ? 'page' : undefined}
>
{link.label}
</a>
{/each}
<p class="mt-1 px-3 pt-2 pb-1 text-xs tracking-wide text-subtle uppercase">
Settings
</p>
{#each settingsLinks as link (link.href)}
<a
href={link.href}
role="menuitem"
class={isActive(link.href) ? menuItemActive : menuItem}
aria-current={isActive(link.href) ? 'page' : undefined}
>
{link.label}
</a>
{/each}
<div class="mt-1 border-t border-line pt-1">
<button type="button" role="menuitem" onclick={signOut} class={menuItem}>
Sign out{session.displayName ? ` (${session.displayName})` : ''}
</button>
</div>
</div>
{/if}
</div>
</div>
{:else}
<!-- Signed out, or on /login: the page owns its own branding, but the
theme toggle has to stay reachable. -->
<div class="ml-auto">
<ThemeToggle />
</div>
{/if}
</div>
</header>
@@ -0,0 +1,54 @@
<script lang="ts">
import { theme } from '$lib/stores/theme.svelte';
import { iconButton } from '$lib/ui/classes';
const isDark = $derived(theme.resolved === 'dark');
const nextLabel = $derived(isDark ? 'Switch to light theme' : 'Switch to dark theme');
// "Following your system" matters: it is the difference between a pinned
// preference and one that will change under the user later.
const title = $derived(
theme.preference === 'system'
? `Following your system (${theme.resolved}). ${nextLabel}.`
: nextLabel
);
</script>
<button
type="button"
onclick={() => theme.toggle()}
class={iconButton}
aria-label={nextLabel}
{title}
>
{#if isDark}
<!-- Sun: clicking moves to light -->
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.8"
stroke-linecap="round"
class="size-4"
aria-hidden="true"
>
<circle cx="12" cy="12" r="4" />
<path
d="M12 2v2M12 20v2M2 12h2M20 12h2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M19.1 4.9l-1.4 1.4M6.3 17.7l-1.4 1.4"
/>
</svg>
{:else}
<!-- Moon: clicking moves to dark -->
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.8"
stroke-linecap="round"
stroke-linejoin="round"
class="size-4"
aria-hidden="true"
>
<path d="M21 12.8A8.5 8.5 0 1 1 11.2 3a6.6 6.6 0 0 0 9.8 9.8Z" />
</svg>
{/if}
</button>
@@ -0,0 +1,61 @@
/**
* The Tailwind class strings shared by the app's pages, expressed entirely in the
* semantic tokens defined in `src/app.css`. Nothing here may name a fixed colour:
* these six constants cover most of the app's themed surface, so a hard-coded
* shade in this file is a hard-coded shade almost everywhere.
*/
export const card =
'rounded-2xl border border-line bg-surface p-5 shadow-card backdrop-blur';
export const field =
'w-full rounded-lg border border-line bg-inset px-3 py-2 text-sm text-ink outline-none transition placeholder:text-subtle focus:border-accent focus:ring-2 focus:ring-accent/40 disabled:opacity-60';
export const primary =
'rounded-lg bg-accent px-4 py-2 text-sm font-semibold text-on-accent transition hover:bg-accent-hover focus:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-canvas disabled:cursor-not-allowed disabled:opacity-50';
export const ghost =
'rounded-lg border border-line-strong px-3 py-2 text-sm font-medium text-ink transition hover:bg-ink/5 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent disabled:cursor-not-allowed disabled:opacity-50';
export const danger =
'rounded-lg border border-danger/30 px-3 py-2 text-sm font-medium text-danger transition hover:bg-danger-soft focus:outline-none focus-visible:ring-2 focus-visible:ring-danger disabled:cursor-not-allowed disabled:opacity-50';
export const label = 'block text-xs font-medium tracking-wide text-muted uppercase';
/** Section heading inside a `card`. */
export const cardHeading = 'text-sm font-semibold tracking-wide text-ink uppercase';
/** Feedback banners. Rendered identically on every page, so they live here. */
export const alertError = 'rounded-lg bg-danger-soft px-3 py-2 text-sm text-danger';
export const alertNotice = 'rounded-lg bg-success-soft px-3 py-2 text-sm text-success';
export const alertWarning = 'rounded-lg bg-warning-soft px-3 py-2 text-sm text-warning';
/**
* Selectable row in a list (games, events, tab strips). `selected` is the quiet
* treatment; `activeTab` is the loud one used where the choice drives a panel.
*/
export const listRow =
'w-full rounded-lg px-2 py-1.5 text-left transition text-muted hover:bg-ink/5';
export const listRowSelected = 'w-full rounded-lg px-2 py-1.5 text-left transition bg-ink/10 text-ink';
export const tab = 'rounded-lg px-3 py-1.5 text-sm font-medium transition text-muted hover:bg-ink/5';
export const tabActive =
'rounded-lg px-3 py-1.5 text-sm font-medium transition bg-accent text-on-accent';
/** Navbar links. `navLinkActive` also carries `aria-current="page"` at the call site. */
export const navLink =
'rounded-lg px-3 py-1.5 text-sm font-medium text-muted transition hover:bg-ink/5 hover:text-ink focus:outline-none focus-visible:ring-2 focus-visible:ring-accent';
export const navLinkActive =
'rounded-lg px-3 py-1.5 text-sm font-medium text-ink transition bg-ink/10 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent';
/** Square button for an icon only — the theme toggle and the menu button. */
export const iconButton =
'inline-flex size-9 items-center justify-center rounded-lg border border-line-strong text-ink transition hover:bg-ink/5 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent';
/** Dropdown panel, e.g. the Settings menu in the navbar. */
export const menuPanel =
'absolute right-0 z-50 mt-2 w-52 overflow-hidden rounded-xl border border-line bg-surface p-1 shadow-card backdrop-blur-lg';
export const menuItem =
'block w-full rounded-lg px-3 py-2 text-left text-sm text-muted transition hover:bg-ink/5 hover:text-ink focus:outline-none focus-visible:bg-ink/5 focus-visible:text-ink';
export const menuItemActive =
'block w-full rounded-lg px-3 py-2 text-left text-sm text-ink transition bg-ink/10 focus:outline-none';
@@ -0,0 +1,21 @@
<script lang="ts">
import favicon from '$lib/assets/favicon.svg';
import Navbar from '$lib/ui/Navbar.svelte';
import '../app.css';
let { children } = $props();
</script>
<svelte:head>
<link rel="icon" href={favicon} />
</svelte:head>
<!--
The page background lives on html/body in app.css, not on a class here: with
ssr = false this element does not exist until the bundle hydrates, so painting
the canvas from it would flash white on every cold load of every route.
-->
<div class="antialiased">
<Navbar />
{@render children()}
</div>
@@ -0,0 +1,4 @@
// The app is a static SPA in front of LaDOSE.Api: rendering on the server would
// have no access to the browser-held JWT, so everything runs client-side.
export const ssr = false;
export const prerender = false;
@@ -0,0 +1,66 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { session } from '$lib/stores/session.svelte';
import { card } from '$lib/ui/classes';
// Guard the page: no session (or an expired JWT) sends the user to /login.
$effect(() => {
if (!session.isLoggedIn) goto('/login', { replaceState: true });
});
interface Shortcut {
href: string;
title: string;
description: string;
adminOnly?: boolean;
}
const shortcuts: Shortcut[] = [
{
href: '/tournaments',
title: 'Tournaments',
description: 'Import a start.gg tournament and score one event or a whole ranking season.'
},
{
href: '/statistiques',
title: 'Statistiques',
description: 'Leaderboards, attendance over time and head-to-head records.'
},
{
href: '/games',
title: 'Games',
description: 'The catalogue behind rankings, WordPress tags and bracket matching.'
},
{
href: '/users',
title: 'Users',
description: 'Add and remove the accounts that can sign in.',
adminOnly: true
}
];
const visible = $derived(shortcuts.filter((s) => !s.adminOnly || session.isAdmin));
</script>
<svelte:head>
<title>LaDOSE</title>
</svelte:head>
<main id="main" class="mx-auto max-w-4xl px-4 py-12">
{#if session.user}
<h1 class="text-4xl font-semibold tracking-tight">Hello, {session.displayName}.</h1>
<p class="mt-3 text-sm text-muted">You are signed in to LaDOSE.</p>
<div class="mt-10 grid gap-4 sm:grid-cols-2">
{#each visible as shortcut (shortcut.href)}
<a
href={shortcut.href}
class="{card} block transition hover:border-accent focus:outline-none focus-visible:ring-2 focus-visible:ring-accent"
>
<h2 class="text-base font-semibold text-ink">{shortcut.title}</h2>
<p class="mt-1 text-sm text-muted">{shortcut.description}</p>
</a>
{/each}
</div>
{/if}
</main>
@@ -0,0 +1,352 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { deleteGame, listGames, saveGame, searchSmashGames } from '$lib/api/games';
import { toErrorMessage } from '$lib/api/errors';
import type { GameDTO } from '$lib/api/schema-helpers';
import { blankDraft, nextOrder, toDraft, toDto, type Draft } from '$lib/games/draft';
import { session } from '$lib/stores/session.svelte';
import {
alertError,
alertNotice,
card,
cardHeading,
danger,
field,
ghost,
label,
listRow,
listRowSelected,
primary
} from '$lib/ui/classes';
let games = $state<GameDTO[]>([]);
let draft = $state<Draft>({ ...blankDraft });
let pristine = $state(JSON.stringify(blankDraft));
let smashMatches = $state<GameDTO[] | null>(null);
let loading = $state(false);
let saving = $state(false);
let deleting = $state(false);
let searching = $state(false);
let notice = $state<string | null>(null);
let error = $state<string | null>(null);
const ordered = $derived([...games].sort((a, b) => (a.order ?? 0) - (b.order ?? 0)));
const isNew = $derived(draft.id === 0);
const dirty = $derived(JSON.stringify(draft) !== pristine);
const canSave = $derived(draft.name.trim() !== '' && !saving);
/** The provider searches start.gg by name; the long name is the one that matches. */
const searchTerm = $derived(draft.longName.trim() || draft.name.trim());
let started = false;
$effect(() => {
if (!session.isLoggedIn) {
goto('/login', { replaceState: true });
return;
}
if (!started) {
started = true;
void refresh();
}
});
function report(cause: unknown, fallback: string) {
error = toErrorMessage(cause, fallback);
}
function load(game: GameDTO) {
draft = toDraft(game);
pristine = JSON.stringify(draft);
smashMatches = null;
}
async function refresh(keepId = draft.id) {
loading = true;
error = null;
try {
games = await listGames();
// Re-read the edited game so the form shows what the server actually stored.
const current = games.find((game) => game.id === keepId);
if (current) load(current);
else if (keepId !== 0) reset();
} catch (cause) {
report(cause, 'Could not load the game list.');
} finally {
loading = false;
}
}
function reset() {
draft = { ...blankDraft, order: nextOrder(games) };
pristine = JSON.stringify(draft);
smashMatches = null;
}
function select(game: GameDTO) {
if (game.id === draft.id) return;
if (dirty && !confirm('Discard the unsaved changes to this game?')) return;
load(game);
notice = null;
error = null;
}
function startNew() {
if (dirty && !confirm('Discard the unsaved changes to this game?')) return;
reset();
notice = null;
error = null;
}
async function save() {
if (!canSave) return;
saving = true;
error = null;
notice = null;
const creating = isNew;
try {
const saved = await saveGame(toDto(draft));
notice = creating ? `Created "${saved.name}".` : `Saved "${saved.name}".`;
await refresh(saved.id ?? 0);
} catch (cause) {
report(cause, 'Could not save this game.');
} finally {
saving = false;
}
}
async function remove() {
if (isNew || deleting) return;
if (!confirm(`Delete "${draft.name}"? This cannot be undone.`)) return;
deleting = true;
error = null;
notice = null;
try {
await deleteGame(draft.id);
notice = `Deleted "${draft.name}".`;
reset();
await refresh(0);
} catch (cause) {
// The service swallows DbUpdateException and answers 404, so an FK clash
// and a missing row are indistinguishable from here.
report(
cause,
'Delete failed — the game may already be gone, or still be attached to tournaments.'
);
} finally {
deleting = false;
}
}
async function findOnSmash() {
if (searchTerm === '' || searching) return;
searching = true;
error = null;
try {
smashMatches = await searchSmashGames(searchTerm);
if (smashMatches.length === 0) notice = `start.gg has no game matching "${searchTerm}".`;
} catch (cause) {
report(cause, `Could not search start.gg for "${searchTerm}".`);
} finally {
searching = false;
}
}
</script>
<svelte:head>
<title>Games · LaDOSE</title>
</svelte:head>
<main id="main" class="mx-auto max-w-5xl px-4 py-10">
<header class="mb-8">
<h1 class="text-3xl font-semibold tracking-tight">Games</h1>
<p class="mt-1 text-sm text-muted">
The catalogue behind rankings, WordPress tags and start.gg bracket matching.
</p>
</header>
{#if error}
<p role="alert" class="{alertError} mb-4">
{error}
</p>
{/if}
{#if notice}
<p class="{alertNotice} mb-4">{notice}</p>
{/if}
<div class="grid gap-6 md:grid-cols-[18rem_1fr] md:items-start">
<section class={card}>
<div class="flex items-baseline justify-between gap-3">
<h2 class={cardHeading}>
Catalogue
<span class="ml-1 font-normal text-muted normal-case">({games.length})</span>
</h2>
<button class={ghost} onclick={() => refresh()} disabled={loading}>
{loading ? '…' : 'Reload'}
</button>
</div>
<ul class="mt-4 max-h-[26rem] space-y-1 overflow-y-auto pr-1 text-sm">
{#each ordered as game (game.id)}
<li>
<button
onclick={() => select(game)}
class="{draft.id === game.id ? listRowSelected : listRow} flex items-center gap-3"
>
<span class="w-6 shrink-0 text-right text-xs text-subtle">{game.order}</span>
<span class="truncate">{game.name}</span>
</button>
</li>
{:else}
<li class="px-2 py-6 text-center text-subtle">
{loading ? 'Loading…' : 'No game yet.'}
</li>
{/each}
</ul>
<button class="{primary} mt-4 w-full" onclick={startNew}>New game</button>
</section>
<section class={card}>
<div class="flex flex-wrap items-baseline justify-between gap-3">
<h2 class={cardHeading}>
{isNew ? 'New game' : `Editing #${draft.id}`}
</h2>
{#if dirty}
<span class="rounded-full bg-warning-soft px-2 py-0.5 text-xs text-warning">
Unsaved changes
</span>
{/if}
</div>
<form
class="mt-5 grid gap-4 sm:grid-cols-2"
onsubmit={(event) => {
event.preventDefault();
void save();
}}
>
<div class="space-y-1.5">
<label class={label} for="game-name">Name</label>
<input id="game-name" bind:value={draft.name} class={field} placeholder="SF6" required />
<p class="text-xs text-subtle">Short label shown in ranking columns.</p>
</div>
<div class="space-y-1.5">
<label class={label} for="game-order">Order</label>
<input id="game-order" type="number" bind:value={draft.order} class={field} />
<p class="text-xs text-subtle">Sorts lists and the HTML recap.</p>
</div>
<div class="space-y-1.5 sm:col-span-2">
<label class={label} for="game-longname">Long name</label>
<input
id="game-longname"
bind:value={draft.longName}
class={field}
placeholder="Street Fighter 6"
/>
<p class="text-xs text-subtle">
Used as the podium heading, and as the start.gg search term below.
</p>
</div>
<div class="space-y-1.5">
<label class={label} for="game-wptag">WordPress tag</label>
<input id="game-wptag" bind:value={draft.wordPressTag} class={field} />
</div>
<div class="space-y-1.5">
<label class={label} for="game-wptagos">WordPress tag (OS)</label>
<input id="game-wptagos" bind:value={draft.wordPressTagOs} class={field} />
</div>
<div class="space-y-1.5 sm:col-span-2">
<label class={label} for="game-img">Image URL</label>
<input
id="game-img"
bind:value={draft.imgUrl}
class={field}
placeholder="https://ladose.net/…"
/>
</div>
<div class="space-y-1.5 sm:col-span-2">
<label class={label} for="game-smashid">start.gg videogame id</label>
<div class="flex gap-2">
<input
id="game-smashid"
type="number"
bind:value={draft.smashId}
class={field}
placeholder="none"
/>
<button
type="button"
class="{ghost} shrink-0"
onclick={findOnSmash}
disabled={searching || searchTerm === ''}
>
{searching ? 'Searching…' : 'Find on start.gg'}
</button>
</div>
<p class="text-xs text-subtle">
Imports match brackets on this id — without it, results land under "GAME NOT FOUND".
</p>
{#if smashMatches?.length}
<ul class="mt-2 max-h-40 space-y-1 overflow-y-auto rounded-lg bg-inset p-1">
{#each smashMatches as match (match.id)}
<li>
<button
type="button"
onclick={() => (draft.smashId = match.id ?? null)}
class="flex w-full items-center gap-3 rounded px-2 py-1.5 text-left text-sm transition hover:bg-ink/5 {draft.smashId ===
match.id
? 'text-accent'
: 'text-ink'}"
>
<span class="w-14 shrink-0 text-right text-xs text-subtle">
{match.id}
</span>
<span class="truncate">{match.name}</span>
</button>
</li>
{/each}
</ul>
{/if}
</div>
<div
class="flex flex-wrap items-center gap-3 border-t border-line pt-4 sm:col-span-2"
>
<button type="submit" class={primary} disabled={!canSave}>
{saving ? 'Saving…' : isNew ? 'Create game' : 'Save'}
</button>
<button
type="button"
class={ghost}
onclick={() => {
const current = games.find((game) => game.id === draft.id);
if (current) load(current);
else reset();
}}
disabled={!dirty}
>
Revert
</button>
<button
type="button"
class="{danger} ml-auto"
onclick={remove}
disabled={isNew || deleting}
>
{deleting ? 'Deleting…' : 'Delete'}
</button>
</div>
</form>
</section>
</div>
</main>
@@ -0,0 +1,99 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { ApiError } from '$lib/api/client';
import { login } from '$lib/api/users';
import { session } from '$lib/stores/session.svelte';
import { alertError, field, label, primary } from '$lib/ui/classes';
let username = $state('');
let password = $state('');
let error = $state<string | null>(null);
let submitting = $state(false);
const canSubmit = $derived(username.trim() !== '' && password !== '' && !submitting);
async function handleSubmit(event: SubmitEvent) {
event.preventDefault();
if (!canSubmit) return;
submitting = true;
error = null;
try {
session.start(await login({ username: username.trim(), password }));
await goto('/');
} catch (cause) {
// A 400 from /Users/auth means bad credentials; status 0 means the API is unreachable.
error =
cause instanceof ApiError
? cause.message
: 'Something went wrong while signing in. Please try again.';
password = '';
} finally {
submitting = false;
}
}
$effect(() => {
if (session.isLoggedIn) goto('/');
});
</script>
<svelte:head>
<title>Sign in · LaDOSE</title>
</svelte:head>
<main
id="main"
class="flex min-h-[calc(100svh-var(--nav-h))] items-center justify-center px-4 py-12"
>
<div class="w-full max-w-sm">
<div class="mb-8 text-center">
<h1 class="text-3xl font-semibold tracking-tight">LaDOSE</h1>
<p class="mt-2 text-sm text-muted">Sign in to manage tournaments and events.</p>
</div>
<form
onsubmit={handleSubmit}
class="space-y-5 rounded-2xl border border-line bg-surface p-6 shadow-card backdrop-blur"
>
<div class="space-y-2">
<label for="username" class={label}>Username</label>
<input
id="username"
name="username"
type="text"
autocomplete="username"
required
disabled={submitting}
bind:value={username}
class={field}
placeholder="your.username"
/>
</div>
<div class="space-y-2">
<label for="password" class={label}>Password</label>
<input
id="password"
name="password"
type="password"
autocomplete="current-password"
required
disabled={submitting}
bind:value={password}
class={field}
placeholder="••••••••"
/>
</div>
{#if error}
<p role="alert" class={alertError}>{error}</p>
{/if}
<button type="submit" disabled={!canSubmit} class="{primary} w-full py-2.5">
{submitting ? 'Signing in…' : 'Sign in'}
</button>
</form>
</div>
</main>
@@ -0,0 +1,590 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { ApiError } from '$lib/api/client';
import { toErrorMessage } from '$lib/api/errors';
import type { EventDTO, MatchStatsDTO } from '$lib/api/schema-helpers';
import { getMatchStats } from '$lib/api/statistics';
import { listEvents } from '$lib/api/tournaments';
import { session } from '$lib/stores/session.svelte';
import { aggregate, formatMonth, standingsCsv, type Aggregate } from '$lib/statistics/aggregate';
import { loadEventResults, type FailedEvent } from '$lib/statistics/load';
import AttendanceChart from '$lib/ui/AttendanceChart.svelte';
import {
alertError,
alertWarning,
card,
cardHeading,
field,
ghost,
primary,
tab as tabClass,
tabActive as tabActiveClass
} from '$lib/ui/classes';
/*
* Two independent sources, deliberately kept apart:
*
* - points, placements and attendance come from GetResults, one event at a
* time (see $lib/statistics/load), and are reshaped by aggregate();
* - set-level win/loss and head to head come from the Statistics endpoint in
* one call.
*
* The second one is far patchier than the first — brackets imported before sets
* were persisted have placements but no matches — so it lives in its own tab
* behind its own coverage line rather than being mixed into the standings.
*/
let events = $state<EventDTO[]>([]);
let selectedIds = $state<number[]>([]);
let pattern = $state('');
let stats = $state<Aggregate | null>(null);
let matches = $state<MatchStatsDTO | null>(null);
let failed = $state<FailedEvent[]>([]);
/** Ids the loaded figures actually describe, so the header cannot drift. */
let loadedIds = $state<number[]>([]);
let tab = $state<'standings' | 'games' | 'events' | 'matches'>('standings');
let loadingEvents = $state(false);
let loading = $state(false);
let progress = $state({ done: 0, total: 0 });
let error = $state<string | null>(null);
let inFlight: AbortController | null = null;
const standings = $derived(stats?.standings ?? []);
const podium = $derived(standings.filter((row) => row.podiums > 0));
/** Decided sets only, so a 0-0 player cannot claim a 0% win rate. */
const players = $derived(
[...(matches?.players ?? [])]
.filter((row) => (row.sets ?? 0) > 0)
.sort((a, b) => winRate(b) - winRate(a) || (b.sets ?? 0) - (a.sets ?? 0))
);
const headToHead = $derived(
[...(matches?.headToHead ?? [])].sort(
(a, b) => played(b) - played(a) || (a.playerA ?? '').localeCompare(b.playerA ?? '')
)
);
function winRate(row: { wins?: number | null; sets?: number | null }): number {
const sets = row.sets ?? 0;
return sets === 0 ? 0 : ((row.wins ?? 0) / sets) * 100;
}
function played(row: { winsA?: number | null; winsB?: number | null }): number {
return (row.winsA ?? 0) + (row.winsB ?? 0);
}
let started = false;
$effect(() => {
if (!session.isLoggedIn) {
goto('/login', { replaceState: true });
return;
}
if (!started) {
started = true;
void refreshEvents();
}
});
async function refreshEvents() {
loadingEvents = true;
error = null;
try {
events = await listEvents();
} catch (cause) {
error = toErrorMessage(cause, 'Could not load the event list.');
} finally {
loadingEvents = false;
}
}
function toggle(id: number | undefined) {
if (id === undefined) return;
selectedIds = selectedIds.includes(id)
? selectedIds.filter((selected) => selected !== id)
: [...selectedIds, id];
}
function selectAll() {
selectedIds = events.filter((e) => e.id !== undefined).map((e) => e.id as number);
}
/** `GET /api/Event` is newest first, so the head of the list is the recent season. */
function selectRecent(count: number) {
selectedIds = events
.filter((e) => e.id !== undefined)
.slice(0, count)
.map((e) => e.id as number);
}
function selectMatching() {
const value = pattern.trim();
if (value === '') return;
let regex: RegExp;
try {
regex = new RegExp(value);
} catch {
error = `"${value}" is not a valid regular expression.`;
return;
}
error = null;
selectedIds = events
.filter((e) => e.id !== undefined && e.name && regex.test(e.name))
.map((e) => e.id as number);
}
async function load() {
if (selectedIds.length === 0 || loading) return;
inFlight?.abort();
const controller = new AbortController();
inFlight = controller;
const scope = [...selectedIds];
loading = true;
error = null;
failed = [];
progress = { done: 0, total: scope.length };
try {
// Both sources at once: the match call is one request and would otherwise
// sit idle behind the per-event fan-out.
const [outcome, matchStats] = await Promise.all([
loadEventResults(
events.filter((e) => e.id !== undefined && scope.includes(e.id)),
{
signal: controller.signal,
onProgress: (done, total) => (progress = { done, total })
}
),
getMatchStats(scope, { signal: controller.signal }).catch((cause: unknown) => {
// A missing set table must not cost us the standings.
if (cause instanceof DOMException && cause.name === 'AbortError') throw cause;
if (cause instanceof ApiError && cause.status === 401) throw cause;
return null;
})
]);
if (controller.signal.aborted) return;
stats = aggregate(outcome.loaded);
matches = matchStats;
failed = outcome.failed;
loadedIds = scope;
if (tab === 'matches' && !matchStats) tab = 'standings';
} catch (cause) {
if (cause instanceof DOMException && cause.name === 'AbortError') return;
error = toErrorMessage(cause, 'Could not compute statistics for this selection.');
} finally {
if (inFlight === controller) {
inFlight = null;
loading = false;
}
}
}
function exportCsv() {
const blob = new Blob([standingsCsv(standings)], { type: 'text/csv;charset=utf-8' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = `ladose-standings-${loadedIds.length}-events.csv`;
link.click();
URL.revokeObjectURL(url);
}
const tabs = [
['standings', 'Standings'],
['games', 'Games'],
['events', 'Events'],
['matches', 'Matches']
] as const;
/** Percentages get one decimal only under 100, so the column stays narrow. */
function percent(value: number): string {
return value >= 99.95 ? '100%' : `${value.toFixed(1)}%`;
}
</script>
<svelte:head>
<title>Statistiques · LaDOSE</title>
</svelte:head>
<main id="main" class="mx-auto max-w-6xl px-4 py-10">
<header class="mb-8">
<h1 class="text-3xl font-semibold tracking-tight">Statistiques</h1>
<p class="mt-1 text-sm text-muted">
Pick a scope — a season, a year, everything — and see who turned up, who won, and how
the games compare.
</p>
</header>
{#if error}
<p role="alert" class="{alertError} mb-4">{error}</p>
{/if}
<section class={card}>
<div class="flex items-baseline justify-between gap-3">
<h2 class={cardHeading}>
Scope
<span class="ml-1 font-normal text-muted normal-case">
({selectedIds.length} of {events.length} events)
</span>
</h2>
<button class={ghost} onclick={refreshEvents} disabled={loadingEvents}>
{loadingEvents ? 'Loading…' : 'Refresh'}
</button>
</div>
<div class="mt-4 flex flex-wrap items-center gap-2">
<button class={ghost} onclick={selectAll} disabled={!events.length}>All</button>
<button class={ghost} onclick={() => selectRecent(12)} disabled={!events.length}>
Last 12
</button>
<button class={ghost} onclick={() => selectRecent(6)} disabled={!events.length}>
Last 6
</button>
<button class={ghost} onclick={() => (selectedIds = [])} disabled={!selectedIds.length}>
Clear
</button>
<div class="ml-auto flex gap-2">
<input
bind:value={pattern}
class="{field} w-48"
placeholder="Ranking #13\d{'{'}2{'}'}"
aria-label="Regular expression matching event names"
/>
<button class={ghost} onclick={selectMatching} disabled={pattern.trim() === ''}>
Select
</button>
</div>
</div>
<ul class="mt-4 grid max-h-64 gap-1 overflow-y-auto pr-1 text-sm sm:grid-cols-2">
{#each events as event (event.id)}
<li>
<label
class="flex cursor-pointer items-center gap-3 rounded-lg px-2 py-1.5 transition hover:bg-ink/5"
>
<input
type="checkbox"
checked={event.id !== undefined && selectedIds.includes(event.id)}
onchange={() => toggle(event.id)}
class="size-4 accent-accent"
/>
<span class="truncate">{event.name}</span>
{#if formatMonth(event.date ?? null)}
<span class="ml-auto shrink-0 text-xs text-subtle">
{formatMonth(event.date ?? null)}
</span>
{/if}
</label>
</li>
{:else}
<li class="px-2 py-6 text-center text-subtle sm:col-span-2">
{loadingEvents ? 'Loading events…' : 'No event imported yet.'}
</li>
{/each}
</ul>
<div class="mt-4 flex items-center justify-between gap-3 border-t border-line pt-4">
<span class="text-xs text-muted">
{#if loading}
Scoring event {progress.done} of {progress.total}
{:else}
One request per event, so a broken import costs only its own row.
{/if}
</span>
<button class={primary} onclick={load} disabled={loading || !selectedIds.length}>
{loading ? 'Computing…' : 'Compute statistics'}
</button>
</div>
</section>
{#if failed.length}
<div class="{alertWarning} mt-4">
<p class="font-semibold">
{failed.length} event{failed.length === 1 ? '' : 's'} skipped — everything below excludes
{failed.length === 1 ? 'it' : 'them'}.
</p>
<ul class="mt-1 space-y-0.5 text-xs">
{#each failed as entry (entry.event.id)}
<li>{entry.event.name ?? `#${entry.event.id}`}{entry.message}</li>
{/each}
</ul>
</div>
{/if}
{#if stats}
<!--
Totals as plain figures, not charts: five unrelated counts have no shared
scale, and each one is a single number best read as a number.
-->
<section class="mt-6 grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-5">
{#each [['Events', stats.totals.events], ['Players', stats.totals.players], ['Brackets', stats.totals.brackets], ['Entries', stats.totals.entries], ['Points', stats.totals.points]] as const as [caption, value] (caption)}
<div class="{card} p-4">
<p class="text-xs tracking-wide text-muted uppercase">{caption}</p>
<p class="mt-1 text-2xl font-semibold tracking-tight tabular-nums">
{value.toLocaleString()}
</p>
</div>
{/each}
</section>
<section class="{card} mt-6">
<h2 class={cardHeading}>Unique players per event</h2>
<p class="mt-1 text-xs text-muted">
Oldest first. The same numbers are in the Events tab below.
</p>
<div class="mt-4">
<AttendanceChart data={stats.attendance} />
</div>
</section>
<section class="{card} mt-6">
<div class="flex flex-wrap items-center justify-between gap-3">
<div class="flex flex-wrap gap-2" role="tablist">
{#each tabs as [id, label] (id)}
<button
role="tab"
aria-selected={tab === id}
onclick={() => (tab = id)}
class={tab === id ? tabActiveClass : tabClass}
>
{label}
</button>
{/each}
</div>
<p class="text-xs text-muted">
{loadedIds.length} event{loadedIds.length === 1 ? '' : 's'} in scope
</p>
</div>
{#if tab === 'standings'}
<div class="mt-5 overflow-x-auto">
<table class="w-full min-w-max text-sm">
<thead class="text-left text-xs tracking-wide text-muted uppercase">
<tr class="border-b border-line">
<th class="py-2 pr-4 font-medium">#</th>
<th class="py-2 pr-4 font-medium">Player</th>
<th class="px-2 py-2 text-right font-medium">Points</th>
<th class="px-2 py-2 text-right font-medium">Events</th>
<th class="px-2 py-2 text-right font-medium">Entries</th>
<th class="px-2 py-2 text-right font-medium">Games</th>
<th class="px-2 py-2 text-right font-medium">1st</th>
<th class="px-2 py-2 text-right font-medium">2nd</th>
<th class="px-2 py-2 text-right font-medium">3rd</th>
<th class="py-2 pl-2 text-right font-medium">Best</th>
</tr>
</thead>
<tbody class="tabular-nums">
{#each standings as row, index (row.player)}
<tr class="border-b border-line/60 last:border-0">
<td class="py-1.5 pr-4 text-subtle">{index + 1}</td>
<td class="py-1.5 pr-4 font-medium">{row.player}</td>
<td class="px-2 py-1.5 text-right font-semibold">{row.points}</td>
<td class="px-2 py-1.5 text-right">{row.events}</td>
<td class="px-2 py-1.5 text-right">{row.entries}</td>
<td class="px-2 py-1.5 text-right">{row.games}</td>
<td class="px-2 py-1.5 text-right {row.firsts ? '' : 'text-subtle/60'}">
{row.firsts}
</td>
<td class="px-2 py-1.5 text-right {row.seconds ? '' : 'text-subtle/60'}">
{row.seconds}
</td>
<td class="px-2 py-1.5 text-right {row.thirds ? '' : 'text-subtle/60'}">
{row.thirds}
</td>
<td class="py-1.5 pl-2 text-right">{row.bestRank ?? '—'}</td>
</tr>
{/each}
</tbody>
</table>
</div>
<div class="mt-4 flex items-center justify-between gap-3">
<p class="text-xs text-muted">
{podium.length} of {standings.length} players reached a podium.
</p>
<button class={ghost} onclick={exportCsv} disabled={!standings.length}>
Export CSV
</button>
</div>
{:else if tab === 'games'}
<div class="mt-5 overflow-x-auto">
<table class="w-full min-w-max text-sm">
<thead class="text-left text-xs tracking-wide text-muted uppercase">
<tr class="border-b border-line">
<th class="py-2 pr-4 font-medium">Game</th>
<th class="px-2 py-2 text-right font-medium">Brackets</th>
<th class="px-2 py-2 text-right font-medium">Players</th>
<th class="px-2 py-2 text-right font-medium">Entries</th>
<th class="px-2 py-2 text-right font-medium">Avg field</th>
<th class="py-2 pl-2 font-medium">Top player</th>
</tr>
</thead>
<tbody class="tabular-nums">
{#each stats.games as game (game.gameId)}
<tr class="border-b border-line/60 last:border-0">
<td class="py-1.5 pr-4">
<span class="font-medium">{game.name}</span>
{#if game.longName && game.longName !== game.name}
<span class="ml-1 text-xs text-subtle">{game.longName}</span>
{/if}
</td>
<td class="px-2 py-1.5 text-right">{game.brackets}</td>
<td class="px-2 py-1.5 text-right">{game.players}</td>
<td class="px-2 py-1.5 text-right">{game.entries}</td>
<td class="px-2 py-1.5 text-right">{game.averageField.toFixed(1)}</td>
<td class="py-1.5 pl-2 tabular-nums">
{#if game.topPlayer}
{game.topPlayer}
<span class="text-subtle">· {game.topPoints} pts</span>
{:else}
<span class="text-subtle"></span>
{/if}
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{:else if tab === 'events'}
<div class="mt-5 overflow-x-auto">
<table class="w-full min-w-max text-sm">
<thead class="text-left text-xs tracking-wide text-muted uppercase">
<tr class="border-b border-line">
<th class="py-2 pr-4 font-medium">Event</th>
<th class="py-2 pr-4 font-medium">When</th>
<th class="px-2 py-2 text-right font-medium">Players</th>
<th class="px-2 py-2 text-right font-medium">Entries</th>
<th class="px-2 py-2 text-right font-medium">Games</th>
<th class="py-2 pl-2 text-right font-medium">Brackets</th>
</tr>
</thead>
<tbody class="tabular-nums">
{#each stats.attendance as entry (entry.eventId)}
<tr class="border-b border-line/60 last:border-0">
<td class="py-1.5 pr-4">{entry.name}</td>
<td class="py-1.5 pr-4 text-subtle">{formatMonth(entry.date) || '—'}</td>
<td class="px-2 py-1.5 text-right font-semibold">{entry.players}</td>
<td class="px-2 py-1.5 text-right">{entry.entries}</td>
<td class="px-2 py-1.5 text-right">{entry.games}</td>
<td class="py-1.5 pl-2 text-right">{entry.brackets}</td>
</tr>
{/each}
</tbody>
</table>
</div>
{:else if matches}
{@const coverage = matches.coverage}
<!--
Coverage first, deliberately: brackets imported before set rows were
persisted contribute placements but no matches, so these numbers can
describe a fraction of the scope. Reading them without that line is
how "nobody played SF6" gets believed.
-->
<p class="mt-5 text-xs text-muted">
{coverage?.bracketsWithSets ?? 0} of {coverage?.brackets ?? 0} brackets in scope have
match data — {coverage?.decidedSets ?? 0} decided sets out of {coverage?.sets ?? 0}
recorded. Anything imported without sets is invisible here but still counted in the
standings above.
</p>
{#if !players.length}
<p class="mt-4 text-sm text-subtle">
No decided set in this scope. Re-import an event to populate its matches.
</p>
{:else}
<div class="mt-5 grid gap-6 lg:grid-cols-2">
<div>
<h3 class={cardHeading}>Set win rate</h3>
<div class="mt-3 overflow-x-auto">
<table class="w-full min-w-max text-sm">
<thead class="text-left text-xs tracking-wide text-muted uppercase">
<tr class="border-b border-line">
<th class="py-2 pr-4 font-medium">Player</th>
<th class="px-2 py-2 text-right font-medium">Sets</th>
<th class="px-2 py-2 text-right font-medium">W</th>
<th class="px-2 py-2 text-right font-medium">L</th>
<th class="px-2 py-2 text-right font-medium">Games</th>
<th class="py-2 pl-2 text-right font-medium">Win rate</th>
</tr>
</thead>
<tbody class="tabular-nums">
{#each players as row (row.playerId)}
<tr class="border-b border-line/60 last:border-0">
<td class="py-1.5 pr-4 font-medium">{row.player}</td>
<td class="px-2 py-1.5 text-right">{row.sets}</td>
<td class="px-2 py-1.5 text-right">{row.wins}</td>
<td class="px-2 py-1.5 text-right">{row.losses}</td>
<td class="px-2 py-1.5 text-right text-subtle">
{row.gamesWon}{row.gamesLost}
</td>
<td class="py-1.5 pl-2 text-right font-semibold">
{percent(winRate(row))}
</td>
</tr>
{/each}
</tbody>
</table>
</div>
</div>
<div>
<h3 class={cardHeading}>Head to head</h3>
<p class="mt-1 text-xs text-muted">Most-played pairings first.</p>
<ul class="mt-3 max-h-96 space-y-2 overflow-y-auto pr-1 text-sm">
{#each headToHead as row (`${row.playerAId}-${row.playerBId}`)}
{@const total = played(row)}
{@const shareA = total === 0 ? 0 : ((row.winsA ?? 0) / total) * 100}
<li class="border-b border-line/60 pb-2 last:border-0">
<div class="flex items-baseline justify-between gap-3">
<span class="truncate">
<span class={(row.winsA ?? 0) >= (row.winsB ?? 0) ? 'font-semibold' : ''}>
{row.playerA}
</span>
<span class="text-subtle">vs</span>
<span class={(row.winsB ?? 0) > (row.winsA ?? 0) ? 'font-semibold' : ''}>
{row.playerB}
</span>
</span>
<span class="shrink-0 font-semibold tabular-nums">
{row.winsA}{row.winsB}
</span>
</div>
<!--
A share meter, not a two-series bar: one measure (the first
player's share of the pairing) painted in the accent over an
inset track, so no second data hue is introduced. The score
beside it carries the same numbers for anyone who can't see it.
-->
<div
class="mt-1.5 h-1.5 overflow-hidden rounded-full bg-inset"
role="img"
aria-label="{row.playerA} won {row.winsA} of {total} sets against {row.playerB}"
>
<div class="h-full rounded-full bg-accent" style="width: {shareA}%"></div>
</div>
</li>
{:else}
<li class="py-6 text-center text-subtle">
No pairing met twice in this scope.
</li>
{/each}
</ul>
</div>
</div>
{/if}
{:else}
<p class="mt-5 text-sm text-subtle">
Match statistics are unavailable for this scope — the endpoint could not be reached.
</p>
{/if}
</section>
{/if}
</main>
@@ -0,0 +1,422 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { toErrorMessage } from '$lib/api/errors';
import type { EventDTO, TournamentsResultDTO } from '$lib/api/schema-helpers';
import { getResults, importSmashTournament, listEvents } from '$lib/api/tournaments';
import { session } from '$lib/stores/session.svelte';
import {
buildCsv,
buildHtml,
buildRanking,
playedGames,
resultsForGame
} from '$lib/tournaments/results';
import {
alertError,
alertNotice,
card,
cardHeading,
field,
ghost,
listRow,
listRowSelected,
primary,
// aliased: `tab` is already the name of this page's selected-panel state
tab as tabClass,
tabActive as tabActiveClass
} from '$lib/ui/classes';
let events = $state<EventDTO[]>([]);
let selectedIds = $state<number[]>([]);
let slug = $state('');
let pattern = $state('');
let results = $state<TournamentsResultDTO | null>(null);
let tab = $state<'ranking' | 'game' | 'html'>('ranking');
let selectedGameId = $state<number | null>(null);
let loadingEvents = $state(false);
let importing = $state(false);
let generating = $state(false);
let notice = $state<string | null>(null);
let error = $state<string | null>(null);
let copied = $state(false);
const ranking = $derived(buildRanking(results));
const games = $derived(playedGames(results));
const gameResults = $derived(resultsForGame(results, selectedGameId));
const html = $derived(buildHtml(results));
const selectedGame = $derived(games.find((g) => g.id === selectedGameId) ?? null);
let started = false;
$effect(() => {
if (!session.isLoggedIn) {
goto('/login', { replaceState: true });
return;
}
if (!started) {
started = true;
void refreshEvents();
}
});
function report(cause: unknown, fallback: string) {
error = toErrorMessage(cause, fallback);
}
async function refreshEvents() {
loadingEvents = true;
error = null;
try {
events = await listEvents();
} catch (cause) {
report(cause, 'Could not load the event list.');
} finally {
loadingEvents = false;
}
}
async function importSlug() {
const value = slug.trim();
if (value === '' || importing) return;
importing = true;
error = null;
notice = null;
try {
const imported = await importSmashTournament(value);
if (!imported) {
error = `start.gg returned nothing for "${value}".`;
return;
}
notice = `Imported "${value}".`;
slug = '';
await refreshEvents();
} catch (cause) {
// ParseSmash throws when a bracket is still running or the slug is unknown.
report(
cause,
`Could not import "${value}". Check the slug, and that every bracket is finished.`
);
} finally {
importing = false;
}
}
function toggle(id: number | undefined) {
if (id === undefined) return;
selectedIds = selectedIds.includes(id)
? selectedIds.filter((selected) => selected !== id)
: [...selectedIds, id];
}
/** Replaces the selection with every event whose name matches the regex. */
function selectMatching() {
const value = pattern.trim();
if (value === '') return;
let regex: RegExp;
try {
regex = new RegExp(value);
} catch {
error = `"${value}" is not a valid regular expression.`;
return;
}
error = null;
selectedIds = events
.filter((e) => e.id !== undefined && e.name && regex.test(e.name))
.map((e) => e.id as number);
if (selectedIds.length === 0) notice = `No event name matches ${value}.`;
}
async function generate() {
if (selectedIds.length === 0 || generating) return;
generating = true;
error = null;
notice = null;
try {
results = await getResults(selectedIds);
selectedGameId = playedGames(results)[0]?.id ?? null;
tab = 'ranking';
} catch (cause) {
report(cause, 'Could not compute the results for this selection.');
} finally {
generating = false;
}
}
async function copyHtml() {
try {
await navigator.clipboard.writeText(html);
copied = true;
setTimeout(() => (copied = false), 2000);
} catch {
error = 'The browser refused clipboard access — select the HTML and copy it manually.';
}
}
function exportCsv() {
const blob = new Blob([buildCsv(ranking)], { type: 'text/csv;charset=utf-8' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = `ladose-results-${selectedIds.join('-')}.csv`;
link.click();
URL.revokeObjectURL(url);
}
</script>
<svelte:head>
<title>Tournaments · LaDOSE</title>
</svelte:head>
<main id="main" class="mx-auto max-w-6xl px-4 py-10">
<header class="mb-8">
<h1 class="text-3xl font-semibold tracking-tight">Tournaments</h1>
<p class="mt-1 text-sm text-muted">
Import a start.gg tournament, then score one event or a whole ranking season.
</p>
</header>
{#if error}
<p role="alert" class="{alertError} mb-4">
{error}
</p>
{/if}
{#if notice}
<p class="{alertNotice} mb-4">{notice}</p>
{/if}
<div class="grid gap-6 lg:grid-cols-2">
<section class={card}>
<h2 class={cardHeading}>
Import from start.gg
</h2>
<p class="mt-1 text-xs text-muted">
The slug is the tail of the tournament URL —
<code class="text-ink">start.gg/tournament/<b>ranking-130</b></code>. Every bracket
must be finished.
</p>
<form
class="mt-4 flex gap-2"
onsubmit={(event) => {
event.preventDefault();
void importSlug();
}}
>
<input
bind:value={slug}
disabled={importing}
class={field}
placeholder="ranking-130"
aria-label="start.gg tournament slug"
/>
<button type="submit" class={primary} disabled={importing || slug.trim() === ''}>
{importing ? 'Importing…' : 'Import'}
</button>
</form>
</section>
<section class={card}>
<div class="flex items-baseline justify-between gap-3">
<h2 class={cardHeading}>
Events
<span class="ml-1 font-normal text-muted normal-case">({events.length})</span>
</h2>
<button class={ghost} onclick={refreshEvents} disabled={loadingEvents}>
{loadingEvents ? 'Loading…' : 'Refresh'}
</button>
</div>
<div class="mt-4 flex gap-2">
<input
bind:value={pattern}
class={field}
placeholder="Ranking #13\d{'{'}2{'}'}"
aria-label="Regular expression matching event names"
/>
<button class={ghost} onclick={selectMatching} disabled={pattern.trim() === ''}>
Select
</button>
</div>
<ul class="mt-4 max-h-72 space-y-1 overflow-y-auto pr-1 text-sm">
{#each events as event (event.id)}
<li>
<label
class="flex cursor-pointer items-center gap-3 rounded-lg px-2 py-1.5 transition hover:bg-ink/5"
>
<input
type="checkbox"
checked={event.id !== undefined && selectedIds.includes(event.id)}
onchange={() => toggle(event.id)}
class="size-4 accent-accent"
/>
<span class="w-12 shrink-0 text-right text-xs text-subtle">{event.id}</span>
<span class="truncate">{event.name}</span>
</label>
</li>
{:else}
<li class="px-2 py-6 text-center text-subtle">
{loadingEvents ? 'Loading events…' : 'No event imported yet.'}
</li>
{/each}
</ul>
<div class="mt-4 flex items-center justify-between gap-3 border-t border-line pt-4">
<span class="text-xs text-muted">
{selectedIds.length} selected
{#if selectedIds.length > 1}· bracket links need a single event{/if}
</span>
<div class="flex gap-2">
<button
class={ghost}
onclick={() => (selectedIds = [])}
disabled={selectedIds.length === 0}>Clear</button
>
<button class={primary} onclick={generate} disabled={generating || !selectedIds.length}>
{generating ? 'Computing…' : 'Generate results'}
</button>
</div>
</div>
</section>
</div>
{#if results}
<section class="{card} mt-6">
<div class="flex flex-wrap items-center justify-between gap-3">
<div class="flex gap-2" role="tablist">
{#each [['ranking', 'Ranking'], ['game', 'By game'], ['html', 'HTML']] as const as [id, label] (id)}
<button
role="tab"
aria-selected={tab === id}
onclick={() => (tab = id)}
class={tab === id ? tabActiveClass : tabClass}
>
{label}
</button>
{/each}
</div>
<p class="text-xs text-muted">
{ranking.rows.length} players · {games.length} games · {results.results?.length ?? 0} placements
</p>
</div>
{#if tab === 'ranking'}
<div class="mt-5 overflow-x-auto">
<table class="w-full min-w-max text-sm">
<thead class="text-left text-xs tracking-wide text-muted uppercase">
<tr class="border-b border-line">
<th class="py-2 pr-4 font-medium">#</th>
<th class="py-2 pr-4 font-medium">Player</th>
{#each ranking.games as game (game.id)}
<th class="px-2 py-2 text-right font-medium">{game.name}</th>
{/each}
<th class="py-2 pl-2 text-right font-medium">Total</th>
</tr>
</thead>
<tbody>
{#each ranking.rows as row, index (row.player)}
<tr class="border-b border-line/60 last:border-0">
<td class="py-1.5 pr-4 text-subtle">{index + 1}</td>
<td class="py-1.5 pr-4">{row.player}</td>
{#each row.points as point, i (ranking.games[i].id)}
<td class="px-2 py-1.5 text-right {point ? '' : 'text-subtle/60'}">
{point}
</td>
{/each}
<td class="py-1.5 pl-2 text-right font-semibold">{row.total}</td>
</tr>
{/each}
</tbody>
</table>
</div>
<button class="{ghost} mt-4" onclick={exportCsv} disabled={!ranking.rows.length}>
Export CSV
</button>
{:else if tab === 'game'}
<div class="mt-5 grid gap-5 sm:grid-cols-[14rem_1fr]">
<ul class="max-h-80 space-y-1 overflow-y-auto pr-1 text-sm">
{#each games as game (game.id)}
<li>
<button
onclick={() => (selectedGameId = game.id ?? null)}
class="{selectedGameId === game.id ? listRowSelected : listRow} truncate"
>
{game.name}
</button>
</li>
{/each}
</ul>
<div>
{#if selectedGame}
<h3 class="text-sm font-semibold">
{selectedGame.longName ?? selectedGame.name}
<span class="ml-1 font-normal text-muted">
({gameResults.length} participants)
</span>
</h3>
<ol class="mt-3 space-y-1 text-sm">
{#each gameResults as result (result.player)}
<li class="flex items-center gap-3 border-b border-line/60 py-1.5 last:border-0">
<span class="w-10 shrink-0 text-right text-subtle">
{result.rank === 999 ? '—' : result.rank}
</span>
<span class="grow truncate">{result.player}</span>
<span class="shrink-0 font-semibold">{result.point} pts</span>
</li>
{/each}
</ol>
{:else}
<p class="text-sm text-subtle">Pick a game to see its placements.</p>
{/if}
</div>
</div>
{:else}
<div class="mt-5">
<div class="flex flex-wrap items-center justify-between gap-3">
<p class="text-xs text-muted">
{#if results.slug}
Bracket links point at start.gg/tournament/{results.slug}.
{:else}
Bracket links are omitted: the API only returns the slug for a single event.
{/if}
</p>
<button class={ghost} onclick={copyHtml}>{copied ? 'Copied' : 'Copy HTML'}</button>
</div>
<textarea
readonly
value={html}
class="{field} mt-3 h-64 resize-y font-mono text-xs"
aria-label="Generated HTML"
></textarea>
<!--
Deliberately pinned to a fixed dark surface, NOT the app theme: this is a
preview of how the markup will look on the (dark) WordPress site, and
buildHtml emits an inline `color: #ff0000` heading that no token can reach
and that would be unreadable on a light background.
-->
<div class="mt-4 rounded-xl border border-ladose-900 bg-ladose-950 p-4">
<p class="mb-2 text-xs tracking-wide text-ladose-200/50 uppercase">
Preview <span class="normal-case">(as it appears on ladose.net)</span>
</p>
<!--
Safe to inject: the markup is built by buildHtml from this response, and
every value taken from start.gg (player and game names) is escaped there.
The Bootstrap classes it carries are for WordPress, not styled here.
-->
<div
class="text-sm text-ladose-50 [&_a]:text-ladose-400 [&_a]:underline [&_table]:w-full [&_td]:py-2 [&_td]:pr-4 [&_td]:align-top"
>
{@html html}
</div>
</div>
</div>
{/if}
</section>
{/if}
</main>
@@ -0,0 +1,293 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { addUser, deleteUser, listRoles, listUsers } from '$lib/api/admin-users';
import { toErrorMessage } from '$lib/api/errors';
import type { ApplicationUserDTO } from '$lib/api/schema-helpers';
import { session } from '$lib/stores/session.svelte';
import { alertError, alertNotice, card, cardHeading, danger, field, ghost, label, primary } from '$lib/ui/classes';
let users = $state<ApplicationUserDTO[]>([]);
let roles = $state<string[]>([]);
let username = $state('');
let password = $state('');
let firstName = $state('');
let lastName = $state('');
let selectedRoles = $state<string[]>([]);
let loading = $state(false);
let creating = $state(false);
let deletingId = $state<number | null>(null);
let notice = $state<string | null>(null);
let error = $state<string | null>(null);
const canCreate = $derived(username.trim() !== '' && password !== '' && !creating);
let started = false;
$effect(() => {
// The page is admin-only on the server too; this just avoids showing a shell that
// can only produce 403s.
if (!session.isLoggedIn) {
goto('/login', { replaceState: true });
return;
}
if (!session.isAdmin) {
goto('/', { replaceState: true });
return;
}
if (!started) {
started = true;
void refresh();
}
});
function report(cause: unknown, fallback: string) {
error = toErrorMessage(cause, fallback);
}
async function refresh() {
loading = true;
error = null;
try {
// Roles are reference data; fetch them alongside the list on first load.
[users, roles] = await Promise.all([listUsers(), listRoles()]);
} catch (cause) {
report(cause, 'Could not load the accounts.');
} finally {
loading = false;
}
}
function toggleRole(role: string) {
selectedRoles = selectedRoles.includes(role)
? selectedRoles.filter((selected) => selected !== role)
: [...selectedRoles, role];
}
async function create() {
if (!canCreate) return;
creating = true;
error = null;
notice = null;
const name = username.trim();
try {
await addUser({
username: name,
password,
firstName: firstName.trim() || null,
lastName: lastName.trim() || null,
roles: selectedRoles
});
notice = `Created "${name}".`;
username = '';
password = '';
firstName = '';
lastName = '';
selectedRoles = [];
await refresh();
} catch (cause) {
// A 400 carries the reason: username taken, missing password, unknown role.
report(cause, `Could not create "${name}".`);
} finally {
creating = false;
}
}
async function remove(user: ApplicationUserDTO) {
if (user.id === undefined || deletingId !== null) return;
if (!confirm(`Delete "${user.username}"? This cannot be undone.`)) return;
deletingId = user.id;
error = null;
notice = null;
try {
await deleteUser(user.id);
notice = `Deleted "${user.username}".`;
await refresh();
} catch (cause) {
report(cause, `Could not delete "${user.username}".`);
} finally {
deletingId = null;
}
}
function fullName(user: ApplicationUserDTO): string {
return [user.firstName, user.lastName].filter(Boolean).join(' ').trim();
}
</script>
<svelte:head>
<title>Users · LaDOSE</title>
</svelte:head>
<main id="main" class="mx-auto max-w-5xl px-4 py-10">
<header class="mb-8">
<h1 class="text-3xl font-semibold tracking-tight">Users</h1>
<p class="mt-1 text-sm text-muted">
Accounts that can sign in. Admins may also manage this list.
</p>
</header>
{#if error}
<p role="alert" class="{alertError} mb-4">
{error}
</p>
{/if}
{#if notice}
<p class="{alertNotice} mb-4">{notice}</p>
{/if}
<section class={card}>
<div class="flex items-baseline justify-between gap-3">
<h2 class={cardHeading}>
Accounts
<span class="ml-1 font-normal text-muted normal-case">({users.length})</span>
</h2>
<button class={ghost} onclick={refresh} disabled={loading}>
{loading ? 'Loading…' : 'Refresh'}
</button>
</div>
<div class="mt-4 overflow-x-auto">
<table class="w-full min-w-max text-sm">
<thead class="text-left text-xs tracking-wide text-muted uppercase">
<tr class="border-b border-line">
<th class="py-2 pr-4 font-medium">Username</th>
<th class="py-2 pr-4 font-medium">Name</th>
<th class="py-2 pr-4 font-medium">Roles</th>
<th class="py-2 pl-2"></th>
</tr>
</thead>
<tbody>
{#each users as user (user.id)}
<tr class="border-b border-line/60 last:border-0">
<td class="py-2 pr-4 font-medium">
{user.username}
{#if user.id === session.user?.id}
<span class="ml-1 text-xs font-normal text-subtle">(you)</span>
{/if}
</td>
<td class="py-2 pr-4 text-muted">{fullName(user) || '—'}</td>
<td class="py-2 pr-4">
{#if user.roles?.length}
{#each user.roles as role (role)}
<span
class="mr-1 rounded-full px-2 py-0.5 text-xs {role.toLowerCase() === 'admin'
? 'bg-accent/20 text-accent'
: 'bg-ink/5 text-muted'}"
>
{role}
</span>
{/each}
{:else}
<span class="text-subtle">none</span>
{/if}
</td>
<td class="py-2 pl-2 text-right">
{#if user.id === session.user?.id}
<!-- The API refuses this, which is what keeps an admin in place. -->
<span class="text-xs text-subtle">can't delete yourself</span>
{:else}
<button
class={danger}
onclick={() => remove(user)}
disabled={deletingId !== null}
>
{deletingId === user.id ? 'Deleting…' : 'Delete'}
</button>
{/if}
</td>
</tr>
{:else}
<tr>
<td colspan="4" class="py-6 text-center text-subtle">
{loading ? 'Loading…' : 'No account.'}
</td>
</tr>
{/each}
</tbody>
</table>
</div>
</section>
<section class="{card} mt-6">
<h2 class={cardHeading}>Add a user</h2>
<form
class="mt-5 grid gap-4 sm:grid-cols-2"
onsubmit={(event) => {
event.preventDefault();
void create();
}}
>
<div class="space-y-1.5">
<label class={label} for="new-username">Username</label>
<input
id="new-username"
bind:value={username}
class={field}
autocomplete="off"
required
/>
</div>
<div class="space-y-1.5">
<label class={label} for="new-password">Password</label>
<input
id="new-password"
type="password"
bind:value={password}
class={field}
autocomplete="new-password"
required
/>
</div>
<div class="space-y-1.5">
<label class={label} for="new-firstname">First name</label>
<input id="new-firstname" bind:value={firstName} class={field} autocomplete="off" />
</div>
<div class="space-y-1.5">
<label class={label} for="new-lastname">Last name</label>
<input id="new-lastname" bind:value={lastName} class={field} autocomplete="off" />
</div>
<fieldset class="space-y-2 sm:col-span-2">
<legend class={label}>Roles</legend>
{#if roles.length}
<div class="flex flex-wrap gap-3">
{#each roles as role (role)}
<label
class="flex cursor-pointer items-center gap-2 rounded-lg border border-line px-3 py-1.5 text-sm transition hover:bg-ink/5"
>
<input
type="checkbox"
checked={selectedRoles.includes(role)}
onchange={() => toggleRole(role)}
class="size-4 accent-accent"
/>
{role}
</label>
{/each}
</div>
<p class="text-xs text-subtle">
No role still signs in and uses everything else — only this page needs Admin.
</p>
{:else}
<p class="text-xs text-warning">
No role exists in the database yet. Run <code>Sql/2026-08-05_roles.sql</code> to seed
Admin and User.
</p>
{/if}
</fieldset>
<div class="border-t border-line pt-4 sm:col-span-2">
<button type="submit" class={primary} disabled={!canCreate}>
{creating ? 'Creating…' : 'Create user'}
</button>
</div>
</form>
</section>
</main>
@@ -0,0 +1,8 @@
// Default runtime configuration. Empty on purpose: with no override the API
// client falls back to VITE_API_BASE_URL and then to http://localhost:5000.
//
// The container entrypoint replaces this file at start-up from LADOSE_API_BASE_URL
// (see Dockerfile / docker-entrypoint.sh), which is what lets one image serve
// several environments without a rebuild. Shipping it means `vite dev` does not
// 404 on the <script> tag in src/app.html.
window.__LADOSE_CONFIG__ = {};
@@ -0,0 +1,3 @@
# allow crawling everything by default
User-agent: *
Disallow:
+20
View File
@@ -0,0 +1,20 @@
{
"extends": "./.svelte-kit/tsconfig.json",
"compilerOptions": {
"rewriteRelativeImportExtensions": true,
"allowJs": true,
"checkJs": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"sourceMap": true,
"strict": true,
"moduleResolution": "bundler"
}
// Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias
// except $lib which is handled by https://svelte.dev/docs/kit/configuration#files
//
// To make changes to top-level options such as include and exclude, we recommend extending
// the generated config; see https://svelte.dev/docs/kit/configuration#typescript
}
+21
View File
@@ -0,0 +1,21 @@
import adapter from '@sveltejs/adapter-static';
import { sveltekit } from '@sveltejs/kit/vite';
import tailwindcss from '@tailwindcss/vite';
import { defineConfig } from 'vite';
export default defineConfig({
plugins: [
tailwindcss(),
sveltekit({
compilerOptions: {
// Force runes mode for the project, except for libraries. Can be removed in svelte 6.
runes: ({ filename }) =>
filename.split(/[/\\]/).includes('node_modules') ? undefined : true
},
// This app is a pure client for LaDOSE.Api, so it ships as a static SPA.
// `fallback` hands every unknown path to the client-side router.
adapter: adapter({ fallback: 'index.html' })
})
]
});
+1 -1
View File
@@ -2,7 +2,7 @@
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<LangVersion>12</LangVersion>
+3 -7
View File
@@ -4,17 +4,13 @@
## Server
.Net Core 6
.Net Core 8
PostgreSQL
## Desktop
.Net Framework 4.6.1
Caliburn Micro
## Cross Plateform Desktop Client
.Net Core 6
.Net Core 8
Avalonia
## Challonge Provider is a modified version of this
+58
View File
@@ -0,0 +1,58 @@
-- Roles for the API's user management (2026-08-05)
--
-- `POST /Users/register` used to be anonymous so that the first account could be
-- created. It is now `POST /Users/AddUser` and requires the Admin role, so the first
-- admin has to be granted here, by hand.
--
-- No DDL is needed: applicationrole and applicationuserrole already exist and match
-- what Entity Framework now expects (userid, roleid, no surrogate key). The blocks
-- below are safe to re-run.
--
-- psql "$LADOSE_DB" -v ON_ERROR_STOP=1 -f Sql/2026-08-05_roles.sql
SET search_path TO ladoseapi;
BEGIN;
-- 1. Reference data. These two names are the ones the API knows about
-- (LaDOSE.Entity/Roles.cs); only 'Admin' grants anything today.
INSERT INTO applicationrole (name)
SELECT 'Admin'
WHERE NOT EXISTS (SELECT 1 FROM applicationrole WHERE lower(name) = 'admin');
INSERT INTO applicationrole (name)
SELECT 'User'
WHERE NOT EXISTS (SELECT 1 FROM applicationrole WHERE lower(name) = 'user');
-- 2. Grant Admin to the first administrator.
-- >>> Replace 'CHANGEME' with your username before running. <<<
-- Existing accounts hold no role until you do this, and a role-less account can
-- still use every other endpoint — only user management is restricted.
INSERT INTO applicationuserrole (userid, roleid)
SELECT u.id, r.id
FROM applicationuser u
CROSS JOIN applicationrole r
WHERE u.username = 'CHANGEME'
AND lower(r.name) = 'admin'
AND NOT EXISTS (
SELECT 1 FROM applicationuserrole ur
WHERE ur.userid = u.id AND ur.roleid = r.id
);
COMMIT;
-- Who is an admin now?
SELECT u.id, u.username, r.name AS role
FROM applicationuser u
LEFT JOIN applicationuserrole ur ON ur.userid = u.id
LEFT JOIN applicationrole r ON r.id = ur.roleid
ORDER BY u.username, r.name;
-- To take Admin away from someone:
-- DELETE FROM applicationuserrole ur
-- USING applicationuser u, applicationrole r
-- WHERE ur.userid = u.id AND ur.roleid = r.id
-- AND u.username = 'CHANGEME' AND lower(r.name) = 'admin';
--
-- The API refuses to delete the last remaining admin, so there is always a way back
-- in through the app itself.
+80
View File
@@ -0,0 +1,80 @@
# Local development stack: LaDOSE.Api + LaDOSE.WebApp, built from the two Dockerfiles
# already in the tree.
#
# docker compose up --build start both, rebuilding when a Dockerfile changed
# docker compose watch same, plus rebuild the service you are editing
# docker compose logs -f api follow the API
# docker compose down stop
#
# Then: webapp on http://localhost:8080, API on http://localhost:5000,
# Scalar API reference on http://localhost:5000/scalar.
#
# The database is NOT part of this stack — it stays wherever appsettings.json points.
# Copy .env.example to .env to change the ports, the connection string or the API keys.
name: ladose
services:
api:
build:
context: ./LaDOSE.Src
dockerfile: Dockerfile
args:
# Debug, not Release: /openapi/v1.json and /scalar are compiled out of a
# Release build. See the comment in LaDOSE.Src/Dockerfile.
BUILD_CONFIGURATION: Debug
environment:
# Startup.cs gates the developer exception page, MapOpenApi() and
# MapScalarApiReference() on IsDevelopment().
ASPNETCORE_ENVIRONMENT: Development
# Double underscore is the .NET section separator, so this overrides
# ConnectionStrings:DbContext from appsettings.json. Left unset in .env, the
# default below reproduces what is committed there.
ConnectionStrings__DbContext: ${LADOSE_DB_CONNECTION:-Host=kafka.local;Username=tom;Password=tom;Database=ladoseapi}
# appsettings.json ships placeholders for these three. Real values belong in
# .env, which git ignores.
JWTTokenSecret: ${LADOSE_JWT_SECRET:-dev-only-secret-not-for-any-deployed-environment}
ApiKey__SmashApiKey: ${LADOSE_SMASH_API_KEY:-}
ApiKey__ChallongeApiKey: ${LADOSE_CHALLONGE_API_KEY:-}
ports:
# Container side is pinned at 5000: Program.cs reads AllowedHosts/Port straight
# from appsettings.json, through a ConfigurationBuilder that ignores env vars.
- "${LADOSE_API_PORT:-5000}:5000"
extra_hosts:
# Lets LADOSE_DB_CONNECTION use Host=host.docker.internal to reach a Postgres
# running on the machine hosting the containers.
- "host.docker.internal:host-gateway"
develop:
watch:
- action: rebuild
path: ./LaDOSE.Src
ignore:
- LaDOSE.WebApp/
- "**/bin/"
- "**/obj/"
web:
build:
context: ./LaDOSE.Src/LaDOSE.WebApp
dockerfile: Dockerfile
# VITE_API_BASE_URL is deliberately not passed. Vite would inline it at build
# time; LADOSE_API_BASE_URL below is read at container start instead, so the
# port can change without rebuilding the image.
environment:
# docker-entrypoint.sh turns this into /config.js. It is resolved by the
# *browser*, so it must be a host-visible URL — not http://api:5000.
LADOSE_API_BASE_URL: ${LADOSE_API_BASE_URL:-http://localhost:${LADOSE_API_PORT:-5000}}
ports:
- "${LADOSE_WEB_PORT:-8080}:80"
depends_on:
# Ordering only. The SPA is served by nginx and talks to the API from the
# browser, so it comes up fine on its own; this just avoids a confusing
# first-load failure when starting both at once.
- api
develop:
watch:
- action: rebuild
path: ./LaDOSE.Src/LaDOSE.WebApp
ignore:
- node_modules/
- build/
- .svelte-kit/