SPB Git forge

spb/spb-cloud

Public
10commits 1branches 0releases
3.2 MBsize
maindefault branch
2 h agolast push
JavaScript 56.7% TypeScript 42.6%
12.7 KB · 284 lines tsx
Raw Blame History
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