diff --git a/src/routes/(app)/+layout.svelte b/src/routes/(app)/+layout.svelte index a4de69b..51b9599 100644 --- a/src/routes/(app)/+layout.svelte +++ b/src/routes/(app)/+layout.svelte @@ -1,4 +1,5 @@ -
- {#if !data.user} -
-

VASH

-

Automated esports match management for osu!

- - Sign in with osu! - -
- {:else} - -
-

+
+ +
+

+ {#if data.user} Welcome back, {data.user.name} -

-
-
-

ELO

-

{data.rating?.elo ?? 1000}

-
-
-

W / L

-

- {data.rating?.wins ?? 0} - / - {data.rating?.losses ?? 0} -

+ {:else} + Welcome to VASH + {/if} +

+

Automated esports match management for osu!

+
+ + +
+
+

{data.stats.matches}

+

Total Matches

+
+
+

{data.stats.finished}

+

Completed

+
+
+

{data.stats.teams}

+

Teams

+
+
+

{data.stats.mappools}

+

Mappools

+
+
+ + + {#if data.liveMatches.length > 0} +
+
+
+
+
+

Live Now

+
+
+ {#each data.liveMatches as m} + {@const p1 = m.participants[0]} + {@const p2 = m.participants[1]} + {@const sc = stateConfig[m.state]} + +
+ {#if p1?.team.avatarUrl} + + {/if} + {p1?.team.name ?? '?'} +
+
+ {p1?.score ?? 0} + vs + {p2?.score ?? 0} +
+
+ {p2?.team.name ?? '?'} + {#if p2?.team.avatarUrl} + + {/if} +
+ + {#if sc?.dot} + + {/if} + {sc?.label} + +
+ {/each} +
+
+ {/if} + +
+ + - -
- {#if data.queueStatus?.inQueue} -
-
-

In Queue

-

- Searching for opponent... - {queueTimer} -

-

- {data.queueStatus.queueSize} player{data.queueStatus.queueSize !== 1 ? 's' : ''} in queue -

-
- -
-
-
-
-
-
- Matchmaking active -
- {:else} -
-
-

Ranked Queue

-

- {#if data.poolCount > 0} - 1v1 · Best of 7 · - {data.queueStatus?.queueSize ?? 0} searching - {:else} - No mappools available — create one first - {/if} -

-
- -
- {/if} -
- - -
diff --git a/src/routes/(app)/matches/+page.svelte b/src/routes/(app)/matches/+page.svelte index 482c62f..f45faec 100644 --- a/src/routes/(app)/matches/+page.svelte +++ b/src/routes/(app)/matches/+page.svelte @@ -2,187 +2,272 @@ import { enhance } from '$app/forms'; let { data } = $props(); - let creating = $state(false); + + let showCreate = $state(false); let createError = $state(''); - function stateColor(state: string) { - const colors: Record = { - CREATED: 'border-border text-text-secondary', - LOBBY: 'border-yellow-500/30 text-yellow-400', - ROLLING: 'border-yellow-500/30 text-yellow-400', - PICKING: 'border-blue-500/30 text-blue-400', - PLAYING: 'border-green-500/30 text-green-400', - FINISHED: 'border-border text-text-secondary', - CANCELLED: 'border-red-500/30 text-red-400' - }; - return colors[state] ?? 'border-border text-text-secondary'; + const stateConfig: Record = { + CREATED: { label: 'Created', color: 'text-text-secondary border-border' }, + LOBBY: { label: 'In Lobby', color: 'text-yellow-400 border-yellow-500/30' }, + ROLLING: { label: 'Rolling', color: 'text-yellow-400 border-yellow-500/30', dot: 'bg-yellow-400' }, + PICKING: { label: 'Picking', color: 'text-blue-400 border-blue-500/30', dot: 'bg-blue-400' }, + PLAYING: { label: 'Live', color: 'text-green-400 border-green-500/30', dot: 'bg-green-400' }, + FINISHED: { label: 'Finished', color: 'text-text-secondary border-border' }, + CANCELLED: { label: 'Cancelled', color: 'text-red-400 border-red-500/30' } + }; + + 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 }[] }) { - if (pool.slots.length === 0) return pool.name; - const avg = pool.slots.reduce((s, sl) => s + (sl.starRating ?? 0), 0) / pool.slots.length; - return `${pool.name} (★${avg.toFixed(1)}, ${pool.slots.length} maps)`; + function timeAgo(date: string | Date) { + const d = new Date(date); + const diff = Date.now() - d.getTime(); + 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`; } -
+
+

Matches

-

All matches

+

+ {data.matches.length} match{data.matches.length !== 1 ? 'es' : ''} + {#if liveMatches.length > 0} + · {liveMatches.length} live + {/if} +

- - {#if creating} + + {#if showCreate}
{ - createError = ''; - return async ({ result, update }) => { - if (result.type === 'failure') { - createError = (result.data as { error?: string })?.error ?? 'Failed to create match'; - } else { - await update(); - } - }; - }} - class="mt-4 rounded-lg border border-border bg-surface-800 p-5" + use:enhance={() => { + createError = ''; + return async ({ result }) => { + if (result.type === 'failure' || (result.type === 'success' && (result.data as any)?.error)) { + createError = (result.data as any)?.error ?? 'Failed to create match'; + } + }; + }} + class="mt-4 rounded-lg border border-accent/20 bg-surface-800 p-5" >

Create Match

-
-
- - -
- -
- - -
- -
- - -
- -
- - -
- -
- - -
-
- {#if createError} -

{createError}

+

{createError}

{/if} - +
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+
+ +
+ +
{/if} - -
- {#each data.matches as m} - {@const p1 = m.participants[0]} - {@const p2 = m.participants[1]} - -
-
- {m.name} - - {m.state} - -
-

- {p1?.team.name ?? '?'} vs {p2?.team.name ?? '?'} - · {new Date(m.createdAt).toLocaleDateString()} -

+ +{#if liveMatches.length > 0} +
diff --git a/src/routes/(app)/teams/+page.server.ts b/src/routes/(app)/teams/+page.server.ts new file mode 100644 index 0000000..b6a8030 --- /dev/null +++ b/src/routes/(app)/teams/+page.server.ts @@ -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 }; + } +}; diff --git a/src/routes/(app)/teams/+page.svelte b/src/routes/(app)/teams/+page.svelte new file mode 100644 index 0000000..3c87bef --- /dev/null +++ b/src/routes/(app)/teams/+page.svelte @@ -0,0 +1,229 @@ + + +
+ +
+
+

Teams

+

+ {data.teams.length} team{data.teams.length !== 1 ? 's' : ''} +

+
+ +
+ + + {#if creating} +
{ + 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" + > + + +
+ {/if} + + {#if actionError} +

{actionError}

+ {/if} + + + {#if myTeams.length > 0} +
+

My Teams

+
+ {#each myTeams as t} +
+ + + + + {#if expandedTeam === t.id} +
+ +
+ {#each t.members as member} +
+ {#if member.user?.image} + + {:else} +
+ {/if} + {member.user?.name ?? 'Unknown'} + {member.role} + {#if isOwner(t) && member.userId !== data.userId} +
{ + return async ({ update }) => { await update(); }; + }}> + + +
+ {/if} +
+ {/each} +
+ + + {#if isOwner(t) && !t.isPersonal} + {#if addingMemberTo === t.id} +
{ + 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" + > + + + + +
+ {:else} + + {/if} + {/if} + + + {#if isOwner(t) && !t.isPersonal} +
+ + +
+ {/if} +
+ {/if} +
+ {/each} +
+
+ {/if} + + + {#if otherTeams.length > 0} +
+

Other Teams

+
+ {#each otherTeams as t} +
+ {#if t.avatarUrl} + + {:else} +
+ {t.name.charAt(0).toUpperCase()} +
+ {/if} +
+ {t.name} +

{t.members.length} member{t.members.length !== 1 ? 's' : ''}

+
+
+ {/each} +
+
+ {/if} + + + {#if data.teams.length === 0} +
+

No teams yet. Your personal team is created when you sign in.

+
+ {/if} +