Skip to content

Commit abafd11

Browse files
feat: build interactive animated notification panel dropdown
1 parent eb8d58f commit abafd11

2 files changed

Lines changed: 202 additions & 5 deletions

File tree

src/components/layout/Header.tsx

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
1-
import { Bell, Search, LogOut, Terminal } from 'lucide-react';
1+
import { Search, LogOut, Terminal } from 'lucide-react';
22
import { useAuth } from '@/contexts/AuthContext';
33
import { useSearch } from '@/contexts/SearchContext';
4+
import { NotificationsPanel } from '@/components/layout/NotificationsPanel';
45

56
export function Header() {
67
const { user, logout } = useAuth();
@@ -32,10 +33,7 @@ export function Header() {
3233
</div>
3334

3435
<div className="flex items-center gap-8">
35-
<button className="w-12 h-12 border-2 border-black flex items-center justify-center hover:bg-black hover:text-white transition-all relative">
36-
<Bell size={20} strokeWidth={3} />
37-
<span className="absolute top-1 right-1 w-3 h-3 bg-yellow-400 border-2 border-black" />
38-
</button>
36+
<NotificationsPanel />
3937

4038
<div className="h-10 w-1 bg-black mx-2 hidden md:block" />
4139

Lines changed: 199 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,199 @@
1+
import { useState, useRef, useEffect } from 'react';
2+
import { motion, AnimatePresence } from 'framer-motion';
3+
import { Bell, Terminal, GitMerge, Star, Activity, CheckCircle2 } from 'lucide-react';
4+
import { cn } from '@/lib/utils';
5+
6+
interface Notification {
7+
id: string;
8+
title: string;
9+
description: string;
10+
time: string;
11+
isRead: boolean;
12+
type: 'system' | 'github' | 'alert' | 'success';
13+
}
14+
15+
const MOCK_NOTIFICATIONS: Notification[] = [
16+
{
17+
id: '1',
18+
title: 'System_Sync_Complete',
19+
description: 'Successfully mapped 12 new repositories to your DevSignal profile.',
20+
time: '2M_AGO',
21+
isRead: false,
22+
type: 'system'
23+
},
24+
{
25+
id: '2',
26+
title: 'Pull_Request_Merged',
27+
description: 'Your PR #42 in tarunyaio/DevSignal was merged into main.',
28+
time: '1H_AGO',
29+
isRead: false,
30+
type: 'github'
31+
},
32+
{
33+
id: '3',
34+
title: 'New_Star_Received',
35+
description: 'tarunyaio/Photon gained a new star.',
36+
time: '3H_AGO',
37+
isRead: true,
38+
type: 'success'
39+
},
40+
{
41+
id: '4',
42+
title: 'Anomaly_Detected',
43+
description: 'High iteration depth noticed in local branch. Consider pushing.',
44+
time: '1D_AGO',
45+
isRead: true,
46+
type: 'alert'
47+
}
48+
];
49+
50+
export function NotificationsPanel() {
51+
const [isOpen, setIsOpen] = useState(false);
52+
const [notifications, setNotifications] = useState<Notification[]>(MOCK_NOTIFICATIONS);
53+
const panelRef = useRef<HTMLDivElement>(null);
54+
55+
const unreadCount = notifications.filter(n => !n.isRead).length;
56+
57+
useEffect(() => {
58+
function handleClickOutside(event: MouseEvent) {
59+
if (panelRef.current && !panelRef.current.contains(event.target as Node)) {
60+
setIsOpen(false);
61+
}
62+
}
63+
document.addEventListener('mousedown', handleClickOutside);
64+
return () => document.removeEventListener('mousedown', handleClickOutside);
65+
}, []);
66+
67+
const markAllRead = () => {
68+
setNotifications(prev => prev.map(n => ({ ...n, isRead: true })));
69+
};
70+
71+
const markAsRead = (id: string) => {
72+
setNotifications(prev => prev.map(n => n.id === id ? { ...n, isRead: true } : n));
73+
};
74+
75+
const getIcon = (type: string) => {
76+
switch (type) {
77+
case 'system': return <Terminal size={16} className="text-emerald-500" />;
78+
case 'github': return <GitMerge size={16} className="text-violet-500" />;
79+
case 'success': return <Star size={16} className="text-amber-500" />;
80+
case 'alert': return <Activity size={16} className="text-rose-500" />;
81+
default: return <Bell size={16} />;
82+
}
83+
};
84+
85+
return (
86+
<div className="relative" ref={panelRef}>
87+
{/* Bell Button */}
88+
<button
89+
onClick={() => setIsOpen(!isOpen)}
90+
className={cn(
91+
"w-12 h-12 border-2 border-black flex items-center justify-center transition-all relative z-50",
92+
isOpen ? "bg-black text-white" : "hover:bg-black hover:text-white bg-white text-black"
93+
)}
94+
>
95+
<motion.div
96+
animate={unreadCount > 0 ? { rotate: [0, -10, 10, -10, 10, 0] } : {}}
97+
transition={{ duration: 0.5, delay: 2, repeat: Infinity, repeatDelay: 5 }}
98+
>
99+
<Bell size={20} strokeWidth={3} />
100+
</motion.div>
101+
102+
{unreadCount > 0 && (
103+
<span className="absolute top-1 right-1 w-3 h-3 bg-yellow-400 border-2 border-black" />
104+
)}
105+
</button>
106+
107+
{/* Dropdown Panel */}
108+
<AnimatePresence>
109+
{isOpen && (
110+
<motion.div
111+
initial={{ opacity: 0, y: 10, scale: 0.95 }}
112+
animate={{ opacity: 1, y: 0, scale: 1 }}
113+
exit={{ opacity: 0, scale: 0.95, pointerEvents: 'none' }}
114+
transition={{ duration: 0.2, ease: "easeOut" }}
115+
className="absolute top-full right-0 mt-4 w-[380px] bg-white border-4 border-black shadow-[8px_8px_0px_0px_rgba(0,0,0,1)] z-50"
116+
>
117+
{/* Header */}
118+
<div className="p-6 border-b-4 border-black flex items-center justify-between bg-zinc-50">
119+
<h3 className="font-black uppercase tracking-tighter text-xl italic flex items-center gap-3">
120+
<Activity size={20} className="text-accent-indigo" />
121+
ACTIVITY_LOG
122+
</h3>
123+
{unreadCount > 0 && (
124+
<button
125+
onClick={markAllRead}
126+
className="text-[8px] font-black uppercase tracking-[0.2em] text-zinc-500 hover:text-black hover:underline transition-all"
127+
>
128+
MARK_ALL_READ
129+
</button>
130+
)}
131+
</div>
132+
133+
{/* List */}
134+
<div className="max-h-[400px] overflow-y-auto no-scrollbar bg-white">
135+
{notifications.length > 0 ? (
136+
<div className="flex flex-col">
137+
{notifications.map((notif, index) => (
138+
<motion.div
139+
key={notif.id}
140+
initial={{ opacity: 0, x: -20 }}
141+
animate={{ opacity: 1, x: 0 }}
142+
transition={{ delay: index * 0.05 }}
143+
onClick={() => markAsRead(notif.id)}
144+
className={cn(
145+
"group cursor-pointer p-6 border-b-2 border-black last:border-b-0 hover:bg-black transition-colors relative overflow-hidden",
146+
notif.isRead ? "opacity-70" : ""
147+
)}
148+
>
149+
<div className="relative z-10 flex gap-4">
150+
<div className="mt-1 flex-shrink-0 w-8 h-8 border-2 border-black bg-white flex items-center justify-center group-hover:border-transparent group-hover:bg-zinc-800 transition-all">
151+
{getIcon(notif.type)}
152+
</div>
153+
<div className="flex-1">
154+
<div className="flex items-center justify-between mb-1">
155+
<h4 className={cn(
156+
"font-black text-xs uppercase tracking-widest transition-colors",
157+
"group-hover:text-white",
158+
!notif.isRead ? "text-black" : "text-zinc-600"
159+
)}>
160+
{notif.title}
161+
</h4>
162+
<span className="text-[8px] font-black text-zinc-400 uppercase tracking-widest group-hover:text-zinc-500">
163+
{notif.time}
164+
</span>
165+
</div>
166+
<p className="text-xs text-zinc-500 font-bold group-hover:text-zinc-400">
167+
{notif.description}
168+
</p>
169+
</div>
170+
</div>
171+
172+
{/* Unread dot */}
173+
{!notif.isRead && (
174+
<div className="absolute top-1/2 -translate-y-1/2 right-6 w-2 h-2 bg-yellow-400 border border-black rounded-full" />
175+
)}
176+
</motion.div>
177+
))}
178+
</div>
179+
) : (
180+
<div className="p-12 text-center flex flex-col items-center justify-center text-zinc-400">
181+
<CheckCircle2 size={32} className="mb-4 opacity-20" />
182+
<p className="font-black text-xs uppercase tracking-widest">SYSTEM_CLEAR</p>
183+
<p className="text-[10px] font-bold mt-2 tracking-widest">NO NEW LOGS FOUND.</p>
184+
</div>
185+
)}
186+
</div>
187+
188+
{/* Footer */}
189+
<div className="p-4 border-t-4 border-black bg-zinc-50 text-center">
190+
<button className="text-[10px] font-black uppercase tracking-[0.2em] text-accent-indigo hover:text-black transition-colors underline decoration-2 underline-offset-4">
191+
VIEW_FULL_ARCHIVE
192+
</button>
193+
</div>
194+
</motion.div>
195+
)}
196+
</AnimatePresence>
197+
</div>
198+
);
199+
}

0 commit comments

Comments
 (0)