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:
@@ -2,10 +2,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: PageServerLoad = async ({ locals }) => {
|
||||
if (!locals.user) redirect(302, '/');
|
||||
const u = requireAuth(locals);
|
||||
|
||||
const teams = await db.query.team.findMany({
|
||||
with: {
|
||||
@@ -19,22 +20,22 @@ export const load: PageServerLoad = async ({ locals }) => {
|
||||
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: Actions = {
|
||||
create: async ({ request, locals }) => {
|
||||
if (!locals.user) redirect(302, '/');
|
||||
const u = requireAuth(locals);
|
||||
|
||||
const form = await request.formData();
|
||||
const name = form.get('name')?.toString()?.trim();
|
||||
@@ -45,7 +46,7 @@ export const actions: Actions = {
|
||||
.insert(team)
|
||||
.values({
|
||||
name,
|
||||
ownerId: locals.user.id,
|
||||
ownerId: u.id,
|
||||
isPersonal: false
|
||||
})
|
||||
.returning();
|
||||
@@ -53,15 +54,32 @@ export const actions: 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 }) => {
|
||||
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 }) => {
|
||||
if (!locals.user) redirect(302, '/');
|
||||
requireAuth(locals);
|
||||
|
||||
const form = await request.formData();
|
||||
const teamId = form.get('teamId')?.toString();
|
||||
@@ -69,6 +87,11 @@ export const actions: 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)
|
||||
@@ -93,26 +116,34 @@ export const actions: Actions = {
|
||||
},
|
||||
|
||||
removeMember: async ({ request, locals }) => {
|
||||
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 }) => {
|
||||
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 };
|
||||
|
||||
@@ -7,15 +7,17 @@
|
||||
let creating = $state(false);
|
||||
let expandedTeam = $state<string | null>(null);
|
||||
let addingMemberTo = $state<string | null>(null);
|
||||
let renamingTeam = $state<string | null>(null);
|
||||
let renameValue = $state('');
|
||||
let memberUsername = $state('');
|
||||
let actionError = $state('');
|
||||
|
||||
const myTeams = $derived(data.teams.filter((t: any) =>
|
||||
t.members.some((m: any) => m.userId === data.userId)
|
||||
));
|
||||
const otherTeams = $derived(data.teams.filter((t: any) =>
|
||||
!t.members.some((m: any) => m.userId === data.userId)
|
||||
));
|
||||
const myTeams = $derived(
|
||||
data.teams.filter((t: any) => t.members.some((m: any) => m.userId === data.userId))
|
||||
);
|
||||
const otherTeams = $derived(
|
||||
data.teams.filter((t: any) => !t.members.some((m: any) => m.userId === data.userId))
|
||||
);
|
||||
|
||||
function isOwner(t: any) {
|
||||
return t.ownerId === data.userId;
|
||||
@@ -89,7 +91,9 @@
|
||||
{#if t.avatarUrl}
|
||||
<img src={t.avatarUrl} alt="" class="h-10 w-10 rounded-full" />
|
||||
{:else}
|
||||
<div class="flex h-10 w-10 items-center justify-center rounded-full bg-surface-600 text-sm font-700 text-text-secondary">
|
||||
<div
|
||||
class="flex h-10 w-10 items-center justify-center rounded-full bg-surface-600 text-sm font-700 text-text-secondary"
|
||||
>
|
||||
{t.name.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
{/if}
|
||||
@@ -97,19 +101,81 @@
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-sm font-600">{t.name}</span>
|
||||
{#if t.isPersonal}
|
||||
<span class="rounded bg-surface-600 px-1.5 py-0.5 text-[10px] font-500 text-text-secondary">solo</span>
|
||||
<span
|
||||
class="rounded bg-surface-600 px-1.5 py-0.5 text-[10px] font-500 text-text-secondary"
|
||||
>solo</span
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
<p class="mt-0.5 text-xs text-text-secondary">
|
||||
{t.members.length} member{t.members.length !== 1 ? 's' : ''}
|
||||
</p>
|
||||
</div>
|
||||
<span class="text-sm text-text-secondary transition-transform {expandedTeam === t.id ? 'rotate-90' : ''}">›</span>
|
||||
<span
|
||||
class="text-sm text-text-secondary transition-transform {expandedTeam === t.id
|
||||
? 'rotate-90'
|
||||
: ''}">›</span
|
||||
>
|
||||
</button>
|
||||
|
||||
<!-- Expanded: members -->
|
||||
{#if expandedTeam === t.id}
|
||||
<div class="border-t border-border px-4 pb-4 pt-3">
|
||||
<!-- Rename team -->
|
||||
{#if isOwner(t) && !t.isPersonal}
|
||||
{#if renamingTeam === t.id}
|
||||
<form
|
||||
method="post"
|
||||
action="?/rename"
|
||||
use:enhance={() => {
|
||||
actionError = '';
|
||||
return async ({ result, update }) => {
|
||||
if (result.type === 'success' && (result.data as any)?.success) {
|
||||
renamingTeam = null;
|
||||
await update();
|
||||
} else if (
|
||||
result.type === 'success' &&
|
||||
(result.data as any)?.error
|
||||
) {
|
||||
actionError = (result.data as any).error;
|
||||
}
|
||||
};
|
||||
}}
|
||||
class="mb-3 flex items-center gap-2"
|
||||
>
|
||||
<input type="hidden" name="teamId" value={t.id} />
|
||||
<input
|
||||
type="text"
|
||||
name="name"
|
||||
bind:value={renameValue}
|
||||
class="flex-1 rounded-md border border-accent bg-surface-700 px-3 py-1.5 text-sm text-text-primary focus:outline-none"
|
||||
autofocus
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
class="rounded-md bg-accent px-3 py-1.5 text-xs font-600 text-surface-900 hover:bg-accent-hover"
|
||||
>Save</button
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (renamingTeam = null)}
|
||||
class="rounded-md border border-border px-3 py-1.5 text-xs text-text-secondary hover:bg-surface-600"
|
||||
>Cancel</button
|
||||
>
|
||||
</form>
|
||||
{:else}
|
||||
<button
|
||||
onclick={() => {
|
||||
renamingTeam = t.id;
|
||||
renameValue = t.name;
|
||||
}}
|
||||
class="mb-3 text-xs text-text-secondary transition-colors hover:text-accent"
|
||||
>
|
||||
✎ Rename team
|
||||
</button>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<!-- Member list -->
|
||||
<div class="flex flex-col gap-2">
|
||||
{#each t.members as member}
|
||||
@@ -122,11 +188,21 @@
|
||||
<span class="flex-1 text-sm">{member.user?.name ?? 'Unknown'}</span>
|
||||
<span class="text-xs text-text-secondary">{member.role}</span>
|
||||
{#if isOwner(t) && member.userId !== data.userId}
|
||||
<form method="post" action="?/removeMember" use:enhance={() => {
|
||||
return async ({ update }) => { await update(); };
|
||||
}}>
|
||||
<form
|
||||
method="post"
|
||||
action="?/removeMember"
|
||||
use:enhance={() => {
|
||||
return async ({ update }) => {
|
||||
await update();
|
||||
};
|
||||
}}
|
||||
>
|
||||
<input type="hidden" name="memberId" value={member.id} />
|
||||
<button type="submit" class="text-xs text-red-400 opacity-50 transition-opacity hover:opacity-100">×</button>
|
||||
<button
|
||||
type="submit"
|
||||
class="text-xs text-red-400 opacity-50 transition-opacity hover:opacity-100"
|
||||
>×</button
|
||||
>
|
||||
</form>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -146,7 +222,10 @@
|
||||
memberUsername = '';
|
||||
addingMemberTo = null;
|
||||
await update();
|
||||
} else if (result.type === 'success' && (result.data as any)?.error) {
|
||||
} else if (
|
||||
result.type === 'success' &&
|
||||
(result.data as any)?.error
|
||||
) {
|
||||
actionError = (result.data as any).error;
|
||||
}
|
||||
};
|
||||
@@ -162,8 +241,17 @@
|
||||
required
|
||||
class="flex-1 rounded-md border border-border bg-surface-600 px-3 py-1.5 text-sm text-text-primary placeholder:text-text-secondary/50 focus:border-accent focus:outline-none"
|
||||
/>
|
||||
<button type="submit" class="rounded-md bg-accent px-3 py-1.5 text-xs font-600 text-surface-900 hover:bg-accent-hover">Add</button>
|
||||
<button type="button" onclick={() => (addingMemberTo = null)} class="rounded-md border border-border px-3 py-1.5 text-xs text-text-secondary hover:bg-surface-600">Cancel</button>
|
||||
<button
|
||||
type="submit"
|
||||
class="rounded-md bg-accent px-3 py-1.5 text-xs font-600 text-surface-900 hover:bg-accent-hover"
|
||||
>Add</button
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (addingMemberTo = null)}
|
||||
class="rounded-md border border-border px-3 py-1.5 text-xs text-text-secondary hover:bg-surface-600"
|
||||
>Cancel</button
|
||||
>
|
||||
</form>
|
||||
{:else}
|
||||
<button
|
||||
@@ -181,7 +269,9 @@
|
||||
<input type="hidden" name="teamId" value={t.id} />
|
||||
<button
|
||||
type="submit"
|
||||
onclick={(e) => { if (!confirm(`Delete ${t.name}?`)) e.preventDefault() }}
|
||||
onclick={(e) => {
|
||||
if (!confirm(`Delete ${t.name}?`)) e.preventDefault();
|
||||
}}
|
||||
class="mt-3 text-xs text-red-400 opacity-50 transition-opacity hover:opacity-100"
|
||||
>
|
||||
Delete team
|
||||
@@ -206,13 +296,17 @@
|
||||
{#if t.avatarUrl}
|
||||
<img src={t.avatarUrl} alt="" class="h-8 w-8 rounded-full" />
|
||||
{:else}
|
||||
<div class="flex h-8 w-8 items-center justify-center rounded-full bg-surface-600 text-xs font-700 text-text-secondary">
|
||||
<div
|
||||
class="flex h-8 w-8 items-center justify-center rounded-full bg-surface-600 text-xs font-700 text-text-secondary"
|
||||
>
|
||||
{t.name.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
{/if}
|
||||
<div class="flex-1">
|
||||
<span class="text-sm font-600">{t.name}</span>
|
||||
<p class="text-xs text-text-secondary">{t.members.length} member{t.members.length !== 1 ? 's' : ''}</p>
|
||||
<p class="text-xs text-text-secondary">
|
||||
{t.members.length} member{t.members.length !== 1 ? 's' : ''}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
@@ -223,7 +317,9 @@
|
||||
<!-- Empty state -->
|
||||
{#if data.teams.length === 0}
|
||||
<div class="mt-6 rounded-lg border border-dashed border-border py-12 text-center">
|
||||
<p class="text-sm text-text-secondary">No teams yet. Your personal team is created when you sign in.</p>
|
||||
<p class="text-sm text-text-secondary">
|
||||
No teams yet. Your personal team is created when you sign in.
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user