diff --git a/web_server.py b/web_server.py index 36a6cbb5..b04dec4e 100644 --- a/web_server.py +++ b/web_server.py @@ -20014,6 +20014,19 @@ def get_version_info(): "title": "What's New in SoulSync", "subtitle": f"Version {SOULSYNC_VERSION} — Latest Changes", "sections": [ + { + "title": "🔔 Redesigned Notification System", + "description": "Modern compact toasts with notification history and bell button", + "features": [ + "• Compact pill-shaped toasts in the bottom-right — one at a time, auto-dismiss after 3.5s", + "• Notification bell button with unread badge counter next to the help button", + "• Click the bell to open the notification history panel with the last 50 notifications", + "• Each notification shows type icon, message, relative timestamp, and optional 'Learn more' link", + "• Unread dot indicators — panel marks all as read when opened", + "• Clear All button to empty notification history", + "• Click any toast to dismiss it immediately" + ] + }, { "title": "🔄 Track Redownload — Fix Mismatched Downloads", "description": "Replace wrong downloads with the correct version using manual source selection", diff --git a/webui/index.html b/webui/index.html index b96cea07..7d6d45a7 100644 --- a/webui/index.html +++ b/webui/index.html @@ -6790,7 +6790,11 @@ - + + diff --git a/webui/static/helper.js b/webui/static/helper.js index 552c94d9..87d03ae2 100644 --- a/webui/static/helper.js +++ b/webui/static/helper.js @@ -3403,6 +3403,7 @@ function closeHelperSearch() { const WHATS_NEW = { '2.1': [ // Newest features first + { title: 'Redesigned Notifications', desc: 'Compact pill toasts, notification bell with unread badge, history panel with last 50 notifications and Learn More links' }, { title: 'Track Redownload & Source Info', desc: 'Fix mismatched downloads — search all metadata and download sources in columns, pick the right version, auto-replace. Source Info shows where tracks came from with blacklist option', page: 'library' }, { title: 'Spotify API Rate Limit Improvements', desc: 'Cached get_artist_albums, eliminated duplicate search calls, auth probe reduced 66%, enrichment workers auto-pause during downloads' }, { title: 'Server Playlist Manager', desc: 'Compare source playlists against your media server — find missing tracks, swap wrong matches, remove extras with a dual-column editor', page: 'sync' }, diff --git a/webui/static/script.js b/webui/static/script.js index 25bf6fca..f9c4c86f 100644 --- a/webui/static/script.js +++ b/webui/static/script.js @@ -16205,61 +16205,181 @@ function hideLoadingOverlay() { document.getElementById('loading-overlay').classList.add('hidden'); } -// Toast deduplication cache -let recentToasts = new Map(); +// ================================================================================== +// NOTIFICATION SYSTEM — Compact toasts + bell button + notification history panel +// ================================================================================== + +const _notifState = { + history: [], + unreadCount: 0, + panelOpen: false, + currentToast: null, + toastTimer: null, + maxHistory: 50, +}; +const _recentToastKeys = new Map(); + +const _notifIcons = { success: '✓', error: '✕', warning: '⚠', info: 'ℹ' }; function showToast(message, type = 'success', helpSection = null) { - const container = document.getElementById('toast-container'); - - // Create a unique key for this toast const toastKey = `${type}:${message}`; const now = Date.now(); - // Check if we've shown this exact toast recently (within 5 seconds) - if (recentToasts.has(toastKey)) { - const lastShown = recentToasts.get(toastKey); - if (now - lastShown < 5000) { - console.log(`🚫 Suppressing duplicate toast: "${message}"`); - return; // Don't show duplicate - } - } - - // Record this toast - recentToasts.set(toastKey, now); - - // Clean up old entries (older than 10 seconds) - for (const [key, timestamp] of recentToasts.entries()) { - if (now - timestamp > 10000) { - recentToasts.delete(key); - } + // Deduplication — suppress identical toasts within 5 seconds + if (_recentToastKeys.has(toastKey) && now - _recentToastKeys.get(toastKey) < 5000) return; + _recentToastKeys.set(toastKey, now); + for (const [k, t] of _recentToastKeys) { if (now - t > 10000) _recentToastKeys.delete(k); } + + // Add to notification history + const entry = { id: now + Math.random(), message, type, helpSection, timestamp: now, read: false }; + _notifState.history.unshift(entry); + if (_notifState.history.length > _notifState.maxHistory) _notifState.history.pop(); + _notifState.unreadCount++; + _updateNotifBadge(); + + // Show compact toast — dismiss current if showing + const container = document.getElementById('toast-container'); + if (!container) return; + + if (_notifState.currentToast && container.contains(_notifState.currentToast)) { + _notifState.currentToast.classList.add('toast-exit'); + const old = _notifState.currentToast; + setTimeout(() => { if (container.contains(old)) container.removeChild(old); }, 200); } + if (_notifState.toastTimer) clearTimeout(_notifState.toastTimer); + const icon = _notifIcons[type] || 'ℹ'; const toast = document.createElement('div'); - toast.className = `toast ${type}`; - toast.textContent = message; - - // Add contextual help link if a docs section is specified + toast.className = `toast-compact toast-${type}`; + toast.innerHTML = `${icon}${_escToast(message)}`; if (helpSection) { - const helpLink = document.createElement('span'); - helpLink.className = 'toast-help-link'; - helpLink.textContent = 'Learn more'; - helpLink.onclick = (e) => { - e.stopPropagation(); - if (typeof navigateToDocsSection === 'function') { - navigateToDocsSection(helpSection); - } - }; - toast.appendChild(helpLink); + const link = document.createElement('span'); + link.className = 'toast-compact-link'; + link.textContent = 'Learn more →'; + link.onclick = e => { e.stopPropagation(); if (typeof navigateToDocsSection === 'function') navigateToDocsSection(helpSection); }; + toast.appendChild(link); } + toast.onclick = () => { toast.classList.add('toast-exit'); setTimeout(() => { if (container.contains(toast)) container.removeChild(toast); }, 200); }; container.appendChild(toast); + requestAnimationFrame(() => toast.classList.add('toast-enter')); + _notifState.currentToast = toast; - // Auto-remove after 3 seconds (5 seconds if has help link) - setTimeout(() => { + _notifState.toastTimer = setTimeout(() => { if (container.contains(toast)) { - container.removeChild(toast); + toast.classList.add('toast-exit'); + setTimeout(() => { if (container.contains(toast)) container.removeChild(toast); }, 300); } - }, helpSection ? 5000 : 3000); + _notifState.currentToast = null; + }, helpSection ? 5000 : 3500); +} + +function _escToast(s) { const d = document.createElement('div'); d.textContent = s; return d.innerHTML; } + +function _updateNotifBadge() { + const badge = document.getElementById('notif-bell-badge'); + if (badge) { + badge.textContent = _notifState.unreadCount > 99 ? '99+' : _notifState.unreadCount; + badge.style.display = _notifState.unreadCount > 0 ? '' : 'none'; + } +} + +function toggleNotifPanel() { + if (_notifState.panelOpen) { + _closeNotifPanel(); + } else { + _openNotifPanel(); + } +} + +function _openNotifPanel() { + _closeNotifPanel(); // Remove existing + + _notifState.panelOpen = true; + _notifState.unreadCount = 0; + _notifState.history.forEach(e => e.read = true); + _updateNotifBadge(); + + const btn = document.getElementById('notif-bell-btn'); + const panel = document.createElement('div'); + panel.id = 'notif-panel'; + panel.className = 'notif-panel'; + + const entries = _notifState.history; + + panel.innerHTML = ` +
+ Notifications + ${entries.length > 0 ? '' : ''} +
+
+ ${entries.length === 0 ? '
No notifications yet
' : + entries.map(e => { + const icon = _notifIcons[e.type] || 'ℹ'; + const ago = _notifTimeAgo(e.timestamp); + const unreadDot = e.read ? '' : ''; + const learnMore = e.helpSection ? `Learn more →` : ''; + return ` +
+ ${unreadDot} + ${icon} +
+
${_escToast(e.message)}
+ +
+
`; + }).join('')} +
+ `; + + document.body.appendChild(panel); + + // Position above the bell button + if (btn) { + const rect = btn.getBoundingClientRect(); + panel.style.right = (window.innerWidth - rect.right) + 'px'; + panel.style.bottom = (window.innerHeight - rect.top + 8) + 'px'; + } + + requestAnimationFrame(() => panel.classList.add('visible')); + + // Close on outside click + setTimeout(() => { + const closeHandler = e => { + if (!panel.contains(e.target) && e.target.id !== 'notif-bell-btn') { + _closeNotifPanel(); + document.removeEventListener('click', closeHandler); + } + }; + document.addEventListener('click', closeHandler); + }, 100); +} + +function _closeNotifPanel() { + _notifState.panelOpen = false; + const panel = document.getElementById('notif-panel'); + if (panel) { + panel.classList.remove('visible'); + setTimeout(() => panel.remove(), 200); + } +} + +function _clearNotifHistory() { + _notifState.history = []; + _notifState.unreadCount = 0; + _updateNotifBadge(); + _closeNotifPanel(); +} + +function _notifTimeAgo(ts) { + const s = Math.floor((Date.now() - ts) / 1000); + if (s < 5) return 'just now'; + if (s < 60) return `${s}s ago`; + const m = Math.floor(s / 60); + if (m < 60) return `${m}m ago`; + const h = Math.floor(m / 60); + if (h < 24) return `${h}h ago`; + return `${Math.floor(h / 24)}d ago`; } function escapeHtml(text) { diff --git a/webui/static/style.css b/webui/static/style.css index bcb6e889..c93ec627 100644 --- a/webui/static/style.css +++ b/webui/static/style.css @@ -4983,60 +4983,191 @@ body.helper-mode-active #dashboard-activity-feed:hover { } /* Toast Notifications */ +/* ================================================================================== + NOTIFICATION SYSTEM — Compact toasts + bell button + notification panel + ================================================================================== */ + +/* Toast container — bottom-right, above the bell/helper buttons */ .toast-container { position: fixed; - bottom: 20px; - left: 50%; - transform: translateX(-50%); + bottom: 84px; + right: 24px; z-index: 99999; + pointer-events: none; +} + +/* Compact toast pill */ +.toast-compact { + display: flex; + align-items: center; + gap: 10px; + max-width: 380px; + padding: 12px 16px; + background: rgba(18, 18, 26, 0.92); + backdrop-filter: blur(20px); + border: 1px solid rgba(255, 255, 255, 0.08); + border-left: 3px solid rgba(255, 255, 255, 0.2); + border-radius: 12px; + color: #fff; + font-size: 13px; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4), 0 0 0 1px rgba(255,255,255,0.03) inset; + pointer-events: auto; + cursor: pointer; + opacity: 0; + transform: translateY(12px) scale(0.96); + transition: opacity 0.25s ease, transform 0.25s cubic-bezier(0.4, 0, 0.2, 1); +} +.toast-compact.toast-enter { opacity: 1; transform: translateY(0) scale(1); } +.toast-compact.toast-exit { opacity: 0; transform: translateY(-8px) scale(0.96); } + +/* Type-specific left border colors */ +.toast-success { border-left-color: #4caf50; } +.toast-error { border-left-color: #ef5350; } +.toast-warning { border-left-color: #ffb74d; } +.toast-info { border-left-color: #64b5f6; } + +.toast-compact-icon { + font-size: 14px; font-weight: 800; flex-shrink: 0; + width: 22px; height: 22px; border-radius: 6px; + display: flex; align-items: center; justify-content: center; +} +.toast-success .toast-compact-icon { background: rgba(76,175,80,0.15); color: #4caf50; } +.toast-error .toast-compact-icon { background: rgba(239,83,80,0.15); color: #ef5350; } +.toast-warning .toast-compact-icon { background: rgba(255,183,77,0.15); color: #ffb74d; } +.toast-info .toast-compact-icon { background: rgba(100,181,246,0.15); color: #64b5f6; } + +.toast-compact-msg { flex: 1; line-height: 1.3; min-width: 0; word-break: break-word; } + +.toast-compact-link { + font-size: 11px; font-weight: 600; color: var(--accent); + white-space: nowrap; flex-shrink: 0; cursor: pointer; + transition: opacity 0.15s; +} +.toast-compact-link:hover { opacity: 0.7; } + +/* ── Notification Bell Button ── */ +.notif-bell-btn { + position: fixed; + bottom: 24px; + right: 80px; + width: 44px; + height: 44px; + border-radius: 50%; + background: rgba(20, 20, 28, 0.85); + backdrop-filter: blur(16px); + border: 1px solid rgba(255, 255, 255, 0.08); + color: rgba(255, 255, 255, 0.5); + cursor: pointer; + z-index: 999999; + display: flex; + align-items: center; + justify-content: center; + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4); + transition: all 0.2s; +} +.notif-bell-btn:hover { + background: rgba(30, 30, 40, 0.95); + color: rgba(255, 255, 255, 0.8); + border-color: rgba(255, 255, 255, 0.15); + transform: scale(1.05); +} + +.notif-bell-badge { + position: absolute; + top: -2px; + right: -2px; + min-width: 18px; + height: 18px; + border-radius: 9px; + background: #ef5350; + color: #fff; + font-size: 10px; + font-weight: 800; + display: flex; + align-items: center; + justify-content: center; + padding: 0 4px; + border: 2px solid rgba(16, 16, 16, 0.95); + line-height: 1; +} + +/* ── Notification Panel ── */ +.notif-panel { + position: fixed; + width: 380px; + max-width: 95vw; + max-height: 60vh; + background: rgba(14, 14, 20, 0.97); + backdrop-filter: blur(24px); + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 16px; + box-shadow: 0 16px 48px rgba(0, 0, 0, 0.6), 0 0 0 1px rgba(255,255,255,0.03) inset; + z-index: 999998; display: flex; flex-direction: column; - align-items: center; + opacity: 0; + transform: translateY(8px) scale(0.96); + transition: opacity 0.2s ease, transform 0.2s cubic-bezier(0.4, 0, 0.2, 1); +} +.notif-panel.visible { opacity: 1; transform: translateY(0) scale(1); } + +.notif-panel-header { + display: flex; justify-content: space-between; align-items: center; + padding: 16px 18px 12px; + border-bottom: 1px solid rgba(255, 255, 255, 0.06); +} +.notif-panel-title { font-size: 14px; font-weight: 700; color: #fff; } +.notif-panel-clear { + font-size: 11px; font-weight: 600; color: rgba(255,255,255,0.35); + background: none; border: none; cursor: pointer; transition: color 0.15s; +} +.notif-panel-clear:hover { color: rgba(255,255,255,0.7); } + +.notif-panel-body { + overflow-y: auto; flex: 1; padding: 6px; +} +.notif-panel-body::-webkit-scrollbar { width: 4px; } +.notif-panel-body::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.08); border-radius: 2px; } + +.notif-panel-empty { + padding: 30px 16px; text-align: center; + font-size: 12px; color: rgba(255,255,255,0.2); } -.toast { - background: rgba(26, 26, 26, 0.95); - border: 1px solid rgba(var(--accent-rgb), 0.3); - border-radius: 8px; - padding: 16px 20px; - margin-bottom: 8px; - color: #ffffff; - font-size: 14px; - box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); - animation: slideIn 0.3s ease-out; +/* Individual notification entry */ +.notif-entry { + display: flex; align-items: flex-start; gap: 10px; + padding: 10px 12px; border-radius: 10px; + margin-bottom: 2px; position: relative; + transition: background 0.15s; +} +.notif-entry:hover { background: rgba(255, 255, 255, 0.03); } + +.notif-entry-unread { + position: absolute; left: 4px; top: 50%; transform: translateY(-50%); + width: 6px; height: 6px; border-radius: 50%; + background: var(--accent); } -.toast.success { - border-color: rgba(var(--accent-rgb), 0.5); - background: linear-gradient(135deg, rgba(var(--accent-rgb), 0.1) 0%, rgba(26, 26, 26, 0.95) 100%); +.notif-entry-icon { + width: 24px; height: 24px; border-radius: 6px; + display: flex; align-items: center; justify-content: center; + font-size: 12px; font-weight: 800; flex-shrink: 0; margin-top: 1px; } +.notif-icon-success { background: rgba(76,175,80,0.12); color: #4caf50; } +.notif-icon-error { background: rgba(239,83,80,0.12); color: #ef5350; } +.notif-icon-warning { background: rgba(255,183,77,0.12); color: #ffb74d; } +.notif-icon-info { background: rgba(100,181,246,0.12); color: #64b5f6; } -.toast.error { - border-color: rgba(255, 107, 107, 0.5); - background: linear-gradient(135deg, rgba(255, 107, 107, 0.1) 0%, rgba(26, 26, 26, 0.95) 100%); -} - -.toast.warning { - border-color: rgba(250, 204, 21, 0.5); - background: linear-gradient(135deg, rgba(250, 204, 21, 0.1) 0%, rgba(26, 26, 26, 0.95) 100%); -} - -.toast.info { - border-color: rgba(59, 130, 246, 0.5); - background: linear-gradient(135deg, rgba(59, 130, 246, 0.1) 0%, rgba(26, 26, 26, 0.95) 100%); -} - -@keyframes slideIn { - from { - transform: translateY(100%); - opacity: 0; - } - - to { - transform: translateY(0); - opacity: 1; - } +.notif-entry-body { flex: 1; min-width: 0; } +.notif-entry-msg { font-size: 12px; color: rgba(255,255,255,0.8); line-height: 1.35; word-break: break-word; } +.notif-entry-meta { font-size: 10px; color: rgba(255,255,255,0.25); margin-top: 3px; display: flex; align-items: center; gap: 8px; } + +.notif-entry-link { + font-size: 10px; font-weight: 600; color: var(--accent); + cursor: pointer; transition: opacity 0.15s; } +.notif-entry-link:hover { opacity: 0.7; } /* Desktop-Only Optimizations */ @media (min-width: 769px) { @@ -43798,6 +43929,7 @@ tr.tag-diff-same { } /* Toast help link */ +/* Legacy toast-help-link — kept for backward compat, now handled by toast-compact-link */ .toast-help-link { display: inline-block; margin-left: 8px;