Compare commits
6 Commits
097ff77c8a
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 3b636243ff | |||
| 17acf0a497 | |||
| a3ead298af | |||
| 8394ad45c8 | |||
| feecae7792 | |||
| 7f9a03eed6 |
7450
package-lock.json
generated
Normal file
7450
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -25,6 +25,7 @@
|
|||||||
"@eslint/js": "^10.0.1",
|
"@eslint/js": "^10.0.1",
|
||||||
"@libsql/client": "^0.17.2",
|
"@libsql/client": "^0.17.2",
|
||||||
"@sveltejs/adapter-auto": "^7.0.1",
|
"@sveltejs/adapter-auto": "^7.0.1",
|
||||||
|
"@sveltejs/adapter-node": "^5.5.7",
|
||||||
"@sveltejs/kit": "^2.57.0",
|
"@sveltejs/kit": "^2.57.0",
|
||||||
"@sveltejs/vite-plugin-svelte": "^7.0.0",
|
"@sveltejs/vite-plugin-svelte": "^7.0.0",
|
||||||
"@tailwindcss/forms": "^0.5.11",
|
"@tailwindcss/forms": "^0.5.11",
|
||||||
|
|||||||
1
run.sh
Normal file
1
run.sh
Normal file
@@ -0,0 +1 @@
|
|||||||
|
DATABASE_URL="file:local.db" ORIGIN="http://localhost:3000" node build/index.js
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
div_name
|
div_name
|
||||||
|
Seniors
|
||||||
Year 7
|
Year 7
|
||||||
Year 8
|
Year 8
|
||||||
Year 9
|
Year 9
|
||||||
|
|||||||
|
@@ -1,6 +1,6 @@
|
|||||||
team_name,color
|
team_name,color
|
||||||
|
County,red
|
||||||
East,pink
|
East,pink
|
||||||
West,yellow
|
|
||||||
Laud,blue
|
Laud,blue
|
||||||
School,green
|
School,green
|
||||||
County,red
|
West,yellow
|
||||||
|
|||||||
|
@@ -127,9 +127,24 @@ async function seed() {
|
|||||||
const divisionId = divisionMap.get(row.division);
|
const divisionId = divisionMap.get(row.division);
|
||||||
if (!teamId) throw new Error(`Team "${row.team}" not found`);
|
if (!teamId) throw new Error(`Team "${row.team}" not found`);
|
||||||
|
|
||||||
|
let firstName = row.firstName;
|
||||||
|
let lastName = row.lastName;
|
||||||
|
|
||||||
|
// If lastName is empty, default to a space (column is nullable in practice)
|
||||||
|
if (!lastName) {
|
||||||
|
lastName = ' ';
|
||||||
|
}
|
||||||
|
|
||||||
|
// If firstName contains a space, treat the second word as lastName (only if lastName was blank)
|
||||||
|
if (lastName === ' ' && firstName && firstName.includes(' ')) {
|
||||||
|
const parts = firstName.split(' ');
|
||||||
|
firstName = parts[0];
|
||||||
|
lastName = parts.slice(1).join(' ');
|
||||||
|
}
|
||||||
|
|
||||||
await db.insert(schema.players).values({
|
await db.insert(schema.players).values({
|
||||||
firstName: row.firstName,
|
firstName,
|
||||||
lastName: row.lastName,
|
lastName,
|
||||||
team: teamId,
|
team: teamId,
|
||||||
division: divisionId || null
|
division: divisionId || null
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ export async function setSetting(key: string, value: string): Promise<void> {
|
|||||||
export async function getAllInitialInfo() {
|
export async function getAllInitialInfo() {
|
||||||
return {
|
return {
|
||||||
teams: await getTeams(),
|
teams: await getTeams(),
|
||||||
events: await getRegisteredEvents()
|
events: await getRegisteredEventsWithPlayers()
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { drizzle } from 'drizzle-orm/libsql';
|
import { createClient } from './libsql-wrapper';
|
||||||
import { createClient } from '@libsql/client';
|
import { construct } from 'drizzle-orm/libsql/driver-core';
|
||||||
import * as schema from './schema';
|
import * as schema from './schema';
|
||||||
import { env } from '$env/dynamic/private';
|
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 });
|
const client = createClient({ url: env.DATABASE_URL });
|
||||||
|
|
||||||
export const db = drizzle(client, { schema });
|
export const db = construct(client, { schema });
|
||||||
|
|||||||
7
src/lib/server/db/libsql-wrapper.ts
Normal file
7
src/lib/server/db/libsql-wrapper.ts
Normal 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;
|
||||||
@@ -56,8 +56,11 @@
|
|||||||
<div class="dropdown-menu" role="menu" tabindex="-1">
|
<div class="dropdown-menu" role="menu" tabindex="-1">
|
||||||
<a href="/ledger" role="menuitem">ledger</a>
|
<a href="/ledger" role="menuitem">ledger</a>
|
||||||
<a href="/registrations" role="menuitem">registrations</a>
|
<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="/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>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -9,8 +9,8 @@ import { flip } from 'svelte/animate';
|
|||||||
focusEventId?: number | null;
|
focusEventId?: number | null;
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
let teams = $derived(data.teams.teams);
|
let teams = $state(data.teams.teams);
|
||||||
let eventTable = $derived(data.events.events);
|
let eventTable = $state(data.events);
|
||||||
|
|
||||||
let scoreEndpoint: EventSource;
|
let scoreEndpoint: EventSource;
|
||||||
let eventEndpoint: EventSource;
|
let eventEndpoint: EventSource;
|
||||||
@@ -64,7 +64,7 @@ import { flip } from 'svelte/animate';
|
|||||||
if (focusEventId == null) return;
|
if (focusEventId == null) return;
|
||||||
tick().then(() => {
|
tick().then(() => {
|
||||||
const el = eventRefs[focusEventId!];
|
const el = eventRefs[focusEventId!];
|
||||||
el.scrollIntoView({ alignToTop: true, behavior: 'instant', container: 'nearest' });
|
el.scrollIntoView({ block: 'start', behavior: 'instant' });
|
||||||
tick().then(() => {
|
tick().then(() => {
|
||||||
window.scrollTo(0, 0);
|
window.scrollTo(0, 0);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ export async function POST({ request }: any) {
|
|||||||
return new Error('send a response dummy');
|
return new Error('send a response dummy');
|
||||||
} else {
|
} else {
|
||||||
console.log(responseBody);
|
console.log(responseBody);
|
||||||
startEvent(responseBody.eventId);
|
await startEvent(responseBody.eventId);
|
||||||
return new Response('ok');
|
return new Response('ok');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
21
src/routes/api/eventTypes/+server.ts
Normal file
21
src/routes/api/eventTypes/+server.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import { json } from '@sveltejs/kit';
|
||||||
|
import { db } from '$lib/server/db';
|
||||||
|
import { eventTypes } from '$lib/server/db/schema';
|
||||||
|
|
||||||
|
export async function POST({ request, locals }) {
|
||||||
|
if (!locals.user) return new Response('Unauthorized', { status: 401 });
|
||||||
|
|
||||||
|
const body = await request.json();
|
||||||
|
const { name, scoringPreset, resultPreset } = body;
|
||||||
|
|
||||||
|
if (!name || scoringPreset == null || resultPreset == null) {
|
||||||
|
return json({ error: 'Missing required fields' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const [inserted] = await db
|
||||||
|
.insert(eventTypes)
|
||||||
|
.values({ name, scoringPreset, resultPreset })
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
return json(inserted);
|
||||||
|
}
|
||||||
24
src/routes/api/registerEvent/+server.ts
Normal file
24
src/routes/api/registerEvent/+server.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import { json } from '@sveltejs/kit';
|
||||||
|
import { db } from '$lib/server/db';
|
||||||
|
import { registeredEvents } from '$lib/server/db/schema';
|
||||||
|
import { globalEmitter } from '$lib/server/globalEmitter';
|
||||||
|
|
||||||
|
export async function POST({ request, locals }) {
|
||||||
|
if (!locals.user) return new Response('Unauthorized', { status: 401 });
|
||||||
|
|
||||||
|
const body = await request.json();
|
||||||
|
const { eventType, division } = body;
|
||||||
|
|
||||||
|
if (eventType == null || division == null) {
|
||||||
|
return json({ error: 'Missing required fields' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const [inserted] = await db
|
||||||
|
.insert(registeredEvents)
|
||||||
|
.values({ eventType, division, state: 0 })
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
globalEmitter.emit('eventUpdate');
|
||||||
|
|
||||||
|
return json(inserted);
|
||||||
|
}
|
||||||
@@ -3,7 +3,7 @@ import { globalEmitter } from '$lib/server/globalEmitter';
|
|||||||
import { generateEndpoint } from '$lib/server/endpoint';
|
import { generateEndpoint } from '$lib/server/endpoint';
|
||||||
|
|
||||||
export async function POST({ request }: any) {
|
export async function POST({ request }: any) {
|
||||||
globalEmitter.emit('incrementScores');
|
globalEmitter.emit('scoreUpdate');
|
||||||
return new Response('ok');
|
return new Response('ok');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,14 +1,25 @@
|
|||||||
import { eq } from 'drizzle-orm';
|
import { eq } from 'drizzle-orm';
|
||||||
import { db } from '$lib/server/db';
|
import { db } from '$lib/server/db';
|
||||||
import { scorers } from '$lib/server/db/schema';
|
import { scorers } from '$lib/server/db/schema';
|
||||||
|
import { getRegisteredEventsWithPlayers } from '$lib/server/databaseManager';
|
||||||
import type { PageServerLoad } from './$types';
|
import type { PageServerLoad } from './$types';
|
||||||
|
|
||||||
export const load: PageServerLoad = async ({ locals }) => {
|
export const load: PageServerLoad = async ({ locals, params }) => {
|
||||||
if (!locals.user) return { user: null };
|
const eventId = parseInt(params.eventId);
|
||||||
|
const userInfo: { user: any } = { user: null };
|
||||||
|
|
||||||
const [row] = await db
|
if (locals.user) {
|
||||||
.select({ role: scorers.role })
|
const [row] = await db
|
||||||
.from(scorers)
|
.select({ role: scorers.role })
|
||||||
.where(eq(scorers.id, locals.user.id));
|
.from(scorers)
|
||||||
return { user: { ...locals.user, role: row?.role ?? 'scorer' } };
|
.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
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -31,59 +31,50 @@
|
|||||||
|
|
||||||
let eventEndpoint: EventSource;
|
let eventEndpoint: EventSource;
|
||||||
|
|
||||||
async function getEventData() {
|
let eventData = $state<any>(data.event);
|
||||||
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();
|
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
eventEndpoint = new EventSource('/api/registeredEvents');
|
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>
|
</script>
|
||||||
|
|
||||||
{#await eventDataPromise}
|
{#if eventData}
|
||||||
<div class="page-container"><div>loading</div></div>
|
|
||||||
{:then eventData}
|
|
||||||
{@const event = eventData[0]}
|
|
||||||
<div class="page-container">
|
<div class="page-container">
|
||||||
<div class="event-card" class:ongoing-event={event.state == 1} class:completed-event={event.state == 2}
|
<div class="event-card" class:ongoing-event={eventData.state == 1} class:completed-event={eventData.state == 2}
|
||||||
style={event.state == 2 ? `--event-color: ${event.winner?.color ?? 'currentColor'}` : ''}>
|
style={eventData.state == 2 ? `--event-color: ${eventData.winner?.color ?? 'currentColor'}` : ''}>
|
||||||
<div class="event-header">
|
<div class="event-header">
|
||||||
<span class="event-name goldman">{event.name}</span>
|
<span class="event-name goldman">{eventData.name}</span>
|
||||||
<span class="event-division">{event.division}</span>
|
<span class="event-division">{eventData.division}</span>
|
||||||
{#if event.state == 1}
|
{#if eventData.state == 1}
|
||||||
<span class="event-status goldman">ONGOING</span>
|
<span class="event-status goldman">ONGOING</span>
|
||||||
{:else if event.state == 2}
|
{:else if eventData.state == 2}
|
||||||
<span class="event-status goldman">FINISHED</span>
|
<span class="event-status goldman">FINISHED</span>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="brackets px-[5vw]">
|
<div class="brackets px-[5vw]">
|
||||||
{#each event.registeredPlayers as bracket, bi}
|
{#each eventData.registeredPlayers as bracket, bi}
|
||||||
{#if bi > 0}
|
{#if bi > 0}
|
||||||
<div class="bracket-sep" aria-hidden="true"></div>
|
<div class="bracket-sep" aria-hidden="true"></div>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -118,7 +109,7 @@
|
|||||||
<div class="flex flex-col justify-center">
|
<div class="flex flex-col justify-center">
|
||||||
{#each player.playerScores as score}
|
{#each player.playerScores as score}
|
||||||
<div class="mx-1 my-0.5 rounded border px-1 text-xs" style="border-color:{player.teamColor}">
|
<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>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
@@ -141,4 +132,6 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/await}
|
{:else}
|
||||||
|
<div class="page-container"><div>loading</div></div>
|
||||||
|
{/if}
|
||||||
@@ -209,6 +209,9 @@
|
|||||||
eventId: eventId
|
eventId: eventId
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
event = { ...event, state: 1 };
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
return new Error();
|
return new Error();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -74,12 +74,21 @@ export const actions: Actions = {
|
|||||||
const record = records[i];
|
const record = records[i];
|
||||||
const rowNum = i + 2;
|
const rowNum = i + 2;
|
||||||
|
|
||||||
const firstName = String(record['First Name'] ?? '').trim();
|
let firstName = String(record['First Name'] ?? '').trim();
|
||||||
const lastName = String(record['Last name'] ?? '').trim();
|
let lastName = String(record['Last name'] ?? '').trim();
|
||||||
|
|
||||||
if (!firstName && !lastName) continue;
|
if (!firstName && !lastName) continue;
|
||||||
if (!firstName) { errors.push(`Row ${rowNum}: Missing first name`); continue; }
|
if (!firstName) { errors.push(`Row ${rowNum}: Missing first name`); continue; }
|
||||||
if (!lastName) { errors.push(`Row ${rowNum}: Missing last name`); continue; }
|
|
||||||
|
if (!lastName) {
|
||||||
|
lastName = ' ';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lastName === ' ' && firstName.includes(' ')) {
|
||||||
|
const parts = firstName.split(' ');
|
||||||
|
firstName = parts[0];
|
||||||
|
lastName = parts.slice(1).join(' ');
|
||||||
|
}
|
||||||
|
|
||||||
const rawBracket = String(record['Bracket'] ?? '').trim();
|
const rawBracket = String(record['Bracket'] ?? '').trim();
|
||||||
const rawEvent = String(record['Event'] ?? '').trim();
|
const rawEvent = String(record['Event'] ?? '').trim();
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { ActionData, PageData } from './$types';
|
import type { ActionData, PageData } from './$types';
|
||||||
export let data: PageData;
|
let { data, form }: { data: PageData; form: any } = $props();
|
||||||
export let form: ActionData;
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="page-container">
|
<div class="page-container">
|
||||||
|
|||||||
74
src/routes/manage-events/+page.server.ts
Normal file
74
src/routes/manage-events/+page.server.ts
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
import { error, redirect } from '@sveltejs/kit';
|
||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
import { db } from '$lib/server/db';
|
||||||
|
import {
|
||||||
|
scorers,
|
||||||
|
eventTypes,
|
||||||
|
registeredEvents,
|
||||||
|
divisions,
|
||||||
|
scoringPresets,
|
||||||
|
resultPresets,
|
||||||
|
teams
|
||||||
|
} from '$lib/server/db/schema';
|
||||||
|
import type { PageServerLoad } 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 eventTypeList = await db.select().from(eventTypes).orderBy(eventTypes.id);
|
||||||
|
const divisionList = await db.select().from(divisions).orderBy(divisions.id);
|
||||||
|
const resultPresetList = await db.select().from(resultPresets).orderBy(resultPresets.id);
|
||||||
|
|
||||||
|
const scoringPresetRows = await db
|
||||||
|
.select()
|
||||||
|
.from(scoringPresets)
|
||||||
|
.orderBy(scoringPresets.presetID, scoringPresets.placement);
|
||||||
|
|
||||||
|
const scoringPresetMap = new Map<number, { placement: number; points: number }[]>();
|
||||||
|
for (const row of scoringPresetRows) {
|
||||||
|
if (!scoringPresetMap.has(row.presetID)) {
|
||||||
|
scoringPresetMap.set(row.presetID, []);
|
||||||
|
}
|
||||||
|
scoringPresetMap.get(row.presetID)!.push({ placement: row.placement, points: row.points });
|
||||||
|
}
|
||||||
|
|
||||||
|
const scoringPresetsList = Array.from(scoringPresetMap.entries()).map(([id, entries]) => ({
|
||||||
|
presetID: id,
|
||||||
|
entries
|
||||||
|
}));
|
||||||
|
|
||||||
|
const registeredEventRows = await db
|
||||||
|
.select({
|
||||||
|
id: registeredEvents.id,
|
||||||
|
eventId: registeredEvents.id,
|
||||||
|
eventName: eventTypes.name,
|
||||||
|
division: divisions.name,
|
||||||
|
state: registeredEvents.state,
|
||||||
|
timeCompleted: registeredEvents.timeCompleted,
|
||||||
|
winner: registeredEvents.teamWinner,
|
||||||
|
winnerName: teams.name,
|
||||||
|
winnerColor: teams.color,
|
||||||
|
scorePreset: eventTypes.scoringPreset,
|
||||||
|
resultPreset: eventTypes.resultPreset
|
||||||
|
})
|
||||||
|
.from(registeredEvents)
|
||||||
|
.innerJoin(eventTypes, eq(registeredEvents.eventType, eventTypes.id))
|
||||||
|
.innerJoin(divisions, eq(registeredEvents.division, divisions.id))
|
||||||
|
.leftJoin(teams, eq(registeredEvents.teamWinner, teams.id))
|
||||||
|
.orderBy(registeredEvents.id);
|
||||||
|
|
||||||
|
return {
|
||||||
|
eventTypes: eventTypeList,
|
||||||
|
divisions: divisionList,
|
||||||
|
resultPresets: resultPresetList,
|
||||||
|
scoringPresets: scoringPresetsList,
|
||||||
|
registeredEvents: registeredEventRows
|
||||||
|
};
|
||||||
|
};
|
||||||
321
src/routes/manage-events/+page.svelte
Normal file
321
src/routes/manage-events/+page.svelte
Normal file
@@ -0,0 +1,321 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import Table from '$lib/ui/Table.svelte';
|
||||||
|
import type { PageData } from './$types';
|
||||||
|
|
||||||
|
let { data }: { data: PageData } = $props();
|
||||||
|
|
||||||
|
// --- Create Event Type form ---
|
||||||
|
let showCreateForm = $state(false);
|
||||||
|
let newEventName = $state('');
|
||||||
|
let newScoringPreset = $state<number>(0);
|
||||||
|
let newResultPreset = $state<number>(0);
|
||||||
|
|
||||||
|
// --- Register Event form ---
|
||||||
|
let showRegisterForm = $state(false);
|
||||||
|
let registerEventType = $state<number>(0);
|
||||||
|
let registerDivision = $state<number>(0);
|
||||||
|
|
||||||
|
function scoringPresetDescription(entries: { placement: number; points: number }[]): string {
|
||||||
|
return entries.map((e) => `${e.placement}→${e.points}`).join(' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitCreateEventType() {
|
||||||
|
if (!newEventName || !newScoringPreset || !newResultPreset) return;
|
||||||
|
const res = await fetch('/api/eventTypes', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
name: newEventName,
|
||||||
|
scoringPreset: newScoringPreset,
|
||||||
|
resultPreset: newResultPreset
|
||||||
|
})
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
alert('Failed to create event type');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const result = await res.json();
|
||||||
|
data.eventTypes.push({
|
||||||
|
id: result.id,
|
||||||
|
name: newEventName,
|
||||||
|
scoringPreset: newScoringPreset,
|
||||||
|
resultPreset: newResultPreset
|
||||||
|
});
|
||||||
|
newEventName = '';
|
||||||
|
newScoringPreset = 0;
|
||||||
|
newResultPreset = 0;
|
||||||
|
showCreateForm = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitRegisterEvent() {
|
||||||
|
if (!registerEventType || !registerDivision) return;
|
||||||
|
const res = await fetch('/api/registerEvent', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
eventType: registerEventType,
|
||||||
|
division: registerDivision
|
||||||
|
})
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
alert('Failed to register event');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const result = await res.json();
|
||||||
|
const et = data.eventTypes.find((e) => e.id === registerEventType);
|
||||||
|
const dv = data.divisions.find((d) => d.id === registerDivision);
|
||||||
|
if (et && dv) {
|
||||||
|
data.registeredEvents.push({
|
||||||
|
id: result.id,
|
||||||
|
eventId: result.id,
|
||||||
|
eventName: et.name,
|
||||||
|
division: dv.name,
|
||||||
|
state: 0,
|
||||||
|
timeCompleted: null,
|
||||||
|
winner: null,
|
||||||
|
winnerName: null,
|
||||||
|
winnerColor: null,
|
||||||
|
scorePreset: et.scoringPreset,
|
||||||
|
resultPreset: et.resultPreset
|
||||||
|
});
|
||||||
|
}
|
||||||
|
registerEventType = 0;
|
||||||
|
registerDivision = 0;
|
||||||
|
showRegisterForm = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
let eventTypesWithPresets = $derived(
|
||||||
|
data.eventTypes.map((et) => {
|
||||||
|
const sp = data.scoringPresets.find((s) => s.presetID === et.scoringPreset);
|
||||||
|
const rp = data.resultPresets.find((r) => r.id === et.resultPreset);
|
||||||
|
return {
|
||||||
|
id: et.id,
|
||||||
|
name: et.name,
|
||||||
|
scoringPresetDescription: sp ? scoringPresetDescription(sp.entries) : 'Unknown',
|
||||||
|
resultPresetName: rp ? rp.presetName : 'Unknown'
|
||||||
|
};
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
function stateLabel(state: number): string {
|
||||||
|
if (state === 0) return 'Pending';
|
||||||
|
if (state === 1) return 'Ongoing';
|
||||||
|
return 'Finished';
|
||||||
|
}
|
||||||
|
|
||||||
|
function stateColor(state: number): string {
|
||||||
|
if (state === 0) return 'var(--ctp-mocha-yellow)';
|
||||||
|
if (state === 1) return 'var(--ctp-mocha-green)';
|
||||||
|
return 'var(--ctp-mocha-blue)';
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="page-container">
|
||||||
|
<div class="section-label">Manage Events</div>
|
||||||
|
|
||||||
|
<!-- Create Event Type -->
|
||||||
|
<div
|
||||||
|
class="section-label"
|
||||||
|
style="padding-bottom: 4px; display: flex; justify-content: space-between; align-items: center;"
|
||||||
|
>
|
||||||
|
<span>Create Event Type</span>
|
||||||
|
<button class="action-btn new-btn" onclick={() => { showCreateForm = !showCreateForm; }}>
|
||||||
|
{showCreateForm ? 'Cancel' : 'New Event Type'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if showCreateForm}
|
||||||
|
<div class="card new-form-card">
|
||||||
|
<div class="new-form-title">New Event Type</div>
|
||||||
|
<div class="flex flex-wrap gap-4 items-end">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm mb-1" for="new-event-name">Event Name</label>
|
||||||
|
<input
|
||||||
|
id="new-event-name"
|
||||||
|
type="text"
|
||||||
|
bind:value={newEventName}
|
||||||
|
placeholder="e.g. 100m Sprint"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm mb-1" for="new-scoring-preset">Scoring Preset</label>
|
||||||
|
<select id="new-scoring-preset" bind:value={newScoringPreset}>
|
||||||
|
<option value={0}>Select scoring...</option>
|
||||||
|
{#each data.scoringPresets as preset (preset.presetID)}
|
||||||
|
<option value={preset.presetID}>
|
||||||
|
Preset {preset.presetID} ({scoringPresetDescription(preset.entries)})
|
||||||
|
</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm mb-1" for="new-result-preset">Result Preset</label>
|
||||||
|
<select id="new-result-preset" bind:value={newResultPreset}>
|
||||||
|
<option value={0}>Select result...</option>
|
||||||
|
{#each data.resultPresets as rp (rp.id)}
|
||||||
|
<option value={rp.id}>{rp.presetName} ({rp.numberOfResults} result(s), {rp.unit})</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
class="action-btn submit-btn"
|
||||||
|
onclick={submitCreateEventType}
|
||||||
|
disabled={!newEventName || !newScoringPreset || !newResultPreset}
|
||||||
|
>
|
||||||
|
Create
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Event Types Table -->
|
||||||
|
<div
|
||||||
|
class="section-label"
|
||||||
|
style="padding-bottom: 4px; display: flex; justify-content: space-between; align-items: center;"
|
||||||
|
>
|
||||||
|
<span>Event Types</span>
|
||||||
|
<span class="text-sm opacity-60">{data.eventTypes.length} entries</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Table data={eventTypesWithPresets} maxHeight="none">
|
||||||
|
{#snippet header()}
|
||||||
|
<th class="px-2">ID</th>
|
||||||
|
<th class="px-2">Name</th>
|
||||||
|
<th class="px-2">Scoring Preset</th>
|
||||||
|
<th class="px-2">Result Preset</th>
|
||||||
|
{/snippet}
|
||||||
|
{#snippet row(et: (typeof eventTypesWithPresets)[number])}
|
||||||
|
<td class="px-2">{et.id}</td>
|
||||||
|
<td class="px-2">{et.name}</td>
|
||||||
|
<td class="px-2">{et.scoringPresetDescription}</td>
|
||||||
|
<td class="px-2">{et.resultPresetName}</td>
|
||||||
|
{/snippet}
|
||||||
|
</Table>
|
||||||
|
|
||||||
|
<!-- Register Event -->
|
||||||
|
<div
|
||||||
|
class="section-label"
|
||||||
|
style="padding-bottom: 4px; display: flex; justify-content: space-between; align-items: center;"
|
||||||
|
>
|
||||||
|
<span>Register Event</span>
|
||||||
|
<button class="action-btn new-btn" onclick={() => { showRegisterForm = !showRegisterForm; }}>
|
||||||
|
{showRegisterForm ? 'Cancel' : 'Register Event'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if showRegisterForm}
|
||||||
|
<div class="card new-form-card">
|
||||||
|
<div class="new-form-title">Register an Event for a Division</div>
|
||||||
|
<div class="flex flex-wrap gap-4 items-end">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm mb-1" for="register-event-type">Event Type</label>
|
||||||
|
<select id="register-event-type" bind:value={registerEventType}>
|
||||||
|
<option value={0}>Select event type...</option>
|
||||||
|
{#each data.eventTypes as et (et.id)}
|
||||||
|
<option value={et.id}>{et.name}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm mb-1" for="register-division">Division</label>
|
||||||
|
<select id="register-division" bind:value={registerDivision}>
|
||||||
|
<option value={0}>Select division...</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"
|
||||||
|
onclick={submitRegisterEvent}
|
||||||
|
disabled={!registerEventType || !registerDivision}
|
||||||
|
>
|
||||||
|
Register
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Registered Events Table -->
|
||||||
|
<div
|
||||||
|
class="section-label"
|
||||||
|
style="padding-bottom: 4px; display: flex; justify-content: space-between; align-items: center;"
|
||||||
|
>
|
||||||
|
<span>Registered Events</span>
|
||||||
|
<span class="text-sm opacity-60">{data.registeredEvents.length} entries</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Table data={data.registeredEvents} maxHeight="none">
|
||||||
|
{#snippet header()}
|
||||||
|
<th class="px-2">ID</th>
|
||||||
|
<th class="px-2">Event Name</th>
|
||||||
|
<th class="px-2">Division</th>
|
||||||
|
<th class="px-2">State</th>
|
||||||
|
<th class="px-2">Winner</th>
|
||||||
|
{/snippet}
|
||||||
|
{#snippet row(event: (typeof data.registeredEvents)[number])}
|
||||||
|
<td class="px-2">{event.eventId}</td>
|
||||||
|
<td class="px-2">{event.eventName}</td>
|
||||||
|
<td class="px-2">{event.division}</td>
|
||||||
|
<td class="px-2" style="color: {stateColor(event.state)};">{stateLabel(event.state)}</td>
|
||||||
|
<td
|
||||||
|
class="px-2"
|
||||||
|
style={event.winnerName ? `color: var(--ctp-mocha-${event.winnerColor});` : ''}
|
||||||
|
>
|
||||||
|
{event.winnerName ?? '-'}
|
||||||
|
</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;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
136
src/routes/manage-players/+page.server.ts
Normal file
136
src/routes/manage-players/+page.server.ts
Normal 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 };
|
||||||
|
}
|
||||||
|
};
|
||||||
383
src/routes/manage-players/+page.svelte
Normal file
383
src/routes/manage-players/+page.svelte
Normal 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>
|
||||||
90
src/routes/manage-users/+page.server.ts
Normal file
90
src/routes/manage-users/+page.server.ts
Normal 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 };
|
||||||
|
}
|
||||||
|
};
|
||||||
246
src/routes/manage-users/+page.svelte
Normal file
246
src/routes/manage-users/+page.svelte
Normal 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>
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { PageData, ActionData } from './$types';
|
import type { PageData } from './$types';
|
||||||
export let data: PageData;
|
let { data, form }: { data: PageData; form: any } = $props();
|
||||||
export let form: ActionData;
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="page-container">
|
<div class="page-container">
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
import adapter from '@sveltejs/adapter-auto';
|
import adapter from '@sveltejs/adapter-node';
|
||||||
|
|
||||||
/** @type {import('@sveltejs/kit').Config} */
|
/** @type {import('@sveltejs/kit').Config} */
|
||||||
const config = {
|
const config = {
|
||||||
compilerOptions: {
|
compilerOptions: {
|
||||||
// Force runes mode (except in node_modules). Remove when Svelte 6 makes it default.
|
|
||||||
runes: ({ filename }) => (filename.split(/[/\\]/).includes('node_modules') ? undefined : true)
|
runes: ({ filename }) => (filename.split(/[/\\]/).includes('node_modules') ? undefined : true)
|
||||||
},
|
},
|
||||||
kit: {
|
kit: {
|
||||||
@@ -18,11 +17,6 @@ const config = {
|
|||||||
experimental: {
|
experimental: {
|
||||||
remoteFunctions: true
|
remoteFunctions: true
|
||||||
}
|
}
|
||||||
},
|
|
||||||
compilerOptions: {
|
|
||||||
experimental: {
|
|
||||||
async: true
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user