feat: add search and data tables
This commit is contained in:
@@ -2,140 +2,195 @@ import { db } from '$lib/server/db';
|
||||
import {
|
||||
playerRating,
|
||||
team,
|
||||
teamMember,
|
||||
matchParticipant,
|
||||
matchGameScore,
|
||||
matchGame,
|
||||
matchParticipantPlayer,
|
||||
mappoolSlot,
|
||||
match
|
||||
} from '$lib/server/db/schema';
|
||||
import { user } from '$lib/server/db/auth.schema';
|
||||
import { eq, desc, sql, and } from 'drizzle-orm';
|
||||
import { parseTableParams, buildTableMeta } from '$lib/server/table';
|
||||
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
|
||||
});
|
||||
export const load: PageServerLoad = async ({ url }) => {
|
||||
const tab = url.searchParams.get('tab') ?? 'players';
|
||||
const params = parseTableParams(url, { sortBy: '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) : '—'
|
||||
};
|
||||
})
|
||||
);
|
||||
if (tab === 'teams') {
|
||||
return loadTeams(params);
|
||||
} else if (tab === 'scores') {
|
||||
return loadScores(params);
|
||||
}
|
||||
return loadPlayers(params);
|
||||
};
|
||||
|
||||
// ── 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);
|
||||
async function loadPlayers(params: ReturnType<typeof parseTableParams>) {
|
||||
const [rows, countResult] = await Promise.all([
|
||||
db
|
||||
.select({
|
||||
userId: playerRating.userId,
|
||||
name: user.name,
|
||||
image: user.image,
|
||||
elo: playerRating.elo,
|
||||
wins: playerRating.wins,
|
||||
losses: playerRating.losses
|
||||
})
|
||||
.from(playerRating)
|
||||
.innerJoin(user, eq(playerRating.userId, user.id))
|
||||
.orderBy(desc(playerRating.elo))
|
||||
.limit(params.limit)
|
||||
.offset((params.page - 1) * params.limit),
|
||||
db.select({ count: sql<number>`count(*)` }).from(playerRating)
|
||||
]);
|
||||
|
||||
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;
|
||||
if (t.isPersonal) return null; // Skip personal teams
|
||||
|
||||
// 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((x): x is NonNullable<typeof x> => Boolean(x));
|
||||
|
||||
// ── High scores — sorted by pp (nulls last) then score ──
|
||||
const recentHighScores = await db
|
||||
.select()
|
||||
.from(matchGameScore)
|
||||
.orderBy(sql`${matchGameScore.pp} DESC NULLS LAST`, desc(matchGameScore.score))
|
||||
.limit(15)
|
||||
.then((rows) =>
|
||||
Promise.all(
|
||||
rows.map((s) =>
|
||||
db.query.matchGameScore.findFirst({
|
||||
where: eq(matchGameScore.id, s.id),
|
||||
with: {
|
||||
player: true,
|
||||
game: {
|
||||
with: { slot: true, match: true }
|
||||
}
|
||||
}
|
||||
})
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
const highScoresWithNames = await Promise.all(
|
||||
recentHighScores.map(async (s, i) => {
|
||||
if (!s) return null;
|
||||
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,
|
||||
count300: s.count300,
|
||||
count100: s.count100,
|
||||
count50: s.count50,
|
||||
countMiss: s.countMiss,
|
||||
pp: s.pp,
|
||||
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
|
||||
};
|
||||
})
|
||||
).then((rows) => rows.filter((x): x is NonNullable<typeof x> => Boolean(x)));
|
||||
const topPlayers = rows.map((r, i) => ({
|
||||
...r,
|
||||
rank: (params.page - 1) * params.limit + i + 1,
|
||||
winRate: r.wins + r.losses > 0 ? ((r.wins / (r.wins + r.losses)) * 100).toFixed(1) : '—'
|
||||
}));
|
||||
|
||||
return {
|
||||
topPlayers: playersWithUsers,
|
||||
topTeams: topTeams,
|
||||
highScores: highScoresWithNames
|
||||
tab: 'players' as const,
|
||||
topPlayers,
|
||||
topTeams: [],
|
||||
highScores: [],
|
||||
meta: buildTableMeta(params, Number(countResult[0].count))
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
async function loadTeams(params: ReturnType<typeof parseTableParams>) {
|
||||
// Get teams ranked by match wins, excluding personal teams
|
||||
const [rows, countResult] = await Promise.all([
|
||||
db
|
||||
.select({
|
||||
teamId: match.winnerId,
|
||||
name: team.name,
|
||||
avatarUrl: team.avatarUrl,
|
||||
isPersonal: team.isPersonal,
|
||||
wins: sql<number>`count(*)`.as('wins'),
|
||||
memberCount: sql<number>`(SELECT count(*) FROM team_member WHERE team_id = ${team.id})`
|
||||
})
|
||||
.from(match)
|
||||
.innerJoin(team, eq(match.winnerId, team.id))
|
||||
.where(
|
||||
and(
|
||||
sql`${match.winnerId} IS NOT NULL`,
|
||||
eq(match.state, 'FINISHED'),
|
||||
eq(team.isPersonal, false)
|
||||
)
|
||||
)
|
||||
.groupBy(match.winnerId, team.id, team.name, team.avatarUrl, team.isPersonal)
|
||||
.orderBy(sql`count(*) DESC`)
|
||||
.limit(params.limit)
|
||||
.offset((params.page - 1) * params.limit),
|
||||
db
|
||||
.select({ count: sql<number>`count(DISTINCT ${match.winnerId})` })
|
||||
.from(match)
|
||||
.innerJoin(team, eq(match.winnerId, team.id))
|
||||
.where(
|
||||
and(
|
||||
sql`${match.winnerId} IS NOT NULL`,
|
||||
eq(match.state, 'FINISHED'),
|
||||
eq(team.isPersonal, false)
|
||||
)
|
||||
)
|
||||
]);
|
||||
|
||||
// Batch-fetch total match counts for these teams
|
||||
const teamIds = rows.map((r) => r.teamId).filter(Boolean) as string[];
|
||||
const matchCounts: Record<string, number> = {};
|
||||
if (teamIds.length > 0) {
|
||||
const counts = await db
|
||||
.select({
|
||||
teamId: matchParticipant.teamId,
|
||||
count: sql<number>`count(DISTINCT ${matchParticipant.matchId})`
|
||||
})
|
||||
.from(matchParticipant)
|
||||
.where(
|
||||
sql`${matchParticipant.teamId} IN (${sql.join(
|
||||
teamIds.map((id) => sql`${id}`),
|
||||
sql`, `
|
||||
)})`
|
||||
)
|
||||
.groupBy(matchParticipant.teamId);
|
||||
for (const c of counts) {
|
||||
matchCounts[c.teamId] = Number(c.count);
|
||||
}
|
||||
}
|
||||
|
||||
const topTeams = rows.map((r, i) => ({
|
||||
rank: (params.page - 1) * params.limit + i + 1,
|
||||
teamId: r.teamId,
|
||||
name: r.name,
|
||||
avatarUrl: r.avatarUrl,
|
||||
isPersonal: r.isPersonal,
|
||||
memberCount: Number(r.memberCount),
|
||||
wins: Number(r.wins),
|
||||
totalMatches: matchCounts[r.teamId!] ?? 0
|
||||
}));
|
||||
|
||||
return {
|
||||
tab: 'teams' as const,
|
||||
topPlayers: [],
|
||||
topTeams,
|
||||
highScores: [],
|
||||
meta: buildTableMeta(params, Number(countResult[0].count))
|
||||
};
|
||||
}
|
||||
|
||||
async function loadScores(params: ReturnType<typeof parseTableParams>) {
|
||||
const [rows, countResult] = await Promise.all([
|
||||
db
|
||||
.select({
|
||||
id: matchGameScore.id,
|
||||
score: matchGameScore.score,
|
||||
accuracy: matchGameScore.accuracy,
|
||||
maxCombo: matchGameScore.maxCombo,
|
||||
pp: matchGameScore.pp,
|
||||
mods: matchGameScore.mods,
|
||||
passed: matchGameScore.passed,
|
||||
playerUserId: matchParticipantPlayer.userId,
|
||||
playerName: user.name,
|
||||
playerImage: user.image,
|
||||
mapCategory: mappoolSlot.category,
|
||||
mapOrder: mappoolSlot.orderInCategory,
|
||||
matchName: match.name,
|
||||
matchId: matchGame.matchId
|
||||
})
|
||||
.from(matchGameScore)
|
||||
.innerJoin(matchParticipantPlayer, eq(matchGameScore.playerId, matchParticipantPlayer.id))
|
||||
.innerJoin(user, eq(matchParticipantPlayer.userId, user.id))
|
||||
.innerJoin(matchGame, eq(matchGameScore.matchGameId, matchGame.id))
|
||||
.innerJoin(mappoolSlot, eq(matchGame.mappoolSlotId, mappoolSlot.id))
|
||||
.innerJoin(match, eq(matchGame.matchId, match.id))
|
||||
.orderBy(sql`${matchGameScore.pp} DESC NULLS LAST`, desc(matchGameScore.score))
|
||||
.limit(params.limit)
|
||||
.offset((params.page - 1) * params.limit),
|
||||
db.select({ count: sql<number>`count(*)` }).from(matchGameScore)
|
||||
]);
|
||||
|
||||
const highScores = rows.map((s, i) => ({
|
||||
rank: (params.page - 1) * params.limit + i + 1,
|
||||
playerUserId: s.playerUserId,
|
||||
playerName: s.playerName ?? 'Unknown',
|
||||
playerImage: s.playerImage,
|
||||
score: s.score,
|
||||
accuracy: s.accuracy,
|
||||
pp: s.pp,
|
||||
mods: s.mods,
|
||||
mapCategory: s.mapCategory ?? '',
|
||||
mapOrder: s.mapOrder ?? 0,
|
||||
matchName: s.matchName ?? 'Match',
|
||||
matchId: s.matchId
|
||||
}));
|
||||
|
||||
return {
|
||||
tab: 'scores' as const,
|
||||
topPlayers: [],
|
||||
topTeams: [],
|
||||
highScores,
|
||||
meta: buildTableMeta(params, Number(countResult[0].count))
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,12 +1,27 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { page } from '$app/stores';
|
||||
import DataTable from '$lib/components/DataTable.svelte';
|
||||
|
||||
function rowClick(href: string) {
|
||||
return () => goto(href);
|
||||
}
|
||||
|
||||
const { data } = $props();
|
||||
|
||||
let activeTab = $state<'players' | 'teams' | 'scores'>('players');
|
||||
const activeTab = $derived(data.tab);
|
||||
|
||||
function setTab(tab: string) {
|
||||
const url = new URL($page.url);
|
||||
url.searchParams.set('tab', tab);
|
||||
url.searchParams.set('page', '1');
|
||||
goto(url.toString(), { keepFocus: true, noScroll: true });
|
||||
}
|
||||
|
||||
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: '🎯' }
|
||||
{ id: 'players', label: 'Top Players', icon: '👤' },
|
||||
{ id: 'teams', label: 'Top Teams', icon: '⚑' },
|
||||
{ id: 'scores', label: 'High Scores', icon: '🎯' }
|
||||
];
|
||||
|
||||
function eloColor(elo: number): string {
|
||||
@@ -32,6 +47,33 @@
|
||||
FM: 'text-green-400',
|
||||
TB: 'text-pink-400'
|
||||
};
|
||||
|
||||
const playerColumns = [
|
||||
{ key: 'rank', label: '#', class: 'w-10 pl-4 text-center' },
|
||||
{ key: 'avatar', label: '', class: 'w-10' },
|
||||
{ key: 'name', label: 'Player', class: 'px-4 text-left' },
|
||||
{ key: 'elo', label: 'ELO', class: 'w-16 pr-4 text-right' },
|
||||
{ key: 'wl', label: 'W/L', class: 'w-16 pr-4 text-right' },
|
||||
{ key: 'winRate', label: 'Win %', class: 'w-16 pr-4 text-right' }
|
||||
];
|
||||
|
||||
const teamColumns = [
|
||||
{ key: 'rank', label: '#', class: 'w-10 pl-4 text-center' },
|
||||
{ key: 'avatar', label: '', class: 'w-10' },
|
||||
{ key: 'name', label: 'Team', class: 'px-4 text-left' },
|
||||
{ key: 'wins', label: 'Wins', class: 'w-16 pr-4 text-right' },
|
||||
{ key: 'matches', label: 'Matches', class: 'w-20 pr-4 text-right' }
|
||||
];
|
||||
|
||||
const scoreColumns = [
|
||||
{ key: 'rank', label: '#', class: 'w-10 pl-4 text-center' },
|
||||
{ key: 'avatar', label: '', class: 'w-10' },
|
||||
{ key: 'player', label: 'Player', class: 'px-4 text-left' },
|
||||
{ key: 'map', label: 'Map', class: 'w-12 pr-4 text-right' },
|
||||
{ key: 'pp', label: 'PP', class: 'w-16 pr-4 text-right' },
|
||||
{ key: 'score', label: 'Score', class: 'w-24 pr-4 text-right' },
|
||||
{ key: 'acc', label: 'Acc', class: 'w-16 pr-4 text-right' }
|
||||
];
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
@@ -53,7 +95,7 @@
|
||||
<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)}
|
||||
onclick={() => setTab(tab.id)}
|
||||
class="font-600 flex flex-1 items-center justify-center gap-2 rounded-md px-4 py-2 text-sm transition-colors {activeTab ===
|
||||
tab.id
|
||||
? 'bg-accent text-surface-900'
|
||||
@@ -67,219 +109,161 @@
|
||||
|
||||
<!-- Top Players -->
|
||||
{#if activeTab === 'players'}
|
||||
<div class="mt-6 overflow-x-auto 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}
|
||||
<table class="w-full">
|
||||
<thead>
|
||||
<tr class="font-600 border-b border-border text-left text-xs text-text-secondary">
|
||||
<th class="w-10 py-3 pl-4 text-center">#</th>
|
||||
<th class="w-10 py-3"></th>
|
||||
<th class="py-3">Player</th>
|
||||
<th class="w-16 py-3 pr-4 text-right">ELO</th>
|
||||
<th class="w-16 py-3 pr-4 text-right">W/L</th>
|
||||
<th class="w-16 py-3 pr-4 text-right">Win %</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each data.topPlayers as p}
|
||||
{@const medal = rankBadge(p.rank)}
|
||||
<tr
|
||||
class="border-b border-border/50 last:border-b-0 {p.rank <= 3
|
||||
? 'bg-accent/[0.02]'
|
||||
: ''}"
|
||||
<div class="mt-6">
|
||||
<DataTable data={data.topPlayers} meta={data.meta} columns={playerColumns}>
|
||||
{#snippet row(p, _i)}
|
||||
{@const medal = rankBadge(p.rank)}
|
||||
<tr
|
||||
onclick={rowClick(`/users/${p.userId}`)}
|
||||
class="cursor-pointer border-b border-border/50 transition-colors last:border-b-0 hover:bg-surface-800/50 {p.rank <=
|
||||
3
|
||||
? 'bg-accent/[0.02]'
|
||||
: ''}"
|
||||
>
|
||||
<td
|
||||
class="font-700 py-3 pl-4 text-center text-sm tabular-nums {medal?.color ??
|
||||
'text-text-secondary'}"
|
||||
>
|
||||
{medal ? medal.emoji : p.rank}
|
||||
</td>
|
||||
<td class="py-3">
|
||||
{#if p.image}
|
||||
<img src={p.image} alt="" class="h-8 w-8 rounded-full" />
|
||||
{:else}
|
||||
<div
|
||||
class="font-700 flex h-8 w-8 items-center justify-center rounded-full bg-surface-600 text-xs text-text-secondary"
|
||||
>
|
||||
{p.name.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
{/if}
|
||||
</td>
|
||||
<td class="font-600 py-3 text-sm">{p.name}</td>
|
||||
<td class="font-700 py-3 pr-4 text-right text-sm tabular-nums {eloColor(p.elo)}"
|
||||
>{p.elo}</td
|
||||
>
|
||||
<td class="py-3 pr-4 text-right text-xs text-text-secondary tabular-nums">
|
||||
<span class="text-green-400">{p.wins}</span>/<span class="text-red-400"
|
||||
>{p.losses}</span
|
||||
>
|
||||
<td
|
||||
class="font-700 py-3 pl-4 text-center text-sm tabular-nums {medal?.color ??
|
||||
'text-text-secondary'}"
|
||||
>
|
||||
{medal ? medal.emoji : p.rank}
|
||||
</td>
|
||||
<td class="py-3">
|
||||
{#if p.image}
|
||||
<img src={p.image} alt="" class="h-8 w-8 rounded-full" />
|
||||
{:else}
|
||||
<div
|
||||
class="font-700 flex h-8 w-8 items-center justify-center rounded-full bg-surface-600 text-xs text-text-secondary"
|
||||
>
|
||||
{p.name.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
{/if}
|
||||
</td>
|
||||
<td class="font-600 py-3 text-sm">{p.name}</td>
|
||||
<td class="font-700 py-3 pr-4 text-right text-sm tabular-nums {eloColor(p.elo)}"
|
||||
>{p.elo}</td
|
||||
>
|
||||
<td class="py-3 pr-4 text-right text-xs text-text-secondary tabular-nums">
|
||||
<span class="text-green-400">{p.wins}</span>/<span class="text-red-400"
|
||||
>{p.losses}</span
|
||||
>
|
||||
</td>
|
||||
<td
|
||||
class="font-600 py-3 pr-4 text-right text-xs tabular-nums {p.winRate !== '—' &&
|
||||
parseFloat(p.winRate) >= 50
|
||||
? 'text-green-400'
|
||||
: 'text-text-secondary'}"
|
||||
>
|
||||
{p.winRate}{p.winRate !== '—' ? '%' : ''}
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
{/if}
|
||||
</td>
|
||||
<td
|
||||
class="font-600 py-3 pr-4 text-right text-xs tabular-nums {p.winRate !== '—' &&
|
||||
parseFloat(p.winRate) >= 50
|
||||
? 'text-green-400'
|
||||
: 'text-text-secondary'}"
|
||||
>
|
||||
{p.winRate}{p.winRate !== '—' ? '%' : ''}
|
||||
</td>
|
||||
</tr>
|
||||
{/snippet}
|
||||
</DataTable>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Top Teams -->
|
||||
{#if activeTab === 'teams'}
|
||||
<div class="mt-6 overflow-x-auto 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}
|
||||
<table class="w-full">
|
||||
<thead>
|
||||
<tr class="font-600 border-b border-border text-left text-xs text-text-secondary">
|
||||
<th class="w-10 py-3 pl-4 text-center">#</th>
|
||||
<th class="w-10 py-3"></th>
|
||||
<th class="py-3">Team</th>
|
||||
<th class="w-16 py-3 pr-4 text-right">Wins</th>
|
||||
<th class="w-20 py-3 pr-4 text-right">Matches</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each data.topTeams as t}
|
||||
{@const medal = rankBadge(t.rank)}
|
||||
<tr
|
||||
class="border-b border-border/50 last:border-b-0 {t.rank <= 3
|
||||
? 'bg-accent/[0.02]'
|
||||
: ''}"
|
||||
>
|
||||
<td
|
||||
class="font-700 py-3 pl-4 text-center text-sm tabular-nums {medal?.color ??
|
||||
'text-text-secondary'}"
|
||||
<div class="mt-6">
|
||||
<DataTable data={data.topTeams} meta={data.meta} columns={teamColumns}>
|
||||
{#snippet row(t, _i)}
|
||||
{@const medal = rankBadge(t.rank)}
|
||||
<tr
|
||||
onclick={rowClick(`/teams/${t.teamId}`)}
|
||||
class="cursor-pointer border-b border-border/50 transition-colors last:border-b-0 hover:bg-surface-800/50 {t.rank <=
|
||||
3
|
||||
? 'bg-accent/[0.02]'
|
||||
: ''}"
|
||||
>
|
||||
<td
|
||||
class="font-700 py-3 pl-4 text-center text-sm tabular-nums {medal?.color ??
|
||||
'text-text-secondary'}"
|
||||
>
|
||||
{medal ? medal.emoji : t.rank}
|
||||
</td>
|
||||
<td class="py-3">
|
||||
{#if t.avatarUrl}
|
||||
<img src={t.avatarUrl} alt="" class="h-8 w-8 rounded-full" />
|
||||
{:else}
|
||||
<div
|
||||
class="font-700 flex h-8 w-8 items-center justify-center rounded-full bg-surface-600 text-xs text-text-secondary"
|
||||
>
|
||||
{medal ? medal.emoji : t.rank}
|
||||
</td>
|
||||
<td class="py-3">
|
||||
{#if t.avatarUrl}
|
||||
<img src={t.avatarUrl} alt="" class="h-8 w-8 rounded-full" />
|
||||
{:else}
|
||||
<div
|
||||
class="font-700 flex h-8 w-8 items-center justify-center rounded-full bg-surface-600 text-xs text-text-secondary"
|
||||
>
|
||||
{t.name.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
{/if}
|
||||
</td>
|
||||
<td class="py-3">
|
||||
<span class="font-600 text-sm">{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>
|
||||
</td>
|
||||
<td class="font-700 py-3 pr-4 text-right text-sm text-green-400 tabular-nums"
|
||||
>{t.wins}</td
|
||||
>
|
||||
<td class="py-3 pr-4 text-right text-xs text-text-secondary tabular-nums"
|
||||
>{t.totalMatches}</td
|
||||
>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
{/if}
|
||||
{t.name.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
{/if}
|
||||
</td>
|
||||
<td class="py-3">
|
||||
<span class="font-600 text-sm">{t.name}</span>
|
||||
<p class="text-xs text-text-secondary">
|
||||
{t.memberCount} member{t.memberCount !== 1 ? 's' : ''}
|
||||
</p>
|
||||
</td>
|
||||
<td class="font-700 py-3 pr-4 text-right text-sm text-green-400 tabular-nums"
|
||||
>{t.wins}</td
|
||||
>
|
||||
<td class="py-3 pr-4 text-right text-xs text-text-secondary tabular-nums"
|
||||
>{t.totalMatches}</td
|
||||
>
|
||||
</tr>
|
||||
{/snippet}
|
||||
</DataTable>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- High Scores -->
|
||||
{#if activeTab === 'scores'}
|
||||
<div class="mt-6 overflow-x-auto 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="border-b border-border px-4 py-2.5">
|
||||
<p class="text-xs text-text-secondary">
|
||||
Ranked by pp (performance points) when available, then score
|
||||
</p>
|
||||
</div>
|
||||
<table class="w-full">
|
||||
<thead>
|
||||
<tr class="font-600 border-b border-border text-left text-xs text-text-secondary">
|
||||
<th class="w-10 py-3 pl-4 text-center">#</th>
|
||||
<th class="w-10 py-3"></th>
|
||||
<th class="py-3">Player</th>
|
||||
<th class="w-12 py-3 pr-4 text-right">Map</th>
|
||||
<th class="w-16 py-3 pr-4 text-right text-accent">PP</th>
|
||||
<th class="w-24 py-3 pr-4 text-right">Score</th>
|
||||
<th class="w-16 py-3 pr-4 text-right">Acc</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each data.highScores as s}
|
||||
{@const medal = rankBadge(s.rank)}
|
||||
<tr
|
||||
class="border-b border-border/50 last:border-b-0 {s.rank <= 3
|
||||
? 'bg-accent/[0.02]'
|
||||
: ''}"
|
||||
>
|
||||
<td
|
||||
class="font-700 py-3 pl-4 text-center text-sm tabular-nums {medal?.color ??
|
||||
'text-text-secondary'}"
|
||||
<div class="mt-6">
|
||||
<DataTable data={data.highScores} meta={data.meta} columns={scoreColumns}>
|
||||
{#snippet row(s, _i)}
|
||||
{@const medal = rankBadge(s.rank)}
|
||||
<tr
|
||||
onclick={rowClick(`/users/${s.playerUserId}`)}
|
||||
class="cursor-pointer border-b border-border/50 transition-colors last:border-b-0 hover:bg-surface-800/50 {s.rank <=
|
||||
3
|
||||
? 'bg-accent/[0.02]'
|
||||
: ''}"
|
||||
>
|
||||
<td
|
||||
class="font-700 py-3 pl-4 text-center text-sm tabular-nums {medal?.color ??
|
||||
'text-text-secondary'}"
|
||||
>
|
||||
{medal ? medal.emoji : s.rank}
|
||||
</td>
|
||||
<td class="py-3">
|
||||
{#if s.playerImage}
|
||||
<img src={s.playerImage} alt="" class="h-8 w-8 rounded-full" />
|
||||
{:else}
|
||||
<div
|
||||
class="font-700 flex h-8 w-8 items-center justify-center rounded-full bg-surface-600 text-xs text-text-secondary"
|
||||
>
|
||||
{medal ? medal.emoji : s.rank}
|
||||
</td>
|
||||
<td class="py-3">
|
||||
{#if s.playerImage}
|
||||
<img src={s.playerImage} alt="" class="h-8 w-8 rounded-full" />
|
||||
{:else}
|
||||
<div
|
||||
class="font-700 flex h-8 w-8 items-center justify-center rounded-full bg-surface-600 text-xs text-text-secondary"
|
||||
>
|
||||
{s.playerName.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
{/if}
|
||||
</td>
|
||||
<td class="py-3">
|
||||
<span class="font-600 text-sm">{s.playerName}</span>
|
||||
<p class="truncate text-xs text-text-secondary">
|
||||
<a href="/matches/{s.matchId}" class="hover:text-accent">{s.matchName}</a>
|
||||
</p>
|
||||
</td>
|
||||
<td
|
||||
class="font-600 py-3 pr-4 text-right text-xs {catColors[s.mapCategory] ??
|
||||
'text-text-secondary'}"
|
||||
>
|
||||
{s.mapCategory}{s.mapOrder}
|
||||
</td>
|
||||
<td
|
||||
class="font-700 py-3 pr-4 text-right text-sm tabular-nums {s.pp != null
|
||||
? 'text-accent'
|
||||
: 'text-text-secondary/40'}"
|
||||
>
|
||||
{s.pp != null ? `${Math.round(s.pp)}pp` : '—'}
|
||||
</td>
|
||||
<td class="font-700 py-3 pr-4 text-right text-sm text-text-primary tabular-nums">
|
||||
{s.score.toLocaleString()}
|
||||
</td>
|
||||
<td class="py-3 pr-4 text-right text-xs text-text-secondary tabular-nums">
|
||||
{s.accuracy > 0 ? `${(s.accuracy * 100).toFixed(1)}%` : '—'}
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
{/if}
|
||||
{s.playerName.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
{/if}
|
||||
</td>
|
||||
<td class="py-3">
|
||||
<span class="font-600 text-sm">{s.playerName}</span>
|
||||
<p class="truncate text-xs text-text-secondary">{s.matchName}</p>
|
||||
</td>
|
||||
<td
|
||||
class="font-600 py-3 pr-4 text-right text-xs {catColors[s.mapCategory] ??
|
||||
'text-text-secondary'}"
|
||||
>
|
||||
{s.mapCategory}{s.mapOrder}
|
||||
</td>
|
||||
<td
|
||||
class="font-700 py-3 pr-4 text-right text-sm tabular-nums {s.pp != null
|
||||
? 'text-accent'
|
||||
: 'text-text-secondary/40'}"
|
||||
>
|
||||
{s.pp != null ? `${Math.round(s.pp)}pp` : '—'}
|
||||
</td>
|
||||
<td class="font-700 py-3 pr-4 text-right text-sm text-text-primary tabular-nums">
|
||||
{s.score.toLocaleString()}
|
||||
</td>
|
||||
<td class="py-3 pr-4 text-right text-xs text-text-secondary tabular-nums">
|
||||
{s.accuracy > 0 ? `${(s.accuracy * 100).toFixed(1)}%` : '—'}
|
||||
</td>
|
||||
</tr>
|
||||
{/snippet}
|
||||
</DataTable>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user