Files
ersatztv/web/src/routing.ts
T
timothyandClaude Opus 4.8 6ce448d265 feat(spa): media-source SPA foundation — shared client, helpers, App-owned popstate wrapper (#202 slice S5)
Owns the shared single-files so the S6a (Local) / S6b (Remote) editor slices touch
disjoint files. Editor screens are stubbed (MediaSourceEditorPlaceholder) for S6.

- web/src/api/mediaSources.ts (+test): client module over the new media-source write
  endpoints (local CRUD + move/path-exists; Plex pin-flow/sign-out; shared remote
  state/connection/libraries/path-replacements/refresh; family = only URL variance),
  DTOs re-exported from generated v1, messageFromMediaSourcesError; barrel export.
- web/src/mediaSources/{familyMeta,paths,pinFlowPoll}.ts (+tests): family labels/routes/
  remote-path column naming (owns RemoteFamily); client-side NormalizePath mirror for
  in-draft dup detection; pure §C1 pin-flow poll state machine (waiting/finalizing/
  success/timeout/budget-exhausted), timer-free and fully unit-tested.
- routing.ts parseLibrariesSubRoute + LibrariesSubRoute union split Local vs Remote.
- App.tsx: libraries route allowSubPaths; LibrariesRouteScreen wrapper dispatching a
  flat switch to placeholders; App-owned popstate (finding 4) — App is the single
  popstate owner, consults canLeaveCurrentScreen() and only on approval updates
  librariesSubPath passed DOWN to the wrapper (wrapper never self-listens); state write
  scoped to the libraries route so Playouts/Media pops stay byte-identical (nit 3).
- LibrariesScreen hub wiring: Add-Source menu (Local/Plex/Jellyfin/Emby), remote source
  gear -> family screen, local library row gear -> edit route; removed the disabled
  Scan-All button + the deferred-sources card (§C7/§D.1).
- Tests: App-owned-popstate dirty-guard case (confirm false keeps URL+sub-screen; true
  navigates); mediaSources client URL/verb mapping; pinFlowPoll transitions; familyMeta/
  paths units; hub-wiring navigation.
- docs/spa-conventions.md §8 (resolved sub-path+dirty-guard caveat -> App-owned popstate)
  + §2 exemplar list (guarded-route exception).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 16:11:16 +02:00

147 lines
5.6 KiB
TypeScript

import { parseRemoteFamily, type RemoteFamily } from './mediaSources/familyMeta';
// Client-side navigation shared by the shell (App.tsx) and screens that live in
// their own modules (e.g. the Channel Builder). Kept in its own file so screens
// don't import App.tsx (which would create an import cycle).
export function navigateToPath(path: string) {
window.history.pushState(null, '', path);
window.dispatchEvent(new PopStateEvent('popstate'));
}
// The Playouts screen owns two sub-path editors: /app/playouts/{id}/alternate-schedules (classic)
// and /app/playouts/{id}/templates (block). Parsing lives here (not in the screen module) so the
// screen file only exports components (react-refresh) while App.tsx's render switch can dispatch.
export type PlayoutSubRoute = { id: number; kind: 'alternate-schedules' | 'templates' };
export function parsePlayoutSubRoute(pathname: string): PlayoutSubRoute | null {
const base = '/app/playouts';
const normalized = pathname.replace(/\/+$/, '');
if (!normalized.startsWith(`${base}/`)) {
return null;
}
const parts = normalized.slice(base.length + 1).split('/');
if (parts.length !== 2) {
return null;
}
const id = Number(parts[0]);
if (!Number.isInteger(id) || id <= 0) {
return null;
}
if (parts[1] === 'alternate-schedules') {
return { id, kind: 'alternate-schedules' };
}
if (parts[1] === 'templates') {
return { id, kind: 'templates' };
}
return null;
}
// The Media nav entry owns detail sub-pages (/app/media/{movies|shows|seasons|artists}/{id}) and the
// image folder browser (/app/media/images/browser). Parsing lives here so the screen module only
// exports components (react-refresh) while App.tsx's render switch can dispatch. Like the playout
// sub-routes, routeFromLocation() returns the same 'media' route object for the base grid and every
// detail path, so the wrapper in App.tsx tracks pathname locally to re-render the right sub-screen.
export type MediaDetailKind = 'movie' | 'show' | 'season' | 'artist';
export type MediaSubRoute = { kind: MediaDetailKind; id: number } | { kind: 'images' };
const mediaDetailSlugs: Record<string, MediaDetailKind> = {
movies: 'movie',
shows: 'show',
seasons: 'season',
artists: 'artist'
};
export function parseMediaSubRoute(pathname: string): MediaSubRoute | null {
const base = '/app/media';
const normalized = pathname.replace(/\/+$/, '');
if (!normalized.startsWith(`${base}/`)) {
return null;
}
const parts = normalized.slice(base.length + 1).split('/');
if (parts.length === 2 && parts[0] === 'images' && parts[1] === 'browser') {
return { kind: 'images' };
}
if (parts.length === 2) {
const kind = mediaDetailSlugs[parts[0]];
const id = Number(parts[1]);
if (kind && Number.isInteger(id) && id > 0) {
return { id, kind };
}
}
return null;
}
// The Libraries nav entry (an allowSubPaths route) owns the media-source editor sub-paths. Unlike
// PlayoutsRouteScreen/MediaRouteScreen (which self-listen for popstate), the libraries wrapper's
// sub-screens register a dirty guard, so App owns pathname/popstate for this route and passes the
// approved sub-path DOWN (see spa-conventions §8, design §D.2). Parsing lives here so the screen
// module can stay component-only (react-refresh) and App.tsx's wrapper can dispatch. A `null` result
// means "render the hub" (base path, `/local`, or any unrecognized sub-path).
//
// The union is split into a Local group (S6a owns these) and a Remote group (S6b owns these) so the
// two feature slices edit disjoint dispatch branches.
export type LibrariesSubRoute =
// Local (slice S6a)
| { kind: 'local-new' }
| { kind: 'local-edit'; id: number }
// Remote (slice S6b)
| { kind: 'remote-source'; family: RemoteFamily }
| { kind: 'remote-connection'; family: 'jellyfin' | 'emby' }
| { kind: 'remote-libraries'; family: RemoteFamily; id: number }
| { kind: 'remote-path-replacements'; family: RemoteFamily; id: number };
export function parseLibrariesSubRoute(pathname: string): LibrariesSubRoute | null {
const base = '/app/libraries';
const normalized = pathname.replace(/\/+$/, '');
if (!normalized.startsWith(`${base}/`)) {
return null; // base path -> hub
}
const parts = normalized.slice(base.length + 1).split('/');
// --- Local: /app/libraries/local/new | /app/libraries/local/{id} ---
if (parts[0] === 'local') {
if (parts.length === 2 && parts[1] === 'new') {
return { kind: 'local-new' };
}
if (parts.length === 2) {
const id = Number(parts[1]);
if (Number.isInteger(id) && id > 0) {
return { kind: 'local-edit', id };
}
}
return null; // bare /app/libraries/local (or garbage) -> hub (its list lives on the hub)
}
// --- Remote: /app/libraries/{plex|jellyfin|emby}[/...] ---
const family = parseRemoteFamily(parts[0]);
if (family === null) {
return null;
}
// /app/libraries/{family}
if (parts.length === 1) {
return { kind: 'remote-source', family };
}
// /app/libraries/{jellyfin|emby}/connection
if (parts.length === 2 && parts[1] === 'connection' && (family === 'jellyfin' || family === 'emby')) {
return { kind: 'remote-connection', family };
}
// /app/libraries/{family}/{id}/sync | /app/libraries/{family}/{id}/path-replacements
if (parts.length === 3) {
const id = Number(parts[1]);
if (Number.isInteger(id) && id > 0) {
if (parts[2] === 'sync') {
return { kind: 'remote-libraries', family, id };
}
if (parts[2] === 'path-replacements') {
return { kind: 'remote-path-replacements', family, id };
}
}
}
return null; // unrecognized sub-path -> hub
}