diff --git a/webui/index.html b/webui/index.html index c0c86dd8..4bcfba98 100644 --- a/webui/index.html +++ b/webui/index.html @@ -2418,7 +2418,7 @@
- +
- +
diff --git a/webui/src/app/router.test.tsx b/webui/src/app/router.test.tsx index f347a5be..63295b4b 100644 --- a/webui/src/app/router.test.tsx +++ b/webui/src/app/router.test.tsx @@ -60,6 +60,8 @@ function createShellBridge(overrides: Partial = {}): ShellBridge { resolveLegacyPath: vi.fn<(pathname: string) => ShellPageId | null>(() => 'search'), setActivePageChrome: vi.fn(), activateLegacyPath: vi.fn(), + navigateToArtistDetail: vi.fn(), + cancelSimilarArtistsLoad: 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..9682bbe7 100644 --- a/webui/src/platform/shell/bridge.test.ts +++ b/webui/src/platform/shell/bridge.test.ts @@ -17,6 +17,7 @@ describe('waitForShellContext', () => { getCurrentProfileContext: vi.fn(() => ({ profileId: 2, isAdmin: true })), resolveLegacyPath: vi.fn(() => 'issues'), setActivePageChrome: vi.fn(), + cancelSimilarArtistsLoad: vi.fn(), showReactHost: vi.fn(), } as NonNullable; @@ -38,6 +39,7 @@ describe('waitForShellContext', () => { getCurrentProfileContext, resolveLegacyPath: vi.fn(() => 'issues'), setActivePageChrome: vi.fn(), + cancelSimilarArtistsLoad: vi.fn(), showReactHost: vi.fn(), } as NonNullable; @@ -88,4 +90,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..65d5b471 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?: { + skipRouteChange?: boolean; + }, + ) => void; + cancelSimilarArtistsLoad: () => 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..6987c00e --- /dev/null +++ b/webui/src/routes/artist-detail/$source/$id.tsx @@ -0,0 +1,31 @@ +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. The route +// owns cancellation so similar-artist loading stops when this page changes. +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, { + skipRouteChange: true, + }); + + return () => { + bridge.cancelSimilarArtistsLoad(); + }; + }, [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..232ff944 --- /dev/null +++ b/webui/src/routes/artist-detail/-route.test.tsx @@ -0,0 +1,99 @@ +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(), + cancelSimilarArtistsLoad: 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', + { + skipRouteChange: true, + }, + ); + }); + }); + + it('normalizes library sources before handing off', async () => { + renderArtistDetailRoute(['/artist-detail/library/42']); + + await waitFor(() => { + expect(window.SoulSyncWebShellBridge?.navigateToArtistDetail).toHaveBeenCalledWith( + '42', + '', + null, + { + skipRouteChange: true, + }, + ); + }); + }); + + it('cancels the similar artists stream when the route unmounts', async () => { + const { unmount } = renderArtistDetailRoute(['/artist-detail/spotify/2YZyLoL8N0Wb9xBt1NhZWg']); + + await waitFor(() => { + expect(window.SoulSyncWebShellBridge?.navigateToArtistDetail).toHaveBeenCalledWith( + '2YZyLoL8N0Wb9xBt1NhZWg', + '', + 'spotify', + { + skipRouteChange: true, + }, + ); + }); + + const cancelSimilarArtistsLoad = window.SoulSyncWebShellBridge?.cancelSimilarArtistsLoad as ReturnType; + cancelSimilarArtistsLoad.mockClear(); + + unmount(); + + await waitFor(() => { + expect(cancelSimilarArtistsLoad).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/webui/src/routes/issues/-route.test.tsx b/webui/src/routes/issues/-route.test.tsx index 402966e1..7bbc0353 100644 --- a/webui/src/routes/issues/-route.test.tsx +++ b/webui/src/routes/issues/-route.test.tsx @@ -22,6 +22,8 @@ function createShellBridge(overrides: Partial = {}): ShellBridge { resolveLegacyPath: vi.fn<(pathname: string) => ShellPageId | null>(() => 'search'), setActivePageChrome: vi.fn(), activateLegacyPath: vi.fn(), + navigateToArtistDetail: vi.fn(), + cancelSimilarArtistsLoad: vi.fn(), showReactHost: vi.fn(), ...overrides, }; diff --git a/webui/static/api-monitor.js b/webui/static/api-monitor.js index 4b0fc398..d53d0667 100644 --- a/webui/static/api-monitor.js +++ b/webui/static/api-monitor.js @@ -2316,6 +2316,25 @@ async function openWatchlistArtistDetailView(artistId, artistName) { if (artist.mood) metaTags.push(`${escapeHtml(artist.mood)}`); if (artist.label) metaTags.push(`${escapeHtml(artist.label)}`); + let discogId = null; + let discogSource = null; + const activeSrc = (currentMusicSourceName || '').toLowerCase(); + if (activeSrc.includes('spotify') && spotify_artist_id) { + discogId = spotify_artist_id; discogSource = 'spotify'; + } else if (activeSrc.includes('discogs') && discogs_artist_id) { + discogId = discogs_artist_id; discogSource = 'discogs'; + } else if (activeSrc.includes('deezer') && deezer_artist_id) { + discogId = deezer_artist_id; discogSource = 'deezer'; + } else if (activeSrc.includes('musicbrainz') && musicbrainz_artist_id) { + discogId = musicbrainz_artist_id; discogSource = 'musicbrainz'; + } else if (itunes_artist_id) { + discogId = itunes_artist_id; discogSource = 'itunes'; + } else { + discogId = spotify_artist_id || discogs_artist_id || deezer_artist_id || musicbrainz_artist_id || itunes_artist_id; + discogSource = spotify_artist_id ? 'spotify' : discogs_artist_id ? 'discogs' : deezer_artist_id ? 'deezer' : musicbrainz_artist_id ? 'musicbrainz' : 'itunes'; + } + const discogHref = discogId ? buildArtistDetailPath(discogId, discogSource) : '#'; + overlay.innerHTML = ` ${artist.banner_url ? `
@@ -2398,7 +2417,7 @@ async function openWatchlistArtistDetailView(artistId, artistName) {
- + View Discography
@@ -2410,30 +2429,6 @@ async function openWatchlistArtistDetailView(artistId, artistName) { closeWatchlistArtistDetailView(); }); - overlay.querySelector('.watchlist-detail-discog-action').addEventListener('click', () => { - // Use the ID matching the active metadata source - let discogId, source; - const activeSrc = (currentMusicSourceName || '').toLowerCase(); - if (activeSrc.includes('spotify') && spotify_artist_id) { - discogId = spotify_artist_id; source = 'spotify'; - } else if (activeSrc.includes('discogs') && discogs_artist_id) { - discogId = discogs_artist_id; source = 'discogs'; - } else if (activeSrc.includes('deezer') && deezer_artist_id) { - discogId = deezer_artist_id; source = 'deezer'; - } else if (activeSrc.includes('musicbrainz') && musicbrainz_artist_id) { - discogId = musicbrainz_artist_id; source = 'musicbrainz'; - } else if (itunes_artist_id) { - discogId = itunes_artist_id; source = 'itunes'; - } else { - discogId = spotify_artist_id || discogs_artist_id || deezer_artist_id || musicbrainz_artist_id || itunes_artist_id; - source = spotify_artist_id ? 'spotify' : discogs_artist_id ? 'discogs' : deezer_artist_id ? 'deezer' : musicbrainz_artist_id ? 'musicbrainz' : 'itunes'; - } - if (discogId) { - closeWatchlistArtistDetailView(); - navigateToArtistDetail(discogId, artistName, source); - } - }); - overlay.querySelector('.watchlist-detail-settings-action').addEventListener('click', () => { // Remove overlay immediately so it doesn't block the config modal const detailOverlay = document.querySelector('.watchlist-artist-detail-overlay'); diff --git a/webui/static/core.js b/webui/static/core.js index 426db439..f1b047f6 100644 --- a/webui/static/core.js +++ b/webui/static/core.js @@ -201,6 +201,13 @@ let artistsSearchController = null; let artistCompletionController = null; // Track ongoing completion check to cancel when navigating away let similarArtistsController = null; // Track ongoing similar artists stream to cancel when navigating away +function cancelSimilarArtistsLoad() { + if (similarArtistsController) { + similarArtistsController.abort(); + similarArtistsController = null; + } +} + // --- Lazy Background Image Observer --- // Watches elements with data-bg-src, applies background-image when visible, unobserves after. const lazyBgObserver = new IntersectionObserver((entries) => { diff --git a/webui/static/discover.js b/webui/static/discover.js index 99e7124c..e1d05c12 100644 --- a/webui/static/discover.js +++ b/webui/static/discover.js @@ -269,10 +269,9 @@ function displayDiscoverHeroArtist(artist) { if (discographyBtn && artistId) { discographyBtn.setAttribute('data-artist-id', artistId); discographyBtn.setAttribute('data-artist-name', artist.artist_name); - // Source the click handler will pass to navigateToArtistDetail. Without - // this, source-only hero artists (which is the typical case โ€” they - // come from discover similar-artists, not the library) get looked up - // as library IDs and 404. Backend always includes artist.source. + discographyBtn.href = buildArtistDetailPath(artistId, artist.source || null); + // Keep the source on the link so source-only hero artists resolve to + // the correct artist-detail URL instead of being treated as library IDs. if (artist.source) discographyBtn.setAttribute('data-source', artist.source); else discographyBtn.removeAttribute('data-source'); // Also store both IDs for cross-source operations @@ -557,7 +556,7 @@ function renderRecommendedArtistsModal(modal, artists, source = null) { const similarText = artist.occurrence_count > 1 ? `Similar to ${artist.occurrence_count} in your watchlist` : 'Similar to an artist in your watchlist'; - const artistSource = artist.source || source || _recommendedArtistsSource || ''; + const artistSource = artist.source || source || _recommendedArtistsSource || ''; return ` `; }).join('')} @@ -599,16 +602,6 @@ function renderRecommendedArtistsModal(modal, artists, source = null) { if (watchlistBtn) { e.stopPropagation(); toggleRecommendedWatchlist(watchlistBtn); - return; - } - - const card = e.target.closest('.recommended-artist-card'); - if (card) { - const artistId = card.getAttribute('data-artist-id'); - const artistSource = card.getAttribute('data-artist-source') || _recommendedArtistsSource || null; - const nameEl = card.querySelector('.recommended-card-name'); - const artistName = nameEl ? nameEl.textContent : ''; - viewRecommendedArtistDiscography(artistId, artistName, artistSource); } }); } @@ -739,11 +732,6 @@ async function checkRecommendedWatchlistStatuses(artists) { } } -async function viewRecommendedArtistDiscography(artistId, artistName, source = null) { - closeRecommendedArtistsModal(); - navigateToArtistDetail(artistId, artistName, source || null); -} - async function checkAllHeroWatchlistStatus() { const btn = document.getElementById('discover-hero-watch-all'); if (!btn || !discoverHeroArtists || discoverHeroArtists.length === 0) return; @@ -811,26 +799,6 @@ function jumpToDiscoverHeroSlide(index) { updateDiscoverHeroIndicators(); } -async function viewDiscoverHeroDiscography() { - const button = document.getElementById('discover-hero-discography'); - if (!button) return; - - const artistId = button.getAttribute('data-artist-id'); - const artistName = button.getAttribute('data-artist-name'); - // Pass the source so /api/artist-detail knows to synthesize from that - // metadata provider instead of doing a local DB lookup. Hero similar - // artists are almost always source-only (not in the library). - const source = button.getAttribute('data-source') || null; - - if (!artistId || !artistName) { - console.error('No artist data found for discography view'); - return; - } - - console.log(`๐ŸŽต Navigating to artist detail for: ${artistName} (source: ${source || 'library'})`); - navigateToArtistDetail(artistId, artistName, source); -} - function showDiscoverHeroEmpty() { const titleEl = document.getElementById('discover-hero-title'); const subtitleEl = document.getElementById('discover-hero-subtitle'); @@ -4514,10 +4482,7 @@ function _renderYourArtistCard(artist) { const detailSource = _pickArtistDetailSource(artist); const hasId = detailSource.id && detailSource.id !== ''; - // Navigate to Artists page (name click) โ€” source artist id, needs inline view - const navAction = hasId - ? `event.stopPropagation(); navigateToArtistDetail('${escapeForInlineJs(detailSource.id)}', '${escapeForInlineJs(artist.artist_name)}', '${escapeForInlineJs(detailSource.source)}' || null)` - : ''; + const detailHref = hasId ? buildArtistDetailPath(detailSource.id, detailSource.source) : ''; // Open info modal (card body click) โ€” pass pool ID so we can look up all data const infoAction = hasId @@ -4545,7 +4510,9 @@ function _renderYourArtistCard(artist) {
${originDots}
-
${_esc(artist.artist_name)}
+ ${hasId + ? `${_esc(artist.artist_name)}` + : `
${_esc(artist.artist_name)}
`} `; @@ -4695,10 +4662,10 @@ async function openYourArtistInfoModal(poolId) { Explore - + `; } } catch (err) { @@ -6595,9 +6562,9 @@ function _artMapSetupInteraction(canvas) {
Artist Info
- +
👁 ${node.type === 'watchlist' ? 'On Watchlist' : 'Add to Watchlist'}
@@ -7275,9 +7242,9 @@ async function openGenreDeepDive(genre) { // Always open on Artists page with discography โ€” pass source for correct routing const imgUrl = _esc(a.image_url || ''); const artSource = _esc(a.source || ''); - const clickAction = `onclick="document.getElementById('genre-deep-dive-modal').remove();navigateToArtistDetail('${_esc(a.entity_id)}','${_esc(a.name)}','${artSource}' || null)"`; + const detailHref = a.entity_id ? buildArtistDetailPath(a.entity_id, a.source || null) : '#'; const srcClass = (a.source || '').toLowerCase(); - return ``; + `; }).join('')} `; @@ -8768,4 +8735,3 @@ if (document.readyState === 'loading') { } // ============================================================================ - diff --git a/webui/static/downloads.js b/webui/static/downloads.js index 7374753d..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; - } - navigateToArtistDetail(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); - navigateToArtistDetail(id, name, source); -} - async function _gsClickAlbum(albumId, albumName, artistName, imageUrl, source) { _gsDeactivate(); // Same flow as handleEnhancedSearchAlbumClick โ€” fetch album, open download modal @@ -6400,4 +6351,3 @@ const additionalStyles = ` document.head.insertAdjacentHTML('beforeend', additionalStyles); // ============================================================================ - 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..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) { - navigateToArtistDetail(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) { @@ -688,11 +685,6 @@ let artistDetailPageState = { currentArtistId: null, currentArtistName: null, currentArtistSource: null, - // Stack of origins captured by navigateToArtistDetail for the back button. - // Each entry is either {type:'page', pageId} or {type:'artist', id, name, source} - // so chained navigation (Search โ†’ A โ†’ similar B โ†’ similar C) walks back one - // step at a time instead of jumping straight to Search. - originStack: [], enhancedView: false, enhancedData: null, expandedAlbums: new Set(), @@ -710,7 +702,6 @@ function clearArtistDetailPageState() { artistDetailPageState.currentArtistId = null; artistDetailPageState.currentArtistName = null; artistDetailPageState.currentArtistSource = null; - artistDetailPageState.originStack = []; } if (typeof window !== 'undefined') { @@ -786,22 +777,6 @@ if (typeof window !== 'undefined') { } -// Friendly labels for the dynamic "โ† Back to X" button on the artist-detail page. -// Page id (the value of currentPage) -> button label. -const _ARTIST_DETAIL_BACK_LABELS = { - library: 'Back to Library', - search: 'Back to Search', - discover: 'Back to Discover', - watchlist: 'Back to Watchlist', - wishlist: 'Back to Wishlist', - stats: 'Back to Stats', - 'playlist-explorer': 'Back to Explorer', - automations: 'Back to Automations', - dashboard: 'Back to Dashboard', - sync: 'Back to Sync', - 'active-downloads': 'Back to Downloads', -}; - function navigateToArtistDetail(artistId, artistName, sourceOverride = null, options = {}) { const normalizedSource = sourceOverride || null; @@ -815,51 +790,13 @@ function navigateToArtistDetail(artistId, artistName, sourceOverride = null, opt navigateToPage('artist-detail', { artistId, artistSource: normalizedSource, - skipRouteChange: true + skipRouteChange: options.skipRouteChange === true }); - _updateArtistDetailBackButtonLabel(); } return; } console.log(`๐ŸŽต Navigating to artist detail: ${artistName} (ID: ${artistId}${sourceOverride ? `, source: ${sourceOverride}` : ''})`); - // Capture the current location on the origin stack BEFORE navigateToPage - // flips currentPage. The back button walks this stack one step at a time, - // so a chain like Search โ†’ A โ†’ similar B โ†’ similar C steps back through - // C โ†’ B โ†’ A โ†’ Search instead of jumping straight home. `skipOriginPush` - // lets the back button re-enter a prior artist without re-pushing. - if (!options.skipOriginPush) { - // Fresh entry (from a non-artist page) starts a new chain; any stale - // entries from a prior artist-detail visit are dropped. - if (currentPage !== 'artist-detail') { - artistDetailPageState.originStack = []; - } - - let entry; - if (currentPage === 'artist-detail' && artistDetailPageState.currentArtistId) { - entry = { - type: 'artist', - id: artistDetailPageState.currentArtistId, - name: artistDetailPageState.currentArtistName, - source: artistDetailPageState.currentArtistSource, - }; - } else { - const pageId = (typeof currentPage === 'string' && currentPage && currentPage !== 'artist-detail') - ? currentPage : 'library'; - entry = { type: 'page', pageId }; - } - - // Avoid pushing a duplicate top entry on repeated clicks of the same target. - const top = artistDetailPageState.originStack[artistDetailPageState.originStack.length - 1]; - const isDuplicate = top && top.type === entry.type && ( - (entry.type === 'page' && top.pageId === entry.pageId) || - (entry.type === 'artist' && String(top.id) === String(entry.id)) - ); - if (!isDuplicate) { - artistDetailPageState.originStack.push(entry); - } - } - // Abort any in-progress completion stream if (artistDetailPageState.completionController) { artistDetailPageState.completionController.abort(); @@ -909,9 +846,6 @@ function navigateToArtistDetail(artistId, artistName, sourceOverride = null, opt skipRouteChange: options.skipRouteChange === true }); - // Update back-button label to reflect where the next pop will land. - _updateArtistDetailBackButtonLabel(); - // Initialize if needed and load data if (!artistDetailPageState.isInitialized) { initializeArtistDetailPage(); @@ -921,27 +855,11 @@ function navigateToArtistDetail(artistId, artistName, sourceOverride = null, opt loadArtistDetailData(artistId, artistName); } -function _updateArtistDetailBackButtonLabel() { - const backBtnLabel = document.querySelector('#artist-detail-back-btn span'); - if (!backBtnLabel) return; - const stack = artistDetailPageState.originStack || []; - const top = stack[stack.length - 1]; - if (!top) { - backBtnLabel.textContent = `โ† ${_ARTIST_DETAIL_BACK_LABELS.library}`; - } else if (top.type === 'artist') { - backBtnLabel.textContent = `โ† Back to ${top.name}`; - } else { - const friendly = _ARTIST_DETAIL_BACK_LABELS[top.pageId] || _ARTIST_DETAIL_BACK_LABELS.library; - backBtnLabel.textContent = `โ† ${friendly}`; - } -} - function initializeArtistDetailPage() { console.log("๐Ÿ”ง Initializing Artist Detail page..."); - // Initialize back button โ€” pops the origin stack one step at a time so a - // chain like Search โ†’ A โ†’ B โ†’ C walks back through C โ†’ B โ†’ A โ†’ Search - // instead of jumping straight to the original entry page. + // Initialize back button โ€” use browser history when possible, with a + // simple library fallback if the user lands here without in-app history. const backBtn = document.getElementById("artist-detail-back-btn"); if (backBtn) { backBtn.addEventListener("click", () => { @@ -951,27 +869,12 @@ function initializeArtistDetailPage() { artistDetailPageState.completionController = null; } - const stack = artistDetailPageState.originStack || []; - if (stack.length > 0) { - const target = stack.pop(); - if (target.type === 'artist') { - // Re-enter a prior artist in the chain without re-pushing, - // so the stack keeps shrinking as the user steps back. - navigateToArtistDetail(target.id, target.name, target.source, { skipOriginPush: true }); - return; - } - // target.type === 'page' โ€” fully exit the artist-detail chain - artistDetailPageState.currentArtistId = null; - artistDetailPageState.currentArtistName = null; - artistDetailPageState.originStack = []; - navigateToPage(target.pageId); + if (window.history.length > 1) { + window.history.back(); return; } // No history โ€” default to library - artistDetailPageState.currentArtistId = null; - artistDetailPageState.currentArtistName = null; - artistDetailPageState.originStack = []; navigateToPage('library'); }); } @@ -1265,6 +1168,9 @@ function populateArtistDetailPage(data) { // MusicMap name lookup). Fire-and-forget โ€” the function handles its own // loading state and errors. if (artist && artist.name && typeof loadSimilarArtists === 'function') { + if (typeof cancelSimilarArtistsLoad === 'function') { + cancelSimilarArtistsLoad(); + } loadSimilarArtists(artist.name); } } diff --git a/webui/static/media-player.js b/webui/static/media-player.js index 85f25796..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(); - navigateToArtistDetail(currentTrack.artist_id, currentTrack.artist || ''); - } - }); + gotoArtistBtn.style.textDecoration = 'none'; + gotoArtistBtn.style.color = 'inherit'; } // Buffering state listeners on audioPlayer if (audioPlayer) { @@ -2396,4 +2415,3 @@ function updateMediaSessionPlaybackState() { } // =============================== - diff --git a/webui/static/search.js b/webui/static/search.js index fd6d2604..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(); - navigateToArtistDetail(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(); - navigateToArtistDetail(artist.id, artist.name, sourceOverride || null); - } + href: buildArtistDetailPath(artist.id, searchController.state.activeSource || null), }) ); @@ -1197,16 +1188,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/shared-helpers.js b/webui/static/shared-helpers.js index 7a509f6c..e5aeb0bc 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) { @@ -695,7 +705,7 @@ async function checkDiscographyCompletion(artistId, discography) { } catch (error) { // Don't show error if it was aborted (user navigated away) if (error.name === 'AbortError') { - console.log('โน๏ธ Completion check aborted (user navigated to new artist)'); + console.log('โน๏ธ Completion check aborted (user navigated away)'); return; } @@ -3574,16 +3584,21 @@ async function loadSimilarArtists(artistName) { container.innerHTML = ''; section.style.display = 'block'; + let controller = null; + let signal = null; + try { // Create new abort controller for this similar artists stream - similarArtistsController = new AbortController(); + controller = new AbortController(); + similarArtistsController = controller; + signal = controller.signal; // Use streaming endpoint for real-time bubble creation const url = `/api/artist/similar/${encodeURIComponent(artistName)}/stream`; console.log(`๐Ÿ“ก Streaming from: ${url}`); const response = await fetch(url, { - signal: similarArtistsController.signal + signal }); if (!response.ok) { @@ -3612,6 +3627,7 @@ async function loadSimilarArtists(artistName) { buffer = messages.pop() || ''; // Keep incomplete message in buffer for (const message of messages) { + if (signal?.aborted) return; if (!message.trim() || !message.startsWith('data: ')) continue; try { @@ -3622,6 +3638,7 @@ async function loadSimilarArtists(artistName) { } if (jsonData.artist) { + if (signal?.aborted) return; // Hide loading on first artist if (artistCount === 0) { loadingEl.classList.add('hidden'); @@ -3636,6 +3653,7 @@ async function loadSimilarArtists(artistName) { } if (jsonData.complete) { + if (signal?.aborted) return; console.log(`๐ŸŽ‰ Streaming complete: ${jsonData.total} artists`); if (artistCount === 0) { @@ -3648,7 +3666,7 @@ async function loadSimilarArtists(artistName) { `; } else { // Lazy load images for similar artists that don't have them - lazyLoadSimilarArtistImages(container); + await lazyLoadSimilarArtistImages(container, signal); } } } catch (parseError) { @@ -3658,13 +3676,14 @@ async function loadSimilarArtists(artistName) { } // Clear the controller when done - similarArtistsController = null; + if (similarArtistsController === controller) { + similarArtistsController = null; + } } catch (error) { // Don't show error if it was aborted (user navigated away) - if (error.name === 'AbortError') { - console.log('โน๏ธ Similar artists stream aborted (user navigated to new artist)'); - loadingEl.classList.add('hidden'); + if (error.name === 'AbortError' || signal?.aborted) { + console.log('โน๏ธ Similar artists stream aborted (user navigated away)'); return; } @@ -3683,15 +3702,18 @@ async function loadSimilarArtists(artistName) { `; } finally { // Always clear the controller - similarArtistsController = null; + if (similarArtistsController === controller) { + similarArtistsController = null; + } } } /** * Lazy load images for similar artist bubbles that don't have images */ -async function lazyLoadSimilarArtistImages(container) { +async function lazyLoadSimilarArtistImages(container, signal) { if (!container) return; + if (signal?.aborted) return; const bubblesNeedingImages = container.querySelectorAll('.similar-artist-bubble[data-needs-image="true"]'); @@ -3707,9 +3729,11 @@ async function lazyLoadSimilarArtistImages(container) { const bubbles = Array.from(bubblesNeedingImages); for (let i = 0; i < bubbles.length; i += batchSize) { + if (signal?.aborted) return; const batch = bubbles.slice(i, i + batchSize); await Promise.all(batch.map(async (bubble) => { + if (signal?.aborted) return; const artistId = bubble.getAttribute('data-artist-id'); const artistSource = bubble.getAttribute('data-artist-source') || ''; const artistPlugin = bubble.getAttribute('data-artist-plugin') || ''; @@ -3724,9 +3748,11 @@ async function lazyLoadSimilarArtistImages(container) { ? `/api/artist/${encodeURIComponent(artistId)}/image?${params.toString()}` : `/api/artist/${encodeURIComponent(artistId)}/image`; - const response = await fetch(imageUrl); + const response = await fetch(imageUrl, { signal }); const data = await response.json(); + if (signal?.aborted) return; + if (data.success && data.image_url) { const imageContainer = bubble.querySelector('.similar-artist-bubble-image'); if (imageContainer) { @@ -3737,6 +3763,9 @@ async function lazyLoadSimilarArtistImages(container) { } } } catch (error) { + if (error?.name === 'AbortError' || signal?.aborted) { + return; + } console.warn(`โš ๏ธ Failed to load image for similar artist ${artistId}:`, error); } })); @@ -3803,10 +3832,23 @@ function displaySimilarArtists(artists) { * Create a similar artist bubble card element */ function createSimilarArtistBubble(artist) { + const artistId = artist.id ? String(artist.id).trim() : ''; + const hasArtistDetail = !!artistId; + // Create bubble container - const bubble = document.createElement('div'); + const bubble = document.createElement(hasArtistDetail ? 'a' : 'div'); bubble.className = 'similar-artist-bubble'; - bubble.setAttribute('data-artist-id', artist.id); + if (hasArtistDetail) { + bubble.href = buildArtistDetailPath(artistId, artist.source || null); + } else { + bubble.setAttribute('aria-disabled', 'true'); + bubble.style.cursor = 'default'; + bubble.style.pointerEvents = 'none'; + } + bubble.style.color = 'inherit'; + bubble.style.textDecoration = 'none'; + bubble.setAttribute('aria-label', artist.name); + bubble.setAttribute('data-artist-id', artistId); bubble.setAttribute('data-artist-source', artist.source || ''); if (artist.plugin) { bubble.setAttribute('data-artist-plugin', artist.plugin); @@ -3858,13 +3900,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})`); - navigateToArtistDetail(artist.id, artist.name, artist.source || null); - }); - return bubble; } diff --git a/webui/static/shell-bridge.js b/webui/static/shell-bridge.js index 8d2ac58a..66127869 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,10 +165,58 @@ window.SoulSyncWebShellBridge = { activateLegacyPath(pathname) { activateLegacyPath(pathname); }, + navigateToArtistDetail, + cancelSimilarArtistsLoad() { + if (typeof cancelSimilarArtistsLoad === 'function') { + cancelSimilarArtistsLoad(); + } + }, showReactHost(pageId) { showReactHost(pageId); }, }; +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 265d71eb..100f4a55 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) {