@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,198 @@
1
+ 'use client'
2
+
3
+ import { memo, useRef, useEffect, useCallback, Suspense, lazy, useState, useMemo } from 'react'
4
+ import { cn } from '@djangocfg/ui-core/lib'
5
+ import type { GalleryThumbnailsProps } from '../../types'
6
+ import { normalizeImageUrl } from '../../utils'
7
+ import { ImageSpinner } from '../shared'
8
+
9
+ // Lazy load virtualized thumbnails - only for 50+ images
10
+ const GalleryThumbnailsVirtual = lazy(() =>
11
+ import('./GalleryThumbnailsVirtual').then((mod) => ({ default: mod.GalleryThumbnailsVirtual }))
12
+ )
13
+
14
+ const SIZES = {
15
+ sm: 'w-14 h-10',
16
+ md: 'w-20 h-14',
17
+ lg: 'w-24 h-18',
18
+ } as const
19
+
20
+ /** Threshold for switching to virtualized rendering */
21
+ const VIRTUALIZATION_THRESHOLD = 50
22
+
23
+ /**
24
+ * GalleryThumbnails - Horizontal scrollable thumbnail strip
25
+ * Automatically switches to virtualized rendering for 50+ images
26
+ */
27
+ export const GalleryThumbnails = memo(function GalleryThumbnails(props: GalleryThumbnailsProps) {
28
+ const { images } = props
29
+
30
+ // Use virtualized version for large galleries (lazy loaded)
31
+ if (images.length >= VIRTUALIZATION_THRESHOLD) {
32
+ return (
33
+ <Suspense fallback={<ThumbnailsSkeleton className={props.className} />}>
34
+ <GalleryThumbnailsVirtual {...props} />
35
+ </Suspense>
36
+ )
37
+ }
38
+
39
+ return <GalleryThumbnailsRegular {...props} />
40
+ })
41
+
42
+ // Simple skeleton for loading state
43
+ const ThumbnailsSkeleton = memo(function ThumbnailsSkeleton({ className }: { className?: string }) {
44
+ return (
45
+ <div className={cn('flex gap-2 overflow-hidden p-1', className)}>
46
+ {Array.from({ length: 5 }).map((_, i) => (
47
+ <div key={i} className="w-20 h-14 rounded-lg bg-muted animate-pulse flex-shrink-0" />
48
+ ))}
49
+ </div>
50
+ )
51
+ })
52
+
53
+ /**
54
+ * Regular (non-virtualized) thumbnails for smaller galleries
55
+ */
56
+ const GalleryThumbnailsRegular = memo(function GalleryThumbnailsRegular({
57
+ images,
58
+ currentIndex,
59
+ onSelect,
60
+ size = 'md',
61
+ className,
62
+ }: GalleryThumbnailsProps) {
63
+ const containerRef = useRef<HTMLDivElement>(null)
64
+ const itemRefs = useRef<(HTMLButtonElement | null)[]>([])
65
+
66
+ // Scroll active thumbnail into view
67
+ useEffect(() => {
68
+ const activeItem = itemRefs.current[currentIndex]
69
+ if (activeItem && containerRef.current) {
70
+ const container = containerRef.current
71
+ const itemLeft = activeItem.offsetLeft
72
+ const itemWidth = activeItem.offsetWidth
73
+ const containerWidth = container.offsetWidth
74
+ const scrollLeft = container.scrollLeft
75
+
76
+ // Check if item is not fully visible
77
+ if (itemLeft < scrollLeft || itemLeft + itemWidth > scrollLeft + containerWidth) {
78
+ activeItem.scrollIntoView({
79
+ behavior: 'smooth',
80
+ block: 'nearest',
81
+ inline: 'center',
82
+ })
83
+ }
84
+ }
85
+ }, [currentIndex])
86
+
87
+ // Handle thumbnail click
88
+ const handleClick = useCallback(
89
+ (index: number) => {
90
+ onSelect(index)
91
+ },
92
+ [onSelect]
93
+ )
94
+
95
+ if (images.length <= 1) return null
96
+
97
+ return (
98
+ <div
99
+ ref={containerRef}
100
+ className={cn(
101
+ 'flex gap-2 overflow-x-auto scrollbar-hide p-1',
102
+ className
103
+ )}
104
+ >
105
+ {images.map((image, index) => (
106
+ <ThumbnailButton
107
+ key={image.id}
108
+ ref={(el) => {
109
+ itemRefs.current[index] = el
110
+ }}
111
+ image={image}
112
+ index={index}
113
+ isActive={index === currentIndex}
114
+ size={size}
115
+ onClick={handleClick}
116
+ />
117
+ ))}
118
+ </div>
119
+ )
120
+ })
121
+
122
+ // Extracted thumbnail button component
123
+ interface ThumbnailButtonProps {
124
+ image: { id: string; src: string; thumbnail?: string; alt?: string }
125
+ index: number
126
+ isActive: boolean
127
+ size: 'sm' | 'md' | 'lg'
128
+ onClick: (index: number) => void
129
+ }
130
+
131
+ const ThumbnailButton = memo(
132
+ function ThumbnailButton({
133
+ image,
134
+ index,
135
+ isActive,
136
+ size,
137
+ onClick,
138
+ ref,
139
+ }: ThumbnailButtonProps & { ref?: React.Ref<HTMLButtonElement> }) {
140
+ const [isLoaded, setIsLoaded] = useState(false)
141
+
142
+ // Reset loading state when image source changes
143
+ const imageSrc = useMemo(() => image?.thumbnail || image?.src, [image?.thumbnail, image?.src])
144
+ useEffect(() => {
145
+ setIsLoaded(false)
146
+ }, [imageSrc])
147
+
148
+ const handleClick = useCallback(() => {
149
+ onClick(index)
150
+ }, [onClick, index])
151
+
152
+ const handleLoad = useCallback(() => {
153
+ setIsLoaded(true)
154
+ }, [])
155
+
156
+ return (
157
+ <button
158
+ ref={ref}
159
+ type="button"
160
+ className={cn(
161
+ 'relative flex-shrink-0 rounded-lg overflow-hidden bg-muted',
162
+ 'border-2 transition-all duration-200',
163
+ 'focus:outline-none focus-visible:ring-2 focus-visible:ring-primary',
164
+ SIZES[size],
165
+ isActive
166
+ ? 'border-primary ring-1 ring-primary/30'
167
+ : 'border-transparent hover:border-muted-foreground/30'
168
+ )}
169
+ onClick={handleClick}
170
+ aria-label={`View image ${index + 1}`}
171
+ aria-current={isActive ? 'true' : undefined}
172
+ >
173
+ {/* Loading spinner */}
174
+ {!isLoaded && (
175
+ <div className="absolute inset-0 flex items-center justify-center">
176
+ <ImageSpinner size="sm" />
177
+ </div>
178
+ )}
179
+
180
+ <img
181
+ src={normalizeImageUrl(image.thumbnail || image.src)}
182
+ alt={image.alt || `Thumbnail ${index + 1}`}
183
+ className={cn(
184
+ 'w-full h-full object-cover transition-opacity duration-200',
185
+ isLoaded ? 'opacity-100' : 'opacity-0'
186
+ )}
187
+ loading="lazy"
188
+ onLoad={handleLoad}
189
+ />
190
+
191
+ {/* Active overlay */}
192
+ {isActive && (
193
+ <div className="absolute inset-0 bg-primary/10" />
194
+ )}
195
+ </button>
196
+ )
197
+ }
198
+ )
@@ -0,0 +1,164 @@
1
+ 'use client'
2
+
3
+ import { memo, useCallback, useEffect, useMemo, useState } from 'react'
4
+ import { cn } from '@djangocfg/ui-core/lib'
5
+ import { useVirtualList } from '../../hooks/useVirtualList'
6
+ import type { GalleryThumbnailsProps } from '../../types'
7
+ import { normalizeImageUrl } from '../../utils'
8
+ import { ImageSpinner } from '../shared'
9
+
10
+ const SIZES = {
11
+ sm: { width: 56, height: 40 },
12
+ md: { width: 80, height: 56 },
13
+ lg: { width: 96, height: 72 },
14
+ } as const
15
+
16
+ /**
17
+ * GalleryThumbnailsVirtual - Virtualized thumbnail strip for large galleries
18
+ * Only renders visible thumbnails + buffer for optimal performance
19
+ */
20
+ export const GalleryThumbnailsVirtual = memo(function GalleryThumbnailsVirtual({
21
+ images,
22
+ currentIndex,
23
+ onSelect,
24
+ size = 'md',
25
+ className,
26
+ }: GalleryThumbnailsProps) {
27
+ const dimensions = SIZES[size]
28
+
29
+ const { containerRef, totalWidth, virtualItems, scrollToIndex } = useVirtualList({
30
+ itemCount: images.length,
31
+ itemWidth: dimensions.width,
32
+ gap: 8,
33
+ overscan: 3,
34
+ })
35
+
36
+ // Scroll active thumbnail into view
37
+ useEffect(() => {
38
+ scrollToIndex(currentIndex)
39
+ }, [currentIndex, scrollToIndex])
40
+
41
+ // Handle thumbnail click
42
+ const handleClick = useCallback(
43
+ (index: number) => {
44
+ onSelect(index)
45
+ },
46
+ [onSelect]
47
+ )
48
+
49
+ if (images.length <= 1) return null
50
+
51
+ return (
52
+ <div
53
+ ref={containerRef}
54
+ className={cn(
55
+ 'relative overflow-x-auto scrollbar-hide p-1',
56
+ className
57
+ )}
58
+ >
59
+ {/* Spacer for total scroll width */}
60
+ <div style={{ width: totalWidth, height: dimensions.height }} className="relative">
61
+ {virtualItems.map((virtualItem) => {
62
+ const image = images[virtualItem.index]
63
+ if (!image) return null
64
+ const isActive = virtualItem.index === currentIndex
65
+
66
+ return (
67
+ <VirtualThumbnail
68
+ key={image.id}
69
+ image={image}
70
+ index={virtualItem.index}
71
+ isActive={isActive}
72
+ left={virtualItem.start}
73
+ width={dimensions.width}
74
+ height={dimensions.height}
75
+ onClick={handleClick}
76
+ />
77
+ )
78
+ })}
79
+ </div>
80
+ </div>
81
+ )
82
+ })
83
+
84
+ // Extracted virtual thumbnail component
85
+ interface VirtualThumbnailProps {
86
+ image: { id: string; src: string; thumbnail?: string; alt?: string }
87
+ index: number
88
+ isActive: boolean
89
+ left: number
90
+ width: number
91
+ height: number
92
+ onClick: (index: number) => void
93
+ }
94
+
95
+ const VirtualThumbnail = memo(function VirtualThumbnail({
96
+ image,
97
+ index,
98
+ isActive,
99
+ left,
100
+ width,
101
+ height,
102
+ onClick,
103
+ }: VirtualThumbnailProps) {
104
+ const [isLoaded, setIsLoaded] = useState(false)
105
+
106
+ // Reset loading state when image source changes
107
+ const imageSrc = useMemo(() => image?.thumbnail || image?.src, [image?.thumbnail, image?.src])
108
+ useEffect(() => {
109
+ setIsLoaded(false)
110
+ }, [imageSrc])
111
+
112
+ const handleClick = useCallback(() => {
113
+ onClick(index)
114
+ }, [onClick, index])
115
+
116
+ const handleLoad = useCallback(() => {
117
+ setIsLoaded(true)
118
+ }, [])
119
+
120
+ return (
121
+ <button
122
+ type="button"
123
+ className={cn(
124
+ 'absolute top-0 rounded-lg overflow-hidden bg-muted',
125
+ 'border-2 transition-all duration-200',
126
+ 'focus:outline-none focus-visible:ring-2 focus-visible:ring-primary',
127
+ isActive
128
+ ? 'border-primary ring-1 ring-primary/30'
129
+ : 'border-transparent hover:border-muted-foreground/30'
130
+ )}
131
+ style={{
132
+ left,
133
+ width,
134
+ height,
135
+ }}
136
+ onClick={handleClick}
137
+ aria-label={`View image ${index + 1}`}
138
+ aria-current={isActive ? 'true' : undefined}
139
+ >
140
+ {/* Loading spinner */}
141
+ {!isLoaded && (
142
+ <div className="absolute inset-0 flex items-center justify-center">
143
+ <ImageSpinner size="sm" />
144
+ </div>
145
+ )}
146
+
147
+ <img
148
+ src={normalizeImageUrl(image.thumbnail || image.src)}
149
+ alt={image.alt || `Thumbnail ${index + 1}`}
150
+ className={cn(
151
+ 'w-full h-full object-cover transition-opacity duration-200',
152
+ isLoaded ? 'opacity-100' : 'opacity-0'
153
+ )}
154
+ loading="lazy"
155
+ onLoad={handleLoad}
156
+ />
157
+
158
+ {/* Active overlay */}
159
+ {isActive && (
160
+ <div className="absolute inset-0 bg-primary/10" />
161
+ )}
162
+ </button>
163
+ )
164
+ })
@@ -0,0 +1,2 @@
1
+ export { GalleryThumbnails } from './GalleryThumbnails'
2
+ export { GalleryThumbnailsVirtual } from './GalleryThumbnailsVirtual'
@@ -0,0 +1,182 @@
1
+ import type { Meta, StoryObj } from '@storybook/react-vite';
2
+ import {
3
+ Gallery,
4
+ GalleryCompact,
5
+ GalleryGrid,
6
+ type GalleryMediaItem,
7
+ } from '.';
8
+
9
+ const CAT: GalleryMediaItem = { id: '1', src: 'https://images.unsplash.com/photo-1518791841217-8f162f1e1131?w=900', alt: 'Cat' };
10
+ const DOG: GalleryMediaItem = { id: '2', src: 'https://images.unsplash.com/photo-1546182990-dffeafbe841d?w=900', alt: 'Dog' };
11
+ const BIRDS: GalleryMediaItem = { id: '3', src: 'https://images.unsplash.com/photo-1444464666168-49d633b86797?w=900', alt: 'Birds' };
12
+ const MOUNTAINS: GalleryMediaItem = { id: '4', src: 'https://images.unsplash.com/photo-1551279880-03041531948f?w=900', alt: 'Mountains' };
13
+ const FOREST: GalleryMediaItem = { id: '5', src: 'https://images.unsplash.com/photo-1502082553048-f009c37129b9?w=900', alt: 'Forest' };
14
+ const FIELD: GalleryMediaItem = { id: '6', src: 'https://images.unsplash.com/photo-1500382017468-9049fed747ef?w=900', alt: 'Field' };
15
+
16
+ const IMAGES: GalleryMediaItem[] = [CAT, DOG, BIRDS, MOUNTAINS, FOREST, FIELD];
17
+
18
+ const MIXED: GalleryMediaItem[] = [
19
+ CAT,
20
+ {
21
+ id: 'v1',
22
+ type: 'video',
23
+ src: 'https://images.unsplash.com/photo-1517694712202-14dd9538aa97?w=900',
24
+ videoSrc:
25
+ 'https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4',
26
+ videoType: 'video/mp4',
27
+ alt: 'Big Buck Bunny',
28
+ },
29
+ DOG,
30
+ BIRDS,
31
+ {
32
+ id: 'v2',
33
+ type: 'video',
34
+ src: 'https://images.unsplash.com/photo-1485846234645-a62644f84728?w=900',
35
+ videoSrc:
36
+ 'https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ElephantsDream.mp4',
37
+ videoType: 'video/mp4',
38
+ alt: 'Elephants Dream',
39
+ },
40
+ MOUNTAINS,
41
+ ];
42
+
43
+ const meta = {
44
+ title: 'Widgets/Visual/Gallery',
45
+ component: GalleryCompact,
46
+ tags: ['autodocs'],
47
+ parameters: {
48
+ layout: 'centered',
49
+ docs: {
50
+ description: {
51
+ component:
52
+ 'Gallery surface kit: `GalleryCompact` (card carousel), `GalleryGrid` (Airbnb-style mosaic), and `Gallery` (orchestrator with thumbnails + lightbox). Mix images and videos via `type: "video"` on items.',
53
+ },
54
+ },
55
+ },
56
+ args: { images: IMAGES },
57
+ } satisfies Meta<typeof GalleryCompact>;
58
+
59
+ export default meta;
60
+
61
+ type Story = StoryObj<typeof meta>;
62
+
63
+ /**
64
+ * Playground for `GalleryCompact` — tune the carousel surface used inside cards.
65
+ */
66
+ export const Playground: Story = {
67
+ args: {
68
+ aspectRatio: '16/9',
69
+ showDots: true,
70
+ showArrows: true,
71
+ showCounter: true,
72
+ enableZoom: true,
73
+ maxDots: 5,
74
+ },
75
+ argTypes: {
76
+ aspectRatio: {
77
+ control: 'inline-radio',
78
+ options: ['16/9', '4/3', '3/2', '1/1', 'auto'],
79
+ table: { category: 'Layout' },
80
+ },
81
+ showDots: { control: 'boolean', table: { category: 'Chrome' } },
82
+ showArrows: { control: 'boolean', table: { category: 'Chrome' } },
83
+ showCounter: { control: 'boolean', table: { category: 'Chrome' } },
84
+ enableZoom: { control: 'boolean', table: { category: 'Behaviour' } },
85
+ maxDots: {
86
+ control: { type: 'number', min: 1, max: 10, step: 1 },
87
+ table: { category: 'Chrome' },
88
+ },
89
+ },
90
+ render: (args) => (
91
+ <div className="w-[640px]">
92
+ <GalleryCompact {...args} />
93
+ </div>
94
+ ),
95
+ };
96
+
97
+ /**
98
+ * Compact carousel inside a typical product card — the most common deployment.
99
+ */
100
+ export const CompactCarousel: Story = {
101
+ render: () => (
102
+ <div className="grid grid-cols-1 md:grid-cols-2 gap-6 w-[720px]">
103
+ {[0, 1].map((i) => (
104
+ <div key={i} className="rounded-xl border border-border overflow-hidden bg-card">
105
+ <GalleryCompact
106
+ images={IMAGES}
107
+ aspectRatio="4/3"
108
+ showDots
109
+ showCounter
110
+ maxDots={5}
111
+ />
112
+ <div className="p-4">
113
+ <h3 className="font-medium text-sm">Listing {i + 1}</h3>
114
+ <p className="text-xs text-muted-foreground">$24,900 · 12k miles</p>
115
+ </div>
116
+ </div>
117
+ ))}
118
+ </div>
119
+ ),
120
+ };
121
+
122
+ /**
123
+ * Airbnb-style grid mosaic — layout auto-picks from image count, but you can pin one.
124
+ */
125
+ export const GridMasonry: Story = {
126
+ render: () => (
127
+ <div className="space-y-8 w-[820px]">
128
+ <div>
129
+ <div className="text-xs font-medium text-muted-foreground mb-2">layout="mosaic-5"</div>
130
+ <GalleryGrid images={IMAGES} layout="mosaic-5" rounded="lg" gap={2} showMoreBadge />
131
+ </div>
132
+ <div>
133
+ <div className="text-xs font-medium text-muted-foreground mb-2">layout="hero-left"</div>
134
+ <GalleryGrid images={IMAGES.slice(0, 3)} layout="hero-left" rounded="lg" gap={2} />
135
+ </div>
136
+ <div>
137
+ <div className="text-xs font-medium text-muted-foreground mb-2">layout="grid-2x2"</div>
138
+ <GalleryGrid images={IMAGES.slice(0, 4)} layout="grid-2x2" rounded="lg" gap={2} />
139
+ </div>
140
+ </div>
141
+ ),
142
+ };
143
+
144
+ /**
145
+ * Full `Gallery` orchestrator — main image, thumbnail strip, click to open lightbox.
146
+ */
147
+ export const Lightbox: Story = {
148
+ render: () => (
149
+ <div className="w-[820px]">
150
+ <Gallery
151
+ images={IMAGES}
152
+ previewMode="carousel"
153
+ showThumbnails
154
+ showControls
155
+ showCounter
156
+ enableLightbox
157
+ enableKeyboard
158
+ aspectRatio={16 / 9}
159
+ />
160
+ </div>
161
+ ),
162
+ };
163
+
164
+ /**
165
+ * Mixed media — `type: "video"` items render inline video players inside the gallery.
166
+ */
167
+ export const MixedMedia: Story = {
168
+ render: () => (
169
+ <div className="w-[820px]">
170
+ <Gallery
171
+ images={MIXED}
172
+ previewMode="carousel"
173
+ showThumbnails
174
+ showControls
175
+ showCounter
176
+ enableLightbox
177
+ enableKeyboard
178
+ aspectRatio={16 / 9}
179
+ />
180
+ </div>
181
+ ),
182
+ };
@@ -0,0 +1,23 @@
1
+ export { useGallery } from './useGallery'
2
+ export type { UseGalleryOptions } from './useGallery'
3
+
4
+ export { useSwipe } from './useSwipe'
5
+ export type { UseSwipeOptions } from './useSwipe'
6
+
7
+ export { usePreloadImages, preloadImage, preloadImages } from './usePreloadImages'
8
+
9
+ export { useZoom } from './useZoom'
10
+ export type { ZoomState, UseZoomOptions, UseZoomReturn } from './useZoom'
11
+
12
+ export { usePinchZoom } from './usePinchZoom'
13
+ export type { PinchZoomState, UsePinchZoomOptions, UsePinchZoomReturn } from './usePinchZoom'
14
+
15
+ export { useVirtualList } from './useVirtualList'
16
+ export type { VirtualListOptions, VirtualListReturn, VirtualItem } from './useVirtualList'
17
+
18
+ export { useImageDimensions, clearDimensionsCache, getCachedDimensions } from './useImageDimensions'
19
+ export type {
20
+ ImageWithDimensions,
21
+ UseImageDimensionsResult,
22
+ UseImageDimensionsOptions,
23
+ } from './useImageDimensions'
@@ -0,0 +1,138 @@
1
+ 'use client'
2
+
3
+ import { useCallback, useEffect, useMemo, useState } from 'react';
4
+
5
+ import type { GalleryMediaItem, GalleryContextValue } from '../types'
6
+
7
+ export interface UseGalleryOptions {
8
+ /** Array of images */
9
+ images: GalleryMediaItem[]
10
+ /** Initial selected index */
11
+ initialIndex?: number
12
+ /** Loop navigation */
13
+ loop?: boolean
14
+ /** Callback when image changes */
15
+ onImageChange?: (index: number, image: GalleryMediaItem) => void
16
+ }
17
+
18
+ /**
19
+ * Hook for managing gallery state
20
+ */
21
+ export function useGallery({
22
+ images,
23
+ initialIndex = 0,
24
+ loop = true,
25
+ onImageChange,
26
+ }: UseGalleryOptions): GalleryContextValue {
27
+ const [currentIndex, setCurrentIndex] = useState(initialIndex)
28
+ const [isLightboxOpen, setIsLightboxOpen] = useState(false)
29
+ const [isZoomed, setIsZoomed] = useState(false)
30
+ const [isLoading, setIsLoading] = useState(false)
31
+
32
+ const total = images.length
33
+ const hasMultiple = total > 1
34
+ const currentImage = images[currentIndex] ?? null
35
+
36
+ // Reset state when images change
37
+ useEffect(() => {
38
+ if (currentIndex >= total) {
39
+ setCurrentIndex(Math.max(0, total - 1))
40
+ }
41
+ }, [total, currentIndex])
42
+
43
+ // Reset zoom when image changes
44
+ useEffect(() => {
45
+ setIsZoomed(false)
46
+ setIsLoading(true)
47
+ }, [currentIndex])
48
+
49
+ const goTo = useCallback(
50
+ (index: number) => {
51
+ if (index < 0 || index >= total) return
52
+ setCurrentIndex(index)
53
+ onImageChange?.(index, images[index]!)
54
+ },
55
+ [total, images, onImageChange]
56
+ )
57
+
58
+ const next = useCallback(() => {
59
+ if (!hasMultiple) return
60
+ const nextIndex = loop
61
+ ? (currentIndex + 1) % total
62
+ : Math.min(currentIndex + 1, total - 1)
63
+ if (nextIndex !== currentIndex) {
64
+ goTo(nextIndex)
65
+ }
66
+ }, [currentIndex, total, hasMultiple, loop, goTo])
67
+
68
+ const prev = useCallback(() => {
69
+ if (!hasMultiple) return
70
+ const prevIndex = loop
71
+ ? (currentIndex - 1 + total) % total
72
+ : Math.max(currentIndex - 1, 0)
73
+ if (prevIndex !== currentIndex) {
74
+ goTo(prevIndex)
75
+ }
76
+ }, [currentIndex, total, hasMultiple, loop, goTo])
77
+
78
+ const openLightbox = useCallback((index?: number) => {
79
+ if (index !== undefined) {
80
+ setCurrentIndex(index)
81
+ }
82
+ setIsLightboxOpen(true)
83
+ setIsZoomed(false)
84
+ }, [])
85
+
86
+ const closeLightbox = useCallback(() => {
87
+ setIsLightboxOpen(false)
88
+ setIsZoomed(false)
89
+ }, [])
90
+
91
+ const toggleZoom = useCallback(() => {
92
+ setIsZoomed((prev) => !prev)
93
+ }, [])
94
+
95
+ const setLoadingState = useCallback((loading: boolean) => {
96
+ setIsLoading(loading)
97
+ }, [])
98
+
99
+ return useMemo(
100
+ () => ({
101
+ // State
102
+ currentIndex,
103
+ isLightboxOpen,
104
+ isZoomed,
105
+ isLoading,
106
+ // Computed
107
+ images,
108
+ currentImage,
109
+ total,
110
+ hasMultiple,
111
+ // Actions
112
+ goTo,
113
+ next,
114
+ prev,
115
+ openLightbox,
116
+ closeLightbox,
117
+ toggleZoom,
118
+ setLoading: setLoadingState,
119
+ }),
120
+ [
121
+ currentIndex,
122
+ isLightboxOpen,
123
+ isZoomed,
124
+ isLoading,
125
+ images,
126
+ currentImage,
127
+ total,
128
+ hasMultiple,
129
+ goTo,
130
+ next,
131
+ prev,
132
+ openLightbox,
133
+ closeLightbox,
134
+ toggleZoom,
135
+ setLoadingState,
136
+ ]
137
+ )
138
+ }