add custom matches
This commit is contained in:
@@ -4,25 +4,31 @@ import { isRootAdmin } from '$lib/server/permissions';
|
||||
import { db } from '$lib/server/db';
|
||||
import { match, matchParticipant, matchParticipantPlayer } from '$lib/server/db/schema';
|
||||
import { eq, and, inArray } from 'drizzle-orm';
|
||||
import { getUnreadCount } from '$lib/server/notifications';
|
||||
|
||||
export const load: LayoutServerLoad = async ({ locals }) => {
|
||||
let activeMatch: { id: string; name: string | null; state: string } | null = null;
|
||||
let unreadNotificationCount = 0;
|
||||
|
||||
if (locals.user) {
|
||||
const rows = await db
|
||||
.select({ id: match.id, name: match.name, state: match.state })
|
||||
.from(matchParticipantPlayer)
|
||||
.innerJoin(matchParticipant, eq(matchParticipantPlayer.participantId, matchParticipant.id))
|
||||
.innerJoin(match, eq(matchParticipant.matchId, match.id))
|
||||
.where(
|
||||
and(
|
||||
eq(matchParticipantPlayer.userId, locals.user.id),
|
||||
inArray(match.state, ['CREATED', 'LOBBY', 'ROLLING', 'PICKING', 'PLAYING'])
|
||||
const [rows, notifCount] = await Promise.all([
|
||||
db
|
||||
.select({ id: match.id, name: match.name, state: match.state })
|
||||
.from(matchParticipantPlayer)
|
||||
.innerJoin(matchParticipant, eq(matchParticipantPlayer.participantId, matchParticipant.id))
|
||||
.innerJoin(match, eq(matchParticipant.matchId, match.id))
|
||||
.where(
|
||||
and(
|
||||
eq(matchParticipantPlayer.userId, locals.user.id),
|
||||
inArray(match.state, ['CREATED', 'LOBBY', 'ROLLING', 'PICKING', 'PLAYING'])
|
||||
)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
.limit(1),
|
||||
getUnreadCount(locals.user.id)
|
||||
]);
|
||||
|
||||
activeMatch = rows[0] ?? null;
|
||||
unreadNotificationCount = notifCount;
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -36,6 +42,7 @@ export const load: LayoutServerLoad = async ({ locals }) => {
|
||||
isRootAdmin: isRootAdmin(locals.user.email)
|
||||
}
|
||||
: null,
|
||||
activeMatch
|
||||
activeMatch,
|
||||
unreadNotificationCount
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,34 +1,41 @@
|
||||
import { db } from '$lib/server/db';
|
||||
import { match } from '$lib/server/db/schema';
|
||||
import { match, teamMember } from '$lib/server/db/schema';
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import { desc } from 'drizzle-orm';
|
||||
import { desc, eq } from 'drizzle-orm';
|
||||
import { createMatch } from '$lib/server/match/engine';
|
||||
import { initMatchLobby } from '$lib/server/match/orchestrator';
|
||||
import { createInvite } from '$lib/server/match/invites';
|
||||
import { requireAuth, requireRole, hasRole } from '$lib/server/permissions';
|
||||
import type { PageServerLoad, Actions } from './$types';
|
||||
|
||||
export const load: PageServerLoad = async ({ locals }) => {
|
||||
requireAuth(locals);
|
||||
|
||||
const matches = await db.query.match.findMany({
|
||||
with: { participants: { with: { team: true } }, mappool: { with: { slots: true } } },
|
||||
orderBy: desc(match.createdAt),
|
||||
limit: 50
|
||||
});
|
||||
|
||||
const teams = await db.query.team.findMany({
|
||||
orderBy: (t, { asc }) => [asc(t.name)]
|
||||
});
|
||||
|
||||
const mappools = await db.query.mappool.findMany({
|
||||
with: { slots: true },
|
||||
orderBy: (m, { desc }) => [desc(m.createdAt)]
|
||||
});
|
||||
const [matches, teams, mappools, userTeamMemberships] = await Promise.all([
|
||||
db.query.match.findMany({
|
||||
with: { participants: { with: { team: true } }, mappool: { with: { slots: true } } },
|
||||
orderBy: desc(match.createdAt),
|
||||
limit: 50
|
||||
}),
|
||||
db.query.team.findMany({
|
||||
with: { members: true },
|
||||
orderBy: (t, { asc }) => [asc(t.name)]
|
||||
}),
|
||||
db.query.mappool.findMany({
|
||||
with: { slots: true },
|
||||
orderBy: (m, { desc }) => [desc(m.createdAt)]
|
||||
}),
|
||||
db.query.teamMember.findMany({
|
||||
where: eq(teamMember.userId, locals.user!.id),
|
||||
with: { team: true }
|
||||
})
|
||||
]);
|
||||
|
||||
return {
|
||||
matches,
|
||||
teams,
|
||||
teams: teams.map((t) => ({ ...t, memberCount: t.members.length })),
|
||||
mappools,
|
||||
userTeams: userTeamMemberships.map((m) => m.team),
|
||||
canCreateMatch: hasRole(locals.user!.role, 'referee')
|
||||
};
|
||||
};
|
||||
@@ -72,5 +79,52 @@ export const actions: Actions = {
|
||||
});
|
||||
|
||||
redirect(303, `/matches/${result.id}`);
|
||||
},
|
||||
|
||||
createInvite: async ({ request, locals }) => {
|
||||
requireAuth(locals);
|
||||
|
||||
const form = await request.formData();
|
||||
const creatorTeamId = form.get('creatorTeamId')?.toString();
|
||||
const invitedTeamId = form.get('invitedTeamId')?.toString();
|
||||
const mappoolId = form.get('mappool')?.toString();
|
||||
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 allowEloChange = form.get('allowEloChange') === 'on';
|
||||
const message = form.get('message')?.toString()?.trim() || '';
|
||||
const scheduledAtStr = form.get('scheduledAt')?.toString();
|
||||
|
||||
if (!creatorTeamId || !invitedTeamId || !mappoolId) {
|
||||
return { error: 'All fields are required' };
|
||||
}
|
||||
|
||||
// Validate bestOf is odd and >= 1
|
||||
if (bestOf < 1 || bestOf % 2 === 0) {
|
||||
return { error: 'Best of must be an odd number >= 1' };
|
||||
}
|
||||
|
||||
try {
|
||||
await createInvite({
|
||||
createdBy: locals.user!.id,
|
||||
creatorTeamId,
|
||||
invitedTeamId,
|
||||
config: {
|
||||
bestOf,
|
||||
teamSize: Math.max(teamSize1, teamSize2),
|
||||
teamSizes: [teamSize1, teamSize2],
|
||||
scoringType,
|
||||
allowEloChange
|
||||
},
|
||||
mappoolId,
|
||||
message: message || undefined,
|
||||
scheduledAt: scheduledAtStr ? new Date(scheduledAtStr) : null
|
||||
});
|
||||
return { inviteSuccess: true };
|
||||
} catch (e: any) {
|
||||
console.error('[Matches] Invite error:', e);
|
||||
return { error: e.message };
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,10 +1,68 @@
|
||||
<script lang="ts">
|
||||
import { enhance } from '$app/forms';
|
||||
import { invalidateAll } from '$app/navigation';
|
||||
|
||||
let { data } = $props();
|
||||
|
||||
let showCreate = $state(false);
|
||||
let showInvite = $state(false);
|
||||
let createError = $state('');
|
||||
let inviteError = $state('');
|
||||
let inviteSuccess = $state(false);
|
||||
|
||||
// Challenge form state
|
||||
let selectedCreatorTeamId = $state('');
|
||||
let selectedInvitedTeamId = $state('');
|
||||
let bestOf = $state(5);
|
||||
let customBestOf = $state('');
|
||||
let useCustomBo = $state(false);
|
||||
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 hasMultipleTeams = $derived(nonPersonalUserTeams.length > 0);
|
||||
|
||||
// Auto-select personal team if no other teams
|
||||
$effect(() => {
|
||||
if (!hasMultipleTeams && personalTeam) {
|
||||
selectedCreatorTeamId = personalTeam.id;
|
||||
}
|
||||
});
|
||||
|
||||
const selectedCreatorTeam = $derived(data.teams.find((t: any) => t.id === selectedCreatorTeamId));
|
||||
const selectedInvitedTeam = $derived(data.teams.find((t: any) => t.id === selectedInvitedTeamId));
|
||||
const creatorIsPersonal = $derived(selectedCreatorTeam?.isPersonal ?? true);
|
||||
|
||||
const creatorMaxSize = $derived(selectedCreatorTeam?.memberCount ?? 1);
|
||||
const invitedMaxSize = $derived(selectedInvitedTeam?.memberCount ?? 1);
|
||||
|
||||
const effectiveBestOf = $derived(useCustomBo ? parseInt(customBestOf) || 5 : bestOf);
|
||||
|
||||
// Filter opponent teams for search
|
||||
const filteredOpponentTeams = $derived(
|
||||
data.teams.filter((t: any) => {
|
||||
if (t.id === selectedCreatorTeamId) return false;
|
||||
if (!opponentSearch) return true;
|
||||
return t.name.toLowerCase().includes(opponentSearch.toLowerCase());
|
||||
})
|
||||
);
|
||||
|
||||
function teamDisplayName(t: any): string {
|
||||
return t.name;
|
||||
}
|
||||
|
||||
function mappoolSummary(p: any): string {
|
||||
const slots = p.slots ?? [];
|
||||
if (!slots.length) return '0 maps';
|
||||
const avg = slots.reduce((s: number, sl: any) => s + (sl.starRating ?? 0), 0) / slots.length;
|
||||
// Count per category
|
||||
const counts: Record<string, number> = {};
|
||||
for (const sl of slots) {
|
||||
counts[sl.category] = (counts[sl.category] ?? 0) + 1;
|
||||
}
|
||||
const parts = Object.entries(counts).map(([cat, n]) => `${n}${cat}`);
|
||||
return `${avg.toFixed(1)}★ · ${parts.join(', ')}`;
|
||||
}
|
||||
|
||||
const stateConfig: Record<string, { label: string; color: string; dot?: string }> = {
|
||||
CREATED: { label: 'Created', color: 'text-text-secondary border-border' },
|
||||
@@ -80,14 +138,24 @@
|
||||
{/if}
|
||||
</p>
|
||||
</div>
|
||||
{#if data.canCreateMatch}
|
||||
<button
|
||||
onclick={() => (showCreate = !showCreate)}
|
||||
class="rounded-md bg-accent px-4 py-2 text-sm font-600 text-surface-900 transition-colors hover:bg-accent-hover"
|
||||
>
|
||||
{showCreate ? 'Cancel' : 'New Match'}
|
||||
</button>
|
||||
{/if}
|
||||
<div 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"
|
||||
>
|
||||
{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"
|
||||
>
|
||||
{showCreate ? 'Cancel' : 'New Match'}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Create Match Modal -->
|
||||
@@ -151,7 +219,7 @@
|
||||
>
|
||||
<option value="">Select team...</option>
|
||||
{#each data.teams as t}
|
||||
<option value={t.id}>{t.name}{t.isPersonal ? ' (solo)' : ''}</option>
|
||||
<option value={t.id}>{t.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
@@ -166,7 +234,7 @@
|
||||
>
|
||||
<option value="">Select team...</option>
|
||||
{#each data.teams as t}
|
||||
<option value={t.id}>{t.name}{t.isPersonal ? ' (solo)' : ''}</option>
|
||||
<option value={t.id}>{t.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
@@ -198,6 +266,201 @@
|
||||
</form>
|
||||
{/if}
|
||||
|
||||
<!-- Challenge / Invite Form -->
|
||||
{#if showInvite}
|
||||
<form
|
||||
method="post"
|
||||
action="?/createInvite"
|
||||
use:enhance={() => {
|
||||
inviteError = '';
|
||||
inviteSuccess = false;
|
||||
return async ({ result, update }) => {
|
||||
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);
|
||||
} else {
|
||||
await update();
|
||||
}
|
||||
};
|
||||
}}
|
||||
class="mt-4 rounded-lg border border-accent/20 bg-surface-800 p-5"
|
||||
>
|
||||
<h2 class="text-sm font-600">Challenge</h2>
|
||||
|
||||
{#if inviteError}
|
||||
<p class="mt-2 text-sm text-red-400">{inviteError}</p>
|
||||
{/if}
|
||||
{#if inviteSuccess}
|
||||
<p class="mt-2 text-sm text-green-400">Invite sent!</p>
|
||||
{/if}
|
||||
|
||||
<input type="hidden" name="bestOf" value={effectiveBestOf} />
|
||||
<input type="hidden" name="creatorTeamId" value={selectedCreatorTeamId} />
|
||||
<input type="hidden" name="invitedTeamId" value={selectedInvitedTeamId} />
|
||||
|
||||
<div class="mt-4 flex flex-col gap-4">
|
||||
<!-- 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">
|
||||
{#if personalTeam}
|
||||
<option value={personalTeam.id}>{teamDisplayName(personalTeam)}</option>
|
||||
{/if}
|
||||
{#each nonPersonalUserTeams as t}
|
||||
<option value={t.id}>{t.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Opponent (searchable) -->
|
||||
<div>
|
||||
<label for="inv-opponent-search" class="text-xs font-500 text-text-secondary">Opponent</label>
|
||||
<input
|
||||
type="text"
|
||||
id="inv-opponent-search"
|
||||
bind:value={opponentSearch}
|
||||
placeholder="Search players or teams..."
|
||||
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"
|
||||
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">
|
||||
<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">×</button>
|
||||
</div>
|
||||
{:else if opponentSearch}
|
||||
<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 = ''; }}
|
||||
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}
|
||||
<img src={t.avatarUrl} alt="" class="h-5 w-5 rounded-full" />
|
||||
{/if}
|
||||
<span class="font-500">{teamDisplayName(t)}</span>
|
||||
{#if !t.isPersonal}
|
||||
<span class="text-xs text-text-secondary">({t.memberCount}p)</span>
|
||||
{/if}
|
||||
</button>
|
||||
{:else}
|
||||
<div class="px-3 py-2 text-xs text-text-secondary">No results</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Best Of -->
|
||||
<div>
|
||||
<span class="text-xs font-500 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>
|
||||
{/each}
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
step="2"
|
||||
placeholder="Custom"
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<!-- 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">
|
||||
{#each Array.from({ length: creatorMaxSize }, (_, i) => i + 1) as n}
|
||||
<option value={n} selected={n === creatorMaxSize}>{n}v</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
{:else}
|
||||
<input type="hidden" name="teamSize1" value="1" />
|
||||
{/if}
|
||||
|
||||
{#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">
|
||||
{#each Array.from({ length: invitedMaxSize }, (_, i) => i + 1) as n}
|
||||
<option value={n} selected={n === invitedMaxSize}>v{n}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
{:else}
|
||||
<input type="hidden" name="teamSize2" value="1" />
|
||||
{/if}
|
||||
|
||||
<!-- 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">
|
||||
<option value="score_v2" selected>ScoreV2</option>
|
||||
<option value="score">Score</option>
|
||||
<option value="accuracy">Accuracy</option>
|
||||
<option value="combo">Combo</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- 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">
|
||||
<option value="">Select mappool...</option>
|
||||
{#each data.mappools as p}
|
||||
<option value={p.id}>{p.name} — {mappoolSummary(p)}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 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" />
|
||||
Affects ELO
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- 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" />
|
||||
</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" />
|
||||
</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">
|
||||
Send Challenge
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
{/if}
|
||||
|
||||
<!-- Live Matches -->
|
||||
{#if liveMatches.length > 0}
|
||||
<div class="mt-6">
|
||||
|
||||
@@ -16,8 +16,9 @@
|
||||
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))
|
||||
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);
|
||||
|
||||
function isOwner(t: any) {
|
||||
return t.ownerId === data.userId;
|
||||
@@ -39,7 +40,7 @@
|
||||
<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' : ''}
|
||||
{teamCount} team{teamCount !== 1 ? 's' : ''}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
@@ -108,7 +109,7 @@
|
||||
{/if}
|
||||
<div class="flex-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-sm font-600">{t.name}</span>
|
||||
<a href="/teams/{t.id}" onclick={(e: MouseEvent) => e.stopPropagation()} class="text-sm font-600 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"
|
||||
@@ -301,7 +302,7 @@
|
||||
<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">
|
||||
<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}
|
||||
@@ -317,7 +318,7 @@
|
||||
{t.members.length} member{t.members.length !== 1 ? 's' : ''}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
56
src/routes/(app)/teams/[id]/+page.server.ts
Normal file
56
src/routes/(app)/teams/[id]/+page.server.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { db } from '$lib/server/db';
|
||||
import { team, teamMember, match, matchParticipant } from '$lib/server/db/schema';
|
||||
import { user } from '$lib/server/db/auth.schema';
|
||||
import { eq, and, desc } from 'drizzle-orm';
|
||||
import { error } from '@sveltejs/kit';
|
||||
import { requireAuth } from '$lib/server/permissions';
|
||||
import type { PageServerLoad } from './$types';
|
||||
|
||||
export const load: PageServerLoad = async ({ params, locals }) => {
|
||||
requireAuth(locals);
|
||||
|
||||
const t = await db.query.team.findFirst({
|
||||
where: eq(team.id, params.id),
|
||||
with: { members: true }
|
||||
});
|
||||
|
||||
if (!t) error(404, 'Team not found');
|
||||
|
||||
// Fetch member user details
|
||||
const membersWithUsers = await Promise.all(
|
||||
t.members.map(async (m) => {
|
||||
const usr = await db.query.user.findFirst({
|
||||
where: eq(user.id, m.userId)
|
||||
});
|
||||
return { ...m, user: usr ? { id: usr.id, name: usr.name, image: usr.image } : null };
|
||||
})
|
||||
);
|
||||
|
||||
// Fetch recent matches for this team
|
||||
const participants = await db.query.matchParticipant.findMany({
|
||||
where: eq(matchParticipant.teamId, params.id),
|
||||
with: {
|
||||
match: {
|
||||
with: {
|
||||
participants: { with: { team: true } }
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const recentMatches = participants
|
||||
.map((p) => p.match)
|
||||
.filter((m) => m.state === 'FINISHED' || m.state === 'CANCELLED')
|
||||
.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())
|
||||
.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;
|
||||
|
||||
return {
|
||||
team: { ...t, members: membersWithUsers },
|
||||
recentMatches,
|
||||
wins,
|
||||
losses
|
||||
};
|
||||
};
|
||||
94
src/routes/(app)/teams/[id]/+page.svelte
Normal file
94
src/routes/(app)/teams/[id]/+page.svelte
Normal file
@@ -0,0 +1,94 @@
|
||||
<script lang="ts">
|
||||
let { data } = $props();
|
||||
|
||||
const t = $derived(data.team);
|
||||
|
||||
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`;
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{t.name} — Vash Esports</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="mx-auto max-w-4xl">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center gap-4">
|
||||
{#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">
|
||||
{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>
|
||||
{#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-sm text-text-secondary">
|
||||
{t.members.length} member{t.members.length !== 1 ? 's' : ''}
|
||||
· {data.wins}W {data.losses}L
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Players -->
|
||||
<div class="mt-6">
|
||||
<h2 class="text-sm font-600 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">
|
||||
{#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="text-xs text-text-secondary">{member.role}</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Recent Matches -->
|
||||
{#if data.recentMatches.length > 0}
|
||||
<div class="mt-6">
|
||||
<h2 class="text-sm font-600 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]}
|
||||
{@const p2 = m.participants[1]}
|
||||
{@const won = m.winnerId === t.id}
|
||||
<a
|
||||
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>
|
||||
{#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>
|
||||
<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}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
36
src/routes/api/invites/+server.ts
Normal file
36
src/routes/api/invites/+server.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { json, error } from '@sveltejs/kit';
|
||||
import { createInvite, getInvitesForUser } from '$lib/server/match/invites';
|
||||
import type { RequestHandler } from './$types';
|
||||
|
||||
export const GET: RequestHandler = async ({ locals }) => {
|
||||
if (!locals.user) error(401, 'Not logged in');
|
||||
const invites = await getInvitesForUser(locals.user.id);
|
||||
return json(invites);
|
||||
};
|
||||
|
||||
export const POST: RequestHandler = async ({ request, locals }) => {
|
||||
if (!locals.user) error(401, 'Not logged in');
|
||||
|
||||
const body = await request.json();
|
||||
const { creatorTeamId, invitedTeamId, config, mappoolId, name, message, scheduledAt } = body;
|
||||
|
||||
if (!creatorTeamId || !invitedTeamId || !config || !mappoolId) {
|
||||
error(400, 'Missing required fields');
|
||||
}
|
||||
|
||||
try {
|
||||
const invite = await createInvite({
|
||||
createdBy: locals.user.id,
|
||||
creatorTeamId,
|
||||
invitedTeamId,
|
||||
config,
|
||||
mappoolId,
|
||||
name,
|
||||
message,
|
||||
scheduledAt: scheduledAt ? new Date(scheduledAt) : null
|
||||
});
|
||||
return json(invite, { status: 201 });
|
||||
} catch (e: any) {
|
||||
error(400, e.message);
|
||||
}
|
||||
};
|
||||
14
src/routes/api/invites/[id]/accept/+server.ts
Normal file
14
src/routes/api/invites/[id]/accept/+server.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { json, error } from '@sveltejs/kit';
|
||||
import { acceptInvite } from '$lib/server/match/invites';
|
||||
import type { RequestHandler } from './$types';
|
||||
|
||||
export const POST: RequestHandler = async ({ params, locals }) => {
|
||||
if (!locals.user) error(401, 'Not logged in');
|
||||
|
||||
try {
|
||||
const match = await acceptInvite(params.id, locals.user.id);
|
||||
return json(match);
|
||||
} catch (e: any) {
|
||||
error(400, e.message);
|
||||
}
|
||||
};
|
||||
14
src/routes/api/invites/[id]/cancel/+server.ts
Normal file
14
src/routes/api/invites/[id]/cancel/+server.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { json, error } from '@sveltejs/kit';
|
||||
import { cancelInvite } from '$lib/server/match/invites';
|
||||
import type { RequestHandler } from './$types';
|
||||
|
||||
export const POST: RequestHandler = async ({ params, locals }) => {
|
||||
if (!locals.user) error(401, 'Not logged in');
|
||||
|
||||
try {
|
||||
await cancelInvite(params.id, locals.user.id);
|
||||
return json({ success: true });
|
||||
} catch (e: any) {
|
||||
error(400, e.message);
|
||||
}
|
||||
};
|
||||
14
src/routes/api/invites/[id]/decline/+server.ts
Normal file
14
src/routes/api/invites/[id]/decline/+server.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { json, error } from '@sveltejs/kit';
|
||||
import { declineInvite } from '$lib/server/match/invites';
|
||||
import type { RequestHandler } from './$types';
|
||||
|
||||
export const POST: RequestHandler = async ({ params, locals }) => {
|
||||
if (!locals.user) error(401, 'Not logged in');
|
||||
|
||||
try {
|
||||
await declineInvite(params.id, locals.user.id);
|
||||
return json({ success: true });
|
||||
} catch (e: any) {
|
||||
error(400, e.message);
|
||||
}
|
||||
};
|
||||
13
src/routes/api/notifications/+server.ts
Normal file
13
src/routes/api/notifications/+server.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { json, error } from '@sveltejs/kit';
|
||||
import { getNotifications } from '$lib/server/notifications';
|
||||
import type { RequestHandler } from './$types';
|
||||
|
||||
export const GET: RequestHandler = async ({ locals, url }) => {
|
||||
if (!locals.user) error(401, 'Not logged in');
|
||||
|
||||
const limit = parseInt(url.searchParams.get('limit') ?? '50');
|
||||
const offset = parseInt(url.searchParams.get('offset') ?? '0');
|
||||
|
||||
const notifications = await getNotifications(locals.user.id, { limit, offset });
|
||||
return json(notifications);
|
||||
};
|
||||
9
src/routes/api/notifications/count/+server.ts
Normal file
9
src/routes/api/notifications/count/+server.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { json, error } from '@sveltejs/kit';
|
||||
import { getUnreadCount } from '$lib/server/notifications';
|
||||
import type { RequestHandler } from './$types';
|
||||
|
||||
export const GET: RequestHandler = async ({ locals }) => {
|
||||
if (!locals.user) error(401, 'Not logged in');
|
||||
const count = await getUnreadCount(locals.user.id);
|
||||
return json({ count });
|
||||
};
|
||||
19
src/routes/api/notifications/read/+server.ts
Normal file
19
src/routes/api/notifications/read/+server.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { json, error } from '@sveltejs/kit';
|
||||
import { markRead, markAllRead } from '$lib/server/notifications';
|
||||
import type { RequestHandler } from './$types';
|
||||
|
||||
export const POST: RequestHandler = async ({ request, locals }) => {
|
||||
if (!locals.user) error(401, 'Not logged in');
|
||||
|
||||
const body = await request.json();
|
||||
|
||||
if (body.all) {
|
||||
await markAllRead(locals.user.id);
|
||||
} else if (body.notificationId) {
|
||||
await markRead(body.notificationId, locals.user.id);
|
||||
} else {
|
||||
error(400, 'Provide notificationId or { all: true }');
|
||||
}
|
||||
|
||||
return json({ success: true });
|
||||
};
|
||||
Reference in New Issue
Block a user