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:
@@ -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"
|
||||
],
|
||||
|
||||
2
.svelte-kit/types/src/routes/$types.d.ts
vendored
2
.svelte-kit/types/src/routes/$types.d.ts
vendored
@@ -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<{}>;
|
||||
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
@@ -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 };
|
||||
|
||||
Reference in New Issue
Block a user