Added this stupid vibecoded app as a test
Build App / Build (push) Canceled after 0s

This commit is contained in:
2026-08-06 09:58:06 +02:00
parent d9e05fb487
commit a9860f4c94
51 changed files with 10154 additions and 7 deletions
@@ -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>