From 491b89a1d22b727bac785df8600345365ea4c4d8 Mon Sep 17 00:00:00 2001 From: Broque Thomas <26755000+Nezreka@users.noreply.github.com> Date: Sat, 21 Mar 2026 23:04:34 -0700 Subject: [PATCH] Redesign library artist hero with Last.fm integration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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//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 --- core/lastfm_client.py | 30 ++++ database/music_database.py | 8 +- web_server.py | 18 +++ webui/index.html | 68 ++++---- webui/static/script.js | 215 ++++++++++++++++--------- webui/static/style.css | 319 ++++++++++++++++++++++++++++--------- 6 files changed, 473 insertions(+), 185 deletions(-) diff --git a/core/lastfm_client.py b/core/lastfm_client.py index 7041d13d..6c5f7f1b 100644 --- a/core/lastfm_client.py +++ b/core/lastfm_client.py @@ -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]]: """ diff --git a/database/music_database.py b/database/music_database.py index 0f91d775..749efd1b 100644 --- a/database/music_database.py +++ b/database/music_database.py @@ -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 }, diff --git a/web_server.py b/web_server.py index 7989860e..98743d78 100644 --- a/web_server.py +++ b/web_server.py @@ -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//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 # ================================================================================================ diff --git a/webui/index.html b/webui/index.html index 13aacda7..1cca0c62 100644 --- a/webui/index.html +++ b/webui/index.html @@ -2535,59 +2535,55 @@
+
Artist Image
🎵
+
-

Artist Name

+
+

Artist Name

+
+
+ +
+ + +
-
- Albums - 0 owned, 0 missing -
-
-
-
-
-
- 0% -
+ Albums +
+ 0/0
-
-
- EPs - 0 owned, 0 missing -
-
-
-
-
-
- 0% -
+ EPs +
+ 0/0
-
-
- Singles - 0 owned, 0 missing -
-
-
-
-
-
- 0% -
+ Singles +
+ 0/0
+ + +
diff --git a/webui/static/script.js b/webui/static/script.js index 08e0589b..b10b6fd9 100644 --- a/webui/static/script.js +++ b/webui/static/script.js @@ -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 + ? `${fallback}` + : `${fallback}`; + return `
${inner}
`; + }; 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>/gi, '').replace(/<[^>]+>/g, '').trim(); + if (cleanBio) { + bioEl.innerHTML = `${cleanBio} + Read more`; + 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) => ` +
+ ${i + 1} + ${t.name} + ${_fmtNum(t.playcount)} +
+ `).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) { diff --git a/webui/static/style.css b/webui/static/style.css index c60a067d..94ece033 100644 --- a/webui/static/style.css +++ b/webui/static/style.css @@ -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; } }