feat: redesign match play and add broadcast tooling
This commit is contained in:
147
src/lib/components/match/MapHistory.svelte
Normal file
147
src/lib/components/match/MapHistory.svelte
Normal file
@@ -0,0 +1,147 @@
|
||||
<script lang="ts">
|
||||
import { slide } from 'svelte/transition';
|
||||
import { SvelteSet } from 'svelte/reactivity';
|
||||
import type { MatchLive } from './live.svelte';
|
||||
import { SIDE_COLORS, formatScore, modColor } from './theme';
|
||||
import ChevronDown from '@lucide/svelte/icons/chevron-down';
|
||||
|
||||
const {
|
||||
live,
|
||||
isReferee,
|
||||
embedded = false
|
||||
}: { live: MatchLive; isReferee: boolean; embedded?: boolean } = $props();
|
||||
const view = $derived(live.view);
|
||||
const open = new SvelteSet<number>();
|
||||
|
||||
function name(playerId: string) {
|
||||
for (const side of view.sides) {
|
||||
const player = side.players.find((each) => each.id === playerId);
|
||||
if (player) return player.osuName ?? player.name;
|
||||
}
|
||||
return 'Unknown';
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if view.maps.length}
|
||||
<section class={embedded ? 'p-2' : 'rounded-2xl border border-white/8 bg-zinc-950/60 p-3 sm:p-4'}>
|
||||
<h2 class="mb-3 text-sm font-semibold tracking-wide text-zinc-200 uppercase">Maps played</h2>
|
||||
<ol class="space-y-2">
|
||||
{#each [...view.maps].reverse() as map (map.index)}
|
||||
{@const slot = live.slot(map.slotId)}
|
||||
{@const winner = live.side(map.winner)}
|
||||
{@const red = map.totals[view.sides[0].id] ?? 0}
|
||||
{@const blue = map.totals[view.sides[1].id] ?? 0}
|
||||
{@const share = red + blue > 0 ? red / (red + blue) : 0.5}
|
||||
<li
|
||||
class="overflow-hidden rounded-xl border border-l-[3px] border-white/8 bg-white/[0.03]"
|
||||
style="border-left-color:{slot ? modColor(slot.category) : '#71717a'}"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="w-full p-3 text-left transition hover:bg-white/[0.04]"
|
||||
aria-expanded={open.has(map.index)}
|
||||
onclick={() => (open.has(map.index) ? open.delete(map.index) : open.add(map.index))}
|
||||
>
|
||||
<div class="flex min-w-0 items-center gap-2">
|
||||
<span class="w-4 shrink-0 text-center text-[11px] text-zinc-500 tabular-nums"
|
||||
>{map.index + 1}</span
|
||||
>
|
||||
<span
|
||||
class="shrink-0 font-mono text-xs font-black"
|
||||
style="color:{slot ? modColor(slot.category) : '#fff'}">{slot?.label ?? 'Map'}</span
|
||||
>
|
||||
<span class="min-w-0 flex-1 truncate text-sm font-medium"
|
||||
>{slot?.title ?? `Beatmap #${slot?.beatmapId ?? ''}`}</span
|
||||
>
|
||||
<ChevronDown
|
||||
class="size-4 shrink-0 text-zinc-500 transition {open.has(map.index)
|
||||
? 'rotate-180'
|
||||
: ''}"
|
||||
/>
|
||||
</div>
|
||||
<div class="mt-2 flex items-center justify-between gap-2 pl-6 text-xs">
|
||||
<span
|
||||
class="min-w-0 truncate font-semibold"
|
||||
style="color:{winner ? SIDE_COLORS[winner.color].text : '#a1a1aa'}"
|
||||
>{winner?.name ?? 'Draw'}{winner ? ' won' : ''}</span
|
||||
>
|
||||
<span class="shrink-0 font-mono text-[11px] tabular-nums"
|
||||
><span style="color:{SIDE_COLORS.red.text}"
|
||||
>{formatScore(red, view.rules.winCondition)}</span
|
||||
><span class="px-1 text-zinc-600">–</span><span
|
||||
style="color:{SIDE_COLORS.blue.text}"
|
||||
>{formatScore(blue, view.rules.winCondition)}</span
|
||||
></span
|
||||
>
|
||||
</div>
|
||||
<div class="mt-2 ml-6 flex h-1.5 overflow-hidden rounded-full bg-white/5">
|
||||
<div style="width:{share * 100}%;background:{SIDE_COLORS.red.solid}"></div>
|
||||
<div class="flex-1" style="background:{SIDE_COLORS.blue.solid}"></div>
|
||||
</div>
|
||||
</button>
|
||||
{#if open.has(map.index)}
|
||||
<div transition:slide={{ duration: 180 }} class="border-t border-white/5 px-3 py-2">
|
||||
{#if map.scores.length}
|
||||
<p class="mb-1 text-[10px] font-semibold tracking-wide text-zinc-500 uppercase">
|
||||
Player scores
|
||||
</p>
|
||||
{#each map.scores as score (score.playerId)}
|
||||
{@const side = live.side(score.side)}
|
||||
<div class="flex items-center gap-2 border-t border-white/5 py-1.5 text-xs">
|
||||
<span
|
||||
class="size-2 shrink-0 rounded-full"
|
||||
style="background:{side ? SIDE_COLORS[side.color].solid : '#71717a'}"
|
||||
></span>
|
||||
<div class="min-w-0 flex-1">
|
||||
<a
|
||||
href="/users/{score.userId}"
|
||||
class="block truncate font-medium hover:underline">{name(score.playerId)}</a
|
||||
><span class="text-[10px] text-zinc-500"
|
||||
>{score.accuracy ? `${score.accuracy.toFixed(2)}%` : '–'} · {score.combo
|
||||
? `${score.combo}x`
|
||||
: '–'} · {score.mods.filter((mod) => mod !== 'NF').join('') ||
|
||||
'NM'}{#if !score.passed}
|
||||
· <span class="text-red-400">FAIL</span>{/if}</span
|
||||
>
|
||||
</div>
|
||||
<span class="shrink-0 font-mono font-semibold tabular-nums"
|
||||
>{score.score.toLocaleString('en-US')}</span
|
||||
>
|
||||
</div>
|
||||
{/each}
|
||||
{:else}
|
||||
<p class="text-xs text-zinc-500">
|
||||
No per-player scores were recorded for this map.
|
||||
</p>
|
||||
{/if}
|
||||
{#if isReferee}
|
||||
<div
|
||||
class="mt-2 flex flex-wrap items-center gap-1.5 border-t border-white/5 pt-2 text-xs"
|
||||
>
|
||||
<span class="w-full text-zinc-500">Correct winner:</span>
|
||||
{#each view.sides as side (side.id)}
|
||||
<button
|
||||
type="button"
|
||||
class="rounded border border-white/10 px-2 py-0.5 hover:bg-white/10"
|
||||
style="color:{SIDE_COLORS[side.color].text}"
|
||||
onclick={() =>
|
||||
live.send({ type: 'correct', mapIndex: map.index, winner: side.id })}
|
||||
>{side.name}</button
|
||||
>
|
||||
{/each}
|
||||
<button
|
||||
type="button"
|
||||
class="rounded border border-white/10 px-2 py-0.5 text-zinc-400 hover:bg-white/10"
|
||||
onclick={() =>
|
||||
live.send({ type: 'correct', mapIndex: map.index, winner: null })}
|
||||
>Draw</button
|
||||
>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</li>
|
||||
{/each}
|
||||
</ol>
|
||||
</section>
|
||||
{/if}
|
||||
Reference in New Issue
Block a user