221 lines
6.8 KiB
TypeScript
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>
|
|
)
|
|
} |