Spaces:
Running
Running
| <script lang="ts"> | |
| import type { PreviewChunk } from '$lib/types'; | |
| import * as ToggleGroup from '$lib/components/ui/toggle-group'; | |
| import { Switch } from '$lib/components/ui/switch'; | |
| import { Label } from '$lib/components/ui/label'; | |
| import { cn } from '$lib/utils'; | |
| import SkullIcon from 'phosphor-svelte/lib/SkullIcon'; | |
| import ShieldIcon from 'phosphor-svelte/lib/ShieldIcon'; | |
| import SwordIcon from 'phosphor-svelte/lib/SwordIcon'; | |
| import { formatDuration } from '$lib/utils/format'; | |
| import { playerColor } from '$lib/utils/player-colors'; | |
| interface Props { | |
| previews: PreviewChunk[]; | |
| currentPlayer: number; | |
| preloadedPlayers: Set<number>; | |
| availablePlayers: Set<number>; | |
| playerDurations: Map<number, number>; | |
| preloadAll: boolean; | |
| onSelect: (player: number) => void; | |
| onPreloadAllChange: (enabled: boolean) => void; | |
| } | |
| let { | |
| previews, | |
| currentPlayer, | |
| preloadedPlayers, | |
| availablePlayers, | |
| playerDurations, | |
| preloadAll, | |
| onSelect, | |
| onPreloadAllChange | |
| }: Props = $props(); | |
| type PlayerSummary = { | |
| player: number; | |
| side: 'CT' | 'T' | string; | |
| weapon: string; | |
| survived: boolean; | |
| duration: number; | |
| }; | |
| const players = $derived.by<PlayerSummary[]>(() => { | |
| const map = new Map<number, PreviewChunk[]>(); | |
| for (const p of previews) { | |
| if (!map.has(p.player)) map.set(p.player, []); | |
| map.get(p.player)!.push(p); | |
| } | |
| const out: PlayerSummary[] = []; | |
| for (const [player, chunks] of map.entries()) { | |
| chunks.sort((a, b) => a.chunk_index - b.chunk_index); | |
| const last = chunks[chunks.length - 1]; | |
| out.push({ | |
| player, | |
| side: last.player_side, | |
| weapon: last.primary_weapon, | |
| survived: last.survived_chunk, | |
| duration: playerDurations.get(player) ?? 0 | |
| }); | |
| } | |
| out.sort((a, b) => a.player - b.player); | |
| return out; | |
| }); | |
| const ctPlayers = $derived(players.filter((p) => p.side === 'CT')); | |
| const tPlayers = $derived(players.filter((p) => p.side === 'T')); | |
| </script> | |
| {#snippet playerItem(p: PlayerSummary)} | |
| { ct = p.side === 'CT'} | |
| { ready = preloadedPlayers.has(p.player)} | |
| { available = availablePlayers.has(p.player)} | |
| <ToggleGroup.Item | |
| data-ready={available && ready ? true : undefined} | |
| data-side={ct ? 'ct' : 't'} | |
| value={String(p.player)} | |
| aria-label="Player {p.player} ({p.weapon || 'no weapon'})" | |
| title={available | |
| ? `Player ${p.player} — ${p.weapon || 'no weapon'}` | |
| : `Player ${p.player} died at ${formatDuration(p.duration)} — not available at current time`} | |
| disabled={!available} | |
| class={cn( | |
| 'group flex h-auto min-w-0 flex-1 basis-0 items-center justify-start gap-2 rounded-md border px-2 py-1.5 text-left text-sm transition', | |
| 'disabled:cursor-not-allowed disabled:opacity-40 data-[state=on]:border-current data-[state=on]:bg-accent data-[state=on]:text-accent-foreground', | |
| 'data-[side=ct]:data-[state=on]:border-sky-500 data-[side=ct]:data-[state=on]:bg-sky-500/10 data-[side=ct]:data-[state=on]:text-foreground', | |
| 'data-[side=t]:data-[state=on]:border-amber-500 data-[side=t]:data-[state=on]:bg-amber-500/10 data-[side=t]:data-[state=on]:text-foreground', | |
| 'data-[ready=true]:ring-2 data-[ready=true]:ring-emerald-500/60 data-[ready=true]:ring-offset-1 data-[ready=true]:ring-offset-background' | |
| )} | |
| > | |
| <span | |
| class="flex size-7 shrink-0 items-center justify-center rounded-sm font-mono text-xs font-bold text-white/95 shadow-sm ring-1 ring-black/20" | |
| style="background-color: {playerColor(p.player)};" | |
| > | |
| {p.player} | |
| </span> | |
| <div class="min-w-0 flex-1"> | |
| <div class="truncate font-medium">Player {p.player}</div> | |
| {#if !available} | |
| <div class="text-[10px] text-muted-foreground tabular-nums"> | |
| died · {formatDuration(p.duration)} | |
| </div> | |
| {/if} | |
| </div> | |
| {#if p.survived} | |
| <ShieldIcon size={14} weight="duotone" class="text-emerald-500" /> | |
| {:else if !available} | |
| <SkullIcon size={14} weight="duotone" class="text-rose-500" /> | |
| {/if} | |
| </ToggleGroup.Item> | |
| {/snippet} | |
| <div class="space-y-2"> | |
| <div class="flex items-center justify-between gap-3"> | |
| <div class="text-xs tracking-wide text-muted-foreground uppercase">Player perspectives</div> | |
| <div class="flex items-center gap-2"> | |
| <Label | |
| for="preload-all-players" | |
| class="text-[11px] tracking-wide text-muted-foreground uppercase" | |
| > | |
| Preload all | |
| </Label> | |
| <Switch | |
| id="preload-all-players" | |
| size="sm" | |
| checked={preloadAll} | |
| onCheckedChange={(v) => onPreloadAllChange(!!v)} | |
| /> | |
| </div> | |
| </div> | |
| <ToggleGroup.Root | |
| type="single" | |
| value={String(currentPlayer)} | |
| onValueChange={(v) => v && onSelect(Number(v))} | |
| variant="outline" | |
| spacing={1} | |
| aria-label="Player perspective" | |
| class="w-full! flex-col gap-2" | |
| > | |
| <div class="w-full"> | |
| <div | |
| class="mb-1 flex items-center gap-1.5 text-[10px] font-semibold tracking-wide text-sky-700 uppercase dark:text-sky-400" | |
| > | |
| <ShieldIcon size={11} weight="duotone" /> Counter-Terrorists | |
| </div> | |
| <div class="flex w-full items-stretch gap-1.5"> | |
| {#each ctPlayers as p (p.player)} | |
| {@render playerItem(p)} | |
| {/each} | |
| </div> | |
| </div> | |
| <div class="w-full"> | |
| <div | |
| class="mb-1 flex items-center gap-1.5 text-[10px] font-semibold tracking-wide text-amber-700 uppercase dark:text-amber-400" | |
| > | |
| <SwordIcon size={11} weight="duotone" /> Terrorists | |
| </div> | |
| <div class="flex w-full items-stretch gap-1.5"> | |
| {#each tPlayers as p (p.player)} | |
| {@render playerItem(p)} | |
| {/each} | |
| </div> | |
| </div> | |
| </ToggleGroup.Root> | |
| </div> | |