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
104 lines
3.1 KiB
TypeScript
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 };
|