@gravito/zenith 1.1.2 → 1.1.6

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