@moontra/moonui-pro 2.14.2 → 2.15.0

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.
@@ -1,10 +1,28 @@
1
1
  "use client"
2
2
 
3
- import React from 'react'
3
+ import React, { useState, useCallback, useMemo, useRef, useEffect } from 'react'
4
+ import { motion, AnimatePresence, Reorder } from 'framer-motion'
4
5
  import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '../ui/card'
5
6
  import { MoonUIBadgePro as Badge } from '../ui/badge'
6
7
  import { MoonUIAvatarPro, MoonUIAvatarFallbackPro, MoonUIAvatarImagePro } from '../ui/avatar'
7
8
  import { Button } from '../ui/button'
9
+ import { Input } from '../ui/input'
10
+ import { Popover, PopoverContent, PopoverTrigger } from '../ui/popover'
11
+ import {
12
+ Select,
13
+ SelectContent,
14
+ SelectItem,
15
+ SelectTrigger,
16
+ SelectValue,
17
+ } from '../ui/select'
18
+ import {
19
+ DropdownMenu,
20
+ DropdownMenuContent,
21
+ DropdownMenuItem,
22
+ DropdownMenuLabel,
23
+ DropdownMenuSeparator,
24
+ DropdownMenuTrigger,
25
+ } from '../ui/dropdown-menu'
8
26
  import {
9
27
  Clock,
10
28
  CheckCircle2,
@@ -17,104 +35,359 @@ import {
17
35
  Paperclip,
18
36
  ExternalLink,
19
37
  Lock,
20
- Sparkles
38
+ Sparkles,
39
+ Filter,
40
+ Search,
41
+ Download,
42
+ Printer,
43
+ ChevronRight,
44
+ ChevronDown,
45
+ MoreVertical,
46
+ Edit,
47
+ Trash,
48
+ Copy,
49
+ Share,
50
+ Flag,
51
+ Zap,
52
+ Target,
53
+ TrendingUp,
54
+ Repeat,
55
+ GitBranch,
56
+ Layers,
57
+ ArrowRight,
58
+ ArrowLeft,
59
+ Maximize2,
60
+ Minimize2
21
61
  } from 'lucide-react'
22
62
  import { cn } from '../../lib/utils'
63
+ import { VariantProps, cva } from 'class-variance-authority'
64
+
65
+ // Enhanced Event Types
66
+ export type TimelineEventType = 'success' | 'warning' | 'error' | 'info' | 'pending' | 'milestone' | 'custom'
67
+ export type TimelineLayout = 'vertical' | 'vertical-left' | 'vertical-right' | 'horizontal' | 'alternating' | 'grouped'
68
+ export type TimelineTheme = 'default' | 'minimal' | 'detailed' | 'compact'
69
+ export type TimelineAnimation = 'fade' | 'slide' | 'scale' | 'none'
70
+ export type TimelineGroupBy = 'none' | 'date' | 'week' | 'month' | 'year'
23
71
 
24
- interface TimelineEvent {
72
+ // Event interfaces
73
+ export interface TimelineEventBase {
25
74
  id: string
26
75
  title: string
27
76
  description?: string
28
77
  date: Date
29
- type: 'success' | 'warning' | 'error' | 'info' | 'pending'
30
- user?: {
31
- name: string
32
- avatar?: string
33
- email?: string
34
- }
35
- metadata?: {
36
- location?: string
37
- duration?: string
38
- tags?: string[]
39
- attachments?: number
40
- comments?: number
41
- externalLink?: string
42
- }
78
+ type: TimelineEventType
43
79
  icon?: React.ReactNode
44
80
  color?: string
81
+ metadata?: TimelineEventMetadata
82
+ user?: TimelineUser
45
83
  }
46
84
 
47
- interface TimelineProps {
85
+ export interface TimelineEventMetadata {
86
+ location?: string
87
+ duration?: string
88
+ tags?: string[]
89
+ attachments?: number
90
+ comments?: number
91
+ externalLink?: string
92
+ priority?: 'low' | 'medium' | 'high'
93
+ progress?: number
94
+ custom?: Record<string, any>
95
+ }
96
+
97
+ export interface TimelineUser {
98
+ name: string
99
+ avatar?: string
100
+ email?: string
101
+ role?: string
102
+ }
103
+
104
+ // Extended event types
105
+ export interface MilestoneEvent extends TimelineEventBase {
106
+ type: 'milestone'
107
+ milestone: {
108
+ target?: string
109
+ achievement?: string
110
+ impact?: string
111
+ }
112
+ }
113
+
114
+ export interface RangeEvent extends TimelineEventBase {
115
+ endDate: Date
116
+ isActive?: boolean
117
+ }
118
+
119
+ export interface RecurringEvent extends TimelineEventBase {
120
+ recurrence: {
121
+ frequency: 'daily' | 'weekly' | 'monthly' | 'yearly'
122
+ interval?: number
123
+ endDate?: Date
124
+ occurrences?: number
125
+ }
126
+ }
127
+
128
+ export interface NestedEvent extends TimelineEventBase {
129
+ subEvents?: TimelineEvent[]
130
+ expanded?: boolean
131
+ }
132
+
133
+ export type TimelineEvent = TimelineEventBase | MilestoneEvent | RangeEvent | RecurringEvent | NestedEvent
134
+
135
+ // Render function types
136
+ export type EventRenderer = (event: TimelineEvent, index: number) => React.ReactNode
137
+ export type ConnectorRenderer = (fromEvent: TimelineEvent, toEvent: TimelineEvent) => React.ReactNode
138
+ export type IconRenderer = (event: TimelineEvent) => React.ReactNode
139
+
140
+ // Timeline Props
141
+ export interface TimelineProps extends VariantProps<typeof timelineVariants> {
48
142
  events: TimelineEvent[]
49
143
  onEventClick?: (event: TimelineEvent) => void
144
+ onEventEdit?: (event: TimelineEvent) => void
145
+ onEventDelete?: (event: TimelineEvent) => void
146
+ onEventsReorder?: (events: TimelineEvent[]) => void
50
147
  className?: string
148
+
149
+ // Layout options
150
+ layout?: TimelineLayout
151
+ theme?: TimelineTheme
152
+ animation?: TimelineAnimation
153
+ groupBy?: TimelineGroupBy
154
+
155
+ // Display options
51
156
  showUserInfo?: boolean
52
157
  showMetadata?: boolean
53
158
  showRelativeTime?: boolean
54
- groupByDate?: boolean
55
- orientation?: 'vertical' | 'horizontal'
56
- compact?: boolean
57
- interactive?: boolean
159
+ showSearch?: boolean
160
+ showFilter?: boolean
161
+ showExport?: boolean
162
+
163
+ // Customization
164
+ eventRenderer?: EventRenderer
165
+ connectorRenderer?: ConnectorRenderer
166
+ iconRenderer?: IconRenderer
167
+ colorScheme?: Record<TimelineEventType, string>
168
+
169
+ // Interactive features
170
+ draggable?: boolean
171
+ expandable?: boolean
172
+ editable?: boolean
173
+ selectable?: boolean
174
+ virtualScroll?: boolean
175
+
176
+ // Advanced features
177
+ maxEventsPerGroup?: number
178
+ printMode?: boolean
179
+ compactMode?: boolean
180
+ parallaxEffect?: boolean
181
+ gradientConnectors?: boolean
182
+ animatedProgress?: boolean
183
+
184
+ // Accessibility
185
+ ariaLabel?: string
186
+ keyboardNavigation?: boolean
58
187
  }
59
188
 
60
- const EVENT_COLORS = {
189
+ // Timeline variants
190
+ const timelineVariants = cva("w-full", {
191
+ variants: {
192
+ theme: {
193
+ default: "",
194
+ minimal: "border-0 shadow-none",
195
+ detailed: "border-2",
196
+ compact: "p-0"
197
+ }
198
+ },
199
+ defaultVariants: {
200
+ theme: "default"
201
+ }
202
+ })
203
+
204
+ // Default color schemes
205
+ const DEFAULT_COLORS: Record<TimelineEventType, string> = {
61
206
  success: 'bg-green-500 border-green-500',
62
207
  warning: 'bg-yellow-500 border-yellow-500',
63
208
  error: 'bg-red-500 border-red-500',
64
209
  info: 'bg-blue-500 border-blue-500',
65
- pending: 'bg-gray-400 border-gray-400'
210
+ pending: 'bg-gray-400 border-gray-400',
211
+ milestone: 'bg-purple-500 border-purple-500',
212
+ custom: 'bg-slate-500 border-slate-500'
66
213
  }
67
214
 
68
- const EVENT_ICONS = {
215
+ const DEFAULT_ICONS: Record<TimelineEventType, React.ReactNode> = {
69
216
  success: <CheckCircle2 className="h-4 w-4 text-white" />,
70
217
  warning: <AlertCircle className="h-4 w-4 text-white" />,
71
218
  error: <XCircle className="h-4 w-4 text-white" />,
72
219
  info: <Circle className="h-4 w-4 text-white" />,
73
- pending: <Clock className="h-4 w-4 text-white" />
220
+ pending: <Clock className="h-4 w-4 text-white" />,
221
+ milestone: <Flag className="h-4 w-4 text-white" />,
222
+ custom: <Sparkles className="h-4 w-4 text-white" />
223
+ }
224
+
225
+ const TEXT_COLORS: Record<TimelineEventType, string> = {
226
+ success: 'text-green-700 dark:text-green-400',
227
+ warning: 'text-yellow-700 dark:text-yellow-400',
228
+ error: 'text-red-700 dark:text-red-400',
229
+ info: 'text-blue-700 dark:text-blue-400',
230
+ pending: 'text-gray-700 dark:text-gray-400',
231
+ milestone: 'text-purple-700 dark:text-purple-400',
232
+ custom: 'text-slate-700 dark:text-slate-400'
74
233
  }
75
234
 
76
- const EVENT_TEXT_COLORS = {
77
- success: 'text-green-700',
78
- warning: 'text-yellow-700',
79
- error: 'text-red-700',
80
- info: 'text-blue-700',
81
- pending: 'text-gray-700'
235
+ // Animation variants
236
+ const animationVariants = {
237
+ fade: {
238
+ initial: { opacity: 0 },
239
+ animate: { opacity: 1 },
240
+ exit: { opacity: 0 }
241
+ },
242
+ slide: {
243
+ initial: { opacity: 0, x: -20 },
244
+ animate: { opacity: 1, x: 0 },
245
+ exit: { opacity: 0, x: 20 }
246
+ },
247
+ scale: {
248
+ initial: { opacity: 0, scale: 0.8 },
249
+ animate: { opacity: 1, scale: 1 },
250
+ exit: { opacity: 0, scale: 0.8 }
251
+ }
82
252
  }
83
253
 
84
254
  export function Timeline({
85
- events,
255
+ events: initialEvents,
86
256
  onEventClick,
257
+ onEventEdit,
258
+ onEventDelete,
259
+ onEventsReorder,
87
260
  className,
261
+ layout = 'vertical',
262
+ theme = 'default',
263
+ animation = 'fade',
264
+ groupBy = 'none',
88
265
  showUserInfo = true,
89
266
  showMetadata = true,
90
267
  showRelativeTime = true,
91
- groupByDate = false,
92
- orientation = 'vertical',
93
- compact = false,
94
- interactive = true
268
+ showSearch = true,
269
+ showFilter = true,
270
+ showExport = true,
271
+ eventRenderer,
272
+ connectorRenderer,
273
+ iconRenderer,
274
+ colorScheme = DEFAULT_COLORS,
275
+ draggable = false,
276
+ expandable = true,
277
+ editable = false,
278
+ selectable = false,
279
+ virtualScroll = false,
280
+ maxEventsPerGroup = 20,
281
+ printMode = false,
282
+ compactMode = false,
283
+ parallaxEffect = false,
284
+ gradientConnectors = false,
285
+ animatedProgress = false,
286
+ ariaLabel = "Timeline of events",
287
+ keyboardNavigation = true,
288
+ ...props
95
289
  }: TimelineProps) {
96
- const sortedEvents = [...events].sort((a, b) => b.date.getTime() - a.date.getTime())
290
+ const [events, setEvents] = useState<TimelineEvent[]>(initialEvents)
291
+ const [searchQuery, setSearchQuery] = useState('')
292
+ const [filterType, setFilterType] = useState<TimelineEventType | 'all'>('all')
293
+ const [selectedEvents, setSelectedEvents] = useState<Set<string>>(new Set())
294
+ const [expandedEvents, setExpandedEvents] = useState<Set<string>>(new Set())
295
+ const [focusedEventId, setFocusedEventId] = useState<string | null>(null)
296
+
297
+ const timelineRef = useRef<HTMLDivElement>(null)
298
+ const eventRefs = useRef<Map<string, HTMLDivElement>>(new Map())
299
+
300
+ // Update events when prop changes
301
+ useEffect(() => {
302
+ setEvents(initialEvents)
303
+ }, [initialEvents])
304
+
305
+ // Keyboard navigation
306
+ useEffect(() => {
307
+ if (!keyboardNavigation) return
308
+
309
+ const handleKeyDown = (e: KeyboardEvent) => {
310
+ const currentIndex = events.findIndex(event => event.id === focusedEventId)
311
+
312
+ switch (e.key) {
313
+ case 'ArrowDown':
314
+ case 'ArrowRight':
315
+ e.preventDefault()
316
+ if (currentIndex < events.length - 1) {
317
+ const nextEvent = events[currentIndex + 1]
318
+ setFocusedEventId(nextEvent.id)
319
+ eventRefs.current.get(nextEvent.id)?.focus()
320
+ }
321
+ break
322
+ case 'ArrowUp':
323
+ case 'ArrowLeft':
324
+ e.preventDefault()
325
+ if (currentIndex > 0) {
326
+ const prevEvent = events[currentIndex - 1]
327
+ setFocusedEventId(prevEvent.id)
328
+ eventRefs.current.get(prevEvent.id)?.focus()
329
+ }
330
+ break
331
+ case 'Enter':
332
+ case ' ':
333
+ e.preventDefault()
334
+ if (focusedEventId) {
335
+ const event = events.find(e => e.id === focusedEventId)
336
+ if (event && onEventClick) {
337
+ onEventClick(event)
338
+ }
339
+ }
340
+ break
341
+ case 'Delete':
342
+ if (editable && focusedEventId && onEventDelete) {
343
+ const event = events.find(e => e.id === focusedEventId)
344
+ if (event) {
345
+ onEventDelete(event)
346
+ }
347
+ }
348
+ break
349
+ }
350
+ }
97
351
 
98
- const formatDate = (date: Date) => {
352
+ timelineRef.current?.addEventListener('keydown', handleKeyDown)
353
+ return () => timelineRef.current?.removeEventListener('keydown', handleKeyDown)
354
+ }, [keyboardNavigation, events, focusedEventId, onEventClick, onEventDelete, editable])
355
+
356
+ // Utility functions
357
+ const formatDate = useCallback((date: Date) => {
99
358
  return date.toLocaleDateString('en-US', {
100
359
  year: 'numeric',
101
360
  month: 'long',
102
361
  day: 'numeric'
103
362
  })
104
- }
363
+ }, [])
105
364
 
106
- const formatTime = (date: Date) => {
365
+ const formatTime = useCallback((date: Date) => {
107
366
  return date.toLocaleTimeString('en-US', {
108
367
  hour: '2-digit',
109
368
  minute: '2-digit',
110
369
  hour12: true
111
370
  })
112
- }
371
+ }, [])
113
372
 
114
- const getRelativeTime = (date: Date) => {
373
+ const getRelativeTime = useCallback((date: Date) => {
115
374
  const now = new Date()
116
375
  const diffInSeconds = Math.floor((now.getTime() - date.getTime()) / 1000)
117
376
 
377
+ if (diffInSeconds < 0) {
378
+ const futureDiff = Math.abs(diffInSeconds)
379
+ if (futureDiff < 3600) {
380
+ const minutes = Math.floor(futureDiff / 60)
381
+ return `In ${minutes} minute${minutes !== 1 ? 's' : ''}`
382
+ } else if (futureDiff < 86400) {
383
+ const hours = Math.floor(futureDiff / 3600)
384
+ return `In ${hours} hour${hours !== 1 ? 's' : ''}`
385
+ } else {
386
+ const days = Math.floor(futureDiff / 86400)
387
+ return `In ${days} day${days !== 1 ? 's' : ''}`
388
+ }
389
+ }
390
+
118
391
  if (diffInSeconds < 60) {
119
392
  return 'Just now'
120
393
  } else if (diffInSeconds < 3600) {
@@ -123,23 +396,42 @@ export function Timeline({
123
396
  } else if (diffInSeconds < 86400) {
124
397
  const hours = Math.floor(diffInSeconds / 3600)
125
398
  return `${hours} hour${hours > 1 ? 's' : ''} ago`
126
- } else if (diffInSeconds < 2592000) {
399
+ } else if (diffInSeconds < 604800) {
127
400
  const days = Math.floor(diffInSeconds / 86400)
128
401
  return `${days} day${days > 1 ? 's' : ''} ago`
129
402
  } else {
130
403
  return formatDate(date)
131
404
  }
132
- }
405
+ }, [formatDate])
133
406
 
134
- const getInitials = (name: string) => {
135
- return name.split(' ').map(n => n[0]).join('').toUpperCase()
136
- }
407
+ const getInitials = useCallback((name: string) => {
408
+ return name.split(' ').map(n => n[0]).join('').toUpperCase().slice(0, 2)
409
+ }, [])
137
410
 
138
- const groupEventsByDate = (events: TimelineEvent[]) => {
411
+ // Grouping functions
412
+ const groupEventsByDate = useCallback((events: TimelineEvent[]) => {
139
413
  const groups: { [key: string]: TimelineEvent[] } = {}
140
414
 
141
415
  events.forEach(event => {
142
- const dateKey = formatDate(event.date)
416
+ let dateKey: string
417
+
418
+ switch (groupBy) {
419
+ case 'week':
420
+ const weekStart = new Date(event.date)
421
+ weekStart.setDate(weekStart.getDate() - weekStart.getDay())
422
+ dateKey = `Week of ${formatDate(weekStart)}`
423
+ break
424
+ case 'month':
425
+ dateKey = event.date.toLocaleDateString('en-US', { year: 'numeric', month: 'long' })
426
+ break
427
+ case 'year':
428
+ dateKey = event.date.getFullYear().toString()
429
+ break
430
+ case 'date':
431
+ default:
432
+ dateKey = formatDate(event.date)
433
+ }
434
+
143
435
  if (!groups[dateKey]) {
144
436
  groups[dateKey] = []
145
437
  }
@@ -147,76 +439,320 @@ export function Timeline({
147
439
  })
148
440
 
149
441
  return groups
150
- }
442
+ }, [groupBy, formatDate])
151
443
 
152
- const handleEventClick = (event: TimelineEvent) => {
153
- if (interactive && onEventClick) {
154
- onEventClick(event)
444
+ // Filtering and sorting
445
+ const filteredAndSortedEvents = useMemo(() => {
446
+ let filtered = [...events]
447
+
448
+ // Apply search filter
449
+ if (searchQuery) {
450
+ filtered = filtered.filter(event =>
451
+ event.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
452
+ event.description?.toLowerCase().includes(searchQuery.toLowerCase()) ||
453
+ event.metadata?.tags?.some(tag => tag.toLowerCase().includes(searchQuery.toLowerCase()))
454
+ )
155
455
  }
156
- }
456
+
457
+ // Apply type filter
458
+ if (filterType !== 'all') {
459
+ filtered = filtered.filter(event => event.type === filterType)
460
+ }
461
+
462
+ // Sort by date
463
+ return filtered.sort((a, b) => b.date.getTime() - a.date.getTime())
464
+ }, [events, searchQuery, filterType])
157
465
 
158
- const renderEvent = (event: TimelineEvent, index: number, isLast: boolean) => {
159
- const eventColor = event.color || EVENT_COLORS[event.type]
160
- const eventIcon = event.icon || EVENT_ICONS[event.type]
161
- const textColor = EVENT_TEXT_COLORS[event.type]
466
+ // Event handlers
467
+ const handleEventClick = useCallback((event: TimelineEvent) => {
468
+ if (selectable) {
469
+ setSelectedEvents(prev => {
470
+ const newSet = new Set(prev)
471
+ if (newSet.has(event.id)) {
472
+ newSet.delete(event.id)
473
+ } else {
474
+ newSet.add(event.id)
475
+ }
476
+ return newSet
477
+ })
478
+ }
479
+
480
+ if (expandable && 'subEvents' in event) {
481
+ setExpandedEvents(prev => {
482
+ const newSet = new Set(prev)
483
+ if (newSet.has(event.id)) {
484
+ newSet.delete(event.id)
485
+ } else {
486
+ newSet.add(event.id)
487
+ }
488
+ return newSet
489
+ })
490
+ }
491
+
492
+ onEventClick?.(event)
493
+ }, [selectable, expandable, onEventClick])
494
+
495
+ const handleExport = useCallback(() => {
496
+ const dataStr = JSON.stringify(filteredAndSortedEvents, null, 2)
497
+ const dataUri = 'data:application/json;charset=utf-8,'+ encodeURIComponent(dataStr)
498
+
499
+ const exportFileDefaultName = `timeline-export-${new Date().toISOString()}.json`
500
+
501
+ const linkElement = document.createElement('a')
502
+ linkElement.setAttribute('href', dataUri)
503
+ linkElement.setAttribute('download', exportFileDefaultName)
504
+ linkElement.click()
505
+ }, [filteredAndSortedEvents])
506
+
507
+ const handlePrint = useCallback(() => {
508
+ window.print()
509
+ }, [])
162
510
 
511
+ // Render helpers
512
+ const renderEventIcon = useCallback((event: TimelineEvent) => {
513
+ if (iconRenderer) {
514
+ return iconRenderer(event)
515
+ }
516
+
517
+ if (event.icon) {
518
+ return event.icon
519
+ }
520
+
521
+ if ('milestone' in event) {
522
+ return <Target className="h-4 w-4 text-white" />
523
+ }
524
+
525
+ if ('recurrence' in event) {
526
+ return <Repeat className="h-4 w-4 text-white" />
527
+ }
528
+
529
+ if ('subEvents' in event) {
530
+ return <GitBranch className="h-4 w-4 text-white" />
531
+ }
532
+
533
+ return DEFAULT_ICONS[event.type] || DEFAULT_ICONS.custom
534
+ }, [iconRenderer])
535
+
536
+ const renderConnector = useCallback((fromEvent: TimelineEvent, toEvent: TimelineEvent, isLast: boolean) => {
537
+ if (connectorRenderer) {
538
+ return connectorRenderer(fromEvent, toEvent)
539
+ }
540
+
541
+ if (isLast) return null
542
+
543
+ const baseClasses = cn(
544
+ "absolute",
545
+ layout === 'horizontal' ? "h-0.5 top-4" : "w-0.5 left-4 -ml-px",
546
+ gradientConnectors && "bg-gradient-to-b from-border to-transparent"
547
+ )
548
+
549
+ if (layout === 'horizontal') {
550
+ return <div className={cn(baseClasses, "bg-border")} style={{ width: '100px' }} />
551
+ }
552
+
163
553
  return (
554
+ <div className={cn(
555
+ baseClasses,
556
+ "bg-border",
557
+ layout === 'alternating' && "hidden"
558
+ )}
559
+ style={{
560
+ height: 'calc(100% + 2rem)',
561
+ top: '2rem'
562
+ }} />
563
+ )
564
+ }, [connectorRenderer, layout, gradientConnectors])
565
+
566
+ const renderEvent = useCallback((event: TimelineEvent, index: number, isLast: boolean = false) => {
567
+ const eventColor = event.color || colorScheme[event.type] || DEFAULT_COLORS[event.type]
568
+ const textColor = TEXT_COLORS[event.type] || TEXT_COLORS.custom
569
+ const isSelected = selectedEvents.has(event.id)
570
+ const isExpanded = expandedEvents.has(event.id)
571
+ const isFocused = focusedEventId === event.id
572
+
573
+ // Custom renderer
574
+ if (eventRenderer) {
575
+ return eventRenderer(event, index)
576
+ }
577
+
578
+ // Check if event is a range event
579
+ const isRangeEvent = 'endDate' in event
580
+ const isNestedEvent = 'subEvents' in event
581
+ const isMilestoneEvent = 'milestone' in event
582
+
583
+ const eventContent = (
164
584
  <div
165
- key={event.id}
585
+ ref={(el) => {
586
+ if (el) eventRefs.current.set(event.id, el)
587
+ }}
588
+ tabIndex={keyboardNavigation ? 0 : -1}
166
589
  className={cn(
167
590
  "relative flex gap-4",
168
- compact ? "pb-4" : "pb-8",
169
- interactive && "cursor-pointer hover:bg-muted/50 rounded-lg p-2 -m-2 transition-colors"
591
+ compactMode ? "pb-2" : "pb-8",
592
+ layout === 'alternating' && index % 2 === 0 && "flex-row-reverse",
593
+ layout === 'horizontal' && "flex-col items-center",
594
+ "cursor-pointer rounded-lg p-3 -m-1 transition-all duration-200",
595
+ "hover:bg-muted/50 focus:outline-none focus:ring-2 focus:ring-ring",
596
+ isSelected && "bg-muted/70",
597
+ isFocused && "ring-2 ring-ring",
598
+ printMode && "print:break-inside-avoid"
170
599
  )}
171
600
  onClick={() => handleEventClick(event)}
601
+ onFocus={() => setFocusedEventId(event.id)}
602
+ role="button"
603
+ aria-label={`Event: ${event.title}`}
604
+ aria-expanded={isNestedEvent ? isExpanded : undefined}
172
605
  >
173
- {/* Timeline Line */}
174
- {orientation === 'vertical' && (
175
- <div className="flex flex-col items-center">
176
- <div className={cn(
177
- "flex items-center justify-center w-8 h-8 rounded-full border-2 bg-background",
178
- eventColor
179
- )}>
180
- {eventIcon}
181
- </div>
182
- {!isLast && (
183
- <div className="w-0.5 h-full bg-border mt-2" />
606
+ {/* Timeline Node */}
607
+ <div className={cn(
608
+ "flex flex-col items-center",
609
+ layout === 'horizontal' && "flex-row"
610
+ )}>
611
+ <motion.div
612
+ className={cn(
613
+ "flex items-center justify-center rounded-full border-2 bg-background z-10",
614
+ isMilestoneEvent ? "w-10 h-10" : "w-8 h-8",
615
+ eventColor,
616
+ isSelected && "ring-2 ring-ring ring-offset-2"
184
617
  )}
185
- </div>
186
- )}
618
+ whileHover={{ scale: 1.1 }}
619
+ whileTap={{ scale: 0.95 }}
620
+ >
621
+ {renderEventIcon(event)}
622
+ </motion.div>
623
+
624
+ {/* Connector */}
625
+ {!isLast && renderConnector(event, events[index + 1], isLast)}
626
+ </div>
187
627
 
188
628
  {/* Event Content */}
189
- <div className="flex-1 min-w-0">
190
- <div className="flex items-start justify-between gap-2">
629
+ <div className={cn(
630
+ "flex-1 min-w-0",
631
+ layout === 'alternating' && index % 2 === 0 && "text-right"
632
+ )}>
633
+ {/* Header */}
634
+ <div className="flex items-start justify-between gap-2 mb-2">
191
635
  <div className="flex-1">
192
636
  <h4 className={cn(
193
- "font-medium text-sm",
194
- compact ? "mb-1" : "mb-2"
637
+ "font-semibold",
638
+ compactMode ? "text-sm" : "text-base",
639
+ isMilestoneEvent && "text-lg"
195
640
  )}>
196
641
  {event.title}
642
+ {isMilestoneEvent && (
643
+ <Badge variant="secondary" className="ml-2 bg-purple-500">
644
+ Milestone
645
+ </Badge>
646
+ )}
197
647
  </h4>
198
- {event.description && (
199
- <p className={cn(
200
- "text-muted-foreground text-sm",
201
- compact ? "mb-1" : "mb-2"
202
- )}>
203
- {event.description}
204
- </p>
205
- )}
648
+
649
+ {/* Date/Time display */}
650
+ <div className="flex items-center gap-2 mt-1 text-xs text-muted-foreground">
651
+ <Clock className="h-3 w-3" />
652
+ {showRelativeTime ? (
653
+ <span>{getRelativeTime(event.date)}</span>
654
+ ) : (
655
+ <span>{formatTime(event.date)}</span>
656
+ )}
657
+ {isRangeEvent && (
658
+ <>
659
+ <ArrowRight className="h-3 w-3" />
660
+ <span>{formatTime((event as RangeEvent).endDate)}</span>
661
+ </>
662
+ )}
663
+ </div>
206
664
  </div>
207
- <div className="flex items-center gap-2 text-xs text-muted-foreground">
208
- {showRelativeTime ? (
209
- <span>{getRelativeTime(event.date)}</span>
210
- ) : (
211
- <span>{formatTime(event.date)}</span>
665
+
666
+ {/* Actions */}
667
+ {(editable || event.metadata?.externalLink) && (
668
+ <DropdownMenu>
669
+ <DropdownMenuTrigger asChild>
670
+ <Button variant="ghost" size="sm" className="h-8 w-8 p-0">
671
+ <MoreVertical className="h-4 w-4" />
672
+ </Button>
673
+ </DropdownMenuTrigger>
674
+ <DropdownMenuContent align="end">
675
+ {editable && (
676
+ <>
677
+ <DropdownMenuItem onClick={() => onEventEdit?.(event)}>
678
+ <Edit className="mr-2 h-4 w-4" />
679
+ Edit
680
+ </DropdownMenuItem>
681
+ <DropdownMenuItem onClick={() => onEventDelete?.(event)}>
682
+ <Trash className="mr-2 h-4 w-4" />
683
+ Delete
684
+ </DropdownMenuItem>
685
+ <DropdownMenuSeparator />
686
+ </>
687
+ )}
688
+ <DropdownMenuItem onClick={() => navigator.clipboard.writeText(event.title)}>
689
+ <Copy className="mr-2 h-4 w-4" />
690
+ Copy
691
+ </DropdownMenuItem>
692
+ <DropdownMenuItem>
693
+ <Share className="mr-2 h-4 w-4" />
694
+ Share
695
+ </DropdownMenuItem>
696
+ {event.metadata?.externalLink && (
697
+ <DropdownMenuItem onClick={() => window.open(event.metadata?.externalLink, '_blank')}>
698
+ <ExternalLink className="mr-2 h-4 w-4" />
699
+ View Details
700
+ </DropdownMenuItem>
701
+ )}
702
+ </DropdownMenuContent>
703
+ </DropdownMenu>
704
+ )}
705
+ </div>
706
+
707
+ {/* Description */}
708
+ {event.description && (
709
+ <p className={cn(
710
+ "text-muted-foreground mb-2",
711
+ compactMode ? "text-xs" : "text-sm"
712
+ )}>
713
+ {event.description}
714
+ </p>
715
+ )}
716
+
717
+ {/* Milestone details */}
718
+ {isMilestoneEvent && (event as MilestoneEvent).milestone && (
719
+ <div className="bg-purple-50 dark:bg-purple-950/20 rounded-md p-3 mb-2">
720
+ {(event as MilestoneEvent).milestone.target && (
721
+ <div className="flex items-center gap-2 text-sm">
722
+ <Target className="h-4 w-4 text-purple-600" />
723
+ <span className="font-medium">Target:</span>
724
+ <span>{(event as MilestoneEvent).milestone.target}</span>
725
+ </div>
726
+ )}
727
+ {(event as MilestoneEvent).milestone.achievement && (
728
+ <div className="flex items-center gap-2 text-sm mt-1">
729
+ <TrendingUp className="h-4 w-4 text-purple-600" />
730
+ <span className="font-medium">Achievement:</span>
731
+ <span>{(event as MilestoneEvent).milestone.achievement}</span>
732
+ </div>
212
733
  )}
213
- <Badge variant="outline" className={cn("text-xs", textColor)}>
214
- {event.type}
215
- </Badge>
216
734
  </div>
217
- </div>
735
+ )}
218
736
 
219
- {/* User Info */}
737
+ {/* Progress bar for events with progress */}
738
+ {event.metadata?.progress !== undefined && (
739
+ <div className="mb-2">
740
+ <div className="flex justify-between text-xs mb-1">
741
+ <span>Progress</span>
742
+ <span>{event.metadata.progress}%</span>
743
+ </div>
744
+ <div className="w-full bg-muted rounded-full h-2 overflow-hidden">
745
+ <motion.div
746
+ className="h-full bg-primary"
747
+ initial={{ width: 0 }}
748
+ animate={{ width: `${event.metadata.progress}%` }}
749
+ transition={{ duration: 0.5, ease: "easeOut" }}
750
+ />
751
+ </div>
752
+ </div>
753
+ )}
754
+
755
+ {/* User info */}
220
756
  {showUserInfo && event.user && (
221
757
  <div className="flex items-center gap-2 mb-2">
222
758
  <MoonUIAvatarPro className="h-6 w-6">
@@ -225,9 +761,12 @@ export function Timeline({
225
761
  {getInitials(event.user.name)}
226
762
  </MoonUIAvatarFallbackPro>
227
763
  </MoonUIAvatarPro>
228
- <span className="text-sm text-muted-foreground">
229
- {event.user.name}
230
- </span>
764
+ <div className="flex flex-col">
765
+ <span className="text-sm font-medium">{event.user.name}</span>
766
+ {event.user.role && (
767
+ <span className="text-xs text-muted-foreground">{event.user.role}</span>
768
+ )}
769
+ </div>
231
770
  </div>
232
771
  )}
233
772
 
@@ -246,23 +785,29 @@ export function Timeline({
246
785
  <span>{event.metadata.location}</span>
247
786
  </div>
248
787
  )}
249
- {event.metadata.comments && event.metadata.comments > 0 && (
788
+ {event.metadata.comments !== undefined && event.metadata.comments > 0 && (
250
789
  <div className="flex items-center gap-1">
251
790
  <MessageCircle className="h-3 w-3" />
252
791
  <span>{event.metadata.comments}</span>
253
792
  </div>
254
793
  )}
255
- {event.metadata.attachments && event.metadata.attachments > 0 && (
794
+ {event.metadata.attachments !== undefined && event.metadata.attachments > 0 && (
256
795
  <div className="flex items-center gap-1">
257
796
  <Paperclip className="h-3 w-3" />
258
797
  <span>{event.metadata.attachments}</span>
259
798
  </div>
260
799
  )}
261
- {event.metadata.externalLink && (
262
- <div className="flex items-center gap-1">
263
- <ExternalLink className="h-3 w-3" />
264
- <span>View details</span>
265
- </div>
800
+ {event.metadata.priority && (
801
+ <Badge
802
+ variant={
803
+ event.metadata.priority === 'high' ? 'destructive' :
804
+ event.metadata.priority === 'medium' ? 'outline' :
805
+ 'secondary'
806
+ }
807
+ className="text-xs"
808
+ >
809
+ {event.metadata.priority}
810
+ </Badge>
266
811
  )}
267
812
  </div>
268
813
  )}
@@ -271,62 +816,336 @@ export function Timeline({
271
816
  {event.metadata?.tags && event.metadata.tags.length > 0 && (
272
817
  <div className="flex flex-wrap gap-1 mt-2">
273
818
  {event.metadata.tags.map((tag, tagIndex) => (
274
- <Badge key={tagIndex} variant="secondary" className="text-xs">
819
+ <Badge key={tagIndex} variant="outline" className="text-xs">
275
820
  {tag}
276
821
  </Badge>
277
822
  ))}
278
823
  </div>
279
824
  )}
825
+
826
+ {/* Nested events */}
827
+ {isNestedEvent && isExpanded && (event as NestedEvent).subEvents && (
828
+ <div className="mt-3 ml-4 pl-4 border-l-2 border-muted">
829
+ {(event as NestedEvent).subEvents!.map((subEvent, subIndex) => (
830
+ <div key={subEvent.id} className="mb-2 last:mb-0">
831
+ <div className="flex items-start gap-2">
832
+ <div className={cn(
833
+ "w-2 h-2 rounded-full mt-1.5",
834
+ colorScheme[subEvent.type] || DEFAULT_COLORS[subEvent.type]
835
+ )} />
836
+ <div className="flex-1">
837
+ <h5 className="text-sm font-medium">{subEvent.title}</h5>
838
+ {subEvent.description && (
839
+ <p className="text-xs text-muted-foreground">{subEvent.description}</p>
840
+ )}
841
+ <span className="text-xs text-muted-foreground">
842
+ {getRelativeTime(subEvent.date)}
843
+ </span>
844
+ </div>
845
+ </div>
846
+ </div>
847
+ ))}
848
+ </div>
849
+ )}
850
+
851
+ {/* Expand/Collapse button for nested events */}
852
+ {isNestedEvent && (event as NestedEvent).subEvents && (event as NestedEvent).subEvents!.length > 0 && (
853
+ <Button
854
+ variant="ghost"
855
+ size="sm"
856
+ className="mt-2 h-6 text-xs"
857
+ onClick={(e) => {
858
+ e.stopPropagation()
859
+ setExpandedEvents(prev => {
860
+ const newSet = new Set(prev)
861
+ if (newSet.has(event.id)) {
862
+ newSet.delete(event.id)
863
+ } else {
864
+ newSet.add(event.id)
865
+ }
866
+ return newSet
867
+ })
868
+ }}
869
+ >
870
+ {isExpanded ? (
871
+ <>
872
+ <ChevronDown className="mr-1 h-3 w-3" />
873
+ Hide {(event as NestedEvent).subEvents!.length} sub-events
874
+ </>
875
+ ) : (
876
+ <>
877
+ <ChevronRight className="mr-1 h-3 w-3" />
878
+ Show {(event as NestedEvent).subEvents!.length} sub-events
879
+ </>
880
+ )}
881
+ </Button>
882
+ )}
280
883
  </div>
281
884
  </div>
282
885
  )
283
- }
284
886
 
285
- const renderGroupedEvents = () => {
286
- const groups = groupEventsByDate(sortedEvents)
887
+ if (draggable && onEventsReorder) {
888
+ return (
889
+ <Reorder.Item
890
+ key={event.id}
891
+ value={event}
892
+ dragListener={false}
893
+ dragControls={undefined}
894
+ >
895
+ {eventContent}
896
+ </Reorder.Item>
897
+ )
898
+ }
899
+
900
+ const variants = animation !== 'none' ? animationVariants[animation] : undefined
901
+
902
+ return (
903
+ <motion.div
904
+ key={event.id}
905
+ variants={variants}
906
+ initial={variants?.initial}
907
+ animate={variants?.animate}
908
+ exit={variants?.exit}
909
+ transition={{ duration: 0.3, delay: index * 0.05 }}
910
+ >
911
+ {eventContent}
912
+ </motion.div>
913
+ )
914
+ }, [
915
+ eventRenderer,
916
+ colorScheme,
917
+ selectedEvents,
918
+ expandedEvents,
919
+ focusedEventId,
920
+ keyboardNavigation,
921
+ layout,
922
+ compactMode,
923
+ printMode,
924
+ handleEventClick,
925
+ renderEventIcon,
926
+ renderConnector,
927
+ events,
928
+ showRelativeTime,
929
+ getRelativeTime,
930
+ formatTime,
931
+ editable,
932
+ onEventEdit,
933
+ onEventDelete,
934
+ showUserInfo,
935
+ getInitials,
936
+ showMetadata,
937
+ draggable,
938
+ onEventsReorder,
939
+ animation
940
+ ])
941
+
942
+ const renderGroupedEvents = useCallback(() => {
943
+ const groups = groupEventsByDate(filteredAndSortedEvents)
287
944
 
288
- return Object.entries(groups).map(([date, events], groupIndex) => (
289
- <div key={date} className="mb-8">
290
- <div className="sticky top-0 bg-background/80 backdrop-blur-sm border-b p-2 mb-4">
291
- <h3 className="font-medium text-sm text-muted-foreground">{date}</h3>
292
- </div>
293
- {events.map((event, index) => renderEvent(event, index, index === events.length - 1))}
294
- </div>
295
- ))
296
- }
945
+ return Object.entries(groups).map(([dateGroup, groupEvents], groupIndex) => {
946
+ const displayEvents = maxEventsPerGroup
947
+ ? groupEvents.slice(0, maxEventsPerGroup)
948
+ : groupEvents
949
+ const remainingCount = groupEvents.length - displayEvents.length
297
950
 
298
- return (
299
- <Card className={cn("w-full", className)}>
300
- <CardHeader>
301
- <CardTitle className="flex items-center gap-2">
302
- <Clock className="h-5 w-5" />
303
- Timeline
304
- </CardTitle>
305
- <CardDescription>
306
- {sortedEvents.length} event{sortedEvents.length !== 1 ? 's' : ''} tracked
307
- </CardDescription>
308
- </CardHeader>
309
- <CardContent>
310
- {sortedEvents.length > 0 ? (
951
+ return (
952
+ <div key={dateGroup} className="mb-8">
311
953
  <div className={cn(
312
- "space-y-0",
313
- orientation === 'horizontal' && "flex overflow-x-auto gap-6"
954
+ "sticky top-0 z-20 bg-background/80 backdrop-blur-sm border-b p-3 mb-4 -mx-6 px-6",
955
+ printMode && "print:relative print:bg-transparent"
314
956
  )}>
315
- {groupByDate
316
- ? renderGroupedEvents()
317
- : sortedEvents.map((event, index) =>
318
- renderEvent(event, index, index === sortedEvents.length - 1)
319
- )
320
- }
957
+ <h3 className="font-semibold text-sm text-muted-foreground flex items-center justify-between">
958
+ <span>{dateGroup}</span>
959
+ <Badge variant="secondary" className="text-xs">
960
+ {groupEvents.length} event{groupEvents.length !== 1 ? 's' : ''}
961
+ </Badge>
962
+ </h3>
321
963
  </div>
322
- ) : (
323
- <div className="text-center py-8">
324
- <Clock className="h-12 w-12 mx-auto mb-4 text-muted-foreground" />
325
- <p className="text-muted-foreground">No events to display</p>
964
+
965
+ <AnimatePresence mode="popLayout">
966
+ {displayEvents.map((event, index) =>
967
+ renderEvent(event, index, index === displayEvents.length - 1)
968
+ )}
969
+ </AnimatePresence>
970
+
971
+ {remainingCount > 0 && (
972
+ <Button
973
+ variant="ghost"
974
+ size="sm"
975
+ className="w-full mt-2"
976
+ onClick={() => {
977
+ // In a real implementation, this would expand the group
978
+ console.log(`Show ${remainingCount} more events`)
979
+ }}
980
+ >
981
+ Show {remainingCount} more event{remainingCount !== 1 ? 's' : ''}
982
+ </Button>
983
+ )}
984
+ </div>
985
+ )
986
+ })
987
+ }, [groupEventsByDate, filteredAndSortedEvents, maxEventsPerGroup, printMode, renderEvent])
988
+
989
+ const renderTimeline = () => {
990
+ const content = groupBy !== 'none'
991
+ ? renderGroupedEvents()
992
+ : (
993
+ <AnimatePresence mode="popLayout">
994
+ {draggable && onEventsReorder ? (
995
+ <Reorder.Group
996
+ axis={layout === 'horizontal' ? 'x' : 'y'}
997
+ values={filteredAndSortedEvents}
998
+ onReorder={(newOrder) => {
999
+ setEvents(newOrder)
1000
+ onEventsReorder(newOrder)
1001
+ }}
1002
+ className={cn(
1003
+ "space-y-0",
1004
+ layout === 'horizontal' && "flex overflow-x-auto gap-6 pb-4"
1005
+ )}
1006
+ >
1007
+ {filteredAndSortedEvents.map((event, index) =>
1008
+ renderEvent(event, index, index === filteredAndSortedEvents.length - 1)
1009
+ )}
1010
+ </Reorder.Group>
1011
+ ) : (
1012
+ <div className={cn(
1013
+ "space-y-0",
1014
+ layout === 'horizontal' && "flex overflow-x-auto gap-6 pb-4",
1015
+ layout === 'alternating' && "relative"
1016
+ )}>
1017
+ {filteredAndSortedEvents.map((event, index) =>
1018
+ renderEvent(event, index, index === filteredAndSortedEvents.length - 1)
1019
+ )}
1020
+ </div>
1021
+ )}
1022
+ </AnimatePresence>
1023
+ )
1024
+
1025
+ if (virtualScroll && filteredAndSortedEvents.length > 50) {
1026
+ // In a real implementation, use a virtual scrolling library
1027
+ return (
1028
+ <div className="h-[600px] overflow-y-auto">
1029
+ {content}
1030
+ </div>
1031
+ )
1032
+ }
1033
+
1034
+ return content
1035
+ }
1036
+
1037
+ return (
1038
+ <Card
1039
+ className={cn(
1040
+ timelineVariants({ theme }),
1041
+ "relative",
1042
+ printMode && "print:shadow-none print:border-0",
1043
+ className
1044
+ )}
1045
+ ref={timelineRef}
1046
+ role="region"
1047
+ aria-label={ariaLabel}
1048
+ {...props}
1049
+ >
1050
+ <CardHeader className={cn(
1051
+ compactMode && "pb-3",
1052
+ printMode && "print:pb-2"
1053
+ )}>
1054
+ <div className="flex items-center justify-between">
1055
+ <div>
1056
+ <CardTitle className="flex items-center gap-2">
1057
+ <Clock className="h-5 w-5" />
1058
+ Timeline
1059
+ <Badge variant="pro" className="ml-2">
1060
+ <Sparkles className="mr-1 h-3 w-3" />
1061
+ Pro
1062
+ </Badge>
1063
+ </CardTitle>
1064
+ <CardDescription>
1065
+ {filteredAndSortedEvents.length} event{filteredAndSortedEvents.length !== 1 ? 's' : ''}
1066
+ {searchQuery && ` matching "${searchQuery}"`}
1067
+ {filterType !== 'all' && ` of type ${filterType}`}
1068
+ </CardDescription>
1069
+ </div>
1070
+
1071
+ {/* Actions */}
1072
+ <div className="flex items-center gap-2">
1073
+ {showExport && (
1074
+ <Button
1075
+ variant="ghost"
1076
+ size="sm"
1077
+ onClick={handleExport}
1078
+ className="print:hidden"
1079
+ >
1080
+ <Download className="h-4 w-4" />
1081
+ </Button>
1082
+ )}
1083
+ <Button
1084
+ variant="ghost"
1085
+ size="sm"
1086
+ onClick={handlePrint}
1087
+ className="print:hidden"
1088
+ >
1089
+ <Printer className="h-4 w-4" />
1090
+ </Button>
1091
+ </div>
326
1092
  </div>
327
- )}
328
- </CardContent>
329
- </Card>
1093
+
1094
+ {/* Search and Filter */}
1095
+ {(showSearch || showFilter) && (
1096
+ <div className="flex gap-2 mt-4 print:hidden">
1097
+ {showSearch && (
1098
+ <div className="relative flex-1">
1099
+ <Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
1100
+ <Input
1101
+ type="search"
1102
+ placeholder="Search events..."
1103
+ value={searchQuery}
1104
+ onChange={(e) => setSearchQuery(e.target.value)}
1105
+ className="pl-9"
1106
+ />
1107
+ </div>
1108
+ )}
1109
+
1110
+ {showFilter && (
1111
+ <Select value={filterType} onValueChange={(value: any) => setFilterType(value)}>
1112
+ <SelectTrigger className="w-[150px]">
1113
+ <Filter className="mr-2 h-4 w-4" />
1114
+ <SelectValue placeholder="Filter by type" />
1115
+ </SelectTrigger>
1116
+ <SelectContent>
1117
+ <SelectItem value="all">All Types</SelectItem>
1118
+ <SelectItem value="success">Success</SelectItem>
1119
+ <SelectItem value="warning">Warning</SelectItem>
1120
+ <SelectItem value="error">Error</SelectItem>
1121
+ <SelectItem value="info">Info</SelectItem>
1122
+ <SelectItem value="pending">Pending</SelectItem>
1123
+ <SelectItem value="milestone">Milestone</SelectItem>
1124
+ </SelectContent>
1125
+ </Select>
1126
+ )}
1127
+ </div>
1128
+ )}
1129
+ </CardHeader>
1130
+
1131
+ <CardContent className={cn(
1132
+ compactMode && "pt-0",
1133
+ "relative"
1134
+ )}>
1135
+ {filteredAndSortedEvents.length > 0 ? (
1136
+ renderTimeline()
1137
+ ) : (
1138
+ <div className="text-center py-12">
1139
+ <Clock className="h-12 w-12 mx-auto mb-4 text-muted-foreground" />
1140
+ <p className="text-muted-foreground">
1141
+ {searchQuery || filterType !== 'all'
1142
+ ? "No events match your filters"
1143
+ : "No events to display"}
1144
+ </p>
1145
+ </div>
1146
+ )}
1147
+ </CardContent>
1148
+ </Card>
330
1149
  )
331
1150
  }
332
1151