@djangocfg/widget-visual 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +41 -0
- package/package.json +106 -0
- package/src/design/ColorPalette/ColorPalette.tsx +129 -0
- package/src/design/ColorPalette/README.md +34 -0
- package/src/design/ColorPalette/color-palette.stories.tsx +95 -0
- package/src/design/ColorPalette/components/Swatch.tsx +102 -0
- package/src/design/ColorPalette/hooks/useCopyToClipboard.ts +41 -0
- package/src/design/ColorPalette/index.ts +12 -0
- package/src/design/ColorPalette/lazy.tsx +21 -0
- package/src/design/ColorPalette/types.ts +63 -0
- package/src/design/ColorPalette/utils.ts +114 -0
- package/src/design/ColorPicker/ColorPicker.tsx +257 -0
- package/src/design/ColorPicker/color-picker.stories.tsx +113 -0
- package/src/design/ColorPicker/context/ColorPickerContext.tsx +28 -0
- package/src/design/ColorPicker/context/ColorPickerStore.tsx +166 -0
- package/src/design/ColorPicker/context/index.ts +2 -0
- package/src/design/ColorPicker/index.ts +15 -0
- package/src/design/ColorPicker/lazy.tsx +24 -0
- package/src/design/ColorPicker/lib/color-utils.ts +323 -0
- package/src/design/ColorPicker/parts/ColorPickerAlphaSlider.tsx +72 -0
- package/src/design/ColorPicker/parts/ColorPickerArea.tsx +155 -0
- package/src/design/ColorPicker/parts/ColorPickerEyeDropper.tsx +73 -0
- package/src/design/ColorPicker/parts/ColorPickerFormatSelect.tsx +64 -0
- package/src/design/ColorPicker/parts/ColorPickerHueSlider.tsx +64 -0
- package/src/design/ColorPicker/parts/ColorPickerInput.tsx +512 -0
- package/src/design/ColorPicker/parts/ColorPickerSwatch.tsx +63 -0
- package/src/design/ColorPicker/parts/index.ts +7 -0
- package/src/design/ColorPicker/types.ts +77 -0
- package/src/gallery/components/Gallery.tsx +182 -0
- package/src/gallery/components/compact/GalleryCompact.tsx +396 -0
- package/src/gallery/components/compact/index.ts +1 -0
- package/src/gallery/components/index.ts +21 -0
- package/src/gallery/components/lightbox/GalleryLightbox.tsx +426 -0
- package/src/gallery/components/lightbox/index.ts +1 -0
- package/src/gallery/components/media/GalleryImage.tsx +105 -0
- package/src/gallery/components/media/GalleryMedia.tsx +70 -0
- package/src/gallery/components/media/GalleryVideo.tsx +241 -0
- package/src/gallery/components/media/index.ts +3 -0
- package/src/gallery/components/preview/GalleryCarousel.tsx +374 -0
- package/src/gallery/components/preview/GalleryGrid.tsx +519 -0
- package/src/gallery/components/preview/index.ts +2 -0
- package/src/gallery/components/shared/ImageSpinner.tsx +37 -0
- package/src/gallery/components/shared/index.ts +1 -0
- package/src/gallery/components/thumbnails/GalleryThumbnails.tsx +198 -0
- package/src/gallery/components/thumbnails/GalleryThumbnailsVirtual.tsx +164 -0
- package/src/gallery/components/thumbnails/index.ts +2 -0
- package/src/gallery/gallery.stories.tsx +182 -0
- package/src/gallery/hooks/index.ts +23 -0
- package/src/gallery/hooks/useGallery.ts +138 -0
- package/src/gallery/hooks/useImageDimensions.ts +224 -0
- package/src/gallery/hooks/usePinchZoom.ts +239 -0
- package/src/gallery/hooks/usePreloadImages.ts +119 -0
- package/src/gallery/hooks/useSwipe.ts +87 -0
- package/src/gallery/hooks/useVirtualList.ts +129 -0
- package/src/gallery/hooks/useZoom.ts +321 -0
- package/src/gallery/index.ts +66 -0
- package/src/gallery/thumbnails.stories.tsx +127 -0
- package/src/gallery/types.ts +185 -0
- package/src/gallery/utils/imageAnalysis.ts +52 -0
- package/src/gallery/utils/index.ts +16 -0
- package/src/gallery/utils/normalizeUrl.ts +31 -0
- package/src/index.ts +18 -0
- package/src/lottie/LottiePlayer.client.tsx +319 -0
- package/src/lottie/index.tsx +66 -0
- package/src/lottie/lazy.tsx +65 -0
- package/src/lottie/lottie-player.stories.tsx +173 -0
- package/src/lottie/types.ts +138 -0
- package/src/lottie/useLottie.ts +187 -0
- package/src/lottie/usePrefersReducedMotion.ts +46 -0
- package/src/marquee/Marquee.tsx +637 -0
- package/src/marquee/index.ts +7 -0
- package/src/marquee/lazy.tsx +14 -0
- package/src/marquee/marquee.stories.tsx +118 -0
- package/src/marquee/types.ts +40 -0
- package/src/qrcode/QRCode.tsx +468 -0
- package/src/qrcode/index.ts +10 -0
- package/src/qrcode/lazy.tsx +19 -0
- package/src/qrcode/qr-code.stories.tsx +102 -0
- package/src/qrcode/types.ts +57 -0
|
@@ -0,0 +1,426 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { memo, useEffect, useCallback, useState, useMemo, useRef } from 'react'
|
|
4
|
+
import { createPortal } from 'react-dom'
|
|
5
|
+
import { cn } from '@djangocfg/ui-core/lib'
|
|
6
|
+
import { useAppT } 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 '../media'
|
|
12
|
+
import { GalleryThumbnails } from '../thumbnails'
|
|
13
|
+
import type { GalleryLightboxProps } from '../../types'
|
|
14
|
+
|
|
15
|
+
// Liquid-glass control — token-driven (--card / --foreground), so it stays
|
|
16
|
+
// legible on BOTH the light scrim and the dark vibrancy backdrop. Foreground
|
|
17
|
+
// icon color flips with the theme; hover wash adapts via the foreground token.
|
|
18
|
+
const GLASS_BTN =
|
|
19
|
+
'glass-liquid text-foreground/80 transition-colors ' +
|
|
20
|
+
'hover:text-foreground hover:bg-[color-mix(in_oklab,var(--foreground)_12%,transparent)] ' +
|
|
21
|
+
'focus:outline-none focus-visible:ring-2 focus-visible:ring-ring'
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* GalleryLightbox - Fullscreen image viewer with zoom and navigation
|
|
25
|
+
*
|
|
26
|
+
* Features:
|
|
27
|
+
* - Preloads adjacent images for smooth navigation
|
|
28
|
+
* - Loading skeleton while image loads
|
|
29
|
+
* - Zoom with pan/drag support
|
|
30
|
+
* - Keyboard navigation (arrows, Home/End, Esc), focus trap, backdrop click
|
|
31
|
+
* - Swipe navigation
|
|
32
|
+
*/
|
|
33
|
+
export const GalleryLightbox = memo(function GalleryLightbox({
|
|
34
|
+
open,
|
|
35
|
+
onClose,
|
|
36
|
+
images,
|
|
37
|
+
currentIndex,
|
|
38
|
+
onIndexChange,
|
|
39
|
+
showThumbnails = true,
|
|
40
|
+
enableZoom = true,
|
|
41
|
+
enableDownload = true,
|
|
42
|
+
enableShare = true,
|
|
43
|
+
title,
|
|
44
|
+
}: GalleryLightboxProps) {
|
|
45
|
+
const t = useAppT()
|
|
46
|
+
const [mounted, setMounted] = useState(false)
|
|
47
|
+
const dialogRef = useRef<HTMLDivElement>(null)
|
|
48
|
+
// Element focused before the lightbox opened, restored on close
|
|
49
|
+
const previousFocusRef = useRef<HTMLElement | null>(null)
|
|
50
|
+
|
|
51
|
+
// Translations
|
|
52
|
+
const labels = useMemo(() => ({
|
|
53
|
+
lightbox: t('tools.gallery.lightbox'),
|
|
54
|
+
previous: t('tools.gallery.previous'),
|
|
55
|
+
next: t('tools.gallery.next'),
|
|
56
|
+
close: t('tools.gallery.close'),
|
|
57
|
+
download: t('tools.gallery.download'),
|
|
58
|
+
share: t('tools.gallery.share'),
|
|
59
|
+
zoomIn: t('tools.image.zoomIn'),
|
|
60
|
+
zoomOut: t('tools.image.zoomOut'),
|
|
61
|
+
}), [t])
|
|
62
|
+
|
|
63
|
+
// Unified zoom (handles both desktop click/drag and mobile pinch/tap)
|
|
64
|
+
const zoom = useZoom({
|
|
65
|
+
minScale: 1,
|
|
66
|
+
maxScale: 3,
|
|
67
|
+
desktopScale: 2,
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
// Prepare data
|
|
71
|
+
const currentImage = useMemo(() => images[currentIndex], [images, currentIndex])
|
|
72
|
+
const hasMultiple = useMemo(() => images.length > 1, [images.length])
|
|
73
|
+
const isVideo = currentImage?.type === 'video' && Boolean(currentImage.videoSrc)
|
|
74
|
+
const { isZoomed, isDragging } = zoom
|
|
75
|
+
const resetZoom = zoom.reset
|
|
76
|
+
|
|
77
|
+
// Preload adjacent images when lightbox is open
|
|
78
|
+
usePreloadImages(open ? images : [], currentIndex, 2)
|
|
79
|
+
|
|
80
|
+
// Handle client-side mounting for portal
|
|
81
|
+
useEffect(() => {
|
|
82
|
+
setMounted(true)
|
|
83
|
+
}, [])
|
|
84
|
+
|
|
85
|
+
// Reset zoom when image changes
|
|
86
|
+
useEffect(() => {
|
|
87
|
+
resetZoom()
|
|
88
|
+
}, [currentIndex, resetZoom])
|
|
89
|
+
|
|
90
|
+
// Prevent body scroll when open
|
|
91
|
+
useEffect(() => {
|
|
92
|
+
if (!open) return
|
|
93
|
+
const originalStyle = document.body.style.overflow
|
|
94
|
+
document.body.style.overflow = 'hidden'
|
|
95
|
+
return () => {
|
|
96
|
+
document.body.style.overflow = originalStyle
|
|
97
|
+
}
|
|
98
|
+
}, [open])
|
|
99
|
+
|
|
100
|
+
// Focus management: move focus into the dialog on open, restore on close
|
|
101
|
+
useEffect(() => {
|
|
102
|
+
if (!open || !mounted) return
|
|
103
|
+
previousFocusRef.current = document.activeElement as HTMLElement | null
|
|
104
|
+
// Defer to allow the portal content to mount
|
|
105
|
+
const id = window.requestAnimationFrame(() => {
|
|
106
|
+
dialogRef.current?.focus()
|
|
107
|
+
})
|
|
108
|
+
return () => {
|
|
109
|
+
window.cancelAnimationFrame(id)
|
|
110
|
+
previousFocusRef.current?.focus?.()
|
|
111
|
+
}
|
|
112
|
+
}, [open, mounted])
|
|
113
|
+
|
|
114
|
+
// Navigation handlers
|
|
115
|
+
const goToPrev = useCallback(() => {
|
|
116
|
+
if (hasMultiple) {
|
|
117
|
+
onIndexChange((currentIndex - 1 + images.length) % images.length)
|
|
118
|
+
}
|
|
119
|
+
}, [hasMultiple, currentIndex, images.length, onIndexChange])
|
|
120
|
+
|
|
121
|
+
const goToNext = useCallback(() => {
|
|
122
|
+
if (hasMultiple) {
|
|
123
|
+
onIndexChange((currentIndex + 1) % images.length)
|
|
124
|
+
}
|
|
125
|
+
}, [hasMultiple, currentIndex, images.length, onIndexChange])
|
|
126
|
+
|
|
127
|
+
// Keyboard navigation + focus trap
|
|
128
|
+
useEffect(() => {
|
|
129
|
+
if (!open) return
|
|
130
|
+
|
|
131
|
+
const handleKeyDown = (e: KeyboardEvent) => {
|
|
132
|
+
switch (e.key) {
|
|
133
|
+
case 'Escape':
|
|
134
|
+
e.preventDefault()
|
|
135
|
+
if (isZoomed) {
|
|
136
|
+
resetZoom()
|
|
137
|
+
} else {
|
|
138
|
+
onClose()
|
|
139
|
+
}
|
|
140
|
+
break
|
|
141
|
+
case 'ArrowLeft':
|
|
142
|
+
if (!isZoomed) goToPrev()
|
|
143
|
+
break
|
|
144
|
+
case 'ArrowRight':
|
|
145
|
+
if (!isZoomed) goToNext()
|
|
146
|
+
break
|
|
147
|
+
case 'Home':
|
|
148
|
+
if (!isZoomed && hasMultiple) {
|
|
149
|
+
e.preventDefault()
|
|
150
|
+
onIndexChange(0)
|
|
151
|
+
}
|
|
152
|
+
break
|
|
153
|
+
case 'End':
|
|
154
|
+
if (!isZoomed && hasMultiple) {
|
|
155
|
+
e.preventDefault()
|
|
156
|
+
onIndexChange(images.length - 1)
|
|
157
|
+
}
|
|
158
|
+
break
|
|
159
|
+
case 'Tab': {
|
|
160
|
+
// Trap focus within the dialog
|
|
161
|
+
const dialog = dialogRef.current
|
|
162
|
+
if (!dialog) break
|
|
163
|
+
const focusable = dialog.querySelectorAll<HTMLElement>(
|
|
164
|
+
'button:not([disabled]), [href], [tabindex]:not([tabindex="-1"])'
|
|
165
|
+
)
|
|
166
|
+
const first = focusable[0]
|
|
167
|
+
const last = focusable[focusable.length - 1]
|
|
168
|
+
if (!first || !last) {
|
|
169
|
+
e.preventDefault()
|
|
170
|
+
break
|
|
171
|
+
}
|
|
172
|
+
const active = document.activeElement
|
|
173
|
+
if (e.shiftKey && (active === first || active === dialog)) {
|
|
174
|
+
e.preventDefault()
|
|
175
|
+
last.focus()
|
|
176
|
+
} else if (!e.shiftKey && active === last) {
|
|
177
|
+
e.preventDefault()
|
|
178
|
+
first.focus()
|
|
179
|
+
}
|
|
180
|
+
break
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
window.addEventListener('keydown', handleKeyDown)
|
|
186
|
+
return () => window.removeEventListener('keydown', handleKeyDown)
|
|
187
|
+
}, [open, isZoomed, goToPrev, goToNext, onClose, resetZoom, hasMultiple, images.length, onIndexChange])
|
|
188
|
+
|
|
189
|
+
// Swipe handlers (disabled when zoomed)
|
|
190
|
+
const swipeHandlers = useSwipe({
|
|
191
|
+
onSwipeLeft: isZoomed ? undefined : goToNext,
|
|
192
|
+
onSwipeRight: isZoomed ? undefined : goToPrev,
|
|
193
|
+
})
|
|
194
|
+
|
|
195
|
+
// Action handlers
|
|
196
|
+
const handleDownload = useCallback(() => {
|
|
197
|
+
if (!currentImage) return
|
|
198
|
+
// For videos download the video file, otherwise the image
|
|
199
|
+
const downloadUrl = isVideo ? currentImage.videoSrc! : currentImage.src
|
|
200
|
+
// Derive a filename from the URL path, fall back to a numbered name
|
|
201
|
+
let filename = isVideo ? `video-${currentIndex + 1}.mp4` : `image-${currentIndex + 1}.jpg`
|
|
202
|
+
try {
|
|
203
|
+
const path = new URL(downloadUrl, window.location.href).pathname
|
|
204
|
+
const last = path.split('/').pop()
|
|
205
|
+
if (last && /\.[a-z0-9]+$/i.test(last)) {
|
|
206
|
+
filename = decodeURIComponent(last)
|
|
207
|
+
}
|
|
208
|
+
} catch {
|
|
209
|
+
// Keep fallback filename
|
|
210
|
+
}
|
|
211
|
+
const link = document.createElement('a')
|
|
212
|
+
link.href = downloadUrl
|
|
213
|
+
link.download = filename
|
|
214
|
+
link.rel = 'noopener'
|
|
215
|
+
document.body.appendChild(link)
|
|
216
|
+
link.click()
|
|
217
|
+
document.body.removeChild(link)
|
|
218
|
+
}, [currentImage, currentIndex, isVideo])
|
|
219
|
+
|
|
220
|
+
const handleShare = useCallback(async () => {
|
|
221
|
+
if (!currentImage) return
|
|
222
|
+
if (navigator.share) {
|
|
223
|
+
try {
|
|
224
|
+
await navigator.share({
|
|
225
|
+
title: title || 'Image',
|
|
226
|
+
url: currentImage.src,
|
|
227
|
+
})
|
|
228
|
+
} catch {
|
|
229
|
+
// User cancelled or error
|
|
230
|
+
}
|
|
231
|
+
} else {
|
|
232
|
+
await navigator.clipboard.writeText(currentImage.src)
|
|
233
|
+
}
|
|
234
|
+
}, [currentImage, title])
|
|
235
|
+
|
|
236
|
+
const handleToggleZoom = useCallback(() => {
|
|
237
|
+
zoom.toggleZoom()
|
|
238
|
+
}, [zoom])
|
|
239
|
+
|
|
240
|
+
// Close only when the backdrop itself (not a child) is clicked
|
|
241
|
+
const handleBackdropClick = useCallback((e: React.MouseEvent) => {
|
|
242
|
+
if (e.target === e.currentTarget) onClose()
|
|
243
|
+
}, [onClose])
|
|
244
|
+
|
|
245
|
+
if (!open || !mounted) return null
|
|
246
|
+
|
|
247
|
+
const lightbox = (
|
|
248
|
+
<div
|
|
249
|
+
ref={dialogRef}
|
|
250
|
+
className={cn(
|
|
251
|
+
'fixed inset-0 z-50',
|
|
252
|
+
// Theme-aware Quick-Look scrim: a LIGHT neutral wash in light mode so
|
|
253
|
+
// the lightbox feels native (Quick Look matches system appearance),
|
|
254
|
+
// a deep vibrancy floor in dark mode. blur+saturate in both so the
|
|
255
|
+
// page behind bleeds through softly and the image floats.
|
|
256
|
+
'backdrop-blur-2xl backdrop-saturate-150',
|
|
257
|
+
'[background-color:color-mix(in_oklab,var(--background)_72%,transparent)]',
|
|
258
|
+
'dark:[background-color:color-mix(in_oklab,#000_82%,transparent)]',
|
|
259
|
+
'animate-in fade-in-0 duration-200',
|
|
260
|
+
'focus:outline-none'
|
|
261
|
+
)}
|
|
262
|
+
role="dialog"
|
|
263
|
+
aria-modal="true"
|
|
264
|
+
aria-label={title || labels.lightbox}
|
|
265
|
+
tabIndex={-1}
|
|
266
|
+
>
|
|
267
|
+
{/* Content */}
|
|
268
|
+
<div
|
|
269
|
+
className="relative w-full h-full flex flex-col"
|
|
270
|
+
{...swipeHandlers}
|
|
271
|
+
>
|
|
272
|
+
{/* Header */}
|
|
273
|
+
<div className="absolute top-0 left-0 right-0 z-10 flex items-center justify-between p-4">
|
|
274
|
+
{/* Title & Counter — liquid-glass pill so it reads on both the light
|
|
275
|
+
and dark scrim (glass-liquid is token-driven, foreground text
|
|
276
|
+
flips with the theme). */}
|
|
277
|
+
{(title || hasMultiple) ? (
|
|
278
|
+
<div className="glass-liquid rounded-2xl px-3 py-1.5 text-foreground">
|
|
279
|
+
{title && <div className="font-medium">{title}</div>}
|
|
280
|
+
{hasMultiple && (
|
|
281
|
+
<div className="text-sm text-foreground/70" aria-live="polite">
|
|
282
|
+
{currentIndex + 1} / {images.length}
|
|
283
|
+
</div>
|
|
284
|
+
)}
|
|
285
|
+
</div>
|
|
286
|
+
) : (
|
|
287
|
+
<span aria-hidden />
|
|
288
|
+
)}
|
|
289
|
+
|
|
290
|
+
{/* Actions */}
|
|
291
|
+
<div className="flex items-center gap-2">
|
|
292
|
+
{enableZoom && !isVideo && (
|
|
293
|
+
<button
|
|
294
|
+
type="button"
|
|
295
|
+
className={cn(GLASS_BTN, 'p-2 rounded-full')}
|
|
296
|
+
onClick={handleToggleZoom}
|
|
297
|
+
aria-label={isZoomed ? labels.zoomOut : labels.zoomIn}
|
|
298
|
+
>
|
|
299
|
+
{isZoomed ? (
|
|
300
|
+
<ZoomOut className="w-5 h-5" />
|
|
301
|
+
) : (
|
|
302
|
+
<ZoomIn className="w-5 h-5" />
|
|
303
|
+
)}
|
|
304
|
+
</button>
|
|
305
|
+
)}
|
|
306
|
+
|
|
307
|
+
{enableDownload && (
|
|
308
|
+
<button
|
|
309
|
+
type="button"
|
|
310
|
+
className={cn(GLASS_BTN, 'p-2 rounded-full')}
|
|
311
|
+
onClick={handleDownload}
|
|
312
|
+
aria-label={labels.download}
|
|
313
|
+
>
|
|
314
|
+
<Download className="w-5 h-5" />
|
|
315
|
+
</button>
|
|
316
|
+
)}
|
|
317
|
+
|
|
318
|
+
{enableShare && (
|
|
319
|
+
<button
|
|
320
|
+
type="button"
|
|
321
|
+
className={cn(GLASS_BTN, 'p-2 rounded-full')}
|
|
322
|
+
onClick={handleShare}
|
|
323
|
+
aria-label={labels.share}
|
|
324
|
+
>
|
|
325
|
+
<Share2 className="w-5 h-5" />
|
|
326
|
+
</button>
|
|
327
|
+
)}
|
|
328
|
+
|
|
329
|
+
<button
|
|
330
|
+
type="button"
|
|
331
|
+
className={cn(GLASS_BTN, 'p-2 rounded-full')}
|
|
332
|
+
onClick={onClose}
|
|
333
|
+
aria-label={labels.close}
|
|
334
|
+
>
|
|
335
|
+
<X className="w-5 h-5" />
|
|
336
|
+
</button>
|
|
337
|
+
</div>
|
|
338
|
+
</div>
|
|
339
|
+
|
|
340
|
+
{/* Main Image */}
|
|
341
|
+
<div
|
|
342
|
+
className="flex-1 flex items-center justify-center p-4 pt-16 pb-24 overflow-hidden"
|
|
343
|
+
onClick={handleBackdropClick}
|
|
344
|
+
>
|
|
345
|
+
{currentImage && (
|
|
346
|
+
<div
|
|
347
|
+
className={cn(
|
|
348
|
+
'relative w-full h-full select-none',
|
|
349
|
+
// Zoom cursors only apply to images, not video (needs native controls)
|
|
350
|
+
!isVideo && (isZoomed ? 'cursor-grab' : enableZoom ? 'cursor-zoom-in' : 'cursor-default'),
|
|
351
|
+
!isVideo && isDragging && 'cursor-grabbing'
|
|
352
|
+
)}
|
|
353
|
+
{...(enableZoom && !isVideo ? zoom.handlers : {})}
|
|
354
|
+
style={{
|
|
355
|
+
transform: isZoomed && !isVideo
|
|
356
|
+
? `scale(${zoom.state.scale}) translate(${zoom.state.x}px, ${zoom.state.y}px)`
|
|
357
|
+
: 'scale(1)',
|
|
358
|
+
transition: isDragging ? 'none' : 'transform 0.3s ease-out',
|
|
359
|
+
touchAction: 'none',
|
|
360
|
+
}}
|
|
361
|
+
>
|
|
362
|
+
<GalleryMedia
|
|
363
|
+
media={currentImage}
|
|
364
|
+
// pointer-events disabled for images so the zoom wrapper gets clicks;
|
|
365
|
+
// video needs pointer events for its native/overlay controls
|
|
366
|
+
className={cn('w-full h-full', !isVideo && 'pointer-events-none')}
|
|
367
|
+
objectFit="contain"
|
|
368
|
+
showLoading
|
|
369
|
+
priority
|
|
370
|
+
autoPlay
|
|
371
|
+
/>
|
|
372
|
+
</div>
|
|
373
|
+
)}
|
|
374
|
+
</div>
|
|
375
|
+
|
|
376
|
+
{/* Navigation Arrows */}
|
|
377
|
+
{hasMultiple && !isZoomed && (
|
|
378
|
+
<>
|
|
379
|
+
<button
|
|
380
|
+
type="button"
|
|
381
|
+
className={cn(
|
|
382
|
+
GLASS_BTN,
|
|
383
|
+
'absolute left-4 top-1/2 -translate-y-1/2 z-10',
|
|
384
|
+
'w-12 h-12 rounded-full',
|
|
385
|
+
'flex items-center justify-center'
|
|
386
|
+
)}
|
|
387
|
+
onClick={goToPrev}
|
|
388
|
+
aria-label={labels.previous}
|
|
389
|
+
>
|
|
390
|
+
<ChevronLeft className="w-6 h-6" />
|
|
391
|
+
</button>
|
|
392
|
+
|
|
393
|
+
<button
|
|
394
|
+
type="button"
|
|
395
|
+
className={cn(
|
|
396
|
+
GLASS_BTN,
|
|
397
|
+
'absolute right-4 top-1/2 -translate-y-1/2 z-10',
|
|
398
|
+
'w-12 h-12 rounded-full',
|
|
399
|
+
'flex items-center justify-center'
|
|
400
|
+
)}
|
|
401
|
+
onClick={goToNext}
|
|
402
|
+
aria-label={labels.next}
|
|
403
|
+
>
|
|
404
|
+
<ChevronRight className="w-6 h-6" />
|
|
405
|
+
</button>
|
|
406
|
+
</>
|
|
407
|
+
)}
|
|
408
|
+
|
|
409
|
+
{/* Thumbnails */}
|
|
410
|
+
{showThumbnails && hasMultiple && !isZoomed && (
|
|
411
|
+
<div className="absolute bottom-0 left-0 right-0 p-4 bg-gradient-to-t from-white/40 dark:from-black/50 to-transparent">
|
|
412
|
+
<GalleryThumbnails
|
|
413
|
+
images={images}
|
|
414
|
+
currentIndex={currentIndex}
|
|
415
|
+
onSelect={onIndexChange}
|
|
416
|
+
size="sm"
|
|
417
|
+
className="justify-center"
|
|
418
|
+
/>
|
|
419
|
+
</div>
|
|
420
|
+
)}
|
|
421
|
+
</div>
|
|
422
|
+
</div>
|
|
423
|
+
)
|
|
424
|
+
|
|
425
|
+
return createPortal(lightbox, document.body)
|
|
426
|
+
})
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { GalleryLightbox } from './GalleryLightbox'
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { memo, useMemo } from 'react'
|
|
4
|
+
import { cn } from '@djangocfg/ui-core/lib'
|
|
5
|
+
import { useAppT } from '@djangocfg/i18n'
|
|
6
|
+
import { useImageLoader } from '@djangocfg/ui-core/hooks'
|
|
7
|
+
import { ImageOff } from 'lucide-react'
|
|
8
|
+
import type { GalleryImageProps } from '../../types'
|
|
9
|
+
import { normalizeImageUrl } from '../../utils'
|
|
10
|
+
import { ImageSpinner } from '../shared'
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* GalleryImage - Single image with loading state and error handling
|
|
14
|
+
* Uses CSS background-image for cover mode, <img> for contain mode
|
|
15
|
+
*/
|
|
16
|
+
export const GalleryImage = memo(function GalleryImage({
|
|
17
|
+
image,
|
|
18
|
+
showLoading = true,
|
|
19
|
+
onLoad,
|
|
20
|
+
onError,
|
|
21
|
+
onClick,
|
|
22
|
+
priority = false,
|
|
23
|
+
objectFit = 'cover',
|
|
24
|
+
className,
|
|
25
|
+
}: GalleryImageProps) {
|
|
26
|
+
const t = useAppT()
|
|
27
|
+
const failedToLoadText = useMemo(() => t('tools.image.failedToLoad'), [t])
|
|
28
|
+
const normalizedSrc = useMemo(() => normalizeImageUrl(image.src), [image.src])
|
|
29
|
+
const callbacks = useMemo(() => ({ onLoad, onError }), [onLoad, onError])
|
|
30
|
+
const { isLoading, isLoaded, hasError } = useImageLoader(normalizedSrc, callbacks)
|
|
31
|
+
|
|
32
|
+
const backgroundStyle = useMemo(() =>
|
|
33
|
+
isLoaded && objectFit === 'cover' ? {
|
|
34
|
+
backgroundImage: `url(${normalizedSrc})`,
|
|
35
|
+
backgroundSize: 'cover',
|
|
36
|
+
backgroundPosition: 'center',
|
|
37
|
+
backgroundRepeat: 'no-repeat',
|
|
38
|
+
} : undefined
|
|
39
|
+
, [isLoaded, normalizedSrc, objectFit])
|
|
40
|
+
|
|
41
|
+
if (hasError) {
|
|
42
|
+
return (
|
|
43
|
+
<div
|
|
44
|
+
className={cn(
|
|
45
|
+
'flex items-center justify-center bg-muted',
|
|
46
|
+
className
|
|
47
|
+
)}
|
|
48
|
+
>
|
|
49
|
+
<div className="text-center text-muted-foreground">
|
|
50
|
+
<ImageOff className="w-12 h-12 mx-auto mb-2 opacity-50" />
|
|
51
|
+
<p className="text-sm">{failedToLoadText}</p>
|
|
52
|
+
</div>
|
|
53
|
+
</div>
|
|
54
|
+
)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Use <img> tag for contain mode (lightbox)
|
|
58
|
+
if (objectFit === 'contain') {
|
|
59
|
+
return (
|
|
60
|
+
<div
|
|
61
|
+
className={cn(
|
|
62
|
+
'relative w-full h-full flex items-center justify-center bg-muted',
|
|
63
|
+
className
|
|
64
|
+
)}
|
|
65
|
+
onClick={onClick}
|
|
66
|
+
>
|
|
67
|
+
{/* Loading spinner */}
|
|
68
|
+
{isLoading && showLoading && (
|
|
69
|
+
<div className="absolute inset-0 flex items-center justify-center">
|
|
70
|
+
<ImageSpinner size="lg" />
|
|
71
|
+
</div>
|
|
72
|
+
)}
|
|
73
|
+
{isLoaded && (
|
|
74
|
+
<img
|
|
75
|
+
src={normalizedSrc}
|
|
76
|
+
alt={image.alt || 'Gallery image'}
|
|
77
|
+
className="max-w-full max-h-full object-contain animate-in fade-in-0 duration-300"
|
|
78
|
+
/>
|
|
79
|
+
)}
|
|
80
|
+
</div>
|
|
81
|
+
)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Use background-image for cover mode (thumbnails, grid)
|
|
85
|
+
return (
|
|
86
|
+
<div
|
|
87
|
+
className={cn(
|
|
88
|
+
'relative w-full h-full bg-muted',
|
|
89
|
+
isLoaded && 'animate-in fade-in-0 duration-300',
|
|
90
|
+
className
|
|
91
|
+
)}
|
|
92
|
+
style={backgroundStyle}
|
|
93
|
+
onClick={onClick}
|
|
94
|
+
role="img"
|
|
95
|
+
aria-label={image.alt || 'Gallery image'}
|
|
96
|
+
>
|
|
97
|
+
{/* Loading spinner */}
|
|
98
|
+
{isLoading && showLoading && (
|
|
99
|
+
<div className="absolute inset-0 flex items-center justify-center">
|
|
100
|
+
<ImageSpinner size="md" />
|
|
101
|
+
</div>
|
|
102
|
+
)}
|
|
103
|
+
</div>
|
|
104
|
+
)
|
|
105
|
+
})
|
|
@@ -0,0 +1,70 @@
|
|
|
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
|
+
/** Object fit mode: cover for thumbnails/grid, contain for lightbox */
|
|
24
|
+
objectFit?: 'cover' | 'contain'
|
|
25
|
+
/** Additional CSS class */
|
|
26
|
+
className?: string
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* GalleryMedia - Renders image or video based on media type
|
|
31
|
+
*/
|
|
32
|
+
export const GalleryMedia = memo(function GalleryMedia({
|
|
33
|
+
media,
|
|
34
|
+
showLoading = true,
|
|
35
|
+
onLoad,
|
|
36
|
+
onError,
|
|
37
|
+
onClick,
|
|
38
|
+
priority = false,
|
|
39
|
+
autoPlay = false,
|
|
40
|
+
objectFit = 'cover',
|
|
41
|
+
className,
|
|
42
|
+
}: GalleryMediaProps) {
|
|
43
|
+
const isVideo = media.type === 'video' && media.videoSrc
|
|
44
|
+
|
|
45
|
+
if (isVideo) {
|
|
46
|
+
return (
|
|
47
|
+
<GalleryVideo
|
|
48
|
+
media={media}
|
|
49
|
+
autoPlay={autoPlay}
|
|
50
|
+
muted
|
|
51
|
+
loop
|
|
52
|
+
showControls
|
|
53
|
+
className={className}
|
|
54
|
+
/>
|
|
55
|
+
)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return (
|
|
59
|
+
<GalleryImageComponent
|
|
60
|
+
image={media}
|
|
61
|
+
showLoading={showLoading}
|
|
62
|
+
onLoad={onLoad}
|
|
63
|
+
onError={onError}
|
|
64
|
+
onClick={onClick}
|
|
65
|
+
priority={priority}
|
|
66
|
+
objectFit={objectFit}
|
|
67
|
+
className={className}
|
|
68
|
+
/>
|
|
69
|
+
)
|
|
70
|
+
})
|