diff --git a/LaDOSE.Src/LaDOSE.Api/appsettings.json b/LaDOSE.Src/LaDOSE.Api/appsettings.json index 9175c1e..1b8ab61 100644 --- a/LaDOSE.Src/LaDOSE.Api/appsettings.json +++ b/LaDOSE.Src/LaDOSE.Api/appsettings.json @@ -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" diff --git a/LaDOSE.Src/LaDOSE.WebApp/.dockerignore b/LaDOSE.Src/LaDOSE.WebApp/.dockerignore new file mode 100644 index 0000000..bbc4c48 --- /dev/null +++ b/LaDOSE.Src/LaDOSE.WebApp/.dockerignore @@ -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-* diff --git a/LaDOSE.Src/LaDOSE.WebApp/.env.example b/LaDOSE.Src/LaDOSE.WebApp/.env.example new file mode 100644 index 0000000..37d479e --- /dev/null +++ b/LaDOSE.Src/LaDOSE.WebApp/.env.example @@ -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 diff --git a/LaDOSE.Src/LaDOSE.WebApp/.gitignore b/LaDOSE.Src/LaDOSE.WebApp/.gitignore new file mode 100644 index 0000000..3b462cb --- /dev/null +++ b/LaDOSE.Src/LaDOSE.WebApp/.gitignore @@ -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-* diff --git a/LaDOSE.Src/LaDOSE.WebApp/.npmrc b/LaDOSE.Src/LaDOSE.WebApp/.npmrc new file mode 100644 index 0000000..b6f27f1 --- /dev/null +++ b/LaDOSE.Src/LaDOSE.WebApp/.npmrc @@ -0,0 +1 @@ +engine-strict=true diff --git a/LaDOSE.Src/LaDOSE.WebApp/.nvmrc b/LaDOSE.Src/LaDOSE.WebApp/.nvmrc new file mode 100644 index 0000000..b009dfb --- /dev/null +++ b/LaDOSE.Src/LaDOSE.WebApp/.nvmrc @@ -0,0 +1 @@ +lts/* diff --git a/LaDOSE.Src/LaDOSE.WebApp/.vscode/extensions.json b/LaDOSE.Src/LaDOSE.WebApp/.vscode/extensions.json new file mode 100644 index 0000000..28d1e67 --- /dev/null +++ b/LaDOSE.Src/LaDOSE.WebApp/.vscode/extensions.json @@ -0,0 +1,3 @@ +{ + "recommendations": ["svelte.svelte-vscode"] +} diff --git a/LaDOSE.Src/LaDOSE.WebApp/Dockerfile b/LaDOSE.Src/LaDOSE.WebApp/Dockerfile new file mode 100644 index 0000000..1af3a7b --- /dev/null +++ b/LaDOSE.Src/LaDOSE.WebApp/Dockerfile @@ -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;"] diff --git a/LaDOSE.Src/LaDOSE.WebApp/README.md b/LaDOSE.Src/LaDOSE.WebApp/README.md new file mode 100644 index 0000000..c1d4194 --- /dev/null +++ b/LaDOSE.Src/LaDOSE.WebApp/README.md @@ -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('/api/Game', { token: session.token }); +const one = await apiRequest(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, \.** 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/`) 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 +``` diff --git a/LaDOSE.Src/LaDOSE.WebApp/docker-entrypoint.sh b/LaDOSE.Src/LaDOSE.WebApp/docker-entrypoint.sh new file mode 100644 index 0000000..861fa89 --- /dev/null +++ b/LaDOSE.Src/LaDOSE.WebApp/docker-entrypoint.sh @@ -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 "$@" diff --git a/LaDOSE.Src/LaDOSE.WebApp/nginx.conf b/LaDOSE.Src/LaDOSE.WebApp/nginx.conf new file mode 100644 index 0000000..177c8d4 --- /dev/null +++ b/LaDOSE.Src/LaDOSE.WebApp/nginx.conf @@ -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; + } +} diff --git a/LaDOSE.Src/LaDOSE.WebApp/openapi.json b/LaDOSE.Src/LaDOSE.WebApp/openapi.json new file mode 100644 index 0000000..97a73a4 --- /dev/null +++ b/LaDOSE.Src/LaDOSE.WebApp/openapi.json @@ -0,0 +1,1924 @@ +{ + "openapi": "3.0.1", + "info": { + "title": "LaDOSE.Api | v1", + "version": "1.0.0" + }, + "servers": [ + { + "url": "http://localhost:5000/" + } + ], + "paths": { + "/api/BotEvent/ResultBotEvent": { + "post": { + "tags": [ + "BotEvent" + ], + "requestBody": { + "content": { + "application/json-patch+json": { + "schema": { + "$ref": "#/components/schemas/BotEventSendDTO" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/BotEventSendDTO" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/BotEventSendDTO" + } + }, + "application/*+json": { + "schema": { + "$ref": "#/components/schemas/BotEventSendDTO" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "boolean" + } + } + } + } + } + } + }, + "/api/BotEvent": { + "post": { + "tags": [ + "BotEvent" + ], + "requestBody": { + "content": { + "application/json-patch+json": { + "schema": { + "$ref": "#/components/schemas/BotEventDTO" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/BotEventDTO" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/BotEventDTO" + } + }, + "application/*+json": { + "schema": { + "$ref": "#/components/schemas/BotEventDTO" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BotEventDTO" + } + } + } + } + } + }, + "get": { + "tags": [ + "BotEvent" + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/BotEventDTO" + } + } + } + } + } + } + } + }, + "/api/BotEvent/{id}": { + "get": { + "tags": [ + "BotEvent" + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int32" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BotEventDTO" + } + } + } + } + } + }, + "delete": { + "tags": [ + "BotEvent" + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int32" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { } + } + } + } + } + }, + "/api/Event": { + "get": { + "tags": [ + "Event" + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/EventDTO" + } + } + } + } + } + } + }, + "post": { + "tags": [ + "Event" + ], + "requestBody": { + "content": { + "application/json-patch+json": { + "schema": { + "$ref": "#/components/schemas/EventDTO" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/EventDTO" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/EventDTO" + } + }, + "application/*+json": { + "schema": { + "$ref": "#/components/schemas/EventDTO" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/EventDTO" + } + } + } + } + } + } + }, + "/api/Event/{id}": { + "get": { + "tags": [ + "Event" + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int32" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/EventDTO" + } + } + } + } + } + }, + "delete": { + "tags": [ + "Event" + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int32" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { } + } + } + } + } + }, + "/api/Game/smash/{name}": { + "get": { + "tags": [ + "Game" + ], + "parameters": [ + { + "name": "name", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/GameDTO" + } + } + } + } + } + } + } + }, + "/api/Game": { + "post": { + "tags": [ + "Game" + ], + "requestBody": { + "content": { + "application/json-patch+json": { + "schema": { + "$ref": "#/components/schemas/GameDTO" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/GameDTO" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/GameDTO" + } + }, + "application/*+json": { + "schema": { + "$ref": "#/components/schemas/GameDTO" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GameDTO" + } + } + } + } + } + }, + "get": { + "tags": [ + "Game" + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/GameDTO" + } + } + } + } + } + } + } + }, + "/api/Game/{id}": { + "get": { + "tags": [ + "Game" + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int32" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GameDTO" + } + } + } + } + } + }, + "delete": { + "tags": [ + "Game" + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int32" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { } + } + } + } + } + }, + "/api/Statistics/Matches": { + "post": { + "tags": [ + "Statistics" + ], + "requestBody": { + "content": { + "application/json-patch+json": { + "schema": { + "type": "array", + "items": { + "type": "integer", + "format": "int32" + } + } + }, + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "integer", + "format": "int32" + } + } + }, + "text/json": { + "schema": { + "type": "array", + "items": { + "type": "integer", + "format": "int32" + } + } + }, + "application/*+json": { + "schema": { + "type": "array", + "items": { + "type": "integer", + "format": "int32" + } + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MatchStatsDTO" + } + } + } + } + } + } + }, + "/api/Todo": { + "post": { + "tags": [ + "Todo" + ], + "requestBody": { + "content": { + "application/json-patch+json": { + "schema": { + "$ref": "#/components/schemas/TodoDTO" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/TodoDTO" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/TodoDTO" + } + }, + "application/*+json": { + "schema": { + "$ref": "#/components/schemas/TodoDTO" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TodoDTO" + } + } + } + } + } + }, + "get": { + "tags": [ + "Todo" + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/TodoDTO" + } + } + } + } + } + } + } + }, + "/api/Todo/{id}": { + "get": { + "tags": [ + "Todo" + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int32" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TodoDTO" + } + } + } + } + } + }, + "delete": { + "tags": [ + "Todo" + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int32" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { } + } + } + } + } + }, + "/api/Tournament/GetTournaments": { + "post": { + "tags": [ + "Tournament" + ], + "requestBody": { + "content": { + "application/json-patch+json": { + "schema": { + "$ref": "#/components/schemas/TimeRangeDTO" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/TimeRangeDTO" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/TimeRangeDTO" + } + }, + "application/*+json": { + "schema": { + "$ref": "#/components/schemas/TimeRangeDTO" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/TournamentDTO" + } + } + } + } + } + } + } + }, + "/api/Tournament/GetResults": { + "post": { + "tags": [ + "Tournament" + ], + "requestBody": { + "content": { + "application/json-patch+json": { + "schema": { + "type": "array", + "items": { + "type": "integer", + "format": "int32" + } + } + }, + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "integer", + "format": "int32" + } + } + }, + "text/json": { + "schema": { + "type": "array", + "items": { + "type": "integer", + "format": "int32" + } + } + }, + "application/*+json": { + "schema": { + "type": "array", + "items": { + "type": "integer", + "format": "int32" + } + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TournamentsResultDTO" + } + } + } + } + } + } + }, + "/api/Tournament/ParseSmash/{tournamentSlug}": { + "get": { + "tags": [ + "Tournament" + ], + "parameters": [ + { + "name": "tournamentSlug", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "boolean" + } + } + } + } + } + } + }, + "/api/Tournament/ParseChallonge": { + "post": { + "tags": [ + "Tournament" + ], + "requestBody": { + "content": { + "application/json-patch+json": { + "schema": { + "type": "array", + "items": { + "type": "integer", + "format": "int32" + } + } + }, + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "integer", + "format": "int32" + } + } + }, + "text/json": { + "schema": { + "type": "array", + "items": { + "type": "integer", + "format": "int32" + } + } + }, + "application/*+json": { + "schema": { + "type": "array", + "items": { + "type": "integer", + "format": "int32" + } + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "boolean" + } + } + } + } + } + } + }, + "/api/Tournament/GetPlayers/{slug}": { + "get": { + "tags": [ + "Tournament" + ], + "parameters": [ + { + "name": "slug", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "string" + } + } + } + } + } + } + } + }, + "/Users/auth": { + "post": { + "tags": [ + "Users" + ], + "requestBody": { + "content": { + "application/json-patch+json": { + "schema": { + "$ref": "#/components/schemas/ApplicationUserDTO" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApplicationUserDTO" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ApplicationUserDTO" + } + }, + "application/*+json": { + "schema": { + "$ref": "#/components/schemas/ApplicationUserDTO" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApplicationUserDTO" + } + } + } + }, + "400": { + "description": "Bad Request", + "content": { + "application/json": { } + } + } + } + } + }, + "/Users": { + "get": { + "tags": [ + "Users" + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ApplicationUserDTO" + } + } + } + } + } + } + } + }, + "/Users/Roles": { + "get": { + "tags": [ + "Users" + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "string" + } + } + } + } + } + } + } + }, + "/Users/AddUser": { + "post": { + "tags": [ + "Users" + ], + "requestBody": { + "content": { + "application/json-patch+json": { + "schema": { + "$ref": "#/components/schemas/ApplicationUserDTO" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApplicationUserDTO" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ApplicationUserDTO" + } + }, + "application/*+json": { + "schema": { + "$ref": "#/components/schemas/ApplicationUserDTO" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApplicationUserDTO" + } + } + } + }, + "400": { + "description": "Bad Request", + "content": { + "application/json": { } + } + } + } + } + }, + "/Users/{id}": { + "delete": { + "tags": [ + "Users" + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int32" + } + } + ], + "responses": { + "204": { + "description": "No Content", + "content": { + "application/json": { } + } + }, + "400": { + "description": "Bad Request", + "content": { + "application/json": { } + } + }, + "404": { + "description": "Not Found", + "content": { + "application/json": { } + } + } + } + } + }, + "/api/WordPress/WPEvent": { + "get": { + "tags": [ + "WordPress" + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/WPEventDTO" + } + } + } + } + } + } + } + }, + "/api/WordPress/NextEvent": { + "get": { + "tags": [ + "WordPress" + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WPEventDTO" + } + } + } + } + } + } + }, + "/api/WordPress/GetUsers/{wpEventId}/{gameId}": { + "get": { + "tags": [ + "WordPress" + ], + "parameters": [ + { + "name": "wpEventId", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "gameId", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int32" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/WPUserDTO2" + } + } + } + } + } + } + } + }, + "/api/WordPress/GetUsersOptions/{wpEventId}/{gameId}": { + "get": { + "tags": [ + "WordPress" + ], + "parameters": [ + { + "name": "wpEventId", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "gameId", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int32" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/WPUserDTO2" + } + } + } + } + } + } + } + }, + "/api/WordPress/UpdateDb": { + "get": { + "tags": [ + "WordPress" + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "boolean" + } + } + } + } + } + } + }, + "/api/WordPress/CreateChallonge/{gameId}/{wpEventId}": { + "get": { + "tags": [ + "WordPress" + ], + "parameters": [ + { + "name": "gameId", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "wpEventId", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int32" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "string" + } + } + } + } + } + }, + "post": { + "tags": [ + "WordPress" + ], + "parameters": [ + { + "name": "gameId", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "wpEventId", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int32" + } + } + ], + "requestBody": { + "content": { + "application/json-patch+json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/WPUser" + } + } + }, + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/WPUser" + } + } + }, + "text/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/WPUser" + } + } + }, + "application/*+json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/WPUser" + } + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "string" + } + } + } + } + } + } + } + }, + "components": { + "schemas": { + "ApplicationUserDTO": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "format": "int32" + }, + "firstName": { + "type": "string", + "nullable": true + }, + "lastName": { + "type": "string", + "nullable": true + }, + "username": { + "type": "string", + "nullable": true + }, + "password": { + "type": "string", + "nullable": true + }, + "roles": { + "type": "array", + "items": { + "type": "string" + }, + "nullable": true + }, + "token": { + "type": "string", + "nullable": true + }, + "expire": { + "type": "string", + "format": "date-time" + } + } + }, + "BotEventDTO": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "format": "int32" + }, + "name": { + "type": "string", + "nullable": true + }, + "date": { + "type": "string", + "format": "date-time" + }, + "results": { + "type": "array", + "items": { + "$ref": "#/components/schemas/BotEventResultDTO" + }, + "nullable": true + } + } + }, + "BotEventResultDTO": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "format": "int32" + }, + "name": { + "type": "string", + "nullable": true + }, + "discordId": { + "type": "string", + "nullable": true + }, + "result": { + "type": "boolean" + } + } + }, + "BotEventSendDTO": { + "type": "object", + "properties": { + "discordId": { + "type": "string", + "nullable": true + }, + "discordName": { + "type": "string", + "nullable": true + }, + "present": { + "type": "boolean" + } + } + }, + "EventDTO": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "format": "int32" + }, + "name": { + "type": "string", + "nullable": true + }, + "date": { + "type": "string", + "format": "date-time" + } + } + }, + "GameDTO": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "format": "int32" + }, + "name": { + "type": "string", + "nullable": true + }, + "longName": { + "type": "string", + "nullable": true + }, + "order": { + "type": "integer", + "format": "int32" + }, + "imgUrl": { + "type": "string", + "nullable": true + }, + "wordPressTag": { + "type": "string", + "nullable": true + }, + "wordPressTagOs": { + "type": "string", + "nullable": true + }, + "smashId": { + "type": "integer", + "format": "int32", + "nullable": true + } + } + }, + "HeadToHeadDTO": { + "type": "object", + "properties": { + "playerAId": { + "type": "integer", + "format": "int32" + }, + "playerA": { + "type": "string", + "nullable": true + }, + "playerBId": { + "type": "integer", + "format": "int32" + }, + "playerB": { + "type": "string", + "nullable": true + }, + "winsA": { + "type": "integer", + "format": "int32" + }, + "winsB": { + "type": "integer", + "format": "int32" + } + } + }, + "MatchCoverageDTO": { + "type": "object", + "properties": { + "events": { + "type": "integer", + "format": "int32" + }, + "brackets": { + "type": "integer", + "format": "int32" + }, + "bracketsWithSets": { + "type": "integer", + "format": "int32" + }, + "sets": { + "type": "integer", + "format": "int32" + }, + "decidedSets": { + "type": "integer", + "format": "int32" + } + }, + "nullable": true + }, + "MatchStatsDTO": { + "type": "object", + "properties": { + "coverage": { + "$ref": "#/components/schemas/MatchCoverageDTO" + }, + "players": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PlayerMatchStatsDTO" + }, + "nullable": true + }, + "headToHead": { + "type": "array", + "items": { + "$ref": "#/components/schemas/HeadToHeadDTO" + }, + "nullable": true + } + } + }, + "ParticipentDTO": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "format": "int32" + }, + "challongeId": { + "type": "integer", + "format": "int32" + }, + "name": { + "type": "string", + "nullable": true + }, + "rank": { + "type": "integer", + "format": "int32" + }, + "isMember": { + "type": "boolean", + "nullable": true + } + } + }, + "PlayerMatchStatsDTO": { + "type": "object", + "properties": { + "playerId": { + "type": "integer", + "format": "int32" + }, + "player": { + "type": "string", + "nullable": true + }, + "sets": { + "type": "integer", + "format": "int32" + }, + "wins": { + "type": "integer", + "format": "int32" + }, + "losses": { + "type": "integer", + "format": "int32" + }, + "gamesWon": { + "type": "integer", + "format": "int32" + }, + "gamesLost": { + "type": "integer", + "format": "int32" + } + } + }, + "ResultDTO": { + "type": "object", + "properties": { + "gameId": { + "type": "integer", + "format": "int32" + }, + "player": { + "type": "string", + "nullable": true + }, + "point": { + "type": "integer", + "format": "int32" + }, + "tournamendId": { + "type": "integer", + "format": "int32" + }, + "tournamentUrl": { + "type": "string", + "nullable": true + }, + "rank": { + "type": "integer", + "format": "int32" + } + } + }, + "TimeRangeDTO": { + "type": "object", + "properties": { + "from": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "to": { + "type": "string", + "format": "date-time", + "nullable": true + } + } + }, + "TodoDTO": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "format": "int32" + }, + "user": { + "type": "string", + "nullable": true + }, + "task": { + "type": "string", + "nullable": true + }, + "done": { + "type": "boolean" + }, + "created": { + "type": "string", + "format": "date-time" + }, + "deleted": { + "type": "string", + "format": "date-time", + "nullable": true + } + } + }, + "TournamentDTO": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "format": "int32" + }, + "challongeId": { + "type": "integer", + "format": "int32" + }, + "name": { + "type": "string", + "nullable": true + }, + "game": { + "type": "string", + "nullable": true + }, + "participents": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ParticipentDTO" + }, + "nullable": true + } + } + }, + "TournamentsResultDTO": { + "type": "object", + "properties": { + "participents": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ParticipentDTO" + }, + "nullable": true + }, + "games": { + "type": "array", + "items": { + "$ref": "#/components/schemas/GameDTO" + }, + "nullable": true + }, + "results": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ResultDTO" + }, + "nullable": true + }, + "slug": { + "type": "string", + "nullable": true + } + } + }, + "WPBooking": { + "type": "object", + "properties": { + "wpEventId": { + "type": "integer", + "format": "int32" + }, + "wpEvent": { + "$ref": "#/components/schemas/WPEvent" + }, + "wpUserId": { + "type": "integer", + "format": "int32" + }, + "wpUser": { + "$ref": "#/components/schemas/WPUser2" + }, + "message": { + "type": "string", + "nullable": true + }, + "meta": { + "type": "string", + "nullable": true + } + } + }, + "WPBookingDTO": { + "type": "object", + "properties": { + "wpUser": { + "$ref": "#/components/schemas/WPUserDTO" + }, + "message": { + "type": "string", + "nullable": true + }, + "meta": { + "type": "string", + "nullable": true + } + } + }, + "WPEvent": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "format": "int32" + }, + "name": { + "type": "string", + "nullable": true + }, + "slug": { + "type": "string", + "nullable": true + }, + "date": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "wpBookings": { + "type": "array", + "items": { }, + "nullable": true + } + }, + "nullable": true + }, + "WPEventDTO": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "format": "int32" + }, + "name": { + "type": "string", + "nullable": true + }, + "slug": { + "type": "string", + "nullable": true + }, + "date": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "wpBookings": { + "type": "array", + "items": { + "$ref": "#/components/schemas/WPBookingDTO" + }, + "nullable": true + } + } + }, + "WPUser": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "format": "int32" + }, + "name": { + "type": "string", + "nullable": true + }, + "wpUserLogin": { + "type": "string", + "nullable": true + }, + "wpMail": { + "type": "string", + "nullable": true + }, + "wpBookings": { + "type": "array", + "items": { + "$ref": "#/components/schemas/WPBooking" + }, + "nullable": true + } + } + }, + "WPUser2": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "format": "int32" + }, + "name": { + "type": "string", + "nullable": true + }, + "wpUserLogin": { + "type": "string", + "nullable": true + }, + "wpMail": { + "type": "string", + "nullable": true + }, + "wpBookings": { + "type": "array", + "items": { + "$ref": "#/components/schemas/WPBooking" + }, + "nullable": true + } + }, + "nullable": true + }, + "WPUserDTO": { + "type": "object", + "properties": { + "id": { + "type": "string", + "nullable": true + }, + "name": { + "type": "string", + "nullable": true + } + }, + "nullable": true + }, + "WPUserDTO2": { + "type": "object", + "properties": { + "id": { + "type": "string", + "nullable": true + }, + "name": { + "type": "string", + "nullable": true + } + } + } + } + }, + "tags": [ + { + "name": "BotEvent" + }, + { + "name": "Event" + }, + { + "name": "Game" + }, + { + "name": "Statistics" + }, + { + "name": "Todo" + }, + { + "name": "Tournament" + }, + { + "name": "Users" + }, + { + "name": "WordPress" + } + ] +} \ No newline at end of file diff --git a/LaDOSE.Src/LaDOSE.WebApp/package-lock.json b/LaDOSE.Src/LaDOSE.WebApp/package-lock.json new file mode 100644 index 0000000..b0f957a --- /dev/null +++ b/LaDOSE.Src/LaDOSE.WebApp/package-lock.json @@ -0,0 +1,2308 @@ +{ + "name": "ladose.webapp", + "version": "0.0.1", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "ladose.webapp", + "version": "0.0.1", + "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" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.142.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.142.0.tgz", + "integrity": "sha512-7W+2q5AKQVU36fkaryontrHn3YDt1RyUYXatw9i5H8ocYe2sPKSFB6eS8WNPeRKiN1qAWWZUPm7gwFzJGrccqQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@polka/url": { + "version": "1.0.0-next.29", + "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz", + "integrity": "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==", + "dev": true, + "license": "MIT" + }, + "node_modules/@redocly/ajv": { + "version": "8.11.2", + "resolved": "https://registry.npmjs.org/@redocly/ajv/-/ajv-8.11.2.tgz", + "integrity": "sha512-io1JpnwtIcvojV7QKDUSIuMN/ikdOUd1ReEnUnMKGfDVridQZ31J0MmIuqwuRjWDZfmvr+Q0MqCcfHM2gTivOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2", + "uri-js-replace": "^1.0.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/@redocly/config": { + "version": "0.22.0", + "resolved": "https://registry.npmjs.org/@redocly/config/-/config-0.22.0.tgz", + "integrity": "sha512-gAy93Ddo01Z3bHuVdPWfCwzgfaYgMdaZPcfL7JZ7hWJoK9V0lXDbigTWkhiPFAaLWzbOJ+kbUQG1+XwIm0KRGQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@redocly/openapi-core": { + "version": "1.34.18", + "resolved": "https://registry.npmjs.org/@redocly/openapi-core/-/openapi-core-1.34.18.tgz", + "integrity": "sha512-UyKIm0wTPw5BcY7Z2PkbK1Ma260um96LSBWXHrdSMe+ZV0EPMyDfAcUcjjm3qEiGST9OK/1TriekdPCZkn4Q3A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@redocly/ajv": "8.11.2", + "@redocly/config": "0.22.0", + "colorette": "1.4.0", + "https-proxy-agent": "7.0.6", + "js-levenshtein": "1.1.6", + "js-yaml": "4.3.0", + "minimatch": "5.1.9", + "pluralize": "8.0.0", + "yaml-ast-parser": "0.0.43" + }, + "engines": { + "node": ">=18.17.0", + "npm": ">=9.5.0" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.2.tgz", + "integrity": "sha512-l7x215OGvo1s52JWmR8U/DAVzEDWBCIbTm28aeJV/WDTSHgcKXaZTuBT0hJMs5NggilfJTW3clZVvd24yfKJxA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.2.tgz", + "integrity": "sha512-9u9Xv6c1AJZT0FfwH5vrMG5Jjcwhc1MlyrPu0XfTqkzsmqfks2M6W/o5XwAJgVVN/jHpqqngC1WevHKKTIUtIA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.2.tgz", + "integrity": "sha512-9W1mbGZAfW3oqd85bhBkmpyHCCzL1TeG/zFFP3vg7b0rlly8cxOcre5nXwz+LHazCwac2MNWgPdPCHndABjpWQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.2.tgz", + "integrity": "sha512-0p1lhiCSCyaerFwtrdZQUx7NqGk6LQnaRKWX7tFQqwQgvX0rjM15cIkm3pax1UpEakK14C4mOxx/jSqCBdBRqQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.2.tgz", + "integrity": "sha512-e+cOJXrJ2L3zx6YzqPg+f6Wbk3V1cKB8bOhbaYdVYN3DdquzNdRAmrbETz1qnt5yp/c7JNlNjmITiA2cVneQ7w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.2.tgz", + "integrity": "sha512-JsSMsj6sNat/MuhG5fnBD7QgbtpHKVe30x5/bAVirDHdhoQRXJkF6xc0Jqk8O4fiCUQAzMOoH9wZi3m60c8wtg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.2.tgz", + "integrity": "sha512-B5G/zJdHaoJn9vD50eGHWkiWfmq8Uhi3IiLPJTzmZTrAalk1bztUikSXo0qga18ibE0IXboyeMUnhPjhAJ45wQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.2.tgz", + "integrity": "sha512-6mC/awzKka8W6EoekjegpfGkjz8jXWDX63pqu/HYVpyKtZfu65Jsh4QAH3Kej3CAv/c1oGX7psTmFEbr0mDxLA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.2.tgz", + "integrity": "sha512-412MX9fJLdA1IK28EZnc8jYv2HRTleOZgfLQumJ5zy7OeJLZlg/CETwFaXjNmGVxG51cFHpKLqb5LKvBC+HsHA==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.2.tgz", + "integrity": "sha512-Q/+HI/ToJafZ1iCqGgVQXUEkIjufHCTF0gBQ2a5o3cg7GJ2h0qyq3nvvSmU+bGda2/7ygXpTY4TM6gO9OhQ0ZA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.2.tgz", + "integrity": "sha512-ZKp/w41n6wCvxzxQHtQSbuphfX3Y4cCvbjkKHusrLx4lh+JWLTU7StSltO/DKARISzbj368d+qUaCjI8K2wzXw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.2.tgz", + "integrity": "sha512-pxE6xD4KS3eAROkKK5yrhB9/3+vhlhVGMvlQLbdpzrBGDbKrnzx3RLwPaHvasLo6jgaiBLn7e04Df9C4tYhjmA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.2.tgz", + "integrity": "sha512-4MqEue5re+xIZzAWsB8sj0P1kqZySWqIuN4t6QaIO/YA6SFwySOLruvWQFzfmqk8LBK2P30KCSJwf6mJCZZ5/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.2.tgz", + "integrity": "sha512-NweNxxD0Nf9t8v7kodun45Ijp3EIwYY+uydPP6qBEYvfBqhIjN6dZMzlQja3tqX/aLs3F3Uz+AxDpKgRhpOZQg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@sveltejs/acorn-typescript": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.11.tgz", + "integrity": "sha512-LFuZUkjJ9iF7JZye/aG5XM0SFcQ5VyL0oVX4WJ9dc0Va3R3s0OauX1BESVCb+YN/ol8TAfqGDDAQsTG627Y5kw==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^8.9.0" + } + }, + "node_modules/@sveltejs/adapter-auto": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/@sveltejs/adapter-auto/-/adapter-auto-7.0.1.tgz", + "integrity": "sha512-dvuPm1E7M9NI/+canIQ6KKQDU2AkEefEZ2Dp7cY6uKoPq9Z/PhOXABe526UdW2mN986gjVkuSLkOYIBnS/M2LQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@sveltejs/kit": "^2.0.0" + } + }, + "node_modules/@sveltejs/adapter-static": { + "version": "3.0.10", + "resolved": "https://registry.npmjs.org/@sveltejs/adapter-static/-/adapter-static-3.0.10.tgz", + "integrity": "sha512-7D9lYFWJmB7zxZyTE/qxjksvMqzMuYrrsyh1f4AlZqeZeACPRySjbC3aFiY55wb1tWUaKOQG9PVbm74JcN2Iew==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@sveltejs/kit": "^2.0.0" + } + }, + "node_modules/@sveltejs/kit": { + "version": "2.70.2", + "resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.70.2.tgz", + "integrity": "sha512-RzRoRpuR2KXqc5yMO0akQHDZeT4AslOlznGITURsqHaVbtyYP4Wn3eE3gxj9JcDyNYO0crkxhdwFHc+2vkVm6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@sveltejs/acorn-typescript": "^1.0.9", + "@types/cookie": "^0.6.0", + "acorn": "^8.16.0", + "cookie": "^0.6.0", + "devalue": "^5.8.1", + "esm-env": "^1.2.2", + "kleur": "^4.1.5", + "magic-string": "^0.30.5", + "mrmime": "^2.0.0", + "set-cookie-parser": "^3.0.0", + "sirv": "^3.0.0" + }, + "bin": { + "svelte-kit": "svelte-kit.js" + }, + "engines": { + "node": ">=18.13" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.0.0", + "@sveltejs/vite-plugin-svelte": "^3.0.0 || ^4.0.0-next.1 || ^5.0.0 || ^6.0.0-next.0 || ^7.0.0", + "svelte": "^4.0.0 || ^5.0.0-next.0", + "typescript": "^5.3.3 || ^6.0.0", + "vite": "^5.0.3 || ^6.0.0 || ^7.0.0-beta.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@opentelemetry/api": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, + "node_modules/@sveltejs/load-config": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/@sveltejs/load-config/-/load-config-0.2.1.tgz", + "integrity": "sha512-5m3B2cbqQ4TbwW6Xkh66Ntw6dD7gNc77cCxABTTesWcq9jxIzMgTk97pZx5vEtvQx8iokgi7GIphqZe+PGwcZA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 18.0.0" + } + }, + "node_modules/@sveltejs/vite-plugin-svelte": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-7.2.0.tgz", + "integrity": "sha512-1SpkuMSRLfugrVX+IrKfE1RUegzo8AQzKQ6qQPfVzbcWi5IhuTPaKb5ZrLpucleFznkc4/RTeSPoRnGWFxX+EQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "deepmerge": "^4.3.1", + "magic-string": "^0.30.21", + "obug": "^2.1.0", + "vitefu": "^1.1.2" + }, + "engines": { + "node": "^20.19 || ^22.12 || >=24" + }, + "peerDependencies": { + "svelte": "^5.46.4", + "vite": "^8.0.0-beta.7 || ^8.0.0" + } + }, + "node_modules/@tailwindcss/node": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.3.tgz", + "integrity": "sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "^5.24.1", + "jiti": "^2.7.0", + "lightningcss": "1.32.0", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.3.3" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.3.tgz", + "integrity": "sha512-krXjAikiaFSPaK/FkAQT5UTx3VormQaiZ5hBFlJZ9UFQGB/rwg1MZIhHAG9smMQRTdyJxP6Qt5MwMtdyU5FWrA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.3.3", + "@tailwindcss/oxide-darwin-arm64": "4.3.3", + "@tailwindcss/oxide-darwin-x64": "4.3.3", + "@tailwindcss/oxide-freebsd-x64": "4.3.3", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.3", + "@tailwindcss/oxide-linux-arm64-gnu": "4.3.3", + "@tailwindcss/oxide-linux-arm64-musl": "4.3.3", + "@tailwindcss/oxide-linux-x64-gnu": "4.3.3", + "@tailwindcss/oxide-linux-x64-musl": "4.3.3", + "@tailwindcss/oxide-wasm32-wasi": "4.3.3", + "@tailwindcss/oxide-win32-arm64-msvc": "4.3.3", + "@tailwindcss/oxide-win32-x64-msvc": "4.3.3" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.3.tgz", + "integrity": "sha512-Y85A2gmPSkl5Ve5qR86GL4HT509cFqQh1aes9p3sSkyTPwt0Pppf3GkwGe4JPACcRYjgJIEhQgM6dBClnr0NYw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.3.tgz", + "integrity": "sha512-BiaWatpBcERQFDlOjRDpIVXuFK5PJez5SA4JMg6VYZdBYU+qKfV/vqjcIs+IYmtitf1xYQZTwXvU/8y4lfZUGw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.3.tgz", + "integrity": "sha512-fAeUqfV5ndhxRwai8cXGzdLvul9utWOmeTkv69unv4ZXixjn61Z+p9lCWdwOwA3TYboG3BwdVuN/RDjhBRl0mw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.3.tgz", + "integrity": "sha512-iyf5bV6+wnAlflVeEy7R25dupxTNECZN5QMI0qNT6eT+EgaGdZcKhGkr5SdoaWiLJ3spLqIY9VCeSGrwmtg4kw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.3.tgz", + "integrity": "sha512-aAYUprJAJQWWbRrPvtjdroZ56Md+JM8pMiopS6xGEwDfLhqj+2ver2p4nU4Mb3CRqcMmNBjo8KkUgcxhkzVQGQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.3.tgz", + "integrity": "sha512-nDxldcEENOxZRzC2uu9jrutZdAAQtb+8WWDCSnWL1zvBk1+FN+x6MtDViPB5AJMfttVCUhehGWus3XBPgatM/w==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.3.tgz", + "integrity": "sha512-Md44bD6veX/PC5iyF8cDVnw4HBIANZepRZZ7a8DQOvkfo5WUBwcp6iAuCUz23u+4SUkhJlD3eL7hNdW8ezd/kA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.3.tgz", + "integrity": "sha512-tx7us1muwOKAKWao2v/GaafFeQboE6aj88vC6ziN2NCGcRm8gWUhwjzg+YdVB1e4boAtdtma4L43onunI6NS4w==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.3.tgz", + "integrity": "sha512-SJxX60smvHgasZoBy11dX6YRjXJFovwWBoedhbQPOBzgFWBHGB+TVPWB9BxzR7TTxU8FQZAI2AyiNCMzFm8Img==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.3.tgz", + "integrity": "sha512-jx1+rPhY/5Ympkktd656HBWEBLxP7dH06losBLjjf5vgCODXvi9KhtftWcMIwTFIDqBr7cRnQkdLnAG+IOlGvQ==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.11.1", + "@emnapi/runtime": "^1.11.1", + "@emnapi/wasi-threads": "^1.2.2", + "@napi-rs/wasm-runtime": "^1.1.4", + "@tybys/wasm-util": "^0.10.2", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.3.tgz", + "integrity": "sha512-3rc292Ca2ceK6Ulcc/bAVnTs/3nDtoPhyEKlgPv+yQJQi/JS/AMJlqzxvlDacL1nekbrcf6bTqp/jV4qgnPxNQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.3.tgz", + "integrity": "sha512-yJ0pwIVc/nYeGoV02WtsN8KYyLQv7kyI2wDnkezyJlGGjkd4QLwDGAwl47YpPJeuI0M0ObaXGSPjvWDPeTPggw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/vite": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.3.3.tgz", + "integrity": "sha512-yYU8cogLeSh/ms2jh8Fj7jaba/EWa7Ja6GoUqYZaraEuCI5YS6ms6ObZgjjedm+jm6XZjdNRWBpPP6Z86oOxcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tailwindcss/node": "4.3.3", + "@tailwindcss/oxide": "4.3.3", + "tailwindcss": "4.3.3" + }, + "peerDependencies": { + "vite": "^5.2.0 || ^6 || ^7 || ^8" + } + }, + "node_modules/@types/cookie": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz", + "integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "dev": true, + "license": "MIT" + }, + "node_modules/acorn": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz", + "integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/ansi-colors": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.3.tgz", + "integrity": "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/aria-query": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.1.tgz", + "integrity": "sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/axobject-query": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", + "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/brace-expansion": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz", + "integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/change-case": { + "version": "5.4.4", + "resolved": "https://registry.npmjs.org/change-case/-/change-case-5.4.4.tgz", + "integrity": "sha512-HRQyTk2/YPEkt9TnUPbOpr64Uw3KOicFWPVBb+xiHvd6eBx/qPr9xqfBFDT8P2vWsvvz4jbEkfDe71W3VyNu2w==", + "dev": true, + "license": "MIT" + }, + "node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/colorette": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/colorette/-/colorette-1.4.0.tgz", + "integrity": "sha512-Y2oEozpomLn7Q3HFP7dpww7AtMJplbM9lGZP6RDfHqmbeRjiwRg4n6VM6j4KLmRke85uWEI7JqF17f3pqdRA0g==", + "dev": true, + "license": "MIT" + }, + "node_modules/cookie": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz", + "integrity": "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/devalue": { + "version": "5.9.0", + "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.9.0.tgz", + "integrity": "sha512-RWrqdArjvPbsATEhOPUo6Wndc/iWnkWKlhIrdlF3zMMYo/c3CVtoaVAyLtWxz5h8nSlkHzxnzV2uLydPXmtF+A==", + "dev": true, + "license": "MIT" + }, + "node_modules/enhanced-resolve": { + "version": "5.24.5", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.24.5.tgz", + "integrity": "sha512-L1l8TNvomm6UVW5B253AGxQagSQr+vGwhMlrrfRS2qmhx46AMpMVJKQYLvWYbysTMY8VoicOvzHzoHMbyzB+4A==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/esm-env": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz", + "integrity": "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/esrap": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/esrap/-/esrap-2.3.0.tgz", + "integrity": "sha512-GQ/7RN8uOtEfNpzZzBMTzW9JBcX42oaSVtPzdF+6cEL8pqIL094iUpr9jzYGn4O4P/1S60dJ6izyT8F4LYARng==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.4.15" + }, + "peerDependencies": { + "@typescript-eslint/types": "^8.2.0" + }, + "peerDependenciesMeta": { + "@typescript-eslint/types": { + "optional": true + } + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/index-to-position": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/index-to-position/-/index-to-position-1.2.0.tgz", + "integrity": "sha512-Yg7+ztRkqslMAS2iFaU+Oa4KTSidr63OsFGlOrJoW981kIYO3CGCS3wA95P1mUi/IVSJkn0D479KTJpVpvFNuw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-reference": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz", + "integrity": "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.6" + } + }, + "node_modules/jiti": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/js-levenshtein": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/js-levenshtein/-/js-levenshtein-1.1.6.tgz", + "integrity": "sha512-X2BB11YZtrRqY4EnQcLX5Rh373zbK4alC1FW7D7MBhL2gtcC17cTnr6DmfHZeS0s2rTHjUTMMHfG7gO8SSdw+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz", + "integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true, + "license": "MIT" + }, + "node_modules/kleur": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", + "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/locate-character": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz", + "integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==", + "dev": true, + "license": "MIT" + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/minimatch": { + "version": "5.1.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz", + "integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/mri": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz", + "integrity": "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/mrmime": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", + "integrity": "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.17", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.17.tgz", + "integrity": "sha512-xQLf0A3HOMlgHq0n247/LRuAOYmB7dXJ/DvAxGvsSBij45XtBSmQycu+F8ODbHwns/XyFZagyL1+J0Offw1E0g==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/obug": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.4.tgz", + "integrity": "sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/openapi-typescript": { + "version": "7.13.0", + "resolved": "https://registry.npmjs.org/openapi-typescript/-/openapi-typescript-7.13.0.tgz", + "integrity": "sha512-EFP392gcqXS7ntPvbhBzbF8TyBA+baIYEm791Hy5YkjDYKTnk/Tn5OQeKm5BIZvJihpp8Zzr4hzx0Irde1LNGQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@redocly/openapi-core": "^1.34.6", + "ansi-colors": "^4.1.3", + "change-case": "^5.4.4", + "parse-json": "^8.3.0", + "supports-color": "^10.2.2", + "yargs-parser": "^21.1.1" + }, + "bin": { + "openapi-typescript": "bin/cli.js" + }, + "peerDependencies": { + "typescript": "^5.x" + } + }, + "node_modules/parse-json": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-8.3.0.tgz", + "integrity": "sha512-ybiGyvspI+fAoRQbIPRddCcSTV9/LsJbf0e/S85VLowVGzRmokfneg2kwVW/KU5rOXrPSbF1qAKPMgNTqqROQQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.26.2", + "index-to-position": "^1.1.0", + "type-fest": "^4.39.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pluralize": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/pluralize/-/pluralize-8.0.0.tgz", + "integrity": "sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss": { + "version": "8.5.25", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.25.tgz", + "integrity": "sha512-DTPx3RWSSnWyzLxQnlH0rJP+EW5ekl16ZU4/psbIhA0e53kJfdgaN5vKM+xP7yJtXVu+nfdVFmlgFDEKAe4Pyw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.16", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/rolldown": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.2.tgz", + "integrity": "sha512-opwpo1tQBAcpSUJDt94B7hhLNGOKjCdE//XXjeLrnx9b83bjnw45tXdg1b09yEw/VLFBJGZpwRULMmOZo7ol+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.142.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.2.2", + "@rolldown/binding-darwin-arm64": "1.2.2", + "@rolldown/binding-darwin-x64": "1.2.2", + "@rolldown/binding-freebsd-x64": "1.2.2", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.2", + "@rolldown/binding-linux-arm64-gnu": "1.2.2", + "@rolldown/binding-linux-arm64-musl": "1.2.2", + "@rolldown/binding-linux-ppc64-gnu": "1.2.2", + "@rolldown/binding-linux-s390x-gnu": "1.2.2", + "@rolldown/binding-linux-x64-gnu": "1.2.2", + "@rolldown/binding-linux-x64-musl": "1.2.2", + "@rolldown/binding-openharmony-arm64": "1.2.2", + "@rolldown/binding-win32-arm64-msvc": "1.2.2", + "@rolldown/binding-win32-x64-msvc": "1.2.2" + } + }, + "node_modules/sade": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/sade/-/sade-1.8.1.tgz", + "integrity": "sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "mri": "^1.1.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/set-cookie-parser": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-3.1.2.tgz", + "integrity": "sha512-5/r/lTwbJ3zQ+qwdUFZYeRNqda7P5HD8zQKqlSjdGt1/S0cjLAphHusj4Y58ahDtWn/g32xrIS58/ikOvwl0Lw==", + "dev": true, + "license": "MIT" + }, + "node_modules/sirv": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.2.tgz", + "integrity": "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@polka/url": "^1.0.0-next.24", + "mrmime": "^2.0.0", + "totalist": "^3.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/supports-color": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-10.2.2.tgz", + "integrity": "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/svelte": { + "version": "5.56.8", + "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.56.8.tgz", + "integrity": "sha512-PY8LOw7xP6c8IOiVqdo0sbbZVYhXRSfklOQLAUyGBKqjTX0wx/z4l/9J+PmBpmlLnxzEb1NqltxQ5/wZme/Cmg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.4", + "@jridgewell/sourcemap-codec": "^1.5.0", + "@sveltejs/acorn-typescript": "^1.0.10", + "@types/estree": "^1.0.5", + "@types/trusted-types": "^2.0.7", + "acorn": "^8.12.1", + "aria-query": "5.3.1", + "axobject-query": "^4.1.0", + "clsx": "^2.1.1", + "devalue": "^5.8.1", + "esm-env": "^1.2.1", + "esrap": "^2.2.12", + "is-reference": "^3.0.3", + "locate-character": "^3.0.0", + "magic-string": "^0.30.11", + "zimmerframe": "^1.1.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/svelte-check": { + "version": "4.7.4", + "resolved": "https://registry.npmjs.org/svelte-check/-/svelte-check-4.7.4.tgz", + "integrity": "sha512-IW9ot9YqAoyv8FvyN+eb4ZTe8zgcKZrJLNYU6dzSKkGwEBsSPc4K7lmQ8bKn8W2YMXM6WDfZSSVOaGtekyUfOQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.25", + "@sveltejs/load-config": "^0.2.1", + "chokidar": "^4.0.1", + "fdir": "^6.2.0", + "picocolors": "^1.0.0", + "sade": "^1.7.4" + }, + "bin": { + "svelte-check": "bin/svelte-check" + }, + "engines": { + "node": ">= 18.0.0" + }, + "peerDependencies": { + "svelte": "^4.0.0 || ^5.0.0-next.0", + "typescript": "^5.0.0 || ^6.0.0" + } + }, + "node_modules/tailwindcss": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.3.tgz", + "integrity": "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", + "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/totalist": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz", + "integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/type-fest": { + "version": "4.41.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", + "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/typescript": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/uri-js-replace": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/uri-js-replace/-/uri-js-replace-1.0.1.tgz", + "integrity": "sha512-W+C9NWNLFOoBI2QWDp4UT9pv65r2w5Cx+3sTYFvtMdDBxkKt1syCqsUdSFAChbEe1uK5TfS04wt/nGwmaeIQ0g==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.2.0.tgz", + "integrity": "sha512-pn+CFpM0lwDeKwmOq1ZaBK/9sjorZcgqxki6MbY/jPEVd9vichIlmlD4HmQ5wdP5EgqQCFRaACBxMC7uEGc6lQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.33.0", + "picomatch": "^4.0.5", + "postcss": "^8.5.23", + "rolldown": "~1.2.0", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.4.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vitefu": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/vitefu/-/vitefu-1.1.3.tgz", + "integrity": "sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg==", + "dev": true, + "license": "MIT", + "workspaces": [ + "tests/deps/*", + "tests/projects/*", + "tests/projects/workspace/packages/*" + ], + "peerDependencies": { + "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "vite": { + "optional": true + } + } + }, + "node_modules/yaml-ast-parser": { + "version": "0.0.43", + "resolved": "https://registry.npmjs.org/yaml-ast-parser/-/yaml-ast-parser-0.0.43.tgz", + "integrity": "sha512-2PTINUwsRqSd+s8XxKaJWQlUuEMHJQyEuh2edBbW8KNJz0SJPwUSD2zRWqezFEdN7IzAgeuYHFUCF7o8zRdZ0A==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/zimmerframe": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz", + "integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/LaDOSE.Src/LaDOSE.WebApp/package.json b/LaDOSE.Src/LaDOSE.WebApp/package.json new file mode 100644 index 0000000..247f457 --- /dev/null +++ b/LaDOSE.Src/LaDOSE.WebApp/package.json @@ -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" + } +} diff --git a/LaDOSE.Src/LaDOSE.WebApp/src/app.css b/LaDOSE.Src/LaDOSE.WebApp/src/app.css new file mode 100644 index 0000000..4ef18ac --- /dev/null +++ b/LaDOSE.Src/LaDOSE.WebApp/src/app.css @@ -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; +} diff --git a/LaDOSE.Src/LaDOSE.WebApp/src/app.d.ts b/LaDOSE.Src/LaDOSE.WebApp/src/app.d.ts new file mode 100644 index 0000000..da08e6d --- /dev/null +++ b/LaDOSE.Src/LaDOSE.WebApp/src/app.d.ts @@ -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 {}; diff --git a/LaDOSE.Src/LaDOSE.WebApp/src/app.html b/LaDOSE.Src/LaDOSE.WebApp/src/app.html new file mode 100644 index 0000000..5e201f0 --- /dev/null +++ b/LaDOSE.Src/LaDOSE.WebApp/src/app.html @@ -0,0 +1,37 @@ + + + + + + + + + + + + + +
%sveltekit.body%
+ + diff --git a/LaDOSE.Src/LaDOSE.WebApp/src/lib/api/admin-users.ts b/LaDOSE.Src/LaDOSE.WebApp/src/lib/api/admin-users.ts new file mode 100644 index 0000000..1bec6ca --- /dev/null +++ b/LaDOSE.Src/LaDOSE.WebApp/src/lib/api/admin-users.ts @@ -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 { + return apiRequest('/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 { + return apiRequest('/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 { + return apiRequest('/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 { + await apiRequest(buildPath('/Users/{id}', { id }), { + ...authed(options), + method: 'DELETE' + }); +} diff --git a/LaDOSE.Src/LaDOSE.WebApp/src/lib/api/client.ts b/LaDOSE.Src/LaDOSE.WebApp/src/lib/api/client.ts new file mode 100644 index 0000000..69810f9 --- /dev/null +++ b/LaDOSE.Src/LaDOSE.WebApp/src/lib/api/client.ts @@ -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( + path: ApiPath | (string & {}), + options: RequestOptions = {} +): Promise { + const { method = 'GET', body, token, fetch: fetchImpl = globalThis.fetch, signal } = options; + + const headers: Record = { 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 { + 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)); + }); +} diff --git a/LaDOSE.Src/LaDOSE.WebApp/src/lib/api/errors.ts b/LaDOSE.Src/LaDOSE.WebApp/src/lib/api/errors.ts new file mode 100644 index 0000000..ea99cf4 --- /dev/null +++ b/LaDOSE.Src/LaDOSE.WebApp/src/lib/api/errors.ts @@ -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; +} diff --git a/LaDOSE.Src/LaDOSE.WebApp/src/lib/api/games.ts b/LaDOSE.Src/LaDOSE.WebApp/src/lib/api/games.ts new file mode 100644 index 0000000..d577e99 --- /dev/null +++ b/LaDOSE.Src/LaDOSE.WebApp/src/lib/api/games.ts @@ -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 { + return apiRequest('/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 { + return apiRequest('/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 { + await apiRequest(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 { + return apiRequest(buildPath('/api/Game/smash/{name}', { name }), authed(options)); +} diff --git a/LaDOSE.Src/LaDOSE.WebApp/src/lib/api/schema-helpers.ts b/LaDOSE.Src/LaDOSE.WebApp/src/lib/api/schema-helpers.ts new file mode 100644 index 0000000..2a50b54 --- /dev/null +++ b/LaDOSE.Src/LaDOSE.WebApp/src/lib/api/schema-helpers.ts @@ -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; + +/** 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; diff --git a/LaDOSE.Src/LaDOSE.WebApp/src/lib/api/schema.d.ts b/LaDOSE.Src/LaDOSE.WebApp/src/lib/api/schema.d.ts new file mode 100644 index 0000000..6f6a090 --- /dev/null +++ b/LaDOSE.Src/LaDOSE.WebApp/src/lib/api/schema.d.ts @@ -0,0 +1,1498 @@ +/** + * This file was auto-generated by openapi-typescript. + * Do not make direct changes to the file. + */ + +export interface paths { + "/api/BotEvent/ResultBotEvent": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json-patch+json": components["schemas"]["BotEventSendDTO"]; + "application/json": components["schemas"]["BotEventSendDTO"]; + "text/json": components["schemas"]["BotEventSendDTO"]; + "application/*+json": components["schemas"]["BotEventSendDTO"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": boolean; + }; + }; + }; + }; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/BotEvent": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["BotEventDTO"][]; + }; + }; + }; + }; + put?: never; + post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json-patch+json": components["schemas"]["BotEventDTO"]; + "application/json": components["schemas"]["BotEventDTO"]; + "text/json": components["schemas"]["BotEventDTO"]; + "application/*+json": components["schemas"]["BotEventDTO"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["BotEventDTO"]; + }; + }; + }; + }; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/BotEvent/{id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: { + parameters: { + query?: never; + header?: never; + path: { + id: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["BotEventDTO"]; + }; + }; + }; + }; + put?: never; + post?: never; + delete: { + parameters: { + query?: never; + header?: never; + path: { + id: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": unknown; + }; + }; + }; + }; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/Event": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["EventDTO"][]; + }; + }; + }; + }; + put?: never; + post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json-patch+json": components["schemas"]["EventDTO"]; + "application/json": components["schemas"]["EventDTO"]; + "text/json": components["schemas"]["EventDTO"]; + "application/*+json": components["schemas"]["EventDTO"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["EventDTO"]; + }; + }; + }; + }; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/Event/{id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: { + parameters: { + query?: never; + header?: never; + path: { + id: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["EventDTO"]; + }; + }; + }; + }; + put?: never; + post?: never; + delete: { + parameters: { + query?: never; + header?: never; + path: { + id: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": unknown; + }; + }; + }; + }; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/Game/smash/{name}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: { + parameters: { + query?: never; + header?: never; + path: { + name: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["GameDTO"][]; + }; + }; + }; + }; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/Game": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["GameDTO"][]; + }; + }; + }; + }; + put?: never; + post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json-patch+json": components["schemas"]["GameDTO"]; + "application/json": components["schemas"]["GameDTO"]; + "text/json": components["schemas"]["GameDTO"]; + "application/*+json": components["schemas"]["GameDTO"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["GameDTO"]; + }; + }; + }; + }; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/Game/{id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: { + parameters: { + query?: never; + header?: never; + path: { + id: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["GameDTO"]; + }; + }; + }; + }; + put?: never; + post?: never; + delete: { + parameters: { + query?: never; + header?: never; + path: { + id: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": unknown; + }; + }; + }; + }; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/Statistics/Matches": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json-patch+json": number[]; + "application/json": number[]; + "text/json": number[]; + "application/*+json": number[]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["MatchStatsDTO"]; + }; + }; + }; + }; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/Todo": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["TodoDTO"][]; + }; + }; + }; + }; + put?: never; + post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json-patch+json": components["schemas"]["TodoDTO"]; + "application/json": components["schemas"]["TodoDTO"]; + "text/json": components["schemas"]["TodoDTO"]; + "application/*+json": components["schemas"]["TodoDTO"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["TodoDTO"]; + }; + }; + }; + }; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/Todo/{id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: { + parameters: { + query?: never; + header?: never; + path: { + id: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["TodoDTO"]; + }; + }; + }; + }; + put?: never; + post?: never; + delete: { + parameters: { + query?: never; + header?: never; + path: { + id: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": unknown; + }; + }; + }; + }; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/Tournament/GetTournaments": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json-patch+json": components["schemas"]["TimeRangeDTO"]; + "application/json": components["schemas"]["TimeRangeDTO"]; + "text/json": components["schemas"]["TimeRangeDTO"]; + "application/*+json": components["schemas"]["TimeRangeDTO"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["TournamentDTO"][]; + }; + }; + }; + }; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/Tournament/GetResults": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json-patch+json": number[]; + "application/json": number[]; + "text/json": number[]; + "application/*+json": number[]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["TournamentsResultDTO"]; + }; + }; + }; + }; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/Tournament/ParseSmash/{tournamentSlug}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: { + parameters: { + query?: never; + header?: never; + path: { + tournamentSlug: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": boolean; + }; + }; + }; + }; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/Tournament/ParseChallonge": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json-patch+json": number[]; + "application/json": number[]; + "text/json": number[]; + "application/*+json": number[]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": boolean; + }; + }; + }; + }; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/Tournament/GetPlayers/{slug}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: { + parameters: { + query?: never; + header?: never; + path: { + slug: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": string[]; + }; + }; + }; + }; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/Users/auth": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json-patch+json": components["schemas"]["ApplicationUserDTO"]; + "application/json": components["schemas"]["ApplicationUserDTO"]; + "text/json": components["schemas"]["ApplicationUserDTO"]; + "application/*+json": components["schemas"]["ApplicationUserDTO"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApplicationUserDTO"]; + }; + }; + /** @description Bad Request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": unknown; + }; + }; + }; + }; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/Users": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApplicationUserDTO"][]; + }; + }; + }; + }; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/Users/Roles": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": string[]; + }; + }; + }; + }; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/Users/AddUser": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json-patch+json": components["schemas"]["ApplicationUserDTO"]; + "application/json": components["schemas"]["ApplicationUserDTO"]; + "text/json": components["schemas"]["ApplicationUserDTO"]; + "application/*+json": components["schemas"]["ApplicationUserDTO"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApplicationUserDTO"]; + }; + }; + /** @description Bad Request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": unknown; + }; + }; + }; + }; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/Users/{id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + delete: { + parameters: { + query?: never; + header?: never; + path: { + id: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description No Content */ + 204: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": unknown; + }; + }; + /** @description Bad Request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": unknown; + }; + }; + /** @description Not Found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": unknown; + }; + }; + }; + }; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/WordPress/WPEvent": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["WPEventDTO"][]; + }; + }; + }; + }; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/WordPress/NextEvent": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["WPEventDTO"]; + }; + }; + }; + }; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/WordPress/GetUsers/{wpEventId}/{gameId}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: { + parameters: { + query?: never; + header?: never; + path: { + wpEventId: number; + gameId: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["WPUserDTO2"][]; + }; + }; + }; + }; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/WordPress/GetUsersOptions/{wpEventId}/{gameId}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: { + parameters: { + query?: never; + header?: never; + path: { + wpEventId: number; + gameId: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["WPUserDTO2"][]; + }; + }; + }; + }; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/WordPress/UpdateDb": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": boolean; + }; + }; + }; + }; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/WordPress/CreateChallonge/{gameId}/{wpEventId}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: { + parameters: { + query?: never; + header?: never; + path: { + gameId: number; + wpEventId: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": string; + }; + }; + }; + }; + put?: never; + post: { + parameters: { + query?: never; + header?: never; + path: { + gameId: number; + wpEventId: number; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json-patch+json": components["schemas"]["WPUser"][]; + "application/json": components["schemas"]["WPUser"][]; + "text/json": components["schemas"]["WPUser"][]; + "application/*+json": components["schemas"]["WPUser"][]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": string; + }; + }; + }; + }; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; +} +export type webhooks = Record; +export interface components { + schemas: { + ApplicationUserDTO: { + /** Format: int32 */ + id?: number; + firstName?: string | null; + lastName?: string | null; + username?: string | null; + password?: string | null; + roles?: string[] | null; + token?: string | null; + /** Format: date-time */ + expire?: string; + }; + BotEventDTO: { + /** Format: int32 */ + id?: number; + name?: string | null; + /** Format: date-time */ + date?: string; + results?: components["schemas"]["BotEventResultDTO"][] | null; + }; + BotEventResultDTO: { + /** Format: int32 */ + id?: number; + name?: string | null; + discordId?: string | null; + result?: boolean; + }; + BotEventSendDTO: { + discordId?: string | null; + discordName?: string | null; + present?: boolean; + }; + EventDTO: { + /** Format: int32 */ + id?: number; + name?: string | null; + /** Format: date-time */ + date?: string; + }; + GameDTO: { + /** Format: int32 */ + id?: number; + name?: string | null; + longName?: string | null; + /** Format: int32 */ + order?: number; + imgUrl?: string | null; + wordPressTag?: string | null; + wordPressTagOs?: string | null; + /** Format: int32 */ + smashId?: number | null; + }; + HeadToHeadDTO: { + /** Format: int32 */ + playerAId?: number; + playerA?: string | null; + /** Format: int32 */ + playerBId?: number; + playerB?: string | null; + /** Format: int32 */ + winsA?: number; + /** Format: int32 */ + winsB?: number; + }; + MatchCoverageDTO: { + /** Format: int32 */ + events?: number; + /** Format: int32 */ + brackets?: number; + /** Format: int32 */ + bracketsWithSets?: number; + /** Format: int32 */ + sets?: number; + /** Format: int32 */ + decidedSets?: number; + } | null; + MatchStatsDTO: { + coverage?: components["schemas"]["MatchCoverageDTO"]; + players?: components["schemas"]["PlayerMatchStatsDTO"][] | null; + headToHead?: components["schemas"]["HeadToHeadDTO"][] | null; + }; + ParticipentDTO: { + /** Format: int32 */ + id?: number; + /** Format: int32 */ + challongeId?: number; + name?: string | null; + /** Format: int32 */ + rank?: number; + isMember?: boolean | null; + }; + PlayerMatchStatsDTO: { + /** Format: int32 */ + playerId?: number; + player?: string | null; + /** Format: int32 */ + sets?: number; + /** Format: int32 */ + wins?: number; + /** Format: int32 */ + losses?: number; + /** Format: int32 */ + gamesWon?: number; + /** Format: int32 */ + gamesLost?: number; + }; + ResultDTO: { + /** Format: int32 */ + gameId?: number; + player?: string | null; + /** Format: int32 */ + point?: number; + /** Format: int32 */ + tournamendId?: number; + tournamentUrl?: string | null; + /** Format: int32 */ + rank?: number; + }; + TimeRangeDTO: { + /** Format: date-time */ + from?: string | null; + /** Format: date-time */ + to?: string | null; + }; + TodoDTO: { + /** Format: int32 */ + id?: number; + user?: string | null; + task?: string | null; + done?: boolean; + /** Format: date-time */ + created?: string; + /** Format: date-time */ + deleted?: string | null; + }; + TournamentDTO: { + /** Format: int32 */ + id?: number; + /** Format: int32 */ + challongeId?: number; + name?: string | null; + game?: string | null; + participents?: components["schemas"]["ParticipentDTO"][] | null; + }; + TournamentsResultDTO: { + participents?: components["schemas"]["ParticipentDTO"][] | null; + games?: components["schemas"]["GameDTO"][] | null; + results?: components["schemas"]["ResultDTO"][] | null; + slug?: string | null; + }; + WPBooking: { + /** Format: int32 */ + wpEventId?: number; + wpEvent?: components["schemas"]["WPEvent"]; + /** Format: int32 */ + wpUserId?: number; + wpUser?: components["schemas"]["WPUser2"]; + message?: string | null; + meta?: string | null; + }; + WPBookingDTO: { + wpUser?: components["schemas"]["WPUserDTO"]; + message?: string | null; + meta?: string | null; + }; + WPEvent: { + /** Format: int32 */ + id?: number; + name?: string | null; + slug?: string | null; + /** Format: date-time */ + date?: string | null; + wpBookings?: unknown[] | null; + } | null; + WPEventDTO: { + /** Format: int32 */ + id?: number; + name?: string | null; + slug?: string | null; + /** Format: date-time */ + date?: string | null; + wpBookings?: components["schemas"]["WPBookingDTO"][] | null; + }; + WPUser: { + /** Format: int32 */ + id?: number; + name?: string | null; + wpUserLogin?: string | null; + wpMail?: string | null; + wpBookings?: components["schemas"]["WPBooking"][] | null; + }; + WPUser2: { + /** Format: int32 */ + id?: number; + name?: string | null; + wpUserLogin?: string | null; + wpMail?: string | null; + wpBookings?: components["schemas"]["WPBooking"][] | null; + } | null; + WPUserDTO: { + id?: string | null; + name?: string | null; + } | null; + WPUserDTO2: { + id?: string | null; + name?: string | null; + }; + }; + responses: never; + parameters: never; + requestBodies: never; + headers: never; + pathItems: never; +} +export type $defs = Record; +export type operations = Record; diff --git a/LaDOSE.Src/LaDOSE.WebApp/src/lib/api/statistics.ts b/LaDOSE.Src/LaDOSE.WebApp/src/lib/api/statistics.ts new file mode 100644 index 0000000..bc48983 --- /dev/null +++ b/LaDOSE.Src/LaDOSE.WebApp/src/lib/api/statistics.ts @@ -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 { + return apiRequest('/api/Statistics/Matches', { + ...authed(options), + method: 'POST', + body: eventIds + }); +} diff --git a/LaDOSE.Src/LaDOSE.WebApp/src/lib/api/tournaments.ts b/LaDOSE.Src/LaDOSE.WebApp/src/lib/api/tournaments.ts new file mode 100644 index 0000000..15067b0 --- /dev/null +++ b/LaDOSE.Src/LaDOSE.WebApp/src/lib/api/tournaments.ts @@ -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 { + return apiRequest('/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 { + const path = buildPath('/api/Tournament/ParseSmash/{tournamentSlug}', { + tournamentSlug: slug + }); + return apiRequest(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 { + return apiRequest('/api/Tournament/GetResults', { + ...authed(options), + method: 'POST', + body: eventIds + }); +} diff --git a/LaDOSE.Src/LaDOSE.WebApp/src/lib/api/users.ts b/LaDOSE.Src/LaDOSE.WebApp/src/lib/api/users.ts new file mode 100644 index 0000000..359d139 --- /dev/null +++ b/LaDOSE.Src/LaDOSE.WebApp/src/lib/api/users.ts @@ -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 { + const user = await apiRequest('/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 { + await apiRequest('/Users/register', { method: 'POST', body: credentials }); +} diff --git a/LaDOSE.Src/LaDOSE.WebApp/src/lib/assets/favicon.svg b/LaDOSE.Src/LaDOSE.WebApp/src/lib/assets/favicon.svg new file mode 100644 index 0000000..cc5dc66 --- /dev/null +++ b/LaDOSE.Src/LaDOSE.WebApp/src/lib/assets/favicon.svg @@ -0,0 +1 @@ +svelte-logo \ No newline at end of file diff --git a/LaDOSE.Src/LaDOSE.WebApp/src/lib/games/draft.ts b/LaDOSE.Src/LaDOSE.WebApp/src/lib/games/draft.ts new file mode 100644 index 0000000..35a25ef --- /dev/null +++ b/LaDOSE.Src/LaDOSE.WebApp/src/lib/games/draft.ts @@ -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; +} diff --git a/LaDOSE.Src/LaDOSE.WebApp/src/lib/index.ts b/LaDOSE.Src/LaDOSE.WebApp/src/lib/index.ts new file mode 100644 index 0000000..bd9eb15 --- /dev/null +++ b/LaDOSE.Src/LaDOSE.WebApp/src/lib/index.ts @@ -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'; diff --git a/LaDOSE.Src/LaDOSE.WebApp/src/lib/statistics/aggregate.ts b/LaDOSE.Src/LaDOSE.WebApp/src/lib/statistics/aggregate.ts new file mode 100644 index 0000000..226db3c --- /dev/null +++ b/LaDOSE.Src/LaDOSE.WebApp/src/lib/statistics/aggregate.ts @@ -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; + games: Set; + firsts: number; + seconds: number; + thirds: number; + bestRank: number | null; +} + +interface GameAccumulator { + game: GameDTO; + entries: number; + players: Set; + brackets: Set; + pointsByPlayer: Map; +} + +export function aggregate(loaded: EventResult[]): Aggregate { + const players = new Map(); + const games = new Map(); + 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(); + const eventGames = new Set(); + const eventBrackets = new Set(); + + 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): 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): 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'; +} diff --git a/LaDOSE.Src/LaDOSE.WebApp/src/lib/statistics/load.ts b/LaDOSE.Src/LaDOSE.WebApp/src/lib/statistics/load.ts new file mode 100644 index 0000000..f05616f --- /dev/null +++ b/LaDOSE.Src/LaDOSE.WebApp/src/lib/statistics/load.ts @@ -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 { + 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 }; +} diff --git a/LaDOSE.Src/LaDOSE.WebApp/src/lib/stores/session.svelte.ts b/LaDOSE.Src/LaDOSE.WebApp/src/lib/stores/session.svelte.ts new file mode 100644 index 0000000..c7cfd83 --- /dev/null +++ b/LaDOSE.Src/LaDOSE.WebApp/src/lib/stores/session.svelte.ts @@ -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(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(); diff --git a/LaDOSE.Src/LaDOSE.WebApp/src/lib/stores/theme.svelte.ts b/LaDOSE.Src/LaDOSE.WebApp/src/lib/stores/theme.svelte.ts new file mode 100644 index 0000000..044bca7 --- /dev/null +++ b/LaDOSE.Src/LaDOSE.WebApp/src/lib/stores/theme.svelte.ts @@ -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(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(); diff --git a/LaDOSE.Src/LaDOSE.WebApp/src/lib/tournaments/results.ts b/LaDOSE.Src/LaDOSE.WebApp/src/lib/tournaments/results.ts new file mode 100644 index 0000000..ffe64b9 --- /dev/null +++ b/LaDOSE.Src/LaDOSE.WebApp/src/lib/tournaments/results.ts @@ -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(); + 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[] = [ + '' + ]; + + 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(''); + columns++; + // A lone game on the last row spans both columns. + const span = columns === games.length && columns % 2 !== 0 ? 2 : 1; + + parts.push( + `'); + + if (columns % 2 === 0) parts.push(''); + } + + if (columns % 2 !== 0) parts.push(''); + parts.push('
` + + '' + + `${escapeHtml(game.longName ?? game.name ?? '')} (${forGame.length} participants) :` + + '' + ); + parts.push( + '
' + top3.map((player, i) => ` ${i + 1}/ ${escapeHtml(player)}
`).join('') + ); + + const tournamentUrl = forGame[0]?.tournamentUrl; + if (result.slug && tournamentUrl) { + const href = `https://start.gg/tournament/${result.slug}/event/${bracketSlug(tournamentUrl)}`; + parts.push(`Voir le Bracket`); + } + parts.push('
'); + + 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'; +} diff --git a/LaDOSE.Src/LaDOSE.WebApp/src/lib/ui/AttendanceChart.svelte b/LaDOSE.Src/LaDOSE.WebApp/src/lib/ui/AttendanceChart.svelte new file mode 100644 index 0000000..7c6898f --- /dev/null +++ b/LaDOSE.Src/LaDOSE.WebApp/src/lib/ui/AttendanceChart.svelte @@ -0,0 +1,220 @@ + + +
+ {#if data.length === 0} +

No event in this scope.

+ {:else} + + + {#each ticks as tick (tick)} + {@const y = plot.y + plot.h - (scaleMax === 0 ? 0 : (tick / scaleMax) * plot.h)} + + + {tick} + + {/each} + + {#each bars as bar, index (bar.entry.eventId)} + + {/each} + + {#each bars as bar, index (bar.entry.eventId)} + {#if showLabel(index)} + + {bar.label} + + {/if} + {/each} + + + {#each bars as bar, index (bar.entry.eventId)} + (hovered = index)} + onmouseleave={() => (hovered = null)} + role="presentation" + /> + {/each} + + + {#if active} +
+

{active.entry.name}

+ {#if formatMonth(active.entry.date)} +

{formatMonth(active.entry.date)}

+ {/if} +
+
+
Players
+
{active.entry.players}
+
+
+
Entries
+
{active.entry.entries}
+
+
+
Brackets
+
{active.entry.brackets}
+
+
+
+ {/if} + {/if} +
diff --git a/LaDOSE.Src/LaDOSE.WebApp/src/lib/ui/Navbar.svelte b/LaDOSE.Src/LaDOSE.WebApp/src/lib/ui/Navbar.svelte new file mode 100644 index 0000000..9f4d15f --- /dev/null +++ b/LaDOSE.Src/LaDOSE.WebApp/src/lib/ui/Navbar.svelte @@ -0,0 +1,227 @@ + + + + Skip to content + + +
+
+ {#if showNav} + + LaDOSE + + + + + +
+ + + + + +
+ + + {#if openMenu === 'mobile'} + + {/if} +
+
+ {:else} + +
+ +
+ {/if} +
+
diff --git a/LaDOSE.Src/LaDOSE.WebApp/src/lib/ui/ThemeToggle.svelte b/LaDOSE.Src/LaDOSE.WebApp/src/lib/ui/ThemeToggle.svelte new file mode 100644 index 0000000..5856f23 --- /dev/null +++ b/LaDOSE.Src/LaDOSE.WebApp/src/lib/ui/ThemeToggle.svelte @@ -0,0 +1,54 @@ + + + diff --git a/LaDOSE.Src/LaDOSE.WebApp/src/lib/ui/classes.ts b/LaDOSE.Src/LaDOSE.WebApp/src/lib/ui/classes.ts new file mode 100644 index 0000000..b9919fe --- /dev/null +++ b/LaDOSE.Src/LaDOSE.WebApp/src/lib/ui/classes.ts @@ -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'; diff --git a/LaDOSE.Src/LaDOSE.WebApp/src/routes/+layout.svelte b/LaDOSE.Src/LaDOSE.WebApp/src/routes/+layout.svelte new file mode 100644 index 0000000..1f0edfb --- /dev/null +++ b/LaDOSE.Src/LaDOSE.WebApp/src/routes/+layout.svelte @@ -0,0 +1,21 @@ + + + + + + + +
+ + {@render children()} +
diff --git a/LaDOSE.Src/LaDOSE.WebApp/src/routes/+layout.ts b/LaDOSE.Src/LaDOSE.WebApp/src/routes/+layout.ts new file mode 100644 index 0000000..0cf9da5 --- /dev/null +++ b/LaDOSE.Src/LaDOSE.WebApp/src/routes/+layout.ts @@ -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; diff --git a/LaDOSE.Src/LaDOSE.WebApp/src/routes/+page.svelte b/LaDOSE.Src/LaDOSE.WebApp/src/routes/+page.svelte new file mode 100644 index 0000000..0b605c8 --- /dev/null +++ b/LaDOSE.Src/LaDOSE.WebApp/src/routes/+page.svelte @@ -0,0 +1,66 @@ + + + + LaDOSE + + +
+ {#if session.user} +

Hello, {session.displayName}.

+

You are signed in to LaDOSE.

+ +
+ {#each visible as shortcut (shortcut.href)} + +

{shortcut.title}

+

{shortcut.description}

+
+ {/each} +
+ {/if} +
diff --git a/LaDOSE.Src/LaDOSE.WebApp/src/routes/games/+page.svelte b/LaDOSE.Src/LaDOSE.WebApp/src/routes/games/+page.svelte new file mode 100644 index 0000000..ad4ee7f --- /dev/null +++ b/LaDOSE.Src/LaDOSE.WebApp/src/routes/games/+page.svelte @@ -0,0 +1,352 @@ + + + + Games · LaDOSE + + +
+
+

Games

+

+ The catalogue behind rankings, WordPress tags and start.gg bracket matching. +

+
+ + {#if error} + + {/if} + {#if notice} +

{notice}

+ {/if} + +
+
+
+

+ Catalogue + ({games.length}) +

+ +
+ +
    + {#each ordered as game (game.id)} +
  • + +
  • + {:else} +
  • + {loading ? 'Loading…' : 'No game yet.'} +
  • + {/each} +
+ + +
+ +
+
+

+ {isNew ? 'New game' : `Editing #${draft.id}`} +

+ {#if dirty} + + Unsaved changes + + {/if} +
+ +
{ + event.preventDefault(); + void save(); + }} + > +
+ + +

Short label shown in ranking columns.

+
+ +
+ + +

Sorts lists and the HTML recap.

+
+ +
+ + +

+ Used as the podium heading, and as the start.gg search term below. +

+
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ +
+ + +
+

+ Imports match brackets on this id — without it, results land under "GAME NOT FOUND". +

+ + {#if smashMatches?.length} +
    + {#each smashMatches as match (match.id)} +
  • + +
  • + {/each} +
+ {/if} +
+ +
+ + + +
+
+
+
+
diff --git a/LaDOSE.Src/LaDOSE.WebApp/src/routes/login/+page.svelte b/LaDOSE.Src/LaDOSE.WebApp/src/routes/login/+page.svelte new file mode 100644 index 0000000..bca0a7a --- /dev/null +++ b/LaDOSE.Src/LaDOSE.WebApp/src/routes/login/+page.svelte @@ -0,0 +1,99 @@ + + + + Sign in · LaDOSE + + +
+
+
+

LaDOSE

+

Sign in to manage tournaments and events.

+
+ +
+
+ + +
+ +
+ + +
+ + {#if error} + + {/if} + + +
+
+
diff --git a/LaDOSE.Src/LaDOSE.WebApp/src/routes/statistiques/+page.svelte b/LaDOSE.Src/LaDOSE.WebApp/src/routes/statistiques/+page.svelte new file mode 100644 index 0000000..2ff467f --- /dev/null +++ b/LaDOSE.Src/LaDOSE.WebApp/src/routes/statistiques/+page.svelte @@ -0,0 +1,590 @@ + + + + Statistiques · LaDOSE + + +
+
+

Statistiques

+

+ Pick a scope — a season, a year, everything — and see who turned up, who won, and how + the games compare. +

+
+ + {#if error} + + {/if} + +
+
+

+ Scope + + ({selectedIds.length} of {events.length} events) + +

+ +
+ +
+ + + + +
+ + +
+
+ +
    + {#each events as event (event.id)} +
  • + +
  • + {:else} +
  • + {loadingEvents ? 'Loading events…' : 'No event imported yet.'} +
  • + {/each} +
+ +
+ + {#if loading} + Scoring event {progress.done} of {progress.total}… + {:else} + One request per event, so a broken import costs only its own row. + {/if} + + +
+
+ + {#if failed.length} +
+

+ {failed.length} event{failed.length === 1 ? '' : 's'} skipped — everything below excludes + {failed.length === 1 ? 'it' : 'them'}. +

+
    + {#each failed as entry (entry.event.id)} +
  • {entry.event.name ?? `#${entry.event.id}`} — {entry.message}
  • + {/each} +
+
+ {/if} + + {#if stats} + +
+ {#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)} +
+

{caption}

+

+ {value.toLocaleString()} +

+
+ {/each} +
+ +
+

Unique players per event

+

+ Oldest first. The same numbers are in the Events tab below. +

+
+ +
+
+ +
+
+
+ {#each tabs as [id, label] (id)} + + {/each} +
+

+ {loadedIds.length} event{loadedIds.length === 1 ? '' : 's'} in scope +

+
+ + {#if tab === 'standings'} +
+ + + + + + + + + + + + + + + + + {#each standings as row, index (row.player)} + + + + + + + + + + + + + {/each} + +
#PlayerPointsEventsEntriesGames1st2nd3rdBest
{index + 1}{row.player}{row.points}{row.events}{row.entries}{row.games} + {row.firsts} + + {row.seconds} + + {row.thirds} + {row.bestRank ?? '—'}
+
+
+

+ {podium.length} of {standings.length} players reached a podium. +

+ +
+ {:else if tab === 'games'} +
+ + + + + + + + + + + + + {#each stats.games as game (game.gameId)} + + + + + + + + + {/each} + +
GameBracketsPlayersEntriesAvg fieldTop player
+ {game.name} + {#if game.longName && game.longName !== game.name} + {game.longName} + {/if} + {game.brackets}{game.players}{game.entries}{game.averageField.toFixed(1)} + {#if game.topPlayer} + {game.topPlayer} + · {game.topPoints} pts + {:else} + + {/if} +
+
+ {:else if tab === 'events'} +
+ + + + + + + + + + + + + {#each stats.attendance as entry (entry.eventId)} + + + + + + + + + {/each} + +
EventWhenPlayersEntriesGamesBrackets
{entry.name}{formatMonth(entry.date) || '—'}{entry.players}{entry.entries}{entry.games}{entry.brackets}
+
+ {:else if matches} + {@const coverage = matches.coverage} + +

+ {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. +

+ + {#if !players.length} +

+ No decided set in this scope. Re-import an event to populate its matches. +

+ {:else} +
+
+

Set win rate

+
+ + + + + + + + + + + + + {#each players as row (row.playerId)} + + + + + + + + + {/each} + +
PlayerSetsWLGamesWin rate
{row.player}{row.sets}{row.wins}{row.losses} + {row.gamesWon}–{row.gamesLost} + + {percent(winRate(row))} +
+
+
+ +
+

Head to head

+

Most-played pairings first.

+
    + {#each headToHead as row (`${row.playerAId}-${row.playerBId}`)} + {@const total = played(row)} + {@const shareA = total === 0 ? 0 : ((row.winsA ?? 0) / total) * 100} +
  • +
    + + = (row.winsB ?? 0) ? 'font-semibold' : ''}> + {row.playerA} + + vs + (row.winsA ?? 0) ? 'font-semibold' : ''}> + {row.playerB} + + + + {row.winsA}–{row.winsB} + +
    + + +
  • + {:else} +
  • + No pairing met twice in this scope. +
  • + {/each} +
+
+
+ {/if} + {:else} +

+ Match statistics are unavailable for this scope — the endpoint could not be reached. +

+ {/if} +
+ {/if} +
diff --git a/LaDOSE.Src/LaDOSE.WebApp/src/routes/tournaments/+page.svelte b/LaDOSE.Src/LaDOSE.WebApp/src/routes/tournaments/+page.svelte new file mode 100644 index 0000000..ab5332f --- /dev/null +++ b/LaDOSE.Src/LaDOSE.WebApp/src/routes/tournaments/+page.svelte @@ -0,0 +1,422 @@ + + + + Tournaments · LaDOSE + + +
+
+

Tournaments

+

+ Import a start.gg tournament, then score one event or a whole ranking season. +

+
+ + {#if error} + + {/if} + {#if notice} +

{notice}

+ {/if} + +
+
+

+ Import from start.gg +

+

+ The slug is the tail of the tournament URL — + start.gg/tournament/ranking-130. Every bracket + must be finished. +

+ +
{ + event.preventDefault(); + void importSlug(); + }} + > + + +
+
+ +
+
+

+ Events + ({events.length}) +

+ +
+ +
+ + +
+ +
    + {#each events as event (event.id)} +
  • + +
  • + {:else} +
  • + {loadingEvents ? 'Loading events…' : 'No event imported yet.'} +
  • + {/each} +
+ +
+ + {selectedIds.length} selected + {#if selectedIds.length > 1}· bracket links need a single event{/if} + +
+ + +
+
+
+
+ + {#if results} +
+
+
+ {#each [['ranking', 'Ranking'], ['game', 'By game'], ['html', 'HTML']] as const as [id, label] (id)} + + {/each} +
+

+ {ranking.rows.length} players · {games.length} games · {results.results?.length ?? 0} placements +

+
+ + {#if tab === 'ranking'} +
+ + + + + + {#each ranking.games as game (game.id)} + + {/each} + + + + + {#each ranking.rows as row, index (row.player)} + + + + {#each row.points as point, i (ranking.games[i].id)} + + {/each} + + + {/each} + +
#Player{game.name}Total
{index + 1}{row.player} + {point} + {row.total}
+
+ + {:else if tab === 'game'} +
+
    + {#each games as game (game.id)} +
  • + +
  • + {/each} +
+ +
+ {#if selectedGame} +

+ {selectedGame.longName ?? selectedGame.name} + + ({gameResults.length} participants) + +

+
    + {#each gameResults as result (result.player)} +
  1. + + {result.rank === 999 ? '—' : result.rank} + + {result.player} + {result.point} pts +
  2. + {/each} +
+ {:else} +

Pick a game to see its placements.

+ {/if} +
+
+ {:else} +
+
+

+ {#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} +

+ +
+ + +
+

+ Preview (as it appears on ladose.net) +

+ +
+ {@html html} +
+
+
+ {/if} +
+ {/if} +
diff --git a/LaDOSE.Src/LaDOSE.WebApp/src/routes/users/+page.svelte b/LaDOSE.Src/LaDOSE.WebApp/src/routes/users/+page.svelte new file mode 100644 index 0000000..8de5050 --- /dev/null +++ b/LaDOSE.Src/LaDOSE.WebApp/src/routes/users/+page.svelte @@ -0,0 +1,293 @@ + + + + Users · LaDOSE + + +
+
+

Users

+

+ Accounts that can sign in. Admins may also manage this list. +

+
+ + {#if error} + + {/if} + {#if notice} +

{notice}

+ {/if} + +
+
+

+ Accounts + ({users.length}) +

+ +
+ +
+ + + + + + + + + + + {#each users as user (user.id)} + + + + + + + {:else} + + + + {/each} + +
UsernameNameRoles
+ {user.username} + {#if user.id === session.user?.id} + (you) + {/if} + {fullName(user) || '—'} + {#if user.roles?.length} + {#each user.roles as role (role)} + + {role} + + {/each} + {:else} + none + {/if} + + {#if user.id === session.user?.id} + + can't delete yourself + {:else} + + {/if} +
+ {loading ? 'Loading…' : 'No account.'} +
+
+
+ +
+

Add a user

+ +
{ + event.preventDefault(); + void create(); + }} + > +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ Roles + {#if roles.length} +
+ {#each roles as role (role)} + + {/each} +
+

+ No role still signs in and uses everything else — only this page needs Admin. +

+ {:else} +

+ No role exists in the database yet. Run Sql/2026-08-05_roles.sql to seed + Admin and User. +

+ {/if} +
+ +
+ +
+
+
+
diff --git a/LaDOSE.Src/LaDOSE.WebApp/static/config.js b/LaDOSE.Src/LaDOSE.WebApp/static/config.js new file mode 100644 index 0000000..1e867cc --- /dev/null +++ b/LaDOSE.Src/LaDOSE.WebApp/static/config.js @@ -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