feat: notifications backend system and frontend display

Rafael Uzarowski committed Jul 1, 2025 at 16:14 UTC e476b4bfceca16ef7d8ae29b0d8af050f4f53f8a
19 files changed +1581 -9
agent.py
+8
@@ -36,6 +36,7 @@ class AgentContext:
36
37 _contexts: dict[str, "AgentContext"] = {}
38 _counter: int = 0
39 + _notification_manager = None
40
41 def __init__(
42 self,
@@ -85,6 +86,13 @@ class AgentContext:
86 def all():
87 return list(AgentContext._contexts.values())
88
89 + @classmethod
90 + def get_notification_manager(cls):
91 + if cls._notification_manager is None:
92 + from python.helpers.notification import NotificationManager
93 + cls._notification_manager = NotificationManager()
94 + return cls._notification_manager
95 +
96 @staticmethod
97 def remove(id: str):
98 context = AgentContext._contexts.pop(id, None)
prompts/default/agent.system.tool.notify_user.md new
+43
@@ -0,0 +1,43 @@
1 +### notify_user:
2 +This tool can be used to notify the user of a message independent of the current task.
3 +
4 +!!! This is a universal notification tool
5 +!!! Supported notification types: info, success, warning, error, progress
6 +
7 +#### Arguments:
8 + * "message" (string) : The message to be displayed to the user.
9 + * "title" (Optional, string) : The title of the notification.
10 + * "detail" (Optional, string) : The detail of the notification. May contain html tags.
11 + * "type" (Optional, string) : The type of the notification. Can be "info", "success", "warning", "error", "progress".
12 +
13 +#### Usage examples:
14 +##### 1: Success notification
15 +```json
16 +{
17 + "thoughts": [
18 + "...",
19 + ],
20 + "tool_name": "notify_user",
21 + "tool_args": {
22 + "message": "Important notification: task xyz is completed succesfully",
23 + "title": "Task Completed",
24 + "detail": "This is a test notification detail with <a href='https://www.google.com'>link</a>",
25 + "type": "success"
26 + }
27 +}
28 +```
29 +##### 2: Error notification
30 +```json
31 +{
32 + "thoughts": [
33 + "...",
34 + ],
35 + "tool_name": "notify_user",
36 + "tool_args": {
37 + "message": "Important notification: task xyz is failed",
38 + "title": "Task Failed",
39 + "detail": "This is a test notification detail with <a href='https://www.google.com'>link</a> and <img src='https://www.google.com/images/branding/googlelogo/1x/googlelogo_color_272x92dp.png'>",
40 + "type": "error"
41 + }
42 +}
43 +```
prompts/default/agent.system.tools.md
+2
@@ -19,3 +19,5 @@
19 {{ include './agent.system.tool.scheduler.md' }}
20
21 {{ include './agent.system.tool.document_query.md' }}
22 +
23 +{{ include './agent.system.tool.notify_user.md' }}
prompts/default/fw.notify_user.notification_sent.md new
+1
@@ -0,0 +1 @@
1 +The notification has been sent to the user.
python/api/notification_create.py new
+63
@@ -0,0 +1,63 @@
1 +from python.helpers.api import ApiHandler
2 +from flask import Request, Response
3 +from python.helpers.notification import AgentNotification, NotificationType
4 +
5 +
6 +class NotificationCreate(ApiHandler):
7 + @classmethod
8 + def requires_auth(cls) -> bool:
9 + return True
10 +
11 + async def process(self, input: dict, request: Request) -> dict | Response:
12 + # Extract notification data
13 + notification_type = input.get("type", "info")
14 + message = input.get("message", "")
15 + title = input.get("title", "")
16 + detail = input.get("detail", "")
17 + display_time = input.get("display_time", 3) # Default to 3 seconds
18 +
19 + # Validate required fields
20 + if not message:
21 + return {"success": False, "error": "Message is required"}
22 +
23 + # Validate display_time
24 + try:
25 + display_time = int(display_time)
26 + if display_time <= 0:
27 + display_time = 3 # Reset to default if invalid
28 + except (ValueError, TypeError):
29 + display_time = 3 # Reset to default if not convertible to int
30 +
31 + # Validate notification type
32 + try:
33 + if isinstance(notification_type, str):
34 + notification_type = NotificationType(notification_type.lower())
35 + except ValueError:
36 + return {"success": False, "error": f"Invalid notification type: {notification_type}"}
37 +
38 + # Create notification using the appropriate helper method
39 + try:
40 + if notification_type == NotificationType.INFO:
41 + notification = AgentNotification.info(message, title, detail, display_time)
42 + elif notification_type == NotificationType.SUCCESS:
43 + notification = AgentNotification.success(message, title, detail, display_time)
44 + elif notification_type == NotificationType.WARNING:
45 + notification = AgentNotification.warning(message, title, detail, display_time)
46 + elif notification_type == NotificationType.ERROR:
47 + notification = AgentNotification.error(message, title, detail, display_time)
48 + elif notification_type == NotificationType.PROGRESS:
49 + notification = AgentNotification.progress(message, title, detail, display_time)
50 + else:
51 + notification = AgentNotification.info(message, title, detail, display_time)
52 +
53 + return {
54 + "success": True,
55 + "notification_id": notification.id,
56 + "message": "Notification created successfully"
57 + }
58 +
59 + except Exception as e:
60 + return {
61 + "success": False,
62 + "error": f"Failed to create notification: {str(e)}"
63 + }
python/api/notifications_history.py new
+20
@@ -0,0 +1,20 @@
1 +from python.helpers.api import ApiHandler
2 +from flask import Request, Response
3 +from agent import AgentContext
4 +
5 +
6 +class NotificationsHistory(ApiHandler):
7 + @classmethod
8 + def requires_auth(cls) -> bool:
9 + return True
10 +
11 + async def process(self, input: dict, request: Request) -> dict | Response:
12 + # Get the global notification manager
13 + notification_manager = AgentContext.get_notification_manager()
14 +
15 + # Return all notifications for history modal
16 + return {
17 + "notifications": [n.output() for n in notification_manager.notifications],
18 + "guid": notification_manager.guid,
19 + "count": len(notification_manager.notifications),
20 + }
python/api/notifications_mark_read.py new
+38
@@ -0,0 +1,38 @@
1 +from python.helpers.api import ApiHandler
2 +from flask import Request, Response
3 +from agent import AgentContext
4 +
5 +
6 +class NotificationsMarkRead(ApiHandler):
7 + @classmethod
8 + def requires_auth(cls) -> bool:
9 + return True
10 +
11 + async def process(self, input: dict, request: Request) -> dict | Response:
12 + notification_ids = input.get("notification_ids", [])
13 + mark_all = input.get("mark_all", False)
14 +
15 + notification_manager = AgentContext.get_notification_manager()
16 +
17 + if mark_all:
18 + notification_manager.mark_all_read()
19 + return {"success": True, "message": "All notifications marked as read"}
20 +
21 + if not notification_ids:
22 + return {"success": False, "error": "No notification IDs provided"}
23 +
24 + # Mark specific notifications as read
25 + marked_count = 0
26 + for notification_id in notification_ids:
27 + # Find notification by ID and mark as read
28 + for notification in notification_manager.notifications:
29 + if notification.id == notification_id and not notification.read:
30 + notification.mark_read()
31 + marked_count += 1
32 + break
33 +
34 + return {
35 + "success": True,
36 + "marked_count": marked_count,
37 + "message": f"Marked {marked_count} notifications as read"
38 + }
python/api/poll.py
+9 -4
@@ -1,11 +1,8 @@
1 -import time
2 -from datetime import datetime
1 from python.helpers.api import ApiHandler
2 from flask import Request, Response
3
4 from agent import AgentContext
5
8 -from python.helpers import persist_chat
6 from python.helpers.task_scheduler import TaskScheduler
7 from python.helpers.localization import Localization
8 from python.helpers.dotenv import get_dotenv_value
@@ -16,6 +13,7 @@ class Poll(ApiHandler):
13 async def process(self, input: dict, request: Request) -> dict | Response:
14 ctxid = input.get("context", None)
15 from_no = input.get("log_from", 0)
16 + notifications_from = input.get("notifications_from", 0)
17
18 # Get timezone from input (default to dotenv default or UTC if not provided)
19 timezone = input.get("timezone", get_dotenv_value("DEFAULT_USER_TIMEZONE", "UTC"))
@@ -26,6 +24,10 @@ class Poll(ApiHandler):
24
25 logs = context.log.output(start=from_no)
26
27 + # Get notifications from global notification manager
28 + notification_manager = AgentContext.get_notification_manager()
29 + notifications = notification_manager.output(start=notifications_from)
30 +
31 # loop AgentContext._contexts
32
33 # Get a task scheduler instance
@@ -65,7 +67,7 @@ class Poll(ApiHandler):
67 # Add task details to context_data with the same field names
68 # as used in scheduler endpoints to maintain UI compatibility
69 context_data.update({
68 - "task_name": task_details.get("name"), # name is for context, task_name for the task name
70 + "task_name": task_details.get("name"), # name is for context, task_name for the task name
71 "uuid": task_details.get("uuid"),
72 "state": task_details.get("state"),
73 "type": task_details.get("type"),
@@ -105,4 +107,7 @@ class Poll(ApiHandler):
107 "log_progress": context.log.progress,
108 "log_progress_active": context.log.progress_active,
109 "paused": context.paused,
110 + "notifications": notifications,
111 + "notifications_guid": notification_manager.guid,
112 + "notifications_version": len(notification_manager.updates),
113 }
python/helpers/notification.py new
+174
@@ -0,0 +1,174 @@
1 +from dataclasses import dataclass
2 +import uuid
3 +from datetime import datetime, timezone, timedelta
4 +from enum import Enum
5 +
6 +
7 +class NotificationType(Enum):
8 + INFO = "info"
9 + SUCCESS = "success"
10 + WARNING = "warning"
11 + ERROR = "error"
12 + PROGRESS = "progress"
13 +
14 +
15 +@dataclass
16 +class NotificationItem:
17 + manager: "NotificationManager"
18 + no: int
19 + type: NotificationType
20 + title: str
21 + message: str
22 + detail: str # HTML content for expandable details
23 + timestamp: datetime
24 + display_time: int = 3 # Display duration in seconds, default 3 seconds
25 + read: bool = False
26 + id: str = ""
27 +
28 + def __post_init__(self):
29 + if not self.id:
30 + self.id = str(uuid.uuid4())
31 + # Ensure type is always NotificationType
32 + if isinstance(self.type, str):
33 + self.type = NotificationType(self.type)
34 +
35 + def mark_read(self):
36 + self.read = True
37 + self.manager._update_item(self.no, read=True)
38 +
39 + def output(self):
40 + return {
41 + "no": self.no,
42 + "id": self.id,
43 + "type": self.type.value,
44 + "title": self.title,
45 + "message": self.message,
46 + "detail": self.detail,
47 + "timestamp": self.timestamp.isoformat(),
48 + "display_time": self.display_time,
49 + "read": self.read,
50 + }
51 +
52 +
53 +class NotificationManager:
54 + def __init__(self, max_notifications: int = 100):
55 + self.guid: str = str(uuid.uuid4())
56 + self.updates: list[int] = []
57 + self.notifications: list[NotificationItem] = []
58 + self.max_notifications = max_notifications
59 +
60 + def add_notification(
61 + self,
62 + type: NotificationType,
63 + message: str,
64 + title: str = "",
65 + detail: str = "",
66 + display_time: int = 3,
67 + ) -> NotificationItem:
68 + # Create notification item
69 + item = NotificationItem(
70 + manager=self,
71 + no=len(self.notifications),
72 + type=type,
73 + title=title,
74 + message=message,
75 + detail=detail,
76 + timestamp=datetime.now(timezone.utc),
77 + display_time=display_time,
78 + )
79 +
80 + # Add to notifications
81 + self.notifications.append(item)
82 + self.updates.append(item.no)
83 +
84 + # Enforce limit
85 + self._enforce_limit()
86 +
87 + return item
88 +
89 + def _enforce_limit(self):
90 + if len(self.notifications) > self.max_notifications:
91 + # Remove oldest notifications
92 + to_remove = len(self.notifications) - self.max_notifications
93 + self.notifications = self.notifications[to_remove:]
94 + # Adjust notification numbers
95 + for i, notification in enumerate(self.notifications):
96 + notification.no = i
97 + # Adjust updates list
98 + self.updates = [no - to_remove for no in self.updates if no >= to_remove]
99 +
100 + def get_recent_notifications(self, seconds: int = 30) -> list[NotificationItem]:
101 + cutoff = datetime.now(timezone.utc) - timedelta(seconds=seconds)
102 + return [n for n in self.notifications if n.timestamp >= cutoff]
103 +
104 + def output(self, start: int | None = None, end: int | None = None) -> list[dict]:
105 + if start is None:
106 + start = 0
107 + if end is None:
108 + end = len(self.updates)
109 +
110 + out = []
111 + seen = set()
112 + for update in self.updates[start:end]:
113 + if update not in seen and update < len(self.notifications):
114 + out.append(self.notifications[update].output())
115 + seen.add(update)
116 +
117 + return out
118 +
119 + def _update_item(self, no: int, **kwargs):
120 + if no < len(self.notifications):
121 + item = self.notifications[no]
122 + for key, value in kwargs.items():
123 + if hasattr(item, key):
124 + setattr(item, key, value)
125 + self.updates.append(no)
126 +
127 + def mark_all_read(self):
128 + for notification in self.notifications:
129 + notification.read = True
130 +
131 + def clear_all(self):
132 + self.notifications = []
133 + self.updates = []
134 + self.guid = str(uuid.uuid4())
135 +
136 + def get_notifications_by_type(self, type: NotificationType) -> list[NotificationItem]:
137 + return [n for n in self.notifications if n.type == type]
138 +
139 +
140 +class AgentNotification:
141 + @staticmethod
142 + def info(message: str, title: str = "", detail: str = "", display_time: int = 3) -> NotificationItem:
143 + from agent import AgentContext
144 + return AgentContext.get_notification_manager().add_notification(
145 + NotificationType.INFO, message, title, detail, display_time
146 + )
147 +
148 + @staticmethod
149 + def success(message: str, title: str = "", detail: str = "", display_time: int = 3) -> NotificationItem:
150 + from agent import AgentContext
151 + return AgentContext.get_notification_manager().add_notification(
152 + NotificationType.SUCCESS, message, title, detail, display_time
153 + )
154 +
155 + @staticmethod
156 + def warning(message: str, title: str = "", detail: str = "", display_time: int = 3) -> NotificationItem:
157 + from agent import AgentContext
158 + return AgentContext.get_notification_manager().add_notification(
159 + NotificationType.WARNING, message, title, detail, display_time
160 + )
161 +
162 + @staticmethod
163 + def error(message: str, title: str = "", detail: str = "", display_time: int = 3) -> NotificationItem:
164 + from agent import AgentContext
165 + return AgentContext.get_notification_manager().add_notification(
166 + NotificationType.ERROR, message, title, detail, display_time
167 + )
168 +
169 + @staticmethod
170 + def progress(message: str, title: str = "", detail: str = "", display_time: int = 3) -> NotificationItem:
171 + from agent import AgentContext
172 + return AgentContext.get_notification_manager().add_notification(
173 + NotificationType.PROGRESS, message, title, detail, display_time
174 + )
python/tools/notify_user.py new
+26
@@ -0,0 +1,26 @@
1 +from python.helpers.tool import Tool, Response
2 +from agent import AgentContext
3 +
4 +
5 +class NotifyUserTool(Tool):
6 +
7 + async def execute(self, **kwargs):
8 +
9 + message = self.args.get("message", "")
10 + title = self.args.get("title", "")
11 + detail = self.args.get("detail", "")
12 + notification_type = self.args.get("type", "info")
13 +
14 + if notification_type not in ["info", "success", "warning", "error", "progress"]:
15 + return Response(message=f"Invalid notification type: {notification_type}", break_loop=False)
16 +
17 + if not message:
18 + return Response(message="Message is required", break_loop=False)
19 +
20 + AgentContext.get_notification_manager().add_notification(
21 + message=message,
22 + title=title,
23 + detail=detail,
24 + type=notification_type,
25 + )
26 + return Response(message=self.agent.read_prompt("fw.notify_user.notification_sent.md"), break_loop=False)
webui/components/notifications/notification-icons.html new
+34
@@ -0,0 +1,34 @@
1 +<html>
2 +<head>
3 + <script type="module">
4 + import { store } from "/js/notificationStore.js";
5 + console.log('Notification component script loaded');
6 + </script>
7 +</head>
8 +<body>
9 + <div x-data>
10 + <!-- Notification Toggle Button (always visible and clickable) -->
11 + <div class="notification-toggle"
12 + :class="{
13 + 'has-unread': $store.notificationStore.unreadCount > 0,
14 + 'has-notifications': $store.notificationStore.notifications.length > 0
15 + }"
16 + @click="$store.notificationStore.openModal()"
17 + title="View Notifications">
18 + <div class="notification-icon">
19 + 🔔
20 + </div>
21 + <span x-show="$store.notificationStore.unreadCount > 0"
22 + class="notification-badge"
23 + x-text="$store.notificationStore.unreadCount"></span>
24 + </div>
25 +
26 + <!-- Test Notification Button (for development) -->
27 + <button class="notification-test-button"
28 + @click="$store.notificationStore.info('Test notification message', 'Test Title', '<p>This is test detail content with <strong>HTML</strong> formatting.</p>')"
29 + title="Create Test Notification">
30 + <div class="notification-icon">🧪</div>
31 + </button>
32 + </div>
33 +</body>
34 +</html>
webui/components/notifications/notification-modal.html new
+359
@@ -0,0 +1,359 @@
1 +<html>
2 +<head>
3 + <title>Notifications</title>
4 + <script type="module">
5 + import { store } from "/js/notificationStore.js";
6 +
7 + // Ensure notification store is available globally for the modal
8 + if (!window.notificationStore) {
9 + window.notificationStore = store;
10 + }
11 + </script>
12 +</head>
13 +<body>
14 + <div x-data="notificationModalComponent()">
15 + <!-- Modal Header Actions -->
16 + <div class="modal-subheader">
17 + <div class="notification-header-actions">
18 + <button class="notification-action"
19 + @click="$store.notificationStore.clearAll()"
20 + :disabled="$store.notificationStore.getDisplayNotifications().length === 0"
21 + title="Clear All">
22 + 🗑️ Clear All
23 + </button>
24 + </div>
25 + </div>
26 +
27 + <!-- Notifications List -->
28 + <div class="notification-list" x-show="$store.notificationStore.getDisplayNotifications().length > 0">
29 + <template x-for="notification in $store.notificationStore.getDisplayNotifications()" :key="notification.id">
30 + <div class="notification-item"
31 + x-data="{ expanded: false }"
32 + :class="$store.notificationStore.getNotificationItemClass(notification)"
33 + @click="$store.notificationStore.markAsRead(notification.id)">
34 +
35 + <div class="notification-icon"
36 + x-html="$store.notificationStore.getNotificationIcon(notification.type)">
37 + </div>
38 +
39 + <div class="notification-content">
40 + <div class="notification-title"
41 + x-show="notification.title"
42 + x-text="notification.title">
43 + </div>
44 + <div class="notification-message"
45 + x-text="notification.message">
46 + </div>
47 + <div class="notification-timestamp"
48 + x-text="$store.notificationStore.formatTimestamp(notification.timestamp)">
49 + </div>
50 +
51 + <!-- Expand Toggle Button (as last row element) -->
52 + <button class="notification-expand-toggle"
53 + x-show="notification.detail"
54 + @click.stop="expanded = !expanded"
55 + :title="expanded ? 'Collapse Details' : 'Expand Details'">
56 + <span x-show="!expanded">▶ Show Details</span>
57 + <span x-show="expanded">▼ Hide Details</span>
58 + </button>
59 +
60 + <!-- Expandable Detail Content -->
61 + <div class="notification-detail"
62 + x-show="expanded && notification.detail"
63 + x-transition:enter="transition ease-out duration-200"
64 + x-transition:enter-start="opacity-0 max-h-0"
65 + x-transition:enter-end="opacity-100 max-h-96"
66 + x-transition:leave="transition ease-in duration-200"
67 + x-transition:leave-start="opacity-100 max-h-96"
68 + x-transition:leave-end="opacity-0 max-h-0">
69 + <div class="notification-detail-content" x-html="notification.detail"></div>
70 + </div>
71 + </div>
72 + </div>
73 + </template>
74 + </div>
75 +
76 + <!-- Empty State -->
77 + <div class="notification-empty" x-show="$store.notificationStore.getDisplayNotifications().length === 0">
78 + <div class="notification-empty-icon">🔔</div>
79 + <p>No notifications to display</p>
80 + <p style="font-size: 0.8rem; opacity: 0.7; margin-top: 0.5rem;"
81 + x-show="$store.notificationStore.notifications.length > 0">
82 + All notifications have been read and are older than 5 minutes
83 + </p>
84 + </div>
85 + </div>
86 +
87 + <style>
88 + /* Modal-specific styles that override the standard modal */
89 + .modal-container.notification-modal {
90 + width: 90%;
91 + max-width: 600px;
92 + max-height: 80vh;
93 + }
94 +
95 + .notification-header-actions {
96 + display: flex;
97 + gap: 0.5rem;
98 + align-items: center;
99 + justify-content: flex-end;
100 + width: 100%;
101 + }
102 +
103 + .notification-action {
104 + padding: 0.5rem 0.75rem;
105 + background: rgba(255, 255, 255, 0.1);
106 + border: 1px solid rgba(255, 255, 255, 0.2);
107 + border-radius: 4px;
108 + color: var(--color-text);
109 + cursor: pointer;
110 + transition: all 0.2s ease;
111 + font-size: 0.85rem;
112 + display: flex;
113 + align-items: center;
114 + gap: 0.25rem;
115 + }
116 +
117 + .notification-action:hover {
118 + background: rgba(255, 255, 255, 0.15);
119 + border-color: rgba(255, 255, 255, 0.3);
120 + }
121 +
122 + .notification-action:disabled {
123 + opacity: 0.4;
124 + cursor: not-allowed;
125 + }
126 +
127 + .notification-action:disabled:hover {
128 + background: rgba(255, 255, 255, 0.1);
129 + border-color: rgba(255, 255, 255, 0.2);
130 + }
131 +
132 + /* Notification List */
133 + .notification-list {
134 + max-height: 60vh;
135 + overflow-y: auto;
136 + padding: 0.5rem 0;
137 + }
138 +
139 + .notification-list::-webkit-scrollbar {
140 + width: 6px;
141 + }
142 +
143 + .notification-list::-webkit-scrollbar-track {
144 + background: transparent;
145 + }
146 +
147 + .notification-list::-webkit-scrollbar-thumb {
148 + background-color: rgba(155, 155, 155, 0.3);
149 + border-radius: 6px;
150 + }
151 +
152 + .notification-list::-webkit-scrollbar-thumb:hover {
153 + background-color: rgba(155, 155, 155, 0.5);
154 + }
155 +
156 + /* Include all the notification item styles from the CSS file */
157 + .notification-item {
158 + display: flex;
159 + align-items: flex-start;
160 + gap: 0.75rem;
161 + padding: 1rem;
162 + margin-bottom: 0.5rem;
163 + background: rgba(0, 0, 0, 0.1);
164 + border: 1px solid rgba(255, 255, 255, 0.1);
165 + border-radius: 8px;
166 + cursor: pointer;
167 + transition: all 0.2s ease;
168 + position: relative;
169 + }
170 +
171 + .notification-item:hover {
172 + background: rgba(0, 0, 0, 0.15);
173 + border-color: rgba(255, 255, 255, 0.2);
174 + }
175 +
176 + .notification-item:last-child {
177 + margin-bottom: 0;
178 + }
179 +
180 + .notification-item.unread {
181 + border-left: 4px solid #2196F3;
182 + background: rgba(33, 150, 243, 0.05);
183 + }
184 +
185 + .notification-item.read {
186 + opacity: 0.7;
187 + }
188 +
189 + .notification-item.read .notification-title {
190 + color: var(--color-text-muted);
191 + }
192 +
193 + .notification-item.read .notification-message {
194 + color: var(--color-text-muted);
195 + }
196 +
197 + /* Notification Icon */
198 + .notification-icon {
199 + font-size: 1.1rem;
200 + line-height: 1;
201 + opacity: 0.8;
202 + min-width: 20px;
203 + text-align: center;
204 + margin-top: 0.1rem;
205 + }
206 +
207 + .notification-info .notification-icon {
208 + color: #2196F3;
209 + }
210 +
211 + .notification-success .notification-icon {
212 + color: #4CAF50;
213 + }
214 +
215 + .notification-warning .notification-icon {
216 + color: #FF9800;
217 + }
218 +
219 + .notification-error .notification-icon {
220 + color: #F44336;
221 + }
222 +
223 + .notification-progress .notification-icon {
224 + color: #9C27B0;
225 + animation: spin 2s linear infinite;
226 + }
227 +
228 + /* Notification Content */
229 + .notification-content {
230 + flex: 1;
231 + min-width: 0;
232 + }
233 +
234 + .notification-title {
235 + font-weight: 600;
236 + font-size: 0.9rem;
237 + color: var(--color-primary);
238 + margin-bottom: 0.25rem;
239 + line-height: 1.3;
240 + }
241 +
242 + .notification-message {
243 + font-size: 0.85rem;
244 + color: var(--color-text);
245 + line-height: 1.4;
246 + margin-bottom: 0.5rem;
247 + }
248 +
249 + .notification-expand-toggle {
250 + background: transparent;
251 + border: none;
252 + color: var(--color-text);
253 + cursor: pointer;
254 + padding: 0.25rem;
255 + border-radius: 4px;
256 + transition: all 0.2s ease;
257 + font-size: 0.8rem;
258 + opacity: 0.7;
259 + display: flex;
260 + align-items: center;
261 + gap: 0.25rem;
262 + }
263 +
264 + .notification-expand-toggle:hover {
265 + opacity: 1;
266 + background: rgba(255, 255, 255, 0.1);
267 + }
268 +
269 + .notification-timestamp {
270 + font-size: 0.75rem;
271 + color: var(--color-text);
272 + opacity: 0.6;
273 + margin-top: 0.5rem;
274 + }
275 +
276 + /* Notification Detail */
277 + .notification-detail {
278 + margin-top: 0.75rem;
279 + padding: 0.75rem;
280 + background: rgba(0, 0, 0, 0.1);
281 + border-radius: 6px;
282 + border-left: 3px solid rgba(255, 255, 255, 0.2);
283 + overflow: hidden;
284 + transition: all 0.2s ease;
285 + }
286 +
287 + .notification-detail-content {
288 + font-size: 0.85rem;
289 + line-height: 1.4;
290 + color: var(--color-text);
291 + }
292 +
293 + /* Empty State */
294 + .notification-empty {
295 + text-align: center;
296 + padding: 3rem 1rem;
297 + color: var(--color-text);
298 + opacity: 0.6;
299 + }
300 +
301 + .notification-empty-icon {
302 + font-size: 3rem;
303 + margin-bottom: 1rem;
304 + opacity: 0.5;
305 + }
306 +
307 + /* Animations */
308 + @keyframes spin {
309 + from { transform: rotate(0deg); }
310 + to { transform: rotate(360deg); }
311 + }
312 +
313 + /* Light Mode Styles */
314 + .light-mode .notification-item {
315 + background: rgba(0, 0, 0, 0.03);
316 + border-color: rgba(0, 0, 0, 0.1);
317 + }
318 +
319 + .light-mode .notification-item:hover {
320 + background: rgba(0, 0, 0, 0.06);
321 + border-color: rgba(0, 0, 0, 0.15);
322 + }
323 +
324 + .light-mode .notification-action {
325 + border-color: rgba(0, 0, 0, 0.2);
326 + color: var(--color-text);
327 + }
328 +
329 + .light-mode .notification-action:hover {
330 + background: rgba(0, 0, 0, 0.05);
331 + border-color: rgba(0, 0, 0, 0.3);
332 + }
333 +
334 + .light-mode .notification-detail {
335 + background: rgba(0, 0, 0, 0.05);
336 + border-left-color: rgba(0, 0, 0, 0.15);
337 + }
338 + </style>
339 +
340 + <script>
341 + function notificationModalComponent() {
342 + return {
343 + init() {
344 + // Initialize component when modal loads
345 + console.log('Notification modal component initialized');
346 +
347 + // Mark all notifications as read when modal opens
348 + // This can be configured later if needed
349 + setTimeout(() => {
350 + if (this.$store.notificationStore) {
351 + this.$store.notificationStore.markAllAsRead();
352 + }
353 + }, 1000);
354 + }
355 + };
356 + }
357 + </script>
358 +</body>
359 +</html>
webui/components/notifications/notification-toast-stack.html new
+245
@@ -0,0 +1,245 @@
1 +<html>
2 +<head>
3 + <title>Notification Toast Stack</title>
4 + <script type="module">
5 + import { store } from "/js/notificationStore.js";
6 + </script>
7 +</head>
8 +<body>
9 + <div x-data>
10 + <!-- Toast Stack Container -->
11 + <div class="toast-stack-container"
12 + x-show="$store.notificationStore.toastStack.length > 0">
13 +
14 + <template x-for="(toast, index) in $store.notificationStore.toastStack" :key="toast.toastId">
15 + <div class="toast-item"
16 + :class="$store.notificationStore.getNotificationClass(toast.type)"
17 + @click="$store.notificationStore.handleToastClick(toast.toastId)"
18 + x-transition:enter="toast-enter"
19 + x-transition:leave="toast-leave">
20 +
21 + <!-- Toast Icon -->
22 + <div class="toast-icon"
23 + x-html="$store.notificationStore.getNotificationIcon(toast.type)">
24 + </div>
25 +
26 + <!-- Toast Content -->
27 + <div class="toast-content">
28 + <div class="toast-title"
29 + x-show="toast.title"
30 + x-text="toast.title">
31 + </div>
32 + <div class="toast-message"
33 + x-text="toast.message">
34 + </div>
35 + <div class="toast-timestamp"
36 + x-text="$store.notificationStore.formatTimestamp(toast.timestamp)">
37 + </div>
38 + </div>
39 +
40 + <!-- Toast Dismiss -->
41 + <button class="toast-dismiss"
42 + @click.stop="$store.notificationStore.removeFromToastStack(toast.toastId)"
43 + title="Dismiss">
44 + ✕
45 + </button>
46 + </div>
47 + </template>
48 + </div>
49 + </div>
50 +
51 + <style>
52 + /* Toast Stack Container */
53 + .toast-stack-container {
54 + position: fixed;
55 + bottom: 20px;
56 + right: 20px;
57 + z-index: 1500;
58 + display: flex;
59 + flex-direction: column;
60 + gap: 8px;
61 + pointer-events: none;
62 + max-width: 400px;
63 + width: 100%;
64 + }
65 +
66 + /* Individual Toast Items */
67 + .toast-item {
68 + pointer-events: auto;
69 + display: flex;
70 + align-items: flex-start;
71 + gap: 12px;
72 + padding: 16px;
73 + background: rgba(0, 0, 0, 0.9);
74 + border: 1px solid rgba(255, 255, 255, 0.2);
75 + border-radius: 8px;
76 + cursor: pointer;
77 + transition: all 0.2s ease;
78 + backdrop-filter: blur(10px);
79 + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
80 + position: relative;
81 + min-height: 60px;
82 + }
83 +
84 + .toast-item:hover {
85 + background: rgba(0, 0, 0, 0.95);
86 + border-color: rgba(255, 255, 255, 0.3);
87 + transform: translateY(-2px);
88 + box-shadow: 0 6px 20px rgba(0, 0, 0, 0.4);
89 + }
90 +
91 + /* Toast Type Styling */
92 + .toast-item.notification-info {
93 + border-left: 4px solid #2196F3;
94 + background: rgba(33, 150, 243, 0.1);
95 + }
96 +
97 + .toast-item.notification-success {
98 + border-left: 4px solid #4CAF50;
99 + background: rgba(76, 175, 80, 0.1);
100 + }
101 +
102 + .toast-item.notification-warning {
103 + border-left: 4px solid #FF9800;
104 + background: rgba(255, 152, 0, 0.1);
105 + }
106 +
107 + .toast-item.notification-error {
108 + border-left: 4px solid #F44336;
109 + background: rgba(244, 67, 54, 0.1);
110 + }
111 +
112 + .toast-item.notification-progress {
113 + border-left: 4px solid #9C27B0;
114 + background: rgba(156, 39, 176, 0.1);
115 + }
116 +
117 + /* Toast Icon */
118 + .toast-icon {
119 + font-size: 1.2rem;
120 + line-height: 1;
121 + opacity: 0.9;
122 + min-width: 24px;
123 + text-align: center;
124 + margin-top: 2px;
125 + }
126 +
127 + /* Toast Content */
128 + .toast-content {
129 + flex: 1;
130 + min-width: 0;
131 + }
132 +
133 + .toast-title {
134 + font-weight: 600;
135 + font-size: 0.9rem;
136 + color: var(--color-primary);
137 + margin-bottom: 4px;
138 + line-height: 1.3;
139 + }
140 +
141 + .toast-message {
142 + font-size: 0.85rem;
143 + color: var(--color-text);
144 + line-height: 1.4;
145 + margin-bottom: 4px;
146 + }
147 +
148 + .toast-timestamp {
149 + font-size: 0.75rem;
150 + color: var(--color-text);
151 + opacity: 0.6;
152 + }
153 +
154 + /* Toast Dismiss */
155 + .toast-dismiss {
156 + background: rgba(255, 255, 255, 0.1);
157 + border: none;
158 + border-radius: 4px;
159 + color: var(--color-text);
160 + cursor: pointer;
161 + padding: 4px 6px;
162 + font-size: 0.8rem;
163 + transition: all 0.2s ease;
164 + opacity: 0;
165 + }
166 +
167 + .toast-item:hover .toast-dismiss {
168 + opacity: 1;
169 + }
170 +
171 + .toast-dismiss:hover {
172 + background: rgba(255, 255, 255, 0.2);
173 + color: #fff;
174 + }
175 +
176 + /* Toast Animations */
177 + .toast-enter {
178 + transition: all 0.3s cubic-bezier(0.68, -0.55, 0.265, 1.55);
179 + opacity: 0;
180 + transform: translateX(100%) scale(0.8);
181 + }
182 +
183 + .toast-leave {
184 + transition: all 0.2s ease;
185 + opacity: 0;
186 + transform: translateX(100%) scale(0.8);
187 + }
188 +
189 + /* Light Mode Styles */
190 + .light-mode .toast-item {
191 + background: rgba(255, 255, 255, 0.95);
192 + border-color: rgba(0, 0, 0, 0.1);
193 + color: var(--color-text);
194 + }
195 +
196 + .light-mode .toast-item:hover {
197 + background: rgba(255, 255, 255, 0.98);
198 + border-color: rgba(0, 0, 0, 0.2);
199 + }
200 +
201 + .light-mode .toast-dismiss {
202 + background: rgba(0, 0, 0, 0.1);
203 + }
204 +
205 + .light-mode .toast-dismiss:hover {
206 + background: rgba(0, 0, 0, 0.2);
207 + }
208 +
209 + /* Mobile Responsive */
210 + @media (max-width: 768px) {
211 + .toast-stack-container {
212 + bottom: 10px;
213 + right: 10px;
214 + left: 10px;
215 + max-width: none;
216 + }
217 +
218 + .toast-item {
219 + padding: 12px;
220 + font-size: 0.9rem;
221 + }
222 +
223 + .toast-title {
224 + font-size: 0.85rem;
225 + }
226 +
227 + .toast-message {
228 + font-size: 0.8rem;
229 + }
230 + }
231 +
232 + /* Accessibility */
233 + @media (prefers-reduced-motion: reduce) {
234 + .toast-enter,
235 + .toast-leave {
236 + transition: none;
237 + }
238 +
239 + .toast-item:hover {
240 + transform: none;
241 + }
242 + }
243 + </style>
244 +</body>
245 +</html>
webui/css/notification.css new
+156
@@ -0,0 +1,156 @@
1 +/* ===== NOTIFICATION SYSTEM STYLES ===== */
2 +
3 +/* Notification Toggle Button */
4 +.notification-toggle {
5 + display: inline-flex;
6 + align-items: center;
7 + justify-content: center;
8 + position: relative;
9 + padding: 0;
10 + background: rgba(255, 255, 255, 0.05);
11 + border: 1px solid rgba(255, 255, 255, 0.1);
12 + border-radius: 6px;
13 + color: var(--color-text);
14 + cursor: pointer;
15 + transition: all 0.2s ease;
16 + font-size: 1rem;
17 + width: 36px;
18 + height: 36px;
19 + flex-shrink: 0;
20 + margin: 0;
21 + box-sizing: border-box;
22 + text-align: center;
23 +}
24 +
25 +.notification-toggle:hover {
26 + background: rgba(255, 255, 255, 0.1);
27 + border-color: rgba(255, 255, 255, 0.2);
28 + transform: scale(1.05);
29 +}
30 +
31 +.notification-toggle .notification-icon {
32 + display: flex;
33 + align-items: center;
34 + justify-content: center;
35 + font-size: 1rem;
36 + width: 100%;
37 + height: 100%;
38 + text-align: center;
39 + line-height: 1;
40 + vertical-align: middle;
41 + margin: 0;
42 + padding: 0;
43 +}
44 +
45 +.notification-toggle.has-unread {
46 + border-color: #2196F3;
47 + background: rgba(33, 150, 243, 0.1);
48 + animation: pulse 2s infinite;
49 +}
50 +
51 +.notification-badge {
52 + position: absolute;
53 + top: -8px;
54 + right: -8px;
55 + background: #F44336;
56 + color: white;
57 + font-size: 0.7rem;
58 + font-weight: bold;
59 + padding: 0.2rem 0.4rem;
60 + border-radius: 10px;
61 + min-width: 1.2rem;
62 + text-align: center;
63 + line-height: 1;
64 + border: 2px solid var(--bg-color);
65 + z-index: 1;
66 + display: flex;
67 + align-items: center;
68 + justify-content: center;
69 +}
70 +
71 +.notification-toggle.disabled {
72 + opacity: 0.4;
73 + cursor: not-allowed;
74 +}
75 +
76 +.notification-toggle.has-notifications {
77 + opacity: 1;
78 +}
79 +
80 +.notification-toggle.disabled:hover {
81 + background: rgba(255, 255, 255, 0.05);
82 + border-color: rgba(255, 255, 255, 0.1);
83 + transform: none;
84 +}
85 +
86 +/* Test Button (for development) - positioned next to bell icon */
87 +.notification-test-button {
88 + display: flex;
89 + align-items: center;
90 + justify-content: center;
91 + width: 36px;
92 + height: 36px;
93 + background: rgba(33, 150, 243, 0.1);
94 + border: 1px solid rgba(33, 150, 243, 0.3);
95 + border-radius: 6px;
96 + color: var(--color-text);
97 + cursor: pointer;
98 + transition: all 0.2s ease;
99 + font-size: 0.85rem;
100 + margin-left: 0.5rem;
101 +}
102 +
103 +.notification-test-button:hover {
104 + background: rgba(33, 150, 243, 0.2);
105 + border-color: rgba(33, 150, 243, 0.5);
106 +}
107 +
108 +.notification-test-button .notification-icon {
109 + font-size: 1.2rem;
110 +}
111 +
112 +/* Light Mode Styles */
113 +.light-mode .notification-toggle {
114 + background: rgba(0, 0, 0, 0.05);
115 + border-color: rgba(0, 0, 0, 0.1);
116 +}
117 +
118 +.light-mode .notification-toggle:hover {
119 + background: rgba(0, 0, 0, 0.1);
120 + border-color: rgba(0, 0, 0, 0.2);
121 +}
122 +
123 +.light-mode .notification-toggle.has-unread {
124 + border-color: #2196F3;
125 + background: rgba(33, 150, 243, 0.1);
126 +}
127 +
128 +.light-mode .notification-toggle.disabled:hover {
129 + background: rgba(0, 0, 0, 0.05);
130 + border-color: rgba(0, 0, 0, 0.1);
131 +}
132 +
133 +.light-mode .notification-test-button {
134 + background: rgba(33, 150, 243, 0.1);
135 + border-color: rgba(33, 150, 243, 0.3);
136 +}
137 +
138 +.light-mode .notification-test-button:hover {
139 + background: rgba(33, 150, 243, 0.2);
140 + border-color: rgba(33, 150, 243, 0.5);
141 +}
142 +
143 +/* Animations */
144 +@keyframes pulse {
145 + 0%, 100% { transform: scale(1); }
146 + 50% { transform: scale(1.05); }
147 +}
148 +
149 +/* Mobile Responsive */
150 +@media (max-width: 768px) {
151 + .notification-toggle {
152 + min-width: 40px;
153 + min-height: 40px;
154 + padding: 0.4rem;
155 + }
156 +}
webui/index.css
+3 -4
@@ -198,7 +198,7 @@ img {
198 padding: 0.47rem 0.56rem;
199 position: absolute;
200 top: var(--spacing-md);
201 - z-index: 1004;
201 + z-index: 999;
202 -webkit-transition: all var(--transition-speed) ease-in-out;
203 transition: all var(--transition-speed) ease-in-out;
204 }
@@ -403,11 +403,10 @@ img {
403
404 #time-date-container {
405 position: fixed;
406 - right: 0;
406 + right: var(--spacing-md);
407 display: flex;
408 align-items: center;
409 gap: var(--spacing-sm);
410 - margin-right: var(--spacing-md);
410 margin-top: var(--spacing-md);
411 }
412
@@ -466,7 +465,7 @@ pre {
465 position: fixed;
466 margin-left: 4.6rem;
467 margin-top: var(--spacing-md);
469 - z-index: 1004;
468 + z-index: 999;
469 -webkit-transition: margin-left var(--transition-speed) ease-in-out;
470 transition: margin-left var(--transition-speed) ease-in-out;
471 }
webui/index.html
+9 -1
@@ -17,6 +17,7 @@
17 <link rel="stylesheet" href="css/history.css">
18 <link rel="stylesheet" href="css/scheduler-datepicker.css">
19 <link rel="stylesheet" href="css/tunnel.css">
20 + <link rel="stylesheet" href="css/notification.css">
21
22 <!-- Font Awesome for icons -->
23 <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/5.15.4/css/all.min.css">
@@ -102,6 +103,7 @@
103 <script type="module" src="js/scheduler.js"></script>
104 <script type="module" src="js/speech.js"></script>
105 <script type="module" src="js/history.js"></script>
106 + <script type="module" src="js/notificationStore.js"></script>
107 <script type="module" src="index.js"></script>
108
109 <!-- Then load Alpine.js -->
@@ -121,7 +123,7 @@
123
124 <!-- Google Icons -->
125 <link rel="stylesheet" href="vendor/google/google-icons.css" />
124 -
126 +
127
128 <!-- Non-module scripts after Alpine.js -->
129 <script type="text/javascript" src="js/settings.js"></script>
@@ -349,9 +351,15 @@
351 stroke-width="3" x-bind:opacity="connected ? 0 : 1" />
352 </svg>
353 </div>
354 + <!-- Notification Toggle positioned next to time-date -->
355 + <x-component path="notifications/notification-icons.html"></x-component>
356 </div>
357 <div id="chat-history">
358 </div>
359 +
360 + <!-- NEW: Toast Stack Component -->
361 + <x-component path="notifications/notification-toast-stack.html"></x-component>
362 +
363 <div id="toast" class="toast">
364 <div class="toast__content">
365 <div class="toast__title"></div>
webui/index.js
+6
@@ -331,6 +331,7 @@ async function poll() {
331
332 const response = await sendJsonData("/poll", {
333 log_from: lastLogVersion,
334 + notifications_from: globalThis.Alpine?.store('notificationStore')?.lastNotificationVersion || 0,
335 context: context || null,
336 timezone: timezone,
337 });
@@ -370,6 +371,11 @@ async function poll() {
371
372 updateProgress(response.log_progress, response.log_progress_active);
373
374 + // Update notifications from response
375 + if (globalThis.Alpine?.store('notificationStore')) {
376 + globalThis.Alpine.store('notificationStore').updateFromPoll(response);
377 + }
378 +
379 //set ui model vars from backend
380 if (window.Alpine && inputSection) {
381 const inputAD = Alpine.$data(inputSection);
webui/js/initFw.js
+11
@@ -1,5 +1,6 @@
1 import * as _modals from "./modals.js";
2 import * as _components from "./components.js";
3 +import "./notificationStore.js";
4
5 await import("../vendor/alpine/alpine.min.js");
6
@@ -11,3 +12,13 @@ Alpine.directive(
12 cleanup(() => onDestroy());
13 }
14 );
15 +
16 +// Initialize notification store when Alpine is ready
17 +document.addEventListener('alpine:init', () => {
18 + // Initialize the notification store
19 + setTimeout(() => {
20 + if (Alpine.store('notificationStore')) {
21 + Alpine.store('notificationStore').initialize();
22 + }
23 + }, 100);
24 +});
webui/js/notificationStore.js new
+374
@@ -0,0 +1,374 @@
1 +import { createStore } from "/js/AlpineStore.js";
2 +import * as API from "/js/api.js";
3 +
4 +const model = {
5 + notifications: [],
6 + loading: false,
7 + lastNotificationVersion: 0,
8 + lastNotificationGuid: "",
9 + unreadCount: 0,
10 +
11 + // NEW: Toast stack management
12 + toastStack: [],
13 + maxToastStack: 5,
14 +
15 + // Initialize the notification store
16 + initialize() {
17 + console.log("NotificationStore: Initializing with toast stack");
18 + this.loading = true;
19 + this.updateUnreadCount();
20 + this.removeOldNotifications();
21 + this.toastStack = [];
22 +
23 + // Auto-cleanup old notifications and toasts
24 + setInterval(() => {
25 + this.removeOldNotifications();
26 + this.cleanupExpiredToasts();
27 + }, 5 * 60 * 1000); // Every 5 minutes
28 + },
29 +
30 + // Update notifications from polling data
31 + updateFromPoll(pollData) {
32 + if (!pollData) return;
33 +
34 + // Check if GUID changed (system restart)
35 + if (pollData.notifications_guid !== this.lastNotificationGuid) {
36 + this.lastNotificationVersion = 0;
37 + this.notifications = [];
38 + this.toastStack = []; // Clear toast stack on restart
39 + this.lastNotificationGuid = pollData.notifications_guid || '';
40 + }
41 +
42 + // Process new notifications and add to toast stack
43 + if (pollData.notifications && pollData.notifications.length > 0) {
44 + pollData.notifications.forEach(notification => {
45 + const isNew = !this.notifications.find(n => n.id === notification.id);
46 + this.addOrUpdateNotification(notification);
47 +
48 + // Add new notifications to toast stack
49 + if (isNew && !notification.read) {
50 + this.addToToastStack(notification);
51 + }
52 + });
53 + }
54 +
55 + // Update version tracking
56 + this.lastNotificationVersion = pollData.notifications_version || 0;
57 + this.lastNotificationGuid = pollData.notifications_guid || '';
58 +
59 + // Update UI state
60 + this.updateUnreadCount();
61 + this.removeOldNotifications();
62 +
63 + // Limit notifications to prevent memory issues (keep most recent)
64 + if (this.notifications.length > 50) {
65 + // Sort by timestamp and keep newest 50
66 + this.notifications.sort((a, b) => new Date(b.timestamp) - new Date(a.timestamp));
67 + this.notifications = this.notifications.slice(0, 50);
68 + }
69 + },
70 +
71 + // NEW: Add notification to toast stack
72 + addToToastStack(notification) {
73 + // Create toast object with auto-dismiss timer
74 + const toast = {
75 + ...notification,
76 + toastId: `toast-${notification.id}`,
77 + addedAt: Date.now(),
78 + autoRemoveTimer: null
79 + };
80 +
81 + // Add to bottom of stack (newest at bottom)
82 + this.toastStack.push(toast);
83 +
84 + // Enforce max stack limit (remove oldest from top)
85 + if (this.toastStack.length > this.maxToastStack) {
86 + const removed = this.toastStack.shift(); // Remove from top
87 + if (removed.autoRemoveTimer) {
88 + clearTimeout(removed.autoRemoveTimer);
89 + }
90 + }
91 +
92 + // Set auto-dismiss timer
93 + toast.autoRemoveTimer = setTimeout(() => {
94 + this.removeFromToastStack(toast.toastId);
95 + }, notification.display_time * 1000);
96 +
97 + console.log(`Toast added: ${notification.type} - ${notification.message}`);
98 + },
99 +
100 + // NEW: Remove toast from stack
101 + removeFromToastStack(toastId) {
102 + const index = this.toastStack.findIndex(t => t.toastId === toastId);
103 + if (index >= 0) {
104 + const toast = this.toastStack[index];
105 + if (toast.autoRemoveTimer) {
106 + clearTimeout(toast.autoRemoveTimer);
107 + }
108 + this.toastStack.splice(index, 1);
109 + console.log(`Toast removed: ${toastId}`);
110 + }
111 + },
112 +
113 + // NEW: Clear entire toast stack
114 + clearToastStack() {
115 + this.toastStack.forEach(toast => {
116 + if (toast.autoRemoveTimer) {
117 + clearTimeout(toast.autoRemoveTimer);
118 + }
119 + });
120 + this.toastStack = [];
121 + console.log('Toast stack cleared');
122 + },
123 +
124 + // NEW: Clean up expired toasts (backup cleanup)
125 + cleanupExpiredToasts() {
126 + const now = Date.now();
127 + this.toastStack = this.toastStack.filter(toast => {
128 + const age = now - toast.addedAt;
129 + const maxAge = toast.display_time * 1000;
130 +
131 + if (age > maxAge) {
132 + if (toast.autoRemoveTimer) {
133 + clearTimeout(toast.autoRemoveTimer);
134 + }
135 + return false;
136 + }
137 + return true;
138 + });
139 + },
140 +
141 + // NEW: Handle toast click (opens modal)
142 + async handleToastClick(toastId) {
143 + console.log(`Toast clicked: ${toastId}`);
144 + await this.openModal();
145 + // Modal opening will clear toast stack via markAllAsRead
146 + },
147 +
148 + // Add or update a notification
149 + addOrUpdateNotification(notification) {
150 + const existingIndex = this.notifications.findIndex(n => n.id === notification.id);
151 +
152 + if (existingIndex >= 0) {
153 + // Update existing notification
154 + this.notifications[existingIndex] = notification;
155 + } else {
156 + // Add new notification at the beginning (most recent first)
157 + this.notifications.unshift(notification);
158 + }
159 + },
160 +
161 + // Update unread count
162 + updateUnreadCount() {
163 + this.unreadCount = this.notifications.filter(n => !n.read).length;
164 + },
165 +
166 + // Mark notification as read
167 + async markAsRead(notificationId) {
168 + const notification = this.notifications.find(n => n.id === notificationId);
169 + if (notification && !notification.read) {
170 + notification.read = true;
171 + this.updateUnreadCount();
172 +
173 + // Sync with backend (non-blocking)
174 + try {
175 + await API.callJsonApi('notifications_mark_read', {
176 + notification_ids: [notificationId]
177 + });
178 + } catch (error) {
179 + console.error('Failed to sync notification read status:', error);
180 + // Don't revert the UI change - user experience should not be affected
181 + }
182 + }
183 + },
184 +
185 + // Enhanced: Mark all as read and clear toast stack
186 + async markAllAsRead() {
187 + const unreadNotifications = this.notifications.filter(n => !n.read);
188 + if (unreadNotifications.length === 0) return;
189 +
190 + // Update UI immediately
191 + this.notifications.forEach(notification => {
192 + notification.read = true;
193 + });
194 + this.updateUnreadCount();
195 +
196 + // Clear toast stack when marking all as read
197 + this.clearToastStack();
198 +
199 + // Sync with backend (non-blocking)
200 + try {
201 + await API.callJsonApi('notifications_mark_read', {
202 + mark_all: true
203 + });
204 + } catch (error) {
205 + console.error('Failed to sync mark all as read:', error);
206 + }
207 + },
208 +
209 + // Clear all notifications
210 + async clearAll() {
211 + this.notifications = [];
212 + this.unreadCount = 0;
213 + this.clearToastStack(); // Also clear toast stack
214 +
215 + // Note: We don't sync clear with backend as notifications are stored in memory only
216 + console.log('All notifications cleared');
217 + },
218 +
219 + // Get notifications by type
220 + getNotificationsByType(type) {
221 + return this.notifications.filter(n => n.type === type);
222 + },
223 +
224 + // Get notifications for display: ALL unread + read from last 5 minutes
225 + getDisplayNotifications() {
226 + const fiveMinutesAgo = new Date(Date.now() - 5 * 60 * 1000);
227 +
228 + return this.notifications.filter(notification => {
229 + // Always show unread notifications
230 + if (!notification.read) {
231 + return true;
232 + }
233 +
234 + // Show read notifications only if they're from the last 5 minutes
235 + const notificationDate = new Date(notification.timestamp);
236 + return notificationDate > fiveMinutesAgo;
237 + });
238 + },
239 +
240 + // Get recent notifications (last 5) - kept for backwards compatibility
241 + getRecentNotifications() {
242 + return this.notifications.slice(0, 5);
243 + },
244 +
245 + // Get notification by ID
246 + getNotificationById(id) {
247 + return this.notifications.find(n => n.id === id);
248 + },
249 +
250 + // Remove old notifications (older than 1 hour)
251 + removeOldNotifications() {
252 + const oneHourAgo = new Date(Date.now() - 60 * 60 * 1000);
253 + const initialCount = this.notifications.length;
254 + this.notifications = this.notifications.filter(n =>
255 + new Date(n.timestamp) > oneHourAgo
256 + );
257 +
258 + if (this.notifications.length !== initialCount) {
259 + this.updateUnreadCount();
260 + }
261 + },
262 +
263 + // Format timestamp for display
264 + formatTimestamp(timestamp) {
265 + const date = new Date(timestamp);
266 + const now = new Date();
267 + const diffMs = now - date;
268 + const diffMins = Math.floor(diffMs / 60000);
269 + const diffHours = Math.floor(diffMs / 3600000);
270 + const diffDays = Math.floor(diffMs / 86400000);
271 +
272 + if (diffMins < 1) return 'Just now';
273 + if (diffMins < 60) return `${diffMins}m ago`;
274 + if (diffHours < 24) return `${diffHours}h ago`;
275 + if (diffDays < 7) return `${diffDays}d ago`;
276 +
277 + return date.toLocaleDateString();
278 + },
279 +
280 + // Get CSS class for notification type
281 + getNotificationClass(type) {
282 + const classes = {
283 + info: "notification-info",
284 + success: "notification-success",
285 + warning: "notification-warning",
286 + error: "notification-error",
287 + progress: "notification-progress"
288 + };
289 + return classes[type] || "notification-info";
290 + },
291 +
292 + // Get CSS class for notification item including read state
293 + getNotificationItemClass(notification) {
294 + const typeClass = this.getNotificationClass(notification.type);
295 + const readClass = notification.read ? "read" : "unread";
296 + return `notification-item ${typeClass} ${readClass}`;
297 + },
298 +
299 + // Get icon for notification type
300 + getNotificationIcon(type) {
301 + const icons = {
302 + info: "ℹ️",
303 + success: "✅",
304 + warning: "⚠️",
305 + error: "❌",
306 + progress: "⏳"
307 + };
308 + return icons[type] || "ℹ️";
309 + },
310 +
311 + // Create notification via backend (will appear via polling)
312 + async createNotification(type, message, title = "", detail = "", display_time = 3) {
313 + try {
314 + const response = await window.sendJsonData('/notification_create', {
315 + type: type,
316 + message: message,
317 + title: title,
318 + detail: detail,
319 + display_time: display_time
320 + });
321 +
322 + if (response.success) {
323 + console.log('Notification created:', response.notification_id);
324 + return response.notification_id;
325 + } else {
326 + console.error('Failed to create notification:', response.error);
327 + return null;
328 + }
329 + } catch (error) {
330 + console.error('Error creating notification:', error);
331 + return null;
332 + }
333 + },
334 +
335 + // Convenience methods for different notification types
336 + async info(message, title = "", detail = "", display_time = 3) {
337 + return await this.createNotification('info', message, title, detail, display_time);
338 + },
339 +
340 + async success(message, title = "", detail = "", display_time = 3) {
341 + return await this.createNotification('success', message, title, detail, display_time);
342 + },
343 +
344 + async warning(message, title = "", detail = "", display_time = 3) {
345 + return await this.createNotification('warning', message, title, detail, display_time);
346 + },
347 +
348 + async error(message, title = "", detail = "", display_time = 3) {
349 + return await this.createNotification('error', message, title, detail, display_time);
350 + },
351 +
352 + async progress(message, title = "", detail = "", display_time = 3) {
353 + return await this.createNotification('progress', message, title, detail, display_time);
354 + },
355 +
356 + // Enhanced: Open modal and clear toast stack
357 + async openModal() {
358 + // Import the standard modal system
359 + const { openModal } = await import("/js/modals.js");
360 + await openModal("notifications/notification-modal.html");
361 +
362 + // Clear toast stack when modal opens
363 + this.clearToastStack();
364 + },
365 +
366 + // Legacy method for backward compatibility
367 + toggleNotifications() {
368 + this.openModal();
369 + }
370 +};
371 +
372 +// Create and export the store
373 +const store = createStore("notificationStore", model);
374 +export { store };