From 119840fa5dc74993ae3f9be9c19dd6574728ac2c Mon Sep 17 00:00:00 2001
From: Broque Thomas <26755000+Nezreka@users.noreply.github.com>
Date: Wed, 1 Apr 2026 16:41:41 -0700
Subject: [PATCH] Redesign toast notification system with bell button and
history panel
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Complete replacement of the old bottom-center stacking toast system:
Compact Toasts: Single toast at a time, bottom-right above buttons.
Pill shape with type-colored left border stripe, icon, message, and
optional "Learn more" link. Slides in, fades out after 3.5s. Click
to dismiss. New toasts replace the current one smoothly.
Notification Bell: 44px circle button next to the helper (?), with
red badge counter for unread notifications. Click opens panel.
Notification Panel: Glass popover above bell button showing history
of last 50 notifications. Each entry has type icon, message, relative
timestamp, and optional help link. Unread dot indicator. Clear All
button. Marks all as read when panel opens.
Same showToast(message, type, helpSection) signature — all 842
callers unchanged. Deduplication preserved. Updated version modal
and helper What's New.
---
web_server.py | 13 +++
webui/index.html | 6 +-
webui/static/helper.js | 1 +
webui/static/script.js | 200 ++++++++++++++++++++++++++++++--------
webui/static/style.css | 216 +++++++++++++++++++++++++++++++++--------
5 files changed, 353 insertions(+), 83 deletions(-)
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;