@djangocfg/widget-visual 0.1.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/LICENSE +21 -0
- package/README.md +41 -0
- package/package.json +106 -0
- package/src/design/ColorPalette/ColorPalette.tsx +129 -0
- package/src/design/ColorPalette/README.md +34 -0
- package/src/design/ColorPalette/color-palette.stories.tsx +95 -0
- package/src/design/ColorPalette/components/Swatch.tsx +102 -0
- package/src/design/ColorPalette/hooks/useCopyToClipboard.ts +41 -0
- package/src/design/ColorPalette/index.ts +12 -0
- package/src/design/ColorPalette/lazy.tsx +21 -0
- package/src/design/ColorPalette/types.ts +63 -0
- package/src/design/ColorPalette/utils.ts +114 -0
- package/src/design/ColorPicker/ColorPicker.tsx +257 -0
- package/src/design/ColorPicker/color-picker.stories.tsx +113 -0
- package/src/design/ColorPicker/context/ColorPickerContext.tsx +28 -0
- package/src/design/ColorPicker/context/ColorPickerStore.tsx +166 -0
- package/src/design/ColorPicker/context/index.ts +2 -0
- package/src/design/ColorPicker/index.ts +15 -0
- package/src/design/ColorPicker/lazy.tsx +24 -0
- package/src/design/ColorPicker/lib/color-utils.ts +323 -0
- package/src/design/ColorPicker/parts/ColorPickerAlphaSlider.tsx +72 -0
- package/src/design/ColorPicker/parts/ColorPickerArea.tsx +155 -0
- package/src/design/ColorPicker/parts/ColorPickerEyeDropper.tsx +73 -0
- package/src/design/ColorPicker/parts/ColorPickerFormatSelect.tsx +64 -0
- package/src/design/ColorPicker/parts/ColorPickerHueSlider.tsx +64 -0
- package/src/design/ColorPicker/parts/ColorPickerInput.tsx +512 -0
- package/src/design/ColorPicker/parts/ColorPickerSwatch.tsx +63 -0
- package/src/design/ColorPicker/parts/index.ts +7 -0
- package/src/design/ColorPicker/types.ts +77 -0
- package/src/gallery/components/Gallery.tsx +182 -0
- package/src/gallery/components/compact/GalleryCompact.tsx +396 -0
- package/src/gallery/components/compact/index.ts +1 -0
- package/src/gallery/components/index.ts +21 -0
- package/src/gallery/components/lightbox/GalleryLightbox.tsx +426 -0
- package/src/gallery/components/lightbox/index.ts +1 -0
- package/src/gallery/components/media/GalleryImage.tsx +105 -0
- package/src/gallery/components/media/GalleryMedia.tsx +70 -0
- package/src/gallery/components/media/GalleryVideo.tsx +241 -0
- package/src/gallery/components/media/index.ts +3 -0
- package/src/gallery/components/preview/GalleryCarousel.tsx +374 -0
- package/src/gallery/components/preview/GalleryGrid.tsx +519 -0
- package/src/gallery/components/preview/index.ts +2 -0
- package/src/gallery/components/shared/ImageSpinner.tsx +37 -0
- package/src/gallery/components/shared/index.ts +1 -0
- package/src/gallery/components/thumbnails/GalleryThumbnails.tsx +198 -0
- package/src/gallery/components/thumbnails/GalleryThumbnailsVirtual.tsx +164 -0
- package/src/gallery/components/thumbnails/index.ts +2 -0
- package/src/gallery/gallery.stories.tsx +182 -0
- package/src/gallery/hooks/index.ts +23 -0
- package/src/gallery/hooks/useGallery.ts +138 -0
- package/src/gallery/hooks/useImageDimensions.ts +224 -0
- package/src/gallery/hooks/usePinchZoom.ts +239 -0
- package/src/gallery/hooks/usePreloadImages.ts +119 -0
- package/src/gallery/hooks/useSwipe.ts +87 -0
- package/src/gallery/hooks/useVirtualList.ts +129 -0
- package/src/gallery/hooks/useZoom.ts +321 -0
- package/src/gallery/index.ts +66 -0
- package/src/gallery/thumbnails.stories.tsx +127 -0
- package/src/gallery/types.ts +185 -0
- package/src/gallery/utils/imageAnalysis.ts +52 -0
- package/src/gallery/utils/index.ts +16 -0
- package/src/gallery/utils/normalizeUrl.ts +31 -0
- package/src/index.ts +18 -0
- package/src/lottie/LottiePlayer.client.tsx +319 -0
- package/src/lottie/index.tsx +66 -0
- package/src/lottie/lazy.tsx +65 -0
- package/src/lottie/lottie-player.stories.tsx +173 -0
- package/src/lottie/types.ts +138 -0
- package/src/lottie/useLottie.ts +187 -0
- package/src/lottie/usePrefersReducedMotion.ts +46 -0
- package/src/marquee/Marquee.tsx +637 -0
- package/src/marquee/index.ts +7 -0
- package/src/marquee/lazy.tsx +14 -0
- package/src/marquee/marquee.stories.tsx +118 -0
- package/src/marquee/types.ts +40 -0
- package/src/qrcode/QRCode.tsx +468 -0
- package/src/qrcode/index.ts +10 -0
- package/src/qrcode/lazy.tsx +19 -0
- package/src/qrcode/qr-code.stories.tsx +102 -0
- package/src/qrcode/types.ts +57 -0
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { memo, useCallback, useMemo, useRef, useState } from 'react'
|
|
4
|
+
import { cn } from '@djangocfg/ui-core/lib'
|
|
5
|
+
import { useAppT } from '@djangocfg/i18n'
|
|
6
|
+
import { Play, Pause, Volume2, VolumeX, Maximize, AlertCircle } from 'lucide-react'
|
|
7
|
+
import type { GalleryMediaItem } from '../../types'
|
|
8
|
+
|
|
9
|
+
export interface GalleryVideoProps {
|
|
10
|
+
/** Video data */
|
|
11
|
+
media: GalleryMediaItem
|
|
12
|
+
/** Whether video should autoplay */
|
|
13
|
+
autoPlay?: boolean
|
|
14
|
+
/** Whether video should be muted by default */
|
|
15
|
+
muted?: boolean
|
|
16
|
+
/** Whether video should loop */
|
|
17
|
+
loop?: boolean
|
|
18
|
+
/** Show controls overlay */
|
|
19
|
+
showControls?: boolean
|
|
20
|
+
/** Additional CSS class */
|
|
21
|
+
className?: string
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* GalleryVideo - Video player for gallery items
|
|
26
|
+
*/
|
|
27
|
+
export const GalleryVideo = memo(function GalleryVideo({
|
|
28
|
+
media,
|
|
29
|
+
autoPlay = false,
|
|
30
|
+
muted = true,
|
|
31
|
+
loop = true,
|
|
32
|
+
showControls = true,
|
|
33
|
+
className,
|
|
34
|
+
}: GalleryVideoProps) {
|
|
35
|
+
const t = useAppT()
|
|
36
|
+
const videoRef = useRef<HTMLVideoElement>(null)
|
|
37
|
+
const [isPlaying, setIsPlaying] = useState(autoPlay)
|
|
38
|
+
const [isMuted, setIsMuted] = useState(muted)
|
|
39
|
+
const [showOverlay, setShowOverlay] = useState(true)
|
|
40
|
+
const [hasError, setHasError] = useState(false)
|
|
41
|
+
|
|
42
|
+
const labels = useMemo(() => ({
|
|
43
|
+
play: t('tools.video.play'),
|
|
44
|
+
fullscreen: t('tools.video.fullscreen'),
|
|
45
|
+
unavailable: t('tools.video.unavailable'),
|
|
46
|
+
}), [t])
|
|
47
|
+
|
|
48
|
+
const handlePlayPause = useCallback((e?: React.MouseEvent) => {
|
|
49
|
+
e?.stopPropagation()
|
|
50
|
+
const video = videoRef.current
|
|
51
|
+
if (!video) return
|
|
52
|
+
|
|
53
|
+
if (video.paused) {
|
|
54
|
+
// play() returns a promise that can reject (autoplay policy / no source)
|
|
55
|
+
const playPromise = video.play()
|
|
56
|
+
if (playPromise && typeof playPromise.then === 'function') {
|
|
57
|
+
playPromise.then(() => setIsPlaying(true)).catch(() => setIsPlaying(false))
|
|
58
|
+
} else {
|
|
59
|
+
setIsPlaying(true)
|
|
60
|
+
}
|
|
61
|
+
} else {
|
|
62
|
+
video.pause()
|
|
63
|
+
setIsPlaying(false)
|
|
64
|
+
}
|
|
65
|
+
}, [])
|
|
66
|
+
|
|
67
|
+
const handleMuteToggle = useCallback((e: React.MouseEvent) => {
|
|
68
|
+
e.stopPropagation()
|
|
69
|
+
const video = videoRef.current
|
|
70
|
+
if (!video) return
|
|
71
|
+
|
|
72
|
+
video.muted = !video.muted
|
|
73
|
+
setIsMuted(video.muted)
|
|
74
|
+
}, [])
|
|
75
|
+
|
|
76
|
+
const handleFullscreen = useCallback((e: React.MouseEvent) => {
|
|
77
|
+
e.stopPropagation()
|
|
78
|
+
const video = videoRef.current
|
|
79
|
+
if (!video) return
|
|
80
|
+
|
|
81
|
+
if (video.requestFullscreen) {
|
|
82
|
+
video.requestFullscreen()
|
|
83
|
+
}
|
|
84
|
+
}, [])
|
|
85
|
+
|
|
86
|
+
const handleVideoClick = useCallback((e: React.MouseEvent) => {
|
|
87
|
+
e.stopPropagation()
|
|
88
|
+
handlePlayPause()
|
|
89
|
+
}, [handlePlayPause])
|
|
90
|
+
|
|
91
|
+
const handleMouseEnter = useCallback(() => {
|
|
92
|
+
setShowOverlay(true)
|
|
93
|
+
}, [])
|
|
94
|
+
|
|
95
|
+
const handleMouseLeave = useCallback(() => {
|
|
96
|
+
if (isPlaying) {
|
|
97
|
+
setShowOverlay(false)
|
|
98
|
+
}
|
|
99
|
+
}, [isPlaying])
|
|
100
|
+
|
|
101
|
+
const handleError = useCallback(() => {
|
|
102
|
+
setHasError(true)
|
|
103
|
+
setIsPlaying(false)
|
|
104
|
+
}, [])
|
|
105
|
+
|
|
106
|
+
// Keep local state in sync with native video events (native controls, end of playback)
|
|
107
|
+
const handlePlay = useCallback(() => setIsPlaying(true), [])
|
|
108
|
+
const handlePause = useCallback(() => setIsPlaying(false), [])
|
|
109
|
+
const handleEnded = useCallback(() => {
|
|
110
|
+
setIsPlaying(false)
|
|
111
|
+
setShowOverlay(true)
|
|
112
|
+
}, [])
|
|
113
|
+
|
|
114
|
+
const handleContainerClick = useCallback((e: React.MouseEvent) => {
|
|
115
|
+
e.stopPropagation()
|
|
116
|
+
}, [])
|
|
117
|
+
|
|
118
|
+
if (!media.videoSrc) {
|
|
119
|
+
return null
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Error state - show poster with error overlay
|
|
123
|
+
if (hasError) {
|
|
124
|
+
return (
|
|
125
|
+
<div
|
|
126
|
+
data-nav
|
|
127
|
+
className={cn('relative w-full h-full bg-black', className)}
|
|
128
|
+
onClick={handleContainerClick}
|
|
129
|
+
>
|
|
130
|
+
{media.src && (
|
|
131
|
+
<img
|
|
132
|
+
src={media.src}
|
|
133
|
+
alt={media.alt || 'Video thumbnail'}
|
|
134
|
+
className="w-full h-full object-contain opacity-50"
|
|
135
|
+
/>
|
|
136
|
+
)}
|
|
137
|
+
<div className="absolute inset-0 flex flex-col items-center justify-center bg-black/50">
|
|
138
|
+
<AlertCircle className="w-12 h-12 text-white/70 mb-2" />
|
|
139
|
+
<span className="text-white/70 text-sm">{labels.unavailable}</span>
|
|
140
|
+
</div>
|
|
141
|
+
<div className="absolute top-3 left-3 px-2 py-1 bg-black/60 rounded text-white text-xs font-medium">
|
|
142
|
+
Video
|
|
143
|
+
</div>
|
|
144
|
+
</div>
|
|
145
|
+
)
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
return (
|
|
149
|
+
<div
|
|
150
|
+
data-nav
|
|
151
|
+
className={cn('relative w-full h-full bg-black', className)}
|
|
152
|
+
onMouseEnter={handleMouseEnter}
|
|
153
|
+
onMouseLeave={handleMouseLeave}
|
|
154
|
+
onClick={handleContainerClick}
|
|
155
|
+
>
|
|
156
|
+
<video
|
|
157
|
+
ref={videoRef}
|
|
158
|
+
className="w-full h-full object-contain"
|
|
159
|
+
src={media.videoSrc}
|
|
160
|
+
poster={media.src}
|
|
161
|
+
aria-label={media.alt || 'Video'}
|
|
162
|
+
autoPlay={autoPlay}
|
|
163
|
+
muted={muted}
|
|
164
|
+
loop={loop}
|
|
165
|
+
playsInline
|
|
166
|
+
onClick={handleVideoClick}
|
|
167
|
+
onError={handleError}
|
|
168
|
+
onPlay={handlePlay}
|
|
169
|
+
onPause={handlePause}
|
|
170
|
+
onEnded={handleEnded}
|
|
171
|
+
>
|
|
172
|
+
{media.videoType && (
|
|
173
|
+
<source src={media.videoSrc} type={media.videoType} />
|
|
174
|
+
)}
|
|
175
|
+
</video>
|
|
176
|
+
|
|
177
|
+
{/* Play button overlay */}
|
|
178
|
+
{!isPlaying && (
|
|
179
|
+
<div className="absolute inset-0 flex items-center justify-center bg-black/30">
|
|
180
|
+
<button
|
|
181
|
+
type="button"
|
|
182
|
+
className="w-16 h-16 rounded-full bg-white/90 flex items-center justify-center shadow-lg hover:bg-white transition-colors"
|
|
183
|
+
onClick={handlePlayPause}
|
|
184
|
+
aria-label={labels.play}
|
|
185
|
+
>
|
|
186
|
+
<Play className="w-8 h-8 text-black ml-1" />
|
|
187
|
+
</button>
|
|
188
|
+
</div>
|
|
189
|
+
)}
|
|
190
|
+
|
|
191
|
+
{/* Controls overlay */}
|
|
192
|
+
{showControls && showOverlay && isPlaying && (
|
|
193
|
+
<div className="absolute bottom-0 left-0 right-0 p-4 bg-gradient-to-t from-black/60 to-transparent">
|
|
194
|
+
<div className="flex items-center gap-3">
|
|
195
|
+
<button
|
|
196
|
+
type="button"
|
|
197
|
+
className="p-2 rounded-full bg-white/20 hover:bg-white/30 text-white transition-colors"
|
|
198
|
+
onClick={handlePlayPause}
|
|
199
|
+
aria-label={isPlaying ? 'Pause' : 'Play'}
|
|
200
|
+
>
|
|
201
|
+
{isPlaying ? (
|
|
202
|
+
<Pause className="w-5 h-5" />
|
|
203
|
+
) : (
|
|
204
|
+
<Play className="w-5 h-5" />
|
|
205
|
+
)}
|
|
206
|
+
</button>
|
|
207
|
+
|
|
208
|
+
<button
|
|
209
|
+
type="button"
|
|
210
|
+
className="p-2 rounded-full bg-white/20 hover:bg-white/30 text-white transition-colors"
|
|
211
|
+
onClick={handleMuteToggle}
|
|
212
|
+
aria-label={isMuted ? 'Unmute' : 'Mute'}
|
|
213
|
+
>
|
|
214
|
+
{isMuted ? (
|
|
215
|
+
<VolumeX className="w-5 h-5" />
|
|
216
|
+
) : (
|
|
217
|
+
<Volume2 className="w-5 h-5" />
|
|
218
|
+
)}
|
|
219
|
+
</button>
|
|
220
|
+
|
|
221
|
+
<div className="flex-1" />
|
|
222
|
+
|
|
223
|
+
<button
|
|
224
|
+
type="button"
|
|
225
|
+
className="p-2 rounded-full bg-white/20 hover:bg-white/30 text-white transition-colors"
|
|
226
|
+
onClick={handleFullscreen}
|
|
227
|
+
aria-label={labels.fullscreen}
|
|
228
|
+
>
|
|
229
|
+
<Maximize className="w-5 h-5" />
|
|
230
|
+
</button>
|
|
231
|
+
</div>
|
|
232
|
+
</div>
|
|
233
|
+
)}
|
|
234
|
+
|
|
235
|
+
{/* Video indicator badge */}
|
|
236
|
+
<div className="absolute top-3 left-3 px-2 py-1 bg-black/60 rounded text-white text-xs font-medium">
|
|
237
|
+
Video
|
|
238
|
+
</div>
|
|
239
|
+
</div>
|
|
240
|
+
)
|
|
241
|
+
})
|
|
@@ -0,0 +1,374 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import * as React from 'react'
|
|
4
|
+
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
|
5
|
+
import { cn } from '@djangocfg/ui-core/lib'
|
|
6
|
+
import {
|
|
7
|
+
Carousel,
|
|
8
|
+
CarouselContent,
|
|
9
|
+
CarouselItem,
|
|
10
|
+
CarouselPrevious,
|
|
11
|
+
CarouselNext,
|
|
12
|
+
type CarouselApi,
|
|
13
|
+
} from '@djangocfg/ui-core/components'
|
|
14
|
+
import { ZoomIn } from 'lucide-react'
|
|
15
|
+
import { GalleryMedia } from '../media'
|
|
16
|
+
import { GalleryThumbnails } from '../thumbnails'
|
|
17
|
+
import type { GalleryMediaItem } from '../../types'
|
|
18
|
+
|
|
19
|
+
export interface GalleryCarouselProps {
|
|
20
|
+
images: GalleryMediaItem[]
|
|
21
|
+
currentIndex: number
|
|
22
|
+
total: number
|
|
23
|
+
hasMultiple: boolean
|
|
24
|
+
initialIndex?: number
|
|
25
|
+
aspectRatio?: number
|
|
26
|
+
showControls?: boolean
|
|
27
|
+
showCounter?: boolean
|
|
28
|
+
showThumbnails?: boolean
|
|
29
|
+
enableLightbox?: boolean
|
|
30
|
+
enableKeyboard?: boolean
|
|
31
|
+
isLightboxOpen?: boolean
|
|
32
|
+
onApiChange?: (api: CarouselApi | undefined) => void
|
|
33
|
+
onIndexChange: (index: number) => void
|
|
34
|
+
onLightboxOpen?: () => void
|
|
35
|
+
onImageLoad?: () => void
|
|
36
|
+
className?: string
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* GalleryCarousel - Embla-based carousel for gallery images
|
|
41
|
+
*
|
|
42
|
+
* Features:
|
|
43
|
+
* - Smooth slide transitions
|
|
44
|
+
* - Touch/swipe support
|
|
45
|
+
* - Keyboard navigation
|
|
46
|
+
* - Mobile dots + desktop thumbnails
|
|
47
|
+
*/
|
|
48
|
+
export const GalleryCarousel = memo(function GalleryCarousel({
|
|
49
|
+
images,
|
|
50
|
+
currentIndex,
|
|
51
|
+
total,
|
|
52
|
+
hasMultiple,
|
|
53
|
+
initialIndex = 0,
|
|
54
|
+
aspectRatio = 16 / 9,
|
|
55
|
+
showControls = true,
|
|
56
|
+
showCounter = true,
|
|
57
|
+
showThumbnails = true,
|
|
58
|
+
enableLightbox = true,
|
|
59
|
+
enableKeyboard = true,
|
|
60
|
+
isLightboxOpen = false,
|
|
61
|
+
onApiChange,
|
|
62
|
+
onIndexChange,
|
|
63
|
+
onLightboxOpen,
|
|
64
|
+
onImageLoad,
|
|
65
|
+
className,
|
|
66
|
+
}: GalleryCarouselProps) {
|
|
67
|
+
const [api, setApi] = React.useState<CarouselApi>()
|
|
68
|
+
// Track if component is mounted (client-side) to avoid hydration mismatches
|
|
69
|
+
const [isMounted, setIsMounted] = useState(false)
|
|
70
|
+
// Stable key for carousel reset on images change
|
|
71
|
+
const carouselKey = images[0]?.id ?? 'empty'
|
|
72
|
+
|
|
73
|
+
useEffect(() => {
|
|
74
|
+
setIsMounted(true)
|
|
75
|
+
}, [])
|
|
76
|
+
|
|
77
|
+
// Notify parent when API is ready
|
|
78
|
+
useEffect(() => {
|
|
79
|
+
onApiChange?.(api)
|
|
80
|
+
}, [api, onApiChange])
|
|
81
|
+
|
|
82
|
+
// Mobile dots (max 5)
|
|
83
|
+
const mobileDots = useMemo(() => {
|
|
84
|
+
return images.slice(0, 5).map((_, index) => index)
|
|
85
|
+
}, [images])
|
|
86
|
+
|
|
87
|
+
const remainingCount = useMemo(() => {
|
|
88
|
+
return images.length > 5 ? images.length - 5 : 0
|
|
89
|
+
}, [images.length])
|
|
90
|
+
|
|
91
|
+
// Sync carousel with external state
|
|
92
|
+
useEffect(() => {
|
|
93
|
+
if (!api) return
|
|
94
|
+
api.scrollTo(currentIndex)
|
|
95
|
+
}, [api, currentIndex])
|
|
96
|
+
|
|
97
|
+
// Listen to carousel changes
|
|
98
|
+
useEffect(() => {
|
|
99
|
+
if (!api) return
|
|
100
|
+
|
|
101
|
+
const onSelect = () => {
|
|
102
|
+
const index = api.selectedScrollSnap()
|
|
103
|
+
if (index !== currentIndex) {
|
|
104
|
+
onIndexChange(index)
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
api.on('select', onSelect)
|
|
109
|
+
return () => {
|
|
110
|
+
api.off('select', onSelect)
|
|
111
|
+
}
|
|
112
|
+
}, [api, currentIndex, onIndexChange])
|
|
113
|
+
|
|
114
|
+
// Keyboard navigation
|
|
115
|
+
useEffect(() => {
|
|
116
|
+
if (!enableKeyboard || isLightboxOpen || !api) return
|
|
117
|
+
|
|
118
|
+
const handleKeyDown = (e: KeyboardEvent) => {
|
|
119
|
+
if (e.key === 'ArrowLeft' && hasMultiple) {
|
|
120
|
+
api.scrollPrev()
|
|
121
|
+
} else if (e.key === 'ArrowRight' && hasMultiple) {
|
|
122
|
+
api.scrollNext()
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
window.addEventListener('keydown', handleKeyDown)
|
|
127
|
+
return () => window.removeEventListener('keydown', handleKeyDown)
|
|
128
|
+
}, [enableKeyboard, isLightboxOpen, hasMultiple, api])
|
|
129
|
+
|
|
130
|
+
// Dot click handler
|
|
131
|
+
const handleDotClick = useCallback(
|
|
132
|
+
(e: React.MouseEvent, index: number) => {
|
|
133
|
+
e.stopPropagation()
|
|
134
|
+
api?.scrollTo(index)
|
|
135
|
+
},
|
|
136
|
+
[api]
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
// Scroll to index
|
|
140
|
+
const handleScrollTo = useCallback(
|
|
141
|
+
(index: number) => {
|
|
142
|
+
api?.scrollTo(index)
|
|
143
|
+
},
|
|
144
|
+
[api]
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
// Track pointer down position to distinguish click from drag
|
|
148
|
+
const pointerStartRef = useRef<{ x: number; y: number; time: number } | null>(null)
|
|
149
|
+
const CLICK_THRESHOLD = 10 // pixels
|
|
150
|
+
const CLICK_TIME_THRESHOLD = 300 // ms
|
|
151
|
+
|
|
152
|
+
const handlePointerDown = useCallback((e: React.PointerEvent) => {
|
|
153
|
+
pointerStartRef.current = { x: e.clientX, y: e.clientY, time: Date.now() }
|
|
154
|
+
}, [])
|
|
155
|
+
|
|
156
|
+
const handlePointerUp = useCallback((e: React.PointerEvent) => {
|
|
157
|
+
if (!pointerStartRef.current || !enableLightbox || !onLightboxOpen) return
|
|
158
|
+
|
|
159
|
+
const { x, y, time } = pointerStartRef.current
|
|
160
|
+
const dx = Math.abs(e.clientX - x)
|
|
161
|
+
const dy = Math.abs(e.clientY - y)
|
|
162
|
+
const dt = Date.now() - time
|
|
163
|
+
|
|
164
|
+
// Don't open lightbox when interacting with video controls
|
|
165
|
+
const target = e.target as HTMLElement
|
|
166
|
+
const isVideoInteraction =
|
|
167
|
+
target.tagName === 'VIDEO' || target.closest('video, [data-nav]') !== null
|
|
168
|
+
|
|
169
|
+
// Only trigger click if pointer didn't move much and was quick
|
|
170
|
+
if (
|
|
171
|
+
!isVideoInteraction &&
|
|
172
|
+
dx < CLICK_THRESHOLD &&
|
|
173
|
+
dy < CLICK_THRESHOLD &&
|
|
174
|
+
dt < CLICK_TIME_THRESHOLD
|
|
175
|
+
) {
|
|
176
|
+
onLightboxOpen()
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
pointerStartRef.current = null
|
|
180
|
+
}, [enableLightbox, onLightboxOpen])
|
|
181
|
+
|
|
182
|
+
// SSR fallback - render first image only to avoid hydration mismatch
|
|
183
|
+
// The carousel requires DOM access and will cause issues during SSR
|
|
184
|
+
if (!isMounted) {
|
|
185
|
+
const firstImage = images[0]
|
|
186
|
+
if (!firstImage) return null
|
|
187
|
+
return (
|
|
188
|
+
<div className={cn('space-y-3', className)}>
|
|
189
|
+
<div className="relative rounded-xl overflow-hidden">
|
|
190
|
+
<div
|
|
191
|
+
className="relative bg-muted"
|
|
192
|
+
style={{ aspectRatio }}
|
|
193
|
+
>
|
|
194
|
+
<GalleryMedia
|
|
195
|
+
media={firstImage}
|
|
196
|
+
className="w-full h-full"
|
|
197
|
+
priority
|
|
198
|
+
/>
|
|
199
|
+
{/* Dark overlay */}
|
|
200
|
+
<div className="absolute inset-0 bg-gradient-to-t from-black/40 via-transparent to-black/20 pointer-events-none" />
|
|
201
|
+
</div>
|
|
202
|
+
|
|
203
|
+
{/* Counter */}
|
|
204
|
+
{showCounter && hasMultiple && (
|
|
205
|
+
<div
|
|
206
|
+
className={cn(
|
|
207
|
+
'absolute bottom-3 right-3 z-10',
|
|
208
|
+
'bg-black/60 backdrop-blur-sm text-white px-3 py-1.5 rounded-full',
|
|
209
|
+
'text-sm font-medium'
|
|
210
|
+
)}
|
|
211
|
+
>
|
|
212
|
+
1 / {total}
|
|
213
|
+
</div>
|
|
214
|
+
)}
|
|
215
|
+
|
|
216
|
+
{/* Mobile dots */}
|
|
217
|
+
{hasMultiple && (
|
|
218
|
+
<div className="absolute bottom-3 left-1/2 -translate-x-1/2 flex gap-2 md:hidden z-10">
|
|
219
|
+
{mobileDots.map((index) => (
|
|
220
|
+
<div
|
|
221
|
+
key={index}
|
|
222
|
+
className={cn(
|
|
223
|
+
'rounded-full',
|
|
224
|
+
index === 0
|
|
225
|
+
? 'w-2.5 h-2.5 bg-white shadow-[0_0_4px_rgba(255,255,255,0.8)]'
|
|
226
|
+
: 'w-2 h-2 bg-white/40'
|
|
227
|
+
)}
|
|
228
|
+
/>
|
|
229
|
+
))}
|
|
230
|
+
{remainingCount > 0 && (
|
|
231
|
+
<span className="text-white/70 text-xs ml-1">+{remainingCount}</span>
|
|
232
|
+
)}
|
|
233
|
+
</div>
|
|
234
|
+
)}
|
|
235
|
+
</div>
|
|
236
|
+
|
|
237
|
+
{/* Thumbnails placeholder */}
|
|
238
|
+
{showThumbnails && hasMultiple && (
|
|
239
|
+
<div className="hidden md:flex gap-2">
|
|
240
|
+
{images.slice(0, 8).map((image, index) => (
|
|
241
|
+
<div
|
|
242
|
+
key={image.id}
|
|
243
|
+
className={cn(
|
|
244
|
+
'w-16 h-16 rounded-lg overflow-hidden bg-muted flex-shrink-0',
|
|
245
|
+
index === 0 && 'ring-2 ring-primary'
|
|
246
|
+
)}
|
|
247
|
+
>
|
|
248
|
+
<GalleryMedia
|
|
249
|
+
media={image}
|
|
250
|
+
className="w-full h-full"
|
|
251
|
+
/>
|
|
252
|
+
</div>
|
|
253
|
+
))}
|
|
254
|
+
</div>
|
|
255
|
+
)}
|
|
256
|
+
</div>
|
|
257
|
+
)
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
return (
|
|
261
|
+
<div className={cn('space-y-3', className)}>
|
|
262
|
+
<Carousel
|
|
263
|
+
key={carouselKey}
|
|
264
|
+
setApi={setApi}
|
|
265
|
+
opts={{
|
|
266
|
+
loop: true,
|
|
267
|
+
startIndex: initialIndex,
|
|
268
|
+
}}
|
|
269
|
+
className="relative rounded-xl overflow-hidden"
|
|
270
|
+
>
|
|
271
|
+
<CarouselContent className="-ml-0">
|
|
272
|
+
{images.map((image, index) => (
|
|
273
|
+
<CarouselItem key={image.id} className="pl-0">
|
|
274
|
+
<div
|
|
275
|
+
className={cn('relative bg-muted', enableLightbox && 'cursor-pointer')}
|
|
276
|
+
style={{ aspectRatio }}
|
|
277
|
+
onPointerDown={enableLightbox ? handlePointerDown : undefined}
|
|
278
|
+
onPointerUp={enableLightbox ? handlePointerUp : undefined}
|
|
279
|
+
>
|
|
280
|
+
<GalleryMedia
|
|
281
|
+
media={image}
|
|
282
|
+
className="w-full h-full"
|
|
283
|
+
onLoad={index === currentIndex ? onImageLoad : undefined}
|
|
284
|
+
priority={index === 0}
|
|
285
|
+
/>
|
|
286
|
+
|
|
287
|
+
{/* Dark overlay */}
|
|
288
|
+
<div className="absolute inset-0 bg-gradient-to-t from-black/40 via-transparent to-black/20 pointer-events-none" />
|
|
289
|
+
|
|
290
|
+
{/* Lightbox indicator */}
|
|
291
|
+
{enableLightbox && (
|
|
292
|
+
<div className="absolute inset-0 flex items-center justify-center opacity-0 hover:opacity-100 transition-opacity">
|
|
293
|
+
<div className="bg-black/60 backdrop-blur-sm rounded-full p-3 shadow-lg border border-white/20">
|
|
294
|
+
<ZoomIn className="w-6 h-6 text-white" />
|
|
295
|
+
</div>
|
|
296
|
+
</div>
|
|
297
|
+
)}
|
|
298
|
+
</div>
|
|
299
|
+
</CarouselItem>
|
|
300
|
+
))}
|
|
301
|
+
</CarouselContent>
|
|
302
|
+
|
|
303
|
+
{/* Navigation Controls */}
|
|
304
|
+
{showControls && hasMultiple && (
|
|
305
|
+
<>
|
|
306
|
+
<CarouselPrevious
|
|
307
|
+
className={cn(
|
|
308
|
+
'absolute left-3 top-1/2 -translate-y-1/2',
|
|
309
|
+
'w-10 h-10 rounded-full',
|
|
310
|
+
'bg-black/50 hover:bg-black/70 text-white backdrop-blur-sm border-white/20',
|
|
311
|
+
'disabled:opacity-30'
|
|
312
|
+
)}
|
|
313
|
+
/>
|
|
314
|
+
<CarouselNext
|
|
315
|
+
className={cn(
|
|
316
|
+
'absolute right-3 top-1/2 -translate-y-1/2',
|
|
317
|
+
'w-10 h-10 rounded-full',
|
|
318
|
+
'bg-black/50 hover:bg-black/70 text-white backdrop-blur-sm border-white/20',
|
|
319
|
+
'disabled:opacity-30'
|
|
320
|
+
)}
|
|
321
|
+
/>
|
|
322
|
+
</>
|
|
323
|
+
)}
|
|
324
|
+
|
|
325
|
+
{/* Counter */}
|
|
326
|
+
{showCounter && hasMultiple && (
|
|
327
|
+
<div
|
|
328
|
+
className={cn(
|
|
329
|
+
'absolute bottom-3 right-3 z-10',
|
|
330
|
+
'bg-black/60 backdrop-blur-sm text-white px-3 py-1.5 rounded-full',
|
|
331
|
+
'text-sm font-medium'
|
|
332
|
+
)}
|
|
333
|
+
>
|
|
334
|
+
{currentIndex + 1} / {total}
|
|
335
|
+
</div>
|
|
336
|
+
)}
|
|
337
|
+
|
|
338
|
+
{/* Mobile dots */}
|
|
339
|
+
{hasMultiple && (
|
|
340
|
+
<div className="absolute bottom-3 left-1/2 -translate-x-1/2 flex gap-2 md:hidden z-10">
|
|
341
|
+
{mobileDots.map((index) => (
|
|
342
|
+
<button
|
|
343
|
+
key={index}
|
|
344
|
+
type="button"
|
|
345
|
+
className={cn(
|
|
346
|
+
'rounded-full transition-all',
|
|
347
|
+
index === currentIndex
|
|
348
|
+
? 'w-2.5 h-2.5 bg-white shadow-[0_0_4px_rgba(255,255,255,0.8)]'
|
|
349
|
+
: 'w-2 h-2 bg-white/40'
|
|
350
|
+
)}
|
|
351
|
+
onClick={(e) => handleDotClick(e, index)}
|
|
352
|
+
aria-label={`Go to image ${index + 1}`}
|
|
353
|
+
/>
|
|
354
|
+
))}
|
|
355
|
+
{remainingCount > 0 && (
|
|
356
|
+
<span className="text-white/70 text-xs ml-1">+{remainingCount}</span>
|
|
357
|
+
)}
|
|
358
|
+
</div>
|
|
359
|
+
)}
|
|
360
|
+
</Carousel>
|
|
361
|
+
|
|
362
|
+
{/* Thumbnails */}
|
|
363
|
+
{showThumbnails && hasMultiple && (
|
|
364
|
+
<GalleryThumbnails
|
|
365
|
+
images={images}
|
|
366
|
+
currentIndex={currentIndex}
|
|
367
|
+
onSelect={handleScrollTo}
|
|
368
|
+
size="md"
|
|
369
|
+
className="hidden md:flex"
|
|
370
|
+
/>
|
|
371
|
+
)}
|
|
372
|
+
</div>
|
|
373
|
+
)
|
|
374
|
+
})
|