mirror of
https://github.com/Dispatcharr/Dispatcharr.git
synced 2026-07-18 17:16:26 +00:00
435 lines
12 KiB
JavaScript
435 lines
12 KiB
JavaScript
import React, { useEffect, useState, useCallback } from 'react';
|
|
import { useNavigate } from 'react-router-dom';
|
|
import {
|
|
ActionIcon,
|
|
Badge,
|
|
Box,
|
|
Button,
|
|
Card,
|
|
Divider,
|
|
Group,
|
|
Indicator,
|
|
Popover,
|
|
PopoverDropdown,
|
|
PopoverTarget,
|
|
ScrollAreaAutosize,
|
|
Stack,
|
|
Text,
|
|
ThemeIcon,
|
|
Tooltip,
|
|
useMantineTheme,
|
|
} from '@mantine/core';
|
|
import {
|
|
Bell,
|
|
Check,
|
|
CheckCheck,
|
|
Download,
|
|
ExternalLink,
|
|
Info,
|
|
Settings,
|
|
AlertTriangle,
|
|
Megaphone,
|
|
X,
|
|
Eye,
|
|
EyeOff,
|
|
ArrowRight,
|
|
} from 'lucide-react';
|
|
import useNotificationsStore from '../store/notifications';
|
|
import {
|
|
dismissAllNotifications,
|
|
dismissNotification,
|
|
getNotifications,
|
|
} from '../utils/components/NotificationCenterUtils.js';
|
|
|
|
// Get icon for notification type
|
|
const getNotificationIcon = (type) => {
|
|
switch (type) {
|
|
case 'version_update':
|
|
return <Download size={16} />;
|
|
case 'setting_recommendation':
|
|
return <Settings size={16} />;
|
|
case 'announcement':
|
|
return <Megaphone size={16} />;
|
|
case 'warning':
|
|
return <AlertTriangle size={16} />;
|
|
case 'info':
|
|
default:
|
|
return <Info size={16} />;
|
|
}
|
|
};
|
|
|
|
// Get color for notification priority
|
|
const getPriorityColor = (priority) => {
|
|
switch (priority) {
|
|
case 'critical':
|
|
return 'red';
|
|
case 'high':
|
|
return 'orange';
|
|
case 'normal':
|
|
return 'blue';
|
|
case 'low':
|
|
default:
|
|
return 'gray';
|
|
}
|
|
};
|
|
|
|
// Get color for notification type
|
|
const getTypeColor = (type) => {
|
|
switch (type) {
|
|
case 'version_update':
|
|
return 'green';
|
|
case 'setting_recommendation':
|
|
return 'blue';
|
|
case 'announcement':
|
|
return 'violet';
|
|
case 'warning':
|
|
return 'orange';
|
|
case 'info':
|
|
default:
|
|
return 'gray';
|
|
}
|
|
};
|
|
|
|
// Individual notification item component
|
|
const NotificationItem = ({ notification, onDismiss, onAction, onClose }) => {
|
|
const theme = useMantineTheme();
|
|
const navigate = useNavigate();
|
|
const typeColor = getTypeColor(notification.notification_type);
|
|
const priorityColor = getPriorityColor(notification.priority);
|
|
const isDismissed = notification.is_dismissed;
|
|
|
|
const handleDismiss = (e) => {
|
|
e.stopPropagation();
|
|
onDismiss(notification.id, 'dismissed');
|
|
};
|
|
|
|
const handleAction = () => {
|
|
// Handle action_url from action_data
|
|
const actionUrl = notification.action_data?.action_url;
|
|
const releaseUrl = notification.action_data?.release_url;
|
|
|
|
if (actionUrl) {
|
|
// Internal navigation
|
|
onClose(); // Close the popover
|
|
navigate(actionUrl);
|
|
} else if (releaseUrl) {
|
|
// External link
|
|
window.open(releaseUrl, '_blank');
|
|
}
|
|
|
|
if (onAction) {
|
|
onAction(notification);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<Card
|
|
padding="sm"
|
|
radius="md"
|
|
withBorder
|
|
style={{
|
|
borderLeft: `3px solid ${theme.colors[priorityColor][5]}`,
|
|
backgroundColor:
|
|
notification.priority === 'critical'
|
|
? theme.colors.red[9] + '10'
|
|
: undefined,
|
|
opacity: isDismissed ? 0.6 : 1,
|
|
position: 'relative',
|
|
}}
|
|
>
|
|
{/* Dismiss button for non-setting notifications (only if not already dismissed) */}
|
|
{notification.notification_type !== 'setting_recommendation' &&
|
|
!isDismissed && (
|
|
<ActionIcon
|
|
variant="subtle"
|
|
color="gray"
|
|
size="sm"
|
|
onClick={handleDismiss}
|
|
style={{ position: 'absolute', top: 8, right: 8 }}
|
|
>
|
|
<X size={14} />
|
|
</ActionIcon>
|
|
)}
|
|
|
|
<Group wrap="nowrap" align="flex-start" gap="sm">
|
|
<ThemeIcon color={typeColor} variant="light" size="md" radius="xl">
|
|
{getNotificationIcon(notification.notification_type)}
|
|
</ThemeIcon>
|
|
<Box style={{ flex: 1 }}>
|
|
<Group gap="xs" mb={4}>
|
|
<Text size="sm" fw={600} lineClamp={1}>
|
|
{notification.title}
|
|
</Text>
|
|
{isDismissed && (
|
|
<Badge size="xs" color="gray" variant="light">
|
|
Dismissed
|
|
</Badge>
|
|
)}
|
|
{notification.priority === 'high' ||
|
|
notification.priority === 'critical' ? (
|
|
<Badge size="xs" color={priorityColor} variant="filled">
|
|
{notification.priority}
|
|
</Badge>
|
|
) : null}
|
|
</Group>
|
|
<Text size="xs" c="dimmed" lineClamp={5}>
|
|
{notification.message}
|
|
</Text>
|
|
|
|
{/* Action buttons for specific notification types */}
|
|
{notification.notification_type === 'version_update' &&
|
|
notification.action_data?.release_url && (
|
|
<Button
|
|
size="xs"
|
|
variant="light"
|
|
color="green"
|
|
mt="xs"
|
|
leftSection={<ExternalLink size={12} />}
|
|
onClick={handleAction}
|
|
>
|
|
View Release
|
|
</Button>
|
|
)}
|
|
|
|
{/* Generic action button for notifications with action_url/action_text */}
|
|
{notification.action_data?.action_url &&
|
|
notification.action_data?.action_text && (
|
|
<Button
|
|
size="xs"
|
|
variant="light"
|
|
color={typeColor}
|
|
mt="xs"
|
|
rightSection={<ArrowRight size={12} />}
|
|
onClick={handleAction}
|
|
>
|
|
{notification.action_data.action_text}
|
|
</Button>
|
|
)}
|
|
|
|
{notification.notification_type === 'setting_recommendation' &&
|
|
!notification.action_data?.action_url && (
|
|
<Group gap="xs" mt="xs">
|
|
<Button
|
|
size="xs"
|
|
variant="light"
|
|
color="blue"
|
|
onClick={() => {
|
|
onDismiss(notification.id, 'applied');
|
|
// Navigate to settings or apply the setting
|
|
if (onAction) onAction(notification);
|
|
}}
|
|
>
|
|
Apply
|
|
</Button>
|
|
<Button
|
|
size="xs"
|
|
variant="subtle"
|
|
color="gray"
|
|
onClick={handleDismiss}
|
|
>
|
|
Ignore
|
|
</Button>
|
|
</Group>
|
|
)}
|
|
</Box>
|
|
</Group>
|
|
|
|
<Text size="xs" c="dimmed" mt="xs" ta="right">
|
|
{new Date(notification.created_at).toLocaleDateString()}
|
|
</Text>
|
|
</Card>
|
|
);
|
|
};
|
|
|
|
// Main notification center component with bell icon and popover
|
|
const NotificationCenter = ({ onSettingAction }) => {
|
|
const [opened, setOpened] = useState(false);
|
|
const [showDismissed, setShowDismissed] = useState(false);
|
|
|
|
const notifications = useNotificationsStore((s) => s.notifications);
|
|
const unreadCount = useNotificationsStore((s) => s.unreadCount);
|
|
const getUnreadNotifications = useNotificationsStore(
|
|
(s) => s.getUnreadNotifications
|
|
);
|
|
|
|
// Fetch notifications on mount and periodically
|
|
const fetchNotifications = useCallback(async () => {
|
|
try {
|
|
await getNotifications(showDismissed);
|
|
} catch (error) {
|
|
console.error('Failed to fetch notifications:', error);
|
|
}
|
|
}, [showDismissed]);
|
|
|
|
useEffect(() => {
|
|
fetchNotifications();
|
|
|
|
// Refresh notifications every 5 minutes
|
|
const interval = setInterval(fetchNotifications, 5 * 60 * 1000);
|
|
return () => clearInterval(interval);
|
|
}, [fetchNotifications]);
|
|
|
|
const handleDismiss = async (notificationId, actionTaken = null) => {
|
|
try {
|
|
await dismissNotification(notificationId, actionTaken);
|
|
} catch (error) {
|
|
console.error('Failed to dismiss notification:', error);
|
|
}
|
|
};
|
|
|
|
const handleDismissAll = async () => {
|
|
try {
|
|
await dismissAllNotifications();
|
|
} catch (error) {
|
|
console.error('Failed to dismiss all notifications:', error);
|
|
}
|
|
};
|
|
|
|
const handleAction = (notification) => {
|
|
if (
|
|
notification.notification_type === 'setting_recommendation' &&
|
|
onSettingAction
|
|
) {
|
|
onSettingAction(notification);
|
|
}
|
|
};
|
|
|
|
const unreadNotifications = getUnreadNotifications();
|
|
const displayedNotifications = showDismissed
|
|
? notifications
|
|
: unreadNotifications;
|
|
|
|
return (
|
|
<Popover
|
|
opened={opened}
|
|
onChange={setOpened}
|
|
width={380}
|
|
position="bottom-end"
|
|
shadow="lg"
|
|
withArrow
|
|
>
|
|
<PopoverTarget>
|
|
<Indicator
|
|
color="red"
|
|
size={16}
|
|
label={unreadCount > 9 ? '9+' : unreadCount}
|
|
disabled={unreadCount === 0}
|
|
offset={4}
|
|
processing={unreadCount > 0}
|
|
>
|
|
<ActionIcon
|
|
variant="subtle"
|
|
color="gray"
|
|
size="lg"
|
|
onClick={() => setOpened((o) => !o)}
|
|
aria-label="Notifications"
|
|
>
|
|
<Bell size={20} />
|
|
</ActionIcon>
|
|
</Indicator>
|
|
</PopoverTarget>
|
|
|
|
<PopoverDropdown p={0}>
|
|
{/* Header */}
|
|
<Group justify="space-between" p="sm" pb="xs">
|
|
<Group gap="xs">
|
|
<Text fw={600} size="sm">
|
|
Notifications
|
|
</Text>
|
|
{unreadCount > 0 && (
|
|
<Badge size="sm" color="blue" variant="light">
|
|
{unreadCount} new
|
|
</Badge>
|
|
)}
|
|
</Group>
|
|
<Group gap="xs">
|
|
<Tooltip
|
|
label={showDismissed ? 'Hide dismissed' : 'Show dismissed'}
|
|
>
|
|
<ActionIcon
|
|
variant="subtle"
|
|
color="gray"
|
|
size="sm"
|
|
onClick={() => setShowDismissed((prev) => !prev)}
|
|
>
|
|
{showDismissed ? <EyeOff size={16} /> : <Eye size={16} />}
|
|
</ActionIcon>
|
|
</Tooltip>
|
|
{unreadCount > 0 && (
|
|
<Tooltip label="Mark all as read">
|
|
<ActionIcon
|
|
variant="subtle"
|
|
color="gray"
|
|
size="sm"
|
|
onClick={handleDismissAll}
|
|
>
|
|
<CheckCheck size={16} />
|
|
</ActionIcon>
|
|
</Tooltip>
|
|
)}
|
|
</Group>
|
|
</Group>
|
|
|
|
<Divider />
|
|
|
|
{/* Notification list */}
|
|
<ScrollAreaAutosize mah={400} type="auto" offsetScrollbars>
|
|
{displayedNotifications.length === 0 ? (
|
|
<Box p="lg" ta="center">
|
|
<ThemeIcon
|
|
color="gray"
|
|
variant="light"
|
|
size="xl"
|
|
radius="xl"
|
|
mb="sm"
|
|
>
|
|
<Check size={20} />
|
|
</ThemeIcon>
|
|
<Text size="sm" c="dimmed">
|
|
{showDismissed
|
|
? 'No dismissed notifications'
|
|
: 'All caught up!'}
|
|
</Text>
|
|
<Text size="xs" c="dimmed">
|
|
{showDismissed
|
|
? 'Dismissed notifications appear here'
|
|
: 'No new notifications'}
|
|
</Text>
|
|
</Box>
|
|
) : (
|
|
<Stack gap="xs" p="xs">
|
|
{displayedNotifications.map((notification) => (
|
|
<NotificationItem
|
|
key={notification.id}
|
|
notification={notification}
|
|
onDismiss={handleDismiss}
|
|
onAction={handleAction}
|
|
onClose={() => setOpened(false)}
|
|
/>
|
|
))}
|
|
</Stack>
|
|
)}
|
|
</ScrollAreaAutosize>
|
|
|
|
{/* Footer with info text */}
|
|
{!showDismissed &&
|
|
notifications.length > unreadNotifications.length && (
|
|
<>
|
|
<Divider />
|
|
<Box p="xs" ta="center">
|
|
<Text size="xs" c="dimmed">
|
|
{notifications.length - unreadNotifications.length} dismissed
|
|
notification
|
|
{notifications.length - unreadNotifications.length !== 1
|
|
? 's'
|
|
: ''}
|
|
</Text>
|
|
</Box>
|
|
</>
|
|
)}
|
|
</PopoverDropdown>
|
|
</Popover>
|
|
);
|
|
};
|
|
|
|
export default NotificationCenter;
|