blanchon commited on
Commit
91677d6
·
1 Parent(s): c3540d6

Rich data table on home page, interactive bigger minimap, tactical-reveal player swap

Browse files

- Replace home-page card grid with a TanStack data table (Rounds default / Maps / Matches via Tabs); global search, map filter, sort, pagination, column-visibility, deep-link to specific round
- Minimap: bigger badges + wider/fainter vision cones, halo on the active POV, click-to-switch with hover/focus ring (works in single + grid)
- Player swap tactical reveal: diagonal beam sweep, corner reticle brackets, edge vignette, HUD chip slide-in (bottom-left), and a clip-path wipe on the freeze-frame so the old POV transitions out instead of snapping
- Don't echo currentTime=0 from the freshly-mounted <video> back to the parent — kept the minimap and timeline from flashing to round-start during a swap; also don't drop bufferedRanges

src/lib/components/map-preview.svelte CHANGED
@@ -30,12 +30,24 @@
30
  type Props = {
31
  mapName: string;
32
  players: Player[];
 
 
 
33
  size?: number;
34
  floor?: 'upper' | 'lower' | 'auto';
35
  class?: string;
36
  };
37
 
38
- let { mapName, players, size, floor = 'auto', class: className = '' }: Props = $props();
 
 
 
 
 
 
 
 
 
39
 
40
  let allMaps = $state<Record<string, MapData> | null>(null);
41
  let userFloor = $state<Floor | null>(null);
@@ -84,7 +96,7 @@
84
  <img
85
  src={radarUrl(mapName, displayedFloor)}
86
  alt="{mapName} radar"
87
- class="absolute inset-0 h-full w-full select-none rounded-md"
88
  draggable="false"
89
  />
90
  <svg
@@ -96,27 +108,96 @@
96
  {@const { px, py } = worldToImage(map, p.X, p.Y)}
97
  {@const playerFloor = floorForZ(map, p.Z)}
98
  {@const onDisplayed = !lowerAvailable || playerFloor === displayedFloor}
99
- {@const baseOpacity = onDisplayed ? 1 : 0.3}
100
- <g opacity={baseOpacity}>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
101
  <title>{p.name}{p.slot !== undefined ? ` (#${p.slot})` : ''}</title>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
102
  {#if p.is_alive}
103
  {#if p.yaw !== undefined}
 
 
104
  <polygon
105
- points="0,-7 26,0 0,7"
106
  fill={p.color}
107
- fill-opacity="0.45"
108
  stroke={p.color}
109
- stroke-width="1.5"
 
110
  transform="translate({px} {py}) rotate({yawToScreenDeg(p.yaw)})"
111
  />
112
  {/if}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
113
  <circle
114
  cx={px}
115
  cy={py}
116
- r="14"
117
  fill={p.color}
118
  stroke="#0b0f14"
119
- stroke-width="2.5"
120
  />
121
  {#if p.slot !== undefined}
122
  <text
@@ -124,11 +205,11 @@
124
  y={py}
125
  text-anchor="middle"
126
  dominant-baseline="central"
127
- font-size="16"
128
- font-weight="700"
129
  fill="#fff"
130
  stroke="#0b0f14"
131
- stroke-width="0.5"
132
  paint-order="stroke"
133
  style="pointer-events: none; user-select: none;"
134
  >
@@ -140,12 +221,12 @@
140
  opacity={baseOpacity * 0.55}
141
  transform="translate({px} {py})"
142
  stroke={p.color}
143
- stroke-width="2.5"
144
  fill="none"
145
  >
146
- <circle r="11" />
147
- <line x1="-6" y1="-6" x2="6" y2="6" />
148
- <line x1="-6" y1="6" x2="6" y2="-6" />
149
  </g>
150
  {/if}
151
  </g>
@@ -177,3 +258,36 @@
177
  {/if}
178
  {/if}
179
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
30
  type Props = {
31
  mapName: string;
32
  players: Player[];
33
+ activePlayer?: number | null;
34
+ availablePlayers?: Set<number> | null;
35
+ onSelect?: (player: number) => void;
36
  size?: number;
37
  floor?: 'upper' | 'lower' | 'auto';
38
  class?: string;
39
  };
40
 
41
+ let {
42
+ mapName,
43
+ players,
44
+ activePlayer = null,
45
+ availablePlayers = null,
46
+ onSelect,
47
+ size,
48
+ floor = 'auto',
49
+ class: className = ''
50
+ }: Props = $props();
51
 
52
  let allMaps = $state<Record<string, MapData> | null>(null);
53
  let userFloor = $state<Floor | null>(null);
 
96
  <img
97
  src={radarUrl(mapName, displayedFloor)}
98
  alt="{mapName} radar"
99
+ class="absolute inset-0 h-full w-full select-none rounded-md opacity-70"
100
  draggable="false"
101
  />
102
  <svg
 
108
  {@const { px, py } = worldToImage(map, p.X, p.Y)}
109
  {@const playerFloor = floorForZ(map, p.Z)}
110
  {@const onDisplayed = !lowerAvailable || playerFloor === displayedFloor}
111
+ {@const baseOpacity = onDisplayed ? 1 : 0.35}
112
+ {@const isActive = activePlayer !== null && p.slot === activePlayer}
113
+ {@const r = isActive ? 42 : 32}
114
+ {@const fontSize = isActive ? 38 : 30}
115
+ {@const coneLen = isActive ? 110 : 90}
116
+ {@const coneHalf = isActive ? 38 : 32}
117
+ {@const slotKnown = p.slot !== undefined}
118
+ {@const available =
119
+ !availablePlayers || (slotKnown && availablePlayers.has(p.slot as number))}
120
+ {@const clickable = !!onSelect && slotKnown && available}
121
+ <!-- svelte-ignore a11y_click_events_have_key_events -->
122
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
123
+ <!-- svelte-ignore a11y_no_noninteractive_tabindex -->
124
+ <g
125
+ class="mp-marker"
126
+ class:mp-marker--clickable={clickable}
127
+ class:mp-marker--unavailable={!!onSelect && slotKnown && !available}
128
+ class:mp-marker--active={isActive}
129
+ opacity={baseOpacity}
130
+ role={clickable ? 'button' : undefined}
131
+ tabindex={clickable ? 0 : undefined}
132
+ aria-label={slotKnown ? `Switch to player ${p.slot}` : undefined}
133
+ onclick={clickable ? () => onSelect?.(p.slot as number) : undefined}
134
+ onkeydown={clickable
135
+ ? (e: KeyboardEvent) => {
136
+ if (e.key === 'Enter' || e.key === ' ') {
137
+ e.preventDefault();
138
+ onSelect?.(p.slot as number);
139
+ }
140
+ }
141
+ : undefined}
142
+ >
143
  <title>{p.name}{p.slot !== undefined ? ` (#${p.slot})` : ''}</title>
144
+ {#if clickable || isActive}
145
+ <!-- Hit-target + hover ring. Transparent stroke by default; the
146
+ :hover style flips it on. Larger than the badge so the
147
+ click area is generous. -->
148
+ <circle
149
+ class="mp-marker__hit"
150
+ cx={px}
151
+ cy={py}
152
+ r={r + 14}
153
+ fill="transparent"
154
+ stroke={p.color}
155
+ stroke-opacity="0"
156
+ stroke-width="3"
157
+ />
158
+ {/if}
159
  {#if p.is_alive}
160
  {#if p.yaw !== undefined}
161
+ <!-- Vision cone: wide + faint fill, soft edge. Reads at a
162
+ glance without overpowering the badge. -->
163
  <polygon
164
+ points="0,-{coneHalf} {coneLen},0 0,{coneHalf}"
165
  fill={p.color}
166
+ fill-opacity="0.18"
167
  stroke={p.color}
168
+ stroke-opacity="0.55"
169
+ stroke-width="2"
170
  transform="translate({px} {py}) rotate({yawToScreenDeg(p.yaw)})"
171
  />
172
  {/if}
173
+ {#if isActive}
174
+ <!-- Active player halo: a white inner ring + colored outer ring. -->
175
+ <circle
176
+ cx={px}
177
+ cy={py}
178
+ r={r + 7}
179
+ fill="none"
180
+ stroke="#fff"
181
+ stroke-width="2.5"
182
+ stroke-opacity="0.9"
183
+ />
184
+ <circle
185
+ cx={px}
186
+ cy={py}
187
+ r={r + 12}
188
+ fill="none"
189
+ stroke={p.color}
190
+ stroke-width="2.5"
191
+ stroke-opacity="0.75"
192
+ />
193
+ {/if}
194
  <circle
195
  cx={px}
196
  cy={py}
197
+ {r}
198
  fill={p.color}
199
  stroke="#0b0f14"
200
+ stroke-width={isActive ? 4 : 3}
201
  />
202
  {#if p.slot !== undefined}
203
  <text
 
205
  y={py}
206
  text-anchor="middle"
207
  dominant-baseline="central"
208
+ font-size={fontSize}
209
+ font-weight="800"
210
  fill="#fff"
211
  stroke="#0b0f14"
212
+ stroke-width="0.9"
213
  paint-order="stroke"
214
  style="pointer-events: none; user-select: none;"
215
  >
 
221
  opacity={baseOpacity * 0.55}
222
  transform="translate({px} {py})"
223
  stroke={p.color}
224
+ stroke-width="3.5"
225
  fill="none"
226
  >
227
+ <circle r="18" />
228
+ <line x1="-10" y1="-10" x2="10" y2="10" />
229
+ <line x1="-10" y1="10" x2="10" y2="-10" />
230
  </g>
231
  {/if}
232
  </g>
 
258
  {/if}
259
  {/if}
260
  </div>
261
+
262
+ <style>
263
+ .mp-marker--clickable {
264
+ cursor: pointer;
265
+ }
266
+ .mp-marker--unavailable {
267
+ cursor: not-allowed;
268
+ }
269
+ /* Hover & focus on a clickable marker: light up the hit-ring and lift the
270
+ group with a subtle drop-shadow so it feels selectable. */
271
+ .mp-marker--clickable:hover .mp-marker__hit,
272
+ .mp-marker--clickable:focus-visible .mp-marker__hit {
273
+ stroke-opacity: 0.9;
274
+ }
275
+ .mp-marker--clickable {
276
+ transition: filter 120ms ease-out;
277
+ }
278
+ .mp-marker--clickable:hover,
279
+ .mp-marker--clickable:focus-visible {
280
+ filter: drop-shadow(0 0 6px rgba(255, 255, 255, 0.45));
281
+ }
282
+ .mp-marker:focus {
283
+ outline: none;
284
+ }
285
+ .mp-marker:focus-visible {
286
+ outline: none;
287
+ }
288
+ @media (prefers-reduced-motion: reduce) {
289
+ .mp-marker--clickable {
290
+ transition: none;
291
+ }
292
+ }
293
+ </style>
src/lib/components/match-map.svelte CHANGED
@@ -11,9 +11,21 @@
11
  round: number;
12
  chunks: PreviewChunk[];
13
  virtualTime: number;
 
 
 
14
  };
15
 
16
- let { matchId, mapName, round, chunks, virtualTime }: Props = $props();
 
 
 
 
 
 
 
 
 
17
 
18
  let world = $state<RoundWorld | null>(null);
19
  let error = $state<string | null>(null);
@@ -70,5 +82,5 @@
70
  {:else if !world}
71
  <Skeleton class="aspect-square w-full rounded-md" />
72
  {:else}
73
- <MapPreview {mapName} {players} />
74
  {/if}
 
11
  round: number;
12
  chunks: PreviewChunk[];
13
  virtualTime: number;
14
+ activePlayer?: number | null;
15
+ availablePlayers?: Set<number> | null;
16
+ onSelect?: (player: number) => void;
17
  };
18
 
19
+ let {
20
+ matchId,
21
+ mapName,
22
+ round,
23
+ chunks,
24
+ virtualTime,
25
+ activePlayer = null,
26
+ availablePlayers = null,
27
+ onSelect
28
+ }: Props = $props();
29
 
30
  let world = $state<RoundWorld | null>(null);
31
  let error = $state<string | null>(null);
 
82
  {:else if !world}
83
  <Skeleton class="aspect-square w-full rounded-md" />
84
  {:else}
85
+ <MapPreview {mapName} {players} {activePlayer} {availablePlayers} {onSelect} />
86
  {/if}
src/lib/components/match-table/cells/date-cell.svelte ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ <script lang="ts">
2
+ import { formatDate } from '$lib/utils/format';
3
+
4
+ interface Props {
5
+ iso: string;
6
+ }
7
+ let { iso }: Props = $props();
8
+ </script>
9
+
10
+ <span class="text-muted-foreground text-xs whitespace-nowrap">{formatDate(iso)}</span>
src/lib/components/match-table/cells/duration-cell.svelte ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ <script lang="ts">
2
+ import { formatLongDuration } from '$lib/utils/format';
3
+
4
+ interface Props {
5
+ seconds: number;
6
+ }
7
+ let { seconds }: Props = $props();
8
+ </script>
9
+
10
+ <span class="font-mono tabular-nums text-xs text-muted-foreground">{formatLongDuration(seconds)}</span>
src/lib/components/match-table/cells/event-cell.svelte ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <script lang="ts">
2
+ interface Props {
3
+ event: string;
4
+ format?: string;
5
+ }
6
+ let { event, format }: Props = $props();
7
+ </script>
8
+
9
+ <div class="flex min-w-0 flex-col">
10
+ <span class="truncate text-sm font-medium" title={event}>{event}</span>
11
+ {#if format}
12
+ <span class="text-muted-foreground/70 text-[0.65rem] uppercase tracking-wide">{format}</span>
13
+ {/if}
14
+ </div>
src/lib/components/match-table/cells/map-cell.svelte ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <script lang="ts">
2
+ import { Badge } from '$lib/components/ui/badge';
3
+ import MapPin from 'phosphor-svelte/lib/MapPin';
4
+ import { mapColorClasses } from '$lib/utils/map-colors';
5
+ import { prettyMap } from '$lib/utils/format';
6
+
7
+ interface Props {
8
+ map: string;
9
+ }
10
+ let { map }: Props = $props();
11
+ </script>
12
+
13
+ <Badge class="gap-1 border capitalize {mapColorClasses(map)}">
14
+ <MapPin size={11} weight="duotone" />{prettyMap(map)}
15
+ </Badge>
src/lib/components/match-table/cells/maps-list-cell.svelte ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <script lang="ts">
2
+ import { Badge } from '$lib/components/ui/badge';
3
+ import MapPin from 'phosphor-svelte/lib/MapPin';
4
+ import { mapColorClasses } from '$lib/utils/map-colors';
5
+ import { prettyMap } from '$lib/utils/format';
6
+
7
+ interface Props {
8
+ maps: string[];
9
+ }
10
+ let { maps }: Props = $props();
11
+ </script>
12
+
13
+ <div class="flex flex-wrap items-center gap-1">
14
+ {#each maps as map (map)}
15
+ <Badge class="gap-1 border capitalize text-[0.65rem] {mapColorClasses(map)}">
16
+ <MapPin size={10} weight="duotone" />{prettyMap(map)}
17
+ </Badge>
18
+ {/each}
19
+ </div>
src/lib/components/match-table/cells/round-cell.svelte ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <script lang="ts">
2
+ interface Props {
3
+ round: number;
4
+ total: number;
5
+ }
6
+ let { round, total }: Props = $props();
7
+ </script>
8
+
9
+ <div class="font-mono text-xs tabular-nums">
10
+ <span class="text-foreground">{round}</span>
11
+ <span class="text-muted-foreground/60">/{total}</span>
12
+ </div>
src/lib/components/match-table/cells/rounds-played-cell.svelte ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <script lang="ts">
2
+ import FilmSlate from 'phosphor-svelte/lib/FilmSlate';
3
+
4
+ interface Props {
5
+ rounds: number;
6
+ }
7
+ let { rounds }: Props = $props();
8
+ </script>
9
+
10
+ <span class="text-muted-foreground inline-flex items-center gap-1 font-mono text-xs tabular-nums">
11
+ <FilmSlate size={11} weight="duotone" />
12
+ {rounds}
13
+ </span>
src/lib/components/match-table/cells/score-cell.svelte ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <script lang="ts">
2
+ interface Props {
3
+ score1: number;
4
+ score2: number;
5
+ winner: string;
6
+ }
7
+ let { score1, score2, winner }: Props = $props();
8
+
9
+ const team1Wins = $derived(winner === 'team1');
10
+ const team2Wins = $derived(winner === 'team2');
11
+ </script>
12
+
13
+ <div class="flex items-center justify-center gap-1 font-mono text-sm tabular-nums">
14
+ <span class={team1Wins ? 'text-primary font-semibold' : 'text-muted-foreground'}>{score1}</span>
15
+ <span class="text-muted-foreground/40 text-xs">:</span>
16
+ <span class={team2Wins ? 'text-primary font-semibold' : 'text-muted-foreground'}>{score2}</span>
17
+ </div>
src/lib/components/match-table/cells/teams-cell.svelte ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <script lang="ts">
2
+ interface Props {
3
+ team1: string;
4
+ team2: string;
5
+ winner: string;
6
+ }
7
+ let { team1, team2, winner }: Props = $props();
8
+
9
+ const team1Wins = $derived(winner === 'team1');
10
+ const team2Wins = $derived(winner === 'team2');
11
+ </script>
12
+
13
+ <div class="flex min-w-0 items-center gap-1.5 text-xs">
14
+ <span
15
+ class="truncate font-medium {team1Wins
16
+ ? 'text-foreground'
17
+ : 'text-muted-foreground'}"
18
+ title={team1}
19
+ >
20
+ {team1}
21
+ </span>
22
+ <span class="text-muted-foreground/40 shrink-0">vs</span>
23
+ <span
24
+ class="truncate font-medium {team2Wins
25
+ ? 'text-foreground'
26
+ : 'text-muted-foreground'}"
27
+ title={team2}
28
+ >
29
+ {team2}
30
+ </span>
31
+ </div>
src/lib/components/match-table/cells/winner-side-cell.svelte ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <script lang="ts">
2
+ import { Badge } from '$lib/components/ui/badge';
3
+ import Shield from 'phosphor-svelte/lib/Shield';
4
+ import Sword from 'phosphor-svelte/lib/Sword';
5
+
6
+ interface Props {
7
+ side: string | null | undefined;
8
+ }
9
+ let { side }: Props = $props();
10
+
11
+ const norm = $derived((side ?? '').toLowerCase());
12
+ </script>
13
+
14
+ {#if norm === 'ct'}
15
+ <Badge class="gap-1 border border-sky-500/40 bg-sky-500/15 text-sky-700 dark:text-sky-300">
16
+ <Shield size={11} weight="duotone" /> CT
17
+ </Badge>
18
+ {:else if norm === 't'}
19
+ <Badge
20
+ class="gap-1 border border-amber-500/40 bg-amber-500/15 text-amber-700 dark:text-amber-300"
21
+ >
22
+ <Sword size={11} weight="duotone" /> T
23
+ </Badge>
24
+ {:else}
25
+ <span class="text-muted-foreground/60 text-xs">—</span>
26
+ {/if}
src/lib/components/match-table/columns.ts ADDED
@@ -0,0 +1,259 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { ColumnDef, RowData } from '@tanstack/table-core';
2
+ import { renderComponent } from '$lib/components/ui/data-table';
3
+ import SortHeader from './data-table-sort-header.svelte';
4
+ import PlainHeader from './data-table-plain-header.svelte';
5
+ import EventCell from './cells/event-cell.svelte';
6
+ import TeamsCell from './cells/teams-cell.svelte';
7
+ import ScoreCell from './cells/score-cell.svelte';
8
+ import MapCell from './cells/map-cell.svelte';
9
+ import MapsListCell from './cells/maps-list-cell.svelte';
10
+ import WinnerSideCell from './cells/winner-side-cell.svelte';
11
+ import DurationCell from './cells/duration-cell.svelte';
12
+ import DateCell from './cells/date-cell.svelte';
13
+ import RoundCell from './cells/round-cell.svelte';
14
+ import RoundsPlayedCell from './cells/rounds-played-cell.svelte';
15
+ import type { MapRow, MatchRow, RoundRow } from './rows';
16
+ import type { Match } from '$lib/types';
17
+
18
+ declare module '@tanstack/table-core' {
19
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
20
+ interface ColumnMeta<TData extends RowData, TValue> {
21
+ label?: string;
22
+ }
23
+ }
24
+
25
+ const dateSort = (a: { match_date: string }, b: { match_date: string }) =>
26
+ new Date(a.match_date).getTime() - new Date(b.match_date).getTime();
27
+
28
+ // Cells use `accessorFn` returning the underlying value so global filtering and
29
+ // sorting see the right thing; visual rendering happens in `cell` via components.
30
+
31
+ export const roundColumns: ColumnDef<RoundRow>[] = [
32
+ {
33
+ id: 'event',
34
+ accessorKey: 'event',
35
+ header: ({ column }) => renderComponent(SortHeader, { column, label: 'Event' }),
36
+ cell: ({ row }) =>
37
+ renderComponent(EventCell, { event: row.original.event, format: row.original.format }),
38
+ meta: { label: 'Event' }
39
+ },
40
+ {
41
+ id: 'map_name',
42
+ accessorKey: 'map_name',
43
+ header: () => renderComponent(PlainHeader, { label: 'Map' }),
44
+ cell: ({ row }) => renderComponent(MapCell, { map: row.original.map_name }),
45
+ enableSorting: false,
46
+ filterFn: (row, id, value) => {
47
+ if (!value) return true;
48
+ return row.getValue<string>(id) === value;
49
+ },
50
+ meta: { label: 'Map' }
51
+ },
52
+ {
53
+ id: 'teams',
54
+ accessorFn: (r) => `${r.team1} ${r.team2}`,
55
+ header: () => renderComponent(PlainHeader, { label: 'Teams' }),
56
+ cell: ({ row }) =>
57
+ renderComponent(TeamsCell, {
58
+ team1: row.original.team1,
59
+ team2: row.original.team2,
60
+ winner: row.original.winner
61
+ }),
62
+ enableSorting: false,
63
+ meta: { label: 'Teams' }
64
+ },
65
+ {
66
+ id: 'round',
67
+ accessorKey: 'round',
68
+ header: ({ column }) =>
69
+ renderComponent(SortHeader, { column, label: 'Round' }),
70
+ cell: ({ row }) =>
71
+ renderComponent(RoundCell, { round: row.original.round, total: row.original.rounds_played }),
72
+ enableGlobalFilter: false,
73
+ meta: { label: 'Round' }
74
+ },
75
+ {
76
+ id: 'duration_s',
77
+ accessorKey: 'duration_s',
78
+ header: ({ column }) =>
79
+ renderComponent(SortHeader, { column, label: 'Duration' }),
80
+ cell: ({ row }) => renderComponent(DurationCell, { seconds: row.original.duration_s }),
81
+ enableGlobalFilter: false,
82
+ meta: { label: 'Duration' }
83
+ },
84
+ {
85
+ id: 'match_date',
86
+ accessorFn: (r) => new Date(r.match_date).getTime(),
87
+ sortingFn: (a, b) => dateSort(a.original, b.original),
88
+ header: ({ column }) =>
89
+ renderComponent(SortHeader, { column, label: 'Date' }),
90
+ cell: ({ row }) => renderComponent(DateCell, { iso: row.original.match_date }),
91
+ enableGlobalFilter: false,
92
+ meta: { label: 'Date' }
93
+ }
94
+ ];
95
+
96
+ export const mapColumns: ColumnDef<MapRow>[] = [
97
+ {
98
+ id: 'event',
99
+ accessorKey: 'event',
100
+ header: ({ column }) => renderComponent(SortHeader, { column, label: 'Event' }),
101
+ cell: ({ row }) =>
102
+ renderComponent(EventCell, { event: row.original.event, format: row.original.format }),
103
+ meta: { label: 'Event' }
104
+ },
105
+ {
106
+ id: 'map_name',
107
+ accessorKey: 'map_name',
108
+ header: () => renderComponent(PlainHeader, { label: 'Map' }),
109
+ cell: ({ row }) => renderComponent(MapCell, { map: row.original.map_name }),
110
+ enableSorting: false,
111
+ filterFn: (row, id, value) => {
112
+ if (!value) return true;
113
+ return row.getValue<string>(id) === value;
114
+ },
115
+ meta: { label: 'Map' }
116
+ },
117
+ {
118
+ id: 'teams',
119
+ accessorFn: (r) => `${r.team1} ${r.team2}`,
120
+ header: () => renderComponent(PlainHeader, { label: 'Teams' }),
121
+ cell: ({ row }) =>
122
+ renderComponent(TeamsCell, {
123
+ team1: row.original.team1,
124
+ team2: row.original.team2,
125
+ winner: row.original.winner
126
+ }),
127
+ enableSorting: false,
128
+ meta: { label: 'Teams' }
129
+ },
130
+ {
131
+ id: 'score',
132
+ accessorFn: (r) => r.score1 + r.score2,
133
+ header: ({ column }) =>
134
+ renderComponent(SortHeader, { column, label: 'Score', align: 'start' }),
135
+ cell: ({ row }) =>
136
+ renderComponent(ScoreCell, {
137
+ score1: row.original.score1,
138
+ score2: row.original.score2,
139
+ winner: row.original.winner
140
+ }),
141
+ enableGlobalFilter: false,
142
+ meta: { label: 'Score' }
143
+ },
144
+ {
145
+ id: 'winner_side',
146
+ accessorKey: 'winner_side',
147
+ header: () => renderComponent(PlainHeader, { label: 'Won by' }),
148
+ cell: ({ row }) =>
149
+ renderComponent(WinnerSideCell, { side: (row.original as Match).winner_side }),
150
+ enableSorting: false,
151
+ filterFn: (row, id, value) => {
152
+ if (!value) return true;
153
+ return ((row.getValue<string>(id) ?? '') + '').toLowerCase() === value;
154
+ },
155
+ meta: { label: 'Won by' }
156
+ },
157
+ {
158
+ id: 'rounds_played',
159
+ accessorKey: 'rounds_played',
160
+ header: ({ column }) =>
161
+ renderComponent(SortHeader, { column, label: 'Rounds' }),
162
+ cell: ({ row }) => renderComponent(RoundsPlayedCell, { rounds: row.original.rounds_played }),
163
+ enableGlobalFilter: false,
164
+ meta: { label: 'Rounds' }
165
+ },
166
+ {
167
+ id: 'duration_s',
168
+ accessorKey: 'duration_s',
169
+ header: ({ column }) =>
170
+ renderComponent(SortHeader, { column, label: 'Duration' }),
171
+ cell: ({ row }) => renderComponent(DurationCell, { seconds: row.original.duration_s }),
172
+ enableGlobalFilter: false,
173
+ meta: { label: 'Duration' }
174
+ },
175
+ {
176
+ id: 'match_date',
177
+ accessorFn: (r) => new Date(r.match_date).getTime(),
178
+ sortingFn: (a, b) => dateSort(a.original, b.original),
179
+ header: ({ column }) => renderComponent(SortHeader, { column, label: 'Date' }),
180
+ cell: ({ row }) => renderComponent(DateCell, { iso: row.original.match_date }),
181
+ enableGlobalFilter: false,
182
+ meta: { label: 'Date' }
183
+ }
184
+ ];
185
+
186
+ export const matchColumns: ColumnDef<MatchRow>[] = [
187
+ {
188
+ id: 'event',
189
+ accessorKey: 'event',
190
+ header: ({ column }) =>
191
+ renderComponent(SortHeader, { column, label: 'Event' }),
192
+ cell: ({ row }) =>
193
+ renderComponent(EventCell, { event: row.original.event, format: row.original.format }),
194
+ meta: { label: 'Event' }
195
+ },
196
+ {
197
+ id: 'teams',
198
+ accessorFn: (r) => `${r.team1} ${r.team2}`,
199
+ header: () => renderComponent(PlainHeader, { label: 'Teams' }),
200
+ cell: ({ row }) =>
201
+ renderComponent(TeamsCell, {
202
+ team1: row.original.team1,
203
+ team2: row.original.team2,
204
+ winner: row.original.winner
205
+ }),
206
+ enableSorting: false,
207
+ meta: { label: 'Teams' }
208
+ },
209
+ {
210
+ id: 'score',
211
+ accessorFn: (r) => r.score1 + r.score2,
212
+ header: ({ column }) =>
213
+ renderComponent(SortHeader, { column, label: 'Maps' }),
214
+ cell: ({ row }) =>
215
+ renderComponent(ScoreCell, {
216
+ score1: row.original.score1,
217
+ score2: row.original.score2,
218
+ winner: row.original.winner
219
+ }),
220
+ enableGlobalFilter: false,
221
+ meta: { label: 'Maps won' }
222
+ },
223
+ {
224
+ id: 'maps',
225
+ accessorFn: (r) => r.maps.join(' '),
226
+ header: () => renderComponent(PlainHeader, { label: 'Map pool' }),
227
+ cell: ({ row }) => renderComponent(MapsListCell, { maps: row.original.maps }),
228
+ enableSorting: false,
229
+ meta: { label: 'Map pool' }
230
+ },
231
+ {
232
+ id: 'rounds_played',
233
+ accessorKey: 'rounds_played',
234
+ header: ({ column }) =>
235
+ renderComponent(SortHeader, { column, label: 'Rounds' }),
236
+ cell: ({ row }) => renderComponent(RoundsPlayedCell, { rounds: row.original.rounds_played }),
237
+ enableGlobalFilter: false,
238
+ meta: { label: 'Rounds' }
239
+ },
240
+ {
241
+ id: 'duration_s',
242
+ accessorKey: 'duration_s',
243
+ header: ({ column }) =>
244
+ renderComponent(SortHeader, { column, label: 'Duration' }),
245
+ cell: ({ row }) => renderComponent(DurationCell, { seconds: row.original.duration_s }),
246
+ enableGlobalFilter: false,
247
+ meta: { label: 'Duration' }
248
+ },
249
+ {
250
+ id: 'match_date',
251
+ accessorFn: (r) => new Date(r.match_date).getTime(),
252
+ sortingFn: (a, b) => dateSort(a.original, b.original),
253
+ header: ({ column }) =>
254
+ renderComponent(SortHeader, { column, label: 'Date' }),
255
+ cell: ({ row }) => renderComponent(DateCell, { iso: row.original.match_date }),
256
+ enableGlobalFilter: false,
257
+ meta: { label: 'Date' }
258
+ }
259
+ ];
src/lib/components/match-table/data-table-pagination.svelte ADDED
@@ -0,0 +1,93 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <script lang="ts" generics="TData">
2
+ import type { Table } from '@tanstack/table-core';
3
+ import { Button } from '$lib/components/ui/button';
4
+ import * as Select from '$lib/components/ui/select';
5
+ import CaretLeft from 'phosphor-svelte/lib/CaretLeft';
6
+ import CaretRight from 'phosphor-svelte/lib/CaretRight';
7
+ import CaretDoubleLeft from 'phosphor-svelte/lib/CaretDoubleLeft';
8
+ import CaretDoubleRight from 'phosphor-svelte/lib/CaretDoubleRight';
9
+
10
+ interface Props {
11
+ table: Table<TData>;
12
+ pageSizes?: number[];
13
+ }
14
+ let { table, pageSizes = [10, 25, 50, 100] }: Props = $props();
15
+
16
+ const pageIndex = $derived(table.getState().pagination.pageIndex);
17
+ const pageSize = $derived(table.getState().pagination.pageSize);
18
+ const pageCount = $derived(table.getPageCount());
19
+ const totalRows = $derived(table.getFilteredRowModel().rows.length);
20
+ </script>
21
+
22
+ <div class="flex flex-wrap items-center justify-between gap-3 pt-3 text-xs">
23
+ <div class="text-muted-foreground">
24
+ {totalRows.toLocaleString()} row{totalRows === 1 ? '' : 's'}
25
+ </div>
26
+
27
+ <div class="flex flex-wrap items-center gap-4">
28
+ <div class="flex items-center gap-2">
29
+ <span class="text-muted-foreground">Rows per page</span>
30
+ <Select.Root
31
+ type="single"
32
+ value={String(pageSize)}
33
+ onValueChange={(v) => table.setPageSize(Number(v))}
34
+ >
35
+ <Select.Trigger size="sm" class="h-7 w-[70px]">
36
+ {pageSize}
37
+ </Select.Trigger>
38
+ <Select.Content>
39
+ {#each pageSizes as n (n)}
40
+ <Select.Item value={String(n)}>{n}</Select.Item>
41
+ {/each}
42
+ </Select.Content>
43
+ </Select.Root>
44
+ </div>
45
+
46
+ <div class="text-muted-foreground tabular-nums">
47
+ Page {pageIndex + 1} of {Math.max(1, pageCount)}
48
+ </div>
49
+
50
+ <div class="flex items-center gap-1">
51
+ <Button
52
+ variant="outline"
53
+ size="icon-sm"
54
+ class="size-7"
55
+ onclick={() => table.setPageIndex(0)}
56
+ disabled={!table.getCanPreviousPage()}
57
+ aria-label="First page"
58
+ >
59
+ <CaretDoubleLeft size={12} weight="bold" />
60
+ </Button>
61
+ <Button
62
+ variant="outline"
63
+ size="icon-sm"
64
+ class="size-7"
65
+ onclick={() => table.previousPage()}
66
+ disabled={!table.getCanPreviousPage()}
67
+ aria-label="Previous page"
68
+ >
69
+ <CaretLeft size={12} weight="bold" />
70
+ </Button>
71
+ <Button
72
+ variant="outline"
73
+ size="icon-sm"
74
+ class="size-7"
75
+ onclick={() => table.nextPage()}
76
+ disabled={!table.getCanNextPage()}
77
+ aria-label="Next page"
78
+ >
79
+ <CaretRight size={12} weight="bold" />
80
+ </Button>
81
+ <Button
82
+ variant="outline"
83
+ size="icon-sm"
84
+ class="size-7"
85
+ onclick={() => table.setPageIndex(pageCount - 1)}
86
+ disabled={!table.getCanNextPage()}
87
+ aria-label="Last page"
88
+ >
89
+ <CaretDoubleRight size={12} weight="bold" />
90
+ </Button>
91
+ </div>
92
+ </div>
93
+ </div>
src/lib/components/match-table/data-table-plain-header.svelte ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <script lang="ts">
2
+ import { cn } from '$lib/utils';
3
+
4
+ interface Props {
5
+ label: string;
6
+ align?: 'start' | 'end';
7
+ class?: string;
8
+ }
9
+
10
+ let { label, align = 'start', class: className }: Props = $props();
11
+ </script>
12
+
13
+ <span
14
+ class={cn(
15
+ 'text-muted-foreground inline-block px-2 text-[0.7rem] font-medium uppercase tracking-wide',
16
+ align === 'end' && 'w-full text-right',
17
+ className
18
+ )}
19
+ >
20
+ {label}
21
+ </span>
src/lib/components/match-table/data-table-sort-header.svelte ADDED
@@ -0,0 +1,43 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <script lang="ts">
2
+ import { Button } from '$lib/components/ui/button';
3
+ import { cn } from '$lib/utils';
4
+ import { CaretUp, CaretDown, CaretUpDown } from 'phosphor-svelte';
5
+
6
+ // `column` comes from the TanStack header context. Its `TData/TValue` generics
7
+ // vary across columns, so we type it permissively here and only touch the
8
+ // methods we actually use.
9
+ interface SortableColumn {
10
+ getIsSorted(): false | 'asc' | 'desc';
11
+ toggleSorting(desc?: boolean): void;
12
+ }
13
+
14
+ interface Props {
15
+ column: SortableColumn;
16
+ label: string;
17
+ align?: 'start' | 'end';
18
+ class?: string;
19
+ }
20
+
21
+ let { column, label, align = 'start', class: className }: Props = $props();
22
+ const sort = $derived(column.getIsSorted());
23
+ </script>
24
+
25
+ <Button
26
+ variant="ghost"
27
+ size="sm"
28
+ onclick={() => column.toggleSorting(sort === 'asc')}
29
+ class={cn(
30
+ '-ml-2 h-7 px-2 font-medium tracking-wide uppercase text-[0.7rem] text-muted-foreground hover:text-foreground',
31
+ align === 'end' && 'ml-0 -mr-2 w-full justify-end',
32
+ className
33
+ )}
34
+ >
35
+ {label}
36
+ {#if sort === 'asc'}
37
+ <CaretUp size={12} weight="bold" class="ml-1" />
38
+ {:else if sort === 'desc'}
39
+ <CaretDown size={12} weight="bold" class="ml-1" />
40
+ {:else}
41
+ <CaretUpDown size={12} weight="bold" class="ml-1 opacity-60" />
42
+ {/if}
43
+ </Button>
src/lib/components/match-table/data-table-toolbar.svelte ADDED
@@ -0,0 +1,101 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <script lang="ts" generics="TData">
2
+ import type { Table } from '@tanstack/table-core';
3
+ import { Input } from '$lib/components/ui/input';
4
+ import { Button } from '$lib/components/ui/button';
5
+ import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
6
+ import * as Select from '$lib/components/ui/select';
7
+ import { MagnifyingGlass, SlidersHorizontal, X } from 'phosphor-svelte';
8
+ import { prettyMap } from '$lib/utils/format';
9
+
10
+ interface Props {
11
+ table: Table<TData>;
12
+ searchPlaceholder?: string;
13
+ mapOptions?: string[];
14
+ }
15
+
16
+ let { table, searchPlaceholder = 'Search…', mapOptions = [] }: Props = $props();
17
+
18
+ const globalFilter = $derived((table.getState().globalFilter as string) ?? '');
19
+ const mapFilter = $derived((table.getColumn('map_name')?.getFilterValue() as string) ?? '__all');
20
+ const isFiltered = $derived(
21
+ (table.getState().globalFilter as string)?.length > 0 ||
22
+ table.getState().columnFilters.length > 0
23
+ );
24
+ </script>
25
+
26
+ <div class="flex flex-wrap items-center gap-2 py-3">
27
+ <div class="relative">
28
+ <MagnifyingGlass
29
+ size={14}
30
+ weight="bold"
31
+ class="text-muted-foreground absolute left-2.5 top-1/2 -translate-y-1/2"
32
+ />
33
+ <Input
34
+ placeholder={searchPlaceholder}
35
+ value={globalFilter}
36
+ oninput={(e) => table.setGlobalFilter(e.currentTarget.value)}
37
+ class="h-8 w-[200px] pl-8 lg:w-[260px]"
38
+ />
39
+ </div>
40
+
41
+ {#if mapOptions.length > 0 && table.getColumn('map_name')}
42
+ <Select.Root
43
+ type="single"
44
+ value={mapFilter}
45
+ onValueChange={(v) => {
46
+ table.getColumn('map_name')?.setFilterValue(v === '__all' ? undefined : v);
47
+ }}
48
+ >
49
+ <Select.Trigger size="sm" class="h-8 min-w-[130px] capitalize">
50
+ {mapFilter === '__all' ? 'All maps' : prettyMap(mapFilter)}
51
+ </Select.Trigger>
52
+ <Select.Content>
53
+ <Select.Item value="__all">All maps</Select.Item>
54
+ {#each mapOptions as map (map)}
55
+ <Select.Item value={map} class="capitalize">{prettyMap(map)}</Select.Item>
56
+ {/each}
57
+ </Select.Content>
58
+ </Select.Root>
59
+ {/if}
60
+
61
+ {#if isFiltered}
62
+ <Button
63
+ variant="ghost"
64
+ size="sm"
65
+ class="h-8 px-2"
66
+ onclick={() => {
67
+ table.setGlobalFilter('');
68
+ table.resetColumnFilters();
69
+ }}
70
+ >
71
+ Reset
72
+ <X size={12} weight="bold" class="ml-1" />
73
+ </Button>
74
+ {/if}
75
+
76
+ <div class="ml-auto flex items-center gap-2">
77
+ <DropdownMenu.Root>
78
+ <DropdownMenu.Trigger>
79
+ {#snippet child({ props })}
80
+ <Button {...props} variant="outline" size="sm" class="h-8">
81
+ <SlidersHorizontal size={12} weight="bold" />
82
+ View
83
+ </Button>
84
+ {/snippet}
85
+ </DropdownMenu.Trigger>
86
+ <DropdownMenu.Content align="end" class="w-[180px]">
87
+ <DropdownMenu.Label>Toggle columns</DropdownMenu.Label>
88
+ <DropdownMenu.Separator />
89
+ {#each table.getAllColumns().filter((c) => c.getCanHide()) as column (column.id)}
90
+ <DropdownMenu.CheckboxItem
91
+ class="capitalize"
92
+ checked={column.getIsVisible()}
93
+ onCheckedChange={(v) => column.toggleVisibility(!!v)}
94
+ >
95
+ {(column.columnDef.meta as { label?: string } | undefined)?.label ?? column.id.replace(/_/g, ' ')}
96
+ </DropdownMenu.CheckboxItem>
97
+ {/each}
98
+ </DropdownMenu.Content>
99
+ </DropdownMenu.Root>
100
+ </div>
101
+ </div>
src/lib/components/match-table/match-table.svelte ADDED
@@ -0,0 +1,176 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <script lang="ts" generics="TData">
2
+ import {
3
+ type ColumnDef,
4
+ type ColumnFiltersState,
5
+ type PaginationState,
6
+ type SortingState,
7
+ type VisibilityState,
8
+ getCoreRowModel,
9
+ getFilteredRowModel,
10
+ getPaginationRowModel,
11
+ getSortedRowModel
12
+ } from '@tanstack/table-core';
13
+ import { createSvelteTable, FlexRender } from '$lib/components/ui/data-table';
14
+ import * as Table from '$lib/components/ui/table';
15
+ import DataTableToolbar from './data-table-toolbar.svelte';
16
+ import DataTablePagination from './data-table-pagination.svelte';
17
+ import { goto } from '$app/navigation';
18
+ import { cn } from '$lib/utils';
19
+ import { untrack } from 'svelte';
20
+
21
+ interface Props {
22
+ data: TData[];
23
+ columns: ColumnDef<TData>[];
24
+ searchPlaceholder?: string;
25
+ mapOptions?: string[];
26
+ initialSorting?: SortingState;
27
+ initialPageSize?: number;
28
+ getRowHref?: (row: TData) => string;
29
+ emptyMessage?: string;
30
+ }
31
+
32
+ let {
33
+ data,
34
+ columns,
35
+ searchPlaceholder,
36
+ mapOptions = [],
37
+ initialSorting = [],
38
+ initialPageSize = 25,
39
+ getRowHref,
40
+ emptyMessage = 'No results.'
41
+ }: Props = $props();
42
+
43
+ // svelte-ignore state_referenced_locally
44
+ let pagination = $state<PaginationState>({ pageIndex: 0, pageSize: initialPageSize });
45
+ // svelte-ignore state_referenced_locally
46
+ let sorting = $state<SortingState>(initialSorting);
47
+ let columnFilters = $state<ColumnFiltersState>([]);
48
+ let columnVisibility = $state<VisibilityState>({});
49
+ let globalFilter = $state('');
50
+
51
+ // Reset to first page whenever filters change so users don't end up on an
52
+ // empty page after narrowing the result set.
53
+ $effect(() => {
54
+ void globalFilter;
55
+ void columnFilters;
56
+ untrack(() => {
57
+ if (pagination.pageIndex !== 0) {
58
+ pagination = { ...pagination, pageIndex: 0 };
59
+ }
60
+ });
61
+ });
62
+
63
+ const table = createSvelteTable<TData>({
64
+ get data() {
65
+ return data;
66
+ },
67
+ get columns() {
68
+ return columns;
69
+ },
70
+ state: {
71
+ get pagination() {
72
+ return pagination;
73
+ },
74
+ get sorting() {
75
+ return sorting;
76
+ },
77
+ get columnFilters() {
78
+ return columnFilters;
79
+ },
80
+ get columnVisibility() {
81
+ return columnVisibility;
82
+ },
83
+ get globalFilter() {
84
+ return globalFilter;
85
+ }
86
+ },
87
+ globalFilterFn: 'includesString',
88
+ getCoreRowModel: getCoreRowModel(),
89
+ getSortedRowModel: getSortedRowModel(),
90
+ getFilteredRowModel: getFilteredRowModel(),
91
+ getPaginationRowModel: getPaginationRowModel(),
92
+ onPaginationChange: (u) => {
93
+ pagination = typeof u === 'function' ? u(pagination) : u;
94
+ },
95
+ onSortingChange: (u) => {
96
+ sorting = typeof u === 'function' ? u(sorting) : u;
97
+ },
98
+ onColumnFiltersChange: (u) => {
99
+ columnFilters = typeof u === 'function' ? u(columnFilters) : u;
100
+ },
101
+ onColumnVisibilityChange: (u) => {
102
+ columnVisibility = typeof u === 'function' ? u(columnVisibility) : u;
103
+ },
104
+ onGlobalFilterChange: (u) => {
105
+ globalFilter = typeof u === 'function' ? u(globalFilter) : u;
106
+ }
107
+ });
108
+
109
+ function handleRowClick(row: TData, e: MouseEvent | KeyboardEvent) {
110
+ if (!getRowHref) return;
111
+ const href = getRowHref(row);
112
+ if (e instanceof MouseEvent && (e.metaKey || e.ctrlKey || e.button === 1)) {
113
+ window.open(href, '_blank', 'noopener,noreferrer');
114
+ return;
115
+ }
116
+ goto(href);
117
+ }
118
+ </script>
119
+
120
+ <div>
121
+ <DataTableToolbar {table} {searchPlaceholder} {mapOptions} />
122
+
123
+ <div class="rounded-md border">
124
+ <Table.Root>
125
+ <Table.Header>
126
+ {#each table.getHeaderGroups() as headerGroup (headerGroup.id)}
127
+ <Table.Row class="bg-muted/40 hover:bg-muted/40">
128
+ {#each headerGroup.headers as header (header.id)}
129
+ <Table.Head class="h-9 text-xs">
130
+ {#if !header.isPlaceholder}
131
+ <FlexRender
132
+ content={header.column.columnDef.header}
133
+ context={header.getContext()}
134
+ />
135
+ {/if}
136
+ </Table.Head>
137
+ {/each}
138
+ </Table.Row>
139
+ {/each}
140
+ </Table.Header>
141
+ <Table.Body>
142
+ {#each table.getRowModel().rows as row (row.id)}
143
+ <Table.Row
144
+ class={cn(getRowHref && 'cursor-pointer')}
145
+ onclick={(e: MouseEvent) => handleRowClick(row.original, e)}
146
+ onkeydown={(e: KeyboardEvent) => {
147
+ if (e.key === 'Enter' || e.key === ' ') {
148
+ e.preventDefault();
149
+ handleRowClick(row.original, e);
150
+ }
151
+ }}
152
+ tabindex={getRowHref ? 0 : undefined}
153
+ role={getRowHref ? 'link' : undefined}
154
+ >
155
+ {#each row.getVisibleCells() as cell (cell.id)}
156
+ <Table.Cell class="py-2 align-middle">
157
+ <FlexRender
158
+ content={cell.column.columnDef.cell}
159
+ context={cell.getContext()}
160
+ />
161
+ </Table.Cell>
162
+ {/each}
163
+ </Table.Row>
164
+ {:else}
165
+ <Table.Row>
166
+ <Table.Cell colspan={columns.length} class="text-muted-foreground h-24 text-center text-sm">
167
+ {emptyMessage}
168
+ </Table.Cell>
169
+ </Table.Row>
170
+ {/each}
171
+ </Table.Body>
172
+ </Table.Root>
173
+ </div>
174
+
175
+ <DataTablePagination {table} />
176
+ </div>
src/lib/components/match-table/rows.ts ADDED
@@ -0,0 +1,129 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { Match, Round } from '$lib/types';
2
+
3
+ const TICK_RATE = 64;
4
+
5
+ export type MapRow = Match & {
6
+ duration_s: number;
7
+ };
8
+
9
+ export type RoundRow = {
10
+ match_id: number;
11
+ map_name: string;
12
+ round: number;
13
+ demo_round: number;
14
+ duration_s: number;
15
+ event: string;
16
+ team1: string;
17
+ team2: string;
18
+ score1: number;
19
+ score2: number;
20
+ winner: string;
21
+ format: string;
22
+ match_date: string;
23
+ rounds_played: number;
24
+ };
25
+
26
+ export type MatchRow = {
27
+ match_id: number;
28
+ event: string;
29
+ team1: string;
30
+ team2: string;
31
+ score1: number;
32
+ score2: number;
33
+ winner: string;
34
+ format: string;
35
+ match_date: string;
36
+ maps: string[];
37
+ maps_played: number;
38
+ first_map: string;
39
+ rounds_played: number;
40
+ duration_s: number;
41
+ };
42
+
43
+ export function buildMapRows(matches: Match[], rounds: Round[]): MapRow[] {
44
+ const durationByMap = new Map<string, number>();
45
+ for (const r of rounds) {
46
+ const k = `${r.match_id}|${r.map_name}`;
47
+ durationByMap.set(k, (durationByMap.get(k) ?? 0) + (r.round_duration_ticks || 0) / TICK_RATE);
48
+ }
49
+ return matches.map((m) => ({
50
+ ...m,
51
+ duration_s: durationByMap.get(`${m.match_id}|${m.map_name}`) ?? 0
52
+ }));
53
+ }
54
+
55
+ export function buildRoundRows(matches: Match[], rounds: Round[]): RoundRow[] {
56
+ const matchByMap = new Map<string, Match>();
57
+ for (const m of matches) matchByMap.set(`${m.match_id}|${m.map_name}`, m);
58
+
59
+ const out: RoundRow[] = [];
60
+ for (const r of rounds) {
61
+ const m = matchByMap.get(`${r.match_id}|${r.map_name}`);
62
+ if (!m) continue;
63
+ out.push({
64
+ match_id: r.match_id,
65
+ map_name: r.map_name,
66
+ round: r.round,
67
+ demo_round: r.demo_round,
68
+ duration_s: (r.round_duration_ticks || 0) / TICK_RATE,
69
+ event: m.event,
70
+ team1: m.team1,
71
+ team2: m.team2,
72
+ score1: m.score1,
73
+ score2: m.score2,
74
+ winner: m.winner,
75
+ format: m.format,
76
+ match_date: m.match_date,
77
+ rounds_played: m.rounds_played
78
+ });
79
+ }
80
+ return out;
81
+ }
82
+
83
+ export function buildMatchRows(matches: Match[], rounds: Round[]): MatchRow[] {
84
+ const durationByMap = new Map<string, number>();
85
+ for (const r of rounds) {
86
+ const k = `${r.match_id}|${r.map_name}`;
87
+ durationByMap.set(k, (durationByMap.get(k) ?? 0) + (r.round_duration_ticks || 0) / TICK_RATE);
88
+ }
89
+
90
+ const grouped = new Map<number, Match[]>();
91
+ for (const m of matches) {
92
+ const arr = grouped.get(m.match_id);
93
+ if (arr) arr.push(m);
94
+ else grouped.set(m.match_id, [m]);
95
+ }
96
+
97
+ const rows: MatchRow[] = [];
98
+ for (const [matchId, mapsForMatch] of grouped) {
99
+ const sorted = [...mapsForMatch].sort((a, b) => (a.map_index ?? 0) - (b.map_index ?? 0));
100
+ const head = sorted[0];
101
+ const team1MapWins = sorted.filter((m) => m.winner === 'team1').length;
102
+ const team2MapWins = sorted.filter((m) => m.winner === 'team2').length;
103
+ const totalRounds = sorted.reduce((acc, m) => acc + m.rounds_played, 0);
104
+ const totalDuration = sorted.reduce(
105
+ (acc, m) => acc + (durationByMap.get(`${m.match_id}|${m.map_name}`) ?? 0),
106
+ 0
107
+ );
108
+ rows.push({
109
+ match_id: matchId,
110
+ event: head.event,
111
+ team1: head.team1,
112
+ team2: head.team2,
113
+ score1: team1MapWins,
114
+ score2: team2MapWins,
115
+ winner: team1MapWins > team2MapWins ? 'team1' : team2MapWins > team1MapWins ? 'team2' : '',
116
+ format: head.format,
117
+ match_date: head.match_date,
118
+ maps: sorted.map((m) => m.map_name),
119
+ maps_played: sorted.length,
120
+ first_map: head.map_name,
121
+ rounds_played: totalRounds,
122
+ duration_s: totalDuration
123
+ });
124
+ }
125
+ rows.sort(
126
+ (a, b) => new Date(b.match_date).getTime() - new Date(a.match_date).getTime() || b.match_id - a.match_id
127
+ );
128
+ return rows;
129
+ }
src/lib/components/video-player/video-player.svelte CHANGED
@@ -55,7 +55,13 @@
55
  let pendingSeek: number | null = null;
56
  let prevSig: string | null = null;
57
  let showFreezeFrame = $state(false);
 
 
 
 
58
  let freezeTimer: ReturnType<typeof setTimeout> | null = null;
 
 
59
 
60
  function locateChunkAt(t: number): number {
61
  if (!chunks.length) return 0;
@@ -76,6 +82,12 @@
76
  let lastEmitted = -1;
77
  $effect(() => {
78
  const t = currentTime;
 
 
 
 
 
 
79
  if (Math.abs(t - lastEmitted) >= 0.2) {
80
  lastEmitted = t;
81
  onTimeUpdate?.(t);
@@ -97,6 +109,12 @@
97
  try {
98
  ctx.drawImage(videoEl, 0, 0, canvasEl.width, canvasEl.height);
99
  showFreezeFrame = true;
 
 
 
 
 
 
100
  if (freezeTimer) clearTimeout(freezeTimer);
101
  freezeTimer = setTimeout(() => {
102
  showFreezeFrame = false;
@@ -108,11 +126,20 @@
108
  }
109
 
110
  function clearFreezeFrame() {
111
- showFreezeFrame = false;
 
 
 
112
  if (freezeTimer) {
113
  clearTimeout(freezeTimer);
114
  freezeTimer = null;
115
  }
 
 
 
 
 
 
116
  }
117
 
118
  $effect(() => {
@@ -248,6 +275,7 @@
248
  mse?.destroy();
249
  if (videoSrc) URL.revokeObjectURL(videoSrc);
250
  if (freezeTimer) clearTimeout(freezeTimer);
 
251
  };
252
  });
253
 
@@ -291,8 +319,9 @@
291
 
292
  <canvas
293
  bind:this={canvasEl}
294
- class="pointer-events-none absolute inset-0 z-10 h-full w-full object-contain"
295
  class:hidden={!showFreezeFrame}
 
296
  aria-hidden="true"
297
  ></canvas>
298
 
@@ -315,3 +344,31 @@
315
  </div>
316
  {/if}
317
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
55
  let pendingSeek: number | null = null;
56
  let prevSig: string | null = null;
57
  let showFreezeFrame = $state(false);
58
+ // `dissolveFreeze` triggers a CSS clip-path wipe on the freeze canvas so
59
+ // the old frame visibly transitions out instead of snapping. The wipe is
60
+ // L→R diagonal to feel cohesive with the VideoStage beam sweep.
61
+ let dissolveFreeze = $state(false);
62
  let freezeTimer: ReturnType<typeof setTimeout> | null = null;
63
+ let dissolveTimer: ReturnType<typeof setTimeout> | null = null;
64
+ const FREEZE_WIPE_MS = 480;
65
 
66
  function locateChunkAt(t: number): number {
67
  if (!chunks.length) return 0;
 
82
  let lastEmitted = -1;
83
  $effect(() => {
84
  const t = currentTime;
85
+ // During a chunk swap the new <video> element transiently reports
86
+ // currentTime=0 before its loadedmetadata seek; echoing that to the
87
+ // parent would snap virtualTime (and therefore the minimap + timeline)
88
+ // back to round-start for ~1 frame. Hold off until the new clip's
89
+ // data has actually loaded.
90
+ if (!isVideoData) return;
91
  if (Math.abs(t - lastEmitted) >= 0.2) {
92
  lastEmitted = t;
93
  onTimeUpdate?.(t);
 
109
  try {
110
  ctx.drawImage(videoEl, 0, 0, canvasEl.width, canvasEl.height);
111
  showFreezeFrame = true;
112
+ // New capture aborts any in-flight wipe so the next swap starts clean.
113
+ dissolveFreeze = false;
114
+ if (dissolveTimer) {
115
+ clearTimeout(dissolveTimer);
116
+ dissolveTimer = null;
117
+ }
118
  if (freezeTimer) clearTimeout(freezeTimer);
119
  freezeTimer = setTimeout(() => {
120
  showFreezeFrame = false;
 
126
  }
127
 
128
  function clearFreezeFrame() {
129
+ if (!showFreezeFrame || dissolveFreeze) return;
130
+ // Trigger the wipe; the canvas stays mounted so the keyframes can run,
131
+ // then we drop it once the animation is done.
132
+ dissolveFreeze = true;
133
  if (freezeTimer) {
134
  clearTimeout(freezeTimer);
135
  freezeTimer = null;
136
  }
137
+ if (dissolveTimer) clearTimeout(dissolveTimer);
138
+ dissolveTimer = setTimeout(() => {
139
+ showFreezeFrame = false;
140
+ dissolveFreeze = false;
141
+ dissolveTimer = null;
142
+ }, FREEZE_WIPE_MS);
143
  }
144
 
145
  $effect(() => {
 
275
  mse?.destroy();
276
  if (videoSrc) URL.revokeObjectURL(videoSrc);
277
  if (freezeTimer) clearTimeout(freezeTimer);
278
+ if (dissolveTimer) clearTimeout(dissolveTimer);
279
  };
280
  });
281
 
 
319
 
320
  <canvas
321
  bind:this={canvasEl}
322
+ class="vp-freeze pointer-events-none absolute inset-0 z-10 h-full w-full object-contain"
323
  class:hidden={!showFreezeFrame}
324
+ class:vp-freeze--wipe={dissolveFreeze}
325
  aria-hidden="true"
326
  ></canvas>
327
 
 
344
  </div>
345
  {/if}
346
  </div>
347
+
348
+ <style>
349
+ /* Diagonal wipe of the freeze-frame on player swap. The clip-path
350
+ parallelogram travels L→R, eating the canvas from the left side first
351
+ so the new <video> underneath is progressively revealed. The slight
352
+ brightness/saturation boost mid-wipe gives a subtle "energize" feel. */
353
+ .vp-freeze--wipe {
354
+ animation: vp-freeze-wipe 480ms cubic-bezier(0.65, 0, 0.35, 1) forwards;
355
+ }
356
+ @keyframes vp-freeze-wipe {
357
+ 0% {
358
+ clip-path: polygon(-20% 0, 110% 0, 110% 100%, -45% 100%);
359
+ filter: none;
360
+ }
361
+ 55% {
362
+ filter: brightness(1.18) saturate(1.2) contrast(1.05);
363
+ }
364
+ 100% {
365
+ clip-path: polygon(125% 0, 110% 0, 110% 100%, 100% 100%);
366
+ filter: brightness(1.5) saturate(1.3) contrast(1.05);
367
+ }
368
+ }
369
+ @media (prefers-reduced-motion: reduce) {
370
+ .vp-freeze--wipe {
371
+ animation-duration: 1ms;
372
+ }
373
+ }
374
+ </style>
src/lib/components/video-stage.svelte CHANGED
@@ -42,22 +42,32 @@
42
 
43
  const sideMeta = $derived(chunks[0] ?? null);
44
 
45
- // Brief identity overlay on player swap so the freeze-frame + reload feels
46
- // like an intentional cut, not a hiccup. We track the previous slot and
47
- // show the new slot's chip for ~700ms whenever it changes.
 
 
48
  let prevPlayer = $state<number | null>(null);
49
- let switchPing = $state<{ player: number; side: string } | null>(null);
 
 
50
  let switchTimer: ReturnType<typeof setTimeout> | null = null;
 
51
  $effect(() => {
52
  const cur = sideMeta?.player;
53
  if (cur === undefined || cur === null) return;
54
- if (prevPlayer !== null && cur !== prevPlayer) {
55
- switchPing = { player: cur, side: sideMeta?.player_side ?? '' };
 
 
 
 
 
56
  if (switchTimer) clearTimeout(switchTimer);
57
  switchTimer = setTimeout(() => {
58
  switchPing = null;
59
  switchTimer = null;
60
- }, 700);
61
  }
62
  prevPlayer = cur;
63
  });
@@ -117,25 +127,199 @@
117
  {/if}
118
 
119
  {#if switchPing}
120
- <div
121
- class="pointer-events-none absolute left-1/2 top-4 z-40 -translate-x-1/2 select-none"
122
- transition:fade={{ duration: 180 }}
123
- >
124
  <div
125
- class="flex items-center gap-2 rounded-md bg-black/70 px-3 py-1.5 shadow-lg ring-1 ring-white/10 backdrop-blur-sm"
 
 
 
 
 
126
  >
127
- <span
128
- class="inline-flex h-5 w-5 items-center justify-center rounded-sm font-mono text-[11px] font-bold text-white"
129
- style="background-color: {playerColor(switchPing.player)};"
 
 
 
 
 
 
 
 
 
 
 
 
130
  >
131
- {switchPing.player}
132
- </span>
133
- <span class="text-xs font-medium text-white/95">
134
- Switched to <span class="font-mono">{switchPing.side}</span>
135
- </span>
 
 
 
 
 
 
 
136
  </div>
137
- </div>
138
  {/if}
139
  </AspectRatio>
140
 
141
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
42
 
43
  const sideMeta = $derived(chunks[0] ?? null);
44
 
45
+ // Tactical reveal overlay on player swap: freezes the previous frame in the
46
+ // underlying VideoPlayer, plus we paint a player-colored beam sweep + corner
47
+ // brackets + edge vignette on top to make the ~0.5–1s clip swap feel like an
48
+ // intentional cut rather than a buffering hiccup. The `key` field bumps on
49
+ // every swap so the CSS animations re-trigger even on rapid back-and-forth.
50
  let prevPlayer = $state<number | null>(null);
51
+ let switchPing = $state<{ player: number; side: string; color: string; key: number } | null>(
52
+ null
53
+ );
54
  let switchTimer: ReturnType<typeof setTimeout> | null = null;
55
+ let switchSeq = 0;
56
  $effect(() => {
57
  const cur = sideMeta?.player;
58
  if (cur === undefined || cur === null) return;
59
+ if (prevPlayer !== null && cur !== prevPlayer && !loading) {
60
+ switchPing = {
61
+ player: cur,
62
+ side: sideMeta?.player_side ?? '',
63
+ color: playerColor(cur),
64
+ key: ++switchSeq
65
+ };
66
  if (switchTimer) clearTimeout(switchTimer);
67
  switchTimer = setTimeout(() => {
68
  switchPing = null;
69
  switchTimer = null;
70
+ }, 600);
71
  }
72
  prevPlayer = cur;
73
  });
 
127
  {/if}
128
 
129
  {#if switchPing}
130
+ {#key switchPing.key}
131
+ <!-- Tactical reveal: edge vignette + diagonal beam + corner brackets.
132
+ All overlays are pointer-events:none so they don't steal clicks. -->
 
133
  <div
134
+ class="vs-vignette pointer-events-none absolute inset-0 z-30"
135
+ style="--ping-color: {switchPing.color};"
136
+ ></div>
137
+ <div
138
+ class="vs-beam-wrap pointer-events-none absolute inset-0 z-30 overflow-hidden"
139
+ style="--ping-color: {switchPing.color};"
140
  >
141
+ <div class="vs-beam"></div>
142
+ </div>
143
+ <div
144
+ class="vs-brackets pointer-events-none absolute inset-3 z-30"
145
+ style="--ping-color: {switchPing.color};"
146
+ >
147
+ <span class="vs-bracket vs-bracket--tl"></span>
148
+ <span class="vs-bracket vs-bracket--tr"></span>
149
+ <span class="vs-bracket vs-bracket--bl"></span>
150
+ <span class="vs-bracket vs-bracket--br"></span>
151
+ </div>
152
+ <div class="vs-chip pointer-events-none absolute bottom-4 left-4 z-40 select-none">
153
+ <div
154
+ class="flex items-center gap-2 rounded-md bg-black/75 px-2.5 py-1.5 shadow-lg ring-1 ring-white/10 backdrop-blur-sm"
155
+ style="box-shadow: 0 0 24px {switchPing.color}55;"
156
  >
157
+ <span
158
+ class="inline-flex h-5 w-5 items-center justify-center rounded-sm font-mono text-[11px] font-bold text-white"
159
+ style="background-color: {switchPing.color};"
160
+ >
161
+ {switchPing.player}
162
+ </span>
163
+ <span
164
+ class="border-l border-white/15 pl-2 text-[10px] font-semibold uppercase tracking-wide text-white/70"
165
+ >
166
+ {switchPing.side || 'POV'}
167
+ </span>
168
+ </div>
169
  </div>
170
+ {/key}
171
  {/if}
172
  </AspectRatio>
173
 
174
  </div>
175
+
176
+ <style>
177
+ /* Diagonal player-colored beam swept across the stage. The bright white
178
+ edges + colored core + screen blend produce the "neon scan" look. */
179
+ .vs-beam {
180
+ position: absolute;
181
+ top: -25%;
182
+ bottom: -25%;
183
+ left: -40%;
184
+ width: 50%;
185
+ background: linear-gradient(
186
+ 110deg,
187
+ transparent 0%,
188
+ transparent 35%,
189
+ rgba(255, 255, 255, 0.55) 47%,
190
+ var(--ping-color) 50%,
191
+ rgba(255, 255, 255, 0.55) 53%,
192
+ transparent 65%,
193
+ transparent 100%
194
+ );
195
+ filter: blur(0.5px);
196
+ mix-blend-mode: screen;
197
+ opacity: 0;
198
+ transform: translateX(-30%) skewX(-18deg);
199
+ animation: vs-beam-sweep 520ms cubic-bezier(0.65, 0, 0.35, 1) forwards;
200
+ }
201
+ @keyframes vs-beam-sweep {
202
+ 0% {
203
+ opacity: 0;
204
+ transform: translateX(-30%) skewX(-18deg);
205
+ }
206
+ 18% {
207
+ opacity: 1;
208
+ }
209
+ 82% {
210
+ opacity: 1;
211
+ }
212
+ 100% {
213
+ opacity: 0;
214
+ transform: translateX(380%) skewX(-18deg);
215
+ }
216
+ }
217
+
218
+ /* Soft player-colored vignette around the edges, like a HUD glow. */
219
+ .vs-vignette {
220
+ background: radial-gradient(
221
+ ellipse at center,
222
+ transparent 55%,
223
+ color-mix(in srgb, var(--ping-color) 40%, transparent) 100%
224
+ );
225
+ opacity: 0;
226
+ animation: vs-vignette-pulse 520ms ease-out forwards;
227
+ }
228
+ @keyframes vs-vignette-pulse {
229
+ 0% {
230
+ opacity: 0;
231
+ }
232
+ 25% {
233
+ opacity: 1;
234
+ }
235
+ 100% {
236
+ opacity: 0;
237
+ }
238
+ }
239
+
240
+ /* Four reticle-style corner brackets in the player color. */
241
+ .vs-bracket {
242
+ position: absolute;
243
+ width: 26px;
244
+ height: 26px;
245
+ border: 2px solid var(--ping-color);
246
+ opacity: 0;
247
+ filter: drop-shadow(0 0 4px var(--ping-color));
248
+ animation: vs-bracket-pulse 520ms cubic-bezier(0.4, 0, 0.2, 1) forwards;
249
+ }
250
+ .vs-bracket--tl {
251
+ top: 0;
252
+ left: 0;
253
+ border-right: 0;
254
+ border-bottom: 0;
255
+ }
256
+ .vs-bracket--tr {
257
+ top: 0;
258
+ right: 0;
259
+ border-left: 0;
260
+ border-bottom: 0;
261
+ }
262
+ .vs-bracket--bl {
263
+ bottom: 0;
264
+ left: 0;
265
+ border-right: 0;
266
+ border-top: 0;
267
+ }
268
+ .vs-bracket--br {
269
+ bottom: 0;
270
+ right: 0;
271
+ border-left: 0;
272
+ border-top: 0;
273
+ }
274
+ @keyframes vs-bracket-pulse {
275
+ 0% {
276
+ opacity: 0;
277
+ transform: scale(1.45);
278
+ }
279
+ 25% {
280
+ opacity: 1;
281
+ transform: scale(1);
282
+ }
283
+ 70% {
284
+ opacity: 1;
285
+ }
286
+ 100% {
287
+ opacity: 0;
288
+ transform: scale(1);
289
+ }
290
+ }
291
+
292
+ /* HUD chip: slides up from below the stage edge. */
293
+ .vs-chip {
294
+ animation: vs-chip-in 520ms cubic-bezier(0.34, 1.56, 0.64, 1) forwards;
295
+ }
296
+ @keyframes vs-chip-in {
297
+ 0% {
298
+ opacity: 0;
299
+ transform: translateY(14px);
300
+ }
301
+ 22% {
302
+ opacity: 1;
303
+ transform: translateY(-2px);
304
+ }
305
+ 38% {
306
+ transform: translateY(0);
307
+ }
308
+ 78% {
309
+ opacity: 1;
310
+ }
311
+ 100% {
312
+ opacity: 0;
313
+ transform: translateY(0);
314
+ }
315
+ }
316
+
317
+ @media (prefers-reduced-motion: reduce) {
318
+ .vs-beam,
319
+ .vs-vignette,
320
+ .vs-bracket,
321
+ .vs-chip {
322
+ animation-duration: 1ms;
323
+ }
324
+ }
325
+ </style>
src/lib/utils/format.ts CHANGED
@@ -24,6 +24,16 @@ export function formatDuration(seconds: number): string {
24
  return `${m}:${s.toString().padStart(2, '0')}`;
25
  }
26
 
 
 
 
 
 
 
 
 
 
 
27
  export function ticksToSeconds(ticks: number, tickRate = 64): number {
28
  return ticks / tickRate;
29
  }
 
24
  return `${m}:${s.toString().padStart(2, '0')}`;
25
  }
26
 
27
+ export function formatLongDuration(seconds: number): string {
28
+ if (!Number.isFinite(seconds) || seconds <= 0) return '—';
29
+ const total = Math.round(seconds);
30
+ const h = Math.floor(total / 3600);
31
+ const m = Math.floor((total % 3600) / 60);
32
+ const s = total % 60;
33
+ if (h > 0) return `${h}:${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`;
34
+ return `${m}:${s.toString().padStart(2, '0')}`;
35
+ }
36
+
37
  export function ticksToSeconds(ticks: number, tickRate = 64): number {
38
  return ticks / tickRate;
39
  }
src/routes/+page.svelte CHANGED
@@ -1,7 +1,18 @@
1
  <script lang="ts">
2
  import Header from '$lib/components/header.svelte';
3
- import MatchCard from '$lib/components/match-card.svelte';
4
  import { Skeleton } from '$lib/components/ui/skeleton';
 
 
 
 
 
 
 
 
 
 
 
 
5
  import type { PageData } from './$types';
6
 
7
  let { data }: { data: PageData } = $props();
@@ -24,6 +35,25 @@
24
  PERSPECTIVES_PER_ROUND
25
  );
26
  const totalHours = $derived(totalSeconds / 3600);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
27
  </script>
28
 
29
  <Header />
@@ -66,20 +96,56 @@
66
  </section>
67
 
68
  <section>
69
- <h2 class="font-heading mb-4 text-lg font-semibold tracking-tight">All maps</h2>
70
-
71
- {#if data.matches.length === 0}
72
- <div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
73
- {#each Array(8) as _, i (i)}
74
- <Skeleton class="h-40 w-full" />
75
- {/each}
76
- </div>
77
- {:else}
78
- <div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
79
- {#each data.matches as match (match.match_id + '-' + match.map_name)}
80
- <MatchCard {match} />
81
- {/each}
82
  </div>
83
- {/if}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
84
  </section>
85
  </main>
 
1
  <script lang="ts">
2
  import Header from '$lib/components/header.svelte';
 
3
  import { Skeleton } from '$lib/components/ui/skeleton';
4
+ import * as Tabs from '$lib/components/ui/tabs';
5
+ import MatchTable from '$lib/components/match-table/match-table.svelte';
6
+ import {
7
+ buildMapRows,
8
+ buildMatchRows,
9
+ buildRoundRows
10
+ } from '$lib/components/match-table/rows';
11
+ import {
12
+ mapColumns,
13
+ matchColumns,
14
+ roundColumns
15
+ } from '$lib/components/match-table/columns';
16
  import type { PageData } from './$types';
17
 
18
  let { data }: { data: PageData } = $props();
 
35
  PERSPECTIVES_PER_ROUND
36
  );
37
  const totalHours = $derived(totalSeconds / 3600);
38
+
39
+ const roundRows = $derived(buildRoundRows(data.matches, data.rounds));
40
+ const mapRows = $derived(buildMapRows(data.matches, data.rounds));
41
+ const matchRows = $derived(buildMatchRows(data.matches, data.rounds));
42
+
43
+ const mapOptions = $derived(
44
+ Array.from(new Set(data.matches.map((m) => m.map_name))).sort()
45
+ );
46
+
47
+ const linkToMap = (m: { match_id: number; map_name: string }) =>
48
+ `/match/${encodeURIComponent(m.match_id)}/${encodeURIComponent(m.map_name)}`;
49
+
50
+ const linkToRound = (r: { match_id: number; map_name: string; round: number }) =>
51
+ `${linkToMap(r)}?round=${r.round}`;
52
+
53
+ const linkToFirstMap = (m: { match_id: number; first_map: string }) =>
54
+ `/match/${encodeURIComponent(m.match_id)}/${encodeURIComponent(m.first_map)}`;
55
+
56
+ let view = $state<'rounds' | 'maps' | 'matches'>('rounds');
57
  </script>
58
 
59
  <Header />
 
96
  </section>
97
 
98
  <section>
99
+ <Tabs.Root bind:value={view} class="w-full">
100
+ <div class="mb-2 flex items-center justify-between gap-2">
101
+ <h2 class="font-heading text-lg font-semibold tracking-tight">Browse the archive</h2>
102
+ <Tabs.List>
103
+ <Tabs.Trigger value="rounds">Rounds</Tabs.Trigger>
104
+ <Tabs.Trigger value="maps">Maps</Tabs.Trigger>
105
+ <Tabs.Trigger value="matches">Matches</Tabs.Trigger>
106
+ </Tabs.List>
 
 
 
 
 
107
  </div>
108
+
109
+ {#if data.matches.length === 0}
110
+ <div class="space-y-2">
111
+ {#each Array(8) as _, i (i)}
112
+ <Skeleton class="h-10 w-full" />
113
+ {/each}
114
+ </div>
115
+ {:else}
116
+ <Tabs.Content value="rounds">
117
+ <MatchTable
118
+ data={roundRows}
119
+ columns={roundColumns}
120
+ mapOptions={mapOptions}
121
+ searchPlaceholder="Search event, team, map…"
122
+ initialSorting={[{ id: 'match_date', desc: true }]}
123
+ getRowHref={linkToRound}
124
+ emptyMessage="No rounds yet. Round indexes load from the rounds parquet shards."
125
+ />
126
+ </Tabs.Content>
127
+
128
+ <Tabs.Content value="maps">
129
+ <MatchTable
130
+ data={mapRows}
131
+ columns={mapColumns}
132
+ mapOptions={mapOptions}
133
+ searchPlaceholder="Search event, team, map…"
134
+ initialSorting={[{ id: 'match_date', desc: true }]}
135
+ getRowHref={linkToMap}
136
+ />
137
+ </Tabs.Content>
138
+
139
+ <Tabs.Content value="matches">
140
+ <MatchTable
141
+ data={matchRows}
142
+ columns={matchColumns}
143
+ searchPlaceholder="Search event or team…"
144
+ initialSorting={[{ id: 'match_date', desc: true }]}
145
+ getRowHref={linkToFirstMap}
146
+ />
147
+ </Tabs.Content>
148
+ {/if}
149
+ </Tabs.Root>
150
  </section>
151
  </main>
src/routes/match/[matchId]/[mapName]/+page.svelte CHANGED
@@ -199,7 +199,9 @@
199
  // New active video; let it drive the clock again unless we're
200
  // already past its own duration (rare — guarded by availablePlayers).
201
  activeVideoEnded = virtualTime >= (playerDurations.get(player) ?? 0) - 0.05;
202
- bufferedRanges = [];
 
 
203
  markPlayerReady(player);
204
  syncUrl();
205
  }
@@ -521,6 +523,9 @@
521
  round={currentRoundNum}
522
  chunks={previews}
523
  {virtualTime}
 
 
 
524
  />
525
  </Card.Content>
526
  </Card.Root>
 
199
  // New active video; let it drive the clock again unless we're
200
  // already past its own duration (rare — guarded by availablePlayers).
201
  activeVideoEnded = virtualTime >= (playerDurations.get(player) ?? 0) - 0.05;
202
+ // Keep `bufferedRanges` as-is; the new video's onProgress will
203
+ // overwrite them within a frame or two. Clearing first makes the
204
+ // timeline buffered indicator flash off mid-swap.
205
  markPlayerReady(player);
206
  syncUrl();
207
  }
 
523
  round={currentRoundNum}
524
  chunks={previews}
525
  {virtualTime}
526
+ activePlayer={currentPlayer}
527
+ {availablePlayers}
528
+ onSelect={selectPlayer}
529
  />
530
  </Card.Content>
531
  </Card.Root>