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:
@@ -1,5 +1,6 @@
|
||||
// src/routes/(app)/+layout.server.ts
|
||||
import type { LayoutServerLoad } from './$types';
|
||||
import { isRootAdmin } from '$lib/server/permissions';
|
||||
|
||||
export const load: LayoutServerLoad = async ({ locals }) => {
|
||||
return {
|
||||
@@ -9,7 +10,8 @@ export const load: LayoutServerLoad = async ({ locals }) => {
|
||||
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,8 +3,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: PageServerLoad = async ({ locals }) => {
|
||||
@@ -14,7 +13,6 @@ export const load: PageServerLoad = async ({ locals }) => {
|
||||
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({
|
||||
@@ -29,12 +27,16 @@ export const load: PageServerLoad = async ({ locals }) => {
|
||||
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: Actions = {
|
||||
@@ -50,12 +52,13 @@ export const actions: 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 };
|
||||
}
|
||||
};
|
||||
|
||||
@@ -13,6 +13,13 @@
|
||||
},
|
||||
admin: { label: 'Admin', color: 'bg-red-500/20 text-red-400 border-red-500/30' }
|
||||
};
|
||||
|
||||
// Non-root admins can only assign player/referee
|
||||
const availableRoles = $derived(
|
||||
data.actorIsRootAdmin
|
||||
? ['player', 'referee', 'admin']
|
||||
: ['player', 'referee']
|
||||
);
|
||||
</script>
|
||||
|
||||
<div class="mx-auto max-w-4xl">
|
||||
@@ -20,6 +27,9 @@
|
||||
<h1 class="text-2xl font-700 tracking-tight">Admin Panel</h1>
|
||||
<p class="mt-1 text-sm text-text-secondary">
|
||||
{data.users.length} registered user{data.users.length !== 1 ? 's' : ''}
|
||||
{#if data.actorIsRootAdmin}
|
||||
<span class="ml-2 rounded bg-red-500/20 px-1.5 py-0.5 text-[10px] font-700 text-red-400">ROOT ADMIN</span>
|
||||
{/if}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -39,6 +49,8 @@
|
||||
|
||||
{#each data.users as u}
|
||||
{@const badge = roleBadge[u.role] ?? roleBadge.player}
|
||||
{@const isProtected = u.isRootAdmin}
|
||||
{@const canChangeRole = !isProtected && (data.actorIsRootAdmin || u.role !== 'admin')}
|
||||
<div
|
||||
class="grid grid-cols-[auto_1fr_auto_auto_auto] items-center gap-4 border-b border-border/50 px-4 py-3 last:border-b-0"
|
||||
>
|
||||
@@ -55,7 +67,12 @@
|
||||
|
||||
<!-- Name -->
|
||||
<div class="min-w-0">
|
||||
<p class="truncate text-sm font-500">{u.name}</p>
|
||||
<div class="flex items-center gap-2">
|
||||
<p class="truncate text-sm font-500">{u.name}</p>
|
||||
{#if u.isRootAdmin}
|
||||
<span class="rounded bg-red-500/20 px-1.5 py-0.5 text-[10px] font-700 text-red-400">ROOT</span>
|
||||
{/if}
|
||||
</div>
|
||||
<p class="truncate text-xs text-text-secondary">{u.email}</p>
|
||||
</div>
|
||||
|
||||
@@ -69,35 +86,68 @@
|
||||
</span>
|
||||
|
||||
<!-- Role selector -->
|
||||
<form
|
||||
method="post"
|
||||
action="?/setRole"
|
||||
use:enhance={() => {
|
||||
actionError = '';
|
||||
return async ({ result }) => {
|
||||
if (
|
||||
result.type === 'failure' ||
|
||||
(result.type === 'success' && (result.data as any)?.error)
|
||||
) {
|
||||
actionError = (result.data as any)?.error ?? 'Failed to update role';
|
||||
}
|
||||
};
|
||||
}}
|
||||
class="flex items-center gap-2"
|
||||
>
|
||||
<input type="hidden" name="userId" value={u.id} />
|
||||
<select
|
||||
name="role"
|
||||
value={u.role}
|
||||
onchange={(e) => e.currentTarget.form?.requestSubmit()}
|
||||
class="rounded-md border px-2 py-1 text-xs font-500 {badge.color} cursor-pointer bg-transparent focus:outline-none"
|
||||
{#if canChangeRole}
|
||||
<form
|
||||
method="post"
|
||||
action="?/setRole"
|
||||
use:enhance={() => {
|
||||
actionError = '';
|
||||
return async ({ result }) => {
|
||||
if (
|
||||
result.type === 'failure' ||
|
||||
(result.type === 'success' && (result.data as any)?.error)
|
||||
) {
|
||||
actionError = (result.data as any)?.error ?? 'Failed to update role';
|
||||
}
|
||||
};
|
||||
}}
|
||||
class="flex items-center gap-2"
|
||||
>
|
||||
<option value="player" class="bg-surface-800 text-text-primary">Player</option>
|
||||
<option value="referee" class="bg-surface-800 text-text-primary">Referee</option>
|
||||
<option value="admin" class="bg-surface-800 text-text-primary">Admin</option>
|
||||
</select>
|
||||
</form>
|
||||
<input type="hidden" name="userId" value={u.id} />
|
||||
<select
|
||||
name="role"
|
||||
value={u.role}
|
||||
onchange={(e) => e.currentTarget.form?.requestSubmit()}
|
||||
class="rounded-md border px-2 py-1 text-xs font-500 {badge.color} cursor-pointer bg-transparent focus:outline-none"
|
||||
>
|
||||
{#each availableRoles as r}
|
||||
<option value={r} class="bg-surface-800 text-text-primary">
|
||||
{r.charAt(0).toUpperCase() + r.slice(1)}
|
||||
</option>
|
||||
{/each}
|
||||
</select>
|
||||
</form>
|
||||
{:else}
|
||||
<span class="rounded-md border px-2 py-1 text-xs font-500 {badge.color}">
|
||||
{badge.label}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- Permission Info -->
|
||||
<div class="mt-6 rounded-lg border border-border bg-surface-800 p-5">
|
||||
<h2 class="text-sm font-600">Permission Levels</h2>
|
||||
<div class="mt-3 flex flex-col gap-2 text-xs text-text-secondary">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="rounded bg-surface-600 px-1.5 py-0.5 font-600 text-text-secondary">Player</span>
|
||||
Create account, join queue, create/manage own mappools and teams
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="rounded bg-yellow-500/20 px-1.5 py-0.5 font-600 text-yellow-400">Referee</span>
|
||||
All player permissions + manage any match, force start games
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="rounded bg-red-500/20 px-1.5 py-0.5 font-600 text-red-400">Admin</span>
|
||||
Full access + manage all mappools/teams + promote players to referee
|
||||
</div>
|
||||
{#if data.actorIsRootAdmin}
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="rounded bg-red-500/20 px-1.5 py-0.5 font-700 text-red-400">Root Admin</span>
|
||||
Can promote/demote admins. Set via ROOT_ADMIN_EMAIL env var. Cannot be demoted.
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
133
src/routes/(app)/leaderboard/+page.server.ts
Normal file
133
src/routes/(app)/leaderboard/+page.server.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
import { db } from '$lib/server/db';
|
||||
import {
|
||||
playerRating,
|
||||
team,
|
||||
teamMember,
|
||||
matchParticipant,
|
||||
matchGameScore,
|
||||
matchGame,
|
||||
matchParticipantPlayer,
|
||||
match
|
||||
} from '$lib/server/db/schema';
|
||||
import { user } from '$lib/server/db/auth.schema';
|
||||
import { eq, desc, sql, and } from 'drizzle-orm';
|
||||
import type { PageServerLoad } from './$types';
|
||||
|
||||
export const load: PageServerLoad = async () => {
|
||||
// ── Top players by ELO ──
|
||||
const topPlayers = await db.query.playerRating.findMany({
|
||||
orderBy: desc(playerRating.elo),
|
||||
limit: 25
|
||||
});
|
||||
|
||||
const playersWithUsers = await Promise.all(
|
||||
topPlayers.map(async (r, i) => {
|
||||
const u = await db.query.user.findFirst({
|
||||
where: eq(user.id, r.userId)
|
||||
});
|
||||
return {
|
||||
rank: i + 1,
|
||||
userId: r.userId,
|
||||
name: u?.name ?? 'Unknown',
|
||||
image: u?.image ?? null,
|
||||
elo: r.elo,
|
||||
wins: r.wins,
|
||||
losses: r.losses,
|
||||
winRate:
|
||||
r.wins + r.losses > 0
|
||||
? ((r.wins / (r.wins + r.losses)) * 100).toFixed(1)
|
||||
: '—'
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
// ── Top teams by match wins ──
|
||||
// Count wins for each team from matchParticipant scores
|
||||
const teamWins = await db
|
||||
.select({
|
||||
teamId: match.winnerId,
|
||||
wins: sql<number>`count(*)`.as('wins')
|
||||
})
|
||||
.from(match)
|
||||
.where(and(
|
||||
sql`${match.winnerId} IS NOT NULL`,
|
||||
eq(match.state, 'FINISHED')
|
||||
))
|
||||
.groupBy(match.winnerId)
|
||||
.orderBy(sql`count(*) DESC`)
|
||||
.limit(15);
|
||||
|
||||
const teamsWithDetails = await Promise.all(
|
||||
teamWins.map(async (tw, i) => {
|
||||
if (!tw.teamId) return null;
|
||||
const t = await db.query.team.findFirst({
|
||||
where: eq(team.id, tw.teamId),
|
||||
with: { members: true }
|
||||
});
|
||||
if (!t) return null;
|
||||
|
||||
// Count total matches for this team
|
||||
const totalMatches = await db
|
||||
.select({ count: sql<number>`count(DISTINCT ${matchParticipant.matchId})` })
|
||||
.from(matchParticipant)
|
||||
.where(eq(matchParticipant.teamId, tw.teamId));
|
||||
|
||||
return {
|
||||
rank: i + 1,
|
||||
teamId: t.id,
|
||||
name: t.name,
|
||||
avatarUrl: t.avatarUrl,
|
||||
isPersonal: t.isPersonal,
|
||||
memberCount: t.members.length,
|
||||
wins: Number(tw.wins),
|
||||
totalMatches: Number(totalMatches[0]?.count ?? 0)
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
const topTeams = teamsWithDetails.filter(Boolean);
|
||||
|
||||
// ── Recent high scores (individual map scores) ──
|
||||
const recentHighScores = await db.query.matchGameScore.findMany({
|
||||
orderBy: desc(matchGameScore.score),
|
||||
limit: 15,
|
||||
with: {
|
||||
player: true,
|
||||
game: {
|
||||
with: {
|
||||
slot: true,
|
||||
match: true
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const highScoresWithNames = await Promise.all(
|
||||
recentHighScores.map(async (s, i) => {
|
||||
const u = await db.query.user.findFirst({
|
||||
where: eq(user.id, s.player.userId)
|
||||
});
|
||||
return {
|
||||
rank: i + 1,
|
||||
playerName: u?.name ?? 'Unknown',
|
||||
playerImage: u?.image ?? null,
|
||||
score: s.score,
|
||||
accuracy: s.accuracy,
|
||||
maxCombo: s.maxCombo,
|
||||
mods: s.mods,
|
||||
passed: s.passed,
|
||||
beatmapId: s.game.slot?.beatmapId ?? null,
|
||||
mapCategory: s.game.slot?.category ?? '',
|
||||
mapOrder: s.game.slot?.orderInCategory ?? 0,
|
||||
matchName: s.game.match?.name ?? 'Match',
|
||||
matchId: s.game.matchId
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
return {
|
||||
topPlayers: playersWithUsers,
|
||||
topTeams: topTeams,
|
||||
highScores: highScoresWithNames
|
||||
};
|
||||
};
|
||||
263
src/routes/(app)/leaderboard/+page.svelte
Normal file
263
src/routes/(app)/leaderboard/+page.svelte
Normal file
@@ -0,0 +1,263 @@
|
||||
<script lang="ts">
|
||||
let { data } = $props();
|
||||
|
||||
let activeTab = $state<'players' | 'teams' | 'scores'>('players');
|
||||
|
||||
const tabs = [
|
||||
{ id: 'players' as const, label: 'Top Players', icon: '👤' },
|
||||
{ id: 'teams' as const, label: 'Top Teams', icon: '⚑' },
|
||||
{ id: 'scores' as const, label: 'High Scores', icon: '🎯' }
|
||||
];
|
||||
|
||||
function eloColor(elo: number): string {
|
||||
if (elo >= 1400) return 'text-yellow-400';
|
||||
if (elo >= 1200) return 'text-purple-400';
|
||||
if (elo >= 1000) return 'text-blue-400';
|
||||
return 'text-text-secondary';
|
||||
}
|
||||
|
||||
function rankBadge(rank: number): { emoji: string; color: string } | null {
|
||||
if (rank === 1) return { emoji: '🥇', color: 'text-yellow-400' };
|
||||
if (rank === 2) return { emoji: '🥈', color: 'text-gray-300' };
|
||||
if (rank === 3) return { emoji: '🥉', color: 'text-amber-600' };
|
||||
return null;
|
||||
}
|
||||
|
||||
const catColors: Record<string, string> = {
|
||||
NM: 'text-blue-400',
|
||||
HD: 'text-yellow-400',
|
||||
HR: 'text-red-400',
|
||||
DT: 'text-purple-400',
|
||||
FM: 'text-green-400',
|
||||
TB: 'text-pink-400'
|
||||
};
|
||||
</script>
|
||||
|
||||
<div class="mx-auto max-w-4xl">
|
||||
<div>
|
||||
<h1 class="text-2xl font-700 tracking-tight">Leaderboard</h1>
|
||||
<p class="mt-1 text-sm text-text-secondary">Rankings and top performances</p>
|
||||
</div>
|
||||
|
||||
<!-- Tabs -->
|
||||
<div class="mt-6 flex gap-1 rounded-lg border border-border bg-surface-800 p-1">
|
||||
{#each tabs as tab}
|
||||
<button
|
||||
onclick={() => (activeTab = tab.id)}
|
||||
class="flex flex-1 items-center justify-center gap-2 rounded-md px-4 py-2 text-sm font-600 transition-colors {activeTab ===
|
||||
tab.id
|
||||
? 'bg-accent text-surface-900'
|
||||
: 'text-text-secondary hover:text-text-primary'}"
|
||||
>
|
||||
<span>{tab.icon}</span>
|
||||
{tab.label}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- Top Players -->
|
||||
{#if activeTab === 'players'}
|
||||
<div class="mt-6 rounded-lg border border-border bg-surface-800">
|
||||
{#if data.topPlayers.length === 0}
|
||||
<div class="py-12 text-center">
|
||||
<p class="text-sm text-text-secondary">No ranked players yet. Play some matches!</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div
|
||||
class="grid grid-cols-[auto_auto_1fr_auto_auto_auto] gap-4 border-b border-border px-4 py-3"
|
||||
>
|
||||
<span class="text-xs font-600 text-text-secondary">#</span>
|
||||
<span class="text-xs font-600 text-text-secondary"></span>
|
||||
<span class="text-xs font-600 text-text-secondary">Player</span>
|
||||
<span class="text-xs font-600 text-text-secondary">ELO</span>
|
||||
<span class="text-xs font-600 text-text-secondary">W/L</span>
|
||||
<span class="text-xs font-600 text-text-secondary">Win %</span>
|
||||
</div>
|
||||
|
||||
{#each data.topPlayers as p}
|
||||
{@const medal = rankBadge(p.rank)}
|
||||
<div
|
||||
class="grid grid-cols-[auto_auto_1fr_auto_auto_auto] items-center gap-4 border-b border-border/50 px-4 py-3 last:border-b-0 {p.rank <=
|
||||
3
|
||||
? 'bg-accent/[0.02]'
|
||||
: ''}"
|
||||
>
|
||||
<!-- Rank -->
|
||||
<span class="w-6 text-center text-sm font-700 tabular-nums {medal?.color ?? 'text-text-secondary'}">
|
||||
{medal ? medal.emoji : p.rank}
|
||||
</span>
|
||||
|
||||
<!-- Avatar -->
|
||||
{#if p.image}
|
||||
<img src={p.image} 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"
|
||||
>
|
||||
{p.name.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Name -->
|
||||
<span class="truncate text-sm font-600">{p.name}</span>
|
||||
|
||||
<!-- ELO -->
|
||||
<span class="text-sm font-700 tabular-nums {eloColor(p.elo)}">{p.elo}</span>
|
||||
|
||||
<!-- W/L -->
|
||||
<span class="text-xs tabular-nums text-text-secondary">
|
||||
<span class="text-green-400">{p.wins}</span>/<span class="text-red-400">{p.losses}</span>
|
||||
</span>
|
||||
|
||||
<!-- Win Rate -->
|
||||
<span class="text-xs font-600 tabular-nums {p.winRate !== '—' && parseFloat(p.winRate) >= 50 ? 'text-green-400' : 'text-text-secondary'}">
|
||||
{p.winRate}{p.winRate !== '—' ? '%' : ''}
|
||||
</span>
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Top Teams -->
|
||||
{#if activeTab === 'teams'}
|
||||
<div class="mt-6 rounded-lg border border-border bg-surface-800">
|
||||
{#if data.topTeams.length === 0}
|
||||
<div class="py-12 text-center">
|
||||
<p class="text-sm text-text-secondary">No team results yet.</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="grid grid-cols-[auto_auto_1fr_auto_auto] gap-4 border-b border-border px-4 py-3">
|
||||
<span class="text-xs font-600 text-text-secondary">#</span>
|
||||
<span class="text-xs font-600 text-text-secondary"></span>
|
||||
<span class="text-xs font-600 text-text-secondary">Team</span>
|
||||
<span class="text-xs font-600 text-text-secondary">Wins</span>
|
||||
<span class="text-xs font-600 text-text-secondary">Matches</span>
|
||||
</div>
|
||||
|
||||
{#each data.topTeams as t}
|
||||
{@const medal = rankBadge(t.rank)}
|
||||
<div
|
||||
class="grid grid-cols-[auto_auto_1fr_auto_auto] items-center gap-4 border-b border-border/50 px-4 py-3 last:border-b-0 {t.rank <=
|
||||
3
|
||||
? 'bg-accent/[0.02]'
|
||||
: ''}"
|
||||
>
|
||||
<!-- Rank -->
|
||||
<span class="w-6 text-center text-sm font-700 tabular-nums {medal?.color ?? 'text-text-secondary'}">
|
||||
{medal ? medal.emoji : t.rank}
|
||||
</span>
|
||||
|
||||
<!-- Avatar -->
|
||||
{#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"
|
||||
>
|
||||
{t.name.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Name -->
|
||||
<div class="min-w-0">
|
||||
<span class="truncate text-sm font-600">{t.name}</span>
|
||||
{#if t.isPersonal}
|
||||
<span class="ml-1 text-[10px] text-text-secondary">(solo)</span>
|
||||
{/if}
|
||||
<p class="text-xs text-text-secondary">{t.memberCount} member{t.memberCount !== 1 ? 's' : ''}</p>
|
||||
</div>
|
||||
|
||||
<!-- Wins -->
|
||||
<span class="text-sm font-700 tabular-nums text-green-400">{t.wins}</span>
|
||||
|
||||
<!-- Total Matches -->
|
||||
<span class="text-xs tabular-nums text-text-secondary">{t.totalMatches}</span>
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- High Scores -->
|
||||
{#if activeTab === 'scores'}
|
||||
<div class="mt-6 rounded-lg border border-border bg-surface-800">
|
||||
{#if data.highScores.length === 0}
|
||||
<div class="py-12 text-center">
|
||||
<p class="text-sm text-text-secondary">No scores recorded yet.</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div
|
||||
class="grid grid-cols-[auto_auto_1fr_auto_auto_auto] gap-4 border-b border-border px-4 py-3"
|
||||
>
|
||||
<span class="text-xs font-600 text-text-secondary">#</span>
|
||||
<span class="text-xs font-600 text-text-secondary"></span>
|
||||
<span class="text-xs font-600 text-text-secondary">Player</span>
|
||||
<span class="text-xs font-600 text-text-secondary">Map</span>
|
||||
<span class="text-xs font-600 text-text-secondary">Score</span>
|
||||
<span class="text-xs font-600 text-text-secondary">Acc</span>
|
||||
</div>
|
||||
|
||||
{#each data.highScores as s}
|
||||
{@const medal = rankBadge(s.rank)}
|
||||
<div
|
||||
class="grid grid-cols-[auto_auto_1fr_auto_auto_auto] items-center gap-4 border-b border-border/50 px-4 py-3 last:border-b-0 {s.rank <=
|
||||
3
|
||||
? 'bg-accent/[0.02]'
|
||||
: ''}"
|
||||
>
|
||||
<!-- Rank -->
|
||||
<span class="w-6 text-center text-sm font-700 tabular-nums {medal?.color ?? 'text-text-secondary'}">
|
||||
{medal ? medal.emoji : s.rank}
|
||||
</span>
|
||||
|
||||
<!-- Avatar -->
|
||||
{#if s.playerImage}
|
||||
<img src={s.playerImage} 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"
|
||||
>
|
||||
{s.playerName.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Player + match context -->
|
||||
<div class="min-w-0">
|
||||
<span class="truncate text-sm font-600">{s.playerName}</span>
|
||||
<p class="truncate text-xs text-text-secondary">
|
||||
<a href="/matches/{s.matchId}" class="hover:text-accent">{s.matchName}</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Map slot -->
|
||||
<span class="text-xs font-600 {catColors[s.mapCategory] ?? 'text-text-secondary'}">
|
||||
{s.mapCategory}{s.mapOrder}
|
||||
</span>
|
||||
|
||||
<!-- Score -->
|
||||
<span class="text-sm font-700 tabular-nums text-text-primary">
|
||||
{s.score.toLocaleString()}
|
||||
</span>
|
||||
|
||||
<!-- Accuracy -->
|
||||
<span class="text-xs tabular-nums text-text-secondary">
|
||||
{s.accuracy > 0 ? `${(s.accuracy * 100).toFixed(1)}%` : '—'}
|
||||
</span>
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- ELO Explanation -->
|
||||
<div class="mt-6 rounded-lg border border-border bg-surface-800 p-5">
|
||||
<h2 class="text-sm font-600">How ELO Works</h2>
|
||||
<p class="mt-2 text-xs text-text-secondary leading-relaxed">
|
||||
Every player starts at 1000 ELO. Win a match to gain points, lose to drop.
|
||||
The system uses K=32, so new players' ratings shift quickly. As you play more,
|
||||
your rating stabilizes around your true skill level. Queue matchmaking pairs players
|
||||
with similar ELO for fair matches.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -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');
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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