feat: add tournaments and validation and security
This commit is contained in:
903
src/routes/(app)/tournaments/[id]/+page.svelte
Normal file
903
src/routes/(app)/tournaments/[id]/+page.svelte
Normal file
@@ -0,0 +1,903 @@
|
||||
<script lang="ts">
|
||||
import { enhance } from '$app/forms';
|
||||
import { invalidateAll } from '$app/navigation';
|
||||
import { SvelteMap } from 'svelte/reactivity';
|
||||
|
||||
const { data } = $props();
|
||||
const t = $derived(data.tournament);
|
||||
const matches = $derived(data.matches);
|
||||
|
||||
let activeTab = $state('overview');
|
||||
let actionError = $state('');
|
||||
let actionSuccess = $state('');
|
||||
|
||||
// round form state
|
||||
let showAddRound = $state(false);
|
||||
let editingRoundId = $state<string | null>(null);
|
||||
|
||||
const formatLabels: Record<string, string> = {
|
||||
single_elim: 'Single Elimination',
|
||||
double_elim: 'Double Elimination',
|
||||
groups_bracket: 'Groups + Bracket'
|
||||
};
|
||||
|
||||
const stateColors: Record<string, string> = {
|
||||
DRAFT: 'bg-surface-600 text-text-secondary',
|
||||
REGISTRATION: 'bg-green-900 text-green-300',
|
||||
QUALIFIERS: 'bg-yellow-900 text-yellow-300',
|
||||
SEEDING: 'bg-yellow-900 text-yellow-300',
|
||||
BRACKET: 'bg-blue-900 text-blue-300',
|
||||
FINISHED: 'bg-surface-700 text-text-secondary',
|
||||
CANCELLED: 'bg-red-900 text-red-300'
|
||||
};
|
||||
|
||||
const activeRegs = $derived(
|
||||
t.registrations.filter((r: any) => r.status !== 'withdrawn' && r.status !== 'eliminated')
|
||||
);
|
||||
|
||||
// invite link
|
||||
const inviteUrl = $derived(
|
||||
typeof window !== 'undefined'
|
||||
? `${window.location.origin}/tournaments/${t.id}`
|
||||
: `/tournaments/${t.id}`
|
||||
);
|
||||
let copied = $state(false);
|
||||
function copyInvite() {
|
||||
navigator.clipboard.writeText(inviteUrl);
|
||||
copied = true;
|
||||
setTimeout(() => (copied = false), 2000);
|
||||
}
|
||||
|
||||
// bracket helpers
|
||||
const wbMatches = $derived(
|
||||
matches.filter((m: any) => m.round?.bracketType === 'winners' || !m.round?.bracketType)
|
||||
);
|
||||
const lbMatches = $derived(matches.filter((m: any) => m.round?.bracketType === 'losers'));
|
||||
const gfMatches = $derived(matches.filter((m: any) => m.round?.bracketType === 'grand_final'));
|
||||
|
||||
function groupByRound(matchList: any[]) {
|
||||
const rounds = new SvelteMap<number, { round: any; matches: any[] }>();
|
||||
for (const m of matchList) {
|
||||
const order = m.round?.roundOrder ?? 0;
|
||||
if (!rounds.has(order)) {
|
||||
rounds.set(order, { round: m.round, matches: [] });
|
||||
}
|
||||
rounds.get(order)!.matches.push(m);
|
||||
}
|
||||
return Array.from(rounds.values()).sort((a, b) => a.round.roundOrder - b.round.roundOrder);
|
||||
}
|
||||
|
||||
const wbRounds = $derived(groupByRound(wbMatches));
|
||||
const lbRounds = $derived(groupByRound(lbMatches));
|
||||
|
||||
function handleAction() {
|
||||
return ({ result }: any) => {
|
||||
if (result.type === 'failure' || (result.type === 'success' && result.data?.error)) {
|
||||
actionError = result.data?.error ?? 'Something went wrong';
|
||||
actionSuccess = '';
|
||||
} else {
|
||||
actionError = '';
|
||||
invalidateAll();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function handleActionWithMessage(msg: string) {
|
||||
return ({ result }: any) => {
|
||||
if (result.type === 'failure' || (result.type === 'success' && result.data?.error)) {
|
||||
actionError = result.data?.error ?? 'Something went wrong';
|
||||
actionSuccess = '';
|
||||
} else {
|
||||
actionError = '';
|
||||
actionSuccess = msg;
|
||||
showAddRound = false;
|
||||
editingRoundId = null;
|
||||
invalidateAll();
|
||||
setTimeout(() => (actionSuccess = ''), 3000);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// which tabs to show
|
||||
const tabs = $derived(() => {
|
||||
const list: [string, string][] = [['overview', 'Overview']];
|
||||
if (matches.length > 0) list.push(['bracket', 'Bracket']);
|
||||
list.push(['registrations', 'Registrations']);
|
||||
list.push(['rounds', 'Rounds']);
|
||||
if (t.format === 'groups_bracket' && t.groups.length > 0) list.push(['groups', 'Groups']);
|
||||
if (data.isStaff) list.push(['settings', 'Settings']);
|
||||
return list;
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{t.name} — Vash Esports</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="mx-auto max-w-5xl">
|
||||
<!-- Header -->
|
||||
<div class="flex items-start justify-between">
|
||||
<div>
|
||||
<div class="flex items-center gap-3">
|
||||
<h1 class="font-700 text-2xl tracking-tight">{t.name}</h1>
|
||||
<span
|
||||
class="font-500 rounded px-2 py-0.5 text-xs {stateColors[t.state] ??
|
||||
'bg-surface-600 text-text-secondary'}"
|
||||
>
|
||||
{t.state}
|
||||
</span>
|
||||
</div>
|
||||
{#if t.description}
|
||||
<p class="mt-1 text-sm text-text-secondary">{t.description}</p>
|
||||
{/if}
|
||||
<p class="mt-1 text-xs text-text-secondary">
|
||||
{formatLabels[t.format] ?? t.format}
|
||||
· {activeRegs.length}/{t.maxSlots} players
|
||||
{#if t.startAt}
|
||||
· starts {new Date(t.startAt).toLocaleDateString()}
|
||||
{/if}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
onclick={copyInvite}
|
||||
class="font-600 rounded-md border border-border px-3 py-2 text-xs text-text-secondary transition-colors hover:border-accent/40 hover:text-text-primary"
|
||||
>
|
||||
{copied ? 'Copied!' : 'Copy Link'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Errors / Success -->
|
||||
{#if actionError}
|
||||
<div class="mt-4 rounded-md border border-red-800 bg-red-900/30 px-4 py-3 text-sm text-red-300">
|
||||
{actionError}
|
||||
<button onclick={() => (actionError = '')} class="ml-2 text-red-400 hover:text-red-300"
|
||||
>×</button
|
||||
>
|
||||
</div>
|
||||
{/if}
|
||||
{#if actionSuccess}
|
||||
<div
|
||||
class="mt-4 rounded-md border border-green-800 bg-green-900/30 px-4 py-3 text-sm text-green-300"
|
||||
>
|
||||
{actionSuccess}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Registration banner for players -->
|
||||
{#if t.state === 'REGISTRATION' && !data.isStaff}
|
||||
<div class="mt-4 rounded-lg border border-border bg-surface-800 p-4">
|
||||
{#if data.userRegistration}
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm text-green-400"
|
||||
>You are registered with {data.userRegistration.team?.name ?? 'your team'}</span
|
||||
>
|
||||
<form method="POST" action="?/withdraw" use:enhance={() => handleAction()}>
|
||||
<input type="hidden" name="teamId" value={data.userRegistration.teamId} />
|
||||
<button
|
||||
class="font-600 rounded-md border border-red-800 px-3 py-1.5 text-xs text-red-300 hover:bg-red-900/30"
|
||||
>
|
||||
Withdraw
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
{:else if data.userTeams.length > 0}
|
||||
<form
|
||||
method="POST"
|
||||
action="?/register"
|
||||
use:enhance={() => handleAction()}
|
||||
class="flex items-center gap-3"
|
||||
>
|
||||
<span class="font-500 text-xs text-text-secondary">Register as:</span>
|
||||
<select
|
||||
name="teamId"
|
||||
required
|
||||
class="rounded-md border border-border bg-surface-700 px-3 py-1.5 text-sm text-text-primary focus:border-accent focus:outline-none"
|
||||
>
|
||||
{#each data.userTeams as team}
|
||||
<option value={team.id}>{team.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
<button
|
||||
class="font-600 rounded-md bg-accent px-4 py-1.5 text-xs text-surface-900 transition-colors hover:bg-accent-hover"
|
||||
>
|
||||
Register
|
||||
</button>
|
||||
</form>
|
||||
{:else}
|
||||
<p class="text-sm text-text-secondary">You need a team to register for this tournament</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Tabs -->
|
||||
<div class="mt-6 flex border-b border-border">
|
||||
{#each tabs() as [value, label]}
|
||||
<button
|
||||
class="font-500 border-b-2 px-4 py-2 text-sm transition-colors {activeTab === value
|
||||
? 'border-accent text-text-primary'
|
||||
: 'border-transparent text-text-secondary hover:text-text-primary'}"
|
||||
onclick={() => (activeTab = value)}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- Tab Content -->
|
||||
<div class="mt-4">
|
||||
{#if activeTab === 'overview'}
|
||||
{@render overviewTab()}
|
||||
{:else if activeTab === 'bracket'}
|
||||
{@render bracketTab()}
|
||||
{:else if activeTab === 'registrations'}
|
||||
{@render registrationsTab()}
|
||||
{:else if activeTab === 'rounds'}
|
||||
{@render roundsTab()}
|
||||
{:else if activeTab === 'groups'}
|
||||
{@render groupsTab()}
|
||||
{:else if activeTab === 'settings'}
|
||||
{@render settingsTab()}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ─── Overview Tab ─────────────────────────────────────────────────── -->
|
||||
{#snippet overviewTab()}
|
||||
<div class="grid gap-4 md:grid-cols-2">
|
||||
<!-- Tournament Info -->
|
||||
<div class="rounded-lg border border-border bg-surface-800 p-5">
|
||||
<h3 class="font-600 text-sm">Details</h3>
|
||||
<dl class="mt-3 flex flex-col gap-2 text-sm">
|
||||
<div class="flex justify-between">
|
||||
<dt class="text-text-secondary">Format</dt>
|
||||
<dd class="font-500">{formatLabels[t.format] ?? t.format}</dd>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<dt class="text-text-secondary">Players</dt>
|
||||
<dd class="font-500">{activeRegs.length} / {t.maxSlots}</dd>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<dt class="text-text-secondary">Team Size</dt>
|
||||
<dd class="font-500">
|
||||
{(t.config as any)?.teamSize ?? 1}v{(t.config as any)?.teamSize ?? 1}
|
||||
</dd>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<dt class="text-text-secondary">Scoring</dt>
|
||||
<dd class="font-500">{(t.config as any)?.scoringType ?? 'score_v2'}</dd>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<dt class="text-text-secondary">Rounds</dt>
|
||||
<dd class="font-500">{t.rounds.length}</dd>
|
||||
</div>
|
||||
{#if t.startAt}
|
||||
<div class="flex justify-between">
|
||||
<dt class="text-text-secondary">Start Date</dt>
|
||||
<dd class="font-500">{new Date(t.startAt).toLocaleString()}</dd>
|
||||
</div>
|
||||
{/if}
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
<!-- Staff -->
|
||||
<div class="rounded-lg border border-border bg-surface-800 p-5">
|
||||
<h3 class="font-600 text-sm">Staff</h3>
|
||||
{#if t.staff.length === 0}
|
||||
<p class="mt-3 text-sm text-text-secondary">No staff assigned</p>
|
||||
{:else}
|
||||
<div class="mt-3 flex flex-col gap-1.5">
|
||||
{#each t.staff as s}
|
||||
<div class="flex items-center justify-between text-sm">
|
||||
<span class="font-500">{s.userId.slice(0, 8)}...</span>
|
||||
<span class="rounded bg-surface-600 px-2 py-0.5 text-xs text-text-secondary"
|
||||
>{s.role}</span
|
||||
>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Share / Invite -->
|
||||
<div class="rounded-lg border border-border bg-surface-800 p-5 md:col-span-2">
|
||||
<h3 class="font-600 text-sm">Invite Players</h3>
|
||||
<p class="mt-1 text-xs text-text-secondary">
|
||||
Share this link with players to let them register
|
||||
</p>
|
||||
<div class="mt-3 flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
readonly
|
||||
value={inviteUrl}
|
||||
class="flex-1 rounded-md border border-border bg-surface-700 px-3 py-2 text-sm text-text-primary focus:outline-none"
|
||||
/>
|
||||
<button
|
||||
onclick={copyInvite}
|
||||
class="font-600 rounded-md bg-accent px-4 py-2 text-sm text-surface-900 transition-colors hover:bg-accent-hover"
|
||||
>
|
||||
{copied ? 'Copied!' : 'Copy'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Quick Rounds Preview -->
|
||||
{#if t.rounds.length > 0}
|
||||
<div class="rounded-lg border border-border bg-surface-800 p-5 md:col-span-2">
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="font-600 text-sm">Rounds</h3>
|
||||
<button
|
||||
onclick={() => (activeTab = 'rounds')}
|
||||
class="text-xs text-accent hover:text-accent-hover">View all</button
|
||||
>
|
||||
</div>
|
||||
<div class="mt-3 flex flex-wrap gap-2">
|
||||
{#each t.rounds as round}
|
||||
<div
|
||||
class="flex items-center gap-2 rounded-md border border-border bg-surface-700 px-3 py-1.5 text-xs"
|
||||
>
|
||||
<span class="font-600">{round.abbreviation ?? round.name}</span>
|
||||
<span class="text-text-secondary">BO{round.bestOf}</span>
|
||||
{#if round.mappool}
|
||||
<span class="text-text-secondary">· {round.mappool.name}</span>
|
||||
{:else}
|
||||
<span class="text-yellow-400">no pool</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
<!-- ─── Bracket Tab ──────────────────────────────────────────────────── -->
|
||||
{#snippet bracketTab()}
|
||||
{#if matches.length === 0}
|
||||
<div class="rounded-lg border border-dashed border-border py-12 text-center">
|
||||
<p class="text-sm text-text-secondary">Bracket has not been generated yet</p>
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Winners Bracket -->
|
||||
{#if wbRounds.length > 0}
|
||||
{#if t.format === 'double_elim'}
|
||||
<h3 class="font-600 mb-3 text-xs text-text-secondary uppercase">Winners Bracket</h3>
|
||||
{/if}
|
||||
<div class="mb-8 flex gap-6 overflow-x-auto pb-4">
|
||||
{#each wbRounds as { round, matches: roundMatches }}
|
||||
<div class="shrink-0">
|
||||
<div class="mb-2 text-center text-xs text-text-secondary">
|
||||
{round?.name ?? 'Round'}
|
||||
</div>
|
||||
<div
|
||||
class="flex flex-col justify-around gap-3"
|
||||
style="min-height: {roundMatches.length * 80}px"
|
||||
>
|
||||
{#each roundMatches.sort((a: any, b: any) => a.bracketPosition - b.bracketPosition) as m}
|
||||
{@render matchCard(m)}
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Losers Bracket -->
|
||||
{#if lbRounds.length > 0}
|
||||
<h3 class="font-600 mb-3 text-xs text-text-secondary uppercase">Losers Bracket</h3>
|
||||
<div class="mb-8 flex gap-6 overflow-x-auto pb-4">
|
||||
{#each lbRounds as { round, matches: roundMatches }}
|
||||
<div class="shrink-0">
|
||||
<div class="mb-2 text-center text-xs text-text-secondary">
|
||||
{round?.name ?? 'Round'}
|
||||
</div>
|
||||
<div class="flex flex-col justify-around gap-3">
|
||||
{#each roundMatches.sort((a: any, b: any) => a.bracketPosition - b.bracketPosition) as m}
|
||||
{@render matchCard(m)}
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Grand Finals -->
|
||||
{#if gfMatches.length > 0}
|
||||
<h3 class="font-600 mb-3 text-xs text-text-secondary uppercase">Grand Finals</h3>
|
||||
<div class="mb-8 flex gap-6">
|
||||
{#each gfMatches.sort((a: any, b: any) => (a.round?.roundOrder ?? 0) - (b.round?.roundOrder ?? 0)) as m}
|
||||
{@render matchCard(m)}
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
<!-- ─── Registrations Tab ────────────────────────────────────────────── -->
|
||||
{#snippet registrationsTab()}
|
||||
<div class="rounded-lg border border-border bg-surface-800">
|
||||
{#if t.registrations.length === 0}
|
||||
<div class="py-8 text-center text-sm text-text-secondary">No registrations yet</div>
|
||||
{:else}
|
||||
<table class="w-full text-sm">
|
||||
<thead>
|
||||
<tr class="border-b border-border text-left">
|
||||
<th class="font-500 px-4 py-3 text-xs text-text-secondary">Seed</th>
|
||||
<th class="font-500 px-4 py-3 text-xs text-text-secondary">Team</th>
|
||||
<th class="font-500 px-4 py-3 text-xs text-text-secondary">Status</th>
|
||||
<th class="font-500 px-4 py-3 text-xs text-text-secondary">Registered</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each t.registrations as reg}
|
||||
<tr class="border-b border-border/50 hover:bg-surface-700">
|
||||
<td class="px-4 py-3 text-text-secondary">{reg.seed ?? '-'}</td>
|
||||
<td class="font-500 px-4 py-3">{reg.team?.name ?? 'Unknown'}</td>
|
||||
<td class="px-4 py-3">
|
||||
<span
|
||||
class="font-500 rounded px-2 py-0.5 text-xs {reg.status === 'registered' ||
|
||||
reg.status === 'confirmed'
|
||||
? 'bg-green-900 text-green-300'
|
||||
: reg.status === 'withdrawn'
|
||||
? 'bg-red-900 text-red-300'
|
||||
: 'bg-surface-600 text-text-secondary'}"
|
||||
>
|
||||
{reg.status}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-text-secondary">
|
||||
{new Date(reg.registeredAt).toLocaleDateString()}
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
{/if}
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
<!-- ─── Rounds Tab ───────────────────────────────────────────────────── -->
|
||||
{#snippet roundsTab()}
|
||||
<div class="flex flex-col gap-3">
|
||||
{#if t.rounds.length === 0 && !showAddRound}
|
||||
<div class="rounded-lg border border-dashed border-border py-8 text-center">
|
||||
<p class="text-sm text-text-secondary">No rounds configured yet</p>
|
||||
{#if data.isStaff && t.state === 'DRAFT'}
|
||||
<button
|
||||
onclick={() => (showAddRound = true)}
|
||||
class="font-600 mt-3 rounded-md bg-accent px-4 py-2 text-xs text-surface-900 transition-colors hover:bg-accent-hover"
|
||||
>
|
||||
Add First Round
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
{#each t.rounds as round}
|
||||
{#if editingRoundId === round.id && data.isStaff}
|
||||
<!-- Inline edit form -->
|
||||
<form
|
||||
method="POST"
|
||||
action="?/updateRound"
|
||||
use:enhance={() => handleActionWithMessage('Round updated')}
|
||||
class="rounded-lg border border-accent/20 bg-surface-800 p-4"
|
||||
>
|
||||
<input type="hidden" name="roundId" value={round.id} />
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<span class="font-500 text-xs text-text-secondary">Name</span>
|
||||
<input
|
||||
name="name"
|
||||
type="text"
|
||||
value={round.name}
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<span class="font-500 text-xs text-text-secondary">Abbreviation</span>
|
||||
<input
|
||||
name="abbreviation"
|
||||
type="text"
|
||||
value={round.abbreviation ?? ''}
|
||||
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>
|
||||
<span class="font-500 text-xs text-text-secondary">Best Of</span>
|
||||
<select
|
||||
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"
|
||||
>
|
||||
{#each [3, 5, 7, 9, 11, 13] as n}
|
||||
<option value={n} selected={n === round.bestOf}>BO{n}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<span class="font-500 text-xs text-text-secondary">Mappool</span>
|
||||
<select
|
||||
name="mappoolId"
|
||||
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="">None</option>
|
||||
{#each data.mappools as mp}
|
||||
<option value={mp.id} selected={mp.id === round.mappoolId}
|
||||
>{mp.name} ({mp.slotCount} maps)</option
|
||||
>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-span-2">
|
||||
<span class="font-500 text-xs text-text-secondary">Scheduled At</span>
|
||||
<input
|
||||
name="scheduledAt"
|
||||
type="datetime-local"
|
||||
value={round.scheduledAt
|
||||
? new Date(round.scheduledAt).toISOString().slice(0, 16)
|
||||
: ''}
|
||||
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 class="mt-3 flex justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (editingRoundId = null)}
|
||||
class="font-600 rounded-md border border-border px-3 py-1.5 text-xs text-text-secondary hover:text-text-primary"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
class="font-600 rounded-md bg-accent px-4 py-1.5 text-xs text-surface-900 transition-colors hover:bg-accent-hover"
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
{:else}
|
||||
<!-- Round card -->
|
||||
<div class="rounded-lg border border-border bg-surface-800 p-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="font-600 text-sm">{round.name}</span>
|
||||
{#if round.abbreviation}
|
||||
<span class="rounded bg-surface-600 px-2 py-0.5 text-xs text-text-secondary"
|
||||
>{round.abbreviation}</span
|
||||
>
|
||||
{/if}
|
||||
<span class="text-xs text-text-secondary">BO{round.bestOf}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
{#if round.mappool}
|
||||
<span class="text-xs text-text-secondary"
|
||||
>{round.mappool.name} ({round.mappool.slots.length} maps)</span
|
||||
>
|
||||
{:else}
|
||||
<span class="text-xs text-yellow-400">no mappool</span>
|
||||
{/if}
|
||||
{#if round.scheduledAt}
|
||||
<span class="text-xs text-text-secondary"
|
||||
>{new Date(round.scheduledAt).toLocaleString()}</span
|
||||
>
|
||||
{/if}
|
||||
{#if data.isStaff && t.state === 'DRAFT'}
|
||||
<button
|
||||
onclick={() => (editingRoundId = round.id)}
|
||||
class="text-xs text-accent hover:text-accent-hover"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<form
|
||||
method="POST"
|
||||
action="?/deleteRound"
|
||||
use:enhance={() => handleActionWithMessage('Round deleted')}
|
||||
>
|
||||
<input type="hidden" name="roundId" value={round.id} />
|
||||
<button class="text-xs text-red-400 hover:text-red-300">Delete</button>
|
||||
</form>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
{/if}
|
||||
|
||||
<!-- Add Round Form -->
|
||||
{#if data.isStaff && t.state === 'DRAFT'}
|
||||
{#if showAddRound}
|
||||
<form
|
||||
method="POST"
|
||||
action="?/addRound"
|
||||
use:enhance={() => handleActionWithMessage('Round added')}
|
||||
class="rounded-lg border border-accent/20 bg-surface-800 p-4"
|
||||
>
|
||||
<h3 class="font-600 text-sm">Add Round</h3>
|
||||
<div class="mt-3 grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<span class="font-500 text-xs text-text-secondary">Name</span>
|
||||
<input
|
||||
name="name"
|
||||
type="text"
|
||||
required
|
||||
placeholder="e.g. Quarterfinals"
|
||||
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>
|
||||
<span class="font-500 text-xs text-text-secondary">Abbreviation</span>
|
||||
<input
|
||||
name="abbreviation"
|
||||
type="text"
|
||||
placeholder="e.g. QF"
|
||||
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>
|
||||
<span class="font-500 text-xs text-text-secondary">Best Of</span>
|
||||
<select
|
||||
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"
|
||||
>
|
||||
{#each [3, 5, 7, 9, 11, 13] as n}
|
||||
<option value={n} selected={n === 5}>BO{n}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<span class="font-500 text-xs text-text-secondary">Mappool</span>
|
||||
<select
|
||||
name="mappoolId"
|
||||
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="">None</option>
|
||||
{#each data.mappools as mp}
|
||||
<option value={mp.id}>{mp.name} ({mp.slotCount} maps)</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-span-2">
|
||||
<span class="font-500 text-xs text-text-secondary">Scheduled At (optional)</span>
|
||||
<input
|
||||
name="scheduledAt"
|
||||
type="datetime-local"
|
||||
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 class="mt-3 flex justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (showAddRound = false)}
|
||||
class="font-600 rounded-md border border-border px-3 py-1.5 text-xs text-text-secondary hover:text-text-primary"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
class="font-600 rounded-md bg-accent px-4 py-1.5 text-xs text-surface-900 transition-colors hover:bg-accent-hover"
|
||||
>
|
||||
Add Round
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
{:else if t.rounds.length > 0}
|
||||
<button
|
||||
onclick={() => (showAddRound = true)}
|
||||
class="font-600 self-start rounded-md border border-dashed border-border px-4 py-2 text-xs text-text-secondary transition-colors hover:border-accent/40 hover:text-text-primary"
|
||||
>
|
||||
+ Add Round
|
||||
</button>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
<!-- ─── Groups Tab ───────────────────────────────────────────────────── -->
|
||||
{#snippet groupsTab()}
|
||||
<div class="grid gap-4 md:grid-cols-2">
|
||||
{#each t.groups as group}
|
||||
<div class="rounded-lg border border-border bg-surface-800 p-4">
|
||||
<h3 class="font-600 text-sm">{group.name}</h3>
|
||||
<table class="mt-3 w-full text-sm">
|
||||
<thead>
|
||||
<tr class="border-b border-border text-left">
|
||||
<th class="font-500 px-2 py-2 text-xs text-text-secondary">Team</th>
|
||||
<th class="font-500 px-2 py-2 text-center text-xs text-text-secondary">W</th>
|
||||
<th class="font-500 px-2 py-2 text-center text-xs text-text-secondary">L</th>
|
||||
<th class="font-500 px-2 py-2 text-center text-xs text-text-secondary">MD</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each group.entries.sort((a: any, b: any) => b.wins - a.wins || b.mapWins - b.mapLosses - (a.mapWins - a.mapLosses)) as entry}
|
||||
<tr class="border-b border-border/50">
|
||||
<td class="font-500 px-2 py-2">{entry.team?.name ?? 'TBD'}</td>
|
||||
<td class="px-2 py-2 text-center text-green-400">{entry.wins}</td>
|
||||
<td class="px-2 py-2 text-center text-red-400">{entry.losses}</td>
|
||||
<td class="px-2 py-2 text-center text-text-secondary">
|
||||
{entry.mapWins - entry.mapLosses > 0 ? '+' : ''}{entry.mapWins - entry.mapLosses}
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
<!-- ─── Settings Tab (Staff Only) ────────────────────────────────────── -->
|
||||
{#snippet settingsTab()}
|
||||
<div class="flex flex-col gap-4">
|
||||
<!-- Lifecycle Actions -->
|
||||
<div class="rounded-lg border border-border bg-surface-800 p-5">
|
||||
<h3 class="font-600 text-sm">Tournament Actions</h3>
|
||||
<p class="mt-1 text-xs text-text-secondary">Manage the tournament state and lifecycle</p>
|
||||
<div class="mt-4 flex flex-wrap gap-2">
|
||||
{#if t.state === 'DRAFT'}
|
||||
<form method="POST" action="?/openRegistration" use:enhance={() => handleAction()}>
|
||||
<button
|
||||
class="font-600 rounded-md bg-green-800 px-4 py-2 text-xs text-green-200 transition-colors hover:bg-green-700"
|
||||
>
|
||||
Open Registration
|
||||
</button>
|
||||
</form>
|
||||
{/if}
|
||||
{#if t.state === 'REGISTRATION'}
|
||||
<form method="POST" action="?/closeRegistration" use:enhance={() => handleAction()}>
|
||||
<button
|
||||
class="font-600 rounded-md bg-yellow-800 px-4 py-2 text-xs text-yellow-200 transition-colors hover:bg-yellow-700"
|
||||
>
|
||||
Close Registration
|
||||
</button>
|
||||
</form>
|
||||
{/if}
|
||||
{#if t.state === 'SEEDING' || t.state === 'QUALIFIERS'}
|
||||
<form method="POST" action="?/finalizeSeeding" use:enhance={() => handleAction()}>
|
||||
<button
|
||||
class="font-600 rounded-md bg-blue-800 px-4 py-2 text-xs text-blue-200 transition-colors hover:bg-blue-700"
|
||||
>
|
||||
Generate Bracket
|
||||
</button>
|
||||
</form>
|
||||
{/if}
|
||||
{#if t.state !== 'FINISHED' && t.state !== 'CANCELLED'}
|
||||
<form method="POST" action="?/cancel" use:enhance={() => handleAction()}>
|
||||
<button
|
||||
class="font-600 rounded-md bg-red-800 px-4 py-2 text-xs text-red-200 transition-colors hover:bg-red-700"
|
||||
>
|
||||
Cancel Tournament
|
||||
</button>
|
||||
</form>
|
||||
{/if}
|
||||
{#if t.state === 'FINISHED' || t.state === 'CANCELLED'}
|
||||
<p class="self-center text-xs text-text-secondary">This tournament has ended</p>
|
||||
{/if}
|
||||
</div>
|
||||
{#if t.state === 'DRAFT' && t.rounds.length === 0}
|
||||
<p class="mt-3 text-xs text-yellow-400">
|
||||
You need to add at least one round before opening registration
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Staff Management -->
|
||||
<div class="rounded-lg border border-border bg-surface-800 p-5">
|
||||
<h3 class="font-600 text-sm">Staff</h3>
|
||||
<div class="mt-3 flex flex-col gap-2">
|
||||
{#each t.staff as s}
|
||||
<div
|
||||
class="flex items-center justify-between rounded-md bg-surface-700 px-3 py-2 text-sm"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="font-500">{s.userId.slice(0, 8)}...</span>
|
||||
<span class="rounded bg-surface-600 px-2 py-0.5 text-xs text-text-secondary"
|
||||
>{s.role}</span
|
||||
>
|
||||
</div>
|
||||
{#if t.staff.length > 1}
|
||||
<form
|
||||
method="POST"
|
||||
action="?/removeStaff"
|
||||
use:enhance={() => handleActionWithMessage('Staff removed')}
|
||||
>
|
||||
<input type="hidden" name="staffId" value={s.id} />
|
||||
<button class="text-xs text-red-400 hover:text-red-300">Remove</button>
|
||||
</form>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tournament Info -->
|
||||
<div class="rounded-lg border border-border bg-surface-800 p-5">
|
||||
<h3 class="font-600 text-sm">Info</h3>
|
||||
<dl class="mt-3 flex flex-col gap-1 text-sm">
|
||||
<div class="flex justify-between">
|
||||
<dt class="text-text-secondary">ID</dt>
|
||||
<dd class="font-mono text-xs text-text-secondary">{t.id}</dd>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<dt class="text-text-secondary">Created</dt>
|
||||
<dd class="text-text-secondary">{new Date(t.createdAt).toLocaleString()}</dd>
|
||||
</div>
|
||||
{#if t.registrationOpenAt}
|
||||
<div class="flex justify-between">
|
||||
<dt class="text-text-secondary">Registration Opened</dt>
|
||||
<dd class="text-text-secondary">{new Date(t.registrationOpenAt).toLocaleString()}</dd>
|
||||
</div>
|
||||
{/if}
|
||||
{#if t.finishedAt}
|
||||
<div class="flex justify-between">
|
||||
<dt class="text-text-secondary">Finished</dt>
|
||||
<dd class="text-text-secondary">{new Date(t.finishedAt).toLocaleString()}</dd>
|
||||
</div>
|
||||
{/if}
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
<!-- ─── Match Card Snippet ───────────────────────────────────────────── -->
|
||||
{#snippet matchCard(m: any)}
|
||||
<div
|
||||
class="w-52 overflow-hidden rounded-lg border bg-surface-800 {m.state === 'LIVE'
|
||||
? 'border-blue-500/50'
|
||||
: 'border-border'}"
|
||||
>
|
||||
<div
|
||||
class="flex items-center justify-between border-b border-border/50 px-3 py-2 {m.winnerId ===
|
||||
m.team1Id
|
||||
? 'bg-green-900/20'
|
||||
: ''}"
|
||||
>
|
||||
<span class="font-500 truncate text-sm {m.team1 ? '' : 'text-text-secondary'}">
|
||||
{m.team1?.name ?? 'TBD'}
|
||||
</span>
|
||||
{#if m.match?.participants}
|
||||
{@const p = m.match.participants.find((p: any) => p.teamId === m.team1Id)}
|
||||
{#if p}
|
||||
<span
|
||||
class="font-700 ml-2 font-mono text-sm tabular-nums {m.winnerId === m.team1Id
|
||||
? 'text-green-400'
|
||||
: 'text-text-secondary'}">{p.score}</span
|
||||
>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
<div
|
||||
class="flex items-center justify-between px-3 py-2 {m.winnerId === m.team2Id
|
||||
? 'bg-green-900/20'
|
||||
: ''}"
|
||||
>
|
||||
<span class="font-500 truncate text-sm {m.team2 ? '' : 'text-text-secondary'}">
|
||||
{m.team2?.name ?? 'TBD'}
|
||||
</span>
|
||||
{#if m.match?.participants}
|
||||
{@const p = m.match.participants.find((p: any) => p.teamId === m.team2Id)}
|
||||
{#if p}
|
||||
<span
|
||||
class="font-700 ml-2 font-mono text-sm tabular-nums {m.winnerId === m.team2Id
|
||||
? 'text-green-400'
|
||||
: 'text-text-secondary'}">{p.score}</span
|
||||
>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
{#if m.state === 'LIVE'}
|
||||
<div class="bg-blue-900/30 px-3 py-1 text-center text-xs text-blue-300">LIVE</div>
|
||||
{:else if m.state === 'BYE'}
|
||||
<div class="bg-surface-700 px-3 py-1 text-center text-xs text-text-secondary">BYE</div>
|
||||
{:else if m.matchId}
|
||||
<a
|
||||
href="/matches/{m.matchId}"
|
||||
class="block bg-surface-700 px-3 py-1 text-center text-xs text-text-secondary hover:text-text-primary"
|
||||
>
|
||||
View Match
|
||||
</a>
|
||||
{/if}
|
||||
</div>
|
||||
{/snippet}
|
||||
Reference in New Issue
Block a user