Files
dashboard/frontend/src/components/shared/NotificationBell.tsx
Matthias Hochmeister 501b697ca2 update FDISK sync
2026-03-13 08:46:12 +01:00

316 lines
10 KiB
TypeScript

import React, { useState, useEffect, useCallback, useRef } from 'react';
import {
Badge,
Box,
Button,
CircularProgress,
Divider,
IconButton,
List,
ListItem,
ListItemButton,
ListItemText,
Popover,
Tooltip,
Typography,
} from '@mui/material';
import {
Notifications as BellIcon,
NotificationsNone as BellEmptyIcon,
Circle as CircleIcon,
} from '@mui/icons-material';
import { useNavigate } from 'react-router-dom';
import { notificationsApi } from '../../services/notifications';
import { useNotification } from '../../contexts/NotificationContext';
import type { Notification, NotificationSchwere } from '../../types/notification.types';
const POLL_INTERVAL_MS = 15_000; // 15 seconds
function playNotificationSound() {
try {
const ctx = new AudioContext();
const oscillator = ctx.createOscillator();
const gain = ctx.createGain();
oscillator.connect(gain);
gain.connect(ctx.destination);
oscillator.type = 'sine';
oscillator.frequency.value = 600;
const now = ctx.currentTime;
gain.gain.setValueAtTime(0, now);
gain.gain.linearRampToValueAtTime(0.3, now + 0.02);
gain.gain.linearRampToValueAtTime(0, now + 0.15);
oscillator.start(now);
oscillator.stop(now + 0.15);
oscillator.onended = () => ctx.close();
} catch {
// Audio blocked before first user interaction — fail silently
}
}
/**
* Only allow window.open for URLs whose origin matches the current app origin.
* External-looking URLs (different host or protocol-relative) are rejected to
* prevent open-redirect / tab-napping via notification link data from the backend.
*/
function isTrustedUrl(url: string): boolean {
try {
const parsed = new URL(url, window.location.origin);
return parsed.origin === window.location.origin;
} catch {
return false;
}
}
function schwerebColor(schwere: NotificationSchwere): 'error' | 'warning' | 'info' {
if (schwere === 'fehler') return 'error';
if (schwere === 'warnung') return 'warning';
return 'info';
}
function formatRelative(iso: string): string {
const diff = Date.now() - new Date(iso).getTime();
const minutes = Math.floor(diff / 60_000);
if (minutes < 2) return 'Gerade eben';
if (minutes < 60) return `vor ${minutes} Min.`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `vor ${hours} Std.`;
const days = Math.floor(hours / 24);
return `vor ${days} Tag${days !== 1 ? 'en' : ''}`;
}
const NotificationBell: React.FC = () => {
const navigate = useNavigate();
const { showNotificationToast } = useNotification();
const [unreadCount, setUnreadCount] = useState(0);
const [notifications, setNotifications] = useState<Notification[]>([]);
const [loadingList, setLoadingList] = useState(false);
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
const pollTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
// Track known notification IDs to detect new ones; null = not yet initialized
const knownIdsRef = useRef<Set<string> | null>(null);
const fetchAndToastNew = useCallback(async () => {
try {
const data = await notificationsApi.getNotifications();
const unread = data.filter((n) => !n.gelesen);
const count = unread.length;
setUnreadCount(count);
if (knownIdsRef.current === null) {
// First load — initialize without toasting
knownIdsRef.current = new Set(data.map((n) => n.id));
return;
}
// Find notifications we haven't seen before
const newOnes = unread.filter((n) => !knownIdsRef.current!.has(n.id));
if (newOnes.length > 0) {
playNotificationSound();
}
newOnes.forEach((n) => {
knownIdsRef.current!.add(n.id);
const severity = n.schwere === 'fehler' ? 'error' : n.schwere === 'warnung' ? 'warning' : 'info';
showNotificationToast(n.titel, severity);
});
// Also add all known IDs to avoid re-toasting on re-fetch
data.forEach((n) => knownIdsRef.current!.add(n.id));
} catch {
// non-critical
}
}, [showNotificationToast]);
// Poll for new notifications every 15 seconds
useEffect(() => {
fetchAndToastNew();
pollTimerRef.current = setInterval(fetchAndToastNew, POLL_INTERVAL_MS);
return () => {
if (pollTimerRef.current) clearInterval(pollTimerRef.current);
};
}, [fetchAndToastNew]);
const handleOpen = async (event: React.MouseEvent<HTMLElement>) => {
setAnchorEl(event.currentTarget);
setLoadingList(true);
try {
const data = await notificationsApi.getNotifications();
setNotifications(data);
// Mark all as known so we don't toast them again
if (knownIdsRef.current === null) {
knownIdsRef.current = new Set(data.map((n) => n.id));
} else {
data.forEach((n) => knownIdsRef.current!.add(n.id));
}
// Refresh count after loading full list
const count = await notificationsApi.getUnreadCount();
setUnreadCount(count);
} catch {
// non-critical
} finally {
setLoadingList(false);
}
};
const handleClose = () => {
setAnchorEl(null);
};
const handleClickNotification = async (n: Notification) => {
if (!n.gelesen) {
try {
await notificationsApi.markRead(n.id);
setNotifications((prev) =>
prev.map((item) => item.id === n.id ? { ...item, gelesen: true } : item)
);
setUnreadCount((c) => Math.max(0, c - 1));
} catch {
// non-critical
}
}
handleClose();
if (n.link) {
if (n.link.startsWith('http://') || n.link.startsWith('https://')) {
if (isTrustedUrl(n.link)) {
window.open(n.link, '_blank');
} else {
console.warn('NotificationBell: blocked navigation to untrusted URL', n.link);
}
} else {
navigate(n.link);
}
}
};
const handleMarkAllRead = async () => {
try {
await notificationsApi.markAllRead();
setNotifications((prev) => prev.map((n) => ({ ...n, gelesen: true })));
setUnreadCount(0);
} catch {
// non-critical
}
};
const handleDeleteAllRead = async () => {
try {
await notificationsApi.deleteAllRead();
setNotifications((prev) => prev.filter((n) => !n.gelesen));
} catch {
// non-critical
}
};
const open = Boolean(anchorEl);
const hasUnread = unreadCount > 0;
return (
<>
<Tooltip title="Benachrichtigungen">
<IconButton
color="inherit"
onClick={handleOpen}
aria-label="Benachrichtigungen öffnen"
size="small"
>
<Badge badgeContent={unreadCount} color="error" invisible={!hasUnread}>
{hasUnread ? <BellIcon /> : <BellEmptyIcon />}
</Badge>
</IconButton>
</Tooltip>
<Popover
open={open}
anchorEl={anchorEl}
onClose={handleClose}
anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }}
transformOrigin={{ vertical: 'top', horizontal: 'right' }}
PaperProps={{ sx: { width: 360, maxHeight: 500, display: 'flex', flexDirection: 'column' } }}
>
{/* Header */}
<Box sx={{ px: 2, py: 1.5, display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<Typography variant="subtitle1" fontWeight={600}>
Benachrichtigungen
</Typography>
<Box sx={{ display: 'flex', gap: 0.5 }}>
{notifications.some((n) => n.gelesen) && (
<Button size="small" onClick={handleDeleteAllRead} sx={{ fontSize: '0.75rem' }}>
Gelesene löschen
</Button>
)}
{unreadCount > 0 && (
<Button size="small" onClick={handleMarkAllRead} sx={{ fontSize: '0.75rem' }}>
Alle als gelesen markieren
</Button>
)}
</Box>
</Box>
<Divider />
{/* Body */}
<Box sx={{ overflowY: 'auto', flexGrow: 1 }}>
{loadingList ? (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}>
<CircularProgress size={28} />
</Box>
) : notifications.length === 0 ? (
<Box sx={{ textAlign: 'center', py: 4 }}>
<BellEmptyIcon sx={{ fontSize: 40, color: 'text.disabled', mb: 1 }} />
<Typography variant="body2" color="text.secondary">
Keine Benachrichtigungen
</Typography>
</Box>
) : (
<List disablePadding>
{notifications.map((n, idx) => (
<React.Fragment key={n.id}>
{idx > 0 && <Divider component="li" />}
<ListItem disablePadding>
<ListItemButton
onClick={() => handleClickNotification(n)}
sx={{
py: 1.5,
px: 2,
bgcolor: n.gelesen ? 'transparent' : 'action.hover',
alignItems: 'flex-start',
gap: 1,
}}
>
<CircleIcon
sx={{
fontSize: 8,
mt: 0.75,
flexShrink: 0,
color: n.gelesen ? 'transparent' : `${schwerebColor(n.schwere)}.main`,
}}
/>
<ListItemText
primary={
<Typography variant="body2" fontWeight={n.gelesen ? 400 : 600}>
{n.titel}
</Typography>
}
secondary={
<Box>
<Typography variant="caption" color="text.secondary" display="block">
{n.nachricht}
</Typography>
<Typography variant="caption" color="text.disabled">
{formatRelative(n.erstellt_am)}
</Typography>
</Box>
}
disableTypography
/>
</ListItemButton>
</ListItem>
</React.Fragment>
))}
</List>
)}
</Box>
</Popover>
</>
);
};
export default NotificationBell;