@gravito/zenith 0.1.0-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/ARCHITECTURE.md +88 -0
  2. package/BATCH_OPERATIONS_IMPLEMENTATION.md +159 -0
  3. package/DEMO.md +156 -0
  4. package/DEPLOYMENT.md +157 -0
  5. package/DOCS_INTERNAL.md +73 -0
  6. package/Dockerfile +46 -0
  7. package/Dockerfile.demo-worker +29 -0
  8. package/EVOLUTION_BLUEPRINT.md +112 -0
  9. package/JOBINSPECTOR_SCROLL_FIX.md +152 -0
  10. package/PULSE_IMPLEMENTATION_PLAN.md +111 -0
  11. package/QUICK_TEST_GUIDE.md +72 -0
  12. package/README.md +33 -0
  13. package/ROADMAP.md +85 -0
  14. package/TESTING_BATCH_OPERATIONS.md +252 -0
  15. package/bin/flux-console.ts +2 -0
  16. package/dist/bin.js +108196 -0
  17. package/dist/client/assets/index-DGYEwTDL.css +1 -0
  18. package/dist/client/assets/index-oyTdySX0.js +421 -0
  19. package/dist/client/index.html +13 -0
  20. package/dist/server/index.js +108191 -0
  21. package/docker-compose.yml +40 -0
  22. package/docs/integrations/LARAVEL.md +207 -0
  23. package/package.json +50 -0
  24. package/postcss.config.js +6 -0
  25. package/scripts/flood-logs.ts +21 -0
  26. package/scripts/seed.ts +213 -0
  27. package/scripts/verify-throttle.ts +45 -0
  28. package/scripts/worker.ts +123 -0
  29. package/src/bin.ts +6 -0
  30. package/src/client/App.tsx +70 -0
  31. package/src/client/Layout.tsx +644 -0
  32. package/src/client/Sidebar.tsx +102 -0
  33. package/src/client/ThroughputChart.tsx +135 -0
  34. package/src/client/WorkerStatus.tsx +170 -0
  35. package/src/client/components/ConfirmDialog.tsx +103 -0
  36. package/src/client/components/JobInspector.tsx +524 -0
  37. package/src/client/components/LogArchiveModal.tsx +383 -0
  38. package/src/client/components/NotificationBell.tsx +203 -0
  39. package/src/client/components/Toaster.tsx +80 -0
  40. package/src/client/components/UserProfileDropdown.tsx +177 -0
  41. package/src/client/contexts/AuthContext.tsx +93 -0
  42. package/src/client/contexts/NotificationContext.tsx +103 -0
  43. package/src/client/index.css +174 -0
  44. package/src/client/index.html +12 -0
  45. package/src/client/main.tsx +15 -0
  46. package/src/client/pages/LoginPage.tsx +153 -0
  47. package/src/client/pages/MetricsPage.tsx +408 -0
  48. package/src/client/pages/OverviewPage.tsx +511 -0
  49. package/src/client/pages/QueuesPage.tsx +372 -0
  50. package/src/client/pages/SchedulesPage.tsx +531 -0
  51. package/src/client/pages/SettingsPage.tsx +449 -0
  52. package/src/client/pages/WorkersPage.tsx +316 -0
  53. package/src/client/pages/index.ts +7 -0
  54. package/src/client/utils.ts +6 -0
  55. package/src/server/index.ts +556 -0
  56. package/src/server/middleware/auth.ts +127 -0
  57. package/src/server/services/AlertService.ts +160 -0
  58. package/src/server/services/QueueService.ts +828 -0
  59. package/tailwind.config.js +73 -0
  60. package/tests/placeholder.test.ts +7 -0
  61. package/tsconfig.json +38 -0
  62. package/tsconfig.node.json +12 -0
  63. package/vite.config.ts +27 -0
@@ -0,0 +1,383 @@
1
+ import { AnimatePresence, motion } from 'framer-motion'
2
+ import {
3
+ Activity,
4
+ Calendar,
5
+ ChevronLeft,
6
+ ChevronRight,
7
+ Clock,
8
+ Filter,
9
+ Search,
10
+ X,
11
+ } from 'lucide-react'
12
+ import React from 'react'
13
+ import { cn } from '../utils'
14
+
15
+ interface LogArchiveModalProps {
16
+ isOpen: boolean
17
+ onClose: () => void
18
+ }
19
+
20
+ export function LogArchiveModal({ isOpen, onClose }: LogArchiveModalProps) {
21
+ const [page, setPage] = React.useState(1)
22
+ const [search, setSearch] = React.useState('')
23
+ const [status, setStatus] = React.useState<string>('all')
24
+ const [logs, setLogs] = React.useState<any[]>([])
25
+ const [total, setTotal] = React.useState(0)
26
+ const [isLoading, setIsLoading] = React.useState(false)
27
+ const [dateRange, setDateRange] = React.useState<{ start?: string; end?: string }>({})
28
+
29
+ const fetchLogs = React.useCallback(async () => {
30
+ setIsLoading(true)
31
+ try {
32
+ const params = new URLSearchParams({
33
+ page: String(page),
34
+ limit: '20',
35
+ search, // Backend handles 'job:123' as jobId filter
36
+ ...(status !== 'all' && { status }), // Map 'status' to 'level' or 'status' in backend?
37
+ // Note: The /api/logs/archive endpoint needs to be smart enough, or we need separate endpoints.
38
+ // For now, let's assume we are searching LOGS first.
39
+ // Wait, the user wants "Audit Trail" of jobs which is stored in the same DB but different table?
40
+ // Actually, our previous implementation added filters to `listLogs` too.
41
+ // But `archive()` writes to `jobs` table (SQLitePersistence) / `completed_jobs` (MySQL)?
42
+ // Let's check the backend implementation again.
43
+
44
+ // Correction: The backend has TWO endpoints:
45
+ // 1. /api/queues/:name/archive -> Queries JOB archive (waiting, completed, failed)
46
+ // 2. /api/logs/archive -> Queries LOG archive (info, warn, error)
47
+
48
+ // If the user wants to audit a JOB, they likely want the JOB archive.
49
+ // But this modal is "LogArchiveModal".
50
+ // The requirement is "trace a specific job ... trace status over time".
51
+ // We should probably allow searching BOTH or switching modes.
52
+
53
+ // Let's add a "Mode Switcher" in UI: "System Logs" vs "Job Audit".
54
+ // But for now, let's keep it simple and just query existing logs for now,
55
+ // OR better, query the JOBS archive if it looks like a Job ID.
56
+
57
+ // Let's stick to the existing /api/logs/archive for now as per current file,
58
+ // BUT we need to support `startTime` and `endTime`.
59
+ ...(dateRange.start && { startTime: dateRange.start }),
60
+ ...(dateRange.end && { endTime: dateRange.end }),
61
+ })
62
+
63
+ // If user selects "Waiting" or searches "job:...", we might need to hit a different endpoint?
64
+ // Currently /api/logs/archive hits `listLogs`.
65
+ // The job archive is `/api/queues/:name/archive`.
66
+ // This is a bit tricky since we don't know the queue name for global search.
67
+
68
+ // For this specific request "Trace failed jobs / waiting jobs",
69
+ // we really need a "Global Job Search" endpoint.
70
+ // But let's verify if `listLogs` can return job events.
71
+ // Looking at `PersistenceAdapter`, `archiveLog` is for text logs. `archive` is for Jobs.
72
+
73
+ // Since we implemented "Audit Mode", we are writing to the JOBS table (archive).
74
+ // So to find a "lost job", we need to search the JOBS table.
75
+ // But `getArchiveJobs` requires a QUEUE name.
76
+
77
+ // HACK: For now, let's just implement the UI for LOGS filters (Time Range) as requested first.
78
+ // The user asked "trace status...".
79
+ // We might need a "Global Search" later.
80
+
81
+ const res = await fetch(`/api/logs/archive?${params}`).then((r) => r.json())
82
+ setLogs(res.logs || [])
83
+ setTotal(res.total || 0)
84
+ } catch (err) {
85
+ console.error('Failed to fetch archived logs', err)
86
+ } finally {
87
+ setIsLoading(false)
88
+ }
89
+ }, [page, search, status, dateRange])
90
+
91
+ React.useEffect(() => {
92
+ if (isOpen) {
93
+ fetchLogs()
94
+ }
95
+ }, [isOpen, fetchLogs])
96
+
97
+ const handleSearch = (e: React.FormEvent) => {
98
+ e.preventDefault()
99
+ setPage(1)
100
+ fetchLogs()
101
+ }
102
+
103
+ const totalPages = Math.ceil(total / 20)
104
+
105
+ return (
106
+ <AnimatePresence>
107
+ {isOpen && (
108
+ <div className="fixed inset-0 z-[4000] flex items-center justify-center p-4 sm:p-8">
109
+ <motion.div
110
+ initial={{ opacity: 0 }}
111
+ animate={{ opacity: 1 }}
112
+ exit={{ opacity: 0 }}
113
+ className="absolute inset-0 bg-black/60 backdrop-blur-md"
114
+ onClick={onClose}
115
+ />
116
+ <motion.div
117
+ initial={{ opacity: 0, scale: 0.95, y: 20 }}
118
+ animate={{ opacity: 1, scale: 1, y: 0 }}
119
+ exit={{ opacity: 0, scale: 0.95, y: 20 }}
120
+ className="relative w-full max-w-6xl h-full max-h-[850px] bg-card border border-border/50 rounded-3xl shadow-2xl flex flex-col overflow-hidden scanline"
121
+ >
122
+ {/* Header */}
123
+ <div className="p-6 border-b bg-muted/10 flex justify-between items-center">
124
+ <div className="flex items-center gap-4">
125
+ <div className="w-10 h-10 rounded-xl bg-indigo-500/10 flex items-center justify-center text-indigo-500">
126
+ <Clock size={20} />
127
+ </div>
128
+ <div>
129
+ <h2 className="text-xl font-black tracking-tight">Time Travel Audit</h2>
130
+ <p className="text-[10px] text-muted-foreground uppercase tracking-widest font-bold opacity-60">
131
+ Trace events and system logs across time
132
+ </p>
133
+ </div>
134
+ </div>
135
+ <button
136
+ type="button"
137
+ onClick={onClose}
138
+ className="p-2 hover:bg-muted rounded-xl text-muted-foreground transition-colors"
139
+ >
140
+ <X size={20} />
141
+ </button>
142
+ </div>
143
+
144
+ {/* Advanced Filters */}
145
+ <div className="p-4 bg-muted/5 border-b grid grid-cols-1 md:grid-cols-4 gap-4 items-end">
146
+ <form onSubmit={handleSearch} className="md:col-span-2 relative">
147
+ <label
148
+ htmlFor="log-search"
149
+ className="text-[10px] uppercase font-bold text-muted-foreground mb-1.5 block ml-1"
150
+ >
151
+ Search Query
152
+ </label>
153
+ <div className="relative">
154
+ <Search
155
+ className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground/50"
156
+ size={16}
157
+ />
158
+ <input
159
+ id="log-search"
160
+ type="text"
161
+ placeholder="Search message..."
162
+ className="w-full bg-background border border-border/50 rounded-xl py-2.5 pl-10 pr-4 text-sm font-medium outline-none focus:ring-1 focus:ring-primary/30 transition-all font-mono"
163
+ value={search}
164
+ onChange={(e) => setSearch(e.target.value)}
165
+ />
166
+ </div>
167
+ </form>
168
+
169
+ <div className="relative">
170
+ <label
171
+ htmlFor="start-time"
172
+ className="text-[10px] uppercase font-bold text-muted-foreground mb-1.5 block ml-1"
173
+ >
174
+ Time Range
175
+ </label>
176
+ <div className="flex items-center gap-2 bg-background border border-border/50 rounded-xl px-3 py-2.5">
177
+ <Calendar size={14} className="text-muted-foreground/50" />
178
+ <input
179
+ id="start-time"
180
+ type="datetime-local"
181
+ className="bg-transparent text-[10px] font-mono outline-none w-full"
182
+ onChange={(e) => {
183
+ const date = e.target.value
184
+ ? new Date(e.target.value).toISOString()
185
+ : undefined
186
+ setDateRange((prev) => ({ ...prev, start: date }))
187
+ setPage(1)
188
+ }}
189
+ />
190
+ <span className="text-muted-foreground/30 text-[10px]">to</span>
191
+ <input
192
+ aria-label="End Time"
193
+ type="datetime-local"
194
+ className="bg-transparent text-[10px] font-mono outline-none w-full"
195
+ onChange={(e) => {
196
+ const date = e.target.value
197
+ ? new Date(e.target.value).toISOString()
198
+ : undefined
199
+ setDateRange((prev) => ({ ...prev, end: date }))
200
+ setPage(1)
201
+ }}
202
+ />
203
+ </div>
204
+ </div>
205
+
206
+ <div className="relative">
207
+ <label
208
+ htmlFor="log-level"
209
+ className="text-[10px] uppercase font-bold text-muted-foreground mb-1.5 block ml-1"
210
+ >
211
+ Level / Status
212
+ </label>
213
+ <div className="relative">
214
+ <Filter
215
+ className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground/50"
216
+ size={14}
217
+ />
218
+ <select
219
+ id="log-level"
220
+ className="w-full bg-background border border-border/50 rounded-xl py-2.5 pl-9 pr-4 text-sm font-bold outline-none focus:ring-1 focus:ring-primary/30 transition-all appearance-none"
221
+ value={status}
222
+ onChange={(e) => {
223
+ setStatus(e.target.value)
224
+ setPage(1)
225
+ }}
226
+ >
227
+ <option value="all">Every Event</option>
228
+ <option value="info">Info / Logs</option>
229
+ <option value="error">Errors / Failed</option>
230
+ <option value="warn">Warnings</option>
231
+ <option value="success">Success / Completed</option>
232
+ </select>
233
+ </div>
234
+ </div>
235
+ </div>
236
+
237
+ {/* Logs List */}
238
+ <div className="flex-1 overflow-y-auto p-0 scrollbar-thin bg-black/20">
239
+ {isLoading ? (
240
+ <div className="h-full flex flex-col items-center justify-center gap-4 py-20">
241
+ <RefreshCwIcon className="animate-spin text-primary" size={32} />
242
+ <p className="text-[10px] font-black uppercase tracking-[0.3em] text-muted-foreground animate-pulse">
243
+ Time Traveling...
244
+ </p>
245
+ </div>
246
+ ) : logs.length === 0 ? (
247
+ <div className="h-full flex flex-col items-center justify-center text-muted-foreground/30 gap-4 py-20">
248
+ <Activity size={48} className="opacity-10 animate-pulse" />
249
+ <p className="font-bold uppercase tracking-widest italic text-sm">
250
+ No events found in this timeline
251
+ </p>
252
+ </div>
253
+ ) : (
254
+ <div className="divide-y divide-border/10">
255
+ {logs.map((log) => (
256
+ <div
257
+ key={log.id}
258
+ className="p-4 flex gap-4 hover:bg-white/[0.02] transition-colors group cursor-default"
259
+ >
260
+ {/* Column 1: Time */}
261
+ <div className="shrink-0 w-32 pt-1">
262
+ <div className="flex flex-col text-right">
263
+ <span className="text-[10px] font-black text-muted-foreground/50 uppercase tracking-tighter tabular-nums">
264
+ {new Date(log.timestamp).toLocaleDateString()}
265
+ </span>
266
+ <span className="text-xs font-mono font-bold tabular-nums text-foreground/80">
267
+ {new Date(log.timestamp).toLocaleTimeString([], {
268
+ hour12: false,
269
+ hour: '2-digit',
270
+ minute: '2-digit',
271
+ second: '2-digit',
272
+ })}
273
+ </span>
274
+ </div>
275
+ </div>
276
+
277
+ {/* Column 2: Status Indicator */}
278
+ <div className="shrink-0 pt-1.5 relative">
279
+ <div className="w-px h-full absolute left-1/2 -translate-x-1/2 top-4 bg-border/30 last:hidden"></div>
280
+ <div
281
+ className={cn(
282
+ 'w-3 h-3 rounded-full border-2 relative z-10',
283
+ log.level === 'error'
284
+ ? 'border-red-500 bg-red-500/20'
285
+ : log.level === 'warn'
286
+ ? 'border-amber-500 bg-amber-500/20'
287
+ : log.level === 'success'
288
+ ? 'border-green-500 bg-green-500/20'
289
+ : 'border-blue-500 bg-blue-500/20'
290
+ )}
291
+ ></div>
292
+ </div>
293
+
294
+ {/* Column 3: Event Details */}
295
+ <div className="flex-1 min-w-0">
296
+ <div className="flex items-center gap-3 mb-1.5">
297
+ <span
298
+ className={cn(
299
+ 'px-1.5 py-0.5 rounded text-[9px] font-black uppercase tracking-widest',
300
+ log.level === 'error'
301
+ ? 'bg-red-500/10 text-red-500'
302
+ : log.level === 'warn'
303
+ ? 'bg-amber-500/10 text-amber-500'
304
+ : log.level === 'success'
305
+ ? 'bg-green-500/10 text-green-500'
306
+ : 'bg-blue-500/10 text-blue-500'
307
+ )}
308
+ >
309
+ {log.level}
310
+ </span>
311
+ <span className="text-[10px] font-mono text-muted-foreground/50">
312
+ {log.worker_id}
313
+ </span>
314
+ {log.queue && (
315
+ <span className="text-[10px] font-black text-indigo-400/80 uppercase tracking-wider bg-indigo-500/10 px-1.5 rounded">
316
+ {log.queue}
317
+ </span>
318
+ )}
319
+ </div>
320
+ <p className="text-sm text-foreground/90 font-mono break-all leading-relaxed opacity-90">
321
+ {log.message}
322
+ </p>
323
+ </div>
324
+ </div>
325
+ ))}
326
+ </div>
327
+ )}
328
+ </div>
329
+
330
+ {/* Footer / Pagination */}
331
+ <div className="p-4 border-t bg-muted/5 flex flex-col sm:flex-row justify-between items-center gap-4 text-[10px] uppercase font-bold text-muted-foreground">
332
+ <div>
333
+ Scanning {total.toLocaleString()} events • Page {page} of {totalPages || 1}
334
+ </div>
335
+ <div className="flex items-center gap-2">
336
+ <button
337
+ type="button"
338
+ disabled={page === 1 || isLoading}
339
+ onClick={() => setPage((p) => p - 1)}
340
+ className="p-2 border rounded-xl hover:bg-muted disabled:opacity-30 transition-all active:scale-95"
341
+ >
342
+ <ChevronLeft size={16} />
343
+ </button>
344
+ <button
345
+ type="button"
346
+ disabled={page >= totalPages || isLoading}
347
+ onClick={() => setPage((p) => p + 1)}
348
+ className="p-2 border rounded-xl hover:bg-muted disabled:opacity-30 transition-all active:scale-95"
349
+ >
350
+ <ChevronRight size={16} />
351
+ </button>
352
+ </div>
353
+ </div>
354
+ </motion.div>
355
+ </div>
356
+ )}
357
+ </AnimatePresence>
358
+ )
359
+ }
360
+
361
+ function RefreshCwIcon({ className, size }: { className?: string; size?: number }) {
362
+ return (
363
+ <svg
364
+ xmlns="http://www.w3.org/2000/svg"
365
+ width={size}
366
+ height={size}
367
+ viewBox="0 0 24 24"
368
+ fill="none"
369
+ stroke="currentColor"
370
+ strokeWidth="2"
371
+ strokeLinecap="round"
372
+ strokeLinejoin="round"
373
+ className={className}
374
+ aria-label="Refreshing"
375
+ >
376
+ <title>Refreshing</title>
377
+ <path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8" />
378
+ <path d="M21 3v5h-5" />
379
+ <path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16" />
380
+ <path d="M3 21v-5h5" />
381
+ </svg>
382
+ )
383
+ }
@@ -0,0 +1,203 @@
1
+ import { AnimatePresence, motion } from 'framer-motion'
2
+ import {
3
+ AlertCircle,
4
+ AlertTriangle,
5
+ Bell,
6
+ CheckCheck,
7
+ CheckCircle,
8
+ Info,
9
+ Trash2,
10
+ X,
11
+ } from 'lucide-react'
12
+ import { useEffect, useRef, useState } from 'react'
13
+ import { type Notification, useNotifications } from '../contexts/NotificationContext'
14
+ import { cn } from '../utils'
15
+
16
+ export function NotificationBell() {
17
+ const [isOpen, setIsOpen] = useState(false)
18
+ const { notifications, unreadCount, markAsRead, markAllAsRead, clearAll, removeNotification } =
19
+ useNotifications()
20
+ const panelRef = useRef<HTMLDivElement>(null)
21
+
22
+ // Close panel when clicking outside
23
+ useEffect(() => {
24
+ const handleClickOutside = (event: MouseEvent) => {
25
+ if (panelRef.current && !panelRef.current.contains(event.target as Node)) {
26
+ setIsOpen(false)
27
+ }
28
+ }
29
+
30
+ if (isOpen) {
31
+ document.addEventListener('mousedown', handleClickOutside)
32
+ }
33
+
34
+ return () => {
35
+ document.removeEventListener('mousedown', handleClickOutside)
36
+ }
37
+ }, [isOpen])
38
+
39
+ const getIcon = (type: Notification['type']) => {
40
+ switch (type) {
41
+ case 'error':
42
+ return <AlertCircle className="text-red-500" size={16} />
43
+ case 'warning':
44
+ return <AlertTriangle className="text-amber-500" size={16} />
45
+ case 'success':
46
+ return <CheckCircle className="text-green-500" size={16} />
47
+ default:
48
+ return <Info className="text-blue-500" size={16} />
49
+ }
50
+ }
51
+
52
+ const formatTime = (timestamp: number) => {
53
+ const diff = Date.now() - timestamp
54
+ const minutes = Math.floor(diff / 60000)
55
+ const hours = Math.floor(diff / 3600000)
56
+
57
+ if (minutes < 1) {
58
+ return 'Just now'
59
+ }
60
+ if (minutes < 60) {
61
+ return `${minutes}m ago`
62
+ }
63
+ if (hours < 24) {
64
+ return `${hours}h ago`
65
+ }
66
+ return new Date(timestamp).toLocaleDateString()
67
+ }
68
+
69
+ return (
70
+ <div className="relative" ref={panelRef}>
71
+ <button
72
+ type="button"
73
+ onClick={() => setIsOpen(!isOpen)}
74
+ className="text-muted-foreground hover:text-foreground transition-all relative p-2 hover:bg-muted rounded-xl"
75
+ >
76
+ <Bell size={20} />
77
+ {unreadCount > 0 && (
78
+ <motion.span
79
+ initial={{ scale: 0 }}
80
+ animate={{ scale: 1 }}
81
+ className="absolute top-1.5 right-1.5 min-w-[16px] h-4 bg-red-500 rounded-full border-2 border-background text-[9px] font-black text-white flex items-center justify-center px-0.5"
82
+ >
83
+ {unreadCount > 9 ? '9+' : unreadCount}
84
+ </motion.span>
85
+ )}
86
+ </button>
87
+
88
+ <AnimatePresence>
89
+ {isOpen && (
90
+ <motion.div
91
+ initial={{ opacity: 0, y: 10, scale: 0.95 }}
92
+ animate={{ opacity: 1, y: 0, scale: 1 }}
93
+ exit={{ opacity: 0, y: 10, scale: 0.95 }}
94
+ transition={{ duration: 0.2 }}
95
+ className="absolute right-0 top-full mt-2 w-96 bg-card border rounded-2xl shadow-2xl overflow-hidden z-50"
96
+ >
97
+ {/* Header */}
98
+ <div className="p-4 border-b bg-muted/20 flex items-center justify-between">
99
+ <div>
100
+ <h3 className="font-bold">Notifications</h3>
101
+ <p className="text-[10px] text-muted-foreground uppercase tracking-widest">
102
+ {unreadCount} unread
103
+ </p>
104
+ </div>
105
+ <div className="flex items-center gap-2">
106
+ {unreadCount > 0 && (
107
+ <button
108
+ type="button"
109
+ onClick={markAllAsRead}
110
+ className="p-1.5 hover:bg-muted rounded-lg text-muted-foreground hover:text-foreground transition-colors"
111
+ title="Mark all as read"
112
+ >
113
+ <CheckCheck size={16} />
114
+ </button>
115
+ )}
116
+ {notifications.length > 0 && (
117
+ <button
118
+ type="button"
119
+ onClick={clearAll}
120
+ className="p-1.5 hover:bg-red-500/10 rounded-lg text-muted-foreground hover:text-red-500 transition-colors"
121
+ title="Clear all"
122
+ >
123
+ <Trash2 size={16} />
124
+ </button>
125
+ )}
126
+ </div>
127
+ </div>
128
+
129
+ {/* Notification List */}
130
+ <div className="max-h-[400px] overflow-y-auto">
131
+ {notifications.length === 0 ? (
132
+ <div className="p-8 text-center">
133
+ <Bell className="mx-auto mb-3 text-muted-foreground/20" size={32} />
134
+ <p className="text-sm text-muted-foreground">No notifications</p>
135
+ </div>
136
+ ) : (
137
+ <div className="divide-y divide-border/50">
138
+ {notifications.map((notification) => (
139
+ <motion.div
140
+ key={notification.id}
141
+ initial={{ opacity: 0, x: -10 }}
142
+ animate={{ opacity: 1, x: 0 }}
143
+ className={cn(
144
+ 'p-4 hover:bg-muted/30 transition-colors cursor-pointer group relative',
145
+ !notification.read && 'bg-primary/5'
146
+ )}
147
+ onClick={() => markAsRead(notification.id)}
148
+ >
149
+ <div className="flex gap-3">
150
+ <div className="shrink-0 mt-0.5">{getIcon(notification.type)}</div>
151
+ <div className="flex-1 min-w-0">
152
+ <div className="flex items-start justify-between gap-2">
153
+ <p
154
+ className={cn(
155
+ 'text-sm font-semibold truncate',
156
+ !notification.read && 'text-foreground',
157
+ notification.read && 'text-muted-foreground'
158
+ )}
159
+ >
160
+ {notification.title}
161
+ </p>
162
+ <span className="text-[10px] text-muted-foreground/60 shrink-0">
163
+ {formatTime(notification.timestamp)}
164
+ </span>
165
+ </div>
166
+ <p className="text-xs text-muted-foreground mt-0.5 line-clamp-2">
167
+ {notification.message}
168
+ </p>
169
+ {notification.source && (
170
+ <span className="inline-block mt-1 text-[10px] font-mono bg-muted/50 px-1.5 py-0.5 rounded">
171
+ {notification.source}
172
+ </span>
173
+ )}
174
+ </div>
175
+ </div>
176
+
177
+ {/* Delete button on hover */}
178
+ <button
179
+ type="button"
180
+ onClick={(e) => {
181
+ e.stopPropagation()
182
+ removeNotification(notification.id)
183
+ }}
184
+ className="absolute right-2 top-1/2 -translate-y-1/2 p-1.5 opacity-0 group-hover:opacity-100 hover:bg-red-500/10 rounded-lg text-muted-foreground hover:text-red-500 transition-all"
185
+ >
186
+ <X size={14} />
187
+ </button>
188
+
189
+ {/* Unread indicator */}
190
+ {!notification.read && (
191
+ <div className="absolute left-1 top-1/2 -translate-y-1/2 w-1.5 h-1.5 bg-primary rounded-full" />
192
+ )}
193
+ </motion.div>
194
+ ))}
195
+ </div>
196
+ )}
197
+ </div>
198
+ </motion.div>
199
+ )}
200
+ </AnimatePresence>
201
+ </div>
202
+ )
203
+ }
@@ -0,0 +1,80 @@
1
+ import { AnimatePresence, motion } from 'framer-motion'
2
+ import { AlertCircle, CheckCircle2, Info, X } from 'lucide-react'
3
+ import { useEffect, useState } from 'react'
4
+ import { useNotifications } from '../contexts/NotificationContext'
5
+ import { cn } from '../utils'
6
+
7
+ export function Toaster() {
8
+ const { notifications, removeNotification } = useNotifications()
9
+ const [activeIds, setActiveIds] = useState<Set<string>>(new Set())
10
+
11
+ useEffect(() => {
12
+ const now = Date.now()
13
+ // Check for new notifications to add to display
14
+ notifications.forEach((n) => {
15
+ if (!n.read && now - n.timestamp < 5000 && !activeIds.has(n.id)) {
16
+ setActiveIds((prev) => new Set(prev).add(n.id))
17
+
18
+ // Set timer to remove this specific ID
19
+ setTimeout(() => {
20
+ setActiveIds((prev) => {
21
+ const next = new Set(prev)
22
+ next.delete(n.id)
23
+ return next
24
+ })
25
+ }, 5000)
26
+ }
27
+ })
28
+ }, [notifications, activeIds])
29
+
30
+ const visibleNotifications = notifications.filter((n) => activeIds.has(n.id))
31
+
32
+ return (
33
+ <div className="fixed bottom-8 right-8 z-[2000] flex flex-col gap-3 w-full max-w-sm pointer-events-none">
34
+ <AnimatePresence mode="popLayout">
35
+ {visibleNotifications.map((n) => (
36
+ <motion.div
37
+ key={n.id}
38
+ layout
39
+ initial={{ opacity: 0, x: 50, scale: 0.9 }}
40
+ animate={{ opacity: 1, x: 0, scale: 1 }}
41
+ exit={{ opacity: 0, scale: 0.8, x: 20 }}
42
+ className={cn(
43
+ 'pointer-events-auto group relative flex items-start gap-4 p-4 rounded-2xl border shadow-2xl backdrop-blur-xl transition-all',
44
+ n.type === 'success' && 'bg-green-500/10 border-green-500/20 text-green-500',
45
+ n.type === 'error' && 'bg-red-500/10 border-red-500/20 text-red-500',
46
+ n.type === 'warning' && 'bg-yellow-500/10 border-yellow-500/20 text-yellow-500',
47
+ n.type === 'info' && 'bg-primary/10 border-primary/20 text-primary'
48
+ )}
49
+ >
50
+ <div className="flex-shrink-0 mt-0.5">
51
+ {n.type === 'success' && <CheckCircle2 size={18} />}
52
+ {n.type === 'error' && <AlertCircle size={18} />}
53
+ {n.type === 'warning' && <AlertCircle size={18} />}
54
+ {n.type === 'info' && <Info size={18} />}
55
+ </div>
56
+ <div className="flex-1 min-w-0">
57
+ <h4 className="text-sm font-black tracking-tight leading-none mb-1">{n.title}</h4>
58
+ <p className="text-xs font-medium opacity-80 leading-relaxed break-words">
59
+ {n.message}
60
+ </p>
61
+ {n.source && (
62
+ <span className="inline-block mt-2 px-1.5 py-0.5 rounded bg-white/10 text-[9px] font-black uppercase tracking-widest">
63
+ {n.source}
64
+ </span>
65
+ )}
66
+ </div>
67
+ <button
68
+ type="button"
69
+ onClick={() => removeNotification(n.id)}
70
+ className="mt-0.5 opacity-0 group-hover:opacity-100 transition-opacity p-1 hover:bg-white/10 rounded-lg"
71
+ >
72
+ <X size={14} />
73
+ </button>
74
+ <div className="absolute left-0 bottom-0 h-1 bg-current opacity-20 animate-toast-progress origin-left" />
75
+ </motion.div>
76
+ ))}
77
+ </AnimatePresence>
78
+ </div>
79
+ )
80
+ }