Redesign library artist hero with Last.fm integration

- Add get_artist_top_tracks to Last.fm client (up to 100 tracks)
- Include lastfm_listeners, lastfm_playcount, lastfm_tags, lastfm_bio,
  and soul_id in artist detail API response
- New endpoint: /api/artist/<id>/lastfm-top-tracks for lazy loading
- Hero layout: image (160px) | center (name, badges, genres, bio,
  listener/play stats, progress bars) | right card (scrollable top
  100 tracks from Last.fm)
- Badges 36px with hover lift, bio in subtle card with Read More
  toggle, Last.fm tags merged with existing genres
- Numbers formatted: 1234567 → 1.2M
- Graceful degradation: sections hidden when Last.fm data unavailable
This commit is contained in:
Broque Thomas 2026-03-21 23:04:34 -07:00
parent f9fc95c9f5
commit 491b89a1d2
6 changed files with 473 additions and 185 deletions

View file

@ -176,6 +176,36 @@ class LastFMClient:
tags = data.get('toptags', {}).get('tag', [])
return tags if isinstance(tags, list) else [tags] if tags else []
@rate_limited
def get_artist_top_tracks(self, artist_name: str, limit: int = 5) -> List[Dict[str, Any]]:
"""
Get top tracks for an artist.
Returns:
List of track dicts with: name, playcount, listeners, url
"""
data = self._make_request('artist.gettoptracks', {
'artist': artist_name,
'autocorrect': 1,
'limit': limit
})
if not data:
return []
tracks = data.get('toptracks', {}).get('track', [])
if not isinstance(tracks, list):
tracks = [tracks] if tracks else []
result = []
for t in tracks:
result.append({
'name': t.get('name', ''),
'playcount': int(t.get('playcount', 0)),
'listeners': int(t.get('listeners', 0)),
'url': t.get('url', ''),
})
return result
@rate_limited
def get_similar_artists(self, artist_name: str, limit: int = 10) -> List[Dict[str, Any]]:
"""

View file

@ -7297,7 +7297,8 @@ class MusicDatabase:
id, name, thumb_url, genres, server_source,
musicbrainz_id, deezer_id, audiodb_id,
spotify_artist_id, itunes_artist_id, lastfm_url, genius_url,
tidal_id, qobuz_id
tidal_id, qobuz_id, soul_id,
lastfm_listeners, lastfm_playcount, lastfm_tags, lastfm_bio
FROM artists
WHERE id = ?
""", (artist_id,))
@ -7452,6 +7453,11 @@ class MusicDatabase:
'genius_url': artist_row['genius_url'],
'tidal_id': artist_row['tidal_id'],
'qobuz_id': artist_row['qobuz_id'],
'soul_id': artist_row['soul_id'],
'lastfm_listeners': artist_row['lastfm_listeners'],
'lastfm_playcount': artist_row['lastfm_playcount'],
'lastfm_tags': artist_row['lastfm_tags'],
'lastfm_bio': artist_row['lastfm_bio'],
'album_count': album_count,
'track_count': track_count
},

View file

@ -41056,6 +41056,24 @@ def lastfm_enrichment_resume():
logger.error(f"Error resuming Last.fm worker: {e}")
return jsonify({'error': str(e)}), 500
@app.route('/api/artist/<artist_id>/lastfm-top-tracks', methods=['GET'])
def get_artist_lastfm_top_tracks(artist_id):
"""Get top tracks for an artist from Last.fm (lazy-loaded by frontend)."""
try:
artist_name = request.args.get('name', '')
if not artist_name:
return jsonify({'success': False, 'error': 'Artist name required'}), 400
if not lastfm_worker or not lastfm_worker.client:
return jsonify({'success': True, 'tracks': []})
limit = int(request.args.get('limit', 100))
tracks = lastfm_worker.client.get_artist_top_tracks(artist_name, limit=min(limit, 100))
return jsonify({'success': True, 'tracks': tracks})
except Exception as e:
logger.error(f"Error fetching Last.fm top tracks: {e}")
return jsonify({'success': True, 'tracks': []})
# ================================================================================================
# END LAST.FM ENRICHMENT INTEGRATION
# ================================================================================================

View file

@ -2535,59 +2535,55 @@
<!-- Artist Hero Section -->
<div class="artist-hero-section" id="artist-hero-section">
<div class="artist-hero-content">
<!-- Left: Image -->
<div class="artist-image-container">
<img class="artist-image" id="artist-detail-image" src="" alt="Artist Image" />
<div class="artist-image-fallback" id="artist-detail-image-fallback">🎵</div>
</div>
<!-- Center: Identity + Meta -->
<div class="artist-info">
<h1 class="artist-name" id="artist-detail-name">Artist Name</h1>
<div class="artist-hero-identity">
<h1 class="artist-name" id="artist-detail-name">Artist Name</h1>
<div class="artist-hero-badges" id="artist-hero-badges"></div>
</div>
<div class="artist-genres-container" id="artist-genres"></div>
<div class="artist-hero-bio" id="artist-hero-bio" style="display:none;"></div>
<div class="artist-hero-numbers">
<div class="artist-hero-stat" id="artist-hero-listeners" style="display:none;">
<span class="hero-stat-value">0</span>
<span class="hero-stat-label">listeners</span>
</div>
<div class="artist-hero-stat" id="artist-hero-playcount" style="display:none;">
<span class="hero-stat-value">0</span>
<span class="hero-stat-label">plays</span>
</div>
</div>
<div class="collection-overview">
<div class="collection-category">
<div class="category-header">
<span class="category-label">Albums</span>
<span class="category-stats" id="albums-stats">0 owned, 0 missing</span>
</div>
<div class="completion-section">
<div class="completion-bar">
<div class="completion-fill" id="albums-completion-fill" style="width: 0%">
</div>
</div>
<span class="completion-text" id="albums-completion-text">0%</span>
</div>
<span class="category-label">Albums</span>
<div class="completion-bar"><div class="completion-fill" id="albums-completion-fill" style="width: 0%"></div></div>
<span class="category-stats" id="albums-stats">0/0</span>
</div>
<div class="collection-category">
<div class="category-header">
<span class="category-label">EPs</span>
<span class="category-stats" id="eps-stats">0 owned, 0 missing</span>
</div>
<div class="completion-section">
<div class="completion-bar">
<div class="completion-fill" id="eps-completion-fill" style="width: 0%">
</div>
</div>
<span class="completion-text" id="eps-completion-text">0%</span>
</div>
<span class="category-label">EPs</span>
<div class="completion-bar"><div class="completion-fill" id="eps-completion-fill" style="width: 0%"></div></div>
<span class="category-stats" id="eps-stats">0/0</span>
</div>
<div class="collection-category">
<div class="category-header">
<span class="category-label">Singles</span>
<span class="category-stats" id="singles-stats">0 owned, 0 missing</span>
</div>
<div class="completion-section">
<div class="completion-bar">
<div class="completion-fill" id="singles-completion-fill" style="width: 0%">
</div>
</div>
<span class="completion-text" id="singles-completion-text">0%</span>
</div>
<span class="category-label">Singles</span>
<div class="completion-bar"><div class="completion-fill" id="singles-completion-fill" style="width: 0%"></div></div>
<span class="category-stats" id="singles-stats">0/0</span>
</div>
</div>
</div>
<!-- Right: Top Tracks only -->
<div class="artist-hero-right" id="artist-hero-sidebar" style="display:none;">
<div class="hero-sidebar-title">Popular on Last.fm</div>
<div class="hero-top-tracks" id="hero-top-tracks"></div>
</div>
</div>
</div>

View file

@ -38815,47 +38815,39 @@ function updateArtistDetailPageHeader(artistName) {
}
function updateArtistDetailPageHeaderWithData(artist) {
// Target the visible header element
// Update name
const mainTitle = document.getElementById("artist-detail-name");
if (mainTitle) {
mainTitle.textContent = artist.name;
// Remove existing source links (to prevent duplicates)
// Remove any old source links that were appended to the h1
mainTitle.querySelectorAll('.source-link-btn').forEach(el => el.remove());
}
// Render badges in dedicated container
const badgesContainer = document.getElementById("artist-hero-badges");
if (badgesContainer) {
const _hb = (logo, fallback, title, url) => {
const attr = url ? `data-url="${url}" onclick="window.open(this.dataset.url,'_blank')"` : '';
const inner = logo
? `<img src="${logo}" alt="${fallback}" onerror="this.parentNode.textContent='${fallback}'">`
: `<span style="font-size:9px;font-weight:700;">${fallback}</span>`;
return `<div class="artist-hero-badge" title="${title}" ${attr}>${inner}</div>`;
};
const adbSlug = artist.name ? artist.name.replace(/\s+/g, '-').replace(/[^a-zA-Z0-9-]/g, '') : '';
const sources = [
{ id: artist.spotify_artist_id, url: `https://open.spotify.com/artist/${artist.spotify_artist_id}`, logo: SPOTIFY_LOGO_URL, label: 'Spotify' },
{ id: artist.itunes_artist_id, url: `https://music.apple.com/artist/${artist.itunes_artist_id}`, logo: ITUNES_LOGO_URL, label: 'Apple Music' },
{ id: artist.musicbrainz_id, url: `https://musicbrainz.org/artist/${artist.musicbrainz_id}`, logo: MUSICBRAINZ_LOGO_URL, label: 'MusicBrainz' },
{ id: artist.deezer_id, url: `https://www.deezer.com/artist/${artist.deezer_id}`, logo: DEEZER_LOGO_URL, label: 'Deezer' },
{ id: artist.audiodb_id, url: `https://www.theaudiodb.com/artist/${artist.audiodb_id}-${adbSlug}`, logo: getAudioDBLogoURL(), label: 'TheAudioDB' },
{ id: artist.lastfm_url, url: artist.lastfm_url, logo: LASTFM_LOGO_URL, label: 'Last.fm' },
{ id: artist.genius_url, url: artist.genius_url, logo: GENIUS_LOGO_URL, label: 'Genius' },
{ id: artist.tidal_id, url: `https://tidal.com/browse/artist/${artist.tidal_id}`, logo: TIDAL_LOGO_URL, label: 'Tidal' },
{ id: artist.qobuz_id, url: `https://www.qobuz.com/artist/${artist.qobuz_id}`, logo: QOBUZ_LOGO_URL, label: 'Qobuz' },
];
const badges = [];
if (artist.spotify_artist_id) badges.push(_hb(SPOTIFY_LOGO_URL, 'SP', 'Spotify', `https://open.spotify.com/artist/${artist.spotify_artist_id}`));
if (artist.musicbrainz_id) badges.push(_hb(MUSICBRAINZ_LOGO_URL, 'MB', 'MusicBrainz', `https://musicbrainz.org/artist/${artist.musicbrainz_id}`));
if (artist.deezer_id) badges.push(_hb(DEEZER_LOGO_URL, 'Dz', 'Deezer', `https://www.deezer.com/artist/${artist.deezer_id}`));
if (artist.audiodb_id) badges.push(_hb(typeof getAudioDBLogoURL === 'function' ? getAudioDBLogoURL() : '', 'ADB', 'AudioDB', `https://www.theaudiodb.com/artist/${artist.audiodb_id}-${adbSlug}`));
if (artist.itunes_artist_id) badges.push(_hb(ITUNES_LOGO_URL, 'IT', 'Apple Music', `https://music.apple.com/artist/${artist.itunes_artist_id}`));
if (artist.lastfm_url) badges.push(_hb(LASTFM_LOGO_URL, 'LFM', 'Last.fm', artist.lastfm_url));
if (artist.genius_url) badges.push(_hb(GENIUS_LOGO_URL, 'GEN', 'Genius', artist.genius_url));
if (artist.tidal_id) badges.push(_hb(TIDAL_LOGO_URL, 'TD', 'Tidal', `https://tidal.com/browse/artist/${artist.tidal_id}`));
if (artist.qobuz_id) badges.push(_hb(QOBUZ_LOGO_URL, 'Qz', 'Qobuz', `https://www.qobuz.com/artist/${artist.qobuz_id}`));
if (artist.soul_id && !artist.soul_id.startsWith('soul_unnamed_')) badges.push(_hb('/static/trans2.png', 'SS', `SoulID: ${artist.soul_id}`, null));
sources.forEach(source => {
if (!source.id) return;
const link = document.createElement('a');
link.className = 'source-link-btn';
link.href = source.url;
link.target = '_blank';
link.title = `View on ${source.label}`;
if (source.logo) {
const img = document.createElement('img');
img.src = source.logo;
img.onerror = () => { img.style.display = 'none'; };
link.appendChild(img);
}
const span = document.createElement('span');
span.textContent = source.label;
link.appendChild(span);
mainTitle.appendChild(link);
});
badgesContainer.innerHTML = badges.join('');
}
}
@ -39026,23 +39018,129 @@ function updateArtistHeroSection(artist, discography) {
updateCategoryStats('albums', discography.albums);
updateCategoryStats('eps', discography.eps);
updateCategoryStats('singles', discography.singles);
// Last.fm stats (listeners / playcount)
const _fmtNum = (n) => {
if (!n || n <= 0) return '0';
if (n >= 1000000) return (n / 1000000).toFixed(1).replace(/\.0$/, '') + 'M';
if (n >= 1000) return (n / 1000).toFixed(1).replace(/\.0$/, '') + 'K';
return n.toLocaleString();
};
const listenersEl = document.getElementById('artist-hero-listeners');
if (listenersEl) {
if (artist.lastfm_listeners) {
listenersEl.querySelector('.hero-stat-value').textContent = _fmtNum(artist.lastfm_listeners);
listenersEl.style.display = '';
} else {
listenersEl.style.display = 'none';
}
}
const playcountEl = document.getElementById('artist-hero-playcount');
if (playcountEl) {
if (artist.lastfm_playcount) {
playcountEl.querySelector('.hero-stat-value').textContent = _fmtNum(artist.lastfm_playcount);
playcountEl.style.display = '';
} else {
playcountEl.style.display = 'none';
}
}
// Last.fm bio
const bioEl = document.getElementById('artist-hero-bio');
if (bioEl) {
const bio = artist.lastfm_bio;
if (bio && bio.trim()) {
// Strip HTML tags and "Read more on Last.fm" links
let cleanBio = bio.replace(/<a\b[^>]*>.*?<\/a>/gi, '').replace(/<[^>]+>/g, '').trim();
if (cleanBio) {
bioEl.innerHTML = `<span class="bio-text">${cleanBio}</span>
<span class="artist-hero-bio-toggle" onclick="this.parentElement.classList.toggle('expanded');this.textContent=this.parentElement.classList.contains('expanded')?'Show less':'Read more'">Read more</span>`;
bioEl.style.display = '';
} else {
bioEl.style.display = 'none';
}
} else {
bioEl.style.display = 'none';
}
}
// Last.fm tags — merge with existing genres (deduplicate)
if (artist.lastfm_tags) {
try {
let lfmTags = typeof artist.lastfm_tags === 'string' ? JSON.parse(artist.lastfm_tags) : artist.lastfm_tags;
if (Array.isArray(lfmTags) && lfmTags.length > 0) {
const existingGenres = new Set((artist.genres || []).map(g => g.toLowerCase()));
const newTags = lfmTags.filter(t => !existingGenres.has(t.toLowerCase())).slice(0, 5);
if (newTags.length > 0) {
const genresContainer = document.getElementById('artist-genres');
if (genresContainer) {
newTags.forEach(tag => {
const el = document.createElement('span');
el.className = 'genre-tag';
el.textContent = tag;
el.style.opacity = '0.6';
genresContainer.appendChild(el);
});
}
}
}
} catch (e) {
console.debug('Failed to parse Last.fm tags:', e);
}
}
// Lazy-load top tracks sidebar
if (artist.lastfm_url || artist.lastfm_listeners) {
_loadArtistTopTracks(artist.name);
}
}
async function _loadArtistTopTracks(artistName) {
const sidebar = document.getElementById('artist-hero-sidebar');
const container = document.getElementById('hero-top-tracks');
if (!sidebar || !container) return;
try {
const resp = await fetch(`/api/artist/0/lastfm-top-tracks?name=${encodeURIComponent(artistName)}`);
const data = await resp.json();
if (!data.success || !data.tracks || data.tracks.length === 0) {
sidebar.style.display = 'none';
return;
}
const _fmtNum = (n) => {
if (!n || n <= 0) return '0';
if (n >= 1000000) return (n / 1000000).toFixed(1).replace(/\.0$/, '') + 'M';
if (n >= 1000) return (n / 1000).toFixed(1).replace(/\.0$/, '') + 'K';
return n.toLocaleString();
};
container.innerHTML = data.tracks.map((t, i) => `
<div class="hero-top-track">
<span class="hero-top-track-num">${i + 1}</span>
<span class="hero-top-track-name" title="${t.name}">${t.name}</span>
<span class="hero-top-track-plays">${_fmtNum(t.playcount)}</span>
</div>
`).join('');
sidebar.style.display = '';
} catch (e) {
console.debug('Failed to load top tracks:', e);
sidebar.style.display = 'none';
}
}
function updateCategoryStats(category, releases) {
const hasChecking = releases.some(r => r.owned === null);
const owned = releases.filter(r => r.owned === true).length;
const missing = releases.filter(r => r.owned === false).length;
const total = releases.length;
const completion = total > 0 ? Math.round((owned / total) * 100) : 100;
// Update stats text
// Update stats text (compact: "3/12")
const statsElement = document.getElementById(`${category}-stats`);
if (statsElement) {
if (hasChecking) {
statsElement.textContent = `Checking...`;
} else {
statsElement.textContent = `${owned} owned, ${missing} missing`;
}
statsElement.textContent = hasChecking ? '...' : `${owned}/${total}`;
}
// Update completion bar
@ -39056,16 +39154,6 @@ function updateCategoryStats(category, releases) {
fillElement.classList.remove('checking');
}
}
// Update completion text
const textElement = document.getElementById(`${category}-completion-text`);
if (textElement) {
if (hasChecking) {
textElement.textContent = `Checking...`;
} else {
textElement.textContent = `${completion}%`;
}
}
}
function populateDiscographySections(discography) {
@ -39577,34 +39665,19 @@ function updateLibraryReleaseCard(data) {
}
function updateCategoryStatsFromStream(category, ownedCount, missingCount) {
const statsElement = document.getElementById(`${category}-stats`);
if (statsElement) {
statsElement.textContent = `${ownedCount} owned, ${missingCount} missing`;
}
const total = ownedCount + missingCount;
const completion = total > 0 ? Math.round((ownedCount / total) * 100) : 100;
const statsElement = document.getElementById(`${category}-stats`);
if (statsElement) {
statsElement.textContent = `${ownedCount}/${total}`;
}
const fillElement = document.getElementById(`${category}-completion-fill`);
if (fillElement) {
fillElement.classList.remove('checking');
fillElement.style.width = `${completion}%`;
}
const textElement = document.getElementById(`${category}-completion-text`);
if (textElement) {
textElement.textContent = `${completion}%`;
}
// Update section owned/missing counts
const ownedElement = document.getElementById(`${category}-owned-count`);
if (ownedElement) {
ownedElement.textContent = `${ownedCount} owned`;
}
const missingElement = document.getElementById(`${category}-missing-count`);
if (missingElement) {
missingElement.textContent = `${missingCount} missing`;
}
}
function recalculateSummaryStats(artistFormatSet) {

View file

@ -19584,16 +19584,41 @@ body {
background: linear-gradient(135deg, rgba(26, 26, 26, 0.95) 0%, rgba(18, 18, 18, 0.98) 100%);
backdrop-filter: blur(12px) saturate(1.1);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 12px;
margin: 20px 20px 20px 20px;
padding: 20px;
border-radius: 16px;
margin: 20px;
padding: 28px 32px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.3);
}
.artist-hero-content {
display: flex;
align-items: center;
gap: 20px;
align-items: flex-start;
gap: 28px;
}
/* Identity block — name + badges stacked */
.artist-hero-identity {
margin-bottom: 12px;
}
/* Numbers row — listeners + plays */
.artist-hero-numbers {
display: flex;
gap: 10px;
margin-top: 14px;
}
/* Right column — top tracks */
.artist-hero-right {
flex-shrink: 0;
width: 280px;
background: rgba(255, 255, 255, 0.02);
border: 1px solid rgba(255, 255, 255, 0.04);
border-radius: 12px;
padding: 16px 18px;
align-self: stretch;
display: flex;
flex-direction: column;
}
.artist-image-container {
@ -19602,19 +19627,19 @@ body {
}
.artist-image {
width: 100px;
height: 100px;
border-radius: 12px;
width: 160px;
height: 160px;
border-radius: 14px;
object-fit: cover;
border: 2px solid rgba(var(--accent-rgb), 0.3);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.3);
border: 2px solid rgba(var(--accent-rgb), 0.25);
box-shadow: 0 8px 28px rgba(0, 0, 0, 0.4);
display: block;
}
.artist-image-fallback {
width: 100px;
height: 100px;
border-radius: 12px;
width: 160px;
height: 160px;
border-radius: 14px;
background: linear-gradient(135deg, rgba(var(--accent-rgb), 0.2) 0%, rgba(var(--accent-light-rgb), 0.1) 100%);
border: 2px solid rgba(var(--accent-rgb), 0.3);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.3);
@ -19638,74 +19663,105 @@ body {
min-width: 0;
}
.artist-name {
font-size: 32px;
.artist-hero-section .artist-name {
font-size: 2.2em;
font-weight: 700;
color: #ffffff;
margin: 0 0 16px 0;
margin: 0 0 10px 0;
line-height: 1.1;
font-family: 'SF Pro Display', -apple-system, BlinkMacSystemFont, sans-serif;
letter-spacing: -0.3px;
letter-spacing: -0.02em;
}
/* Hero badges — horizontal icon row */
.artist-hero-badges {
display: flex;
flex-wrap: wrap;
gap: 6px;
align-items: center;
margin-bottom: 14px;
}
.artist-hero-badge {
width: 36px;
height: 36px;
border-radius: 8px;
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.08);
display: flex;
align-items: center;
justify-content: center;
transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
}
.artist-hero-badge img {
width: 20px;
height: 20px;
object-fit: contain;
opacity: 0.8;
}
.artist-hero-badge[data-url] {
cursor: pointer;
}
.artist-hero-badge[data-url]:hover {
background: rgba(255, 255, 255, 0.12);
border-color: rgba(255, 255, 255, 0.2);
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
}
.artist-hero-badge[data-url]:hover img {
opacity: 1;
}
.artist-hero-badge:not([data-url]) {
cursor: default;
}
.artist-genres-container {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 24px;
gap: 6px;
margin-bottom: 12px;
}
/* Collection progress bars — below listener/play stats */
.collection-overview {
display: flex;
flex-direction: column;
gap: 12px;
gap: 8px;
margin-top: 14px;
}
.collection-category {
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 8px;
padding: 12px 16px;
transition: all 0.3s ease;
}
.collection-category:hover {
background: rgba(255, 255, 255, 0.06);
border-color: rgba(var(--accent-rgb), 0.2);
}
.category-header {
flex: 1;
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
gap: 10px;
padding: 0;
background: none;
border: none;
}
.category-label {
color: #ffffff;
font-weight: 700;
font-size: 16px;
font-family: 'SF Pro Display', -apple-system, BlinkMacSystemFont, sans-serif;
color: rgba(255, 255, 255, 0.5);
font-weight: 600;
font-size: 0.75em;
min-width: 52px;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.category-stats {
color: #b3b3b3;
font-size: 14px;
color: rgba(255, 255, 255, 0.45);
font-size: 0.75em;
font-weight: 500;
font-family: 'SF Pro Text', -apple-system, BlinkMacSystemFont, sans-serif;
}
.completion-section {
display: flex;
align-items: center;
gap: 16px;
min-width: 32px;
text-align: right;
font-variant-numeric: tabular-nums;
}
.completion-bar {
flex: 1;
height: 8px;
background: rgba(255, 255, 255, 0.1);
border-radius: 4px;
height: 4px;
background: rgba(255, 255, 255, 0.08);
border-radius: 2px;
overflow: hidden;
position: relative;
}
@ -19714,38 +19770,142 @@ body {
height: 100%;
background: linear-gradient(90deg, rgb(var(--accent-rgb)) 0%, rgb(var(--accent-light-rgb)) 100%);
transition: width 0.8s cubic-bezier(0.4, 0, 0.2, 1);
border-radius: 4px;
border-radius: 2px;
position: relative;
}
.completion-fill::after {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
top: 0; left: 0; right: 0; bottom: 0;
background: linear-gradient(90deg, transparent 0%, rgba(255, 255, 255, 0.2) 50%, transparent 100%);
animation: shimmer 2s infinite;
}
@keyframes shimmer {
0% {
transform: translateX(-100%);
}
100% {
transform: translateX(100%);
}
0% { transform: translateX(-100%); }
100% { transform: translateX(100%); }
}
.completion-text {
color: #ffffff;
font-size: 16px;
/* Bio section */
.artist-hero-bio {
font-size: 0.82em;
color: rgba(255, 255, 255, 0.45);
line-height: 1.6;
margin-bottom: 14px;
padding: 10px 14px;
background: rgba(255, 255, 255, 0.02);
border-radius: 10px;
border: 1px solid rgba(255, 255, 255, 0.04);
padding: 16px 20px 32px;
max-height: 8em;
overflow: hidden;
position: relative;
transition: max-height 0.4s ease;
}
.artist-hero-bio.expanded {
max-height: 500px;
}
.artist-hero-bio-toggle {
color: rgb(var(--accent-rgb));
cursor: pointer;
font-size: 0.82em;
font-weight: 600;
display: block;
margin-top: 8px;
padding: 4px 0;
}
.artist-hero-bio-toggle:hover {
text-decoration: underline;
}
/* Stats — listener/play count boxes */
.artist-hero-stat {
display: flex;
flex-direction: column;
align-items: center;
padding: 10px 18px;
background: rgba(255, 255, 255, 0.03);
border: 1px solid rgba(255, 255, 255, 0.06);
border-radius: 10px;
min-width: 80px;
transition: border-color 0.2s;
}
.artist-hero-stat:hover {
border-color: rgba(255, 255, 255, 0.1);
}
.hero-stat-value {
font-size: 1.25em;
font-weight: 700;
min-width: 45px;
color: #fff;
letter-spacing: -0.02em;
line-height: 1.2;
}
.hero-stat-label {
font-size: 0.65em;
color: rgba(255, 255, 255, 0.35);
text-transform: uppercase;
letter-spacing: 0.06em;
margin-top: 3px;
}
/* Right column is now the sidebar itself — styled by .artist-hero-right */
.hero-sidebar-title {
font-size: 0.75em;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.06em;
color: rgba(255, 255, 255, 0.4);
margin-bottom: 10px;
}
.hero-top-tracks {
display: flex;
flex-direction: column;
gap: 4px;
max-height: 500px;
overflow-y: auto;
overflow-x: hidden;
scrollbar-width: thin;
scrollbar-color: rgba(255, 255, 255, 0.1) transparent;
}
@media (max-width: 768px) {
.hero-top-tracks {
max-height: 300px;
}
}
.hero-top-track {
display: flex;
align-items: center;
gap: 8px;
padding: 5px 8px;
border-radius: 6px;
transition: background 0.15s;
}
.hero-top-track:hover {
background: rgba(255, 255, 255, 0.04);
}
.hero-top-track-num {
font-size: 0.72em;
color: rgba(255, 255, 255, 0.25);
font-weight: 600;
width: 14px;
text-align: right;
font-family: 'SF Pro Text', -apple-system, BlinkMacSystemFont, sans-serif;
flex-shrink: 0;
}
.hero-top-track-name {
font-size: 0.82em;
color: rgba(255, 255, 255, 0.8);
flex: 1;
min-width: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.hero-top-track-plays {
font-size: 0.7em;
color: rgba(255, 255, 255, 0.3);
flex-shrink: 0;
font-variant-numeric: tabular-nums;
}
/* Mobile responsiveness */
@ -19768,20 +19928,25 @@ body {
height: 120px;
}
.artist-name {
font-size: 32px;
.artist-hero-section .artist-name {
font-size: 1.6em;
text-align: center;
}
.category-header {
flex-direction: column;
align-items: flex-start;
gap: 4px;
margin-bottom: 8px;
.artist-hero-right {
width: 100%;
border-left: none;
border-top: 1px solid rgba(255, 255, 255, 0.06);
padding-left: 0;
padding-top: 16px;
}
.completion-section {
gap: 12px;
.artist-hero-numbers {
justify-content: center;
}
.artist-hero-identity {
justify-content: center;
}
}