@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,519 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { memo, useCallback, useEffect, useMemo, useState } from 'react'
|
|
4
|
+
import { cn } from '@djangocfg/ui-core/lib'
|
|
5
|
+
import { Play } from 'lucide-react'
|
|
6
|
+
import type { GalleryMediaItem } from '../../types'
|
|
7
|
+
import { normalizeImageUrl } from '../../utils'
|
|
8
|
+
import { ImageSpinner } from '../shared'
|
|
9
|
+
|
|
10
|
+
export type GalleryGridLayout =
|
|
11
|
+
| 'auto'
|
|
12
|
+
| 'single'
|
|
13
|
+
| 'two-cols'
|
|
14
|
+
| 'hero-left'
|
|
15
|
+
| 'grid-2x2'
|
|
16
|
+
| 'mosaic-5'
|
|
17
|
+
|
|
18
|
+
export interface GalleryGridProps {
|
|
19
|
+
/** Array of images to display */
|
|
20
|
+
images: GalleryMediaItem[]
|
|
21
|
+
/** Maximum images to show in grid */
|
|
22
|
+
maxVisible?: number
|
|
23
|
+
/** Grid layout (auto picks based on count) */
|
|
24
|
+
layout?: GalleryGridLayout
|
|
25
|
+
/** Aspect ratio for the grid container */
|
|
26
|
+
aspectRatio?: number
|
|
27
|
+
/** Gap between grid items (tailwind spacing) */
|
|
28
|
+
gap?: 1 | 2 | 3 | 4
|
|
29
|
+
/** Border radius */
|
|
30
|
+
rounded?: 'none' | 'sm' | 'md' | 'lg' | 'xl' | '2xl'
|
|
31
|
+
/** Callback when image is clicked */
|
|
32
|
+
onImageClick?: (index: number) => void
|
|
33
|
+
/** Show "+N more" badge */
|
|
34
|
+
showMoreBadge?: boolean
|
|
35
|
+
/** Show loading skeleton */
|
|
36
|
+
loading?: boolean
|
|
37
|
+
/** Stagger delay between image reveals (ms), 0 to disable */
|
|
38
|
+
staggerDelay?: number
|
|
39
|
+
/** Additional CSS class */
|
|
40
|
+
className?: string
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const GAP_CLASSES = {
|
|
44
|
+
1: 'gap-1',
|
|
45
|
+
2: 'gap-2',
|
|
46
|
+
3: 'gap-3',
|
|
47
|
+
4: 'gap-4',
|
|
48
|
+
} as const
|
|
49
|
+
|
|
50
|
+
const ROUNDED_CLASSES = {
|
|
51
|
+
none: 'rounded-none',
|
|
52
|
+
sm: 'rounded-sm',
|
|
53
|
+
md: 'rounded-md',
|
|
54
|
+
lg: 'rounded-lg',
|
|
55
|
+
xl: 'rounded-xl',
|
|
56
|
+
'2xl': 'rounded-2xl',
|
|
57
|
+
} as const
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Get layout based on image count
|
|
61
|
+
*/
|
|
62
|
+
function getLayoutForCount(count: number): GalleryGridLayout {
|
|
63
|
+
if (count === 1) return 'single'
|
|
64
|
+
if (count === 2) return 'two-cols'
|
|
65
|
+
if (count === 3) return 'hero-left'
|
|
66
|
+
if (count === 4) return 'grid-2x2'
|
|
67
|
+
return 'mosaic-5'
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* GalleryGrid - Fixed layout grid for property images
|
|
72
|
+
*
|
|
73
|
+
* Airbnb-style layouts based on image count:
|
|
74
|
+
* - 1 image: full width
|
|
75
|
+
* - 2 images: side by side
|
|
76
|
+
* - 3 images: hero left + 2 stacked right
|
|
77
|
+
* - 4 images: 2x2 grid
|
|
78
|
+
* - 5+ images: hero left + 2x2 grid right
|
|
79
|
+
*/
|
|
80
|
+
export const GalleryGrid = memo(function GalleryGrid({
|
|
81
|
+
images,
|
|
82
|
+
maxVisible = 5,
|
|
83
|
+
layout = 'auto',
|
|
84
|
+
aspectRatio = 16 / 9,
|
|
85
|
+
gap = 2,
|
|
86
|
+
rounded = 'xl',
|
|
87
|
+
onImageClick,
|
|
88
|
+
showMoreBadge = true,
|
|
89
|
+
loading = false,
|
|
90
|
+
staggerDelay = 75,
|
|
91
|
+
className,
|
|
92
|
+
}: GalleryGridProps) {
|
|
93
|
+
const visibleImages = useMemo(() => {
|
|
94
|
+
return images.slice(0, maxVisible)
|
|
95
|
+
}, [images, maxVisible])
|
|
96
|
+
|
|
97
|
+
const currentLayout = useMemo((): GalleryGridLayout => {
|
|
98
|
+
if (layout !== 'auto') return layout
|
|
99
|
+
return getLayoutForCount(visibleImages.length || maxVisible)
|
|
100
|
+
}, [layout, visibleImages.length, maxVisible])
|
|
101
|
+
|
|
102
|
+
const remainingCount = useMemo(() => {
|
|
103
|
+
return Math.max(0, images.length - maxVisible)
|
|
104
|
+
}, [images.length, maxVisible])
|
|
105
|
+
|
|
106
|
+
const handleClick = useCallback(
|
|
107
|
+
(index: number) => {
|
|
108
|
+
onImageClick?.(index)
|
|
109
|
+
},
|
|
110
|
+
[onImageClick]
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
// Show skeleton when loading or no images yet
|
|
114
|
+
if (loading || images.length === 0) {
|
|
115
|
+
return (
|
|
116
|
+
<div
|
|
117
|
+
className={cn(
|
|
118
|
+
'relative overflow-hidden',
|
|
119
|
+
ROUNDED_CLASSES[rounded],
|
|
120
|
+
className
|
|
121
|
+
)}
|
|
122
|
+
style={{ aspectRatio }}
|
|
123
|
+
>
|
|
124
|
+
<div className="absolute inset-0">
|
|
125
|
+
<GridSkeleton layout={currentLayout} gap={gap} />
|
|
126
|
+
</div>
|
|
127
|
+
</div>
|
|
128
|
+
)
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return (
|
|
132
|
+
<div
|
|
133
|
+
className={cn(
|
|
134
|
+
'relative overflow-hidden',
|
|
135
|
+
ROUNDED_CLASSES[rounded],
|
|
136
|
+
className
|
|
137
|
+
)}
|
|
138
|
+
style={{ aspectRatio }}
|
|
139
|
+
>
|
|
140
|
+
<div className="absolute inset-0">
|
|
141
|
+
<GridLayout
|
|
142
|
+
layout={currentLayout}
|
|
143
|
+
images={visibleImages}
|
|
144
|
+
gap={gap}
|
|
145
|
+
remainingCount={remainingCount}
|
|
146
|
+
showMoreBadge={showMoreBadge}
|
|
147
|
+
staggerDelay={staggerDelay}
|
|
148
|
+
onImageClick={handleClick}
|
|
149
|
+
/>
|
|
150
|
+
</div>
|
|
151
|
+
</div>
|
|
152
|
+
)
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
// Grid layout renderer
|
|
156
|
+
interface GridLayoutProps {
|
|
157
|
+
layout: GalleryGridLayout
|
|
158
|
+
images: GalleryMediaItem[]
|
|
159
|
+
gap: 1 | 2 | 3 | 4
|
|
160
|
+
remainingCount: number
|
|
161
|
+
showMoreBadge: boolean
|
|
162
|
+
staggerDelay: number
|
|
163
|
+
onImageClick: (index: number) => void
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const GridLayout = memo(function GridLayout({
|
|
167
|
+
layout,
|
|
168
|
+
images,
|
|
169
|
+
gap,
|
|
170
|
+
remainingCount,
|
|
171
|
+
showMoreBadge,
|
|
172
|
+
staggerDelay,
|
|
173
|
+
onImageClick,
|
|
174
|
+
}: GridLayoutProps) {
|
|
175
|
+
if (!images || images.length === 0) return null
|
|
176
|
+
|
|
177
|
+
// Guarded non-empty above; bound once so every layout can index it safely.
|
|
178
|
+
const firstImage = images[0]
|
|
179
|
+
if (!firstImage) return null
|
|
180
|
+
|
|
181
|
+
switch (layout) {
|
|
182
|
+
case 'single':
|
|
183
|
+
return (
|
|
184
|
+
<GridItem
|
|
185
|
+
image={firstImage}
|
|
186
|
+
index={0}
|
|
187
|
+
staggerDelay={staggerDelay}
|
|
188
|
+
onClick={onImageClick}
|
|
189
|
+
className="w-full h-full"
|
|
190
|
+
/>
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
case 'two-cols':
|
|
194
|
+
return (
|
|
195
|
+
<div className={cn('grid grid-cols-2 h-full', GAP_CLASSES[gap])}>
|
|
196
|
+
{images.slice(0, 2).map((image, index) => (
|
|
197
|
+
<GridItem
|
|
198
|
+
key={image.id}
|
|
199
|
+
image={image}
|
|
200
|
+
index={index}
|
|
201
|
+
staggerDelay={staggerDelay}
|
|
202
|
+
onClick={onImageClick}
|
|
203
|
+
showBadge={showMoreBadge && index === 1 && remainingCount > 0}
|
|
204
|
+
badgeCount={remainingCount}
|
|
205
|
+
/>
|
|
206
|
+
))}
|
|
207
|
+
</div>
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
case 'hero-left':
|
|
211
|
+
return (
|
|
212
|
+
<div
|
|
213
|
+
className={cn('h-full', GAP_CLASSES[gap])}
|
|
214
|
+
style={{
|
|
215
|
+
display: 'grid',
|
|
216
|
+
gridTemplateColumns: '1fr 1fr',
|
|
217
|
+
}}
|
|
218
|
+
>
|
|
219
|
+
<GridItem
|
|
220
|
+
image={firstImage}
|
|
221
|
+
index={0}
|
|
222
|
+
staggerDelay={staggerDelay}
|
|
223
|
+
onClick={onImageClick}
|
|
224
|
+
/>
|
|
225
|
+
<div
|
|
226
|
+
className={cn('h-full overflow-hidden', GAP_CLASSES[gap])}
|
|
227
|
+
style={{
|
|
228
|
+
display: 'grid',
|
|
229
|
+
gridTemplateRows: '1fr 1fr',
|
|
230
|
+
}}
|
|
231
|
+
>
|
|
232
|
+
{images.slice(1, 3).map((image, index) => (
|
|
233
|
+
<GridItem
|
|
234
|
+
key={image.id}
|
|
235
|
+
image={image}
|
|
236
|
+
index={index + 1}
|
|
237
|
+
staggerDelay={staggerDelay}
|
|
238
|
+
onClick={onImageClick}
|
|
239
|
+
showBadge={showMoreBadge && index === 1 && remainingCount > 0}
|
|
240
|
+
badgeCount={remainingCount}
|
|
241
|
+
/>
|
|
242
|
+
))}
|
|
243
|
+
</div>
|
|
244
|
+
</div>
|
|
245
|
+
)
|
|
246
|
+
|
|
247
|
+
case 'grid-2x2':
|
|
248
|
+
return (
|
|
249
|
+
<div className={cn('grid grid-cols-2 grid-rows-2 h-full', GAP_CLASSES[gap])}>
|
|
250
|
+
{images.slice(0, 4).map((image, index) => (
|
|
251
|
+
<GridItem
|
|
252
|
+
key={image.id}
|
|
253
|
+
image={image}
|
|
254
|
+
index={index}
|
|
255
|
+
staggerDelay={staggerDelay}
|
|
256
|
+
onClick={onImageClick}
|
|
257
|
+
showBadge={showMoreBadge && index === 3 && remainingCount > 0}
|
|
258
|
+
badgeCount={remainingCount}
|
|
259
|
+
/>
|
|
260
|
+
))}
|
|
261
|
+
</div>
|
|
262
|
+
)
|
|
263
|
+
|
|
264
|
+
case 'mosaic-5':
|
|
265
|
+
default:
|
|
266
|
+
// Airbnb-style: hero (50%) + 2x2 grid (50%) using single grid with areas
|
|
267
|
+
return (
|
|
268
|
+
<div
|
|
269
|
+
className={cn('h-full', GAP_CLASSES[gap])}
|
|
270
|
+
style={{
|
|
271
|
+
display: 'grid',
|
|
272
|
+
gridTemplateColumns: '1fr 1fr 1fr 1fr',
|
|
273
|
+
gridTemplateRows: '1fr 1fr',
|
|
274
|
+
gridTemplateAreas: `
|
|
275
|
+
"hero hero img2 img3"
|
|
276
|
+
"hero hero img4 img5"
|
|
277
|
+
`,
|
|
278
|
+
}}
|
|
279
|
+
>
|
|
280
|
+
<GridItem
|
|
281
|
+
image={firstImage}
|
|
282
|
+
index={0}
|
|
283
|
+
staggerDelay={staggerDelay}
|
|
284
|
+
onClick={onImageClick}
|
|
285
|
+
style={{ gridArea: 'hero' }}
|
|
286
|
+
/>
|
|
287
|
+
{images[1] && (
|
|
288
|
+
<GridItem
|
|
289
|
+
key={images[1].id}
|
|
290
|
+
image={images[1]}
|
|
291
|
+
index={1}
|
|
292
|
+
staggerDelay={staggerDelay}
|
|
293
|
+
onClick={onImageClick}
|
|
294
|
+
style={{ gridArea: 'img2' }}
|
|
295
|
+
/>
|
|
296
|
+
)}
|
|
297
|
+
{images[2] && (
|
|
298
|
+
<GridItem
|
|
299
|
+
key={images[2].id}
|
|
300
|
+
image={images[2]}
|
|
301
|
+
index={2}
|
|
302
|
+
staggerDelay={staggerDelay}
|
|
303
|
+
onClick={onImageClick}
|
|
304
|
+
style={{ gridArea: 'img3' }}
|
|
305
|
+
/>
|
|
306
|
+
)}
|
|
307
|
+
{images[3] && (
|
|
308
|
+
<GridItem
|
|
309
|
+
key={images[3].id}
|
|
310
|
+
image={images[3]}
|
|
311
|
+
index={3}
|
|
312
|
+
staggerDelay={staggerDelay}
|
|
313
|
+
onClick={onImageClick}
|
|
314
|
+
style={{ gridArea: 'img4' }}
|
|
315
|
+
/>
|
|
316
|
+
)}
|
|
317
|
+
{images[4] && (
|
|
318
|
+
<GridItem
|
|
319
|
+
key={images[4].id}
|
|
320
|
+
image={images[4]}
|
|
321
|
+
index={4}
|
|
322
|
+
staggerDelay={staggerDelay}
|
|
323
|
+
onClick={onImageClick}
|
|
324
|
+
showBadge={showMoreBadge && remainingCount > 0}
|
|
325
|
+
badgeCount={remainingCount}
|
|
326
|
+
style={{ gridArea: 'img5' }}
|
|
327
|
+
/>
|
|
328
|
+
)}
|
|
329
|
+
</div>
|
|
330
|
+
)
|
|
331
|
+
}
|
|
332
|
+
})
|
|
333
|
+
|
|
334
|
+
// Single grid item
|
|
335
|
+
interface GridItemProps {
|
|
336
|
+
image: GalleryMediaItem
|
|
337
|
+
index: number
|
|
338
|
+
staggerDelay: number
|
|
339
|
+
onClick: (index: number) => void
|
|
340
|
+
className?: string
|
|
341
|
+
style?: React.CSSProperties
|
|
342
|
+
showBadge?: boolean
|
|
343
|
+
badgeCount?: number
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
const GridItem = memo(function GridItem({
|
|
347
|
+
image,
|
|
348
|
+
index,
|
|
349
|
+
staggerDelay,
|
|
350
|
+
onClick,
|
|
351
|
+
className,
|
|
352
|
+
style,
|
|
353
|
+
showBadge = false,
|
|
354
|
+
badgeCount = 0,
|
|
355
|
+
}: GridItemProps) {
|
|
356
|
+
const [isLoaded, setIsLoaded] = useState(false)
|
|
357
|
+
const [hasError, setHasError] = useState(false)
|
|
358
|
+
|
|
359
|
+
// Reset loading state when image source changes
|
|
360
|
+
const imageSrc = image?.thumbnail || image?.src
|
|
361
|
+
useEffect(() => {
|
|
362
|
+
setIsLoaded(false)
|
|
363
|
+
setHasError(false)
|
|
364
|
+
}, [imageSrc])
|
|
365
|
+
|
|
366
|
+
const handleClick = useCallback(() => {
|
|
367
|
+
onClick(index)
|
|
368
|
+
}, [onClick, index])
|
|
369
|
+
|
|
370
|
+
const handleLoad = useCallback(() => {
|
|
371
|
+
setIsLoaded(true)
|
|
372
|
+
}, [])
|
|
373
|
+
|
|
374
|
+
const handleError = useCallback(() => {
|
|
375
|
+
setHasError(true)
|
|
376
|
+
}, [])
|
|
377
|
+
|
|
378
|
+
if (!image) return null
|
|
379
|
+
|
|
380
|
+
const isVideo = image.type === 'video'
|
|
381
|
+
const animationDelay = staggerDelay > 0 ? `${index * staggerDelay}ms` : '0ms'
|
|
382
|
+
|
|
383
|
+
return (
|
|
384
|
+
<button
|
|
385
|
+
type="button"
|
|
386
|
+
className={cn(
|
|
387
|
+
'relative overflow-hidden bg-muted h-full w-full min-w-0 min-h-0',
|
|
388
|
+
'focus:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-inset',
|
|
389
|
+
'group',
|
|
390
|
+
className
|
|
391
|
+
)}
|
|
392
|
+
style={style}
|
|
393
|
+
onClick={handleClick}
|
|
394
|
+
aria-label={`View image ${index + 1}`}
|
|
395
|
+
>
|
|
396
|
+
{/* Loading spinner - shows until image loads */}
|
|
397
|
+
{!isLoaded && !hasError && (
|
|
398
|
+
<div className="absolute inset-0 flex items-center justify-center z-10">
|
|
399
|
+
<ImageSpinner size="md" />
|
|
400
|
+
</div>
|
|
401
|
+
)}
|
|
402
|
+
|
|
403
|
+
<img
|
|
404
|
+
src={normalizeImageUrl(image.thumbnail || image.src)}
|
|
405
|
+
alt={image.alt || `Image ${index + 1}`}
|
|
406
|
+
className={cn(
|
|
407
|
+
'w-full h-full object-cover transition-all duration-300 group-hover:scale-105',
|
|
408
|
+
// Hide until loaded, then fade in
|
|
409
|
+
!isLoaded && 'opacity-0',
|
|
410
|
+
isLoaded && 'opacity-100'
|
|
411
|
+
)}
|
|
412
|
+
style={staggerDelay > 0 && isLoaded ? { animationDelay } : undefined}
|
|
413
|
+
loading={index === 0 ? 'eager' : 'lazy'}
|
|
414
|
+
onLoad={handleLoad}
|
|
415
|
+
onError={handleError}
|
|
416
|
+
/>
|
|
417
|
+
|
|
418
|
+
{/* Hover overlay */}
|
|
419
|
+
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/10 transition-colors" />
|
|
420
|
+
|
|
421
|
+
{/* Video indicator */}
|
|
422
|
+
{isVideo && (
|
|
423
|
+
<div className="absolute inset-0 flex items-center justify-center">
|
|
424
|
+
<div className="w-12 h-12 rounded-full bg-black/60 flex items-center justify-center">
|
|
425
|
+
<Play className="w-6 h-6 text-white ml-0.5" />
|
|
426
|
+
</div>
|
|
427
|
+
</div>
|
|
428
|
+
)}
|
|
429
|
+
|
|
430
|
+
{/* "+N more" badge */}
|
|
431
|
+
{showBadge && badgeCount > 0 && (
|
|
432
|
+
<div className="absolute inset-0 bg-black/50 flex items-center justify-center">
|
|
433
|
+
<span className="text-white text-xl font-semibold">
|
|
434
|
+
+{badgeCount}
|
|
435
|
+
</span>
|
|
436
|
+
</div>
|
|
437
|
+
)}
|
|
438
|
+
</button>
|
|
439
|
+
)
|
|
440
|
+
})
|
|
441
|
+
|
|
442
|
+
// Skeleton component matching grid layouts
|
|
443
|
+
interface GridSkeletonProps {
|
|
444
|
+
layout: GalleryGridLayout
|
|
445
|
+
gap: 1 | 2 | 3 | 4
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
const GridSkeleton = memo(function GridSkeleton({ layout, gap }: GridSkeletonProps) {
|
|
449
|
+
const skeletonCell = "bg-muted animate-pulse"
|
|
450
|
+
|
|
451
|
+
switch (layout) {
|
|
452
|
+
case 'single':
|
|
453
|
+
return <div className={cn('h-full w-full', skeletonCell)} />
|
|
454
|
+
|
|
455
|
+
case 'two-cols':
|
|
456
|
+
return (
|
|
457
|
+
<div className={cn('grid grid-cols-2 h-full', GAP_CLASSES[gap])}>
|
|
458
|
+
<div className={skeletonCell} />
|
|
459
|
+
<div className={skeletonCell} />
|
|
460
|
+
</div>
|
|
461
|
+
)
|
|
462
|
+
|
|
463
|
+
case 'hero-left':
|
|
464
|
+
return (
|
|
465
|
+
<div
|
|
466
|
+
className={cn('h-full', GAP_CLASSES[gap])}
|
|
467
|
+
style={{
|
|
468
|
+
display: 'grid',
|
|
469
|
+
gridTemplateColumns: '1fr 1fr',
|
|
470
|
+
}}
|
|
471
|
+
>
|
|
472
|
+
<div className={skeletonCell} />
|
|
473
|
+
<div
|
|
474
|
+
className={cn('h-full', GAP_CLASSES[gap])}
|
|
475
|
+
style={{
|
|
476
|
+
display: 'grid',
|
|
477
|
+
gridTemplateRows: '1fr 1fr',
|
|
478
|
+
}}
|
|
479
|
+
>
|
|
480
|
+
<div className={skeletonCell} />
|
|
481
|
+
<div className={skeletonCell} />
|
|
482
|
+
</div>
|
|
483
|
+
</div>
|
|
484
|
+
)
|
|
485
|
+
|
|
486
|
+
case 'grid-2x2':
|
|
487
|
+
return (
|
|
488
|
+
<div className={cn('grid grid-cols-2 grid-rows-2 h-full', GAP_CLASSES[gap])}>
|
|
489
|
+
<div className={skeletonCell} />
|
|
490
|
+
<div className={skeletonCell} />
|
|
491
|
+
<div className={skeletonCell} />
|
|
492
|
+
<div className={skeletonCell} />
|
|
493
|
+
</div>
|
|
494
|
+
)
|
|
495
|
+
|
|
496
|
+
case 'mosaic-5':
|
|
497
|
+
default:
|
|
498
|
+
return (
|
|
499
|
+
<div
|
|
500
|
+
className={cn('h-full', GAP_CLASSES[gap])}
|
|
501
|
+
style={{
|
|
502
|
+
display: 'grid',
|
|
503
|
+
gridTemplateColumns: '1fr 1fr 1fr 1fr',
|
|
504
|
+
gridTemplateRows: '1fr 1fr',
|
|
505
|
+
gridTemplateAreas: `
|
|
506
|
+
"hero hero img2 img3"
|
|
507
|
+
"hero hero img4 img5"
|
|
508
|
+
`,
|
|
509
|
+
}}
|
|
510
|
+
>
|
|
511
|
+
<div className={skeletonCell} style={{ gridArea: 'hero' }} />
|
|
512
|
+
<div className={skeletonCell} style={{ gridArea: 'img2' }} />
|
|
513
|
+
<div className={skeletonCell} style={{ gridArea: 'img3' }} />
|
|
514
|
+
<div className={skeletonCell} style={{ gridArea: 'img4' }} />
|
|
515
|
+
<div className={skeletonCell} style={{ gridArea: 'img5' }} />
|
|
516
|
+
</div>
|
|
517
|
+
)
|
|
518
|
+
}
|
|
519
|
+
})
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { memo } from 'react'
|
|
4
|
+
import { cn } from '@djangocfg/ui-core/lib'
|
|
5
|
+
|
|
6
|
+
export interface ImageSpinnerProps {
|
|
7
|
+
/** Size variant */
|
|
8
|
+
size?: 'sm' | 'md' | 'lg'
|
|
9
|
+
/** Additional class */
|
|
10
|
+
className?: string
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const SIZE_CLASSES = {
|
|
14
|
+
sm: 'w-4 h-4 border-[1.5px]',
|
|
15
|
+
md: 'w-6 h-6 border-2',
|
|
16
|
+
lg: 'w-8 h-8 border-2',
|
|
17
|
+
} as const
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Minimal loading spinner for images
|
|
21
|
+
* Adapts to dark/light theme via muted-foreground
|
|
22
|
+
*/
|
|
23
|
+
export const ImageSpinner = memo(function ImageSpinner({
|
|
24
|
+
size = 'md',
|
|
25
|
+
className,
|
|
26
|
+
}: ImageSpinnerProps) {
|
|
27
|
+
return (
|
|
28
|
+
<div
|
|
29
|
+
className={cn(
|
|
30
|
+
'rounded-full animate-spin',
|
|
31
|
+
'border-muted-foreground/20 border-t-muted-foreground/60',
|
|
32
|
+
SIZE_CLASSES[size],
|
|
33
|
+
className
|
|
34
|
+
)}
|
|
35
|
+
/>
|
|
36
|
+
)
|
|
37
|
+
})
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { ImageSpinner, type ImageSpinnerProps } from './ImageSpinner'
|