Redesign Watch All Unwatched as polished preview modal
Replaces the fire-and-forget button with a premium modal that shows exactly which artists will be added before confirming. Features: - Glassmorphic modal with stat cards, two-column artist grid, search filter, collapsible ineligible section, and loading spinner - Source-aware filtering: only shows artists with the active source's ID (Spotify/iTunes/Deezer) as eligible - Frontend and backend both paginate at 400 to avoid SQLite variable limit (SQLITE_MAX_VARIABLE_NUMBER=999) that silently broke queries above ~500 artists - Backend source detection aligned with frontend — uses only the active source's ID, falls back to configured metadata source
This commit is contained in:
parent
56305615a4
commit
3c47281ce5
6 changed files with 421 additions and 64 deletions
|
|
@ -18999,6 +18999,17 @@ def get_version_info():
|
|||
"title": "What's New in SoulSync",
|
||||
"subtitle": f"Version {SOULSYNC_VERSION} — Latest Changes",
|
||||
"sections": [
|
||||
{
|
||||
"title": "👁️ Watch All Unwatched Preview Modal",
|
||||
"description": "The Watch All Unwatched button now opens a modal showing exactly which artists will be added",
|
||||
"features": [
|
||||
"• Preview list shows all eligible artists with images, track counts, and matched sources",
|
||||
"• Clear separation of eligible vs ineligible artists (no external ID)",
|
||||
"• Collapsible section explains why some artists can't be added yet",
|
||||
"• Confirm before adding — no more silent 'Added 0' surprises",
|
||||
"• Results summary shown after completion"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "🔧 Fix Watch All Unwatched Skipping Deezer Artists",
|
||||
"description": "Watch All Unwatched now supports Deezer as an ID source",
|
||||
|
|
@ -34246,34 +34257,36 @@ def watchlist_all_unwatched_library_artists():
|
|||
database = get_database()
|
||||
active_source = _get_active_discovery_source()
|
||||
|
||||
# Get ALL unwatched artists from library (no pagination)
|
||||
result = database.get_library_artists(
|
||||
search_query='',
|
||||
letter='all',
|
||||
page=1,
|
||||
limit=99999,
|
||||
watchlist_filter='unwatched',
|
||||
profile_id=get_current_profile_id()
|
||||
)
|
||||
|
||||
unwatched_artists = result.get('artists', [])
|
||||
# Fetch all unwatched artists in pages (SQLite variable limit safe)
|
||||
unwatched_artists = []
|
||||
page = 1
|
||||
page_size = 400
|
||||
while True:
|
||||
result = database.get_library_artists(
|
||||
search_query='',
|
||||
letter='all',
|
||||
page=page,
|
||||
limit=page_size,
|
||||
watchlist_filter='unwatched',
|
||||
profile_id=get_current_profile_id()
|
||||
)
|
||||
unwatched_artists.extend(result.get('artists', []))
|
||||
if not result.get('pagination', {}).get('has_next', False):
|
||||
break
|
||||
page += 1
|
||||
added = 0
|
||||
skipped_no_id = 0
|
||||
skipped_already = 0
|
||||
|
||||
for artist in unwatched_artists:
|
||||
# Determine the external ID to use based on active source
|
||||
# Use only the active source's ID — matches frontend modal filtering
|
||||
artist_id = None
|
||||
if active_source == 'spotify' and artist.get('spotify_artist_id'):
|
||||
artist_id = artist['spotify_artist_id']
|
||||
elif active_source == 'deezer' and artist.get('deezer_id'):
|
||||
artist_id = artist['deezer_id']
|
||||
elif artist.get('spotify_artist_id'):
|
||||
artist_id = artist['spotify_artist_id']
|
||||
elif artist.get('itunes_artist_id'):
|
||||
artist_id = artist['itunes_artist_id']
|
||||
elif artist.get('deezer_id'):
|
||||
artist_id = artist['deezer_id']
|
||||
if active_source == 'spotify':
|
||||
artist_id = artist.get('spotify_artist_id')
|
||||
elif active_source == 'itunes':
|
||||
artist_id = artist.get('itunes_artist_id')
|
||||
elif active_source == 'deezer':
|
||||
artist_id = artist.get('deezer_id')
|
||||
|
||||
if not artist_id:
|
||||
skipped_no_id += 1
|
||||
|
|
@ -34288,13 +34301,7 @@ def watchlist_all_unwatched_library_artists():
|
|||
skipped_already += 1
|
||||
continue
|
||||
|
||||
# Determine source based on which ID field we picked
|
||||
if artist_id == artist.get('spotify_artist_id'):
|
||||
src = 'spotify'
|
||||
elif artist_id == artist.get('deezer_id'):
|
||||
src = 'deezer'
|
||||
else:
|
||||
src = _get_metadata_fallback_source()
|
||||
src = active_source if active_source in ('spotify', 'itunes', 'deezer') else _get_metadata_fallback_source()
|
||||
success = database.add_artist_to_watchlist(artist_id, artist_name, profile_id=get_current_profile_id(), source=src)
|
||||
if success:
|
||||
added += 1
|
||||
|
|
|
|||
|
|
@ -2484,7 +2484,7 @@
|
|||
<button class="watchlist-filter-btn active" data-filter="all">All</button>
|
||||
<button class="watchlist-filter-btn" data-filter="watched">Watched</button>
|
||||
<button class="watchlist-filter-btn" data-filter="unwatched">Unwatched</button>
|
||||
<button class="library-watchlist-all-btn hidden" id="library-watchlist-all-btn" onclick="addAllUnwatchedToWatchlist(this)">
|
||||
<button class="library-watchlist-all-btn hidden" id="library-watchlist-all-btn" onclick="openWatchAllUnwatchedModal()">
|
||||
<span class="watchlist-all-icon">👁️</span>
|
||||
<span class="watchlist-all-text">Watch All Unwatched</span>
|
||||
</button>
|
||||
|
|
|
|||
|
|
@ -3403,6 +3403,7 @@ function closeHelperSearch() {
|
|||
const WHATS_NEW = {
|
||||
'2.1': [
|
||||
// Newest features first
|
||||
{ title: 'Watch All Preview Modal', desc: 'Watch All Unwatched now opens a modal showing which artists will be added before confirming', page: 'library', selector: '#library-watchlist-all-btn' },
|
||||
{ title: 'Fix Watch All Unwatched', desc: 'Watch All Unwatched now works for Deezer users — was silently skipping artists with only Deezer IDs' },
|
||||
{ title: 'Fix Path Mismatch Fixes', desc: 'Library Maintenance path fixes now use fresh config and show error reasons in the toast' },
|
||||
{ title: 'Fix Wrong Spotify IDs', desc: 'Manual match no longer stores iTunes IDs as Spotify IDs — detects actual provider from results' },
|
||||
|
|
|
|||
|
|
@ -1000,6 +1000,16 @@
|
|||
padding: 5px 12px;
|
||||
}
|
||||
|
||||
.watch-all-modal { max-width: 95vw; width: 95vw; }
|
||||
.watch-all-grid { grid-template-columns: 1fr; }
|
||||
.watch-all-stats { flex-wrap: wrap; gap: 8px; padding: 12px 14px; }
|
||||
.watch-all-stat-card { padding: 8px 12px; }
|
||||
.watch-all-stat-value { font-size: 18px; }
|
||||
.watch-all-cell { padding: 6px 8px; }
|
||||
.watch-all-cell-img, .watch-all-cell-placeholder { width: 32px; height: 32px; }
|
||||
.watch-all-header { padding: 16px 18px; }
|
||||
.watch-all-search-wrap { padding: 8px 14px 4px; }
|
||||
|
||||
.library-card-watchlist-btn {
|
||||
opacity: 1;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -38975,13 +38975,171 @@ function showLibraryEmpty(show) {
|
|||
}
|
||||
}
|
||||
|
||||
async function addAllUnwatchedToWatchlist(btn) {
|
||||
if (btn.classList.contains('adding') || btn.classList.contains('all-added')) return;
|
||||
async function openWatchAllUnwatchedModal() {
|
||||
if (document.getElementById('watch-all-modal-overlay')) return;
|
||||
|
||||
btn.classList.add('adding');
|
||||
const textSpan = btn.querySelector('.watchlist-all-text');
|
||||
const originalText = textSpan.textContent;
|
||||
textSpan.textContent = 'Adding...';
|
||||
const sourceIdField = currentMusicSourceName === 'Apple Music' ? 'itunes_artist_id'
|
||||
: currentMusicSourceName === 'Deezer' ? 'deezer_id' : 'spotify_artist_id';
|
||||
const sourceName = currentMusicSourceName || 'Spotify';
|
||||
|
||||
const overlay = document.createElement('div');
|
||||
overlay.id = 'watch-all-modal-overlay';
|
||||
overlay.className = 'modal-overlay';
|
||||
overlay.onclick = (e) => { if (e.target === overlay) closeWatchAllUnwatchedModal(); };
|
||||
|
||||
overlay.innerHTML = `
|
||||
<div class="watch-all-modal">
|
||||
<div class="watch-all-header">
|
||||
<div class="watch-all-header-content">
|
||||
<div class="watch-all-header-icon">👁</div>
|
||||
<div>
|
||||
<h2 class="watch-all-title">Watch All Unwatched</h2>
|
||||
<p class="watch-all-subtitle">Add unwatched artists with ${_esc(sourceName)} IDs to your watchlist</p>
|
||||
</div>
|
||||
</div>
|
||||
<button class="watch-all-close" onclick="closeWatchAllUnwatchedModal()">×</button>
|
||||
</div>
|
||||
<div class="watch-all-body">
|
||||
<div class="watch-all-loading-state">
|
||||
<div class="watch-all-loading-spinner"></div>
|
||||
<div class="watch-all-loading-text">Loading unwatched artists...</div>
|
||||
<div class="watch-all-loading-count" id="watch-all-load-count"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="watch-all-footer">
|
||||
<button class="watch-all-btn watch-all-btn-cancel" onclick="closeWatchAllUnwatchedModal()">Cancel</button>
|
||||
<button class="watch-all-btn watch-all-btn-primary" id="watch-all-confirm-btn" disabled>Watch All</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
document.body.appendChild(overlay);
|
||||
|
||||
// Fetch all unwatched artists paginated (SQLite variable limit safe)
|
||||
try {
|
||||
const eligible = [];
|
||||
const ineligible = [];
|
||||
let page = 1;
|
||||
const pageSize = 400;
|
||||
const countEl = document.getElementById('watch-all-load-count');
|
||||
|
||||
while (true) {
|
||||
if (!document.getElementById('watch-all-modal-overlay')) return;
|
||||
if (countEl) countEl.textContent = `${eligible.length + ineligible.length} artists loaded...`;
|
||||
|
||||
const params = new URLSearchParams({ search: '', letter: 'all', page, limit: pageSize, watchlist: 'unwatched' });
|
||||
const response = await fetch(`/api/library/artists?${params}`);
|
||||
const data = await response.json();
|
||||
if (!data.success) throw new Error(data.error || 'Failed to load artists');
|
||||
|
||||
for (const a of (data.artists || [])) {
|
||||
if (a[sourceIdField]) eligible.push(a);
|
||||
else ineligible.push(a);
|
||||
}
|
||||
|
||||
if (!data.pagination.has_next) break;
|
||||
page++;
|
||||
}
|
||||
|
||||
_renderWatchAllModalContent(overlay, eligible, ineligible, sourceName);
|
||||
} catch (error) {
|
||||
console.error('Error loading unwatched artists:', error);
|
||||
const body = overlay.querySelector('.watch-all-body');
|
||||
if (body) body.innerHTML = `<div class="watch-all-empty-state"><div class="watch-all-empty-icon">⚠</div><div>Failed to load artists</div><a href="#" onclick="closeWatchAllUnwatchedModal(); openWatchAllUnwatchedModal(); return false;" class="watch-all-retry-link">Retry</a></div>`;
|
||||
}
|
||||
}
|
||||
|
||||
function _renderWatchAllModalContent(overlay, eligible, ineligible, sourceName) {
|
||||
const body = overlay.querySelector('.watch-all-body');
|
||||
const confirmBtn = overlay.querySelector('#watch-all-confirm-btn');
|
||||
|
||||
if (eligible.length === 0 && ineligible.length === 0) {
|
||||
body.innerHTML = '<div class="watch-all-empty-state"><div class="watch-all-empty-icon">🎵</div><div>No unwatched artists found</div></div>';
|
||||
return;
|
||||
}
|
||||
|
||||
// Store data for search filtering
|
||||
overlay._watchAllEligible = eligible;
|
||||
overlay._watchAllIneligible = ineligible;
|
||||
|
||||
let html = '';
|
||||
|
||||
// Summary bar (sticky)
|
||||
html += '<div class="watch-all-stats">';
|
||||
html += `<div class="watch-all-stat-card eligible"><div class="watch-all-stat-value">${eligible.length}</div><div class="watch-all-stat-label">Ready to watch</div></div>`;
|
||||
html += `<div class="watch-all-stat-card ineligible"><div class="watch-all-stat-value">${ineligible.length}</div><div class="watch-all-stat-label">No ${_esc(sourceName)} ID</div></div>`;
|
||||
html += `<div class="watch-all-stat-card total"><div class="watch-all-stat-value">${eligible.length + ineligible.length}</div><div class="watch-all-stat-label">Total unwatched</div></div>`;
|
||||
html += '</div>';
|
||||
|
||||
// Search filter
|
||||
if (eligible.length > 10) {
|
||||
html += '<div class="watch-all-search-wrap"><input type="text" class="watch-all-search" id="watch-all-search" placeholder="Search artists..." oninput="_filterWatchAllList(this.value)"></div>';
|
||||
}
|
||||
|
||||
// Eligible grid
|
||||
if (eligible.length > 0) {
|
||||
html += '<div class="watch-all-section-label">Artists to be watched</div>';
|
||||
html += '<div class="watch-all-grid" id="watch-all-eligible-grid">';
|
||||
html += _buildWatchAllRows(eligible, false);
|
||||
html += '</div>';
|
||||
}
|
||||
|
||||
// Ineligible section
|
||||
if (ineligible.length > 0) {
|
||||
html += `<div class="watch-all-ineligible">
|
||||
<div class="watch-all-ineligible-header" onclick="this.parentElement.classList.toggle('expanded')">
|
||||
<div class="watch-all-ineligible-label">
|
||||
<span class="watch-all-ineligible-icon">⚠</span>
|
||||
<span>${ineligible.length} artist${ineligible.length !== 1 ? 's' : ''} without ${_esc(sourceName)} ID</span>
|
||||
</div>
|
||||
<span class="watch-all-chevron">▼</span>
|
||||
</div>
|
||||
<div class="watch-all-ineligible-body">
|
||||
<div class="watch-all-ineligible-hint">These artists haven't been matched to ${_esc(sourceName)} yet. The background enrichment worker will match them over time.</div>
|
||||
<div class="watch-all-grid" id="watch-all-ineligible-grid">${_buildWatchAllRows(ineligible, true)}</div>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
if (eligible.length === 0) {
|
||||
html += `<div class="watch-all-empty-state"><div class="watch-all-empty-icon">🔌</div><div>None of your unwatched artists have a ${_esc(sourceName)} ID yet</div><div class="watch-all-empty-hint">The background enrichment worker will match them over time.</div></div>`;
|
||||
}
|
||||
|
||||
body.innerHTML = html;
|
||||
|
||||
if (eligible.length > 0 && confirmBtn) {
|
||||
confirmBtn.textContent = `Watch All (${eligible.length})`;
|
||||
confirmBtn.disabled = false;
|
||||
confirmBtn.onclick = () => _confirmWatchAllUnwatched(overlay, eligible.length);
|
||||
}
|
||||
}
|
||||
|
||||
function _buildWatchAllRows(artists, dimmed) {
|
||||
let html = '';
|
||||
for (const a of artists) {
|
||||
const img = a.image_url
|
||||
? `<img src="${_esc(a.image_url)}" alt="" onerror="this.style.display='none'; this.nextElementSibling.style.display='flex'" loading="lazy"><div class="watch-all-cell-placeholder" style="display:none">🎵</div>`
|
||||
: `<div class="watch-all-cell-placeholder">🎵</div>`;
|
||||
html += `<div class="watch-all-cell${dimmed ? ' dimmed' : ''}" data-name="${_esc(a.name.toLowerCase())}">
|
||||
<div class="watch-all-cell-img">${img}</div>
|
||||
<div class="watch-all-cell-name" title="${_esc(a.name)}">${_esc(a.name)}</div>
|
||||
<div class="watch-all-cell-meta">${a.track_count || 0} tracks</div>
|
||||
</div>`;
|
||||
}
|
||||
return html;
|
||||
}
|
||||
|
||||
function _filterWatchAllList(query) {
|
||||
const q = query.toLowerCase().trim();
|
||||
document.querySelectorAll('#watch-all-eligible-grid .watch-all-cell').forEach(cell => {
|
||||
cell.style.display = !q || cell.dataset.name.includes(q) ? '' : 'none';
|
||||
});
|
||||
}
|
||||
|
||||
async function _confirmWatchAllUnwatched(overlay, expectedCount) {
|
||||
const confirmBtn = overlay.querySelector('#watch-all-confirm-btn');
|
||||
const cancelBtn = overlay.querySelector('.watch-all-btn-cancel');
|
||||
if (confirmBtn) { confirmBtn.disabled = true; confirmBtn.textContent = 'Adding...'; }
|
||||
if (cancelBtn) cancelBtn.disabled = true;
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/library/watchlist-all-unwatched', {
|
||||
|
|
@ -38991,43 +39149,36 @@ async function addAllUnwatchedToWatchlist(btn) {
|
|||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
btn.classList.remove('adding');
|
||||
btn.classList.add('all-added');
|
||||
const body = overlay.querySelector('.watch-all-body');
|
||||
body.innerHTML = `<div class="watch-all-results">
|
||||
<div class="watch-all-results-icon">✓</div>
|
||||
<div class="watch-all-results-title">Added ${data.added} artist${data.added !== 1 ? 's' : ''} to watchlist</div>
|
||||
${data.skipped_already > 0 ? `<div class="watch-all-results-detail">${data.skipped_already} already watched</div>` : ''}
|
||||
${data.skipped_no_id > 0 ? `<div class="watch-all-results-detail">${data.skipped_no_id} skipped (no external ID)</div>` : ''}
|
||||
</div>`;
|
||||
|
||||
let resultText = `Added ${data.added}`;
|
||||
if (data.skipped_no_id > 0) {
|
||||
resultText += ` (${data.skipped_no_id} unmatched)`;
|
||||
}
|
||||
textSpan.textContent = resultText;
|
||||
|
||||
if (data.added > 0) {
|
||||
showToast(data.message, 'success');
|
||||
// Reload the library to reflect changes
|
||||
setTimeout(() => {
|
||||
loadLibraryArtists();
|
||||
}, 1500);
|
||||
} else if (data.skipped_no_id > 0) {
|
||||
showToast(`No artists could be added — ${data.skipped_no_id} don't have matching IDs yet. Background workers will match them over time.`, 'warning');
|
||||
} else {
|
||||
showToast('No unwatched artists found', 'info');
|
||||
}
|
||||
|
||||
// Reset button after a delay
|
||||
setTimeout(() => {
|
||||
btn.classList.remove('all-added');
|
||||
textSpan.textContent = originalText;
|
||||
}, 5000);
|
||||
if (confirmBtn) confirmBtn.style.display = 'none';
|
||||
if (cancelBtn) { cancelBtn.disabled = false; cancelBtn.textContent = 'Close'; }
|
||||
overlay.dataset.needsRefresh = 'true';
|
||||
} else {
|
||||
throw new Error(data.error || 'Failed to add artists');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error bulk adding unwatched artists:', error);
|
||||
btn.classList.remove('adding');
|
||||
textSpan.textContent = originalText;
|
||||
console.error('Error in watch all:', error);
|
||||
if (confirmBtn) { confirmBtn.disabled = false; confirmBtn.textContent = `Watch All (${expectedCount})`; }
|
||||
if (cancelBtn) cancelBtn.disabled = false;
|
||||
showToast('Failed to add artists to watchlist', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function closeWatchAllUnwatchedModal() {
|
||||
const overlay = document.getElementById('watch-all-modal-overlay');
|
||||
if (!overlay) return;
|
||||
const needsRefresh = overlay.dataset.needsRefresh === 'true';
|
||||
overlay.remove();
|
||||
if (needsRefresh) loadLibraryArtists();
|
||||
}
|
||||
|
||||
async function toggleLibraryCardWatchlist(btn, artist) {
|
||||
if (btn.disabled) return;
|
||||
btn.disabled = true;
|
||||
|
|
|
|||
|
|
@ -20560,6 +20560,194 @@ body.helper-mode-active #dashboard-activity-feed:hover {
|
|||
display: none;
|
||||
}
|
||||
|
||||
/* ── Watch All Unwatched Modal ──────────────────────────── */
|
||||
.watch-all-modal {
|
||||
width: 880px; max-width: 95vw; max-height: 85vh;
|
||||
background: linear-gradient(135deg, rgba(20,20,20,0.97) 0%, rgba(12,12,12,0.99) 100%);
|
||||
backdrop-filter: blur(20px) saturate(1.2);
|
||||
border-radius: 20px;
|
||||
border: 1px solid rgba(255,255,255,0.1);
|
||||
border-top: 1px solid rgba(255,255,255,0.15);
|
||||
box-shadow: 0 20px 60px rgba(0,0,0,0.6), 0 8px 32px rgba(0,0,0,0.4),
|
||||
0 0 40px rgba(var(--accent-rgb), 0.08), inset 0 1px 0 rgba(255,255,255,0.1);
|
||||
display: flex; flex-direction: column; overflow: hidden;
|
||||
}
|
||||
.watch-all-header {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 20px 24px;
|
||||
background: linear-gradient(90deg, rgba(var(--accent-rgb), 0.06) 0%, transparent 60%);
|
||||
border-bottom: 1px solid rgba(var(--accent-rgb), 0.12);
|
||||
}
|
||||
.watch-all-header-content { display: flex; align-items: center; gap: 14px; }
|
||||
.watch-all-header-icon { font-size: 28px; opacity: 0.9; }
|
||||
.watch-all-title {
|
||||
font-size: 20px; font-weight: 700; color: #fff; letter-spacing: -0.3px;
|
||||
font-family: 'SF Pro Display', -apple-system, BlinkMacSystemFont, sans-serif;
|
||||
}
|
||||
.watch-all-subtitle { font-size: 13px; color: rgba(255,255,255,0.45); margin-top: 2px; }
|
||||
.watch-all-close {
|
||||
background: rgba(255,255,255,0.08); border: none; color: rgba(255,255,255,0.5);
|
||||
width: 36px; height: 36px; border-radius: 50%; font-size: 20px; cursor: pointer;
|
||||
display: flex; align-items: center; justify-content: center; transition: all 0.2s;
|
||||
}
|
||||
.watch-all-close:hover { background: rgba(255,255,255,0.15); color: #fff; transform: scale(1.1); }
|
||||
.watch-all-body {
|
||||
flex: 1; overflow-y: auto; padding: 0;
|
||||
scrollbar-width: thin; scrollbar-color: rgba(255,255,255,0.15) transparent;
|
||||
}
|
||||
.watch-all-body::-webkit-scrollbar { width: 6px; }
|
||||
.watch-all-body::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.15); border-radius: 3px; }
|
||||
.watch-all-footer {
|
||||
display: flex; justify-content: flex-end; gap: 10px;
|
||||
padding: 16px 24px;
|
||||
border-top: 1px solid rgba(255,255,255,0.06);
|
||||
background: rgba(0,0,0,0.2);
|
||||
}
|
||||
.watch-all-btn {
|
||||
padding: 10px 24px; border-radius: 10px; font-size: 14px; font-weight: 600;
|
||||
border: none; cursor: pointer; transition: all 0.2s;
|
||||
}
|
||||
.watch-all-btn-cancel {
|
||||
background: rgba(255,255,255,0.08); color: rgba(255,255,255,0.7);
|
||||
}
|
||||
.watch-all-btn-cancel:hover { background: rgba(255,255,255,0.12); color: #fff; }
|
||||
.watch-all-btn-primary {
|
||||
background: rgb(var(--accent-rgb)); color: #fff;
|
||||
box-shadow: 0 4px 12px rgba(var(--accent-rgb), 0.3);
|
||||
}
|
||||
.watch-all-btn-primary:hover:not(:disabled) {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 6px 20px rgba(var(--accent-rgb), 0.4);
|
||||
}
|
||||
.watch-all-btn-primary:disabled { opacity: 0.4; cursor: default; }
|
||||
|
||||
/* Loading state */
|
||||
.watch-all-loading-state {
|
||||
display: flex; flex-direction: column; align-items: center; justify-content: center;
|
||||
padding: 64px 24px; gap: 16px;
|
||||
}
|
||||
.watch-all-loading-spinner {
|
||||
width: 36px; height: 36px; border-radius: 50%;
|
||||
border: 3px solid rgba(255,255,255,0.1); border-top-color: rgb(var(--accent-rgb));
|
||||
animation: watch-all-spin 0.8s linear infinite;
|
||||
}
|
||||
@keyframes watch-all-spin { to { transform: rotate(360deg); } }
|
||||
.watch-all-loading-text { color: rgba(255,255,255,0.6); font-size: 14px; }
|
||||
.watch-all-loading-count { color: rgba(255,255,255,0.3); font-size: 13px; }
|
||||
|
||||
/* Stats cards */
|
||||
.watch-all-stats {
|
||||
display: flex; gap: 12px; padding: 16px 20px;
|
||||
position: sticky; top: 0; z-index: 2;
|
||||
background: linear-gradient(135deg, rgba(20,20,20,0.98) 0%, rgba(16,16,16,0.98) 100%);
|
||||
border-bottom: 1px solid rgba(255,255,255,0.06);
|
||||
}
|
||||
.watch-all-stat-card {
|
||||
flex: 1; padding: 12px 16px; border-radius: 12px;
|
||||
background: rgba(255,255,255,0.03);
|
||||
border: 1px solid rgba(255,255,255,0.06);
|
||||
text-align: center; transition: all 0.2s;
|
||||
}
|
||||
.watch-all-stat-card:hover { border-color: rgba(255,255,255,0.1); background: rgba(255,255,255,0.05); }
|
||||
.watch-all-stat-card.eligible { border-color: rgba(var(--accent-rgb), 0.2); }
|
||||
.watch-all-stat-card.eligible .watch-all-stat-value { color: rgb(var(--accent-rgb)); }
|
||||
.watch-all-stat-card.ineligible .watch-all-stat-value { color: rgba(255,180,80,0.8); }
|
||||
.watch-all-stat-value { font-size: 22px; font-weight: 700; color: #fff; }
|
||||
.watch-all-stat-label { font-size: 11px; color: rgba(255,255,255,0.4); margin-top: 2px; text-transform: uppercase; letter-spacing: 0.5px; }
|
||||
|
||||
/* Search */
|
||||
.watch-all-search-wrap { padding: 8px 20px 4px; position: sticky; top: 78px; z-index: 2; background: rgba(16,16,16,0.95); }
|
||||
.watch-all-search {
|
||||
width: 100%; padding: 10px 16px; border-radius: 10px;
|
||||
background: rgba(255,255,255,0.06); border: 1px solid rgba(255,255,255,0.08);
|
||||
color: #fff; font-size: 14px; outline: none; transition: border-color 0.2s;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.watch-all-search:focus { border-color: rgba(var(--accent-rgb), 0.4); }
|
||||
.watch-all-search::placeholder { color: rgba(255,255,255,0.3); }
|
||||
|
||||
/* Section labels */
|
||||
.watch-all-section-label {
|
||||
padding: 12px 20px 6px; font-size: 11px; font-weight: 600;
|
||||
color: rgba(255,255,255,0.35); text-transform: uppercase; letter-spacing: 1px;
|
||||
}
|
||||
|
||||
/* Artist grid — two columns */
|
||||
.watch-all-grid {
|
||||
display: grid; grid-template-columns: 1fr 1fr; gap: 2px;
|
||||
padding: 4px 12px 12px;
|
||||
}
|
||||
.watch-all-cell {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
padding: 8px 12px; border-radius: 10px;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.watch-all-cell:hover { background: rgba(255,255,255,0.04); }
|
||||
.watch-all-cell.dimmed { opacity: 0.4; }
|
||||
.watch-all-cell-img {
|
||||
width: 38px; height: 38px; flex-shrink: 0; border-radius: 50%; overflow: hidden;
|
||||
background: rgba(255,255,255,0.06);
|
||||
}
|
||||
.watch-all-cell-img img { width: 100%; height: 100%; object-fit: cover; display: block; }
|
||||
.watch-all-cell-placeholder {
|
||||
width: 38px; height: 38px; border-radius: 50%;
|
||||
background: rgba(255,255,255,0.06);
|
||||
display: flex; align-items: center; justify-content: center; font-size: 16px;
|
||||
}
|
||||
.watch-all-cell-name {
|
||||
font-size: 13px; font-weight: 500; color: #fff;
|
||||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||
flex: 1; min-width: 0;
|
||||
}
|
||||
.watch-all-cell-meta { font-size: 11px; color: rgba(255,255,255,0.3); white-space: nowrap; }
|
||||
|
||||
/* Ineligible collapsible section */
|
||||
.watch-all-ineligible {
|
||||
border-top: 1px solid rgba(255,255,255,0.06); margin-top: 8px;
|
||||
}
|
||||
.watch-all-ineligible-header {
|
||||
display: flex; justify-content: space-between; align-items: center;
|
||||
padding: 12px 20px; cursor: pointer; transition: background 0.15s;
|
||||
}
|
||||
.watch-all-ineligible-header:hover { background: rgba(255,255,255,0.03); }
|
||||
.watch-all-ineligible-label { display: flex; align-items: center; gap: 8px; font-size: 13px; color: rgba(255,180,80,0.7); }
|
||||
.watch-all-ineligible-icon { font-size: 14px; }
|
||||
.watch-all-chevron { font-size: 10px; color: rgba(255,255,255,0.3); transition: transform 0.25s; }
|
||||
.watch-all-ineligible.expanded .watch-all-chevron { transform: rotate(180deg); }
|
||||
.watch-all-ineligible-body {
|
||||
max-height: 0; overflow: hidden; transition: max-height 0.35s ease;
|
||||
}
|
||||
.watch-all-ineligible.expanded .watch-all-ineligible-body { max-height: 400px; overflow-y: auto; }
|
||||
.watch-all-ineligible-hint {
|
||||
padding: 8px 20px 4px; font-size: 12px; color: rgba(255,255,255,0.3);
|
||||
border-left: 3px solid rgba(255,180,80,0.3); margin: 0 20px 8px; padding-left: 12px;
|
||||
}
|
||||
|
||||
/* Results state */
|
||||
.watch-all-results {
|
||||
display: flex; flex-direction: column; align-items: center; justify-content: center;
|
||||
padding: 64px 24px; gap: 8px;
|
||||
}
|
||||
.watch-all-results-icon {
|
||||
width: 64px; height: 64px; border-radius: 50%;
|
||||
background: rgba(var(--accent-rgb), 0.12); color: rgb(var(--accent-rgb));
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-size: 32px; margin-bottom: 8px;
|
||||
box-shadow: 0 0 24px rgba(var(--accent-rgb), 0.15);
|
||||
}
|
||||
.watch-all-results-title { font-size: 20px; font-weight: 600; color: #fff; }
|
||||
.watch-all-results-detail { font-size: 13px; color: rgba(255,255,255,0.4); }
|
||||
|
||||
/* Empty / error state */
|
||||
.watch-all-empty-state {
|
||||
display: flex; flex-direction: column; align-items: center; justify-content: center;
|
||||
padding: 64px 24px; gap: 8px; color: rgba(255,255,255,0.5); font-size: 14px;
|
||||
}
|
||||
.watch-all-empty-icon { font-size: 36px; margin-bottom: 4px; opacity: 0.5; }
|
||||
.watch-all-empty-hint { font-size: 12px; color: rgba(255,255,255,0.3); margin-top: 4px; }
|
||||
.watch-all-retry-link { color: rgb(var(--accent-rgb)); text-decoration: none; margin-top: 8px; }
|
||||
.watch-all-retry-link:hover { text-decoration: underline; }
|
||||
|
||||
.alphabet-selector {
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
|
|
|
|||
Loading…
Reference in a new issue