From d9b4e5b85324512e13c6f0a46b8e883d1edf3279 Mon Sep 17 00:00:00 2001 From: Broque Thomas <26755000+Nezreka@users.noreply.github.com> Date: Thu, 16 Apr 2026 11:45:29 -0700 Subject: [PATCH] Add smart Library Status card to Dashboard with deep scan support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adaptive card on the Dashboard showing library state with four modes: - No server: gold accent, directs to Settings - Disconnected: gold warning with troubleshooting guidance - Empty library: blue accent with prominent Scan Now button - Healthy: green accent with stats grid (artists/albums/tracks/DB size), Refresh button (incremental) and Deep Scan button (full re-check) Stats displayed as mini cards with individual icons. Animated glow orb, gradient accent top line, shimmer progress bar during scans. Deep scan added to /api/database/update endpoint (deep_scan flag) — re-checks every track, adds new ones, removes stale, preserves enrichment data. Confirmation dialog explains what deep scan does before starting. --- web_server.py | 18 +- webui/index.html | 77 +++++++++ webui/static/helper.js | 2 + webui/static/script.js | 323 +++++++++++++++++++++++++++++++++- webui/static/style.css | 381 +++++++++++++++++++++++++++++++++++++++++ 5 files changed, 791 insertions(+), 10 deletions(-) diff --git a/web_server.py b/web_server.py index 86ec5c04..dd6a0db1 100644 --- a/web_server.py +++ b/web_server.py @@ -25163,21 +25163,25 @@ def start_database_update(): data = request.get_json() full_refresh = data.get('full_refresh', False) + deep_scan = data.get('deep_scan', False) active_server = config_manager.get_active_media_server() + scan_type = "Deep scan" if deep_scan else ("Full" if full_refresh else "Incremental") db_update_state.update({ "status": "running", - "phase": "Initializing...", + "phase": f"{scan_type}: Initializing...", "progress": 0, "current_item": "", "processed": 0, "total": 0, "error_message": "" }) - + # Add activity for database update start - update_type = "Full" if full_refresh else "Incremental" server_name = active_server.capitalize() - add_activity_item("", "Database Update", f"Starting {update_type.lower()} update from {server_name}...", "Now") - - # Submit the worker function to the executor - db_update_executor.submit(_run_db_update_task, full_refresh, active_server) + add_activity_item("", "Database Update", f"Starting {scan_type.lower()} update from {server_name}...", "Now") + + # Submit the appropriate worker + if deep_scan: + db_update_executor.submit(_run_deep_scan_task, active_server) + else: + db_update_executor.submit(_run_db_update_task, full_refresh, active_server) return jsonify({"success": True, "message": "Database update started."}) diff --git a/webui/index.html b/webui/index.html index cde26366..cfacb79d 100644 --- a/webui/index.html +++ b/webui/index.html @@ -680,6 +680,83 @@ + +
+
+ +
+ +
+
+ +
+
+

Library

+

Checking status...

+
+
+ + +
+
+ + + + + + +
+
+

Recent Syncs

diff --git a/webui/static/helper.js b/webui/static/helper.js index 964ebd93..889479b5 100644 --- a/webui/static/helper.js +++ b/webui/static/helper.js @@ -3602,6 +3602,8 @@ const WHATS_NEW = { '2.2': [ // --- April 15, 2026 --- { date: 'April 15, 2026' }, + { title: 'Dashboard Library Status Card', desc: 'Smart card on the Dashboard showing your library state — server connection, track counts, last refresh time. Guides new users through setup, shows empty-library prompts, and lets you trigger a scan directly from the dashboard', page: 'dashboard' }, + { title: 'AcoustID Scanner Upgrade', desc: 'Now scans your full library (not just Transfer) to detect wrong downloads. Actionable fixes: retag with correct metadata, re-download the right track, or delete the wrong file. Enabled by default, runs daily' }, { title: 'Tools Page', desc: 'All tool cards (Database Updater, Quality Scanner, Duplicate Cleaner, Retag, Backups, Cache, etc.) and Library Maintenance moved from the Dashboard to a dedicated Tools page in the sidebar. Dashboard shows a quick-link card', page: 'tools' }, { 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' }, diff --git a/webui/static/script.js b/webui/static/script.js index f5069d10..804297bd 100644 --- a/webui/static/script.js +++ b/webui/static/script.js @@ -381,6 +381,9 @@ function initializeWebSocket() { } function handleServiceStatusUpdate(data) { + // Cache for library status card + _lastServiceStatus = data; + // Same logic as fetchAndUpdateServiceStatus response handler updateServiceStatus('spotify', data.spotify); updateServiceStatus('media-server', data.media_server); @@ -25173,9 +25176,10 @@ async function loadDashboardData() { stopWishlistCountPolling(); // Ensure no duplicates wishlistCountInterval = setInterval(updateWishlistCount, 10000); - // Initial load of service status and system statistics + // Initial load of service status, system statistics, and library status await fetchAndUpdateServiceStatus(); await fetchAndUpdateSystemStats(); + await fetchAndUpdateDbStats(); // Service status is already polled globally (line 311) // System stats polling kept here (dashboard-specific) @@ -25221,8 +25225,318 @@ async function fetchAndUpdateDbStats() { } function updateDashboardStatCards(stats) { - // You can expand this later to update the main stat cards - // For now, we focus on the updater tool itself. + // Update the Library Status card on the dashboard + updateLibraryStatusCard(stats); +} + +/** + * Smart Library Status card on the Dashboard. + * Shows different states: no server, empty library, healthy library, scanning. + */ +function updateLibraryStatusCard(dbStats) { + const card = document.getElementById('library-status-card'); + if (!card) return; + + const title = document.getElementById('library-status-title'); + const subtitle = document.getElementById('library-status-subtitle'); + const statsRow = document.getElementById('library-status-stats'); + const scanBtn = document.getElementById('library-status-scan-btn'); + const scanLabel = document.getElementById('library-status-scan-label'); + const deepBtn = document.getElementById('library-status-deep-btn'); + const progressDiv = document.getElementById('library-status-progress'); + const messageDiv = document.getElementById('library-status-message'); + + const artists = dbStats ? (dbStats.artists || 0) : 0; + const albums = dbStats ? (dbStats.albums || 0) : 0; + const tracks = dbStats ? (dbStats.tracks || 0) : 0; + const sizeMb = dbStats ? (dbStats.database_size_mb || 0) : 0; + const lastUpdate = dbStats ? dbStats.last_update : null; + const serverSource = dbStats ? dbStats.server_source : null; + + // Check if a scan is in progress + const isScanning = window._libraryStatusScanning || false; + + // Determine state + const serverConnected = _lastServiceStatus && _lastServiceStatus.media_server && _lastServiceStatus.media_server.connected; + const serverType = _lastServiceStatus && _lastServiceStatus.active_media_server; + const hasData = tracks > 0; + const hasServer = !!serverType && serverType !== 'none'; + + // Reset classes + card.className = 'library-status-card'; + + if (isScanning) { + // State: Scanning + card.classList.add('scanning'); + if (title) title.textContent = 'Library Scan'; + if (subtitle) subtitle.textContent = 'Updating library database...'; + if (scanBtn) { + scanBtn.style.display = ''; + scanBtn.classList.add('scanning'); + scanLabel.textContent = 'Stop'; + scanBtn.disabled = false; + } + if (deepBtn) deepBtn.style.display = 'none'; + if (statsRow) statsRow.style.display = hasData ? '' : 'none'; + if (progressDiv) progressDiv.style.display = ''; + if (messageDiv) messageDiv.style.display = 'none'; + + } else if (!hasServer) { + // State: No server configured + card.classList.add('needs-setup'); + if (title) title.textContent = 'No Media Server'; + if (subtitle) subtitle.textContent = 'Connect a server to get started'; + if (scanBtn) scanBtn.style.display = 'none'; + if (deepBtn) deepBtn.style.display = 'none'; + if (statsRow) statsRow.style.display = 'none'; + if (progressDiv) progressDiv.style.display = 'none'; + if (messageDiv) { + messageDiv.style.display = ''; + messageDiv.innerHTML = 'SoulSync needs a media server to manage your library. ' + + 'Go to Settings ' + + 'to connect Plex, Jellyfin, or Navidrome.'; + } + + } else if (!serverConnected) { + // State: Server configured but not connected + card.classList.add('needs-setup'); + const serverName = _capitalize(serverType); + if (title) title.textContent = `${serverName} — Disconnected`; + if (subtitle) subtitle.textContent = 'Cannot reach your media server'; + if (scanBtn) scanBtn.style.display = 'none'; + if (deepBtn) deepBtn.style.display = 'none'; + if (statsRow) statsRow.style.display = 'none'; + if (progressDiv) progressDiv.style.display = 'none'; + if (messageDiv) { + messageDiv.style.display = ''; + messageDiv.innerHTML = `Your ${serverName} server is configured but not responding. ` + + 'Check that it\'s running and the connection details are correct in ' + + 'Settings.'; + } + + } else if (!hasData) { + // State: Server connected but library is empty + card.classList.add('empty-library'); + const serverName = _capitalize(serverType); + if (title) title.textContent = `${serverName} Connected`; + if (subtitle) subtitle.textContent = 'Library database is empty'; + if (scanBtn) { + scanBtn.style.display = ''; + scanBtn.classList.remove('scanning'); + scanLabel.textContent = 'Scan Now'; + scanBtn.disabled = false; + } + if (deepBtn) deepBtn.style.display = 'none'; + if (statsRow) statsRow.style.display = 'none'; + if (progressDiv) progressDiv.style.display = 'none'; + if (messageDiv) { + messageDiv.style.display = ''; + messageDiv.innerHTML = 'Your server is connected but SoulSync hasn\'t imported your library yet. ' + + 'Click Scan Now to pull your artists, albums, and tracks into SoulSync.'; + } + + } else { + // State: Healthy library with data + card.classList.add('has-data'); + const serverName = _capitalize(serverType); + let lastRefreshText = 'Never'; + if (lastUpdate) { + const d = new Date(lastUpdate); + if (!isNaN(d.getTime())) { + lastRefreshText = typeof _formatTimeAgo === 'function' ? _formatTimeAgo(d) : d.toLocaleDateString(); + } + } + if (title) title.textContent = `${serverName} Library`; + if (subtitle) subtitle.textContent = `Last refreshed ${lastRefreshText}`; + if (scanBtn) { + scanBtn.style.display = ''; + scanBtn.classList.remove('scanning'); + scanLabel.textContent = 'Refresh'; + scanBtn.disabled = false; + } + if (deepBtn) deepBtn.style.display = ''; + if (statsRow) { + statsRow.style.display = ''; + document.getElementById('library-status-artists').textContent = artists.toLocaleString(); + document.getElementById('library-status-albums').textContent = albums.toLocaleString(); + document.getElementById('library-status-tracks').textContent = tracks.toLocaleString(); + document.getElementById('library-status-size').textContent = sizeMb < 1 ? `${Math.round(sizeMb * 1024)} KB` : `${sizeMb.toFixed(1)} MB`; + } + if (progressDiv) progressDiv.style.display = 'none'; + if (messageDiv) messageDiv.style.display = 'none'; + } +} + +// Track last service status for library card +let _lastServiceStatus = null; +const _origFetchServiceStatus = typeof fetchAndUpdateServiceStatus === 'function' ? fetchAndUpdateServiceStatus : null; + +function _capitalize(s) { return s ? s.charAt(0).toUpperCase() + s.slice(1) : ''; } + +/** + * Dashboard library scan button handler — triggers incremental DB update. + */ +async function dashboardLibraryScan(fullRefresh = false) { + const scanBtn = document.getElementById('library-status-scan-btn'); + const scanLabel = document.getElementById('library-status-scan-label'); + + // If already scanning, stop it + if (window._libraryStatusScanning) { + try { + await fetch('/api/database/update/stop', { method: 'POST' }); + window._libraryStatusScanning = false; + showToast('Library scan stopped', 'info'); + // Refresh the card + try { + const r = await fetch('/api/database/stats'); + if (r.ok) updateLibraryStatusCard(await r.json()); + } catch (e) {} + } catch (e) { + showToast('Failed to stop scan', 'error'); + } + return; + } + + // Start scan + try { + window._libraryStatusScanning = true; + updateLibraryStatusCard(null); // Update to scanning state + + const response = await fetch('/api/database/update', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ full_refresh: fullRefresh }) + }); + const data = await response.json(); + if (!data.success) { + window._libraryStatusScanning = false; + showToast(data.error || 'Failed to start scan', 'error'); + return; + } + + showToast('Library scan started', 'success'); + + // Poll for progress + const pollInterval = setInterval(async () => { + try { + const statusResp = await fetch('/api/database/update/status'); + if (!statusResp.ok) return; + const status = await statusResp.json(); + + const phase = document.getElementById('library-status-phase'); + const barFill = document.getElementById('library-status-bar-fill'); + const detail = document.getElementById('library-status-progress-detail'); + + if (phase) phase.textContent = status.phase || 'Scanning...'; + if (barFill) barFill.style.width = `${status.progress || 0}%`; + if (detail && status.processed !== undefined) { + detail.textContent = `${status.processed} / ${status.total || '?'}`; + } + + if (status.status === 'completed' || status.status === 'error' || status.status === 'idle') { + clearInterval(pollInterval); + window._libraryStatusScanning = false; + + if (status.status === 'completed') { + showToast('Library scan complete', 'success'); + } else if (status.status === 'error') { + showToast(`Scan error: ${status.error_message || 'Unknown'}`, 'error'); + } + + // Refresh stats + try { + const r = await fetch('/api/database/stats'); + if (r.ok) updateLibraryStatusCard(await r.json()); + } catch (e) {} + } + } catch (e) { + clearInterval(pollInterval); + window._libraryStatusScanning = false; + } + }, 2000); + + } catch (e) { + window._libraryStatusScanning = false; + showToast(`Scan failed: ${e.message}`, 'error'); + } +} + +/** + * Dashboard deep scan — finds new tracks, removes stale ones, preserves enrichment data. + */ +async function dashboardLibraryDeepScan() { + if (window._libraryStatusScanning) { + showToast('A scan is already running', 'warning'); + return; + } + + if (!await showConfirmDialog({ + title: 'Deep Scan Library', + message: 'A deep scan re-checks every track in your media server library.\n\n' + + '• Adds any new tracks that were missed\n' + + '• Removes tracks no longer on your server\n' + + '• Preserves all existing metadata and enrichment data\n\n' + + 'This may take a while for large libraries. Continue?', + })) return; + + // Use the same scan flow as dashboardLibraryScan but with deep_scan flag + try { + window._libraryStatusScanning = true; + updateLibraryStatusCard(null); + + const response = await fetch('/api/database/update', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ deep_scan: true }) + }); + const data = await response.json(); + if (!data.success) { + window._libraryStatusScanning = false; + showToast(data.error || 'Failed to start deep scan', 'error'); + try { const r = await fetch('/api/database/stats'); if (r.ok) updateLibraryStatusCard(await r.json()); } catch (e) {} + return; + } + + showToast('Deep scan started — this may take a while', 'success'); + + const pollInterval = setInterval(async () => { + try { + const statusResp = await fetch('/api/database/update/status'); + if (!statusResp.ok) return; + const status = await statusResp.json(); + + const phase = document.getElementById('library-status-phase'); + const barFill = document.getElementById('library-status-bar-fill'); + const detail = document.getElementById('library-status-progress-detail'); + + if (phase) phase.textContent = status.phase || 'Deep scanning...'; + if (barFill) barFill.style.width = `${status.progress || 0}%`; + if (detail && status.processed !== undefined) { + detail.textContent = `${status.processed} / ${status.total || '?'}`; + } + + if (status.status === 'completed' || status.status === 'error' || status.status === 'idle') { + clearInterval(pollInterval); + window._libraryStatusScanning = false; + + if (status.status === 'completed') { + showToast('Deep scan complete', 'success'); + } else if (status.status === 'error') { + showToast(`Deep scan error: ${status.error_message || 'Unknown'}`, 'error'); + } + + try { const r = await fetch('/api/database/stats'); if (r.ok) updateLibraryStatusCard(await r.json()); } catch (e) {} + } + } catch (e) { + clearInterval(pollInterval); + window._libraryStatusScanning = false; + } + }, 2000); + + } catch (e) { + window._libraryStatusScanning = false; + showToast(`Deep scan failed: ${e.message}`, 'error'); + } } /** @@ -38873,6 +39187,9 @@ async function fetchAndUpdateServiceStatus() { const data = await response.json(); + // Cache for library status card + _lastServiceStatus = data; + // Update service status indicators and text (dashboard) updateServiceStatus('spotify', data.spotify); updateServiceStatus('media-server', data.media_server); diff --git a/webui/static/style.css b/webui/static/style.css index d24f99f7..39ae1b17 100644 --- a/webui/static/style.css +++ b/webui/static/style.css @@ -55797,6 +55797,387 @@ body.reduce-effects *::after { } } +/* ═══════════════════════════════════════════════════════════════════ + DASHBOARD LIBRARY STATUS CARD + ═══════════════════════════════════════════════════════════════════ */ + +.library-status-card { + background: linear-gradient(135deg, + rgba(20, 20, 20, 0.95) 0%, + rgba(14, 14, 14, 0.98) 100%); + border: 1px solid rgba(255, 255, 255, 0.08); + border-top: 1px solid rgba(255, 255, 255, 0.14); + border-radius: 16px; + padding: 22px 24px; + box-shadow: + 0 6px 24px rgba(0, 0, 0, 0.35), + 0 2px 8px rgba(0, 0, 0, 0.2), + inset 0 1px 0 rgba(255, 255, 255, 0.08); + position: relative; + overflow: hidden; + transition: border-color 0.3s, box-shadow 0.3s; +} + +.library-status-card:hover { + border-color: rgba(var(--accent-rgb), 0.15); + box-shadow: + 0 8px 28px rgba(0, 0, 0, 0.4), + 0 0 16px rgba(var(--accent-rgb), 0.04), + inset 0 1px 0 rgba(255, 255, 255, 0.1); +} + +/* Accent top line */ +.library-status-card::before { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + height: 3px; + background: linear-gradient(90deg, + transparent 0%, + rgba(var(--accent-rgb), 0.3) 20%, + rgba(var(--accent-rgb), 0.6) 50%, + rgba(var(--accent-rgb), 0.3) 80%, + transparent 100%); + opacity: 0; + transition: opacity 0.3s; +} + +/* Animated background glow */ +.library-status-glow { + position: absolute; + top: -50%; + right: -20%; + width: 300px; + height: 300px; + border-radius: 50%; + background: radial-gradient(circle, rgba(var(--accent-rgb), 0.06) 0%, transparent 70%); + pointer-events: none; + opacity: 0; + transition: opacity 0.5s; +} + +.library-status-card.has-data .library-status-glow { + opacity: 1; +} + +.library-status-card.has-data::before { + opacity: 1; +} + +.library-status-card.needs-setup { + border-color: rgba(255, 193, 7, 0.15); +} + +.library-status-card.needs-setup::before { + background: linear-gradient(90deg, + transparent, rgba(255, 193, 7, 0.4), rgba(255, 193, 7, 0.6), rgba(255, 193, 7, 0.4), transparent); + opacity: 1; +} + +.library-status-card.needs-setup .library-status-glow { + background: radial-gradient(circle, rgba(255, 193, 7, 0.05) 0%, transparent 70%); + opacity: 1; +} + +.library-status-card.empty-library::before { + background: linear-gradient(90deg, + transparent, rgba(59, 130, 246, 0.4), rgba(59, 130, 246, 0.6), rgba(59, 130, 246, 0.4), transparent); + opacity: 1; +} + +.library-status-card.empty-library .library-status-glow { + background: radial-gradient(circle, rgba(59, 130, 246, 0.05) 0%, transparent 70%); + opacity: 1; +} + +.library-status-card.scanning::before { + opacity: 1; + animation: scanPulse 2s ease-in-out infinite; +} + +.library-status-card.scanning .library-status-glow { + opacity: 1; + animation: glowPulse 3s ease-in-out infinite; +} + +@keyframes glowPulse { + 0%, 100% { opacity: 0.5; transform: scale(1); } + 50% { opacity: 1; transform: scale(1.1); } +} + +.library-status-header { + display: flex; + align-items: center; + gap: 14px; +} + +.library-status-icon { + width: 44px; + height: 44px; + border-radius: 12px; + display: flex; + align-items: center; + justify-content: center; + background: linear-gradient(135deg, rgba(var(--accent-rgb), 0.15), rgba(var(--accent-rgb), 0.05)); + color: rgb(var(--accent-rgb)); + flex-shrink: 0; + border: 1px solid rgba(var(--accent-rgb), 0.1); +} + +.library-status-card.needs-setup .library-status-icon { + background: linear-gradient(135deg, rgba(255, 193, 7, 0.15), rgba(255, 193, 7, 0.05)); + color: #ffc107; + border-color: rgba(255, 193, 7, 0.1); +} + +.library-status-card.empty-library .library-status-icon { + background: linear-gradient(135deg, rgba(59, 130, 246, 0.15), rgba(59, 130, 246, 0.05)); + color: #60a5fa; + border-color: rgba(59, 130, 246, 0.1); +} + +.library-status-info { + flex: 1; + min-width: 0; +} + +.library-status-title { + font-size: 15px; + font-weight: 700; + color: #fff; + margin: 0; + letter-spacing: -0.2px; +} + +.library-status-subtitle { + font-size: 12px; + color: rgba(255, 255, 255, 0.4); + margin: 2px 0 0 0; + font-weight: 500; +} + +.library-status-actions { + display: flex; + gap: 8px; + flex-shrink: 0; +} + +.library-status-btn { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 8px 16px; + border-radius: 8px; + border: 1px solid rgba(var(--accent-rgb), 0.25); + background: rgba(var(--accent-rgb), 0.1); + color: rgb(var(--accent-light-rgb)); + font-size: 12px; + font-weight: 600; + cursor: pointer; + transition: all 0.2s; + white-space: nowrap; +} + +.library-status-btn:hover { + background: rgba(var(--accent-rgb), 0.2); + border-color: rgba(var(--accent-rgb), 0.4); + transform: translateY(-1px); +} + +.library-status-btn:disabled { + opacity: 0.4; + cursor: not-allowed; + transform: none; +} + +.library-status-btn-secondary { + background: rgba(255, 255, 255, 0.04); + border-color: rgba(255, 255, 255, 0.1); + color: rgba(255, 255, 255, 0.55); +} + +.library-status-btn-secondary:hover { + background: rgba(255, 255, 255, 0.08); + border-color: rgba(255, 255, 255, 0.18); + color: rgba(255, 255, 255, 0.8); +} + +.library-status-btn.scanning { + border-color: rgba(239, 68, 68, 0.3); + background: rgba(239, 68, 68, 0.1); + color: #f87171; +} + +.library-status-stats { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 10px; + margin-top: 16px; + padding-top: 16px; + border-top: 1px solid rgba(255, 255, 255, 0.05); +} + +.library-status-stat { + display: flex; + align-items: center; + gap: 12px; + padding: 12px 14px; + background: rgba(255, 255, 255, 0.025); + border: 1px solid rgba(255, 255, 255, 0.05); + border-radius: 10px; + transition: all 0.2s; +} + +.library-status-stat:hover { + background: rgba(255, 255, 255, 0.04); + border-color: rgba(255, 255, 255, 0.08); +} + +.library-status-stat-icon { + width: 32px; + height: 32px; + border-radius: 8px; + display: flex; + align-items: center; + justify-content: center; + background: rgba(var(--accent-rgb), 0.08); + color: rgba(var(--accent-rgb), 0.7); + flex-shrink: 0; +} + +.library-status-stat-text { + display: flex; + flex-direction: column; + gap: 1px; + min-width: 0; +} + +.library-status-stat-value { + font-size: 18px; + font-weight: 700; + color: #fff; + letter-spacing: -0.5px; + line-height: 1.1; + font-family: 'SF Pro Display', -apple-system, BlinkMacSystemFont, sans-serif; +} + +.library-status-stat-label { + font-size: 10px; + font-weight: 500; + color: rgba(255, 255, 255, 0.3); + text-transform: uppercase; + letter-spacing: 0.8px; +} + +.library-status-progress { + margin-top: 14px; + padding-top: 14px; + border-top: 1px solid rgba(255, 255, 255, 0.05); +} + +.library-status-phase { + font-size: 12px; + color: rgba(255, 255, 255, 0.5); + font-weight: 500; + margin-bottom: 8px; +} + +.library-status-bar { + height: 5px; + background: rgba(255, 255, 255, 0.06); + border-radius: 3px; + overflow: hidden; +} + +.library-status-bar-fill { + height: 100%; + background: linear-gradient(90deg, rgb(var(--accent-rgb)), rgb(var(--accent-light-rgb))); + border-radius: 3px; + transition: width 0.5s ease; + box-shadow: 0 0 12px rgba(var(--accent-rgb), 0.4); + position: relative; +} + +.library-status-bar-fill::after { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: linear-gradient(90deg, transparent 0%, rgba(255,255,255,0.2) 50%, transparent 100%); + animation: barShimmer 2s ease-in-out infinite; +} + +@keyframes barShimmer { + 0% { transform: translateX(-100%); } + 100% { transform: translateX(100%); } +} + +.library-status-progress-detail { + font-size: 11px; + color: rgba(255, 255, 255, 0.35); + margin-top: 6px; + text-align: right; +} + +.library-status-message { + margin-top: 12px; + padding: 12px 16px; + background: rgba(255, 255, 255, 0.025); + border: 1px solid rgba(255, 255, 255, 0.05); + border-radius: 8px; + font-size: 13px; + color: rgba(255, 255, 255, 0.55); + line-height: 1.5; +} + +.library-status-message a, +.library-status-message span.link { + color: rgb(var(--accent-light-rgb)); + cursor: pointer; + text-decoration: none; + font-weight: 600; +} + +.library-status-message a:hover, +.library-status-message span.link:hover { + text-decoration: underline; +} + +@media (max-width: 900px) { + .library-status-stats { + grid-template-columns: repeat(2, 1fr); + } +} + +@media (max-width: 768px) { + .library-status-header { + flex-wrap: wrap; + gap: 10px; + } + + .library-status-actions { + width: 100%; + } + + .library-status-btn { + flex: 1; + justify-content: center; + } + + .library-status-stats { + grid-template-columns: 1fr 1fr; + gap: 8px; + } + + .library-status-stat-value { + font-size: 15px; + } +} + /* ═══════════════════════════════════════════════════════════════════ DASHBOARD TOOLS LINK CARD ═══════════════════════════════════════════════════════════════════ */