Promote Watchlist and Wishlist from modals to full sidebar pages

Watchlist and Wishlist are now proper sidebar pages with full design
treatment matching the app's established visual language — glass
containers, gradient headers, accent lines, card hover effects.

Watchlist page: artist grid with sort (name/scan date/date added),
search filter, last scan summary strip, live scan activity, batch
selection, all existing sub-modals (artist config, global settings,
artist detail slideout) preserved and working.

Wishlist page: stats strip (album count, singles count, next cycle),
category cards with mosaic backgrounds, track list with inline search
filter, batch operations, download integration. Auto-processing
detection on header button shows download progress modal when active.

Header buttons rewired to navigate to pages. All refresh points updated
to reinitialize pages instead of reopening modals. Timer/polling cleanup
on page navigation. Artist detail overlay converted to fixed positioning.
This commit is contained in:
Broque Thomas 2026-04-15 23:00:17 -07:00
parent 316d4cb466
commit cf18590794
4 changed files with 1472 additions and 41 deletions

View file

@ -212,6 +212,16 @@
<span class="nav-icon"><svg class="nav-svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M9 18V5l12-2v13"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="16" r="3"/></svg></span>
<span class="nav-text">Artists</span>
</button>
<button class="nav-button" data-page="watchlist">
<span class="nav-icon"><svg class="nav-svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg></span>
<span class="nav-text">Watchlist</span>
<span class="dl-nav-badge hidden" id="watchlist-nav-badge">0</span>
</button>
<button class="nav-button" data-page="wishlist">
<span class="nav-icon"><svg class="nav-svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M9 18V5l12-2v13"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="16" r="3"/><line x1="1" y1="1" x2="5" y2="5" stroke-width="0"/></svg></span>
<span class="nav-text">Wishlist</span>
<span class="dl-nav-badge hidden" id="wishlist-nav-badge">0</span>
</button>
<button class="nav-button" data-page="automations">
<span class="nav-icon"><svg class="nav-svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><polyline points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/></svg></span>
<span class="nav-text">Automations</span>
@ -6401,6 +6411,229 @@
</div>
</div>
<!-- ═══════════════════════════════════════════════════════════════════
WATCHLIST PAGE
═══════════════════════════════════════════════════════════════════ -->
<div class="page" id="watchlist-page">
<div class="watchlist-page-container">
<!-- Header -->
<div class="watchlist-page-header">
<div class="watchlist-page-header-left">
<h2 class="watchlist-page-title">
<svg width="24" height="24" viewBox="0 0 24 24" fill="rgb(var(--accent-rgb))"><path d="M12 4.5C7 4.5 2.73 7.61 1 12c1.73 4.39 6 7.5 11 7.5s9.27-3.11 11-7.5c-1.73-4.39-6-7.5-11-7.5zM12 17c-2.76 0-5-2.24-5-5s2.24-5 5-5 5 2.24 5 5-2.24 5-5 5zm0-8c-1.66 0-3 1.34-3 3s1.34 3 3 3 3-1.34 3-3-1.34-3-3-3z"/></svg>
Watchlist
</h2>
<div class="watchlist-page-meta">
<span class="watchlist-page-count" id="watchlist-page-count">0 artists</span>
<span class="watchlist-page-timer" id="watchlist-next-auto-timer">Next Auto: --</span>
</div>
</div>
</div>
<!-- Scan status / live activity (IDs match old modal for compatibility) -->
<div id="watchlist-scan-status" class="watchlist-page-scan-status" style="display: none;">
<div id="watchlist-live-activity" class="watchlist-live-activity" style="display: none;">
<div class="watchlist-live-activity-col">
<img id="watchlist-artist-img" class="watchlist-live-activity-artist-img" src="" alt="Artist" onerror="this.style.display='none';" />
<div id="watchlist-artist-name" class="watchlist-live-activity-label">Waiting...</div>
</div>
<div class="watchlist-live-activity-col">
<img id="watchlist-album-img" class="watchlist-live-activity-album-img" src="" alt="Album" onerror="this.style.display='none';" />
<div id="watchlist-album-name" class="watchlist-live-activity-label">Waiting...</div>
</div>
<div class="watchlist-live-activity-feed">
<div class="watchlist-live-activity-feed-label">Current Track:</div>
<div id="watchlist-track-name" class="watchlist-live-activity-track">Waiting...</div>
<div class="watchlist-live-activity-feed-label-orange">Recently Added:</div>
<div id="watchlist-additions-feed" style="max-height: 80px; overflow-y: auto; display: flex; flex-direction: column; gap: 4px; font-size: 10px;"></div>
</div>
</div>
<div id="watchlist-page-scan-summary" class="scan-status-summary" style="display: none;"></div>
</div>
<!-- Action buttons -->
<div class="watchlist-page-actions">
<button class="watchlist-action-btn watchlist-action-primary" id="scan-watchlist-btn" onclick="startWatchlistScan()">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
Scan for New Releases
</button>
<button class="watchlist-action-btn watchlist-action-secondary" id="cancel-watchlist-scan-btn" onclick="cancelWatchlistScan()" style="display: none;">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="15" y1="9" x2="9" y2="15"/><line x1="9" y1="9" x2="15" y2="15"/></svg>
Cancel Scan
</button>
<button class="watchlist-action-btn watchlist-action-secondary" id="update-similar-artists-btn" onclick="updateSimilarArtists()">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>
Update Similar Artists
</button>
<button class="watchlist-action-btn watchlist-action-secondary" id="watchlist-page-settings-btn" onclick="openWatchlistGlobalSettingsModal()">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>
Global Settings
</button>
</div>
<!-- Global override banner -->
<div class="watchlist-global-override-banner" id="watchlist-page-override-banner" style="display: none;">
<span>&#9888;&#65039;</span>
<span>Global override is active — per-artist settings are being ignored during scans.</span>
</div>
<!-- Last scan summary -->
<div class="watchlist-last-scan-strip" id="watchlist-last-scan-strip" style="display: none;">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
<span id="watchlist-last-scan-text">Last scan: --</span>
</div>
<!-- Search + Sort row -->
<div class="watchlist-toolbar">
<div class="watchlist-search-container">
<svg class="watchlist-search-icon" width="16" height="16" viewBox="0 0 24 24" fill="rgba(255,255,255,0.35)"><path d="M15.5 14h-.79l-.28-.27A6.471 6.471 0 0016 9.5 6.5 6.5 0 109.5 16c1.61 0 3.09-.59 4.23-1.57l.27.28v.79l5 4.99L20.49 19l-4.99-5zm-6 0C7.01 14 5 11.99 5 9.5S7.01 5 9.5 5 14 7.01 14 9.5 11.99 14 9.5 14z"/></svg>
<input type="text" id="watchlist-search-input" class="watchlist-search-input" placeholder="Search watchlist..." oninput="filterWatchlistArtists()">
</div>
<select class="watchlist-sort-select" id="watchlist-sort-select" onchange="sortWatchlistArtists(this.value)">
<option value="name-asc">Name A-Z</option>
<option value="name-desc">Name Z-A</option>
<option value="scan-oldest">Oldest Scanned</option>
<option value="scan-newest">Recently Scanned</option>
<option value="added-newest">Recently Added</option>
</select>
</div>
<!-- Batch bar -->
<div class="watchlist-batch-bar" id="watchlist-batch-bar">
<label class="watchlist-select-all-label" onclick="event.stopPropagation();">
<input type="checkbox" id="watchlist-select-all-cb" onchange="toggleWatchlistSelectAll(this.checked)">
<span>Select All</span>
</label>
<span class="watchlist-batch-count" id="watchlist-batch-count"></span>
<button class="watchlist-action-btn watchlist-action-secondary watchlist-batch-remove-btn" id="watchlist-batch-remove-btn" onclick="batchRemoveFromWatchlist()" style="display: none;">
Remove Selected
</button>
</div>
<!-- Artist grid -->
<div class="watchlist-artists-grid" id="watchlist-artists-list">
<!-- Populated by JS -->
</div>
<!-- Empty state -->
<div class="watchlist-page-empty" id="watchlist-page-empty" style="display: none;">
<div class="watchlist-page-empty-icon">
<svg width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="rgba(255,255,255,0.15)" stroke-width="1.5"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>
</div>
<h3>Your watchlist is empty</h3>
<p>Add artists from the Artists page to monitor them for new releases.</p>
<button class="watchlist-action-btn watchlist-action-primary" onclick="navigateToPage('artists')">Browse Artists</button>
</div>
</div>
</div>
<!-- ═══════════════════════════════════════════════════════════════════
WISHLIST PAGE
═══════════════════════════════════════════════════════════════════ -->
<div class="page" id="wishlist-page">
<div class="wishlist-page-container">
<!-- Header -->
<div class="wishlist-page-header">
<div class="wishlist-page-header-left">
<h2 class="wishlist-page-title">
<span class="wishlist-page-title-icon">&#127925;</span>
Wishlist
</h2>
<div class="wishlist-page-meta">
<span class="wishlist-page-count" id="wishlist-page-count">0 tracks</span>
<span class="wishlist-page-timer" id="wishlist-next-auto-timer">Next Auto: --</span>
</div>
</div>
<div class="wishlist-page-header-right">
<button class="watchlist-action-btn watchlist-action-secondary" onclick="cleanupWishlistOverview()">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18"/><path d="M8 6V4h8v2"/></svg>
Cleanup
</button>
<button class="watchlist-action-btn watchlist-action-danger" onclick="clearEntireWishlist()">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14H6L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/></svg>
Clear All
</button>
</div>
</div>
<!-- Stats strip -->
<div class="wishlist-stats-strip" id="wishlist-stats-strip">
<div class="wishlist-stat-item">
<span class="wishlist-stat-value" id="wishlist-stat-albums">0</span>
<span class="wishlist-stat-label">Album Tracks</span>
</div>
<div class="wishlist-stat-divider"></div>
<div class="wishlist-stat-item">
<span class="wishlist-stat-value" id="wishlist-stat-singles">0</span>
<span class="wishlist-stat-label">Singles</span>
</div>
<div class="wishlist-stat-divider"></div>
<div class="wishlist-stat-item">
<span class="wishlist-stat-value wishlist-stat-cycle" id="wishlist-stat-cycle">Albums/EPs</span>
<span class="wishlist-stat-label">Next Cycle</span>
</div>
</div>
<!-- Category grid -->
<div class="wishlist-category-grid" id="wishlist-page-categories">
<div class="wishlist-category-card" data-category="albums" onclick="selectWishlistCategory('albums')">
<div class="wishlist-mosaic-bg" id="wishlist-page-albums-mosaic"></div>
<div class="wishlist-category-content">
<div class="wishlist-category-icon">&#128191;</div>
<div class="wishlist-category-title">Albums / EPs</div>
<div class="wishlist-category-count" id="wishlist-page-albums-count">0 tracks</div>
<div class="wishlist-category-badge" id="wishlist-page-albums-badge" style="display: none;">Next in Queue</div>
</div>
</div>
<div class="wishlist-category-card" data-category="singles" onclick="selectWishlistCategory('singles')">
<div class="wishlist-mosaic-bg" id="wishlist-page-singles-mosaic"></div>
<div class="wishlist-category-content">
<div class="wishlist-category-icon">&#127925;</div>
<div class="wishlist-category-title">Singles</div>
<div class="wishlist-category-count" id="wishlist-page-singles-count">0 tracks</div>
<div class="wishlist-category-badge" id="wishlist-page-singles-badge" style="display: none;">Next in Queue</div>
</div>
</div>
</div>
<!-- Track list (hidden until category selected) -->
<div id="wishlist-category-tracks" class="wishlist-category-tracks" style="display: none;">
<div class="wishlist-category-header">
<button class="wishlist-back-btn" onclick="backToCategories()">&#8592; Back</button>
<span id="wishlist-category-name" class="wishlist-category-name"></span>
<div class="wishlist-category-header-right">
<button class="wishlist-select-all-btn" id="wishlist-select-all-btn" onclick="toggleWishlistSelectAll()">Select All</button>
<button id="wishlist-download-btn" class="watchlist-action-btn watchlist-action-primary" style="display: none;" onclick="downloadSelectedCategory()">
Download Selection
</button>
</div>
</div>
<div class="wishlist-track-search-container">
<svg class="wishlist-track-search-icon" width="14" height="14" viewBox="0 0 24 24" fill="rgba(255,255,255,0.35)"><path d="M15.5 14h-.79l-.28-.27A6.471 6.471 0 0016 9.5 6.5 6.5 0 109.5 16c1.61 0 3.09-.59 4.23-1.57l.27.28v.79l5 4.99L20.49 19l-4.99-5zm-6 0C7.01 14 5 11.99 5 9.5S7.01 5 9.5 5 14 7.01 14 9.5 11.99 14 9.5 14z"/></svg>
<input type="text" id="wishlist-track-search-input" class="wishlist-track-search-input" placeholder="Filter tracks..." oninput="filterWishlistTracks()">
</div>
<div class="wishlist-batch-bar" id="wishlist-batch-bar" style="display: none;">
<span class="wishlist-batch-count" id="wishlist-batch-count">0 selected</span>
<button class="watchlist-action-btn watchlist-action-secondary wishlist-batch-remove-btn" onclick="batchRemoveFromWishlist()">
Remove Selected
</button>
</div>
<div id="wishlist-tracks-list" class="wishlist-tracks-scroll">
<div class="loading-indicator">Loading tracks...</div>
</div>
</div>
<!-- Empty state -->
<div class="wishlist-page-empty" id="wishlist-page-empty" style="display: none;">
<div class="wishlist-page-empty-icon">
<svg width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="rgba(255,255,255,0.15)" stroke-width="1.5"><path d="M9 18V5l12-2v13"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="16" r="3"/></svg>
</div>
<h3>Your wishlist is empty</h3>
<p>Failed downloads and tracks from watchlist scans will appear here automatically.</p>
</div>
</div>
</div>
</div> <!-- /main-content -->
</div> <!-- /main-container -->

View file

@ -3602,6 +3602,7 @@ const WHATS_NEW = {
'2.2': [
// --- April 15, 2026 ---
{ date: 'April 15, 2026' },
{ title: 'Watchlist & Wishlist Sidebar Pages', desc: 'Watchlist and Wishlist promoted from modals to full sidebar pages. All features preserved — artist grid, scan controls, batch operations, live activity, countdown timers, category cards with mosaic backgrounds. Header buttons now navigate to the pages', page: 'watchlist' },
{ title: 'Picard-Style MusicBrainz Album Consistency', desc: 'Recording MBIDs now pulled from the matched release tracklist instead of independent searches. Batch-level artist name used for stable cache keys. Post-batch consistency pass rewrites album-level tags on all files to guarantee identical MusicBrainz IDs — prevents Navidrome album splits' },
{ title: 'Fix Spotify API Leaking When Deezer/iTunes is Primary', desc: 'Spotify was being called for watchlist album scanning, similar artist discovery, repair jobs, and the Artists page search even when another source was set as primary. All data-fetching now respects the configured primary source. Spotify playlist sync is unaffected' },
{ title: 'Fix OAuth Callback Port Hardcoding', desc: 'Custom callback ports (SOULSYNC_SPOTIFY_CALLBACK_PORT / SOULSYNC_TIDAL_CALLBACK_PORT) are now respected in auth instruction pages and log messages instead of always showing 8888. Added startup diagnostics logging for callback port binding' },

View file

@ -33,6 +33,10 @@ let wishlistCountInterval = null;
let wishlistCountdownInterval = null; // Countdown timer for wishlist overview modal
let watchlistCountdownInterval = null; // Countdown timer for watchlist overview modal
// Page state for Watchlist & Wishlist sidebar pages
let watchlistPageState = { isInitialized: false, artists: [] };
let wishlistPageState = { isInitialized: false };
// --- Add these globals for the Sync Page ---
let spotifyPlaylists = [];
let selectedPlaylists = new Set();
@ -2795,16 +2799,43 @@ function initializeMobileNavigation() {
}
function initializeWatchlist() {
// Add watchlist button click handler
// Watchlist button navigates to watchlist page
const watchlistButton = document.getElementById('watchlist-button');
if (watchlistButton) {
watchlistButton.addEventListener('click', showWatchlistModal);
watchlistButton.addEventListener('click', () => navigateToPage('watchlist'));
}
// Add wishlist button click handler (global init so it works on all pages)
// Wishlist button: check for active download process first, otherwise navigate to page
const wishlistButton = document.getElementById('wishlist-button');
if (wishlistButton) {
wishlistButton.addEventListener('click', handleWishlistButtonClick);
wishlistButton.addEventListener('click', async () => {
try {
const resp = await fetch('/api/active-processes');
if (resp.ok) {
const data = await resp.json();
const serverProcess = (data.active_processes || []).find(p => p.playlist_id === 'wishlist');
if (serverProcess) {
// Active wishlist download — show the download progress modal
WishlistModalState.clearUserClosed();
const clientProcess = activeDownloadProcesses['wishlist'];
const needsRehydration = !clientProcess ||
clientProcess.batchId !== serverProcess.batch_id ||
!clientProcess.modalElement ||
!document.body.contains(clientProcess.modalElement);
if (needsRehydration) {
await rehydrateModal(serverProcess, true);
} else {
clientProcess.modalElement.style.display = 'flex';
WishlistModalState.setVisible();
}
return;
}
}
} catch (e) {
console.debug('Could not check active processes:', e);
}
navigateToPage('wishlist');
});
}
// Update watchlist count initially
@ -2920,6 +2951,9 @@ async function loadPageData(pageId) {
stopDbUpdatePolling();
stopWishlistCountPolling();
stopLogPolling();
// Stop watchlist/wishlist page timers when navigating away
if (watchlistCountdownInterval) { clearInterval(watchlistCountdownInterval); watchlistCountdownInterval = null; }
if (wishlistCountdownInterval) { clearInterval(wishlistCountdownInterval); wishlistCountdownInterval = null; }
switch (pageId) {
case 'dashboard':
await loadDashboardData();
@ -3014,6 +3048,12 @@ async function loadPageData(pageId) {
// Load comparisons
loadHydrabaseComparisons();
break;
case 'watchlist':
await initializeWatchlistPage();
break;
case 'wishlist':
await initializeWishlistPage();
break;
case 'automations':
await loadAutomations();
break;
@ -3528,8 +3568,8 @@ let _wishlistAutoProcessingNotified = false;
function updateWishlistStatsFromData(data) {
// Auto-processing detection: close modal and notify (once only)
if (data.is_auto_processing) {
if (!_wishlistAutoProcessingNotified && typeof closeWishlistOverviewModal === 'function') {
closeWishlistOverviewModal();
if (!_wishlistAutoProcessingNotified) {
if (currentPage === 'wishlist') navigateToPage('active-downloads');
showToast('Wishlist auto-processing started. View progress in Download Manager.', 'info');
_wishlistAutoProcessingNotified = true;
}
@ -13600,7 +13640,7 @@ function startWishlistCountdownTimer(currentCycle, initialSeconds) {
const data = _lastWishlistStats;
if (data.is_auto_processing) {
if (!_wishlistAutoProcessingNotified) {
closeWishlistOverviewModal();
navigateToPage('active-downloads');
showToast('Wishlist auto-processing started. View progress in Download Manager.', 'info');
_wishlistAutoProcessingNotified = true;
}
@ -13713,13 +13753,14 @@ async function cleanupWishlistOverview() {
const statsData = await statsResponse.json();
if (statsData.total === 0) {
// Wishlist is empty, just close the modal
closeWishlistOverviewModal();
// Wishlist is empty, refresh the page to show empty state
wishlistPageState.isInitialized = false;
await initializeWishlistPage();
await updateWishlistCount();
} else {
// Wishlist still has items, refresh the modal to show updated counts
closeWishlistOverviewModal();
await openWishlistOverviewModal();
// Wishlist still has items, refresh the page to show updated counts
wishlistPageState.isInitialized = false;
await initializeWishlistPage();
}
} else {
showToast(`Failed to cleanup wishlist: ${result.error || 'Unknown error'}`, 'error');
@ -13766,9 +13807,9 @@ async function clearEntireWishlist() {
console.log('Updating wishlist button count...');
await updateWishlistCount();
console.log('Closing modal...');
closeWishlistOverviewModal();
console.log('Modal should be closed now');
console.log('Refreshing wishlist page...');
wishlistPageState.isInitialized = false;
await initializeWishlistPage();
} else {
console.error('Clear failed:', result.error);
showToast(`Failed to clear wishlist: ${result.error || 'Unknown error'}`, 'error');
@ -14071,11 +14112,13 @@ function backToCategories() {
const categoryGrid = document.querySelector('.wishlist-category-grid');
const downloadBtn = document.getElementById('wishlist-download-btn');
const batchBar = document.getElementById('wishlist-batch-bar');
const trackSearch = document.getElementById('wishlist-track-search-input');
categoryTracksSection.style.display = 'none';
categoryGrid.style.display = 'grid';
downloadBtn.style.display = 'none';
if (batchBar) batchBar.style.display = 'none';
if (trackSearch) trackSearch.value = '';
window.selectedWishlistCategory = null;
}
@ -14383,11 +14426,10 @@ async function downloadSelectedCategory() {
return;
}
// Collect checked track IDs BEFORE closing the modal (checkboxes are destroyed on close)
// Collect checked track IDs
const checkedBoxes = document.querySelectorAll('.wishlist-select-cb:checked');
const selectedTrackIds = new Set(Array.from(checkedBoxes).map(cb => cb.dataset.trackId).filter(Boolean));
closeWishlistOverviewModal();
await openDownloadMissingWishlistModal(category, selectedTrackIds.size > 0 ? selectedTrackIds : null);
}
@ -39937,7 +39979,377 @@ async function updateArtistCardWatchlistStatus() {
}
/**
* Show watchlist modal
* Initialize/refresh the watchlist sidebar page
*/
async function initializeWatchlistPage() {
try {
const emptyEl = document.getElementById('watchlist-page-empty');
const gridEl = document.getElementById('watchlist-artists-list');
const countEl = document.getElementById('watchlist-page-count');
const overrideBanner = document.getElementById('watchlist-page-override-banner');
// Fetch count, artists, scan status, global config in parallel
const [countRes, artistsRes, statusRes, globalRes] = await Promise.all([
fetch('/api/watchlist/count').then(r => r.json()),
fetch('/api/watchlist/artists').then(r => r.json()),
fetch('/api/watchlist/scan/status').then(r => r.json()),
fetch('/api/watchlist/global-config').then(r => r.json()).catch(() => ({ success: false })),
]);
const count = countRes.success ? countRes.count : 0;
const artists = artistsRes.success ? artistsRes.artists : [];
const scanStatus = statusRes.success ? statusRes.status : 'idle';
const globalOverrideActive = globalRes.success && globalRes.config && globalRes.config.global_override_enabled;
// Update count
if (countEl) countEl.textContent = `${count} artist${count !== 1 ? 's' : ''}`;
// Update nav badge
const navBadge = document.getElementById('watchlist-nav-badge');
if (navBadge) {
navBadge.textContent = count;
navBadge.classList.toggle('hidden', count === 0);
}
// Empty state
if (count === 0) {
if (emptyEl) emptyEl.style.display = '';
if (gridEl) gridEl.style.display = 'none';
watchlistPageState.isInitialized = true;
return;
}
if (emptyEl) emptyEl.style.display = 'none';
if (gridEl) gridEl.style.display = '';
// Store artists for sorting
watchlistPageState.artists = artists;
// Last scan summary strip
const scanStrip = document.getElementById('watchlist-last-scan-strip');
const scanText = document.getElementById('watchlist-last-scan-text');
if (scanStrip && scanText && statusRes.completed_at && statusRes.summary) {
const completedDate = new Date(statusRes.completed_at);
const ago = _formatTimeAgo(completedDate);
const found = statusRes.summary.new_tracks_found || 0;
const added = statusRes.summary.tracks_added_to_wishlist || 0;
scanText.textContent = `Last scan: ${ago}${found} new track${found !== 1 ? 's' : ''} found, ${added} added to wishlist`;
scanStrip.style.display = '';
} else if (scanStrip) {
scanStrip.style.display = 'none';
}
// Global override banner
if (overrideBanner) overrideBanner.style.display = globalOverrideActive ? '' : 'none';
const settingsBtn = document.getElementById('watchlist-page-settings-btn');
if (settingsBtn) {
settingsBtn.classList.toggle('watchlist-global-settings-active', globalOverrideActive);
settingsBtn.innerHTML = `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg> ${globalOverrideActive ? 'Global Override ON' : 'Global Settings'}`;
}
// Render artist cards
if (gridEl) {
gridEl.innerHTML = artists.map(artist => {
const pills = [];
if (artist.include_albums) pills.push('<span class="watchlist-pill watchlist-pill-active">Albums</span>');
if (artist.include_eps) pills.push('<span class="watchlist-pill watchlist-pill-active">EPs</span>');
if (artist.include_singles) pills.push('<span class="watchlist-pill watchlist-pill-active">Singles</span>');
if (artist.include_live) pills.push('<span class="watchlist-pill watchlist-pill-filter">Live</span>');
if (artist.include_remixes) pills.push('<span class="watchlist-pill watchlist-pill-filter">Remixes</span>');
if (artist.include_acoustic) pills.push('<span class="watchlist-pill watchlist-pill-filter">Acoustic</span>');
if (artist.include_compilations) pills.push('<span class="watchlist-pill watchlist-pill-filter">Compilations</span>');
const sourceBadges = [];
if (artist.spotify_artist_id) sourceBadges.push('<span class="watchlist-source-badge watchlist-source-spotify">Spotify</span>');
if (artist.itunes_artist_id) sourceBadges.push('<span class="watchlist-source-badge watchlist-source-itunes">iTunes</span>');
if (artist.deezer_artist_id) sourceBadges.push('<span class="watchlist-source-badge watchlist-source-deezer">Deezer</span>');
if (artist.discogs_artist_id) sourceBadges.push('<span class="watchlist-source-badge watchlist-source-discogs">Discogs</span>');
const artistPrimaryId = artist.spotify_artist_id || artist.itunes_artist_id || artist.deezer_artist_id || artist.discogs_artist_id;
return `
<div class="watchlist-artist-card"
data-artist-name="${artist.artist_name.toLowerCase().replace(/"/g, '&quot;')}"
data-artist-id="${artistPrimaryId}"
data-last-scan="${artist.last_scan_timestamp || ''}"
data-added="${artist.date_added || ''}">
<label class="watchlist-card-checkbox" onclick="event.stopPropagation();">
<input type="checkbox" class="watchlist-select-cb"
data-artist-id="${artistPrimaryId}"
data-artist-name="${escapeHtml(artist.artist_name)}"
onchange="updateWatchlistBatchBar()">
<span class="watchlist-checkbox-custom"></span>
</label>
<button class="watchlist-card-gear"
data-artist-id="${artistPrimaryId}"
data-artist-name="${escapeHtml(artist.artist_name)}"
onclick="event.stopPropagation();"
title="Artist settings">
<svg viewBox="0 0 24 24"><path d="M19.14 12.94c.04-.3.06-.61.06-.94 0-.32-.02-.64-.07-.94l2.03-1.58a.49.49 0 00.12-.61l-1.92-3.32a.49.49 0 00-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54a.48.48 0 00-.48-.41h-3.84a.48.48 0 00-.48.41l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96a.49.49 0 00-.59.22L2.74 8.87a.48.48 0 00.12.61l2.03 1.58c-.05.3-.07.62-.07.94s.02.64.07.94l-2.03 1.58a.49.49 0 00-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.26.41.48.41h3.84c.24 0 .44-.17.48-.41l.36-2.54c.59-.24 1.13-.56 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32c.12-.22.07-.47-.12-.61l-2.01-1.58zM12 15.6A3.6 3.6 0 1115.6 12 3.6 3.6 0 0112 15.6z"/></svg>
</button>
<div class="watchlist-card-image">
${artist.image_url ? `<img src="${artist.image_url}" alt="${escapeHtml(artist.artist_name)}" onerror="if(!this.dataset.retried){this.dataset.retried='1';this.src=this.src;}else{this.parentElement.innerHTML='<div class=\\'watchlist-card-image-fallback\\'>🎤</div>';}">` : '<div class="watchlist-card-image-fallback">🎤</div>'}
</div>
<div class="watchlist-card-info">
<span class="watchlist-card-name">${escapeHtml(artist.artist_name)}</span>
<span class="watchlist-card-meta">${formatRelativeScanTime(artist.last_scan_timestamp)}</span>
</div>
${sourceBadges.length > 0 ? `<div class="watchlist-card-sources">${sourceBadges.join('')}</div>` : ''}
${pills.length > 0 ? `<div class="watchlist-card-pills">${pills.join('')}</div>` : ''}
</div>
`;
}).join('');
// Wire up gear buttons
gridEl.querySelectorAll('.watchlist-card-gear').forEach(button => {
button.addEventListener('click', () => {
openWatchlistArtistConfigModal(button.getAttribute('data-artist-id'), button.getAttribute('data-artist-name'));
});
});
// Wire up artist card clicks
gridEl.querySelectorAll('.watchlist-artist-card').forEach(item => {
item.addEventListener('click', (e) => {
if (e.target.closest('.watchlist-card-gear') || e.target.closest('.watchlist-card-checkbox')) return;
const artistId = item.getAttribute('data-artist-id');
const artistName = item.querySelector('.watchlist-card-name').textContent;
openWatchlistArtistDetailView(artistId, artistName);
});
});
}
// Scan status
const scanStatusEl = document.getElementById('watchlist-scan-status');
const liveActivityEl = document.getElementById('watchlist-live-activity');
const scanBtn = document.getElementById('scan-watchlist-btn');
const cancelBtn = document.getElementById('cancel-watchlist-scan-btn');
if (scanStatus === 'scanning') {
if (scanStatusEl) scanStatusEl.style.display = '';
if (liveActivityEl) liveActivityEl.style.display = 'flex';
if (scanBtn) { scanBtn.disabled = true; scanBtn.classList.add('btn-processing'); scanBtn.innerHTML = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg> Scanning...'; }
if (cancelBtn) cancelBtn.style.display = '';
pollWatchlistScanStatus();
} else {
if (scanStatusEl && statusRes.summary) {
scanStatusEl.style.display = '';
const summaryEl = document.getElementById('watchlist-page-scan-summary');
if (summaryEl) {
summaryEl.style.display = '';
summaryEl.innerHTML = `<span class="sync-stat">Artists: ${statusRes.summary.total_artists || 0}</span><span class="sync-separator"> • </span><span class="sync-stat">New tracks: ${statusRes.summary.new_tracks_found || 0}</span><span class="sync-separator"> • </span><span class="sync-stat">Added to wishlist: ${statusRes.summary.tracks_added_to_wishlist || 0}</span>`;
}
}
}
// Start countdown timer
const nextRunSeconds = countRes.next_run_in_seconds || 0;
startWatchlistCountdownTimer(nextRunSeconds);
watchlistPageState.isInitialized = true;
} catch (error) {
console.error('Error initializing watchlist page:', error);
showToast('Failed to load watchlist', 'error');
}
}
/**
* Initialize/refresh the wishlist sidebar page
*/
async function initializeWishlistPage() {
try {
const emptyEl = document.getElementById('wishlist-page-empty');
const categoriesEl = document.getElementById('wishlist-page-categories');
const countEl = document.getElementById('wishlist-page-count');
const tracksSection = document.getElementById('wishlist-category-tracks');
// Fetch stats and cycle
const [statsRes, cycleRes] = await Promise.all([
fetch('/api/wishlist/stats').then(r => r.json()),
fetch('/api/wishlist/cycle').then(r => r.json()),
]);
const { singles = 0, albums = 0, total = 0 } = statsRes;
const currentCycle = cycleRes.cycle || 'albums';
// Update count
if (countEl) countEl.textContent = `${total} track${total !== 1 ? 's' : ''}`;
// Update nav badge
const navBadge = document.getElementById('wishlist-nav-badge');
if (navBadge) {
navBadge.textContent = total;
navBadge.classList.toggle('hidden', total === 0);
}
// Stats strip
const statAlbums = document.getElementById('wishlist-stat-albums');
const statSingles = document.getElementById('wishlist-stat-singles');
const statCycle = document.getElementById('wishlist-stat-cycle');
const statsStrip = document.getElementById('wishlist-stats-strip');
if (statAlbums) statAlbums.textContent = albums;
if (statSingles) statSingles.textContent = singles;
if (statCycle) statCycle.textContent = currentCycle === 'albums' ? 'Albums/EPs' : 'Singles';
// Empty state
if (total === 0) {
if (emptyEl) emptyEl.style.display = '';
if (categoriesEl) categoriesEl.style.display = 'none';
if (tracksSection) tracksSection.style.display = 'none';
if (statsStrip) statsStrip.style.display = 'none';
wishlistPageState.isInitialized = true;
return;
}
if (emptyEl) emptyEl.style.display = 'none';
if (categoriesEl) categoriesEl.style.display = '';
if (statsStrip) statsStrip.style.display = '';
// Reset to category view
if (tracksSection) tracksSection.style.display = 'none';
if (categoriesEl) categoriesEl.style.display = '';
// Update category cards
const albumsCountEl = document.getElementById('wishlist-page-albums-count');
const singlesCountEl = document.getElementById('wishlist-page-singles-count');
const albumsBadge = document.getElementById('wishlist-page-albums-badge');
const singlesBadge = document.getElementById('wishlist-page-singles-badge');
if (albumsCountEl) albumsCountEl.textContent = `${albums} tracks`;
if (singlesCountEl) singlesCountEl.textContent = `${singles} tracks`;
if (albumsBadge) albumsBadge.style.display = currentCycle === 'albums' ? '' : 'none';
if (singlesBadge) singlesBadge.style.display = currentCycle === 'singles' ? '' : 'none';
// Add/remove next-in-queue class
const albumCard = categoriesEl.querySelector('[data-category="albums"]');
const singleCard = categoriesEl.querySelector('[data-category="singles"]');
if (albumCard) albumCard.classList.toggle('next-in-queue', currentCycle === 'albums');
if (singleCard) singleCard.classList.toggle('next-in-queue', currentCycle === 'singles');
// Load mosaic covers
try {
const [albumTracksData, singleTracksData] = await Promise.all([
fetch('/api/wishlist/tracks?category=albums&limit=50').then(r => r.json()),
fetch('/api/wishlist/tracks?category=singles&limit=50').then(r => r.json()),
]);
const albumCovers = extractUniqueCoverImages(albumTracksData.tracks || [], 20);
const singleCovers = extractUniqueCoverImages(singleTracksData.tracks || [], 20);
const albumsMosaic = document.getElementById('wishlist-page-albums-mosaic');
const singlesMosaic = document.getElementById('wishlist-page-singles-mosaic');
if (albumsMosaic) albumsMosaic.innerHTML = generateMosaicBackground(albumCovers);
if (singlesMosaic) singlesMosaic.innerHTML = generateMosaicBackground(singleCovers);
} catch (e) {
console.debug('Could not load mosaic covers:', e);
}
// Start countdown timer
const nextRunSeconds = statsRes.next_run_in_seconds || 0;
const nextCycleText = currentCycle === 'albums' ? 'Albums/EPs' : 'Singles';
startWishlistCountdownTimer(currentCycle, nextRunSeconds);
wishlistPageState.isInitialized = true;
} catch (error) {
console.error('Error initializing wishlist page:', error);
showToast('Failed to load wishlist', 'error');
}
}
/**
* Sort the watchlist artist grid by the selected criteria.
*/
function sortWatchlistArtists(sortBy) {
const grid = document.getElementById('watchlist-artists-list');
if (!grid) return;
const cards = Array.from(grid.querySelectorAll('.watchlist-artist-card'));
if (cards.length === 0) return;
cards.sort((a, b) => {
switch (sortBy) {
case 'name-asc':
return (a.dataset.artistName || '').localeCompare(b.dataset.artistName || '');
case 'name-desc':
return (b.dataset.artistName || '').localeCompare(a.dataset.artistName || '');
case 'scan-oldest': {
const aTime = a.dataset.lastScan ? new Date(a.dataset.lastScan).getTime() : 0;
const bTime = b.dataset.lastScan ? new Date(b.dataset.lastScan).getTime() : 0;
return aTime - bTime; // oldest first (never scanned = 0 = top)
}
case 'scan-newest': {
const aTime = a.dataset.lastScan ? new Date(a.dataset.lastScan).getTime() : 0;
const bTime = b.dataset.lastScan ? new Date(b.dataset.lastScan).getTime() : 0;
return bTime - aTime;
}
case 'added-newest': {
const aTime = a.dataset.added ? new Date(a.dataset.added).getTime() : 0;
const bTime = b.dataset.added ? new Date(b.dataset.added).getTime() : 0;
return bTime - aTime;
}
default:
return 0;
}
});
// Re-append in sorted order (preserves event listeners)
cards.forEach(card => grid.appendChild(card));
}
/**
* Filter wishlist tracks by search query within the active track list.
*/
function filterWishlistTracks() {
const input = document.getElementById('wishlist-track-search-input');
if (!input) return;
const query = input.value.toLowerCase().trim();
const tracksList = document.getElementById('wishlist-tracks-list');
if (!tracksList) return;
// For albums view: filter album cards by album name or track names within
const albumCards = tracksList.querySelectorAll('.wishlist-album-card');
if (albumCards.length > 0) {
albumCards.forEach(card => {
const albumHeader = card.querySelector('.wishlist-album-header');
const albumName = (albumHeader?.querySelector('.wishlist-album-name')?.textContent || '').toLowerCase();
const artistName = (albumHeader?.querySelector('.wishlist-album-artist')?.textContent || '').toLowerCase();
const tracks = card.querySelectorAll('.wishlist-album-track');
let albumHasMatch = !query || albumName.includes(query) || artistName.includes(query);
// Also check individual track names
if (!albumHasMatch && tracks.length > 0) {
tracks.forEach(track => {
const trackName = (track.textContent || '').toLowerCase();
if (trackName.includes(query)) albumHasMatch = true;
});
}
card.style.display = albumHasMatch ? '' : 'none';
});
return;
}
// For singles view: filter individual track rows
const trackRows = tracksList.querySelectorAll('.playlist-track-item-with-image, .playlist-track-item');
trackRows.forEach(row => {
const text = (row.textContent || '').toLowerCase();
row.style.display = (!query || text.includes(query)) ? '' : 'none';
});
}
/**
* Format a Date object as a relative time string (e.g. "2 hours ago")
*/
function _formatTimeAgo(date) {
const now = new Date();
const diffMs = now - date;
const diffMins = Math.floor(diffMs / 60000);
if (diffMins < 1) return 'just now';
if (diffMins < 60) return `${diffMins}m ago`;
const diffHours = Math.floor(diffMins / 60);
if (diffHours < 24) return `${diffHours}h ago`;
const diffDays = Math.floor(diffHours / 24);
if (diffDays === 1) return 'yesterday';
if (diffDays < 7) return `${diffDays}d ago`;
return date.toLocaleDateString();
}
/**
* Show watchlist modal (legacy kept for backward compatibility)
*/
async function showWatchlistModal() {
try {
@ -40725,9 +41137,8 @@ async function openWatchlistArtistDetailView(artistId, artistName) {
source = spotify_artist_id ? 'spotify' : discogs_artist_id ? 'discogs' : deezer_artist_id ? 'deezer' : 'itunes';
}
if (discogId) {
// Close watchlist modal + detail overlay
// Close detail overlay and navigate to Artists page
closeWatchlistArtistDetailView();
closeWatchlistModal();
// Navigate to Artists page and load discography
navigateToPage('artists');
setTimeout(() => {
@ -40750,13 +41161,10 @@ async function openWatchlistArtistDetailView(artistId, artistName) {
removeFromWatchlistModal(artistId, artistName);
});
// Append to the modal container
const container = document.querySelector('.watchlist-fullscreen');
if (container) {
container.appendChild(overlay);
// Trigger slide-in animation
requestAnimationFrame(() => overlay.classList.add('visible'));
}
// Append to body as a fixed overlay
document.body.appendChild(overlay);
// Trigger slide-in animation
requestAnimationFrame(() => overlay.classList.add('visible'));
} catch (error) {
console.error('Error opening artist detail view:', error);
@ -40932,10 +41340,10 @@ async function saveWatchlistGlobalConfig() {
showToast('Global watchlist settings saved', 'success');
closeWatchlistGlobalSettingsModal();
// Refresh the watchlist modal to update button and banner
const watchlistModal = document.getElementById('watchlist-modal');
if (watchlistModal && watchlistModal.style.display === 'flex') {
await showWatchlistModal();
// Refresh the watchlist page to update the grid
if (currentPage === 'watchlist') {
watchlistPageState.isInitialized = false;
await initializeWatchlistPage();
}
} else {
showToast(`Error: ${data.error}`, 'error');
@ -41006,10 +41414,10 @@ async function saveWatchlistArtistConfig(artistId) {
showToast('Artist preferences saved successfully', 'success');
closeWatchlistArtistConfigModal();
// Refresh watchlist modal if it's open
const watchlistModal = document.getElementById('watchlist-modal');
if (watchlistModal && watchlistModal.style.display === 'flex') {
await showWatchlistModal();
// Refresh watchlist page if we're on it
if (currentPage === 'watchlist') {
watchlistPageState.isInitialized = false;
await initializeWatchlistPage();
}
} else {
showToast(`Error saving preferences: ${data.error}`, 'error');
@ -41494,8 +41902,9 @@ async function removeFromWatchlistModal(artistId, artistName) {
// Close detail view if open
closeWatchlistArtistDetailView();
// Refresh the modal
showWatchlistModal();
// Refresh the watchlist page
watchlistPageState.isInitialized = false;
await initializeWatchlistPage();
// Update button count
updateWatchlistButtonCount();
@ -41585,8 +41994,9 @@ async function batchRemoveFromWatchlist() {
console.log(`❌ Batch removed ${data.removed} artists from watchlist`);
// Refresh the modal
showWatchlistModal();
// Refresh the watchlist page
watchlistPageState.isInitialized = false;
await initializeWatchlistPage();
// Update button count
updateWatchlistButtonCount();

View file

@ -15564,13 +15564,13 @@ body.helper-mode-active #dashboard-activity-feed:hover {
/* Watchlist Artist Detail Overlay */
.watchlist-artist-detail-overlay {
position: absolute;
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(18, 18, 18, 0.98);
z-index: 10;
z-index: 1000;
transform: translateX(100%);
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
overflow-y: auto;
@ -55796,3 +55796,790 @@ body.reduce-effects *::after {
min-width: 70px;
}
}
/*
WATCHLIST PAGE
*/
.watchlist-page-container {
padding: 28px 24px 30px;
max-width: 1400px;
margin: 0 auto;
background: linear-gradient(135deg,
rgba(20, 20, 20, 0.55) 0%,
rgba(12, 12, 12, 0.62) 100%);
border-radius: 24px;
border: 1px solid rgba(255, 255, 255, 0.08);
border-top: 1px solid rgba(255, 255, 255, 0.12);
box-shadow:
0 8px 32px rgba(0, 0, 0, 0.3),
0 4px 16px rgba(0, 0, 0, 0.2),
inset 0 1px 0 rgba(255, 255, 255, 0.08);
position: relative;
}
/* ── Header ── */
.watchlist-page-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
padding: 20px 24px 18px;
margin: -28px -24px 20px -24px;
background: linear-gradient(180deg,
rgba(255, 193, 7, 0.08) 0%,
rgba(255, 193, 7, 0.03) 40%,
transparent 100%);
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
border-top-left-radius: 24px;
border-top-right-radius: 24px;
position: relative;
}
.watchlist-page-header::after {
content: '';
position: absolute;
bottom: -1px;
left: 10%;
right: 10%;
height: 1px;
background: linear-gradient(90deg,
transparent 0%,
rgba(255, 193, 7, 0.2) 20%,
rgba(255, 193, 7, 0.35) 50%,
rgba(255, 193, 7, 0.2) 80%,
transparent 100%);
}
.watchlist-page-header-left {
display: flex;
flex-direction: column;
gap: 6px;
}
.watchlist-page-title {
display: flex;
align-items: center;
gap: 10px;
font-size: 26px;
font-weight: 700;
color: #fff;
margin: 0;
letter-spacing: -0.5px;
font-family: 'SF Pro Display', -apple-system, BlinkMacSystemFont, sans-serif;
}
.watchlist-page-meta {
display: flex;
align-items: center;
gap: 16px;
font-size: 13px;
color: rgba(255, 255, 255, 0.45);
font-weight: 500;
}
.watchlist-page-meta span {
display: flex;
align-items: center;
gap: 5px;
}
.watchlist-page-count {
color: rgba(255, 193, 7, 0.8);
font-weight: 600;
}
.watchlist-page-timer {
padding-left: 16px;
border-left: 1px solid rgba(255, 255, 255, 0.08);
}
/* ── Action buttons ── */
.watchlist-page-actions {
display: flex;
gap: 8px;
align-items: center;
flex-wrap: wrap;
margin-bottom: 20px;
padding: 0 4px;
}
.watchlist-action-btn {
display: inline-flex;
align-items: center;
gap: 7px;
padding: 9px 18px;
border-radius: 10px;
border: 1px solid transparent;
cursor: pointer;
font-size: 13px;
font-weight: 500;
transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
white-space: nowrap;
letter-spacing: 0.1px;
}
.watchlist-action-primary {
background: linear-gradient(135deg, rgb(var(--accent-rgb)), rgba(var(--accent-rgb), 0.85));
color: #fff;
border-color: rgba(var(--accent-rgb), 0.3);
box-shadow: 0 2px 8px rgba(var(--accent-rgb), 0.2);
}
.watchlist-action-primary:hover {
transform: translateY(-1px);
box-shadow: 0 4px 14px rgba(var(--accent-rgb), 0.3);
filter: brightness(1.1);
}
.watchlist-action-primary:disabled {
opacity: 0.45;
cursor: not-allowed;
transform: none;
box-shadow: none;
}
.watchlist-action-secondary {
background: rgba(255, 255, 255, 0.05);
color: rgba(255, 255, 255, 0.7);
border-color: rgba(255, 255, 255, 0.08);
}
.watchlist-action-secondary:hover {
background: rgba(255, 255, 255, 0.1);
color: #fff;
border-color: rgba(255, 255, 255, 0.15);
transform: translateY(-1px);
}
.watchlist-action-danger {
background: rgba(239, 68, 68, 0.1);
color: #f87171;
border-color: rgba(239, 68, 68, 0.15);
}
.watchlist-action-danger:hover {
background: rgba(239, 68, 68, 0.18);
border-color: rgba(239, 68, 68, 0.25);
transform: translateY(-1px);
}
/* ── Scan status card ── */
.watchlist-page-scan-status {
display: flex;
flex-direction: column;
align-items: center;
padding: 18px 24px;
background: linear-gradient(135deg,
rgba(20, 20, 20, 0.95) 0%,
rgba(12, 12, 12, 0.98) 100%);
border-radius: 14px;
margin-bottom: 20px;
border: 1px solid rgba(255, 255, 255, 0.08);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.3),
inset 0 1px 0 rgba(255, 255, 255, 0.06);
position: relative;
overflow: hidden;
}
.watchlist-page-scan-status::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 2px;
background: linear-gradient(90deg, transparent, rgba(var(--accent-rgb), 0.5), transparent);
opacity: 0;
transition: opacity 0.3s;
}
.watchlist-page-scan-status:has(.watchlist-live-activity[style*="flex"])::before {
opacity: 1;
animation: scanPulse 2s ease-in-out infinite;
}
@keyframes scanPulse {
0%, 100% { opacity: 0.5; }
50% { opacity: 1; }
}
/* ── Last scan summary strip ── */
.watchlist-last-scan-strip {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 16px;
background: linear-gradient(135deg,
rgba(255, 193, 7, 0.06) 0%,
rgba(255, 152, 0, 0.03) 100%);
border: 1px solid rgba(255, 193, 7, 0.1);
border-radius: 10px;
margin-bottom: 16px;
font-size: 13px;
color: rgba(255, 255, 255, 0.55);
font-weight: 500;
}
.watchlist-last-scan-strip svg {
color: rgba(255, 193, 7, 0.6);
flex-shrink: 0;
}
/* ── Toolbar (search + sort) ── */
.watchlist-toolbar {
display: flex;
gap: 10px;
align-items: center;
margin-bottom: 16px;
padding: 0 4px;
}
.watchlist-toolbar .watchlist-search-container {
flex: 1;
margin-bottom: 0;
}
.watchlist-sort-select {
padding: 10px 32px 10px 14px;
border: 1px solid rgba(255, 255, 255, 0.1);
background: rgba(255, 255, 255, 0.04);
color: rgba(255, 255, 255, 0.65);
font-size: 13px;
font-weight: 500;
border-radius: 10px;
cursor: pointer;
transition: all 0.2s;
appearance: none;
-webkit-appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='rgba(255,255,255,0.4)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 10px center;
white-space: nowrap;
}
.watchlist-sort-select:hover {
border-color: rgba(255, 255, 255, 0.2);
color: rgba(255, 255, 255, 0.85);
}
.watchlist-sort-select:focus {
border-color: rgba(255, 193, 7, 0.4);
outline: none;
box-shadow: 0 0 12px rgba(255, 193, 7, 0.1);
}
.watchlist-sort-select option {
background: #1a1a1a;
color: #fff;
}
/* ── Search bar (page-level) ── */
.watchlist-page-container .watchlist-search-container {
margin-bottom: 16px;
padding: 0;
position: relative;
}
.watchlist-page-container .watchlist-search-input {
width: 100%;
padding: 12px 20px 12px 44px;
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 12px;
background: rgba(255, 255, 255, 0.04);
color: #ffffff;
font-size: 14px;
font-weight: 500;
transition: all 0.3s ease;
box-sizing: border-box;
}
.watchlist-page-container .watchlist-search-input:focus {
border-color: rgba(255, 193, 7, 0.5);
background: rgba(255, 255, 255, 0.06);
box-shadow: 0 0 16px rgba(255, 193, 7, 0.12);
outline: none;
}
.watchlist-page-container .watchlist-search-icon {
left: 20px;
}
/* ── Artist grid (page-level) ── */
.watchlist-page-container .watchlist-artists-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 16px;
padding: 4px;
}
/* ── Artist cards upgrade ── */
.watchlist-page-container .watchlist-artist-card {
background: linear-gradient(135deg,
rgba(26, 26, 26, 0.95) 0%,
rgba(18, 18, 18, 0.98) 100%);
border: 1px solid rgba(255, 255, 255, 0.07);
border-top: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 14px;
box-shadow:
0 4px 12px rgba(0, 0, 0, 0.3),
inset 0 1px 0 rgba(255, 255, 255, 0.05);
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
position: relative;
overflow: hidden;
contain: none;
}
.watchlist-page-container .watchlist-artist-card::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 2px;
background: linear-gradient(90deg, transparent, rgba(255, 193, 7, 0.4), transparent);
opacity: 0;
transition: opacity 0.3s;
}
.watchlist-page-container .watchlist-artist-card:hover {
transform: translateY(-3px);
border-color: rgba(255, 193, 7, 0.15);
box-shadow:
0 8px 24px rgba(0, 0, 0, 0.4),
0 0 20px rgba(255, 193, 7, 0.06),
inset 0 1px 0 rgba(255, 255, 255, 0.08);
}
.watchlist-page-container .watchlist-artist-card:hover::before {
opacity: 1;
}
/* ── Batch bar ── */
.watchlist-page-container .watchlist-batch-bar {
background: rgba(255, 255, 255, 0.03);
border: 1px solid rgba(255, 255, 255, 0.06);
border-radius: 10px;
padding: 8px 16px;
margin-bottom: 16px;
}
/* ── Override banner ── */
.watchlist-page-container .watchlist-global-override-banner {
background: linear-gradient(135deg,
rgba(255, 193, 7, 0.08) 0%,
rgba(255, 152, 0, 0.05) 100%);
border: 1px solid rgba(255, 193, 7, 0.15);
border-radius: 10px;
padding: 10px 16px;
margin-bottom: 16px;
font-size: 13px;
color: rgba(255, 193, 7, 0.85);
display: flex;
align-items: center;
gap: 8px;
}
/* ── Empty state ── */
.watchlist-page-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 80px 20px;
text-align: center;
}
.watchlist-page-empty-icon {
opacity: 0.15;
margin-bottom: 8px;
}
.watchlist-page-empty h3 {
margin: 12px 0 8px;
color: rgba(255, 255, 255, 0.7);
font-size: 20px;
font-weight: 700;
letter-spacing: -0.3px;
}
.watchlist-page-empty p {
margin: 0 0 24px;
font-size: 14px;
color: rgba(255, 255, 255, 0.35);
max-width: 360px;
line-height: 1.5;
}
/*
WISHLIST PAGE
*/
.wishlist-page-container {
padding: 28px 24px 30px;
max-width: 1400px;
margin: 0 auto;
background: linear-gradient(135deg,
rgba(20, 20, 20, 0.55) 0%,
rgba(12, 12, 12, 0.62) 100%);
border-radius: 24px;
border: 1px solid rgba(255, 255, 255, 0.08);
border-top: 1px solid rgba(255, 255, 255, 0.12);
box-shadow:
0 8px 32px rgba(0, 0, 0, 0.3),
0 4px 16px rgba(0, 0, 0, 0.2),
inset 0 1px 0 rgba(255, 255, 255, 0.08);
position: relative;
}
/* ── Header ── */
.wishlist-page-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
padding: 20px 24px 18px;
margin: -28px -24px 24px -24px;
background: linear-gradient(180deg,
rgba(var(--accent-rgb), 0.08) 0%,
rgba(var(--accent-rgb), 0.03) 40%,
transparent 100%);
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
border-top-left-radius: 24px;
border-top-right-radius: 24px;
position: relative;
}
.wishlist-page-header::after {
content: '';
position: absolute;
bottom: -1px;
left: 10%;
right: 10%;
height: 1px;
background: linear-gradient(90deg,
transparent 0%,
rgba(var(--accent-rgb), 0.2) 20%,
rgba(var(--accent-rgb), 0.35) 50%,
rgba(var(--accent-rgb), 0.2) 80%,
transparent 100%);
}
.wishlist-page-header-left {
display: flex;
flex-direction: column;
gap: 6px;
}
.wishlist-page-header-right {
display: flex;
gap: 8px;
align-items: center;
}
.wishlist-page-title {
display: flex;
align-items: center;
gap: 10px;
font-size: 26px;
font-weight: 700;
color: #fff;
margin: 0;
letter-spacing: -0.5px;
font-family: 'SF Pro Display', -apple-system, BlinkMacSystemFont, sans-serif;
}
.wishlist-page-title-icon {
font-size: 26px;
}
.wishlist-page-meta {
display: flex;
align-items: center;
gap: 16px;
font-size: 13px;
color: rgba(255, 255, 255, 0.45);
font-weight: 500;
}
.wishlist-page-count {
color: rgb(var(--accent-light-rgb));
font-weight: 600;
}
.wishlist-page-timer {
padding-left: 16px;
border-left: 1px solid rgba(255, 255, 255, 0.08);
}
/* ── Stats strip ── */
.wishlist-stats-strip {
display: flex;
align-items: center;
justify-content: center;
gap: 0;
padding: 14px 24px;
background: linear-gradient(135deg,
rgba(20, 20, 20, 0.95) 0%,
rgba(12, 12, 12, 0.98) 100%);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 12px;
margin-bottom: 20px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.2),
inset 0 1px 0 rgba(255, 255, 255, 0.05);
}
.wishlist-stat-item {
display: flex;
flex-direction: column;
align-items: center;
gap: 2px;
padding: 0 32px;
}
.wishlist-stat-value {
font-size: 22px;
font-weight: 700;
color: #fff;
letter-spacing: -0.5px;
font-family: 'SF Pro Display', -apple-system, BlinkMacSystemFont, sans-serif;
}
.wishlist-stat-value.wishlist-stat-cycle {
font-size: 14px;
font-weight: 600;
color: rgb(var(--accent-light-rgb));
}
.wishlist-stat-label {
font-size: 11px;
font-weight: 500;
color: rgba(255, 255, 255, 0.35);
text-transform: uppercase;
letter-spacing: 0.8px;
}
.wishlist-stat-divider {
width: 1px;
height: 32px;
background: rgba(255, 255, 255, 0.08);
}
/* ── Track search ── */
.wishlist-track-search-container {
position: relative;
margin-bottom: 12px;
}
.wishlist-track-search-icon {
position: absolute;
left: 12px;
top: 50%;
transform: translateY(-50%);
pointer-events: none;
}
.wishlist-track-search-input {
width: 100%;
padding: 9px 16px 9px 36px;
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 8px;
background: rgba(255, 255, 255, 0.03);
color: #fff;
font-size: 13px;
font-weight: 500;
transition: all 0.2s;
box-sizing: border-box;
}
.wishlist-track-search-input:focus {
border-color: rgba(var(--accent-rgb), 0.4);
background: rgba(255, 255, 255, 0.05);
box-shadow: 0 0 10px rgba(var(--accent-rgb), 0.1);
outline: none;
}
.wishlist-track-search-input::placeholder {
color: rgba(255, 255, 255, 0.3);
}
/* ── Category cards upgrade ── */
#wishlist-page-categories {
margin-bottom: 24px;
}
.wishlist-page-container .wishlist-category-card {
border: 1px solid rgba(255, 255, 255, 0.08);
border-top: 1px solid rgba(255, 255, 255, 0.12);
box-shadow:
0 6px 20px rgba(0, 0, 0, 0.35),
inset 0 1px 0 rgba(255, 255, 255, 0.06);
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.wishlist-page-container .wishlist-category-card:hover {
transform: translateY(-4px) scale(1.01);
border-color: rgba(var(--accent-rgb), 0.2);
box-shadow:
0 12px 30px rgba(0, 0, 0, 0.4),
0 0 24px rgba(var(--accent-rgb), 0.08),
inset 0 1px 0 rgba(255, 255, 255, 0.1);
}
.wishlist-page-container .wishlist-category-card.next-in-queue {
border-color: rgba(var(--accent-rgb), 0.25);
box-shadow:
0 6px 20px rgba(0, 0, 0, 0.35),
0 0 16px rgba(var(--accent-rgb), 0.06),
inset 0 1px 0 rgba(var(--accent-rgb), 0.1);
}
/* ── Mosaic background ── */
.wishlist-mosaic-bg {
position: absolute;
inset: 0;
overflow: hidden;
border-radius: inherit;
z-index: 0;
}
.wishlist-mosaic-bg::after {
content: '';
position: absolute;
inset: 0;
background: linear-gradient(135deg, rgba(0, 0, 0, 0.75), rgba(0, 0, 0, 0.55));
z-index: 1;
}
/* ── Track list section ── */
.wishlist-page-container .wishlist-category-tracks {
max-height: none;
background: rgba(255, 255, 255, 0.015);
border: 1px solid rgba(255, 255, 255, 0.05);
border-radius: 14px;
padding: 16px;
}
.wishlist-page-container .wishlist-category-header {
margin-bottom: 12px;
}
.wishlist-page-container .wishlist-tracks-scroll {
max-height: calc(100vh - 320px);
overflow-y: auto;
}
/* Category header right side with download button */
.wishlist-category-header-right {
display: flex;
align-items: center;
gap: 8px;
}
/* ── Batch bar ── */
.wishlist-page-container .wishlist-batch-bar {
background: rgba(var(--accent-rgb), 0.06);
border: 1px solid rgba(var(--accent-rgb), 0.12);
border-radius: 10px;
padding: 8px 16px;
margin-bottom: 12px;
}
/* ── Album cards in track list ── */
.wishlist-page-container .wishlist-album-card {
border: 1px solid rgba(255, 255, 255, 0.06);
border-radius: 12px;
background: rgba(255, 255, 255, 0.02);
transition: border-color 0.2s;
}
.wishlist-page-container .wishlist-album-card:hover {
border-color: rgba(255, 255, 255, 0.1);
}
/* ── Empty state ── */
.wishlist-page-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 80px 20px;
text-align: center;
}
.wishlist-page-empty-icon {
opacity: 0.15;
margin-bottom: 8px;
}
.wishlist-page-empty h3 {
margin: 12px 0 8px;
color: rgba(255, 255, 255, 0.7);
font-size: 20px;
font-weight: 700;
letter-spacing: -0.3px;
}
.wishlist-page-empty p {
margin: 0 0 24px;
font-size: 14px;
color: rgba(255, 255, 255, 0.35);
max-width: 360px;
line-height: 1.5;
}
/*
WATCHLIST & WISHLIST RESPONSIVE
*/
@media (max-width: 768px) {
.watchlist-page-container,
.wishlist-page-container {
padding: 16px;
border-radius: 16px;
}
.watchlist-page-header,
.wishlist-page-header {
flex-direction: column;
gap: 12px;
padding: 16px;
margin: -16px -16px 16px -16px;
border-top-left-radius: 16px;
border-top-right-radius: 16px;
}
.watchlist-page-title,
.wishlist-page-title {
font-size: 22px;
}
.watchlist-page-actions {
flex-direction: column;
align-items: stretch;
}
.watchlist-page-container .watchlist-artists-grid {
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
gap: 12px;
}
.watchlist-toolbar {
flex-direction: column;
}
.watchlist-sort-select {
width: 100%;
}
.wishlist-page-header-right {
width: 100%;
justify-content: flex-end;
}
.wishlist-stat-item {
padding: 0 16px;
}
.wishlist-stat-value {
font-size: 18px;
}
}