use prettier formatting
This commit is contained in:
@@ -91,7 +91,11 @@ export const actions: Actions = {
|
||||
const bestOf = parseInt(form.get('bestOf')?.toString() ?? '5');
|
||||
const teamSize1 = parseInt(form.get('teamSize1')?.toString() ?? '1');
|
||||
const teamSize2 = parseInt(form.get('teamSize2')?.toString() ?? '1');
|
||||
const scoringType = (form.get('scoringType')?.toString() ?? 'score_v2') as 'score' | 'score_v2' | 'accuracy' | 'combo';
|
||||
const scoringType = (form.get('scoringType')?.toString() ?? 'score_v2') as
|
||||
| 'score'
|
||||
| 'score_v2'
|
||||
| 'accuracy'
|
||||
| 'combo';
|
||||
const allowEloChange = form.get('allowEloChange') === 'on';
|
||||
const message = form.get('message')?.toString()?.trim() || '';
|
||||
const scheduledAtStr = form.get('scheduledAt')?.toString();
|
||||
|
||||
@@ -19,7 +19,9 @@
|
||||
let opponentSearch = $state('');
|
||||
|
||||
const personalTeam = $derived((data as any).userTeams?.find((t: any) => t.isPersonal));
|
||||
const nonPersonalUserTeams = $derived((data as any).userTeams?.filter((t: any) => !t.isPersonal) ?? []);
|
||||
const nonPersonalUserTeams = $derived(
|
||||
(data as any).userTeams?.filter((t: any) => !t.isPersonal) ?? []
|
||||
);
|
||||
const hasMultipleTeams = $derived(nonPersonalUserTeams.length > 0);
|
||||
|
||||
// Auto-select personal team by default
|
||||
@@ -130,7 +132,7 @@
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 class="text-2xl font-700 tracking-tight">Matches</h1>
|
||||
<h1 class="font-700 text-2xl tracking-tight">Matches</h1>
|
||||
<p class="mt-1 text-sm text-text-secondary">
|
||||
{data.matches.length} match{data.matches.length !== 1 ? 'es' : ''}
|
||||
{#if liveMatches.length > 0}
|
||||
@@ -141,16 +143,22 @@
|
||||
<div class="flex gap-2">
|
||||
{#if (data as any).userTeams?.length > 0}
|
||||
<button
|
||||
onclick={() => { showInvite = !showInvite; if (showInvite) showCreate = false; }}
|
||||
class="rounded-md border border-accent/30 px-4 py-2 text-sm font-600 text-accent transition-colors hover:bg-accent/10"
|
||||
onclick={() => {
|
||||
showInvite = !showInvite;
|
||||
if (showInvite) showCreate = false;
|
||||
}}
|
||||
class="font-600 rounded-md border border-accent/30 px-4 py-2 text-sm text-accent transition-colors hover:bg-accent/10"
|
||||
>
|
||||
{showInvite ? 'Cancel' : 'Challenge'}
|
||||
</button>
|
||||
{/if}
|
||||
{#if data.canCreateMatch}
|
||||
<button
|
||||
onclick={() => { showCreate = !showCreate; if (showCreate) showInvite = false; }}
|
||||
class="rounded-md bg-accent px-4 py-2 text-sm font-600 text-surface-900 transition-colors hover:bg-accent-hover"
|
||||
onclick={() => {
|
||||
showCreate = !showCreate;
|
||||
if (showCreate) showInvite = false;
|
||||
}}
|
||||
class="font-600 rounded-md bg-accent px-4 py-2 text-sm text-surface-900 transition-colors hover:bg-accent-hover"
|
||||
>
|
||||
{showCreate ? 'Cancel' : 'New Match'}
|
||||
</button>
|
||||
@@ -178,7 +186,7 @@
|
||||
}}
|
||||
class="mt-4 rounded-lg border border-accent/20 bg-surface-800 p-5"
|
||||
>
|
||||
<h2 class="text-sm font-600">Create Match</h2>
|
||||
<h2 class="font-600 text-sm">Create Match</h2>
|
||||
|
||||
{#if createError}
|
||||
<p class="mt-2 text-sm text-red-400">{createError}</p>
|
||||
@@ -186,7 +194,7 @@
|
||||
|
||||
<div class="mt-4 grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label for="name" class="text-xs font-500 text-text-secondary">Match Name</label>
|
||||
<label for="name" class="font-500 text-xs text-text-secondary">Match Name</label>
|
||||
<input
|
||||
type="text"
|
||||
id="name"
|
||||
@@ -197,7 +205,7 @@
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="bestOf" class="text-xs font-500 text-text-secondary">Best Of</label>
|
||||
<label for="bestOf" class="font-500 text-xs text-text-secondary">Best Of</label>
|
||||
<select
|
||||
id="bestOf"
|
||||
name="bestOf"
|
||||
@@ -210,7 +218,7 @@
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="team1" class="text-xs font-500 text-text-secondary">Team 1</label>
|
||||
<label for="team1" class="font-500 text-xs text-text-secondary">Team 1</label>
|
||||
<select
|
||||
id="team1"
|
||||
name="team1"
|
||||
@@ -225,7 +233,7 @@
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="team2" class="text-xs font-500 text-text-secondary">Team 2</label>
|
||||
<label for="team2" class="font-500 text-xs text-text-secondary">Team 2</label>
|
||||
<select
|
||||
id="team2"
|
||||
name="team2"
|
||||
@@ -240,7 +248,7 @@
|
||||
</div>
|
||||
|
||||
<div class="col-span-2">
|
||||
<label for="mappool" class="text-xs font-500 text-text-secondary">Mappool</label>
|
||||
<label for="mappool" class="font-500 text-xs text-text-secondary">Mappool</label>
|
||||
<select
|
||||
id="mappool"
|
||||
name="mappool"
|
||||
@@ -258,7 +266,7 @@
|
||||
<div class="mt-4 flex justify-end">
|
||||
<button
|
||||
type="submit"
|
||||
class="rounded-md bg-accent px-5 py-2 text-sm font-600 text-surface-900 transition-colors hover:bg-accent-hover"
|
||||
class="font-600 rounded-md bg-accent px-5 py-2 text-sm text-surface-900 transition-colors hover:bg-accent-hover"
|
||||
>
|
||||
Create & Start
|
||||
</button>
|
||||
@@ -275,12 +283,18 @@
|
||||
inviteError = '';
|
||||
inviteSuccess = false;
|
||||
return async ({ result, update }) => {
|
||||
if (result.type === 'failure' || (result.type === 'success' && (result.data as any)?.error)) {
|
||||
if (
|
||||
result.type === 'failure' ||
|
||||
(result.type === 'success' && (result.data as any)?.error)
|
||||
) {
|
||||
inviteError = (result.data as any)?.error ?? 'Failed to send invite';
|
||||
} else if (result.type === 'success' && (result.data as any)?.inviteSuccess) {
|
||||
inviteSuccess = true;
|
||||
await invalidateAll();
|
||||
setTimeout(() => { showInvite = false; inviteSuccess = false; }, 2000);
|
||||
setTimeout(() => {
|
||||
showInvite = false;
|
||||
inviteSuccess = false;
|
||||
}, 2000);
|
||||
} else {
|
||||
await update();
|
||||
}
|
||||
@@ -288,7 +302,7 @@
|
||||
}}
|
||||
class="mt-4 rounded-lg border border-accent/20 bg-surface-800 p-5"
|
||||
>
|
||||
<h2 class="text-sm font-600">Challenge</h2>
|
||||
<h2 class="font-600 text-sm">Challenge</h2>
|
||||
|
||||
{#if inviteError}
|
||||
<p class="mt-2 text-sm text-red-400">{inviteError}</p>
|
||||
@@ -305,8 +319,12 @@
|
||||
<!-- Your Team (only shown if you have non-personal teams) -->
|
||||
{#if hasMultipleTeams}
|
||||
<div>
|
||||
<label for="inv-myTeam" class="text-xs font-500 text-text-secondary">Your Team</label>
|
||||
<select id="inv-myTeam" bind:value={selectedCreatorTeamId} class="mt-1 w-full rounded-md border border-border bg-surface-700 px-3 py-2 text-sm text-text-primary focus:border-accent focus:outline-none">
|
||||
<label for="inv-myTeam" class="font-500 text-xs text-text-secondary">Your Team</label>
|
||||
<select
|
||||
id="inv-myTeam"
|
||||
bind:value={selectedCreatorTeamId}
|
||||
class="mt-1 w-full rounded-md border border-border bg-surface-700 px-3 py-2 text-sm text-text-primary focus:border-accent focus:outline-none"
|
||||
>
|
||||
{#if personalTeam}
|
||||
<option value={personalTeam.id}>{teamDisplayName(personalTeam)}</option>
|
||||
{/if}
|
||||
@@ -319,7 +337,9 @@
|
||||
|
||||
<!-- Opponent (searchable) -->
|
||||
<div>
|
||||
<label for="inv-opponent-search" class="text-xs font-500 text-text-secondary">Opponent</label>
|
||||
<label for="inv-opponent-search" class="font-500 text-xs text-text-secondary"
|
||||
>Opponent</label
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
id="inv-opponent-search"
|
||||
@@ -329,17 +349,35 @@
|
||||
autocomplete="off"
|
||||
/>
|
||||
{#if selectedInvitedTeam}
|
||||
<div class="mt-1.5 flex items-center gap-2 rounded-md bg-accent/10 px-3 py-1.5 text-xs text-accent">
|
||||
<div
|
||||
class="mt-1.5 flex items-center gap-2 rounded-md bg-accent/10 px-3 py-1.5 text-xs text-accent"
|
||||
>
|
||||
<span class="font-600">{teamDisplayName(selectedInvitedTeam)}</span>
|
||||
<span class="text-text-secondary">({selectedInvitedTeam.memberCount} player{selectedInvitedTeam.memberCount !== 1 ? 's' : ''})</span>
|
||||
<button type="button" onclick={() => { selectedInvitedTeamId = ''; opponentSearch = ''; }} class="ml-auto text-text-secondary hover:text-red-400">×</button>
|
||||
<span class="text-text-secondary"
|
||||
>({selectedInvitedTeam.memberCount} player{selectedInvitedTeam.memberCount !== 1
|
||||
? 's'
|
||||
: ''})</span
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => {
|
||||
selectedInvitedTeamId = '';
|
||||
opponentSearch = '';
|
||||
}}
|
||||
class="ml-auto text-text-secondary hover:text-red-400">×</button
|
||||
>
|
||||
</div>
|
||||
{:else if opponentSearch}
|
||||
<div class="mt-1 max-h-40 overflow-y-auto rounded-md border border-border bg-surface-700">
|
||||
<div
|
||||
class="mt-1 max-h-40 overflow-y-auto rounded-md border border-border bg-surface-700"
|
||||
>
|
||||
{#each filteredOpponentTeams.slice(0, 20) as t}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => { selectedInvitedTeamId = t.id; opponentSearch = ''; }}
|
||||
onclick={() => {
|
||||
selectedInvitedTeamId = t.id;
|
||||
opponentSearch = '';
|
||||
}}
|
||||
class="flex w-full items-center gap-2 px-3 py-2 text-left text-sm transition-colors hover:bg-surface-600"
|
||||
>
|
||||
{#if t.avatarUrl}
|
||||
@@ -359,14 +397,22 @@
|
||||
|
||||
<!-- Best Of -->
|
||||
<div>
|
||||
<span class="text-xs font-500 text-text-secondary">Best Of</span>
|
||||
<span class="font-500 text-xs text-text-secondary">Best Of</span>
|
||||
<div class="mt-1.5 flex flex-wrap gap-1.5">
|
||||
{#each [3, 5, 7, 9, 11, 13] as n}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => { bestOf = n; useCustomBo = false; customBestOf = ''; }}
|
||||
class="rounded-md px-3 py-1.5 text-xs font-600 transition-colors {!useCustomBo && bestOf === n ? 'bg-accent text-surface-900' : 'border border-border bg-surface-700 text-text-secondary hover:border-accent/40 hover:text-text-primary'}"
|
||||
>BO{n}</button>
|
||||
onclick={() => {
|
||||
bestOf = n;
|
||||
useCustomBo = false;
|
||||
customBestOf = '';
|
||||
}}
|
||||
class="font-600 rounded-md px-3 py-1.5 text-xs transition-colors {!useCustomBo &&
|
||||
bestOf === n
|
||||
? 'bg-accent text-surface-900'
|
||||
: 'border border-border bg-surface-700 text-text-secondary hover:border-accent/40 hover:text-text-primary'}"
|
||||
>BO{n}</button
|
||||
>
|
||||
{/each}
|
||||
<input
|
||||
type="number"
|
||||
@@ -376,7 +422,9 @@
|
||||
bind:value={customBestOf}
|
||||
onfocus={() => (useCustomBo = true)}
|
||||
oninput={() => (useCustomBo = true)}
|
||||
class="w-20 rounded-md border {useCustomBo ? 'border-accent' : 'border-border'} bg-surface-700 px-2 py-1.5 text-xs text-text-primary placeholder:text-text-secondary/50 focus:border-accent focus:outline-none"
|
||||
class="w-20 rounded-md border {useCustomBo
|
||||
? 'border-accent'
|
||||
: 'border-border'} bg-surface-700 px-2 py-1.5 text-xs text-text-primary placeholder:text-text-secondary/50 focus:border-accent focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -385,8 +433,14 @@
|
||||
<!-- Team sizes (only if creator is not personal team) -->
|
||||
{#if !creatorIsPersonal && creatorMaxSize > 1}
|
||||
<div>
|
||||
<label for="inv-ts1" class="text-xs font-500 text-text-secondary">Your Team Size</label>
|
||||
<select id="inv-ts1" name="teamSize1" class="mt-1 w-full rounded-md border border-border bg-surface-700 px-3 py-2 text-sm text-text-primary focus:border-accent focus:outline-none">
|
||||
<label for="inv-ts1" class="font-500 text-xs text-text-secondary"
|
||||
>Your Team Size</label
|
||||
>
|
||||
<select
|
||||
id="inv-ts1"
|
||||
name="teamSize1"
|
||||
class="mt-1 w-full rounded-md border border-border bg-surface-700 px-3 py-2 text-sm text-text-primary focus:border-accent focus:outline-none"
|
||||
>
|
||||
{#each Array.from({ length: creatorMaxSize }, (_, i) => i + 1) as n}
|
||||
<option value={n} selected={n === creatorMaxSize}>{n}v</option>
|
||||
{/each}
|
||||
@@ -398,8 +452,14 @@
|
||||
|
||||
{#if selectedInvitedTeam && !selectedInvitedTeam.isPersonal && invitedMaxSize > 1}
|
||||
<div>
|
||||
<label for="inv-ts2" class="text-xs font-500 text-text-secondary">Opponent Team Size</label>
|
||||
<select id="inv-ts2" name="teamSize2" class="mt-1 w-full rounded-md border border-border bg-surface-700 px-3 py-2 text-sm text-text-primary focus:border-accent focus:outline-none">
|
||||
<label for="inv-ts2" class="font-500 text-xs text-text-secondary"
|
||||
>Opponent Team Size</label
|
||||
>
|
||||
<select
|
||||
id="inv-ts2"
|
||||
name="teamSize2"
|
||||
class="mt-1 w-full rounded-md border border-border bg-surface-700 px-3 py-2 text-sm text-text-primary focus:border-accent focus:outline-none"
|
||||
>
|
||||
{#each Array.from({ length: invitedMaxSize }, (_, i) => i + 1) as n}
|
||||
<option value={n} selected={n === invitedMaxSize}>v{n}</option>
|
||||
{/each}
|
||||
@@ -411,8 +471,12 @@
|
||||
|
||||
<!-- Scoring -->
|
||||
<div>
|
||||
<label for="inv-scoring" class="text-xs font-500 text-text-secondary">Scoring</label>
|
||||
<select id="inv-scoring" name="scoringType" class="mt-1 w-full rounded-md border border-border bg-surface-700 px-3 py-2 text-sm text-text-primary focus:border-accent focus:outline-none">
|
||||
<label for="inv-scoring" class="font-500 text-xs text-text-secondary">Scoring</label>
|
||||
<select
|
||||
id="inv-scoring"
|
||||
name="scoringType"
|
||||
class="mt-1 w-full rounded-md border border-border bg-surface-700 px-3 py-2 text-sm text-text-primary focus:border-accent focus:outline-none"
|
||||
>
|
||||
<option value="score_v2" selected>ScoreV2</option>
|
||||
<option value="score">Score</option>
|
||||
<option value="accuracy">Accuracy</option>
|
||||
@@ -422,8 +486,13 @@
|
||||
|
||||
<!-- Mappool -->
|
||||
<div>
|
||||
<label for="inv-mappool" class="text-xs font-500 text-text-secondary">Mappool</label>
|
||||
<select id="inv-mappool" name="mappool" required class="mt-1 w-full rounded-md border border-border bg-surface-700 px-3 py-2 text-sm text-text-primary focus:border-accent focus:outline-none">
|
||||
<label for="inv-mappool" class="font-500 text-xs text-text-secondary">Mappool</label>
|
||||
<select
|
||||
id="inv-mappool"
|
||||
name="mappool"
|
||||
required
|
||||
class="mt-1 w-full rounded-md border border-border bg-surface-700 px-3 py-2 text-sm text-text-primary focus:border-accent focus:outline-none"
|
||||
>
|
||||
<option value="">Select mappool...</option>
|
||||
{#each data.mappools as p}
|
||||
<option value={p.id}>{p.name} — {mappoolSummary(p)}</option>
|
||||
@@ -435,7 +504,12 @@
|
||||
<!-- ELO toggle (default on) -->
|
||||
<div class="flex items-center gap-3">
|
||||
<label class="flex items-center gap-2 text-xs text-text-secondary">
|
||||
<input type="checkbox" name="allowEloChange" checked class="rounded border-border bg-surface-700 text-accent focus:ring-accent" />
|
||||
<input
|
||||
type="checkbox"
|
||||
name="allowEloChange"
|
||||
checked
|
||||
class="rounded border-border bg-surface-700 text-accent focus:ring-accent"
|
||||
/>
|
||||
Affects ELO
|
||||
</label>
|
||||
</div>
|
||||
@@ -443,18 +517,37 @@
|
||||
<!-- Message + Schedule -->
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label for="inv-message" class="text-xs font-500 text-text-secondary">Message (optional)</label>
|
||||
<input type="text" id="inv-message" name="message" placeholder="e.g. ggs only" class="mt-1 w-full rounded-md border border-border bg-surface-700 px-3 py-2 text-sm text-text-primary placeholder:text-text-secondary/50 focus:border-accent focus:outline-none" />
|
||||
<label for="inv-message" class="font-500 text-xs text-text-secondary"
|
||||
>Message (optional)</label
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
id="inv-message"
|
||||
name="message"
|
||||
placeholder="e.g. ggs only"
|
||||
class="mt-1 w-full rounded-md border border-border bg-surface-700 px-3 py-2 text-sm text-text-primary placeholder:text-text-secondary/50 focus:border-accent focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label for="inv-schedule" class="text-xs font-500 text-text-secondary">Schedule (optional)</label>
|
||||
<input type="datetime-local" id="inv-schedule" name="scheduledAt" class="mt-1 w-full rounded-md border border-border bg-surface-700 px-3 py-2 text-sm text-text-primary focus:border-accent focus:outline-none" />
|
||||
<label for="inv-schedule" class="font-500 text-xs text-text-secondary"
|
||||
>Schedule (optional)</label
|
||||
>
|
||||
<input
|
||||
type="datetime-local"
|
||||
id="inv-schedule"
|
||||
name="scheduledAt"
|
||||
class="mt-1 w-full rounded-md border border-border bg-surface-700 px-3 py-2 text-sm text-text-primary focus:border-accent focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 flex justify-end">
|
||||
<button type="submit" disabled={!selectedInvitedTeamId} class="rounded-md bg-accent px-5 py-2 text-sm font-600 text-surface-900 transition-colors hover:bg-accent-hover disabled:opacity-40">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!selectedInvitedTeamId}
|
||||
class="font-600 rounded-md bg-accent px-5 py-2 text-sm text-surface-900 transition-colors hover:bg-accent-hover disabled:opacity-40"
|
||||
>
|
||||
Send Challenge
|
||||
</button>
|
||||
</div>
|
||||
@@ -464,7 +557,7 @@
|
||||
<!-- Live Matches -->
|
||||
{#if liveMatches.length > 0}
|
||||
<div class="mt-6">
|
||||
<h2 class="flex items-center gap-2 text-sm font-600">
|
||||
<h2 class="font-600 flex items-center gap-2 text-sm">
|
||||
<div class="relative h-2 w-2">
|
||||
<div class="absolute inset-0 animate-ping rounded-full bg-green-400 opacity-75"></div>
|
||||
<div class="relative h-2 w-2 rounded-full bg-green-400"></div>
|
||||
@@ -483,24 +576,24 @@
|
||||
{#if p1?.team.avatarUrl}
|
||||
<img src={p1.team.avatarUrl} alt="" class="h-8 w-8 rounded-full" />
|
||||
{/if}
|
||||
<span class="text-sm font-600">{p1?.team.name ?? '?'}</span>
|
||||
<span class="font-600 text-sm">{p1?.team.name ?? '?'}</span>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="text-xl font-800 tabular-nums">{p1?.score ?? 0}</span>
|
||||
<span class="text-xs font-600 text-text-secondary">vs</span>
|
||||
<span class="text-xl font-800 tabular-nums">{p2?.score ?? 0}</span>
|
||||
<span class="font-800 text-xl tabular-nums">{p1?.score ?? 0}</span>
|
||||
<span class="font-600 text-xs text-text-secondary">vs</span>
|
||||
<span class="font-800 text-xl tabular-nums">{p2?.score ?? 0}</span>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-1 items-center justify-end gap-3">
|
||||
<span class="text-sm font-600">{p2?.team.name ?? '?'}</span>
|
||||
<span class="font-600 text-sm">{p2?.team.name ?? '?'}</span>
|
||||
{#if p2?.team.avatarUrl}
|
||||
<img src={p2.team.avatarUrl} alt="" class="h-8 w-8 rounded-full" />
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<span
|
||||
class="flex items-center gap-1.5 rounded border px-2 py-0.5 text-xs font-500 {sc?.color ??
|
||||
class="font-500 flex items-center gap-1.5 rounded border px-2 py-0.5 text-xs {sc?.color ??
|
||||
'border-border'}"
|
||||
>
|
||||
{#if sc?.dot}
|
||||
@@ -517,7 +610,7 @@
|
||||
<!-- All Matches -->
|
||||
<div class="mt-6">
|
||||
{#if liveMatches.length > 0}
|
||||
<h2 class="text-sm font-600 text-text-secondary">Past Matches</h2>
|
||||
<h2 class="font-600 text-sm text-text-secondary">Past Matches</h2>
|
||||
{/if}
|
||||
<div class="mt-3 flex flex-col gap-2">
|
||||
{#each recentMatches as m}
|
||||
@@ -531,40 +624,45 @@
|
||||
<!-- Match info -->
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-sm font-600">{p1?.team.name ?? '?'}</span>
|
||||
<span class="font-600 text-sm">{p1?.team.name ?? '?'}</span>
|
||||
{#if m.state === 'FINISHED'}
|
||||
<span
|
||||
class="text-xs font-700 tabular-nums {(p1?.score ?? 0) > (p2?.score ?? 0)
|
||||
class="font-700 text-xs tabular-nums {(p1?.score ?? 0) > (p2?.score ?? 0)
|
||||
? 'text-green-400'
|
||||
: 'text-text-secondary'}">{p1?.score ?? 0}</span
|
||||
>
|
||||
<span class="text-xs text-text-secondary">-</span>
|
||||
<span
|
||||
class="text-xs font-700 tabular-nums {(p2?.score ?? 0) > (p1?.score ?? 0)
|
||||
class="font-700 text-xs tabular-nums {(p2?.score ?? 0) > (p1?.score ?? 0)
|
||||
? 'text-green-400'
|
||||
: 'text-text-secondary'}">{p2?.score ?? 0}</span
|
||||
>
|
||||
{:else}
|
||||
<span class="text-xs text-text-secondary">vs</span>
|
||||
{/if}
|
||||
<span class="text-sm font-600">{p2?.team.name ?? '?'}</span>
|
||||
<span class="font-600 text-sm">{p2?.team.name ?? '?'}</span>
|
||||
</div>
|
||||
<p class="mt-0.5 text-xs text-text-secondary">{m.name ? `${m.name} · ` : ''}BO{config.bestOf}{#if m.finishedAt} · {timeAgo(m.finishedAt)}{:else if m.createdAt} · {timeAgo(m.createdAt)}{/if}</p>
|
||||
<p class="mt-0.5 text-xs text-text-secondary">
|
||||
{m.name ? `${m.name} · ` : ''}BO{config.bestOf}{#if m.finishedAt}
|
||||
· {timeAgo(m.finishedAt)}{:else if m.createdAt}
|
||||
· {timeAgo(m.createdAt)}{/if}
|
||||
</p>
|
||||
{#if m.mappool}
|
||||
<p class="text-xs text-text-secondary">{m.mappool.name}{#if avgSR(m.mappool)} ({avgSR(m.mappool)}★){/if}</p>
|
||||
<p class="text-xs text-text-secondary">
|
||||
{m.mappool.name}{#if avgSR(m.mappool)}
|
||||
({avgSR(m.mappool)}★){/if}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Winner indicator -->
|
||||
{#if m.state === 'FINISHED' && m.winnerId}
|
||||
{@const winner = m.participants.find((p: any) => p.teamId === m.winnerId)}
|
||||
<span class="text-xs font-500 text-green-400">🏆 {winner?.team.name}</span>
|
||||
<span class="font-500 text-xs text-green-400">🏆 {winner?.team.name}</span>
|
||||
{/if}
|
||||
|
||||
<!-- State -->
|
||||
<span
|
||||
class="rounded border px-2 py-0.5 text-xs font-500 {sc?.color ?? 'border-border'}"
|
||||
>
|
||||
<span class="font-500 rounded border px-2 py-0.5 text-xs {sc?.color ?? 'border-border'}">
|
||||
{sc?.label ?? m.state}
|
||||
</span>
|
||||
</a>
|
||||
|
||||
@@ -13,7 +13,6 @@ import { eq } from 'drizzle-orm';
|
||||
import type { MatchConfig } from '$lib/server/match/types';
|
||||
import { playerRating } from '$lib/server/db/schema';
|
||||
|
||||
|
||||
export const load: PageServerLoad = async ({ params, locals }) => {
|
||||
if (!locals.user) redirect(302, '/');
|
||||
|
||||
@@ -45,7 +44,9 @@ export const load: PageServerLoad = async ({ params, locals }) => {
|
||||
if (slot.title !== null) {
|
||||
// Sanitize any stale ppy.sh fallback URLs (image was unavailable when first fetched)
|
||||
const coverUrl = slot.coverUrl?.includes('ppy.sh') ? null : (slot.coverUrl ?? null);
|
||||
const listCoverUrl = slot.listCoverUrl?.includes('ppy.sh') ? null : (slot.listCoverUrl ?? null);
|
||||
const listCoverUrl = slot.listCoverUrl?.includes('ppy.sh')
|
||||
? null
|
||||
: (slot.listCoverUrl ?? null);
|
||||
beatmapCache[slot.beatmapId] = {
|
||||
title: slot.title,
|
||||
artist: slot.artist ?? '',
|
||||
@@ -90,7 +91,9 @@ export const load: PageServerLoad = async ({ params, locals }) => {
|
||||
totalLength: bm.total_length
|
||||
})
|
||||
.where(eq(mappoolSlot.id, slot.id));
|
||||
} catch { /* skip */ }
|
||||
} catch {
|
||||
/* skip */
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
@@ -120,7 +123,15 @@ export const load: PageServerLoad = async ({ params, locals }) => {
|
||||
}
|
||||
}
|
||||
|
||||
return { match: m, beatmapCache, userId: locals.user.id, isStaff, lobbyStatus, playerNames, playerRatings };
|
||||
return {
|
||||
match: m,
|
||||
beatmapCache,
|
||||
userId: locals.user.id,
|
||||
isStaff,
|
||||
lobbyStatus,
|
||||
playerNames,
|
||||
playerRatings
|
||||
};
|
||||
};
|
||||
|
||||
export const actions: Actions = {
|
||||
@@ -171,7 +182,9 @@ export const actions: Actions = {
|
||||
// Announce in IRC lobby
|
||||
const lobby = getLobby(params.id);
|
||||
if (lobby) {
|
||||
lobby.chat(`${myParticipant.team?.name ?? 'Player'} rolled ${value} (via web)`).catch(() => {});
|
||||
lobby
|
||||
.chat(`${myParticipant.team?.name ?? 'Player'} rolled ${value} (via web)`)
|
||||
.catch(() => {});
|
||||
|
||||
// Check if all rolled → announce pick order or tie
|
||||
const updated = await getMatchFull(params.id);
|
||||
@@ -184,13 +197,17 @@ export const actions: Actions = {
|
||||
const isTBRoll = updated.participants.every((p: any) => p.score === rollWinsNeeded - 1);
|
||||
|
||||
if (isTBRoll) {
|
||||
lobby.chat(
|
||||
`Rolls complete! ${sorted[0]?.team.name} picks the tiebreaker. Use !pick TB<n> or pick in web UI.`
|
||||
).catch(() => {});
|
||||
lobby
|
||||
.chat(
|
||||
`Rolls complete! ${sorted[0]?.team.name} picks the tiebreaker. Use !pick TB<n> or pick in web UI.`
|
||||
)
|
||||
.catch(() => {});
|
||||
} else {
|
||||
lobby.chat(
|
||||
`Rolls complete! ${sorted[0]?.team.name} picks first. Use !pick <slot> (e.g. !pick NM1) or pick in web UI.`
|
||||
).catch(() => {});
|
||||
lobby
|
||||
.chat(
|
||||
`Rolls complete! ${sorted[0]?.team.name} picks first. Use !pick <slot> (e.g. !pick NM1) or pick in web UI.`
|
||||
)
|
||||
.catch(() => {});
|
||||
}
|
||||
} else if (isLastRoll && updated.participants.every((p: any) => p.rollValue === null)) {
|
||||
lobby.chat(`Tie! All players rolled ${value}. Please !roll again.`).catch(() => {});
|
||||
@@ -210,9 +227,7 @@ export const actions: Actions = {
|
||||
const m = await getMatchFull(params.id);
|
||||
|
||||
// Find the expected picker based on pick order and game count
|
||||
const sorted = [...m.participants].sort(
|
||||
(a, b) => (a.pickOrder ?? 99) - (b.pickOrder ?? 99)
|
||||
);
|
||||
const sorted = [...m.participants].sort((a, b) => (a.pickOrder ?? 99) - (b.pickOrder ?? 99));
|
||||
const expectedIdx = m.games.length % sorted.length;
|
||||
const expectedPicker = sorted[expectedIdx];
|
||||
|
||||
@@ -220,9 +235,7 @@ export const actions: Actions = {
|
||||
|
||||
// ── Verify the logged-in user is on the expected picker's team ──
|
||||
// No staff bypass here — even admins can't pick for another team
|
||||
const isOnPickerTeam = expectedPicker.players.some(
|
||||
(pl) => pl.userId === locals.user!.id
|
||||
);
|
||||
const isOnPickerTeam = expectedPicker.players.some((pl) => pl.userId === locals.user!.id);
|
||||
if (!isOnPickerTeam) {
|
||||
return { error: "It's not your turn to pick" };
|
||||
}
|
||||
|
||||
@@ -38,9 +38,7 @@
|
||||
);
|
||||
|
||||
// Should the mappool be visible?
|
||||
const showMappool = $derived(
|
||||
['PICKING', 'PLAYING', 'ROLLING', 'FINISHED'].includes(m.state)
|
||||
);
|
||||
const showMappool = $derived(['PICKING', 'PLAYING', 'ROLLING', 'FINISHED'].includes(m.state));
|
||||
|
||||
// Can picks actually be made right now?
|
||||
const pickingPhase = $derived(m.state === 'PICKING');
|
||||
@@ -77,7 +75,9 @@
|
||||
let now = $state(Date.now());
|
||||
$effect(() => {
|
||||
if (m.startedAt && !['FINISHED', 'CANCELLED'].includes(m.state)) {
|
||||
const interval = setInterval(() => { now = Date.now(); }, 1000);
|
||||
const interval = setInterval(() => {
|
||||
now = Date.now();
|
||||
}, 1000);
|
||||
return () => clearInterval(interval);
|
||||
}
|
||||
});
|
||||
@@ -99,7 +99,8 @@
|
||||
const mappoolAvgSR = $derived(() => {
|
||||
const slots = m.mappool?.slots;
|
||||
if (!slots?.length) return null;
|
||||
const avg = slots.reduce((sum: number, s: unknown) => sum + (s.starRating ?? 0), 0) / slots.length;
|
||||
const avg =
|
||||
slots.reduce((sum: number, s: unknown) => sum + (s.starRating ?? 0), 0) / slots.length;
|
||||
return avg.toFixed(2);
|
||||
});
|
||||
|
||||
@@ -129,16 +130,44 @@
|
||||
}
|
||||
|
||||
function stateLabel(s: string) {
|
||||
return ({ CREATED: 'Waiting', LOBBY: 'In Lobby', ROLLING: 'Rolling', PICKING: 'Pick Phase', PLAYING: 'Playing', FINISHED: 'Finished', CANCELLED: 'Cancelled' })[s] ?? s;
|
||||
return (
|
||||
{
|
||||
CREATED: 'Waiting',
|
||||
LOBBY: 'In Lobby',
|
||||
ROLLING: 'Rolling',
|
||||
PICKING: 'Pick Phase',
|
||||
PLAYING: 'Playing',
|
||||
FINISHED: 'Finished',
|
||||
CANCELLED: 'Cancelled'
|
||||
}[s] ?? s
|
||||
);
|
||||
}
|
||||
|
||||
function stateBorder(s: string) {
|
||||
return ({ ROLLING: 'border-yellow-500/30', PICKING: 'border-blue-500/30', PLAYING: 'border-green-500/30', FINISHED: 'border-accent/30' })[s] ?? 'border-border';
|
||||
return (
|
||||
{
|
||||
ROLLING: 'border-yellow-500/30',
|
||||
PICKING: 'border-blue-500/30',
|
||||
PLAYING: 'border-green-500/30',
|
||||
FINISHED: 'border-accent/30'
|
||||
}[s] ?? 'border-border'
|
||||
);
|
||||
}
|
||||
|
||||
const stateLabels: Record<string, string> = { LOBBY: 'In Lobby', ROLLING: 'Rolling', PICKING: 'Picking', PLAYING: 'Live Now', FINISHED: 'Finished', CANCELLED: 'Cancelled' };
|
||||
const ogTitle = $derived(`${p1?.team?.name ?? 'TBD'} vs ${p2?.team?.name ?? 'TBD'} — ${m.name} | Vash Esports`);
|
||||
const ogDesc = $derived(`${stateLabels[m.state] ?? m.state} · Best of ${(m.config as any)?.bestOf ?? '?'} · Watch on Vash Esports`);
|
||||
const stateLabels: Record<string, string> = {
|
||||
LOBBY: 'In Lobby',
|
||||
ROLLING: 'Rolling',
|
||||
PICKING: 'Picking',
|
||||
PLAYING: 'Live Now',
|
||||
FINISHED: 'Finished',
|
||||
CANCELLED: 'Cancelled'
|
||||
};
|
||||
const ogTitle = $derived(
|
||||
`${p1?.team?.name ?? 'TBD'} vs ${p2?.team?.name ?? 'TBD'} — ${m.name} | Vash Esports`
|
||||
);
|
||||
const ogDesc = $derived(
|
||||
`${stateLabels[m.state] ?? m.state} · Best of ${(m.config as any)?.bestOf ?? '?'} · Watch on Vash Esports`
|
||||
);
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
@@ -155,10 +184,17 @@
|
||||
|
||||
<!-- Shared reinvite snippet -->
|
||||
{#snippet reinviteButton()}
|
||||
<form method="post" action="?/reinvite" use:enhance={() => {
|
||||
reinviting = true;
|
||||
return async ({ update }) => { reinviting = false; await update(); };
|
||||
}}>
|
||||
<form
|
||||
method="post"
|
||||
action="?/reinvite"
|
||||
use:enhance={() => {
|
||||
reinviting = true;
|
||||
return async ({ update }) => {
|
||||
reinviting = false;
|
||||
await update();
|
||||
};
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={reinviting}
|
||||
@@ -172,24 +208,39 @@
|
||||
<div class="mx-auto max-w-5xl">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center gap-3">
|
||||
<a href="/matches" class="text-text-secondary transition-colors hover:text-text-primary">←</a>
|
||||
<a href="/matches" class="text-text-secondary transition-colors hover:text-text-primary"
|
||||
>←</a
|
||||
>
|
||||
<div class="flex-1">
|
||||
<h1 class="text-xl font-700 tracking-tight">{m.name || 'Match'}</h1>
|
||||
<h1 class="font-700 text-xl tracking-tight">{m.name || 'Match'}</h1>
|
||||
<p class="mt-0.5 text-xs text-text-secondary">
|
||||
Best of {config.bestOf} · First to {winsNeeded}
|
||||
{#if duration}
|
||||
· {duration}
|
||||
{/if}
|
||||
{#if m.osuLobbyId}
|
||||
· <a href="https://osu.ppy.sh/mp/{m.osuLobbyId}" target="_blank" class="text-accent hover:underline">osu! mp/{m.osuLobbyId}</a>
|
||||
· <a
|
||||
href="https://osu.ppy.sh/mp/{m.osuLobbyId}"
|
||||
target="_blank"
|
||||
class="text-accent hover:underline">osu! mp/{m.osuLobbyId}</a
|
||||
>
|
||||
{/if}
|
||||
</p>
|
||||
</div>
|
||||
<span class="rounded border px-2.5 py-1 text-xs font-600 {stateBorder(m.state)}">{stateLabel(m.state)}</span>
|
||||
<span class="font-600 rounded border px-2.5 py-1 text-xs {stateBorder(m.state)}"
|
||||
>{stateLabel(m.state)}</span
|
||||
>
|
||||
|
||||
{#if !['FINISHED', 'CANCELLED'].includes(m.state) && data.isStaff}
|
||||
<form method="post" action="?/cancel" use:enhance>
|
||||
<button type="submit" onclick={(e) => { if (!confirm('Cancel this match?')) e.preventDefault() }} class="rounded-md border border-red-500/30 px-3 py-1.5 text-xs text-red-400 hover:bg-red-500/10">Cancel</button>
|
||||
<button
|
||||
type="submit"
|
||||
onclick={(e) => {
|
||||
if (!confirm('Cancel this match?')) e.preventDefault();
|
||||
}}
|
||||
class="rounded-md border border-red-500/30 px-3 py-1.5 text-xs text-red-400 hover:bg-red-500/10"
|
||||
>Cancel</button
|
||||
>
|
||||
</form>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -197,12 +248,16 @@
|
||||
<!-- Scoreboard -->
|
||||
{#if p1 && p2}
|
||||
<div class="mt-6 flex items-center gap-4">
|
||||
<div class="flex flex-1 items-center gap-4 rounded-lg border p-4 {m.winnerId === p1.teamId ? 'border-green-500/40 bg-green-500/5' : 'border-border bg-surface-800'}">
|
||||
<div
|
||||
class="flex flex-1 items-center gap-4 rounded-lg border p-4 {m.winnerId === p1.teamId
|
||||
? 'border-green-500/40 bg-green-500/5'
|
||||
: 'border-border bg-surface-800'}"
|
||||
>
|
||||
{#if p1.team.avatarUrl}
|
||||
<img src={p1.team.avatarUrl} alt="" class="h-10 w-10 rounded-full" />
|
||||
{/if}
|
||||
<div class="flex-1">
|
||||
<p class="text-sm font-600">{p1.team.name}</p>
|
||||
<p class="font-600 text-sm">{p1.team.name}</p>
|
||||
{#if config.teamSize === 1}
|
||||
{@const r1 = data.playerRatings?.[p1.players[0]?.userId]}
|
||||
{#if r1}
|
||||
@@ -210,18 +265,32 @@
|
||||
{/if}
|
||||
{/if}
|
||||
{#if p1.rollValue != null}
|
||||
<p class="text-xs text-text-secondary">Roll: {p1.rollValue}{p1.pickOrder === 1 ? ' ★' : ''}</p>
|
||||
<p class="text-xs text-text-secondary">
|
||||
Roll: {p1.rollValue}{p1.pickOrder === 1 ? ' ★' : ''}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
<span class="text-3xl font-800 tabular-nums {p1.score >= winsNeeded ? 'text-green-400' : 'text-text-primary'}">{p1.score}</span>
|
||||
<span
|
||||
class="font-800 text-3xl tabular-nums {p1.score >= winsNeeded
|
||||
? 'text-green-400'
|
||||
: 'text-text-primary'}">{p1.score}</span
|
||||
>
|
||||
</div>
|
||||
|
||||
<span class="text-lg font-700 text-text-secondary">vs</span>
|
||||
<span class="font-700 text-lg text-text-secondary">vs</span>
|
||||
|
||||
<div class="flex flex-1 items-center gap-4 rounded-lg border p-4 {m.winnerId === p2.teamId ? 'border-green-500/40 bg-green-500/5' : 'border-border bg-surface-800'}">
|
||||
<span class="text-3xl font-800 tabular-nums {p2.score >= winsNeeded ? 'text-green-400' : 'text-text-primary'}">{p2.score}</span>
|
||||
<div
|
||||
class="flex flex-1 items-center gap-4 rounded-lg border p-4 {m.winnerId === p2.teamId
|
||||
? 'border-green-500/40 bg-green-500/5'
|
||||
: 'border-border bg-surface-800'}"
|
||||
>
|
||||
<span
|
||||
class="font-800 text-3xl tabular-nums {p2.score >= winsNeeded
|
||||
? 'text-green-400'
|
||||
: 'text-text-primary'}">{p2.score}</span
|
||||
>
|
||||
<div class="flex-1 text-right">
|
||||
<p class="text-sm font-600">{p2.team.name}</p>
|
||||
<p class="font-600 text-sm">{p2.team.name}</p>
|
||||
{#if config.teamSize === 1}
|
||||
{@const r2 = data.playerRatings?.[p2.players[0]?.userId]}
|
||||
{#if r2}
|
||||
@@ -229,7 +298,9 @@
|
||||
{/if}
|
||||
{/if}
|
||||
{#if p2.rollValue != null}
|
||||
<p class="text-xs text-text-secondary">{p2.pickOrder === 1 ? '★ ' : ''}Roll: {p2.rollValue}</p>
|
||||
<p class="text-xs text-text-secondary">
|
||||
{p2.pickOrder === 1 ? '★ ' : ''}Roll: {p2.rollValue}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
{#if p2.team.avatarUrl}
|
||||
@@ -244,7 +315,11 @@
|
||||
<div class="mt-3 flex items-center justify-center gap-3">
|
||||
{@render reinviteButton()}
|
||||
{#if m.osuLobbyId}
|
||||
<a href="https://osu.ppy.sh/mp/{m.osuLobbyId}" target="_blank" class="text-xs text-text-secondary hover:text-accent">Open lobby →</a>
|
||||
<a
|
||||
href="https://osu.ppy.sh/mp/{m.osuLobbyId}"
|
||||
target="_blank"
|
||||
class="text-xs text-text-secondary hover:text-accent">Open lobby →</a
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
@@ -252,41 +327,66 @@
|
||||
<!-- ROLLING MODAL (stays as overlay — it's a brief phase) -->
|
||||
{#if m.state === 'ROLLING'}
|
||||
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/70 backdrop-blur-sm">
|
||||
<div class="w-full max-w-md rounded-xl border border-yellow-500/30 bg-surface-800 p-8 text-center shadow-2xl">
|
||||
<h2 class="text-lg font-700">{isTiebreakerAllowed ? '🔥 Tiebreaker Roll' : '🎲 Roll Phase'}</h2>
|
||||
<p class="mt-2 text-sm text-text-secondary">{isTiebreakerAllowed ? 'Highest roll picks the tiebreaker' : 'Highest roll picks first'}</p>
|
||||
<div
|
||||
class="w-full max-w-md rounded-xl border border-yellow-500/30 bg-surface-800 p-8 text-center shadow-2xl"
|
||||
>
|
||||
<h2 class="font-700 text-lg">
|
||||
{isTiebreakerAllowed ? '🔥 Tiebreaker Roll' : '🎲 Roll Phase'}
|
||||
</h2>
|
||||
<p class="mt-2 text-sm text-text-secondary">
|
||||
{isTiebreakerAllowed ? 'Highest roll picks the tiebreaker' : 'Highest roll picks first'}
|
||||
</p>
|
||||
|
||||
<div class="mt-6 flex justify-center gap-6">
|
||||
{#each m.participants as p}
|
||||
<div class="rounded-lg border border-border bg-surface-700 px-6 py-4">
|
||||
<p class="text-xs text-text-secondary">{p.team.name}</p>
|
||||
{#if p.rollValue != null}
|
||||
<p class="mt-1 text-2xl font-800 text-yellow-400">{p.rollValue}</p>
|
||||
<p class="font-800 mt-1 text-2xl text-yellow-400">{p.rollValue}</p>
|
||||
{:else}
|
||||
<p class="mt-1 text-2xl font-800 text-text-secondary/30">—</p>
|
||||
<p class="font-800 mt-1 text-2xl text-text-secondary/30">—</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
{#if canRoll}
|
||||
<form method="post" action="?/roll" use:enhance={() => {
|
||||
rolling = true;
|
||||
return async ({ update }) => { rolling = false; await update(); };
|
||||
}}>
|
||||
<button type="submit" disabled={rolling} class="mt-6 rounded-md bg-accent px-8 py-3 text-sm font-600 text-surface-900 transition-colors hover:bg-accent-hover disabled:opacity-50">
|
||||
<form
|
||||
method="post"
|
||||
action="?/roll"
|
||||
use:enhance={() => {
|
||||
rolling = true;
|
||||
return async ({ update }) => {
|
||||
rolling = false;
|
||||
await update();
|
||||
};
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={rolling}
|
||||
class="font-600 mt-6 rounded-md bg-accent px-8 py-3 text-sm text-surface-900 transition-colors hover:bg-accent-hover disabled:opacity-50"
|
||||
>
|
||||
{rolling ? 'Rolling...' : '🎲 Roll!'}
|
||||
</button>
|
||||
</form>
|
||||
<p class="mt-3 text-xs text-text-secondary">or type <code class="rounded bg-surface-700 px-1.5 py-0.5 font-mono text-accent">!roll</code> in osu! chat</p>
|
||||
<p class="mt-3 text-xs text-text-secondary">
|
||||
or type <code class="rounded bg-surface-700 px-1.5 py-0.5 font-mono text-accent"
|
||||
>!roll</code
|
||||
> in osu! chat
|
||||
</p>
|
||||
{:else}
|
||||
<p class="mt-6 text-sm text-text-secondary animate-pulse">Waiting for all rolls...</p>
|
||||
<p class="mt-6 animate-pulse text-sm text-text-secondary">Waiting for all rolls...</p>
|
||||
{/if}
|
||||
|
||||
<div class="mt-6 flex flex-col items-center gap-2 border-t border-border pt-4">
|
||||
{@render reinviteButton()}
|
||||
{#if m.osuLobbyId}
|
||||
<a href="https://osu.ppy.sh/mp/{m.osuLobbyId}" target="_blank" class="text-xs text-text-secondary hover:text-accent">Open lobby in browser →</a>
|
||||
<a
|
||||
href="https://osu.ppy.sh/mp/{m.osuLobbyId}"
|
||||
target="_blank"
|
||||
class="text-xs text-text-secondary hover:text-accent">Open lobby in browser →</a
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
@@ -303,13 +403,17 @@
|
||||
<div class="absolute inset-0 animate-ping rounded-full bg-green-400 opacity-75"></div>
|
||||
<div class="relative h-3 w-3 rounded-full bg-green-400"></div>
|
||||
</div>
|
||||
<span class="text-sm font-600 text-green-400">
|
||||
<span class="font-600 text-sm text-green-400">
|
||||
{lobbyStatus?.gameInProgress ? 'Playing' : 'Now Playing — Waiting for ready'}
|
||||
</span>
|
||||
</div>
|
||||
{#if bm}
|
||||
<p class="mt-2 text-center text-sm">{bm.artist} - {bm.title} <span class="text-text-secondary">[{bm.version}]</span></p>
|
||||
<p class="mt-1 text-center text-xs text-text-secondary">{bm.starRating.toFixed(2)}★ · {bm.bpm}bpm · {formatLength(bm.totalLength)}</p>
|
||||
<p class="mt-2 text-center text-sm">
|
||||
{bm.artist} - {bm.title} <span class="text-text-secondary">[{bm.version}]</span>
|
||||
</p>
|
||||
<p class="mt-1 text-center text-xs text-text-secondary">
|
||||
{bm.starRating.toFixed(2)}★ · {bm.bpm}bpm · {formatLength(bm.totalLength)}
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
<!-- Per-player lobby status -->
|
||||
@@ -320,17 +424,31 @@
|
||||
{@const username = playerNames[player.userId]}
|
||||
{@const inLobby = username && lobbyStatus.inLobby.includes(username)}
|
||||
{@const isReady = username && lobbyStatus.readyPlayers.includes(username)}
|
||||
<div class="flex items-center gap-2 rounded border border-border bg-surface-800/50 px-3 py-1.5 text-xs">
|
||||
<div
|
||||
class="flex items-center gap-2 rounded border border-border bg-surface-800/50 px-3 py-1.5 text-xs"
|
||||
>
|
||||
<span class="font-500">{username ?? player.userId}</span>
|
||||
<span class="ml-auto flex items-center gap-1.5">
|
||||
{#if lobbyStatus.gameInProgress}
|
||||
<span class="rounded bg-green-500/20 px-1.5 py-0.5 text-[10px] font-600 text-green-400">Playing</span>
|
||||
<span
|
||||
class="font-600 rounded bg-green-500/20 px-1.5 py-0.5 text-[10px] text-green-400"
|
||||
>Playing</span
|
||||
>
|
||||
{:else if isReady}
|
||||
<span class="rounded bg-green-500/20 px-1.5 py-0.5 text-[10px] font-600 text-green-400">Ready</span>
|
||||
<span
|
||||
class="font-600 rounded bg-green-500/20 px-1.5 py-0.5 text-[10px] text-green-400"
|
||||
>Ready</span
|
||||
>
|
||||
{:else if inLobby}
|
||||
<span class="rounded bg-yellow-500/20 px-1.5 py-0.5 text-[10px] font-600 text-yellow-400">In Lobby</span>
|
||||
<span
|
||||
class="font-600 rounded bg-yellow-500/20 px-1.5 py-0.5 text-[10px] text-yellow-400"
|
||||
>In Lobby</span
|
||||
>
|
||||
{:else}
|
||||
<span class="rounded bg-surface-700 px-1.5 py-0.5 text-[10px] font-600 text-text-secondary">Not in lobby</span>
|
||||
<span
|
||||
class="font-600 rounded bg-surface-700 px-1.5 py-0.5 text-[10px] text-text-secondary"
|
||||
>Not in lobby</span
|
||||
>
|
||||
{/if}
|
||||
</span>
|
||||
</div>
|
||||
@@ -338,21 +456,28 @@
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<p class="mt-3 text-center text-xs text-text-secondary animate-pulse">
|
||||
<p class="mt-3 animate-pulse text-center text-xs text-text-secondary">
|
||||
Waiting for players to ready up & play in osu!...
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
{#if data.isStaff}
|
||||
<div class="mt-3 text-center">
|
||||
<form method="post" action="?/forceStart" use:enhance={() => {
|
||||
forceStarting = true;
|
||||
return async ({ update }) => { forceStarting = false; await update(); };
|
||||
}}>
|
||||
<form
|
||||
method="post"
|
||||
action="?/forceStart"
|
||||
use:enhance={() => {
|
||||
forceStarting = true;
|
||||
return async ({ update }) => {
|
||||
forceStarting = false;
|
||||
await update();
|
||||
};
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={forceStarting}
|
||||
class="rounded-md border border-yellow-500/30 px-4 py-1.5 text-xs font-600 text-yellow-400 transition-colors hover:bg-yellow-500/10 disabled:opacity-50"
|
||||
class="font-600 rounded-md border border-yellow-500/30 px-4 py-1.5 text-xs text-yellow-400 transition-colors hover:bg-yellow-500/10 disabled:opacity-50"
|
||||
>
|
||||
{forceStarting ? 'Starting...' : 'Force Start (skip ready)'}
|
||||
</button>
|
||||
@@ -367,9 +492,11 @@
|
||||
<div class="mt-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<h2 class="text-sm font-600">
|
||||
<h2 class="font-600 text-sm">
|
||||
{#if m.mappool}
|
||||
<a href="/mappools/{m.mappool.id}" class="hover:text-accent hover:underline">{m.mappool.name}</a>
|
||||
<a href="/mappools/{m.mappool.id}" class="hover:text-accent hover:underline"
|
||||
>{m.mappool.name}</a
|
||||
>
|
||||
{:else}
|
||||
Mappool
|
||||
{/if}
|
||||
@@ -378,17 +505,21 @@
|
||||
<span class="text-xs text-text-secondary">avg {mappoolAvgSR()}★</span>
|
||||
{/if}
|
||||
{#if pickingPhase}
|
||||
<span class="rounded bg-blue-500/20 px-2 py-0.5 text-[10px] font-600 text-blue-400">
|
||||
<span class="font-600 rounded bg-blue-500/20 px-2 py-0.5 text-[10px] text-blue-400">
|
||||
{isMyTurnToPick ? 'Your pick!' : `${expectedPicker?.team.name}'s pick`}
|
||||
</span>
|
||||
{:else if m.state === 'PLAYING'}
|
||||
<span class="rounded bg-green-500/20 px-2 py-0.5 text-[10px] font-600 text-green-400">In progress</span>
|
||||
<span class="font-600 rounded bg-green-500/20 px-2 py-0.5 text-[10px] text-green-400"
|
||||
>In progress</span
|
||||
>
|
||||
{:else if m.state === 'ROLLING'}
|
||||
<span class="rounded bg-yellow-500/20 px-2 py-0.5 text-[10px] font-600 text-yellow-400">Rolling...</span>
|
||||
<span class="font-600 rounded bg-yellow-500/20 px-2 py-0.5 text-[10px] text-yellow-400"
|
||||
>Rolling...</span
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
<button
|
||||
onclick={() => mappoolCollapsed = !mappoolCollapsed}
|
||||
onclick={() => (mappoolCollapsed = !mappoolCollapsed)}
|
||||
class="text-xs text-text-secondary hover:text-text-primary"
|
||||
>
|
||||
{mappoolCollapsed ? 'Show ▼' : 'Hide ▲'}
|
||||
@@ -397,12 +528,18 @@
|
||||
|
||||
{#if pickingPhase && !isMyTurnToPick}
|
||||
<p class="mt-1 text-xs text-text-secondary">
|
||||
Waiting for <span class="text-accent">{expectedPicker?.team.name}</span> to pick...
|
||||
You can also type <code class="rounded bg-surface-700 px-1 py-0.5 font-mono text-[10px] text-accent">!pick NM1</code> in osu! chat when it's your turn.
|
||||
Waiting for <span class="text-accent">{expectedPicker?.team.name}</span> to pick... You
|
||||
can also type
|
||||
<code class="rounded bg-surface-700 px-1 py-0.5 font-mono text-[10px] text-accent"
|
||||
>!pick NM1</code
|
||||
> in osu! chat when it's your turn.
|
||||
</p>
|
||||
{:else if pickingPhase && isMyTurnToPick}
|
||||
<p class="mt-1 text-xs text-text-secondary">
|
||||
Pick a map below, or type <code class="rounded bg-surface-700 px-1 py-0.5 font-mono text-[10px] text-accent">!pick NM1</code> in osu! chat.
|
||||
Pick a map below, or type <code
|
||||
class="rounded bg-surface-700 px-1 py-0.5 font-mono text-[10px] text-accent"
|
||||
>!pick NM1</code
|
||||
> in osu! chat.
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
@@ -415,7 +552,11 @@
|
||||
{#each Object.entries(groupedSlots()) as [category, slots] (category)}
|
||||
<!-- Grouped slots -->
|
||||
<div>
|
||||
<span class="mb-1.5 inline-block rounded border px-2 py-0.5 text-xs font-600 {catColors[category] ?? 'border-border'}">
|
||||
<span
|
||||
class="font-600 mb-1.5 inline-block rounded border px-2 py-0.5 text-xs {catColors[
|
||||
category
|
||||
] ?? 'border-border'}"
|
||||
>
|
||||
{category}
|
||||
</span>
|
||||
<div class="grid grid-cols-1 gap-1.5 sm:grid-cols-2">
|
||||
@@ -429,55 +570,82 @@
|
||||
{@const slotLocked = tbLocked || matchPointLocked}
|
||||
{@const cantPick = !isMyTurnToPick || !pickingPhase}
|
||||
|
||||
<div class="flex items-center gap-2 rounded-lg border p-2 transition-all {isPlayed
|
||||
? 'border-border/50 bg-surface-900 opacity-30'
|
||||
: slotLocked
|
||||
<div
|
||||
class="flex items-center gap-2 rounded-lg border p-2 transition-all {isPlayed
|
||||
? 'border-border/50 bg-surface-900 opacity-30'
|
||||
: 'border-border bg-surface-800'}">
|
||||
: slotLocked
|
||||
? 'border-border/50 bg-surface-900 opacity-30'
|
||||
: 'border-border bg-surface-800'}"
|
||||
>
|
||||
<a
|
||||
href="https://osu.ppy.sh/beatmaps/{slot.beatmapId}"
|
||||
target="_blank"
|
||||
class="flex min-w-0 flex-1 items-center gap-3"
|
||||
>
|
||||
{#if bm?.listCoverUrl}
|
||||
<img src={bm.listCoverUrl} alt="" class="h-10 w-20 shrink-0 rounded object-cover" />
|
||||
<img
|
||||
src={bm.listCoverUrl}
|
||||
alt=""
|
||||
class="h-10 w-20 shrink-0 rounded object-cover"
|
||||
/>
|
||||
{:else}
|
||||
<div class="flex h-10 w-20 shrink-0 items-center justify-center rounded bg-surface-600 text-xs text-text-secondary">?</div>
|
||||
<div
|
||||
class="flex h-10 w-20 shrink-0 items-center justify-center rounded bg-surface-600 text-xs text-text-secondary"
|
||||
>
|
||||
?
|
||||
</div>
|
||||
{/if}
|
||||
<div class="min-w-0 flex-1">
|
||||
{#if bm}
|
||||
<p class="truncate text-sm font-500">{bm.artist} - {bm.title}</p>
|
||||
<p class="text-xs text-text-secondary">[{bm.version}] · {bm.starRating.toFixed(2)}★ · {bm.bpm}bpm</p>
|
||||
<p class="font-500 truncate text-sm">{bm.artist} - {bm.title}</p>
|
||||
<p class="text-xs text-text-secondary">
|
||||
[{bm.version}] · {bm.starRating.toFixed(2)}★ · {bm.bpm}bpm
|
||||
</p>
|
||||
{:else}
|
||||
<p class="text-xs text-text-secondary">#{slot.beatmapId}</p>
|
||||
{/if}
|
||||
</div>
|
||||
</a>
|
||||
<span class="text-xs font-600 text-text-secondary">{category}{slot.orderInCategory}</span>
|
||||
<span class="font-600 text-xs text-text-secondary"
|
||||
>{category}{slot.orderInCategory}</span
|
||||
>
|
||||
{#if isPlayed}
|
||||
<span class="text-xs text-text-secondary">✓</span>
|
||||
{:else if slotLocked}
|
||||
<span class="text-[10px] text-pink-400/60" title={tbLocked ? 'Match point only' : 'Tiebreaker only'}>🔒</span>
|
||||
<span
|
||||
class="text-[10px] text-pink-400/60"
|
||||
title={tbLocked ? 'Match point only' : 'Tiebreaker only'}>🔒</span
|
||||
>
|
||||
{:else if pickingPhase}
|
||||
<form method="post" action="?/pick" use:enhance={() => {
|
||||
picking = true;
|
||||
pickError = '';
|
||||
return async ({ result, update }) => {
|
||||
picking = false;
|
||||
if (result.type === 'failure' || (result.type === 'success' && (result.data as Record<string, unknown>)?.error)) {
|
||||
pickError = (result.data as Record<string, unknown>)?.error as string ?? 'Pick failed';
|
||||
} else {
|
||||
await update();
|
||||
}
|
||||
};
|
||||
}}>
|
||||
<form
|
||||
method="post"
|
||||
action="?/pick"
|
||||
use:enhance={() => {
|
||||
picking = true;
|
||||
pickError = '';
|
||||
return async ({ result, update }) => {
|
||||
picking = false;
|
||||
if (
|
||||
result.type === 'failure' ||
|
||||
(result.type === 'success' &&
|
||||
(result.data as Record<string, unknown>)?.error)
|
||||
) {
|
||||
pickError =
|
||||
((result.data as Record<string, unknown>)?.error as string) ??
|
||||
'Pick failed';
|
||||
} else {
|
||||
await update();
|
||||
}
|
||||
};
|
||||
}}
|
||||
>
|
||||
<input type="hidden" name="slotId" value={slot.id} />
|
||||
<button
|
||||
type="submit"
|
||||
disabled={picking || cantPick}
|
||||
class="rounded px-2 py-1 text-[10px] font-700 transition-colors {cantPick
|
||||
? 'bg-surface-600 text-text-secondary/40 cursor-default'
|
||||
: 'bg-white text-black hover:bg-white/80 cursor-pointer'}"
|
||||
class="font-700 rounded px-2 py-1 text-[10px] transition-colors {cantPick
|
||||
? 'cursor-default bg-surface-600 text-text-secondary/40'
|
||||
: 'cursor-pointer bg-white text-black hover:bg-white/80'}"
|
||||
>
|
||||
PICK
|
||||
</button>
|
||||
@@ -498,10 +666,10 @@
|
||||
{@const winner = m.participants.find((p: any) => p.teamId === m.winnerId)}
|
||||
<div class="mt-6 rounded-xl border border-accent/30 bg-surface-800 p-6 text-center">
|
||||
<div class="text-3xl">🏆</div>
|
||||
<h2 class="mt-3 text-xl font-800">
|
||||
<h2 class="font-800 mt-3 text-xl">
|
||||
<span class="text-accent">{winner?.team.name}</span> wins!
|
||||
</h2>
|
||||
<p class="mt-1 text-base font-700 tabular-nums text-text-secondary">
|
||||
<p class="font-700 mt-1 text-base text-text-secondary tabular-nums">
|
||||
{p1?.score} – {p2?.score}
|
||||
</p>
|
||||
</div>
|
||||
@@ -510,15 +678,21 @@
|
||||
<!-- GAME HISTORY -->
|
||||
{#if m.games.length > 0}
|
||||
<div class="mt-6">
|
||||
<h2 class="text-sm font-600">Games Played</h2>
|
||||
<h2 class="font-600 text-sm">Games Played</h2>
|
||||
<div class="mt-3 flex flex-col gap-2">
|
||||
{#each m.games as game}
|
||||
{@const bm = data.beatmapCache[game.slot?.beatmapId]}
|
||||
{@const pickerTeam = m.participants.find((p: any) => p.id === game.pickedByParticipantId)?.team}
|
||||
{@const winnerTeam = m.participants.find((p: any) => p.id === game.winnerParticipantId)?.team}
|
||||
{@const pickerTeam = m.participants.find(
|
||||
(p: any) => p.id === game.pickedByParticipantId
|
||||
)?.team}
|
||||
{@const winnerTeam = m.participants.find(
|
||||
(p: any) => p.id === game.winnerParticipantId
|
||||
)?.team}
|
||||
|
||||
<div class="flex items-center gap-3 rounded-lg border border-border bg-surface-800 p-3">
|
||||
<span class="w-6 text-center text-xs font-600 text-text-secondary">{game.gameNumber}</span>
|
||||
<span class="font-600 w-6 text-center text-xs text-text-secondary"
|
||||
>{game.gameNumber}</span
|
||||
>
|
||||
{#if bm?.listCoverUrl}
|
||||
<img src={bm.listCoverUrl} alt="" class="h-8 w-16 rounded object-cover" />
|
||||
{:else}
|
||||
@@ -526,16 +700,21 @@
|
||||
{/if}
|
||||
<div class="min-w-0 flex-1">
|
||||
{#if bm}
|
||||
<p class="truncate text-sm font-500">{bm.artist} - {bm.title} <span class="text-text-secondary">[{bm.version}]</span></p>
|
||||
<p class="font-500 truncate text-sm">
|
||||
{bm.artist} - {bm.title} <span class="text-text-secondary">[{bm.version}]</span>
|
||||
</p>
|
||||
{/if}
|
||||
<div class="flex items-center gap-2 text-xs text-text-secondary">
|
||||
<span class="rounded border px-1.5 py-0.5 {catColors[game.slot?.category] ?? 'border-border'}">{game.slot?.category}{game.slot?.orderInCategory}</span>
|
||||
<span
|
||||
class="rounded border px-1.5 py-0.5 {catColors[game.slot?.category] ??
|
||||
'border-border'}">{game.slot?.category}{game.slot?.orderInCategory}</span
|
||||
>
|
||||
{#if pickerTeam}<span>Picked by {pickerTeam.name}</span>{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if game.state === 'PLAYING'}
|
||||
<span class="text-xs text-green-400 animate-pulse">Live</span>
|
||||
<span class="animate-pulse text-xs text-green-400">Live</span>
|
||||
{:else if winnerTeam}
|
||||
<span class="text-xs text-green-400">✓ {winnerTeam.name}</span>
|
||||
{/if}
|
||||
@@ -546,9 +725,13 @@
|
||||
<div class="mt-1.5 flex flex-col gap-1">
|
||||
{#each game.scores as s}
|
||||
{@const username = playerNames[s.player?.userId]}
|
||||
<div class="flex flex-wrap items-center gap-x-3 gap-y-0.5 rounded bg-surface-900 px-3 py-1.5 text-xs">
|
||||
<span class="w-24 truncate font-500">{username ?? s.player?.userId ?? '?'}</span>
|
||||
<span class="font-mono tabular-nums text-text-primary">{s.score.toLocaleString()}</span>
|
||||
<div
|
||||
class="flex flex-wrap items-center gap-x-3 gap-y-0.5 rounded bg-surface-900 px-3 py-1.5 text-xs"
|
||||
>
|
||||
<span class="font-500 w-24 truncate">{username ?? s.player?.userId ?? '?'}</span>
|
||||
<span class="font-mono text-text-primary tabular-nums"
|
||||
>{s.score.toLocaleString()}</span
|
||||
>
|
||||
{#if s.accuracy > 0}
|
||||
<span class="text-text-secondary">{(s.accuracy * 100).toFixed(2)}%</span>
|
||||
{/if}
|
||||
@@ -581,7 +764,7 @@
|
||||
<!-- CREATED / LOBBY waiting states -->
|
||||
{#if m.state === 'CREATED'}
|
||||
<div class="mt-6 rounded-lg border border-border bg-surface-800 p-6 text-center">
|
||||
<p class="text-sm text-text-secondary animate-pulse">Creating osu! lobby...</p>
|
||||
<p class="animate-pulse text-sm text-text-secondary">Creating osu! lobby...</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -590,7 +773,11 @@
|
||||
<p class="text-sm text-text-secondary">Lobby created. Waiting for players to join...</p>
|
||||
{#if m.osuLobbyId}
|
||||
<p class="mt-2 text-xs text-text-secondary">
|
||||
<a href="https://osu.ppy.sh/mp/{m.osuLobbyId}" target="_blank" class="text-accent hover:underline">osu! mp/{m.osuLobbyId}</a>
|
||||
<a
|
||||
href="https://osu.ppy.sh/mp/{m.osuLobbyId}"
|
||||
target="_blank"
|
||||
class="text-accent hover:underline">osu! mp/{m.osuLobbyId}</a
|
||||
>
|
||||
</p>
|
||||
{/if}
|
||||
{#if lobbyStatus}
|
||||
@@ -599,13 +786,21 @@
|
||||
{#each participant.players as player}
|
||||
{@const username = playerNames[player.userId]}
|
||||
{@const inLobby = username && lobbyStatus.inLobby.includes(username)}
|
||||
<div class="flex items-center gap-2 rounded border border-border bg-surface-800/50 px-3 py-1.5 text-xs">
|
||||
<div
|
||||
class="flex items-center gap-2 rounded border border-border bg-surface-800/50 px-3 py-1.5 text-xs"
|
||||
>
|
||||
<span class="font-500">{username ?? player.userId}</span>
|
||||
<span class="ml-auto">
|
||||
{#if inLobby}
|
||||
<span class="rounded bg-green-500/20 px-1.5 py-0.5 text-[10px] font-600 text-green-400">In Lobby</span>
|
||||
<span
|
||||
class="font-600 rounded bg-green-500/20 px-1.5 py-0.5 text-[10px] text-green-400"
|
||||
>In Lobby</span
|
||||
>
|
||||
{:else}
|
||||
<span class="rounded bg-surface-700 px-1.5 py-0.5 text-[10px] font-600 text-text-secondary">Not in lobby</span>
|
||||
<span
|
||||
class="font-600 rounded bg-surface-700 px-1.5 py-0.5 text-[10px] text-text-secondary"
|
||||
>Not in lobby</span
|
||||
>
|
||||
{/if}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user