From c54d71ceb729f09459c903dc74717df72a3796c5 Mon Sep 17 00:00:00 2001 From: Sudha Rajput Date: Sat, 30 May 2026 19:32:56 +0000 Subject: [PATCH] feat: add notifications & alert system for long-running tasks (closes #337) - Add NotificationContext with useNotifications hook - Add NotificationBell component with unread badge counter - Add notification dropdown panel with read/unread states - Support mark as read, mark all as read, clear all actions - Integrate browser Web Notifications API with permission handling - Add bell icon to desktop sidebar and mobile header - Persist up to 50 notifications with timestamps - Match existing SecuScan brutalist UI design system --- frontend/src/App.tsx | 3 + frontend/src/components/AppShell.tsx | 3 +- .../src/components/NotificationContext.tsx | 212 ++++++++++++++++++ frontend/src/components/Sidebar.tsx | 2 + 4 files changed, 219 insertions(+), 1 deletion(-) create mode 100644 frontend/src/components/NotificationContext.tsx diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index f4f6b39f..9c4e53ab 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -13,6 +13,7 @@ import Workflows from './pages/Workflows' import { ThemeProvider } from './components/ThemeContext' import { ToastProvider, ToastContainer } from './components/ToastContext' +import { NotificationProvider } from './components/NotificationContext' import { I18nProvider } from './components/I18nContext' import { routes } from './routes' @@ -38,6 +39,7 @@ export default function App() { return ( + @@ -45,6 +47,7 @@ export default function App() { + ) diff --git a/frontend/src/components/AppShell.tsx b/frontend/src/components/AppShell.tsx index 1c73e91b..7889a91d 100644 --- a/frontend/src/components/AppShell.tsx +++ b/frontend/src/components/AppShell.tsx @@ -3,6 +3,7 @@ import { NavLink, useLocation } from 'react-router-dom' import Sidebar from './Sidebar' import Background from './Background' import { useShortcuts } from '../hooks/useShortcuts' +import { NotificationBell } from './NotificationContext' import { routes } from '../routes' interface AppShellProps { @@ -74,7 +75,7 @@ export default function AppShell({ children }: AppShellProps) { SecuScan - + {mobileMenuOpen && ( diff --git a/frontend/src/components/NotificationContext.tsx b/frontend/src/components/NotificationContext.tsx new file mode 100644 index 00000000..bda94ed5 --- /dev/null +++ b/frontend/src/components/NotificationContext.tsx @@ -0,0 +1,212 @@ +import React, { createContext, useContext, useState, useCallback, ReactNode } from 'react' +import { motion, AnimatePresence } from 'framer-motion' + +export type NotificationType = 'success' | 'error' | 'info' | 'warning' + +export interface Notification { + id: string + title: string + message: string + type: NotificationType + read: boolean + timestamp: Date +} + +interface NotificationContextType { + notifications: Notification[] + unreadCount: number + addNotification: (title: string, message: string, type?: NotificationType) => void + markAsRead: (id: string) => void + markAllAsRead: () => void + removeNotification: (id: string) => void + clearAll: () => void +} + +const NotificationContext = createContext(undefined) + +export const useNotifications = () => { + const context = useContext(NotificationContext) + if (!context) throw new Error('useNotifications must be used within NotificationProvider') + return context +} + +export const NotificationProvider = ({ children }: { children: ReactNode }) => { + const [notifications, setNotifications] = useState([]) + + const addNotification = useCallback((title: string, message: string, type: NotificationType = 'info') => { + const id = Math.random().toString(36).substring(2, 9) + const notification: Notification = { id, title, message, type, read: false, timestamp: new Date() } + setNotifications((prev) => [notification, ...prev].slice(0, 50)) + + // Browser notification + if (Notification.permission === 'granted') { + new Notification(title, { body: message, icon: '/favicon.ico' }) + } + }, []) + + const markAsRead = useCallback((id: string) => { + setNotifications((prev) => prev.map((n) => n.id === id ? { ...n, read: true } : n)) + }, []) + + const markAllAsRead = useCallback(() => { + setNotifications((prev) => prev.map((n) => ({ ...n, read: true }))) + }, []) + + const removeNotification = useCallback((id: string) => { + setNotifications((prev) => prev.filter((n) => n.id !== id)) + }, []) + + const clearAll = useCallback(() => setNotifications([]), []) + + const unreadCount = notifications.filter((n) => !n.read).length + + return ( + + {children} + + ) +} + +function timeAgo(date: Date): string { + const seconds = Math.floor((new Date().getTime() - date.getTime()) / 1000) + if (seconds < 60) return 'just now' + if (seconds < 3600) return `${Math.floor(seconds / 60)}m ago` + if (seconds < 86400) return `${Math.floor(seconds / 3600)}h ago` + return `${Math.floor(seconds / 86400)}d ago` +} + +export function NotificationBell() { + const { notifications, unreadCount, markAsRead, markAllAsRead, removeNotification, clearAll, addNotification } = useNotifications() + const [open, setOpen] = useState(false) + + const requestPermission = async () => { + if ('Notification' in window && Notification.permission === 'default') { + await Notification.requestPermission() + } + } + + React.useEffect(() => { requestPermission() }, []) + + const typeIcon = (type: NotificationType) => { + if (type === 'success') return 'check_circle' + if (type === 'error') return 'error' + if (type === 'warning') return 'warning' + return 'info' + } + + const typeBg = (type: NotificationType) => { + if (type === 'success') return 'bg-rag-green' + if (type === 'error') return 'bg-rag-red' + if (type === 'warning') return 'bg-rag-amber' + return 'bg-rag-blue' + } + + return ( +
+ + + + {open && ( + <> +
setOpen(false)} /> + + {/* Header */} +
+
+ notifications + Notifications + {unreadCount > 0 && ( + {unreadCount} + )} +
+
+ {unreadCount > 0 && ( + + )} + {notifications.length > 0 && ( + + )} +
+
+ + {/* List */} +
+ {notifications.length === 0 ? ( +
+ notifications_none + No notifications +
+ ) : ( + + {notifications.map((n) => ( + markAsRead(n.id)} + > + + {typeIcon(n.type)} + +
+
+ {n.title} + {!n.read && } +
+

{n.message}

+ {timeAgo(n.timestamp)} +
+ +
+ ))} +
+ )} +
+
+ + )} + +
+ ) +} diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index 99bf5e2b..3d254951 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -3,6 +3,7 @@ import { NavLink } from 'react-router-dom' import { motion, AnimatePresence } from 'framer-motion' import { routes } from '../routes' import ThemeToggle from './ThemeToggle' +import { NotificationBell } from './NotificationContext' interface NavItemProps { to: string; @@ -176,6 +177,7 @@ export default function Sidebar() {
+