@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,77 @@
1
+ import * as React from 'react';
2
+
3
+ export const colorFormats = ['hex', 'rgb', 'hsl', 'hsb'] as const;
4
+
5
+ export type ColorFormat = (typeof colorFormats)[number];
6
+
7
+ export interface ColorValue {
8
+ r: number;
9
+ g: number;
10
+ b: number;
11
+ a: number;
12
+ }
13
+
14
+ export interface HSVColorValue {
15
+ h: number;
16
+ s: number;
17
+ v: number;
18
+ a: number;
19
+ }
20
+
21
+ export interface StoreState {
22
+ color: ColorValue;
23
+ hsv: HSVColorValue;
24
+ open: boolean;
25
+ format: ColorFormat;
26
+ }
27
+
28
+ export interface Store {
29
+ subscribe: (cb: () => void) => () => void;
30
+ getState: () => StoreState;
31
+ setColor: (value: ColorValue) => void;
32
+ setHsv: (value: HSVColorValue) => void;
33
+ setOpen: (value: boolean) => void;
34
+ setFormat: (value: ColorFormat) => void;
35
+ notify: () => void;
36
+ }
37
+
38
+ export interface ColorPickerContextValue {
39
+ dir: 'ltr' | 'rtl';
40
+ disabled?: boolean;
41
+ inline?: boolean;
42
+ readOnly?: boolean;
43
+ required?: boolean;
44
+ }
45
+
46
+ export interface DivProps extends React.ComponentProps<'div'> {
47
+ asChild?: boolean;
48
+ }
49
+
50
+ export interface ColorPickerProps
51
+ extends Omit<DivProps, 'onValueChange'>,
52
+ Pick<
53
+ React.ComponentProps<typeof import('@djangocfg/ui-core/components').Popover>,
54
+ 'defaultOpen' | 'open' | 'onOpenChange' | 'modal'
55
+ > {
56
+ value?: string;
57
+ defaultValue?: string;
58
+ onValueChange?: (value: string) => void;
59
+ dir?: 'ltr' | 'rtl';
60
+ format?: ColorFormat;
61
+ defaultFormat?: ColorFormat;
62
+ onFormatChange?: (format: ColorFormat) => void;
63
+ name?: string;
64
+ asChild?: boolean;
65
+ disabled?: boolean;
66
+ inline?: boolean;
67
+ readOnly?: boolean;
68
+ required?: boolean;
69
+ }
70
+
71
+ export interface ColorPickerInputProps
72
+ extends Omit<
73
+ React.ComponentProps<typeof import('@djangocfg/ui-core/components').Input>,
74
+ 'value' | 'onChange' | 'color'
75
+ > {
76
+ withoutAlpha?: boolean;
77
+ }
@@ -0,0 +1,182 @@
1
+ 'use client'
2
+
3
+ import { memo, useCallback, useMemo, Suspense, lazy } from 'react'
4
+ import { cn } from '@djangocfg/ui-core/lib'
5
+ import { useAppT } from '@djangocfg/i18n'
6
+ import { ImageOff } from 'lucide-react'
7
+ import { useGallery } from '../hooks/useGallery'
8
+ import { GalleryGrid } from './preview'
9
+ import { GalleryCarousel } from './preview'
10
+ import type { GalleryProps } from '../types'
11
+
12
+ // Lazy load lightbox - only loaded when user opens it
13
+ const GalleryLightbox = lazy(() =>
14
+ import('./lightbox').then((mod) => ({ default: mod.GalleryLightbox }))
15
+ )
16
+
17
+ /**
18
+ * Gallery - Complete image gallery with carousel/grid preview and lightbox
19
+ *
20
+ * Features:
21
+ * - Two preview modes: carousel (default) or grid
22
+ * - Smooth slide transitions (embla-carousel)
23
+ * - Touch/swipe support
24
+ * - Keyboard navigation
25
+ * - Fullscreen lightbox
26
+ * - Dark overlay for controls
27
+ */
28
+ export const Gallery = memo(function Gallery({
29
+ images,
30
+ initialIndex = 0,
31
+ previewMode = 'carousel',
32
+ previewCount = 5,
33
+ showThumbnails = true,
34
+ showControls = true,
35
+ showCounter = true,
36
+ aspectRatio = 16 / 9,
37
+ enableLightbox = true,
38
+ enableKeyboard = true,
39
+ onImageChange,
40
+ onLightboxOpen,
41
+ onLightboxClose,
42
+ emptyState,
43
+ className,
44
+ }: GalleryProps) {
45
+ const t = useAppT()
46
+ const noImagesText = useMemo(() => t('tools.gallery.noImages'), [t])
47
+
48
+ const gallery = useGallery({
49
+ images,
50
+ initialIndex,
51
+ loop: true,
52
+ onImageChange,
53
+ })
54
+
55
+ const {
56
+ currentIndex,
57
+ total,
58
+ hasMultiple,
59
+ isLightboxOpen,
60
+ goTo,
61
+ openLightbox,
62
+ closeLightbox,
63
+ setLoading,
64
+ } = gallery
65
+
66
+ // Lightbox callbacks
67
+ const handleLightboxOpen = useCallback(() => {
68
+ openLightbox()
69
+ onLightboxOpen?.()
70
+ }, [openLightbox, onLightboxOpen])
71
+
72
+ const handleLightboxClose = useCallback(() => {
73
+ closeLightbox()
74
+ onLightboxClose?.()
75
+ }, [closeLightbox, onLightboxClose])
76
+
77
+ // Open lightbox at specific index (for grid mode)
78
+ const handleGridImageClick = useCallback(
79
+ (index: number) => {
80
+ goTo(index)
81
+ openLightbox()
82
+ onLightboxOpen?.()
83
+ },
84
+ [goTo, openLightbox, onLightboxOpen]
85
+ )
86
+
87
+ // Current image load handler
88
+ const handleCurrentImageLoad = useCallback(() => {
89
+ setLoading(false)
90
+ }, [setLoading])
91
+
92
+ // Empty state
93
+ if (images.length === 0) {
94
+ if (emptyState) {
95
+ return <>{emptyState}</>
96
+ }
97
+
98
+ return (
99
+ <div
100
+ className={cn(
101
+ 'bg-muted rounded-xl flex items-center justify-center',
102
+ className
103
+ )}
104
+ style={{ aspectRatio }}
105
+ >
106
+ <div className="text-center text-muted-foreground p-8">
107
+ <ImageOff className="w-12 h-12 mx-auto mb-3 opacity-50" />
108
+ <p>{noImagesText}</p>
109
+ </div>
110
+ </div>
111
+ )
112
+ }
113
+
114
+ // Grid mode
115
+ if (previewMode === 'grid') {
116
+ return (
117
+ <div className={className}>
118
+ <GalleryGrid
119
+ images={images}
120
+ maxVisible={previewCount}
121
+ aspectRatio={aspectRatio}
122
+ gap={2}
123
+ rounded="xl"
124
+ onImageClick={enableLightbox ? handleGridImageClick : undefined}
125
+ showMoreBadge
126
+ />
127
+
128
+ {/* Lightbox - lazy loaded */}
129
+ {enableLightbox && isLightboxOpen && (
130
+ <Suspense fallback={null}>
131
+ <GalleryLightbox
132
+ open={isLightboxOpen}
133
+ onClose={handleLightboxClose}
134
+ images={images}
135
+ currentIndex={currentIndex}
136
+ onIndexChange={goTo}
137
+ showThumbnails={showThumbnails}
138
+ />
139
+ </Suspense>
140
+ )}
141
+ </div>
142
+ )
143
+ }
144
+
145
+ // Carousel mode (default)
146
+ return (
147
+ <>
148
+ <GalleryCarousel
149
+ images={images}
150
+ currentIndex={currentIndex}
151
+ total={total}
152
+ hasMultiple={hasMultiple}
153
+ initialIndex={initialIndex}
154
+ aspectRatio={aspectRatio}
155
+ showControls={showControls}
156
+ showCounter={showCounter}
157
+ showThumbnails={showThumbnails}
158
+ enableLightbox={enableLightbox}
159
+ enableKeyboard={enableKeyboard}
160
+ isLightboxOpen={isLightboxOpen}
161
+ onIndexChange={goTo}
162
+ onLightboxOpen={handleLightboxOpen}
163
+ onImageLoad={handleCurrentImageLoad}
164
+ className={className}
165
+ />
166
+
167
+ {/* Lightbox - lazy loaded */}
168
+ {enableLightbox && isLightboxOpen && (
169
+ <Suspense fallback={null}>
170
+ <GalleryLightbox
171
+ open={isLightboxOpen}
172
+ onClose={handleLightboxClose}
173
+ images={images}
174
+ currentIndex={currentIndex}
175
+ onIndexChange={goTo}
176
+ showThumbnails={showThumbnails}
177
+ />
178
+ </Suspense>
179
+ )}
180
+ </>
181
+ )
182
+ })
@@ -0,0 +1,396 @@
1
+ 'use client'
2
+
3
+ import { memo, useCallback, useEffect, useState, useMemo } from 'react'
4
+ import { cn } from '@djangocfg/ui-core/lib'
5
+ import {
6
+ Carousel,
7
+ CarouselContent,
8
+ CarouselItem,
9
+ type CarouselApi,
10
+ } from '@djangocfg/ui-core/components'
11
+ import { ChevronLeft, ChevronRight, ImageOff } from 'lucide-react'
12
+ import { GalleryMedia } from '../media'
13
+ import type { GalleryMediaItem } from '../../types'
14
+
15
+ /** Preset aspect ratios for common use cases */
16
+ export type GalleryAspectRatio = '16/9' | '4/3' | '3/2' | '1/1' | 'auto'
17
+
18
+ export interface GalleryCompactProps {
19
+ /** Array of images to display */
20
+ images: GalleryMediaItem[]
21
+ /** Aspect ratio preset or 'auto' to fill parent (default: 'auto') */
22
+ aspectRatio?: GalleryAspectRatio
23
+ /** Show dots indicator (default: true) */
24
+ showDots?: boolean
25
+ /** Max dots to show (default: 5) */
26
+ maxDots?: number
27
+ /** Show counter badge (default: false) */
28
+ showCounter?: boolean
29
+ /** Show navigation arrows on hover (default: true) */
30
+ showArrows?: boolean
31
+ /** Enable zoom effect on hover (default: true) */
32
+ enableZoom?: boolean
33
+ /** On image click callback */
34
+ onClick?: () => void
35
+ /** Additional CSS class */
36
+ className?: string
37
+ }
38
+
39
+ /**
40
+ * GalleryCompact - Minimal carousel for property/vehicle cards
41
+ *
42
+ * Features:
43
+ * - Simple swipe carousel
44
+ * - Dots indicator (mobile-style)
45
+ * - Navigation arrows on hover (desktop)
46
+ * - Hover zoom effect on images
47
+ * - Lazy loading - only loads active image + neighbors
48
+ * - Fills parent container
49
+ * - Stops event propagation on navigation
50
+ */
51
+ /** Map aspect ratio presets to CSS values */
52
+ const aspectRatioMap: Record<GalleryAspectRatio, string | undefined> = {
53
+ '16/9': '16 / 9',
54
+ '4/3': '4 / 3',
55
+ '3/2': '3 / 2',
56
+ '1/1': '1 / 1',
57
+ 'auto': undefined,
58
+ }
59
+
60
+ export const GalleryCompact = memo(function GalleryCompact({
61
+ images,
62
+ aspectRatio = 'auto',
63
+ showDots = true,
64
+ maxDots = 5,
65
+ showCounter = false,
66
+ showArrows = true,
67
+ enableZoom = true,
68
+ onClick,
69
+ className,
70
+ }: GalleryCompactProps) {
71
+ const [api, setApi] = useState<CarouselApi>()
72
+ const [currentIndex, setCurrentIndex] = useState(0)
73
+ const [isHovered, setIsHovered] = useState(false)
74
+ // Track if component is mounted (client-side) to avoid hydration mismatches
75
+ const [isMounted, setIsMounted] = useState(false)
76
+
77
+ // Compute aspect ratio style - if set, we control height via aspect-ratio, otherwise fill parent
78
+ const aspectRatioStyle = aspectRatioMap[aspectRatio]
79
+ const containerStyle = aspectRatioStyle ? { aspectRatio: aspectRatioStyle } : undefined
80
+ const hasFixedAspect = aspectRatio !== 'auto'
81
+ const containerClass = hasFixedAspect ? 'relative w-full overflow-hidden' : 'relative w-full h-full overflow-hidden'
82
+
83
+ useEffect(() => {
84
+ setIsMounted(true)
85
+ }, [])
86
+
87
+ const total = images.length
88
+ const hasMultiple = total > 1
89
+ const firstImage = images[0]
90
+ // Stable key for carousel reset on images change
91
+ const carouselKey = images[0]?.id ?? 'empty'
92
+
93
+ // Determine which images should be loaded (current + neighbors for smooth transition)
94
+ const loadedIndices = useMemo(() => {
95
+ if (total === 0) return new Set<number>()
96
+ const indices = new Set<number>()
97
+ // Always load first image
98
+ indices.add(0)
99
+ // Load current
100
+ indices.add(currentIndex)
101
+ // Load neighbors for smooth transitions
102
+ if (hasMultiple) {
103
+ indices.add((currentIndex - 1 + total) % total)
104
+ indices.add((currentIndex + 1) % total)
105
+ }
106
+ return indices
107
+ }, [currentIndex, total, hasMultiple])
108
+
109
+ // Listen to carousel changes
110
+ useEffect(() => {
111
+ if (!api) return
112
+
113
+ const onSelect = () => {
114
+ setCurrentIndex(api.selectedScrollSnap())
115
+ }
116
+
117
+ api.on('select', onSelect)
118
+ return () => {
119
+ api.off('select', onSelect)
120
+ }
121
+ }, [api])
122
+
123
+ // Navigation handlers - stop all propagation to parent Link
124
+ const stopEvent = useCallback((e: React.MouseEvent | React.PointerEvent) => {
125
+ e.preventDefault()
126
+ e.stopPropagation()
127
+ e.nativeEvent.stopImmediatePropagation()
128
+ }, [])
129
+
130
+ const handlePrev = useCallback(
131
+ (e: React.MouseEvent) => {
132
+ stopEvent(e)
133
+ api?.scrollPrev()
134
+ },
135
+ [api, stopEvent]
136
+ )
137
+
138
+ const handleNext = useCallback(
139
+ (e: React.MouseEvent) => {
140
+ stopEvent(e)
141
+ api?.scrollNext()
142
+ },
143
+ [api, stopEvent]
144
+ )
145
+
146
+ // Dot click handler
147
+ const handleDotClick = useCallback(
148
+ (e: React.MouseEvent, index: number) => {
149
+ e.preventDefault()
150
+ e.stopPropagation()
151
+ api?.scrollTo(index)
152
+ },
153
+ [api]
154
+ )
155
+
156
+ // Handle container click
157
+ const handleClick = useCallback(
158
+ (e: React.MouseEvent) => {
159
+ // Don't trigger if clicking on navigation elements
160
+ if ((e.target as HTMLElement).closest('[data-nav]')) {
161
+ return
162
+ }
163
+ onClick?.()
164
+ },
165
+ [onClick]
166
+ )
167
+
168
+ // Empty state
169
+ if (total === 0) {
170
+ return (
171
+ <div
172
+ className={cn(containerClass, 'bg-muted flex items-center justify-center', className)}
173
+ style={containerStyle}
174
+ >
175
+ <ImageOff className="w-8 h-8 text-muted-foreground/50" />
176
+ </div>
177
+ )
178
+ }
179
+
180
+ // Single image (or none) - no carousel needed
181
+ if (!hasMultiple) {
182
+ if (!firstImage) return null
183
+ return (
184
+ <div
185
+ className={cn(containerClass, 'overflow-hidden group', className)}
186
+ style={containerStyle}
187
+ onClick={onClick}
188
+ >
189
+ <div className={cn(
190
+ 'w-full h-full transition-transform duration-500 ease-out',
191
+ enableZoom && 'group-hover:scale-110'
192
+ )}>
193
+ <GalleryMedia
194
+ media={firstImage}
195
+ className="w-full h-full"
196
+ priority
197
+ />
198
+ </div>
199
+ </div>
200
+ )
201
+ }
202
+
203
+ // Calculate visible dots
204
+ const visibleDots = images.slice(0, maxDots).map((_, i) => i)
205
+ const remainingCount = total > maxDots ? total - maxDots : 0
206
+
207
+ // SSR fallback - render first image only to avoid hydration mismatch
208
+ // The carousel requires DOM access and will cause issues during SSR
209
+ if (!isMounted) {
210
+ if (!firstImage) return null
211
+ return (
212
+ <div
213
+ className={cn(containerClass, 'overflow-hidden', className)}
214
+ style={containerStyle}
215
+ onClick={onClick}
216
+ >
217
+ <GalleryMedia
218
+ media={firstImage}
219
+ className="w-full h-full"
220
+ priority
221
+ />
222
+ {/* Show dots indicator placeholder for consistent layout */}
223
+ {showDots && (
224
+ <div
225
+ className={cn(
226
+ 'absolute bottom-3 left-1/2 -translate-x-1/2 z-10',
227
+ 'flex items-center gap-1.5',
228
+ 'px-2 py-1 rounded-full',
229
+ 'bg-black/30 backdrop-blur-sm'
230
+ )}
231
+ >
232
+ {visibleDots.map((index) => (
233
+ <div
234
+ key={index}
235
+ className={cn(
236
+ 'rounded-full',
237
+ index === 0
238
+ ? 'w-2 h-2 bg-white shadow-[0_0_4px_rgba(255,255,255,0.8)]'
239
+ : 'w-1.5 h-1.5 bg-white/50'
240
+ )}
241
+ />
242
+ ))}
243
+ {remainingCount > 0 && (
244
+ <span className="text-white/80 text-[10px] font-medium ml-0.5">
245
+ +{remainingCount}
246
+ </span>
247
+ )}
248
+ </div>
249
+ )}
250
+ {/* Counter badge placeholder */}
251
+ {showCounter && (
252
+ <div className="absolute bottom-3 right-3 z-10 bg-black/50 backdrop-blur-sm text-white text-xs px-2 py-1 rounded-full font-medium">
253
+ 1 / {total}
254
+ </div>
255
+ )}
256
+ </div>
257
+ )
258
+ }
259
+
260
+ return (
261
+ <div
262
+ className={cn(containerClass, 'group/gallery', className)}
263
+ style={containerStyle}
264
+ onClick={handleClick}
265
+ onMouseEnter={() => setIsHovered(true)}
266
+ onMouseLeave={() => setIsHovered(false)}
267
+ >
268
+ <Carousel
269
+ key={carouselKey}
270
+ setApi={setApi}
271
+ opts={{
272
+ loop: true,
273
+ }}
274
+ className="w-full h-full"
275
+ >
276
+ <CarouselContent className="-ml-0 h-full">
277
+ {images.map((image, index) => {
278
+ const shouldLoad = loadedIndices.has(index)
279
+ const isActive = index === currentIndex
280
+
281
+ return (
282
+ <CarouselItem key={image.id} className="pl-0 h-full overflow-hidden">
283
+ <div className={cn(
284
+ 'w-full h-full transition-transform duration-500 ease-out',
285
+ enableZoom && isActive && isHovered && 'scale-110'
286
+ )}>
287
+ {shouldLoad ? (
288
+ <GalleryMedia
289
+ media={image}
290
+ className="w-full h-full"
291
+ priority={index === 0}
292
+ />
293
+ ) : (
294
+ // Placeholder for unloaded images
295
+ <div className="w-full h-full bg-muted animate-pulse" />
296
+ )}
297
+ </div>
298
+ </CarouselItem>
299
+ )
300
+ })}
301
+ </CarouselContent>
302
+ </Carousel>
303
+
304
+ {/* Navigation arrows - always visible but subtle, more prominent on hover */}
305
+ {showArrows && (
306
+ <>
307
+ <button
308
+ data-nav
309
+ type="button"
310
+ className={cn(
311
+ 'absolute left-2 top-1/2 -translate-y-1/2 z-10',
312
+ 'w-7 h-7 rounded-full',
313
+ 'bg-black/30 backdrop-blur-sm text-white/80',
314
+ 'flex items-center justify-center',
315
+ 'transition-all duration-200',
316
+ // Always visible but subtle, more prominent on hover
317
+ 'opacity-60 group-hover/gallery:opacity-100',
318
+ 'hover:bg-black/60 hover:text-white hover:scale-110',
319
+ 'active:scale-95',
320
+ 'focus:outline-none focus:ring-2 focus:ring-white/50'
321
+ )}
322
+ onClick={handlePrev}
323
+ onMouseDown={stopEvent}
324
+ onPointerDown={stopEvent}
325
+ aria-label="Previous image"
326
+ >
327
+ <ChevronLeft className="w-4 h-4" />
328
+ </button>
329
+ <button
330
+ data-nav
331
+ type="button"
332
+ className={cn(
333
+ 'absolute right-2 top-1/2 -translate-y-1/2 z-10',
334
+ 'w-7 h-7 rounded-full',
335
+ 'bg-black/30 backdrop-blur-sm text-white/80',
336
+ 'flex items-center justify-center',
337
+ 'transition-all duration-200',
338
+ // Always visible but subtle, more prominent on hover
339
+ 'opacity-60 group-hover/gallery:opacity-100',
340
+ 'hover:bg-black/60 hover:text-white hover:scale-110',
341
+ 'active:scale-95',
342
+ 'focus:outline-none focus:ring-2 focus:ring-white/50'
343
+ )}
344
+ onClick={handleNext}
345
+ onMouseDown={stopEvent}
346
+ onPointerDown={stopEvent}
347
+ aria-label="Next image"
348
+ >
349
+ <ChevronRight className="w-4 h-4" />
350
+ </button>
351
+ </>
352
+ )}
353
+
354
+ {/* Dots indicator */}
355
+ {showDots && (
356
+ <div
357
+ data-nav
358
+ className={cn(
359
+ 'absolute bottom-3 left-1/2 -translate-x-1/2 z-10',
360
+ 'flex items-center gap-1.5',
361
+ 'px-2 py-1 rounded-full',
362
+ 'bg-black/30 backdrop-blur-sm'
363
+ )}
364
+ onClick={(e) => e.stopPropagation()}
365
+ >
366
+ {visibleDots.map((index) => (
367
+ <button
368
+ key={index}
369
+ type="button"
370
+ className={cn(
371
+ 'rounded-full transition-all duration-200',
372
+ index === currentIndex
373
+ ? 'w-2 h-2 bg-white shadow-[0_0_4px_rgba(255,255,255,0.8)]'
374
+ : 'w-1.5 h-1.5 bg-white/50 hover:bg-white/80'
375
+ )}
376
+ onClick={(e) => handleDotClick(e, index)}
377
+ aria-label={`Go to image ${index + 1}`}
378
+ />
379
+ ))}
380
+ {remainingCount > 0 && (
381
+ <span className="text-white/80 text-[10px] font-medium ml-0.5">
382
+ +{remainingCount}
383
+ </span>
384
+ )}
385
+ </div>
386
+ )}
387
+
388
+ {/* Counter badge */}
389
+ {showCounter && (
390
+ <div className="absolute bottom-3 right-3 z-10 bg-black/50 backdrop-blur-sm text-white text-xs px-2 py-1 rounded-full font-medium">
391
+ {currentIndex + 1} / {total}
392
+ </div>
393
+ )}
394
+ </div>
395
+ )
396
+ })
@@ -0,0 +1 @@
1
+ export { GalleryCompact, type GalleryCompactProps, type GalleryAspectRatio } from './GalleryCompact'
@@ -0,0 +1,21 @@
1
+ // Main Gallery component
2
+ export { Gallery } from './Gallery'
3
+
4
+ // Compact mode (for cards)
5
+ export { GalleryCompact, type GalleryCompactProps, type GalleryAspectRatio } from './compact'
6
+
7
+ // Preview modes
8
+ export { GalleryCarousel } from './preview'
9
+ export { GalleryGrid, type GalleryGridProps, type GalleryGridLayout } from './preview'
10
+
11
+ // Lightbox
12
+ export { GalleryLightbox } from './lightbox'
13
+
14
+ // Media components
15
+ export { GalleryImage, GalleryVideo, GalleryMedia, type GalleryMediaProps } from './media'
16
+
17
+ // Thumbnails
18
+ export { GalleryThumbnails, GalleryThumbnailsVirtual } from './thumbnails'
19
+
20
+ // Shared components
21
+ export { ImageSpinner, type ImageSpinnerProps } from './shared'