@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.
Files changed (80) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +41 -0
  3. package/package.json +106 -0
  4. package/src/design/ColorPalette/ColorPalette.tsx +129 -0
  5. package/src/design/ColorPalette/README.md +34 -0
  6. package/src/design/ColorPalette/color-palette.stories.tsx +95 -0
  7. package/src/design/ColorPalette/components/Swatch.tsx +102 -0
  8. package/src/design/ColorPalette/hooks/useCopyToClipboard.ts +41 -0
  9. package/src/design/ColorPalette/index.ts +12 -0
  10. package/src/design/ColorPalette/lazy.tsx +21 -0
  11. package/src/design/ColorPalette/types.ts +63 -0
  12. package/src/design/ColorPalette/utils.ts +114 -0
  13. package/src/design/ColorPicker/ColorPicker.tsx +257 -0
  14. package/src/design/ColorPicker/color-picker.stories.tsx +113 -0
  15. package/src/design/ColorPicker/context/ColorPickerContext.tsx +28 -0
  16. package/src/design/ColorPicker/context/ColorPickerStore.tsx +166 -0
  17. package/src/design/ColorPicker/context/index.ts +2 -0
  18. package/src/design/ColorPicker/index.ts +15 -0
  19. package/src/design/ColorPicker/lazy.tsx +24 -0
  20. package/src/design/ColorPicker/lib/color-utils.ts +323 -0
  21. package/src/design/ColorPicker/parts/ColorPickerAlphaSlider.tsx +72 -0
  22. package/src/design/ColorPicker/parts/ColorPickerArea.tsx +155 -0
  23. package/src/design/ColorPicker/parts/ColorPickerEyeDropper.tsx +73 -0
  24. package/src/design/ColorPicker/parts/ColorPickerFormatSelect.tsx +64 -0
  25. package/src/design/ColorPicker/parts/ColorPickerHueSlider.tsx +64 -0
  26. package/src/design/ColorPicker/parts/ColorPickerInput.tsx +512 -0
  27. package/src/design/ColorPicker/parts/ColorPickerSwatch.tsx +63 -0
  28. package/src/design/ColorPicker/parts/index.ts +7 -0
  29. package/src/design/ColorPicker/types.ts +77 -0
  30. package/src/gallery/components/Gallery.tsx +182 -0
  31. package/src/gallery/components/compact/GalleryCompact.tsx +396 -0
  32. package/src/gallery/components/compact/index.ts +1 -0
  33. package/src/gallery/components/index.ts +21 -0
  34. package/src/gallery/components/lightbox/GalleryLightbox.tsx +426 -0
  35. package/src/gallery/components/lightbox/index.ts +1 -0
  36. package/src/gallery/components/media/GalleryImage.tsx +105 -0
  37. package/src/gallery/components/media/GalleryMedia.tsx +70 -0
  38. package/src/gallery/components/media/GalleryVideo.tsx +241 -0
  39. package/src/gallery/components/media/index.ts +3 -0
  40. package/src/gallery/components/preview/GalleryCarousel.tsx +374 -0
  41. package/src/gallery/components/preview/GalleryGrid.tsx +519 -0
  42. package/src/gallery/components/preview/index.ts +2 -0
  43. package/src/gallery/components/shared/ImageSpinner.tsx +37 -0
  44. package/src/gallery/components/shared/index.ts +1 -0
  45. package/src/gallery/components/thumbnails/GalleryThumbnails.tsx +198 -0
  46. package/src/gallery/components/thumbnails/GalleryThumbnailsVirtual.tsx +164 -0
  47. package/src/gallery/components/thumbnails/index.ts +2 -0
  48. package/src/gallery/gallery.stories.tsx +182 -0
  49. package/src/gallery/hooks/index.ts +23 -0
  50. package/src/gallery/hooks/useGallery.ts +138 -0
  51. package/src/gallery/hooks/useImageDimensions.ts +224 -0
  52. package/src/gallery/hooks/usePinchZoom.ts +239 -0
  53. package/src/gallery/hooks/usePreloadImages.ts +119 -0
  54. package/src/gallery/hooks/useSwipe.ts +87 -0
  55. package/src/gallery/hooks/useVirtualList.ts +129 -0
  56. package/src/gallery/hooks/useZoom.ts +321 -0
  57. package/src/gallery/index.ts +66 -0
  58. package/src/gallery/thumbnails.stories.tsx +127 -0
  59. package/src/gallery/types.ts +185 -0
  60. package/src/gallery/utils/imageAnalysis.ts +52 -0
  61. package/src/gallery/utils/index.ts +16 -0
  62. package/src/gallery/utils/normalizeUrl.ts +31 -0
  63. package/src/index.ts +18 -0
  64. package/src/lottie/LottiePlayer.client.tsx +319 -0
  65. package/src/lottie/index.tsx +66 -0
  66. package/src/lottie/lazy.tsx +65 -0
  67. package/src/lottie/lottie-player.stories.tsx +173 -0
  68. package/src/lottie/types.ts +138 -0
  69. package/src/lottie/useLottie.ts +187 -0
  70. package/src/lottie/usePrefersReducedMotion.ts +46 -0
  71. package/src/marquee/Marquee.tsx +637 -0
  72. package/src/marquee/index.ts +7 -0
  73. package/src/marquee/lazy.tsx +14 -0
  74. package/src/marquee/marquee.stories.tsx +118 -0
  75. package/src/marquee/types.ts +40 -0
  76. package/src/qrcode/QRCode.tsx +468 -0
  77. package/src/qrcode/index.ts +10 -0
  78. package/src/qrcode/lazy.tsx +19 -0
  79. package/src/qrcode/qr-code.stories.tsx +102 -0
  80. 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
+ })