@moontra/moonui-pro 2.17.5 → 2.18.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.mjs +2003 -882
- package/package.json +3 -1
- package/src/components/github-stars/github-api.ts +413 -0
- package/src/components/github-stars/hooks.ts +362 -0
- package/src/components/github-stars/index.tsx +215 -288
- package/src/components/github-stars/types.ts +146 -0
- package/src/components/github-stars/variants.tsx +380 -0
- package/src/components/lazy-component/index.tsx +567 -85
- package/src/components/virtual-list/index.tsx +6 -105
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
"use client"
|
|
2
2
|
|
|
3
|
-
import React, { useState, useEffect, useRef, Suspense } from "react"
|
|
4
|
-
import { motion } from "framer-motion"
|
|
3
|
+
import React, { useState, useEffect, useRef, Suspense, useCallback, useMemo } from "react"
|
|
4
|
+
import { motion, AnimatePresence } from "framer-motion"
|
|
5
5
|
import { Card, CardContent } from "../ui/card"
|
|
6
6
|
import { Button } from "../ui/button"
|
|
7
7
|
import { MoonUISkeletonPro as Skeleton } from "../ui/skeleton"
|
|
8
8
|
import { cn } from "../../lib/utils"
|
|
9
|
-
import { Eye, Loader2, Lock, Sparkles, RefreshCw } from "lucide-react"
|
|
9
|
+
import { Eye, Loader2, Lock, Sparkles, RefreshCw, Activity, Zap, BarChart3, CheckCircle2, XCircle } from "lucide-react"
|
|
10
10
|
import { useSubscription } from "../../hooks/use-subscription"
|
|
11
11
|
|
|
12
12
|
interface LazyComponentProps {
|
|
@@ -18,9 +18,39 @@ interface LazyComponentProps {
|
|
|
18
18
|
disabled?: boolean
|
|
19
19
|
onLoad?: () => void
|
|
20
20
|
onVisible?: () => void
|
|
21
|
+
onError?: (error: Error) => void
|
|
21
22
|
showLoadingState?: boolean
|
|
22
23
|
delay?: number
|
|
23
24
|
className?: string
|
|
25
|
+
// Advanced features
|
|
26
|
+
priority?: 'high' | 'normal' | 'low'
|
|
27
|
+
retryCount?: number
|
|
28
|
+
retryDelay?: number
|
|
29
|
+
enableAnalytics?: boolean
|
|
30
|
+
analyticsCallback?: (metrics: LazyLoadMetrics) => void
|
|
31
|
+
prefetch?: boolean
|
|
32
|
+
prefetchDelay?: number
|
|
33
|
+
fadeInDuration?: number
|
|
34
|
+
blurDataURL?: string
|
|
35
|
+
enableProgressiveEnhancement?: boolean
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
interface LazyLoadMetrics {
|
|
39
|
+
loadTime: number
|
|
40
|
+
visibilityTime: number
|
|
41
|
+
retryAttempts: number
|
|
42
|
+
success: boolean
|
|
43
|
+
componentName?: string
|
|
44
|
+
timestamp: number
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
interface LoadingState {
|
|
48
|
+
isVisible: boolean
|
|
49
|
+
isLoaded: boolean
|
|
50
|
+
hasError: boolean
|
|
51
|
+
error?: Error
|
|
52
|
+
retryCount: number
|
|
53
|
+
metrics: LazyLoadMetrics
|
|
24
54
|
}
|
|
25
55
|
|
|
26
56
|
const LazyComponentInternal: React.FC<LazyComponentProps> = ({
|
|
@@ -32,47 +62,160 @@ const LazyComponentInternal: React.FC<LazyComponentProps> = ({
|
|
|
32
62
|
disabled = false,
|
|
33
63
|
onLoad,
|
|
34
64
|
onVisible,
|
|
65
|
+
onError,
|
|
35
66
|
showLoadingState = true,
|
|
36
67
|
delay = 0,
|
|
37
|
-
className
|
|
68
|
+
className,
|
|
69
|
+
priority = 'normal',
|
|
70
|
+
retryCount = 3,
|
|
71
|
+
retryDelay = 1000,
|
|
72
|
+
enableAnalytics = false,
|
|
73
|
+
analyticsCallback,
|
|
74
|
+
prefetch = false,
|
|
75
|
+
prefetchDelay = 2000,
|
|
76
|
+
fadeInDuration = 300,
|
|
77
|
+
blurDataURL,
|
|
78
|
+
enableProgressiveEnhancement = true
|
|
38
79
|
}) => {
|
|
39
|
-
const [
|
|
40
|
-
|
|
80
|
+
const [loadingState, setLoadingState] = useState<LoadingState>({
|
|
81
|
+
isVisible: disabled,
|
|
82
|
+
isLoaded: disabled,
|
|
83
|
+
hasError: false,
|
|
84
|
+
retryCount: 0,
|
|
85
|
+
metrics: {
|
|
86
|
+
loadTime: 0,
|
|
87
|
+
visibilityTime: 0,
|
|
88
|
+
retryAttempts: 0,
|
|
89
|
+
success: false,
|
|
90
|
+
timestamp: Date.now()
|
|
91
|
+
}
|
|
92
|
+
})
|
|
41
93
|
const [hasTriggered, setHasTriggered] = useState(false)
|
|
42
94
|
const containerRef = useRef<HTMLDivElement>(null)
|
|
95
|
+
const visibilityTimeRef = useRef<number>(0)
|
|
96
|
+
const loadStartTimeRef = useRef<number>(0)
|
|
97
|
+
|
|
98
|
+
// Retry logic
|
|
99
|
+
const handleRetry = useCallback(() => {
|
|
100
|
+
setLoadingState(prev => ({
|
|
101
|
+
...prev,
|
|
102
|
+
hasError: false,
|
|
103
|
+
retryCount: prev.retryCount + 1
|
|
104
|
+
}))
|
|
105
|
+
loadStartTimeRef.current = Date.now()
|
|
106
|
+
|
|
107
|
+
// Simulate retry with delay
|
|
108
|
+
setTimeout(() => {
|
|
109
|
+
try {
|
|
110
|
+
setLoadingState(prev => ({
|
|
111
|
+
...prev,
|
|
112
|
+
isLoaded: true,
|
|
113
|
+
hasError: false,
|
|
114
|
+
metrics: {
|
|
115
|
+
...prev.metrics,
|
|
116
|
+
loadTime: Date.now() - loadStartTimeRef.current,
|
|
117
|
+
success: true,
|
|
118
|
+
retryAttempts: prev.retryCount
|
|
119
|
+
}
|
|
120
|
+
}))
|
|
121
|
+
|
|
122
|
+
if (enableAnalytics && analyticsCallback) {
|
|
123
|
+
analyticsCallback(loadingState.metrics)
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
onLoad?.()
|
|
127
|
+
} catch (error) {
|
|
128
|
+
if (loadingState.retryCount < retryCount) {
|
|
129
|
+
setTimeout(handleRetry, retryDelay)
|
|
130
|
+
} else {
|
|
131
|
+
setLoadingState(prev => ({
|
|
132
|
+
...prev,
|
|
133
|
+
hasError: true,
|
|
134
|
+
error: error as Error
|
|
135
|
+
}))
|
|
136
|
+
onError?.(error as Error)
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}, delay)
|
|
140
|
+
}, [delay, retryCount, retryDelay, onLoad, onError, enableAnalytics, analyticsCallback, loadingState.metrics, loadingState.retryCount])
|
|
141
|
+
|
|
142
|
+
// Prefetch logic
|
|
143
|
+
useEffect(() => {
|
|
144
|
+
if (prefetch && !disabled) {
|
|
145
|
+
const prefetchTimer = setTimeout(() => {
|
|
146
|
+
// Prefetch logic here
|
|
147
|
+
console.log('Prefetching component...')
|
|
148
|
+
}, prefetchDelay)
|
|
149
|
+
|
|
150
|
+
return () => clearTimeout(prefetchTimer)
|
|
151
|
+
}
|
|
152
|
+
}, [prefetch, prefetchDelay, disabled])
|
|
43
153
|
|
|
44
154
|
useEffect(() => {
|
|
45
155
|
if (disabled) {
|
|
46
|
-
|
|
47
|
-
|
|
156
|
+
setLoadingState(prev => ({
|
|
157
|
+
...prev,
|
|
158
|
+
isVisible: true,
|
|
159
|
+
isLoaded: true
|
|
160
|
+
}))
|
|
48
161
|
return
|
|
49
162
|
}
|
|
50
163
|
|
|
51
164
|
const observer = new IntersectionObserver(
|
|
52
165
|
([entry]) => {
|
|
53
166
|
if (entry.isIntersecting && (!triggerOnce || !hasTriggered)) {
|
|
167
|
+
visibilityTimeRef.current = Date.now()
|
|
168
|
+
|
|
54
169
|
if (delay > 0) {
|
|
55
170
|
setTimeout(() => {
|
|
56
|
-
|
|
171
|
+
setLoadingState(prev => ({ ...prev, isVisible: true }))
|
|
57
172
|
setHasTriggered(true)
|
|
58
173
|
onVisible?.()
|
|
59
174
|
|
|
60
|
-
|
|
175
|
+
loadStartTimeRef.current = Date.now()
|
|
176
|
+
|
|
177
|
+
// Simulate loading with priority
|
|
178
|
+
const loadDelay = priority === 'high' ? delay * 0.5 : priority === 'low' ? delay * 1.5 : delay
|
|
179
|
+
|
|
61
180
|
setTimeout(() => {
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
181
|
+
try {
|
|
182
|
+
setLoadingState(prev => ({
|
|
183
|
+
...prev,
|
|
184
|
+
isLoaded: true,
|
|
185
|
+
metrics: {
|
|
186
|
+
...prev.metrics,
|
|
187
|
+
loadTime: Date.now() - loadStartTimeRef.current,
|
|
188
|
+
visibilityTime: Date.now() - visibilityTimeRef.current,
|
|
189
|
+
success: true
|
|
190
|
+
}
|
|
191
|
+
}))
|
|
192
|
+
|
|
193
|
+
if (enableAnalytics && analyticsCallback) {
|
|
194
|
+
analyticsCallback(loadingState.metrics)
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
onLoad?.()
|
|
198
|
+
} catch (error) {
|
|
199
|
+
handleRetry()
|
|
200
|
+
}
|
|
201
|
+
}, loadDelay)
|
|
65
202
|
}, 100)
|
|
66
203
|
} else {
|
|
67
|
-
|
|
68
|
-
|
|
204
|
+
setLoadingState(prev => ({
|
|
205
|
+
...prev,
|
|
206
|
+
isVisible: true,
|
|
207
|
+
isLoaded: true
|
|
208
|
+
}))
|
|
69
209
|
setHasTriggered(true)
|
|
70
210
|
onVisible?.()
|
|
71
211
|
onLoad?.()
|
|
72
212
|
}
|
|
73
213
|
} else if (!entry.isIntersecting && !triggerOnce) {
|
|
74
|
-
|
|
75
|
-
|
|
214
|
+
setLoadingState(prev => ({
|
|
215
|
+
...prev,
|
|
216
|
+
isVisible: false,
|
|
217
|
+
isLoaded: false
|
|
218
|
+
}))
|
|
76
219
|
}
|
|
77
220
|
},
|
|
78
221
|
{
|
|
@@ -90,9 +233,40 @@ const LazyComponentInternal: React.FC<LazyComponentProps> = ({
|
|
|
90
233
|
observer.unobserve(containerRef.current)
|
|
91
234
|
}
|
|
92
235
|
}
|
|
93
|
-
}, [threshold, rootMargin, triggerOnce, delay, disabled, hasTriggered, onLoad, onVisible])
|
|
236
|
+
}, [threshold, rootMargin, triggerOnce, delay, disabled, hasTriggered, onLoad, onVisible, priority, enableAnalytics, analyticsCallback, handleRetry])
|
|
94
237
|
|
|
95
238
|
const renderFallback = () => {
|
|
239
|
+
if (loadingState.hasError) {
|
|
240
|
+
return (
|
|
241
|
+
<motion.div
|
|
242
|
+
initial={{ opacity: 0 }}
|
|
243
|
+
animate={{ opacity: 1 }}
|
|
244
|
+
className="flex items-center justify-center p-8 border border-destructive/50 rounded-lg bg-destructive/10"
|
|
245
|
+
>
|
|
246
|
+
<div className="text-center space-y-3">
|
|
247
|
+
<XCircle className="h-8 w-8 mx-auto text-destructive" />
|
|
248
|
+
<div>
|
|
249
|
+
<p className="text-sm font-medium">Failed to load component</p>
|
|
250
|
+
<p className="text-xs text-muted-foreground mt-1">
|
|
251
|
+
{loadingState.error?.message || 'An error occurred'}
|
|
252
|
+
</p>
|
|
253
|
+
</div>
|
|
254
|
+
{loadingState.retryCount < retryCount && (
|
|
255
|
+
<Button
|
|
256
|
+
size="sm"
|
|
257
|
+
variant="outline"
|
|
258
|
+
onClick={handleRetry}
|
|
259
|
+
className="mt-2"
|
|
260
|
+
>
|
|
261
|
+
<RefreshCw className="h-3 w-3 mr-2" />
|
|
262
|
+
Retry ({loadingState.retryCount}/{retryCount})
|
|
263
|
+
</Button>
|
|
264
|
+
)}
|
|
265
|
+
</div>
|
|
266
|
+
</motion.div>
|
|
267
|
+
)
|
|
268
|
+
}
|
|
269
|
+
|
|
96
270
|
if (fallback) {
|
|
97
271
|
return fallback
|
|
98
272
|
}
|
|
@@ -102,18 +276,53 @@ const LazyComponentInternal: React.FC<LazyComponentProps> = ({
|
|
|
102
276
|
<motion.div
|
|
103
277
|
initial={{ opacity: 0 }}
|
|
104
278
|
animate={{ opacity: 1 }}
|
|
105
|
-
className="
|
|
279
|
+
className="relative"
|
|
106
280
|
>
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
<
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
</
|
|
116
|
-
|
|
281
|
+
{blurDataURL && enableProgressiveEnhancement ? (
|
|
282
|
+
<div className="relative overflow-hidden rounded-lg">
|
|
283
|
+
<img
|
|
284
|
+
src={blurDataURL}
|
|
285
|
+
alt="Loading placeholder"
|
|
286
|
+
className="w-full h-32 object-cover filter blur-xl"
|
|
287
|
+
/>
|
|
288
|
+
<div className="absolute inset-0 bg-background/50 backdrop-blur-sm" />
|
|
289
|
+
</div>
|
|
290
|
+
) : (
|
|
291
|
+
<div className="flex items-center justify-center p-8 bg-muted/50 rounded-lg">
|
|
292
|
+
<div className="text-center space-y-3">
|
|
293
|
+
{delay > 0 && loadingState.isVisible && !loadingState.isLoaded ? (
|
|
294
|
+
<>
|
|
295
|
+
<div className="relative">
|
|
296
|
+
<Loader2 className="h-8 w-8 animate-spin mx-auto text-primary" />
|
|
297
|
+
{priority === 'high' && (
|
|
298
|
+
<Zap className="h-3 w-3 absolute -top-1 -right-1 text-yellow-500" />
|
|
299
|
+
)}
|
|
300
|
+
</div>
|
|
301
|
+
<div className="space-y-1">
|
|
302
|
+
<p className="text-sm font-medium">Loading component...</p>
|
|
303
|
+
<p className="text-xs text-muted-foreground">
|
|
304
|
+
Priority: {priority} • Attempt {loadingState.retryCount + 1}
|
|
305
|
+
</p>
|
|
306
|
+
</div>
|
|
307
|
+
</>
|
|
308
|
+
) : (
|
|
309
|
+
<>
|
|
310
|
+
<Eye className="h-8 w-8 mx-auto text-muted-foreground" />
|
|
311
|
+
<p className="text-sm text-muted-foreground">
|
|
312
|
+
Scroll to load content
|
|
313
|
+
</p>
|
|
314
|
+
</>
|
|
315
|
+
)}
|
|
316
|
+
</div>
|
|
317
|
+
</div>
|
|
318
|
+
)}
|
|
319
|
+
|
|
320
|
+
{enableAnalytics && loadingState.isVisible && !loadingState.isLoaded && (
|
|
321
|
+
<div className="absolute top-2 right-2 flex items-center gap-2 text-xs text-muted-foreground bg-background/80 backdrop-blur-sm px-2 py-1 rounded">
|
|
322
|
+
<Activity className="h-3 w-3" />
|
|
323
|
+
<span>Tracking performance</span>
|
|
324
|
+
</div>
|
|
325
|
+
)}
|
|
117
326
|
</motion.div>
|
|
118
327
|
)
|
|
119
328
|
}
|
|
@@ -123,17 +332,35 @@ const LazyComponentInternal: React.FC<LazyComponentProps> = ({
|
|
|
123
332
|
|
|
124
333
|
return (
|
|
125
334
|
<div ref={containerRef} className={cn("w-full", className)}>
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
335
|
+
<AnimatePresence mode="wait">
|
|
336
|
+
{loadingState.isLoaded ? (
|
|
337
|
+
<motion.div
|
|
338
|
+
key="content"
|
|
339
|
+
initial={{ opacity: 0, y: 20 }}
|
|
340
|
+
animate={{ opacity: 1, y: 0 }}
|
|
341
|
+
exit={{ opacity: 0, y: -20 }}
|
|
342
|
+
transition={{ duration: fadeInDuration / 1000 }}
|
|
343
|
+
>
|
|
344
|
+
{enableAnalytics && (
|
|
345
|
+
<div className="absolute -top-8 right-0 flex items-center gap-2 text-xs text-muted-foreground bg-background/80 backdrop-blur-sm px-2 py-1 rounded">
|
|
346
|
+
<CheckCircle2 className="h-3 w-3 text-green-500" />
|
|
347
|
+
<span>Loaded in {loadingState.metrics.loadTime}ms</span>
|
|
348
|
+
</div>
|
|
349
|
+
)}
|
|
350
|
+
{children}
|
|
351
|
+
</motion.div>
|
|
352
|
+
) : (
|
|
353
|
+
<motion.div
|
|
354
|
+
key="fallback"
|
|
355
|
+
initial={{ opacity: 0 }}
|
|
356
|
+
animate={{ opacity: 1 }}
|
|
357
|
+
exit={{ opacity: 0 }}
|
|
358
|
+
transition={{ duration: 0.2 }}
|
|
359
|
+
>
|
|
360
|
+
{renderFallback()}
|
|
361
|
+
</motion.div>
|
|
362
|
+
)}
|
|
363
|
+
</AnimatePresence>
|
|
137
364
|
</div>
|
|
138
365
|
)
|
|
139
366
|
}
|
|
@@ -149,6 +376,17 @@ export interface LazyImageProps extends React.ImgHTMLAttributes<HTMLImageElement
|
|
|
149
376
|
onLoad?: () => void
|
|
150
377
|
onError?: () => void
|
|
151
378
|
className?: string
|
|
379
|
+
// Advanced features
|
|
380
|
+
quality?: number
|
|
381
|
+
priority?: boolean
|
|
382
|
+
blur?: boolean
|
|
383
|
+
blurDataURL?: string
|
|
384
|
+
aspectRatio?: string
|
|
385
|
+
objectFit?: 'cover' | 'contain' | 'fill' | 'none' | 'scale-down'
|
|
386
|
+
sizes?: string
|
|
387
|
+
srcSet?: string
|
|
388
|
+
enableProgressiveLoading?: boolean
|
|
389
|
+
onProgress?: (progress: number) => void
|
|
152
390
|
}
|
|
153
391
|
|
|
154
392
|
export const LazyImage: React.FC<LazyImageProps> = ({
|
|
@@ -161,11 +399,23 @@ export const LazyImage: React.FC<LazyImageProps> = ({
|
|
|
161
399
|
onLoad,
|
|
162
400
|
onError,
|
|
163
401
|
className,
|
|
402
|
+
quality = 85,
|
|
403
|
+
priority = false,
|
|
404
|
+
blur = true,
|
|
405
|
+
blurDataURL,
|
|
406
|
+
aspectRatio,
|
|
407
|
+
objectFit = 'cover',
|
|
408
|
+
sizes,
|
|
409
|
+
srcSet,
|
|
410
|
+
enableProgressiveLoading = false,
|
|
411
|
+
onProgress,
|
|
164
412
|
...props
|
|
165
413
|
}) => {
|
|
166
|
-
const [imageSrc, setImageSrc] = useState<string | null>(null)
|
|
414
|
+
const [imageSrc, setImageSrc] = useState<string | null>(priority ? src : null)
|
|
167
415
|
const [imageError, setImageError] = useState(false)
|
|
168
|
-
const [isVisible, setIsVisible] = useState(
|
|
416
|
+
const [isVisible, setIsVisible] = useState(priority)
|
|
417
|
+
const [loadProgress, setLoadProgress] = useState(0)
|
|
418
|
+
const [isLoading, setIsLoading] = useState(false)
|
|
169
419
|
const imgRef = useRef<HTMLImageElement>(null)
|
|
170
420
|
const containerRef = useRef<HTMLDivElement>(null)
|
|
171
421
|
|
|
@@ -188,42 +438,137 @@ export const LazyImage: React.FC<LazyImageProps> = ({
|
|
|
188
438
|
}, [threshold, rootMargin])
|
|
189
439
|
|
|
190
440
|
useEffect(() => {
|
|
191
|
-
if (!isVisible) return
|
|
441
|
+
if (!isVisible || imageSrc) return
|
|
192
442
|
|
|
443
|
+
setIsLoading(true)
|
|
193
444
|
const img = new Image()
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
445
|
+
|
|
446
|
+
// Progressive loading simulation
|
|
447
|
+
if (enableProgressiveLoading) {
|
|
448
|
+
let progress = 0
|
|
449
|
+
const progressInterval = setInterval(() => {
|
|
450
|
+
progress += Math.random() * 30
|
|
451
|
+
if (progress > 90) progress = 90
|
|
452
|
+
setLoadProgress(progress)
|
|
453
|
+
onProgress?.(progress)
|
|
454
|
+
}, 100)
|
|
455
|
+
|
|
456
|
+
img.onload = () => {
|
|
457
|
+
clearInterval(progressInterval)
|
|
458
|
+
setLoadProgress(100)
|
|
459
|
+
onProgress?.(100)
|
|
460
|
+
setImageSrc(src)
|
|
461
|
+
setIsLoading(false)
|
|
462
|
+
onLoad?.()
|
|
463
|
+
}
|
|
464
|
+
} else {
|
|
465
|
+
img.onload = () => {
|
|
466
|
+
setImageSrc(src)
|
|
467
|
+
setIsLoading(false)
|
|
468
|
+
onLoad?.()
|
|
469
|
+
}
|
|
197
470
|
}
|
|
471
|
+
|
|
198
472
|
img.onerror = () => {
|
|
199
473
|
setImageError(true)
|
|
474
|
+
setIsLoading(false)
|
|
200
475
|
if (fallbackSrc) {
|
|
201
476
|
setImageSrc(fallbackSrc)
|
|
202
477
|
}
|
|
203
478
|
onError?.()
|
|
204
479
|
}
|
|
205
|
-
|
|
206
|
-
|
|
480
|
+
|
|
481
|
+
// Add quality parameter to image URL if supported
|
|
482
|
+
const qualityParam = src.includes('?') ? `&q=${quality}` : `?q=${quality}`
|
|
483
|
+
img.src = src + (src.includes('http') ? qualityParam : '')
|
|
484
|
+
}, [isVisible, src, fallbackSrc, onLoad, onError, quality, enableProgressiveLoading, onProgress, imageSrc])
|
|
207
485
|
|
|
208
486
|
return (
|
|
209
|
-
<div
|
|
210
|
-
{
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
487
|
+
<div
|
|
488
|
+
ref={containerRef}
|
|
489
|
+
className={cn("relative overflow-hidden bg-muted", className)}
|
|
490
|
+
style={aspectRatio ? { aspectRatio } : undefined}
|
|
491
|
+
>
|
|
492
|
+
<AnimatePresence mode="wait">
|
|
493
|
+
{/* Blur placeholder */}
|
|
494
|
+
{blur && blurDataURL && !imageSrc && (
|
|
495
|
+
<motion.img
|
|
496
|
+
key="blur"
|
|
497
|
+
src={blurDataURL}
|
|
498
|
+
alt=""
|
|
499
|
+
initial={{ opacity: 0 }}
|
|
500
|
+
animate={{ opacity: 1 }}
|
|
501
|
+
exit={{ opacity: 0 }}
|
|
502
|
+
className="absolute inset-0 w-full h-full object-cover filter blur-xl scale-110"
|
|
503
|
+
/>
|
|
504
|
+
)}
|
|
505
|
+
|
|
506
|
+
{/* Main image */}
|
|
507
|
+
{imageSrc && (
|
|
508
|
+
<motion.img
|
|
509
|
+
key="main"
|
|
510
|
+
ref={imgRef}
|
|
511
|
+
src={imageSrc}
|
|
512
|
+
alt={alt}
|
|
513
|
+
sizes={sizes}
|
|
514
|
+
srcSet={srcSet}
|
|
515
|
+
initial={{ opacity: 0, scale: 1.02 }}
|
|
516
|
+
animate={{ opacity: 1, scale: 1 }}
|
|
517
|
+
transition={{ duration: 0.4 }}
|
|
518
|
+
className={cn("w-full h-full", `object-${objectFit}`)}
|
|
519
|
+
{...props}
|
|
520
|
+
/>
|
|
521
|
+
)}
|
|
522
|
+
|
|
523
|
+
{/* Loading state */}
|
|
524
|
+
{isLoading && showPlaceholder && (
|
|
525
|
+
<motion.div
|
|
526
|
+
key="loading"
|
|
527
|
+
initial={{ opacity: 0 }}
|
|
528
|
+
animate={{ opacity: 1 }}
|
|
529
|
+
exit={{ opacity: 0 }}
|
|
530
|
+
className="absolute inset-0 flex items-center justify-center bg-background/50 backdrop-blur-sm"
|
|
531
|
+
>
|
|
532
|
+
<div className="text-center">
|
|
533
|
+
<Loader2 className="h-8 w-8 animate-spin mx-auto text-primary mb-2" />
|
|
534
|
+
{enableProgressiveLoading && (
|
|
535
|
+
<div className="w-32 h-1 bg-muted rounded-full overflow-hidden">
|
|
536
|
+
<motion.div
|
|
537
|
+
className="h-full bg-primary"
|
|
538
|
+
initial={{ width: 0 }}
|
|
539
|
+
animate={{ width: `${loadProgress}%` }}
|
|
540
|
+
/>
|
|
541
|
+
</div>
|
|
542
|
+
)}
|
|
543
|
+
</div>
|
|
544
|
+
</motion.div>
|
|
545
|
+
)}
|
|
546
|
+
|
|
547
|
+
{/* Skeleton placeholder */}
|
|
548
|
+
{!imageSrc && !isLoading && showPlaceholder && (
|
|
549
|
+
<Skeleton className="absolute inset-0" />
|
|
550
|
+
)}
|
|
551
|
+
|
|
552
|
+
{/* Error state */}
|
|
553
|
+
{imageError && !fallbackSrc && (
|
|
554
|
+
<motion.div
|
|
555
|
+
initial={{ opacity: 0 }}
|
|
556
|
+
animate={{ opacity: 1 }}
|
|
557
|
+
className="absolute inset-0 flex items-center justify-center bg-muted"
|
|
558
|
+
>
|
|
559
|
+
<div className="text-center">
|
|
560
|
+
<XCircle className="h-8 w-8 mx-auto text-muted-foreground mb-2" />
|
|
561
|
+
<p className="text-sm text-muted-foreground">Failed to load image</p>
|
|
562
|
+
</div>
|
|
563
|
+
</motion.div>
|
|
564
|
+
)}
|
|
565
|
+
</AnimatePresence>
|
|
223
566
|
|
|
224
|
-
{
|
|
225
|
-
|
|
226
|
-
|
|
567
|
+
{/* Priority badge */}
|
|
568
|
+
{priority && (
|
|
569
|
+
<div className="absolute top-2 right-2 bg-yellow-500/90 text-yellow-900 text-xs px-2 py-1 rounded flex items-center gap-1">
|
|
570
|
+
<Zap className="h-3 w-3" />
|
|
571
|
+
Priority
|
|
227
572
|
</div>
|
|
228
573
|
)}
|
|
229
574
|
</div>
|
|
@@ -238,6 +583,16 @@ export interface LazyListProps<T> {
|
|
|
238
583
|
batchSize?: number
|
|
239
584
|
threshold?: number
|
|
240
585
|
className?: string
|
|
586
|
+
// Advanced features
|
|
587
|
+
enableVirtualization?: boolean
|
|
588
|
+
overscan?: number
|
|
589
|
+
showLoadingIndicator?: boolean
|
|
590
|
+
loadingIndicator?: React.ReactNode
|
|
591
|
+
emptyState?: React.ReactNode
|
|
592
|
+
onBatchLoad?: (startIndex: number, endIndex: number) => void
|
|
593
|
+
enableSmoothScroll?: boolean
|
|
594
|
+
staggerAnimation?: boolean
|
|
595
|
+
animationDelay?: number
|
|
241
596
|
}
|
|
242
597
|
|
|
243
598
|
export function LazyList<T>({
|
|
@@ -246,25 +601,63 @@ export function LazyList<T>({
|
|
|
246
601
|
itemHeight = 100,
|
|
247
602
|
batchSize = 10,
|
|
248
603
|
threshold = 0.5,
|
|
249
|
-
className
|
|
604
|
+
className,
|
|
605
|
+
enableVirtualization = false,
|
|
606
|
+
overscan = 3,
|
|
607
|
+
showLoadingIndicator = true,
|
|
608
|
+
loadingIndicator,
|
|
609
|
+
emptyState,
|
|
610
|
+
onBatchLoad,
|
|
611
|
+
enableSmoothScroll = true,
|
|
612
|
+
staggerAnimation = true,
|
|
613
|
+
animationDelay = 50
|
|
250
614
|
}: LazyListProps<T>) {
|
|
251
615
|
const [visibleItems, setVisibleItems] = useState<T[]>([])
|
|
252
616
|
const [currentBatch, setCurrentBatch] = useState(0)
|
|
617
|
+
const [isLoading, setIsLoading] = useState(false)
|
|
253
618
|
const loadingRef = useRef<HTMLDivElement>(null)
|
|
619
|
+
const containerRef = useRef<HTMLDivElement>(null)
|
|
620
|
+
const [scrollPosition, setScrollPosition] = useState(0)
|
|
254
621
|
|
|
255
622
|
useEffect(() => {
|
|
256
623
|
setVisibleItems(items.slice(0, batchSize))
|
|
257
624
|
setCurrentBatch(1)
|
|
258
625
|
}, [items, batchSize])
|
|
259
626
|
|
|
627
|
+
// Handle scroll for virtualization
|
|
628
|
+
const handleScroll = useCallback((e: Event) => {
|
|
629
|
+
if (enableVirtualization && containerRef.current) {
|
|
630
|
+
setScrollPosition((e.target as HTMLElement).scrollTop)
|
|
631
|
+
}
|
|
632
|
+
}, [enableVirtualization])
|
|
633
|
+
|
|
634
|
+
useEffect(() => {
|
|
635
|
+
const container = containerRef.current
|
|
636
|
+
if (enableVirtualization && container) {
|
|
637
|
+
container.addEventListener('scroll', handleScroll)
|
|
638
|
+
return () => container.removeEventListener('scroll', handleScroll)
|
|
639
|
+
}
|
|
640
|
+
}, [enableVirtualization, handleScroll])
|
|
641
|
+
|
|
260
642
|
useEffect(() => {
|
|
261
643
|
const observer = new IntersectionObserver(
|
|
262
644
|
([entry]) => {
|
|
263
|
-
if (entry.isIntersecting && currentBatch * batchSize < items.length) {
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
645
|
+
if (entry.isIntersecting && currentBatch * batchSize < items.length && !isLoading) {
|
|
646
|
+
setIsLoading(true)
|
|
647
|
+
|
|
648
|
+
// Simulate loading delay
|
|
649
|
+
setTimeout(() => {
|
|
650
|
+
const nextBatch = currentBatch + 1
|
|
651
|
+
const startIndex = currentBatch * batchSize
|
|
652
|
+
const endIndex = nextBatch * batchSize
|
|
653
|
+
const newItems = items.slice(0, endIndex)
|
|
654
|
+
|
|
655
|
+
setVisibleItems(newItems)
|
|
656
|
+
setCurrentBatch(nextBatch)
|
|
657
|
+
setIsLoading(false)
|
|
658
|
+
|
|
659
|
+
onBatchLoad?.(startIndex, endIndex)
|
|
660
|
+
}, 300)
|
|
268
661
|
}
|
|
269
662
|
},
|
|
270
663
|
{ threshold }
|
|
@@ -275,25 +668,114 @@ export function LazyList<T>({
|
|
|
275
668
|
}
|
|
276
669
|
|
|
277
670
|
return () => observer.disconnect()
|
|
278
|
-
}, [currentBatch, items, batchSize, threshold])
|
|
671
|
+
}, [currentBatch, items, batchSize, threshold, isLoading, onBatchLoad])
|
|
672
|
+
|
|
673
|
+
// Calculate visible range for virtualization
|
|
674
|
+
const visibleRange = useMemo(() => {
|
|
675
|
+
if (!enableVirtualization) return { start: 0, end: visibleItems.length }
|
|
676
|
+
|
|
677
|
+
const start = Math.floor(scrollPosition / itemHeight) - overscan
|
|
678
|
+
const visibleCount = Math.ceil(400 / itemHeight) // Assuming container height of 400px
|
|
679
|
+
const end = start + visibleCount + overscan * 2
|
|
680
|
+
|
|
681
|
+
return {
|
|
682
|
+
start: Math.max(0, start),
|
|
683
|
+
end: Math.min(visibleItems.length, end)
|
|
684
|
+
}
|
|
685
|
+
}, [scrollPosition, itemHeight, overscan, visibleItems.length, enableVirtualization])
|
|
686
|
+
|
|
687
|
+
if (items.length === 0 && emptyState) {
|
|
688
|
+
return (
|
|
689
|
+
<div className={cn("flex items-center justify-center p-8", className)}>
|
|
690
|
+
{emptyState}
|
|
691
|
+
</div>
|
|
692
|
+
)
|
|
693
|
+
}
|
|
279
694
|
|
|
280
695
|
return (
|
|
281
|
-
<div
|
|
282
|
-
{
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
696
|
+
<div
|
|
697
|
+
ref={containerRef}
|
|
698
|
+
className={cn(
|
|
699
|
+
"relative",
|
|
700
|
+
enableVirtualization && "overflow-auto max-h-[400px]",
|
|
701
|
+
enableSmoothScroll && "scroll-smooth",
|
|
702
|
+
className
|
|
703
|
+
)}
|
|
704
|
+
>
|
|
705
|
+
{enableVirtualization ? (
|
|
706
|
+
// Virtualized rendering
|
|
707
|
+
<>
|
|
708
|
+
<div style={{ height: visibleItems.length * itemHeight }} />
|
|
709
|
+
<div className="absolute top-0 left-0 right-0">
|
|
710
|
+
{visibleItems.slice(visibleRange.start, visibleRange.end).map((item, index) => {
|
|
711
|
+
const actualIndex = visibleRange.start + index
|
|
712
|
+
return (
|
|
713
|
+
<motion.div
|
|
714
|
+
key={actualIndex}
|
|
715
|
+
initial={staggerAnimation ? { opacity: 0, x: -20 } : { opacity: 1 }}
|
|
716
|
+
animate={{ opacity: 1, x: 0 }}
|
|
717
|
+
transition={staggerAnimation ? {
|
|
718
|
+
delay: index * (animationDelay / 1000),
|
|
719
|
+
duration: 0.3
|
|
720
|
+
} : undefined}
|
|
721
|
+
style={{
|
|
722
|
+
position: 'absolute',
|
|
723
|
+
top: actualIndex * itemHeight,
|
|
724
|
+
left: 0,
|
|
725
|
+
right: 0,
|
|
726
|
+
height: itemHeight
|
|
727
|
+
}}
|
|
728
|
+
>
|
|
729
|
+
{renderItem(item, actualIndex)}
|
|
730
|
+
</motion.div>
|
|
731
|
+
)
|
|
732
|
+
})}
|
|
733
|
+
</div>
|
|
734
|
+
</>
|
|
735
|
+
) : (
|
|
736
|
+
// Regular lazy loading
|
|
737
|
+
<div className="space-y-2">
|
|
738
|
+
{visibleItems.map((item, index) => (
|
|
739
|
+
<motion.div
|
|
740
|
+
key={index}
|
|
741
|
+
initial={staggerAnimation ? { opacity: 0, y: 20 } : { opacity: 1 }}
|
|
742
|
+
animate={{ opacity: 1, y: 0 }}
|
|
743
|
+
transition={staggerAnimation ? {
|
|
744
|
+
delay: (index % batchSize) * (animationDelay / 1000),
|
|
745
|
+
duration: 0.3
|
|
746
|
+
} : undefined}
|
|
747
|
+
style={{ minHeight: itemHeight }}
|
|
748
|
+
>
|
|
749
|
+
{renderItem(item, index)}
|
|
750
|
+
</motion.div>
|
|
751
|
+
))}
|
|
752
|
+
</div>
|
|
753
|
+
)}
|
|
293
754
|
|
|
294
755
|
{currentBatch * batchSize < items.length && (
|
|
295
756
|
<div ref={loadingRef} className="flex justify-center py-4">
|
|
296
|
-
|
|
757
|
+
{showLoadingIndicator && (
|
|
758
|
+
loadingIndicator || (
|
|
759
|
+
<div className="flex items-center gap-2">
|
|
760
|
+
<Loader2 className="h-5 w-5 animate-spin text-primary" />
|
|
761
|
+
<span className="text-sm text-muted-foreground">
|
|
762
|
+
Loading more items...
|
|
763
|
+
</span>
|
|
764
|
+
</div>
|
|
765
|
+
)
|
|
766
|
+
)}
|
|
767
|
+
</div>
|
|
768
|
+
)}
|
|
769
|
+
|
|
770
|
+
{/* Progress indicator */}
|
|
771
|
+
{items.length > batchSize && (
|
|
772
|
+
<div className="sticky bottom-0 left-0 right-0 bg-gradient-to-t from-background to-transparent pt-8 pb-2">
|
|
773
|
+
<div className="flex items-center justify-center gap-2 text-xs text-muted-foreground">
|
|
774
|
+
<BarChart3 className="h-3 w-3" />
|
|
775
|
+
<span>
|
|
776
|
+
Showing {visibleItems.length} of {items.length} items
|
|
777
|
+
</span>
|
|
778
|
+
</div>
|
|
297
779
|
</div>
|
|
298
780
|
)}
|
|
299
781
|
</div>
|