2bec205a30
- Bell icon in icon registry (Phosphor Bell) - NotificationProvider context with polling (15s unread / 30s list) - useNotifications() hook with optimistic updates - NotificationCenter component: bell + badge + dropdown panel - NotificationItem component: severity icon, title, relative time, actions - AppShell integration: mount in header-actions, hidden on mobile - CSS styles: dropdown, items, unread/read states, empty state - formatRelativeTime utility (custom, no new deps) - 25 frontend tests (9 hook + 6 item + 10 center) Quality gates: vitest 25 passed, tsc clean, eslint clean
125 lines
2.7 KiB
TypeScript
125 lines
2.7 KiB
TypeScript
import { useEffect, useRef } from "react";
|
|
import { useNotifications } from "../hooks/use-notifications";
|
|
import { NotificationItem } from "./notification-item";
|
|
import { Icon } from "./icon";
|
|
|
|
interface NotificationCenterProps {
|
|
isMobileTerminal?: boolean;
|
|
}
|
|
|
|
export function NotificationCenter({
|
|
isMobileTerminal = false,
|
|
}: NotificationCenterProps) {
|
|
const {
|
|
notifications,
|
|
unreadCount,
|
|
markRead,
|
|
markAllRead,
|
|
dismiss,
|
|
refreshList,
|
|
isDropdownOpen,
|
|
setIsDropdownOpen,
|
|
} = useNotifications();
|
|
|
|
const dropdownRef = useRef<HTMLDivElement>(null);
|
|
|
|
useEffect(() => {
|
|
if (!isDropdownOpen) return;
|
|
|
|
const handleMouseDown = (e: MouseEvent) => {
|
|
if (
|
|
dropdownRef.current &&
|
|
!dropdownRef.current.contains(e.target as Node)
|
|
) {
|
|
setIsDropdownOpen(false);
|
|
}
|
|
};
|
|
|
|
const handleKeyDown = (e: KeyboardEvent) => {
|
|
if (e.key === "Escape") {
|
|
setIsDropdownOpen(false);
|
|
}
|
|
};
|
|
|
|
document.addEventListener("mousedown", handleMouseDown);
|
|
document.addEventListener("keydown", handleKeyDown);
|
|
|
|
return () => {
|
|
document.removeEventListener("mousedown", handleMouseDown);
|
|
document.removeEventListener("keydown", handleKeyDown);
|
|
};
|
|
}, [isDropdownOpen, setIsDropdownOpen]);
|
|
|
|
useEffect(() => {
|
|
if (isDropdownOpen) {
|
|
void refreshList();
|
|
}
|
|
}, [isDropdownOpen, refreshList]);
|
|
|
|
if (isMobileTerminal) {
|
|
return null;
|
|
}
|
|
|
|
const badgeText = unreadCount > 99 ? "99+" : String(unreadCount);
|
|
|
|
return (
|
|
<div className="notification-center">
|
|
<button
|
|
type="button"
|
|
className="notification-bell"
|
|
onClick={() => setIsDropdownOpen(!isDropdownOpen)}
|
|
aria-label="Notifications"
|
|
aria-haspopup="dialog"
|
|
aria-expanded={isDropdownOpen}
|
|
>
|
|
<Icon name="bell" size="md" />
|
|
{unreadCount > 0 && (
|
|
<span className="nav-badge notification-badge">{badgeText}</span>
|
|
)}
|
|
</button>
|
|
|
|
{isDropdownOpen && (
|
|
<div
|
|
ref={dropdownRef}
|
|
role="dialog"
|
|
aria-label="Notifications"
|
|
className="notification-dropdown"
|
|
>
|
|
<div className="notification-dropdown-header">
|
|
<span>Notifications</span>
|
|
</div>
|
|
|
|
<ul className="notification-list">
|
|
{notifications.length === 0 ? (
|
|
<li className="notification-empty">No notifications</li>
|
|
) : (
|
|
notifications.map((n) => (
|
|
<NotificationItem
|
|
key={n.id}
|
|
notification={n}
|
|
onMarkRead={markRead}
|
|
onDismiss={dismiss}
|
|
/>
|
|
))
|
|
)}
|
|
</ul>
|
|
|
|
{notifications.length > 0 && (
|
|
<div className="notification-dropdown-footer">
|
|
<button
|
|
type="button"
|
|
className="notification-mark-all"
|
|
onClick={() => {
|
|
void markAllRead();
|
|
}}
|
|
>
|
|
Mark all as read
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|