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
65 lines
1.6 KiB
TypeScript
65 lines
1.6 KiB
TypeScript
import { Icon } from "./icon";
|
|
import { formatRelativeTime } from "../utils/time";
|
|
import type { NotificationItem as NotificationItemType } from "../api/notifications";
|
|
|
|
export interface NotificationItemProps {
|
|
notification: NotificationItemType;
|
|
onMarkRead: (id: string) => void;
|
|
onDismiss: (id: string) => void;
|
|
}
|
|
|
|
import type { IconName } from "../utils/icons";
|
|
|
|
const severityIconMap: Record<string, IconName> = {
|
|
info: "info",
|
|
warning: "warning",
|
|
error: "error",
|
|
success: "success",
|
|
};
|
|
|
|
export function NotificationItem({
|
|
notification,
|
|
onMarkRead,
|
|
onDismiss,
|
|
}: NotificationItemProps) {
|
|
const isUnread = notification.read_at === null;
|
|
const iconName = severityIconMap[notification.severity] ?? "info";
|
|
|
|
return (
|
|
<li
|
|
role="listitem"
|
|
className={`notification-item ${isUnread ? "notification-item--unread" : "notification-item--read"}`}
|
|
>
|
|
<div className="notification-item-icon">
|
|
<Icon name={iconName} size="md" />
|
|
</div>
|
|
<div className="notification-item-content">
|
|
<div className="notification-item-title">{notification.title}</div>
|
|
<div className="notification-item-time">
|
|
{formatRelativeTime(notification.created_at)}
|
|
</div>
|
|
</div>
|
|
<div className="notification-item-actions">
|
|
{isUnread && (
|
|
<button
|
|
type="button"
|
|
className="notification-item-action"
|
|
onClick={() => onMarkRead(notification.id)}
|
|
aria-label="Mark read"
|
|
>
|
|
Mark read
|
|
</button>
|
|
)}
|
|
<button
|
|
type="button"
|
|
className="notification-item-action"
|
|
onClick={() => onDismiss(notification.id)}
|
|
aria-label="Dismiss"
|
|
>
|
|
Dismiss
|
|
</button>
|
|
</div>
|
|
</li>
|
|
);
|
|
}
|