soulsync/webui/static/shell-bridge.js
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

194 lines
6.5 KiB
JavaScript

// SoulSync shell bridge glue
// Keep this file loaded after init.js so the legacy shell runtime state is ready.
function getWebRouter() {
return window.SoulSyncWebRouter ?? null;
}
function showLegacyPage(pageId) {
document.querySelectorAll('.page').forEach(page => {
page.classList.remove('active');
});
const page = document.getElementById(`${pageId}-page`);
if (page) {
page.classList.add('active');
}
const reactHost = document.getElementById('webui-react-root');
if (reactHost) {
reactHost.classList.remove('active');
}
}
function setActivePageChrome(pageId) {
document.querySelectorAll('.nav-button').forEach(btn => {
btn.classList.remove('active');
});
const navButton = document.querySelector(`[data-page="${pageId}"]`);
if (navButton) {
navButton.classList.add('active');
} else if (pageId === 'artist-detail') {
// Artist detail is a Library context, so keep the sidebar anchored there.
const libraryBtn = document.querySelector('[data-page="library"]');
if (libraryBtn) {
libraryBtn.classList.add('active');
}
}
currentPage = pageId;
if (typeof _updateSidebarLibraryBreadcrumb === 'function') _updateSidebarLibraryBreadcrumb();
if (typeof _gsUpdateVisibility === 'function') _gsUpdateVisibility();
const downloadSidebar = document.getElementById('discover-download-sidebar');
if (downloadSidebar) {
if (pageId === 'discover') {
const activeDownloads = typeof discoverDownloads !== 'undefined'
? Object.keys(discoverDownloads).length
: 0;
if (activeDownloads > 0 && typeof updateDiscoverDownloadBar === 'function') {
updateDiscoverDownloadBar();
}
} else {
downloadSidebar.classList.add('hidden');
}
}
if (window.pageParticles && window._particlesEnabled !== false) window.pageParticles.setPage(pageId);
if (window.workerOrbs) window.workerOrbs.setPage(pageId);
}
function showReactHost(pageId) {
document.querySelectorAll('.page').forEach(page => {
page.classList.remove('active');
});
const host = document.getElementById('webui-react-root');
if (host) {
host.classList.add('active');
}
currentPage = pageId;
if (typeof _gsUpdateVisibility === 'function') _gsUpdateVisibility();
if (window.pageParticles && window._particlesEnabled !== false) window.pageParticles.setPage(pageId);
if (window.workerOrbs) window.workerOrbs.setPage(pageId);
}
function activateLegacyPath(pathname) {
const router = getWebRouter();
const targetPage = router?.resolvePageId?.(pathname) || _getPageFromPath(pathname);
if (!targetPage) return;
if (!isPageAllowed(targetPage)) {
const home = getProfileHomePage();
if (home !== targetPage) {
navigateToPage(home, { replace: true });
}
return;
}
if (targetPage === 'artist-detail') {
const deepArtist = _getDeepLinkArtistDetail(pathname);
if (deepArtist?.artistId && typeof navigateToArtistDetail === 'function') {
navigateToArtistDetail(deepArtist.artistId, '', deepArtist.source === 'library' ? null : deepArtist.source, { skipOriginPush: true, skipRouteChange: true });
return;
}
navigateToPage('library', { replace: true });
return;
}
notifyPageWillChange(targetPage);
activatePage(targetPage, { forceReload: true });
}
function syncActivePageFromLocation() {
const router = getWebRouter();
const targetPage = router?.resolvePageId?.(window.location.pathname) || _getPageFromPath(window.location.pathname);
if (!targetPage) return;
if (!isPageAllowed(targetPage)) {
const home = getProfileHomePage();
if (home !== targetPage) {
navigateToPage(home, { replace: true });
}
return;
}
if (targetPage === 'artist-detail') {
const deepArtist = _getDeepLinkArtistDetail();
if (deepArtist?.artistId && typeof navigateToArtistDetail === 'function') {
navigateToArtistDetail(deepArtist.artistId, '', deepArtist.source === 'library' ? null : deepArtist.source, { skipOriginPush: true, skipRouteChange: true });
return;
}
navigateToPage('library', { replace: true });
return;
}
notifyPageWillChange(targetPage);
const route = router?.routeManifest?.find((entry) => entry.pageId === targetPage);
if (route?.kind === 'react') {
showReactHost(targetPage);
} else {
showLegacyPage(targetPage);
}
setActivePageChrome(targetPage);
}
const SHELL_BRIDGE_READY_EVENT = 'ss:webui-shell-bridge-ready';
function openDownloadMissingAlbumWorkflow(input) {
if (typeof openDownloadMissingModalForArtistAlbum !== 'function') {
throw new Error('Download workflow host is not ready yet');
}
return openDownloadMissingModalForArtistAlbum(
input.virtualPlaylistId,
input.playlistName,
input.tracks,
input.album,
input.artist,
false,
);
}
function openAddToWishlistAlbumWorkflow(input) {
if (typeof openAddToWishlistModal !== 'function') {
throw new Error('Wishlist workflow host is not ready yet');
}
return openAddToWishlistModal(input.album, input.artist, input.tracks, input.albumType);
}
window.SoulSyncWorkflowActions = {
openDownloadMissingAlbum: openDownloadMissingAlbumWorkflow,
openAddToWishlistAlbum: openAddToWishlistAlbumWorkflow,
notify(message, type) {
if (typeof showToast === 'function') {
showToast(message, type);
}
},
};
window.SoulSyncWebShellBridge = {
getCurrentProfileContext() {
if (!currentProfile) return null;
return {
profileId: currentProfile.id,
isAdmin: !!currentProfile.is_admin,
};
},
isPageAllowed(pageId) {
return isPageAllowed(pageId);
},
getProfileHomePage() {
return getProfileHomePage();
},
resolveLegacyPath(pathname) {
return getWebRouter()?.resolvePageId?.(pathname) ?? null;
},
setActivePageChrome(pageId) {
setActivePageChrome(pageId);
},
activateLegacyPath(pathname) {
activateLegacyPath(pathname);
},
showReactHost(pageId) {
showReactHost(pageId);
},
};
window.addEventListener('popstate', syncActivePageFromLocation);
window.dispatchEvent(new CustomEvent(SHELL_BRIDGE_READY_EVENT));