Compare commits

..

3 Commits

Author SHA1 Message Date
3b636243ff added command so i dont forget 2026-07-07 22:46:56 +01:00
17acf0a497 added player management screen; we are good to go!!!!! 2026-07-07 22:45:36 +01:00
a3ead298af PRODUCTION READY BOYS 2026-07-07 22:31:54 +01:00
20 changed files with 8385 additions and 70 deletions

7450
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -25,6 +25,7 @@
"@eslint/js": "^10.0.1",
"@libsql/client": "^0.17.2",
"@sveltejs/adapter-auto": "^7.0.1",
"@sveltejs/adapter-node": "^5.5.7",
"@sveltejs/kit": "^2.57.0",
"@sveltejs/vite-plugin-svelte": "^7.0.0",
"@tailwindcss/forms": "^0.5.11",

1
run.sh Normal file
View File

@@ -0,0 +1 @@
DATABASE_URL="file:local.db" ORIGIN="http://localhost:3000" node build/index.js

View File

@@ -22,7 +22,7 @@ export async function setSetting(key: string, value: string): Promise<void> {
export async function getAllInitialInfo() {
return {
teams: await getTeams(),
events: await getRegisteredEvents()
events: await getRegisteredEventsWithPlayers()
};
}

View File

@@ -1,5 +1,5 @@
import { drizzle } from 'drizzle-orm/libsql';
import { createClient } from '@libsql/client';
import { createClient } from './libsql-wrapper';
import { construct } from 'drizzle-orm/libsql/driver-core';
import * as schema from './schema';
import { env } from '$env/dynamic/private';
@@ -7,4 +7,4 @@ if (!env.DATABASE_URL) throw new Error('DATABASE_URL is not set');
const client = createClient({ url: env.DATABASE_URL });
export const db = drizzle(client, { schema });
export const db = construct(client, { schema });

View File

@@ -0,0 +1,7 @@
import { createRequire } from 'node:module';
const require = createRequire(import.meta.url);
const libsql = require('@libsql/client');
export const createClient = libsql.createClient;
export default libsql;

View File

@@ -58,7 +58,9 @@
<a href="/registrations" role="menuitem">registrations</a>
<a href="/manage-events" role="menuitem">events</a>
<a href="/import-csv" role="menuitem">import</a>
<a href="/settings" role="menuitem">settings</a>
<a href="/manage-players" role="menuitem">players</a>
<a href="/manage-users" role="menuitem">users</a>
<a href="/settings" role="menuitem">settings</a>
</div>
{/if}
</div>

View File

@@ -9,8 +9,8 @@ import { flip } from 'svelte/animate';
focusEventId?: number | null;
} = $props();
let teams = $derived(data.teams.teams);
let eventTable = $derived(data.events.events);
let teams = $state(data.teams.teams);
let eventTable = $state(data.events);
let scoreEndpoint: EventSource;
let eventEndpoint: EventSource;
@@ -64,7 +64,7 @@ import { flip } from 'svelte/animate';
if (focusEventId == null) return;
tick().then(() => {
const el = eventRefs[focusEventId!];
el.scrollIntoView({ alignToTop: true, behavior: 'instant', container: 'nearest' });
el.scrollIntoView({ block: 'start', behavior: 'instant' });
tick().then(() => {
window.scrollTo(0, 0);
});

View File

@@ -11,7 +11,7 @@ export async function POST({ request }: any) {
return new Error('send a response dummy');
} else {
console.log(responseBody);
startEvent(responseBody.eventId);
await startEvent(responseBody.eventId);
return new Response('ok');
}
}

View File

@@ -3,7 +3,7 @@ import { globalEmitter } from '$lib/server/globalEmitter';
import { generateEndpoint } from '$lib/server/endpoint';
export async function POST({ request }: any) {
globalEmitter.emit('incrementScores');
globalEmitter.emit('scoreUpdate');
return new Response('ok');
}

View File

@@ -1,14 +1,25 @@
import { eq } from 'drizzle-orm';
import { db } from '$lib/server/db';
import { scorers } from '$lib/server/db/schema';
import { getRegisteredEventsWithPlayers } from '$lib/server/databaseManager';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ locals }) => {
if (!locals.user) return { user: null };
export const load: PageServerLoad = async ({ locals, params }) => {
const eventId = parseInt(params.eventId);
const userInfo: { user: any } = { user: null };
const [row] = await db
.select({ role: scorers.role })
.from(scorers)
.where(eq(scorers.id, locals.user.id));
return { user: { ...locals.user, role: row?.role ?? 'scorer' } };
if (locals.user) {
const [row] = await db
.select({ role: scorers.role })
.from(scorers)
.where(eq(scorers.id, locals.user.id));
userInfo.user = { ...locals.user, role: row?.role ?? 'scorer' };
}
const events = await getRegisteredEventsWithPlayers(eventId);
return {
...userInfo,
event: events[0] ?? null
};
};

View File

@@ -31,59 +31,50 @@
let eventEndpoint: EventSource;
async function getEventData() {
let response = await fetch('/api/registeredEvents', {
method: 'POST',
body: JSON.stringify({
eventId: eventId
}),
headers: {
'Content-type': 'application/json; charset=UTF-8'
}
});
const data = await response.json();
if (data && data[0] && data[0].registeredPlayers) {
data[0].registeredPlayers.forEach((bracket: any) => {
bracket.items.sort((a: any, b: any) => {
if (a.placement === 0 && b.placement === 0) return 0;
if (a.placement === 0) return 1;
if (b.placement === 0) return -1;
return a.placement - b.placement;
});
});
}
return data;
}
let eventDataPromise = getEventData();
let eventData = $state<any>(data.event);
onMount(() => {
eventEndpoint = new EventSource('/api/registeredEvents');
eventEndpoint.onmessage = (e) => {
const allEvents = JSON.parse(e.data);
const updated = allEvents.find((ev: any) => ev.id == eventId);
if (updated) {
if (updated.registeredPlayers) {
updated.registeredPlayers.forEach((bracket: any) => {
bracket.items.sort((a: any, b: any) => {
if (a.placement === 0 && b.placement === 0) return 0;
if (a.placement === 0) return 1;
if (b.placement === 0) return -1;
return a.placement - b.placement;
});
});
}
eventData = updated;
}
};
});
onDestroy(() => {
eventEndpoint?.close();
});
</script>
{#await eventDataPromise}
<div class="page-container"><div>loading</div></div>
{:then eventData}
{@const event = eventData[0]}
{#if eventData}
<div class="page-container">
<div class="event-card" class:ongoing-event={event.state == 1} class:completed-event={event.state == 2}
style={event.state == 2 ? `--event-color: ${event.winner?.color ?? 'currentColor'}` : ''}>
<div class="event-card" class:ongoing-event={eventData.state == 1} class:completed-event={eventData.state == 2}
style={eventData.state == 2 ? `--event-color: ${eventData.winner?.color ?? 'currentColor'}` : ''}>
<div class="event-header">
<span class="event-name goldman">{event.name}</span>
<span class="event-division">{event.division}</span>
{#if event.state == 1}
<span class="event-name goldman">{eventData.name}</span>
<span class="event-division">{eventData.division}</span>
{#if eventData.state == 1}
<span class="event-status goldman">ONGOING</span>
{:else if event.state == 2}
{:else if eventData.state == 2}
<span class="event-status goldman">FINISHED</span>
{/if}
</div>
<div class="brackets px-[5vw]">
{#each event.registeredPlayers as bracket, bi}
{#each eventData.registeredPlayers as bracket, bi}
{#if bi > 0}
<div class="bracket-sep" aria-hidden="true"></div>
{/if}
@@ -118,7 +109,7 @@
<div class="flex flex-col justify-center">
{#each player.playerScores as score}
<div class="mx-1 my-0.5 rounded border px-1 text-xs" style="border-color:{player.teamColor}">
Run {score.resultIndex + 1}: {score.result}{event.resultPresets[0].unit}
Run {score.resultIndex + 1}: {score.result}{eventData.resultPresets[0].unit}
</div>
{/each}
</div>
@@ -141,4 +132,6 @@
</div>
{/if}
</div>
{/await}
{:else}
<div class="page-container"><div>loading</div></div>
{/if}

View File

@@ -209,6 +209,9 @@
eventId: eventId
})
});
if (res.ok) {
event = { ...event, state: 1 };
}
} catch {
return new Error();
}

View File

@@ -1,7 +1,6 @@
<script lang="ts">
import type { ActionData, PageData } from './$types';
export let data: PageData;
export let form: ActionData;
let { data, form }: { data: PageData; form: any } = $props();
</script>
<div class="page-container">

View File

@@ -0,0 +1,136 @@
import { error, redirect, fail } from '@sveltejs/kit';
import { eq } from 'drizzle-orm';
import { db } from '$lib/server/db';
import { scorers, players, teams, divisions } from '$lib/server/db/schema';
import type { PageServerLoad, Actions } from './$types';
export const load: PageServerLoad = async ({ locals }) => {
if (!locals.user) throw redirect(303, '/login');
const [row] = await db
.select({ role: scorers.role })
.from(scorers)
.where(eq(scorers.id, locals.user.id));
if (row?.role !== 'admin') throw error(403, 'Forbidden');
const playerList = await db
.select({
id: players.id,
firstName: players.firstName,
lastName: players.lastName,
teamId: players.team,
teamName: teams.name,
divisionId: players.division,
divisionName: divisions.name
})
.from(players)
.leftJoin(teams, eq(players.team, teams.id))
.leftJoin(divisions, eq(players.division, divisions.id))
.orderBy(players.firstName, players.lastName);
const teamList = await db.select().from(teams).orderBy(teams.name);
const divisionList = await db.select().from(divisions).orderBy(divisions.name);
return { players: playerList, teams: teamList, divisions: divisionList };
};
export const actions: Actions = {
createPlayer: async ({ locals, request }) => {
if (!locals.user) throw error(401, 'Unauthorized');
const [row] = await db
.select({ role: scorers.role })
.from(scorers)
.where(eq(scorers.id, locals.user.id));
if (row?.role !== 'admin') throw error(403, 'Forbidden');
const data = await request.formData();
const firstName = (data.get('firstName') as string)?.trim();
const lastName = (data.get('lastName') as string)?.trim();
const teamId = data.get('teamId') as string;
const divisionId = data.get('divisionId') as string;
if (!firstName || !lastName || !teamId) {
return fail(400, { createMessage: 'First name, last name, and team are required' });
}
await db
.insert(players)
.values({
firstName,
lastName,
team: parseInt(teamId),
division: divisionId ? parseInt(divisionId) : null
});
return { success: true };
},
editPlayer: async ({ locals, request }) => {
if (!locals.user) throw error(401, 'Unauthorized');
const [row] = await db
.select({ role: scorers.role })
.from(scorers)
.where(eq(scorers.id, locals.user.id));
if (row?.role !== 'admin') throw error(403, 'Forbidden');
const data = await request.formData();
const playerId = data.get('playerId') as string;
const firstName = (data.get('firstName') as string)?.trim();
const lastName = (data.get('lastName') as string)?.trim();
const teamId = data.get('teamId') as string;
const divisionId = data.get('divisionId') as string;
if (!playerId || !firstName || !lastName || !teamId) {
return fail(400, { editMessage: 'First name, last name, and team are required' });
}
const [existing] = await db.select().from(players).where(eq(players.id, parseInt(playerId)));
if (!existing) {
return fail(404, { editMessage: 'Player not found' });
}
await db
.update(players)
.set({
firstName,
lastName,
team: parseInt(teamId),
division: divisionId ? parseInt(divisionId) : null
})
.where(eq(players.id, parseInt(playerId)));
return { success: true };
},
deletePlayer: async ({ locals, request }) => {
if (!locals.user) throw error(401, 'Unauthorized');
const [row] = await db
.select({ role: scorers.role })
.from(scorers)
.where(eq(scorers.id, locals.user.id));
if (row?.role !== 'admin') throw error(403, 'Forbidden');
const data = await request.formData();
const playerId = data.get('playerId') as string;
if (!playerId) {
return fail(400, { deleteMessage: 'Player ID required' });
}
const [existing] = await db.select().from(players).where(eq(players.id, parseInt(playerId)));
if (!existing) {
return fail(404, { deleteMessage: 'Player not found' });
}
await db.delete(players).where(eq(players.id, parseInt(playerId)));
return { success: true };
}
};

View File

@@ -0,0 +1,383 @@
<script lang="ts">
import { enhance } from '$app/forms';
import { invalidateAll } from '$app/navigation';
import Table from '$lib/ui/Table.svelte';
import type { PageData } from './$types';
let { data }: { data: PageData } = $props();
let showCreateForm = $state(false);
let editingId = $state<number | null>(null);
let createFirstName = $state('');
let createLastName = $state('');
let createTeamId = $state('');
let createDivisionId = $state('');
let editFirstName = $state('');
let editLastName = $state('');
let editTeamId = $state('');
let editDivisionId = $state('');
let searchQuery = $state('');
let createMessage = $state('');
let editMessage = $state('');
let deleteMessage = $state('');
let filteredPlayers = $derived(
searchQuery
? data.players.filter((p) =>
p.firstName.toLowerCase().includes(searchQuery.toLowerCase()) ||
p.lastName.toLowerCase().includes(searchQuery.toLowerCase()) ||
p.teamName.toLowerCase().includes(searchQuery.toLowerCase())
)
: data.players
);
function resetCreateForm() {
createFirstName = '';
createLastName = '';
createTeamId = '';
createDivisionId = '';
createMessage = '';
showCreateForm = false;
}
function startEdit(player: (typeof data.players)[number]) {
editingId = player.id;
editMessage = '';
editFirstName = player.firstName;
editLastName = player.lastName;
editTeamId = String(player.teamId);
editDivisionId = player.divisionId ? String(player.divisionId) : '';
}
function cancelEdit() {
editingId = null;
editMessage = '';
}
</script>
<div class="page-container">
<div class="section-label">Manage Players</div>
<!-- Create Player -->
<div
class="section-label"
style="padding-bottom: 4px; display: flex; justify-content: space-between; align-items: center;"
>
<span>Add New Player</span>
<button class="action-btn new-btn" onclick={() => { showCreateForm = !showCreateForm; }}>
{showCreateForm ? 'Cancel' : 'New Player'}
</button>
</div>
{#if showCreateForm}
<div class="card new-form-card">
<div class="new-form-title">Create a new player</div>
<form
method="POST"
action="?/createPlayer"
use:enhance={() => {
return async ({ result }) => {
if (result.type === 'success') {
resetCreateForm();
await invalidateAll();
} else if (result.type === 'failure') {
const d = result.data as { createMessage?: string } | null;
createMessage = d?.createMessage ?? 'Failed to create player';
}
};
}}
>
<div class="flex flex-wrap gap-4 items-end">
<div>
<label class="block text-sm mb-1" for="create-first-name">First Name</label>
<input
id="create-first-name"
name="firstName"
type="text"
bind:value={createFirstName}
placeholder="First name"
required
/>
</div>
<div>
<label class="block text-sm mb-1" for="create-last-name">Last Name</label>
<input
id="create-last-name"
name="lastName"
type="text"
bind:value={createLastName}
placeholder="Last name"
required
/>
</div>
<div>
<label class="block text-sm mb-1" for="create-team">Team</label>
<select id="create-team" name="teamId" bind:value={createTeamId} required>
<option value="">Select team...</option>
{#each data.teams as team (team.id)}
<option value={team.id}>{team.name}</option>
{/each}
</select>
</div>
<div>
<label class="block text-sm mb-1" for="create-division">Division</label>
<select id="create-division" name="divisionId" bind:value={createDivisionId}>
<option value="">None</option>
{#each data.divisions as div (div.id)}
<option value={div.id}>{div.name}</option>
{/each}
</select>
</div>
<div>
<button
class="action-btn submit-btn"
type="submit"
disabled={!createFirstName || !createLastName || !createTeamId}
>
Create
</button>
</div>
</div>
{#if createMessage}
<p class="error" style="margin-top: 0.5rem;">{createMessage}</p>
{/if}
</form>
</div>
{/if}
<!-- Edit Player -->
{#if editingId !== null}
<div
class="section-label"
style="padding-bottom: 4px; display: flex; justify-content: space-between; align-items: center;"
>
<span>Edit Player</span>
<button class="action-btn new-btn" onclick={cancelEdit}>Cancel</button>
</div>
<div class="card new-form-card">
<div class="new-form-title">Update player details</div>
<form
method="POST"
action="?/editPlayer"
use:enhance={() => {
return async ({ result }) => {
if (result.type === 'success') {
cancelEdit();
await invalidateAll();
} else if (result.type === 'failure') {
const d = result.data as { editMessage?: string } | null;
editMessage = d?.editMessage ?? 'Failed to update player';
}
};
}}
>
<input type="hidden" name="playerId" value={editingId} />
<div class="flex flex-wrap gap-4 items-end">
<div>
<label class="block text-sm mb-1" for="edit-first-name">First Name</label>
<input
id="edit-first-name"
name="firstName"
type="text"
bind:value={editFirstName}
required
/>
</div>
<div>
<label class="block text-sm mb-1" for="edit-last-name">Last Name</label>
<input
id="edit-last-name"
name="lastName"
type="text"
bind:value={editLastName}
required
/>
</div>
<div>
<label class="block text-sm mb-1" for="edit-team">Team</label>
<select id="edit-team" name="teamId" bind:value={editTeamId} required>
{#each data.teams as team (team.id)}
<option value={String(team.id)}>{team.name}</option>
{/each}
</select>
</div>
<div>
<label class="block text-sm mb-1" for="edit-division">Division</label>
<select id="edit-division" name="divisionId" bind:value={editDivisionId}>
<option value="">None</option>
{#each data.divisions as div (div.id)}
<option value={String(div.id)}>{div.name}</option>
{/each}
</select>
</div>
<div>
<button class="action-btn submit-btn" type="submit">Save</button>
</div>
</div>
{#if editMessage}
<p class="error" style="margin-top: 0.5rem;">{editMessage}</p>
{/if}
</form>
</div>
{/if}
<!-- Players Table -->
<div
class="section-label"
style="padding-bottom: 4px; display: flex; justify-content: space-between; align-items: center;"
>
<span>All Players</span>
<span class="text-sm opacity-60">{filteredPlayers.length} / {data.players.length} entries</span>
</div>
<div class="search-bar">
<input
type="text"
placeholder="Search by name or team..."
bind:value={searchQuery}
/>
</div>
<div class="table-section">
<Table data={filteredPlayers} maxHeight="none">
{#snippet header()}
<th class="px-2">First Name</th>
<th class="px-2">Last Name</th>
<th class="px-2">Team</th>
<th class="px-2">Division</th>
<th class="px-2">Actions</th>
{/snippet}
{#snippet row(player: (typeof data.players)[number])}
<td class="px-2">{player.firstName}</td>
<td class="px-2">{player.lastName}</td>
<td class="px-2">{player.teamName}</td>
<td class="px-2">{player.divisionName ?? '-'}</td>
<td class="px-2" style="white-space: nowrap;">
<button
class="action-btn edit-btn"
onclick={() => startEdit(player)}
>
Edit
</button>
<form
method="POST"
action="?/deletePlayer"
use:enhance={() => {
return async ({ result }) => {
if (result.type === 'success') {
await invalidateAll();
} else if (result.type === 'failure') {
const d = result.data as { deleteMessage?: string } | null;
deleteMessage = d?.deleteMessage ?? 'Failed to delete player';
}
};
}}
style="display: inline;"
onsubmit={(e) => {
if (!confirm(`Delete ${player.firstName} ${player.lastName}?`)) {
e.preventDefault();
}
}}
>
<input type="hidden" name="playerId" value={player.id} />
<button class="action-btn delete-btn" type="submit">Delete</button>
</form>
</td>
{/snippet}
</Table>
{#if deleteMessage}
<p class="error" style="margin-top: 0.5rem;">{deleteMessage}</p>
{/if}
</div>
</div>
<style>
.action-btn {
padding: 0.15rem 0.5rem;
font-size: 0.75rem;
border: 1px solid color-mix(in srgb, currentColor 18%, transparent);
border-radius: 4px;
background: color-mix(in srgb, currentColor 6%, transparent);
color: inherit;
cursor: pointer;
margin: 0 2px;
}
.action-btn:hover {
filter: brightness(1.15);
}
.action-btn.new-btn {
border-color: var(--ctp-mocha-green, #a6e3a1);
color: var(--ctp-mocha-green, #a6e3a1);
font-size: 0.85rem;
padding: 0.25rem 0.75rem;
}
.action-btn.submit-btn {
border-color: var(--ctp-mocha-blue, #89b4fa);
color: var(--ctp-mocha-blue, #89b4fa);
padding: 0.3rem 0.75rem;
font-size: 0.85rem;
}
.action-btn.submit-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.action-btn.edit-btn {
border-color: var(--ctp-mocha-yellow, #f9e2af);
color: var(--ctp-mocha-yellow, #f9e2af);
}
.action-btn.delete-btn {
border-color: var(--ctp-mocha-red, #f38ba8);
color: var(--ctp-mocha-red, #f38ba8);
}
.new-form-card {
margin-bottom: 1rem;
padding: 0.75rem 1rem;
}
.new-form-title {
font-size: 0.85rem;
font-weight: 600;
margin-bottom: 0.5rem;
opacity: 0.8;
}
.error {
color: var(--ctp-mocha-red, #f38ba8);
font-size: 0.85rem;
}
.table-section {
margin-top: 0.5rem;
}
.search-bar {
padding: 0 16px 8px;
}
.search-bar input {
width: 100%;
padding: 0.5rem;
border: 1px solid color-mix(in srgb, currentColor 18%, transparent);
border-radius: 6px;
background: color-mix(in srgb, currentColor 6%, transparent);
color: inherit;
font-size: 0.9rem;
}
.search-bar input:focus {
outline: none;
border-color: currentColor;
}
</style>

View File

@@ -0,0 +1,90 @@
import { error, redirect, fail } from '@sveltejs/kit';
import { eq } from 'drizzle-orm';
import { db } from '$lib/server/db';
import { scorers } from '$lib/server/db/schema';
import { hash } from '$lib/server/password';
import type { PageServerLoad, Actions } from './$types';
export const load: PageServerLoad = async ({ locals }) => {
if (!locals.user) {
throw redirect(303, '/login');
}
const [row] = await db
.select({ role: scorers.role })
.from(scorers)
.where(eq(scorers.id, locals.user.id));
if (row?.role !== 'admin') {
throw error(403, 'Forbidden');
}
const users = await db
.select({ id: scorers.id, username: scorers.username, role: scorers.role })
.from(scorers)
.orderBy(scorers.username);
return { users };
};
export const actions: Actions = {
register: async ({ locals, request }) => {
if (!locals.user) throw error(401, 'Unauthorized');
const [row] = await db
.select({ role: scorers.role })
.from(scorers)
.where(eq(scorers.id, locals.user.id));
if (row?.role !== 'admin') throw error(403, 'Forbidden');
const data = await request.formData();
const username = data.get('username') as string;
const password = data.get('password') as string;
if (!username || !password || password.length < 8) {
return fail(400, { registerMessage: 'Username and an 8+ character password are required' });
}
const [existing] = await db.select().from(scorers).where(eq(scorers.username, username));
if (existing) {
return fail(400, { registerMessage: 'Username already taken' });
}
const passwordHash = await hash(password);
const userId = crypto.randomUUID();
await db.insert(scorers).values({ id: userId, username, passwordHash });
return { success: true };
},
changePassword: async ({ locals, request }) => {
if (!locals.user) throw error(401, 'Unauthorized');
const [row] = await db
.select({ role: scorers.role })
.from(scorers)
.where(eq(scorers.id, locals.user.id));
if (row?.role !== 'admin') throw error(403, 'Forbidden');
const data = await request.formData();
const userId = data.get('userId') as string;
const newPassword = data.get('newPassword') as string;
if (!userId || !newPassword || newPassword.length < 8) {
return fail(400, { passwordMessage: 'User selection and an 8+ character password are required' });
}
const [user] = await db.select().from(scorers).where(eq(scorers.id, userId));
if (!user) {
return fail(404, { passwordMessage: 'User not found' });
}
const passwordHash = await hash(newPassword);
await db.update(scorers).set({ passwordHash }).where(eq(scorers.id, userId));
return { success: true };
}
};

View File

@@ -0,0 +1,246 @@
<script lang="ts">
import { enhance } from '$app/forms';
import { invalidateAll } from '$app/navigation';
import Table from '$lib/ui/Table.svelte';
import type { PageData } from './$types';
let { data }: { data: PageData } = $props();
let showRegisterForm = $state(false);
let showPasswordForm = $state(false);
let registerUsername = $state('');
let registerPassword = $state('');
let selectedUserId = $state('');
let newPassword = $state('');
let registerMessage = $state('');
let passwordMessage = $state('');
function resetRegisterForm() {
registerUsername = '';
registerPassword = '';
registerMessage = '';
showRegisterForm = false;
}
function resetPasswordForm() {
selectedUserId = '';
newPassword = '';
passwordMessage = '';
showPasswordForm = false;
}
</script>
<div class="page-container">
<div class="section-label">Manage Users</div>
<!-- Register New Scorer -->
<div
class="section-label"
style="padding-bottom: 4px; display: flex; justify-content: space-between; align-items: center;"
>
<span>Register New Scorer</span>
<button class="action-btn new-btn" onclick={() => { showRegisterForm = !showRegisterForm; }}>
{showRegisterForm ? 'Cancel' : 'New Scorer'}
</button>
</div>
{#if showRegisterForm}
<div class="card new-form-card">
<div class="new-form-title">Create a new scorer account</div>
<form
method="POST"
action="?/register"
use:enhance={() => {
return async ({ result }) => {
if (result.type === 'success') {
resetRegisterForm();
await invalidateAll();
} else if (result.type === 'failure') {
const d = result.data as { registerMessage?: string } | null;
registerMessage = d?.registerMessage ?? 'Registration failed';
}
};
}}
>
<div class="flex flex-wrap gap-4 items-end">
<div>
<label class="block text-sm mb-1" for="register-username">Username</label>
<input
id="register-username"
name="username"
type="text"
bind:value={registerUsername}
placeholder="scorer username"
required
/>
</div>
<div>
<label class="block text-sm mb-1" for="register-password">Password</label>
<input
id="register-password"
name="password"
type="password"
bind:value={registerPassword}
placeholder="min 8 characters"
required
/>
</div>
<div>
<button
class="action-btn submit-btn"
type="submit"
disabled={!registerUsername || registerPassword.length < 8}
>
Register
</button>
</div>
</div>
{#if registerMessage}
<p class="error" style="margin-top: 0.5rem;">{registerMessage}</p>
{/if}
</form>
</div>
{/if}
<!-- Change Password -->
<div
class="section-label"
style="padding-bottom: 4px; display: flex; justify-content: space-between; align-items: center;"
>
<span>Change Password</span>
<button class="action-btn new-btn" onclick={() => { showPasswordForm = !showPasswordForm; }}>
{showPasswordForm ? 'Cancel' : 'Change Password'}
</button>
</div>
{#if showPasswordForm}
<div class="card new-form-card">
<div class="new-form-title">Set a new password for an existing scorer</div>
<form
method="POST"
action="?/changePassword"
use:enhance={() => {
return async ({ result }) => {
if (result.type === 'success') {
resetPasswordForm();
} else if (result.type === 'failure') {
const d = result.data as { passwordMessage?: string } | null;
passwordMessage = d?.passwordMessage ?? 'Password change failed';
}
};
}}
>
<div class="flex flex-wrap gap-4 items-end">
<div>
<label class="block text-sm mb-1" for="change-user">User</label>
<select id="change-user" name="userId" bind:value={selectedUserId} required>
<option value="">Select user...</option>
{#each data.users as user (user.id)}
<option value={user.id}>{user.username} ({user.role})</option>
{/each}
</select>
</div>
<div>
<label class="block text-sm mb-1" for="change-password">New Password</label>
<input
id="change-password"
name="newPassword"
type="password"
bind:value={newPassword}
placeholder="min 8 characters"
required
/>
</div>
<div>
<button
class="action-btn submit-btn"
type="submit"
disabled={!selectedUserId || newPassword.length < 8}
>
Update Password
</button>
</div>
</div>
{#if passwordMessage}
<p class="error" style="margin-top: 0.5rem;">{passwordMessage}</p>
{/if}
</form>
</div>
{/if}
<!-- Users Table -->
<div
class="section-label"
style="padding-bottom: 4px; display: flex; justify-content: space-between; align-items: center;"
>
<span>All Scorers</span>
<span class="text-sm opacity-60">{data.users.length} entries</span>
</div>
<Table data={data.users} maxHeight="none">
{#snippet header()}
<th class="px-2">Username</th>
<th class="px-2">Role</th>
{/snippet}
{#snippet row(user: (typeof data.users)[number])}
<td class="px-2">{user.username}</td>
<td class="px-2">{user.role}</td>
{/snippet}
</Table>
</div>
<style>
.action-btn {
padding: 0.15rem 0.5rem;
font-size: 0.75rem;
border: 1px solid color-mix(in srgb, currentColor 18%, transparent);
border-radius: 4px;
background: color-mix(in srgb, currentColor 6%, transparent);
color: inherit;
cursor: pointer;
margin: 0 2px;
}
.action-btn:hover {
filter: brightness(1.15);
}
.action-btn.new-btn {
border-color: var(--ctp-mocha-green, #a6e3a1);
color: var(--ctp-mocha-green, #a6e3a1);
font-size: 0.85rem;
padding: 0.25rem 0.75rem;
}
.action-btn.submit-btn {
border-color: var(--ctp-mocha-blue, #89b4fa);
color: var(--ctp-mocha-blue, #89b4fa);
padding: 0.3rem 0.75rem;
font-size: 0.85rem;
}
.action-btn.submit-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.new-form-card {
margin-bottom: 1rem;
padding: 0.75rem 1rem;
}
.new-form-title {
font-size: 0.85rem;
font-weight: 600;
margin-bottom: 0.5rem;
opacity: 0.8;
}
.error {
color: var(--ctp-mocha-red, #f38ba8);
font-size: 0.85rem;
}
</style>

View File

@@ -1,7 +1,6 @@
<script lang="ts">
import type { PageData, ActionData } from './$types';
export let data: PageData;
export let form: ActionData;
import type { PageData } from './$types';
let { data, form }: { data: PageData; form: any } = $props();
</script>
<div class="page-container">

View File

@@ -1,9 +1,8 @@
import adapter from '@sveltejs/adapter-auto';
import adapter from '@sveltejs/adapter-node';
/** @type {import('@sveltejs/kit').Config} */
const config = {
compilerOptions: {
// Force runes mode (except in node_modules). Remove when Svelte 6 makes it default.
runes: ({ filename }) => (filename.split(/[/\\]/).includes('node_modules') ? undefined : true)
},
kit: {
@@ -18,11 +17,6 @@ const config = {
experimental: {
remoteFunctions: true
}
},
compilerOptions: {
experimental: {
async: true
}
}
};