view recommended artists.
This commit is contained in:
parent
8f2dd66aee
commit
1f73ba4f94
5 changed files with 624 additions and 8 deletions
102
web_server.py
102
web_server.py
|
|
@ -23087,6 +23087,108 @@ def get_discover_hero():
|
|||
return jsonify({"success": False, "error": str(e)}), 500
|
||||
|
||||
|
||||
@app.route('/api/discover/similar-artists', methods=['GET'])
|
||||
def get_discover_similar_artists():
|
||||
"""Get all recommended similar artists (basic data, no enrichment for speed)"""
|
||||
try:
|
||||
database = get_database()
|
||||
active_source = _get_active_discovery_source()
|
||||
|
||||
similar_artists = database.get_top_similar_artists(limit=200)
|
||||
|
||||
if not similar_artists:
|
||||
return jsonify({"success": True, "artists": [], "source": active_source, "count": 0})
|
||||
|
||||
# Filter to artists with valid ID for active source
|
||||
result_artists = []
|
||||
for artist in similar_artists:
|
||||
has_spotify = bool(artist.similar_artist_spotify_id)
|
||||
has_itunes = bool(artist.similar_artist_itunes_id)
|
||||
|
||||
if active_source == 'spotify' and not has_spotify and not has_itunes:
|
||||
continue
|
||||
if active_source == 'itunes' and not has_itunes and not has_spotify:
|
||||
continue
|
||||
|
||||
if active_source == 'spotify':
|
||||
artist_id = artist.similar_artist_spotify_id or artist.similar_artist_itunes_id
|
||||
else:
|
||||
artist_id = artist.similar_artist_itunes_id or artist.similar_artist_spotify_id
|
||||
|
||||
result_artists.append({
|
||||
"artist_id": artist_id,
|
||||
"spotify_artist_id": artist.similar_artist_spotify_id,
|
||||
"itunes_artist_id": artist.similar_artist_itunes_id,
|
||||
"artist_name": artist.similar_artist_name,
|
||||
"occurrence_count": artist.occurrence_count,
|
||||
"similarity_rank": artist.similarity_rank,
|
||||
"source": active_source,
|
||||
})
|
||||
|
||||
print(f"[Similar Artists] {len(similar_artists)} from DB, {len(result_artists)} valid for {active_source}")
|
||||
|
||||
return jsonify({
|
||||
"success": True,
|
||||
"artists": result_artists,
|
||||
"source": active_source,
|
||||
"count": len(result_artists)
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error getting similar artists: {e}")
|
||||
return jsonify({"success": False, "error": str(e)}), 500
|
||||
|
||||
|
||||
@app.route('/api/discover/similar-artists/enrich', methods=['POST'])
|
||||
def enrich_similar_artists():
|
||||
"""Enrich a batch of artist IDs with images/genres from Spotify or iTunes"""
|
||||
try:
|
||||
data = request.get_json()
|
||||
artist_ids = data.get('artist_ids', [])
|
||||
source = data.get('source', 'spotify')
|
||||
|
||||
if not artist_ids:
|
||||
return jsonify({"success": True, "artists": {}})
|
||||
|
||||
enriched = {}
|
||||
|
||||
if source == 'spotify' and spotify_client and spotify_client.is_authenticated():
|
||||
try:
|
||||
batch_result = spotify_client.sp.artists(artist_ids[:50])
|
||||
if batch_result and 'artists' in batch_result:
|
||||
for sp_artist in batch_result['artists']:
|
||||
if sp_artist:
|
||||
enriched[sp_artist['id']] = {
|
||||
"artist_name": sp_artist.get('name'),
|
||||
"image_url": sp_artist['images'][0]['url'] if sp_artist.get('images') else None,
|
||||
"genres": sp_artist.get('genres', [])[:3],
|
||||
"popularity": sp_artist.get('popularity', 0)
|
||||
}
|
||||
except Exception as e:
|
||||
print(f"Error enriching Spotify batch: {e}")
|
||||
else:
|
||||
from core.itunes_client import iTunesClient
|
||||
itunes_client = iTunesClient()
|
||||
for aid in artist_ids[:50]:
|
||||
try:
|
||||
itunes_artist = itunes_client.get_artist(aid)
|
||||
if itunes_artist:
|
||||
enriched[aid] = {
|
||||
"artist_name": itunes_artist.get('name'),
|
||||
"image_url": itunes_artist.get('images', [{}])[0].get('url') if itunes_artist.get('images') else None,
|
||||
"genres": itunes_artist.get('genres', [])[:3],
|
||||
"popularity": 0
|
||||
}
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return jsonify({"success": True, "artists": enriched})
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error enriching similar artists: {e}")
|
||||
return jsonify({"success": False, "error": str(e)}), 500
|
||||
|
||||
|
||||
@app.route('/api/discover/recent-releases', methods=['GET'])
|
||||
def get_discover_recent_releases():
|
||||
"""Get cached recent albums from watchlist and similar artists"""
|
||||
|
|
|
|||
|
|
@ -2190,11 +2190,16 @@
|
|||
<!-- Slideshow Indicators -->
|
||||
<div class="discover-hero-indicators" id="discover-hero-indicators"></div>
|
||||
|
||||
<!-- Watch All Button -->
|
||||
<button class="discover-hero-watch-all" id="discover-hero-watch-all" onclick="watchAllHeroArtists(this)">
|
||||
<span class="watch-all-icon">👁️</span>
|
||||
<span class="watch-all-text">Watch All</span>
|
||||
</button>
|
||||
<!-- Hero Bottom Buttons -->
|
||||
<div class="discover-hero-bottom-actions">
|
||||
<button class="discover-hero-watch-all" id="discover-hero-watch-all" onclick="watchAllHeroArtists(this)">
|
||||
<span class="watch-all-icon">👁️</span>
|
||||
<span class="watch-all-text">Watch All</span>
|
||||
</button>
|
||||
<button class="discover-hero-view-all" id="discover-hero-view-all" onclick="openRecommendedArtistsModal()">
|
||||
View Recommended
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Recent Releases Section -->
|
||||
|
|
|
|||
|
|
@ -913,6 +913,40 @@
|
|||
padding: 16px;
|
||||
}
|
||||
|
||||
.discover-hero-bottom-actions {
|
||||
bottom: 12px;
|
||||
right: 12px;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.discover-hero-watch-all,
|
||||
.discover-hero-view-all {
|
||||
padding: 6px 12px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
/* Recommended Artists Modal - Mobile */
|
||||
.recommended-modal {
|
||||
max-width: 100vw;
|
||||
width: 100vw;
|
||||
max-height: 100vh;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.recommended-artists-grid {
|
||||
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
|
||||
gap: 12px;
|
||||
padding: 12px 16px;
|
||||
}
|
||||
|
||||
.recommended-search-container {
|
||||
padding: 0 16px;
|
||||
}
|
||||
|
||||
.recommended-card-watchlist-btn {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.discover-section-header {
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
|
|
|
|||
|
|
@ -33596,6 +33596,311 @@ async function watchAllHeroArtists(btn) {
|
|||
}
|
||||
}
|
||||
|
||||
// Cache for recommended artists data so reopening is instant
|
||||
let _recommendedArtistsCache = null;
|
||||
|
||||
async function openRecommendedArtistsModal() {
|
||||
let modal = document.getElementById('recommended-artists-modal');
|
||||
if (!modal) {
|
||||
modal = document.createElement('div');
|
||||
modal.id = 'recommended-artists-modal';
|
||||
modal.className = 'modal-overlay';
|
||||
document.body.appendChild(modal);
|
||||
|
||||
modal.addEventListener('click', function(e) {
|
||||
if (e.target === modal) closeRecommendedArtistsModal();
|
||||
});
|
||||
}
|
||||
|
||||
// If cached, render instantly and refresh watchlist statuses
|
||||
if (_recommendedArtistsCache) {
|
||||
modal.style.display = 'flex';
|
||||
renderRecommendedArtistsModal(modal, _recommendedArtistsCache);
|
||||
checkRecommendedWatchlistStatuses(_recommendedArtistsCache);
|
||||
return;
|
||||
}
|
||||
|
||||
// Show loading
|
||||
modal.innerHTML = `
|
||||
<div class="modal-container playlist-modal recommended-modal">
|
||||
<div class="playlist-modal-header">
|
||||
<div class="playlist-header-content" style="width: 100%;">
|
||||
<h2>Recommended Artists</h2>
|
||||
<div class="playlist-quick-info">
|
||||
<span class="playlist-track-count">Loading...</span>
|
||||
</div>
|
||||
</div>
|
||||
<span class="playlist-modal-close" onclick="closeRecommendedArtistsModal()">×</span>
|
||||
</div>
|
||||
<div class="playlist-modal-body">
|
||||
<div class="recommended-loading">Loading recommended artists...</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
modal.style.display = 'flex';
|
||||
|
||||
try {
|
||||
// Phase 1: Fetch basic data (instant — no API enrichment)
|
||||
const response = await fetch('/api/discover/similar-artists');
|
||||
const data = await response.json();
|
||||
|
||||
if (!data.success || !data.artists || data.artists.length === 0) {
|
||||
modal.querySelector('.playlist-modal-body').innerHTML = `
|
||||
<div class="recommended-empty-state">
|
||||
No recommended artists yet.<br>
|
||||
Run a watchlist scan to generate recommendations.
|
||||
</div>
|
||||
`;
|
||||
modal.querySelector('.playlist-track-count').textContent = '0 artists';
|
||||
return;
|
||||
}
|
||||
|
||||
// Render cards immediately with fallback images
|
||||
_recommendedArtistsCache = data.artists;
|
||||
renderRecommendedArtistsModal(modal, data.artists);
|
||||
|
||||
// Phase 2: Enrich with images/genres progressively in batches of 50
|
||||
const source = data.source || 'spotify';
|
||||
const idKey = source === 'spotify' ? 'spotify_artist_id' : 'itunes_artist_id';
|
||||
const allIds = data.artists.map(a => a[idKey]).filter(Boolean);
|
||||
|
||||
for (let i = 0; i < allIds.length; i += 50) {
|
||||
const batchIds = allIds.slice(i, i + 50);
|
||||
try {
|
||||
const enrichResp = await fetch('/api/discover/similar-artists/enrich', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ artist_ids: batchIds, source })
|
||||
});
|
||||
const enrichData = await enrichResp.json();
|
||||
if (enrichData.success && enrichData.artists) {
|
||||
// Update cards and cache as each batch arrives
|
||||
for (const [aid, info] of Object.entries(enrichData.artists)) {
|
||||
// Update the card in DOM
|
||||
const card = modal.querySelector(`.recommended-artist-card[data-artist-id="${aid}"]`);
|
||||
if (card && info.image_url) {
|
||||
const imgContainer = 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>';">`;
|
||||
}
|
||||
}
|
||||
if (card && info.genres && info.genres.length > 0) {
|
||||
const genresContainer = card.querySelector('.recommended-card-genres');
|
||||
if (genresContainer) {
|
||||
genresContainer.innerHTML = info.genres.map(g =>
|
||||
`<span class="recommended-card-genre">${escapeHtml(g)}</span>`
|
||||
).join('');
|
||||
} else {
|
||||
const infoDiv = card.querySelector('.recommended-card-info');
|
||||
if (infoDiv) {
|
||||
const genreDiv = document.createElement('div');
|
||||
genreDiv.className = 'recommended-card-genres';
|
||||
genreDiv.innerHTML = info.genres.map(g =>
|
||||
`<span class="recommended-card-genre">${escapeHtml(g)}</span>`
|
||||
).join('');
|
||||
infoDiv.appendChild(genreDiv);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Update cache
|
||||
const cached = _recommendedArtistsCache.find(a => a.artist_id === aid || a.spotify_artist_id === aid || a.itunes_artist_id === aid);
|
||||
if (cached) {
|
||||
if (info.image_url) cached.image_url = info.image_url;
|
||||
if (info.genres) cached.genres = info.genres;
|
||||
if (info.artist_name) cached.artist_name = info.artist_name;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (enrichErr) {
|
||||
console.error('Error enriching batch:', enrichErr);
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 3: Check watchlist statuses
|
||||
checkRecommendedWatchlistStatuses(data.artists);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error loading recommended artists:', error);
|
||||
modal.querySelector('.playlist-modal-body').innerHTML = `
|
||||
<div class="recommended-empty-state">Error loading recommended artists.</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
function renderRecommendedArtistsModal(modal, artists) {
|
||||
modal.innerHTML = `
|
||||
<div class="modal-container playlist-modal recommended-modal">
|
||||
<div class="playlist-modal-header">
|
||||
<div class="playlist-header-content" style="width: 100%;">
|
||||
<h2>Recommended Artists</h2>
|
||||
<div class="playlist-quick-info">
|
||||
<span class="playlist-track-count">${artists.length} artist${artists.length !== 1 ? 's' : ''}</span>
|
||||
</div>
|
||||
</div>
|
||||
<span class="playlist-modal-close" onclick="closeRecommendedArtistsModal()">×</span>
|
||||
</div>
|
||||
<div class="playlist-modal-body">
|
||||
<div class="recommended-search-container">
|
||||
<input type="text"
|
||||
class="recommended-search-input"
|
||||
id="recommended-search-input"
|
||||
placeholder="Search recommended artists..."
|
||||
oninput="filterRecommendedArtists()">
|
||||
</div>
|
||||
<div class="recommended-artists-grid" id="recommended-artists-grid">
|
||||
${artists.map(artist => {
|
||||
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';
|
||||
return `
|
||||
<div class="recommended-artist-card"
|
||||
data-artist-name="${escapeHtml(artist.artist_name).toLowerCase()}"
|
||||
data-artist-id="${artist.artist_id}">
|
||||
<button class="recommended-card-watchlist-btn"
|
||||
data-artist-id="${artist.artist_id}"
|
||||
data-artist-name="${escapeHtml(artist.artist_name)}">
|
||||
Add to Watchlist
|
||||
</button>
|
||||
<div class="recommended-card-image">
|
||||
${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>
|
||||
`}
|
||||
</div>
|
||||
<div class="recommended-card-info">
|
||||
<span class="recommended-card-name">${escapeHtml(artist.artist_name)}</span>
|
||||
<span class="recommended-card-similarity">${similarText}</span>
|
||||
<div class="recommended-card-genres">${genreTags}</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}).join('')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Event delegation for card clicks and watchlist buttons
|
||||
const grid = modal.querySelector('#recommended-artists-grid');
|
||||
if (grid) {
|
||||
grid.addEventListener('click', function(e) {
|
||||
const watchlistBtn = e.target.closest('.recommended-card-watchlist-btn');
|
||||
if (watchlistBtn) {
|
||||
e.stopPropagation();
|
||||
toggleRecommendedWatchlist(watchlistBtn);
|
||||
return;
|
||||
}
|
||||
|
||||
const card = e.target.closest('.recommended-artist-card');
|
||||
if (card) {
|
||||
const artistId = card.getAttribute('data-artist-id');
|
||||
const nameEl = card.querySelector('.recommended-card-name');
|
||||
const artistName = nameEl ? nameEl.textContent : '';
|
||||
viewRecommendedArtistDiscography(artistId, artistName);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function closeRecommendedArtistsModal() {
|
||||
const modal = document.getElementById('recommended-artists-modal');
|
||||
if (modal) modal.style.display = 'none';
|
||||
}
|
||||
|
||||
function filterRecommendedArtists() {
|
||||
const query = (document.getElementById('recommended-search-input')?.value || '').toLowerCase();
|
||||
const cards = document.querySelectorAll('.recommended-artist-card');
|
||||
cards.forEach(card => {
|
||||
const name = card.getAttribute('data-artist-name') || '';
|
||||
card.style.display = name.includes(query) ? '' : 'none';
|
||||
});
|
||||
}
|
||||
|
||||
async function toggleRecommendedWatchlist(btn) {
|
||||
const artistId = btn.getAttribute('data-artist-id');
|
||||
const artistName = btn.getAttribute('data-artist-name');
|
||||
if (!artistId || !artistName) return;
|
||||
|
||||
btn.disabled = true;
|
||||
const wasWatching = btn.classList.contains('watching');
|
||||
|
||||
try {
|
||||
if (wasWatching) {
|
||||
const resp = await fetch('/api/watchlist/remove', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ artist_id: artistId })
|
||||
});
|
||||
const data = await resp.json();
|
||||
if (data.success) {
|
||||
btn.classList.remove('watching');
|
||||
btn.textContent = 'Add to Watchlist';
|
||||
}
|
||||
} else {
|
||||
const resp = await fetch('/api/watchlist/add', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ artist_id: artistId, artist_name: artistName })
|
||||
});
|
||||
const data = await resp.json();
|
||||
if (data.success) {
|
||||
btn.classList.add('watching');
|
||||
btn.textContent = 'Watching';
|
||||
}
|
||||
}
|
||||
if (typeof updateWatchlistButtonCount === 'function') updateWatchlistButtonCount();
|
||||
} catch (error) {
|
||||
console.error('Error toggling recommended watchlist:', error);
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function checkRecommendedWatchlistStatuses(artists) {
|
||||
for (const artist of artists) {
|
||||
try {
|
||||
const resp = await fetch('/api/watchlist/check', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ artist_id: artist.artist_id })
|
||||
});
|
||||
const data = await resp.json();
|
||||
if (data.success && data.is_watching) {
|
||||
const btn = document.querySelector(`.recommended-card-watchlist-btn[data-artist-id="${artist.artist_id}"]`);
|
||||
if (btn) {
|
||||
btn.classList.add('watching');
|
||||
btn.textContent = 'Watching';
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// Non-critical
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function viewRecommendedArtistDiscography(artistId, artistName) {
|
||||
closeRecommendedArtistsModal();
|
||||
|
||||
const artist = {
|
||||
id: artistId,
|
||||
name: artistName
|
||||
};
|
||||
|
||||
// Use same navigation pattern as hero slider
|
||||
navigateToPage('artists');
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
await selectArtistForDetail(artist);
|
||||
}
|
||||
|
||||
async function checkAllHeroWatchlistStatus() {
|
||||
const btn = document.getElementById('discover-hero-watch-all');
|
||||
if (!btn || !discoverHeroArtists || discoverHeroArtists.length === 0) return;
|
||||
|
|
|
|||
|
|
@ -20179,12 +20179,19 @@ body {
|
|||
border-radius: 4px;
|
||||
}
|
||||
|
||||
/* Watch All Button */
|
||||
.discover-hero-watch-all {
|
||||
/* Hero Bottom Action Buttons */
|
||||
.discover-hero-bottom-actions {
|
||||
position: absolute;
|
||||
bottom: 24px;
|
||||
right: 24px;
|
||||
z-index: 10;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.discover-hero-watch-all,
|
||||
.discover-hero-view-all {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
|
|
@ -20200,7 +20207,8 @@ body {
|
|||
transition: background 0.2s ease, border-color 0.2s ease;
|
||||
}
|
||||
|
||||
.discover-hero-watch-all:hover {
|
||||
.discover-hero-watch-all:hover,
|
||||
.discover-hero-view-all:hover {
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
border-color: rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
|
|
@ -20221,6 +20229,168 @@ body {
|
|||
cursor: wait;
|
||||
}
|
||||
|
||||
/* Recommended Artists Modal */
|
||||
.recommended-modal {
|
||||
max-width: 95vw;
|
||||
width: 95vw;
|
||||
max-height: 95vh;
|
||||
}
|
||||
|
||||
.recommended-modal .playlist-modal-body {
|
||||
overflow-y: auto;
|
||||
padding: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.recommended-artists-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||
gap: 20px;
|
||||
padding: 20px 28px;
|
||||
}
|
||||
|
||||
.recommended-artist-card {
|
||||
position: relative;
|
||||
border-radius: 14px;
|
||||
overflow: hidden;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||
cursor: pointer;
|
||||
transition: transform 0.2s ease, border-color 0.2s ease;
|
||||
}
|
||||
|
||||
.recommended-artist-card:hover {
|
||||
transform: translateY(-3px);
|
||||
border-color: rgba(var(--accent-rgb), 0.3);
|
||||
}
|
||||
|
||||
.recommended-card-image {
|
||||
width: 100%;
|
||||
aspect-ratio: 1;
|
||||
overflow: hidden;
|
||||
background: linear-gradient(135deg, rgba(var(--accent-rgb), 0.15), rgba(var(--accent-rgb), 0.05));
|
||||
}
|
||||
|
||||
.recommended-card-image img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.recommended-card-image-fallback {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 48px;
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
.recommended-card-info {
|
||||
padding: 12px 14px;
|
||||
}
|
||||
|
||||
.recommended-card-name {
|
||||
display: block;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
color: #fff;
|
||||
margin-bottom: 4px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.recommended-card-similarity {
|
||||
display: block;
|
||||
font-size: 11px;
|
||||
color: rgba(var(--accent-rgb), 0.8);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.recommended-card-genres {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.recommended-card-genre {
|
||||
padding: 2px 8px;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
border-radius: 10px;
|
||||
font-size: 10px;
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
}
|
||||
|
||||
.recommended-card-watchlist-btn {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
z-index: 3;
|
||||
padding: 5px 10px;
|
||||
border-radius: 14px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
backdrop-filter: blur(8px);
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease, background 0.2s ease;
|
||||
}
|
||||
|
||||
.recommended-artist-card:hover .recommended-card-watchlist-btn {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.recommended-card-watchlist-btn.watching {
|
||||
background: rgba(var(--accent-rgb), 0.2);
|
||||
border-color: rgba(var(--accent-rgb), 0.5);
|
||||
color: rgb(var(--accent-rgb));
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.recommended-search-container {
|
||||
padding: 0 28px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.recommended-search-input {
|
||||
width: 100%;
|
||||
padding: 10px 16px;
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 10px;
|
||||
color: #fff;
|
||||
font-size: 14px;
|
||||
outline: none;
|
||||
transition: border-color 0.2s ease;
|
||||
}
|
||||
|
||||
.recommended-search-input:focus {
|
||||
border-color: rgba(var(--accent-rgb), 0.4);
|
||||
}
|
||||
|
||||
.recommended-search-input::placeholder {
|
||||
color: rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
.recommended-empty-state {
|
||||
text-align: center;
|
||||
padding: 60px 20px;
|
||||
color: rgba(255, 255, 255, 0.4);
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.recommended-loading {
|
||||
text-align: center;
|
||||
padding: 60px 20px;
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
/* Discover Sections */
|
||||
.discover-section {
|
||||
margin-bottom: 50px;
|
||||
|
|
|
|||
Loading…
Reference in a new issue