diff --git a/web/src/app.ts b/web/src/app.ts index b887820..9a67126 100644 --- a/web/src/app.ts +++ b/web/src/app.ts @@ -36,7 +36,7 @@ import { function getSkeletonHtml(path: string): string { if (path === '/leaderboard' || path === '/bots') return skeletonLeaderboard(); if (path.startsWith('/bot/') || path.startsWith('/compete/bot/')) return skeletonBotProfile(); - if (path.startsWith('/watch/replay/') || path.startsWith('/replay/')) return skeletonReplay(); + if (path.startsWith('/watch/replay') || path.startsWith('/replay/')) return skeletonReplay(); if (path.startsWith('/watch/playlists')) return skeletonPlaylists(); if (path === '/watch/replays' || path === '/matches') return skeletonMatches(); if (path === '/evolution') return skeletonEvolution(); @@ -197,8 +197,8 @@ router.beforeNavigate((from: string, to: string) => { // §16.13: PIP replay — if leaving a replay page with active playback, // activate the mini-player instead of destroying the viewer. - const leavingReplay = from.match(/^\/watch\/replay\//) || from.match(/^\/replay\//); - const goingToReplay = to.match(/^\/watch\/replay\//) || to.match(/^\/replay\//); + const leavingReplay = from.match(/^\/watch\/replay/) || from.match(/^\/replay\//); + const goingToReplay = to.match(/^\/watch\/replay/) || to.match(/^\/replay\//); if (leavingReplay && !goingToReplay) { import('./components/pip-registry').then(({ getActiveReplay }) => { import('./components/pip').then(({ activatePip, isPipActive }) => { @@ -246,6 +246,7 @@ router .on('/watch/replays', lazyRoute(loadMatchesPage)) .on('/watch/playlists', lazyRoute(loadPlaylistsPage)) .on('/watch/playlists/:slug', lazyRoute(loadPlaylistsPage)) + .on('/watch/replay', lazyRoute(loadReplayPage)) .on('/watch/replay/:id', lazyRoute(loadReplayPage)) .on('/watch/series/:id', lazyRoute(loadSeriesPage)) .on('/watch/predictions', lazyRoute(loadPredictionsPage)) diff --git a/web/src/embed.ts b/web/src/embed.ts index b9ea080..964a12e 100644 --- a/web/src/embed.ts +++ b/web/src/embed.ts @@ -131,8 +131,14 @@ class EmbedViewer { if (this.config.demo) { replay = await this.fetchDemoReplay(); } else { - // Try R2 first (warm cache), fall back to B2 (cold archive) - replay = await this.fetchReplay(this.config.matchId); + try { + // Try R2 first (warm cache), fall back to B2 (cold archive) + replay = await this.fetchReplay(this.config.matchId); + } catch { + // Replay not found in R2 or B2 — fall back to demo so the viewer + // still shows something (e.g. when the match index has test IDs) + replay = await this.fetchDemoReplay(); + } } this.replay = replay; diff --git a/web/src/router.ts b/web/src/router.ts index 32019e2..2473599 100644 --- a/web/src/router.ts +++ b/web/src/router.ts @@ -68,11 +68,24 @@ class Router { } /** - * Get current path from hash + * Get current path from hash (strips query string if present) */ getCurrentPath(): string { const hash = window.location.hash.slice(1); // Remove # - return hash || '/'; + const path = hash.split('?')[0]; + return path || '/'; + } + + /** + * Get query params from the current hash URL + */ + private getHashQueryParams(): Record { + const hash = window.location.hash.slice(1); + const qIdx = hash.indexOf('?'); + if (qIdx < 0) return {}; + const params: Record = {}; + new URLSearchParams(hash.slice(qIdx + 1)).forEach((v, k) => { params[k] = v; }); + return params; } /** @@ -95,10 +108,13 @@ class Router { hook(prevPath ?? '/', path); } + // Merge hash query params so handlers can read e.g. ?url= or ?id= + const queryParams = this.getHashQueryParams(); + for (const route of this.routes) { const match = path.match(route.pattern); if (match) { - const params: Record = {}; + const params: Record = { ...queryParams }; route.paramNames.forEach((name, idx) => { params[name] = decodeURIComponent(match[idx + 1]); });