@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,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,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
|
+
}
|