fe integration for notification system with context, components, and API integration
This commit is contained in:
@@ -0,0 +1,122 @@
|
||||
'use client'
|
||||
|
||||
import Link from 'next/link'
|
||||
import { useState } from 'react'
|
||||
import { Bell } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
||||
import { useNotifications } from '@/contexts/notification-context'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { NotificationResponse, NotificationSeverity } from '@/types/notifications'
|
||||
|
||||
const SEVERITY_DOT: Record<NotificationSeverity, string> = {
|
||||
Info: 'bg-sky-500',
|
||||
Success: 'bg-emerald-500',
|
||||
Warning: 'bg-amber-500',
|
||||
Critical: 'bg-red-500',
|
||||
}
|
||||
|
||||
export function timeAgo(iso: string): string {
|
||||
const seconds = Math.max(0, Math.floor((Date.now() - new Date(iso).getTime()) / 1000))
|
||||
if (seconds < 60) return 'just now'
|
||||
const minutes = Math.floor(seconds / 60)
|
||||
if (minutes < 60) return `${minutes}m ago`
|
||||
const hours = Math.floor(minutes / 60)
|
||||
if (hours < 24) return `${hours}h ago`
|
||||
const days = Math.floor(hours / 24)
|
||||
if (days < 7) return `${days}d ago`
|
||||
return new Date(iso).toLocaleDateString()
|
||||
}
|
||||
|
||||
export function NotificationRow({
|
||||
notification: n,
|
||||
onOpen,
|
||||
}: {
|
||||
notification: NotificationResponse
|
||||
onOpen: (n: NotificationResponse) => void
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onOpen(n)}
|
||||
className={cn(
|
||||
'flex w-full items-start gap-3 px-4 py-3 text-left transition-colors hover:bg-muted/60',
|
||||
!n.isRead && 'bg-muted/30'
|
||||
)}
|
||||
>
|
||||
<span className={cn('mt-1.5 size-2 shrink-0 rounded-full', n.isRead ? 'bg-transparent' : SEVERITY_DOT[n.severity])} />
|
||||
<span className="flex min-w-0 flex-1 flex-col gap-0.5">
|
||||
<span className={cn('truncate text-sm', n.isRead ? 'text-foreground' : 'font-medium text-foreground')}>
|
||||
{n.title}
|
||||
</span>
|
||||
<span className="line-clamp-2 text-xs text-muted-foreground">{n.message}</span>
|
||||
<span className="text-[11px] text-muted-foreground/70">{timeAgo(n.createdAt)}</span>
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
export function NotificationBell() {
|
||||
const { recent, unreadCount, open, markAllRead } = useNotifications()
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
|
||||
return (
|
||||
<Popover open={isOpen} onOpenChange={setIsOpen}>
|
||||
<PopoverTrigger
|
||||
render={
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="relative h-8 w-8 text-muted-foreground hover:text-foreground"
|
||||
aria-label={unreadCount > 0 ? `Notifications (${unreadCount} unread)` : 'Notifications'}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Bell size={16} />
|
||||
{unreadCount > 0 && (
|
||||
<span className="absolute -top-0.5 -right-0.5 flex h-4 min-w-4 items-center justify-center rounded-full bg-red-500 px-1 text-[10px] font-semibold leading-none text-white">
|
||||
{unreadCount > 99 ? '99+' : unreadCount}
|
||||
</span>
|
||||
)}
|
||||
</PopoverTrigger>
|
||||
|
||||
<PopoverContent>
|
||||
<div className="flex items-center justify-between border-b border-border px-4 py-3">
|
||||
<span className="text-sm font-medium">Notifications</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
disabled={unreadCount === 0}
|
||||
onClick={() => { void markAllRead() }}
|
||||
>
|
||||
Mark all read
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="max-h-96 divide-y divide-border overflow-y-auto">
|
||||
{recent.length === 0 ? (
|
||||
<p className="px-4 py-10 text-center text-sm text-muted-foreground">You're all caught up.</p>
|
||||
) : (
|
||||
recent.map(n => (
|
||||
<NotificationRow
|
||||
key={n.id}
|
||||
notification={n}
|
||||
onOpen={item => { setIsOpen(false); void open(item) }}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="border-t border-border p-2">
|
||||
<Link
|
||||
href="/notifications"
|
||||
onClick={() => setIsOpen(false)}
|
||||
className="block rounded-xl px-3 py-2 text-center text-sm text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
||||
>
|
||||
View all
|
||||
</Link>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Popover as PopoverPrimitive } from "@base-ui/react/popover"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Popover({ ...props }: PopoverPrimitive.Root.Props) {
|
||||
return <PopoverPrimitive.Root data-slot="popover" {...props} />
|
||||
}
|
||||
|
||||
function PopoverTrigger({ ...props }: PopoverPrimitive.Trigger.Props) {
|
||||
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />
|
||||
}
|
||||
|
||||
function PopoverContent({
|
||||
align = "end",
|
||||
alignOffset = 0,
|
||||
side = "bottom",
|
||||
sideOffset = 6,
|
||||
className,
|
||||
...props
|
||||
}: PopoverPrimitive.Popup.Props &
|
||||
Pick<
|
||||
PopoverPrimitive.Positioner.Props,
|
||||
"align" | "alignOffset" | "side" | "sideOffset"
|
||||
>) {
|
||||
return (
|
||||
<PopoverPrimitive.Portal>
|
||||
<PopoverPrimitive.Positioner
|
||||
className="isolate z-50 outline-none"
|
||||
align={align}
|
||||
alignOffset={alignOffset}
|
||||
side={side}
|
||||
sideOffset={sideOffset}
|
||||
>
|
||||
<PopoverPrimitive.Popup
|
||||
data-slot="popover-content"
|
||||
className={cn(
|
||||
"z-50 w-[min(24rem,calc(100vw-2rem))] origin-(--transform-origin) rounded-2xl bg-popover text-popover-foreground shadow-lg ring-1 ring-foreground/5 duration-100 outline-none dark:ring-foreground/10 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</PopoverPrimitive.Positioner>
|
||||
</PopoverPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
export { Popover, PopoverTrigger, PopoverContent }
|
||||
Reference in New Issue
Block a user