@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,222 @@
1
+ 'use client'
2
+
3
+ import { memo, useCallback, useMemo, useRef, useState } from 'react'
4
+ import { cn } from '@djangocfg/ui-core/lib'
5
+ import { useTypedT, type I18nTranslations } from '@djangocfg/i18n'
6
+ import { Play, Pause, Volume2, VolumeX, Maximize, AlertCircle } from 'lucide-react'
7
+ import type { GalleryMediaItem } from '../types'
8
+
9
+ export interface GalleryVideoProps {
10
+ /** Video data */
11
+ media: GalleryMediaItem
12
+ /** Whether video should autoplay */
13
+ autoPlay?: boolean
14
+ /** Whether video should be muted by default */
15
+ muted?: boolean
16
+ /** Whether video should loop */
17
+ loop?: boolean
18
+ /** Show controls overlay */
19
+ showControls?: boolean
20
+ /** Additional CSS class */
21
+ className?: string
22
+ }
23
+
24
+ /**
25
+ * GalleryVideo - Video player for gallery items
26
+ */
27
+ export const GalleryVideo = memo(function GalleryVideo({
28
+ media,
29
+ autoPlay = false,
30
+ muted = true,
31
+ loop = true,
32
+ showControls = true,
33
+ className,
34
+ }: GalleryVideoProps) {
35
+ const t = useTypedT<I18nTranslations>()
36
+ const videoRef = useRef<HTMLVideoElement>(null)
37
+ const [isPlaying, setIsPlaying] = useState(autoPlay)
38
+ const [isMuted, setIsMuted] = useState(muted)
39
+ const [showOverlay, setShowOverlay] = useState(true)
40
+ const [hasError, setHasError] = useState(false)
41
+
42
+ const labels = useMemo(() => ({
43
+ play: t('tools.video.play'),
44
+ fullscreen: t('tools.video.fullscreen'),
45
+ unavailable: t('tools.video.unavailable'),
46
+ }), [t])
47
+
48
+ const handlePlayPause = useCallback((e?: React.MouseEvent) => {
49
+ e?.stopPropagation()
50
+ const video = videoRef.current
51
+ if (!video) return
52
+
53
+ if (video.paused) {
54
+ video.play()
55
+ setIsPlaying(true)
56
+ } else {
57
+ video.pause()
58
+ setIsPlaying(false)
59
+ }
60
+ }, [])
61
+
62
+ const handleMuteToggle = useCallback((e: React.MouseEvent) => {
63
+ e.stopPropagation()
64
+ const video = videoRef.current
65
+ if (!video) return
66
+
67
+ video.muted = !video.muted
68
+ setIsMuted(video.muted)
69
+ }, [])
70
+
71
+ const handleFullscreen = useCallback((e: React.MouseEvent) => {
72
+ e.stopPropagation()
73
+ const video = videoRef.current
74
+ if (!video) return
75
+
76
+ if (video.requestFullscreen) {
77
+ video.requestFullscreen()
78
+ }
79
+ }, [])
80
+
81
+ const handleVideoClick = useCallback((e: React.MouseEvent) => {
82
+ e.stopPropagation()
83
+ handlePlayPause()
84
+ }, [handlePlayPause])
85
+
86
+ const handleMouseEnter = useCallback(() => {
87
+ setShowOverlay(true)
88
+ }, [])
89
+
90
+ const handleMouseLeave = useCallback(() => {
91
+ if (isPlaying) {
92
+ setShowOverlay(false)
93
+ }
94
+ }, [isPlaying])
95
+
96
+ const handleError = useCallback(() => {
97
+ setHasError(true)
98
+ setIsPlaying(false)
99
+ }, [])
100
+
101
+ const handleContainerClick = useCallback((e: React.MouseEvent) => {
102
+ e.stopPropagation()
103
+ }, [])
104
+
105
+ if (!media.videoSrc) {
106
+ return null
107
+ }
108
+
109
+ // Error state - show poster with error overlay
110
+ if (hasError) {
111
+ return (
112
+ <div
113
+ className={cn('relative w-full h-full bg-black', className)}
114
+ onClick={handleContainerClick}
115
+ >
116
+ {media.src && (
117
+ <img
118
+ src={media.src}
119
+ alt={media.alt || 'Video thumbnail'}
120
+ className="w-full h-full object-contain opacity-50"
121
+ />
122
+ )}
123
+ <div className="absolute inset-0 flex flex-col items-center justify-center bg-black/50">
124
+ <AlertCircle className="w-12 h-12 text-white/70 mb-2" />
125
+ <span className="text-white/70 text-sm">{labels.unavailable}</span>
126
+ </div>
127
+ <div className="absolute top-3 left-3 px-2 py-1 bg-black/60 rounded text-white text-xs font-medium">
128
+ Video
129
+ </div>
130
+ </div>
131
+ )
132
+ }
133
+
134
+ return (
135
+ <div
136
+ className={cn('relative w-full h-full bg-black', className)}
137
+ onMouseEnter={handleMouseEnter}
138
+ onMouseLeave={handleMouseLeave}
139
+ onClick={handleContainerClick}
140
+ >
141
+ <video
142
+ ref={videoRef}
143
+ className="w-full h-full object-contain"
144
+ src={media.videoSrc}
145
+ poster={media.src}
146
+ autoPlay={autoPlay}
147
+ muted={muted}
148
+ loop={loop}
149
+ playsInline
150
+ onClick={handleVideoClick}
151
+ onError={handleError}
152
+ >
153
+ {media.videoType && (
154
+ <source src={media.videoSrc} type={media.videoType} />
155
+ )}
156
+ </video>
157
+
158
+ {/* Play button overlay */}
159
+ {!isPlaying && (
160
+ <div className="absolute inset-0 flex items-center justify-center bg-black/30">
161
+ <button
162
+ type="button"
163
+ className="w-16 h-16 rounded-full bg-white/90 flex items-center justify-center shadow-lg hover:bg-white transition-colors"
164
+ onClick={handlePlayPause}
165
+ aria-label={labels.play}
166
+ >
167
+ <Play className="w-8 h-8 text-black ml-1" />
168
+ </button>
169
+ </div>
170
+ )}
171
+
172
+ {/* Controls overlay */}
173
+ {showControls && showOverlay && isPlaying && (
174
+ <div className="absolute bottom-0 left-0 right-0 p-4 bg-gradient-to-t from-black/60 to-transparent">
175
+ <div className="flex items-center gap-3">
176
+ <button
177
+ type="button"
178
+ className="p-2 rounded-full bg-white/20 hover:bg-white/30 text-white transition-colors"
179
+ onClick={handlePlayPause}
180
+ aria-label={isPlaying ? 'Pause' : 'Play'}
181
+ >
182
+ {isPlaying ? (
183
+ <Pause className="w-5 h-5" />
184
+ ) : (
185
+ <Play className="w-5 h-5" />
186
+ )}
187
+ </button>
188
+
189
+ <button
190
+ type="button"
191
+ className="p-2 rounded-full bg-white/20 hover:bg-white/30 text-white transition-colors"
192
+ onClick={handleMuteToggle}
193
+ aria-label={isMuted ? 'Unmute' : 'Mute'}
194
+ >
195
+ {isMuted ? (
196
+ <VolumeX className="w-5 h-5" />
197
+ ) : (
198
+ <Volume2 className="w-5 h-5" />
199
+ )}
200
+ </button>
201
+
202
+ <div className="flex-1" />
203
+
204
+ <button
205
+ type="button"
206
+ className="p-2 rounded-full bg-white/20 hover:bg-white/30 text-white transition-colors"
207
+ onClick={handleFullscreen}
208
+ aria-label={labels.fullscreen}
209
+ >
210
+ <Maximize className="w-5 h-5" />
211
+ </button>
212
+ </div>
213
+ </div>
214
+ )}
215
+
216
+ {/* Video indicator badge */}
217
+ <div className="absolute top-3 left-3 px-2 py-1 bg-black/60 rounded text-white text-xs font-medium">
218
+ Video
219
+ </div>
220
+ </div>
221
+ )
222
+ })
@@ -0,0 +1,13 @@
1
+ export { Gallery } from './Gallery'
2
+ export { GalleryCarousel } from './GalleryCarousel'
3
+ export type { GalleryCarouselProps } from './GalleryCarousel'
4
+ export { GalleryCompact } from './GalleryCompact'
5
+ export type { GalleryCompactProps } from './GalleryCompact'
6
+ export { GalleryGrid } from './GalleryGrid'
7
+ export type { GalleryGridProps, GalleryGridLayout } from './GalleryGrid'
8
+ export { GalleryImage } from './GalleryImage'
9
+ export { GalleryVideo } from './GalleryVideo'
10
+ export { GalleryMedia } from './GalleryMedia'
11
+ export { GalleryThumbnails } from './GalleryThumbnails'
12
+ export { GalleryThumbnailsVirtual } from './GalleryThumbnailsVirtual'
13
+ export { GalleryLightbox } from './GalleryLightbox'
@@ -0,0 +1,23 @@
1
+ export { useGallery } from './useGallery'
2
+ export type { UseGalleryOptions } from './useGallery'
3
+
4
+ export { useSwipe } from './useSwipe'
5
+ export type { UseSwipeOptions } from './useSwipe'
6
+
7
+ export { usePreloadImages, preloadImage, preloadImages } from './usePreloadImages'
8
+
9
+ export { useZoom } from './useZoom'
10
+ export type { ZoomState, UseZoomOptions, UseZoomReturn } from './useZoom'
11
+
12
+ export { usePinchZoom } from './usePinchZoom'
13
+ export type { PinchZoomState, UsePinchZoomOptions, UsePinchZoomReturn } from './usePinchZoom'
14
+
15
+ export { useVirtualList } from './useVirtualList'
16
+ export type { VirtualListOptions, VirtualListReturn, VirtualItem } from './useVirtualList'
17
+
18
+ export { useImageDimensions, clearDimensionsCache, getCachedDimensions } from './useImageDimensions'
19
+ export type {
20
+ ImageWithDimensions,
21
+ UseImageDimensionsResult,
22
+ UseImageDimensionsOptions,
23
+ } from './useImageDimensions'
@@ -0,0 +1,137 @@
1
+ 'use client'
2
+
3
+ import { useState, useCallback, useMemo, useEffect } from 'react'
4
+ import type { GalleryMediaItem, GalleryContextValue } from '../types'
5
+
6
+ export interface UseGalleryOptions {
7
+ /** Array of images */
8
+ images: GalleryMediaItem[]
9
+ /** Initial selected index */
10
+ initialIndex?: number
11
+ /** Loop navigation */
12
+ loop?: boolean
13
+ /** Callback when image changes */
14
+ onImageChange?: (index: number, image: GalleryMediaItem) => void
15
+ }
16
+
17
+ /**
18
+ * Hook for managing gallery state
19
+ */
20
+ export function useGallery({
21
+ images,
22
+ initialIndex = 0,
23
+ loop = true,
24
+ onImageChange,
25
+ }: UseGalleryOptions): GalleryContextValue {
26
+ const [currentIndex, setCurrentIndex] = useState(initialIndex)
27
+ const [isLightboxOpen, setIsLightboxOpen] = useState(false)
28
+ const [isZoomed, setIsZoomed] = useState(false)
29
+ const [isLoading, setIsLoading] = useState(false)
30
+
31
+ const total = images.length
32
+ const hasMultiple = total > 1
33
+ const currentImage = images[currentIndex] ?? null
34
+
35
+ // Reset state when images change
36
+ useEffect(() => {
37
+ if (currentIndex >= total) {
38
+ setCurrentIndex(Math.max(0, total - 1))
39
+ }
40
+ }, [total, currentIndex])
41
+
42
+ // Reset zoom when image changes
43
+ useEffect(() => {
44
+ setIsZoomed(false)
45
+ setIsLoading(true)
46
+ }, [currentIndex])
47
+
48
+ const goTo = useCallback(
49
+ (index: number) => {
50
+ if (index < 0 || index >= total) return
51
+ setCurrentIndex(index)
52
+ onImageChange?.(index, images[index]!)
53
+ },
54
+ [total, images, onImageChange]
55
+ )
56
+
57
+ const next = useCallback(() => {
58
+ if (!hasMultiple) return
59
+ const nextIndex = loop
60
+ ? (currentIndex + 1) % total
61
+ : Math.min(currentIndex + 1, total - 1)
62
+ if (nextIndex !== currentIndex) {
63
+ goTo(nextIndex)
64
+ }
65
+ }, [currentIndex, total, hasMultiple, loop, goTo])
66
+
67
+ const prev = useCallback(() => {
68
+ if (!hasMultiple) return
69
+ const prevIndex = loop
70
+ ? (currentIndex - 1 + total) % total
71
+ : Math.max(currentIndex - 1, 0)
72
+ if (prevIndex !== currentIndex) {
73
+ goTo(prevIndex)
74
+ }
75
+ }, [currentIndex, total, hasMultiple, loop, goTo])
76
+
77
+ const openLightbox = useCallback((index?: number) => {
78
+ if (index !== undefined) {
79
+ setCurrentIndex(index)
80
+ }
81
+ setIsLightboxOpen(true)
82
+ setIsZoomed(false)
83
+ }, [])
84
+
85
+ const closeLightbox = useCallback(() => {
86
+ setIsLightboxOpen(false)
87
+ setIsZoomed(false)
88
+ }, [])
89
+
90
+ const toggleZoom = useCallback(() => {
91
+ setIsZoomed((prev) => !prev)
92
+ }, [])
93
+
94
+ const setLoadingState = useCallback((loading: boolean) => {
95
+ setIsLoading(loading)
96
+ }, [])
97
+
98
+ return useMemo(
99
+ () => ({
100
+ // State
101
+ currentIndex,
102
+ isLightboxOpen,
103
+ isZoomed,
104
+ isLoading,
105
+ // Computed
106
+ images,
107
+ currentImage,
108
+ total,
109
+ hasMultiple,
110
+ // Actions
111
+ goTo,
112
+ next,
113
+ prev,
114
+ openLightbox,
115
+ closeLightbox,
116
+ toggleZoom,
117
+ setLoading: setLoadingState,
118
+ }),
119
+ [
120
+ currentIndex,
121
+ isLightboxOpen,
122
+ isZoomed,
123
+ isLoading,
124
+ images,
125
+ currentImage,
126
+ total,
127
+ hasMultiple,
128
+ goTo,
129
+ next,
130
+ prev,
131
+ openLightbox,
132
+ closeLightbox,
133
+ toggleZoom,
134
+ setLoadingState,
135
+ ]
136
+ )
137
+ }
@@ -0,0 +1,223 @@
1
+ 'use client'
2
+
3
+ import { useState, useEffect, useRef, useMemo } from 'react'
4
+ import type { GalleryMediaItem } from '../types'
5
+
6
+ /**
7
+ * Image with loaded dimensions
8
+ */
9
+ export interface ImageWithDimensions extends GalleryMediaItem {
10
+ width: number
11
+ height: number
12
+ loaded: boolean
13
+ }
14
+
15
+ /**
16
+ * Result of useImageDimensions hook
17
+ */
18
+ export interface UseImageDimensionsResult {
19
+ /** Images with dimensions (loaded or from props) */
20
+ images: ImageWithDimensions[]
21
+ /** Whether all images have dimensions */
22
+ isReady: boolean
23
+ /** Whether currently loading */
24
+ isLoading: boolean
25
+ /** Number of images loaded */
26
+ loadedCount: number
27
+ }
28
+
29
+ /**
30
+ * Options for useImageDimensions
31
+ */
32
+ export interface UseImageDimensionsOptions {
33
+ /** Only load dimensions for first N images */
34
+ maxToLoad?: number
35
+ /** Skip loading if dimensions already present */
36
+ skipIfPresent?: boolean
37
+ /** Timeout per image in ms */
38
+ timeout?: number
39
+ }
40
+
41
+ // Cache for loaded dimensions
42
+ const dimensionsCache = new Map<string, { width: number; height: number }>()
43
+
44
+ /**
45
+ * Load image dimensions
46
+ */
47
+ function loadImageDimensions(
48
+ src: string,
49
+ timeout: number = 5000
50
+ ): Promise<{ width: number; height: number }> {
51
+ // Check cache
52
+ const cached = dimensionsCache.get(src)
53
+ if (cached) return Promise.resolve(cached)
54
+
55
+ return new Promise((resolve, reject) => {
56
+ const img = new Image()
57
+ const timeoutId = setTimeout(() => {
58
+ reject(new Error('Timeout loading image'))
59
+ }, timeout)
60
+
61
+ img.onload = () => {
62
+ clearTimeout(timeoutId)
63
+ const dims = { width: img.naturalWidth, height: img.naturalHeight }
64
+ dimensionsCache.set(src, dims)
65
+ resolve(dims)
66
+ }
67
+
68
+ img.onerror = () => {
69
+ clearTimeout(timeoutId)
70
+ reject(new Error('Failed to load image'))
71
+ }
72
+
73
+ img.src = src
74
+ })
75
+ }
76
+
77
+ /**
78
+ * Hook to load image dimensions for gallery images
79
+ *
80
+ * Uses thumbnail URL when available (faster to load)
81
+ * Caches results to avoid reloading
82
+ */
83
+ export function useImageDimensions(
84
+ images: GalleryMediaItem[],
85
+ options: UseImageDimensionsOptions = {}
86
+ ): UseImageDimensionsResult {
87
+ const {
88
+ maxToLoad = 5,
89
+ skipIfPresent = true,
90
+ timeout = 5000,
91
+ } = options
92
+
93
+ // Create stable key from image IDs
94
+ const imageIds = useMemo(() => {
95
+ return images.slice(0, maxToLoad).map(img => img.id).join(',')
96
+ }, [images, maxToLoad])
97
+
98
+ // Track dimensions in a map by ID (stable across re-renders)
99
+ const dimensionsRef = useRef(new Map<string, { width: number; height: number; loaded: boolean }>())
100
+ const [version, setVersion] = useState(0) // Force re-render when dimensions load
101
+ const [isLoading, setIsLoading] = useState(true)
102
+ const loadingRef = useRef(false)
103
+ const prevImageIdsRef = useRef('')
104
+
105
+ // Initialize dimensions from props or cache
106
+ useEffect(() => {
107
+ const targetImages = images.slice(0, maxToLoad)
108
+ const idsChanged = prevImageIdsRef.current !== imageIds
109
+ prevImageIdsRef.current = imageIds
110
+
111
+ // Update dimensions map with any new images
112
+ for (const img of targetImages) {
113
+ if (!dimensionsRef.current.has(img.id)) {
114
+ // Check cache first
115
+ const src = img.thumbnail || img.src
116
+ const cached = dimensionsCache.get(src)
117
+
118
+ if (cached) {
119
+ dimensionsRef.current.set(img.id, { ...cached, loaded: true })
120
+ } else if (img.width && img.height && skipIfPresent) {
121
+ dimensionsRef.current.set(img.id, { width: img.width, height: img.height, loaded: true })
122
+ } else {
123
+ dimensionsRef.current.set(img.id, { width: 0, height: 0, loaded: false })
124
+ }
125
+ }
126
+ }
127
+
128
+ // Find images that need loading
129
+ const toLoad = targetImages.filter(img => {
130
+ const dims = dimensionsRef.current.get(img.id)
131
+ return !dims?.loaded
132
+ })
133
+
134
+ // Nothing to load
135
+ if (toLoad.length === 0) {
136
+ setIsLoading(false)
137
+ if (idsChanged) setVersion(v => v + 1)
138
+ return
139
+ }
140
+
141
+ // Already loading
142
+ if (loadingRef.current) return
143
+
144
+ loadingRef.current = true
145
+ setIsLoading(true)
146
+
147
+ // Load dimensions
148
+ Promise.all(
149
+ toLoad.map(async (img) => {
150
+ try {
151
+ const src = img.thumbnail || img.src
152
+ const dims = await loadImageDimensions(src, timeout)
153
+ dimensionsRef.current.set(img.id, { ...dims, loaded: true })
154
+ return { id: img.id, success: true }
155
+ } catch {
156
+ // Use default dimensions on error
157
+ dimensionsRef.current.set(img.id, { width: 1600, height: 900, loaded: true })
158
+ return { id: img.id, success: false }
159
+ }
160
+ })
161
+ ).then(() => {
162
+ loadingRef.current = false
163
+ setIsLoading(false)
164
+ setVersion(v => v + 1)
165
+ })
166
+ }, [imageIds, images, maxToLoad, skipIfPresent, timeout])
167
+
168
+ // Build result array
169
+ const result = useMemo((): ImageWithDimensions[] => {
170
+ return images.slice(0, maxToLoad).map(img => {
171
+ const dims = dimensionsRef.current.get(img.id)
172
+ return {
173
+ ...img,
174
+ width: dims?.width || img.width || 0,
175
+ height: dims?.height || img.height || 0,
176
+ loaded: dims?.loaded || false,
177
+ }
178
+ })
179
+ // eslint-disable-next-line react-hooks/exhaustive-deps
180
+ }, [imageIds, version])
181
+
182
+ // Also return all images (not just maxToLoad) with available dimensions
183
+ const allImages = useMemo((): ImageWithDimensions[] => {
184
+ return images.map(img => {
185
+ const dims = dimensionsRef.current.get(img.id)
186
+ return {
187
+ ...img,
188
+ width: dims?.width || img.width || 0,
189
+ height: dims?.height || img.height || 0,
190
+ loaded: dims?.loaded || Boolean(img.width && img.height),
191
+ }
192
+ })
193
+ // eslint-disable-next-line react-hooks/exhaustive-deps
194
+ }, [images, version])
195
+
196
+ const loadedCount = result.filter(img => img.loaded).length
197
+ const targetCount = Math.min(images.length, maxToLoad)
198
+
199
+ const isReady = !isLoading && loadedCount >= targetCount
200
+
201
+ return {
202
+ images: allImages,
203
+ isReady,
204
+ isLoading,
205
+ loadedCount,
206
+ }
207
+ }
208
+
209
+ /**
210
+ * Clear dimensions cache
211
+ */
212
+ export function clearDimensionsCache(): void {
213
+ dimensionsCache.clear()
214
+ }
215
+
216
+ /**
217
+ * Get cached dimensions for an image
218
+ */
219
+ export function getCachedDimensions(
220
+ src: string
221
+ ): { width: number; height: number } | null {
222
+ return dimensionsCache.get(src) || null
223
+ }