soulsync/webui/src/platform/shell/bridge.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

104 lines
3.1 KiB
TypeScript

import type { AnyRouter } from '@tanstack/react-router';
import {
getShellRouteByPageId,
normalizeShellPath,
resolveShellPageFromPath,
shellRouteManifest,
type ShellPageId,
type ShellRouteDefinition,
} from './route-manifest';
export interface ShellProfileContext {
profileId: number;
isAdmin: boolean;
}
export interface ShellContext {
bridge: ShellBridge;
profile: ShellProfileContext;
}
export type ShellBridge = NonNullable<typeof window.SoulSyncWebShellBridge>;
export const SHELL_BRIDGE_READY_EVENT = 'ss:webui-shell-bridge-ready';
export const SHELL_PROFILE_CONTEXT_CHANGED_EVENT = 'ss:webui-profile-context-changed';
export function getShellBridge(): ShellBridge | null {
return window.SoulSyncWebShellBridge ?? null;
}
export function getShellProfileContext(bridge = getShellBridge()): ShellProfileContext | null {
return bridge?.getCurrentProfileContext() ?? null;
}
export function getShellContext(bridge = getShellBridge()): ShellContext | null {
const profile = getShellProfileContext(bridge);
if (!bridge || !profile) return null;
return { bridge, profile };
}
export function getProfileHomePath(bridge = getShellBridge()): `/${string}` {
const pageId = bridge?.getProfileHomePage() ?? 'discover';
return getShellRouteByPageId(pageId)?.path ?? '/discover';
}
export async function waitForShellContext(): Promise<ShellContext> {
const currentContext = getShellContext();
if (currentContext) return currentContext;
return await new Promise<ShellContext>((resolve) => {
const cleanup = () => {
window.removeEventListener(SHELL_BRIDGE_READY_EVENT, handleReady);
window.removeEventListener(SHELL_PROFILE_CONTEXT_CHANGED_EVENT, handleProfileChange);
};
const settleIfReady = () => {
const shell = getShellContext();
if (!shell) return;
cleanup();
resolve(shell);
};
const handleReady = () => {
settleIfReady();
};
const handleProfileChange = () => {
settleIfReady();
};
window.addEventListener(SHELL_BRIDGE_READY_EVENT, handleReady);
window.addEventListener(SHELL_PROFILE_CONTEXT_CHANGED_EVENT, handleProfileChange);
settleIfReady();
});
}
export function bindWindowWebRouter(router: AnyRouter) {
window.SoulSyncWebRouter = {
routeManifest: [...shellRouteManifest],
getCurrentPath() {
return normalizeShellPath(window.location.pathname);
},
resolvePageId(pathname: string) {
return resolveShellPageFromPath(pathname);
},
async navigateToPage(pageId, options) {
const route = getShellRouteByPageId(pageId);
if (!route) return false;
let href: `/${string}` = route.path;
if (pageId === 'artist-detail' && options?.artistId) {
const source = options.artistSource ? String(options.artistSource) : 'library';
href = `/artist-detail/${encodeURIComponent(source)}/${encodeURIComponent(String(options.artistId))}` as `/${string}`;
}
await router.navigate({ href, replace: options?.replace === true });
return true;
},
};
}
export type { ShellPageId, ShellRouteDefinition };