@@ -0,0 +1,29 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/state';
|
||||
import { primary } from '$lib/ui/classes';
|
||||
|
||||
/*
|
||||
* Without this, a thrown `load` (the /statistiques redirect is the one that can)
|
||||
* or an uncaught render error drops the user on SvelteKit's built-in error page,
|
||||
* which is unstyled and outside the app's theme entirely.
|
||||
*/
|
||||
|
||||
const status = $derived(page.status);
|
||||
const message = $derived(page.error?.message ?? 'Something went wrong.');
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{status} · LaDOSE</title>
|
||||
</svelte:head>
|
||||
|
||||
<main id="main" class="mx-auto flex min-h-[60vh] max-w-xl flex-col justify-center px-4 py-10">
|
||||
<p class="text-sm font-semibold tracking-wide text-subtle uppercase">Error {status}</p>
|
||||
<h1 class="mt-2 text-3xl font-semibold tracking-tight">
|
||||
{status === 404 ? 'Page not found' : 'Something went wrong'}
|
||||
</h1>
|
||||
<p role="alert" class="mt-3 text-sm text-muted">{message}</p>
|
||||
|
||||
<div class="mt-8">
|
||||
<a href="/" class="{primary} inline-block">Back to the dashboard</a>
|
||||
</div>
|
||||
</main>
|
||||
@@ -1,9 +1,10 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
import favicon from '$lib/assets/favicon.svg';
|
||||
import Navbar from '$lib/ui/Navbar.svelte';
|
||||
import '../app.css';
|
||||
|
||||
let { children } = $props();
|
||||
let { children }: { children: Snippet } = $props();
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { requireSession } from '$lib/auth/guard.svelte';
|
||||
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 });
|
||||
});
|
||||
// No session (or an expired JWT) sends the user to /login.
|
||||
const guard = requireSession();
|
||||
|
||||
interface Shortcut {
|
||||
href: string;
|
||||
@@ -52,7 +50,7 @@
|
||||
</svelte:head>
|
||||
|
||||
<main id="main" class="mx-auto max-w-4xl px-4 py-12">
|
||||
{#if session.user}
|
||||
{#if guard.ready && 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>
|
||||
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { deleteGame, listGames, saveGame, searchSmashGames } from '$lib/api/games';
|
||||
import { toErrorMessage } from '$lib/api/errors';
|
||||
import { errorReporter } 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 { requireSession } from '$lib/auth/guard.svelte';
|
||||
import { blankDraft, isDirty, nextOrder, toDraft, toDto, type Draft } from '$lib/games/draft';
|
||||
import {
|
||||
alertError,
|
||||
alertNotice,
|
||||
@@ -21,7 +20,8 @@
|
||||
|
||||
let games = $state<GameDTO[]>([]);
|
||||
let draft = $state<Draft>({ ...blankDraft });
|
||||
let pristine = $state(JSON.stringify(blankDraft));
|
||||
/** The draft as it was last loaded or saved; `dirty` compares against it. */
|
||||
let pristine = $state<Draft>({ ...blankDraft });
|
||||
let smashMatches = $state<GameDTO[] | null>(null);
|
||||
|
||||
let loading = $state(false);
|
||||
@@ -33,30 +33,18 @@
|
||||
|
||||
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 dirty = $derived(isDirty(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();
|
||||
}
|
||||
});
|
||||
const guard = requireSession(() => void refresh());
|
||||
|
||||
function report(cause: unknown, fallback: string) {
|
||||
error = toErrorMessage(cause, fallback);
|
||||
}
|
||||
const report = errorReporter((message) => (error = message));
|
||||
|
||||
function load(game: GameDTO) {
|
||||
draft = toDraft(game);
|
||||
pristine = JSON.stringify(draft);
|
||||
pristine = { ...draft };
|
||||
smashMatches = null;
|
||||
}
|
||||
|
||||
@@ -78,7 +66,7 @@
|
||||
|
||||
function reset() {
|
||||
draft = { ...blankDraft, order: nextOrder(games) };
|
||||
pristine = JSON.stringify(draft);
|
||||
pristine = { ...draft };
|
||||
smashMatches = null;
|
||||
}
|
||||
|
||||
@@ -160,6 +148,7 @@
|
||||
</svelte:head>
|
||||
|
||||
<main id="main" class="mx-auto max-w-5xl px-4 py-10">
|
||||
{#if guard.ready}
|
||||
<header class="mb-8">
|
||||
<h1 class="text-3xl font-semibold tracking-tight">Games</h1>
|
||||
<p class="mt-1 text-sm text-muted">
|
||||
@@ -349,4 +338,5 @@
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
{/if}
|
||||
</main>
|
||||
|
||||
@@ -35,7 +35,7 @@
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (session.isLoggedIn) goto('/');
|
||||
if (session.isLoggedIn) void goto('/');
|
||||
});
|
||||
</script>
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { toErrorMessage } from '$lib/api/errors';
|
||||
import { errorReporter } from '$lib/api/errors';
|
||||
import type { PlayerOptionDTO, PlayerVersusDTO } from '$lib/api/schema-helpers';
|
||||
import { getVersus, listVersusPlayers } from '$lib/api/statistics';
|
||||
import { session } from '$lib/stores/session.svelte';
|
||||
import { requireSession } from '$lib/auth/guard.svelte';
|
||||
import { percent } from '$lib/format';
|
||||
import {
|
||||
alertError,
|
||||
alertWarning,
|
||||
@@ -46,17 +46,9 @@
|
||||
/** Undecided meetings: recorded, but with equal scores, so nobody won them. */
|
||||
const undecided = $derived(Math.max(0, sets - decided));
|
||||
|
||||
let started = false;
|
||||
$effect(() => {
|
||||
if (!session.isLoggedIn) {
|
||||
goto('/login', { replaceState: true });
|
||||
return;
|
||||
}
|
||||
if (!started) {
|
||||
started = true;
|
||||
void refreshPlayers();
|
||||
}
|
||||
});
|
||||
const guard = requireSession(() => void refreshPlayers());
|
||||
|
||||
const report = errorReporter((message) => (error = message));
|
||||
|
||||
/*
|
||||
* Reads only the two ids, so writing `versus` / `loading` below cannot re-trigger
|
||||
@@ -84,7 +76,7 @@
|
||||
try {
|
||||
players = await listVersusPlayers();
|
||||
} catch (cause) {
|
||||
error = toErrorMessage(cause, 'Could not load the player list.');
|
||||
report(cause, 'Could not load the player list.');
|
||||
} finally {
|
||||
loadingPlayers = false;
|
||||
}
|
||||
@@ -105,7 +97,7 @@
|
||||
} catch (cause) {
|
||||
if (cause instanceof DOMException && cause.name === 'AbortError') return;
|
||||
versus = null;
|
||||
error = toErrorMessage(cause, 'Could not load this pairing.');
|
||||
report(cause, 'Could not load this pairing.');
|
||||
} finally {
|
||||
if (inFlight === controller) {
|
||||
inFlight = null;
|
||||
@@ -123,11 +115,6 @@
|
||||
playerBId = null;
|
||||
}
|
||||
|
||||
/** 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)}%`;
|
||||
}
|
||||
|
||||
/** First player's share of the decided meetings; 50 when nothing is decided. */
|
||||
function share(a: number, b: number): number {
|
||||
const total = a + b;
|
||||
@@ -140,6 +127,7 @@
|
||||
</svelte:head>
|
||||
|
||||
<main id="main" class="mx-auto max-w-5xl px-4 py-10">
|
||||
{#if guard.ready}
|
||||
<header class="mb-8">
|
||||
<h1 class="text-3xl font-semibold tracking-tight">Players Statistiques</h1>
|
||||
<p class="mt-1 text-sm text-muted">
|
||||
@@ -346,4 +334,5 @@
|
||||
{/if}
|
||||
</p>
|
||||
{/if}
|
||||
{/if}
|
||||
</main>
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { ApiError } from '$lib/api/client';
|
||||
import { toErrorMessage } from '$lib/api/errors';
|
||||
import { errorReporter } 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 { requireSession } from '$lib/auth/guard.svelte';
|
||||
import { downloadCsv } from '$lib/download';
|
||||
import { eventIds, identified, matchingEventIds, toggleId } from '$lib/events';
|
||||
import { percent } from '$lib/format';
|
||||
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';
|
||||
@@ -77,17 +79,9 @@
|
||||
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();
|
||||
}
|
||||
});
|
||||
const guard = requireSession(() => void refreshEvents());
|
||||
|
||||
const report = errorReporter((message) => (error = message));
|
||||
|
||||
async function refreshEvents() {
|
||||
loadingEvents = true;
|
||||
@@ -95,47 +89,35 @@
|
||||
try {
|
||||
events = await listEvents();
|
||||
} catch (cause) {
|
||||
error = toErrorMessage(cause, 'Could not load the event list.');
|
||||
report(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];
|
||||
selectedIds = toggleId(selectedIds, id);
|
||||
}
|
||||
|
||||
function selectAll() {
|
||||
selectedIds = events.filter((e) => e.id !== undefined).map((e) => e.id as number);
|
||||
selectedIds = eventIds(events);
|
||||
}
|
||||
|
||||
/** `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)
|
||||
selectedIds = identified(events)
|
||||
.slice(0, count)
|
||||
.map((e) => e.id as number);
|
||||
.map((event) => event.id);
|
||||
}
|
||||
|
||||
function selectMatching() {
|
||||
const value = pattern.trim();
|
||||
if (value === '') return;
|
||||
if (pattern.trim() === '') return;
|
||||
|
||||
let regex: RegExp;
|
||||
try {
|
||||
regex = new RegExp(value);
|
||||
} catch {
|
||||
error = `"${value}" is not a valid regular expression.`;
|
||||
return;
|
||||
}
|
||||
const match = matchingEventIds(events, pattern);
|
||||
error = match.error;
|
||||
if (match.error) return;
|
||||
|
||||
error = null;
|
||||
selectedIds = events
|
||||
.filter((e) => e.id !== undefined && e.name && regex.test(e.name))
|
||||
.map((e) => e.id as number);
|
||||
selectedIds = match.ids;
|
||||
}
|
||||
|
||||
async function load() {
|
||||
@@ -156,7 +138,7 @@
|
||||
// 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)),
|
||||
identified(events).filter((event) => scope.includes(event.id)),
|
||||
{
|
||||
signal: controller.signal,
|
||||
onProgress: (done, total) => (progress = { done, total })
|
||||
@@ -179,7 +161,7 @@
|
||||
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.');
|
||||
report(cause, 'Could not compute statistics for this selection.');
|
||||
} finally {
|
||||
if (inFlight === controller) {
|
||||
inFlight = null;
|
||||
@@ -189,13 +171,7 @@
|
||||
}
|
||||
|
||||
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);
|
||||
downloadCsv(`ladose-standings-${loadedIds.length}-events.csv`, standingsCsv(standings));
|
||||
}
|
||||
|
||||
const tabs = [
|
||||
@@ -204,11 +180,6 @@
|
||||
['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>
|
||||
@@ -216,6 +187,7 @@
|
||||
</svelte:head>
|
||||
|
||||
<main id="main" class="mx-auto max-w-6xl px-4 py-10">
|
||||
{#if guard.ready}
|
||||
<header class="mb-8">
|
||||
<h1 class="text-3xl font-semibold tracking-tight">Rankings Statistiques</h1>
|
||||
<p class="mt-1 text-sm text-muted">
|
||||
@@ -259,7 +231,7 @@
|
||||
<input
|
||||
bind:value={pattern}
|
||||
class="{field} w-48"
|
||||
placeholder="Ranking #13\d{'{'}2{'}'}"
|
||||
placeholder="Ranking #13\d{2}"
|
||||
aria-label="Regular expression matching event names"
|
||||
/>
|
||||
<button class={ghost} onclick={selectMatching} disabled={pattern.trim() === ''}>
|
||||
@@ -590,4 +562,5 @@
|
||||
{/if}
|
||||
</section>
|
||||
{/if}
|
||||
{/if}
|
||||
</main>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { toErrorMessage } from '$lib/api/errors';
|
||||
import { ApiError } from '$lib/api/client';
|
||||
import { errorReporter } from '$lib/api/errors';
|
||||
import type {
|
||||
EventDTO,
|
||||
SheetExportResultDTO,
|
||||
@@ -9,7 +9,9 @@
|
||||
} from '$lib/api/schema-helpers';
|
||||
import { exportToSheets, getSheetsConfig } from '$lib/api/sheets';
|
||||
import { getResults, importSmashTournament, listEvents } from '$lib/api/tournaments';
|
||||
import { session } from '$lib/stores/session.svelte';
|
||||
import { requireSession } from '$lib/auth/guard.svelte';
|
||||
import { downloadCsv } from '$lib/download';
|
||||
import { matchingEventIds, toggleId } from '$lib/events';
|
||||
import {
|
||||
buildCsv,
|
||||
buildHtml,
|
||||
@@ -75,26 +77,23 @@
|
||||
const suggestedName = $derived(suggestedTabName(generatedEvents));
|
||||
const resolvedTabName = $derived(tabName.trim() || suggestedName);
|
||||
|
||||
let started = false;
|
||||
$effect(() => {
|
||||
if (!session.isLoggedIn) {
|
||||
goto('/login', { replaceState: true });
|
||||
return;
|
||||
}
|
||||
if (!started) {
|
||||
started = true;
|
||||
void refreshEvents();
|
||||
// Not reaching it just leaves the export saying "not configured"; the page must
|
||||
// not otherwise care.
|
||||
void getSheetsConfig()
|
||||
.then((value) => (sheets = value))
|
||||
.catch(() => (sheets = null));
|
||||
}
|
||||
});
|
||||
const report = errorReporter((message) => (error = message));
|
||||
|
||||
function report(cause: unknown, fallback: string) {
|
||||
error = toErrorMessage(cause, fallback);
|
||||
}
|
||||
const guard = requireSession(() => {
|
||||
void refreshEvents();
|
||||
// Not reaching it just leaves the export saying "not configured"; the page must
|
||||
// not otherwise care. A 401 is the exception — that is an expired session, not
|
||||
// an unconfigured server, and must go through `report` so the user is signed out
|
||||
// instead of being told the export is unavailable.
|
||||
void getSheetsConfig()
|
||||
.then((value) => (sheets = value))
|
||||
.catch((cause) => {
|
||||
sheets = null;
|
||||
if (cause instanceof ApiError && cause.status === 401) {
|
||||
report(cause, 'Could not read the Google Sheets configuration.');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
async function refreshEvents() {
|
||||
loadingEvents = true;
|
||||
@@ -136,10 +135,7 @@
|
||||
}
|
||||
|
||||
function toggle(id: number | undefined) {
|
||||
if (id === undefined) return;
|
||||
selectedIds = selectedIds.includes(id)
|
||||
? selectedIds.filter((selected) => selected !== id)
|
||||
: [...selectedIds, id];
|
||||
selectedIds = toggleId(selectedIds, id);
|
||||
}
|
||||
|
||||
/** Replaces the selection with every event whose name matches the regex. */
|
||||
@@ -147,18 +143,11 @@
|
||||
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;
|
||||
}
|
||||
const match = matchingEventIds(events, value);
|
||||
error = match.error;
|
||||
if (match.error) return;
|
||||
|
||||
error = null;
|
||||
selectedIds = events
|
||||
.filter((e) => e.id !== undefined && e.name && regex.test(e.name))
|
||||
.map((e) => e.id as number);
|
||||
selectedIds = match.ids;
|
||||
if (selectedIds.length === 0) notice = `No event name matches ${value}.`;
|
||||
}
|
||||
|
||||
@@ -194,13 +183,9 @@
|
||||
}
|
||||
|
||||
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);
|
||||
// `generatedIds`, not `selectedIds`: the file must be named for the data it
|
||||
// holds, which is whatever `generate` last ran for, not the live checkboxes.
|
||||
downloadCsv(`ladose-results-${generatedIds.join('-')}.csv`, buildCsv(ranking));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -239,6 +224,7 @@
|
||||
</svelte:head>
|
||||
|
||||
<main id="main" class="mx-auto max-w-6xl px-4 py-10">
|
||||
{#if guard.ready}
|
||||
<header class="mb-8">
|
||||
<h1 class="text-3xl font-semibold tracking-tight">Tournaments</h1>
|
||||
<p class="mt-1 text-sm text-muted">
|
||||
@@ -301,7 +287,7 @@
|
||||
<input
|
||||
bind:value={pattern}
|
||||
class={field}
|
||||
placeholder="Ranking #13\d{'{'}2{'}'}"
|
||||
placeholder="Ranking #13\d{2}"
|
||||
aria-label="Regular expression matching event names"
|
||||
/>
|
||||
<button class={ghost} onclick={selectMatching} disabled={pattern.trim() === ''}>
|
||||
@@ -548,11 +534,20 @@
|
||||
<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}
|
||||
<!--
|
||||
The one {@html} in the app, and the only place a suppression is
|
||||
warranted: `html` is not user input passing through, it is built by
|
||||
`buildHtml` in $lib/tournaments/results.ts, which escapes every
|
||||
interpolated value. This is a preview of the markup the user is about
|
||||
to paste into WordPress, so it has to render as markup.
|
||||
-->
|
||||
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
|
||||
{@html html}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
{/if}
|
||||
{/if}
|
||||
</main>
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
<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 { errorReporter } from '$lib/api/errors';
|
||||
import type { ApplicationUserDTO } from '$lib/api/schema-helpers';
|
||||
import { requireAdmin } from '$lib/auth/guard.svelte';
|
||||
import { fullName } from '$lib/format';
|
||||
import { session } from '$lib/stores/session.svelte';
|
||||
import { alertError, alertNotice, card, cardHeading, danger, field, ghost, label, primary } from '$lib/ui/classes';
|
||||
|
||||
@@ -23,27 +24,11 @@
|
||||
|
||||
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();
|
||||
}
|
||||
});
|
||||
// The page is admin-only on the server too; this just avoids showing a shell that
|
||||
// can only produce 403s.
|
||||
const guard = requireAdmin(() => void refresh());
|
||||
|
||||
function report(cause: unknown, fallback: string) {
|
||||
error = toErrorMessage(cause, fallback);
|
||||
}
|
||||
const report = errorReporter((message) => (error = message));
|
||||
|
||||
async function refresh() {
|
||||
loading = true;
|
||||
@@ -112,8 +97,8 @@
|
||||
}
|
||||
}
|
||||
|
||||
function fullName(user: ApplicationUserDTO): string {
|
||||
return [user.firstName, user.lastName].filter(Boolean).join(' ').trim();
|
||||
function displayName(user: ApplicationUserDTO): string {
|
||||
return fullName(user.firstName, user.lastName);
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -122,6 +107,7 @@
|
||||
</svelte:head>
|
||||
|
||||
<main id="main" class="mx-auto max-w-5xl px-4 py-10">
|
||||
{#if guard.ready}
|
||||
<header class="mb-8">
|
||||
<h1 class="text-3xl font-semibold tracking-tight">Users</h1>
|
||||
<p class="mt-1 text-sm text-muted">
|
||||
@@ -168,7 +154,7 @@
|
||||
<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 text-muted">{displayName(user) || '—'}</td>
|
||||
<td class="py-2 pr-4">
|
||||
{#if user.roles?.length}
|
||||
{#each user.roles as role (role)}
|
||||
@@ -290,4 +276,5 @@
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
{/if}
|
||||
</main>
|
||||
|
||||
Reference in New Issue
Block a user