@djangocfg/ui-tools 2.1.110 → 2.1.111

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (159) hide show
  1. package/README.md +242 -49
  2. package/dist/JsonSchemaForm-65NLLK56.mjs +4 -0
  3. package/dist/JsonSchemaForm-65NLLK56.mjs.map +1 -0
  4. package/dist/JsonSchemaForm-PY6DH3HE.cjs +13 -0
  5. package/dist/JsonSchemaForm-PY6DH3HE.cjs.map +1 -0
  6. package/dist/JsonTree-6RYAOPSS.mjs +4 -0
  7. package/dist/JsonTree-6RYAOPSS.mjs.map +1 -0
  8. package/dist/JsonTree-7OH6CIHT.cjs +10 -0
  9. package/dist/JsonTree-7OH6CIHT.cjs.map +1 -0
  10. package/dist/MapContainer-GXQLP5WY.mjs +214 -0
  11. package/dist/MapContainer-GXQLP5WY.mjs.map +1 -0
  12. package/dist/MapContainer-RYG4HPH4.cjs +221 -0
  13. package/dist/MapContainer-RYG4HPH4.cjs.map +1 -0
  14. package/dist/{Mermaid.client-4OCKJ6QD.mjs → Mermaid.client-OKACITCW.mjs} +16 -7
  15. package/dist/Mermaid.client-OKACITCW.mjs.map +1 -0
  16. package/dist/{Mermaid.client-ZP6OE46Z.cjs → Mermaid.client-PNXEC6YL.cjs} +16 -7
  17. package/dist/Mermaid.client-PNXEC6YL.cjs.map +1 -0
  18. package/dist/{PlaygroundLayout-XXVBU4WZ.cjs → PlaygroundLayout-SYMEAG3J.cjs} +25 -24
  19. package/dist/PlaygroundLayout-SYMEAG3J.cjs.map +1 -0
  20. package/dist/{PlaygroundLayout-LMQTVXSP.mjs → PlaygroundLayout-UQRBU5RH.mjs} +4 -3
  21. package/dist/PlaygroundLayout-UQRBU5RH.mjs.map +1 -0
  22. package/dist/{PrettyCode.client-2CLSV2VD.cjs → PrettyCode.client-DANYYQYO.cjs} +11 -4
  23. package/dist/PrettyCode.client-DANYYQYO.cjs.map +1 -0
  24. package/dist/{PrettyCode.client-Y2BVON7R.mjs → PrettyCode.client-RS5ZTNBT.mjs} +11 -4
  25. package/dist/PrettyCode.client-RS5ZTNBT.mjs.map +1 -0
  26. package/dist/chunk-2DSR7V2L.mjs +561 -0
  27. package/dist/chunk-2DSR7V2L.mjs.map +1 -0
  28. package/dist/chunk-47T5ECYV.cjs +1357 -0
  29. package/dist/chunk-47T5ECYV.cjs.map +1 -0
  30. package/dist/chunk-5QT3QYFZ.cjs +189 -0
  31. package/dist/chunk-5QT3QYFZ.cjs.map +1 -0
  32. package/dist/chunk-7IIRYG4S.mjs +1057 -0
  33. package/dist/chunk-7IIRYG4S.mjs.map +1 -0
  34. package/dist/{chunk-FB5QBSI3.cjs → chunk-DI3HUXHK.cjs} +15 -195
  35. package/dist/chunk-DI3HUXHK.cjs.map +1 -0
  36. package/dist/chunk-EVGWYASL.cjs +1528 -0
  37. package/dist/chunk-EVGWYASL.cjs.map +1 -0
  38. package/dist/chunk-F2N7P5XU.cjs +30 -0
  39. package/dist/chunk-F2N7P5XU.cjs.map +1 -0
  40. package/dist/{chunk-L6UHASYQ.mjs → chunk-G6PRZP5I.mjs} +7 -186
  41. package/dist/chunk-G6PRZP5I.mjs.map +1 -0
  42. package/dist/chunk-JWB2EWQO.mjs +5 -0
  43. package/dist/chunk-JWB2EWQO.mjs.map +1 -0
  44. package/dist/chunk-LTJX2JXE.mjs +338 -0
  45. package/dist/chunk-LTJX2JXE.mjs.map +1 -0
  46. package/dist/chunk-OVNC4KW6.mjs +1494 -0
  47. package/dist/chunk-OVNC4KW6.mjs.map +1 -0
  48. package/dist/chunk-PNZSJN6T.cjs +1086 -0
  49. package/dist/chunk-PNZSJN6T.cjs.map +1 -0
  50. package/dist/chunk-TEFRA7GW.cjs +565 -0
  51. package/dist/chunk-TEFRA7GW.cjs.map +1 -0
  52. package/dist/chunk-UOMPPIED.mjs +1343 -0
  53. package/dist/chunk-UOMPPIED.mjs.map +1 -0
  54. package/dist/chunk-W6YHQI4F.mjs +187 -0
  55. package/dist/chunk-W6YHQI4F.mjs.map +1 -0
  56. package/dist/chunk-XTBRWVIV.cjs +346 -0
  57. package/dist/chunk-XTBRWVIV.cjs.map +1 -0
  58. package/dist/components-C7ZL7OMY.mjs +5 -0
  59. package/dist/components-C7ZL7OMY.mjs.map +1 -0
  60. package/dist/components-CJ2IB65O.cjs +27 -0
  61. package/dist/components-CJ2IB65O.cjs.map +1 -0
  62. package/dist/components-EASJYK45.mjs +6 -0
  63. package/dist/components-EASJYK45.mjs.map +1 -0
  64. package/dist/components-LDRFDV4A.cjs +22 -0
  65. package/dist/components-LDRFDV4A.cjs.map +1 -0
  66. package/dist/components-VZKUTDJK.mjs +5 -0
  67. package/dist/components-VZKUTDJK.mjs.map +1 -0
  68. package/dist/components-Y64GTIMQ.cjs +42 -0
  69. package/dist/components-Y64GTIMQ.cjs.map +1 -0
  70. package/dist/index.cjs +701 -4813
  71. package/dist/index.cjs.map +1 -1
  72. package/dist/index.d.cts +1274 -1026
  73. package/dist/index.d.ts +1274 -1026
  74. package/dist/index.mjs +358 -4730
  75. package/dist/index.mjs.map +1 -1
  76. package/package.json +27 -4
  77. package/src/components/index.ts +17 -0
  78. package/src/components/lazy-wrapper.tsx +281 -0
  79. package/src/index.ts +92 -7
  80. package/src/tools/AudioPlayer/components/HybridAudioPlayer.tsx +14 -5
  81. package/src/tools/AudioPlayer/lazy.tsx +85 -0
  82. package/src/tools/Gallery/components/Gallery.tsx +182 -0
  83. package/src/tools/Gallery/components/GalleryCarousel.tsx +251 -0
  84. package/src/tools/Gallery/components/GalleryCompact.tsx +173 -0
  85. package/src/tools/Gallery/components/GalleryGrid.tsx +493 -0
  86. package/src/tools/Gallery/components/GalleryImage.tsx +66 -0
  87. package/src/tools/Gallery/components/GalleryLightbox.tsx +331 -0
  88. package/src/tools/Gallery/components/GalleryMedia.tsx +66 -0
  89. package/src/tools/Gallery/components/GalleryThumbnails.tsx +173 -0
  90. package/src/tools/Gallery/components/GalleryThumbnailsVirtual.tsx +138 -0
  91. package/src/tools/Gallery/components/GalleryVideo.tsx +222 -0
  92. package/src/tools/Gallery/components/index.ts +13 -0
  93. package/src/tools/Gallery/hooks/index.ts +23 -0
  94. package/src/tools/Gallery/hooks/useGallery.ts +137 -0
  95. package/src/tools/Gallery/hooks/useImageDimensions.ts +223 -0
  96. package/src/tools/Gallery/hooks/usePinchZoom.ts +234 -0
  97. package/src/tools/Gallery/hooks/usePreloadImages.ts +71 -0
  98. package/src/tools/Gallery/hooks/useSwipe.ts +86 -0
  99. package/src/tools/Gallery/hooks/useVirtualList.ts +129 -0
  100. package/src/tools/Gallery/hooks/useZoom.ts +316 -0
  101. package/src/tools/Gallery/index.ts +66 -0
  102. package/src/tools/Gallery/types.ts +183 -0
  103. package/src/tools/Gallery/utils/imageAnalysis.ts +52 -0
  104. package/src/tools/Gallery/utils/index.ts +11 -0
  105. package/src/tools/ImageViewer/components/ImageToolbar.tsx +20 -8
  106. package/src/tools/ImageViewer/components/ImageViewer.tsx +12 -4
  107. package/src/tools/ImageViewer/lazy.tsx +37 -0
  108. package/src/tools/JsonForm/lazy.tsx +43 -0
  109. package/src/tools/JsonForm/widgets/ColorWidget.tsx +4 -1
  110. package/src/tools/JsonTree/lazy.tsx +45 -0
  111. package/src/tools/LottiePlayer/lazy.tsx +57 -0
  112. package/src/tools/Map/components/CustomOverlay.tsx +54 -0
  113. package/src/tools/Map/components/DrawControl.tsx +36 -0
  114. package/src/tools/Map/components/GeocoderControl.tsx +70 -0
  115. package/src/tools/Map/components/LayerSwitcher.tsx +225 -0
  116. package/src/tools/Map/components/MapCluster.tsx +273 -0
  117. package/src/tools/Map/components/MapContainer.tsx +191 -0
  118. package/src/tools/Map/components/MapControls.tsx +44 -0
  119. package/src/tools/Map/components/MapLegend.tsx +161 -0
  120. package/src/tools/Map/components/MapMarker.tsx +102 -0
  121. package/src/tools/Map/components/MapPopup.tsx +46 -0
  122. package/src/tools/Map/components/MapSource.tsx +30 -0
  123. package/src/tools/Map/components/index.ts +20 -0
  124. package/src/tools/Map/context/MapContext.tsx +89 -0
  125. package/src/tools/Map/context/index.ts +2 -0
  126. package/src/tools/Map/hooks/index.ts +9 -0
  127. package/src/tools/Map/hooks/useMap.ts +11 -0
  128. package/src/tools/Map/hooks/useMapControl.ts +99 -0
  129. package/src/tools/Map/hooks/useMapEvents.ts +147 -0
  130. package/src/tools/Map/hooks/useMapLayers.ts +83 -0
  131. package/src/tools/Map/hooks/useMapViewport.ts +62 -0
  132. package/src/tools/Map/hooks/useMarkers.ts +85 -0
  133. package/src/tools/Map/index.ts +116 -0
  134. package/src/tools/Map/layers/cluster.ts +94 -0
  135. package/src/tools/Map/layers/index.ts +15 -0
  136. package/src/tools/Map/layers/line.ts +93 -0
  137. package/src/tools/Map/layers/point.ts +61 -0
  138. package/src/tools/Map/layers/polygon.ts +73 -0
  139. package/src/tools/Map/lazy.tsx +56 -0
  140. package/src/tools/Map/styles/index.ts +15 -0
  141. package/src/tools/Map/types.ts +259 -0
  142. package/src/tools/Map/utils/geo.ts +88 -0
  143. package/src/tools/Map/utils/index.ts +16 -0
  144. package/src/tools/Map/utils/transform.ts +107 -0
  145. package/src/tools/Mermaid/Mermaid.client.tsx +12 -4
  146. package/src/tools/Mermaid/components/MermaidFullscreenModal.tsx +6 -2
  147. package/src/tools/Mermaid/lazy.tsx +46 -0
  148. package/src/tools/OpenapiViewer/lazy.tsx +72 -0
  149. package/src/tools/PrettyCode/PrettyCode.client.tsx +10 -3
  150. package/src/tools/PrettyCode/lazy.tsx +64 -0
  151. package/src/tools/VideoPlayer/lazy.tsx +63 -0
  152. package/dist/Mermaid.client-4OCKJ6QD.mjs.map +0 -1
  153. package/dist/Mermaid.client-ZP6OE46Z.cjs.map +0 -1
  154. package/dist/PlaygroundLayout-LMQTVXSP.mjs.map +0 -1
  155. package/dist/PlaygroundLayout-XXVBU4WZ.cjs.map +0 -1
  156. package/dist/PrettyCode.client-2CLSV2VD.cjs.map +0 -1
  157. package/dist/PrettyCode.client-Y2BVON7R.mjs.map +0 -1
  158. package/dist/chunk-FB5QBSI3.cjs.map +0 -1
  159. 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
+ })