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>
140 lines
3.5 KiB
TypeScript
140 lines
3.5 KiB
TypeScript
// Simple hash-based router for single-page navigation
|
|
// §16.14: integrated with back-cache for instant back/forward navigation
|
|
|
|
export type RouteHandler = (params: Record<string, string>) => void | Promise<void>;
|
|
|
|
interface Route {
|
|
pattern: RegExp;
|
|
handler: RouteHandler;
|
|
paramNames: string[];
|
|
}
|
|
|
|
class Router {
|
|
private routes: Route[] = [];
|
|
private notFoundHandler: RouteHandler | null = null;
|
|
private beforeNavigateHooks: Array<(from: string, to: string) => void> = [];
|
|
private afterNavigateHooks: Array<(path: string) => void> = [];
|
|
|
|
/**
|
|
* Register a route with a pattern like "/leaderboard" or "/bot/:id"
|
|
*/
|
|
on(pattern: string, handler: RouteHandler): this {
|
|
const paramNames: string[] = [];
|
|
const regexPattern = pattern.replace(/:(\w+)/g, (_, name) => {
|
|
paramNames.push(name);
|
|
return '([^/]+)';
|
|
});
|
|
|
|
this.routes.push({
|
|
pattern: new RegExp(`^${regexPattern}$`),
|
|
handler,
|
|
paramNames,
|
|
});
|
|
|
|
return this;
|
|
}
|
|
|
|
/**
|
|
* Register a handler for unmatched routes
|
|
*/
|
|
notFound(handler: RouteHandler): this {
|
|
this.notFoundHandler = handler;
|
|
return this;
|
|
}
|
|
|
|
/**
|
|
* Register a hook called before each navigation (after hash changes).
|
|
* Receives (fromPath, toPath).
|
|
*/
|
|
beforeNavigate(hook: (from: string, to: string) => void): this {
|
|
this.beforeNavigateHooks.push(hook);
|
|
return this;
|
|
}
|
|
|
|
/**
|
|
* Register a hook called after each route handler completes.
|
|
* Receives the resolved path.
|
|
*/
|
|
afterNavigate(hook: (path: string) => void): this {
|
|
this.afterNavigateHooks.push(hook);
|
|
return this;
|
|
}
|
|
|
|
/**
|
|
* Navigate to a path
|
|
*/
|
|
navigate(path: string): void {
|
|
window.location.hash = path;
|
|
}
|
|
|
|
/**
|
|
* Get current path from hash (strips query string if present)
|
|
*/
|
|
getCurrentPath(): string {
|
|
const hash = window.location.hash.slice(1); // Remove #
|
|
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;
|
|
}
|
|
|
|
/**
|
|
* Start listening for hash changes
|
|
*/
|
|
start(): void {
|
|
window.addEventListener('hashchange', () => this.handleRoute());
|
|
this.handleRoute();
|
|
}
|
|
|
|
/**
|
|
* Handle the current route
|
|
*/
|
|
private async handleRoute(): Promise<void> {
|
|
const path = this.getCurrentPath();
|
|
const prevPath = (this as any)._lastPath as string | undefined;
|
|
(this as any)._lastPath = path;
|
|
|
|
for (const hook of this.beforeNavigateHooks) {
|
|
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> = { ...queryParams };
|
|
route.paramNames.forEach((name, idx) => {
|
|
params[name] = decodeURIComponent(match[idx + 1]);
|
|
});
|
|
await route.handler(params);
|
|
|
|
for (const hook of this.afterNavigateHooks) {
|
|
hook(path);
|
|
}
|
|
return;
|
|
}
|
|
}
|
|
|
|
if (this.notFoundHandler) {
|
|
await this.notFoundHandler({});
|
|
}
|
|
|
|
for (const hook of this.afterNavigateHooks) {
|
|
hook(path);
|
|
}
|
|
}
|
|
}
|
|
|
|
export const router = new Router();
|