feat: implement leaderboard page with top players, teams, and high scores

- Added a new leaderboard route with server-side loading for top players, teams, and recent high scores.
- Created a new Svelte component for the leaderboard UI, including tabs for players, teams, and scores.
- Implemented ELO ranking display and win/loss statistics for players.
- Added functionality to display team wins and member counts.
- Included recent high scores with player context and map details.
- Enhanced team management features in the teams route, including renaming and member management with permission checks.
This commit is contained in:
2026-02-28 18:18:11 +01:00
parent fb9140a03f
commit b506dba0eb
285 changed files with 1184 additions and 47362 deletions

View File

@@ -3,10 +3,11 @@ import { mappool, mappoolSlot } from '$lib/server/db/schema';
import { eq, and } from 'drizzle-orm';
import { error, redirect } from '@sveltejs/kit';
import { getBeatmap } from '$lib/server/osu/api';
import { requireAuth, requireOwnerOrAdmin } from '$lib/server/permissions';
import type { PageServerLoad, Actions } from './$types';
export const load: PageServerLoad = async ({ params, locals }) => {
if (!locals.user) redirect(302, '/');
requireAuth(locals);
const pool = await db.query.mappool.findFirst({
where: eq(mappool.id, params.id),
@@ -38,68 +39,89 @@ export const load: PageServerLoad = async ({ params, locals }) => {
}
};
} catch {
return {
...slot,
beatmap: null
};
return { ...slot, beatmap: null };
}
})
);
const isOwner = pool.createdBy === locals.user!.id;
const isAdmin = locals.user!.role === 'admin';
return {
pool: { ...pool, slots: slotsWithBeatmaps }
pool: { ...pool, slots: slotsWithBeatmaps },
canEdit: isOwner || isAdmin
};
};
export const actions: Actions = {
rename: async ({ params, request, locals }) => {
const pool = await db.query.mappool.findFirst({
where: eq(mappool.id, params.id)
});
if (!pool) error(404, 'Mappool not found');
requireOwnerOrAdmin(locals, pool.createdBy);
const form = await request.formData();
const name = form.get('name')?.toString()?.trim();
if (!name) return { error: 'Name is required' };
await db.update(mappool).set({ name }).where(eq(mappool.id, params.id));
return { success: true };
},
addSlot: async ({ params, request, locals }) => {
if (!locals.user) redirect(302, '/');
const pool = await db.query.mappool.findFirst({
where: eq(mappool.id, params.id)
});
if (!pool) error(404, 'Mappool not found');
requireOwnerOrAdmin(locals, pool.createdBy);
const form = await request.formData();
let beatmapId = form.get('beatmapId')?.toString()?.trim();
const category = form.get('category')?.toString()?.trim()?.toUpperCase();
const form = await request.formData();
let beatmapId = form.get('beatmapId')?.toString()?.trim();
const category = form.get('category')?.toString()?.trim()?.toUpperCase();
if (!beatmapId || !category) {
return { error: 'Beatmap ID and category are required' };
}
if (!beatmapId || !category) {
return { error: 'Beatmap ID and category are required' };
}
const urlMatch = beatmapId.match(/beatmaps\/(\d+)/);
const setMatch = beatmapId.match(/beatmapsets\/\d+#\w+\/(\d+)/);
if (urlMatch) beatmapId = urlMatch[1];
else if (setMatch) beatmapId = setMatch[1];
const urlMatch = beatmapId.match(/beatmaps\/(\d+)/);
const setMatch = beatmapId.match(/beatmapsets\/\d+#\w+\/(\d+)/);
if (urlMatch) beatmapId = urlMatch[1];
else if (setMatch) beatmapId = setMatch[1];
if (!/^\d+$/.test(beatmapId)) {
return { error: 'Invalid beatmap ID' };
}
if (!/^\d+$/.test(beatmapId)) {
return { error: 'Invalid beatmap ID' };
}
let beatmap;
try {
beatmap = await getBeatmap(beatmapId);
} catch {
return { error: 'Beatmap not found on osu!' };
}
let beatmap;
try {
beatmap = await getBeatmap(beatmapId);
} catch {
return { error: 'Beatmap not found on osu!' };
}
const existing = await db.query.mappoolSlot.findMany({
where: and(
eq(mappoolSlot.mappoolId, params.id),
eq(mappoolSlot.category, category)
)
});
const existing = await db.query.mappoolSlot.findMany({
where: and(eq(mappoolSlot.mappoolId, params.id), eq(mappoolSlot.category, category))
});
await db.insert(mappoolSlot).values({
mappoolId: params.id,
beatmapId,
category,
orderInCategory: existing.length + 1,
starRating: beatmap.difficulty_rating,
mods: category === 'NM' || category === 'TB' || category === 'FM' ? [] : [category]
});
await db.insert(mappoolSlot).values({
mappoolId: params.id,
beatmapId,
category,
orderInCategory: existing.length + 1,
starRating: beatmap.difficulty_rating,
mods: category === 'NM' || category === 'TB' || category === 'FM' ? [] : [category]
});
return { success: true };
},
return { success: true };
},
removeSlot: async ({ request, locals }) => {
if (!locals.user) redirect(302, '/');
removeSlot: async ({ params, request, locals }) => {
const pool = await db.query.mappool.findFirst({
where: eq(mappool.id, params.id)
});
if (!pool) error(404, 'Mappool not found');
requireOwnerOrAdmin(locals, pool.createdBy);
const form = await request.formData();
const slotId = form.get('slotId')?.toString();
@@ -110,7 +132,11 @@ export const actions: Actions = {
},
deletePool: async ({ params, locals }) => {
if (!locals.user) redirect(302, '/');
const pool = await db.query.mappool.findFirst({
where: eq(mappool.id, params.id)
});
if (!pool) error(404, 'Mappool not found');
requireOwnerOrAdmin(locals, pool.createdBy);
await db.delete(mappool).where(eq(mappool.id, params.id));
redirect(303, '/mappools');

View File

@@ -11,6 +11,10 @@
let addError = $state('');
let searching = $state(false);
// Rename state
let editing = $state(false);
let editName = $state(data.pool.name);
// Group slots by category
const grouped = $derived(() => {
const groups: Record<string, typeof data.pool.slots> = {};
@@ -22,7 +26,6 @@
});
function parseBeatmapId(input: string): string | null {
// Accept raw ID or osu! URL
const urlMatch = input.match(/beatmaps\/(\d+)/);
if (urlMatch) return urlMatch[1];
const match = input.match(/^(\d+)$/);
@@ -49,80 +52,138 @@
<!-- Header -->
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<a href="/mappools" class="text-text-secondary transition-colors hover:text-text-primary">←</a
>
<a href="/mappools" class="text-text-secondary transition-colors hover:text-text-primary">←</a>
<div>
<h1 class="text-2xl font-700 tracking-tight">{data.pool.name}</h1>
{#if editing && data.canEdit}
<form
method="post"
action="?/rename"
use:enhance={() => {
return async ({ result, update }) => {
if (result.type === 'success') {
editing = false;
await update();
}
};
}}
class="flex items-center gap-2"
>
<input
type="text"
name="name"
bind:value={editName}
class="rounded-md border border-accent bg-surface-700 px-3 py-1 text-xl font-700 text-text-primary focus:outline-none"
autofocus
/>
<button
type="submit"
class="rounded-md bg-accent px-3 py-1 text-xs font-600 text-surface-900 hover:bg-accent-hover"
>
Save
</button>
<button
type="button"
onclick={() => {
editing = false;
editName = data.pool.name;
}}
class="rounded-md border border-border px-3 py-1 text-xs text-text-secondary hover:bg-surface-700"
>
Cancel
</button>
</form>
{:else}
<div class="flex items-center gap-2">
<h1 class="text-2xl font-700 tracking-tight">{data.pool.name}</h1>
{#if data.canEdit}
<button
onclick={() => {
editName = data.pool.name;
editing = true;
}}
class="rounded p-1 text-text-secondary transition-colors hover:text-accent"
title="Rename"
>
✎
</button>
{/if}
</div>
{/if}
<p class="mt-1 text-sm text-text-secondary">
{data.pool.slots.length} map{data.pool.slots.length !== 1 ? 's' : ''}
</p>
</div>
</div>
<form method="post" action="?/deletePool" use:enhance>
<button
type="submit"
onclick={(e) => {
if (!confirm('Delete this mappool?')) e.preventDefault();
}}
class="rounded-md border border-red-500/30 px-3 py-1.5 text-sm text-red-400 transition-colors hover:bg-red-500/10"
>
Delete Pool
</button>
</form>
{#if data.canEdit}
<form method="post" action="?/deletePool" use:enhance>
<button
type="submit"
onclick={(e) => {
if (!confirm('Delete this mappool?')) e.preventDefault();
}}
class="rounded-md border border-red-500/30 px-3 py-1.5 text-sm text-red-400 transition-colors hover:bg-red-500/10"
>
Delete Pool
</button>
</form>
{/if}
</div>
<!-- Add Map -->
<form
method="post"
action="?/addSlot"
use:enhance={() => {
searching = true;
addError = '';
return async ({ result, update }) => {
searching = false;
if (result.type === 'success') {
beatmapInput = '';
await update();
} else if (result.type === 'failure') {
addError = (result.data as { error?: string })?.error ?? 'Failed to add map'; }
};
}}
class="mt-6 rounded-lg border border-border bg-surface-800 p-4"
>
<h2 class="text-sm font-600">Add Map</h2>
<div class="mt-3 flex gap-3">
<select
name="category"
bind:value={addingCategory}
class="rounded-md border border-border bg-surface-700 px-3 py-2 text-sm text-text-primary focus:border-accent focus:outline-none"
>
{#each categories as cat}
<option value={cat}>{cat}</option>
{/each}
</select>
{#if data.canEdit}
<form
method="post"
action="?/addSlot"
use:enhance={() => {
searching = true;
addError = '';
return async ({ result, update }) => {
searching = false;
if (result.type === 'success') {
beatmapInput = '';
await update();
} else if (result.type === 'failure') {
addError = (result.data as { error?: string })?.error ?? 'Failed to add map';
}
};
}}
class="mt-6 rounded-lg border border-border bg-surface-800 p-4"
>
<h2 class="text-sm font-600">Add Map</h2>
<div class="mt-3 flex gap-3">
<select
name="category"
bind:value={addingCategory}
class="rounded-md border border-border bg-surface-700 px-3 py-2 text-sm text-text-primary focus:border-accent focus:outline-none"
>
{#each categories as cat}
<option value={cat}>{cat}</option>
{/each}
</select>
<input
type="text"
name="beatmapId"
bind:value={beatmapInput}
placeholder="Beatmap ID or URL (e.g. 75 or https://osu.ppy.sh/beatmaps/75)"
class="flex-1 rounded-md border border-border bg-surface-700 px-3 py-2 text-sm text-text-primary placeholder:text-text-secondary/50 focus:border-accent focus:outline-none"
/>
<input
type="text"
name="beatmapId"
bind:value={beatmapInput}
placeholder="Beatmap ID or URL (e.g. 75 or https://osu.ppy.sh/beatmaps/75)"
class="flex-1 rounded-md border border-border bg-surface-700 px-3 py-2 text-sm text-text-primary placeholder:text-text-secondary/50 focus:border-accent focus:outline-none"
/>
<button
type="submit"
disabled={searching || !beatmapInput.trim()}
class="rounded-md bg-accent px-4 py-2 text-sm font-600 text-surface-900 transition-colors hover:bg-accent-hover disabled:opacity-50"
>
{searching ? 'Adding...' : 'Add'}
</button>
</div>
<button
type="submit"
disabled={searching || !beatmapInput.trim()}
class="rounded-md bg-accent px-4 py-2 text-sm font-600 text-surface-900 transition-colors hover:bg-accent-hover disabled:opacity-50"
>
{searching ? 'Adding...' : 'Add'}
</button>
</div>
{#if addError}
<p class="mt-2 text-sm text-red-400">{addError}</p>
{/if}
</form>
{#if addError}
<p class="mt-2 text-sm text-red-400">{addError}</p>
{/if}
</form>
{/if}
<!-- Map List -->
<div class="mt-6 flex flex-col gap-6">
@@ -134,7 +195,9 @@
>
{category}
</span>
<span class="text-xs text-text-secondary">{slots.length} map{slots.length !== 1 ? 's' : ''}</span>
<span class="text-xs text-text-secondary"
>{slots.length} map{slots.length !== 1 ? 's' : ''}</span
>
</div>
<div class="flex flex-col gap-2">
@@ -144,13 +207,11 @@
>
<!-- Cover -->
{#if slot.beatmap?.coverUrl}
<img
src={slot.beatmap.coverUrl}
alt=""
class="h-12 w-24 rounded object-cover"
/>
<img src={slot.beatmap.coverUrl} alt="" class="h-12 w-24 rounded object-cover" />
{:else}
<div class="flex h-12 w-24 items-center justify-center rounded bg-surface-700 text-xs text-text-secondary">
<div
class="flex h-12 w-24 items-center justify-center rounded bg-surface-700 text-xs text-text-secondary"
>
No cover
</div>
{/if}
@@ -158,7 +219,7 @@
<!-- Info -->
<div class="min-w-0 flex-1">
{#if slot.beatmap}
<a
<a
href={slot.beatmap.url}
target="_blank"
rel="noopener"
@@ -183,15 +244,17 @@
</span>
<!-- Delete -->
<form method="post" action="?/removeSlot" use:enhance>
<input type="hidden" name="slotId" value={slot.id} />
<button
type="submit"
class="rounded p-1 text-text-secondary opacity-0 transition-all hover:bg-red-500/10 hover:text-red-400 group-hover:opacity-100"
>
✕
</button>
</form>
{#if data.canEdit}
<form method="post" action="?/removeSlot" use:enhance>
<input type="hidden" name="slotId" value={slot.id} />
<button
type="submit"
class="rounded p-1 text-text-secondary opacity-0 transition-all hover:bg-red-500/10 hover:text-red-400 group-hover:opacity-100"
>
✕
</button>
</form>
{/if}
</div>
{/each}
</div>