Danger/warning (or explicit badge:true) increment the tray badge; success/info stay in the notification center as history without a badge.
This commit is contained in:
@@ -8421,7 +8421,7 @@ function initNotificationTray() {
|
|||||||
const unreadClass = notification.read ? '' : 'unread';
|
const unreadClass = notification.read ? '' : 'unread';
|
||||||
|
|
||||||
return `
|
return `
|
||||||
<div class="notification-item ${notification.type} ${unreadClass}" data-id="${notification.id}">
|
<div class="notification-item ${notification.type} ${unreadClass}${notification.badge ? ' badge-worthy' : ' history-only'}" data-id="${notification.id}">
|
||||||
<div class="notification-icon">
|
<div class="notification-icon">
|
||||||
<i class="fas ${icon}"></i>
|
<i class="fas ${icon}"></i>
|
||||||
</div>
|
</div>
|
||||||
@@ -8451,11 +8451,14 @@ function initNotificationTray() {
|
|||||||
return div.innerHTML;
|
return div.innerHTML;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update badge
|
// Update badge — only badge-worthy unread (danger/warning / explicit badge:true)
|
||||||
function updateBadge() {
|
function updateBadge() {
|
||||||
const unreadCount = notificationManager.getUnreadCount();
|
const badgeCount =
|
||||||
if (unreadCount > 0) {
|
typeof notificationManager.getBadgeCount === 'function'
|
||||||
notificationBadge.textContent = unreadCount > 99 ? '99+' : unreadCount;
|
? notificationManager.getBadgeCount()
|
||||||
|
: notificationManager.getUnreadCount();
|
||||||
|
if (badgeCount > 0) {
|
||||||
|
notificationBadge.textContent = badgeCount > 99 ? '99+' : badgeCount;
|
||||||
notificationBadge.style.display = 'flex';
|
notificationBadge.style.display = 'flex';
|
||||||
} else {
|
} else {
|
||||||
notificationBadge.textContent = '';
|
notificationBadge.textContent = '';
|
||||||
|
|||||||
+150
-100
@@ -1,22 +1,39 @@
|
|||||||
/**
|
/**
|
||||||
* Notification Manager
|
* Notification Manager
|
||||||
* Centralized notification system with persistence and tray support
|
* Centralized notification system with persistence and tray support.
|
||||||
|
*
|
||||||
|
* Bell badge: only notifications with badge: true count (see deservesBadge).
|
||||||
|
* All notifications still appear in the notification center history.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const STORAGE_KEY = 'peardock_notifications';
|
const STORAGE_KEY = 'peardock_notifications'
|
||||||
const MAX_NOTIFICATIONS = 100; // Limit stored notifications
|
const MAX_NOTIFICATIONS = 100 // Limit stored notifications
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether a notification should bump the bell badge.
|
||||||
|
* Default: danger + warning only. success/info are history-only unless
|
||||||
|
* options.badge is forced true.
|
||||||
|
* @param {string} type
|
||||||
|
* @param {{ badge?: boolean }} [options]
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
export function deservesBadge(type, options = {}) {
|
||||||
|
if (options.badge === true) return true
|
||||||
|
if (options.badge === false) return false
|
||||||
|
const t = String(type || 'info').toLowerCase()
|
||||||
|
return t === 'danger' || t === 'warning' || t === 'error'
|
||||||
|
}
|
||||||
|
|
||||||
class NotificationManager {
|
class NotificationManager {
|
||||||
constructor() {
|
constructor() {
|
||||||
this.notifications = [];
|
this.notifications = []
|
||||||
this.listeners = [];
|
this.listeners = []
|
||||||
this._storageLoaded = false;
|
this._storageLoaded = false
|
||||||
// Defer loading from storage to not block initialization
|
// Defer loading from storage to not block initialization
|
||||||
if (typeof requestIdleCallback !== 'undefined') {
|
if (typeof requestIdleCallback !== 'undefined') {
|
||||||
requestIdleCallback(() => this.loadFromStorage(), { timeout: 1000 });
|
requestIdleCallback(() => this.loadFromStorage(), { timeout: 1000 })
|
||||||
} else {
|
} else {
|
||||||
// Fallback for browsers without requestIdleCallback
|
setTimeout(() => this.loadFromStorage(), 0)
|
||||||
setTimeout(() => this.loadFromStorage(), 0);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -24,26 +41,31 @@ class NotificationManager {
|
|||||||
* Load notifications from localStorage
|
* Load notifications from localStorage
|
||||||
*/
|
*/
|
||||||
loadFromStorage() {
|
loadFromStorage() {
|
||||||
if (this._storageLoaded) return;
|
if (this._storageLoaded) return
|
||||||
try {
|
try {
|
||||||
const stored = localStorage.getItem(STORAGE_KEY);
|
const stored = localStorage.getItem(STORAGE_KEY)
|
||||||
if (stored) {
|
if (stored) {
|
||||||
const parsed = JSON.parse(stored);
|
const parsed = JSON.parse(stored)
|
||||||
// Convert date strings back to Date objects
|
this.notifications = parsed.map((n) => {
|
||||||
this.notifications = parsed.map(n => ({
|
const type = n.type || 'info'
|
||||||
|
// Backfill badge for older records (infer from type when missing)
|
||||||
|
const badge =
|
||||||
|
typeof n.badge === 'boolean' ? n.badge : deservesBadge(type, {})
|
||||||
|
return {
|
||||||
...n,
|
...n,
|
||||||
timestamp: new Date(n.timestamp)
|
type,
|
||||||
}));
|
badge,
|
||||||
// Limit to most recent notifications
|
timestamp: new Date(n.timestamp),
|
||||||
this.notifications = this.notifications.slice(-MAX_NOTIFICATIONS);
|
|
||||||
}
|
}
|
||||||
this._storageLoaded = true;
|
})
|
||||||
// Notify listeners after loading
|
this.notifications = this.notifications.slice(-MAX_NOTIFICATIONS)
|
||||||
this.notify();
|
}
|
||||||
|
this._storageLoaded = true
|
||||||
|
this.notify()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[ERROR] Failed to load notifications from storage:', err);
|
console.error('[ERROR] Failed to load notifications from storage:', err)
|
||||||
this.notifications = [];
|
this.notifications = []
|
||||||
this._storageLoaded = true;
|
this._storageLoaded = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -52,114 +74,124 @@ class NotificationManager {
|
|||||||
*/
|
*/
|
||||||
saveToStorage() {
|
saveToStorage() {
|
||||||
try {
|
try {
|
||||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(this.notifications));
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(this.notifications))
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[ERROR] Failed to save notifications to storage:', err);
|
console.error('[ERROR] Failed to save notifications to storage:', err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Subscribe to notification changes
|
* Subscribe to notification changes
|
||||||
* @param {Function} callback - Callback function
|
* @param {Function} callback - (notifications, badgeCount) => void
|
||||||
*/
|
*/
|
||||||
subscribe(callback) {
|
subscribe(callback) {
|
||||||
this.listeners.push(callback);
|
this.listeners.push(callback)
|
||||||
// Immediately notify with current state
|
callback(this.notifications, this.getBadgeCount())
|
||||||
callback(this.notifications, this.getUnreadCount());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Unsubscribe from notification changes
|
* Unsubscribe from notification changes
|
||||||
* @param {Function} callback - Callback function to remove
|
* @param {Function} callback
|
||||||
*/
|
*/
|
||||||
unsubscribe(callback) {
|
unsubscribe(callback) {
|
||||||
this.listeners = this.listeners.filter(listener => listener !== callback);
|
this.listeners = this.listeners.filter((listener) => listener !== callback)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Notify all subscribers
|
* Notify all subscribers
|
||||||
*/
|
*/
|
||||||
notify() {
|
notify() {
|
||||||
this.listeners.forEach(callback => {
|
const badgeCount = this.getBadgeCount()
|
||||||
|
this.listeners.forEach((callback) => {
|
||||||
try {
|
try {
|
||||||
callback(this.notifications, this.getUnreadCount());
|
callback(this.notifications, badgeCount)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[ERROR] Notification listener error:', err);
|
console.error('[ERROR] Notification listener error:', err)
|
||||||
}
|
}
|
||||||
});
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Add a new notification
|
* Add a new notification (always stored in the center).
|
||||||
* @param {string} type - Notification type (success, danger, warning, info)
|
* @param {string} type - success | danger | warning | info
|
||||||
* @param {string} message - Notification message
|
* @param {string} message
|
||||||
* @param {Object} options - Additional options (autoDismiss, duration, etc.)
|
* @param {{
|
||||||
|
* autoDismiss?: boolean,
|
||||||
|
* duration?: number,
|
||||||
|
* badge?: boolean,
|
||||||
|
* key?: string,
|
||||||
|
* }} [options]
|
||||||
|
* badge: force bell badge (true) or suppress (false); default by type
|
||||||
* @returns {string} Notification ID
|
* @returns {string} Notification ID
|
||||||
*/
|
*/
|
||||||
add(type, message, options = {}) {
|
add(type, message, options = {}) {
|
||||||
const id = this.generateId();
|
const id = this.generateId()
|
||||||
|
const tone = String(type || 'info')
|
||||||
|
const badge = deservesBadge(tone, options)
|
||||||
const notification = {
|
const notification = {
|
||||||
id,
|
id,
|
||||||
type,
|
type: tone,
|
||||||
message,
|
message,
|
||||||
timestamp: new Date(),
|
timestamp: new Date(),
|
||||||
read: false,
|
// History-only items are treated as already "seen" for badge purposes
|
||||||
autoDismiss: options.autoDismiss !== false, // Default to true
|
read: !badge,
|
||||||
duration: options.duration || 5000
|
badge,
|
||||||
};
|
autoDismiss: options.autoDismiss !== false,
|
||||||
|
duration: options.duration || 5000,
|
||||||
this.notifications.unshift(notification); // Add to beginning
|
key: options.key || null,
|
||||||
this.notifications = this.notifications.slice(0, MAX_NOTIFICATIONS); // Limit size
|
|
||||||
this.saveToStorage();
|
|
||||||
this.notify();
|
|
||||||
|
|
||||||
// Auto-dismiss if enabled
|
|
||||||
if (notification.autoDismiss) {
|
|
||||||
setTimeout(() => {
|
|
||||||
this.remove(id);
|
|
||||||
}, notification.duration);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return id;
|
this.notifications.unshift(notification)
|
||||||
|
this.notifications = this.notifications.slice(0, MAX_NOTIFICATIONS)
|
||||||
|
this.saveToStorage()
|
||||||
|
this.notify()
|
||||||
|
|
||||||
|
if (notification.autoDismiss) {
|
||||||
|
setTimeout(() => {
|
||||||
|
this.remove(id)
|
||||||
|
}, notification.duration)
|
||||||
|
}
|
||||||
|
|
||||||
|
return id
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Remove a notification
|
* Remove a notification
|
||||||
* @param {string} id - Notification ID
|
* @param {string} id
|
||||||
*/
|
*/
|
||||||
remove(id) {
|
remove(id) {
|
||||||
this.notifications = this.notifications.filter(n => n.id !== id);
|
this.notifications = this.notifications.filter((n) => n.id !== id)
|
||||||
this.saveToStorage();
|
this.saveToStorage()
|
||||||
this.notify();
|
this.notify()
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Mark notification as read
|
* Mark notification as read (clears badge contribution if badge-worthy)
|
||||||
* @param {string} id - Notification ID
|
* @param {string} id
|
||||||
*/
|
*/
|
||||||
markAsRead(id) {
|
markAsRead(id) {
|
||||||
const notification = this.notifications.find(n => n.id === id);
|
const notification = this.notifications.find((n) => n.id === id)
|
||||||
if (notification && !notification.read) {
|
if (notification && !notification.read) {
|
||||||
notification.read = true;
|
notification.read = true
|
||||||
this.saveToStorage();
|
this.saveToStorage()
|
||||||
this.notify();
|
this.notify()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Mark all notifications as read
|
* Mark all notifications as read (clears bell badge)
|
||||||
*/
|
*/
|
||||||
markAllAsRead() {
|
markAllAsRead() {
|
||||||
let changed = false;
|
let changed = false
|
||||||
this.notifications.forEach(n => {
|
this.notifications.forEach((n) => {
|
||||||
if (!n.read) {
|
if (!n.read) {
|
||||||
n.read = true;
|
n.read = true
|
||||||
changed = true;
|
changed = true
|
||||||
}
|
}
|
||||||
});
|
})
|
||||||
if (changed) {
|
if (changed) {
|
||||||
this.saveToStorage();
|
this.saveToStorage()
|
||||||
this.notify();
|
this.notify()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -167,58 +199,76 @@ class NotificationManager {
|
|||||||
* Clear all notifications
|
* Clear all notifications
|
||||||
*/
|
*/
|
||||||
clearAll() {
|
clearAll() {
|
||||||
this.notifications = [];
|
this.notifications = []
|
||||||
this.saveToStorage();
|
this.saveToStorage()
|
||||||
this.notify();
|
this.notify()
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get unread count
|
* Count that drives the bell badge: unread + badge-worthy only.
|
||||||
* @returns {number} Number of unread notifications
|
* @returns {number}
|
||||||
|
*/
|
||||||
|
getBadgeCount() {
|
||||||
|
if (!this._storageLoaded) {
|
||||||
|
this.loadFromStorage()
|
||||||
|
}
|
||||||
|
return this.notifications.filter((n) => n.badge && !n.read).length
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @deprecated Use getBadgeCount — kept for callers; same semantics (badge-only).
|
||||||
|
* @returns {number}
|
||||||
*/
|
*/
|
||||||
getUnreadCount() {
|
getUnreadCount() {
|
||||||
// Ensure storage is loaded
|
return this.getBadgeCount()
|
||||||
if (!this._storageLoaded) {
|
|
||||||
this.loadFromStorage();
|
|
||||||
}
|
}
|
||||||
return this.notifications.filter(n => !n.read).length;
|
|
||||||
|
/**
|
||||||
|
* Unread records in the center (includes history-only if ever marked unread).
|
||||||
|
* @returns {number}
|
||||||
|
*/
|
||||||
|
getCenterUnreadCount() {
|
||||||
|
if (!this._storageLoaded) {
|
||||||
|
this.loadFromStorage()
|
||||||
|
}
|
||||||
|
return this.notifications.filter((n) => !n.read).length
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get notifications with optional filtering
|
* Get notifications with optional filtering
|
||||||
* @param {Object} filters - Filter options (type, read)
|
* @param {{ type?: string, read?: boolean, badge?: boolean }} [filters]
|
||||||
* @returns {Array} Filtered notifications
|
* @returns {Array}
|
||||||
*/
|
*/
|
||||||
getNotifications(filters = {}) {
|
getNotifications(filters = {}) {
|
||||||
// Ensure storage is loaded
|
|
||||||
if (!this._storageLoaded) {
|
if (!this._storageLoaded) {
|
||||||
this.loadFromStorage();
|
this.loadFromStorage()
|
||||||
}
|
}
|
||||||
|
|
||||||
let filtered = [...this.notifications];
|
let filtered = [...this.notifications]
|
||||||
|
|
||||||
if (filters.type && filters.type !== 'all') {
|
if (filters.type && filters.type !== 'all') {
|
||||||
filtered = filtered.filter(n => n.type === filters.type);
|
filtered = filtered.filter((n) => n.type === filters.type)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (filters.read !== undefined) {
|
if (filters.read !== undefined) {
|
||||||
filtered = filtered.filter(n => n.read === filters.read);
|
filtered = filtered.filter((n) => n.read === filters.read)
|
||||||
}
|
}
|
||||||
|
|
||||||
return filtered;
|
if (filters.badge !== undefined) {
|
||||||
|
filtered = filtered.filter((n) => Boolean(n.badge) === Boolean(filters.badge))
|
||||||
|
}
|
||||||
|
|
||||||
|
return filtered
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generate unique ID for notification
|
* @returns {string}
|
||||||
* @returns {string} Unique ID
|
|
||||||
*/
|
*/
|
||||||
generateId() {
|
generateId() {
|
||||||
return `notif_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
return `notif_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create singleton instance
|
const notificationManager = new NotificationManager()
|
||||||
const notificationManager = new NotificationManager();
|
|
||||||
|
|
||||||
export default notificationManager;
|
|
||||||
|
|
||||||
|
export default notificationManager
|
||||||
|
|||||||
@@ -304,6 +304,7 @@ function routeToJobTrayIfActive(type, text) {
|
|||||||
* toast / forceToast: show top-center toast (default false — avoid double UI)
|
* toast / forceToast: show top-center toast (default false — avoid double UI)
|
||||||
* tray: write notification history bell (default true when not handled by job)
|
* tray: write notification history bell (default true when not handled by job)
|
||||||
* job: allow routing into active job tray (default true)
|
* job: allow routing into active job tray (default true)
|
||||||
|
* badge: force (true) or suppress (false) bell badge; default danger/warning only
|
||||||
*/
|
*/
|
||||||
export function showAlert(type, message, options = {}) {
|
export function showAlert(type, message, options = {}) {
|
||||||
const text = String(message ?? '');
|
const text = String(message ?? '');
|
||||||
|
|||||||
Reference in New Issue
Block a user