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

@@ -58,6 +58,10 @@
"/demo/better-auth/login": [
"src/routes/demo/better-auth/login/+page.server.ts"
],
"/(app)/leaderboard": [
"src/routes/(app)/leaderboard/+page.server.ts",
"src/routes/(app)/+layout.server.ts"
],
"/login": [
"src/routes/login/+page.server.ts"
],

View File

@@ -11,7 +11,7 @@ type OutputDataShape<T> = MaybeWithVoid<Omit<App.PageData, RequiredKeys<T>> & Pa
type EnsureDefined<T> = T extends null | undefined ? {} : T;
type OptionalUnion<U extends Record<string, any>, A extends keyof U = U extends U ? keyof U : never> = U extends unknown ? { [P in Exclude<A, keyof U>]?: never } & U : never;
export type Snapshot<T = any> = Kit.Snapshot<T>;
type LayoutRouteId = RouteId | "/(app)" | "/(app)/admin" | "/(app)/mappools" | "/(app)/mappools/[id]" | "/(app)/matches" | "/(app)/matches/[id]" | "/(app)/settings" | "/(app)/teams" | "/demo" | "/demo/better-auth" | "/demo/better-auth/login" | "/login" | null
type LayoutRouteId = RouteId | "/(app)" | "/(app)/admin" | "/(app)/leaderboard" | "/(app)/mappools" | "/(app)/mappools/[id]" | "/(app)/matches" | "/(app)/matches/[id]" | "/(app)/settings" | "/(app)/teams" | "/demo" | "/demo/better-auth" | "/demo/better-auth/login" | "/login" | null
type LayoutParams = RouteParams & { id?: string }
type LayoutParentData = EnsureDefined<{}>;

View File

@@ -13,7 +13,7 @@ type OptionalUnion<U extends Record<string, any>, A extends keyof U = U extends
export type Snapshot<T = any> = Kit.Snapshot<T>;
type PageServerParentData = Omit<EnsureDefined<import('../$types.js').LayoutServerData>, keyof LayoutServerData> & EnsureDefined<LayoutServerData>;
type PageParentData = Omit<EnsureDefined<import('../$types.js').LayoutData>, keyof LayoutData> & EnsureDefined<LayoutData>;
type LayoutRouteId = RouteId | "/(app)" | "/(app)/admin" | "/(app)/mappools" | "/(app)/mappools/[id]" | "/(app)/matches" | "/(app)/matches/[id]" | "/(app)/settings" | "/(app)/teams"
type LayoutRouteId = RouteId | "/(app)" | "/(app)/admin" | "/(app)/leaderboard" | "/(app)/mappools" | "/(app)/mappools/[id]" | "/(app)/matches" | "/(app)/matches/[id]" | "/(app)/settings" | "/(app)/teams"
type LayoutParams = RouteParams & { id?: string }
type LayoutServerParentData = EnsureDefined<import('../$types.js').LayoutServerData>;
type LayoutParentData = EnsureDefined<import('../$types.js').LayoutData>;

View File

@@ -4,8 +4,7 @@ import { db } from '$lib/server/db';
import { user } from '$lib/server/db/auth.schema';
import { playerRating } from '$lib/server/db/schema';
import { eq, desc } from 'drizzle-orm';
import { redirect, error } from '@sveltejs/kit';
import { requireRole, setUserRole } from '$lib/server/permissions';
import { requireRole, setUserRole, isRootAdmin } from '$lib/server/permissions';
import type { PageServerLoad, Actions } from './$types';
export const load = async ({ locals }: Parameters<PageServerLoad>[0]) => {
@@ -15,7 +14,6 @@ export const load = async ({ locals }: Parameters<PageServerLoad>[0]) => {
orderBy: desc(user.createdAt)
});
// Fetch ratings for all users
const usersWithRatings = await Promise.all(
users.map(async (u) => {
const rating = await db.query.playerRating.findFirst({
@@ -30,12 +28,16 @@ export const load = async ({ locals }: Parameters<PageServerLoad>[0]) => {
createdAt: u.createdAt,
elo: rating?.elo ?? 1000,
wins: rating?.wins ?? 0,
losses: rating?.losses ?? 0
losses: rating?.losses ?? 0,
isRootAdmin: isRootAdmin(u.email)
};
})
);
return { users: usersWithRatings };
return {
users: usersWithRatings,
actorIsRootAdmin: isRootAdmin(locals.user!.email)
};
};
export const actions = {
@@ -51,12 +53,13 @@ export const actions = {
return { error: 'Invalid role' };
}
// Prevent demoting yourself
if (userId === locals.user!.id && role !== 'admin') {
return { error: "You can't demote yourself" };
try {
await setUserRole(locals, userId, role as any);
} catch (e: any) {
// setUserRole throws SvelteKit errors; extract the message
return { error: e.body?.message ?? e.message ?? 'Failed to update role' };
}
await setUserRole(userId, role as any);
return { success: true };
}
};

View File

@@ -4,10 +4,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 = async ({ params, locals }: Parameters<PageServerLoad>[0]) => {
if (!locals.user) redirect(302, '/');
requireAuth(locals);
const pool = await db.query.mappool.findFirst({
where: eq(mappool.id, params.id),
@@ -39,68 +40,89 @@ export const load = async ({ params, locals }: Parameters<PageServerLoad>[0]) =>
}
};
} 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 = {
rename: async ({ params, request, locals }: import('./$types').RequestEvent) => {
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 }: import('./$types').RequestEvent) => {
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 }: import('./$types').RequestEvent) => {
if (!locals.user) redirect(302, '/');
removeSlot: async ({ params, request, locals }: import('./$types').RequestEvent) => {
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();
@@ -111,7 +133,11 @@ export const actions = {
},
deletePool: async ({ params, locals }: import('./$types').RequestEvent) => {
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

@@ -5,10 +5,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 = async ({ locals }: Parameters<PageServerLoad>[0]) => {
if (!locals.user) redirect(302, '/');
requireAuth(locals);
const matches = await db.query.match.findMany({
with: { participants: { with: { team: true } } },
@@ -25,12 +26,18 @@ export const load = async ({ locals }: Parameters<PageServerLoad>[0]) => {
orderBy: (m, { desc }) => [desc(m.createdAt)]
});
return { matches, teams, mappools };
return {
matches,
teams,
mappools,
canCreateMatch: hasRole(locals.user!.role, 'referee')
};
};
export const actions = {
createMatch: async ({ request, locals }: import('./$types').RequestEvent) => {
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';
@@ -45,8 +52,6 @@ export const actions = {
return { error: 'All fields are required' };
}
// Same team is allowed for testing/dev
let result;
try {
result = await createMatch({
@@ -54,7 +59,7 @@ export const 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) {
@@ -62,7 +67,7 @@ export const 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);
});

View File

@@ -1,6 +1,7 @@
// @ts-nocheck
// src/routes/(app)/+layout.server.ts
import type { LayoutServerLoad } from './$types';
import { isRootAdmin } from '$lib/server/permissions';
export const load = async ({ locals }: Parameters<LayoutServerLoad>[0]) => {
return {
@@ -10,7 +11,8 @@ export const load = async ({ locals }: Parameters<LayoutServerLoad>[0]) => {
name: locals.user.name,
email: locals.user.email,
image: locals.user.image,
role: locals.user.role ?? 'player'
role: locals.user.role ?? 'player',
isRootAdmin: isRootAdmin(locals.user.email)
}
: null
};

View File

@@ -3,10 +3,11 @@ import { db } from '$lib/server/db';
import { team, teamMember, user } from '$lib/server/db/schema';
import { eq } from 'drizzle-orm';
import { redirect } from '@sveltejs/kit';
import { requireAuth, requireOwnerOrAdmin } from '$lib/server/permissions';
import type { PageServerLoad, Actions } from './$types';
export const load = async ({ locals }: Parameters<PageServerLoad>[0]) => {
if (!locals.user) redirect(302, '/');
const u = requireAuth(locals);
const teams = await db.query.team.findMany({
with: {
@@ -20,22 +21,22 @@ export const load = async ({ locals }: Parameters<PageServerLoad>[0]) => {
teams.map(async (t) => {
const membersWithUsers = await Promise.all(
t.members.map(async (m) => {
const u = await db.query.user.findFirst({
const usr = await db.query.user.findFirst({
where: eq(user.id, m.userId)
});
return { ...m, user: u ? { id: u.id, name: u.name, image: u.image } : null };
return { ...m, user: usr ? { id: usr.id, name: usr.name, image: usr.image } : null };
})
);
return { ...t, members: membersWithUsers };
})
);
return { teams: teamsWithUsers, userId: locals.user.id };
return { teams: teamsWithUsers, userId: u.id };
};
export const actions = {
create: async ({ request, locals }: import('./$types').RequestEvent) => {
if (!locals.user) redirect(302, '/');
const u = requireAuth(locals);
const form = await request.formData();
const name = form.get('name')?.toString()?.trim();
@@ -46,7 +47,7 @@ export const actions = {
.insert(team)
.values({
name,
ownerId: locals.user.id,
ownerId: u.id,
isPersonal: false
})
.returning();
@@ -54,15 +55,32 @@ export const actions = {
// Add creator as captain
await db.insert(teamMember).values({
teamId: created.id,
userId: locals.user.id,
userId: u.id,
role: 'captain'
});
return { success: true };
},
rename: async ({ request, locals }: import('./$types').RequestEvent) => {
const form = await request.formData();
const teamId = form.get('teamId')?.toString();
const name = form.get('name')?.toString()?.trim();
if (!teamId || !name) return { error: 'Team ID and name are required' };
const t = await db.query.team.findFirst({ where: eq(team.id, teamId) });
if (!t) return { error: 'Team not found' };
if (t.isPersonal) return { error: 'Cannot rename personal team' };
requireOwnerOrAdmin(locals, t.ownerId);
await db.update(team).set({ name }).where(eq(team.id, teamId));
return { success: true };
},
addMember: async ({ request, locals }: import('./$types').RequestEvent) => {
if (!locals.user) redirect(302, '/');
requireAuth(locals);
const form = await request.formData();
const teamId = form.get('teamId')?.toString();
@@ -70,6 +88,11 @@ export const actions = {
if (!teamId || !username) return { error: 'Team and username are required' };
// Only owner or admin can add members
const t = await db.query.team.findFirst({ where: eq(team.id, teamId) });
if (!t) return { error: 'Team not found' };
requireOwnerOrAdmin(locals, t.ownerId);
// Find user by osu! username
const u = await db.query.user.findFirst({
where: eq(user.name, username)
@@ -94,26 +117,34 @@ export const actions = {
},
removeMember: async ({ request, locals }: import('./$types').RequestEvent) => {
if (!locals.user) redirect(302, '/');
requireAuth(locals);
const form = await request.formData();
const memberId = form.get('memberId')?.toString();
if (!memberId) return { error: 'Missing member ID' };
// Look up the member to find the team, then check ownership
const member = await db.query.teamMember.findFirst({
where: eq(teamMember.id, memberId),
with: { team: true }
});
if (!member) return { error: 'Member not found' };
requireOwnerOrAdmin(locals, member.team.ownerId);
await db.delete(teamMember).where(eq(teamMember.id, memberId));
return { success: true };
},
deleteTeam: async ({ request, locals }: import('./$types').RequestEvent) => {
if (!locals.user) redirect(302, '/');
const form = await request.formData();
const teamId = form.get('teamId')?.toString();
if (!teamId) return { error: 'Missing team ID' };
// Only allow owner to delete
const t = await db.query.team.findFirst({ where: eq(team.id, teamId) });
if (!t || t.ownerId !== locals.user.id) return { error: 'Not authorized' };
if (!t) return { error: 'Team not found' };
if (t.isPersonal) return { error: 'Cannot delete personal team' };
requireOwnerOrAdmin(locals, t.ownerId);
await db.delete(team).where(eq(team.id, teamId));
return { success: true };