'use client' /** * @file Notification center: bell icon with dropdown of recent notifications. */ import { useEffect, useState, useRef, useCallback } from 'react' import { createPortal } from 'react-dom' import { motion, AnimatePresence } from 'framer-motion' import Link from 'next/link' import { listNotifications, markNotificationRead, markAllNotificationsRead, type Notification } from '@/lib/api/notifications' import { getAuthErrorMessage } from '@ciphera-net/ui' import { formatTimeAgo, getTypeIcon } from '@/lib/utils/notifications' import { SettingsIcon } from '@ciphera-net/ui' import { useUnifiedSettings } from '@/lib/unified-settings-context' import { SkeletonLine, SkeletonCircle } from '@/components/skeletons' // * Bell icon (simple SVG, no extra deps) function BellIcon({ className }: { className?: string }) { return ( ) } const LOADING_DELAY_MS = 250 const POLL_INTERVAL_MS = 90_000 interface NotificationCenterProps { /** Where the dropdown opens. 'right' uses fixed positioning to escape overflow:hidden containers. */ anchor?: 'bottom' | 'right' /** Render variant. 'sidebar' matches NavLink styling. */ variant?: 'default' | 'sidebar' /** Optional label content rendered after the icon (useful for sidebar variant with fading labels). */ children?: React.ReactNode } export default function NotificationCenter({ anchor = 'bottom', variant = 'default', children }: NotificationCenterProps) { const [open, setOpen] = useState(false) const [notifications, setNotifications] = useState([]) const [unreadCount, setUnreadCount] = useState(0) const [loading, setLoading] = useState(false) const [error, setError] = useState(null) const dropdownRef = useRef(null) const panelRef = useRef(null) const buttonRef = useRef(null) const [fixedPos, setFixedPos] = useState<{ left: number; top?: number; bottom?: number } | null>(null) const { openUnifiedSettings } = useUnifiedSettings() const updatePosition = useCallback(() => { if (anchor === 'right' && buttonRef.current) { const rect = buttonRef.current.getBoundingClientRect() const left = rect.right + 8 if (rect.top > window.innerHeight / 2) { setFixedPos({ left, bottom: window.innerHeight - rect.bottom }) } else { setFixedPos({ left, top: rect.top }) } } }, [anchor]) const fetchUnreadCount = async () => { try { const res = await listNotifications({ limit: 1 }) setUnreadCount(typeof res?.unread_count === 'number' ? res.unread_count : 0) } catch { // Ignore polling errors } } const fetchNotifications = async () => { setError(null) const loadingTimer = setTimeout(() => setLoading(true), LOADING_DELAY_MS) try { const res = await listNotifications({}) setNotifications(Array.isArray(res?.notifications) ? res.notifications : []) setUnreadCount(typeof res?.unread_count === 'number' ? res.unread_count : 0) } catch (err) { setError(getAuthErrorMessage(err as Error) || 'Failed to load notifications') setNotifications([]) setUnreadCount(0) } finally { clearTimeout(loadingTimer) setLoading(false) } } useEffect(() => { if (open) { fetchNotifications() updatePosition() } }, [open, updatePosition]) // * Poll unread count in background (when authenticated) useEffect(() => { fetchUnreadCount() const id = setInterval(fetchUnreadCount, POLL_INTERVAL_MS) return () => clearInterval(id) }, []) // * Close dropdown when clicking outside or pressing Escape useEffect(() => { if (!open) return function handleClickOutside(e: MouseEvent) { const target = e.target as Node if ( dropdownRef.current && !dropdownRef.current.contains(target) && (!panelRef.current || !panelRef.current.contains(target)) ) { setOpen(false) } } function handleKeyDown(e: KeyboardEvent) { if (e.key === 'Escape') setOpen(false) } document.addEventListener('mousedown', handleClickOutside) document.addEventListener('keydown', handleKeyDown) return () => { document.removeEventListener('mousedown', handleClickOutside) document.removeEventListener('keydown', handleKeyDown) } }, [open]) const handleMarkRead = async (id: string) => { try { await markNotificationRead(id) setNotifications((prev) => prev.map((n) => (n.id === id ? { ...n, read: true } : n))) setUnreadCount((c) => Math.max(0, c - 1)) } catch { // Ignore; user can retry } } const handleMarkAllRead = async () => { try { await markAllNotificationsRead() setNotifications((prev) => prev.map((n) => ({ ...n, read: true }))) setUnreadCount(0) } catch { // Ignore } } const handleNotificationClick = (n: Notification) => { if (!n.read) { handleMarkRead(n.id) } setOpen(false) } const isSidebar = variant === 'sidebar' return ( setOpen(!open)} aria-expanded={open} aria-haspopup="true" aria-controls={open ? 'notification-dropdown' : undefined} className={isSidebar ? 'relative flex items-center gap-2.5 rounded-lg px-2.5 py-2 text-sm font-medium text-neutral-600 dark:text-neutral-400 hover:text-neutral-900 dark:hover:text-white hover:bg-neutral-100 dark:hover:bg-neutral-800 w-full overflow-hidden transition-colors' : 'relative p-2 text-neutral-600 dark:text-neutral-400 hover:text-neutral-900 dark:hover:text-white rounded-lg hover:bg-neutral-100/50 dark:hover:bg-white/[0.06] transition-colors' } aria-label={unreadCount > 0 ? `Notifications, ${unreadCount} unread` : 'Notifications'} > {isSidebar ? ( <> {unreadCount > 0 && ( )} {children} > ) : ( <> {unreadCount > 0 && ( )} > )} {(() => { const panel = ( {open && ( Notifications {unreadCount > 0 && ( Mark all read )} {loading && ( {Array.from({ length: 4 }).map((_, i) => ( ))} )} {error && ( {error} )} {!loading && !error && (notifications?.length ?? 0) === 0 && ( No notifications yet )} {!loading && !error && (notifications?.length ?? 0) > 0 && ( {(notifications ?? []).map((n) => ( {n.link_url ? ( handleNotificationClick(n)} className={`block px-4 py-3 hover:bg-neutral-50 dark:hover:bg-white/[0.06] transition-colors ${!n.read ? 'bg-brand-orange/5 dark:bg-brand-orange/10' : ''}`} > {getTypeIcon(n.type)} {n.title} {n.body && ( {n.body} )} {formatTimeAgo(n.created_at)} ) : ( handleNotificationClick(n)} className={`w-full text-left block px-4 py-3 hover:bg-neutral-50 dark:hover:bg-white/[0.06] cursor-pointer ${!n.read ? 'bg-brand-orange/5 dark:bg-brand-orange/10' : ''}`} > {getTypeIcon(n.type)} {n.title} {n.body && ( {n.body} )} {formatTimeAgo(n.created_at)} )} ))} )} setOpen(false)} className="text-sm text-brand-orange hover:underline" > View all { setOpen(false) openUnifiedSettings({ context: 'workspace', tab: 'notifications' }) }} className="flex items-center gap-2 text-sm text-neutral-400 hover:text-brand-orange dark:hover:text-brand-orange transition-colors cursor-pointer" > Manage settings )} ) return anchor === 'right' && typeof document !== 'undefined' ? createPortal(panel, document.body) : panel })()} ) }
{n.title}
{n.body}
{formatTimeAgo(n.created_at)}