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 = `
+
+
+ ${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)}
+
${ago}${learnMore}
+
+
`;
+ }).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;