Discover: promote recommendations to a first-class section + show the 'why'
- New 'Recommended For You' carousel section on the Discover page (between the hero and Your Artists), so recommendations aren't buried behind a hero modal button. Reuses the recommended-card markup/CSS, the watchlist add handler, and primes the modal cache so 'View All' opens instantly in sync. - Re-frames the now-stale copy: recommendations are library-wide (the similar- artists worker feeds the whole library), not watchlist-only. - Shows the real explanation from the backend's 'because' field — 'Because you have X & Y' (with a full-list hover tooltip) instead of just a count — in both the section cards, the modal cards, and the hero subtitle. - Cards lazy-enrich their images via the same endpoint the modal uses.
This commit is contained in:
parent
f333607d76
commit
0a5cda5189
3 changed files with 164 additions and 12 deletions
|
|
@ -3067,6 +3067,25 @@
|
|||
<div class="artist-map-search-results" id="artist-map-search-results"></div>
|
||||
</div>
|
||||
|
||||
<!-- Recommended For You Section (similar-artists graph) -->
|
||||
<div class="discover-section" id="recommended-artists-section" style="display: none;">
|
||||
<div class="discover-section-header">
|
||||
<div>
|
||||
<h2 class="discover-section-title">Recommended For You</h2>
|
||||
<p class="discover-section-subtitle">Artists similar to ones across your library — not yet on your watchlist</p>
|
||||
</div>
|
||||
<div class="discover-section-actions">
|
||||
<button class="btn btn--sm btn--secondary ya-header-btn ya-viewall-btn" onclick="openRecommendedArtistsModal()">
|
||||
<span>View All</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>
|
||||
</div>
|
||||
</div>
|
||||
<div class="discover-carousel" id="recommended-artists-carousel">
|
||||
<!-- Populated by JS -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Your Artists Section -->
|
||||
<div class="discover-section" id="your-artists-section" style="display: none;">
|
||||
<div class="discover-section-header">
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ async function loadDiscoverPage() {
|
|||
// Load all sections
|
||||
await Promise.all([
|
||||
loadDiscoverHero(),
|
||||
loadRecommendedArtistsSection(),
|
||||
loadYourArtists(),
|
||||
loadYourAlbums(),
|
||||
loadDiscoverRecentReleases(),
|
||||
|
|
@ -197,14 +198,10 @@ function displayDiscoverHeroArtist(artist) {
|
|||
}
|
||||
|
||||
if (subtitleEl) {
|
||||
// Show recommendation context based on occurrence count
|
||||
let subtitle = '';
|
||||
if (artist.occurrence_count > 1) {
|
||||
subtitle = `Similar to ${artist.occurrence_count} artists in your watchlist`;
|
||||
} else {
|
||||
subtitle = 'Similar to an artist in your watchlist';
|
||||
}
|
||||
subtitleEl.textContent = subtitle;
|
||||
// Prefer the real "because you have X, Y" names; fall back to a
|
||||
// library-wide occurrence count (no longer watchlist-only).
|
||||
subtitleEl.textContent = _recommendationReason(artist);
|
||||
subtitleEl.title = _recommendationReasonTitle(artist);
|
||||
}
|
||||
|
||||
// Build metadata section with popularity and genres
|
||||
|
|
@ -388,6 +385,30 @@ async function watchAllHeroArtists(btn) {
|
|||
let _recommendedArtistsCache = null;
|
||||
let _recommendedArtistsSource = null;
|
||||
|
||||
// Builds the "because you have X, Y" explanation for a recommendation.
|
||||
// Prefers the real contributing artist names from the backend (the `because`
|
||||
// array); falls back to an occurrence count — which is now LIBRARY-wide, not
|
||||
// just watchlist, thanks to the similar-artists enrichment worker.
|
||||
function _recommendationReason(artist) {
|
||||
const names = (artist && artist.because) || [];
|
||||
if (names.length === 1) return `Because you have ${escapeHtml(names[0])}`;
|
||||
if (names.length === 2) return `Because you have ${escapeHtml(names[0])} & ${escapeHtml(names[1])}`;
|
||||
if (names.length >= 3) {
|
||||
const shown = names.slice(0, 2).map(escapeHtml).join(', ');
|
||||
return `Because you have ${shown} +${names.length - 2} more`;
|
||||
}
|
||||
const n = (artist && artist.occurrence_count) || 0;
|
||||
return n > 1
|
||||
? `Similar to ${n} artists in your library`
|
||||
: 'Similar to an artist in your library';
|
||||
}
|
||||
|
||||
// Full contributing-artist list for a hover tooltip (when there are names).
|
||||
function _recommendationReasonTitle(artist) {
|
||||
const names = (artist && artist.because) || [];
|
||||
return names.length ? `In your library: ${names.join(', ')}` : '';
|
||||
}
|
||||
|
||||
async function openRecommendedArtistsModal() {
|
||||
let modal = document.getElementById('recommended-artists-modal');
|
||||
if (!modal) {
|
||||
|
|
@ -553,9 +574,8 @@ function renderRecommendedArtistsModal(modal, artists, source = null) {
|
|||
const genreTags = (artist.genres || []).slice(0, 3).map(g =>
|
||||
`<span class="recommended-card-genre">${escapeHtml(g)}</span>`
|
||||
).join('');
|
||||
const similarText = artist.occurrence_count > 1
|
||||
? `Similar to ${artist.occurrence_count} in your watchlist`
|
||||
: 'Similar to an artist in your watchlist';
|
||||
const similarText = _recommendationReason(artist);
|
||||
const similarTitle = _recommendationReasonTitle(artist);
|
||||
const artistSource = artist.source || source || _recommendedArtistsSource || '';
|
||||
return `
|
||||
<div class="recommended-artist-card"
|
||||
|
|
@ -582,7 +602,7 @@ function renderRecommendedArtistsModal(modal, artists, source = null) {
|
|||
</div>
|
||||
<div class="recommended-card-info">
|
||||
<span class="recommended-card-name">${escapeHtml(artist.artist_name)}</span>
|
||||
<span class="recommended-card-similarity">${similarText}</span>
|
||||
<span class="recommended-card-similarity" title="${escapeHtml(similarTitle)}">${similarText}</span>
|
||||
<div class="recommended-card-genres">${genreTags}</div>
|
||||
</div>
|
||||
</a>
|
||||
|
|
@ -651,6 +671,113 @@ async function addAllRecommendedToWatchlist(btn) {
|
|||
}
|
||||
}
|
||||
|
||||
// ── Recommended For You — first-class Discover section (carousel) ──
|
||||
// Reuses the recommended-card markup/CSS and the modal's watchlist + cache
|
||||
// machinery, so the inline carousel and the "View All" modal stay in sync.
|
||||
let _recommendedSectionCtrl = null;
|
||||
|
||||
function _renderRecommendedMini(artist, source) {
|
||||
const artistSource = artist.source || source || '';
|
||||
const reason = _recommendationReason(artist);
|
||||
const reasonTitle = _recommendationReasonTitle(artist);
|
||||
const genreTags = (artist.genres || []).slice(0, 2).map(g =>
|
||||
`<span class="recommended-card-genre">${escapeHtml(g)}</span>`
|
||||
).join('');
|
||||
const img = artist.image_url
|
||||
? `<img src="${artist.image_url}" alt="${escapeHtml(artist.artist_name)}" loading="lazy"
|
||||
onerror="this.parentElement.innerHTML='<div class=\\'recommended-card-image-fallback\\'>🎤</div>';">`
|
||||
: `<div class="recommended-card-image-fallback">🎤</div>`;
|
||||
return `
|
||||
<div class="recommended-artist-card recommended-card--carousel"
|
||||
data-artist-name="${escapeHtml(artist.artist_name).toLowerCase()}"
|
||||
data-artist-id="${artist.artist_id}"
|
||||
data-artist-source="${escapeHtml(artistSource)}">
|
||||
<button class="recommended-card-watchlist-btn"
|
||||
data-artist-id="${artist.artist_id}"
|
||||
data-artist-name="${escapeHtml(artist.artist_name)}">
|
||||
Add to Watchlist
|
||||
</button>
|
||||
<a class="recommended-card-link" href="${buildArtistDetailPath(artist.artist_id, artistSource || null)}"
|
||||
style="display:block;text-decoration:none;color:inherit;">
|
||||
<div class="recommended-card-image">${img}</div>
|
||||
<div class="recommended-card-info">
|
||||
<span class="recommended-card-name">${escapeHtml(artist.artist_name)}</span>
|
||||
<span class="recommended-card-similarity" title="${escapeHtml(reasonTitle)}">${reason}</span>
|
||||
<div class="recommended-card-genres">${genreTags}</div>
|
||||
</div>
|
||||
</a>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// Progressively fill in images for the cards we actually rendered (the API
|
||||
// returns cached images only; the rest are fetched on demand — same endpoint
|
||||
// the modal uses).
|
||||
async function _enrichRecommendedCarouselCards(items, source) {
|
||||
const idKey = source === 'spotify' ? 'spotify_artist_id'
|
||||
: source === 'deezer' ? 'deezer_artist_id'
|
||||
: 'itunes_artist_id';
|
||||
const ids = items.filter(a => !a.image_url).map(a => a[idKey]).filter(Boolean);
|
||||
if (!ids.length) return;
|
||||
try {
|
||||
const resp = await fetch('/api/discover/similar-artists/enrich', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ artist_ids: ids, source }),
|
||||
});
|
||||
const data = await resp.json();
|
||||
if (!data.success || !data.artists) return;
|
||||
const carousel = document.getElementById('recommended-artists-carousel');
|
||||
if (!carousel) return;
|
||||
for (const [aid, info] of Object.entries(data.artists)) {
|
||||
if (!info.image_url) continue;
|
||||
const card = carousel.querySelector(`.recommended-artist-card[data-artist-id="${aid}"]`);
|
||||
const imgContainer = card && card.querySelector('.recommended-card-image');
|
||||
if (imgContainer) {
|
||||
imgContainer.innerHTML = `<img src="${info.image_url}" alt="" loading="lazy"
|
||||
onerror="this.parentElement.innerHTML='<div class=\\'recommended-card-image-fallback\\'>🎤</div>';">`;
|
||||
}
|
||||
}
|
||||
} catch (e) { /* non-fatal — fallbacks stay */ }
|
||||
}
|
||||
|
||||
async function loadRecommendedArtistsSection() {
|
||||
if (!_recommendedSectionCtrl) {
|
||||
_recommendedSectionCtrl = createDiscoverSectionController({
|
||||
id: 'recommended-artists',
|
||||
sectionEl: '#recommended-artists-section',
|
||||
contentEl: '#recommended-artists-carousel',
|
||||
fetchUrl: '/api/discover/similar-artists',
|
||||
extractItems: (data) => data.artists || [],
|
||||
isEmpty: (items) => items.length === 0,
|
||||
hideWhenEmpty: true,
|
||||
renderItems: (items, data) => {
|
||||
const source = data.source || 'spotify';
|
||||
// Prime the modal cache so "View All" opens instantly in sync
|
||||
_recommendedArtistsCache = items;
|
||||
_recommendedArtistsSource = source;
|
||||
const shown = items.slice(0, 18);
|
||||
return shown.map(a => _renderRecommendedMini(a, source)).join('');
|
||||
},
|
||||
onRendered: ({ data }) => {
|
||||
const carousel = document.getElementById('recommended-artists-carousel');
|
||||
if (carousel && !carousel._recoWired) {
|
||||
carousel._recoWired = true;
|
||||
carousel.addEventListener('click', function (e) {
|
||||
const btn = e.target.closest('.recommended-card-watchlist-btn');
|
||||
if (btn) { e.preventDefault(); e.stopPropagation(); toggleRecommendedWatchlist(btn); }
|
||||
});
|
||||
}
|
||||
const source = (data && data.source) || 'spotify';
|
||||
_enrichRecommendedCarouselCards((data && data.artists || []).slice(0, 18), source);
|
||||
},
|
||||
loadingMessage: 'Finding recommendations...',
|
||||
emptyMessage: 'No recommendations yet — let the Similar Artists worker run',
|
||||
errorMessage: 'Failed to load recommendations',
|
||||
});
|
||||
}
|
||||
return _recommendedSectionCtrl.load();
|
||||
}
|
||||
|
||||
function closeRecommendedArtistsModal() {
|
||||
const modal = document.getElementById('recommended-artists-modal');
|
||||
if (modal) modal.style.display = 'none';
|
||||
|
|
|
|||
|
|
@ -33402,6 +33402,12 @@ div.artist-hero-badge {
|
|||
transition: transform 0.2s ease, border-color 0.2s ease;
|
||||
}
|
||||
|
||||
/* Recommended card placed inside a horizontal Discover carousel */
|
||||
.recommended-card--carousel {
|
||||
flex: 0 0 160px;
|
||||
width: 160px;
|
||||
}
|
||||
|
||||
.recommended-artist-card:hover {
|
||||
transform: translateY(-3px);
|
||||
border-color: rgba(var(--accent-rgb), 0.3);
|
||||
|
|
|
|||
Loading…
Reference in a new issue