Spaces:
Running
Running
File size: 5,466 Bytes
31d3580 95e3d2a 15d8696 31d3580 95e3d2a 31d3580 95e3d2a 8899818 95e3d2a 31d3580 0a96402 31d3580 8899818 31d3580 15d8696 9d3dfa1 15d8696 31d3580 8899818 31d3580 8899818 31d3580 95e3d2a 31d3580 15d8696 31d3580 95e3d2a 31d3580 15d8696 31d3580 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 | <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)}
{@const ct = p.side === 'CT'}
{@const ready = preloadedPlayers.has(p.player)}
{@const 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>
|