ironie-nextjs/src/app/components/NotificationCenter.tsx
2025-09-06 15:19:09 +02:00

150 lines
6.3 KiB
TypeScript

'use client'
import { useEffect, useRef } from 'react'
import Button from './Button'
import { formatDistanceToNow } from 'date-fns'
import { de } from 'date-fns/locale'
type Notification = {
id: string
text: string
read: boolean
actionType?: string
actionData?: string
createdAt?: string
}
type Props = {
notifications: Notification[]
markAllAsRead: () => void
onSingleRead: (id: string) => void
onClose: () => void
onAction: (action: 'accept' | 'reject', invitationId: string) => void
onClickNotification?: (notification: Notification) => void
anchorRef?: React.RefObject<HTMLElement> // Glocke
}
export default function NotificationCenter({
notifications,
markAllAsRead,
onSingleRead,
onClose,
onAction,
onClickNotification,
anchorRef
}: Props) {
const panelRef = useRef<HTMLDivElement | null>(null)
// EIN Outside-Click Listener, der sowohl Panel als auch Anchor ignoriert
useEffect(() => {
const onDocPointerDown = (e: PointerEvent) => {
const t = e.target as Node
if (panelRef.current?.contains(t)) return
if (anchorRef?.current?.contains(t)) return
onClose()
}
document.addEventListener('pointerdown', onDocPointerDown, { passive: true })
return () => document.removeEventListener('pointerdown', onDocPointerDown)
}, [onClose, anchorRef])
return (
<div
ref={panelRef}
className="absolute bottom-20 right-0 w-80 bg-white dark:bg-neutral-800 border border-gray-200 dark:border-neutral-700 rounded-lg shadow-xl overflow-hidden z-50"
>
{/* Kopfzeile */}
<div className="p-2 flex justify-between items-center border-b border-gray-200 dark:border-neutral-700">
<span className="font-semibold text-gray-800 dark:text-white">
Benachrichtigungen
</span>
<Button
title="Alle als gelesen markieren"
onClick={markAllAsRead}
variant="solid"
color="blue"
size="sm"
className="p-2"
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" className="w-4 h-4" fill="currentColor">
<path d="M255.4 48.2c.2-.1.4-.2.6-.2s.4.1.6.2L460.6 194c2.1 1.5 3.4 3.9 3.4 6.5v13.6L291.5 355.7c-20.7 17-50.4 17-71.1 0L48 214.1v-13.6c0-2.6 1.2-5 3.4-6.5L255.4 48.2zM48 276.2L190 392.8c38.4 31.5 93.7 31.5 132 0L464 276.2V456c0 4.4-3.6 8-8 8H56c-4.4 0-8-3.6-8-8V276.2zM256 0c-10.2 0-20.2 3.2-28.5 9.1L23.5 154.9C8.7 165.4 0 182.4 0 200.5V456c0 30.9 25.1 56 56 56h400c30.9 0 56-25.1 56-56V200.5c0-18.1-8.7-35.1-23.4-45.6L284.5 9.1C276.2 3.2 266.2 0 256 0z" />
</svg>
</Button>
</div>
{/* Liste */}
<div className="max-h-60 overflow-y-auto">
{notifications.length === 0 ? (
<div className="p-4 text-center text-gray-500 dark:text-neutral-400">
Keine Benachrichtigungen
</div>
) : (
notifications.map((n) => {
const needsAction =
!n.read && (n.actionType === 'team-invite' || n.actionType === 'team-join-request')
return (
<div
key={n.id}
className="grid grid-cols-[auto_1fr_auto] items-center gap-2 py-3 px-2 border-b border-gray-200 dark:border-neutral-700 text-sm hover:bg-gray-50 dark:hover:bg-neutral-700 cursor-pointer"
onClick={() => {
onClickNotification?.(n)
if (!n.read) onSingleRead(n.id)
}}
>
<div className="flex items-center justify-center h-full">
<span className={`inline-block w-2 h-2 rounded-full ${n.read ? 'bg-transparent' : 'bg-red-500'}`} />
</div>
<div className="flex flex-col gap-1">
<span className={n.read ? 'text-gray-600 dark:text-neutral-400' : 'font-semibold text-gray-900 dark:text-white'}>
{n.text}
</span>
<span className="text-xs text-gray-400 dark:text-neutral-500">
{n.createdAt && formatDistanceToNow(new Date(n.createdAt), { addSuffix: true, locale: de })}
</span>
</div>
<div className="flex items-center gap-1">
{needsAction ? (
<>
<Button
onClick={(e) => { e.stopPropagation(); onAction('accept', n.actionData ?? n.id); onSingleRead(n.id) }}
className="px-2 py-1 text-xs font-medium rounded bg-green-600 text-white hover:bg-green-700"
color="green"
size="sm"
>
</Button>
<Button
onClick={(e) => { e.stopPropagation(); onAction('reject', n.actionData ?? n.id); onSingleRead(n.id) }}
className="px-2 py-1 text-xs font-medium rounded bg-red-600 text-white hover:bg-red-700"
color="red"
size="sm"
>
</Button>
</>
) : (
!n.read && (
<Button
onClick={() => onSingleRead(n.id)}
title="Als gelesen markieren"
className="p-1 text-gray-400 hover:text-gray-700 dark:hover:text-white"
color="gray"
size="sm"
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" className="w-4 h-4" fill="currentColor">
<path d="M255.4 48.2c.2-.1.4-.2.6-.2s.4.1.6.2L460.6 194c2.1 1.5 3.4 3.9 3.4 6.5v13.6L291.5 355.7c-20.7 17-50.4 17-71.1 0L48 214.1v-13.6c0-2.6 1.2-5 3.4-6.5L255.4 48.2zM48 276.2L190 392.8c38.4 31.5 93.7 31.5 132 0L464 276.2V456c0 4.4-3.6 8-8 8H56c-4.4 0-8-3.6-8-8V276.2zM256 0c-10.2 0-20.2 3.2-28.5 9.1L23.5 154.9C8.7 165.4 0 182.4 0 200.5V456c0 30.9 25.1 56 56 56h400c30.9 0 56-25.1 56-56V200.5c0-18.1-8.7-35.1-23.4-45.6L284.5 9.1C276.2 3.2 266.2 0 256 0z" />
</svg>
</Button>
)
)}
</div>
</div>
)
})
)}
</div>
</div>
)
}