@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.
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,331 @@
1
+ 'use client'
2
+
3
+ import { memo, useEffect, useCallback, useState, useMemo } from 'react'
4
+ import { createPortal } from 'react-dom'
5
+ import { cn } from '@djangocfg/ui-core/lib'
6
+ import { useTypedT, type I18nTranslations } from '@djangocfg/i18n'
7
+ import { X, ChevronLeft, ChevronRight, Download, Share2, ZoomIn, ZoomOut } from 'lucide-react'
8
+ import { useSwipe } from '../hooks/useSwipe'
9
+ import { usePreloadImages } from '../hooks/usePreloadImages'
10
+ import { useZoom } from '../hooks/useZoom'
11
+ import { GalleryMedia } from './GalleryMedia'
12
+ import { GalleryThumbnails } from './GalleryThumbnails'
13
+ import type { GalleryLightboxProps } from '../types'
14
+
15
+ /**
16
+ * GalleryLightbox - Fullscreen image viewer with zoom and navigation
17
+ *
18
+ * Features:
19
+ * - Preloads adjacent images for smooth navigation
20
+ * - Loading skeleton while image loads
21
+ * - Zoom with pan/drag support
22
+ * - Keyboard and swipe navigation
23
+ */
24
+ export const GalleryLightbox = memo(function GalleryLightbox({
25
+ open,
26
+ onClose,
27
+ images,
28
+ currentIndex,
29
+ onIndexChange,
30
+ showThumbnails = true,
31
+ enableZoom = true,
32
+ enableDownload = true,
33
+ enableShare = true,
34
+ title,
35
+ }: GalleryLightboxProps) {
36
+ const t = useTypedT<I18nTranslations>()
37
+ const [mounted, setMounted] = useState(false)
38
+
39
+ // Translations
40
+ const labels = useMemo(() => ({
41
+ lightbox: t('tools.gallery.lightbox'),
42
+ previous: t('tools.gallery.previous'),
43
+ next: t('tools.gallery.next'),
44
+ close: t('tools.gallery.close'),
45
+ download: t('tools.gallery.download'),
46
+ share: t('tools.gallery.share'),
47
+ zoomIn: t('tools.image.zoomIn'),
48
+ zoomOut: t('tools.image.zoomOut'),
49
+ }), [t])
50
+
51
+ // Unified zoom (handles both desktop click/drag and mobile pinch/tap)
52
+ const zoom = useZoom({
53
+ minScale: 1,
54
+ maxScale: 3,
55
+ desktopScale: 2,
56
+ })
57
+
58
+ // Prepare data
59
+ const currentImage = useMemo(() => images[currentIndex], [images, currentIndex])
60
+ const hasMultiple = useMemo(() => images.length > 1, [images.length])
61
+ const { isZoomed, isDragging } = zoom
62
+
63
+ // Preload adjacent images when lightbox is open
64
+ usePreloadImages(open ? images : [], currentIndex, 2)
65
+
66
+ // Handle client-side mounting for portal
67
+ useEffect(() => {
68
+ setMounted(true)
69
+ }, [])
70
+
71
+ // Reset zoom when image changes
72
+ useEffect(() => {
73
+ zoom.reset()
74
+ }, [currentIndex])
75
+
76
+ // Prevent body scroll when open
77
+ useEffect(() => {
78
+ if (open) {
79
+ const originalStyle = window.getComputedStyle(document.body).overflow
80
+ document.body.style.overflow = 'hidden'
81
+ return () => {
82
+ document.body.style.overflow = originalStyle
83
+ }
84
+ }
85
+ }, [open])
86
+
87
+ // Navigation handlers
88
+ const goToPrev = useCallback(() => {
89
+ if (hasMultiple) {
90
+ onIndexChange((currentIndex - 1 + images.length) % images.length)
91
+ }
92
+ }, [hasMultiple, currentIndex, images.length, onIndexChange])
93
+
94
+ const goToNext = useCallback(() => {
95
+ if (hasMultiple) {
96
+ onIndexChange((currentIndex + 1) % images.length)
97
+ }
98
+ }, [hasMultiple, currentIndex, images.length, onIndexChange])
99
+
100
+ // Keyboard navigation
101
+ useEffect(() => {
102
+ if (!open) return
103
+
104
+ const handleKeyDown = (e: KeyboardEvent) => {
105
+ switch (e.key) {
106
+ case 'Escape':
107
+ if (isZoomed) {
108
+ zoom.reset()
109
+ } else {
110
+ onClose()
111
+ }
112
+ break
113
+ case 'ArrowLeft':
114
+ if (!isZoomed) goToPrev()
115
+ break
116
+ case 'ArrowRight':
117
+ if (!isZoomed) goToNext()
118
+ break
119
+ }
120
+ }
121
+
122
+ window.addEventListener('keydown', handleKeyDown)
123
+ return () => window.removeEventListener('keydown', handleKeyDown)
124
+ }, [open, isZoomed, goToPrev, goToNext, onClose, zoom])
125
+
126
+ // Swipe handlers (disabled when zoomed)
127
+ const swipeHandlers = useSwipe({
128
+ onSwipeLeft: isZoomed ? undefined : goToNext,
129
+ onSwipeRight: isZoomed ? undefined : goToPrev,
130
+ })
131
+
132
+ // Action handlers
133
+ const handleDownload = useCallback(() => {
134
+ if (!currentImage) return
135
+ const link = document.createElement('a')
136
+ link.href = currentImage.src
137
+ link.download = currentImage.alt || `image-${currentIndex + 1}.jpg`
138
+ document.body.appendChild(link)
139
+ link.click()
140
+ document.body.removeChild(link)
141
+ }, [currentImage, currentIndex])
142
+
143
+ const handleShare = useCallback(async () => {
144
+ if (!currentImage) return
145
+ if (navigator.share) {
146
+ try {
147
+ await navigator.share({
148
+ title: title || 'Image',
149
+ url: currentImage.src,
150
+ })
151
+ } catch {
152
+ // User cancelled or error
153
+ }
154
+ } else {
155
+ await navigator.clipboard.writeText(currentImage.src)
156
+ }
157
+ }, [currentImage, title])
158
+
159
+ const handleToggleZoom = useCallback(() => {
160
+ zoom.toggleZoom()
161
+ }, [zoom])
162
+
163
+ const handleContentClick = useCallback((e: React.MouseEvent) => {
164
+ e.stopPropagation()
165
+ }, [])
166
+
167
+ if (!open || !mounted) return null
168
+
169
+ const lightbox = (
170
+ <div
171
+ className={cn(
172
+ 'fixed inset-0 z-50',
173
+ 'backdrop-blur-sm',
174
+ 'animate-in fade-in-0 duration-200'
175
+ )}
176
+ style={{ backgroundColor: 'rgba(0, 0, 0, 0.85)' }}
177
+ onClick={onClose}
178
+ role="dialog"
179
+ aria-modal="true"
180
+ aria-label={labels.lightbox}
181
+ >
182
+ {/* Content */}
183
+ <div
184
+ className="relative w-full h-full flex flex-col"
185
+ onClick={handleContentClick}
186
+ {...swipeHandlers}
187
+ >
188
+ {/* Header */}
189
+ <div className="absolute top-0 left-0 right-0 z-10 flex items-center justify-between p-4">
190
+ {/* Title & Counter */}
191
+ <div className="text-white">
192
+ {title && <div className="font-medium">{title}</div>}
193
+ {hasMultiple && (
194
+ <div className="text-sm text-white/70">
195
+ {currentIndex + 1} / {images.length}
196
+ </div>
197
+ )}
198
+ </div>
199
+
200
+ {/* Actions */}
201
+ <div className="flex items-center gap-2">
202
+ {enableZoom && (
203
+ <button
204
+ type="button"
205
+ className="p-2 rounded-full bg-white/10 hover:bg-white/20 text-white transition-colors"
206
+ onClick={handleToggleZoom}
207
+ aria-label={isZoomed ? labels.zoomOut : labels.zoomIn}
208
+ >
209
+ {isZoomed ? (
210
+ <ZoomOut className="w-5 h-5" />
211
+ ) : (
212
+ <ZoomIn className="w-5 h-5" />
213
+ )}
214
+ </button>
215
+ )}
216
+
217
+ {enableDownload && (
218
+ <button
219
+ type="button"
220
+ className="p-2 rounded-full bg-white/10 hover:bg-white/20 text-white transition-colors"
221
+ onClick={handleDownload}
222
+ aria-label={labels.download}
223
+ >
224
+ <Download className="w-5 h-5" />
225
+ </button>
226
+ )}
227
+
228
+ {enableShare && (
229
+ <button
230
+ type="button"
231
+ className="p-2 rounded-full bg-white/10 hover:bg-white/20 text-white transition-colors"
232
+ onClick={handleShare}
233
+ aria-label={labels.share}
234
+ >
235
+ <Share2 className="w-5 h-5" />
236
+ </button>
237
+ )}
238
+
239
+ <button
240
+ type="button"
241
+ className="p-2 rounded-full bg-white/10 hover:bg-white/20 text-white transition-colors"
242
+ onClick={onClose}
243
+ aria-label={labels.close}
244
+ >
245
+ <X className="w-5 h-5" />
246
+ </button>
247
+ </div>
248
+ </div>
249
+
250
+ {/* Main Image */}
251
+ <div className="flex-1 flex items-center justify-center p-4 pt-16 pb-24 overflow-hidden">
252
+ {currentImage && (
253
+ <div
254
+ className={cn(
255
+ 'relative max-w-full max-h-full select-none',
256
+ isZoomed ? 'cursor-grab' : enableZoom ? 'cursor-zoom-in' : 'cursor-default',
257
+ isDragging && 'cursor-grabbing'
258
+ )}
259
+ {...(enableZoom ? zoom.handlers : {})}
260
+ style={{
261
+ transform: isZoomed
262
+ ? `scale(${zoom.state.scale}) translate(${zoom.state.x}px, ${zoom.state.y}px)`
263
+ : 'scale(1)',
264
+ transition: isDragging ? 'none' : 'transform 0.3s ease-out',
265
+ touchAction: 'none',
266
+ }}
267
+ >
268
+ <GalleryMedia
269
+ media={currentImage}
270
+ className="max-w-full max-h-[calc(100vh-10rem)] object-contain pointer-events-none"
271
+ showLoading
272
+ priority
273
+ autoPlay
274
+ />
275
+ </div>
276
+ )}
277
+ </div>
278
+
279
+ {/* Navigation Arrows */}
280
+ {hasMultiple && !isZoomed && (
281
+ <>
282
+ <button
283
+ type="button"
284
+ className={cn(
285
+ 'absolute left-4 top-1/2 -translate-y-1/2 z-10',
286
+ 'w-12 h-12 rounded-full',
287
+ 'bg-white/10 hover:bg-white/20 text-white',
288
+ 'flex items-center justify-center transition-colors',
289
+ 'focus:outline-none focus-visible:ring-2 focus-visible:ring-white'
290
+ )}
291
+ onClick={goToPrev}
292
+ aria-label={labels.previous}
293
+ >
294
+ <ChevronLeft className="w-6 h-6" />
295
+ </button>
296
+
297
+ <button
298
+ type="button"
299
+ className={cn(
300
+ 'absolute right-4 top-1/2 -translate-y-1/2 z-10',
301
+ 'w-12 h-12 rounded-full',
302
+ 'bg-white/10 hover:bg-white/20 text-white',
303
+ 'flex items-center justify-center transition-colors',
304
+ 'focus:outline-none focus-visible:ring-2 focus-visible:ring-white'
305
+ )}
306
+ onClick={goToNext}
307
+ aria-label={labels.next}
308
+ >
309
+ <ChevronRight className="w-6 h-6" />
310
+ </button>
311
+ </>
312
+ )}
313
+
314
+ {/* Thumbnails */}
315
+ {showThumbnails && hasMultiple && !isZoomed && (
316
+ <div className="absolute bottom-0 left-0 right-0 p-4 bg-gradient-to-t from-black/50 to-transparent">
317
+ <GalleryThumbnails
318
+ images={images}
319
+ currentIndex={currentIndex}
320
+ onSelect={onIndexChange}
321
+ size="sm"
322
+ className="justify-center"
323
+ />
324
+ </div>
325
+ )}
326
+ </div>
327
+ </div>
328
+ )
329
+
330
+ return createPortal(lightbox, document.body)
331
+ })
@@ -0,0 +1,66 @@
1
+ 'use client'
2
+
3
+ import { memo } from 'react'
4
+ import { GalleryImage as GalleryImageComponent } from './GalleryImage'
5
+ import { GalleryVideo } from './GalleryVideo'
6
+ import type { GalleryMediaItem } from '../types'
7
+
8
+ export interface GalleryMediaProps {
9
+ /** Media data */
10
+ media: GalleryMediaItem
11
+ /** Whether to show loading skeleton */
12
+ showLoading?: boolean
13
+ /** On load callback */
14
+ onLoad?: () => void
15
+ /** On error callback */
16
+ onError?: () => void
17
+ /** On click callback */
18
+ onClick?: () => void
19
+ /** Priority loading (images only) */
20
+ priority?: boolean
21
+ /** Autoplay video */
22
+ autoPlay?: boolean
23
+ /** Additional CSS class */
24
+ className?: string
25
+ }
26
+
27
+ /**
28
+ * GalleryMedia - Renders image or video based on media type
29
+ */
30
+ export const GalleryMedia = memo(function GalleryMedia({
31
+ media,
32
+ showLoading = true,
33
+ onLoad,
34
+ onError,
35
+ onClick,
36
+ priority = false,
37
+ autoPlay = false,
38
+ className,
39
+ }: GalleryMediaProps) {
40
+ const isVideo = media.type === 'video' && media.videoSrc
41
+
42
+ if (isVideo) {
43
+ return (
44
+ <GalleryVideo
45
+ media={media}
46
+ autoPlay={autoPlay}
47
+ muted
48
+ loop
49
+ showControls
50
+ className={className}
51
+ />
52
+ )
53
+ }
54
+
55
+ return (
56
+ <GalleryImageComponent
57
+ image={media}
58
+ showLoading={showLoading}
59
+ onLoad={onLoad}
60
+ onError={onError}
61
+ onClick={onClick}
62
+ priority={priority}
63
+ className={className}
64
+ />
65
+ )
66
+ })
@@ -0,0 +1,173 @@
1
+ 'use client'
2
+
3
+ import { memo, useRef, useEffect, useCallback, Suspense, lazy } from 'react'
4
+ import { cn } from '@djangocfg/ui-core/lib'
5
+ import type { GalleryThumbnailsProps } from '../types'
6
+
7
+ // Lazy load virtualized thumbnails - only for 50+ images
8
+ const GalleryThumbnailsVirtual = lazy(() =>
9
+ import('./GalleryThumbnailsVirtual').then((mod) => ({ default: mod.GalleryThumbnailsVirtual }))
10
+ )
11
+
12
+ const SIZES = {
13
+ sm: 'w-14 h-10',
14
+ md: 'w-20 h-14',
15
+ lg: 'w-24 h-18',
16
+ } as const
17
+
18
+ /** Threshold for switching to virtualized rendering */
19
+ const VIRTUALIZATION_THRESHOLD = 50
20
+
21
+ /**
22
+ * GalleryThumbnails - Horizontal scrollable thumbnail strip
23
+ * Automatically switches to virtualized rendering for 50+ images
24
+ */
25
+ export const GalleryThumbnails = memo(function GalleryThumbnails(props: GalleryThumbnailsProps) {
26
+ const { images } = props
27
+
28
+ // Use virtualized version for large galleries (lazy loaded)
29
+ if (images.length >= VIRTUALIZATION_THRESHOLD) {
30
+ return (
31
+ <Suspense fallback={<ThumbnailsSkeleton className={props.className} />}>
32
+ <GalleryThumbnailsVirtual {...props} />
33
+ </Suspense>
34
+ )
35
+ }
36
+
37
+ return <GalleryThumbnailsRegular {...props} />
38
+ })
39
+
40
+ // Simple skeleton for loading state
41
+ const ThumbnailsSkeleton = memo(function ThumbnailsSkeleton({ className }: { className?: string }) {
42
+ return (
43
+ <div className={cn('flex gap-2 overflow-hidden p-1', className)}>
44
+ {Array.from({ length: 5 }).map((_, i) => (
45
+ <div key={i} className="w-20 h-14 rounded-lg bg-muted animate-pulse flex-shrink-0" />
46
+ ))}
47
+ </div>
48
+ )
49
+ })
50
+
51
+ /**
52
+ * Regular (non-virtualized) thumbnails for smaller galleries
53
+ */
54
+ const GalleryThumbnailsRegular = memo(function GalleryThumbnailsRegular({
55
+ images,
56
+ currentIndex,
57
+ onSelect,
58
+ size = 'md',
59
+ className,
60
+ }: GalleryThumbnailsProps) {
61
+ const containerRef = useRef<HTMLDivElement>(null)
62
+ const itemRefs = useRef<(HTMLButtonElement | null)[]>([])
63
+
64
+ // Scroll active thumbnail into view
65
+ useEffect(() => {
66
+ const activeItem = itemRefs.current[currentIndex]
67
+ if (activeItem && containerRef.current) {
68
+ const container = containerRef.current
69
+ const itemLeft = activeItem.offsetLeft
70
+ const itemWidth = activeItem.offsetWidth
71
+ const containerWidth = container.offsetWidth
72
+ const scrollLeft = container.scrollLeft
73
+
74
+ // Check if item is not fully visible
75
+ if (itemLeft < scrollLeft || itemLeft + itemWidth > scrollLeft + containerWidth) {
76
+ activeItem.scrollIntoView({
77
+ behavior: 'smooth',
78
+ block: 'nearest',
79
+ inline: 'center',
80
+ })
81
+ }
82
+ }
83
+ }, [currentIndex])
84
+
85
+ // Handle thumbnail click
86
+ const handleClick = useCallback(
87
+ (index: number) => {
88
+ onSelect(index)
89
+ },
90
+ [onSelect]
91
+ )
92
+
93
+ if (images.length <= 1) return null
94
+
95
+ return (
96
+ <div
97
+ ref={containerRef}
98
+ className={cn(
99
+ 'flex gap-2 overflow-x-auto scrollbar-hide p-1',
100
+ className
101
+ )}
102
+ >
103
+ {images.map((image, index) => (
104
+ <ThumbnailButton
105
+ key={image.id}
106
+ ref={(el) => {
107
+ itemRefs.current[index] = el
108
+ }}
109
+ image={image}
110
+ index={index}
111
+ isActive={index === currentIndex}
112
+ size={size}
113
+ onClick={handleClick}
114
+ />
115
+ ))}
116
+ </div>
117
+ )
118
+ })
119
+
120
+ // Extracted thumbnail button component
121
+ interface ThumbnailButtonProps {
122
+ image: { id: string; src: string; thumbnail?: string; alt?: string }
123
+ index: number
124
+ isActive: boolean
125
+ size: 'sm' | 'md' | 'lg'
126
+ onClick: (index: number) => void
127
+ }
128
+
129
+ const ThumbnailButton = memo(
130
+ function ThumbnailButton({
131
+ image,
132
+ index,
133
+ isActive,
134
+ size,
135
+ onClick,
136
+ ref,
137
+ }: ThumbnailButtonProps & { ref?: React.Ref<HTMLButtonElement> }) {
138
+ const handleClick = useCallback(() => {
139
+ onClick(index)
140
+ }, [onClick, index])
141
+
142
+ return (
143
+ <button
144
+ ref={ref}
145
+ type="button"
146
+ className={cn(
147
+ 'relative flex-shrink-0 rounded-lg overflow-hidden',
148
+ 'border-2 transition-all duration-200',
149
+ 'focus:outline-none focus-visible:ring-2 focus-visible:ring-primary',
150
+ SIZES[size],
151
+ isActive
152
+ ? 'border-primary ring-1 ring-primary/30'
153
+ : 'border-transparent hover:border-muted-foreground/30'
154
+ )}
155
+ onClick={handleClick}
156
+ aria-label={`View image ${index + 1}`}
157
+ aria-current={isActive ? 'true' : undefined}
158
+ >
159
+ <img
160
+ src={image.thumbnail || image.src}
161
+ alt={image.alt || `Thumbnail ${index + 1}`}
162
+ className="w-full h-full object-cover"
163
+ loading="lazy"
164
+ />
165
+
166
+ {/* Active overlay */}
167
+ {isActive && (
168
+ <div className="absolute inset-0 bg-primary/10" />
169
+ )}
170
+ </button>
171
+ )
172
+ }
173
+ )
@@ -0,0 +1,138 @@
1
+ 'use client'
2
+
3
+ import { memo, useCallback, useEffect } from 'react'
4
+ import { cn } from '@djangocfg/ui-core/lib'
5
+ import { useVirtualList } from '../hooks/useVirtualList'
6
+ import type { GalleryThumbnailsProps } from '../types'
7
+
8
+ const SIZES = {
9
+ sm: { width: 56, height: 40 },
10
+ md: { width: 80, height: 56 },
11
+ lg: { width: 96, height: 72 },
12
+ } as const
13
+
14
+ /**
15
+ * GalleryThumbnailsVirtual - Virtualized thumbnail strip for large galleries
16
+ * Only renders visible thumbnails + buffer for optimal performance
17
+ */
18
+ export const GalleryThumbnailsVirtual = memo(function GalleryThumbnailsVirtual({
19
+ images,
20
+ currentIndex,
21
+ onSelect,
22
+ size = 'md',
23
+ className,
24
+ }: GalleryThumbnailsProps) {
25
+ const dimensions = SIZES[size]
26
+
27
+ const { containerRef, totalWidth, virtualItems, scrollToIndex } = useVirtualList({
28
+ itemCount: images.length,
29
+ itemWidth: dimensions.width,
30
+ gap: 8,
31
+ overscan: 3,
32
+ })
33
+
34
+ // Scroll active thumbnail into view
35
+ useEffect(() => {
36
+ scrollToIndex(currentIndex)
37
+ }, [currentIndex, scrollToIndex])
38
+
39
+ // Handle thumbnail click
40
+ const handleClick = useCallback(
41
+ (index: number) => {
42
+ onSelect(index)
43
+ },
44
+ [onSelect]
45
+ )
46
+
47
+ if (images.length <= 1) return null
48
+
49
+ return (
50
+ <div
51
+ ref={containerRef}
52
+ className={cn(
53
+ 'relative overflow-x-auto scrollbar-hide p-1',
54
+ className
55
+ )}
56
+ >
57
+ {/* Spacer for total scroll width */}
58
+ <div style={{ width: totalWidth, height: dimensions.height }} className="relative">
59
+ {virtualItems.map((virtualItem) => {
60
+ const image = images[virtualItem.index]
61
+ const isActive = virtualItem.index === currentIndex
62
+
63
+ return (
64
+ <VirtualThumbnail
65
+ key={image.id}
66
+ image={image}
67
+ index={virtualItem.index}
68
+ isActive={isActive}
69
+ left={virtualItem.start}
70
+ width={dimensions.width}
71
+ height={dimensions.height}
72
+ onClick={handleClick}
73
+ />
74
+ )
75
+ })}
76
+ </div>
77
+ </div>
78
+ )
79
+ })
80
+
81
+ // Extracted virtual thumbnail component
82
+ interface VirtualThumbnailProps {
83
+ image: { id: string; src: string; thumbnail?: string; alt?: string }
84
+ index: number
85
+ isActive: boolean
86
+ left: number
87
+ width: number
88
+ height: number
89
+ onClick: (index: number) => void
90
+ }
91
+
92
+ const VirtualThumbnail = memo(function VirtualThumbnail({
93
+ image,
94
+ index,
95
+ isActive,
96
+ left,
97
+ width,
98
+ height,
99
+ onClick,
100
+ }: VirtualThumbnailProps) {
101
+ const handleClick = useCallback(() => {
102
+ onClick(index)
103
+ }, [onClick, index])
104
+
105
+ return (
106
+ <button
107
+ type="button"
108
+ className={cn(
109
+ 'absolute top-0 rounded-lg overflow-hidden',
110
+ 'border-2 transition-all duration-200',
111
+ 'focus:outline-none focus-visible:ring-2 focus-visible:ring-primary',
112
+ isActive
113
+ ? 'border-primary ring-1 ring-primary/30'
114
+ : 'border-transparent hover:border-muted-foreground/30'
115
+ )}
116
+ style={{
117
+ left,
118
+ width,
119
+ height,
120
+ }}
121
+ onClick={handleClick}
122
+ aria-label={`View image ${index + 1}`}
123
+ aria-current={isActive ? 'true' : undefined}
124
+ >
125
+ <img
126
+ src={image.thumbnail || image.src}
127
+ alt={image.alt || `Thumbnail ${index + 1}`}
128
+ className="w-full h-full object-cover"
129
+ loading="lazy"
130
+ />
131
+
132
+ {/* Active overlay */}
133
+ {isActive && (
134
+ <div className="absolute inset-0 bg-primary/10" />
135
+ )}
136
+ </button>
137
+ )
138
+ })