Spaces:
Running
Fix navigation state bugs (round/player URL, map switch, table state on back)
Browse filesBug 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.
|
@@ -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(() => {
|
|
@@ -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 {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 50 |
|
| 51 |
const linkToRound = (r: { match_id: number; map_name: string; round: number }) =>
|
| 52 |
-
|
| 53 |
|
| 54 |
const linkToFirstMap = (m: { match_id: number; first_map: string }) =>
|
| 55 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 56 |
|
| 57 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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}
|
|
@@ -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 |
-
|
| 29 |
const param = page.url.searchParams.get('round');
|
| 30 |
const parsed = param ? Number(param) : NaN;
|
| 31 |
-
if (Number.isFinite(parsed) &&
|
| 32 |
-
return
|
| 33 |
-
}
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
(
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 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 |
-
|
| 344 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
|