| // src/components/notifications/SmartNotificationCenter.tsx | |
| import { useState, useMemo } from "react"; | |
| import { useNavigate } from "react-router-dom"; | |
| import { useSmartNotifications } from "@/hooks/useSmartNotifications"; | |
| import { | |
| Bell, BellOff, Zap, Clock, Settings, X, CheckCheck, | |
| AlertTriangle, MessageSquare, Heart, UserPlus, FileText, | |
| Calendar, Filter, ChevronDown, Shield, Brain, | |
| } from "lucide-react"; | |
| import { motion, AnimatePresence } from "framer-motion"; | |
| export function SmartNotificationCenter() { | |
| const navigate = useNavigate(); | |
| const { | |
| notifications, batches, currentContext, settings, | |
| unreadCount, criticalCount, batchedCount, | |
| showReviewPanel, setShowReviewPanel, | |
| showSettings, setShowSettings, | |
| acknowledgeNotification, clearAll, updateSettings, | |
| } = useSmartNotifications(); | |
| const [filterPriority, setFilterPriority] = useState<string>("all"); | |
| const [showContextInfo, setShowContextInfo] = useState(false); | |
| const filtered = useMemo(() => { | |
| if (filterPriority === "all") return notifications; | |
| return notifications.filter(n => n.priority === filterPriority); | |
| }, [notifications, filterPriority]); | |
| const contextColors: Record<string, string> = { | |
| deep_work: "bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-400", | |
| focus: "bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-400", | |
| available: "bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400", | |
| idle: "bg-gray-100 dark:bg-gray-700 text-gray-500", | |
| meeting: "bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-400", | |
| }; | |
| const contextIcons: Record<string, string> = { | |
| deep_work: "π§ ", | |
| focus: "π―", | |
| available: "β ", | |
| idle: "π΄", | |
| meeting: "π ", | |
| }; | |
| return ( | |
| <> | |
| {/* ββ Trigger Button ββββββββββββββββββββββββββββββββ */} | |
| <button | |
| onClick={() => setShowReviewPanel(!showReviewPanel)} | |
| className="relative p-2 text-gray-500 hover:text-primary hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition" | |
| title="Notifications" | |
| > | |
| <Bell className="w-5 h-5" /> | |
| {unreadCount > 0 && ( | |
| <span className="absolute -top-0.5 -right-0.5 min-w-[18px] h-[18px] bg-red-500 text-white text-[10px] font-bold rounded-full flex items-center justify-center px-1"> | |
| {unreadCount > 99 ? "99+" : unreadCount} | |
| </span> | |
| )} | |
| {criticalCount > 0 && ( | |
| <span className="absolute -bottom-0.5 -left-0.5 w-2.5 h-2.5 bg-amber-500 rounded-full border-2 border-white dark:border-gray-900" /> | |
| )} | |
| </button> | |
| {/* ββ Panel ββββββββββββββββββββββββββββββββββββββββββ */} | |
| <AnimatePresence> | |
| {showReviewPanel && ( | |
| <motion.div | |
| initial={{ opacity: 0, x: 300 }} | |
| animate={{ opacity: 1, x: 0 }} | |
| exit={{ opacity: 0, x: 300 }} | |
| className="fixed top-16 right-4 z-50 w-96 bg-white dark:bg-gray-900 rounded-2xl shadow-2xl border border-gray-200 dark:border-gray-800 overflow-hidden max-h-[80vh] flex flex-col" | |
| > | |
| {/* Header */} | |
| <div className="p-4 border-b border-gray-100 dark:border-gray-800"> | |
| <div className="flex items-center justify-between mb-3"> | |
| <h3 className="font-bold text-lg flex items-center gap-2"> | |
| <Bell className="w-5 h-5 text-primary" /> | |
| Notifications | |
| </h3> | |
| <div className="flex items-center gap-1"> | |
| {unreadCount > 0 && ( | |
| <button onClick={clearAll} className="p-1.5 text-gray-400 hover:text-primary rounded-lg" title="Mark all read"> | |
| <CheckCheck className="w-4 h-4" /> | |
| </button> | |
| )} | |
| <button onClick={() => setShowSettings(true)} className="p-1.5 text-gray-400 hover:text-primary rounded-lg" title="Settings"> | |
| <Settings className="w-4 h-4" /> | |
| </button> | |
| <button onClick={() => setShowReviewPanel(false)} className="p-1.5 text-gray-400 hover:text-gray-600 rounded-lg"> | |
| <X className="w-4 h-4" /> | |
| </button> | |
| </div> | |
| </div> | |
| {/* Context badge */} | |
| <button | |
| onClick={() => setShowContextInfo(!showContextInfo)} | |
| className={`flex items-center gap-2 px-3 py-1.5 rounded-full text-xs font-medium ${contextColors[currentContext] ?? "bg-gray-100 text-gray-600"}`} | |
| > | |
| <span>{contextIcons[currentContext] ?? "π¬"}</span> | |
| <span className="capitalize">{currentContext.replace(/_/g, " ")}</span> | |
| <span className="text-[10px] opacity-60"> | |
| Β· {currentContext === "deep_work" ? "Notifications batched" : currentContext === "available" ? "All notifications on" : "Non-critical batched"} | |
| </span> | |
| </button> | |
| {showContextInfo && ( | |
| <div className="mt-2 p-3 bg-gray-50 dark:bg-gray-800 rounded-xl text-xs text-gray-500"> | |
| <p className="flex items-center gap-1"><Brain className="w-3 h-3 text-primary"/> AI detects your context to filter notifications:</p> | |
| <ul className="mt-1.5 space-y-1 ml-5 list-disc"> | |
| <li><strong>Deep Work</strong>: Only critical alerts come through</li> | |
| <li><strong>Focus</strong>: Nonβurgent notifications are batched</li> | |
| <li><strong>Available</strong>: All notifications delivered immediately</li> | |
| </ul> | |
| </div> | |
| )} | |
| </div> | |
| {/* Filter pills */} | |
| <div className="px-4 py-2 border-b border-gray-50 dark:border-gray-800 flex gap-1.5 overflow-x-auto"> | |
| {[ | |
| { key: "all", label: "All", count: notifications.length }, | |
| { key: "critical", label: "Critical", count: criticalCount }, | |
| { key: "high", label: "High", count: notifications.filter(n => n.priority === "high").length }, | |
| { key: "medium", label: "Medium", count: notifications.filter(n => n.priority === "medium").length }, | |
| ].map(f => ( | |
| <button | |
| key={f.key} | |
| onClick={() => setFilterPriority(f.key)} | |
| className={`px-3 py-1 rounded-full text-[10px] font-medium whitespace-nowrap transition ${ | |
| filterPriority === f.key ? "bg-primary text-white" : "bg-gray-100 dark:bg-gray-700 text-gray-500" | |
| }`} | |
| > | |
| {f.label} {f.count > 0 && `(${f.count})`} | |
| </button> | |
| ))} | |
| </div> | |
| {/* Notification list */} | |
| <div className="flex-1 overflow-y-auto"> | |
| {filtered.length === 0 ? ( | |
| <div className="py-12 text-center text-gray-400"> | |
| <BellOff className="w-10 h-10 mx-auto mb-3 opacity-30" /> | |
| <p className="text-sm">All caught up!</p> | |
| </div> | |
| ) : ( | |
| <div className="divide-y divide-gray-50 dark:divide-gray-800"> | |
| {filtered.map(notif => ( | |
| <div | |
| key={notif.id} | |
| onClick={() => { | |
| acknowledgeNotification(notif.id); | |
| if (notif.link) navigate(notif.link); | |
| }} | |
| className={`px-4 py-3 cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800/50 transition ${ | |
| !notif.acknowledged ? "bg-blue-50/50 dark:bg-blue-900/5" : "" | |
| }`} | |
| > | |
| <div className="flex items-start gap-3"> | |
| <span className="text-lg mt-0.5"> | |
| {{ message: "π¬", mention: "π’", follow: "π€", like: "β€οΈ", comment: "π", system: "βΉοΈ", alert: "β οΈ", reminder: "β°", grant_deadline: "π°", review_request: "β ", paper_published: "π" }[notif.type] ?? "π"} | |
| </span> | |
| <div className="flex-1 min-w-0"> | |
| <p className="text-sm font-semibold truncate">{notif.title}</p> | |
| <p className="text-xs text-gray-500 mt-0.5 line-clamp-2">{notif.body}</p> | |
| <div className="flex items-center gap-2 mt-1.5"> | |
| <span className={`text-[9px] px-1.5 py-0.5 rounded-full ${ | |
| notif.priority === "critical" ? "bg-red-100 dark:bg-red-900/30 text-red-700" : | |
| notif.priority === "high" ? "bg-amber-100 dark:bg-amber-900/30 text-amber-700" : | |
| "bg-gray-100 dark:bg-gray-700 text-gray-500" | |
| }`}> | |
| {notif.priority} | |
| </span> | |
| <span className="text-[9px] text-gray-400"> | |
| {new Date(notif.timestamp).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })} | |
| </span> | |
| {notif.deliveryStrategy === "batch" && ( | |
| <span className="text-[9px] text-primary">π¬ Batched</span> | |
| )} | |
| </div> | |
| </div> | |
| {!notif.acknowledged && <div className="w-2 h-2 rounded-full bg-blue-500 mt-1.5 shrink-0" />} | |
| </div> | |
| </div> | |
| ))} | |
| </div> | |
| )} | |
| </div> | |
| {/* Batched count footer */} | |
| {batchedCount > 0 && ( | |
| <div className="px-4 py-2 border-t border-gray-100 dark:border-gray-800 text-center"> | |
| <p className="text-xs text-gray-400"> | |
| <Clock className="w-3 h-3 inline mr-1" /> | |
| {batchedCount} notification{batchedCount !== 1 ? "s" : ""} scheduled for later delivery | |
| </p> | |
| </div> | |
| )} | |
| </motion.div> | |
| )} | |
| </AnimatePresence> | |
| {/* ββ Settings Modal ββββββββββββββββββββββββββββββββββ */} | |
| <AnimatePresence> | |
| {showSettings && ( | |
| <motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} | |
| className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4" onClick={() => setShowSettings(false)}> | |
| <motion.div initial={{ scale: 0.95 }} animate={{ scale: 1 }} className="bg-white dark:bg-gray-800 rounded-2xl max-w-md w-full p-6 shadow-xl" onClick={e => e.stopPropagation()}> | |
| <h2 className="text-lg font-bold mb-4 flex items-center gap-2"><Shield className="w-5 h-5 text-primary"/> Notification Settings</h2> | |
| <div className="space-y-4"> | |
| {/* Deep Work Mute */} | |
| <label className="flex items-center justify-between cursor-pointer"> | |
| <div> | |
| <p className="text-sm font-semibold">Deep Work Mode</p> | |
| <p className="text-xs text-gray-500">Mute all nonβcritical notifications during deep work</p> | |
| </div> | |
| <input type="checkbox" checked={settings.deepWorkMuteAll} | |
| onChange={e => updateSettings({ deepWorkMuteAll: e.target.checked })} | |
| className="rounded border-gray-300 text-primary focus:ring-primary" /> | |
| </label> | |
| {/* Batch interval */} | |
| <div> | |
| <label className="text-sm font-semibold block mb-1">Batch Delivery Interval</label> | |
| <select value={settings.batchIntervalMinutes} | |
| onChange={e => updateSettings({ batchIntervalMinutes: parseInt(e.target.value) })} | |
| className="w-full text-sm border rounded-lg px-3 py-2 dark:bg-gray-700"> | |
| <option value={15}>Every 15 minutes</option> | |
| <option value={30}>Every 30 minutes</option> | |
| <option value={60}>Every hour</option> | |
| <option value={120}>Every 2 hours</option> | |
| </select> | |
| </div> | |
| {/* Quiet hours */} | |
| <div className="grid grid-cols-2 gap-3"> | |
| <div> | |
| <label className="text-sm font-semibold block mb-1">Quiet Hours Start</label> | |
| <input type="time" value={settings.quietHoursStart} | |
| onChange={e => updateSettings({ quietHoursStart: e.target.value })} | |
| className="w-full text-sm border rounded-lg px-3 py-2 dark:bg-gray-700" /> | |
| </div> | |
| <div> | |
| <label className="text-sm font-semibold block mb-1">Quiet Hours End</label> | |
| <input type="time" value={settings.quietHoursEnd} | |
| onChange={e => updateSettings({ quietHoursEnd: e.target.value })} | |
| className="w-full text-sm border rounded-lg px-3 py-2 dark:bg-gray-700" /> | |
| </div> | |
| </div> | |
| </div> | |
| <button onClick={() => setShowSettings(false)} className="btn btn-secondary w-full mt-6">Done</button> | |
| </motion.div> | |
| </motion.div> | |
| )} | |
| </AnimatePresence> | |
| </> | |
| ); | |
| } | |
Xet Storage Details
- Size:
- 13.8 kB
- Xet hash:
- 9dd813e4dcc5a35d48115fa1cfa9528577e47acc60b8381da7f2543f2ad3fd90
Β·
Xet efficiently stores files, intelligently splitting them into unique chunks and accelerating uploads and downloads. More info.