This commit is contained in:
@@ -0,0 +1,21 @@
|
||||
<script lang="ts">
|
||||
import favicon from '$lib/assets/favicon.svg';
|
||||
import Navbar from '$lib/ui/Navbar.svelte';
|
||||
import '../app.css';
|
||||
|
||||
let { children } = $props();
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<link rel="icon" href={favicon} />
|
||||
</svelte:head>
|
||||
|
||||
<!--
|
||||
The page background lives on html/body in app.css, not on a class here: with
|
||||
ssr = false this element does not exist until the bundle hydrates, so painting
|
||||
the canvas from it would flash white on every cold load of every route.
|
||||
-->
|
||||
<div class="antialiased">
|
||||
<Navbar />
|
||||
{@render children()}
|
||||
</div>
|
||||
@@ -0,0 +1,4 @@
|
||||
// The app is a static SPA in front of LaDOSE.Api: rendering on the server would
|
||||
// have no access to the browser-held JWT, so everything runs client-side.
|
||||
export const ssr = false;
|
||||
export const prerender = false;
|
||||
@@ -0,0 +1,66 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { session } from '$lib/stores/session.svelte';
|
||||
import { card } from '$lib/ui/classes';
|
||||
|
||||
// Guard the page: no session (or an expired JWT) sends the user to /login.
|
||||
$effect(() => {
|
||||
if (!session.isLoggedIn) goto('/login', { replaceState: true });
|
||||
});
|
||||
|
||||
interface Shortcut {
|
||||
href: string;
|
||||
title: string;
|
||||
description: string;
|
||||
adminOnly?: boolean;
|
||||
}
|
||||
|
||||
const shortcuts: Shortcut[] = [
|
||||
{
|
||||
href: '/tournaments',
|
||||
title: 'Tournaments',
|
||||
description: 'Import a start.gg tournament and score one event or a whole ranking season.'
|
||||
},
|
||||
{
|
||||
href: '/statistiques',
|
||||
title: 'Statistiques',
|
||||
description: 'Leaderboards, attendance over time and head-to-head records.'
|
||||
},
|
||||
{
|
||||
href: '/games',
|
||||
title: 'Games',
|
||||
description: 'The catalogue behind rankings, WordPress tags and bracket matching.'
|
||||
},
|
||||
{
|
||||
href: '/users',
|
||||
title: 'Users',
|
||||
description: 'Add and remove the accounts that can sign in.',
|
||||
adminOnly: true
|
||||
}
|
||||
];
|
||||
|
||||
const visible = $derived(shortcuts.filter((s) => !s.adminOnly || session.isAdmin));
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>LaDOSE</title>
|
||||
</svelte:head>
|
||||
|
||||
<main id="main" class="mx-auto max-w-4xl px-4 py-12">
|
||||
{#if session.user}
|
||||
<h1 class="text-4xl font-semibold tracking-tight">Hello, {session.displayName}.</h1>
|
||||
<p class="mt-3 text-sm text-muted">You are signed in to LaDOSE.</p>
|
||||
|
||||
<div class="mt-10 grid gap-4 sm:grid-cols-2">
|
||||
{#each visible as shortcut (shortcut.href)}
|
||||
<a
|
||||
href={shortcut.href}
|
||||
class="{card} block transition hover:border-accent focus:outline-none focus-visible:ring-2 focus-visible:ring-accent"
|
||||
>
|
||||
<h2 class="text-base font-semibold text-ink">{shortcut.title}</h2>
|
||||
<p class="mt-1 text-sm text-muted">{shortcut.description}</p>
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</main>
|
||||
@@ -0,0 +1,352 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { deleteGame, listGames, saveGame, searchSmashGames } from '$lib/api/games';
|
||||
import { toErrorMessage } from '$lib/api/errors';
|
||||
import type { GameDTO } from '$lib/api/schema-helpers';
|
||||
import { blankDraft, nextOrder, toDraft, toDto, type Draft } from '$lib/games/draft';
|
||||
import { session } from '$lib/stores/session.svelte';
|
||||
import {
|
||||
alertError,
|
||||
alertNotice,
|
||||
card,
|
||||
cardHeading,
|
||||
danger,
|
||||
field,
|
||||
ghost,
|
||||
label,
|
||||
listRow,
|
||||
listRowSelected,
|
||||
primary
|
||||
} from '$lib/ui/classes';
|
||||
|
||||
let games = $state<GameDTO[]>([]);
|
||||
let draft = $state<Draft>({ ...blankDraft });
|
||||
let pristine = $state(JSON.stringify(blankDraft));
|
||||
let smashMatches = $state<GameDTO[] | null>(null);
|
||||
|
||||
let loading = $state(false);
|
||||
let saving = $state(false);
|
||||
let deleting = $state(false);
|
||||
let searching = $state(false);
|
||||
let notice = $state<string | null>(null);
|
||||
let error = $state<string | null>(null);
|
||||
|
||||
const ordered = $derived([...games].sort((a, b) => (a.order ?? 0) - (b.order ?? 0)));
|
||||
const isNew = $derived(draft.id === 0);
|
||||
const dirty = $derived(JSON.stringify(draft) !== pristine);
|
||||
const canSave = $derived(draft.name.trim() !== '' && !saving);
|
||||
/** The provider searches start.gg by name; the long name is the one that matches. */
|
||||
const searchTerm = $derived(draft.longName.trim() || draft.name.trim());
|
||||
|
||||
let started = false;
|
||||
$effect(() => {
|
||||
if (!session.isLoggedIn) {
|
||||
goto('/login', { replaceState: true });
|
||||
return;
|
||||
}
|
||||
if (!started) {
|
||||
started = true;
|
||||
void refresh();
|
||||
}
|
||||
});
|
||||
|
||||
function report(cause: unknown, fallback: string) {
|
||||
error = toErrorMessage(cause, fallback);
|
||||
}
|
||||
|
||||
function load(game: GameDTO) {
|
||||
draft = toDraft(game);
|
||||
pristine = JSON.stringify(draft);
|
||||
smashMatches = null;
|
||||
}
|
||||
|
||||
async function refresh(keepId = draft.id) {
|
||||
loading = true;
|
||||
error = null;
|
||||
try {
|
||||
games = await listGames();
|
||||
// Re-read the edited game so the form shows what the server actually stored.
|
||||
const current = games.find((game) => game.id === keepId);
|
||||
if (current) load(current);
|
||||
else if (keepId !== 0) reset();
|
||||
} catch (cause) {
|
||||
report(cause, 'Could not load the game list.');
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function reset() {
|
||||
draft = { ...blankDraft, order: nextOrder(games) };
|
||||
pristine = JSON.stringify(draft);
|
||||
smashMatches = null;
|
||||
}
|
||||
|
||||
function select(game: GameDTO) {
|
||||
if (game.id === draft.id) return;
|
||||
if (dirty && !confirm('Discard the unsaved changes to this game?')) return;
|
||||
load(game);
|
||||
notice = null;
|
||||
error = null;
|
||||
}
|
||||
|
||||
function startNew() {
|
||||
if (dirty && !confirm('Discard the unsaved changes to this game?')) return;
|
||||
reset();
|
||||
notice = null;
|
||||
error = null;
|
||||
}
|
||||
|
||||
async function save() {
|
||||
if (!canSave) return;
|
||||
|
||||
saving = true;
|
||||
error = null;
|
||||
notice = null;
|
||||
const creating = isNew;
|
||||
try {
|
||||
const saved = await saveGame(toDto(draft));
|
||||
notice = creating ? `Created "${saved.name}".` : `Saved "${saved.name}".`;
|
||||
await refresh(saved.id ?? 0);
|
||||
} catch (cause) {
|
||||
report(cause, 'Could not save this game.');
|
||||
} finally {
|
||||
saving = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function remove() {
|
||||
if (isNew || deleting) return;
|
||||
if (!confirm(`Delete "${draft.name}"? This cannot be undone.`)) return;
|
||||
|
||||
deleting = true;
|
||||
error = null;
|
||||
notice = null;
|
||||
try {
|
||||
await deleteGame(draft.id);
|
||||
notice = `Deleted "${draft.name}".`;
|
||||
reset();
|
||||
await refresh(0);
|
||||
} catch (cause) {
|
||||
// The service swallows DbUpdateException and answers 404, so an FK clash
|
||||
// and a missing row are indistinguishable from here.
|
||||
report(
|
||||
cause,
|
||||
'Delete failed — the game may already be gone, or still be attached to tournaments.'
|
||||
);
|
||||
} finally {
|
||||
deleting = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function findOnSmash() {
|
||||
if (searchTerm === '' || searching) return;
|
||||
|
||||
searching = true;
|
||||
error = null;
|
||||
try {
|
||||
smashMatches = await searchSmashGames(searchTerm);
|
||||
if (smashMatches.length === 0) notice = `start.gg has no game matching "${searchTerm}".`;
|
||||
} catch (cause) {
|
||||
report(cause, `Could not search start.gg for "${searchTerm}".`);
|
||||
} finally {
|
||||
searching = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Games · LaDOSE</title>
|
||||
</svelte:head>
|
||||
|
||||
<main id="main" class="mx-auto max-w-5xl px-4 py-10">
|
||||
<header class="mb-8">
|
||||
<h1 class="text-3xl font-semibold tracking-tight">Games</h1>
|
||||
<p class="mt-1 text-sm text-muted">
|
||||
The catalogue behind rankings, WordPress tags and start.gg bracket matching.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{#if error}
|
||||
<p role="alert" class="{alertError} mb-4">
|
||||
{error}
|
||||
</p>
|
||||
{/if}
|
||||
{#if notice}
|
||||
<p class="{alertNotice} mb-4">{notice}</p>
|
||||
{/if}
|
||||
|
||||
<div class="grid gap-6 md:grid-cols-[18rem_1fr] md:items-start">
|
||||
<section class={card}>
|
||||
<div class="flex items-baseline justify-between gap-3">
|
||||
<h2 class={cardHeading}>
|
||||
Catalogue
|
||||
<span class="ml-1 font-normal text-muted normal-case">({games.length})</span>
|
||||
</h2>
|
||||
<button class={ghost} onclick={() => refresh()} disabled={loading}>
|
||||
{loading ? '…' : 'Reload'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<ul class="mt-4 max-h-[26rem] space-y-1 overflow-y-auto pr-1 text-sm">
|
||||
{#each ordered as game (game.id)}
|
||||
<li>
|
||||
<button
|
||||
onclick={() => select(game)}
|
||||
class="{draft.id === game.id ? listRowSelected : listRow} flex items-center gap-3"
|
||||
>
|
||||
<span class="w-6 shrink-0 text-right text-xs text-subtle">{game.order}</span>
|
||||
<span class="truncate">{game.name}</span>
|
||||
</button>
|
||||
</li>
|
||||
{:else}
|
||||
<li class="px-2 py-6 text-center text-subtle">
|
||||
{loading ? 'Loading…' : 'No game yet.'}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
|
||||
<button class="{primary} mt-4 w-full" onclick={startNew}>New game</button>
|
||||
</section>
|
||||
|
||||
<section class={card}>
|
||||
<div class="flex flex-wrap items-baseline justify-between gap-3">
|
||||
<h2 class={cardHeading}>
|
||||
{isNew ? 'New game' : `Editing #${draft.id}`}
|
||||
</h2>
|
||||
{#if dirty}
|
||||
<span class="rounded-full bg-warning-soft px-2 py-0.5 text-xs text-warning">
|
||||
Unsaved changes
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<form
|
||||
class="mt-5 grid gap-4 sm:grid-cols-2"
|
||||
onsubmit={(event) => {
|
||||
event.preventDefault();
|
||||
void save();
|
||||
}}
|
||||
>
|
||||
<div class="space-y-1.5">
|
||||
<label class={label} for="game-name">Name</label>
|
||||
<input id="game-name" bind:value={draft.name} class={field} placeholder="SF6" required />
|
||||
<p class="text-xs text-subtle">Short label shown in ranking columns.</p>
|
||||
</div>
|
||||
|
||||
<div class="space-y-1.5">
|
||||
<label class={label} for="game-order">Order</label>
|
||||
<input id="game-order" type="number" bind:value={draft.order} class={field} />
|
||||
<p class="text-xs text-subtle">Sorts lists and the HTML recap.</p>
|
||||
</div>
|
||||
|
||||
<div class="space-y-1.5 sm:col-span-2">
|
||||
<label class={label} for="game-longname">Long name</label>
|
||||
<input
|
||||
id="game-longname"
|
||||
bind:value={draft.longName}
|
||||
class={field}
|
||||
placeholder="Street Fighter 6"
|
||||
/>
|
||||
<p class="text-xs text-subtle">
|
||||
Used as the podium heading, and as the start.gg search term below.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="space-y-1.5">
|
||||
<label class={label} for="game-wptag">WordPress tag</label>
|
||||
<input id="game-wptag" bind:value={draft.wordPressTag} class={field} />
|
||||
</div>
|
||||
|
||||
<div class="space-y-1.5">
|
||||
<label class={label} for="game-wptagos">WordPress tag (OS)</label>
|
||||
<input id="game-wptagos" bind:value={draft.wordPressTagOs} class={field} />
|
||||
</div>
|
||||
|
||||
<div class="space-y-1.5 sm:col-span-2">
|
||||
<label class={label} for="game-img">Image URL</label>
|
||||
<input
|
||||
id="game-img"
|
||||
bind:value={draft.imgUrl}
|
||||
class={field}
|
||||
placeholder="https://ladose.net/…"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="space-y-1.5 sm:col-span-2">
|
||||
<label class={label} for="game-smashid">start.gg videogame id</label>
|
||||
<div class="flex gap-2">
|
||||
<input
|
||||
id="game-smashid"
|
||||
type="number"
|
||||
bind:value={draft.smashId}
|
||||
class={field}
|
||||
placeholder="none"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="{ghost} shrink-0"
|
||||
onclick={findOnSmash}
|
||||
disabled={searching || searchTerm === ''}
|
||||
>
|
||||
{searching ? 'Searching…' : 'Find on start.gg'}
|
||||
</button>
|
||||
</div>
|
||||
<p class="text-xs text-subtle">
|
||||
Imports match brackets on this id — without it, results land under "GAME NOT FOUND".
|
||||
</p>
|
||||
|
||||
{#if smashMatches?.length}
|
||||
<ul class="mt-2 max-h-40 space-y-1 overflow-y-auto rounded-lg bg-inset p-1">
|
||||
{#each smashMatches as match (match.id)}
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (draft.smashId = match.id ?? null)}
|
||||
class="flex w-full items-center gap-3 rounded px-2 py-1.5 text-left text-sm transition hover:bg-ink/5 {draft.smashId ===
|
||||
match.id
|
||||
? 'text-accent'
|
||||
: 'text-ink'}"
|
||||
>
|
||||
<span class="w-14 shrink-0 text-right text-xs text-subtle">
|
||||
{match.id}
|
||||
</span>
|
||||
<span class="truncate">{match.name}</span>
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="flex flex-wrap items-center gap-3 border-t border-line pt-4 sm:col-span-2"
|
||||
>
|
||||
<button type="submit" class={primary} disabled={!canSave}>
|
||||
{saving ? 'Saving…' : isNew ? 'Create game' : 'Save'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class={ghost}
|
||||
onclick={() => {
|
||||
const current = games.find((game) => game.id === draft.id);
|
||||
if (current) load(current);
|
||||
else reset();
|
||||
}}
|
||||
disabled={!dirty}
|
||||
>
|
||||
Revert
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="{danger} ml-auto"
|
||||
onclick={remove}
|
||||
disabled={isNew || deleting}
|
||||
>
|
||||
{deleting ? 'Deleting…' : 'Delete'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
@@ -0,0 +1,99 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { ApiError } from '$lib/api/client';
|
||||
import { login } from '$lib/api/users';
|
||||
import { session } from '$lib/stores/session.svelte';
|
||||
import { alertError, field, label, primary } from '$lib/ui/classes';
|
||||
|
||||
let username = $state('');
|
||||
let password = $state('');
|
||||
let error = $state<string | null>(null);
|
||||
let submitting = $state(false);
|
||||
|
||||
const canSubmit = $derived(username.trim() !== '' && password !== '' && !submitting);
|
||||
|
||||
async function handleSubmit(event: SubmitEvent) {
|
||||
event.preventDefault();
|
||||
if (!canSubmit) return;
|
||||
|
||||
submitting = true;
|
||||
error = null;
|
||||
|
||||
try {
|
||||
session.start(await login({ username: username.trim(), password }));
|
||||
await goto('/');
|
||||
} catch (cause) {
|
||||
// A 400 from /Users/auth means bad credentials; status 0 means the API is unreachable.
|
||||
error =
|
||||
cause instanceof ApiError
|
||||
? cause.message
|
||||
: 'Something went wrong while signing in. Please try again.';
|
||||
password = '';
|
||||
} finally {
|
||||
submitting = false;
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (session.isLoggedIn) goto('/');
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Sign in · LaDOSE</title>
|
||||
</svelte:head>
|
||||
|
||||
<main
|
||||
id="main"
|
||||
class="flex min-h-[calc(100svh-var(--nav-h))] items-center justify-center px-4 py-12"
|
||||
>
|
||||
<div class="w-full max-w-sm">
|
||||
<div class="mb-8 text-center">
|
||||
<h1 class="text-3xl font-semibold tracking-tight">LaDOSE</h1>
|
||||
<p class="mt-2 text-sm text-muted">Sign in to manage tournaments and events.</p>
|
||||
</div>
|
||||
|
||||
<form
|
||||
onsubmit={handleSubmit}
|
||||
class="space-y-5 rounded-2xl border border-line bg-surface p-6 shadow-card backdrop-blur"
|
||||
>
|
||||
<div class="space-y-2">
|
||||
<label for="username" class={label}>Username</label>
|
||||
<input
|
||||
id="username"
|
||||
name="username"
|
||||
type="text"
|
||||
autocomplete="username"
|
||||
required
|
||||
disabled={submitting}
|
||||
bind:value={username}
|
||||
class={field}
|
||||
placeholder="your.username"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<label for="password" class={label}>Password</label>
|
||||
<input
|
||||
id="password"
|
||||
name="password"
|
||||
type="password"
|
||||
autocomplete="current-password"
|
||||
required
|
||||
disabled={submitting}
|
||||
bind:value={password}
|
||||
class={field}
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{#if error}
|
||||
<p role="alert" class={alertError}>{error}</p>
|
||||
{/if}
|
||||
|
||||
<button type="submit" disabled={!canSubmit} class="{primary} w-full py-2.5">
|
||||
{submitting ? 'Signing in…' : 'Sign in'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</main>
|
||||
@@ -0,0 +1,590 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { ApiError } from '$lib/api/client';
|
||||
import { toErrorMessage } from '$lib/api/errors';
|
||||
import type { EventDTO, MatchStatsDTO } from '$lib/api/schema-helpers';
|
||||
import { getMatchStats } from '$lib/api/statistics';
|
||||
import { listEvents } from '$lib/api/tournaments';
|
||||
import { session } from '$lib/stores/session.svelte';
|
||||
import { aggregate, formatMonth, standingsCsv, type Aggregate } from '$lib/statistics/aggregate';
|
||||
import { loadEventResults, type FailedEvent } from '$lib/statistics/load';
|
||||
import AttendanceChart from '$lib/ui/AttendanceChart.svelte';
|
||||
import {
|
||||
alertError,
|
||||
alertWarning,
|
||||
card,
|
||||
cardHeading,
|
||||
field,
|
||||
ghost,
|
||||
primary,
|
||||
tab as tabClass,
|
||||
tabActive as tabActiveClass
|
||||
} from '$lib/ui/classes';
|
||||
|
||||
/*
|
||||
* Two independent sources, deliberately kept apart:
|
||||
*
|
||||
* - points, placements and attendance come from GetResults, one event at a
|
||||
* time (see $lib/statistics/load), and are reshaped by aggregate();
|
||||
* - set-level win/loss and head to head come from the Statistics endpoint in
|
||||
* one call.
|
||||
*
|
||||
* The second one is far patchier than the first — brackets imported before sets
|
||||
* were persisted have placements but no matches — so it lives in its own tab
|
||||
* behind its own coverage line rather than being mixed into the standings.
|
||||
*/
|
||||
|
||||
let events = $state<EventDTO[]>([]);
|
||||
let selectedIds = $state<number[]>([]);
|
||||
let pattern = $state('');
|
||||
|
||||
let stats = $state<Aggregate | null>(null);
|
||||
let matches = $state<MatchStatsDTO | null>(null);
|
||||
let failed = $state<FailedEvent[]>([]);
|
||||
/** Ids the loaded figures actually describe, so the header cannot drift. */
|
||||
let loadedIds = $state<number[]>([]);
|
||||
|
||||
let tab = $state<'standings' | 'games' | 'events' | 'matches'>('standings');
|
||||
let loadingEvents = $state(false);
|
||||
let loading = $state(false);
|
||||
let progress = $state({ done: 0, total: 0 });
|
||||
let error = $state<string | null>(null);
|
||||
|
||||
let inFlight: AbortController | null = null;
|
||||
|
||||
const standings = $derived(stats?.standings ?? []);
|
||||
const podium = $derived(standings.filter((row) => row.podiums > 0));
|
||||
|
||||
/** Decided sets only, so a 0-0 player cannot claim a 0% win rate. */
|
||||
const players = $derived(
|
||||
[...(matches?.players ?? [])]
|
||||
.filter((row) => (row.sets ?? 0) > 0)
|
||||
.sort((a, b) => winRate(b) - winRate(a) || (b.sets ?? 0) - (a.sets ?? 0))
|
||||
);
|
||||
|
||||
const headToHead = $derived(
|
||||
[...(matches?.headToHead ?? [])].sort(
|
||||
(a, b) => played(b) - played(a) || (a.playerA ?? '').localeCompare(b.playerA ?? '')
|
||||
)
|
||||
);
|
||||
|
||||
function winRate(row: { wins?: number | null; sets?: number | null }): number {
|
||||
const sets = row.sets ?? 0;
|
||||
return sets === 0 ? 0 : ((row.wins ?? 0) / sets) * 100;
|
||||
}
|
||||
|
||||
function played(row: { winsA?: number | null; winsB?: number | null }): number {
|
||||
return (row.winsA ?? 0) + (row.winsB ?? 0);
|
||||
}
|
||||
|
||||
let started = false;
|
||||
$effect(() => {
|
||||
if (!session.isLoggedIn) {
|
||||
goto('/login', { replaceState: true });
|
||||
return;
|
||||
}
|
||||
if (!started) {
|
||||
started = true;
|
||||
void refreshEvents();
|
||||
}
|
||||
});
|
||||
|
||||
async function refreshEvents() {
|
||||
loadingEvents = true;
|
||||
error = null;
|
||||
try {
|
||||
events = await listEvents();
|
||||
} catch (cause) {
|
||||
error = toErrorMessage(cause, 'Could not load the event list.');
|
||||
} finally {
|
||||
loadingEvents = false;
|
||||
}
|
||||
}
|
||||
|
||||
function toggle(id: number | undefined) {
|
||||
if (id === undefined) return;
|
||||
selectedIds = selectedIds.includes(id)
|
||||
? selectedIds.filter((selected) => selected !== id)
|
||||
: [...selectedIds, id];
|
||||
}
|
||||
|
||||
function selectAll() {
|
||||
selectedIds = events.filter((e) => e.id !== undefined).map((e) => e.id as number);
|
||||
}
|
||||
|
||||
/** `GET /api/Event` is newest first, so the head of the list is the recent season. */
|
||||
function selectRecent(count: number) {
|
||||
selectedIds = events
|
||||
.filter((e) => e.id !== undefined)
|
||||
.slice(0, count)
|
||||
.map((e) => e.id as number);
|
||||
}
|
||||
|
||||
function selectMatching() {
|
||||
const value = pattern.trim();
|
||||
if (value === '') return;
|
||||
|
||||
let regex: RegExp;
|
||||
try {
|
||||
regex = new RegExp(value);
|
||||
} catch {
|
||||
error = `"${value}" is not a valid regular expression.`;
|
||||
return;
|
||||
}
|
||||
|
||||
error = null;
|
||||
selectedIds = events
|
||||
.filter((e) => e.id !== undefined && e.name && regex.test(e.name))
|
||||
.map((e) => e.id as number);
|
||||
}
|
||||
|
||||
async function load() {
|
||||
if (selectedIds.length === 0 || loading) return;
|
||||
|
||||
inFlight?.abort();
|
||||
const controller = new AbortController();
|
||||
inFlight = controller;
|
||||
|
||||
const scope = [...selectedIds];
|
||||
loading = true;
|
||||
error = null;
|
||||
failed = [];
|
||||
progress = { done: 0, total: scope.length };
|
||||
|
||||
try {
|
||||
// Both sources at once: the match call is one request and would otherwise
|
||||
// sit idle behind the per-event fan-out.
|
||||
const [outcome, matchStats] = await Promise.all([
|
||||
loadEventResults(
|
||||
events.filter((e) => e.id !== undefined && scope.includes(e.id)),
|
||||
{
|
||||
signal: controller.signal,
|
||||
onProgress: (done, total) => (progress = { done, total })
|
||||
}
|
||||
),
|
||||
getMatchStats(scope, { signal: controller.signal }).catch((cause: unknown) => {
|
||||
// A missing set table must not cost us the standings.
|
||||
if (cause instanceof DOMException && cause.name === 'AbortError') throw cause;
|
||||
if (cause instanceof ApiError && cause.status === 401) throw cause;
|
||||
return null;
|
||||
})
|
||||
]);
|
||||
|
||||
if (controller.signal.aborted) return;
|
||||
|
||||
stats = aggregate(outcome.loaded);
|
||||
matches = matchStats;
|
||||
failed = outcome.failed;
|
||||
loadedIds = scope;
|
||||
if (tab === 'matches' && !matchStats) tab = 'standings';
|
||||
} catch (cause) {
|
||||
if (cause instanceof DOMException && cause.name === 'AbortError') return;
|
||||
error = toErrorMessage(cause, 'Could not compute statistics for this selection.');
|
||||
} finally {
|
||||
if (inFlight === controller) {
|
||||
inFlight = null;
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function exportCsv() {
|
||||
const blob = new Blob([standingsCsv(standings)], { type: 'text/csv;charset=utf-8' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = `ladose-standings-${loadedIds.length}-events.csv`;
|
||||
link.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
const tabs = [
|
||||
['standings', 'Standings'],
|
||||
['games', 'Games'],
|
||||
['events', 'Events'],
|
||||
['matches', 'Matches']
|
||||
] as const;
|
||||
|
||||
/** Percentages get one decimal only under 100, so the column stays narrow. */
|
||||
function percent(value: number): string {
|
||||
return value >= 99.95 ? '100%' : `${value.toFixed(1)}%`;
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Statistiques · LaDOSE</title>
|
||||
</svelte:head>
|
||||
|
||||
<main id="main" class="mx-auto max-w-6xl px-4 py-10">
|
||||
<header class="mb-8">
|
||||
<h1 class="text-3xl font-semibold tracking-tight">Statistiques</h1>
|
||||
<p class="mt-1 text-sm text-muted">
|
||||
Pick a scope — a season, a year, everything — and see who turned up, who won, and how
|
||||
the games compare.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{#if error}
|
||||
<p role="alert" class="{alertError} mb-4">{error}</p>
|
||||
{/if}
|
||||
|
||||
<section class={card}>
|
||||
<div class="flex items-baseline justify-between gap-3">
|
||||
<h2 class={cardHeading}>
|
||||
Scope
|
||||
<span class="ml-1 font-normal text-muted normal-case">
|
||||
({selectedIds.length} of {events.length} events)
|
||||
</span>
|
||||
</h2>
|
||||
<button class={ghost} onclick={refreshEvents} disabled={loadingEvents}>
|
||||
{loadingEvents ? 'Loading…' : 'Refresh'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 flex flex-wrap items-center gap-2">
|
||||
<button class={ghost} onclick={selectAll} disabled={!events.length}>All</button>
|
||||
<button class={ghost} onclick={() => selectRecent(12)} disabled={!events.length}>
|
||||
Last 12
|
||||
</button>
|
||||
<button class={ghost} onclick={() => selectRecent(6)} disabled={!events.length}>
|
||||
Last 6
|
||||
</button>
|
||||
<button class={ghost} onclick={() => (selectedIds = [])} disabled={!selectedIds.length}>
|
||||
Clear
|
||||
</button>
|
||||
<div class="ml-auto flex gap-2">
|
||||
<input
|
||||
bind:value={pattern}
|
||||
class="{field} w-48"
|
||||
placeholder="Ranking #13\d{'{'}2{'}'}"
|
||||
aria-label="Regular expression matching event names"
|
||||
/>
|
||||
<button class={ghost} onclick={selectMatching} disabled={pattern.trim() === ''}>
|
||||
Select
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ul class="mt-4 grid max-h-64 gap-1 overflow-y-auto pr-1 text-sm sm:grid-cols-2">
|
||||
{#each events as event (event.id)}
|
||||
<li>
|
||||
<label
|
||||
class="flex cursor-pointer items-center gap-3 rounded-lg px-2 py-1.5 transition hover:bg-ink/5"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={event.id !== undefined && selectedIds.includes(event.id)}
|
||||
onchange={() => toggle(event.id)}
|
||||
class="size-4 accent-accent"
|
||||
/>
|
||||
<span class="truncate">{event.name}</span>
|
||||
{#if formatMonth(event.date ?? null)}
|
||||
<span class="ml-auto shrink-0 text-xs text-subtle">
|
||||
{formatMonth(event.date ?? null)}
|
||||
</span>
|
||||
{/if}
|
||||
</label>
|
||||
</li>
|
||||
{:else}
|
||||
<li class="px-2 py-6 text-center text-subtle sm:col-span-2">
|
||||
{loadingEvents ? 'Loading events…' : 'No event imported yet.'}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
|
||||
<div class="mt-4 flex items-center justify-between gap-3 border-t border-line pt-4">
|
||||
<span class="text-xs text-muted">
|
||||
{#if loading}
|
||||
Scoring event {progress.done} of {progress.total}…
|
||||
{:else}
|
||||
One request per event, so a broken import costs only its own row.
|
||||
{/if}
|
||||
</span>
|
||||
<button class={primary} onclick={load} disabled={loading || !selectedIds.length}>
|
||||
{loading ? 'Computing…' : 'Compute statistics'}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{#if failed.length}
|
||||
<div class="{alertWarning} mt-4">
|
||||
<p class="font-semibold">
|
||||
{failed.length} event{failed.length === 1 ? '' : 's'} skipped — everything below excludes
|
||||
{failed.length === 1 ? 'it' : 'them'}.
|
||||
</p>
|
||||
<ul class="mt-1 space-y-0.5 text-xs">
|
||||
{#each failed as entry (entry.event.id)}
|
||||
<li>{entry.event.name ?? `#${entry.event.id}`} — {entry.message}</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if stats}
|
||||
<!--
|
||||
Totals as plain figures, not charts: five unrelated counts have no shared
|
||||
scale, and each one is a single number best read as a number.
|
||||
-->
|
||||
<section class="mt-6 grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-5">
|
||||
{#each [['Events', stats.totals.events], ['Players', stats.totals.players], ['Brackets', stats.totals.brackets], ['Entries', stats.totals.entries], ['Points', stats.totals.points]] as const as [caption, value] (caption)}
|
||||
<div class="{card} p-4">
|
||||
<p class="text-xs tracking-wide text-muted uppercase">{caption}</p>
|
||||
<p class="mt-1 text-2xl font-semibold tracking-tight tabular-nums">
|
||||
{value.toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
{/each}
|
||||
</section>
|
||||
|
||||
<section class="{card} mt-6">
|
||||
<h2 class={cardHeading}>Unique players per event</h2>
|
||||
<p class="mt-1 text-xs text-muted">
|
||||
Oldest first. The same numbers are in the Events tab below.
|
||||
</p>
|
||||
<div class="mt-4">
|
||||
<AttendanceChart data={stats.attendance} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="{card} mt-6">
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<div class="flex flex-wrap gap-2" role="tablist">
|
||||
{#each tabs as [id, label] (id)}
|
||||
<button
|
||||
role="tab"
|
||||
aria-selected={tab === id}
|
||||
onclick={() => (tab = id)}
|
||||
class={tab === id ? tabActiveClass : tabClass}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
<p class="text-xs text-muted">
|
||||
{loadedIds.length} event{loadedIds.length === 1 ? '' : 's'} in scope
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{#if tab === 'standings'}
|
||||
<div class="mt-5 overflow-x-auto">
|
||||
<table class="w-full min-w-max text-sm">
|
||||
<thead class="text-left text-xs tracking-wide text-muted uppercase">
|
||||
<tr class="border-b border-line">
|
||||
<th class="py-2 pr-4 font-medium">#</th>
|
||||
<th class="py-2 pr-4 font-medium">Player</th>
|
||||
<th class="px-2 py-2 text-right font-medium">Points</th>
|
||||
<th class="px-2 py-2 text-right font-medium">Events</th>
|
||||
<th class="px-2 py-2 text-right font-medium">Entries</th>
|
||||
<th class="px-2 py-2 text-right font-medium">Games</th>
|
||||
<th class="px-2 py-2 text-right font-medium">1st</th>
|
||||
<th class="px-2 py-2 text-right font-medium">2nd</th>
|
||||
<th class="px-2 py-2 text-right font-medium">3rd</th>
|
||||
<th class="py-2 pl-2 text-right font-medium">Best</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="tabular-nums">
|
||||
{#each standings as row, index (row.player)}
|
||||
<tr class="border-b border-line/60 last:border-0">
|
||||
<td class="py-1.5 pr-4 text-subtle">{index + 1}</td>
|
||||
<td class="py-1.5 pr-4 font-medium">{row.player}</td>
|
||||
<td class="px-2 py-1.5 text-right font-semibold">{row.points}</td>
|
||||
<td class="px-2 py-1.5 text-right">{row.events}</td>
|
||||
<td class="px-2 py-1.5 text-right">{row.entries}</td>
|
||||
<td class="px-2 py-1.5 text-right">{row.games}</td>
|
||||
<td class="px-2 py-1.5 text-right {row.firsts ? '' : 'text-subtle/60'}">
|
||||
{row.firsts}
|
||||
</td>
|
||||
<td class="px-2 py-1.5 text-right {row.seconds ? '' : 'text-subtle/60'}">
|
||||
{row.seconds}
|
||||
</td>
|
||||
<td class="px-2 py-1.5 text-right {row.thirds ? '' : 'text-subtle/60'}">
|
||||
{row.thirds}
|
||||
</td>
|
||||
<td class="py-1.5 pl-2 text-right">{row.bestRank ?? '—'}</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="mt-4 flex items-center justify-between gap-3">
|
||||
<p class="text-xs text-muted">
|
||||
{podium.length} of {standings.length} players reached a podium.
|
||||
</p>
|
||||
<button class={ghost} onclick={exportCsv} disabled={!standings.length}>
|
||||
Export CSV
|
||||
</button>
|
||||
</div>
|
||||
{:else if tab === 'games'}
|
||||
<div class="mt-5 overflow-x-auto">
|
||||
<table class="w-full min-w-max text-sm">
|
||||
<thead class="text-left text-xs tracking-wide text-muted uppercase">
|
||||
<tr class="border-b border-line">
|
||||
<th class="py-2 pr-4 font-medium">Game</th>
|
||||
<th class="px-2 py-2 text-right font-medium">Brackets</th>
|
||||
<th class="px-2 py-2 text-right font-medium">Players</th>
|
||||
<th class="px-2 py-2 text-right font-medium">Entries</th>
|
||||
<th class="px-2 py-2 text-right font-medium">Avg field</th>
|
||||
<th class="py-2 pl-2 font-medium">Top player</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="tabular-nums">
|
||||
{#each stats.games as game (game.gameId)}
|
||||
<tr class="border-b border-line/60 last:border-0">
|
||||
<td class="py-1.5 pr-4">
|
||||
<span class="font-medium">{game.name}</span>
|
||||
{#if game.longName && game.longName !== game.name}
|
||||
<span class="ml-1 text-xs text-subtle">{game.longName}</span>
|
||||
{/if}
|
||||
</td>
|
||||
<td class="px-2 py-1.5 text-right">{game.brackets}</td>
|
||||
<td class="px-2 py-1.5 text-right">{game.players}</td>
|
||||
<td class="px-2 py-1.5 text-right">{game.entries}</td>
|
||||
<td class="px-2 py-1.5 text-right">{game.averageField.toFixed(1)}</td>
|
||||
<td class="py-1.5 pl-2 tabular-nums">
|
||||
{#if game.topPlayer}
|
||||
{game.topPlayer}
|
||||
<span class="text-subtle">· {game.topPoints} pts</span>
|
||||
{:else}
|
||||
<span class="text-subtle">—</span>
|
||||
{/if}
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{:else if tab === 'events'}
|
||||
<div class="mt-5 overflow-x-auto">
|
||||
<table class="w-full min-w-max text-sm">
|
||||
<thead class="text-left text-xs tracking-wide text-muted uppercase">
|
||||
<tr class="border-b border-line">
|
||||
<th class="py-2 pr-4 font-medium">Event</th>
|
||||
<th class="py-2 pr-4 font-medium">When</th>
|
||||
<th class="px-2 py-2 text-right font-medium">Players</th>
|
||||
<th class="px-2 py-2 text-right font-medium">Entries</th>
|
||||
<th class="px-2 py-2 text-right font-medium">Games</th>
|
||||
<th class="py-2 pl-2 text-right font-medium">Brackets</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="tabular-nums">
|
||||
{#each stats.attendance as entry (entry.eventId)}
|
||||
<tr class="border-b border-line/60 last:border-0">
|
||||
<td class="py-1.5 pr-4">{entry.name}</td>
|
||||
<td class="py-1.5 pr-4 text-subtle">{formatMonth(entry.date) || '—'}</td>
|
||||
<td class="px-2 py-1.5 text-right font-semibold">{entry.players}</td>
|
||||
<td class="px-2 py-1.5 text-right">{entry.entries}</td>
|
||||
<td class="px-2 py-1.5 text-right">{entry.games}</td>
|
||||
<td class="py-1.5 pl-2 text-right">{entry.brackets}</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{:else if matches}
|
||||
{@const coverage = matches.coverage}
|
||||
<!--
|
||||
Coverage first, deliberately: brackets imported before set rows were
|
||||
persisted contribute placements but no matches, so these numbers can
|
||||
describe a fraction of the scope. Reading them without that line is
|
||||
how "nobody played SF6" gets believed.
|
||||
-->
|
||||
<p class="mt-5 text-xs text-muted">
|
||||
{coverage?.bracketsWithSets ?? 0} of {coverage?.brackets ?? 0} brackets in scope have
|
||||
match data — {coverage?.decidedSets ?? 0} decided sets out of {coverage?.sets ?? 0}
|
||||
recorded. Anything imported without sets is invisible here but still counted in the
|
||||
standings above.
|
||||
</p>
|
||||
|
||||
{#if !players.length}
|
||||
<p class="mt-4 text-sm text-subtle">
|
||||
No decided set in this scope. Re-import an event to populate its matches.
|
||||
</p>
|
||||
{:else}
|
||||
<div class="mt-5 grid gap-6 lg:grid-cols-2">
|
||||
<div>
|
||||
<h3 class={cardHeading}>Set win rate</h3>
|
||||
<div class="mt-3 overflow-x-auto">
|
||||
<table class="w-full min-w-max text-sm">
|
||||
<thead class="text-left text-xs tracking-wide text-muted uppercase">
|
||||
<tr class="border-b border-line">
|
||||
<th class="py-2 pr-4 font-medium">Player</th>
|
||||
<th class="px-2 py-2 text-right font-medium">Sets</th>
|
||||
<th class="px-2 py-2 text-right font-medium">W</th>
|
||||
<th class="px-2 py-2 text-right font-medium">L</th>
|
||||
<th class="px-2 py-2 text-right font-medium">Games</th>
|
||||
<th class="py-2 pl-2 text-right font-medium">Win rate</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="tabular-nums">
|
||||
{#each players as row (row.playerId)}
|
||||
<tr class="border-b border-line/60 last:border-0">
|
||||
<td class="py-1.5 pr-4 font-medium">{row.player}</td>
|
||||
<td class="px-2 py-1.5 text-right">{row.sets}</td>
|
||||
<td class="px-2 py-1.5 text-right">{row.wins}</td>
|
||||
<td class="px-2 py-1.5 text-right">{row.losses}</td>
|
||||
<td class="px-2 py-1.5 text-right text-subtle">
|
||||
{row.gamesWon}–{row.gamesLost}
|
||||
</td>
|
||||
<td class="py-1.5 pl-2 text-right font-semibold">
|
||||
{percent(winRate(row))}
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 class={cardHeading}>Head to head</h3>
|
||||
<p class="mt-1 text-xs text-muted">Most-played pairings first.</p>
|
||||
<ul class="mt-3 max-h-96 space-y-2 overflow-y-auto pr-1 text-sm">
|
||||
{#each headToHead as row (`${row.playerAId}-${row.playerBId}`)}
|
||||
{@const total = played(row)}
|
||||
{@const shareA = total === 0 ? 0 : ((row.winsA ?? 0) / total) * 100}
|
||||
<li class="border-b border-line/60 pb-2 last:border-0">
|
||||
<div class="flex items-baseline justify-between gap-3">
|
||||
<span class="truncate">
|
||||
<span class={(row.winsA ?? 0) >= (row.winsB ?? 0) ? 'font-semibold' : ''}>
|
||||
{row.playerA}
|
||||
</span>
|
||||
<span class="text-subtle">vs</span>
|
||||
<span class={(row.winsB ?? 0) > (row.winsA ?? 0) ? 'font-semibold' : ''}>
|
||||
{row.playerB}
|
||||
</span>
|
||||
</span>
|
||||
<span class="shrink-0 font-semibold tabular-nums">
|
||||
{row.winsA}–{row.winsB}
|
||||
</span>
|
||||
</div>
|
||||
<!--
|
||||
A share meter, not a two-series bar: one measure (the first
|
||||
player's share of the pairing) painted in the accent over an
|
||||
inset track, so no second data hue is introduced. The score
|
||||
beside it carries the same numbers for anyone who can't see it.
|
||||
-->
|
||||
<div
|
||||
class="mt-1.5 h-1.5 overflow-hidden rounded-full bg-inset"
|
||||
role="img"
|
||||
aria-label="{row.playerA} won {row.winsA} of {total} sets against {row.playerB}"
|
||||
>
|
||||
<div class="h-full rounded-full bg-accent" style="width: {shareA}%"></div>
|
||||
</div>
|
||||
</li>
|
||||
{:else}
|
||||
<li class="py-6 text-center text-subtle">
|
||||
No pairing met twice in this scope.
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{:else}
|
||||
<p class="mt-5 text-sm text-subtle">
|
||||
Match statistics are unavailable for this scope — the endpoint could not be reached.
|
||||
</p>
|
||||
{/if}
|
||||
</section>
|
||||
{/if}
|
||||
</main>
|
||||
@@ -0,0 +1,422 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { toErrorMessage } from '$lib/api/errors';
|
||||
import type { EventDTO, TournamentsResultDTO } from '$lib/api/schema-helpers';
|
||||
import { getResults, importSmashTournament, listEvents } from '$lib/api/tournaments';
|
||||
import { session } from '$lib/stores/session.svelte';
|
||||
import {
|
||||
buildCsv,
|
||||
buildHtml,
|
||||
buildRanking,
|
||||
playedGames,
|
||||
resultsForGame
|
||||
} from '$lib/tournaments/results';
|
||||
import {
|
||||
alertError,
|
||||
alertNotice,
|
||||
card,
|
||||
cardHeading,
|
||||
field,
|
||||
ghost,
|
||||
listRow,
|
||||
listRowSelected,
|
||||
primary,
|
||||
// aliased: `tab` is already the name of this page's selected-panel state
|
||||
tab as tabClass,
|
||||
tabActive as tabActiveClass
|
||||
} from '$lib/ui/classes';
|
||||
|
||||
let events = $state<EventDTO[]>([]);
|
||||
let selectedIds = $state<number[]>([]);
|
||||
let slug = $state('');
|
||||
let pattern = $state('');
|
||||
let results = $state<TournamentsResultDTO | null>(null);
|
||||
let tab = $state<'ranking' | 'game' | 'html'>('ranking');
|
||||
let selectedGameId = $state<number | null>(null);
|
||||
|
||||
let loadingEvents = $state(false);
|
||||
let importing = $state(false);
|
||||
let generating = $state(false);
|
||||
let notice = $state<string | null>(null);
|
||||
let error = $state<string | null>(null);
|
||||
let copied = $state(false);
|
||||
|
||||
const ranking = $derived(buildRanking(results));
|
||||
const games = $derived(playedGames(results));
|
||||
const gameResults = $derived(resultsForGame(results, selectedGameId));
|
||||
const html = $derived(buildHtml(results));
|
||||
const selectedGame = $derived(games.find((g) => g.id === selectedGameId) ?? null);
|
||||
|
||||
let started = false;
|
||||
$effect(() => {
|
||||
if (!session.isLoggedIn) {
|
||||
goto('/login', { replaceState: true });
|
||||
return;
|
||||
}
|
||||
if (!started) {
|
||||
started = true;
|
||||
void refreshEvents();
|
||||
}
|
||||
});
|
||||
|
||||
function report(cause: unknown, fallback: string) {
|
||||
error = toErrorMessage(cause, fallback);
|
||||
}
|
||||
|
||||
async function refreshEvents() {
|
||||
loadingEvents = true;
|
||||
error = null;
|
||||
try {
|
||||
events = await listEvents();
|
||||
} catch (cause) {
|
||||
report(cause, 'Could not load the event list.');
|
||||
} finally {
|
||||
loadingEvents = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function importSlug() {
|
||||
const value = slug.trim();
|
||||
if (value === '' || importing) return;
|
||||
|
||||
importing = true;
|
||||
error = null;
|
||||
notice = null;
|
||||
try {
|
||||
const imported = await importSmashTournament(value);
|
||||
if (!imported) {
|
||||
error = `start.gg returned nothing for "${value}".`;
|
||||
return;
|
||||
}
|
||||
notice = `Imported "${value}".`;
|
||||
slug = '';
|
||||
await refreshEvents();
|
||||
} catch (cause) {
|
||||
// ParseSmash throws when a bracket is still running or the slug is unknown.
|
||||
report(
|
||||
cause,
|
||||
`Could not import "${value}". Check the slug, and that every bracket is finished.`
|
||||
);
|
||||
} finally {
|
||||
importing = false;
|
||||
}
|
||||
}
|
||||
|
||||
function toggle(id: number | undefined) {
|
||||
if (id === undefined) return;
|
||||
selectedIds = selectedIds.includes(id)
|
||||
? selectedIds.filter((selected) => selected !== id)
|
||||
: [...selectedIds, id];
|
||||
}
|
||||
|
||||
/** Replaces the selection with every event whose name matches the regex. */
|
||||
function selectMatching() {
|
||||
const value = pattern.trim();
|
||||
if (value === '') return;
|
||||
|
||||
let regex: RegExp;
|
||||
try {
|
||||
regex = new RegExp(value);
|
||||
} catch {
|
||||
error = `"${value}" is not a valid regular expression.`;
|
||||
return;
|
||||
}
|
||||
|
||||
error = null;
|
||||
selectedIds = events
|
||||
.filter((e) => e.id !== undefined && e.name && regex.test(e.name))
|
||||
.map((e) => e.id as number);
|
||||
if (selectedIds.length === 0) notice = `No event name matches ${value}.`;
|
||||
}
|
||||
|
||||
async function generate() {
|
||||
if (selectedIds.length === 0 || generating) return;
|
||||
|
||||
generating = true;
|
||||
error = null;
|
||||
notice = null;
|
||||
try {
|
||||
results = await getResults(selectedIds);
|
||||
selectedGameId = playedGames(results)[0]?.id ?? null;
|
||||
tab = 'ranking';
|
||||
} catch (cause) {
|
||||
report(cause, 'Could not compute the results for this selection.');
|
||||
} finally {
|
||||
generating = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function copyHtml() {
|
||||
try {
|
||||
await navigator.clipboard.writeText(html);
|
||||
copied = true;
|
||||
setTimeout(() => (copied = false), 2000);
|
||||
} catch {
|
||||
error = 'The browser refused clipboard access — select the HTML and copy it manually.';
|
||||
}
|
||||
}
|
||||
|
||||
function exportCsv() {
|
||||
const blob = new Blob([buildCsv(ranking)], { type: 'text/csv;charset=utf-8' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = `ladose-results-${selectedIds.join('-')}.csv`;
|
||||
link.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Tournaments · LaDOSE</title>
|
||||
</svelte:head>
|
||||
|
||||
<main id="main" class="mx-auto max-w-6xl px-4 py-10">
|
||||
<header class="mb-8">
|
||||
<h1 class="text-3xl font-semibold tracking-tight">Tournaments</h1>
|
||||
<p class="mt-1 text-sm text-muted">
|
||||
Import a start.gg tournament, then score one event or a whole ranking season.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{#if error}
|
||||
<p role="alert" class="{alertError} mb-4">
|
||||
{error}
|
||||
</p>
|
||||
{/if}
|
||||
{#if notice}
|
||||
<p class="{alertNotice} mb-4">{notice}</p>
|
||||
{/if}
|
||||
|
||||
<div class="grid gap-6 lg:grid-cols-2">
|
||||
<section class={card}>
|
||||
<h2 class={cardHeading}>
|
||||
Import from start.gg
|
||||
</h2>
|
||||
<p class="mt-1 text-xs text-muted">
|
||||
The slug is the tail of the tournament URL —
|
||||
<code class="text-ink">start.gg/tournament/<b>ranking-130</b></code>. Every bracket
|
||||
must be finished.
|
||||
</p>
|
||||
|
||||
<form
|
||||
class="mt-4 flex gap-2"
|
||||
onsubmit={(event) => {
|
||||
event.preventDefault();
|
||||
void importSlug();
|
||||
}}
|
||||
>
|
||||
<input
|
||||
bind:value={slug}
|
||||
disabled={importing}
|
||||
class={field}
|
||||
placeholder="ranking-130"
|
||||
aria-label="start.gg tournament slug"
|
||||
/>
|
||||
<button type="submit" class={primary} disabled={importing || slug.trim() === ''}>
|
||||
{importing ? 'Importing…' : 'Import'}
|
||||
</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class={card}>
|
||||
<div class="flex items-baseline justify-between gap-3">
|
||||
<h2 class={cardHeading}>
|
||||
Events
|
||||
<span class="ml-1 font-normal text-muted normal-case">({events.length})</span>
|
||||
</h2>
|
||||
<button class={ghost} onclick={refreshEvents} disabled={loadingEvents}>
|
||||
{loadingEvents ? 'Loading…' : 'Refresh'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 flex gap-2">
|
||||
<input
|
||||
bind:value={pattern}
|
||||
class={field}
|
||||
placeholder="Ranking #13\d{'{'}2{'}'}"
|
||||
aria-label="Regular expression matching event names"
|
||||
/>
|
||||
<button class={ghost} onclick={selectMatching} disabled={pattern.trim() === ''}>
|
||||
Select
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<ul class="mt-4 max-h-72 space-y-1 overflow-y-auto pr-1 text-sm">
|
||||
{#each events as event (event.id)}
|
||||
<li>
|
||||
<label
|
||||
class="flex cursor-pointer items-center gap-3 rounded-lg px-2 py-1.5 transition hover:bg-ink/5"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={event.id !== undefined && selectedIds.includes(event.id)}
|
||||
onchange={() => toggle(event.id)}
|
||||
class="size-4 accent-accent"
|
||||
/>
|
||||
<span class="w-12 shrink-0 text-right text-xs text-subtle">{event.id}</span>
|
||||
<span class="truncate">{event.name}</span>
|
||||
</label>
|
||||
</li>
|
||||
{:else}
|
||||
<li class="px-2 py-6 text-center text-subtle">
|
||||
{loadingEvents ? 'Loading events…' : 'No event imported yet.'}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
|
||||
<div class="mt-4 flex items-center justify-between gap-3 border-t border-line pt-4">
|
||||
<span class="text-xs text-muted">
|
||||
{selectedIds.length} selected
|
||||
{#if selectedIds.length > 1}· bracket links need a single event{/if}
|
||||
</span>
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
class={ghost}
|
||||
onclick={() => (selectedIds = [])}
|
||||
disabled={selectedIds.length === 0}>Clear</button
|
||||
>
|
||||
<button class={primary} onclick={generate} disabled={generating || !selectedIds.length}>
|
||||
{generating ? 'Computing…' : 'Generate results'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{#if results}
|
||||
<section class="{card} mt-6">
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<div class="flex gap-2" role="tablist">
|
||||
{#each [['ranking', 'Ranking'], ['game', 'By game'], ['html', 'HTML']] as const as [id, label] (id)}
|
||||
<button
|
||||
role="tab"
|
||||
aria-selected={tab === id}
|
||||
onclick={() => (tab = id)}
|
||||
class={tab === id ? tabActiveClass : tabClass}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
<p class="text-xs text-muted">
|
||||
{ranking.rows.length} players · {games.length} games · {results.results?.length ?? 0} placements
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{#if tab === 'ranking'}
|
||||
<div class="mt-5 overflow-x-auto">
|
||||
<table class="w-full min-w-max text-sm">
|
||||
<thead class="text-left text-xs tracking-wide text-muted uppercase">
|
||||
<tr class="border-b border-line">
|
||||
<th class="py-2 pr-4 font-medium">#</th>
|
||||
<th class="py-2 pr-4 font-medium">Player</th>
|
||||
{#each ranking.games as game (game.id)}
|
||||
<th class="px-2 py-2 text-right font-medium">{game.name}</th>
|
||||
{/each}
|
||||
<th class="py-2 pl-2 text-right font-medium">Total</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each ranking.rows as row, index (row.player)}
|
||||
<tr class="border-b border-line/60 last:border-0">
|
||||
<td class="py-1.5 pr-4 text-subtle">{index + 1}</td>
|
||||
<td class="py-1.5 pr-4">{row.player}</td>
|
||||
{#each row.points as point, i (ranking.games[i].id)}
|
||||
<td class="px-2 py-1.5 text-right {point ? '' : 'text-subtle/60'}">
|
||||
{point}
|
||||
</td>
|
||||
{/each}
|
||||
<td class="py-1.5 pl-2 text-right font-semibold">{row.total}</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<button class="{ghost} mt-4" onclick={exportCsv} disabled={!ranking.rows.length}>
|
||||
Export CSV
|
||||
</button>
|
||||
{:else if tab === 'game'}
|
||||
<div class="mt-5 grid gap-5 sm:grid-cols-[14rem_1fr]">
|
||||
<ul class="max-h-80 space-y-1 overflow-y-auto pr-1 text-sm">
|
||||
{#each games as game (game.id)}
|
||||
<li>
|
||||
<button
|
||||
onclick={() => (selectedGameId = game.id ?? null)}
|
||||
class="{selectedGameId === game.id ? listRowSelected : listRow} truncate"
|
||||
>
|
||||
{game.name}
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
|
||||
<div>
|
||||
{#if selectedGame}
|
||||
<h3 class="text-sm font-semibold">
|
||||
{selectedGame.longName ?? selectedGame.name}
|
||||
<span class="ml-1 font-normal text-muted">
|
||||
({gameResults.length} participants)
|
||||
</span>
|
||||
</h3>
|
||||
<ol class="mt-3 space-y-1 text-sm">
|
||||
{#each gameResults as result (result.player)}
|
||||
<li class="flex items-center gap-3 border-b border-line/60 py-1.5 last:border-0">
|
||||
<span class="w-10 shrink-0 text-right text-subtle">
|
||||
{result.rank === 999 ? '—' : result.rank}
|
||||
</span>
|
||||
<span class="grow truncate">{result.player}</span>
|
||||
<span class="shrink-0 font-semibold">{result.point} pts</span>
|
||||
</li>
|
||||
{/each}
|
||||
</ol>
|
||||
{:else}
|
||||
<p class="text-sm text-subtle">Pick a game to see its placements.</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="mt-5">
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<p class="text-xs text-muted">
|
||||
{#if results.slug}
|
||||
Bracket links point at start.gg/tournament/{results.slug}.
|
||||
{:else}
|
||||
Bracket links are omitted: the API only returns the slug for a single event.
|
||||
{/if}
|
||||
</p>
|
||||
<button class={ghost} onclick={copyHtml}>{copied ? 'Copied' : 'Copy HTML'}</button>
|
||||
</div>
|
||||
<textarea
|
||||
readonly
|
||||
value={html}
|
||||
class="{field} mt-3 h-64 resize-y font-mono text-xs"
|
||||
aria-label="Generated HTML"
|
||||
></textarea>
|
||||
<!--
|
||||
Deliberately pinned to a fixed dark surface, NOT the app theme: this is a
|
||||
preview of how the markup will look on the (dark) WordPress site, and
|
||||
buildHtml emits an inline `color: #ff0000` heading that no token can reach
|
||||
and that would be unreadable on a light background.
|
||||
-->
|
||||
<div class="mt-4 rounded-xl border border-ladose-900 bg-ladose-950 p-4">
|
||||
<p class="mb-2 text-xs tracking-wide text-ladose-200/50 uppercase">
|
||||
Preview <span class="normal-case">(as it appears on ladose.net)</span>
|
||||
</p>
|
||||
<!--
|
||||
Safe to inject: the markup is built by buildHtml from this response, and
|
||||
every value taken from start.gg (player and game names) is escaped there.
|
||||
The Bootstrap classes it carries are for WordPress, not styled here.
|
||||
-->
|
||||
<div
|
||||
class="text-sm text-ladose-50 [&_a]:text-ladose-400 [&_a]:underline [&_table]:w-full [&_td]:py-2 [&_td]:pr-4 [&_td]:align-top"
|
||||
>
|
||||
{@html html}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
{/if}
|
||||
</main>
|
||||
@@ -0,0 +1,293 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { addUser, deleteUser, listRoles, listUsers } from '$lib/api/admin-users';
|
||||
import { toErrorMessage } from '$lib/api/errors';
|
||||
import type { ApplicationUserDTO } from '$lib/api/schema-helpers';
|
||||
import { session } from '$lib/stores/session.svelte';
|
||||
import { alertError, alertNotice, card, cardHeading, danger, field, ghost, label, primary } from '$lib/ui/classes';
|
||||
|
||||
let users = $state<ApplicationUserDTO[]>([]);
|
||||
let roles = $state<string[]>([]);
|
||||
|
||||
let username = $state('');
|
||||
let password = $state('');
|
||||
let firstName = $state('');
|
||||
let lastName = $state('');
|
||||
let selectedRoles = $state<string[]>([]);
|
||||
|
||||
let loading = $state(false);
|
||||
let creating = $state(false);
|
||||
let deletingId = $state<number | null>(null);
|
||||
let notice = $state<string | null>(null);
|
||||
let error = $state<string | null>(null);
|
||||
|
||||
const canCreate = $derived(username.trim() !== '' && password !== '' && !creating);
|
||||
|
||||
let started = false;
|
||||
$effect(() => {
|
||||
// The page is admin-only on the server too; this just avoids showing a shell that
|
||||
// can only produce 403s.
|
||||
if (!session.isLoggedIn) {
|
||||
goto('/login', { replaceState: true });
|
||||
return;
|
||||
}
|
||||
if (!session.isAdmin) {
|
||||
goto('/', { replaceState: true });
|
||||
return;
|
||||
}
|
||||
if (!started) {
|
||||
started = true;
|
||||
void refresh();
|
||||
}
|
||||
});
|
||||
|
||||
function report(cause: unknown, fallback: string) {
|
||||
error = toErrorMessage(cause, fallback);
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
loading = true;
|
||||
error = null;
|
||||
try {
|
||||
// Roles are reference data; fetch them alongside the list on first load.
|
||||
[users, roles] = await Promise.all([listUsers(), listRoles()]);
|
||||
} catch (cause) {
|
||||
report(cause, 'Could not load the accounts.');
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function toggleRole(role: string) {
|
||||
selectedRoles = selectedRoles.includes(role)
|
||||
? selectedRoles.filter((selected) => selected !== role)
|
||||
: [...selectedRoles, role];
|
||||
}
|
||||
|
||||
async function create() {
|
||||
if (!canCreate) return;
|
||||
|
||||
creating = true;
|
||||
error = null;
|
||||
notice = null;
|
||||
const name = username.trim();
|
||||
try {
|
||||
await addUser({
|
||||
username: name,
|
||||
password,
|
||||
firstName: firstName.trim() || null,
|
||||
lastName: lastName.trim() || null,
|
||||
roles: selectedRoles
|
||||
});
|
||||
notice = `Created "${name}".`;
|
||||
username = '';
|
||||
password = '';
|
||||
firstName = '';
|
||||
lastName = '';
|
||||
selectedRoles = [];
|
||||
await refresh();
|
||||
} catch (cause) {
|
||||
// A 400 carries the reason: username taken, missing password, unknown role.
|
||||
report(cause, `Could not create "${name}".`);
|
||||
} finally {
|
||||
creating = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(user: ApplicationUserDTO) {
|
||||
if (user.id === undefined || deletingId !== null) return;
|
||||
if (!confirm(`Delete "${user.username}"? This cannot be undone.`)) return;
|
||||
|
||||
deletingId = user.id;
|
||||
error = null;
|
||||
notice = null;
|
||||
try {
|
||||
await deleteUser(user.id);
|
||||
notice = `Deleted "${user.username}".`;
|
||||
await refresh();
|
||||
} catch (cause) {
|
||||
report(cause, `Could not delete "${user.username}".`);
|
||||
} finally {
|
||||
deletingId = null;
|
||||
}
|
||||
}
|
||||
|
||||
function fullName(user: ApplicationUserDTO): string {
|
||||
return [user.firstName, user.lastName].filter(Boolean).join(' ').trim();
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Users · LaDOSE</title>
|
||||
</svelte:head>
|
||||
|
||||
<main id="main" class="mx-auto max-w-5xl px-4 py-10">
|
||||
<header class="mb-8">
|
||||
<h1 class="text-3xl font-semibold tracking-tight">Users</h1>
|
||||
<p class="mt-1 text-sm text-muted">
|
||||
Accounts that can sign in. Admins may also manage this list.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{#if error}
|
||||
<p role="alert" class="{alertError} mb-4">
|
||||
{error}
|
||||
</p>
|
||||
{/if}
|
||||
{#if notice}
|
||||
<p class="{alertNotice} mb-4">{notice}</p>
|
||||
{/if}
|
||||
|
||||
<section class={card}>
|
||||
<div class="flex items-baseline justify-between gap-3">
|
||||
<h2 class={cardHeading}>
|
||||
Accounts
|
||||
<span class="ml-1 font-normal text-muted normal-case">({users.length})</span>
|
||||
</h2>
|
||||
<button class={ghost} onclick={refresh} disabled={loading}>
|
||||
{loading ? 'Loading…' : 'Refresh'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 overflow-x-auto">
|
||||
<table class="w-full min-w-max text-sm">
|
||||
<thead class="text-left text-xs tracking-wide text-muted uppercase">
|
||||
<tr class="border-b border-line">
|
||||
<th class="py-2 pr-4 font-medium">Username</th>
|
||||
<th class="py-2 pr-4 font-medium">Name</th>
|
||||
<th class="py-2 pr-4 font-medium">Roles</th>
|
||||
<th class="py-2 pl-2"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each users as user (user.id)}
|
||||
<tr class="border-b border-line/60 last:border-0">
|
||||
<td class="py-2 pr-4 font-medium">
|
||||
{user.username}
|
||||
{#if user.id === session.user?.id}
|
||||
<span class="ml-1 text-xs font-normal text-subtle">(you)</span>
|
||||
{/if}
|
||||
</td>
|
||||
<td class="py-2 pr-4 text-muted">{fullName(user) || '—'}</td>
|
||||
<td class="py-2 pr-4">
|
||||
{#if user.roles?.length}
|
||||
{#each user.roles as role (role)}
|
||||
<span
|
||||
class="mr-1 rounded-full px-2 py-0.5 text-xs {role.toLowerCase() === 'admin'
|
||||
? 'bg-accent/20 text-accent'
|
||||
: 'bg-ink/5 text-muted'}"
|
||||
>
|
||||
{role}
|
||||
</span>
|
||||
{/each}
|
||||
{:else}
|
||||
<span class="text-subtle">none</span>
|
||||
{/if}
|
||||
</td>
|
||||
<td class="py-2 pl-2 text-right">
|
||||
{#if user.id === session.user?.id}
|
||||
<!-- The API refuses this, which is what keeps an admin in place. -->
|
||||
<span class="text-xs text-subtle">can't delete yourself</span>
|
||||
{:else}
|
||||
<button
|
||||
class={danger}
|
||||
onclick={() => remove(user)}
|
||||
disabled={deletingId !== null}
|
||||
>
|
||||
{deletingId === user.id ? 'Deleting…' : 'Delete'}
|
||||
</button>
|
||||
{/if}
|
||||
</td>
|
||||
</tr>
|
||||
{:else}
|
||||
<tr>
|
||||
<td colspan="4" class="py-6 text-center text-subtle">
|
||||
{loading ? 'Loading…' : 'No account.'}
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="{card} mt-6">
|
||||
<h2 class={cardHeading}>Add a user</h2>
|
||||
|
||||
<form
|
||||
class="mt-5 grid gap-4 sm:grid-cols-2"
|
||||
onsubmit={(event) => {
|
||||
event.preventDefault();
|
||||
void create();
|
||||
}}
|
||||
>
|
||||
<div class="space-y-1.5">
|
||||
<label class={label} for="new-username">Username</label>
|
||||
<input
|
||||
id="new-username"
|
||||
bind:value={username}
|
||||
class={field}
|
||||
autocomplete="off"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="space-y-1.5">
|
||||
<label class={label} for="new-password">Password</label>
|
||||
<input
|
||||
id="new-password"
|
||||
type="password"
|
||||
bind:value={password}
|
||||
class={field}
|
||||
autocomplete="new-password"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="space-y-1.5">
|
||||
<label class={label} for="new-firstname">First name</label>
|
||||
<input id="new-firstname" bind:value={firstName} class={field} autocomplete="off" />
|
||||
</div>
|
||||
|
||||
<div class="space-y-1.5">
|
||||
<label class={label} for="new-lastname">Last name</label>
|
||||
<input id="new-lastname" bind:value={lastName} class={field} autocomplete="off" />
|
||||
</div>
|
||||
|
||||
<fieldset class="space-y-2 sm:col-span-2">
|
||||
<legend class={label}>Roles</legend>
|
||||
{#if roles.length}
|
||||
<div class="flex flex-wrap gap-3">
|
||||
{#each roles as role (role)}
|
||||
<label
|
||||
class="flex cursor-pointer items-center gap-2 rounded-lg border border-line px-3 py-1.5 text-sm transition hover:bg-ink/5"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedRoles.includes(role)}
|
||||
onchange={() => toggleRole(role)}
|
||||
class="size-4 accent-accent"
|
||||
/>
|
||||
{role}
|
||||
</label>
|
||||
{/each}
|
||||
</div>
|
||||
<p class="text-xs text-subtle">
|
||||
No role still signs in and uses everything else — only this page needs Admin.
|
||||
</p>
|
||||
{:else}
|
||||
<p class="text-xs text-warning">
|
||||
No role exists in the database yet. Run <code>Sql/2026-08-05_roles.sql</code> to seed
|
||||
Admin and User.
|
||||
</p>
|
||||
{/if}
|
||||
</fieldset>
|
||||
|
||||
<div class="border-t border-line pt-4 sm:col-span-2">
|
||||
<button type="submit" class={primary} disabled={!canCreate}>
|
||||
{creating ? 'Creating…' : 'Create user'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
</main>
|
||||
Reference in New Issue
Block a user