import { useMemo, useState } from 'react'; import { Bell, BellOff, Info, AlertTriangle, AlertOctagon, X, Trash2, } from 'lucide-react'; import type { LucideIcon } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; import { SegmentedControl } from '@/components/ui/segmented-control'; import { cn } from '@/lib/utils'; import type { NotificationItem } from './dashboard/types'; import type { Node } from '@/context/NodeContext'; type NotifFilter = 'all' | 'unread' | 'alerts'; type LevelConfig = { icon: LucideIcon; iconClass: string; railClass: string; }; const LEVEL_CONFIG: Record = { info: { icon: Info, iconClass: 'text-info', railClass: 'bg-info' }, warning: { icon: AlertTriangle, iconClass: 'text-warning', railClass: 'bg-warning' }, error: { icon: AlertOctagon, iconClass: 'text-destructive', railClass: 'bg-destructive' }, }; const DAY_MS = 86_400_000; const HOUR_MS = 3_600_000; const MINUTE_MS = 60_000; type GroupLabel = 'Today' | 'Yesterday' | 'This week' | 'Earlier'; const GROUP_ORDER: GroupLabel[] = ['Today', 'Yesterday', 'This week', 'Earlier']; function startOfDay(d: Date): number { const c = new Date(d); c.setHours(0, 0, 0, 0); return c.getTime(); } function groupByDay(items: NotificationItem[]): { label: GroupLabel; items: NotificationItem[] }[] { const today = startOfDay(new Date()); const yesterday = today - DAY_MS; const weekStart = today - 6 * DAY_MS; const buckets: Record = { Today: [], Yesterday: [], 'This week': [], Earlier: [], }; for (const item of items) { const ts = item.timestamp; if (ts >= today) buckets.Today.push(item); else if (ts >= yesterday) buckets.Yesterday.push(item); else if (ts >= weekStart) buckets['This week'].push(item); else buckets.Earlier.push(item); } return GROUP_ORDER.map((label) => ({ label, items: buckets[label] })).filter( (g) => g.items.length > 0, ); } function formatRelative(ms: number): string { const diff = Date.now() - ms; if (diff < MINUTE_MS) return 'just now'; if (diff < HOUR_MS) return `${Math.round(diff / MINUTE_MS)}m ago`; if (diff < DAY_MS) return `${Math.round(diff / HOUR_MS)}h ago`; if (diff < 2 * DAY_MS) return 'yesterday'; return new Date(ms).toLocaleDateString(undefined, { month: 'short', day: 'numeric' }); } function applyFilter(items: NotificationItem[], filter: NotifFilter): NotificationItem[] { if (filter === 'unread') return items.filter((n) => !n.is_read); if (filter === 'alerts') return items.filter((n) => n.level === 'warning' || n.level === 'error'); return items; } interface NotificationPanelProps { notifications: NotificationItem[]; nodes: Node[]; onMarkAllRead: () => void; onClearAll: () => void; onDelete: (notif: NotificationItem) => void; onNavigate?: (notif: NotificationItem) => void; } export function NotificationPanel({ notifications, nodes, onMarkAllRead, onClearAll, onDelete, onNavigate, }: NotificationPanelProps) { const [filter, setFilter] = useState('all'); const [open, setOpen] = useState(false); const unreadCount = useMemo( () => notifications.filter((n) => !n.is_read).length, [notifications], ); const remoteNodeIds = useMemo(() => { const ids = new Set(); for (const n of nodes) if (n.type === 'remote') ids.add(n.id); return ids; }, [nodes]); const filtered = useMemo(() => applyFilter(notifications, filter), [notifications, filter]); const groups = useMemo(() => groupByDay(filtered), [filtered]); const filterOptions = useMemo( () => [ { value: 'all' as const, label: 'All' }, { value: 'unread' as const, label: 'Unread', badge: unreadCount > 0 ? unreadCount : undefined, }, { value: 'alerts' as const, label: 'Alerts' }, ], [unreadCount], ); const bellBadge = unreadCount > 0 ? ( ) : null; const handleNavigate = (notif: NotificationItem) => { if (!onNavigate || !notif.stack_name) return; onNavigate(notif); setOpen(false); }; return ( {/* Masthead */}
Notifications {unreadCount > 0 ? ( {unreadCount} unread ) : null}
{unreadCount > 0 ? ( ) : null} {notifications.length > 0 ? ( ) : null}
{/* Filter segment */}
{/* Stream */} {groups.length === 0 ? ( 0} /> ) : (
{groups.map((group) => (
{group.label}
{group.items.map((notif) => ( ))}
))}
)} ); } interface NotificationRowProps { notif: NotificationItem; showNodeName: boolean; onDelete: (notif: NotificationItem) => void; onNavigate?: (notif: NotificationItem) => void; } function NotificationRow({ notif, showNodeName, onDelete, onNavigate }: NotificationRowProps) { const config = LEVEL_CONFIG[notif.level]; const Icon = config.icon; const isUnread = !notif.is_read; const isRoutable = Boolean(onNavigate && notif.stack_name); const surfaceClasses = cn( 'flex w-full items-start gap-3 px-5 py-3 text-left transition-colors', isRoutable && 'cursor-pointer hover:bg-accent/40 focus-visible:bg-accent/40 focus-visible:outline-none', ); const content = ( <>

{notif.message}

{showNodeName && notif.nodeName ? ( <> {notif.nodeName} ยท ) : null} {formatRelative(notif.timestamp)}
); const ariaLabel = isRoutable ? (notif.container_name ? `Open ${notif.stack_name} and view logs for ${notif.container_name}` : `Open ${notif.stack_name}`) : undefined; return (
{isRoutable ? ( ) : (
{content}
)}
); } interface EmptyStateProps { filter: NotifFilter; hasAny: boolean; } function EmptyState({ filter, hasAny }: EmptyStateProps) { let title = "You're all caught up"; let subtitle = 'New notifications appear here in real time.'; if (hasAny && filter === 'unread') { title = 'No unread notifications'; subtitle = 'Everything in your feed has been read.'; } else if (hasAny && filter === 'alerts') { title = 'No active alerts'; subtitle = 'Warnings and errors will surface here when they occur.'; } return (

{title}

{subtitle}

); }