- Add MarkCaughtButton component to show page setlist - Fix TypeScript errors in profile, settings, welcome pages - Fix Switch component onChange props - Fix notification-bell imports and button size - Fix performance-list orphaned JSX - Fix song-evolution-chart tooltip types - Add Suspense boundaries for useSearchParams (Next.js 16 requirement)
170 lines
6.9 KiB
TypeScript
170 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,
|
|
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="sm"
|
|
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>
|
|
)
|
|
}
|