148 lines
5.6 KiB
Svelte
148 lines
5.6 KiB
Svelte
<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}
|