150 lines
6.3 KiB
TypeScript
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>
|
|
)
|
|
}
|