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
This commit is contained in:
Antti Kettunen 2026-05-19 09:26:10 +03:00
parent 728481db31
commit 5e39f1ee09
No known key found for this signature in database
GPG key ID: C6B2A3D250359BD7
13 changed files with 52 additions and 123 deletions

View file

@ -2418,7 +2418,7 @@
<div class="page" id="artist-detail-page">
<div class="page-header">
<button class="back-btn" id="artist-detail-back-btn">
<span>← Back to Library</span>
<span>← Back</span>
</button>
<button class="library-artist-watchlist-btn" id="library-artist-watchlist-btn">

View file

@ -40,10 +40,17 @@ declare global {
artistName: string,
sourceOverride?: string | null,
options?: {
skipOriginPush?: boolean;
skipRouteChange?: boolean;
},
) => void;
navigateToArtistDetailPage: (
artistId: string | number,
artistName: string,
sourceOverride?: string | null,
options?: {
replace?: boolean;
},
) => void;
showReactHost: (pageId: ShellPageId) => void;
};
}

View file

@ -18,7 +18,6 @@ function ArtistDetailPage() {
const normalizedSource = source.toLowerCase() === 'library' ? null : source.toLowerCase();
bridge.navigateToArtistDetail(id, '', normalizedSource, {
skipOriginPush: true,
skipRouteChange: true,
});
}, [bridge, id, source]);

View file

@ -51,7 +51,6 @@ describe('artist-detail route', () => {
'',
'spotify',
{
skipOriginPush: true,
skipRouteChange: true,
},
);
@ -67,7 +66,6 @@ describe('artist-detail route', () => {
'',
null,
{
skipOriginPush: true,
skipRouteChange: true,
},
);

View file

@ -2430,7 +2430,7 @@ async function openWatchlistArtistDetailView(artistId, artistName) {
}
if (discogId) {
closeWatchlistArtistDetailView();
navigateToArtistDetail(discogId, artistName, source);
navigateToArtistDetailPage(discogId, artistName, source);
}
});

View file

@ -741,7 +741,7 @@ async function checkRecommendedWatchlistStatuses(artists) {
async function viewRecommendedArtistDiscography(artistId, artistName, source = null) {
closeRecommendedArtistsModal();
navigateToArtistDetail(artistId, artistName, source || null);
navigateToArtistDetailPage(artistId, artistName, source || null);
}
async function checkAllHeroWatchlistStatus() {
@ -828,7 +828,7 @@ async function viewDiscoverHeroDiscography() {
}
console.log(`🎵 Navigating to artist detail for: ${artistName} (source: ${source || 'library'})`);
navigateToArtistDetail(artistId, artistName, source);
navigateToArtistDetailPage(artistId, artistName, source);
}
function showDiscoverHeroEmpty() {
@ -4516,7 +4516,7 @@ function _renderYourArtistCard(artist) {
// 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)`
? `event.stopPropagation(); navigateToArtistDetailPage('${escapeForInlineJs(detailSource.id)}', '${escapeForInlineJs(artist.artist_name)}', '${escapeForInlineJs(detailSource.source)}' || null)`
: '';
// Open info modal (card body click) — pass pool ID so we can look up all data
@ -4695,7 +4695,7 @@ async function openYourArtistInfoModal(poolId) {
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
<span>Explore</span>
</button>
<button class="ya-header-btn ya-viewall-btn" onclick="document.getElementById('ya-info-modal-overlay')?.remove(); document.getElementById('your-artists-modal-overlay')?.remove(); navigateToArtistDetail('${escapeForInlineJs(artistId)}', '${escapeForInlineJs(artistName)}', '${escapeForInlineJs(pool.active_source || '')}' || null)">
<button class="ya-header-btn ya-viewall-btn" onclick="document.getElementById('ya-info-modal-overlay')?.remove(); document.getElementById('your-artists-modal-overlay')?.remove(); navigateToArtistDetailPage('${escapeForInlineJs(artistId)}', '${escapeForInlineJs(artistName)}', '${escapeForInlineJs(pool.active_source || '')}' || null)">
<span>View Discography</span>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M5 12h14"/><path d="M12 5l7 7-7 7"/></svg>
</button>
@ -6595,7 +6595,7 @@ function _artMapSetupInteraction(canvas) {
<div class="artmap-ctx-item" onclick="_artMapHideContextMenu(); ${hasId ? `openYourArtistInfoModal_direct(${JSON.stringify(node).replace(/"/g, '&quot;')})` : ''}">
<span>&#9432;</span> Artist Info
</div>
<div class="artmap-ctx-item" onclick="_artMapHideContextMenu(); navigateToArtistDetail('${escapeForInlineJs(bestId)}', '${escapeForInlineJs(node.name)}', '${bestSource}' || null)">
<div class="artmap-ctx-item" onclick="_artMapHideContextMenu(); navigateToArtistDetailPage('${escapeForInlineJs(bestId)}', '${escapeForInlineJs(node.name)}', '${bestSource}' || null)">
<span>&#128191;</span> View Discography
</div>
<div class="artmap-ctx-item" onclick="_artMapHideContextMenu(); toggleYourArtistWatchlist(0,'${escapeForInlineJs(node.name)}','${escapeForInlineJs(bestId)}','${bestSource}',null)">
@ -7275,7 +7275,7 @@ 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 clickAction = `onclick="document.getElementById('genre-deep-dive-modal').remove();navigateToArtistDetailPage('${_esc(a.entity_id)}','${_esc(a.name)}','${artSource}' || null)"`;
const srcClass = (a.source || '').toLowerCase();
return `<div class="genre-dive-artist" ${clickAction}>
<div class="genre-dive-artist-img" style="${a.image_url ? `background-image:url('${_esc(a.image_url)}')` : ''}">
@ -8768,4 +8768,3 @@ if (document.readyState === 'loading') {
}
// ============================================================================

View file

@ -672,7 +672,7 @@ function _navigateToArtistFromModal(artistId, artistName, imageUrl, source, play
showToast(`Artist details are not available for ${artistName}`, 'warning');
return;
}
navigateToArtistDetail(resolvedArtistId, artistName, resolvedSource);
navigateToArtistDetailPage(resolvedArtistId, artistName, resolvedSource);
}
async function closeDownloadMissingModal(playlistId) {
@ -5782,7 +5782,7 @@ function _gsClickArtist(id, name, isLibrary) {
_gsDeactivate();
const activeSource = _gsController && _gsController.state.activeSource;
const source = isLibrary ? null : (activeSource || null);
navigateToArtistDetail(id, name, source);
navigateToArtistDetailPage(id, name, source);
}
async function _gsClickAlbum(albumId, albumName, artistName, imageUrl, source) {
@ -6400,4 +6400,3 @@ const additionalStyles = `
document.head.insertAdjacentHTML('beforeend', additionalStyles);
// ============================================================================

View file

@ -235,7 +235,7 @@ function displayLibraryArtists(artists) {
}
const card = e.target.closest('.library-artist-card');
if (card) {
navigateToArtistDetail(card.dataset.artistId, card.dataset.artistName);
navigateToArtistDetailPage(card.dataset.artistId, card.dataset.artistName);
}
};
}
@ -688,11 +688,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 +705,6 @@ function clearArtistDetailPageState() {
artistDetailPageState.currentArtistId = null;
artistDetailPageState.currentArtistName = null;
artistDetailPageState.currentArtistSource = null;
artistDetailPageState.originStack = [];
}
if (typeof window !== 'undefined') {
@ -786,21 +780,26 @@ 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',
};
// 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;
@ -817,49 +816,11 @@ function navigateToArtistDetail(artistId, artistName, sourceOverride = null, opt
artistSource: normalizedSource,
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 +870,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 +879,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 +893,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');
});
}

View file

@ -1237,7 +1237,7 @@ function initExpandedPlayer() {
gotoArtistBtn.addEventListener('click', () => {
if (currentTrack && currentTrack.artist_id) {
closeNowPlayingModal();
navigateToArtistDetail(currentTrack.artist_id, currentTrack.artist || '');
navigateToArtistDetailPage(currentTrack.artist_id, currentTrack.artist || '');
}
});
}
@ -2396,4 +2396,3 @@ function updateMediaSessionPlaybackState() {
}
// ===============================

View file

@ -320,7 +320,7 @@ function initializeSearchModeToggle() {
onClick: () => {
console.log(`🎵 Opening library artist detail: ${artist.name} (ID: ${artist.id})`);
hideDropdown();
navigateToArtistDetail(artist.id, artist.name);
navigateToArtistDetailPage(artist.id, artist.name);
}
})
);
@ -341,7 +341,7 @@ function initializeSearchModeToggle() {
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);
navigateToArtistDetailPage(artist.id, artist.name, sourceOverride || null);
}
})
);

View file

@ -3862,7 +3862,7 @@ function createSimilarArtistBubble(artist) {
// 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);
navigateToArtistDetailPage(artist.id, artist.name, artist.source || null);
});
return bubble;

View file

@ -165,6 +165,7 @@ window.SoulSyncWebShellBridge = {
activateLegacyPath(pathname) {
activateLegacyPath(pathname);
},
navigateToArtistDetailPage,
navigateToArtistDetail,
showReactHost(pageId) {
showReactHost(pageId);

View file

@ -162,7 +162,7 @@ async function loadStatsData() {
<span class="stats-ranked-num">${i + 1}</span>
${item.image_url ? `<img class="stats-ranked-img" src="${item.image_url}" alt="" onerror="this.style.display='none'">` : ''}
<div class="stats-ranked-info">
<div class="stats-ranked-name">${item.id ? `<a class="stats-artist-link" onclick="navigateToPage('library');setTimeout(()=>navigateToArtistDetail('${item.id}','${_esc(item.name).replace(/'/g, "\\'")}'),300)">${_esc(item.name)}</a>` : _esc(item.name)}${item.soul_id && !String(item.soul_id).startsWith('soul_unnamed_') ? ' <img src="/static/trans2.png" style="width:12px;height:12px;vertical-align:middle;opacity:0.5;" title="SoulID">' : ''}</div>
<div class="stats-ranked-name">${item.id ? `<a class="stats-artist-link" onclick="navigateToArtistDetailPage('${item.id}','${_esc(item.name).replace(/'/g, "\\'")}')">${_esc(item.name)}</a>` : _esc(item.name)}${item.soul_id && !String(item.soul_id).startsWith('soul_unnamed_') ? ' <img src="/static/trans2.png" style="width:12px;height:12px;vertical-align:middle;opacity:0.5;" title="SoulID">' : ''}</div>
<div class="stats-ranked-meta">${item.global_listeners ? _fmt(item.global_listeners) + ' global listeners' : ''}</div>
</div>
<span class="stats-ranked-count">${_fmt(item.play_count)} plays</span>
@ -176,7 +176,7 @@ async function loadStatsData() {
${item.image_url ? `<img class="stats-ranked-img" src="${item.image_url}" alt="" onerror="this.style.display='none'">` : ''}
<div class="stats-ranked-info">
<div class="stats-ranked-name">${_esc(item.name)}</div>
<div class="stats-ranked-meta">${item.artist_id ? `<a class="stats-artist-link" onclick="navigateToPage('library');setTimeout(()=>navigateToArtistDetail('${item.artist_id}','${_esc(item.artist || '').replace(/'/g, "\\'")}'),300)">${_esc(item.artist || '')}</a>` : _esc(item.artist || '')}</div>
<div class="stats-ranked-meta">${item.artist_id ? `<a class="stats-artist-link" onclick="navigateToArtistDetailPage('${item.artist_id}','${_esc(item.artist || '').replace(/'/g, "\\'")}')">${_esc(item.artist || '')}</a>` : _esc(item.artist || '')}</div>
</div>
<span class="stats-ranked-count">${_fmt(item.play_count)} plays</span>
</div>
@ -189,7 +189,7 @@ async function loadStatsData() {
${item.image_url ? `<img class="stats-ranked-img" src="${item.image_url}" alt="" onerror="this.style.display='none'">` : ''}
<div class="stats-ranked-info">
<div class="stats-ranked-name">${_esc(item.name)}</div>
<div class="stats-ranked-meta">${item.artist_id ? `<a class="stats-artist-link" onclick="navigateToPage('library');setTimeout(()=>navigateToArtistDetail('${item.artist_id}','${_esc(item.artist || '').replace(/'/g, "\\'")}'),300)">${_esc(item.artist || '')}</a>` : _esc(item.artist || '')}${item.album ? ' · ' + _esc(item.album) : ''}</div>
<div class="stats-ranked-meta">${item.artist_id ? `<a class="stats-artist-link" onclick="navigateToArtistDetailPage('${item.artist_id}','${_esc(item.artist || '').replace(/'/g, "\\'")}')">${_esc(item.artist || '')}</a>` : _esc(item.artist || '')}${item.album ? ' · ' + _esc(item.album) : ''}</div>
</div>
<button class="stats-play-btn" onclick="event.stopPropagation();playStatsTrack('${_esc(item.name).replace(/'/g, "\\'")}','${_esc(item.artist || '').replace(/'/g, "\\'")}','${_esc(item.album || '').replace(/'/g, "\\'")}')" title="Play"></button>
<span class="stats-ranked-count">${_fmt(item.play_count)} plays</span>
@ -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 `<div class="stats-artist-bubble" onclick="${a.id ? `navigateToPage('library');setTimeout(()=>navigateToArtistDetail('${a.id}','${_esc(a.name).replace(/'/g, "\\\\'")}'),300)` : ''}" style="cursor:${a.id ? 'pointer' : 'default'}">
return `<div class="stats-artist-bubble" onclick="${a.id ? `navigateToArtistDetailPage('${a.id}','${_esc(a.name).replace(/'/g, "\\\\'")}')` : ''}" style="cursor:${a.id ? 'pointer' : 'default'}">
<div class="stats-bubble-img" style="width:${size}px;height:${size}px;${a.image_url ? `background-image:url('${a.image_url}')` : ''}">
${!a.image_url ? `<span>${(a.name || '?')[0]}</span>` : ''}
</div>