fix(web): fix replay viewer routing and embed fallback

Router: strip query string from hash path before route matching, and merge
hash query params (e.g. ?url=) into the params passed to route handlers.
Add /watch/replay route (without :id) so ?url= links work without a path ID.

Embed: fall back to demo replay when the match replay is not found in R2/B2
instead of showing "Failed to fetch" (handles test match IDs with no replay).

App: extend skeleton and PIP checks to match /watch/replay (with or without :id).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
jedarden 2026-04-30 13:07:13 -04:00
parent 651f344247
commit 273736a3f2
3 changed files with 31 additions and 8 deletions

View file

@ -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))

View file

@ -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;

View file

@ -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<string, string> {
const hash = window.location.hash.slice(1);
const qIdx = hash.indexOf('?');
if (qIdx < 0) return {};
const params: Record<string, string> = {};
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<string, string> = {};
const params: Record<string, string> = { ...queryParams };
route.paramNames.forEach((name, idx) => {
params[name] = decodeURIComponent(match[idx + 1]);
});