| 1 | import { createStore } from "/js/AlpineStore.js"; |
| 2 | import * as API from "/js/api.js"; |
| 3 | import { openModal } from "/js/modals.js"; |
| 4 | import { formatDateTime, getCurrentUserISOString } from "/js/time-utils.js"; |
| 5 | |
| 6 | export const NotificationType = { |
| 7 | INFO: "info", |
| 8 | SUCCESS: "success", |
| 9 | WARNING: "warning", |
| 10 | ERROR: "error", |
| 11 | PROGRESS: "progress", |
| 12 | }; |
| 13 | |
| 14 | export const NotificationPriority = { |
| 15 | NORMAL: 10, |
| 16 | HIGH: 20, |
| 17 | }; |
| 18 | |
| 19 | export const defaultPriority = NotificationPriority.NORMAL; |
| 20 | |
| 21 | const maxNotifications = 100; |
| 22 | const maxToasts = 5; |
| 23 | |
| 24 | const model = { |
| 25 | notifications: Array(), |
| 26 | loading: false, |
| 27 | lastNotificationVersion: 0, |
| 28 | lastNotificationGuid: "", |
| 29 | unreadCount: 0, |
| 30 | unreadPrioCount: 0, |
| 31 | |
| 32 | // NEW: Toast stack management |
| 33 | toastStack: Array(), |
| 34 | |
| 35 | init() { |
| 36 | this.initialize(); |
| 37 | }, |
| 38 | |
| 39 | // Initialize the notification store |
| 40 | initialize() { |
| 41 | this.loading = true; |
| 42 | this.updateUnreadCount(); |
| 43 | // this.removeOldNotifications(); |
| 44 | this.toastStack = []; |
| 45 | |
| 46 | // // Auto-cleanup old notifications and toasts |
| 47 | // setInterval(() => { |
| 48 | // this.removeOldNotifications(); |
| 49 | // this.cleanupExpiredToasts(); |
| 50 | // }, 5 * 60 * 1000); // Every 5 minutes |
| 51 | }, |
| 52 | |
| 53 | // Update notifications from polling data |
| 54 | updateFromPoll(pollData) { |
| 55 | if (!pollData) return; |
| 56 | |
| 57 | // Check if GUID changed (system restart) |
| 58 | if (pollData.notifications_guid !== this.lastNotificationGuid) { |
| 59 | this.lastNotificationVersion = 0; |
| 60 | this.notifications = []; |
| 61 | this.toastStack = []; // Clear toast stack on restart |
| 62 | this.lastNotificationGuid = pollData.notifications_guid || ""; |
| 63 | } |
| 64 | |
| 65 | // Process new notifications and add to toast stack |
| 66 | if (pollData.notifications && pollData.notifications.length > 0) { |
| 67 | pollData.notifications.forEach((notification) => { |
| 68 | // should we toast the notification? |
| 69 | const shouldToast = !notification.read; |
| 70 | |
| 71 | // adjust notification data before adding |
| 72 | this.adjustNotificationData(notification); |
| 73 | |
| 74 | const existingNotificationIndex = this.notifications.findIndex( |
| 75 | (n) => n.id === notification.id |
| 76 | ); |
| 77 | const existingNotification = |
| 78 | existingNotificationIndex >= 0 |
| 79 | ? this.notifications[existingNotificationIndex] |
| 80 | : null; |
| 81 | const isNew = !existingNotification; |
| 82 | const shouldRetoast = |
| 83 | !!existingNotification && |
| 84 | shouldToast && |
| 85 | (existingNotification.timestamp !== notification.timestamp || |
| 86 | existingNotification.title !== notification.title || |
| 87 | existingNotification.message !== notification.message || |
| 88 | existingNotification.detail !== notification.detail); |
| 89 | |
| 90 | this.addOrUpdateNotification(notification); |
| 91 | |
| 92 | // Add new unread notifications to toast stack, and also re-toast updated unread notifications |
| 93 | if ((isNew && shouldToast) || shouldRetoast) { |
| 94 | this.addToToastStack(notification); |
| 95 | } |
| 96 | }); |
| 97 | } |
| 98 | |
| 99 | // Update version tracking |
| 100 | this.lastNotificationVersion = pollData.notifications_version || 0; |
| 101 | this.lastNotificationGuid = pollData.notifications_guid || ""; |
| 102 | |
| 103 | // Update UI state |
| 104 | this.updateUnreadCount(); |
| 105 | // this.removeOldNotifications(); |
| 106 | }, |
| 107 | |
| 108 | adjustNotificationData(notification) { |
| 109 | // set default priority if not set |
| 110 | if (!notification.priority) { |
| 111 | notification.priority = defaultPriority; |
| 112 | } |
| 113 | }, |
| 114 | |
| 115 | getToastDisplayTime(toast) { |
| 116 | const displayTime = Number(toast?.display_time); |
| 117 | return Number.isFinite(displayTime) ? displayTime : 3; |
| 118 | }, |
| 119 | |
| 120 | isPersistentToast(toast) { |
| 121 | return this.getToastDisplayTime(toast) <= 0; |
| 122 | }, |
| 123 | |
| 124 | // NEW: Add notification to toast stack |
| 125 | addToToastStack(notification) { |
| 126 | // If notification has a group, remove any existing toasts with the same group |
| 127 | if (notification.group && notification.group.trim() !== "") { |
| 128 | const existingToast = this.toastStack.find( |
| 129 | (t) => t.group === notification.group |
| 130 | ); |
| 131 | if (existingToast && existingToast.toastId) |
| 132 | this.removeFromToastStack(existingToast.toastId); |
| 133 | } |
| 134 | |
| 135 | // Create toast object with auto-dismiss timer |
| 136 | const toast = { |
| 137 | ...notification, |
| 138 | toastId: `toast-${notification.id}`, |
| 139 | addedAt: Date.now(), |
| 140 | autoRemoveTimer: null, |
| 141 | }; |
| 142 | |
| 143 | // Add to bottom of stack (newest at bottom) |
| 144 | this.toastStack.push(toast); |
| 145 | |
| 146 | // Enforce max stack limit (remove oldest) |
| 147 | while (this.toastStack.length > maxToasts) { |
| 148 | const oldest = this.toastStack[0]; |
| 149 | if (oldest && oldest.toastId) this.removeFromToastStack(oldest.toastId); |
| 150 | } |
| 151 | |
| 152 | // Set auto-dismiss timer |
| 153 | this.restartToastTimer(toast.toastId); |
| 154 | }, |
| 155 | |
| 156 | clearToastTimer(toastId) { |
| 157 | const toastIndex = this.toastStack.findIndex((t) => t.toastId === toastId); |
| 158 | if (toastIndex < 0) return; |
| 159 | |
| 160 | const toast = this.toastStack[toastIndex]; |
| 161 | if (this.isPersistentToast(toast)) return; |
| 162 | |
| 163 | if (toast.autoRemoveTimer) { |
| 164 | clearTimeout(toast.autoRemoveTimer); |
| 165 | toast.autoRemoveTimer = null; |
| 166 | } |
| 167 | }, |
| 168 | |
| 169 | restartToastTimer(toastId) { |
| 170 | const toastIndex = this.toastStack.findIndex((t) => t.toastId === toastId); |
| 171 | if (toastIndex < 0) return; |
| 172 | |
| 173 | const toast = this.toastStack[toastIndex]; |
| 174 | if (this.isPersistentToast(toast)) return; |
| 175 | |
| 176 | this.clearToastTimer(toastId); |
| 177 | toast.autoRemoveTimer = setTimeout(() => { |
| 178 | this.removeFromToastStack(toast.toastId); |
| 179 | }, this.getToastDisplayTime(toast) * 1000); |
| 180 | }, |
| 181 | |
| 182 | // NEW: Remove toast from stack |
| 183 | removeFromToastStack(toastId, removedByUser = false) { |
| 184 | const index = this.toastStack.findIndex((t) => t.toastId === toastId); |
| 185 | if (index >= 0) { |
| 186 | const toast = this.toastStack[index]; |
| 187 | if (toast.autoRemoveTimer) { |
| 188 | clearTimeout(toast.autoRemoveTimer); |
| 189 | toast.autoRemoveTimer = null; |
| 190 | } |
| 191 | this.toastStack.splice(index, 1); |
| 192 | |
| 193 | // execute after toast removed callback |
| 194 | this.afterToastRemoved(toast, removedByUser); |
| 195 | } |
| 196 | }, |
| 197 | |
| 198 | // called by UI |
| 199 | dismissToast(toastId) { |
| 200 | this.removeFromToastStack(toastId, true); |
| 201 | }, |
| 202 | |
| 203 | async dismissToastAndReload(toastId) { |
| 204 | const toast = this.toastStack.find((item) => item.toastId === toastId); |
| 205 | if (!toast?.id) return; |
| 206 | |
| 207 | const response = await API.callJsonApi("notifications_mark_read", { |
| 208 | notification_ids: [toast.id], |
| 209 | }); |
| 210 | if (response?.success) window.location.reload(); |
| 211 | }, |
| 212 | |
| 213 | async afterToastRemoved(toast, removedByUser = false) { |
| 214 | // if the toast is closed by the user OR timed out with normal priority, mark it as read |
| 215 | if (removedByUser || toast.priority <= NotificationPriority.NORMAL) { |
| 216 | this.markAsRead(toast.id); |
| 217 | } |
| 218 | }, |
| 219 | |
| 220 | // NEW: Clear entire toast stack |
| 221 | clearToastStack(withCallback = true, removedByUser = false) { |
| 222 | this.toastStack.forEach((toast) => { |
| 223 | if (toast.autoRemoveTimer) { |
| 224 | clearTimeout(toast.autoRemoveTimer); |
| 225 | toast.autoRemoveTimer = null; |
| 226 | } |
| 227 | if (withCallback) this.afterToastRemoved(toast, removedByUser); |
| 228 | }); |
| 229 | this.toastStack = []; |
| 230 | }, |
| 231 | |
| 232 | // NEW: Clean up expired toasts (backup cleanup) |
| 233 | cleanupExpiredToasts() { |
| 234 | const now = Date.now(); |
| 235 | this.toastStack = this.toastStack.filter((toast) => { |
| 236 | if (this.isPersistentToast(toast)) { |
| 237 | return true; |
| 238 | } |
| 239 | const age = now - toast.addedAt; |
| 240 | const maxAge = this.getToastDisplayTime(toast) * 1000; |
| 241 | |
| 242 | if (age > maxAge) { |
| 243 | if (toast.autoRemoveTimer) { |
| 244 | clearTimeout(toast.autoRemoveTimer); |
| 245 | } |
| 246 | return false; |
| 247 | } |
| 248 | return true; |
| 249 | }); |
| 250 | }, |
| 251 | |
| 252 | // NEW: Handle toast click (opens modal) |
| 253 | async handleToastClick(toastId, event) { |
| 254 | const target = event?.target; |
| 255 | const toast = this.toastStack.find((t) => t.toastId === toastId); |
| 256 | if ( |
| 257 | target instanceof Element && |
| 258 | target.closest( |
| 259 | 'button, a, input, select, textarea, summary, label, [role="button"], [data-toast-interactive]' |
| 260 | ) |
| 261 | ) { |
| 262 | if (toast?.id) { |
| 263 | this.markAsRead(toast.id); |
| 264 | } |
| 265 | return; |
| 266 | } |
| 267 | |
| 268 | await this.openModal(); |
| 269 | // Modal opening will clear toast stack via markAllAsRead |
| 270 | }, |
| 271 | |
| 272 | // Add or update a notification |
| 273 | addOrUpdateNotification(notification) { |
| 274 | const existingIndex = this.notifications.findIndex( |
| 275 | (n) => n.id === notification.id |
| 276 | ); |
| 277 | |
| 278 | if (existingIndex >= 0) { |
| 279 | // Update existing notification |
| 280 | this.notifications[existingIndex] = notification; |
| 281 | } else { |
| 282 | // Add new notification at the beginning (most recent first) |
| 283 | this.notifications.unshift(notification); |
| 284 | } |
| 285 | |
| 286 | // Limit notifications to prevent memory issues (keep most recent) |
| 287 | if (this.notifications.length > maxNotifications) { |
| 288 | this.notifications = this.notifications.slice(0, maxNotifications); |
| 289 | } |
| 290 | }, |
| 291 | |
| 292 | // Update unread count |
| 293 | updateUnreadCount() { |
| 294 | const unread = this.notifications.filter((n) => !n.read).length; |
| 295 | const unreadPrio = this.notifications.filter( |
| 296 | (n) => !n.read && n.priority > NotificationPriority.NORMAL |
| 297 | ).length; |
| 298 | if (this.unreadCount !== unread) this.unreadCount = unread; |
| 299 | if (this.unreadPrioCount !== unreadPrio) this.unreadPrioCount = unreadPrio; |
| 300 | }, |
| 301 | |
| 302 | // Mark notification as read |
| 303 | async markAsRead(notificationId) { |
| 304 | const notification = this.notifications.find( |
| 305 | (n) => n.id === notificationId |
| 306 | ); |
| 307 | if (notification && !notification.read) { |
| 308 | notification.read = true; |
| 309 | this.updateUnreadCount(); |
| 310 | |
| 311 | // Sync with backend (non-blocking) |
| 312 | try { |
| 313 | await API.callJsonApi("notifications_mark_read", { |
| 314 | notification_ids: [notificationId], |
| 315 | }); |
| 316 | } catch (error) { |
| 317 | console.error("Failed to sync notification read status:", error); |
| 318 | // Don't revert the UI change - user experience should not be affected |
| 319 | } |
| 320 | } |
| 321 | }, |
| 322 | |
| 323 | // Enhanced: Mark all as read and clear toast stack |
| 324 | async markAllAsRead() { |
| 325 | const unreadNotifications = this.notifications.filter((n) => !n.read); |
| 326 | if (unreadNotifications.length === 0) return; |
| 327 | |
| 328 | // Update UI immediately |
| 329 | this.notifications.forEach((notification) => { |
| 330 | notification.read = true; |
| 331 | }); |
| 332 | this.updateUnreadCount(); |
| 333 | |
| 334 | // Clear toast stack when marking all as read |
| 335 | this.clearToastStack(false); |
| 336 | |
| 337 | // Sync with backend (non-blocking) |
| 338 | try { |
| 339 | await API.callJsonApi("notifications_mark_read", { |
| 340 | mark_all: true, |
| 341 | }); |
| 342 | } catch (error) { |
| 343 | console.error("Failed to sync mark all as read:", error); |
| 344 | } |
| 345 | }, |
| 346 | |
| 347 | // Clear all notifications |
| 348 | async clearAll(syncBackend = true) { |
| 349 | this.notifications = []; |
| 350 | this.unreadCount = 0; |
| 351 | this.clearToastStack(false); // Also clear toast stack |
| 352 | this.clearBackendNotifications(); |
| 353 | }, |
| 354 | |
| 355 | async clearBackendNotifications() { |
| 356 | try { |
| 357 | await API.callJsonApi("notifications_clear", null); |
| 358 | } catch (error) { |
| 359 | console.error("Failed to clear notifications:", error); |
| 360 | } |
| 361 | }, |
| 362 | |
| 363 | // Get notifications by type |
| 364 | getNotificationsByType(type) { |
| 365 | return this.notifications.filter((n) => n.type === type); |
| 366 | }, |
| 367 | |
| 368 | // Get notifications for display: ALL unread + read from last 5 minutes |
| 369 | getDisplayNotifications() { |
| 370 | const fiveMinutesAgo = new Date(Date.now() - 5 * 60 * 1000); |
| 371 | |
| 372 | return this.notifications.filter((notification) => { |
| 373 | // Always show unread notifications |
| 374 | if (!notification.read) { |
| 375 | return true; |
| 376 | } |
| 377 | |
| 378 | // Show read notifications only if they're from the last 5 minutes |
| 379 | const notificationDate = new Date(notification.timestamp); |
| 380 | return notificationDate > fiveMinutesAgo; |
| 381 | }); |
| 382 | }, |
| 383 | |
| 384 | // Get recent notifications (last 5) - kept for backwards compatibility |
| 385 | getRecentNotifications() { |
| 386 | return this.notifications.slice(0, 5); |
| 387 | }, |
| 388 | |
| 389 | // Get notification by ID |
| 390 | getNotificationById(id) { |
| 391 | return this.notifications.find((n) => n.id === id); |
| 392 | }, |
| 393 | |
| 394 | // Remove old notifications (older than 1 hour) |
| 395 | removeOldNotifications() { |
| 396 | const oneHourAgo = new Date(Date.now() - 60 * 60 * 1000); |
| 397 | const initialCount = this.notifications.length; |
| 398 | this.notifications = this.notifications.filter( |
| 399 | (n) => new Date(n.timestamp) > oneHourAgo |
| 400 | ); |
| 401 | |
| 402 | if (this.notifications.length !== initialCount) { |
| 403 | this.updateUnreadCount(); |
| 404 | } |
| 405 | }, |
| 406 | |
| 407 | // Format timestamp for display |
| 408 | formatTimestamp(timestamp) { |
| 409 | const date = new Date(timestamp); |
| 410 | const now = new Date(); |
| 411 | const diffMs = now - date; |
| 412 | const diffMins = diffMs / 60000; |
| 413 | const diffHours = diffMs / 3600000; |
| 414 | const diffDays = diffMs / 86400000; |
| 415 | |
| 416 | if (diffMins < 0.15) return "Just now"; |
| 417 | else if (diffMins < 1) return "Less than a minute ago"; |
| 418 | else if (diffMins < 60) return `${Math.round(diffMins)}m ago`; |
| 419 | else if (diffHours < 24) return `${Math.round(diffHours)}h ago`; |
| 420 | else if (diffDays < 7) return `${Math.round(diffDays)}d ago`; |
| 421 | |
| 422 | return formatDateTime(timestamp, "date"); |
| 423 | }, |
| 424 | |
| 425 | // Get CSS class for notification type |
| 426 | getNotificationClass(type) { |
| 427 | const classes = { |
| 428 | info: "notification-info", |
| 429 | success: "notification-success", |
| 430 | warning: "notification-warning", |
| 431 | error: "notification-error", |
| 432 | progress: "notification-progress", |
| 433 | }; |
| 434 | return classes[type] || "notification-info"; |
| 435 | }, |
| 436 | |
| 437 | // Get CSS class for notification item including read state |
| 438 | getNotificationItemClass(notification) { |
| 439 | const typeClass = this.getNotificationClass(notification.type); |
| 440 | const readClass = notification.read ? "read" : "unread"; |
| 441 | return `notification-item ${typeClass} ${readClass}`; |
| 442 | }, |
| 443 | |
| 444 | // Get icon for notification type (Google Material Icons) |
| 445 | getNotificationIcon(type) { |
| 446 | const icons = { |
| 447 | info: "info", |
| 448 | success: "check_circle", |
| 449 | warning: "warning", |
| 450 | error: "error", |
| 451 | progress: "hourglass_empty", |
| 452 | }; |
| 453 | const iconName = icons[type] || "info"; |
| 454 | return `<x-icon name="${iconName}"></x-icon>`; |
| 455 | }, |
| 456 | |
| 457 | // Create notification via backend (will appear via polling) |
| 458 | async createNotification( |
| 459 | type, |
| 460 | message, |
| 461 | title = "", |
| 462 | detail = "", |
| 463 | display_time = 3, |
| 464 | group = "", |
| 465 | priority = defaultPriority |
| 466 | ) { |
| 467 | try { |
| 468 | const response = await globalThis.sendJsonData("/notification_create", { |
| 469 | type: type, |
| 470 | message: message, |
| 471 | title: title, |
| 472 | detail: detail, |
| 473 | display_time: display_time, |
| 474 | group: group, |
| 475 | priority: priority, |
| 476 | }); |
| 477 | |
| 478 | if (response.success) { |
| 479 | return response.notification_id; |
| 480 | } else { |
| 481 | console.error("Failed to create notification:", response.error); |
| 482 | return null; |
| 483 | } |
| 484 | } catch (error) { |
| 485 | console.error("Error creating notification:", error); |
| 486 | return null; |
| 487 | } |
| 488 | }, |
| 489 | |
| 490 | // Convenience methods for different notification types |
| 491 | async info( |
| 492 | message, |
| 493 | title = "", |
| 494 | detail = "", |
| 495 | display_time = 3, |
| 496 | group = "", |
| 497 | priority = defaultPriority |
| 498 | ) { |
| 499 | return await this.createNotification( |
| 500 | NotificationType.INFO, |
| 501 | message, |
| 502 | title, |
| 503 | detail, |
| 504 | display_time, |
| 505 | group, |
| 506 | priority |
| 507 | ); |
| 508 | }, |
| 509 | |
| 510 | async success( |
| 511 | message, |
| 512 | title = "", |
| 513 | detail = "", |
| 514 | display_time = 3, |
| 515 | group = "", |
| 516 | priority = defaultPriority |
| 517 | ) { |
| 518 | return await this.createNotification( |
| 519 | NotificationType.SUCCESS, |
| 520 | message, |
| 521 | title, |
| 522 | detail, |
| 523 | display_time, |
| 524 | group, |
| 525 | priority |
| 526 | ); |
| 527 | }, |
| 528 | |
| 529 | async warning( |
| 530 | message, |
| 531 | title = "", |
| 532 | detail = "", |
| 533 | display_time = 3, |
| 534 | group = "", |
| 535 | priority = defaultPriority |
| 536 | ) { |
| 537 | return await this.createNotification( |
| 538 | NotificationType.WARNING, |
| 539 | message, |
| 540 | title, |
| 541 | detail, |
| 542 | display_time, |
| 543 | group, |
| 544 | priority |
| 545 | ); |
| 546 | }, |
| 547 | |
| 548 | async error( |
| 549 | message, |
| 550 | title = "", |
| 551 | detail = "", |
| 552 | display_time = 3, |
| 553 | group = "", |
| 554 | priority = defaultPriority |
| 555 | ) { |
| 556 | return await this.createNotification( |
| 557 | NotificationType.ERROR, |
| 558 | message, |
| 559 | title, |
| 560 | detail, |
| 561 | display_time, |
| 562 | group, |
| 563 | priority |
| 564 | ); |
| 565 | }, |
| 566 | |
| 567 | async progress( |
| 568 | message, |
| 569 | title = "", |
| 570 | detail = "", |
| 571 | display_time = 3, |
| 572 | group = "", |
| 573 | priority = defaultPriority |
| 574 | ) { |
| 575 | return await this.createNotification( |
| 576 | NotificationType.PROGRESS, |
| 577 | message, |
| 578 | title, |
| 579 | detail, |
| 580 | display_time, |
| 581 | group, |
| 582 | priority |
| 583 | ); |
| 584 | }, |
| 585 | |
| 586 | // Enhanced: Open modal and clear toast stack |
| 587 | async openModal() { |
| 588 | // Clear toast stack when modal opens |
| 589 | this.clearToastStack(false); |
| 590 | // open modal |
| 591 | await openModal("notifications/notification-modal.html"); |
| 592 | // mark all as read when modal closes |
| 593 | this.markAllAsRead(); |
| 594 | }, |
| 595 | |
| 596 | // Legacy method for backward compatibility |
| 597 | toggleNotifications() { |
| 598 | this.openModal(); |
| 599 | }, |
| 600 | |
| 601 | // NEW: Check if backend connection is available |
| 602 | isConnected() { |
| 603 | // Use the global connection status from index.js, but default to true if undefined |
| 604 | // This handles the case where polling hasn't run yet but backend is actually available |
| 605 | const pollingStatus = |
| 606 | typeof globalThis.getConnectionStatus === "function" |
| 607 | ? globalThis.getConnectionStatus() |
| 608 | : undefined; |
| 609 | |
| 610 | // If polling status is explicitly false, respect that |
| 611 | if (pollingStatus === false) { |
| 612 | return false; |
| 613 | } |
| 614 | |
| 615 | // If polling status is undefined/true, assume backend is available |
| 616 | // (since the page loaded successfully, backend must be working) |
| 617 | return true; |
| 618 | }, |
| 619 | |
| 620 | // NEW: Add frontend-only toast directly to stack (renamed from original addFrontendToast) |
| 621 | addFrontendToastOnly( |
| 622 | type, |
| 623 | message, |
| 624 | title = "", |
| 625 | display_time = 5, |
| 626 | group = "", |
| 627 | priority = defaultPriority |
| 628 | ) { |
| 629 | const timestamp = getCurrentUserISOString(); |
| 630 | const notification = { |
| 631 | id: `frontend-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`, |
| 632 | type: type, |
| 633 | title: title, |
| 634 | message: message, |
| 635 | detail: "", |
| 636 | timestamp: timestamp, |
| 637 | display_time: display_time, |
| 638 | read: false, |
| 639 | frontend: true, // Mark as frontend-only |
| 640 | group: group, |
| 641 | priority: priority, |
| 642 | }; |
| 643 | |
| 644 | //adjust data before using |
| 645 | this.adjustNotificationData(notification); |
| 646 | |
| 647 | // If notification has a group, remove any existing toasts with the same group |
| 648 | if (group && String(group).trim() !== "") { |
| 649 | const existingToastIndex = this.toastStack.findIndex( |
| 650 | (t) => t.group === group |
| 651 | ); |
| 652 | |
| 653 | if (existingToastIndex >= 0) { |
| 654 | const existingToast = this.toastStack[existingToastIndex]; |
| 655 | this.removeFromToastStack(existingToast.toastId); |
| 656 | } |
| 657 | } |
| 658 | |
| 659 | // Create toast object with auto-dismiss timer |
| 660 | const toast = { |
| 661 | ...notification, |
| 662 | toastId: `toast-${notification.id}`, |
| 663 | addedAt: Date.now(), |
| 664 | autoRemoveTimer: null, |
| 665 | hoverTimer: null, |
| 666 | isHovered: false, |
| 667 | }; |
| 668 | |
| 669 | // Add to bottom of stack (newest at bottom) |
| 670 | this.toastStack.push(toast); |
| 671 | |
| 672 | // Enforce max stack limit (remove oldest). |
| 673 | while (this.toastStack.length > maxToasts) { |
| 674 | const removed = this.toastStack.shift(); |
| 675 | if (removed?.autoRemoveTimer) { |
| 676 | clearTimeout(removed.autoRemoveTimer); |
| 677 | } |
| 678 | } |
| 679 | |
| 680 | // Set auto-dismiss timer |
| 681 | this.restartToastTimer(toast.toastId); |
| 682 | |
| 683 | return notification.id; |
| 684 | }, |
| 685 | |
| 686 | // NEW: Enhanced frontend toast that tries backend first, falls back to frontend-only |
| 687 | async addFrontendToast( |
| 688 | type, |
| 689 | message, |
| 690 | title = "", |
| 691 | display_time = 5, |
| 692 | group = "", |
| 693 | priority = defaultPriority, |
| 694 | frontendOnly = false |
| 695 | ) { |
| 696 | // Try to send to backend first if connected |
| 697 | if (!frontendOnly) { |
| 698 | if (this.isConnected()) { |
| 699 | try { |
| 700 | const notificationId = await this.createNotification( |
| 701 | type, |
| 702 | message, |
| 703 | title, |
| 704 | "", |
| 705 | display_time, |
| 706 | group, |
| 707 | priority |
| 708 | ); |
| 709 | if (notificationId) { |
| 710 | // Backend handled it, notification will arrive via polling |
| 711 | return notificationId; |
| 712 | } |
| 713 | } catch (error) { |
| 714 | console.log( |
| 715 | `Backend unavailable for notification, showing as frontend-only: ${ |
| 716 | error.message || error |
| 717 | }` |
| 718 | ); |
| 719 | } |
| 720 | } else { |
| 721 | console.log("Backend disconnected, showing as frontend-only toast"); |
| 722 | } |
| 723 | } |
| 724 | |
| 725 | // Fallback to frontend-only toast |
| 726 | return this.addFrontendToastOnly( |
| 727 | type, |
| 728 | message, |
| 729 | title, |
| 730 | display_time, |
| 731 | group, |
| 732 | priority |
| 733 | ); |
| 734 | }, |
| 735 | |
| 736 | // NEW: Convenience methods for frontend notifications (updated to use new backend-first logic) |
| 737 | async frontendError( |
| 738 | message, |
| 739 | title = "Connection Error", |
| 740 | display_time = 8, |
| 741 | group = "", |
| 742 | priority = defaultPriority, |
| 743 | frontendOnly = false |
| 744 | ) { |
| 745 | return await this.addFrontendToast( |
| 746 | NotificationType.ERROR, |
| 747 | message, |
| 748 | title, |
| 749 | display_time, |
| 750 | group, |
| 751 | priority, |
| 752 | frontendOnly |
| 753 | ); |
| 754 | }, |
| 755 | |
| 756 | async frontendWarning( |
| 757 | message, |
| 758 | title = "Warning", |
| 759 | display_time = 5, |
| 760 | group = "", |
| 761 | priority = defaultPriority, |
| 762 | frontendOnly = false |
| 763 | ) { |
| 764 | return await this.addFrontendToast( |
| 765 | NotificationType.WARNING, |
| 766 | message, |
| 767 | title, |
| 768 | display_time, |
| 769 | group, |
| 770 | priority, |
| 771 | frontendOnly |
| 772 | ); |
| 773 | }, |
| 774 | |
| 775 | async frontendInfo( |
| 776 | message, |
| 777 | title = "Info", |
| 778 | display_time = 3, |
| 779 | group = "", |
| 780 | priority = defaultPriority, |
| 781 | frontendOnly = false |
| 782 | ) { |
| 783 | return await this.addFrontendToast( |
| 784 | NotificationType.INFO, |
| 785 | message, |
| 786 | title, |
| 787 | display_time, |
| 788 | group, |
| 789 | priority, |
| 790 | frontendOnly |
| 791 | ); |
| 792 | }, |
| 793 | |
| 794 | async frontendSuccess( |
| 795 | message, |
| 796 | title = "Success", |
| 797 | display_time = 3, |
| 798 | group = "", |
| 799 | priority = defaultPriority, |
| 800 | frontendOnly = false |
| 801 | ) { |
| 802 | return await this.addFrontendToast( |
| 803 | NotificationType.SUCCESS, |
| 804 | message, |
| 805 | title, |
| 806 | display_time, |
| 807 | group, |
| 808 | priority, |
| 809 | frontendOnly |
| 810 | ); |
| 811 | }, |
| 812 | |
| 813 | async frontendProgress( |
| 814 | message, |
| 815 | title = "Progress", |
| 816 | display_time = 3, |
| 817 | group = "", |
| 818 | priority = defaultPriority, |
| 819 | frontendOnly = false |
| 820 | ) { |
| 821 | return await this.addFrontendToast( |
| 822 | NotificationType.PROGRESS, |
| 823 | message, |
| 824 | title, |
| 825 | display_time, |
| 826 | group, |
| 827 | priority, |
| 828 | frontendOnly |
| 829 | ); |
| 830 | }, |
| 831 | |
| 832 | // NEW: Enhanced frontend toast with object parameters and type annotations |
| 833 | /** |
| 834 | * Adds a frontend toast notification with object parameters. |
| 835 | * @param {Object} options - The options for the toast notification. |
| 836 | * @param {string} options.type - The type of notification (e.g., info, success, error). |
| 837 | * @param {string} options.message - The message content of the notification. |
| 838 | * @param {string} [options.title=''] - The title of the notification. |
| 839 | * @param {number} [options.displayTime=5] - The display duration in seconds. |
| 840 | * @param {string} [options.group=''] - The group identifier for the notification. |
| 841 | * @param {string} [options.priority='medium'] - The priority of the notification. |
| 842 | * @param {boolean} [options.frontendOnly=false] - Whether to show only on frontend. |
| 843 | * @returns {Promise<string>} The ID of the added notification. |
| 844 | */ |
| 845 | async frontendNotification({ |
| 846 | type, |
| 847 | message, |
| 848 | title = '', |
| 849 | displayTime = 5, |
| 850 | group = '', |
| 851 | priority = defaultPriority, |
| 852 | frontendOnly = false |
| 853 | }) { |
| 854 | return await this.addFrontendToast(type, message, title, displayTime, group, priority, frontendOnly); |
| 855 | }, |
| 856 | }; |
| 857 | |
| 858 | // Create and export the store |
| 859 | const store = createStore("notificationStore", model); |
| 860 | export { store }; |
| 861 | |
| 862 | // export toast functions |
| 863 | const toastFrontendInfo = store.frontendInfo.bind(store); |
| 864 | const toastFrontendSuccess = store.frontendSuccess.bind(store); |
| 865 | const toastFrontendWarning = store.frontendWarning.bind(store); |
| 866 | const toastFrontendError = store.frontendError.bind(store); |
| 867 | const toastFrontendProgress = store.frontendProgress.bind(store); |
| 868 | |
| 869 | export { |
| 870 | toastFrontendInfo, |
| 871 | toastFrontendSuccess, |
| 872 | toastFrontendWarning, |
| 873 | toastFrontendError, |
| 874 | toastFrontendProgress, |
| 875 | }; |
| 876 | |
| 877 | // add toasts to global for backward compatibility with older scripts |
| 878 | globalThis.toastFrontendInfo = toastFrontendInfo; |
| 879 | globalThis.toastFrontendSuccess = toastFrontendSuccess; |
| 880 | globalThis.toastFrontendWarning = toastFrontendWarning; |
| 881 | globalThis.toastFrontendError = toastFrontendError; |
| 882 | globalThis.toastFrontendProgress = toastFrontendProgress; |