Implement Agent 6: Real-Time Updates feature for LocalGreenChain: - Add Socket.io server with room-based subscriptions - Create client-side hooks (useSocket, useLiveFeed, usePlantUpdates) - Add SocketProvider context for application-wide state - Implement UI components: - ConnectionStatus: Shows WebSocket connection state - LiveFeed: Real-time event feed display - NotificationToast: Toast notifications with auto-dismiss - LiveChart: Real-time data visualization - Add event type definitions and formatting utilities - Create socket API endpoint for WebSocket initialization - Add socket stats endpoint for monitoring - Extend tailwind with fadeIn/slideIn animations Integrates with existing EventStream SSE system for fallback.
325 lines
8.6 KiB
TypeScript
325 lines
8.6 KiB
TypeScript
/**
|
||
* Notification Toast Component
|
||
*
|
||
* Displays real-time notifications as toast messages.
|
||
*/
|
||
|
||
import React, { useEffect, useState, useCallback } from 'react';
|
||
import classNames from 'classnames';
|
||
import { useSocketContext } from '../../lib/realtime/SocketContext';
|
||
import type { RealtimeNotification } from '../../lib/realtime/types';
|
||
|
||
interface NotificationToastProps {
|
||
position?: 'top-right' | 'top-left' | 'bottom-right' | 'bottom-left';
|
||
maxVisible?: number;
|
||
autoHideDuration?: number;
|
||
className?: string;
|
||
}
|
||
|
||
/**
|
||
* Get position classes
|
||
*/
|
||
function getPositionClasses(position: NotificationToastProps['position']): string {
|
||
switch (position) {
|
||
case 'top-left':
|
||
return 'top-4 left-4';
|
||
case 'bottom-right':
|
||
return 'bottom-4 right-4';
|
||
case 'bottom-left':
|
||
return 'bottom-4 left-4';
|
||
case 'top-right':
|
||
default:
|
||
return 'top-4 right-4';
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Get notification type styles
|
||
*/
|
||
function getTypeStyles(type: RealtimeNotification['type']): {
|
||
bg: string;
|
||
border: string;
|
||
icon: string;
|
||
iconColor: string;
|
||
} {
|
||
switch (type) {
|
||
case 'success':
|
||
return {
|
||
bg: 'bg-green-50',
|
||
border: 'border-green-200',
|
||
icon: '✓',
|
||
iconColor: 'text-green-600',
|
||
};
|
||
case 'warning':
|
||
return {
|
||
bg: 'bg-yellow-50',
|
||
border: 'border-yellow-200',
|
||
icon: '⚠',
|
||
iconColor: 'text-yellow-600',
|
||
};
|
||
case 'error':
|
||
return {
|
||
bg: 'bg-red-50',
|
||
border: 'border-red-200',
|
||
icon: '✕',
|
||
iconColor: 'text-red-600',
|
||
};
|
||
case 'info':
|
||
default:
|
||
return {
|
||
bg: 'bg-blue-50',
|
||
border: 'border-blue-200',
|
||
icon: 'ℹ',
|
||
iconColor: 'text-blue-600',
|
||
};
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Single toast notification
|
||
*/
|
||
function Toast({
|
||
notification,
|
||
onDismiss,
|
||
autoHideDuration,
|
||
}: {
|
||
notification: RealtimeNotification;
|
||
onDismiss: (id: string) => void;
|
||
autoHideDuration: number;
|
||
}) {
|
||
const [isVisible, setIsVisible] = useState(false);
|
||
const [isLeaving, setIsLeaving] = useState(false);
|
||
const styles = getTypeStyles(notification.type);
|
||
|
||
// Animate in
|
||
useEffect(() => {
|
||
const timer = setTimeout(() => setIsVisible(true), 10);
|
||
return () => clearTimeout(timer);
|
||
}, []);
|
||
|
||
// Auto hide
|
||
useEffect(() => {
|
||
if (autoHideDuration <= 0) return;
|
||
|
||
const timer = setTimeout(() => {
|
||
handleDismiss();
|
||
}, autoHideDuration);
|
||
|
||
return () => clearTimeout(timer);
|
||
}, [autoHideDuration]);
|
||
|
||
const handleDismiss = useCallback(() => {
|
||
setIsLeaving(true);
|
||
setTimeout(() => {
|
||
onDismiss(notification.id);
|
||
}, 300);
|
||
}, [notification.id, onDismiss]);
|
||
|
||
return (
|
||
<div
|
||
className={classNames(
|
||
'max-w-sm w-full p-4 rounded-lg border shadow-lg transition-all duration-300',
|
||
styles.bg,
|
||
styles.border,
|
||
{
|
||
'opacity-0 translate-x-4': !isVisible || isLeaving,
|
||
'opacity-100 translate-x-0': isVisible && !isLeaving,
|
||
}
|
||
)}
|
||
role="alert"
|
||
>
|
||
<div className="flex items-start gap-3">
|
||
{/* Icon */}
|
||
<span className={classNames('text-xl font-bold flex-shrink-0', styles.iconColor)}>
|
||
{styles.icon}
|
||
</span>
|
||
|
||
{/* Content */}
|
||
<div className="flex-1 min-w-0">
|
||
<h4 className="font-medium text-gray-900 text-sm">
|
||
{notification.title}
|
||
</h4>
|
||
<p className="text-sm text-gray-600 mt-1">
|
||
{notification.message}
|
||
</p>
|
||
</div>
|
||
|
||
{/* Close button */}
|
||
<button
|
||
onClick={handleDismiss}
|
||
className="flex-shrink-0 text-gray-400 hover:text-gray-600 transition-colors"
|
||
aria-label="Dismiss notification"
|
||
>
|
||
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
|
||
<path
|
||
fillRule="evenodd"
|
||
d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z"
|
||
clipRule="evenodd"
|
||
/>
|
||
</svg>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Notification Toast container
|
||
*/
|
||
export function NotificationToast({
|
||
position = 'top-right',
|
||
maxVisible = 5,
|
||
autoHideDuration = 5000,
|
||
className,
|
||
}: NotificationToastProps) {
|
||
const { notifications, dismissNotification } = useSocketContext();
|
||
|
||
// Only show non-read, non-dismissed notifications
|
||
const visibleNotifications = notifications
|
||
.filter((n) => !n.read && !n.dismissed)
|
||
.slice(0, maxVisible);
|
||
|
||
if (visibleNotifications.length === 0) {
|
||
return null;
|
||
}
|
||
|
||
return (
|
||
<div
|
||
className={classNames(
|
||
'fixed z-50 flex flex-col gap-2',
|
||
getPositionClasses(position),
|
||
className
|
||
)}
|
||
>
|
||
{visibleNotifications.map((notification) => (
|
||
<Toast
|
||
key={notification.id}
|
||
notification={notification}
|
||
onDismiss={dismissNotification}
|
||
autoHideDuration={autoHideDuration}
|
||
/>
|
||
))}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Notification bell with badge
|
||
*/
|
||
export function NotificationBell({
|
||
onClick,
|
||
className,
|
||
}: {
|
||
onClick?: () => void;
|
||
className?: string;
|
||
}) {
|
||
const { unreadCount } = useSocketContext();
|
||
|
||
return (
|
||
<button
|
||
onClick={onClick}
|
||
className={classNames(
|
||
'relative p-2 text-gray-600 hover:text-gray-900 transition-colors',
|
||
className
|
||
)}
|
||
aria-label={`Notifications${unreadCount > 0 ? ` (${unreadCount} unread)` : ''}`}
|
||
>
|
||
{/* Bell icon */}
|
||
<svg
|
||
className="w-6 h-6"
|
||
fill="none"
|
||
stroke="currentColor"
|
||
viewBox="0 0 24 24"
|
||
>
|
||
<path
|
||
strokeLinecap="round"
|
||
strokeLinejoin="round"
|
||
strokeWidth={2}
|
||
d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9"
|
||
/>
|
||
</svg>
|
||
|
||
{/* Badge */}
|
||
{unreadCount > 0 && (
|
||
<span className="absolute top-0 right-0 inline-flex items-center justify-center px-1.5 py-0.5 text-xs font-bold leading-none text-white transform translate-x-1/2 -translate-y-1/2 bg-red-500 rounded-full">
|
||
{unreadCount > 99 ? '99+' : unreadCount}
|
||
</span>
|
||
)}
|
||
</button>
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Notification list dropdown
|
||
*/
|
||
export function NotificationList({
|
||
className,
|
||
onClose,
|
||
}: {
|
||
className?: string;
|
||
onClose?: () => void;
|
||
}) {
|
||
const { notifications, markNotificationRead, markAllRead } = useSocketContext();
|
||
|
||
return (
|
||
<div
|
||
className={classNames(
|
||
'w-80 max-h-96 bg-white rounded-lg shadow-lg border border-gray-200 overflow-hidden',
|
||
className
|
||
)}
|
||
>
|
||
{/* Header */}
|
||
<div className="flex items-center justify-between px-4 py-3 border-b border-gray-200 bg-gray-50">
|
||
<h3 className="font-semibold text-gray-900">Notifications</h3>
|
||
{notifications.length > 0 && (
|
||
<button
|
||
onClick={markAllRead}
|
||
className="text-sm text-blue-600 hover:text-blue-800"
|
||
>
|
||
Mark all read
|
||
</button>
|
||
)}
|
||
</div>
|
||
|
||
{/* List */}
|
||
<div className="overflow-y-auto max-h-72">
|
||
{notifications.length === 0 ? (
|
||
<div className="py-8 text-center text-gray-500">
|
||
<div className="text-3xl mb-2">🔔</div>
|
||
<p className="text-sm">No notifications</p>
|
||
</div>
|
||
) : (
|
||
notifications.map((notification) => (
|
||
<div
|
||
key={notification.id}
|
||
className={classNames(
|
||
'px-4 py-3 border-b border-gray-100 hover:bg-gray-50 cursor-pointer transition-colors',
|
||
{ 'bg-blue-50': !notification.read }
|
||
)}
|
||
onClick={() => markNotificationRead(notification.id)}
|
||
>
|
||
<div className="flex items-start gap-2">
|
||
<span className="text-lg">
|
||
{getTypeStyles(notification.type).icon}
|
||
</span>
|
||
<div className="flex-1 min-w-0">
|
||
<p className={classNames('text-sm', { 'font-medium': !notification.read })}>
|
||
{notification.title}
|
||
</p>
|
||
<p className="text-xs text-gray-500 truncate">
|
||
{notification.message}
|
||
</p>
|
||
<p className="text-xs text-gray-400 mt-1">
|
||
{new Date(notification.timestamp).toLocaleTimeString()}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
))
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export default NotificationToast;
|