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>
147 lines
5.6 KiB
TypeScript
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
|
|
}
|