use prettier formatting

This commit is contained in:
2026-03-13 17:07:06 +01:00
parent 6a0bb9061d
commit d597c6545e
45 changed files with 1909 additions and 861 deletions

View File

@@ -49,11 +49,7 @@
stroke="currentColor"
stroke-width="2"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M4 6h16M4 12h16M4 18h16"
/>
<path stroke-linecap="round" stroke-linejoin="round" d="M4 6h16M4 12h16M4 18h16" />
</svg>
</button>
<div class="lg:flex-1"></div>
@@ -79,7 +75,7 @@
</div>
<a
href="/matches/{activeMatch.id}"
class="rounded-md border border-yellow-500/40 px-3 py-1 text-xs font-600 text-yellow-300 transition-colors hover:bg-yellow-500/20"
class="font-600 rounded-md border border-yellow-500/40 px-3 py-1 text-xs text-yellow-300 transition-colors hover:bg-yellow-500/20"
>
Go to match &rarr;
</a>

View File

@@ -17,23 +17,30 @@ export const load: PageServerLoad = async ({ locals }) => {
}
// ── LOGGED IN → dashboard data ──
const [recentMatches, liveMatches, [matchCount], [teamCount], [poolCount], [finishedCount], queueStatus] =
await Promise.all([
db.query.match.findMany({
with: { participants: { with: { team: true } }, mappool: { with: { slots: true } } },
orderBy: desc(match.createdAt),
limit: 5
}),
db.query.match.findMany({
with: { participants: { with: { team: true } }, mappool: { with: { slots: true } } },
where: inArray(match.state, ['LOBBY', 'ROLLING', 'PICKING', 'PLAYING'])
}),
db.select({ count: count() }).from(match),
db.select({ count: count() }).from(team),
db.select({ count: count() }).from(mappool),
db.select({ count: count() }).from(match).where(eq(match.state, 'FINISHED')),
getQueueStatus(locals.user.id)
]);
const [
recentMatches,
liveMatches,
[matchCount],
[teamCount],
[poolCount],
[finishedCount],
queueStatus
] = await Promise.all([
db.query.match.findMany({
with: { participants: { with: { team: true } }, mappool: { with: { slots: true } } },
orderBy: desc(match.createdAt),
limit: 5
}),
db.query.match.findMany({
with: { participants: { with: { team: true } }, mappool: { with: { slots: true } } },
where: inArray(match.state, ['LOBBY', 'ROLLING', 'PICKING', 'PLAYING'])
}),
db.select({ count: count() }).from(match),
db.select({ count: count() }).from(team),
db.select({ count: count() }).from(mappool),
db.select({ count: count() }).from(match).where(eq(match.state, 'FINISHED')),
getQueueStatus(locals.user.id)
]);
return {
authenticated: true as const,

View File

@@ -1,10 +1,6 @@
<script lang="ts">
import { page } from '$app/state';
import {
queue,
joinQueue,
leaveQueue
} from '$lib/stores/queue.svelte';
import { queue, joinQueue, leaveQueue } from '$lib/stores/queue.svelte';
let { data } = $props();
@@ -44,7 +40,10 @@
<title>Vash Esports</title>
<meta name="description" content="very cool and awesome automated osu! matchmaking platform" />
<meta property="og:title" content="Vash Esports" />
<meta property="og:description" content="very cool and awesome automated osu! matchmaking platform" />
<meta
property="og:description"
content="very cool and awesome automated osu! matchmaking platform"
/>
<meta property="og:type" content="website" />
<meta property="og:url" content={page.url.href} />
<meta property="og:image" content="https://esports.vash.software/logo.png" />
@@ -56,14 +55,20 @@
{#if !data.user}
<div class="flex min-h-[70vh] flex-col items-center justify-center text-center">
<img src="/logo.png" alt="Vash Esports" class="mb-6 h-16 w-16" />
<h1 class="text-4xl font-800 tracking-tight">
Welcome to <span class="text-accent font-semibold">Vash Esports</span>
<h1 class="font-800 text-4xl tracking-tight">
Welcome to <span class="font-semibold text-accent">Vash Esports</span>
</h1>
<p class="mt-3 max-w-md text-text-secondary">
a pretty cool automated osu! matchmaking platform.
</p>
<p class="mt-3 max-w-md text-text-secondary">
Please join <a href="https://discord.gg/n3mZgWk" target="_blank" rel="noopener" class="text-text-primary underline underline-offset-2 hover:text-accent transition-colors">discord.gg/n3mZgWk</a> and give any feedback :)
Please join <a
href="https://discord.gg/n3mZgWk"
target="_blank"
rel="noopener"
class="text-text-primary underline underline-offset-2 transition-colors hover:text-accent"
>discord.gg/n3mZgWk</a
> and give any feedback :)
</p>
<a
href="/login"
@@ -77,31 +82,39 @@
{:else}
<div class="mx-auto max-w-5xl">
<div class="rounded-lg border border-border bg-surface-800 p-5">
<h1 class="text-3xl mb-4 font-semibold text-center font-800 tracking-tight">Vash Esports</h1>
<p class="mt-2 text-sm text-text-secondary leading-relaxed">
Press <span class="font-600 text-text-primary">'Find Match'</span> to join the queue to play a fully automated osu! match with other players in an ELO-appropriate map pool.
<h1 class="font-800 mb-4 text-center text-3xl font-semibold tracking-tight">Vash Esports</h1>
<p class="mt-2 text-sm leading-relaxed text-text-secondary">
Press <span class="font-600 text-text-primary">'Find Match'</span> to join the queue to play a
fully automated osu! match with other players in an ELO-appropriate map pool.
</p>
<p class="mt-2 text-sm text-text-secondary leading-relaxed">
I'll be adding a lot more features soon, like arbitrarily large team sizes, fully automated tournaments, automated broadcasting, and more. Please join <a href="https://discord.gg/n3mZgWk" target="_blank" rel="noopener" class="text-text-primary underline underline-offset-2 hover:text-accent transition-colors">discord.gg/n3mZgWk</a> and give any feedback :)
<p class="mt-2 text-sm leading-relaxed text-text-secondary">
I'll be adding a lot more features soon, like arbitrarily large team sizes, fully automated
tournaments, automated broadcasting, and more. Please join <a
href="https://discord.gg/n3mZgWk"
target="_blank"
rel="noopener"
class="text-text-primary underline underline-offset-2 transition-colors hover:text-accent"
>discord.gg/n3mZgWk</a
> and give any feedback :)
</p>
</div>
<!-- Stats -->
<div class="mt-6 grid grid-cols-2 gap-3 sm:grid-cols-4">
<div class="rounded-lg border border-border bg-surface-800 p-4">
<p class="text-2xl font-800 tabular-nums text-text-primary">{data.stats.matches}</p>
<p class="font-800 text-2xl text-text-primary tabular-nums">{data.stats.matches}</p>
<p class="mt-1 text-xs text-text-secondary">Total Matches</p>
</div>
<div class="rounded-lg border border-border bg-surface-800 p-4">
<p class="text-2xl font-800 tabular-nums text-green-400">{data.stats.finished}</p>
<p class="font-800 text-2xl text-green-400 tabular-nums">{data.stats.finished}</p>
<p class="mt-1 text-xs text-text-secondary">Completed</p>
</div>
<div class="rounded-lg border border-border bg-surface-800 p-4">
<p class="text-2xl font-800 tabular-nums text-text-primary">{data.stats.teams}</p>
<p class="font-800 text-2xl text-text-primary tabular-nums">{data.stats.teams}</p>
<p class="mt-1 text-xs text-text-secondary">Teams</p>
</div>
<div class="rounded-lg border border-border bg-surface-800 p-4">
<p class="text-2xl font-800 tabular-nums text-text-primary">{data.stats.mappools}</p>
<p class="font-800 text-2xl text-text-primary tabular-nums">{data.stats.mappools}</p>
<p class="mt-1 text-xs text-text-secondary">Mappools</p>
</div>
</div>
@@ -114,7 +127,7 @@
<div class="absolute inset-0 animate-ping rounded-full bg-green-400 opacity-75"></div>
<div class="relative h-2 w-2 rounded-full bg-green-400"></div>
</div>
<h2 class="text-sm font-600">Live Now (max 4 until osu! gives me bot account lol)</h2>
<h2 class="font-600 text-sm">Live Now (max 4 until osu! gives me bot account lol)</h2>
</div>
<div class="mt-3 flex flex-col gap-2">
{#each data.liveMatches as m}
@@ -129,20 +142,20 @@
{#if p1?.team.avatarUrl}
<img src={p1.team.avatarUrl} alt="" class="h-8 w-8 rounded-full" />
{/if}
<span class="text-sm font-600">{p1?.team.name ?? '?'}</span>
<span class="font-600 text-sm">{p1?.team.name ?? '?'}</span>
</div>
<div class="flex items-center gap-3">
<span class="text-xl font-800 tabular-nums">{p1?.score ?? 0}</span>
<span class="font-800 text-xl tabular-nums">{p1?.score ?? 0}</span>
<span class="text-xs text-text-secondary">vs</span>
<span class="text-xl font-800 tabular-nums">{p2?.score ?? 0}</span>
<span class="font-800 text-xl tabular-nums">{p2?.score ?? 0}</span>
</div>
<div class="flex flex-1 items-center justify-end gap-3">
<span class="text-sm font-600">{p2?.team.name ?? '?'}</span>
<span class="font-600 text-sm">{p2?.team.name ?? '?'}</span>
{#if p2?.team.avatarUrl}
<img src={p2.team.avatarUrl} alt="" class="h-8 w-8 rounded-full" />
{/if}
</div>
<span class="flex items-center gap-1.5 text-xs font-500 {sc?.color ?? ''}">
<span class="font-500 flex items-center gap-1.5 text-xs {sc?.color ?? ''}">
{#if sc?.dot}
<span class="h-1.5 w-1.5 rounded-full {sc.dot}"></span>
{/if}
@@ -158,7 +171,7 @@
<div class="mt-6 rounded-lg border border-border bg-surface-800 p-5">
<div class="flex items-center justify-between">
<div>
<h2 class="text-sm font-600">Ranked Queue</h2>
<h2 class="font-600 text-sm">Ranked Queue</h2>
<p class="mt-0.5 text-xs text-text-secondary">Find a match at your skill level</p>
</div>
{#if queue.status?.inQueue}
@@ -178,7 +191,7 @@
{:else if data.hasActiveMatch}
<a
href="/matches/{data.activeMatchId}"
class="rounded-md border border-accent/40 px-4 py-2 text-sm font-600 text-accent transition-colors hover:bg-accent/10"
class="font-600 rounded-md border border-accent/40 px-4 py-2 text-sm text-accent transition-colors hover:bg-accent/10"
>
View Active Match →
</a>
@@ -186,7 +199,7 @@
<button
onclick={joinQueue}
disabled={queue.loading}
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"
class="font-600 rounded-md bg-accent px-4 py-2 text-sm text-surface-900 transition-colors hover:bg-accent-hover disabled:opacity-50"
>
{queue.loading ? 'Joining...' : 'Find Match'}
</button>
@@ -197,17 +210,31 @@
<div class="mt-8 grid gap-4 lg:grid-cols-3">
<!-- Quick Actions -->
<div class="lg:col-span-1">
<h2 class="text-sm font-600">Quick Actions</h2>
<h2 class="font-600 text-sm">Quick Actions</h2>
<div class="mt-3 flex flex-col gap-2">
<a
href="/matches"
class="flex items-center gap-3 rounded-lg border border-border bg-surface-800 p-3 transition-colors hover:border-accent/40 hover:bg-surface-700"
>
<span class="flex h-8 w-8 items-center justify-center rounded-md bg-accent/10 text-accent">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14.5 17.5 3 6V3h3l11.5 11.5"/><path d="m13 19 3.5-3.5"/><path d="m16.5 22 5-5"/><path d="M10 5.5 6 2H3v3l4 4"/></svg>
<span
class="flex h-8 w-8 items-center justify-center rounded-md bg-accent/10 text-accent"
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-4 w-4"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
><path d="M14.5 17.5 3 6V3h3l11.5 11.5" /><path d="m13 19 3.5-3.5" /><path
d="m16.5 22 5-5"
/><path d="M10 5.5 6 2H3v3l4 4" /></svg
>
</span>
<div>
<p class="text-sm font-600">Matches</p>
<p class="font-600 text-sm">Matches</p>
<p class="text-xs text-text-secondary">View all matches</p>
</div>
</a>
@@ -215,11 +242,27 @@
href="/mappools"
class="flex items-center gap-3 rounded-lg border border-border bg-surface-800 p-3 transition-colors hover:border-accent/40 hover:bg-surface-700"
>
<span class="flex h-8 w-8 items-center justify-center rounded-md bg-purple-500/10 text-purple-400">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="8" cy="18" r="3"/><path d="M11 18V8l9-1v10"/><circle cx="20" cy="17" r="3"/></svg>
<span
class="flex h-8 w-8 items-center justify-center rounded-md bg-purple-500/10 text-purple-400"
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-4 w-4"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
><circle cx="8" cy="18" r="3" /><path d="M11 18V8l9-1v10" /><circle
cx="20"
cy="17"
r="3"
/></svg
>
</span>
<div>
<p class="text-sm font-600">Mappools</p>
<p class="font-600 text-sm">Mappools</p>
<p class="text-xs text-text-secondary">Build a pool</p>
</div>
</a>
@@ -227,11 +270,27 @@
href="/teams"
class="flex items-center gap-3 rounded-lg border border-border bg-surface-800 p-3 transition-colors hover:border-accent/40 hover:bg-surface-700"
>
<span class="flex h-8 w-8 items-center justify-center rounded-md bg-blue-500/10 text-blue-400">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>
<span
class="flex h-8 w-8 items-center justify-center rounded-md bg-blue-500/10 text-blue-400"
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-4 w-4"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" /><circle
cx="9"
cy="7"
r="4"
/><path d="M23 21v-2a4 4 0 0 0-3-3.87" /><path d="M16 3.13a4 4 0 0 1 0 7.75" /></svg
>
</span>
<div>
<p class="text-sm font-600">Teams</p>
<p class="font-600 text-sm">Teams</p>
<p class="text-xs text-text-secondary">Manage rosters</p>
</div>
</a>
@@ -239,11 +298,28 @@
href="/leaderboard"
class="flex items-center gap-3 rounded-lg border border-border bg-surface-800 p-3 transition-colors hover:border-accent/40 hover:bg-surface-700"
>
<span class="flex h-8 w-8 items-center justify-center rounded-md bg-yellow-500/10 text-yellow-400">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="20" x2="18" y2="10"/><line x1="12" y1="20" x2="12" y2="4"/><line x1="6" y1="20" x2="6" y2="14"/></svg>
<span
class="flex h-8 w-8 items-center justify-center rounded-md bg-yellow-500/10 text-yellow-400"
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-4 w-4"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
><line x1="18" y1="20" x2="18" y2="10" /><line
x1="12"
y1="20"
x2="12"
y2="4"
/><line x1="6" y1="20" x2="6" y2="14" /></svg
>
</span>
<div>
<p class="text-sm font-600">Leaderboard</p>
<p class="font-600 text-sm">Leaderboard</p>
<p class="text-xs text-text-secondary">Rankings & stats</p>
</div>
</a>
@@ -252,7 +328,7 @@
<!-- Recent Activity -->
<div class="lg:col-span-2">
<h2 class="text-sm font-600">Recent Matches</h2>
<h2 class="font-600 text-sm">Recent Matches</h2>
<div class="mt-3 flex flex-col gap-2">
{#each data.recentMatches as m}
{@const p1 = m.participants[0]}
@@ -265,27 +341,38 @@
>
<div class="min-w-0 flex-1">
<div class="flex items-center gap-2">
<span class="text-sm font-600">{p1?.team.name ?? '?'}</span>
<span class="font-600 text-sm">{p1?.team.name ?? '?'}</span>
{#if m.state === 'FINISHED'}
<span
class="text-xs font-700 tabular-nums {(p1?.score ?? 0) > (p2?.score ?? 0)
class="font-700 text-xs 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)
class="font-700 text-xs 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}
<span class="text-sm font-600">{p2?.team.name ?? '?'}</span>
<span class="font-600 text-sm">{p2?.team.name ?? '?'}</span>
</div>
<p class="mt-0.5 text-xs text-text-secondary">{m.name ? `${m.name} · ` : ''}BO{config.bestOf} &middot; {timeAgo(m.finishedAt ?? m.createdAt)}</p>
<p class="mt-0.5 text-xs text-text-secondary">
{m.name ? `${m.name} · ` : ''}BO{config.bestOf} &middot; {timeAgo(
m.finishedAt ?? m.createdAt
)}
</p>
{#if m.mappool}
<p class="text-xs text-text-secondary"><a href='/mappools/{m.mappool.id}' onclick={(e) => e.stopPropagation()} class='hover:text-accent hover:underline'>{m.mappool.name}</a>{#if avgSR(m.mappool)} ({avgSR(m.mappool)}★){/if}</p>
<p class="text-xs text-text-secondary">
<a
href="/mappools/{m.mappool.id}"
onclick={(e) => e.stopPropagation()}
class="hover:text-accent hover:underline">{m.mappool.name}</a
>{#if avgSR(m.mappool)}
({avgSR(m.mappool)}★){/if}
</p>
{/if}
</div>
@@ -299,8 +386,7 @@
{:else}
<div class="rounded-lg border border-dashed border-border py-8 text-center">
<p class="text-sm text-text-secondary">
No matches yet. <a href="/matches" class="text-accent hover:underline"
>Create one</a
No matches yet. <a href="/matches" class="text-accent hover:underline">Create one</a
>
</p>
</div>

View File

@@ -102,13 +102,19 @@ export const actions: Actions = {
await db.delete(matchQueue);
return { success: true, message: `Cancelled ${cancelled.length} match${cancelled.length !== 1 ? 'es' : ''} and cleared queue` };
return {
success: true,
message: `Cancelled ${cancelled.length} match${cancelled.length !== 1 ? 'es' : ''} and cleared queue`
};
},
clearQueue: async ({ locals }) => {
requireRole(locals, 'admin');
const deleted = await db.delete(matchQueue).returning({ id: matchQueue.id });
return { success: true, message: `Removed ${deleted.length} queue entr${deleted.length !== 1 ? 'ies' : 'y'}` };
return {
success: true,
message: `Removed ${deleted.length} queue entr${deleted.length !== 1 ? 'ies' : 'y'}`
};
},
expireInvites: async ({ locals }) => {
@@ -118,13 +124,19 @@ export const actions: Actions = {
.set({ status: 'expired' })
.where(eq(matchInvite.status, 'pending'))
.returning({ id: matchInvite.id });
return { success: true, message: `Expired ${expired.length} pending invite${expired.length !== 1 ? 's' : ''}` };
return {
success: true,
message: `Expired ${expired.length} pending invite${expired.length !== 1 ? 's' : ''}`
};
},
clearNotifications: async ({ locals }) => {
requireRole(locals, 'admin');
const deleted = await db.delete(notification).returning({ id: notification.id });
return { success: true, message: `Cleared ${deleted.length} notification${deleted.length !== 1 ? 's' : ''}` };
return {
success: true,
message: `Cleared ${deleted.length} notification${deleted.length !== 1 ? 's' : ''}`
};
},
resetRatings: async ({ locals }) => {
@@ -161,7 +173,14 @@ export const actions: Actions = {
if (existing) {
await db
.update(playerRating)
.set({ elo, initialElo: elo, osuRankAtSeed: rank, wins: 0, losses: 0, updatedAt: new Date() })
.set({
elo,
initialElo: elo,
osuRankAtSeed: rank,
wins: 0,
losses: 0,
updatedAt: new Date()
})
.where(eq(playerRating.userId, u.id));
} else {
await db.insert(playerRating).values({
@@ -178,7 +197,10 @@ export const actions: Actions = {
updated++;
}
return { success: true, message: `Re-seeded ${updated} rating${updated !== 1 ? 's' : ''} from osu! ranks` };
return {
success: true,
message: `Re-seeded ${updated} rating${updated !== 1 ? 's' : ''} from osu! ranks`
};
},
clearMatchHistory: async ({ locals }) => {

View File

@@ -17,19 +17,19 @@
// Non-root admins can only assign player/referee
const availableRoles = $derived(
data.actorIsRootAdmin
? ['player', 'referee', 'admin']
: ['player', 'referee']
data.actorIsRootAdmin ? ['player', 'referee', 'admin'] : ['player', 'referee']
);
</script>
<div class="mx-auto max-w-4xl">
<div>
<h1 class="text-2xl font-700 tracking-tight">Admin Panel</h1>
<h1 class="font-700 text-2xl 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>
<span class="font-700 ml-2 rounded bg-red-500/20 px-1.5 py-0.5 text-[10px] text-red-400"
>ROOT ADMIN</span
>
{/if}
</p>
</div>
@@ -44,11 +44,11 @@
<!-- Users Table -->
<div class="mt-6 rounded-lg border border-border bg-surface-800">
<div class="grid grid-cols-[auto_1fr_auto_auto_auto] gap-4 border-b border-border px-4 py-3">
<span class="text-xs font-600 text-text-secondary">Avatar</span>
<span class="text-xs font-600 text-text-secondary">User</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">Role</span>
<span class="font-600 text-xs text-text-secondary">Avatar</span>
<span class="font-600 text-xs text-text-secondary">User</span>
<span class="font-600 text-xs text-text-secondary">ELO</span>
<span class="font-600 text-xs text-text-secondary">W/L</span>
<span class="font-600 text-xs text-text-secondary">Role</span>
</div>
{#each data.users as u}
@@ -63,7 +63,7 @@
<img src={u.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"
class="font-700 flex h-8 w-8 items-center justify-center rounded-full bg-surface-600 text-xs text-text-secondary"
>
{u.name.charAt(0).toUpperCase()}
</div>
@@ -72,19 +72,21 @@
<!-- Name -->
<div class="min-w-0">
<div class="flex items-center gap-2">
<p class="truncate text-sm font-500">{u.name}</p>
<p class="font-500 truncate text-sm">{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>
<span class="font-700 rounded bg-red-500/20 px-1.5 py-0.5 text-[10px] text-red-400"
>ROOT</span
>
{/if}
</div>
<p class="truncate text-xs text-text-secondary">{u.email}</p>
</div>
<!-- ELO -->
<span class="text-sm font-600 tabular-nums text-text-primary">{u.elo}</span>
<span class="font-600 text-sm text-text-primary tabular-nums">{u.elo}</span>
<!-- W/L -->
<span class="text-xs tabular-nums text-text-secondary">
<span class="text-xs text-text-secondary tabular-nums">
<span class="text-green-400">{u.wins}W</span> /
<span class="text-red-400">{u.losses}L</span>
</span>
@@ -112,7 +114,7 @@
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"
class="font-500 rounded-md border px-2 py-1 text-xs {badge.color} cursor-pointer bg-transparent focus:outline-none"
>
{#each availableRoles as r}
<option value={r} class="bg-surface-800 text-text-primary">
@@ -122,7 +124,7 @@
</select>
</form>
{:else}
<span class="rounded-md border px-2 py-1 text-xs font-500 {badge.color}">
<span class="font-500 rounded-md border px-2 py-1 text-xs {badge.color}">
{badge.label}
</span>
{/if}
@@ -132,25 +134,24 @@
<!-- Admin Tools -->
<div class="mt-6 rounded-lg border border-border bg-surface-800 p-5">
<h2 class="text-sm font-600">Tools</h2>
<h2 class="font-600 text-sm">Tools</h2>
<!-- Stats -->
<div class="mt-3 flex gap-4 text-xs text-text-secondary">
<span>{data.stats.activeMatches} active match{data.stats.activeMatches !== 1 ? 'es' : ''}</span>
<span
>{data.stats.activeMatches} active match{data.stats.activeMatches !== 1 ? 'es' : ''}</span
>
<span>{data.stats.queueSize} in queue</span>
<span>{data.stats.pendingInvites} pending invite{data.stats.pendingInvites !== 1 ? 's' : ''}</span>
<span
>{data.stats.pendingInvites} pending invite{data.stats.pendingInvites !== 1
? 's'
: ''}</span
>
</div>
<!-- Actions -->
<div class="mt-4 flex flex-wrap gap-2">
{#each [
{ action: 'cancelAllMatches', label: 'Cancel All Matches', confirm: 'Cancel all active matches and clear the queue?', color: 'border-yellow-500/30 text-yellow-400 hover:bg-yellow-500/10' },
{ action: 'clearQueue', label: 'Clear Queue', confirm: 'Remove everyone from the queue?', color: 'border-border text-text-secondary hover:bg-surface-700' },
{ action: 'expireInvites', label: 'Expire All Invites', confirm: 'Expire all pending invites?', color: 'border-border text-text-secondary hover:bg-surface-700' },
{ action: 'clearNotifications', label: 'Clear Notifications', confirm: 'Delete all notifications?', color: 'border-border text-text-secondary hover:bg-surface-700' },
{ action: 'resetRatings', label: 'Re-seed Ratings from osu!', confirm: 'Fetch all osu! ranks and re-seed ELO ratings? W/L records will be reset. Unranked players get 10M rank. This cannot be undone.', color: 'border-red-500/30 text-red-400 hover:bg-red-500/10' },
{ action: 'clearMatchHistory', label: 'Clear All Match Data', confirm: 'Delete ALL matches, scores, invites, and notifications? This cannot be undone.', color: 'border-red-500/30 text-red-400 hover:bg-red-500/10' },
] as tool}
{#each [{ action: 'cancelAllMatches', label: 'Cancel All Matches', confirm: 'Cancel all active matches and clear the queue?', color: 'border-yellow-500/30 text-yellow-400 hover:bg-yellow-500/10' }, { action: 'clearQueue', label: 'Clear Queue', confirm: 'Remove everyone from the queue?', color: 'border-border text-text-secondary hover:bg-surface-700' }, { action: 'expireInvites', label: 'Expire All Invites', confirm: 'Expire all pending invites?', color: 'border-border text-text-secondary hover:bg-surface-700' }, { action: 'clearNotifications', label: 'Clear Notifications', confirm: 'Delete all notifications?', color: 'border-border text-text-secondary hover:bg-surface-700' }, { action: 'resetRatings', label: 'Re-seed Ratings from osu!', confirm: 'Fetch all osu! ranks and re-seed ELO ratings? W/L records will be reset. Unranked players get 10M rank. This cannot be undone.', color: 'border-red-500/30 text-red-400 hover:bg-red-500/10' }, { action: 'clearMatchHistory', label: 'Clear All Match Data', confirm: 'Delete ALL matches, scores, invites, and notifications? This cannot be undone.', color: 'border-red-500/30 text-red-400 hover:bg-red-500/10' }] as tool}
<form
method="post"
action="?/{tool.action}"
@@ -169,9 +170,12 @@
>
<button
type="submit"
onclick={(e) => { if (!confirm(tool.confirm)) e.preventDefault(); }}
class="rounded-md border px-3 py-1.5 text-xs font-500 transition-colors {tool.color}"
>{tool.label}</button>
onclick={(e) => {
if (!confirm(tool.confirm)) e.preventDefault();
}}
class="font-500 rounded-md border px-3 py-1.5 text-xs transition-colors {tool.color}"
>{tool.label}</button
>
</form>
{/each}
</div>
@@ -179,23 +183,24 @@
<!-- 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>
<h2 class="font-600 text-sm">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>
<span class="font-600 rounded bg-surface-600 px-1.5 py-0.5 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>
<span class="font-600 rounded bg-yellow-500/20 px-1.5 py-0.5 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>
<span class="font-600 rounded bg-red-500/20 px-1.5 py-0.5 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>
<span class="font-700 rounded bg-red-500/20 px-1.5 py-0.5 text-red-400">Root Admin</span>
Can promote/demote admins. Set via ROOT_ADMIN_EMAIL env var. Cannot be demoted.
</div>
{/if}

View File

@@ -33,10 +33,7 @@ export const load: PageServerLoad = async () => {
elo: r.elo,
wins: r.wins,
losses: r.losses,
winRate:
r.wins + r.losses > 0
? ((r.wins / (r.wins + r.losses)) * 100).toFixed(1)
: '—'
winRate: r.wins + r.losses > 0 ? ((r.wins / (r.wins + r.losses)) * 100).toFixed(1) : '—'
};
})
);
@@ -49,10 +46,7 @@ export const load: PageServerLoad = async () => {
wins: sql<number>`count(*)`.as('wins')
})
.from(match)
.where(and(
sql`${match.winnerId} IS NOT NULL`,
eq(match.state, 'FINISHED')
))
.where(and(sql`${match.winnerId} IS NOT NULL`, eq(match.state, 'FINISHED')))
.groupBy(match.winnerId)
.orderBy(sql`count(*) DESC`)
.limit(15);
@@ -92,10 +86,7 @@ export const load: PageServerLoad = async () => {
const recentHighScores = await db
.select()
.from(matchGameScore)
.orderBy(
sql`${matchGameScore.pp} DESC NULLS LAST`,
desc(matchGameScore.score)
)
.orderBy(sql`${matchGameScore.pp} DESC NULLS LAST`, desc(matchGameScore.score))
.limit(15)
.then((rows) =>
Promise.all(

View File

@@ -45,7 +45,7 @@
<div class="mx-auto max-w-4xl">
<div>
<h1 class="text-2xl font-700 tracking-tight">Leaderboard</h1>
<h1 class="font-700 text-2xl tracking-tight">Leaderboard</h1>
<p class="mt-1 text-sm text-text-secondary">Rankings and top performances</p>
</div>
@@ -54,7 +54,7 @@
{#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 ===
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'
: 'text-text-secondary hover:text-text-primary'}"
@@ -75,7 +75,7 @@
{:else}
<table class="w-full">
<thead>
<tr class="border-b border-border text-left text-xs font-600 text-text-secondary">
<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>
@@ -87,25 +87,43 @@
<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]' : ''}">
<td class="py-3 pl-4 text-center text-sm font-700 tabular-nums {medal?.color ?? 'text-text-secondary'}">
<tr
class="border-b border-border/50 last:border-b-0 {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="flex h-8 w-8 items-center justify-center rounded-full bg-surface-600 text-xs font-700 text-text-secondary">
<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="py-3 text-sm font-600">{p.name}</td>
<td class="py-3 pr-4 text-right text-sm font-700 tabular-nums {eloColor(p.elo)}">{p.elo}</td>
<td class="py-3 pr-4 text-right text-xs tabular-nums text-text-secondary">
<span class="text-green-400">{p.wins}</span>/<span class="text-red-400">{p.losses}</span>
<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="py-3 pr-4 text-right text-xs font-600 tabular-nums {p.winRate !== '—' && parseFloat(p.winRate) >= 50 ? 'text-green-400' : 'text-text-secondary'}">
<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>
@@ -126,7 +144,7 @@
{:else}
<table class="w-full">
<thead>
<tr class="border-b border-border text-left text-xs font-600 text-text-secondary">
<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>
@@ -137,28 +155,43 @@
<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="py-3 pl-4 text-center text-sm font-700 tabular-nums {medal?.color ?? 'text-text-secondary'}">
<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'}"
>
{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="flex h-8 w-8 items-center justify-center rounded-full bg-surface-600 text-xs font-700 text-text-secondary">
<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="text-sm font-600">{t.name}</span>
<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>
<p class="text-xs text-text-secondary">
{t.memberCount} member{t.memberCount !== 1 ? 's' : ''}
</p>
</td>
<td class="py-3 pr-4 text-right text-sm font-700 tabular-nums text-green-400">{t.wins}</td>
<td class="py-3 pr-4 text-right text-xs tabular-nums text-text-secondary">{t.totalMatches}</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>
@@ -175,12 +208,14 @@
<p class="text-sm text-text-secondary">No scores recorded yet.</p>
</div>
{:else}
<div class="px-4 py-2.5 border-b border-border">
<p class="text-xs text-text-secondary">Ranked by pp (performance points) when available, then score</p>
<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="border-b border-border text-left text-xs font-600 text-text-secondary">
<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>
@@ -193,35 +228,51 @@
<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="py-3 pl-4 text-center text-sm font-700 tabular-nums {medal?.color ?? 'text-text-secondary'}">
<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'}"
>
{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="flex h-8 w-8 items-center justify-center rounded-full bg-surface-600 text-xs font-700 text-text-secondary">
<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="text-sm font-600">{s.playerName}</span>
<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="py-3 pr-4 text-right text-xs font-600 {catColors[s.mapCategory] ?? 'text-text-secondary'}">
<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="py-3 pr-4 text-right text-sm font-700 tabular-nums {s.pp != null ? 'text-accent' : 'text-text-secondary/40'}">
<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="py-3 pr-4 text-right text-sm font-700 tabular-nums text-text-primary">
<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 tabular-nums text-text-secondary">
<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>
@@ -234,13 +285,12 @@
<!-- 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">
Your initial rating is seeded from your osu! global rank. Winning gains ELO and
losing drops it, but the amount depends on your opponent's rating — beating a
stronger player earns more points. New players' ratings shift faster to help
find your true skill level quickly. Queue matchmaking pairs players with similar
ELO for fair matches.
<h2 class="font-600 text-sm">How ELO Works</h2>
<p class="mt-2 text-xs leading-relaxed text-text-secondary">
Your initial rating is seeded from your osu! global rank. Winning gains ELO and losing drops
it, but the amount depends on your opponent's rating — beating a stronger player earns more
points. New players' ratings shift faster to help find your true skill level quickly. Queue
matchmaking pairs players with similar ELO for fair matches.
</p>
</div>
</div>

View File

@@ -8,7 +8,7 @@ export const load: PageServerLoad = async ({ locals }) => {
const mappools = await db.query.mappool.findMany({ with: { slots: true } });
const avgSR = (m: typeof mappools[0]) =>
const avgSR = (m: (typeof mappools)[0]) =>
m.slots.length ? m.slots.reduce((s, sl) => s + (sl.starRating ?? 0), 0) / m.slots.length : 0;
mappools.sort((a, b) => avgSR(b) - avgSR(a));

View File

@@ -9,7 +9,10 @@
<title>Mappools — Vash Esports</title>
<meta name="description" content="Browse and manage osu! tournament mappools on Vash Esports." />
<meta property="og:title" content="Mappools — Vash Esports" />
<meta property="og:description" content="Browse and manage osu! tournament mappools on Vash Esports." />
<meta
property="og:description"
content="Browse and manage osu! tournament mappools on Vash Esports."
/>
<meta property="og:site_name" content="Vash Esports" />
<meta name="twitter:card" content="summary" />
</svelte:head>
@@ -17,12 +20,12 @@
<div class="mx-auto max-w-4xl">
<div class="flex items-center justify-between">
<div>
<h1 class="text-2xl font-700 tracking-tight">Mappools</h1>
<h1 class="font-700 text-2xl tracking-tight">Mappools</h1>
<p class="mt-1 text-sm text-text-secondary">Create and manage your beatmap pools</p>
</div>
<button
onclick={() => (creating = !creating)}
class="rounded-md bg-accent px-4 py-2 text-sm font-600 text-surface-900 transition-colors hover:bg-accent-hover"
class="font-600 rounded-md bg-accent px-4 py-2 text-sm text-surface-900 transition-colors hover:bg-accent-hover"
>
{creating ? 'Cancel' : 'New Pool'}
</button>
@@ -44,7 +47,7 @@
/>
<button
type="submit"
class="rounded-md bg-accent px-4 py-2 text-sm font-600 text-surface-900 transition-colors hover:bg-accent-hover"
class="font-600 rounded-md bg-accent px-4 py-2 text-sm text-surface-900 transition-colors hover:bg-accent-hover"
>
Create
</button>
@@ -54,16 +57,22 @@
<div class="mt-6 flex flex-col gap-3">
{#each data.mappools as pool}
{@const rated = pool.slots.filter((s: any) => s.starRating != null)}
{@const avgSr = rated.length > 0 ? (rated.reduce((sum: number, s: any) => sum + s.starRating, 0) / rated.length).toFixed(2) : null}
{@const avgSr =
rated.length > 0
? (rated.reduce((sum: number, s: any) => sum + s.starRating, 0) / rated.length).toFixed(2)
: null}
<a
href="/mappools/{pool.id}"
class="group flex items-center justify-between rounded-lg border border-border bg-surface-800 p-4 transition-colors hover:border-accent/40 hover:bg-surface-700"
>
<div>
<div class="flex items-center gap-2">
<h2 class="text-sm font-600">{pool.name}</h2>
<h2 class="font-600 text-sm">{pool.name}</h2>
{#if pool.verifiedAt}
<span class="rounded border border-green-500/30 bg-green-500/10 px-1.5 py-0.5 text-[10px] font-600 text-green-400">✓ Verified</span>
<span
class="font-600 rounded border border-green-500/30 bg-green-500/10 px-1.5 py-0.5 text-[10px] text-green-400"
>✓ Verified</span
>
{/if}
</div>
<p class="mt-1 text-xs text-text-secondary">

View File

@@ -190,10 +190,7 @@ export const actions: Actions = {
requireAuth(locals);
if (locals.user!.role !== 'admin') error(403, 'Admins only');
await db
.update(mappool)
.set({ verifiedAt: new Date() })
.where(eq(mappool.id, params.id));
await db.update(mappool).set({ verifiedAt: new Date() }).where(eq(mappool.id, params.id));
return { success: true };
},
@@ -202,10 +199,7 @@ export const actions: Actions = {
requireAuth(locals);
if (locals.user!.role !== 'admin') error(403, 'Admins only');
await db
.update(mappool)
.set({ verifiedAt: null })
.where(eq(mappool.id, params.id));
await db.update(mappool).set({ verifiedAt: null }).where(eq(mappool.id, params.id));
return { success: true };
},
@@ -234,10 +228,7 @@ export const actions: Actions = {
// Fetch target category slots (excluding the moved slot)
const targetSlots = await db.query.mappoolSlot.findMany({
where: and(
eq(mappoolSlot.mappoolId, params.id),
eq(mappoolSlot.category, targetCategory)
),
where: and(eq(mappoolSlot.mappoolId, params.id), eq(mappoolSlot.category, targetCategory)),
orderBy: [asc(mappoolSlot.orderInCategory)]
});
const filteredTarget = targetSlots.filter((s) => s.id !== slotId);
@@ -267,10 +258,7 @@ export const actions: Actions = {
// If cross-category move, re-number the source category too
if (!isSameCategory) {
const sourceSlots = await db.query.mappoolSlot.findMany({
where: and(
eq(mappoolSlot.mappoolId, params.id),
eq(mappoolSlot.category, sourceCategory)
),
where: and(eq(mappoolSlot.mappoolId, params.id), eq(mappoolSlot.category, sourceCategory)),
orderBy: [asc(mappoolSlot.orderInCategory)]
});
for (let i = 0; i < sourceSlots.length; i++) {
@@ -292,7 +280,10 @@ export const actions: Actions = {
const raw = form.get('data')?.toString()?.trim();
if (!raw) return { error: 'No data provided' };
const lines = raw.split(/\n/).map((l) => l.trim()).filter(Boolean);
const lines = raw
.split(/\n/)
.map((l) => l.trim())
.filter(Boolean);
const validMods = ['NM', 'HD', 'HR', 'DT', 'FM', 'TB'];
const errors: string[] = [];
let successCount = 0;

View File

@@ -152,13 +152,6 @@
};
</script>
<style>
:global([draggable='true']) {
-webkit-user-select: none;
user-select: none;
}
</style>
<svelte:head>
<title>{data.pool.name} — Mappool | Vash Esports</title>
<meta
@@ -200,11 +193,11 @@
type="text"
name="name"
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"
class="font-700 rounded-md border border-accent bg-surface-700 px-3 py-1 text-xl text-text-primary focus:outline-none"
/>
<button
type="submit"
class="rounded-md bg-accent px-3 py-1 text-xs font-600 text-surface-900 hover:bg-accent-hover"
class="font-600 rounded-md bg-accent px-3 py-1 text-xs text-surface-900 hover:bg-accent-hover"
>
Save
</button>
@@ -220,10 +213,10 @@
</form>
{:else}
<div class="flex items-center gap-2">
<h1 class="text-2xl font-700 tracking-tight">{data.pool.name}</h1>
<h1 class="font-700 text-2xl tracking-tight">{data.pool.name}</h1>
{#if data.pool.verifiedAt}
<span
class="flex items-center gap-1 rounded border border-green-500/30 bg-green-500/10 px-2 py-0.5 text-xs font-600 text-green-400"
class="font-600 flex items-center gap-1 rounded border border-green-500/30 bg-green-500/10 px-2 py-0.5 text-xs text-green-400"
>
&#10003; Verified
</span>
@@ -305,7 +298,7 @@
}}
class="mt-6 rounded-lg border border-border bg-surface-800 p-4"
>
<h2 class="text-sm font-600">Add Map</h2>
<h2 class="font-600 text-sm">Add Map</h2>
<div class="mt-3 flex gap-3">
<select
name="category"
@@ -328,7 +321,7 @@
<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"
class="font-600 rounded-md bg-accent px-4 py-2 text-sm text-surface-900 transition-colors hover:bg-accent-hover disabled:opacity-50"
>
{searching ? 'Adding...' : 'Add'}
</button>
@@ -345,8 +338,10 @@
<div class="mt-4 rounded-lg border border-border bg-surface-800">
<button
type="button"
onclick={() => { bulkOpen = !bulkOpen; }}
class="flex w-full items-center justify-between px-4 py-3 text-sm font-600 text-text-secondary hover:text-text-primary transition-colors"
onclick={() => {
bulkOpen = !bulkOpen;
}}
class="font-600 flex w-full items-center justify-between px-4 py-3 text-sm text-text-secondary transition-colors hover:text-text-primary"
>
Bulk Import
<span class="text-xs">{bulkOpen ? '▲' : '▼'}</span>
@@ -371,7 +366,10 @@
}
}
} else if (result.type === 'failure') {
bulkResult = { successCount: 0, errors: [(result.data as any)?.error ?? 'Import failed'] };
bulkResult = {
successCount: 0,
errors: [(result.data as any)?.error ?? 'Import failed']
};
}
};
}}
@@ -380,7 +378,7 @@
<textarea
name="data"
bind:value={bulkData}
placeholder={"NM1\t181589\nNM2\t2603758\nHR1\t948389\nTB\t457061"}
placeholder={'NM1\t181589\nNM2\t2603758\nHR1\t948389\nTB\t457061'}
rows="6"
class="w-full rounded-md border border-border bg-surface-700 px-3 py-2 font-mono text-sm text-text-primary placeholder:text-text-secondary/40 focus:border-accent focus:outline-none"
></textarea>
@@ -388,7 +386,7 @@
<button
type="submit"
disabled={bulkImporting || !bulkData.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"
class="font-600 rounded-md bg-accent px-4 py-2 text-sm text-surface-900 transition-colors hover:bg-accent-hover disabled:opacity-50"
>
{bulkImporting ? 'Importing...' : 'Import'}
</button>
@@ -400,7 +398,9 @@
{#if bulkResult}
<div class="mt-3 rounded-md border border-border bg-surface-700 p-3 text-sm">
{#if bulkResult.successCount > 0}
<p class="text-green-400">{bulkResult.successCount} map{bulkResult.successCount !== 1 ? 's' : ''} imported successfully.</p>
<p class="text-green-400">
{bulkResult.successCount} map{bulkResult.successCount !== 1 ? 's' : ''} imported successfully.
</p>
{/if}
{#if bulkResult.errors.length > 0}
<div class="mt-1 space-y-1">
@@ -431,9 +431,8 @@
ondrop={handleDrop}
>
<span
class="rounded border px-2 py-0.5 text-xs font-600 {categoryColors[
category
] ?? 'bg-surface-600 text-text-secondary border-border'}"
class="font-600 rounded border px-2 py-0.5 text-xs {categoryColors[category] ??
'border-border bg-surface-600 text-text-secondary'}"
>
{category}
</span>
@@ -471,7 +470,9 @@
<div
class="group relative flex items-center gap-3 rounded-lg border bg-surface-800 p-2 transition-colors
{data.canEdit ? 'cursor-grab active:cursor-grabbing' : ''}
{draggedSlot?.id === slot.id ? 'border-accent/50 opacity-40' : 'border-border hover:border-accent/30'}"
{draggedSlot?.id === slot.id
? 'border-accent/50 opacity-40'
: 'border-border hover:border-accent/30'}"
style={dropTarget?.category === category && dropTarget.index === i
? `box-shadow: 0 -3px 0 0 var(--color-accent, #8b5cf6); margin-top: 4px;`
: dropTarget?.category === category &&
@@ -482,16 +483,17 @@
ondragover={(e) => handleDragOver(e, category, i)}
ondrop={handleDrop}
role={data.canEdit ? 'listitem' : undefined}
use:makeDraggable={{ slotId: slot.id, category, index: i, enabled: data.canEdit }}
use:makeDraggable={{
slotId: slot.id,
category,
index: i,
enabled: data.canEdit
}}
>
<!-- Drag grip icon -->
{#if data.canEdit}
<div class="flex flex-col gap-0.5 text-text-secondary/40">
<svg
class="h-4 w-4"
fill="currentColor"
viewBox="0 0 24 24"
>
<svg class="h-4 w-4" fill="currentColor" viewBox="0 0 24 24">
<circle cx="9" cy="6" r="1.5" />
<circle cx="15" cy="6" r="1.5" />
<circle cx="9" cy="12" r="1.5" />
@@ -523,25 +525,15 @@
<button
type="button"
onclick={() => window.open(slot.beatmap?.url, '_blank')}
class="truncate text-left text-sm font-500 hover:text-accent"
class="font-500 truncate text-left text-sm hover:text-accent"
>
{slot.beatmap.artist} - {slot.beatmap.title}
</button>
<div
class="mt-0.5 flex items-center gap-3 text-xs text-text-secondary"
>
<div class="mt-0.5 flex items-center gap-3 text-xs text-text-secondary">
<span>[{slot.beatmap.version}]</span>
<span
>{slot.beatmap.starRating.toFixed(
2
)}&#9733;</span
>
<span>{slot.beatmap.starRating.toFixed(2)}&#9733;</span>
<span>{slot.beatmap.bpm} BPM</span>
<span
>{formatLength(
slot.beatmap.totalLength
)}</span
>
<span>{formatLength(slot.beatmap.totalLength)}</span>
</div>
{:else}
<p class="text-sm text-text-secondary">
@@ -551,21 +543,17 @@
</div>
<!-- Slot label -->
<span class="text-xs font-600 text-text-secondary">
<span class="font-600 text-xs text-text-secondary">
{category}{i + 1}
</span>
<!-- Delete -->
{#if data.canEdit}
<form method="post" action="?/removeSlot" use:enhance>
<input
type="hidden"
name="slotId"
value={slot.id}
/>
<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"
class="rounded p-1 text-text-secondary opacity-0 transition-all group-hover:opacity-100 hover:bg-red-500/10 hover:text-red-400"
>
&#10005;
</button>
@@ -590,35 +578,26 @@
await update();
} else if (result.type === 'failure') {
inlineErrors[category] =
(result.data as { error?: string })
?.error ?? 'Failed to add map';
(result.data as { error?: string })?.error ?? 'Failed to add map';
}
};
}}
class="flex items-center gap-2"
>
<input
type="hidden"
name="category"
value={category}
/>
<input type="hidden" name="category" value={category} />
<input
type="text"
name="beatmapId"
bind:value={inlineInputs[category]}
placeholder="Add {category}{slots.length +
1} — paste beatmap ID or URL"
placeholder="Add {category}{slots.length + 1} — paste beatmap ID or URL"
class="flex-1 rounded-md border border-dashed border-border bg-transparent px-3 py-1.5 text-sm text-text-primary placeholder:text-text-secondary/40 focus:border-accent focus:outline-none"
/>
<button
type="submit"
disabled={inlineSearching[category] ||
!inlineInputs[category]?.trim()}
class="rounded-md bg-accent/10 px-3 py-1.5 text-xs font-600 text-accent transition-colors hover:bg-accent/20 disabled:opacity-40"
disabled={inlineSearching[category] || !inlineInputs[category]?.trim()}
class="font-600 rounded-md bg-accent/10 px-3 py-1.5 text-xs text-accent transition-colors hover:bg-accent/20 disabled:opacity-40"
>
{inlineSearching[category]
? 'Adding...'
: '+ Add'}
{inlineSearching[category] ? 'Adding...' : '+ Add'}
</button>
</form>
{#if inlineErrors[category]}
@@ -640,3 +619,10 @@
{/if}
</div>
</div>
<style>
:global([draggable='true']) {
-webkit-user-select: none;
user-select: none;
}
</style>

View File

@@ -91,7 +91,11 @@ export const actions: Actions = {
const bestOf = parseInt(form.get('bestOf')?.toString() ?? '5');
const teamSize1 = parseInt(form.get('teamSize1')?.toString() ?? '1');
const teamSize2 = parseInt(form.get('teamSize2')?.toString() ?? '1');
const scoringType = (form.get('scoringType')?.toString() ?? 'score_v2') as 'score' | 'score_v2' | 'accuracy' | 'combo';
const scoringType = (form.get('scoringType')?.toString() ?? 'score_v2') as
| 'score'
| 'score_v2'
| 'accuracy'
| 'combo';
const allowEloChange = form.get('allowEloChange') === 'on';
const message = form.get('message')?.toString()?.trim() || '';
const scheduledAtStr = form.get('scheduledAt')?.toString();

View File

@@ -19,7 +19,9 @@
let opponentSearch = $state('');
const personalTeam = $derived((data as any).userTeams?.find((t: any) => t.isPersonal));
const nonPersonalUserTeams = $derived((data as any).userTeams?.filter((t: any) => !t.isPersonal) ?? []);
const nonPersonalUserTeams = $derived(
(data as any).userTeams?.filter((t: any) => !t.isPersonal) ?? []
);
const hasMultipleTeams = $derived(nonPersonalUserTeams.length > 0);
// Auto-select personal team by default
@@ -130,7 +132,7 @@
<!-- Header -->
<div class="flex items-center justify-between">
<div>
<h1 class="text-2xl font-700 tracking-tight">Matches</h1>
<h1 class="font-700 text-2xl tracking-tight">Matches</h1>
<p class="mt-1 text-sm text-text-secondary">
{data.matches.length} match{data.matches.length !== 1 ? 'es' : ''}
{#if liveMatches.length > 0}
@@ -141,16 +143,22 @@
<div class="flex gap-2">
{#if (data as any).userTeams?.length > 0}
<button
onclick={() => { showInvite = !showInvite; if (showInvite) showCreate = false; }}
class="rounded-md border border-accent/30 px-4 py-2 text-sm font-600 text-accent transition-colors hover:bg-accent/10"
onclick={() => {
showInvite = !showInvite;
if (showInvite) showCreate = false;
}}
class="font-600 rounded-md border border-accent/30 px-4 py-2 text-sm text-accent transition-colors hover:bg-accent/10"
>
{showInvite ? 'Cancel' : 'Challenge'}
</button>
{/if}
{#if data.canCreateMatch}
<button
onclick={() => { showCreate = !showCreate; if (showCreate) showInvite = false; }}
class="rounded-md bg-accent px-4 py-2 text-sm font-600 text-surface-900 transition-colors hover:bg-accent-hover"
onclick={() => {
showCreate = !showCreate;
if (showCreate) showInvite = false;
}}
class="font-600 rounded-md bg-accent px-4 py-2 text-sm text-surface-900 transition-colors hover:bg-accent-hover"
>
{showCreate ? 'Cancel' : 'New Match'}
</button>
@@ -178,7 +186,7 @@
}}
class="mt-4 rounded-lg border border-accent/20 bg-surface-800 p-5"
>
<h2 class="text-sm font-600">Create Match</h2>
<h2 class="font-600 text-sm">Create Match</h2>
{#if createError}
<p class="mt-2 text-sm text-red-400">{createError}</p>
@@ -186,7 +194,7 @@
<div class="mt-4 grid grid-cols-2 gap-4">
<div>
<label for="name" class="text-xs font-500 text-text-secondary">Match Name</label>
<label for="name" class="font-500 text-xs text-text-secondary">Match Name</label>
<input
type="text"
id="name"
@@ -197,7 +205,7 @@
</div>
<div>
<label for="bestOf" class="text-xs font-500 text-text-secondary">Best Of</label>
<label for="bestOf" class="font-500 text-xs text-text-secondary">Best Of</label>
<select
id="bestOf"
name="bestOf"
@@ -210,7 +218,7 @@
</div>
<div>
<label for="team1" class="text-xs font-500 text-text-secondary">Team 1</label>
<label for="team1" class="font-500 text-xs text-text-secondary">Team 1</label>
<select
id="team1"
name="team1"
@@ -225,7 +233,7 @@
</div>
<div>
<label for="team2" class="text-xs font-500 text-text-secondary">Team 2</label>
<label for="team2" class="font-500 text-xs text-text-secondary">Team 2</label>
<select
id="team2"
name="team2"
@@ -240,7 +248,7 @@
</div>
<div class="col-span-2">
<label for="mappool" class="text-xs font-500 text-text-secondary">Mappool</label>
<label for="mappool" class="font-500 text-xs text-text-secondary">Mappool</label>
<select
id="mappool"
name="mappool"
@@ -258,7 +266,7 @@
<div class="mt-4 flex justify-end">
<button
type="submit"
class="rounded-md bg-accent px-5 py-2 text-sm font-600 text-surface-900 transition-colors hover:bg-accent-hover"
class="font-600 rounded-md bg-accent px-5 py-2 text-sm text-surface-900 transition-colors hover:bg-accent-hover"
>
Create &amp; Start
</button>
@@ -275,12 +283,18 @@
inviteError = '';
inviteSuccess = false;
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)
) {
inviteError = (result.data as any)?.error ?? 'Failed to send invite';
} else if (result.type === 'success' && (result.data as any)?.inviteSuccess) {
inviteSuccess = true;
await invalidateAll();
setTimeout(() => { showInvite = false; inviteSuccess = false; }, 2000);
setTimeout(() => {
showInvite = false;
inviteSuccess = false;
}, 2000);
} else {
await update();
}
@@ -288,7 +302,7 @@
}}
class="mt-4 rounded-lg border border-accent/20 bg-surface-800 p-5"
>
<h2 class="text-sm font-600">Challenge</h2>
<h2 class="font-600 text-sm">Challenge</h2>
{#if inviteError}
<p class="mt-2 text-sm text-red-400">{inviteError}</p>
@@ -305,8 +319,12 @@
<!-- Your Team (only shown if you have non-personal teams) -->
{#if hasMultipleTeams}
<div>
<label for="inv-myTeam" class="text-xs font-500 text-text-secondary">Your Team</label>
<select id="inv-myTeam" bind:value={selectedCreatorTeamId} class="mt-1 w-full rounded-md border border-border bg-surface-700 px-3 py-2 text-sm text-text-primary focus:border-accent focus:outline-none">
<label for="inv-myTeam" class="font-500 text-xs text-text-secondary">Your Team</label>
<select
id="inv-myTeam"
bind:value={selectedCreatorTeamId}
class="mt-1 w-full rounded-md border border-border bg-surface-700 px-3 py-2 text-sm text-text-primary focus:border-accent focus:outline-none"
>
{#if personalTeam}
<option value={personalTeam.id}>{teamDisplayName(personalTeam)}</option>
{/if}
@@ -319,7 +337,9 @@
<!-- Opponent (searchable) -->
<div>
<label for="inv-opponent-search" class="text-xs font-500 text-text-secondary">Opponent</label>
<label for="inv-opponent-search" class="font-500 text-xs text-text-secondary"
>Opponent</label
>
<input
type="text"
id="inv-opponent-search"
@@ -329,17 +349,35 @@
autocomplete="off"
/>
{#if selectedInvitedTeam}
<div class="mt-1.5 flex items-center gap-2 rounded-md bg-accent/10 px-3 py-1.5 text-xs text-accent">
<div
class="mt-1.5 flex items-center gap-2 rounded-md bg-accent/10 px-3 py-1.5 text-xs text-accent"
>
<span class="font-600">{teamDisplayName(selectedInvitedTeam)}</span>
<span class="text-text-secondary">({selectedInvitedTeam.memberCount} player{selectedInvitedTeam.memberCount !== 1 ? 's' : ''})</span>
<button type="button" onclick={() => { selectedInvitedTeamId = ''; opponentSearch = ''; }} class="ml-auto text-text-secondary hover:text-red-400">&times;</button>
<span class="text-text-secondary"
>({selectedInvitedTeam.memberCount} player{selectedInvitedTeam.memberCount !== 1
? 's'
: ''})</span
>
<button
type="button"
onclick={() => {
selectedInvitedTeamId = '';
opponentSearch = '';
}}
class="ml-auto text-text-secondary hover:text-red-400">&times;</button
>
</div>
{:else if opponentSearch}
<div class="mt-1 max-h-40 overflow-y-auto rounded-md border border-border bg-surface-700">
<div
class="mt-1 max-h-40 overflow-y-auto rounded-md border border-border bg-surface-700"
>
{#each filteredOpponentTeams.slice(0, 20) as t}
<button
type="button"
onclick={() => { selectedInvitedTeamId = t.id; opponentSearch = ''; }}
onclick={() => {
selectedInvitedTeamId = t.id;
opponentSearch = '';
}}
class="flex w-full items-center gap-2 px-3 py-2 text-left text-sm transition-colors hover:bg-surface-600"
>
{#if t.avatarUrl}
@@ -359,14 +397,22 @@
<!-- Best Of -->
<div>
<span class="text-xs font-500 text-text-secondary">Best Of</span>
<span class="font-500 text-xs text-text-secondary">Best Of</span>
<div class="mt-1.5 flex flex-wrap gap-1.5">
{#each [3, 5, 7, 9, 11, 13] as n}
<button
type="button"
onclick={() => { bestOf = n; useCustomBo = false; customBestOf = ''; }}
class="rounded-md px-3 py-1.5 text-xs font-600 transition-colors {!useCustomBo && bestOf === n ? 'bg-accent text-surface-900' : 'border border-border bg-surface-700 text-text-secondary hover:border-accent/40 hover:text-text-primary'}"
>BO{n}</button>
onclick={() => {
bestOf = n;
useCustomBo = false;
customBestOf = '';
}}
class="font-600 rounded-md px-3 py-1.5 text-xs transition-colors {!useCustomBo &&
bestOf === n
? 'bg-accent text-surface-900'
: 'border border-border bg-surface-700 text-text-secondary hover:border-accent/40 hover:text-text-primary'}"
>BO{n}</button
>
{/each}
<input
type="number"
@@ -376,7 +422,9 @@
bind:value={customBestOf}
onfocus={() => (useCustomBo = true)}
oninput={() => (useCustomBo = true)}
class="w-20 rounded-md border {useCustomBo ? 'border-accent' : 'border-border'} bg-surface-700 px-2 py-1.5 text-xs text-text-primary placeholder:text-text-secondary/50 focus:border-accent focus:outline-none"
class="w-20 rounded-md border {useCustomBo
? 'border-accent'
: 'border-border'} bg-surface-700 px-2 py-1.5 text-xs text-text-primary placeholder:text-text-secondary/50 focus:border-accent focus:outline-none"
/>
</div>
</div>
@@ -385,8 +433,14 @@
<!-- Team sizes (only if creator is not personal team) -->
{#if !creatorIsPersonal && creatorMaxSize > 1}
<div>
<label for="inv-ts1" class="text-xs font-500 text-text-secondary">Your Team Size</label>
<select id="inv-ts1" name="teamSize1" class="mt-1 w-full rounded-md border border-border bg-surface-700 px-3 py-2 text-sm text-text-primary focus:border-accent focus:outline-none">
<label for="inv-ts1" class="font-500 text-xs text-text-secondary"
>Your Team Size</label
>
<select
id="inv-ts1"
name="teamSize1"
class="mt-1 w-full rounded-md border border-border bg-surface-700 px-3 py-2 text-sm text-text-primary focus:border-accent focus:outline-none"
>
{#each Array.from({ length: creatorMaxSize }, (_, i) => i + 1) as n}
<option value={n} selected={n === creatorMaxSize}>{n}v</option>
{/each}
@@ -398,8 +452,14 @@
{#if selectedInvitedTeam && !selectedInvitedTeam.isPersonal && invitedMaxSize > 1}
<div>
<label for="inv-ts2" class="text-xs font-500 text-text-secondary">Opponent Team Size</label>
<select id="inv-ts2" name="teamSize2" class="mt-1 w-full rounded-md border border-border bg-surface-700 px-3 py-2 text-sm text-text-primary focus:border-accent focus:outline-none">
<label for="inv-ts2" class="font-500 text-xs text-text-secondary"
>Opponent Team Size</label
>
<select
id="inv-ts2"
name="teamSize2"
class="mt-1 w-full rounded-md border border-border bg-surface-700 px-3 py-2 text-sm text-text-primary focus:border-accent focus:outline-none"
>
{#each Array.from({ length: invitedMaxSize }, (_, i) => i + 1) as n}
<option value={n} selected={n === invitedMaxSize}>v{n}</option>
{/each}
@@ -411,8 +471,12 @@
<!-- Scoring -->
<div>
<label for="inv-scoring" class="text-xs font-500 text-text-secondary">Scoring</label>
<select id="inv-scoring" name="scoringType" class="mt-1 w-full rounded-md border border-border bg-surface-700 px-3 py-2 text-sm text-text-primary focus:border-accent focus:outline-none">
<label for="inv-scoring" class="font-500 text-xs text-text-secondary">Scoring</label>
<select
id="inv-scoring"
name="scoringType"
class="mt-1 w-full rounded-md border border-border bg-surface-700 px-3 py-2 text-sm text-text-primary focus:border-accent focus:outline-none"
>
<option value="score_v2" selected>ScoreV2</option>
<option value="score">Score</option>
<option value="accuracy">Accuracy</option>
@@ -422,8 +486,13 @@
<!-- Mappool -->
<div>
<label for="inv-mappool" class="text-xs font-500 text-text-secondary">Mappool</label>
<select id="inv-mappool" name="mappool" required class="mt-1 w-full rounded-md border border-border bg-surface-700 px-3 py-2 text-sm text-text-primary focus:border-accent focus:outline-none">
<label for="inv-mappool" class="font-500 text-xs text-text-secondary">Mappool</label>
<select
id="inv-mappool"
name="mappool"
required
class="mt-1 w-full rounded-md border border-border bg-surface-700 px-3 py-2 text-sm text-text-primary focus:border-accent focus:outline-none"
>
<option value="">Select mappool...</option>
{#each data.mappools as p}
<option value={p.id}>{p.name} — {mappoolSummary(p)}</option>
@@ -435,7 +504,12 @@
<!-- ELO toggle (default on) -->
<div class="flex items-center gap-3">
<label class="flex items-center gap-2 text-xs text-text-secondary">
<input type="checkbox" name="allowEloChange" checked class="rounded border-border bg-surface-700 text-accent focus:ring-accent" />
<input
type="checkbox"
name="allowEloChange"
checked
class="rounded border-border bg-surface-700 text-accent focus:ring-accent"
/>
Affects ELO
</label>
</div>
@@ -443,18 +517,37 @@
<!-- Message + Schedule -->
<div class="grid grid-cols-2 gap-4">
<div>
<label for="inv-message" class="text-xs font-500 text-text-secondary">Message (optional)</label>
<input type="text" id="inv-message" name="message" placeholder="e.g. ggs only" class="mt-1 w-full 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" />
<label for="inv-message" class="font-500 text-xs text-text-secondary"
>Message (optional)</label
>
<input
type="text"
id="inv-message"
name="message"
placeholder="e.g. ggs only"
class="mt-1 w-full 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"
/>
</div>
<div>
<label for="inv-schedule" class="text-xs font-500 text-text-secondary">Schedule (optional)</label>
<input type="datetime-local" id="inv-schedule" name="scheduledAt" class="mt-1 w-full rounded-md border border-border bg-surface-700 px-3 py-2 text-sm text-text-primary focus:border-accent focus:outline-none" />
<label for="inv-schedule" class="font-500 text-xs text-text-secondary"
>Schedule (optional)</label
>
<input
type="datetime-local"
id="inv-schedule"
name="scheduledAt"
class="mt-1 w-full rounded-md border border-border bg-surface-700 px-3 py-2 text-sm text-text-primary focus:border-accent focus:outline-none"
/>
</div>
</div>
</div>
<div class="mt-4 flex justify-end">
<button type="submit" disabled={!selectedInvitedTeamId} class="rounded-md bg-accent px-5 py-2 text-sm font-600 text-surface-900 transition-colors hover:bg-accent-hover disabled:opacity-40">
<button
type="submit"
disabled={!selectedInvitedTeamId}
class="font-600 rounded-md bg-accent px-5 py-2 text-sm text-surface-900 transition-colors hover:bg-accent-hover disabled:opacity-40"
>
Send Challenge
</button>
</div>
@@ -464,7 +557,7 @@
<!-- Live Matches -->
{#if liveMatches.length > 0}
<div class="mt-6">
<h2 class="flex items-center gap-2 text-sm font-600">
<h2 class="font-600 flex items-center gap-2 text-sm">
<div class="relative h-2 w-2">
<div class="absolute inset-0 animate-ping rounded-full bg-green-400 opacity-75"></div>
<div class="relative h-2 w-2 rounded-full bg-green-400"></div>
@@ -483,24 +576,24 @@
{#if p1?.team.avatarUrl}
<img src={p1.team.avatarUrl} alt="" class="h-8 w-8 rounded-full" />
{/if}
<span class="text-sm font-600">{p1?.team.name ?? '?'}</span>
<span class="font-600 text-sm">{p1?.team.name ?? '?'}</span>
</div>
<div class="flex items-center gap-3">
<span class="text-xl font-800 tabular-nums">{p1?.score ?? 0}</span>
<span class="text-xs font-600 text-text-secondary">vs</span>
<span class="text-xl font-800 tabular-nums">{p2?.score ?? 0}</span>
<span class="font-800 text-xl tabular-nums">{p1?.score ?? 0}</span>
<span class="font-600 text-xs text-text-secondary">vs</span>
<span class="font-800 text-xl tabular-nums">{p2?.score ?? 0}</span>
</div>
<div class="flex flex-1 items-center justify-end gap-3">
<span class="text-sm font-600">{p2?.team.name ?? '?'}</span>
<span class="font-600 text-sm">{p2?.team.name ?? '?'}</span>
{#if p2?.team.avatarUrl}
<img src={p2.team.avatarUrl} alt="" class="h-8 w-8 rounded-full" />
{/if}
</div>
<span
class="flex items-center gap-1.5 rounded border px-2 py-0.5 text-xs font-500 {sc?.color ??
class="font-500 flex items-center gap-1.5 rounded border px-2 py-0.5 text-xs {sc?.color ??
'border-border'}"
>
{#if sc?.dot}
@@ -517,7 +610,7 @@
<!-- All Matches -->
<div class="mt-6">
{#if liveMatches.length > 0}
<h2 class="text-sm font-600 text-text-secondary">Past Matches</h2>
<h2 class="font-600 text-sm text-text-secondary">Past Matches</h2>
{/if}
<div class="mt-3 flex flex-col gap-2">
{#each recentMatches as m}
@@ -531,40 +624,45 @@
<!-- Match info -->
<div class="min-w-0 flex-1">
<div class="flex items-center gap-2">
<span class="text-sm font-600">{p1?.team.name ?? '?'}</span>
<span class="font-600 text-sm">{p1?.team.name ?? '?'}</span>
{#if m.state === 'FINISHED'}
<span
class="text-xs font-700 tabular-nums {(p1?.score ?? 0) > (p2?.score ?? 0)
class="font-700 text-xs 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)
class="font-700 text-xs 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}
<span class="text-sm font-600">{p2?.team.name ?? '?'}</span>
<span class="font-600 text-sm">{p2?.team.name ?? '?'}</span>
</div>
<p class="mt-0.5 text-xs text-text-secondary">{m.name ? `${m.name} · ` : ''}BO{config.bestOf}{#if m.finishedAt} &middot; {timeAgo(m.finishedAt)}{:else if m.createdAt} &middot; {timeAgo(m.createdAt)}{/if}</p>
<p class="mt-0.5 text-xs text-text-secondary">
{m.name ? `${m.name} · ` : ''}BO{config.bestOf}{#if m.finishedAt}
&middot; {timeAgo(m.finishedAt)}{:else if m.createdAt}
&middot; {timeAgo(m.createdAt)}{/if}
</p>
{#if m.mappool}
<p class="text-xs text-text-secondary">{m.mappool.name}{#if avgSR(m.mappool)} ({avgSR(m.mappool)}★){/if}</p>
<p class="text-xs text-text-secondary">
{m.mappool.name}{#if avgSR(m.mappool)}
({avgSR(m.mappool)}★){/if}
</p>
{/if}
</div>
<!-- Winner indicator -->
{#if m.state === 'FINISHED' && m.winnerId}
{@const winner = m.participants.find((p: any) => p.teamId === m.winnerId)}
<span class="text-xs font-500 text-green-400">🏆 {winner?.team.name}</span>
<span class="font-500 text-xs text-green-400">🏆 {winner?.team.name}</span>
{/if}
<!-- State -->
<span
class="rounded border px-2 py-0.5 text-xs font-500 {sc?.color ?? 'border-border'}"
>
<span class="font-500 rounded border px-2 py-0.5 text-xs {sc?.color ?? 'border-border'}">
{sc?.label ?? m.state}
</span>
</a>

View File

@@ -13,7 +13,6 @@ import { eq } from 'drizzle-orm';
import type { MatchConfig } from '$lib/server/match/types';
import { playerRating } from '$lib/server/db/schema';
export const load: PageServerLoad = async ({ params, locals }) => {
if (!locals.user) redirect(302, '/');
@@ -45,7 +44,9 @@ export const load: PageServerLoad = async ({ params, locals }) => {
if (slot.title !== null) {
// Sanitize any stale ppy.sh fallback URLs (image was unavailable when first fetched)
const coverUrl = slot.coverUrl?.includes('ppy.sh') ? null : (slot.coverUrl ?? null);
const listCoverUrl = slot.listCoverUrl?.includes('ppy.sh') ? null : (slot.listCoverUrl ?? null);
const listCoverUrl = slot.listCoverUrl?.includes('ppy.sh')
? null
: (slot.listCoverUrl ?? null);
beatmapCache[slot.beatmapId] = {
title: slot.title,
artist: slot.artist ?? '',
@@ -90,7 +91,9 @@ export const load: PageServerLoad = async ({ params, locals }) => {
totalLength: bm.total_length
})
.where(eq(mappoolSlot.id, slot.id));
} catch { /* skip */ }
} catch {
/* skip */
}
})
);
}
@@ -120,7 +123,15 @@ export const load: PageServerLoad = async ({ params, locals }) => {
}
}
return { match: m, beatmapCache, userId: locals.user.id, isStaff, lobbyStatus, playerNames, playerRatings };
return {
match: m,
beatmapCache,
userId: locals.user.id,
isStaff,
lobbyStatus,
playerNames,
playerRatings
};
};
export const actions: Actions = {
@@ -171,7 +182,9 @@ export const actions: Actions = {
// Announce in IRC lobby
const lobby = getLobby(params.id);
if (lobby) {
lobby.chat(`${myParticipant.team?.name ?? 'Player'} rolled ${value} (via web)`).catch(() => {});
lobby
.chat(`${myParticipant.team?.name ?? 'Player'} rolled ${value} (via web)`)
.catch(() => {});
// Check if all rolled → announce pick order or tie
const updated = await getMatchFull(params.id);
@@ -184,13 +197,17 @@ export const actions: Actions = {
const isTBRoll = updated.participants.every((p: any) => p.score === rollWinsNeeded - 1);
if (isTBRoll) {
lobby.chat(
`Rolls complete! ${sorted[0]?.team.name} picks the tiebreaker. Use !pick TB<n> or pick in web UI.`
).catch(() => {});
lobby
.chat(
`Rolls complete! ${sorted[0]?.team.name} picks the tiebreaker. Use !pick TB<n> or pick in web UI.`
)
.catch(() => {});
} else {
lobby.chat(
`Rolls complete! ${sorted[0]?.team.name} picks first. Use !pick <slot> (e.g. !pick NM1) or pick in web UI.`
).catch(() => {});
lobby
.chat(
`Rolls complete! ${sorted[0]?.team.name} picks first. Use !pick <slot> (e.g. !pick NM1) or pick in web UI.`
)
.catch(() => {});
}
} else if (isLastRoll && updated.participants.every((p: any) => p.rollValue === null)) {
lobby.chat(`Tie! All players rolled ${value}. Please !roll again.`).catch(() => {});
@@ -210,9 +227,7 @@ export const actions: Actions = {
const m = await getMatchFull(params.id);
// Find the expected picker based on pick order and game count
const sorted = [...m.participants].sort(
(a, b) => (a.pickOrder ?? 99) - (b.pickOrder ?? 99)
);
const sorted = [...m.participants].sort((a, b) => (a.pickOrder ?? 99) - (b.pickOrder ?? 99));
const expectedIdx = m.games.length % sorted.length;
const expectedPicker = sorted[expectedIdx];
@@ -220,9 +235,7 @@ export const actions: Actions = {
// ── Verify the logged-in user is on the expected picker's team ──
// No staff bypass here — even admins can't pick for another team
const isOnPickerTeam = expectedPicker.players.some(
(pl) => pl.userId === locals.user!.id
);
const isOnPickerTeam = expectedPicker.players.some((pl) => pl.userId === locals.user!.id);
if (!isOnPickerTeam) {
return { error: "It's not your turn to pick" };
}

View File

@@ -38,9 +38,7 @@
);
// Should the mappool be visible?
const showMappool = $derived(
['PICKING', 'PLAYING', 'ROLLING', 'FINISHED'].includes(m.state)
);
const showMappool = $derived(['PICKING', 'PLAYING', 'ROLLING', 'FINISHED'].includes(m.state));
// Can picks actually be made right now?
const pickingPhase = $derived(m.state === 'PICKING');
@@ -77,7 +75,9 @@
let now = $state(Date.now());
$effect(() => {
if (m.startedAt && !['FINISHED', 'CANCELLED'].includes(m.state)) {
const interval = setInterval(() => { now = Date.now(); }, 1000);
const interval = setInterval(() => {
now = Date.now();
}, 1000);
return () => clearInterval(interval);
}
});
@@ -99,7 +99,8 @@
const mappoolAvgSR = $derived(() => {
const slots = m.mappool?.slots;
if (!slots?.length) return null;
const avg = slots.reduce((sum: number, s: unknown) => sum + (s.starRating ?? 0), 0) / slots.length;
const avg =
slots.reduce((sum: number, s: unknown) => sum + (s.starRating ?? 0), 0) / slots.length;
return avg.toFixed(2);
});
@@ -129,16 +130,44 @@
}
function stateLabel(s: string) {
return ({ CREATED: 'Waiting', LOBBY: 'In Lobby', ROLLING: 'Rolling', PICKING: 'Pick Phase', PLAYING: 'Playing', FINISHED: 'Finished', CANCELLED: 'Cancelled' })[s] ?? s;
return (
{
CREATED: 'Waiting',
LOBBY: 'In Lobby',
ROLLING: 'Rolling',
PICKING: 'Pick Phase',
PLAYING: 'Playing',
FINISHED: 'Finished',
CANCELLED: 'Cancelled'
}[s] ?? s
);
}
function stateBorder(s: string) {
return ({ ROLLING: 'border-yellow-500/30', PICKING: 'border-blue-500/30', PLAYING: 'border-green-500/30', FINISHED: 'border-accent/30' })[s] ?? 'border-border';
return (
{
ROLLING: 'border-yellow-500/30',
PICKING: 'border-blue-500/30',
PLAYING: 'border-green-500/30',
FINISHED: 'border-accent/30'
}[s] ?? 'border-border'
);
}
const stateLabels: Record<string, string> = { LOBBY: 'In Lobby', ROLLING: 'Rolling', PICKING: 'Picking', PLAYING: 'Live Now', FINISHED: 'Finished', CANCELLED: 'Cancelled' };
const ogTitle = $derived(`${p1?.team?.name ?? 'TBD'} vs ${p2?.team?.name ?? 'TBD'} — ${m.name} | Vash Esports`);
const ogDesc = $derived(`${stateLabels[m.state] ?? m.state} · Best of ${(m.config as any)?.bestOf ?? '?'} · Watch on Vash Esports`);
const stateLabels: Record<string, string> = {
LOBBY: 'In Lobby',
ROLLING: 'Rolling',
PICKING: 'Picking',
PLAYING: 'Live Now',
FINISHED: 'Finished',
CANCELLED: 'Cancelled'
};
const ogTitle = $derived(
`${p1?.team?.name ?? 'TBD'} vs ${p2?.team?.name ?? 'TBD'} — ${m.name} | Vash Esports`
);
const ogDesc = $derived(
`${stateLabels[m.state] ?? m.state} · Best of ${(m.config as any)?.bestOf ?? '?'} · Watch on Vash Esports`
);
</script>
<svelte:head>
@@ -155,10 +184,17 @@
<!-- Shared reinvite snippet -->
{#snippet reinviteButton()}
<form method="post" action="?/reinvite" use:enhance={() => {
reinviting = true;
return async ({ update }) => { reinviting = false; await update(); };
}}>
<form
method="post"
action="?/reinvite"
use:enhance={() => {
reinviting = true;
return async ({ update }) => {
reinviting = false;
await update();
};
}}
>
<button
type="submit"
disabled={reinviting}
@@ -172,24 +208,39 @@
<div class="mx-auto max-w-5xl">
<!-- Header -->
<div class="flex items-center gap-3">
<a href="/matches" class="text-text-secondary transition-colors hover:text-text-primary">&larr;</a>
<a href="/matches" class="text-text-secondary transition-colors hover:text-text-primary"
>&larr;</a
>
<div class="flex-1">
<h1 class="text-xl font-700 tracking-tight">{m.name || 'Match'}</h1>
<h1 class="font-700 text-xl tracking-tight">{m.name || 'Match'}</h1>
<p class="mt-0.5 text-xs text-text-secondary">
Best of {config.bestOf} &middot; First to {winsNeeded}
{#if duration}
&middot; {duration}
{/if}
{#if m.osuLobbyId}
&middot; <a href="https://osu.ppy.sh/mp/{m.osuLobbyId}" target="_blank" class="text-accent hover:underline">osu! mp/{m.osuLobbyId}</a>
&middot; <a
href="https://osu.ppy.sh/mp/{m.osuLobbyId}"
target="_blank"
class="text-accent hover:underline">osu! mp/{m.osuLobbyId}</a
>
{/if}
</p>
</div>
<span class="rounded border px-2.5 py-1 text-xs font-600 {stateBorder(m.state)}">{stateLabel(m.state)}</span>
<span class="font-600 rounded border px-2.5 py-1 text-xs {stateBorder(m.state)}"
>{stateLabel(m.state)}</span
>
{#if !['FINISHED', 'CANCELLED'].includes(m.state) && data.isStaff}
<form method="post" action="?/cancel" use:enhance>
<button type="submit" onclick={(e) => { if (!confirm('Cancel this match?')) e.preventDefault() }} class="rounded-md border border-red-500/30 px-3 py-1.5 text-xs text-red-400 hover:bg-red-500/10">Cancel</button>
<button
type="submit"
onclick={(e) => {
if (!confirm('Cancel this match?')) e.preventDefault();
}}
class="rounded-md border border-red-500/30 px-3 py-1.5 text-xs text-red-400 hover:bg-red-500/10"
>Cancel</button
>
</form>
{/if}
</div>
@@ -197,12 +248,16 @@
<!-- Scoreboard -->
{#if p1 && p2}
<div class="mt-6 flex items-center gap-4">
<div class="flex flex-1 items-center gap-4 rounded-lg border p-4 {m.winnerId === p1.teamId ? 'border-green-500/40 bg-green-500/5' : 'border-border bg-surface-800'}">
<div
class="flex flex-1 items-center gap-4 rounded-lg border p-4 {m.winnerId === p1.teamId
? 'border-green-500/40 bg-green-500/5'
: 'border-border bg-surface-800'}"
>
{#if p1.team.avatarUrl}
<img src={p1.team.avatarUrl} alt="" class="h-10 w-10 rounded-full" />
{/if}
<div class="flex-1">
<p class="text-sm font-600">{p1.team.name}</p>
<p class="font-600 text-sm">{p1.team.name}</p>
{#if config.teamSize === 1}
{@const r1 = data.playerRatings?.[p1.players[0]?.userId]}
{#if r1}
@@ -210,18 +265,32 @@
{/if}
{/if}
{#if p1.rollValue != null}
<p class="text-xs text-text-secondary">Roll: {p1.rollValue}{p1.pickOrder === 1 ? ' ★' : ''}</p>
<p class="text-xs text-text-secondary">
Roll: {p1.rollValue}{p1.pickOrder === 1 ? ' ★' : ''}
</p>
{/if}
</div>
<span class="text-3xl font-800 tabular-nums {p1.score >= winsNeeded ? 'text-green-400' : 'text-text-primary'}">{p1.score}</span>
<span
class="font-800 text-3xl tabular-nums {p1.score >= winsNeeded
? 'text-green-400'
: 'text-text-primary'}">{p1.score}</span
>
</div>
<span class="text-lg font-700 text-text-secondary">vs</span>
<span class="font-700 text-lg text-text-secondary">vs</span>
<div class="flex flex-1 items-center gap-4 rounded-lg border p-4 {m.winnerId === p2.teamId ? 'border-green-500/40 bg-green-500/5' : 'border-border bg-surface-800'}">
<span class="text-3xl font-800 tabular-nums {p2.score >= winsNeeded ? 'text-green-400' : 'text-text-primary'}">{p2.score}</span>
<div
class="flex flex-1 items-center gap-4 rounded-lg border p-4 {m.winnerId === p2.teamId
? 'border-green-500/40 bg-green-500/5'
: 'border-border bg-surface-800'}"
>
<span
class="font-800 text-3xl tabular-nums {p2.score >= winsNeeded
? 'text-green-400'
: 'text-text-primary'}">{p2.score}</span
>
<div class="flex-1 text-right">
<p class="text-sm font-600">{p2.team.name}</p>
<p class="font-600 text-sm">{p2.team.name}</p>
{#if config.teamSize === 1}
{@const r2 = data.playerRatings?.[p2.players[0]?.userId]}
{#if r2}
@@ -229,7 +298,9 @@
{/if}
{/if}
{#if p2.rollValue != null}
<p class="text-xs text-text-secondary">{p2.pickOrder === 1 ? '★ ' : ''}Roll: {p2.rollValue}</p>
<p class="text-xs text-text-secondary">
{p2.pickOrder === 1 ? '★ ' : ''}Roll: {p2.rollValue}
</p>
{/if}
</div>
{#if p2.team.avatarUrl}
@@ -244,7 +315,11 @@
<div class="mt-3 flex items-center justify-center gap-3">
{@render reinviteButton()}
{#if m.osuLobbyId}
<a href="https://osu.ppy.sh/mp/{m.osuLobbyId}" target="_blank" class="text-xs text-text-secondary hover:text-accent">Open lobby →</a>
<a
href="https://osu.ppy.sh/mp/{m.osuLobbyId}"
target="_blank"
class="text-xs text-text-secondary hover:text-accent">Open lobby →</a
>
{/if}
</div>
{/if}
@@ -252,41 +327,66 @@
<!-- ROLLING MODAL (stays as overlay — it's a brief phase) -->
{#if m.state === 'ROLLING'}
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/70 backdrop-blur-sm">
<div class="w-full max-w-md rounded-xl border border-yellow-500/30 bg-surface-800 p-8 text-center shadow-2xl">
<h2 class="text-lg font-700">{isTiebreakerAllowed ? '🔥 Tiebreaker Roll' : '🎲 Roll Phase'}</h2>
<p class="mt-2 text-sm text-text-secondary">{isTiebreakerAllowed ? 'Highest roll picks the tiebreaker' : 'Highest roll picks first'}</p>
<div
class="w-full max-w-md rounded-xl border border-yellow-500/30 bg-surface-800 p-8 text-center shadow-2xl"
>
<h2 class="font-700 text-lg">
{isTiebreakerAllowed ? '🔥 Tiebreaker Roll' : '🎲 Roll Phase'}
</h2>
<p class="mt-2 text-sm text-text-secondary">
{isTiebreakerAllowed ? 'Highest roll picks the tiebreaker' : 'Highest roll picks first'}
</p>
<div class="mt-6 flex justify-center gap-6">
{#each m.participants as p}
<div class="rounded-lg border border-border bg-surface-700 px-6 py-4">
<p class="text-xs text-text-secondary">{p.team.name}</p>
{#if p.rollValue != null}
<p class="mt-1 text-2xl font-800 text-yellow-400">{p.rollValue}</p>
<p class="font-800 mt-1 text-2xl text-yellow-400">{p.rollValue}</p>
{:else}
<p class="mt-1 text-2xl font-800 text-text-secondary/30">—</p>
<p class="font-800 mt-1 text-2xl text-text-secondary/30">—</p>
{/if}
</div>
{/each}
</div>
{#if canRoll}
<form method="post" action="?/roll" use:enhance={() => {
rolling = true;
return async ({ update }) => { rolling = false; await update(); };
}}>
<button type="submit" disabled={rolling} class="mt-6 rounded-md bg-accent px-8 py-3 text-sm font-600 text-surface-900 transition-colors hover:bg-accent-hover disabled:opacity-50">
<form
method="post"
action="?/roll"
use:enhance={() => {
rolling = true;
return async ({ update }) => {
rolling = false;
await update();
};
}}
>
<button
type="submit"
disabled={rolling}
class="font-600 mt-6 rounded-md bg-accent px-8 py-3 text-sm text-surface-900 transition-colors hover:bg-accent-hover disabled:opacity-50"
>
{rolling ? 'Rolling...' : '🎲 Roll!'}
</button>
</form>
<p class="mt-3 text-xs text-text-secondary">or type <code class="rounded bg-surface-700 px-1.5 py-0.5 font-mono text-accent">!roll</code> in osu! chat</p>
<p class="mt-3 text-xs text-text-secondary">
or type <code class="rounded bg-surface-700 px-1.5 py-0.5 font-mono text-accent"
>!roll</code
> in osu! chat
</p>
{:else}
<p class="mt-6 text-sm text-text-secondary animate-pulse">Waiting for all rolls...</p>
<p class="mt-6 animate-pulse text-sm text-text-secondary">Waiting for all rolls...</p>
{/if}
<div class="mt-6 flex flex-col items-center gap-2 border-t border-border pt-4">
{@render reinviteButton()}
{#if m.osuLobbyId}
<a href="https://osu.ppy.sh/mp/{m.osuLobbyId}" target="_blank" class="text-xs text-text-secondary hover:text-accent">Open lobby in browser →</a>
<a
href="https://osu.ppy.sh/mp/{m.osuLobbyId}"
target="_blank"
class="text-xs text-text-secondary hover:text-accent">Open lobby in browser →</a
>
{/if}
</div>
</div>
@@ -303,13 +403,17 @@
<div class="absolute inset-0 animate-ping rounded-full bg-green-400 opacity-75"></div>
<div class="relative h-3 w-3 rounded-full bg-green-400"></div>
</div>
<span class="text-sm font-600 text-green-400">
<span class="font-600 text-sm text-green-400">
{lobbyStatus?.gameInProgress ? 'Playing' : 'Now Playing — Waiting for ready'}
</span>
</div>
{#if bm}
<p class="mt-2 text-center text-sm">{bm.artist} - {bm.title} <span class="text-text-secondary">[{bm.version}]</span></p>
<p class="mt-1 text-center text-xs text-text-secondary">{bm.starRating.toFixed(2)}★ &middot; {bm.bpm}bpm &middot; {formatLength(bm.totalLength)}</p>
<p class="mt-2 text-center text-sm">
{bm.artist} - {bm.title} <span class="text-text-secondary">[{bm.version}]</span>
</p>
<p class="mt-1 text-center text-xs text-text-secondary">
{bm.starRating.toFixed(2)}★ &middot; {bm.bpm}bpm &middot; {formatLength(bm.totalLength)}
</p>
{/if}
<!-- Per-player lobby status -->
@@ -320,17 +424,31 @@
{@const username = playerNames[player.userId]}
{@const inLobby = username && lobbyStatus.inLobby.includes(username)}
{@const isReady = username && lobbyStatus.readyPlayers.includes(username)}
<div class="flex items-center gap-2 rounded border border-border bg-surface-800/50 px-3 py-1.5 text-xs">
<div
class="flex items-center gap-2 rounded border border-border bg-surface-800/50 px-3 py-1.5 text-xs"
>
<span class="font-500">{username ?? player.userId}</span>
<span class="ml-auto flex items-center gap-1.5">
{#if lobbyStatus.gameInProgress}
<span class="rounded bg-green-500/20 px-1.5 py-0.5 text-[10px] font-600 text-green-400">Playing</span>
<span
class="font-600 rounded bg-green-500/20 px-1.5 py-0.5 text-[10px] text-green-400"
>Playing</span
>
{:else if isReady}
<span class="rounded bg-green-500/20 px-1.5 py-0.5 text-[10px] font-600 text-green-400">Ready</span>
<span
class="font-600 rounded bg-green-500/20 px-1.5 py-0.5 text-[10px] text-green-400"
>Ready</span
>
{:else if inLobby}
<span class="rounded bg-yellow-500/20 px-1.5 py-0.5 text-[10px] font-600 text-yellow-400">In Lobby</span>
<span
class="font-600 rounded bg-yellow-500/20 px-1.5 py-0.5 text-[10px] text-yellow-400"
>In Lobby</span
>
{:else}
<span class="rounded bg-surface-700 px-1.5 py-0.5 text-[10px] font-600 text-text-secondary">Not in lobby</span>
<span
class="font-600 rounded bg-surface-700 px-1.5 py-0.5 text-[10px] text-text-secondary"
>Not in lobby</span
>
{/if}
</span>
</div>
@@ -338,21 +456,28 @@
{/each}
</div>
{:else}
<p class="mt-3 text-center text-xs text-text-secondary animate-pulse">
<p class="mt-3 animate-pulse text-center text-xs text-text-secondary">
Waiting for players to ready up &amp; play in osu!...
</p>
{/if}
{#if data.isStaff}
<div class="mt-3 text-center">
<form method="post" action="?/forceStart" use:enhance={() => {
forceStarting = true;
return async ({ update }) => { forceStarting = false; await update(); };
}}>
<form
method="post"
action="?/forceStart"
use:enhance={() => {
forceStarting = true;
return async ({ update }) => {
forceStarting = false;
await update();
};
}}
>
<button
type="submit"
disabled={forceStarting}
class="rounded-md border border-yellow-500/30 px-4 py-1.5 text-xs font-600 text-yellow-400 transition-colors hover:bg-yellow-500/10 disabled:opacity-50"
class="font-600 rounded-md border border-yellow-500/30 px-4 py-1.5 text-xs text-yellow-400 transition-colors hover:bg-yellow-500/10 disabled:opacity-50"
>
{forceStarting ? 'Starting...' : 'Force Start (skip ready)'}
</button>
@@ -367,9 +492,11 @@
<div class="mt-6">
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<h2 class="text-sm font-600">
<h2 class="font-600 text-sm">
{#if m.mappool}
<a href="/mappools/{m.mappool.id}" class="hover:text-accent hover:underline">{m.mappool.name}</a>
<a href="/mappools/{m.mappool.id}" class="hover:text-accent hover:underline"
>{m.mappool.name}</a
>
{:else}
Mappool
{/if}
@@ -378,17 +505,21 @@
<span class="text-xs text-text-secondary">avg {mappoolAvgSR()}★</span>
{/if}
{#if pickingPhase}
<span class="rounded bg-blue-500/20 px-2 py-0.5 text-[10px] font-600 text-blue-400">
<span class="font-600 rounded bg-blue-500/20 px-2 py-0.5 text-[10px] text-blue-400">
{isMyTurnToPick ? 'Your pick!' : `${expectedPicker?.team.name}'s pick`}
</span>
{:else if m.state === 'PLAYING'}
<span class="rounded bg-green-500/20 px-2 py-0.5 text-[10px] font-600 text-green-400">In progress</span>
<span class="font-600 rounded bg-green-500/20 px-2 py-0.5 text-[10px] text-green-400"
>In progress</span
>
{:else if m.state === 'ROLLING'}
<span class="rounded bg-yellow-500/20 px-2 py-0.5 text-[10px] font-600 text-yellow-400">Rolling...</span>
<span class="font-600 rounded bg-yellow-500/20 px-2 py-0.5 text-[10px] text-yellow-400"
>Rolling...</span
>
{/if}
</div>
<button
onclick={() => mappoolCollapsed = !mappoolCollapsed}
onclick={() => (mappoolCollapsed = !mappoolCollapsed)}
class="text-xs text-text-secondary hover:text-text-primary"
>
{mappoolCollapsed ? 'Show ▼' : 'Hide ▲'}
@@ -397,12 +528,18 @@
{#if pickingPhase && !isMyTurnToPick}
<p class="mt-1 text-xs text-text-secondary">
Waiting for <span class="text-accent">{expectedPicker?.team.name}</span> to pick...
You can also type <code class="rounded bg-surface-700 px-1 py-0.5 font-mono text-[10px] text-accent">!pick NM1</code> in osu! chat when it's your turn.
Waiting for <span class="text-accent">{expectedPicker?.team.name}</span> to pick... You
can also type
<code class="rounded bg-surface-700 px-1 py-0.5 font-mono text-[10px] text-accent"
>!pick NM1</code
> in osu! chat when it's your turn.
</p>
{:else if pickingPhase && isMyTurnToPick}
<p class="mt-1 text-xs text-text-secondary">
Pick a map below, or type <code class="rounded bg-surface-700 px-1 py-0.5 font-mono text-[10px] text-accent">!pick NM1</code> in osu! chat.
Pick a map below, or type <code
class="rounded bg-surface-700 px-1 py-0.5 font-mono text-[10px] text-accent"
>!pick NM1</code
> in osu! chat.
</p>
{/if}
@@ -415,7 +552,11 @@
{#each Object.entries(groupedSlots()) as [category, slots] (category)}
<!-- Grouped slots -->
<div>
<span class="mb-1.5 inline-block rounded border px-2 py-0.5 text-xs font-600 {catColors[category] ?? 'border-border'}">
<span
class="font-600 mb-1.5 inline-block rounded border px-2 py-0.5 text-xs {catColors[
category
] ?? 'border-border'}"
>
{category}
</span>
<div class="grid grid-cols-1 gap-1.5 sm:grid-cols-2">
@@ -429,55 +570,82 @@
{@const slotLocked = tbLocked || matchPointLocked}
{@const cantPick = !isMyTurnToPick || !pickingPhase}
<div class="flex items-center gap-2 rounded-lg border p-2 transition-all {isPlayed
? 'border-border/50 bg-surface-900 opacity-30'
: slotLocked
<div
class="flex items-center gap-2 rounded-lg border p-2 transition-all {isPlayed
? 'border-border/50 bg-surface-900 opacity-30'
: 'border-border bg-surface-800'}">
: slotLocked
? 'border-border/50 bg-surface-900 opacity-30'
: 'border-border bg-surface-800'}"
>
<a
href="https://osu.ppy.sh/beatmaps/{slot.beatmapId}"
target="_blank"
class="flex min-w-0 flex-1 items-center gap-3"
>
{#if bm?.listCoverUrl}
<img src={bm.listCoverUrl} alt="" class="h-10 w-20 shrink-0 rounded object-cover" />
<img
src={bm.listCoverUrl}
alt=""
class="h-10 w-20 shrink-0 rounded object-cover"
/>
{:else}
<div class="flex h-10 w-20 shrink-0 items-center justify-center rounded bg-surface-600 text-xs text-text-secondary">?</div>
<div
class="flex h-10 w-20 shrink-0 items-center justify-center rounded bg-surface-600 text-xs text-text-secondary"
>
?
</div>
{/if}
<div class="min-w-0 flex-1">
{#if bm}
<p class="truncate text-sm font-500">{bm.artist} - {bm.title}</p>
<p class="text-xs text-text-secondary">[{bm.version}] &middot; {bm.starRating.toFixed(2)}★ &middot; {bm.bpm}bpm</p>
<p class="font-500 truncate text-sm">{bm.artist} - {bm.title}</p>
<p class="text-xs text-text-secondary">
[{bm.version}] &middot; {bm.starRating.toFixed(2)}★ &middot; {bm.bpm}bpm
</p>
{:else}
<p class="text-xs text-text-secondary">#{slot.beatmapId}</p>
{/if}
</div>
</a>
<span class="text-xs font-600 text-text-secondary">{category}{slot.orderInCategory}</span>
<span class="font-600 text-xs text-text-secondary"
>{category}{slot.orderInCategory}</span
>
{#if isPlayed}
<span class="text-xs text-text-secondary">✓</span>
{:else if slotLocked}
<span class="text-[10px] text-pink-400/60" title={tbLocked ? 'Match point only' : 'Tiebreaker only'}>🔒</span>
<span
class="text-[10px] text-pink-400/60"
title={tbLocked ? 'Match point only' : 'Tiebreaker only'}>🔒</span
>
{:else if pickingPhase}
<form method="post" action="?/pick" use:enhance={() => {
picking = true;
pickError = '';
return async ({ result, update }) => {
picking = false;
if (result.type === 'failure' || (result.type === 'success' && (result.data as Record<string, unknown>)?.error)) {
pickError = (result.data as Record<string, unknown>)?.error as string ?? 'Pick failed';
} else {
await update();
}
};
}}>
<form
method="post"
action="?/pick"
use:enhance={() => {
picking = true;
pickError = '';
return async ({ result, update }) => {
picking = false;
if (
result.type === 'failure' ||
(result.type === 'success' &&
(result.data as Record<string, unknown>)?.error)
) {
pickError =
((result.data as Record<string, unknown>)?.error as string) ??
'Pick failed';
} else {
await update();
}
};
}}
>
<input type="hidden" name="slotId" value={slot.id} />
<button
type="submit"
disabled={picking || cantPick}
class="rounded px-2 py-1 text-[10px] font-700 transition-colors {cantPick
? 'bg-surface-600 text-text-secondary/40 cursor-default'
: 'bg-white text-black hover:bg-white/80 cursor-pointer'}"
class="font-700 rounded px-2 py-1 text-[10px] transition-colors {cantPick
? 'cursor-default bg-surface-600 text-text-secondary/40'
: 'cursor-pointer bg-white text-black hover:bg-white/80'}"
>
PICK
</button>
@@ -498,10 +666,10 @@
{@const winner = m.participants.find((p: any) => p.teamId === m.winnerId)}
<div class="mt-6 rounded-xl border border-accent/30 bg-surface-800 p-6 text-center">
<div class="text-3xl">🏆</div>
<h2 class="mt-3 text-xl font-800">
<h2 class="font-800 mt-3 text-xl">
<span class="text-accent">{winner?.team.name}</span> wins!
</h2>
<p class="mt-1 text-base font-700 tabular-nums text-text-secondary">
<p class="font-700 mt-1 text-base text-text-secondary tabular-nums">
{p1?.score} – {p2?.score}
</p>
</div>
@@ -510,15 +678,21 @@
<!-- GAME HISTORY -->
{#if m.games.length > 0}
<div class="mt-6">
<h2 class="text-sm font-600">Games Played</h2>
<h2 class="font-600 text-sm">Games Played</h2>
<div class="mt-3 flex flex-col gap-2">
{#each m.games as game}
{@const bm = data.beatmapCache[game.slot?.beatmapId]}
{@const pickerTeam = m.participants.find((p: any) => p.id === game.pickedByParticipantId)?.team}
{@const winnerTeam = m.participants.find((p: any) => p.id === game.winnerParticipantId)?.team}
{@const pickerTeam = m.participants.find(
(p: any) => p.id === game.pickedByParticipantId
)?.team}
{@const winnerTeam = m.participants.find(
(p: any) => p.id === game.winnerParticipantId
)?.team}
<div class="flex items-center gap-3 rounded-lg border border-border bg-surface-800 p-3">
<span class="w-6 text-center text-xs font-600 text-text-secondary">{game.gameNumber}</span>
<span class="font-600 w-6 text-center text-xs text-text-secondary"
>{game.gameNumber}</span
>
{#if bm?.listCoverUrl}
<img src={bm.listCoverUrl} alt="" class="h-8 w-16 rounded object-cover" />
{:else}
@@ -526,16 +700,21 @@
{/if}
<div class="min-w-0 flex-1">
{#if bm}
<p class="truncate text-sm font-500">{bm.artist} - {bm.title} <span class="text-text-secondary">[{bm.version}]</span></p>
<p class="font-500 truncate text-sm">
{bm.artist} - {bm.title} <span class="text-text-secondary">[{bm.version}]</span>
</p>
{/if}
<div class="flex items-center gap-2 text-xs text-text-secondary">
<span class="rounded border px-1.5 py-0.5 {catColors[game.slot?.category] ?? 'border-border'}">{game.slot?.category}{game.slot?.orderInCategory}</span>
<span
class="rounded border px-1.5 py-0.5 {catColors[game.slot?.category] ??
'border-border'}">{game.slot?.category}{game.slot?.orderInCategory}</span
>
{#if pickerTeam}<span>Picked by {pickerTeam.name}</span>{/if}
</div>
</div>
{#if game.state === 'PLAYING'}
<span class="text-xs text-green-400 animate-pulse">Live</span>
<span class="animate-pulse text-xs text-green-400">Live</span>
{:else if winnerTeam}
<span class="text-xs text-green-400">✓ {winnerTeam.name}</span>
{/if}
@@ -546,9 +725,13 @@
<div class="mt-1.5 flex flex-col gap-1">
{#each game.scores as s}
{@const username = playerNames[s.player?.userId]}
<div class="flex flex-wrap items-center gap-x-3 gap-y-0.5 rounded bg-surface-900 px-3 py-1.5 text-xs">
<span class="w-24 truncate font-500">{username ?? s.player?.userId ?? '?'}</span>
<span class="font-mono tabular-nums text-text-primary">{s.score.toLocaleString()}</span>
<div
class="flex flex-wrap items-center gap-x-3 gap-y-0.5 rounded bg-surface-900 px-3 py-1.5 text-xs"
>
<span class="font-500 w-24 truncate">{username ?? s.player?.userId ?? '?'}</span>
<span class="font-mono text-text-primary tabular-nums"
>{s.score.toLocaleString()}</span
>
{#if s.accuracy > 0}
<span class="text-text-secondary">{(s.accuracy * 100).toFixed(2)}%</span>
{/if}
@@ -581,7 +764,7 @@
<!-- CREATED / LOBBY waiting states -->
{#if m.state === 'CREATED'}
<div class="mt-6 rounded-lg border border-border bg-surface-800 p-6 text-center">
<p class="text-sm text-text-secondary animate-pulse">Creating osu! lobby...</p>
<p class="animate-pulse text-sm text-text-secondary">Creating osu! lobby...</p>
</div>
{/if}
@@ -590,7 +773,11 @@
<p class="text-sm text-text-secondary">Lobby created. Waiting for players to join...</p>
{#if m.osuLobbyId}
<p class="mt-2 text-xs text-text-secondary">
<a href="https://osu.ppy.sh/mp/{m.osuLobbyId}" target="_blank" class="text-accent hover:underline">osu! mp/{m.osuLobbyId}</a>
<a
href="https://osu.ppy.sh/mp/{m.osuLobbyId}"
target="_blank"
class="text-accent hover:underline">osu! mp/{m.osuLobbyId}</a
>
</p>
{/if}
{#if lobbyStatus}
@@ -599,13 +786,21 @@
{#each participant.players as player}
{@const username = playerNames[player.userId]}
{@const inLobby = username && lobbyStatus.inLobby.includes(username)}
<div class="flex items-center gap-2 rounded border border-border bg-surface-800/50 px-3 py-1.5 text-xs">
<div
class="flex items-center gap-2 rounded border border-border bg-surface-800/50 px-3 py-1.5 text-xs"
>
<span class="font-500">{username ?? player.userId}</span>
<span class="ml-auto">
{#if inLobby}
<span class="rounded bg-green-500/20 px-1.5 py-0.5 text-[10px] font-600 text-green-400">In Lobby</span>
<span
class="font-600 rounded bg-green-500/20 px-1.5 py-0.5 text-[10px] text-green-400"
>In Lobby</span
>
{:else}
<span class="rounded bg-surface-700 px-1.5 py-0.5 text-[10px] font-600 text-text-secondary">Not in lobby</span>
<span
class="font-600 rounded bg-surface-700 px-1.5 py-0.5 text-[10px] text-text-secondary"
>Not in lobby</span
>
{/if}
</span>
</div>

View File

@@ -3,24 +3,24 @@
<meta name="description" content="Privacy Policy for Vash Esports." />
</svelte:head>
<div class="prose prose-invert mx-auto max-w-3xl">
<div class="mx-auto prose max-w-3xl prose-invert">
<h1>Privacy Policy</h1>
<p class="text-text-secondary text-sm">Last updated: {new Date().getFullYear()}</p>
<p class="text-sm text-text-secondary">Last updated: {new Date().getFullYear()}</p>
<h2>1. Information We Collect</h2>
<p>When you use Vash Esports, we collect:</p>
<ul>
<li>
<strong>osu! account data</strong> — your osu! username, user ID, and profile information
provided via osu! OAuth
<strong>osu! account data</strong> — your osu! username, user ID, and profile information provided
via osu! OAuth
</li>
<li>
<strong>Tournament data</strong> — match results, scores, and team membership you create or
participate in
<strong>Tournament data</strong> — match results, scores, and team membership you create or participate
in
</li>
<li>
<strong>Usage analytics</strong> — anonymized page views and interactions via Umami, a
privacy-focused analytics tool. No cookies are used and no personal data is collected.
<strong>Usage analytics</strong> — anonymized page views and interactions via Umami, a privacy-focused
analytics tool. No cookies are used and no personal data is collected.
</li>
</ul>
@@ -34,9 +34,10 @@
<h2>3. Data Sharing</h2>
<p>
We do not sell or share your personal data with third parties, except as required by law.
osu! account data is obtained via osu!'s OAuth API and subject to
<a href="https://osu.ppy.sh/legal/privacy" target="_blank" rel="noopener">osu!'s Privacy Policy</a
We do not sell or share your personal data with third parties, except as required by law. osu!
account data is obtained via osu!'s OAuth API and subject to
<a href="https://osu.ppy.sh/legal/privacy" target="_blank" rel="noopener"
>osu!'s Privacy Policy</a
>.
</p>

View File

@@ -29,9 +29,7 @@ export const load: PageServerLoad = async ({ locals }) => {
role: locals.user.role ?? 'player',
createdAt: locals.user.createdAt
},
rating: rating
? { elo: rating.elo, wins: rating.wins, losses: rating.losses }
: null,
rating: rating ? { elo: rating.elo, wins: rating.wins, losses: rating.losses } : null,
hasOsuLinked: !!osuAccount
};
};

View File

@@ -19,7 +19,7 @@
</script>
<div class="mx-auto max-w-2xl">
<h1 class="text-2xl font-700 tracking-tight">Settings</h1>
<h1 class="font-700 text-2xl tracking-tight">Settings</h1>
<p class="mt-1 text-sm text-text-secondary">Your profile and account settings</p>
<!-- Profile Card -->
@@ -29,15 +29,15 @@
<img src={data.profile.image} alt={data.profile.name} class="h-16 w-16 rounded-full" />
{:else}
<div
class="flex h-16 w-16 items-center justify-center rounded-full bg-surface-600 text-xl font-700 text-text-secondary"
class="font-700 flex h-16 w-16 items-center justify-center rounded-full bg-surface-600 text-xl text-text-secondary"
>
{data.profile.name.charAt(0).toUpperCase()}
</div>
{/if}
<div class="flex-1">
<div class="flex items-center gap-2">
<h2 class="text-lg font-700">{data.profile.name}</h2>
<span class="rounded px-2 py-0.5 text-xs font-600 {badge.color}">
<h2 class="font-700 text-lg">{data.profile.name}</h2>
<span class="font-600 rounded px-2 py-0.5 text-xs {badge.color}">
{badge.label}
</span>
</div>
@@ -47,11 +47,13 @@
<!-- Connected Accounts -->
<div class="mt-6 border-t border-border pt-4">
<h3 class="text-xs font-600 text-text-secondary uppercase tracking-wider">Connected Accounts</h3>
<h3 class="font-600 text-xs tracking-wider text-text-secondary uppercase">
Connected Accounts
</h3>
<div class="mt-3 flex items-center gap-3 rounded-md bg-surface-700 px-4 py-3">
<img src="/osu.png" alt="osu!" class="h-5 w-5 object-contain" />
<div class="flex-1">
<p class="text-sm font-500">osu!</p>
<p class="font-500 text-sm">osu!</p>
<p class="text-xs text-text-secondary">
{#if data.hasOsuLinked}
Connected as {data.profile.name}
@@ -61,11 +63,13 @@
</p>
</div>
{#if data.hasOsuLinked}
<span class="rounded bg-green-500/20 px-2 py-0.5 text-xs font-500 text-green-400">Linked</span>
<span class="font-500 rounded bg-green-500/20 px-2 py-0.5 text-xs text-green-400"
>Linked</span
>
{:else}
<a
href="/api/auth/osu/login"
class="rounded bg-accent px-3 py-1 text-xs font-600 text-surface-900 hover:bg-accent-hover"
class="font-600 rounded bg-accent px-3 py-1 text-xs text-surface-900 hover:bg-accent-hover"
>
Connect
</a>
@@ -76,22 +80,24 @@
<!-- Stats Card -->
<div class="mt-4 rounded-lg border border-border bg-surface-800 p-6">
<h3 class="text-xs font-600 text-text-secondary uppercase tracking-wider">Ranked Stats</h3>
<h3 class="font-600 text-xs tracking-wider text-text-secondary uppercase">Ranked Stats</h3>
<div class="mt-4 grid grid-cols-4 gap-4">
<div>
<p class="text-2xl font-800 tabular-nums text-accent">{data.rating?.elo ?? '—'}</p>
<p class="font-800 text-2xl text-accent tabular-nums">{data.rating?.elo ?? '—'}</p>
<p class="mt-1 text-xs text-text-secondary">ELO Rating</p>
</div>
<div>
<p class="text-2xl font-800 tabular-nums text-green-400">{data.rating?.wins ?? 0}</p>
<p class="font-800 text-2xl text-green-400 tabular-nums">{data.rating?.wins ?? 0}</p>
<p class="mt-1 text-xs text-text-secondary">Wins</p>
</div>
<div>
<p class="text-2xl font-800 tabular-nums text-red-400">{data.rating?.losses ?? 0}</p>
<p class="font-800 text-2xl text-red-400 tabular-nums">{data.rating?.losses ?? 0}</p>
<p class="mt-1 text-xs text-text-secondary">Losses</p>
</div>
<div>
<p class="text-2xl font-800 tabular-nums text-text-primary">{winRate}{winRate !== '—' ? '%' : ''}</p>
<p class="font-800 text-2xl text-text-primary tabular-nums">
{winRate}{winRate !== '—' ? '%' : ''}
</p>
<p class="mt-1 text-xs text-text-secondary">Win Rate</p>
</div>
</div>
@@ -99,12 +105,12 @@
<!-- Danger Zone -->
<div class="mt-4 rounded-lg border border-border bg-surface-800 p-6">
<h3 class="text-xs font-600 text-red-400 uppercase tracking-wider">Account</h3>
<h3 class="font-600 text-xs tracking-wider text-red-400 uppercase">Account</h3>
<div class="mt-4">
<form method="post" action="?/logout" use:enhance>
<button
type="submit"
class="rounded-md border border-red-500/30 px-4 py-2 text-sm font-500 text-red-400 transition-colors hover:bg-red-500/10"
class="font-500 rounded-md border border-red-500/30 px-4 py-2 text-sm text-red-400 transition-colors hover:bg-red-500/10"
>
Sign Out
</button>

View File

@@ -16,9 +16,15 @@
data.teams.filter((t: any) => t.members.some((m: any) => m.userId === data.userId))
);
const otherTeams = $derived(
data.teams.filter((t: any) => !t.isPersonal && !t.members.some((m: any) => m.userId === data.userId))
data.teams.filter(
(t: any) => !t.isPersonal && !t.members.some((m: any) => m.userId === data.userId)
)
);
const teamCount = $derived(
data.teams.filter(
(t: any) => !t.isPersonal || t.members.some((m: any) => m.userId === data.userId)
).length
);
const teamCount = $derived(data.teams.filter((t: any) => !t.isPersonal || t.members.some((m: any) => m.userId === data.userId)).length);
function isOwner(t: any) {
return t.ownerId === data.userId;
@@ -38,14 +44,14 @@
<!-- Header -->
<div class="flex items-center justify-between">
<div>
<h1 class="text-2xl font-700 tracking-tight">Teams</h1>
<h1 class="font-700 text-2xl tracking-tight">Teams</h1>
<p class="mt-1 text-sm text-text-secondary">
{teamCount} team{teamCount !== 1 ? 's' : ''}
</p>
</div>
<button
onclick={() => (creating = !creating)}
class="rounded-md bg-accent px-4 py-2 text-sm font-600 text-surface-900 transition-colors hover:bg-accent-hover"
class="font-600 rounded-md bg-accent px-4 py-2 text-sm text-surface-900 transition-colors hover:bg-accent-hover"
>
{creating ? 'Cancel' : 'New Team'}
</button>
@@ -75,7 +81,7 @@
/>
<button
type="submit"
class="rounded-md bg-accent px-4 py-2 text-sm font-600 text-surface-900 transition-colors hover:bg-accent-hover"
class="font-600 rounded-md bg-accent px-4 py-2 text-sm text-surface-900 transition-colors hover:bg-accent-hover"
>
Create
</button>
@@ -89,7 +95,7 @@
<!-- My Teams -->
{#if myTeams.length > 0}
<div class="mt-6">
<h2 class="text-sm font-600 text-text-secondary">My Teams</h2>
<h2 class="font-600 text-sm text-text-secondary">My Teams</h2>
<div class="mt-3 flex flex-col gap-3">
{#each myTeams as t}
<div class="rounded-lg border border-border bg-surface-800 transition-colors">
@@ -102,17 +108,21 @@
<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"
class="font-700 flex h-10 w-10 items-center justify-center rounded-full bg-surface-600 text-sm text-text-secondary"
>
{t.name.charAt(0).toUpperCase()}
</div>
{/if}
<div class="flex-1">
<div class="flex items-center gap-2">
<a href="/teams/{t.id}" onclick={(e: MouseEvent) => e.stopPropagation()} class="text-sm font-600 hover:text-accent hover:underline">{t.name}</a>
<a
href="/teams/{t.id}"
onclick={(e: MouseEvent) => e.stopPropagation()}
class="font-600 text-sm hover:text-accent hover:underline">{t.name}</a
>
{#if t.isPersonal}
<span
class="rounded bg-surface-600 px-1.5 py-0.5 text-[10px] font-500 text-text-secondary"
class="font-500 rounded bg-surface-600 px-1.5 py-0.5 text-[10px] text-text-secondary"
>solo</span
>
{/if}
@@ -130,7 +140,7 @@
<!-- Expanded: members -->
{#if expandedTeam === t.id}
<div class="border-t border-border px-4 pb-4 pt-3">
<div class="border-t border-border px-4 pt-3 pb-4">
<!-- Rename team -->
{#if isOwner(t) && !t.isPersonal}
{#if renamingTeam === t.id}
@@ -143,10 +153,7 @@
if (result.type === 'success' && (result.data as any)?.success) {
renamingTeam = 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;
}
};
@@ -163,7 +170,7 @@
/>
<button
type="submit"
class="rounded-md bg-accent px-3 py-1.5 text-xs font-600 text-surface-900 hover:bg-accent-hover"
class="font-600 rounded-md bg-accent px-3 py-1.5 text-xs text-surface-900 hover:bg-accent-hover"
>Save</button
>
<button
@@ -232,10 +239,7 @@
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;
}
};
@@ -253,7 +257,7 @@
/>
<button
type="submit"
class="rounded-md bg-accent px-3 py-1.5 text-xs font-600 text-surface-900 hover:bg-accent-hover"
class="font-600 rounded-md bg-accent px-3 py-1.5 text-xs text-surface-900 hover:bg-accent-hover"
>Add</button
>
<button
@@ -299,21 +303,24 @@
<!-- Other Teams -->
{#if otherTeams.length > 0}
<div class="mt-6">
<h2 class="text-sm font-600 text-text-secondary">Other Teams</h2>
<h2 class="font-600 text-sm text-text-secondary">Other Teams</h2>
<div class="mt-3 flex flex-col gap-2">
{#each otherTeams as t}
<a href="/teams/{t.id}" class="flex items-center gap-4 rounded-lg border border-border bg-surface-800 p-3 transition-colors hover:border-accent/30 hover:bg-surface-700">
<a
href="/teams/{t.id}"
class="flex items-center gap-4 rounded-lg border border-border bg-surface-800 p-3 transition-colors hover:border-accent/30 hover:bg-surface-700"
>
{#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"
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}
<div class="flex-1">
<span class="text-sm font-600">{t.name}</span>
<span class="font-600 text-sm">{t.name}</span>
<p class="text-xs text-text-secondary">
{t.members.length} member{t.members.length !== 1 ? 's' : ''}
</p>

View File

@@ -45,7 +45,9 @@ export const load: PageServerLoad = async ({ params, locals }) => {
.slice(0, 20);
const wins = recentMatches.filter((m) => m.winnerId === params.id).length;
const losses = recentMatches.filter((m) => m.state === 'FINISHED' && m.winnerId !== params.id).length;
const losses = recentMatches.filter(
(m) => m.state === 'FINISHED' && m.winnerId !== params.id
).length;
return {
team: { ...t, members: membersWithUsers },

View File

@@ -26,15 +26,20 @@
{#if t.avatarUrl}
<img src={t.avatarUrl} alt="" class="h-14 w-14 rounded-full" />
{:else}
<div class="flex h-14 w-14 items-center justify-center rounded-full bg-surface-600 text-lg font-700 text-text-secondary">
<div
class="font-700 flex h-14 w-14 items-center justify-center rounded-full bg-surface-600 text-lg text-text-secondary"
>
{t.name.charAt(0).toUpperCase()}
</div>
{/if}
<div>
<div class="flex items-center gap-2">
<h1 class="text-2xl font-700 tracking-tight">{t.name}</h1>
<h1 class="font-700 text-2xl tracking-tight">{t.name}</h1>
{#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="font-500 rounded bg-surface-600 px-1.5 py-0.5 text-[10px] text-text-secondary"
>solo</span
>
{/if}
</div>
<p class="mt-0.5 text-sm text-text-secondary">
@@ -46,16 +51,18 @@
<!-- Players -->
<div class="mt-6">
<h2 class="text-sm font-600 text-text-secondary">Players</h2>
<h2 class="font-600 text-sm text-text-secondary">Players</h2>
<div class="mt-3 flex flex-col gap-2">
{#each t.members as member}
<div class="flex items-center gap-3 rounded-lg border border-border bg-surface-800 px-4 py-3">
<div
class="flex items-center gap-3 rounded-lg border border-border bg-surface-800 px-4 py-3"
>
{#if member.user?.image}
<img src={member.user.image} alt="" class="h-8 w-8 rounded-full" />
{:else}
<div class="h-8 w-8 rounded-full bg-surface-600"></div>
{/if}
<span class="flex-1 text-sm font-600">{member.user?.name ?? 'Unknown'}</span>
<span class="font-600 flex-1 text-sm">{member.user?.name ?? 'Unknown'}</span>
<span class="text-xs text-text-secondary">{member.role}</span>
</div>
{/each}
@@ -65,7 +72,7 @@
<!-- Recent Matches -->
{#if data.recentMatches.length > 0}
<div class="mt-6">
<h2 class="text-sm font-600 text-text-secondary">Recent Matches</h2>
<h2 class="font-600 text-sm text-text-secondary">Recent Matches</h2>
<div class="mt-3 flex flex-col gap-2">
{#each data.recentMatches as m}
{@const p1 = m.participants[0]}
@@ -75,16 +82,26 @@
href="/matches/{m.id}"
class="flex items-center gap-3 rounded-lg border border-border bg-surface-800 px-4 py-3 transition-colors hover:border-accent/30 hover:bg-surface-700"
>
<span class="text-sm font-600">{p1?.team.name ?? '?'}</span>
<span class="font-600 text-sm">{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="font-700 text-xs 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="font-700 text-xs 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}
<span class="text-sm font-600">{p2?.team.name ?? '?'}</span>
<span class="ml-auto text-xs {won ? 'font-600 text-green-400' : 'text-red-400'}">{won ? 'W' : 'L'}</span>
<span class="font-600 text-sm">{p2?.team.name ?? '?'}</span>
<span class="ml-auto text-xs {won ? 'font-600 text-green-400' : 'text-red-400'}"
>{won ? 'W' : 'L'}</span
>
<span class="text-xs text-text-secondary/50">{timeAgo(m.createdAt)}</span>
</a>
{/each}

View File

@@ -3,9 +3,9 @@
<meta name="description" content="Terms of Service for Vash Esports." />
</svelte:head>
<div class="prose prose-invert mx-auto max-w-3xl">
<div class="mx-auto prose max-w-3xl prose-invert">
<h1>Terms of Service</h1>
<p class="text-text-secondary text-sm">Last updated: {new Date().getFullYear()}</p>
<p class="text-sm text-text-secondary">Last updated: {new Date().getFullYear()}</p>
<h2>1. Acceptance of Terms</h2>
<p>
@@ -15,8 +15,8 @@
<h2>2. Use of the Service</h2>
<p>
Vash Esports is an event management platform. You may use the Service only for lawful
purposes and in accordance with these Terms. You agree not to:
Vash Esports is an event management platform. You may use the Service only for lawful purposes
and in accordance with these Terms. You agree not to:
</p>
<ul>
<li>Use the Service in any way that violates applicable laws or regulations</li>

View File

@@ -4,8 +4,8 @@
<div class="flex min-h-dvh items-center justify-center bg-surface-900">
<div class="text-center">
<p class="text-6xl font-800 text-accent">{page.status}</p>
<h1 class="mt-4 text-xl font-700 text-text-primary">
<p class="font-800 text-6xl text-accent">{page.status}</p>
<h1 class="font-700 mt-4 text-xl text-text-primary">
{#if page.status === 404}
Page not found
{:else if page.status === 403}
@@ -21,7 +21,7 @@
</p>
<a
href="/"
class="mt-6 inline-block rounded-md bg-accent px-5 py-2.5 text-sm font-600 text-surface-900 transition-colors hover:bg-accent-hover"
class="font-600 mt-6 inline-block rounded-md bg-accent px-5 py-2.5 text-sm text-surface-900 transition-colors hover:bg-accent-hover"
>
Back to Dashboard
</a>

View File

@@ -2,7 +2,11 @@
import { json } from '@sveltejs/kit';
import { db } from '$lib/server/db';
import { sql } from 'drizzle-orm';
import { isBanchoConnected, isBanchoReconnecting, getActiveLobbyCount } from '$lib/server/bancho/client';
import {
isBanchoConnected,
isBanchoReconnecting,
getActiveLobbyCount
} from '$lib/server/bancho/client';
import type { RequestHandler } from './$types';
export const GET: RequestHandler = async () => {