main
js 882 lines 23.7 KB
Raw
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;