193 lines
7.4 KiB
TypeScript
193 lines
7.4 KiB
TypeScript
'use client'
|
|
|
|
import { useState, useRef, useEffect, useCallback } from 'react'
|
|
import { Bell, X, Check, Loader2 } from 'lucide-react'
|
|
import { createClient } from '@/lib/supabase/client'
|
|
import { cn } from '@/lib/utils'
|
|
|
|
interface Notification {
|
|
id: string
|
|
type: string
|
|
title: string
|
|
body: string
|
|
read: boolean
|
|
created_at: string
|
|
}
|
|
|
|
const typeIcon: Record<string, string> = {
|
|
session_created: '🚀',
|
|
student_joined: '👤',
|
|
session_ended: '✅',
|
|
}
|
|
|
|
function formatRelativeTime(dateStr: string): string {
|
|
const diff = Date.now() - new Date(dateStr).getTime()
|
|
const minutes = Math.floor(diff / 60_000)
|
|
if (minutes < 1) return "à l'instant"
|
|
if (minutes < 60) return `il y a ${minutes} min`
|
|
const hours = Math.floor(minutes / 60)
|
|
if (hours < 24) return `il y a ${hours}h`
|
|
const days = Math.floor(hours / 24)
|
|
return `il y a ${days}j`
|
|
}
|
|
|
|
export default function NotificationBell() {
|
|
const [open, setOpen] = useState(false)
|
|
const [notifications, setNotifications] = useState<Notification[]>([])
|
|
const [loading, setLoading] = useState(true)
|
|
const ref = useRef<HTMLDivElement>(null)
|
|
|
|
const unreadCount = notifications.filter((n) => !n.read).length
|
|
|
|
// ── Fetch initial ────────────────────────────────────────────────────────
|
|
const fetchNotifications = useCallback(async () => {
|
|
try {
|
|
const res = await fetch('/api/notifications')
|
|
if (!res.ok) return
|
|
const data = await res.json()
|
|
setNotifications(data.notifications ?? [])
|
|
} catch {
|
|
// silencieux — la cloche reste vide plutôt que de crasher
|
|
} finally {
|
|
setLoading(false)
|
|
}
|
|
}, [])
|
|
|
|
useEffect(() => {
|
|
fetchNotifications()
|
|
}, [fetchNotifications])
|
|
|
|
// ── Supabase Realtime — nouvelles notifs en temps réel ───────────────────
|
|
useEffect(() => {
|
|
const supabase = createClient()
|
|
|
|
const channel = supabase
|
|
.channel('notifications-realtime')
|
|
.on(
|
|
'postgres_changes',
|
|
{ event: 'INSERT', schema: 'public', table: 'notifications' },
|
|
(payload) => {
|
|
const newNotif = payload.new as Notification
|
|
setNotifications((prev) => [newNotif, ...prev].slice(0, 30))
|
|
}
|
|
)
|
|
.subscribe()
|
|
|
|
return () => {
|
|
supabase.removeChannel(channel)
|
|
}
|
|
}, [])
|
|
|
|
// ── Fermer le panneau au clic extérieur ──────────────────────────────────
|
|
useEffect(() => {
|
|
const handleClick = (e: MouseEvent) => {
|
|
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false)
|
|
}
|
|
document.addEventListener('mousedown', handleClick)
|
|
return () => document.removeEventListener('mousedown', handleClick)
|
|
}, [])
|
|
|
|
// ── Marquer comme lu ─────────────────────────────────────────────────────
|
|
const markRead = async (id: string) => {
|
|
setNotifications((prev) => prev.map((n) => (n.id === id ? { ...n, read: true } : n)))
|
|
await fetch('/api/notifications', {
|
|
method: 'PATCH',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ ids: [id] }),
|
|
})
|
|
}
|
|
|
|
const markAllRead = async () => {
|
|
setNotifications((prev) => prev.map((n) => ({ ...n, read: true })))
|
|
await fetch('/api/notifications', {
|
|
method: 'PATCH',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ all: true }),
|
|
})
|
|
}
|
|
|
|
return (
|
|
<div ref={ref} className="relative">
|
|
<button
|
|
onClick={() => setOpen((v) => !v)}
|
|
className="relative p-2.5 bg-background-card border border-border rounded-lg hover:border-border-light transition-colors"
|
|
aria-label="Notifications"
|
|
>
|
|
<Bell size={18} className="text-text-secondary" />
|
|
{unreadCount > 0 && (
|
|
<span className="absolute top-1.5 right-1.5 w-2 h-2 bg-primary rounded-full animate-pulse" />
|
|
)}
|
|
</button>
|
|
|
|
{open && (
|
|
<div className="absolute right-0 top-12 w-80 bg-background-card border border-border rounded-xl shadow-2xl shadow-black/50 z-50 overflow-hidden animate-in fade-in slide-in-from-top-2 duration-150">
|
|
{/* En-tête */}
|
|
<div className="flex items-center justify-between px-4 py-3 border-b border-border">
|
|
<div className="flex items-center gap-2">
|
|
<h3 className="font-semibold text-text-primary text-sm">Notifications</h3>
|
|
{unreadCount > 0 && (
|
|
<span className="bg-primary/20 text-primary text-xs px-2 py-0.5 rounded-full font-medium">
|
|
{unreadCount}
|
|
</span>
|
|
)}
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
{unreadCount > 0 && (
|
|
<button
|
|
onClick={markAllRead}
|
|
className="text-xs text-primary hover:text-primary-light transition-colors flex items-center gap-1"
|
|
>
|
|
<Check size={11} /> Tout lire
|
|
</button>
|
|
)}
|
|
<button
|
|
onClick={() => setOpen(false)}
|
|
className="p-1 hover:bg-background-elevated rounded transition-colors"
|
|
>
|
|
<X size={14} className="text-text-muted" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Liste */}
|
|
<div className="max-h-80 overflow-y-auto">
|
|
{loading ? (
|
|
<div className="flex items-center justify-center py-8">
|
|
<Loader2 size={20} className="animate-spin text-text-muted" />
|
|
</div>
|
|
) : notifications.length === 0 ? (
|
|
<div className="py-10 text-center">
|
|
<Bell size={28} className="mx-auto text-text-muted/40 mb-2" />
|
|
<p className="text-sm text-text-muted">Aucune notification</p>
|
|
</div>
|
|
) : (
|
|
notifications.map((notif) => (
|
|
<div
|
|
key={notif.id}
|
|
onClick={() => !notif.read && markRead(notif.id)}
|
|
className={cn(
|
|
'flex items-start gap-3 px-4 py-3 hover:bg-background-elevated/50 transition-colors border-b border-border/50 last:border-0',
|
|
!notif.read ? 'bg-primary/5 cursor-pointer' : 'cursor-default'
|
|
)}
|
|
>
|
|
<div className="w-8 h-8 bg-background-elevated rounded-lg flex items-center justify-center text-sm flex-shrink-0">
|
|
{typeIcon[notif.type] ?? '🔔'}
|
|
</div>
|
|
<div className="flex-1 min-w-0">
|
|
<div className="flex items-center justify-between gap-2 mb-0.5">
|
|
<p className="text-sm font-medium text-text-primary truncate">{notif.title}</p>
|
|
{!notif.read && <div className="w-1.5 h-1.5 bg-primary rounded-full flex-shrink-0" />}
|
|
</div>
|
|
<p className="text-xs text-text-muted leading-relaxed">{notif.body}</p>
|
|
<p className="text-xs text-text-muted/50 mt-1">{formatRelativeTime(notif.created_at)}</p>
|
|
</div>
|
|
</div>
|
|
))
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|