blanchon commited on
Commit
c92e42a
·
1 Parent(s): 79f37ad

Fix navigation state bugs (round/player URL, map switch, table state on back)

Browse files

Bug 1: Map dropdown changed the URL but kept the previous map's video, player
positions, etc. Same component instance was reused across `[mapName]` route
changes, so $state and onMount didn't re-fire. Replace onMount with an $effect
that watches a routeKey derived from data.match.{match_id,map_name}; when it
changes, re-read URL params and reset round/player/view + reload previews.

Bug 2: Home links omitted defaults so the destination URL wasn't
self-contained. matchUrl() now always emits ?round=&player=&view= so reloads,
shares, and back-nav stay deterministic.

Bug 3: Tab choice + search/sort/pagination/filter were all internal MatchTable
state and got lost on back-nav. Lift the state to the home page (one slot per
tab) via $bindable props on MatchTable, and use SvelteKit's snapshot API
(capture/restore) on +page to persist {view, tables} across history navigation.

src/lib/components/match-table/match-table.svelte CHANGED
@@ -23,10 +23,15 @@
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 {
@@ -34,20 +39,15 @@
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(() => {
 
23
  columns: ColumnDef<TData>[];
24
  searchPlaceholder?: string;
25
  mapOptions?: string[];
 
 
26
  getRowHref?: (row: TData) => string;
27
  emptyMessage?: string;
28
+ // Bindable state so the parent route can persist it across navigations
29
+ // via SvelteKit's snapshot API.
30
+ pagination?: PaginationState;
31
+ sorting?: SortingState;
32
+ columnFilters?: ColumnFiltersState;
33
+ columnVisibility?: VisibilityState;
34
+ globalFilter?: string;
35
  }
36
 
37
  let {
 
39
  columns,
40
  searchPlaceholder,
41
  mapOptions = [],
 
 
42
  getRowHref,
43
+ emptyMessage = 'No results.',
44
+ pagination = $bindable<PaginationState>({ pageIndex: 0, pageSize: 25 }),
45
+ sorting = $bindable<SortingState>([]),
46
+ columnFilters = $bindable<ColumnFiltersState>([]),
47
+ columnVisibility = $bindable<VisibilityState>({}),
48
+ globalFilter = $bindable<string>('')
49
  }: Props = $props();
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(() => {
src/routes/+page.svelte CHANGED
@@ -19,7 +19,13 @@
19
  import MatchTable from '$lib/components/match-table/match-table.svelte';
20
  import { buildMapRows, buildMatchRows, buildRoundRows } from '$lib/components/match-table/rows';
21
  import { mapColumns, matchColumns, roundColumns } from '$lib/components/match-table/columns';
22
- import type { PageData } from './$types';
 
 
 
 
 
 
23
 
24
  let { data }: { data: PageData } = $props();
25
 
@@ -45,16 +51,60 @@
45
 
46
  const mapOptions = $derived(Array.from(new Set(data.matches.map((m) => m.map_name))).sort());
47
 
 
 
 
 
 
 
 
 
 
 
 
48
  const linkToMap = (m: { match_id: number; map_name: string }) =>
49
- `/match/${encodeURIComponent(m.match_id)}/${encodeURIComponent(m.map_name)}`;
50
 
51
  const linkToRound = (r: { match_id: number; map_name: string; round: number }) =>
52
- `${linkToMap(r)}?round=${r.round}`;
53
 
54
  const linkToFirstMap = (m: { match_id: number; first_map: string }) =>
55
- `/match/${encodeURIComponent(m.match_id)}/${encodeURIComponent(m.first_map)}`;
 
 
 
 
 
 
 
 
 
 
56
 
57
- let view = $state<'rounds' | 'maps' | 'matches'>('rounds');
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
58
 
59
  const motivations = [
60
  {
@@ -315,9 +365,13 @@
315
  columns={roundColumns}
316
  {mapOptions}
317
  searchPlaceholder="Search event, team, map…"
318
- initialSorting={[{ id: 'match_date', desc: true }]}
319
  getRowHref={linkToRound}
320
  emptyMessage="No rounds yet. Round indexes load from the rounds parquet shards."
 
 
 
 
 
321
  />
322
  </Tabs.Content>
323
 
@@ -327,8 +381,12 @@
327
  columns={mapColumns}
328
  {mapOptions}
329
  searchPlaceholder="Search event, team, map…"
330
- initialSorting={[{ id: 'match_date', desc: true }]}
331
  getRowHref={linkToMap}
 
 
 
 
 
332
  />
333
  </Tabs.Content>
334
 
@@ -337,8 +395,12 @@
337
  data={matchRows}
338
  columns={matchColumns}
339
  searchPlaceholder="Search event or team…"
340
- initialSorting={[{ id: 'match_date', desc: true }]}
341
  getRowHref={linkToFirstMap}
 
 
 
 
 
342
  />
343
  </Tabs.Content>
344
  {/if}
 
19
  import MatchTable from '$lib/components/match-table/match-table.svelte';
20
  import { buildMapRows, buildMatchRows, buildRoundRows } from '$lib/components/match-table/rows';
21
  import { mapColumns, matchColumns, roundColumns } from '$lib/components/match-table/columns';
22
+ import type {
23
+ ColumnFiltersState,
24
+ PaginationState,
25
+ SortingState,
26
+ VisibilityState
27
+ } from '@tanstack/table-core';
28
+ import type { PageData, Snapshot } from './$types';
29
 
30
  let { data }: { data: PageData } = $props();
31
 
 
51
 
52
  const mapOptions = $derived(Array.from(new Set(data.matches.map((m) => m.map_name))).sort());
53
 
54
+ // Always emit explicit ?round=&player=&view= so the destination URL is
55
+ // self-contained — no implicit defaults for back/share to lose.
56
+ function matchUrl(matchId: number, mapName: string, round: number) {
57
+ const params = new URLSearchParams({
58
+ round: String(round),
59
+ player: '0',
60
+ view: 'grid'
61
+ });
62
+ return `/match/${encodeURIComponent(matchId)}/${encodeURIComponent(mapName)}?${params}`;
63
+ }
64
+
65
  const linkToMap = (m: { match_id: number; map_name: string }) =>
66
+ matchUrl(m.match_id, m.map_name, 1);
67
 
68
  const linkToRound = (r: { match_id: number; map_name: string; round: number }) =>
69
+ matchUrl(r.match_id, r.map_name, r.round);
70
 
71
  const linkToFirstMap = (m: { match_id: number; first_map: string }) =>
72
+ matchUrl(m.match_id, m.first_map, 1);
73
+
74
+ type TabKey = 'rounds' | 'maps' | 'matches';
75
+
76
+ type TableState = {
77
+ pagination: PaginationState;
78
+ sorting: SortingState;
79
+ columnFilters: ColumnFiltersState;
80
+ columnVisibility: VisibilityState;
81
+ globalFilter: string;
82
+ };
83
 
84
+ const newTableState = (): TableState => ({
85
+ pagination: { pageIndex: 0, pageSize: 25 },
86
+ sorting: [{ id: 'match_date', desc: true }],
87
+ columnFilters: [],
88
+ columnVisibility: {},
89
+ globalFilter: ''
90
+ });
91
+
92
+ let view = $state<TabKey>('rounds');
93
+ const tables = $state<Record<TabKey, TableState>>({
94
+ rounds: newTableState(),
95
+ maps: newTableState(),
96
+ matches: newTableState()
97
+ });
98
+
99
+ // Persist tab + table state across navigations (back from a match page,
100
+ // preload, etc.) using SvelteKit's snapshot API.
101
+ export const snapshot: Snapshot<{ view: TabKey; tables: Record<TabKey, TableState> }> = {
102
+ capture: () => ({ view, tables: $state.snapshot(tables) }),
103
+ restore: (s) => {
104
+ view = s.view;
105
+ Object.assign(tables, s.tables);
106
+ }
107
+ };
108
 
109
  const motivations = [
110
  {
 
365
  columns={roundColumns}
366
  {mapOptions}
367
  searchPlaceholder="Search event, team, map…"
 
368
  getRowHref={linkToRound}
369
  emptyMessage="No rounds yet. Round indexes load from the rounds parquet shards."
370
+ bind:pagination={tables.rounds.pagination}
371
+ bind:sorting={tables.rounds.sorting}
372
+ bind:columnFilters={tables.rounds.columnFilters}
373
+ bind:columnVisibility={tables.rounds.columnVisibility}
374
+ bind:globalFilter={tables.rounds.globalFilter}
375
  />
376
  </Tabs.Content>
377
 
 
381
  columns={mapColumns}
382
  {mapOptions}
383
  searchPlaceholder="Search event, team, map…"
 
384
  getRowHref={linkToMap}
385
+ bind:pagination={tables.maps.pagination}
386
+ bind:sorting={tables.maps.sorting}
387
+ bind:columnFilters={tables.maps.columnFilters}
388
+ bind:columnVisibility={tables.maps.columnVisibility}
389
+ bind:globalFilter={tables.maps.globalFilter}
390
  />
391
  </Tabs.Content>
392
 
 
395
  data={matchRows}
396
  columns={matchColumns}
397
  searchPlaceholder="Search event or team…"
 
398
  getRowHref={linkToFirstMap}
399
+ bind:pagination={tables.matches.pagination}
400
+ bind:sorting={tables.matches.sorting}
401
+ bind:columnFilters={tables.matches.columnFilters}
402
+ bind:columnVisibility={tables.matches.columnVisibility}
403
+ bind:globalFilter={tables.matches.globalFilter}
404
  />
405
  </Tabs.Content>
406
  {/if}
src/routes/match/[matchId]/[mapName]/+page.svelte CHANGED
@@ -1,6 +1,5 @@
1
  <script lang="ts">
2
  import type { PageData } from './$types';
3
- import { onMount } from 'svelte';
4
  import { page } from '$app/state';
5
  import { goto } from '$app/navigation';
6
  import Header from '$lib/components/header.svelte';
@@ -25,21 +24,24 @@
25
 
26
  let { data }: { data: PageData } = $props();
27
 
28
- const initialRound = (() => {
29
  const param = page.url.searchParams.get('round');
30
  const parsed = param ? Number(param) : NaN;
31
- if (Number.isFinite(parsed) && data.rounds.some((r) => r.round === parsed)) return parsed;
32
- return data.rounds[0]?.round ?? 1;
33
- })();
34
-
35
- let currentRoundNum = $state<number>(initialRound);
36
- let currentPlayer = $state<number>(
37
- (() => {
38
- const p = page.url.searchParams.get('player');
39
- const parsed = p ? Number(p) : NaN;
40
- return Number.isFinite(parsed) && parsed >= 0 && parsed < 10 ? parsed : 0;
41
- })()
42
- );
 
 
 
43
 
44
  let previews = $state<PreviewChunk[]>([]);
45
  let previewsLoading = $state(false);
@@ -71,9 +73,7 @@
71
  let preloadAllPlayers = $state(false);
72
  let preloadedPlayers = $state(new Set<number>());
73
 
74
- let viewMode = $state<'single' | 'grid'>(
75
- page.url.searchParams.get('view') === 'single' ? 'single' : 'grid'
76
- );
77
 
78
  const playerChunks = $derived(
79
  previews.filter((p) => p.player === currentPlayer).sort((a, b) => a.chunk_index - b.chunk_index)
@@ -340,8 +340,35 @@
340
  }
341
  }
342
 
343
- onMount(() => {
344
- loadRound(currentRoundNum);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
345
  });
346
  </script>
347
 
 
1
  <script lang="ts">
2
  import type { PageData } from './$types';
 
3
  import { page } from '$app/state';
4
  import { goto } from '$app/navigation';
5
  import Header from '$lib/components/header.svelte';
 
24
 
25
  let { data }: { data: PageData } = $props();
26
 
27
+ function readRoundFromUrl(rounds: { round: number }[]): number {
28
  const param = page.url.searchParams.get('round');
29
  const parsed = param ? Number(param) : NaN;
30
+ if (Number.isFinite(parsed) && rounds.some((r) => r.round === parsed)) return parsed;
31
+ return rounds[0]?.round ?? 1;
32
+ }
33
+ function readPlayerFromUrl(): number {
34
+ const param = page.url.searchParams.get('player');
35
+ const parsed = param ? Number(param) : NaN;
36
+ return Number.isFinite(parsed) && parsed >= 0 && parsed < 10 ? parsed : 0;
37
+ }
38
+ function readViewFromUrl(): 'single' | 'grid' {
39
+ return page.url.searchParams.get('view') === 'single' ? 'single' : 'grid';
40
+ }
41
+
42
+ // svelte-ignore state_referenced_locally
43
+ let currentRoundNum = $state<number>(readRoundFromUrl(data.rounds));
44
+ let currentPlayer = $state<number>(readPlayerFromUrl());
45
 
46
  let previews = $state<PreviewChunk[]>([]);
47
  let previewsLoading = $state(false);
 
73
  let preloadAllPlayers = $state(false);
74
  let preloadedPlayers = $state(new Set<number>());
75
 
76
+ let viewMode = $state<'single' | 'grid'>(readViewFromUrl());
 
 
77
 
78
  const playerChunks = $derived(
79
  previews.filter((p) => p.player === currentPlayer).sort((a, b) => a.chunk_index - b.chunk_index)
 
340
  }
341
  }
342
 
343
+ // React to (matchId, mapName) changes so navigating between maps via the
344
+ // Select dropdown — which only swaps the route param, not the component —
345
+ // resets local state and reloads the new round previews.
346
+ let prevRouteKey = '';
347
+ $effect(() => {
348
+ const routeKey = `${data.match.match_id}|${data.match.map_name}`;
349
+ if (routeKey === prevRouteKey) return;
350
+ prevRouteKey = routeKey;
351
+
352
+ const newRound = readRoundFromUrl(data.rounds);
353
+ const newPlayer = readPlayerFromUrl();
354
+ const newView = readViewFromUrl();
355
+
356
+ untrack(() => {
357
+ currentRoundNum = newRound;
358
+ currentPlayer = newPlayer;
359
+ viewMode = newView;
360
+ previews = [];
361
+ previewsError = null;
362
+ virtualTime = 0;
363
+ initialVirtualTime = 0;
364
+ activeVideoEnded = false;
365
+ bufferedRanges = [];
366
+ preloadedPlayers = new Set();
367
+ preloadAllPlayers = false;
368
+ masterPaused = false;
369
+
370
+ loadRound(newRound);
371
+ });
372
  });
373
  </script>
374