@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.
Files changed (80) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +41 -0
  3. package/package.json +106 -0
  4. package/src/design/ColorPalette/ColorPalette.tsx +129 -0
  5. package/src/design/ColorPalette/README.md +34 -0
  6. package/src/design/ColorPalette/color-palette.stories.tsx +95 -0
  7. package/src/design/ColorPalette/components/Swatch.tsx +102 -0
  8. package/src/design/ColorPalette/hooks/useCopyToClipboard.ts +41 -0
  9. package/src/design/ColorPalette/index.ts +12 -0
  10. package/src/design/ColorPalette/lazy.tsx +21 -0
  11. package/src/design/ColorPalette/types.ts +63 -0
  12. package/src/design/ColorPalette/utils.ts +114 -0
  13. package/src/design/ColorPicker/ColorPicker.tsx +257 -0
  14. package/src/design/ColorPicker/color-picker.stories.tsx +113 -0
  15. package/src/design/ColorPicker/context/ColorPickerContext.tsx +28 -0
  16. package/src/design/ColorPicker/context/ColorPickerStore.tsx +166 -0
  17. package/src/design/ColorPicker/context/index.ts +2 -0
  18. package/src/design/ColorPicker/index.ts +15 -0
  19. package/src/design/ColorPicker/lazy.tsx +24 -0
  20. package/src/design/ColorPicker/lib/color-utils.ts +323 -0
  21. package/src/design/ColorPicker/parts/ColorPickerAlphaSlider.tsx +72 -0
  22. package/src/design/ColorPicker/parts/ColorPickerArea.tsx +155 -0
  23. package/src/design/ColorPicker/parts/ColorPickerEyeDropper.tsx +73 -0
  24. package/src/design/ColorPicker/parts/ColorPickerFormatSelect.tsx +64 -0
  25. package/src/design/ColorPicker/parts/ColorPickerHueSlider.tsx +64 -0
  26. package/src/design/ColorPicker/parts/ColorPickerInput.tsx +512 -0
  27. package/src/design/ColorPicker/parts/ColorPickerSwatch.tsx +63 -0
  28. package/src/design/ColorPicker/parts/index.ts +7 -0
  29. package/src/design/ColorPicker/types.ts +77 -0
  30. package/src/gallery/components/Gallery.tsx +182 -0
  31. package/src/gallery/components/compact/GalleryCompact.tsx +396 -0
  32. package/src/gallery/components/compact/index.ts +1 -0
  33. package/src/gallery/components/index.ts +21 -0
  34. package/src/gallery/components/lightbox/GalleryLightbox.tsx +426 -0
  35. package/src/gallery/components/lightbox/index.ts +1 -0
  36. package/src/gallery/components/media/GalleryImage.tsx +105 -0
  37. package/src/gallery/components/media/GalleryMedia.tsx +70 -0
  38. package/src/gallery/components/media/GalleryVideo.tsx +241 -0
  39. package/src/gallery/components/media/index.ts +3 -0
  40. package/src/gallery/components/preview/GalleryCarousel.tsx +374 -0
  41. package/src/gallery/components/preview/GalleryGrid.tsx +519 -0
  42. package/src/gallery/components/preview/index.ts +2 -0
  43. package/src/gallery/components/shared/ImageSpinner.tsx +37 -0
  44. package/src/gallery/components/shared/index.ts +1 -0
  45. package/src/gallery/components/thumbnails/GalleryThumbnails.tsx +198 -0
  46. package/src/gallery/components/thumbnails/GalleryThumbnailsVirtual.tsx +164 -0
  47. package/src/gallery/components/thumbnails/index.ts +2 -0
  48. package/src/gallery/gallery.stories.tsx +182 -0
  49. package/src/gallery/hooks/index.ts +23 -0
  50. package/src/gallery/hooks/useGallery.ts +138 -0
  51. package/src/gallery/hooks/useImageDimensions.ts +224 -0
  52. package/src/gallery/hooks/usePinchZoom.ts +239 -0
  53. package/src/gallery/hooks/usePreloadImages.ts +119 -0
  54. package/src/gallery/hooks/useSwipe.ts +87 -0
  55. package/src/gallery/hooks/useVirtualList.ts +129 -0
  56. package/src/gallery/hooks/useZoom.ts +321 -0
  57. package/src/gallery/index.ts +66 -0
  58. package/src/gallery/thumbnails.stories.tsx +127 -0
  59. package/src/gallery/types.ts +185 -0
  60. package/src/gallery/utils/imageAnalysis.ts +52 -0
  61. package/src/gallery/utils/index.ts +16 -0
  62. package/src/gallery/utils/normalizeUrl.ts +31 -0
  63. package/src/index.ts +18 -0
  64. package/src/lottie/LottiePlayer.client.tsx +319 -0
  65. package/src/lottie/index.tsx +66 -0
  66. package/src/lottie/lazy.tsx +65 -0
  67. package/src/lottie/lottie-player.stories.tsx +173 -0
  68. package/src/lottie/types.ts +138 -0
  69. package/src/lottie/useLottie.ts +187 -0
  70. package/src/lottie/usePrefersReducedMotion.ts +46 -0
  71. package/src/marquee/Marquee.tsx +637 -0
  72. package/src/marquee/index.ts +7 -0
  73. package/src/marquee/lazy.tsx +14 -0
  74. package/src/marquee/marquee.stories.tsx +118 -0
  75. package/src/marquee/types.ts +40 -0
  76. package/src/qrcode/QRCode.tsx +468 -0
  77. package/src/qrcode/index.ts +10 -0
  78. package/src/qrcode/lazy.tsx +19 -0
  79. package/src/qrcode/qr-code.stories.tsx +102 -0
  80. 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,3 @@
1
+ export { GalleryImage } from './GalleryImage'
2
+ export { GalleryVideo } from './GalleryVideo'
3
+ export { GalleryMedia, type GalleryMediaProps } from './GalleryMedia'
@@ -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
+ })