10 KiB
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:
nvm use # resolves lts/*
npm install
Running
The API must be up — it serves on http://localhost:5000 in development:
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:
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:
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 toPOST /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 AvaloniaTournamentResultView/statistiques— guarded; standings, attendance and match statistics over a chosen scope/games— guarded; the game catalogue editor, from the AvaloniaGamesView/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:
- Import — a slug (
start.gg/tournament/<slug>) is sent toGET /api/Tournament/ParseSmash/{slug}, which pulls the brackets, placements and sets into the database. Every bracket must beCOMPLETEDor the API throws. - 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}). - Generate results —
POST /api/Tournament/GetResultswith the selected ids; the API applies the point rules inExternalProviderService. 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
999is the service's "unplaced" sentinel (the participation bucket inExternalProviderService), 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/Eventreturns newest first, which is the least misleading place to put them. aggregate.tsis pure, so it can be exercised under plain Node with fixtures, the same waysrc/lib/tournaments/results.tsis.
/games
Ports GamesView: the list on the left (ordered by Order), an editor on the right.
- Save —
POST /api/Game.AddOrUpdateinserts whenidis 0 and otherwise replaces every column, so the form always sends a completeGameDTO; blank text fields are sent asnull. - New game — starts an empty draft with
id0 and the next freeOrder. 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 fillssmashId. 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:
# 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/applicationuserroletables. The script seedsAdminandUser; 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
(
OnTokenValidatedinStartup.cs), so granting or revoking Admin applies to the caller's next request instead of whenever their 16-minute token expires. session.isAdminhides 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(seetoErrorMessage). - The API has no exception middleware, so an unhandled server error arrives as an
HTML developer page.
ApiErrorthen carries only the status, which is why each call site supplies its own fallback message. GetResultsonly fillsslugwhen 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.tssetsssr = false: the JWT lives in the browser, so there is nothing meaningful to render on the server.
Checks
npm run check # svelte-check (types + template diagnostics)
npm run build # static build into ./build