From 728481db31034ecb921bf5455439201c52a628b6 Mon Sep 17 00:00:00 2001 From: Antti Kettunen Date: Tue, 19 May 2026 08:13:13 +0300 Subject: [PATCH 1/7] refactor(webui): route artist-detail handoff - add canonical /artist-detail/:source/:id TanStack route - hand the legacy page off through the shell bridge - remove artist-detail branching from generic shell helpers --- webui/src/app/router.test.tsx | 1 + webui/src/platform/shell/bridge.test.ts | 9 +++ webui/src/platform/shell/bridge.ts | 3 + webui/src/platform/shell/globals.d.ts | 9 +++ .../src/platform/shell/route-manifest.test.ts | 3 +- webui/src/platform/shell/route-manifest.ts | 10 ++- webui/src/routeTree.gen.ts | 24 +++++- .../src/routes/artist-detail/$source/$id.tsx | 27 +++++++ .../src/routes/artist-detail/-route.test.tsx | 76 +++++++++++++++++++ webui/src/routes/issues/-route.test.tsx | 1 + webui/static/init.js | 43 +++-------- webui/static/library.js | 2 +- webui/static/search.js | 11 +-- webui/static/shell-bridge.js | 21 +---- 14 files changed, 172 insertions(+), 68 deletions(-) create mode 100644 webui/src/routes/artist-detail/$source/$id.tsx create mode 100644 webui/src/routes/artist-detail/-route.test.tsx diff --git a/webui/src/app/router.test.tsx b/webui/src/app/router.test.tsx index f347a5be..7f1921f9 100644 --- a/webui/src/app/router.test.tsx +++ b/webui/src/app/router.test.tsx @@ -60,6 +60,7 @@ function createShellBridge(overrides: Partial = {}): ShellBridge { resolveLegacyPath: vi.fn<(pathname: string) => ShellPageId | null>(() => 'search'), setActivePageChrome: vi.fn(), activateLegacyPath: vi.fn(), + navigateToArtistDetail: vi.fn(), showReactHost: vi.fn(), ...overrides, }; diff --git a/webui/src/platform/shell/bridge.test.ts b/webui/src/platform/shell/bridge.test.ts index 00dd88d4..a33f44cd 100644 --- a/webui/src/platform/shell/bridge.test.ts +++ b/webui/src/platform/shell/bridge.test.ts @@ -88,4 +88,13 @@ describe('bindWindowWebRouter', () => { replace: true, }); }); + + it('refuses artist detail navigation without an artist id', async () => { + const navigate = vi.fn().mockResolvedValue(undefined); + + bindWindowWebRouter({ navigate } as never); + + await expect(window.SoulSyncWebRouter?.navigateToPage('artist-detail', {} as never)).resolves.toBe(false); + expect(navigate).not.toHaveBeenCalled(); + }); }); diff --git a/webui/src/platform/shell/bridge.ts b/webui/src/platform/shell/bridge.ts index 1ca85768..1c9683f5 100644 --- a/webui/src/platform/shell/bridge.ts +++ b/webui/src/platform/shell/bridge.ts @@ -88,6 +88,9 @@ export function bindWindowWebRouter(router: AnyRouter) { async navigateToPage(pageId, options) { const route = getShellRouteByPageId(pageId); if (!route) return false; + if (pageId === 'artist-detail' && !options?.artistId) { + return false; + } let href: `/${string}` = route.path; if (pageId === 'artist-detail' && options?.artistId) { diff --git a/webui/src/platform/shell/globals.d.ts b/webui/src/platform/shell/globals.d.ts index 27de813c..241464d3 100644 --- a/webui/src/platform/shell/globals.d.ts +++ b/webui/src/platform/shell/globals.d.ts @@ -35,6 +35,15 @@ declare global { resolveLegacyPath: (pathname: string) => ShellPageId | null; setActivePageChrome: (pageId: ShellPageId) => void; activateLegacyPath: (pathname: string) => void; + navigateToArtistDetail: ( + artistId: string | number, + artistName: string, + sourceOverride?: string | null, + options?: { + skipOriginPush?: boolean; + skipRouteChange?: boolean; + }, + ) => void; showReactHost: (pageId: ShellPageId) => void; }; } diff --git a/webui/src/platform/shell/route-manifest.test.ts b/webui/src/platform/shell/route-manifest.test.ts index 4430dd11..369f7b3d 100644 --- a/webui/src/platform/shell/route-manifest.test.ts +++ b/webui/src/platform/shell/route-manifest.test.ts @@ -16,7 +16,7 @@ describe('shellRouteManifest', () => { expect(resolveShellPageFromPath('/discover')).toBe('discover'); expect(resolveShellPageFromPath('/watchlist')).toBe('watchlist'); expect(resolveShellPageFromPath('/active-downloads')).toBe('active-downloads'); - expect(resolveShellPageFromPath('/artist-detail')).toBe('artist-detail'); + expect(resolveShellPageFromPath('/artist-detail')).toBeNull(); expect(resolveShellPageFromPath('/artist-detail/spotify/2YZyLoL8N0Wb9xBt1NhZWg')).toBe('artist-detail'); expect(resolveShellPageFromPath('/artists')).toBeNull(); }); @@ -50,6 +50,7 @@ describe('shellRouteManifest', () => { expect(resolveLegacyShellPageFromPath('/search')).toBe('search'); expect(resolveLegacyShellPageFromPath('/active-downloads')).toBe('active-downloads'); expect(resolveLegacyShellPageFromPath('/tools')).toBe('tools'); + expect(resolveLegacyShellPageFromPath('/artist-detail')).toBeNull(); expect(resolveLegacyShellPageFromPath('/artist-detail/deezer/12345')).toBe('artist-detail'); expect(resolveLegacyShellPageFromPath('/issues')).toBeNull(); expect(resolveLegacyShellPageFromPath('/does-not-exist')).toBeNull(); diff --git a/webui/src/platform/shell/route-manifest.ts b/webui/src/platform/shell/route-manifest.ts index 6b1a209e..469d46d1 100644 --- a/webui/src/platform/shell/route-manifest.ts +++ b/webui/src/platform/shell/route-manifest.ts @@ -72,7 +72,10 @@ export function getShellRouteByPath(pathname: string): ShellRouteDefinition | un export function resolveShellPageFromPath(pathname: string): ShellPageId | null { const normalized = normalizeShellPath(pathname); - if (normalized === '/artist-detail' || normalized.startsWith('/artist-detail/')) { + if (normalized === '/artist-detail') { + return null; + } + if (normalized.startsWith('/artist-detail/')) { return 'artist-detail'; } return getShellRouteByPath(pathname)?.pageId ?? null; @@ -80,7 +83,10 @@ export function resolveShellPageFromPath(pathname: string): ShellPageId | null { export function resolveLegacyShellPageFromPath(pathname: string): ShellPageId | null { const normalized = normalizeShellPath(pathname); - if (normalized === '/artist-detail' || normalized.startsWith('/artist-detail/')) { + if (normalized === '/artist-detail') { + return null; + } + if (normalized.startsWith('/artist-detail/')) { return 'artist-detail'; } const route = getShellRouteByPath(pathname); diff --git a/webui/src/routeTree.gen.ts b/webui/src/routeTree.gen.ts index d90bb0ce..ca94e35b 100644 --- a/webui/src/routeTree.gen.ts +++ b/webui/src/routeTree.gen.ts @@ -12,6 +12,7 @@ import { Route as rootRouteImport } from './routes/__root' import { Route as SplatRouteImport } from './routes/$' import { Route as IssuesRouteRouteImport } from './routes/issues/route' import { Route as IndexRouteImport } from './routes/index' +import { Route as ArtistDetailSourceIdRouteImport } from './routes/artist-detail/$source/$id' const SplatRoute = SplatRouteImport.update({ id: '/$', @@ -28,35 +29,44 @@ const IndexRoute = IndexRouteImport.update({ path: '/', getParentRoute: () => rootRouteImport, } as any) +const ArtistDetailSourceIdRoute = ArtistDetailSourceIdRouteImport.update({ + id: '/artist-detail/$source/$id', + path: '/artist-detail/$source/$id', + getParentRoute: () => rootRouteImport, +} as any) export interface FileRoutesByFullPath { '/': typeof IndexRoute '/issues': typeof IssuesRouteRoute '/$': typeof SplatRoute + '/artist-detail/$source/$id': typeof ArtistDetailSourceIdRoute } export interface FileRoutesByTo { '/': typeof IndexRoute '/issues': typeof IssuesRouteRoute '/$': typeof SplatRoute + '/artist-detail/$source/$id': typeof ArtistDetailSourceIdRoute } export interface FileRoutesById { __root__: typeof rootRouteImport '/': typeof IndexRoute '/issues': typeof IssuesRouteRoute '/$': typeof SplatRoute + '/artist-detail/$source/$id': typeof ArtistDetailSourceIdRoute } export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath - fullPaths: '/' | '/issues' | '/$' + fullPaths: '/' | '/issues' | '/$' | '/artist-detail/$source/$id' fileRoutesByTo: FileRoutesByTo - to: '/' | '/issues' | '/$' - id: '__root__' | '/' | '/issues' | '/$' + to: '/' | '/issues' | '/$' | '/artist-detail/$source/$id' + id: '__root__' | '/' | '/issues' | '/$' | '/artist-detail/$source/$id' fileRoutesById: FileRoutesById } export interface RootRouteChildren { IndexRoute: typeof IndexRoute IssuesRouteRoute: typeof IssuesRouteRoute SplatRoute: typeof SplatRoute + ArtistDetailSourceIdRoute: typeof ArtistDetailSourceIdRoute } declare module '@tanstack/react-router' { @@ -82,6 +92,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof IndexRouteImport parentRoute: typeof rootRouteImport } + '/artist-detail/$source/$id': { + id: '/artist-detail/$source/$id' + path: '/artist-detail/$source/$id' + fullPath: '/artist-detail/$source/$id' + preLoaderRoute: typeof ArtistDetailSourceIdRouteImport + parentRoute: typeof rootRouteImport + } } } @@ -89,6 +106,7 @@ const rootRouteChildren: RootRouteChildren = { IndexRoute: IndexRoute, IssuesRouteRoute: IssuesRouteRoute, SplatRoute: SplatRoute, + ArtistDetailSourceIdRoute: ArtistDetailSourceIdRoute, } export const routeTree = rootRouteImport ._addFileChildren(rootRouteChildren) diff --git a/webui/src/routes/artist-detail/$source/$id.tsx b/webui/src/routes/artist-detail/$source/$id.tsx new file mode 100644 index 00000000..8ae2f2c9 --- /dev/null +++ b/webui/src/routes/artist-detail/$source/$id.tsx @@ -0,0 +1,27 @@ +import { createFileRoute } from '@tanstack/react-router'; +import { useLayoutEffect } from 'react'; + +import { useShellBridge } from '@/platform/shell/route-controllers'; + +export const Route = createFileRoute('/artist-detail/$source/$id')({ + component: ArtistDetailPage, +}); + +// Thin legacy handoff: TanStack owns the URL shape here, but the vanilla JS +// artist-detail page still renders the actual experience for now. +function ArtistDetailPage() { + const bridge = useShellBridge(); + const { source, id } = Route.useParams(); + + useLayoutEffect(() => { + if (!bridge) return; + + const normalizedSource = source.toLowerCase() === 'library' ? null : source.toLowerCase(); + bridge.navigateToArtistDetail(id, '', normalizedSource, { + skipOriginPush: true, + skipRouteChange: true, + }); + }, [bridge, id, source]); + + return null; +} diff --git a/webui/src/routes/artist-detail/-route.test.tsx b/webui/src/routes/artist-detail/-route.test.tsx new file mode 100644 index 00000000..0f96ac5c --- /dev/null +++ b/webui/src/routes/artist-detail/-route.test.tsx @@ -0,0 +1,76 @@ +import { createMemoryHistory } from '@tanstack/react-router'; +import { render, waitFor } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { ShellBridge, ShellPageId } from '@/platform/shell/bridge'; + +import { createAppQueryClient } from '@/app/query-client'; +import { AppRouterProvider, createAppRouter } from '@/app/router'; + +function createShellBridge(overrides: Partial = {}): ShellBridge { + return { + getCurrentProfileContext: vi.fn(() => ({ profileId: 2, isAdmin: false })), + isPageAllowed: vi.fn(() => true), + getProfileHomePage: vi.fn<() => ShellPageId>(() => 'discover'), + resolveLegacyPath: vi.fn<(pathname: string) => ShellPageId | null>(() => 'artist-detail'), + setActivePageChrome: vi.fn(), + activateLegacyPath: vi.fn(), + navigateToArtistDetail: vi.fn(), + showReactHost: vi.fn(), + ...overrides, + }; +} + +function renderArtistDetailRoute(initialEntries = ['/artist-detail/library/42']) { + const queryClient = createAppQueryClient(); + const history = createMemoryHistory({ initialEntries }); + const router = createAppRouter({ history, queryClient }); + + return { + history, + router, + ...render(), + }; +} + +describe('artist-detail route', () => { + beforeEach(() => { + window.SoulSyncWebShellBridge = createShellBridge(); + }); + + afterEach(() => { + window.SoulSyncWebShellBridge = undefined; + }); + + it('hands off canonical artist-detail URLs to the legacy shell', async () => { + renderArtistDetailRoute(['/artist-detail/spotify/2YZyLoL8N0Wb9xBt1NhZWg']); + + await waitFor(() => { + expect(window.SoulSyncWebShellBridge?.navigateToArtistDetail).toHaveBeenCalledWith( + '2YZyLoL8N0Wb9xBt1NhZWg', + '', + 'spotify', + { + skipOriginPush: true, + skipRouteChange: true, + }, + ); + }); + }); + + it('normalizes library sources before handing off', async () => { + renderArtistDetailRoute(['/artist-detail/library/42']); + + await waitFor(() => { + expect(window.SoulSyncWebShellBridge?.navigateToArtistDetail).toHaveBeenCalledWith( + '42', + '', + null, + { + skipOriginPush: true, + skipRouteChange: true, + }, + ); + }); + }); +}); diff --git a/webui/src/routes/issues/-route.test.tsx b/webui/src/routes/issues/-route.test.tsx index 402966e1..547745ef 100644 --- a/webui/src/routes/issues/-route.test.tsx +++ b/webui/src/routes/issues/-route.test.tsx @@ -22,6 +22,7 @@ function createShellBridge(overrides: Partial = {}): ShellBridge { resolveLegacyPath: vi.fn<(pathname: string) => ShellPageId | null>(() => 'search'), setActivePageChrome: vi.fn(), activateLegacyPath: vi.fn(), + navigateToArtistDetail: vi.fn(), showReactHost: vi.fn(), ...overrides, }; diff --git a/webui/static/init.js b/webui/static/init.js index c874f6fc..f00e5852 100644 --- a/webui/static/init.js +++ b/webui/static/init.js @@ -2154,10 +2154,6 @@ function _getPageFromPath() { const basePage = segs[0]; if (!_DEEPLINK_VALID_PAGES.has(basePage)) return 'dashboard'; // Context-dependent pages fall back to a sensible parent - if (basePage === 'artist-detail') { - // /artist-detail/:id deep-link โ€” keep on artist-detail; bare /artist-detail falls back to library - return (segs.length >= 2 && segs[1]) ? 'artist-detail' : 'library'; - } if (basePage === 'playlist-explorer') return 'library'; return basePage; } @@ -2168,35 +2164,13 @@ function _normalizeArtistDetailSource(source) { } function buildArtistDetailPath(artistId, source = null) { - if (!artistId) return '/artist-detail'; + if (!artistId) { + throw new Error('artistId is required for artist-detail navigation'); + } const normalizedSource = _normalizeArtistDetailSource(source); return '/artist-detail/' + encodeURIComponent(normalizedSource) + '/' + encodeURIComponent(String(artistId)); } -/** Extract artist source + ID from /artist-detail/:source/:id or legacy /artist-detail/:id URLs. */ -function _getDeepLinkArtistDetail(pathname = window.location.pathname) { - const path = (pathname || '').replace(/^\/+|\/+$/g, ''); - const segs = path.split('/'); - if (segs[0] !== 'artist-detail' || !segs[1]) return null; - - if (segs[2]) { - return { - source: _normalizeArtistDetailSource(decodeURIComponent(segs[1])), - artistId: decodeURIComponent(segs.slice(2).join('/')), - }; - } - - return { - source: 'library', - artistId: decodeURIComponent(segs[1]), - }; -} - -/** Legacy convenience wrapper for callers that only need the artist ID. */ -function _getDeepLinkArtistId() { - return _getDeepLinkArtistDetail()?.artistId || null; -} - // =============================== // MOBILE NAVIGATION // =============================== @@ -2310,6 +2284,10 @@ function navigateToPage(pageId, options = {}) { return; } + if (pageId === 'artist-detail' && !options.artistId) { + return false; + } + const router = getWebRouter(); if (router && !options.skipRouteChange) { notifyPageWillChange(pageId); @@ -2386,7 +2364,10 @@ async function loadPageData(pageId) { case 'library': // Check if we should return to artist detail view instead of list if (artistDetailPageState.currentArtistId && artistDetailPageState.currentArtistName) { - navigateToPage('artist-detail'); + navigateToPage('artist-detail', { + artistId: artistDetailPageState.currentArtistId, + artistSource: artistDetailPageState.currentArtistSource, + }); if (!artistDetailPageState.isInitialized) { initializeArtistDetailPage(); loadArtistDetailData(artistDetailPageState.currentArtistId, artistDetailPageState.currentArtistName); @@ -2400,7 +2381,7 @@ async function loadPageData(pageId) { } break; case 'artist-detail': - // Artist detail page is handled separately by navigateToArtistDetail() + // Artist detail page is entered through the route handoff and legacy navigator. break; case 'discover': if (!discoverPageInitialized) { diff --git a/webui/static/library.js b/webui/static/library.js index 19818b16..dfdc5fac 100644 --- a/webui/static/library.js +++ b/webui/static/library.js @@ -815,7 +815,7 @@ function navigateToArtistDetail(artistId, artistName, sourceOverride = null, opt navigateToPage('artist-detail', { artistId, artistSource: normalizedSource, - skipRouteChange: true + skipRouteChange: options.skipRouteChange === true }); _updateArtistDetailBackButtonLabel(); } diff --git a/webui/static/search.js b/webui/static/search.js index fd6d2604..682d48f0 100644 --- a/webui/static/search.js +++ b/webui/static/search.js @@ -1197,16 +1197,7 @@ async function loadInitialData() { return; } - if (targetPage === 'artist-detail') { - const deepArtist = _getDeepLinkArtistDetail(); - if (deepArtist?.artistId && typeof navigateToArtistDetail === 'function') { - navigateToArtistDetail(deepArtist.artistId, '', deepArtist.source === 'library' ? null : deepArtist.source); - } else { - navigateToPage('library', { skipRouteChange: true, forceReload: true }); - } - } else { - navigateToPage(targetPage, { skipRouteChange: true, forceReload: true }); - } + navigateToPage(targetPage, { forceReload: true }); } catch (error) { console.error('Error loading initial data:', error); } diff --git a/webui/static/shell-bridge.js b/webui/static/shell-bridge.js index 8d2ac58a..64c73f6e 100644 --- a/webui/static/shell-bridge.js +++ b/webui/static/shell-bridge.js @@ -80,16 +80,6 @@ function activateLegacyPath(pathname) { 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 }); } @@ -107,16 +97,6 @@ function syncActivePageFromLocation() { 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') { @@ -185,6 +165,7 @@ window.SoulSyncWebShellBridge = { activateLegacyPath(pathname) { activateLegacyPath(pathname); }, + navigateToArtistDetail, showReactHost(pageId) { showReactHost(pageId); }, From 5e39f1ee09375667358fee50f6ebef4954bce034 Mon Sep 17 00:00:00 2001 From: Antti Kettunen Date: Tue, 19 May 2026 09:26:10 +0300 Subject: [PATCH 2/7] refactor(webui): centralize artist-detail handoff - add a canonical TanStack route for artist-detail and keep the legacy page as the renderer target - expose page-level artist-detail navigation on the shell bridge for legacy callers - remove artist-detail-specific routing, origin stack, and back-label logic from the shared shell helpers --- webui/index.html | 2 +- webui/src/platform/shell/globals.d.ts | 9 +- .../src/routes/artist-detail/$source/$id.tsx | 1 - .../src/routes/artist-detail/-route.test.tsx | 2 - webui/static/api-monitor.js | 2 +- webui/static/discover.js | 13 +- webui/static/downloads.js | 5 +- webui/static/library.js | 123 ++++-------------- webui/static/media-player.js | 3 +- webui/static/search.js | 4 +- webui/static/shared-helpers.js | 2 +- webui/static/shell-bridge.js | 1 + webui/static/stats-automations.js | 8 +- 13 files changed, 52 insertions(+), 123 deletions(-) diff --git a/webui/index.html b/webui/index.html index c0c86dd8..5ddbcb2a 100644 --- a/webui/index.html +++ b/webui/index.html @@ -2418,7 +2418,7 @@
`; diff --git a/webui/static/downloads.js b/webui/static/downloads.js index 5e5cad38..678c1796 100644 --- a/webui/static/downloads.js +++ b/webui/static/downloads.js @@ -633,48 +633,6 @@ async function openDownloadMissingModalForYouTube(virtualPlaylistId, playlistNam hideLoadingOverlay(); } -function _navigateToArtistFromModal(artistId, artistName, imageUrl, source, playlistId) { - if (!artistName) return; - // Close the download modal - const process = playlistId ? activeDownloadProcesses[playlistId] : null; - const artistContext = process?.artist || {}; - const inferredSource = artistContext.spotify_artist_id ? 'spotify' - : artistContext.itunes_artist_id ? 'itunes' - : (artistContext.deezer_artist_id || artistContext.deezer_id) ? 'deezer' - : (artistContext.discogs_artist_id || artistContext.discogs_id) ? 'discogs' - : (artistContext.amazon_artist_id || artistContext.amazon_id) ? 'amazon' - : (artistContext.soul_id || artistContext.hydrabase_artist_id) ? 'hydrabase' - : null; - const resolvedSource = source || process?.artist?.source || process?.album?.source || process?.source || inferredSource; - const sourceKey = (resolvedSource || '').toString().toLowerCase(); - const sourceIdFields = { - spotify: ['spotify_artist_id', 'id', 'artist_id'], - itunes: ['itunes_artist_id', 'artist_id', 'id'], - deezer: ['deezer_artist_id', 'deezer_id', 'artist_id', 'id'], - discogs: ['discogs_artist_id', 'discogs_id', 'artist_id', 'id'], - amazon: ['amazon_artist_id', 'amazon_id', 'artist_id', 'id'], - hydrabase: ['soul_id', 'hydrabase_artist_id', 'artist_id', 'id'], - musicbrainz: ['musicbrainz_id', 'artist_id', 'id'], - }; - let resolvedArtistId = artistId; - for (const field of (sourceIdFields[sourceKey] || ['artist_id', 'id'])) { - const candidate = artistContext?.[field]; - if (candidate) { - resolvedArtistId = candidate; - break; - } - } - if (resolvedArtistId && String(resolvedArtistId).toLowerCase() === String(artistName).toLowerCase()) { - resolvedArtistId = null; - } - if (playlistId) closeDownloadMissingModal(playlistId); - if (!resolvedArtistId || !resolvedSource) { - showToast(`Artist details are not available for ${artistName}`, 'warning'); - return; - } - navigateToArtistDetailPage(resolvedArtistId, artistName, resolvedSource); -} - async function closeDownloadMissingModal(playlistId) { const process = activeDownloadProcesses[playlistId]; if (!process) { @@ -5698,13 +5656,13 @@ function _gsRenderFromState(state) { if (dbArtists.length) { h += '
๐Ÿ“š In Your Library
'; - h += dbArtists.map(a => `
${a.image_url ? `` : '๐ŸŽค'}
${_escToast(a.name)}
Library
`).join(''); + h += dbArtists.map(a => `${a.image_url ? `
` : '
๐ŸŽค
'}
${_escToast(a.name)}
Library
`).join(''); h += '
'; } if (artists.length) { h += `
๐ŸŽค Artists ${srcLabel}
`; - h += artists.map(a => `
${a.image_url ? `` : '๐ŸŽค'}
${_escToast(a.name)}
`).join(''); + h += artists.map(a => `${a.image_url ? `
` : '
๐ŸŽค
'}
${_escToast(a.name)}
`).join(''); h += '
'; } @@ -5778,13 +5736,6 @@ async function _gsLazyLoadArtistImages() { } } -function _gsClickArtist(id, name, isLibrary) { - _gsDeactivate(); - const activeSource = _gsController && _gsController.state.activeSource; - const source = isLibrary ? null : (activeSource || null); - navigateToArtistDetailPage(id, name, source); -} - async function _gsClickAlbum(albumId, albumName, artistName, imageUrl, source) { _gsDeactivate(); // Same flow as handleEnhancedSearchAlbumClick โ€” fetch album, open download modal diff --git a/webui/static/library.js b/webui/static/library.js index 0ef9f1f2..f915d73a 100644 --- a/webui/static/library.js +++ b/webui/static/library.js @@ -218,6 +218,7 @@ function displayLibraryArtists(artists) { // Ignore clicks on badge icons (they open external links / toggle watchlist) const badge = e.target.closest('.source-card-icon'); if (badge) { + e.preventDefault(); e.stopPropagation(); const url = badge.dataset.url; if (url) { window.open(url, '_blank'); return; } @@ -233,10 +234,6 @@ function displayLibraryArtists(artists) { } return; } - const card = e.target.closest('.library-artist-card'); - if (card) { - navigateToArtistDetailPage(card.dataset.artistId, card.dataset.artistName); - } }; } @@ -299,14 +296,14 @@ function buildLibraryArtistCardHTML(artist, index) { // Track stats const trackStat = artist.track_count > 0 ? `${artist.track_count} track${artist.track_count !== 1 ? 's' : ''}` : ''; - return ``; + `; } function updateLibraryPagination(pagination) { @@ -780,27 +777,6 @@ if (typeof window !== 'undefined') { } -// Public navigation entrypoint for artist detail. Callers should use this so -// artist-detail navigations stay URL-driven, while the renderer handoff below -// remains the legacy implementation detail. -function navigateToArtistDetailPage(artistId, artistName, sourceOverride = null, options = {}) { - const normalizedSource = sourceOverride || null; - - if (!artistId) return false; - if (currentPage === 'artist-detail' && - String(artistId) === String(artistDetailPageState.currentArtistId) && - String(normalizedSource || '') === String(artistDetailPageState.currentArtistSource || '')) { - return true; - } - - return navigateToPage('artist-detail', { - artistId, - artistSource: normalizedSource, - forceReload: true, - replace: options.replace === true, - }); -} - function navigateToArtistDetail(artistId, artistName, sourceOverride = null, options = {}) { const normalizedSource = sourceOverride || null; diff --git a/webui/static/media-player.js b/webui/static/media-player.js index a6889aa4..b300de5a 100644 --- a/webui/static/media-player.js +++ b/webui/static/media-player.js @@ -107,6 +107,21 @@ function setTrackInfo(track) { document.getElementById('no-track-message').classList.add('hidden'); document.getElementById('media-player').classList.remove('idle'); + const gotoArtistBtn = document.getElementById('np-goto-artist'); + if (gotoArtistBtn) { + if (track.artist_id) { + gotoArtistBtn.href = buildArtistDetailPath(track.artist_id, track.artist_source || null); + gotoArtistBtn.style.pointerEvents = ''; + gotoArtistBtn.setAttribute('aria-disabled', 'false'); + gotoArtistBtn.tabIndex = 0; + } else { + gotoArtistBtn.href = '#'; + gotoArtistBtn.style.pointerEvents = 'none'; + gotoArtistBtn.setAttribute('aria-disabled', 'true'); + gotoArtistBtn.tabIndex = -1; + } + } + // Sync expanded player and media session updateNpTrackInfo(); updateMediaSessionMetadata(); @@ -184,6 +199,14 @@ function clearTrack() { document.getElementById('no-track-message').classList.remove('hidden'); document.getElementById('media-player').classList.add('idle'); + const gotoArtistBtn = document.getElementById('np-goto-artist'); + if (gotoArtistBtn) { + gotoArtistBtn.href = '#'; + gotoArtistBtn.style.pointerEvents = 'none'; + gotoArtistBtn.setAttribute('aria-disabled', 'true'); + gotoArtistBtn.tabIndex = -1; + } + // Reset queue state npQueue = []; npQueueIndex = -1; @@ -1231,15 +1254,11 @@ function initExpandedPlayer() { }); } - // Action button (Go to Artist) + // Action link (Go to Artist) const gotoArtistBtn = document.getElementById('np-goto-artist'); if (gotoArtistBtn) { - gotoArtistBtn.addEventListener('click', () => { - if (currentTrack && currentTrack.artist_id) { - closeNowPlayingModal(); - navigateToArtistDetailPage(currentTrack.artist_id, currentTrack.artist || ''); - } - }); + gotoArtistBtn.style.textDecoration = 'none'; + gotoArtistBtn.style.color = 'inherit'; } // Buffering state listeners on audioPlayer if (audioPlayer) { diff --git a/webui/static/search.js b/webui/static/search.js index 8cedc3c1..4974fad9 100644 --- a/webui/static/search.js +++ b/webui/static/search.js @@ -317,11 +317,7 @@ function initializeSearchModeToggle() { name: artist.name, meta: 'In Your Library', badge: { text: 'Library', class: 'enh-badge-library' }, - onClick: () => { - console.log(`๐ŸŽต Opening library artist detail: ${artist.name} (ID: ${artist.id})`); - hideDropdown(); - navigateToArtistDetailPage(artist.id, artist.name); - } + href: buildArtistDetailPath(artist.id), }) ); @@ -337,12 +333,7 @@ function initializeSearchModeToggle() { name: artist.name, meta: 'Artist', badge: sourceBadge, - onClick: () => { - const sourceOverride = searchController.state.activeSource; - console.log(`๐ŸŽต Opening artist detail: ${artist.name} (ID: ${artist.id}, source: ${sourceOverride})`); - hideDropdown(); - navigateToArtistDetailPage(artist.id, artist.name, sourceOverride || null); - } + href: buildArtistDetailPath(artist.id, searchController.state.activeSource || null), }) ); diff --git a/webui/static/shared-helpers.js b/webui/static/shared-helpers.js index bbc9ca0c..26745e78 100644 --- a/webui/static/shared-helpers.js +++ b/webui/static/shared-helpers.js @@ -545,7 +545,8 @@ function renderCompactSection(sectionId, listId, countId, items, mapItem) { items.forEach(item => { const config = mapItem(item); - const elem = document.createElement('div'); + const isLink = isArtist && !!config.href; + const elem = document.createElement(isLink ? 'a' : 'div'); // Add appropriate card class if (isArtist) { @@ -566,6 +567,13 @@ function renderCompactSection(sectionId, listId, countId, items, mapItem) { elem.className = 'enh-compact-item track-item'; } + if (isLink) { + elem.href = config.href; + elem.style.color = 'inherit'; + elem.style.textDecoration = 'none'; + elem.setAttribute('aria-label', config.name || 'Artist'); + } + // Build image HTML with type-specific classes let imageClass = 'enh-item-image'; let placeholderClass = 'enh-item-image-placeholder'; @@ -612,7 +620,9 @@ function renderCompactSection(sectionId, listId, countId, items, mapItem) { ${badgeHtml} `; - elem.addEventListener('click', config.onClick); + if (config.onClick) { + elem.addEventListener('click', config.onClick); + } // Add play button handler for tracks if (isTrack && config.onPlay) { @@ -3823,8 +3833,12 @@ function displaySimilarArtists(artists) { */ function createSimilarArtistBubble(artist) { // Create bubble container - const bubble = document.createElement('div'); + const bubble = document.createElement('a'); bubble.className = 'similar-artist-bubble'; + bubble.href = buildArtistDetailPath(artist.id, artist.source || null); + bubble.style.color = 'inherit'; + bubble.style.textDecoration = 'none'; + bubble.setAttribute('aria-label', artist.name); bubble.setAttribute('data-artist-id', artist.id); bubble.setAttribute('data-artist-source', artist.source || ''); if (artist.plugin) { @@ -3877,13 +3891,6 @@ function createSimilarArtistBubble(artist) { bubble.appendChild(genres); } - // Click โ†’ navigate to the standalone artist-detail page. Works for both - // library and source artists thanks to the source-aware backend endpoint. - bubble.addEventListener('click', () => { - console.log(`๐ŸŽต Clicked similar artist: ${artist.name} (ID: ${artist.id})`); - navigateToArtistDetailPage(artist.id, artist.name, artist.source || null); - }); - return bubble; } diff --git a/webui/static/shell-bridge.js b/webui/static/shell-bridge.js index 16167032..66127869 100644 --- a/webui/static/shell-bridge.js +++ b/webui/static/shell-bridge.js @@ -165,7 +165,6 @@ window.SoulSyncWebShellBridge = { activateLegacyPath(pathname) { activateLegacyPath(pathname); }, - navigateToArtistDetailPage, navigateToArtistDetail, cancelSimilarArtistsLoad() { if (typeof cancelSimilarArtistsLoad === 'function') { @@ -177,5 +176,47 @@ window.SoulSyncWebShellBridge = { }, }; +function _handleShellLinkClick(event) { + if (event.defaultPrevented || event.button !== 0 || _isModifiedLinkClick(event)) return; + + const anchor = event.target?.closest?.('a[href]'); + if (!anchor || (anchor.target && anchor.target !== '_self')) return; + + const href = anchor.getAttribute('href'); + if (!href || href === '#' || href.startsWith('javascript:')) return; + + const router = getWebRouter(); + if (!router?.navigateToPage) return; + + const pathname = anchor.pathname || new URL(anchor.href, window.location.href).pathname; + + if (pathname.startsWith('/artist-detail/')) { + _handleArtistDetailLinkClick(event, pathname, router); + return; + } +} + +function _handleArtistDetailLinkClick(event, pathname, router) { + const parts = pathname.split('/').filter(Boolean); + if (parts.length < 3) return; + + // Keep the semantic link, but hand the click back to TanStack so artist + // detail navigations stay in the SPA when the router is available. + const source = decodeURIComponent(parts[1] || ''); + const artistId = decodeURIComponent(parts.slice(2).join('/')); + if (!source || !artistId) return; + + event.preventDefault(); + void router.navigateToPage('artist-detail', { + artistId, + artistSource: source, + }); +} + +function _isModifiedLinkClick(event) { + return event.metaKey || event.ctrlKey || event.shiftKey || event.altKey; +} + window.addEventListener('popstate', syncActivePageFromLocation); +document.addEventListener('click', _handleShellLinkClick, true); window.dispatchEvent(new CustomEvent(SHELL_BRIDGE_READY_EVENT)); diff --git a/webui/static/stats-automations.js b/webui/static/stats-automations.js index 46304405..8e5e5271 100644 --- a/webui/static/stats-automations.js +++ b/webui/static/stats-automations.js @@ -162,7 +162,7 @@ async function loadStatsData() { ${i + 1} ${item.image_url ? `` : ''}
-
${item.id ? `${_esc(item.name)}` : _esc(item.name)}${item.soul_id && !String(item.soul_id).startsWith('soul_unnamed_') ? ' ' : ''}
+
${item.id ? `${_esc(item.name)}` : _esc(item.name)}${item.soul_id && !String(item.soul_id).startsWith('soul_unnamed_') ? ' ' : ''}
${item.global_listeners ? _fmt(item.global_listeners) + ' global listeners' : ''}
${_fmt(item.play_count)} plays @@ -176,7 +176,7 @@ async function loadStatsData() { ${item.image_url ? `` : ''}
${_esc(item.name)}
-
${item.artist_id ? `${_esc(item.artist || '')}` : _esc(item.artist || '')}
+
${item.artist_id ? `${_esc(item.artist || '')}` : _esc(item.artist || '')}
${_fmt(item.play_count)} plays
@@ -189,7 +189,7 @@ async function loadStatsData() { ${item.image_url ? `` : ''}
${_esc(item.name)}
-
${item.artist_id ? `${_esc(item.artist || '')}` : _esc(item.artist || '')}${item.album ? ' ยท ' + _esc(item.album) : ''}
+
${item.artist_id ? `${_esc(item.artist || '')}` : _esc(item.artist || '')}${item.album ? ' ยท ' + _esc(item.album) : ''}
${_fmt(item.play_count)} plays @@ -231,7 +231,7 @@ function _renderTopArtistsVisual(artists) { ${top5.map((a, i) => { const pct = Math.round((a.play_count / maxPlays) * 100); const size = 44 + (4 - i) * 6; // Largest first: 68, 62, 56, 50, 44 - return `
${_esc(a.name)}
${_fmt(a.play_count)}
- `; +
`; }).join('')} `; } diff --git a/webui/static/style.css b/webui/static/style.css index a17d2abd..a6f7619c 100644 --- a/webui/static/style.css +++ b/webui/static/style.css @@ -16876,7 +16876,7 @@ body.helper-mode-active #dashboard-activity-feed:hover { margin-top: 32px; } -.watchlist-detail-actions button { +.watchlist-detail-actions > * { flex: 1; padding: 12px 20px; border-radius: 8px; @@ -16885,9 +16885,14 @@ body.helper-mode-active #dashboard-activity-feed:hover { font-weight: 600; cursor: pointer; transition: background 0.15s ease, transform 0.1s ease; + display: inline-flex; + align-items: center; + justify-content: center; + text-decoration: none; + box-sizing: border-box; } -.watchlist-detail-actions button:active { +.watchlist-detail-actions > *:active { transform: scale(0.98); } diff --git a/webui/static/sync-spotify.js b/webui/static/sync-spotify.js index e86fe83b..ad8ba041 100644 --- a/webui/static/sync-spotify.js +++ b/webui/static/sync-spotify.js @@ -1993,6 +1993,28 @@ function generateDownloadModalHeroSection(context) { const artistImage = artist?.image_url || artist?.images?.[0]?.url; const albumImage = album?.image_url || album?.images?.[0]?.url; const artistSource = artist?.source || album?.source || context.source || ''; + const sourceKey = (artistSource || '').toString().toLowerCase(); + const sourceIdFields = { + spotify: ['spotify_artist_id', 'id', 'artist_id'], + itunes: ['itunes_artist_id', 'artist_id', 'id'], + deezer: ['deezer_artist_id', 'deezer_id', 'artist_id', 'id'], + discogs: ['discogs_artist_id', 'discogs_id', 'artist_id', 'id'], + amazon: ['amazon_artist_id', 'amazon_id', 'artist_id', 'id'], + hydrabase: ['soul_id', 'hydrabase_artist_id', 'artist_id', 'id'], + musicbrainz: ['musicbrainz_id', 'artist_id', 'id'], + }; + let detailArtistId = artist?.id || artist?.artist_id || ''; + for (const field of (sourceIdFields[sourceKey] || ['artist_id', 'id'])) { + const candidate = artist?.[field]; + if (candidate) { + detailArtistId = candidate; + break; + } + } + if (detailArtistId && String(detailArtistId).toLowerCase() === String(artist?.name || '').toLowerCase()) { + detailArtistId = ''; + } + const artistHref = detailArtistId ? buildArtistDetailPath(detailArtistId, artistSource || null) : '#'; // Use album image as background if available if (albumImage) { @@ -2007,7 +2029,7 @@ function generateDownloadModalHeroSection(context) {