@moontra/moonui-pro 2.14.2 → 2.15.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.
- package/dist/index.d.ts +88 -23
- package/dist/index.mjs +681 -104
- package/package.json +1 -1
- package/src/components/timeline/index.tsx +963 -148
|
@@ -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
|
-
|
|
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:
|
|
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
|
|
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
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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
|
|
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
|
-
|
|
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 <
|
|
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
|
-
|
|
411
|
+
// Grouping functions
|
|
412
|
+
const groupEventsByDate = useCallback((events: TimelineEvent[]) => {
|
|
139
413
|
const groups: { [key: string]: TimelineEvent[] } = {}
|
|
140
414
|
|
|
141
415
|
events.forEach(event => {
|
|
142
|
-
|
|
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
|
-
|
|
153
|
-
|
|
154
|
-
|
|
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
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
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
|
-
|
|
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
|
-
|
|
169
|
-
|
|
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
|
|
174
|
-
{
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
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
|
-
|
|
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=
|
|
190
|
-
|
|
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-
|
|
194
|
-
|
|
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
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
{event.
|
|
204
|
-
|
|
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
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
<
|
|
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
|
-
|
|
735
|
+
)}
|
|
218
736
|
|
|
219
|
-
{/*
|
|
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
|
-
<
|
|
229
|
-
{event.user.name}
|
|
230
|
-
|
|
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.
|
|
262
|
-
<
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
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,332 @@ 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="
|
|
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
|
-
|
|
286
|
-
|
|
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(([
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
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
|
-
|
|
299
|
-
|
|
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
|
-
"
|
|
313
|
-
|
|
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
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
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
|
-
<
|
|
324
|
-
|
|
325
|
-
|
|
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
|
+
</CardTitle>
|
|
1060
|
+
<CardDescription>
|
|
1061
|
+
{filteredAndSortedEvents.length} event{filteredAndSortedEvents.length !== 1 ? 's' : ''}
|
|
1062
|
+
{searchQuery && ` matching "${searchQuery}"`}
|
|
1063
|
+
{filterType !== 'all' && ` of type ${filterType}`}
|
|
1064
|
+
</CardDescription>
|
|
1065
|
+
</div>
|
|
1066
|
+
|
|
1067
|
+
{/* Actions */}
|
|
1068
|
+
<div className="flex items-center gap-2">
|
|
1069
|
+
{showExport && (
|
|
1070
|
+
<Button
|
|
1071
|
+
variant="ghost"
|
|
1072
|
+
size="sm"
|
|
1073
|
+
onClick={handleExport}
|
|
1074
|
+
className="print:hidden"
|
|
1075
|
+
>
|
|
1076
|
+
<Download className="h-4 w-4" />
|
|
1077
|
+
</Button>
|
|
1078
|
+
)}
|
|
1079
|
+
<Button
|
|
1080
|
+
variant="ghost"
|
|
1081
|
+
size="sm"
|
|
1082
|
+
onClick={handlePrint}
|
|
1083
|
+
className="print:hidden"
|
|
1084
|
+
>
|
|
1085
|
+
<Printer className="h-4 w-4" />
|
|
1086
|
+
</Button>
|
|
1087
|
+
</div>
|
|
326
1088
|
</div>
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
1089
|
+
|
|
1090
|
+
{/* Search and Filter */}
|
|
1091
|
+
{(showSearch || showFilter) && (
|
|
1092
|
+
<div className="flex gap-2 mt-4 print:hidden">
|
|
1093
|
+
{showSearch && (
|
|
1094
|
+
<div className="relative flex-1">
|
|
1095
|
+
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
|
1096
|
+
<Input
|
|
1097
|
+
type="search"
|
|
1098
|
+
placeholder="Search events..."
|
|
1099
|
+
value={searchQuery}
|
|
1100
|
+
onChange={(e) => setSearchQuery(e.target.value)}
|
|
1101
|
+
className="pl-9"
|
|
1102
|
+
/>
|
|
1103
|
+
</div>
|
|
1104
|
+
)}
|
|
1105
|
+
|
|
1106
|
+
{showFilter && (
|
|
1107
|
+
<Select value={filterType} onValueChange={(value: any) => setFilterType(value)}>
|
|
1108
|
+
<SelectTrigger className="w-[150px]">
|
|
1109
|
+
<Filter className="mr-2 h-4 w-4" />
|
|
1110
|
+
<SelectValue placeholder="Filter by type" />
|
|
1111
|
+
</SelectTrigger>
|
|
1112
|
+
<SelectContent>
|
|
1113
|
+
<SelectItem value="all">All Types</SelectItem>
|
|
1114
|
+
<SelectItem value="success">Success</SelectItem>
|
|
1115
|
+
<SelectItem value="warning">Warning</SelectItem>
|
|
1116
|
+
<SelectItem value="error">Error</SelectItem>
|
|
1117
|
+
<SelectItem value="info">Info</SelectItem>
|
|
1118
|
+
<SelectItem value="pending">Pending</SelectItem>
|
|
1119
|
+
<SelectItem value="milestone">Milestone</SelectItem>
|
|
1120
|
+
</SelectContent>
|
|
1121
|
+
</Select>
|
|
1122
|
+
)}
|
|
1123
|
+
</div>
|
|
1124
|
+
)}
|
|
1125
|
+
</CardHeader>
|
|
1126
|
+
|
|
1127
|
+
<CardContent className={cn(
|
|
1128
|
+
compactMode && "pt-0",
|
|
1129
|
+
"relative"
|
|
1130
|
+
)}>
|
|
1131
|
+
{filteredAndSortedEvents.length > 0 ? (
|
|
1132
|
+
renderTimeline()
|
|
1133
|
+
) : (
|
|
1134
|
+
<div className="text-center py-12">
|
|
1135
|
+
<Clock className="h-12 w-12 mx-auto mb-4 text-muted-foreground" />
|
|
1136
|
+
<p className="text-muted-foreground">
|
|
1137
|
+
{searchQuery || filterType !== 'all'
|
|
1138
|
+
? "No events match your filters"
|
|
1139
|
+
: "No events to display"}
|
|
1140
|
+
</p>
|
|
1141
|
+
</div>
|
|
1142
|
+
)}
|
|
1143
|
+
</CardContent>
|
|
1144
|
+
</Card>
|
|
330
1145
|
)
|
|
331
1146
|
}
|
|
332
1147
|
|