JavaScript 56.7%
TypeScript 42.6%
1"use client";23import { useState, useEffect, useRef, useCallback } from "react";4import { Bell, Check, CheckCheck, Upload, Share2, MessageCircle, Sparkles, Database, X, BellOff, type LucideIcon } from "lucide-react";5import { motion, AnimatePresence } from "framer-motion";67interface NotificationData {8 id: string;9 type: string;10 title: string;11 message: string;12 read: boolean;13 data: string | null;14 createdAt: string;15}1617const typeIcons: Record<string, LucideIcon> = {18 upload_complete: Upload,19 share: Share2,20 comment: MessageCircle,21 ai_complete: Sparkles,22 backup_complete: Database,23};2425const typeTones: Record<string, string> = {26 upload_complete: "text-emerald-600 bg-emerald-500/10 dark:text-emerald-300",27 share: "text-purple-600 bg-purple-500/10 dark:text-purple-300",28 comment: "text-blue-600 bg-blue-500/10 dark:text-blue-300",29 ai_complete: "text-blue-600 bg-blue-500/10 dark:text-blue-300",30 backup_complete: "text-cyan-600 bg-cyan-500/10 dark:text-cyan-300",31};3233function timeAgo(date: string): string {34 const seconds = Math.floor((Date.now() - new Date(date).getTime()) / 1000);35 if (seconds < 60) return "à l'instant";36 const minutes = Math.floor(seconds / 60);37 if (minutes < 60) return `il y a ${minutes} min`;38 const hours = Math.floor(minutes / 60);39 if (hours < 24) return `il y a ${hours} h`;40 const days = Math.floor(hours / 24);41 if (days < 30) return `il y a ${days} j`;42 const months = Math.floor(days / 30);43 return `il y a ${months} mois`;44}4546export default function NotificationBell() {47 const [notifications, setNotifications] = useState<NotificationData[]>([]);48 const [unreadCount, setUnreadCount] = useState(0);49 const [open, setOpen] = useState(false);50 const [loaded, setLoaded] = useState(false);51 const ref = useRef<HTMLDivElement>(null);5253 const fetchCount = useCallback(async () => {54 try {55 const res = await fetch("/api/notifications?count=true");56 if (res.ok) {57 const data = await res.json();58 setUnreadCount(data.count);59 }60 } catch {61 // silencieux62 }63 }, []);6465 const fetchNotifications = useCallback(async () => {66 try {67 const res = await fetch("/api/notifications");68 if (res.ok) {69 const data = await res.json();70 if (Array.isArray(data)) {71 setNotifications(data);72 setUnreadCount(data.filter((n: NotificationData) => !n.read).length);73 }74 }75 } catch {76 // silencieux77 } finally {78 setLoaded(true);79 }80 }, []);8182 // Sondage toutes les 15 s83 useEffect(() => {84 fetchCount();85 const interval = setInterval(fetchCount, 15000);86 return () => clearInterval(interval);87 }, [fetchCount]);8889 useEffect(() => {90 if (!open) return;91 fetchNotifications();92 const interval = setInterval(fetchNotifications, 15000);93 return () => clearInterval(interval);94 }, [open, fetchNotifications]);9596 // Fermer au clic extérieur / Échap97 useEffect(() => {98 if (!open) return;99 const onClick = (e: MouseEvent) => {100 if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);101 };102 const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") setOpen(false); };103 document.addEventListener("mousedown", onClick);104 window.addEventListener("keydown", onKey);105 return () => {106 document.removeEventListener("mousedown", onClick);107 window.removeEventListener("keydown", onKey);108 };109 }, [open]);110111 const markAllRead = async () => {112 await fetch("/api/notifications", {113 method: "POST",114 headers: { "Content-Type": "application/json" },115 body: JSON.stringify({ action: "mark_all_read" }),116 });117 setUnreadCount(0);118 setNotifications((prev) => prev.map((n) => ({ ...n, read: true })));119 };120121 const markRead = async (id: string) => {122 await fetch(`/api/notifications/${id}`, { method: "PATCH" });123 setNotifications((prev) => prev.map((n) => (n.id === id ? { ...n, read: true } : n)));124 setUnreadCount((prev) => Math.max(0, prev - 1));125 };126127 return (128 <div className="relative" ref={ref}>129 <button130 onClick={() => setOpen(!open)}131 className={`relative w-10 h-10 flex items-center justify-center rounded-xl transition-colors ${open ? "text-gray-900 bg-gray-100 dark:text-white dark:bg-white/10" : "text-gray-500 hover:text-gray-900 hover:bg-gray-100 dark:text-gray-400 dark:hover:text-white dark:hover:bg-white/10"}`}132 aria-label={unreadCount > 0 ? `Notifications, ${unreadCount} non lue${unreadCount > 1 ? "s" : ""}` : "Notifications"}133 aria-expanded={open}134 aria-haspopup="dialog"135 >136 <Bell className="w-[18px] h-[18px]" />137 <AnimatePresence>138 {unreadCount > 0 && (139 <motion.span140 initial={{ scale: 0 }}141 animate={{ scale: 1 }}142 exit={{ scale: 0 }}143 transition={{ type: "spring", stiffness: 500, damping: 28 }}144 className="absolute top-1 right-1 min-w-[17px] h-[17px] px-1 rounded-full bg-accent-gradient text-white text-[10px] font-semibold flex items-center justify-center ring-2 ring-surface dark:ring-canvas tabular-nums"145 >146 {unreadCount > 99 ? "99+" : unreadCount}147 </motion.span>148 )}149 </AnimatePresence>150 </button>151152 <AnimatePresence>153 {open && (154 <>155 {/* Voile mobile */}156 <motion.div157 initial={{ opacity: 0 }}158 animate={{ opacity: 1 }}159 exit={{ opacity: 0 }}160 className="sm:hidden fixed inset-0 z-[55] bg-gray-950/40 backdrop-blur-sm"161 onClick={() => setOpen(false)}162 />163 <motion.div164 initial={{ opacity: 0, y: 8, scale: 0.98 }}165 animate={{ opacity: 1, y: 0, scale: 1 }}166 exit={{ opacity: 0, y: 8, scale: 0.98 }}167 transition={{ type: "spring", stiffness: 420, damping: 34 }}168 role="dialog"169 aria-label="Notifications"170 className="fixed inset-x-0 bottom-0 sm:bottom-auto sm:absolute sm:inset-x-auto sm:top-full sm:right-0 sm:mt-2 sm:w-[360px] glass rounded-t-3xl sm:rounded-2xl shadow-pop z-[60] overflow-hidden pb-safe sm:pb-0"171 >172 {/* Poignée mobile */}173 <div className="sm:hidden flex justify-center pt-2.5 pb-0.5">174 <span className="w-10 h-1 rounded-full bg-gray-300 dark:bg-white/20" />175 </div>176177 {/* En-tête */}178 <div className="flex items-center justify-between pl-4 pr-2 h-12 border-b border-line dark:border-white/10">179 <div className="flex items-center gap-2">180 <h3 className="text-[14px] font-semibold text-gray-900 dark:text-white">Notifications</h3>181 {unreadCount > 0 && (182 <span className="px-1.5 h-5 min-w-[20px] rounded-full bg-blue-500/10 text-blue-600 dark:text-blue-300 text-[11px] font-semibold flex items-center justify-center tabular-nums">{unreadCount}</span>183 )}184 </div>185 <div className="flex items-center gap-0.5">186 {unreadCount > 0 && (187 <button188 onClick={markAllRead}189 className="h-9 px-2.5 rounded-lg text-[12px] font-medium text-blue-600 dark:text-blue-300 hover:bg-blue-500/10 flex items-center gap-1.5 transition-colors"190 >191 <CheckCheck className="w-3.5 h-3.5" />192 Tout lire193 </button>194 )}195 <button196 onClick={() => setOpen(false)}197 className="sm:hidden w-9 h-9 flex items-center justify-center rounded-lg text-gray-400 hover:text-gray-900 dark:hover:text-white hover:bg-gray-100 dark:hover:bg-white/10"198 aria-label="Fermer"199 >200 <X className="w-4 h-4" />201 </button>202 </div>203 </div>204205 {/* Liste */}206 <div className="max-h-[65dvh] sm:max-h-[420px] overflow-y-auto scrollbar-thin">207 {!loaded ? (208 <div className="p-3 space-y-2" aria-busy="true">209 {[...Array(3)].map((_, i) => (210 <div key={i} className="flex items-start gap-3 p-2">211 <span className="w-9 h-9 rounded-xl bg-gray-200/80 dark:bg-white/[0.07] animate-pulse shrink-0" />212 <span className="flex-1 space-y-2 pt-1">213 <span className="block h-3 w-2/3 rounded bg-gray-200/80 dark:bg-white/[0.07] animate-pulse" />214 <span className="block h-2.5 w-1/2 rounded bg-gray-200/80 dark:bg-white/[0.07] animate-pulse" />215 </span>216 </div>217 ))}218 </div>219 ) : notifications.length === 0 ? (220 <div className="flex flex-col items-center justify-center py-12 px-6 text-center">221 <div className="relative mb-4">222 <div className="absolute inset-0 rounded-full bg-blue-500/10 blur-2xl scale-150" />223 <div className="relative w-14 h-14 rounded-3xl bg-gray-100 dark:bg-white/5 border border-line dark:border-white/10 flex items-center justify-center">224 <BellOff className="w-6 h-6 text-gray-400" />225 </div>226 </div>227 <p className="text-[14px] font-medium text-gray-800 dark:text-gray-100">Tout est calme</p>228 <p className="text-[12.5px] text-gray-500 dark:text-gray-400 mt-1">Les téléversements, partages et analyses IA terminés apparaîtront ici.</p>229 </div>230 ) : (231 <ul className="p-1.5" role="list">232 {notifications.map((n, i) => {233 const Icon = typeIcons[n.type] || Bell;234 const tone = typeTones[n.type] || "text-gray-500 bg-gray-500/10 dark:text-gray-300";235 return (236 <motion.li237 key={n.id}238 initial={{ opacity: 0, x: -8 }}239 animate={{ opacity: 1, x: 0 }}240 transition={{ delay: Math.min(i, 10) * 0.025 }}241 >242 <div243 role="button"244 tabIndex={0}245 onClick={() => { if (!n.read) markRead(n.id); }}246 onKeyDown={(e) => { if ((e.key === "Enter" || e.key === " ") && !n.read) { e.preventDefault(); markRead(n.id); } }}247 className={`relative flex items-start gap-3 p-2.5 rounded-xl transition-colors cursor-pointer ${n.read ? "hover:bg-gray-100/70 dark:hover:bg-white/[0.04]" : "bg-blue-500/[0.06] hover:bg-blue-500/10"}`}248 >249 <span className={`w-9 h-9 rounded-xl flex items-center justify-center shrink-0 ${tone}`}>250 <Icon className="w-4 h-4" />251 </span>252 <div className="flex-1 min-w-0">253 <div className="flex items-center gap-2">254 <p className={`text-[13px] font-medium truncate ${n.read ? "text-gray-600 dark:text-gray-300" : "text-gray-900 dark:text-white"}`}>{n.title}</p>255 {!n.read && <span className="w-1.5 h-1.5 rounded-full bg-blue-500 shrink-0" aria-label="Non lue" />}256 </div>257 <p className="text-[12px] text-gray-500 dark:text-gray-400 mt-0.5 line-clamp-2 leading-snug">{n.message}</p>258 <p className="text-[11px] text-gray-400 dark:text-gray-500 mt-1 tabular-nums">{timeAgo(n.createdAt)}</p>259 </div>260 {!n.read && (261 <button262 onClick={(e) => { e.stopPropagation(); markRead(n.id); }}263 className="w-8 h-8 -mr-1 flex items-center justify-center rounded-lg text-gray-400 hover:text-emerald-600 dark:hover:text-emerald-300 hover:bg-emerald-500/10 transition-colors shrink-0"264 title="Marquer comme lue"265 aria-label="Marquer comme lue"266 >267 <Check className="w-3.5 h-3.5" />268 </button>269 )}270 </div>271 </motion.li>272 );273 })}274 </ul>275 )}276 </div>277 </motion.div>278 </>279 )}280 </AnimatePresence>281 </div>282 );283}284