This commit is contained in:
@@ -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
|
||||
```
|
||||
Reference in New Issue
Block a user