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 ( + + setOpen((prev) => !prev)} + className="relative w-9 h-9 border border-accent-silver/20 flex items-center justify-center text-silver-bright bg-charcoal-dark hover:bg-secondary transition-colors" + aria-label={`Notifications — ${unreadCount} unread`} + aria-expanded={open} + aria-haspopup="true" + > + notifications + {unreadCount > 0 && ( + + {unreadCount > 99 ? '99+' : unreadCount} + + )} + + + + {open && ( + <> + setOpen(false)} /> + + {/* Header */} + + + notifications + Notifications + {unreadCount > 0 && ( + {unreadCount} + )} + + + {unreadCount > 0 && ( + + Mark all read + + )} + {notifications.length > 0 && ( + + Clear all + + )} + + + + {/* 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)} + + { e.stopPropagation(); removeNotification(n.id) }} + className="opacity-0 group-hover:opacity-100 shrink-0 text-silver/40 hover:text-rag-red transition-colors" + aria-label="Dismiss notification" + > + close + + + ))} + + )} + + + > + )} + + + ) +} 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() { + { e.stopPropagation();
{n.message}