@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.
- 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,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
|
+
})
|