added player management screen; we are good to go!!!!!
This commit is contained in:
@@ -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>
|
||||
|
||||
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>
|
||||
Reference in New Issue
Block a user