soulsync/webui/src/platform/shell/route-manifest.ts
Broque Thomas 3a4017ea2b feat: artist-detail deep linking — /artist-detail/:source/:id
Artist detail pages previously always pushed /artist-detail to the URL,
so refreshing the page or sharing a link would drop users on a broken
empty page with no artist loaded.

URL format is now /artist-detail/:source/:id (e.g.
/artist-detail/spotify/4tZwfgrHOc3mvqsCAfo4LT or
/artist-detail/library/42). The source segment lets the backend
synthesize a response from the right metadata client without a DB hit.

Changes:

Client routing (legacy shell + TanStack bridge)
- buildArtistDetailPath / _getDeepLinkArtistDetail added to init.js;
  parse both new :source/:id and legacy bare :id formats so old
  bookmarks still work
- navigateToPage passes artistId + artistSource through to the router
  bridge, which builds the dynamic href instead of hardcoding route.path
- resolveShellPageFromPath / resolveLegacyShellPageFromPath use a prefix
  match so /artist-detail/* resolves to artist-detail page-id
- globals.d.ts typed for artistId / artistSource options
- activateLegacyPath and syncActivePageFromLocation (popstate) both
  restore artist from URL using skipRouteChange:true to avoid a
  re-navigation loop back to /artist-detail
- loadInitialData restores artist from URL on page load (router not yet
  mounted at DOMContentLoaded so legacy path runs unconditionally)
- Same-artist guard in navigateToArtistDetail prevents double-fetch
  when the router fires activateLegacyPath after the initial navigation

Server
- artist_source_detail.build_source_only_artist_detail now resolves
  artist name from the source API when none is supplied, so deep-link
  restores with an empty name string still render correctly

Tests
- test_spa_deep_linking: /artist-detail/42 and /artist-detail/spotify/ID
  both serve index.html
- bridge.test.ts: source-aware URL building and library fallback
- route-manifest.test.ts: prefix path resolution
- artist_source_detail: name resolved from source when input is empty
2026-05-18 13:07:54 -07:00

88 lines
3.2 KiB
TypeScript

export const shellPageIds = [
'dashboard',
'sync',
'search',
'discover',
'playlist-explorer',
'watchlist',
'wishlist',
'automations',
'active-downloads',
'library',
'tools',
'artist-detail',
'stats',
'import',
'settings',
'issues',
'help',
'hydrabase',
] as const;
export type ShellPageId = (typeof shellPageIds)[number];
export type ShellRouteKind = 'legacy' | 'react';
export interface ShellRouteDefinition {
pageId: ShellPageId;
path: `/${string}`;
kind: ShellRouteKind;
}
export const shellRouteManifest: readonly ShellRouteDefinition[] = [
{ pageId: 'dashboard', path: '/dashboard', kind: 'legacy' },
{ pageId: 'sync', path: '/sync', kind: 'legacy' },
{ pageId: 'search', path: '/search', kind: 'legacy' },
{ pageId: 'discover', path: '/discover', kind: 'legacy' },
{ pageId: 'playlist-explorer', path: '/playlist-explorer', kind: 'legacy' },
{ pageId: 'watchlist', path: '/watchlist', kind: 'legacy' },
{ pageId: 'wishlist', path: '/wishlist', kind: 'legacy' },
{ pageId: 'automations', path: '/automations', kind: 'legacy' },
{ pageId: 'active-downloads', path: '/active-downloads', kind: 'legacy' },
{ pageId: 'import', path: '/import', kind: 'legacy' },
{ pageId: 'library', path: '/library', kind: 'legacy' },
{ pageId: 'tools', path: '/tools', kind: 'legacy' },
{ pageId: 'artist-detail', path: '/artist-detail', kind: 'legacy' },
{ pageId: 'stats', path: '/stats', kind: 'legacy' },
{ pageId: 'settings', path: '/settings', kind: 'legacy' },
{ pageId: 'issues', path: '/issues', kind: 'react' },
{ pageId: 'help', path: '/help', kind: 'legacy' },
{ pageId: 'hydrabase', path: '/hydrabase', kind: 'legacy' },
] as const;
const routeByPageId = new Map(shellRouteManifest.map((route) => [route.pageId, route]));
const routeByPath = new Map(shellRouteManifest.map((route) => [route.path, route]));
export const reactShellRoutes = shellRouteManifest.filter((route) => route.kind === 'react');
export const legacyShellRoutes = shellRouteManifest.filter((route) => route.kind === 'legacy');
export function normalizeShellPath(pathname: string): string {
if (!pathname) return '/';
if (pathname === '/') return '/';
const normalized = pathname.endsWith('/') ? pathname.slice(0, -1) : pathname;
return normalized || '/';
}
export function getShellRouteByPageId(pageId: ShellPageId): ShellRouteDefinition | undefined {
return routeByPageId.get(pageId);
}
export function getShellRouteByPath(pathname: string): ShellRouteDefinition | undefined {
return routeByPath.get(normalizeShellPath(pathname) as `/${string}`);
}
export function resolveShellPageFromPath(pathname: string): ShellPageId | null {
const normalized = normalizeShellPath(pathname);
if (normalized === '/artist-detail' || normalized.startsWith('/artist-detail/')) {
return 'artist-detail';
}
return getShellRouteByPath(pathname)?.pageId ?? null;
}
export function resolveLegacyShellPageFromPath(pathname: string): ShellPageId | null {
const normalized = normalizeShellPath(pathname);
if (normalized === '/artist-detail' || normalized.startsWith('/artist-detail/')) {
return 'artist-detail';
}
const route = getShellRouteByPath(pathname);
return route?.kind === 'legacy' ? route.pageId : null;
}