@djangocfg/ui-tools 2.1.110 → 2.1.112
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/README.md +242 -49
- package/dist/JsonSchemaForm-65NLLK56.mjs +4 -0
- package/dist/JsonSchemaForm-65NLLK56.mjs.map +1 -0
- package/dist/JsonSchemaForm-PY6DH3HE.cjs +13 -0
- package/dist/JsonSchemaForm-PY6DH3HE.cjs.map +1 -0
- package/dist/JsonTree-6RYAOPSS.mjs +4 -0
- package/dist/JsonTree-6RYAOPSS.mjs.map +1 -0
- package/dist/JsonTree-7OH6CIHT.cjs +10 -0
- package/dist/JsonTree-7OH6CIHT.cjs.map +1 -0
- package/dist/MapContainer-GXQLP5WY.mjs +214 -0
- package/dist/MapContainer-GXQLP5WY.mjs.map +1 -0
- package/dist/MapContainer-RYG4HPH4.cjs +221 -0
- package/dist/MapContainer-RYG4HPH4.cjs.map +1 -0
- package/dist/{Mermaid.client-4OCKJ6QD.mjs → Mermaid.client-OKACITCW.mjs} +16 -7
- package/dist/Mermaid.client-OKACITCW.mjs.map +1 -0
- package/dist/{Mermaid.client-ZP6OE46Z.cjs → Mermaid.client-PNXEC6YL.cjs} +16 -7
- package/dist/Mermaid.client-PNXEC6YL.cjs.map +1 -0
- package/dist/{PlaygroundLayout-XXVBU4WZ.cjs → PlaygroundLayout-SYMEAG3J.cjs} +25 -24
- package/dist/PlaygroundLayout-SYMEAG3J.cjs.map +1 -0
- package/dist/{PlaygroundLayout-LMQTVXSP.mjs → PlaygroundLayout-UQRBU5RH.mjs} +4 -3
- package/dist/PlaygroundLayout-UQRBU5RH.mjs.map +1 -0
- package/dist/{PrettyCode.client-2CLSV2VD.cjs → PrettyCode.client-DANYYQYO.cjs} +11 -4
- package/dist/PrettyCode.client-DANYYQYO.cjs.map +1 -0
- package/dist/{PrettyCode.client-Y2BVON7R.mjs → PrettyCode.client-RS5ZTNBT.mjs} +11 -4
- package/dist/PrettyCode.client-RS5ZTNBT.mjs.map +1 -0
- package/dist/chunk-2DSR7V2L.mjs +561 -0
- package/dist/chunk-2DSR7V2L.mjs.map +1 -0
- package/dist/chunk-47T5ECYV.cjs +1357 -0
- package/dist/chunk-47T5ECYV.cjs.map +1 -0
- package/dist/chunk-5QT3QYFZ.cjs +189 -0
- package/dist/chunk-5QT3QYFZ.cjs.map +1 -0
- package/dist/chunk-7IIRYG4S.mjs +1057 -0
- package/dist/chunk-7IIRYG4S.mjs.map +1 -0
- package/dist/{chunk-FB5QBSI3.cjs → chunk-DI3HUXHK.cjs} +15 -195
- package/dist/chunk-DI3HUXHK.cjs.map +1 -0
- package/dist/chunk-EVGWYASL.cjs +1528 -0
- package/dist/chunk-EVGWYASL.cjs.map +1 -0
- package/dist/chunk-F2N7P5XU.cjs +30 -0
- package/dist/chunk-F2N7P5XU.cjs.map +1 -0
- package/dist/{chunk-L6UHASYQ.mjs → chunk-G6PRZP5I.mjs} +7 -186
- package/dist/chunk-G6PRZP5I.mjs.map +1 -0
- package/dist/chunk-JWB2EWQO.mjs +5 -0
- package/dist/chunk-JWB2EWQO.mjs.map +1 -0
- package/dist/chunk-LTJX2JXE.mjs +338 -0
- package/dist/chunk-LTJX2JXE.mjs.map +1 -0
- package/dist/chunk-OVNC4KW6.mjs +1494 -0
- package/dist/chunk-OVNC4KW6.mjs.map +1 -0
- package/dist/chunk-PNZSJN6T.cjs +1086 -0
- package/dist/chunk-PNZSJN6T.cjs.map +1 -0
- package/dist/chunk-TEFRA7GW.cjs +565 -0
- package/dist/chunk-TEFRA7GW.cjs.map +1 -0
- package/dist/chunk-UOMPPIED.mjs +1343 -0
- package/dist/chunk-UOMPPIED.mjs.map +1 -0
- package/dist/chunk-W6YHQI4F.mjs +187 -0
- package/dist/chunk-W6YHQI4F.mjs.map +1 -0
- package/dist/chunk-XTBRWVIV.cjs +346 -0
- package/dist/chunk-XTBRWVIV.cjs.map +1 -0
- package/dist/components-C7ZL7OMY.mjs +5 -0
- package/dist/components-C7ZL7OMY.mjs.map +1 -0
- package/dist/components-CJ2IB65O.cjs +27 -0
- package/dist/components-CJ2IB65O.cjs.map +1 -0
- package/dist/components-EASJYK45.mjs +6 -0
- package/dist/components-EASJYK45.mjs.map +1 -0
- package/dist/components-LDRFDV4A.cjs +22 -0
- package/dist/components-LDRFDV4A.cjs.map +1 -0
- package/dist/components-VZKUTDJK.mjs +5 -0
- package/dist/components-VZKUTDJK.mjs.map +1 -0
- package/dist/components-Y64GTIMQ.cjs +42 -0
- package/dist/components-Y64GTIMQ.cjs.map +1 -0
- package/dist/index.cjs +701 -4813
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +1274 -1026
- package/dist/index.d.ts +1274 -1026
- package/dist/index.mjs +358 -4730
- package/dist/index.mjs.map +1 -1
- package/package.json +27 -4
- package/src/components/index.ts +17 -0
- package/src/components/lazy-wrapper.tsx +281 -0
- package/src/index.ts +92 -7
- package/src/tools/AudioPlayer/components/HybridAudioPlayer.tsx +14 -5
- package/src/tools/AudioPlayer/lazy.tsx +85 -0
- package/src/tools/Gallery/components/Gallery.tsx +182 -0
- package/src/tools/Gallery/components/GalleryCarousel.tsx +251 -0
- package/src/tools/Gallery/components/GalleryCompact.tsx +173 -0
- package/src/tools/Gallery/components/GalleryGrid.tsx +493 -0
- package/src/tools/Gallery/components/GalleryImage.tsx +66 -0
- package/src/tools/Gallery/components/GalleryLightbox.tsx +331 -0
- package/src/tools/Gallery/components/GalleryMedia.tsx +66 -0
- package/src/tools/Gallery/components/GalleryThumbnails.tsx +173 -0
- package/src/tools/Gallery/components/GalleryThumbnailsVirtual.tsx +138 -0
- package/src/tools/Gallery/components/GalleryVideo.tsx +222 -0
- package/src/tools/Gallery/components/index.ts +13 -0
- package/src/tools/Gallery/hooks/index.ts +23 -0
- package/src/tools/Gallery/hooks/useGallery.ts +137 -0
- package/src/tools/Gallery/hooks/useImageDimensions.ts +223 -0
- package/src/tools/Gallery/hooks/usePinchZoom.ts +234 -0
- package/src/tools/Gallery/hooks/usePreloadImages.ts +71 -0
- package/src/tools/Gallery/hooks/useSwipe.ts +86 -0
- package/src/tools/Gallery/hooks/useVirtualList.ts +129 -0
- package/src/tools/Gallery/hooks/useZoom.ts +316 -0
- package/src/tools/Gallery/index.ts +66 -0
- package/src/tools/Gallery/types.ts +183 -0
- package/src/tools/Gallery/utils/imageAnalysis.ts +52 -0
- package/src/tools/Gallery/utils/index.ts +11 -0
- package/src/tools/ImageViewer/components/ImageToolbar.tsx +20 -8
- package/src/tools/ImageViewer/components/ImageViewer.tsx +12 -4
- package/src/tools/ImageViewer/lazy.tsx +37 -0
- package/src/tools/JsonForm/lazy.tsx +43 -0
- package/src/tools/JsonForm/widgets/ColorWidget.tsx +4 -1
- package/src/tools/JsonTree/lazy.tsx +45 -0
- package/src/tools/LottiePlayer/lazy.tsx +57 -0
- package/src/tools/Map/components/CustomOverlay.tsx +54 -0
- package/src/tools/Map/components/DrawControl.tsx +36 -0
- package/src/tools/Map/components/GeocoderControl.tsx +70 -0
- package/src/tools/Map/components/LayerSwitcher.tsx +225 -0
- package/src/tools/Map/components/MapCluster.tsx +273 -0
- package/src/tools/Map/components/MapContainer.tsx +191 -0
- package/src/tools/Map/components/MapControls.tsx +44 -0
- package/src/tools/Map/components/MapLegend.tsx +161 -0
- package/src/tools/Map/components/MapMarker.tsx +102 -0
- package/src/tools/Map/components/MapPopup.tsx +46 -0
- package/src/tools/Map/components/MapSource.tsx +30 -0
- package/src/tools/Map/components/index.ts +20 -0
- package/src/tools/Map/context/MapContext.tsx +89 -0
- package/src/tools/Map/context/index.ts +2 -0
- package/src/tools/Map/hooks/index.ts +9 -0
- package/src/tools/Map/hooks/useMap.ts +11 -0
- package/src/tools/Map/hooks/useMapControl.ts +99 -0
- package/src/tools/Map/hooks/useMapEvents.ts +147 -0
- package/src/tools/Map/hooks/useMapLayers.ts +83 -0
- package/src/tools/Map/hooks/useMapViewport.ts +62 -0
- package/src/tools/Map/hooks/useMarkers.ts +85 -0
- package/src/tools/Map/index.ts +116 -0
- package/src/tools/Map/layers/cluster.ts +94 -0
- package/src/tools/Map/layers/index.ts +15 -0
- package/src/tools/Map/layers/line.ts +93 -0
- package/src/tools/Map/layers/point.ts +61 -0
- package/src/tools/Map/layers/polygon.ts +73 -0
- package/src/tools/Map/lazy.tsx +56 -0
- package/src/tools/Map/styles/index.ts +15 -0
- package/src/tools/Map/types.ts +259 -0
- package/src/tools/Map/utils/geo.ts +88 -0
- package/src/tools/Map/utils/index.ts +16 -0
- package/src/tools/Map/utils/transform.ts +107 -0
- package/src/tools/Mermaid/Mermaid.client.tsx +12 -4
- package/src/tools/Mermaid/components/MermaidFullscreenModal.tsx +6 -2
- package/src/tools/Mermaid/lazy.tsx +46 -0
- package/src/tools/OpenapiViewer/lazy.tsx +72 -0
- package/src/tools/PrettyCode/PrettyCode.client.tsx +10 -3
- package/src/tools/PrettyCode/lazy.tsx +64 -0
- package/src/tools/VideoPlayer/lazy.tsx +63 -0
- package/dist/Mermaid.client-4OCKJ6QD.mjs.map +0 -1
- package/dist/Mermaid.client-ZP6OE46Z.cjs.map +0 -1
- package/dist/PlaygroundLayout-LMQTVXSP.mjs.map +0 -1
- package/dist/PlaygroundLayout-XXVBU4WZ.cjs.map +0 -1
- package/dist/PrettyCode.client-2CLSV2VD.cjs.map +0 -1
- package/dist/PrettyCode.client-Y2BVON7R.mjs.map +0 -1
- package/dist/chunk-FB5QBSI3.cjs.map +0 -1
- package/dist/chunk-L6UHASYQ.mjs.map +0 -1
|
@@ -0,0 +1,493 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { memo, useCallback, 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
|
+
|
|
8
|
+
export type GalleryGridLayout =
|
|
9
|
+
| 'auto'
|
|
10
|
+
| 'single'
|
|
11
|
+
| 'two-cols'
|
|
12
|
+
| 'hero-left'
|
|
13
|
+
| 'grid-2x2'
|
|
14
|
+
| 'mosaic-5'
|
|
15
|
+
|
|
16
|
+
export interface GalleryGridProps {
|
|
17
|
+
/** Array of images to display */
|
|
18
|
+
images: GalleryMediaItem[]
|
|
19
|
+
/** Maximum images to show in grid */
|
|
20
|
+
maxVisible?: number
|
|
21
|
+
/** Grid layout (auto picks based on count) */
|
|
22
|
+
layout?: GalleryGridLayout
|
|
23
|
+
/** Aspect ratio for the grid container */
|
|
24
|
+
aspectRatio?: number
|
|
25
|
+
/** Gap between grid items (tailwind spacing) */
|
|
26
|
+
gap?: 1 | 2 | 3 | 4
|
|
27
|
+
/** Border radius */
|
|
28
|
+
rounded?: 'none' | 'sm' | 'md' | 'lg' | 'xl' | '2xl'
|
|
29
|
+
/** Callback when image is clicked */
|
|
30
|
+
onImageClick?: (index: number) => void
|
|
31
|
+
/** Show "+N more" badge */
|
|
32
|
+
showMoreBadge?: boolean
|
|
33
|
+
/** Show loading skeleton */
|
|
34
|
+
loading?: boolean
|
|
35
|
+
/** Stagger delay between image reveals (ms), 0 to disable */
|
|
36
|
+
staggerDelay?: number
|
|
37
|
+
/** Additional CSS class */
|
|
38
|
+
className?: string
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const GAP_CLASSES = {
|
|
42
|
+
1: 'gap-1',
|
|
43
|
+
2: 'gap-2',
|
|
44
|
+
3: 'gap-3',
|
|
45
|
+
4: 'gap-4',
|
|
46
|
+
} as const
|
|
47
|
+
|
|
48
|
+
const ROUNDED_CLASSES = {
|
|
49
|
+
none: 'rounded-none',
|
|
50
|
+
sm: 'rounded-sm',
|
|
51
|
+
md: 'rounded-md',
|
|
52
|
+
lg: 'rounded-lg',
|
|
53
|
+
xl: 'rounded-xl',
|
|
54
|
+
'2xl': 'rounded-2xl',
|
|
55
|
+
} as const
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Get layout based on image count
|
|
59
|
+
*/
|
|
60
|
+
function getLayoutForCount(count: number): GalleryGridLayout {
|
|
61
|
+
if (count === 1) return 'single'
|
|
62
|
+
if (count === 2) return 'two-cols'
|
|
63
|
+
if (count === 3) return 'hero-left'
|
|
64
|
+
if (count === 4) return 'grid-2x2'
|
|
65
|
+
return 'mosaic-5'
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* GalleryGrid - Fixed layout grid for property images
|
|
70
|
+
*
|
|
71
|
+
* Airbnb-style layouts based on image count:
|
|
72
|
+
* - 1 image: full width
|
|
73
|
+
* - 2 images: side by side
|
|
74
|
+
* - 3 images: hero left + 2 stacked right
|
|
75
|
+
* - 4 images: 2x2 grid
|
|
76
|
+
* - 5+ images: hero left + 2x2 grid right
|
|
77
|
+
*/
|
|
78
|
+
export const GalleryGrid = memo(function GalleryGrid({
|
|
79
|
+
images,
|
|
80
|
+
maxVisible = 5,
|
|
81
|
+
layout = 'auto',
|
|
82
|
+
aspectRatio = 16 / 9,
|
|
83
|
+
gap = 2,
|
|
84
|
+
rounded = 'xl',
|
|
85
|
+
onImageClick,
|
|
86
|
+
showMoreBadge = true,
|
|
87
|
+
loading = false,
|
|
88
|
+
staggerDelay = 75,
|
|
89
|
+
className,
|
|
90
|
+
}: GalleryGridProps) {
|
|
91
|
+
const visibleImages = useMemo(() => {
|
|
92
|
+
return images.slice(0, maxVisible)
|
|
93
|
+
}, [images, maxVisible])
|
|
94
|
+
|
|
95
|
+
const currentLayout = useMemo((): GalleryGridLayout => {
|
|
96
|
+
if (layout !== 'auto') return layout
|
|
97
|
+
return getLayoutForCount(visibleImages.length || maxVisible)
|
|
98
|
+
}, [layout, visibleImages.length, maxVisible])
|
|
99
|
+
|
|
100
|
+
const remainingCount = useMemo(() => {
|
|
101
|
+
return Math.max(0, images.length - maxVisible)
|
|
102
|
+
}, [images.length, maxVisible])
|
|
103
|
+
|
|
104
|
+
const handleClick = useCallback(
|
|
105
|
+
(index: number) => {
|
|
106
|
+
onImageClick?.(index)
|
|
107
|
+
},
|
|
108
|
+
[onImageClick]
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
// Show skeleton when loading or no images yet
|
|
112
|
+
if (loading || images.length === 0) {
|
|
113
|
+
return (
|
|
114
|
+
<div
|
|
115
|
+
className={cn(
|
|
116
|
+
'relative overflow-hidden',
|
|
117
|
+
ROUNDED_CLASSES[rounded],
|
|
118
|
+
className
|
|
119
|
+
)}
|
|
120
|
+
style={{ aspectRatio }}
|
|
121
|
+
>
|
|
122
|
+
<div className="absolute inset-0">
|
|
123
|
+
<GridSkeleton layout={currentLayout} gap={gap} />
|
|
124
|
+
</div>
|
|
125
|
+
</div>
|
|
126
|
+
)
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
return (
|
|
130
|
+
<div
|
|
131
|
+
className={cn(
|
|
132
|
+
'relative overflow-hidden',
|
|
133
|
+
ROUNDED_CLASSES[rounded],
|
|
134
|
+
className
|
|
135
|
+
)}
|
|
136
|
+
style={{ aspectRatio }}
|
|
137
|
+
>
|
|
138
|
+
<div className="absolute inset-0">
|
|
139
|
+
<GridLayout
|
|
140
|
+
layout={currentLayout}
|
|
141
|
+
images={visibleImages}
|
|
142
|
+
gap={gap}
|
|
143
|
+
remainingCount={remainingCount}
|
|
144
|
+
showMoreBadge={showMoreBadge}
|
|
145
|
+
staggerDelay={staggerDelay}
|
|
146
|
+
onImageClick={handleClick}
|
|
147
|
+
/>
|
|
148
|
+
</div>
|
|
149
|
+
</div>
|
|
150
|
+
)
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
// Grid layout renderer
|
|
154
|
+
interface GridLayoutProps {
|
|
155
|
+
layout: GalleryGridLayout
|
|
156
|
+
images: GalleryMediaItem[]
|
|
157
|
+
gap: 1 | 2 | 3 | 4
|
|
158
|
+
remainingCount: number
|
|
159
|
+
showMoreBadge: boolean
|
|
160
|
+
staggerDelay: number
|
|
161
|
+
onImageClick: (index: number) => void
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const GridLayout = memo(function GridLayout({
|
|
165
|
+
layout,
|
|
166
|
+
images,
|
|
167
|
+
gap,
|
|
168
|
+
remainingCount,
|
|
169
|
+
showMoreBadge,
|
|
170
|
+
staggerDelay,
|
|
171
|
+
onImageClick,
|
|
172
|
+
}: GridLayoutProps) {
|
|
173
|
+
if (!images || images.length === 0) return null
|
|
174
|
+
|
|
175
|
+
switch (layout) {
|
|
176
|
+
case 'single':
|
|
177
|
+
return (
|
|
178
|
+
<GridItem
|
|
179
|
+
image={images[0]}
|
|
180
|
+
index={0}
|
|
181
|
+
staggerDelay={staggerDelay}
|
|
182
|
+
onClick={onImageClick}
|
|
183
|
+
className="w-full h-full"
|
|
184
|
+
/>
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
case 'two-cols':
|
|
188
|
+
return (
|
|
189
|
+
<div className={cn('grid grid-cols-2 h-full', GAP_CLASSES[gap])}>
|
|
190
|
+
{images.slice(0, 2).map((image, index) => (
|
|
191
|
+
<GridItem
|
|
192
|
+
key={image.id}
|
|
193
|
+
image={image}
|
|
194
|
+
index={index}
|
|
195
|
+
staggerDelay={staggerDelay}
|
|
196
|
+
onClick={onImageClick}
|
|
197
|
+
showBadge={showMoreBadge && index === 1 && remainingCount > 0}
|
|
198
|
+
badgeCount={remainingCount}
|
|
199
|
+
/>
|
|
200
|
+
))}
|
|
201
|
+
</div>
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
case 'hero-left':
|
|
205
|
+
return (
|
|
206
|
+
<div
|
|
207
|
+
className={cn('h-full', GAP_CLASSES[gap])}
|
|
208
|
+
style={{
|
|
209
|
+
display: 'grid',
|
|
210
|
+
gridTemplateColumns: '1fr 1fr',
|
|
211
|
+
}}
|
|
212
|
+
>
|
|
213
|
+
<GridItem
|
|
214
|
+
image={images[0]}
|
|
215
|
+
index={0}
|
|
216
|
+
staggerDelay={staggerDelay}
|
|
217
|
+
onClick={onImageClick}
|
|
218
|
+
/>
|
|
219
|
+
<div
|
|
220
|
+
className={cn('h-full overflow-hidden', GAP_CLASSES[gap])}
|
|
221
|
+
style={{
|
|
222
|
+
display: 'grid',
|
|
223
|
+
gridTemplateRows: '1fr 1fr',
|
|
224
|
+
}}
|
|
225
|
+
>
|
|
226
|
+
{images.slice(1, 3).map((image, index) => (
|
|
227
|
+
<GridItem
|
|
228
|
+
key={image.id}
|
|
229
|
+
image={image}
|
|
230
|
+
index={index + 1}
|
|
231
|
+
staggerDelay={staggerDelay}
|
|
232
|
+
onClick={onImageClick}
|
|
233
|
+
showBadge={showMoreBadge && index === 1 && remainingCount > 0}
|
|
234
|
+
badgeCount={remainingCount}
|
|
235
|
+
/>
|
|
236
|
+
))}
|
|
237
|
+
</div>
|
|
238
|
+
</div>
|
|
239
|
+
)
|
|
240
|
+
|
|
241
|
+
case 'grid-2x2':
|
|
242
|
+
return (
|
|
243
|
+
<div className={cn('grid grid-cols-2 grid-rows-2 h-full', GAP_CLASSES[gap])}>
|
|
244
|
+
{images.slice(0, 4).map((image, index) => (
|
|
245
|
+
<GridItem
|
|
246
|
+
key={image.id}
|
|
247
|
+
image={image}
|
|
248
|
+
index={index}
|
|
249
|
+
staggerDelay={staggerDelay}
|
|
250
|
+
onClick={onImageClick}
|
|
251
|
+
showBadge={showMoreBadge && index === 3 && remainingCount > 0}
|
|
252
|
+
badgeCount={remainingCount}
|
|
253
|
+
/>
|
|
254
|
+
))}
|
|
255
|
+
</div>
|
|
256
|
+
)
|
|
257
|
+
|
|
258
|
+
case 'mosaic-5':
|
|
259
|
+
default:
|
|
260
|
+
// Airbnb-style: hero (50%) + 2x2 grid (50%) using single grid with areas
|
|
261
|
+
return (
|
|
262
|
+
<div
|
|
263
|
+
className={cn('h-full', GAP_CLASSES[gap])}
|
|
264
|
+
style={{
|
|
265
|
+
display: 'grid',
|
|
266
|
+
gridTemplateColumns: '1fr 1fr 1fr 1fr',
|
|
267
|
+
gridTemplateRows: '1fr 1fr',
|
|
268
|
+
gridTemplateAreas: `
|
|
269
|
+
"hero hero img2 img3"
|
|
270
|
+
"hero hero img4 img5"
|
|
271
|
+
`,
|
|
272
|
+
}}
|
|
273
|
+
>
|
|
274
|
+
<GridItem
|
|
275
|
+
image={images[0]}
|
|
276
|
+
index={0}
|
|
277
|
+
staggerDelay={staggerDelay}
|
|
278
|
+
onClick={onImageClick}
|
|
279
|
+
style={{ gridArea: 'hero' }}
|
|
280
|
+
/>
|
|
281
|
+
{images[1] && (
|
|
282
|
+
<GridItem
|
|
283
|
+
key={images[1].id}
|
|
284
|
+
image={images[1]}
|
|
285
|
+
index={1}
|
|
286
|
+
staggerDelay={staggerDelay}
|
|
287
|
+
onClick={onImageClick}
|
|
288
|
+
style={{ gridArea: 'img2' }}
|
|
289
|
+
/>
|
|
290
|
+
)}
|
|
291
|
+
{images[2] && (
|
|
292
|
+
<GridItem
|
|
293
|
+
key={images[2].id}
|
|
294
|
+
image={images[2]}
|
|
295
|
+
index={2}
|
|
296
|
+
staggerDelay={staggerDelay}
|
|
297
|
+
onClick={onImageClick}
|
|
298
|
+
style={{ gridArea: 'img3' }}
|
|
299
|
+
/>
|
|
300
|
+
)}
|
|
301
|
+
{images[3] && (
|
|
302
|
+
<GridItem
|
|
303
|
+
key={images[3].id}
|
|
304
|
+
image={images[3]}
|
|
305
|
+
index={3}
|
|
306
|
+
staggerDelay={staggerDelay}
|
|
307
|
+
onClick={onImageClick}
|
|
308
|
+
style={{ gridArea: 'img4' }}
|
|
309
|
+
/>
|
|
310
|
+
)}
|
|
311
|
+
{images[4] && (
|
|
312
|
+
<GridItem
|
|
313
|
+
key={images[4].id}
|
|
314
|
+
image={images[4]}
|
|
315
|
+
index={4}
|
|
316
|
+
staggerDelay={staggerDelay}
|
|
317
|
+
onClick={onImageClick}
|
|
318
|
+
showBadge={showMoreBadge && remainingCount > 0}
|
|
319
|
+
badgeCount={remainingCount}
|
|
320
|
+
style={{ gridArea: 'img5' }}
|
|
321
|
+
/>
|
|
322
|
+
)}
|
|
323
|
+
</div>
|
|
324
|
+
)
|
|
325
|
+
}
|
|
326
|
+
})
|
|
327
|
+
|
|
328
|
+
// Single grid item
|
|
329
|
+
interface GridItemProps {
|
|
330
|
+
image: GalleryMediaItem
|
|
331
|
+
index: number
|
|
332
|
+
staggerDelay: number
|
|
333
|
+
onClick: (index: number) => void
|
|
334
|
+
className?: string
|
|
335
|
+
style?: React.CSSProperties
|
|
336
|
+
showBadge?: boolean
|
|
337
|
+
badgeCount?: number
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
const GridItem = memo(function GridItem({
|
|
341
|
+
image,
|
|
342
|
+
index,
|
|
343
|
+
staggerDelay,
|
|
344
|
+
onClick,
|
|
345
|
+
className,
|
|
346
|
+
style,
|
|
347
|
+
showBadge = false,
|
|
348
|
+
badgeCount = 0,
|
|
349
|
+
}: GridItemProps) {
|
|
350
|
+
const [isLoaded, setIsLoaded] = useState(false)
|
|
351
|
+
|
|
352
|
+
const handleClick = useCallback(() => {
|
|
353
|
+
onClick(index)
|
|
354
|
+
}, [onClick, index])
|
|
355
|
+
|
|
356
|
+
const handleLoad = useCallback(() => {
|
|
357
|
+
setIsLoaded(true)
|
|
358
|
+
}, [])
|
|
359
|
+
|
|
360
|
+
if (!image) return null
|
|
361
|
+
|
|
362
|
+
const isVideo = image.type === 'video'
|
|
363
|
+
const animationDelay = staggerDelay > 0 ? `${index * staggerDelay}ms` : '0ms'
|
|
364
|
+
|
|
365
|
+
return (
|
|
366
|
+
<button
|
|
367
|
+
type="button"
|
|
368
|
+
className={cn(
|
|
369
|
+
'relative overflow-hidden bg-muted h-full w-full min-w-0 min-h-0',
|
|
370
|
+
'focus:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-inset',
|
|
371
|
+
'group',
|
|
372
|
+
className
|
|
373
|
+
)}
|
|
374
|
+
style={style}
|
|
375
|
+
onClick={handleClick}
|
|
376
|
+
aria-label={`View image ${index + 1}`}
|
|
377
|
+
>
|
|
378
|
+
<img
|
|
379
|
+
src={image.thumbnail || image.src}
|
|
380
|
+
alt={image.alt || `Image ${index + 1}`}
|
|
381
|
+
className={cn(
|
|
382
|
+
'w-full h-full object-cover transition-transform duration-300 group-hover:scale-105',
|
|
383
|
+
// Staggered reveal animation
|
|
384
|
+
staggerDelay > 0 && !isLoaded && 'opacity-0 scale-105',
|
|
385
|
+
staggerDelay > 0 && isLoaded && 'animate-in fade-in zoom-in-95 duration-300 fill-mode-both'
|
|
386
|
+
)}
|
|
387
|
+
style={staggerDelay > 0 && isLoaded ? { animationDelay } : undefined}
|
|
388
|
+
loading={index === 0 ? 'eager' : 'lazy'}
|
|
389
|
+
onLoad={handleLoad}
|
|
390
|
+
/>
|
|
391
|
+
|
|
392
|
+
{/* Hover overlay */}
|
|
393
|
+
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/10 transition-colors" />
|
|
394
|
+
|
|
395
|
+
{/* Video indicator */}
|
|
396
|
+
{isVideo && (
|
|
397
|
+
<div className="absolute inset-0 flex items-center justify-center">
|
|
398
|
+
<div className="w-12 h-12 rounded-full bg-black/60 flex items-center justify-center">
|
|
399
|
+
<Play className="w-6 h-6 text-white ml-0.5" />
|
|
400
|
+
</div>
|
|
401
|
+
</div>
|
|
402
|
+
)}
|
|
403
|
+
|
|
404
|
+
{/* "+N more" badge */}
|
|
405
|
+
{showBadge && badgeCount > 0 && (
|
|
406
|
+
<div className="absolute inset-0 bg-black/50 flex items-center justify-center">
|
|
407
|
+
<span className="text-white text-xl font-semibold">
|
|
408
|
+
+{badgeCount}
|
|
409
|
+
</span>
|
|
410
|
+
</div>
|
|
411
|
+
)}
|
|
412
|
+
</button>
|
|
413
|
+
)
|
|
414
|
+
})
|
|
415
|
+
|
|
416
|
+
// Skeleton component matching grid layouts
|
|
417
|
+
interface GridSkeletonProps {
|
|
418
|
+
layout: GalleryGridLayout
|
|
419
|
+
gap: 1 | 2 | 3 | 4
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
const GridSkeleton = memo(function GridSkeleton({ layout, gap }: GridSkeletonProps) {
|
|
423
|
+
const skeletonCell = "bg-muted animate-pulse"
|
|
424
|
+
|
|
425
|
+
switch (layout) {
|
|
426
|
+
case 'single':
|
|
427
|
+
return <div className={cn('h-full w-full', skeletonCell)} />
|
|
428
|
+
|
|
429
|
+
case 'two-cols':
|
|
430
|
+
return (
|
|
431
|
+
<div className={cn('grid grid-cols-2 h-full', GAP_CLASSES[gap])}>
|
|
432
|
+
<div className={skeletonCell} />
|
|
433
|
+
<div className={skeletonCell} />
|
|
434
|
+
</div>
|
|
435
|
+
)
|
|
436
|
+
|
|
437
|
+
case 'hero-left':
|
|
438
|
+
return (
|
|
439
|
+
<div
|
|
440
|
+
className={cn('h-full', GAP_CLASSES[gap])}
|
|
441
|
+
style={{
|
|
442
|
+
display: 'grid',
|
|
443
|
+
gridTemplateColumns: '1fr 1fr',
|
|
444
|
+
}}
|
|
445
|
+
>
|
|
446
|
+
<div className={skeletonCell} />
|
|
447
|
+
<div
|
|
448
|
+
className={cn('h-full', GAP_CLASSES[gap])}
|
|
449
|
+
style={{
|
|
450
|
+
display: 'grid',
|
|
451
|
+
gridTemplateRows: '1fr 1fr',
|
|
452
|
+
}}
|
|
453
|
+
>
|
|
454
|
+
<div className={skeletonCell} />
|
|
455
|
+
<div className={skeletonCell} />
|
|
456
|
+
</div>
|
|
457
|
+
</div>
|
|
458
|
+
)
|
|
459
|
+
|
|
460
|
+
case 'grid-2x2':
|
|
461
|
+
return (
|
|
462
|
+
<div className={cn('grid grid-cols-2 grid-rows-2 h-full', GAP_CLASSES[gap])}>
|
|
463
|
+
<div className={skeletonCell} />
|
|
464
|
+
<div className={skeletonCell} />
|
|
465
|
+
<div className={skeletonCell} />
|
|
466
|
+
<div className={skeletonCell} />
|
|
467
|
+
</div>
|
|
468
|
+
)
|
|
469
|
+
|
|
470
|
+
case 'mosaic-5':
|
|
471
|
+
default:
|
|
472
|
+
return (
|
|
473
|
+
<div
|
|
474
|
+
className={cn('h-full', GAP_CLASSES[gap])}
|
|
475
|
+
style={{
|
|
476
|
+
display: 'grid',
|
|
477
|
+
gridTemplateColumns: '1fr 1fr 1fr 1fr',
|
|
478
|
+
gridTemplateRows: '1fr 1fr',
|
|
479
|
+
gridTemplateAreas: `
|
|
480
|
+
"hero hero img2 img3"
|
|
481
|
+
"hero hero img4 img5"
|
|
482
|
+
`,
|
|
483
|
+
}}
|
|
484
|
+
>
|
|
485
|
+
<div className={skeletonCell} style={{ gridArea: 'hero' }} />
|
|
486
|
+
<div className={skeletonCell} style={{ gridArea: 'img2' }} />
|
|
487
|
+
<div className={skeletonCell} style={{ gridArea: 'img3' }} />
|
|
488
|
+
<div className={skeletonCell} style={{ gridArea: 'img4' }} />
|
|
489
|
+
<div className={skeletonCell} style={{ gridArea: 'img5' }} />
|
|
490
|
+
</div>
|
|
491
|
+
)
|
|
492
|
+
}
|
|
493
|
+
})
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { memo, useMemo } from 'react'
|
|
4
|
+
import { cn } from '@djangocfg/ui-core/lib'
|
|
5
|
+
import { useTypedT, type I18nTranslations } from '@djangocfg/i18n'
|
|
6
|
+
import { useImageLoader } from '@djangocfg/ui-core/hooks'
|
|
7
|
+
import { ImageOff } from 'lucide-react'
|
|
8
|
+
import type { GalleryImageProps } from '../types'
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* GalleryImage - Single image with loading state and error handling
|
|
12
|
+
* Uses useImageLoader for preloading images before displaying
|
|
13
|
+
*/
|
|
14
|
+
export const GalleryImage = memo(function GalleryImage({
|
|
15
|
+
image,
|
|
16
|
+
showLoading = true,
|
|
17
|
+
onLoad,
|
|
18
|
+
onError,
|
|
19
|
+
onClick,
|
|
20
|
+
priority = false,
|
|
21
|
+
className,
|
|
22
|
+
}: GalleryImageProps) {
|
|
23
|
+
const t = useTypedT<I18nTranslations>()
|
|
24
|
+
const failedToLoadText = useMemo(() => t('tools.image.failedToLoad'), [t])
|
|
25
|
+
const callbacks = useMemo(() => ({ onLoad, onError }), [onLoad, onError])
|
|
26
|
+
const { isLoading, isLoaded, hasError } = useImageLoader(image.src, callbacks)
|
|
27
|
+
|
|
28
|
+
if (hasError) {
|
|
29
|
+
return (
|
|
30
|
+
<div
|
|
31
|
+
className={cn(
|
|
32
|
+
'flex items-center justify-center bg-muted',
|
|
33
|
+
className
|
|
34
|
+
)}
|
|
35
|
+
>
|
|
36
|
+
<div className="text-center text-muted-foreground">
|
|
37
|
+
<ImageOff className="w-12 h-12 mx-auto mb-2 opacity-50" />
|
|
38
|
+
<p className="text-sm">{failedToLoadText}</p>
|
|
39
|
+
</div>
|
|
40
|
+
</div>
|
|
41
|
+
)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return (
|
|
45
|
+
<div
|
|
46
|
+
className={cn('relative overflow-hidden', className)}
|
|
47
|
+
onClick={onClick}
|
|
48
|
+
>
|
|
49
|
+
{/* Loading skeleton */}
|
|
50
|
+
{showLoading && isLoading && (
|
|
51
|
+
<div className="absolute inset-0 bg-muted animate-pulse" />
|
|
52
|
+
)}
|
|
53
|
+
|
|
54
|
+
{/* Image - only render when preloaded */}
|
|
55
|
+
{isLoaded && (
|
|
56
|
+
<img
|
|
57
|
+
src={image.src}
|
|
58
|
+
alt={image.alt || 'Gallery image'}
|
|
59
|
+
className="w-full h-full object-cover animate-in fade-in-0 duration-300"
|
|
60
|
+
loading={priority ? 'eager' : 'lazy'}
|
|
61
|
+
decoding="async"
|
|
62
|
+
/>
|
|
63
|
+
)}
|
|
64
|
+
</div>
|
|
65
|
+
)
|
|
66
|
+
})
|