Files

119 lines
4.0 KiB
TypeScript

'use client'
import { useCallback, useEffect, useRef, useState } from 'react'
import Link from 'next/link'
type Notification = {
id: string
title: string | null
link: string | null
sent_at: string
read_at: string | null
}
const POLL_MS = 60_000
export function NotificationBell() {
const [notifications, setNotifications] = useState<Notification[]>([])
const [unread, setUnread] = useState(0)
const [open, setOpen] = useState(false)
const panelRef = useRef<HTMLDivElement>(null)
const load = useCallback(() => {
fetch('/api/notifications')
.then(r => (r.ok ? r.json() : Promise.reject()))
.then((data: { notifications: Notification[]; unread: number }) => {
setNotifications(data.notifications)
setUnread(data.unread)
})
.catch(() => {})
}, [])
useEffect(() => {
load()
const id = setInterval(load, POLL_MS)
return () => clearInterval(id)
}, [load])
useEffect(() => {
if (!open) return
const onClick = (e: MouseEvent) => {
if (panelRef.current && !panelRef.current.contains(e.target as Node)) setOpen(false)
}
document.addEventListener('mousedown', onClick)
return () => document.removeEventListener('mousedown', onClick)
}, [open])
const markAllRead = () => {
fetch('/api/notifications', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ all: true }),
})
.then(() => {
setUnread(0)
setNotifications(prev => prev.map(n => ({ ...n, read_at: n.read_at ?? new Date().toISOString() })))
})
.catch(() => {})
}
return (
<div ref={panelRef} className="fixed top-3 right-3 z-50">
<button
onClick={() => setOpen(o => !o)}
aria-label={`Notifications${unread > 0 ? ` (${unread} unread)` : ''}`}
className="relative bg-white rounded-full shadow-sm border border-gray-200 w-10 h-10 flex items-center justify-center hover:shadow-md transition-shadow"
>
<span className="text-lg" aria-hidden>🔔</span>
{unread > 0 && (
<span className="absolute -top-1 -right-1 bg-red-600 text-white text-[10px] font-bold rounded-full min-w-[18px] h-[18px] px-1 flex items-center justify-center">
{unread > 99 ? '99+' : unread}
</span>
)}
</button>
{open && (
<div className="absolute right-0 mt-2 w-80 max-h-96 overflow-y-auto bg-white rounded-xl shadow-lg border border-gray-200">
<div className="flex items-center justify-between px-4 py-2 border-b border-gray-100">
<span className="text-sm font-semibold text-gray-700">Notifications</span>
{unread > 0 && (
<button onClick={markAllRead} className="text-xs text-blue-600 hover:underline">
Mark all read
</button>
)}
</div>
{notifications.length === 0 ? (
<p className="px-4 py-6 text-sm text-gray-400 text-center">No notifications</p>
) : (
<ul>
{notifications.map(n => {
const inner = (
<>
<p className={`text-sm ${n.read_at ? 'text-gray-500' : 'text-gray-900 font-medium'}`}>
{n.title ?? 'Notification'}
</p>
<p className="text-xs text-gray-400 mt-0.5">
{new Date(n.sent_at).toLocaleString()}
</p>
</>
)
return (
<li key={n.id} className="border-b border-gray-50 last:border-0">
{n.link ? (
<Link href={n.link} className="block px-4 py-3 hover:bg-gray-50" onClick={() => setOpen(false)}>
{inner}
</Link>
) : (
<div className="px-4 py-3">{inner}</div>
)}
</li>
)
})}
</ul>
)}
</div>
)}
</div>
)
}