Files
esports/src/lib/components/match/MatchDashboard.svelte

488 lines
18 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<script lang="ts">
import { fade } from 'svelte/transition';
import type { MapView, MatchView } from '$lib/match-view';
import { MatchLive } from './live.svelte';
import Scoreboard from './Scoreboard.svelte';
import MapPool from './MapPool.svelte';
import MapHistory from './MapHistory.svelte';
import MatchFeed from './MatchFeed.svelte';
import ActionDialog from './ActionDialog.svelte';
import RefereePanel from './RefereePanel.svelte';
import SimPanel from './SimPanel.svelte';
import StageLobby from './StageLobby.svelte';
import StageRoll from './StageRoll.svelte';
import StageTurn from './StageTurn.svelte';
import NowPlaying from './NowPlaying.svelte';
import StageFinal from './StageFinal.svelte';
import MapReveal from './MapReveal.svelte';
import PredictionCard from '$lib/components/PredictionCard.svelte';
import type { PredictionResult, PredictionState } from '$lib/predictions';
import { blinkTitle, chime, notify } from './attention';
import { Button } from '$lib/components/ui/button/index.js';
import ArrowLeft from '@lucide/svelte/icons/arrow-left';
import Bell from '@lucide/svelte/icons/bell';
import BellOff from '@lucide/svelte/icons/bell-off';
import Eye from '@lucide/svelte/icons/eye';
import ExternalLink from '@lucide/svelte/icons/external-link';
import Pause from '@lucide/svelte/icons/pause';
import Wifi from '@lucide/svelte/icons/wifi';
import FlaskConical from '@lucide/svelte/icons/flask-conical';
import CirclePlay from '@lucide/svelte/icons/circle-play';
import WifiOff from '@lucide/svelte/icons/wifi-off';
import ChevronDown from '@lucide/svelte/icons/chevron-down';
import List from '@lucide/svelte/icons/list';
import Layers from '@lucide/svelte/icons/layers';
import Trophy from '@lucide/svelte/icons/trophy';
import Sparkles from '@lucide/svelte/icons/sparkles';
import { SIDE_COLORS, modColor } from './theme';
const {
view,
prediction = null,
videos = { live: [], recorded: [] },
userId,
isReferee,
devTools
}: {
view: MatchView;
prediction?: PredictionState | null;
videos?: {
live: { channelId: string; name: string }[];
recorded: { channelId: string; sessionId: string; name: string; t: number }[];
};
userId: string | null;
isReferee: boolean;
devTools: boolean;
} = $props();
// svelte-ignore state_referenced_locally
const live = new MatchLive(view, userId);
$effect(() => live.connect());
const v = $derived(live.view);
const decided = $derived(
v.phase === 'closing' || v.phase === 'finished' || v.phase === 'cancelled'
);
const turnPhase = $derived(['choosing', 'banning', 'picking', 'tb'].includes(v.phase));
// svelte-ignore state_referenced_locally
const celebrate = !(view.phase === 'closing' || view.phase === 'finished');
// ── Your turn: open the dialog once per prompt, and get attention ──
let dialogOpen = $state(false);
let sound = $state(true);
let lastPrompt = '';
$effect(() => {
try {
sound = localStorage.getItem('vash:sound') !== 'off';
} catch {
/* storage blocked */
}
});
$effect(() => {
const key = live.myTurn ? `${v.prompt.kind}:${v.prompt.side}:${v.log.length}` : '';
if (!key || key === lastPrompt) {
if (!live.myTurn) dialogOpen = false;
return;
}
lastPrompt = key;
if (v.prompt.kind !== 'roll') dialogOpen = true;
const what =
v.prompt.kind === 'roll' ? 'roll' : v.prompt.kind === 'choose' ? 'choice' : v.prompt.kind;
if (sound) chime();
notify(`Your ${what}!`, `${v.sides[0].name} vs ${v.sides[1].name}`);
if (document.hidden) return blinkTitle(`🔔 Your ${what}!`);
});
function toggleSound() {
sound = !sound;
try {
localStorage.setItem('vash:sound', sound ? 'on' : 'off');
} catch {
/* storage blocked */
}
if (sound) {
chime();
if (typeof Notification !== 'undefined' && Notification.permission === 'default')
void Notification.requestPermission();
}
}
// ── A short result reveal after each map ──
let reveal = $state<MapView | null>(null);
// svelte-ignore state_referenced_locally
let seenMaps = view.maps.length;
let revealTimer: ReturnType<typeof setTimeout> | undefined;
// Snapshots arrive every second during a map, so the hide timer lives
// outside the effect; an effect cleanup would cancel it on every update.
$effect(() => {
const count = v.maps.length;
if (count > seenMaps) {
reveal = v.maps.at(-1) ?? null;
clearTimeout(revealTimer);
revealTimer = setTimeout(() => (reveal = null), 6500);
}
seenMaps = count;
});
$effect(() => () => clearTimeout(revealTimer));
const title = $derived(`${v.sides[0].name} vs ${v.sides[1].name}`);
// Predictions close when the first map is picked, and settle when a winner is known.
const predictionsOpen = $derived(
!decided &&
!v.live &&
v.maps.length === 0 &&
['created', 'waiting', 'rolling', 'choosing', 'banning', 'picking'].includes(v.phase)
);
const predictionResult = $derived.by((): PredictionResult | null => {
const winner = v.sides.find((side) => side.id === v.winner);
const loser = v.sides.find((side) => side.id !== v.winner);
return winner && loser && decided
? { winnerTeamId: winner.teamId, score: [winner.points, loser.points] }
: null;
});
let panel = $state<'activity' | 'pool' | 'results' | 'predictions'>('activity');
const activeStep = $derived(
decided ? 3 : v.phase === 'playing' || turnPhase ? 2 : v.phase === 'rolling' ? 1 : 0
);
const steps = ['Lobby', 'Roll for order', 'Pick & play', 'Results'];
const guidance = $derived.by(() => {
if (v.paused) return { label: 'Match paused', detail: 'A referee will resume play.' };
if (decided)
return { label: 'That’s a wrap', detail: 'Open results for every map and player score.' };
if (v.phase === 'created')
return {
label: 'Lobby is being prepared',
detail: 'The invite will arrive in osu! chat when it opens.'
};
if (v.phase === 'waiting')
return live.mySide
? {
label: 'Join the osu! lobby',
detail: 'Accept your invite in osu! chat. Your team’s arrivals update here live.'
}
: { label: 'Waiting for players', detail: 'Both teams are joining the osu! lobby.' };
if (v.phase === 'rolling')
return live.myTurn
? { label: 'Your turn to roll', detail: 'Roll here or type !roll in the osu! lobby.' }
: { label: 'Rolling for order', detail: 'The higher roll decides who goes first.' };
if (turnPhase)
return live.myTurn
? { label: 'Your move', detail: 'Choose below before the timer runs out.' }
: {
label: 'Waiting for a choice',
detail: `${live.side(v.prompt.side)?.name ?? 'A team'} is making the next move.`
};
if (v.phase === 'playing')
return v.live?.startedAt
? { label: 'Map in progress', detail: 'Scores and progress update as players finish.' }
: { label: 'Ready up in osu!', detail: 'The selected map is loading. Get ready to play.' };
return { label: 'Match in progress', detail: 'Updates appear here as they happen.' };
});
</script>
<svelte:head>
<title>{v.sides[0].points}–{v.sides[1].points} · {title} · Vash Esports</title>
<meta name="description" content="{title}, best of {v.rules.bestOf} on Vash Esports." />
<meta property="og:title" content="{title} · Vash Esports" />
<meta
property="og:description"
content="{v.sides[0].points}–{v.sides[1].points} · best of {v.rules.bestOf}"
/>
<meta
property="og:image"
content={v.slots.find((slot) => slot.coverUrl)?.coverUrl ?? '/logo.png'}
/>
<meta name="twitter:card" content="summary_large_image" />
</svelte:head>
<div class="mx-auto flex max-w-[1600px] flex-col gap-3 lg:h-[calc(100dvh-6.5rem)] lg:min-h-[680px]">
<header class="flex min-h-10 flex-wrap items-center gap-2">
<a
href="/matches"
class="grid size-9 place-items-center rounded-lg border border-white/10 text-zinc-400 transition hover:bg-white/5 hover:text-white"
aria-label="All matches"><ArrowLeft class="size-4" /></a
>
<div class="min-w-0 flex-1">
<h1 class="truncate text-base font-bold tracking-tight sm:text-lg">{v.name ?? title}</h1>
<div class="flex flex-wrap items-center gap-x-2 text-xs text-zinc-500">
{#if v.tournament}
<a
href="/tournaments/{v.tournament.id}"
class="font-medium hover:underline"
style="color:{v.tournament.accentColor ?? '#e4e4e7'}"
>{v.tournament.name}{v.tournament.round ? ` · ${v.tournament.round}` : ''}</a
>
{:else}
<span class="capitalize">{v.kind} match</span>
{/if}
<span>·</span><span>BO{v.rules.bestOf} · {v.rules.teamSize}v{v.rules.teamSize}</span>
</div>
</div>
<span
class="hidden items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs font-medium sm:flex {decided ||
live.connected
? 'border-emerald-400/20 bg-emerald-400/5 text-emerald-300'
: 'border-amber-400/30 bg-amber-400/10 text-amber-300'}"
title={decided
? 'Final score'
: live.connected
? 'Live updates connected'
: live.fallbackConnected
? 'Updating every five seconds'
: 'Reconnecting to live updates'}
>
{#if decided}<Trophy class="size-3.5" />Final{:else if live.connected}<Wifi
class="size-3.5"
/>Live · <Eye class="size-3.5" />{live.viewers}{:else if live.fallbackConnected}<Wifi
class="size-3.5"
/>Updating{:else}<WifiOff class="size-3.5" />Reconnecting{/if}
</span>
{#each videos.live as stream (stream.channelId)}
<a
href="/watch/{stream.channelId}"
class="flex items-center gap-1.5 rounded-lg bg-red-500 px-3 py-1.5 text-sm font-semibold text-white hover:bg-red-400"
><CirclePlay class="size-4" />Watch on {stream.name}</a
>
{/each}
{#each videos.recorded.slice(0, 1) as recording (recording.sessionId)}
<a
href="/watch/{recording.channelId}?session={recording.sessionId}&t={recording.t}"
class="flex items-center gap-1.5 rounded-lg border border-white/10 px-3 py-1.5 text-sm hover:bg-white/5"
><CirclePlay class="size-4" />Watch the broadcast</a
>
{/each}
<Button
variant="ghost"
size="icon-sm"
onclick={toggleSound}
title={sound ? 'Turn alerts off' : 'Turn alerts on'}
>
{#if sound}<Bell class="size-4" />{:else}<BellOff class="size-4" />{/if}
</Button>
<details class="relative z-20">
<summary
class="flex h-8 cursor-pointer list-none items-center gap-1 rounded-lg border border-white/10 px-2.5 text-xs text-zinc-300 hover:bg-white/5"
>Details <ChevronDown class="size-3" /></summary
>
<div
class="absolute right-0 mt-2 w-64 rounded-xl border border-white/10 bg-zinc-900 p-3 text-xs shadow-2xl"
>
<p class="mb-2 font-semibold text-white">Match details</p>
<dl class="grid grid-cols-[auto_1fr] gap-x-3 gap-y-1.5 text-zinc-400">
<dt>Format</dt>
<dd class="text-right text-zinc-200">
BO{v.rules.bestOf} · {v.rules.teamSize}v{v.rules.teamSize}
</dd>
<dt>Win by</dt>
<dd class="text-right text-zinc-200 capitalize">
{v.rules.winCondition.replace('_', ' ')}
</dd>
<dt>Viewers</dt>
<dd class="text-right text-zinc-200">{live.viewers}</dd>
</dl>
{#if v.lobby.id && v.lobby.provider === 'irc'}
<a
href="https://osu.ppy.sh/mp/{v.lobby.id}"
target="_blank"
rel="noopener"
class="mt-3 flex items-center justify-between border-t border-white/10 pt-3 text-zinc-200 hover:text-white"
>Open osu! lobby <ExternalLink class="size-3" /></a
>
{:else if v.lobby.provider === 'simulator' && v.lobby.id}
<p class="mt-3 flex items-center gap-1.5 border-t border-white/10 pt-3 text-fuchsia-300">
<FlaskConical class="size-3" />Simulated lobby
</p>
{/if}
</div>
</details>
{#if devTools && v.sim}<SimPanel {live} />{/if}
{#if isReferee}<RefereePanel {live} />{/if}
</header>
<Scoreboard {live} />
<div class="grid min-h-0 flex-1 gap-3 lg:grid-cols-[minmax(0,1fr)_23rem]">
<section
class="relative flex min-h-[25rem] flex-col overflow-hidden rounded-2xl border border-white/10 bg-gradient-to-b from-zinc-900/90 to-zinc-950 lg:min-h-0"
>
<div
class="flex flex-wrap items-center justify-between gap-2 border-b border-white/8 px-4 py-3 sm:px-5"
>
<div class="flex items-center gap-2 text-xs font-semibold text-zinc-300">
<span
class="size-2 rounded-full {live.myTurn
? 'animate-pulse bg-amber-300'
: 'bg-emerald-400'}"
></span>{guidance.label}
</div>
<nav aria-label="Match progress" class="flex items-center gap-1.5">
{#each steps as step, index}
<span
class="rounded-full px-2 py-0.5 text-[10px] font-semibold {index === activeStep
? 'bg-white/10 text-white'
: index < activeStep
? 'text-emerald-300'
: 'text-zinc-600'}">{index < activeStep ? '✓ ' : ''}{step}</span
>
{/each}
</nav>
</div>
<div class="flex min-h-0 flex-1 flex-col overflow-y-auto px-4 py-4 sm:px-6 sm:py-5">
<div class="my-auto w-full">
<p class="mb-4 text-center text-xs text-zinc-500">{guidance.detail}</p>
{#if v.paused && !decided}
<div
transition:fade
class="mb-4 flex items-center gap-2 rounded-xl border border-amber-400/30 bg-amber-400/10 p-3 text-sm text-amber-200"
>
<Pause class="size-4 shrink-0" />
<span class="flex-1">{v.paused.reason}</span>
</div>
{/if}
{#if reveal}
{#key reveal.index}
<div class="mb-4" out:fade={{ duration: 300 }}><MapReveal {live} map={reveal} /></div>
{/key}
{/if}
{#key v.phase === 'playing' ? `playing:${v.live?.slotId}` : v.phase}
<div in:fade={{ duration: 250 }}>
{#if decided}
<StageFinal {live} {celebrate} />
{:else if v.phase === 'created' || v.phase === 'waiting'}
<StageLobby {live} {isReferee} />
{:else if v.phase === 'rolling'}
<StageRoll {live} />
{:else if turnPhase}
<StageTurn {live} onact={() => (dialogOpen = true)} />
{:else if v.phase === 'playing' && v.live}
<NowPlaying {live} />
{/if}
</div>
{/key}
</div>
</div>
<div class="border-t border-white/8 bg-black/20 px-3 py-2.5 sm:px-4">
<div class="mb-2 flex items-center justify-between text-[11px] font-semibold text-zinc-400">
<span>MAP JOURNEY</span><span>{v.maps.length} played · first to {v.rules.winsNeeded}</span
>
</div>
<div class="flex gap-1.5 overflow-x-auto pb-1">
{#each Array.from({ length: v.rules.bestOf }) as _, index}
{@const map = v.maps[index]}
{@const isLive = !map && !!v.live && index === v.maps.length}
{@const slot = map ? live.slot(map.slotId) : isLive ? live.slot(v.live?.slotId) : null}
{@const winner = map ? live.side(map.winner) : null}
<div
class="flex min-w-24 flex-1 basis-24 items-center gap-2 rounded-lg border px-2 py-1.5 text-xs {isLive
? 'border-emerald-400/40 bg-emerald-400/10'
: map
? 'border-white/12 bg-white/5'
: 'border-white/5 bg-white/[0.02] text-zinc-600'}"
>
<span
class="grid size-6 shrink-0 place-items-center rounded-md bg-black/30 font-mono text-[10px] font-bold"
style={slot ? `color:${modColor(slot.category)}` : ''}
>{slot?.label ?? index + 1}</span
>
<span class="min-w-0 truncate"
>{isLive
? 'Now playing'
: winner
? winner.name
: map
? 'Draw'
: `Map ${index + 1}`}</span
>
{#if winner}<span
class="ml-auto size-2 shrink-0 rounded-full"
style="background:{SIDE_COLORS[winner.color].solid}"
></span>{/if}
</div>
{/each}
</div>
</div>
</section>
<aside
class="flex min-h-[26rem] flex-col overflow-hidden rounded-2xl border border-white/10 bg-zinc-950/70 lg:min-h-0"
aria-label="Match information"
>
<div
class="flex shrink-0 items-center gap-1 border-b border-white/8 p-2"
role="tablist"
aria-label="Match information"
>
<button
type="button"
role="tab"
aria-selected={panel === 'activity'}
onclick={() => (panel = 'activity')}
class="flex min-w-0 flex-1 items-center justify-center gap-1 rounded-lg px-2 py-2 text-xs font-semibold transition {panel ===
'activity'
? 'bg-white/10 text-white'
: 'text-zinc-500 hover:bg-white/5 hover:text-zinc-200'}"
><List class="size-3.5" />Activity</button
>
<button
type="button"
role="tab"
aria-selected={panel === 'pool'}
onclick={() => (panel = 'pool')}
class="flex min-w-0 flex-1 items-center justify-center gap-1 rounded-lg px-2 py-2 text-xs font-semibold transition {panel ===
'pool'
? 'bg-white/10 text-white'
: 'text-zinc-500 hover:bg-white/5 hover:text-zinc-200'}"
><Layers class="size-3.5" />Maps</button
>
<button
type="button"
role="tab"
aria-selected={panel === 'results'}
onclick={() => (panel = 'results')}
class="flex min-w-0 flex-1 items-center justify-center gap-1 rounded-lg px-2 py-2 text-xs font-semibold transition {panel ===
'results'
? 'bg-white/10 text-white'
: 'text-zinc-500 hover:bg-white/5 hover:text-zinc-200'}"
><Trophy class="size-3.5" />Results</button
>
{#if prediction}<button
type="button"
role="tab"
aria-selected={panel === 'predictions'}
onclick={() => (panel = 'predictions')}
class="flex min-w-0 flex-1 items-center justify-center gap-1 rounded-lg px-2 py-2 text-xs font-semibold transition {panel ===
'predictions'
? 'bg-white/10 text-white'
: 'text-zinc-500 hover:bg-white/5 hover:text-zinc-200'}"
><Sparkles class="size-3.5" />Picks</button
>{/if}
</div>
<div class="min-h-0 flex-1" role="tabpanel">
{#if panel === 'activity'}
<MatchFeed {live} embedded />
{:else if panel === 'pool'}
<div class="h-full overflow-y-auto p-2"><MapPool {live} compact /></div>
{:else if panel === 'results'}
<div class="h-full overflow-y-auto p-2">
{#if v.maps.length}<MapHistory {live} {isReferee} embedded />{:else}<p
class="px-3 py-12 text-center text-sm text-zinc-500"
>
Map results will appear here after the first play.
</p>{/if}
</div>
{:else if prediction}
<div class="h-full overflow-y-auto p-2">
<PredictionCard
state={prediction}
crowd={live.crowd}
open={predictionsOpen}
result={predictionResult}
/>
</div>
{/if}
</div>
</aside>
</div>
</div>
<ActionDialog {live} bind:open={dialogOpen} />