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:
@@ -4,10 +4,11 @@ import { redirect } from '@sveltejs/kit';
|
||||
import { desc } from 'drizzle-orm';
|
||||
import { createMatch } from '$lib/server/match/engine';
|
||||
import { initMatchLobby } from '$lib/server/match/orchestrator';
|
||||
import { requireAuth, requireRole, hasRole } from '$lib/server/permissions';
|
||||
import type { PageServerLoad, Actions } from './$types';
|
||||
|
||||
export const load: PageServerLoad = async ({ locals }) => {
|
||||
if (!locals.user) redirect(302, '/');
|
||||
requireAuth(locals);
|
||||
|
||||
const matches = await db.query.match.findMany({
|
||||
with: { participants: { with: { team: true } } },
|
||||
@@ -24,12 +25,18 @@ export const load: PageServerLoad = async ({ locals }) => {
|
||||
orderBy: (m, { desc }) => [desc(m.createdAt)]
|
||||
});
|
||||
|
||||
return { matches, teams, mappools };
|
||||
return {
|
||||
matches,
|
||||
teams,
|
||||
mappools,
|
||||
canCreateMatch: hasRole(locals.user!.role, 'referee')
|
||||
};
|
||||
};
|
||||
|
||||
export const actions: Actions = {
|
||||
createMatch: async ({ request, locals }) => {
|
||||
if (!locals.user) redirect(302, '/');
|
||||
// Only referees and admins can create matches manually
|
||||
requireRole(locals, 'referee', 'Only referees and admins can create matches');
|
||||
|
||||
const form = await request.formData();
|
||||
const name = form.get('name')?.toString()?.trim() || 'Custom Match';
|
||||
@@ -44,8 +51,6 @@ export const actions: Actions = {
|
||||
return { error: 'All fields are required' };
|
||||
}
|
||||
|
||||
// Same team is allowed for testing/dev
|
||||
|
||||
let result;
|
||||
try {
|
||||
result = await createMatch({
|
||||
@@ -53,7 +58,7 @@ export const actions: Actions = {
|
||||
config: { bestOf, teamSize: 1, scoringType: 'score' },
|
||||
mappoolId,
|
||||
teams: [team1Id, team2Id],
|
||||
createdBy: locals.user.id
|
||||
createdBy: locals.user!.id
|
||||
});
|
||||
console.log('[Matches] Created:', result.id);
|
||||
} catch (e: any) {
|
||||
@@ -61,7 +66,7 @@ export const actions: Actions = {
|
||||
return { error: e.message };
|
||||
}
|
||||
|
||||
// Create IRC lobby in background (don't block redirect)
|
||||
// Create IRC lobby in background
|
||||
initMatchLobby(result.id).catch((err) => {
|
||||
console.error('[Matches] IRC lobby failed:', err.message);
|
||||
});
|
||||
|
||||
@@ -9,9 +9,21 @@
|
||||
const stateConfig: Record<string, { label: string; color: string; dot?: string }> = {
|
||||
CREATED: { label: 'Created', color: 'text-text-secondary border-border' },
|
||||
LOBBY: { label: 'In Lobby', color: 'text-yellow-400 border-yellow-500/30' },
|
||||
ROLLING: { label: 'Rolling', color: 'text-yellow-400 border-yellow-500/30', dot: 'bg-yellow-400' },
|
||||
PICKING: { label: 'Picking', color: 'text-blue-400 border-blue-500/30', dot: 'bg-blue-400' },
|
||||
PLAYING: { label: 'Live', color: 'text-green-400 border-green-500/30', dot: 'bg-green-400' },
|
||||
ROLLING: {
|
||||
label: 'Rolling',
|
||||
color: 'text-yellow-400 border-yellow-500/30',
|
||||
dot: 'bg-yellow-400'
|
||||
},
|
||||
PICKING: {
|
||||
label: 'Picking',
|
||||
color: 'text-blue-400 border-blue-500/30',
|
||||
dot: 'bg-blue-400'
|
||||
},
|
||||
PLAYING: {
|
||||
label: 'Live',
|
||||
color: 'text-green-400 border-green-500/30',
|
||||
dot: 'bg-green-400'
|
||||
},
|
||||
FINISHED: { label: 'Finished', color: 'text-text-secondary border-border' },
|
||||
CANCELLED: { label: 'Cancelled', color: 'text-red-400 border-red-500/30' }
|
||||
};
|
||||
@@ -52,23 +64,28 @@
|
||||
{/if}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onclick={() => (showCreate = !showCreate)}
|
||||
class="rounded-md bg-accent px-4 py-2 text-sm font-600 text-surface-900 transition-colors hover:bg-accent-hover"
|
||||
>
|
||||
{showCreate ? 'Cancel' : 'New Match'}
|
||||
</button>
|
||||
{#if data.canCreateMatch}
|
||||
<button
|
||||
onclick={() => (showCreate = !showCreate)}
|
||||
class="rounded-md bg-accent px-4 py-2 text-sm font-600 text-surface-900 transition-colors hover:bg-accent-hover"
|
||||
>
|
||||
{showCreate ? 'Cancel' : 'New Match'}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Create Match Modal -->
|
||||
{#if showCreate}
|
||||
{#if showCreate && data.canCreateMatch}
|
||||
<form
|
||||
method="post"
|
||||
action="?/createMatch"
|
||||
use:enhance={() => {
|
||||
createError = '';
|
||||
return async ({ result, update }) => {
|
||||
if (result.type === 'failure' || (result.type === 'success' && (result.data as any)?.error)) {
|
||||
if (
|
||||
result.type === 'failure' ||
|
||||
(result.type === 'success' && (result.data as any)?.error)
|
||||
) {
|
||||
createError = (result.data as any)?.error ?? 'Failed to create match';
|
||||
} else {
|
||||
await update();
|
||||
@@ -166,7 +183,7 @@
|
||||
{/if}
|
||||
|
||||
<!-- Live Matches -->
|
||||
{#if liveMatches.length > 0}
|
||||
{#if liveMatches.length > 0}
|
||||
<div class="mt-6">
|
||||
<h2 class="flex items-center gap-2 text-sm font-600">
|
||||
<div class="relative h-2 w-2">
|
||||
@@ -203,7 +220,10 @@
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<span class="flex items-center gap-1.5 rounded border px-2 py-0.5 text-xs font-500 {sc?.color ?? 'border-border'}">
|
||||
<span
|
||||
class="flex items-center gap-1.5 rounded border px-2 py-0.5 text-xs font-500 {sc?.color ??
|
||||
'border-border'}"
|
||||
>
|
||||
{#if sc?.dot}
|
||||
<span class="h-1.5 w-1.5 rounded-full {sc.dot}"></span>
|
||||
{/if}
|
||||
@@ -234,9 +254,17 @@
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-sm font-600">{p1?.team.name ?? '?'}</span>
|
||||
{#if m.state === 'FINISHED'}
|
||||
<span class="text-xs font-700 tabular-nums {(p1?.score ?? 0) > (p2?.score ?? 0) ? 'text-green-400' : 'text-text-secondary'}">{p1?.score ?? 0}</span>
|
||||
<span
|
||||
class="text-xs font-700 tabular-nums {(p1?.score ?? 0) > (p2?.score ?? 0)
|
||||
? 'text-green-400'
|
||||
: 'text-text-secondary'}">{p1?.score ?? 0}</span
|
||||
>
|
||||
<span class="text-xs text-text-secondary">-</span>
|
||||
<span class="text-xs font-700 tabular-nums {(p2?.score ?? 0) > (p1?.score ?? 0) ? 'text-green-400' : 'text-text-secondary'}">{p2?.score ?? 0}</span>
|
||||
<span
|
||||
class="text-xs font-700 tabular-nums {(p2?.score ?? 0) > (p1?.score ?? 0)
|
||||
? 'text-green-400'
|
||||
: 'text-text-secondary'}">{p2?.score ?? 0}</span
|
||||
>
|
||||
{:else}
|
||||
<span class="text-xs text-text-secondary">vs</span>
|
||||
{/if}
|
||||
@@ -259,14 +287,23 @@
|
||||
{/if}
|
||||
|
||||
<!-- State -->
|
||||
<span class="rounded border px-2 py-0.5 text-xs font-500 {sc?.color ?? 'border-border'}">
|
||||
<span
|
||||
class="rounded border px-2 py-0.5 text-xs font-500 {sc?.color ?? 'border-border'}"
|
||||
>
|
||||
{sc?.label ?? m.state}
|
||||
</span>
|
||||
</a>
|
||||
{:else}
|
||||
{#if liveMatches.length === 0}
|
||||
<div class="rounded-lg border border-dashed border-border py-12 text-center">
|
||||
<p class="text-sm text-text-secondary">No matches yet. Create one to get started.</p>
|
||||
<p class="text-sm text-text-secondary">
|
||||
No matches yet.
|
||||
{#if data.canCreateMatch}
|
||||
Create one to get started.
|
||||
{:else}
|
||||
Join the ranked queue from the dashboard!
|
||||
{/if}
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
|
||||
Reference in New Issue
Block a user