Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c9a3c252e1 | ||
|
|
a9860f4c94 | ||
|
|
d9e05fb487 | ||
|
|
e10663c8c0 | ||
|
|
cd03b39c20 | ||
|
|
9d8a7b3100 | ||
|
|
e99479d8fb |
@@ -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
|
||||
@@ -4,7 +4,7 @@ on: [push]
|
||||
|
||||
jobs:
|
||||
Build:
|
||||
runs-on: ubuntu-latest-real
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Update
|
||||
run: |
|
||||
@@ -54,10 +54,15 @@ jobs:
|
||||
retention-days: 30
|
||||
overwrite: true
|
||||
|
||||
- name: Get current date
|
||||
id: date
|
||||
run: echo "date=$(echo $(date +'%Y-%m-%d'))" >> $GITHUB_OUTPUT
|
||||
- name: Release
|
||||
if: github.ref_name == 'master'
|
||||
uses: akkuman/gitea-release-action@v1
|
||||
env:
|
||||
with:
|
||||
tag_name: release-${{ steps.date.outputs.date }}
|
||||
files: |-
|
||||
build-winx64.zip
|
||||
build-linux64.zip
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
@@ -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" />
|
||||
|
||||
@@ -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
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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,7 +1,7 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
<Platforms>AnyCPU;x64</Platforms>
|
||||
</PropertyGroup>
|
||||
|
||||
|
||||
@@ -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 "#<id>".</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());
|
||||
// }
|
||||
// }
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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 "#<id>".</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,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 "#<id>" (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,13 +153,19 @@ namespace LaDOSE.Business.Service
|
||||
|
||||
public void Delete(int id)
|
||||
{
|
||||
var user = _context.ApplicationUser.Find(id);
|
||||
if (user != null)
|
||||
{
|
||||
// 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
|
||||
|
||||
|
||||
@@ -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-*
|
||||
@@ -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
|
||||
@@ -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-*
|
||||
@@ -0,0 +1 @@
|
||||
engine-strict=true
|
||||
@@ -0,0 +1 @@
|
||||
lts/*
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"recommendations": ["svelte.svelte-vscode"]
|
||||
}
|
||||
@@ -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;"]
|
||||
@@ -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 "$@"
|
||||
@@ -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
+2308
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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 {};
|
||||
@@ -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;
|
||||
+1498
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;
|
||||
}
|
||||
@@ -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('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"');
|
||||
}
|
||||
|
||||
/**
|
||||
* 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:
|
||||
@@ -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
|
||||
}
|
||||
@@ -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' })
|
||||
})
|
||||
]
|
||||
});
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -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/
|
||||
Reference in New Issue
Block a user