feat: implement team management features including team creation, member addition, and deletion

- Added a new page for managing teams with functionalities to create teams, add members, and delete teams.
- Enhanced the matches page with improved match creation form and live match display.
- Refactored state handling for match states and improved UI for displaying match information.
This commit is contained in:
2026-02-28 05:03:11 +01:00
parent e147ba4432
commit f4b94597c7
6 changed files with 777 additions and 360 deletions

View File

@@ -1,4 +1,5 @@
<script lang="ts"> <script lang="ts">
import './layout.css';
import Sidebar from '$lib/components/Sidebar.svelte'; import Sidebar from '$lib/components/Sidebar.svelte';
import Header from '$lib/components/Header.svelte'; import Header from '$lib/components/Header.svelte';

View File

@@ -1,52 +1,42 @@
import { db } from '$lib/server/db'; import { db } from '$lib/server/db';
import { match, playerRating, matchQueue } from '$lib/server/db/schema'; import { match, team, mappool } from '$lib/server/db/schema';
import { eq, desc, sql } from 'drizzle-orm'; import { eq, desc, count, and, inArray } from 'drizzle-orm';
import { redirect } from '@sveltejs/kit';
import type { PageServerLoad } from './$types'; import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ locals }) => { export const load: PageServerLoad = async ({ locals }) => {
if (!locals.user) { if (!locals.user) redirect(302, '/');
return { user: null, rating: null, queueStatus: null, recentMatches: [], poolCount: 0 };
}
const rating = await db.query.playerRating.findFirst({
where: eq(playerRating.userId, locals.user.id)
});
const queueEntry = await db.query.matchQueue.findFirst({
where: eq(matchQueue.userId, locals.user.id)
});
const queueCount = await db
.select({ count: sql<number>`count(*)` })
.from(matchQueue);
const poolCount = await db
.select({ count: sql<number>`count(*)` })
.from(db._.fullSchema.mappool);
// Recent matches (last 5)
const recentMatches = await db.query.match.findMany({ const recentMatches = await db.query.match.findMany({
with: { with: { participants: { with: { team: true } } },
participants: {
with: { team: true, players: true }
}
},
orderBy: desc(match.createdAt), orderBy: desc(match.createdAt),
limit: 10 limit: 5
}); });
const userMatches = recentMatches.filter((m) => // Live matches
m.participants.some((p) => p.players.some((pl) => pl.userId === locals.user!.id)) const liveMatches = await db.query.match.findMany({
); with: { participants: { with: { team: true } } },
where: inArray(match.state, ['LOBBY', 'ROLLING', 'PICKING', 'PLAYING'])
});
// Counts
const [matchCount] = await db.select({ count: count() }).from(match);
const [teamCount] = await db.select({ count: count() }).from(team);
const [poolCount] = await db.select({ count: count() }).from(mappool);
const [finishedCount] = await db
.select({ count: count() })
.from(match)
.where(eq(match.state, 'FINISHED'));
return { return {
user: locals.user, recentMatches,
rating: rating ?? { elo: 1000, wins: 0, losses: 0 }, liveMatches,
queueStatus: { stats: {
inQueue: !!queueEntry, matches: matchCount.count,
queueSize: Number(queueCount[0].count), finished: finishedCount.count,
joinedAt: queueEntry?.joinedAt ?? null teams: teamCount.count,
}, mappools: poolCount.count
recentMatches: userMatches, }
poolCount: Number(poolCount[0].count)
}; };
}; };

View File

@@ -1,202 +1,194 @@
<script lang="ts"> <script lang="ts">
import type { PageProps } from './$types'; let { data } = $props();
import './layout.css';
let { data }: PageProps = $props(); const liveStates = ['LOBBY', 'ROLLING', 'PICKING', 'PLAYING'];
let joining = $state(false); const stateConfig: Record<string, { label: string; color: string; dot?: string }> = {
let leaving = $state(false); CREATED: { label: 'Created', color: 'text-text-secondary' },
let queueTimer = $state(''); LOBBY: { label: 'In Lobby', color: 'text-yellow-400' },
let queueInterval: ReturnType<typeof setInterval> | null = null; ROLLING: { label: 'Rolling', color: 'text-yellow-400', dot: 'bg-yellow-400' },
PICKING: { label: 'Picking', color: 'text-blue-400', dot: 'bg-blue-400' },
$effect(() => { PLAYING: { label: 'Live', color: 'text-green-400', dot: 'bg-green-400' },
if (data.queueStatus?.inQueue && data.queueStatus.joinedAt) { FINISHED: { label: 'Finished', color: 'text-text-secondary' },
const start = new Date(data.queueStatus.joinedAt).getTime(); CANCELLED: { label: 'Cancelled', color: 'text-red-400' }
queueInterval = setInterval(() => {
const elapsed = Math.floor((Date.now() - start) / 1000);
const m = Math.floor(elapsed / 60);
const s = elapsed % 60;
queueTimer = `${m}:${s.toString().padStart(2, '0')}`;
}, 1000);
}
return () => {
if (queueInterval) clearInterval(queueInterval);
}; };
});
async function joinQueue() { function timeAgo(date: string | Date) {
joining = true; const d = new Date(date);
try { const diff = Date.now() - d.getTime();
const res = await fetch('/api/queue', { method: 'POST' }); const mins = Math.floor(diff / 60000);
const result = await res.json(); if (mins < 1) return 'just now';
if (result.matched && result.match) { if (mins < 60) return `${mins}m ago`;
window.location.href = `/matches/${result.match.id}`; const hours = Math.floor(mins / 60);
} else { if (hours < 24) return `${hours}h ago`;
window.location.reload(); const days = Math.floor(hours / 24);
} return `${days}d ago`;
} catch {
joining = false;
}
}
async function leaveQueue() {
leaving = true;
await fetch('/api/queue', { method: 'DELETE' });
window.location.reload();
}
function stateLabel(state: string) {
const map: Record<string, string> = {
CREATED: 'Created', LOBBY: 'In Lobby', ROLLING: 'Rolling',
PICKING: 'Picking', PLAYING: 'Playing', FINISHED: 'Finished', CANCELLED: 'Cancelled'
};
return map[state] ?? state;
}
function stateColor(state: string) {
const map: Record<string, string> = {
CREATED: 'text-text-secondary', LOBBY: 'text-yellow-400', ROLLING: 'text-yellow-400',
PICKING: 'text-blue-400', PLAYING: 'text-green-400', FINISHED: 'text-text-secondary', CANCELLED: 'text-red-400'
};
return map[state] ?? 'text-text-secondary';
} }
</script> </script>
<div class="mx-auto max-w-4xl"> <div class="mx-auto max-w-5xl">
{#if !data.user} <!-- Welcome -->
<div class="mt-20 text-center"> <div>
<h1 class="text-4xl font-800 tracking-tight"><span class="text-accent">VASH</span></h1> <h1 class="text-2xl font-700 tracking-tight">
<p class="mt-3 text-text-secondary">Automated esports match management for osu!</p> {#if data.user}
<a href="/api/auth/osu/login" class="mt-6 inline-block rounded-md bg-accent px-6 py-2.5 text-sm font-600 text-surface-900 transition-colors hover:bg-accent-hover"> Welcome back, <span class="text-accent">{data.user.name}</span>
Sign in with osu! {:else}
Welcome to <span class="text-accent">VASH</span>
{/if}
</h1>
<p class="mt-1 text-sm text-text-secondary">Automated esports match management for osu!</p>
</div>
<!-- Stats -->
<div class="mt-6 grid grid-cols-4 gap-3">
<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="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="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="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="mt-1 text-xs text-text-secondary">Mappools</p>
</div>
</div>
<!-- Live Matches -->
{#if data.liveMatches.length > 0}
<div class="mt-8">
<div class="flex items-center gap-2">
<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>
</div>
<h2 class="text-sm font-600">Live Now</h2>
</div>
<div class="mt-3 flex flex-col gap-2">
{#each data.liveMatches as m}
{@const p1 = m.participants[0]}
{@const p2 = m.participants[1]}
{@const sc = stateConfig[m.state]}
<a
href="/matches/{m.id}"
class="flex items-center gap-4 rounded-lg border border-green-500/20 bg-surface-800 p-4 transition-all hover:border-green-500/40 hover:bg-surface-700"
>
<div class="flex flex-1 items-center gap-3">
{#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>
</div>
<div class="flex items-center gap-3">
<span class="text-xl font-800 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>
</div>
<div class="flex flex-1 items-center justify-end gap-3">
<span class="text-sm font-600">{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 ?? ''}">
{#if sc?.dot}
<span class="h-1.5 w-1.5 rounded-full {sc.dot}"></span>
{/if}
{sc?.label}
</span>
</a>
{/each}
</div>
</div>
{/if}
<div class="mt-8 grid grid-cols-3 gap-4">
<!-- Quick Actions -->
<div class="col-span-1">
<h2 class="text-sm font-600">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">⚔</span>
<div>
<p class="text-sm font-600">New Match</p>
<p class="text-xs text-text-secondary">Start a 1v1</p>
</div>
</a>
<a
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">♫</span>
<div>
<p class="text-sm font-600">Mappools</p>
<p class="text-xs text-text-secondary">Build a pool</p>
</div>
</a>
<a
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">⚑</span>
<div>
<p class="text-sm font-600">Teams</p>
<p class="text-xs text-text-secondary">Manage rosters</p>
</div>
</a> </a>
</div> </div>
{:else}
<!-- Stats -->
<div class="flex items-center gap-6">
<h1 class="text-2xl font-700 tracking-tight">
Welcome back, <span class="text-accent">{data.user.name}</span>
</h1>
<div class="ml-auto flex items-center gap-4">
<div class="rounded-lg border border-border bg-surface-800 px-4 py-2 text-center">
<p class="text-xs text-text-secondary">ELO</p>
<p class="text-lg font-700 tabular-nums">{data.rating?.elo ?? 1000}</p>
</div>
<div class="rounded-lg border border-border bg-surface-800 px-4 py-2 text-center">
<p class="text-xs text-text-secondary">W / L</p>
<p class="text-lg font-700 tabular-nums">
<span class="text-green-400">{data.rating?.wins ?? 0}</span>
<span class="text-text-secondary">/</span>
<span class="text-red-400">{data.rating?.losses ?? 0}</span>
</p>
</div>
</div>
</div> </div>
<!-- Queue --> <!-- Recent Activity -->
<div class="mt-6 rounded-lg border border-border bg-surface-800 p-6"> <div class="col-span-2">
{#if data.queueStatus?.inQueue}
<div class="flex items-center justify-between">
<div>
<h2 class="text-sm font-600">In Queue</h2>
<p class="mt-1 text-sm text-text-secondary">
Searching for opponent...
<span class="ml-2 font-mono text-accent">{queueTimer}</span>
</p>
<p class="mt-1 text-xs text-text-secondary">
{data.queueStatus.queueSize} player{data.queueStatus.queueSize !== 1 ? 's' : ''} in queue
</p>
</div>
<button onclick={leaveQueue} disabled={leaving} class="rounded-md border border-red-500/30 px-4 py-2 text-sm font-600 text-red-400 transition-colors hover:bg-red-500/10 disabled:opacity-50">
{leaving ? 'Leaving...' : 'Leave Queue'}
</button>
</div>
<div class="mt-4 flex items-center gap-2">
<div class="relative h-2.5 w-2.5">
<div class="absolute inset-0 animate-ping rounded-full bg-accent opacity-75"></div>
<div class="relative h-2.5 w-2.5 rounded-full bg-accent"></div>
</div>
<span class="text-xs text-text-secondary">Matchmaking active</span>
</div>
{:else}
<div class="flex items-center justify-between">
<div>
<h2 class="text-sm font-600">Ranked Queue</h2>
<p class="mt-1 text-xs text-text-secondary">
{#if data.poolCount > 0}
1v1 · Best of 7 ·
{data.queueStatus?.queueSize ?? 0} searching
{:else}
<span class="text-yellow-400">No mappools available — create one first</span>
{/if}
</p>
</div>
<button onclick={joinQueue} disabled={joining || data.poolCount === 0} class="rounded-md bg-accent px-6 py-2.5 text-sm font-600 text-surface-900 transition-colors hover:bg-accent-hover disabled:opacity-50">
{joining ? 'Joining...' : 'Find Match'}
</button>
</div>
{/if}
</div>
<!-- Recent Matches -->
<div class="mt-6">
<h2 class="text-sm font-600">Recent Matches</h2> <h2 class="text-sm font-600">Recent Matches</h2>
<div class="mt-3 flex flex-col gap-2"> <div class="mt-3 flex flex-col gap-2">
{#each data.recentMatches as m} {#each data.recentMatches as m}
{@const p1 = m.participants[0]} {@const p1 = m.participants[0]}
{@const p2 = m.participants[1]} {@const p2 = m.participants[1]}
{@const isUserP1 = p1?.players.some((pl) => pl.userId === data.user?.id)} {@const config = m.config as { bestOf: number }}
{@const userP = isUserP1 ? p1 : p2} {@const sc = stateConfig[m.state]}
{@const oppP = isUserP1 ? p2 : p1} <a
{@const won = m.winnerId === userP?.teamId} href="/matches/{m.id}"
{@const lost = m.winnerId && m.winnerId !== userP?.teamId} class="flex items-center gap-3 rounded-lg border border-border bg-surface-800 p-3 transition-colors hover:border-accent/30 hover:bg-surface-700"
>
<a href="/matches/{m.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">
<div class="h-10 w-1 rounded-full {won ? 'bg-green-400' : lost ? 'bg-red-400' : 'bg-text-secondary/30'}"></div>
<div class="min-w-0 flex-1"> <div class="min-w-0 flex-1">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<span class="text-sm font-600">{m.name}</span> <span class="text-sm font-600">{p1?.team.name ?? '?'}</span>
<span class="text-xs {stateColor(m.state)}">{stateLabel(m.state)}</span> {#if m.state === 'FINISHED'}
</div> <span class="text-xs font-700 tabular-nums {(p1?.score ?? 0) > (p2?.score ?? 0) ? 'text-green-400' : 'text-text-secondary'}">{p1?.score ?? 0}</span>
<div class="mt-0.5 text-xs text-text-secondary"> <span class="text-xs text-text-secondary">-</span>
{userP?.team.name ?? '?'} vs {oppP?.team.name ?? '?'} <span class="text-xs font-700 tabular-nums {(p2?.score ?? 0) > (p1?.score ?? 0) ? 'text-green-400' : 'text-text-secondary'}">{p2?.score ?? 0}</span>
</div> {:else}
</div> <span class="text-xs text-text-secondary">vs</span>
{#if p1 && p2}
<div class="flex items-center gap-1 font-mono text-sm font-700 tabular-nums">
<span class="{isUserP1 ? 'text-accent' : 'text-text-primary'}">{p1.score}</span>
<span class="text-text-secondary">-</span>
<span class="{!isUserP1 ? 'text-accent' : 'text-text-primary'}">{p2.score}</span>
</div>
{/if} {/if}
<span class="text-xs text-text-secondary">{new Date(m.createdAt).toLocaleDateString()}</span> <span class="text-sm font-600">{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>
</div>
{#if m.state === 'FINISHED' && m.winnerId}
{@const winner = m.participants.find((p: any) => p.teamId === m.winnerId)}
<span class="text-xs text-green-400">🏆 {winner?.team.name}</span>
{/if}
<span class="text-xs {sc?.color ?? 'text-text-secondary'}">{sc?.label}</span>
</a> </a>
{:else} {:else}
<div class="rounded-lg border border-dashed border-border py-8 text-center"> <div class="rounded-lg border border-dashed border-border py-8 text-center">
<p class="text-sm text-text-secondary">No matches yet. Join the queue!</p> <p class="text-sm text-text-secondary">No matches yet. <a href="/matches" class="text-accent hover:underline">Create one</a></p>
</div> </div>
{/each} {/each}
</div> </div>
</div> </div>
<!-- Quick Actions -->
<div class="mt-6 grid grid-cols-3 gap-4">
<a href="/matches" class="group rounded-lg border border-border bg-surface-800 p-5 transition-colors hover:border-accent/40 hover:bg-surface-700">
<div class="text-2xl">⚔</div>
<h2 class="mt-3 text-sm font-600">All Matches</h2>
<p class="mt-1 text-xs text-text-secondary">View and manage matches</p>
</a>
<a href="/mappools" class="group rounded-lg border border-border bg-surface-800 p-5 transition-colors hover:border-accent/40 hover:bg-surface-700">
<div class="text-2xl">♫</div>
<h2 class="mt-3 text-sm font-600">Mappools</h2>
<p class="mt-1 text-xs text-text-secondary">Build beatmap pools</p>
</a>
<a href="/teams" class="group rounded-lg border border-border bg-surface-800 p-5 transition-colors hover:border-accent/40 hover:bg-surface-700">
<div class="text-2xl">⚑</div>
<h2 class="mt-3 text-sm font-600">Teams</h2>
<p class="mt-1 text-xs text-text-secondary">Manage your teams</p>
</a>
</div> </div>
{/if}
</div> </div>

View File

@@ -2,91 +2,112 @@
import { enhance } from '$app/forms'; import { enhance } from '$app/forms';
let { data } = $props(); let { data } = $props();
let creating = $state(false);
let showCreate = $state(false);
let createError = $state(''); let createError = $state('');
function stateColor(state: string) { const stateConfig: Record<string, { label: string; color: string; dot?: string }> = {
const colors: Record<string, string> = { CREATED: { label: 'Created', color: 'text-text-secondary border-border' },
CREATED: 'border-border text-text-secondary', LOBBY: { label: 'In Lobby', color: 'text-yellow-400 border-yellow-500/30' },
LOBBY: 'border-yellow-500/30 text-yellow-400', ROLLING: { label: 'Rolling', color: 'text-yellow-400 border-yellow-500/30', dot: 'bg-yellow-400' },
ROLLING: 'border-yellow-500/30 text-yellow-400', PICKING: { label: 'Picking', color: 'text-blue-400 border-blue-500/30', dot: 'bg-blue-400' },
PICKING: 'border-blue-500/30 text-blue-400', PLAYING: { label: 'Live', color: 'text-green-400 border-green-500/30', dot: 'bg-green-400' },
PLAYING: 'border-green-500/30 text-green-400', FINISHED: { label: 'Finished', color: 'text-text-secondary border-border' },
FINISHED: 'border-border text-text-secondary', CANCELLED: { label: 'Cancelled', color: 'text-red-400 border-red-500/30' }
CANCELLED: 'border-red-500/30 text-red-400'
}; };
return colors[state] ?? 'border-border text-text-secondary';
const liveStates = ['LOBBY', 'ROLLING', 'PICKING', 'PLAYING'];
const liveMatches = $derived(data.matches.filter((m: any) => liveStates.includes(m.state)));
const recentMatches = $derived(data.matches.filter((m: any) => !liveStates.includes(m.state)));
function teamDisplay(m: any) {
const p1 = m.participants[0];
const p2 = m.participants[1];
return { p1, p2 };
} }
function poolLabel(pool: { name: string; slots: { starRating: number | null }[] }) { function timeAgo(date: string | Date) {
if (pool.slots.length === 0) return pool.name; const d = new Date(date);
const avg = pool.slots.reduce((s, sl) => s + (sl.starRating ?? 0), 0) / pool.slots.length; const diff = Date.now() - d.getTime();
return `${pool.name} (★${avg.toFixed(1)}, ${pool.slots.length} maps)`; const mins = Math.floor(diff / 60000);
if (mins < 1) return 'just now';
if (mins < 60) return `${mins}m ago`;
const hours = Math.floor(mins / 60);
if (hours < 24) return `${hours}h ago`;
const days = Math.floor(hours / 24);
return `${days}d ago`;
} }
</script> </script>
<div class="mx-auto max-w-4xl"> <div class="mx-auto max-w-5xl">
<!-- Header -->
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div> <div>
<h1 class="text-2xl font-700 tracking-tight">Matches</h1> <h1 class="text-2xl font-700 tracking-tight">Matches</h1>
<p class="mt-1 text-sm text-text-secondary">All matches</p> <p class="mt-1 text-sm text-text-secondary">
{data.matches.length} match{data.matches.length !== 1 ? 'es' : ''}
{#if liveMatches.length > 0}
&middot; <span class="text-green-400">{liveMatches.length} live</span>
{/if}
</p>
</div> </div>
<button <button
onclick={() => (creating = !creating)} onclick={() => (showCreate = !showCreate)}
class="rounded-md bg-accent px-4 py-2 text-sm font-600 text-surface-900 transition-colors hover:bg-accent-hover" class="rounded-md bg-accent px-4 py-2 text-sm font-600 text-surface-900 transition-colors hover:bg-accent-hover"
> >
{creating ? 'Cancel' : 'New Match'} {showCreate ? 'Cancel' : 'New Match'}
</button> </button>
</div> </div>
<!-- Create Match Form --> <!-- Create Match Modal -->
{#if creating} {#if showCreate}
<form <form
method="post" method="post"
action="?/createMatch" action="?/createMatch"
use:enhance={() => { use:enhance={() => {
createError = ''; createError = '';
return async ({ result, update }) => { return async ({ result }) => {
if (result.type === 'failure') { if (result.type === 'failure' || (result.type === 'success' && (result.data as any)?.error)) {
createError = (result.data as { error?: string })?.error ?? 'Failed to create match'; createError = (result.data as any)?.error ?? 'Failed to create match';
} else {
await update();
} }
}; };
}} }}
class="mt-4 rounded-lg border border-border bg-surface-800 p-5" 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="text-sm font-600">Create Match</h2>
{#if createError}
<p class="mt-2 text-sm text-red-400">{createError}</p>
{/if}
<div class="mt-4 grid grid-cols-2 gap-4"> <div class="mt-4 grid grid-cols-2 gap-4">
<div> <div>
<label for="match-name" class="text-xs text-text-secondary">Match Name</label> <label for="name" class="text-xs font-500 text-text-secondary">Match Name</label>
<input <input
id="match-name"
type="text" type="text"
id="name"
name="name" name="name"
placeholder="e.g. Grand Finals" placeholder="e.g. Semifinals M1"
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" 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>
<div> <div>
<label for="best-of" class="text-xs text-text-secondary">Best Of</label> <label for="bestOf" class="text-xs font-500 text-text-secondary">Best Of</label>
<select <select
id="best-of" id="bestOf"
name="bestOf" name="bestOf"
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" 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="3">Best of 3</option> {#each [3, 5, 7, 9, 11, 13] as n}
<option value="5">Best of 5</option> <option value={n} selected={n === 7}>BO{n} (first to {Math.ceil(n / 2)})</option>
<option value="7" selected>Best of 7</option> {/each}
<option value="9">Best of 9</option>
<option value="11">Best of 11</option>
</select> </select>
</div> </div>
<div> <div>
<label for="team1" class="text-xs text-text-secondary">Team 1</label> <label for="team1" class="text-xs font-500 text-text-secondary">Team 1</label>
<select <select
id="team1" id="team1"
name="team1" name="team1"
@@ -95,15 +116,13 @@
> >
<option value="">Select team...</option> <option value="">Select team...</option>
{#each data.teams as t} {#each data.teams as t}
<option value={t.id}> <option value={t.id}>{t.name}{t.isPersonal ? ' (solo)' : ''}</option>
{t.name}{t.isPersonal ? ' (personal)' : ''}
</option>
{/each} {/each}
</select> </select>
</div> </div>
<div> <div>
<label for="team2" class="text-xs text-text-secondary">Team 2</label> <label for="team2" class="text-xs font-500 text-text-secondary">Team 2</label>
<select <select
id="team2" id="team2"
name="team2" name="team2"
@@ -112,15 +131,13 @@
> >
<option value="">Select team...</option> <option value="">Select team...</option>
{#each data.teams as t} {#each data.teams as t}
<option value={t.id}> <option value={t.id}>{t.name}{t.isPersonal ? ' (solo)' : ''}</option>
{t.name}{t.isPersonal ? ' (personal)' : ''}
</option>
{/each} {/each}
</select> </select>
</div> </div>
<div class="col-span-2"> <div class="col-span-2">
<label for="mappool" class="text-xs text-text-secondary">Mappool</label> <label for="mappool" class="text-xs font-500 text-text-secondary">Mappool</label>
<select <select
id="mappool" id="mappool"
name="mappool" name="mappool"
@@ -128,61 +145,129 @@
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" 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> <option value="">Select mappool...</option>
{#each data.mappools as pool} {#each data.mappools as p}
<option value={pool.id}>{poolLabel(pool)}</option> <option value={p.id}>{p.name} ({p.slots.length} maps)</option>
{/each} {/each}
</select> </select>
</div> </div>
</div> </div>
{#if createError} <div class="mt-4 flex justify-end">
<p class="mt-3 text-sm text-red-400">{createError}</p>
{/if}
<button <button
type="submit" type="submit"
class="mt-4 rounded-md bg-accent px-5 py-2 text-sm font-600 text-surface-900 transition-colors hover:bg-accent-hover" class="rounded-md bg-accent px-5 py-2 text-sm font-600 text-surface-900 transition-colors hover:bg-accent-hover"
> >
Create Match Create &amp; Start
</button> </button>
</div>
</form> </form>
{/if} {/if}
<!-- Match List --> <!-- Live Matches -->
<div class="mt-6 flex flex-col gap-2"> {#if liveMatches.length > 0}
{#each data.matches as m} <div class="mt-6">
{@const p1 = m.participants[0]} <h2 class="flex items-center gap-2 text-sm font-600">
{@const p2 = m.participants[1]} <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>
</div>
Live Now
</h2>
<div class="mt-3 flex flex-col gap-2">
{#each liveMatches as m}
{@const { p1, p2 } = teamDisplay(m)}
{@const sc = stateConfig[m.state]}
<a <a
href="/matches/{m.id}" href="/matches/{m.id}"
class="flex items-center gap-4 rounded-lg border border-border bg-surface-800 p-4 transition-colors hover:border-accent/30 hover:bg-surface-700" class="group flex items-center gap-4 rounded-lg border border-green-500/20 bg-surface-800 p-4 transition-all hover:border-green-500/40 hover:bg-surface-700"
> >
<div class="min-w-0 flex-1"> <div class="flex flex-1 items-center gap-3">
<div class="flex items-center gap-2"> {#if p1?.team.avatarUrl}
<span class="text-sm font-600">{m.name}</span> <img src={p1.team.avatarUrl} alt="" class="h-8 w-8 rounded-full" />
<span class="rounded border px-2 py-0.5 text-xs font-600 {stateColor(m.state)}"> {/if}
{m.state} <span class="text-sm font-600">{p1?.team.name ?? '?'}</span>
</span>
</div>
<p class="mt-1 text-xs text-text-secondary">
{p1?.team.name ?? '?'} vs {p2?.team.name ?? '?'}
· {new Date(m.createdAt).toLocaleDateString()}
</p>
</div> </div>
{#if p1 && p2} <div class="flex items-center gap-3">
<div class="flex items-center gap-1 font-mono text-lg font-700 tabular-nums"> <span class="text-xl font-800 tabular-nums">{p1?.score ?? 0}</span>
<span>{p1.score}</span> <span class="text-xs font-600 text-text-secondary">vs</span>
<span class="text-text-secondary">-</span> <span class="text-xl font-800 tabular-nums">{p2?.score ?? 0}</span>
<span>{p2.score}</span>
</div> </div>
<div class="flex flex-1 items-center justify-end gap-3">
<span class="text-sm font-600">{p2?.team.name ?? '?'}</span>
{#if p2?.team.avatarUrl}
<img src={p2.team.avatarUrl} alt="" class="h-8 w-8 rounded-full" />
{/if} {/if}
</a>
{:else}
<div class="rounded-lg border border-dashed border-border py-12 text-center">
<p class="text-sm text-text-secondary">No matches yet</p>
</div> </div>
<span class="flex items-center gap-1.5 rounded border px-2 py-0.5 text-xs font-500 {sc?.color ?? 'border-border'}">
{#if sc?.dot}
<span class="h-1.5 w-1.5 rounded-full {sc.dot}"></span>
{/if}
{sc?.label ?? m.state}
</span>
</a>
{/each} {/each}
</div> </div>
</div> </div>
{/if}
<!-- All Matches -->
<div class="mt-6">
{#if liveMatches.length > 0}
<h2 class="text-sm font-600 text-text-secondary">Past Matches</h2>
{/if}
<div class="mt-3 flex flex-col gap-2">
{#each recentMatches as m}
{@const { p1, p2 } = teamDisplay(m)}
{@const config = m.config as { bestOf: number }}
{@const sc = stateConfig[m.state]}
<a
href="/matches/{m.id}"
class="group 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"
>
<!-- 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>
{#if m.state === 'FINISHED'}
<span class="text-xs font-700 tabular-nums {(p1?.score ?? 0) > (p2?.score ?? 0) ? 'text-green-400' : 'text-text-secondary'}">{p1?.score ?? 0}</span>
<span class="text-xs 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>
{:else}
<span class="text-xs text-text-secondary">vs</span>
{/if}
<span class="text-sm font-600">{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>
</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>
{/if}
<!-- State -->
<span class="rounded border px-2 py-0.5 text-xs font-500 {sc?.color ?? 'border-border'}">
{sc?.label ?? m.state}
</span>
</a>
{:else}
{#if liveMatches.length === 0}
<div class="rounded-lg border border-dashed border-border py-12 text-center">
<p class="text-sm text-text-secondary">No matches yet. Create one to get started.</p>
</div>
{/if}
{/each}
</div>
</div>
</div>

View File

@@ -0,0 +1,120 @@
import { db } from '$lib/server/db';
import { team, teamMember, user } from '$lib/server/db/schema';
import { eq } from 'drizzle-orm';
import { redirect } from '@sveltejs/kit';
import type { PageServerLoad, Actions } from './$types';
export const load: PageServerLoad = async ({ locals }) => {
if (!locals.user) redirect(302, '/');
const teams = await db.query.team.findMany({
with: {
members: true
},
orderBy: (t, { desc }) => [desc(t.createdAt)]
});
// Fetch member user details
const teamsWithUsers = await Promise.all(
teams.map(async (t) => {
const membersWithUsers = await Promise.all(
t.members.map(async (m) => {
const u = await db.query.user.findFirst({
where: eq(user.id, m.userId)
});
return { ...m, user: u ? { id: u.id, name: u.name, image: u.image } : null };
})
);
return { ...t, members: membersWithUsers };
})
);
return { teams: teamsWithUsers, userId: locals.user.id };
};
export const actions: Actions = {
create: async ({ request, locals }) => {
if (!locals.user) redirect(302, '/');
const form = await request.formData();
const name = form.get('name')?.toString()?.trim();
if (!name) return { error: 'Team name is required' };
const [created] = await db
.insert(team)
.values({
name,
ownerId: locals.user.id,
isPersonal: false
})
.returning();
// Add creator as captain
await db.insert(teamMember).values({
teamId: created.id,
userId: locals.user.id,
role: 'captain'
});
return { success: true };
},
addMember: async ({ request, locals }) => {
if (!locals.user) redirect(302, '/');
const form = await request.formData();
const teamId = form.get('teamId')?.toString();
const username = form.get('username')?.toString()?.trim();
if (!teamId || !username) return { error: 'Team and username are required' };
// Find user by osu! username
const u = await db.query.user.findFirst({
where: eq(user.name, username)
});
if (!u) return { error: `User "${username}" not found. They need to log in first.` };
// Check not already member
const existing = await db.query.teamMember.findFirst({
where: (m, { and, eq }) => and(eq(m.teamId, teamId), eq(m.userId, u.id))
});
if (existing) return { error: `${username} is already on this team` };
await db.insert(teamMember).values({
teamId,
userId: u.id,
role: 'member'
});
return { success: true };
},
removeMember: async ({ request, locals }) => {
if (!locals.user) redirect(302, '/');
const form = await request.formData();
const memberId = form.get('memberId')?.toString();
if (!memberId) return { error: 'Missing member ID' };
await db.delete(teamMember).where(eq(teamMember.id, memberId));
return { success: true };
},
deleteTeam: async ({ request, locals }) => {
if (!locals.user) redirect(302, '/');
const form = await request.formData();
const teamId = form.get('teamId')?.toString();
if (!teamId) return { error: 'Missing team ID' };
// Only allow owner to delete
const t = await db.query.team.findFirst({ where: eq(team.id, teamId) });
if (!t || t.ownerId !== locals.user.id) return { error: 'Not authorized' };
await db.delete(team).where(eq(team.id, teamId));
return { success: true };
}
};

View File

@@ -0,0 +1,229 @@
<script lang="ts">
import { enhance } from '$app/forms';
import { invalidateAll } from '$app/navigation';
let { data } = $props();
let creating = $state(false);
let expandedTeam = $state<string | null>(null);
let addingMemberTo = $state<string | null>(null);
let memberUsername = $state('');
let actionError = $state('');
const myTeams = $derived(data.teams.filter((t: any) =>
t.members.some((m: any) => m.userId === data.userId)
));
const otherTeams = $derived(data.teams.filter((t: any) =>
!t.members.some((m: any) => m.userId === data.userId)
));
function isOwner(t: any) {
return t.ownerId === data.userId;
}
</script>
<div class="mx-auto max-w-4xl">
<!-- Header -->
<div class="flex items-center justify-between">
<div>
<h1 class="text-2xl font-700 tracking-tight">Teams</h1>
<p class="mt-1 text-sm text-text-secondary">
{data.teams.length} team{data.teams.length !== 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"
>
{creating ? 'Cancel' : 'New Team'}
</button>
</div>
<!-- Create Team -->
{#if creating}
<form
method="post"
action="?/create"
use:enhance={() => {
return async ({ result, update }) => {
if (result.type === 'success' && (result.data as any)?.success) {
creating = false;
await update();
}
};
}}
class="mt-4 flex gap-3 rounded-lg border border-border bg-surface-800 p-4"
>
<input
type="text"
name="name"
placeholder="Team name"
required
class="flex-1 rounded-md border border-border bg-surface-700 px-3 py-2 text-sm text-text-primary placeholder:text-text-secondary/50 focus:border-accent focus:outline-none"
/>
<button
type="submit"
class="rounded-md bg-accent px-4 py-2 text-sm font-600 text-surface-900 transition-colors hover:bg-accent-hover"
>
Create
</button>
</form>
{/if}
{#if actionError}
<p class="mt-3 text-sm text-red-400">{actionError}</p>
{/if}
<!-- My Teams -->
{#if myTeams.length > 0}
<div class="mt-6">
<h2 class="text-sm font-600 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">
<!-- Team header -->
<button
onclick={() => (expandedTeam = expandedTeam === t.id ? null : t.id)}
class="flex w-full items-center gap-4 p-4 text-left transition-colors hover:bg-surface-700"
>
{#if t.avatarUrl}
<img src={t.avatarUrl} alt="" class="h-10 w-10 rounded-full" />
{:else}
<div class="flex h-10 w-10 items-center justify-center rounded-full bg-surface-600 text-sm font-700 text-text-secondary">
{t.name.charAt(0).toUpperCase()}
</div>
{/if}
<div class="flex-1">
<div class="flex items-center gap-2">
<span class="text-sm font-600">{t.name}</span>
{#if t.isPersonal}
<span class="rounded bg-surface-600 px-1.5 py-0.5 text-[10px] font-500 text-text-secondary">solo</span>
{/if}
</div>
<p class="mt-0.5 text-xs text-text-secondary">
{t.members.length} member{t.members.length !== 1 ? 's' : ''}
</p>
</div>
<span class="text-sm text-text-secondary transition-transform {expandedTeam === t.id ? 'rotate-90' : ''}">&rsaquo;</span>
</button>
<!-- Expanded: members -->
{#if expandedTeam === t.id}
<div class="border-t border-border px-4 pb-4 pt-3">
<!-- Member list -->
<div class="flex flex-col gap-2">
{#each t.members as member}
<div class="flex items-center gap-3 rounded-md bg-surface-700 px-3 py-2">
{#if member.user?.image}
<img src={member.user.image} alt="" class="h-6 w-6 rounded-full" />
{:else}
<div class="h-6 w-6 rounded-full bg-surface-600"></div>
{/if}
<span class="flex-1 text-sm">{member.user?.name ?? 'Unknown'}</span>
<span class="text-xs text-text-secondary">{member.role}</span>
{#if isOwner(t) && member.userId !== data.userId}
<form method="post" action="?/removeMember" use:enhance={() => {
return async ({ update }) => { await update(); };
}}>
<input type="hidden" name="memberId" value={member.id} />
<button type="submit" class="text-xs text-red-400 opacity-50 transition-opacity hover:opacity-100">&times;</button>
</form>
{/if}
</div>
{/each}
</div>
<!-- Add member -->
{#if isOwner(t) && !t.isPersonal}
{#if addingMemberTo === t.id}
<form
method="post"
action="?/addMember"
use:enhance={() => {
actionError = '';
return async ({ result, update }) => {
if (result.type === 'success' && (result.data as any)?.success) {
memberUsername = '';
addingMemberTo = null;
await update();
} else if (result.type === 'success' && (result.data as any)?.error) {
actionError = (result.data as any).error;
}
};
}}
class="mt-3 flex gap-2"
>
<input type="hidden" name="teamId" value={t.id} />
<input
type="text"
name="username"
bind:value={memberUsername}
placeholder="osu! username"
required
class="flex-1 rounded-md border border-border bg-surface-600 px-3 py-1.5 text-sm text-text-primary placeholder:text-text-secondary/50 focus:border-accent focus:outline-none"
/>
<button type="submit" class="rounded-md bg-accent px-3 py-1.5 text-xs font-600 text-surface-900 hover:bg-accent-hover">Add</button>
<button type="button" onclick={() => (addingMemberTo = null)} class="rounded-md border border-border px-3 py-1.5 text-xs text-text-secondary hover:bg-surface-600">Cancel</button>
</form>
{:else}
<button
onclick={() => (addingMemberTo = t.id)}
class="mt-3 w-full rounded-md border border-dashed border-border py-2 text-xs text-text-secondary transition-colors hover:border-accent/40 hover:text-accent"
>
+ Add Member
</button>
{/if}
{/if}
<!-- Delete team -->
{#if isOwner(t) && !t.isPersonal}
<form method="post" action="?/deleteTeam" use:enhance>
<input type="hidden" name="teamId" value={t.id} />
<button
type="submit"
onclick={(e) => { if (!confirm(`Delete ${t.name}?`)) e.preventDefault() }}
class="mt-3 text-xs text-red-400 opacity-50 transition-opacity hover:opacity-100"
>
Delete team
</button>
</form>
{/if}
</div>
{/if}
</div>
{/each}
</div>
</div>
{/if}
<!-- Other Teams -->
{#if otherTeams.length > 0}
<div class="mt-6">
<h2 class="text-sm font-600 text-text-secondary">Other Teams</h2>
<div class="mt-3 flex flex-col gap-2">
{#each otherTeams as t}
<div class="flex items-center gap-4 rounded-lg border border-border bg-surface-800 p-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">
{t.name.charAt(0).toUpperCase()}
</div>
{/if}
<div class="flex-1">
<span class="text-sm font-600">{t.name}</span>
<p class="text-xs text-text-secondary">{t.members.length} member{t.members.length !== 1 ? 's' : ''}</p>
</div>
</div>
{/each}
</div>
</div>
{/if}
<!-- Empty state -->
{#if data.teams.length === 0}
<div class="mt-6 rounded-lg border border-dashed border-border py-12 text-center">
<p class="text-sm text-text-secondary">No teams yet. Your personal team is created when you sign in.</p>
</div>
{/if}
</div>