teg/frontend/src/components/NotificationCenter.tsx
2026-05-22 14:11:18 +02:00

221 lines
6.8 KiB
TypeScript

// frontend/src/components/NotificationCenter.tsx
import { useEffect, useMemo, useRef, useState } from 'react'
import {
BellIcon,
EnvelopeOpenIcon,
FaceSmileIcon,
} from '@heroicons/react/20/solid'
import Button from './Button'
import Tabs, { type TabItem } from './Tabs'
import type { Notification } from './types'
type NotificationCenterProps = {
notifications: Notification[]
unreadCount: number
onMarkRead: (notificationId: string) => void
onMarkAllRead: () => void
}
type NotificationTab = 'unread' | 'all'
function formatNotificationDate(value: string) {
try {
return new Intl.DateTimeFormat('de-DE', {
dateStyle: 'short',
timeStyle: 'short',
}).format(new Date(value))
} catch {
return value
}
}
export default function NotificationCenter({
notifications,
unreadCount,
onMarkRead,
onMarkAllRead,
}: NotificationCenterProps) {
const [open, setOpen] = useState(false)
const [activeTab, setActiveTab] = useState<NotificationTab>('unread')
const containerRef = useRef<HTMLDivElement | null>(null)
const filteredNotifications = useMemo(() => {
if (activeTab === 'unread') {
return notifications.filter((notification) => !notification.readAt)
}
return notifications
}, [activeTab, notifications])
const notificationTabs: TabItem[] = [
{
name: 'Ungelesen',
href: '#',
current: activeTab === 'unread',
count: unreadCount > 0 ? unreadCount : undefined,
onClick: () => setActiveTab('unread'),
},
{
name: 'Alle',
href: '#',
current: activeTab === 'all',
count: notifications.length > 0 ? notifications.length : undefined,
onClick: () => setActiveTab('all'),
},
]
useEffect(() => {
if (!open) {
return
}
function handlePointerDown(event: PointerEvent) {
const target = event.target
if (!(target instanceof Node)) {
return
}
if (containerRef.current?.contains(target)) {
return
}
setOpen(false)
}
function handleKeyDown(event: KeyboardEvent) {
if (event.key === 'Escape') {
setOpen(false)
}
}
document.addEventListener('pointerdown', handlePointerDown)
document.addEventListener('keydown', handleKeyDown)
return () => {
document.removeEventListener('pointerdown', handlePointerDown)
document.removeEventListener('keydown', handleKeyDown)
}
}, [open])
function getEmptyText() {
if (activeTab === 'unread') {
return 'Keine ungelesenen Benachrichtigungen vorhanden.'
}
return 'Keine Benachrichtigungen vorhanden.'
}
return (
<div ref={containerRef} className="relative">
<button
type="button"
onClick={() => setOpen((value) => !value)}
className="relative rounded-full p-2 text-gray-500 hover:bg-gray-100 hover:text-gray-700 dark:text-gray-400 dark:hover:bg-white/10 dark:hover:text-white"
>
<span className="sr-only">Benachrichtigungen öffnen</span>
<BellIcon aria-hidden="true" className="size-6" />
{unreadCount > 0 && (
<span className="absolute top-1 right-1 flex size-4 items-center justify-center rounded-full bg-red-600 text-[10px] font-semibold text-white">
{unreadCount > 9 ? '9+' : unreadCount}
</span>
)}
</button>
{open && (
<div className="absolute right-0 z-50 mt-2 w-96 overflow-hidden rounded-lg bg-white shadow-lg ring-1 ring-black/5 dark:bg-gray-900 dark:ring-white/10">
<div className="grid grid-cols-[minmax(0,1fr)_auto] items-center gap-x-3 border-b border-gray-200 dark:border-white/10">
<h2 className="min-w-0 truncate px-4 py-3 text-sm font-semibold text-gray-900 dark:text-white">
Benachrichtigungen
</h2>
<div className="flex justify-end px-4">
{unreadCount > 0 && (
<Button
type="button"
variant="secondary"
color="indigo"
size="sm"
onClick={onMarkAllRead}
aria-label="Alle Benachrichtigungen als gelesen markieren"
title="Alle gelesen"
leadingIcon={
<EnvelopeOpenIcon aria-hidden="true" className="size-4" />
}
>
Alle gelesen
</Button>
)}
</div>
</div>
<Tabs
tabs={notificationTabs}
variant="full-width-underline"
align="center"
ariaLabel="Benachrichtigungsfilter"
className="px-4 [&_a]:py-3"
/>
<div className="h-96 overflow-y-auto">
{filteredNotifications.length === 0 ? (
<div className="flex h-full flex-col items-center justify-center px-6 py-10 text-center">
<FaceSmileIcon
aria-hidden="true"
className="size-12 text-gray-300 dark:text-gray-600"
/>
<p className="mt-3 text-sm font-medium text-gray-900 dark:text-white">
Alles erledigt
</p>
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
{getEmptyText()}
</p>
</div>
) : (
filteredNotifications.map((notification) => {
const unread = !notification.readAt
return (
<button
key={notification.id}
type="button"
onClick={() => onMarkRead(notification.id)}
className="block w-full border-b border-gray-100 px-4 py-3 text-left hover:bg-gray-50 dark:border-white/10 dark:hover:bg-white/5"
>
<div className="flex gap-x-3">
<span
className={
unread
? 'mt-1 size-2 rounded-full bg-indigo-600'
: 'mt-1 size-2 rounded-full bg-transparent'
}
/>
<div className="min-w-0 flex-1">
<p className="text-sm font-medium text-gray-900 dark:text-white">
{notification.title}
</p>
<p className="mt-0.5 line-clamp-2 text-sm text-gray-600 dark:text-gray-300">
{notification.message}
</p>
<p className="mt-1 text-xs text-gray-400 dark:text-gray-500">
{formatNotificationDate(notification.createdAt)}
</p>
</div>
</div>
</button>
)
})
)}
</div>
</div>
)}
</div>
)
}