182 lines
7.1 KiB
TypeScript
182 lines
7.1 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 // z. B. "team-invite" | "team-join-request"
|
||
actionData?: string // invitationId oder teamId
|
||
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
|
||
}
|
||
|
||
export default function NotificationDropdown({
|
||
notifications,
|
||
markAllAsRead,
|
||
onSingleRead,
|
||
onClose,
|
||
onAction,
|
||
onClickNotification
|
||
}: Props) {
|
||
const dropdownRef = useRef<HTMLDivElement>(null)
|
||
|
||
/* --- Klick außerhalb schließt Dropdown ------------------------- */
|
||
useEffect(() => {
|
||
const handleClickOutside = (event: MouseEvent) => {
|
||
if (
|
||
dropdownRef.current &&
|
||
!dropdownRef.current.contains(event.target as Node)
|
||
) {
|
||
onClose()
|
||
}
|
||
}
|
||
|
||
document.addEventListener('mousedown', handleClickOutside)
|
||
return () => document.removeEventListener('mousedown', handleClickOutside)
|
||
}, [onClose])
|
||
|
||
/* --- Render ----------------------------------------------------- */
|
||
return (
|
||
<div
|
||
ref={dropdownRef}
|
||
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"
|
||
>
|
||
{/* Icon */}
|
||
<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) // ⬅️ Navigation / Weiterverarbeitung
|
||
if (!n.read) onSingleRead(n.id) // ⬅️ erst danach als gelesen markieren
|
||
}}
|
||
>
|
||
{/* roter Punkt */}
|
||
<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>
|
||
|
||
{/* Text + Timestamp */}
|
||
<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>
|
||
|
||
{/* Aktionen */}
|
||
<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>
|
||
)
|
||
}
|