@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,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'
|