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:
parent
f9fc95c9f5
commit
491b89a1d2
6 changed files with 473 additions and 185 deletions
|
|
@ -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]]:
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -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
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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
|
||||
# ================================================================================================
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue