elmeg-demo/frontend/components/notifications/notification-bell.tsx
fullsizemalt a4edbb676d
Some checks are pending
Deploy Elmeg / deploy (push) Waiting to run
feat(notifications): Added notification system with mention support
2025-12-21 02:36:02 -08:00

171 lines
6.9 KiB
TypeScript

"use client"
import { useState, useEffect } from "react"
import { Bell, Check, ExternalLink } from "lucide-react"
import { Button } from "@/components/ui/button"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuHeader,
DropdownMenuItem,
DropdownMenuTrigger,
DropdownMenuSeparator
} from "@/components/ui/dropdown-menu"
import { Badge } from "@/components/ui/badge"
import { ScrollArea } from "@/components/ui/scroll-area"
import { getApiUrl } from "@/lib/api-config"
import Link from "next/link"
import { formatDistanceToNow } from "date-fns"
import { cn } from "@/lib/utils"
interface Notification {
id: number
type: string
title: string
message: string
link: string | null
is_read: boolean
created_at: string
}
export function NotificationBell() {
const [notifications, setNotifications] = useState<Notification[]>([])
const [unreadCount, setUnreadCount] = useState(0)
const [isOpen, setIsOpen] = useState(false)
const [loading, setLoading] = useState(false)
const fetchNotifications = async () => {
const token = localStorage.getItem("token")
if (!token) return
setLoading(true)
try {
const res = await fetch(`${getApiUrl()}/notifications/`, {
headers: { Authorization: `Bearer ${token}` }
})
if (res.ok) {
const data = await res.json()
setNotifications(data)
setUnreadCount(data.filter((n: Notification) => !n.is_read).length)
}
} catch (error) {
console.error("Failed to fetch notifications", error)
} finally {
setLoading(false)
}
}
// Determine when to fetch: Mount and when opened
useEffect(() => {
fetchNotifications()
// Optional: Poll every 60s
const interval = setInterval(fetchNotifications, 60000)
return () => clearInterval(interval)
}, [])
const handleMarkAsRead = async (id: number) => {
const token = localStorage.getItem("token")
if (!token) return
try {
await fetch(`${getApiUrl()}/notifications/${id}/read`, {
method: "POST",
headers: { Authorization: `Bearer ${token}` }
})
// Optimistic update
setNotifications(prev => prev.map(n => n.id === id ? { ...n, is_read: true } : n))
setUnreadCount(prev => Math.max(0, prev - 1))
} catch (error) {
console.error("Failed to mark as read", error)
}
}
const handleMarkAllRead = async () => {
const token = localStorage.getItem("token")
if (!token) return
try {
await fetch(`${getApiUrl()}/notifications/mark-all-read`, {
method: "POST",
headers: { Authorization: `Bearer ${token}` }
})
setNotifications(prev => prev.map(n => ({ ...n, is_read: true })))
setUnreadCount(0)
} catch (error) {
console.error("Failed to mark all as read", error)
}
}
return (
<DropdownMenu open={isOpen} onOpenChange={(open) => {
setIsOpen(open)
if (open) fetchNotifications()
}}>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="relative">
<Bell className="h-5 w-5" />
{unreadCount > 0 && (
<span className="absolute top-1 right-1 h-2.5 w-2.5 rounded-full bg-red-600 border-2 border-background" />
)}
<span className="sr-only">Notifications</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-80">
<div className="flex items-center justify-between px-4 py-2 border-b">
<span className="font-semibold text-sm">Notifications</span>
{unreadCount > 0 && (
<Button
variant="ghost"
size="xs"
className="h-auto px-2 py-0.5 text-xs text-muted-foreground hover:text-primary"
onClick={handleMarkAllRead}
>
Mark all read
</Button>
)}
</div>
<ScrollArea className="h-[300px]">
{notifications.length === 0 ? (
<div className="p-8 text-center text-sm text-muted-foreground">
No notifications yet.
</div>
) : (
<div className="flex flex-col">
{notifications.map((notification) => (
<div
key={notification.id}
className={cn(
"flex flex-col gap-1 p-4 border-b text-left transition-colors hover:bg-muted/50 cursor-pointer",
!notification.is_read && "bg-muted/30"
)}
onClick={() => handleMarkAsRead(notification.id)}
>
<div className="flex items-start justify-between gap-2">
<span className={cn("text-sm font-medium leading-none", !notification.is_read && "text-primary")}>
{notification.title}
</span>
<span className="text-[10px] text-muted-foreground whitespace-nowrap">
{formatDistanceToNow(new Date(notification.created_at), { addSuffix: true })}
</span>
</div>
<p className="text-xs text-muted-foreground line-clamp-2">
{notification.message}
</p>
{notification.link && (
<Link
href={notification.link}
className="text-xs text-primary hover:underline mt-1 flex items-center gap-1"
onClick={(e) => e.stopPropagation()}
>
View <ExternalLink className="h-3 w-3" />
</Link>
)}
</div>
))}
</div>
)}
</ScrollArea>
</DropdownMenuContent>
</DropdownMenu>
)
}