@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,224 @@
1
+ 'use client'
2
+
3
+ import { useEffect, useMemo, useRef, useState } from 'react';
4
+
5
+ import type { GalleryMediaItem } from '../types'
6
+
7
+ /**
8
+ * Image with loaded dimensions
9
+ */
10
+ export interface ImageWithDimensions extends GalleryMediaItem {
11
+ width: number
12
+ height: number
13
+ loaded: boolean
14
+ }
15
+
16
+ /**
17
+ * Result of useImageDimensions hook
18
+ */
19
+ export interface UseImageDimensionsResult {
20
+ /** Images with dimensions (loaded or from props) */
21
+ images: ImageWithDimensions[]
22
+ /** Whether all images have dimensions */
23
+ isReady: boolean
24
+ /** Whether currently loading */
25
+ isLoading: boolean
26
+ /** Number of images loaded */
27
+ loadedCount: number
28
+ }
29
+
30
+ /**
31
+ * Options for useImageDimensions
32
+ */
33
+ export interface UseImageDimensionsOptions {
34
+ /** Only load dimensions for first N images */
35
+ maxToLoad?: number
36
+ /** Skip loading if dimensions already present */
37
+ skipIfPresent?: boolean
38
+ /** Timeout per image in ms */
39
+ timeout?: number
40
+ }
41
+
42
+ // Cache for loaded dimensions
43
+ const dimensionsCache = new Map<string, { width: number; height: number }>()
44
+
45
+ /**
46
+ * Load image dimensions
47
+ */
48
+ function loadImageDimensions(
49
+ src: string,
50
+ timeout: number = 5000
51
+ ): Promise<{ width: number; height: number }> {
52
+ // Check cache
53
+ const cached = dimensionsCache.get(src)
54
+ if (cached) return Promise.resolve(cached)
55
+
56
+ return new Promise((resolve, reject) => {
57
+ const img = new Image()
58
+ const timeoutId = setTimeout(() => {
59
+ reject(new Error('Timeout loading image'))
60
+ }, timeout)
61
+
62
+ img.onload = () => {
63
+ clearTimeout(timeoutId)
64
+ const dims = { width: img.naturalWidth, height: img.naturalHeight }
65
+ dimensionsCache.set(src, dims)
66
+ resolve(dims)
67
+ }
68
+
69
+ img.onerror = () => {
70
+ clearTimeout(timeoutId)
71
+ reject(new Error('Failed to load image'))
72
+ }
73
+
74
+ img.src = src
75
+ })
76
+ }
77
+
78
+ /**
79
+ * Hook to load image dimensions for gallery images
80
+ *
81
+ * Uses thumbnail URL when available (faster to load)
82
+ * Caches results to avoid reloading
83
+ */
84
+ export function useImageDimensions(
85
+ images: GalleryMediaItem[],
86
+ options: UseImageDimensionsOptions = {}
87
+ ): UseImageDimensionsResult {
88
+ const {
89
+ maxToLoad = 5,
90
+ skipIfPresent = true,
91
+ timeout = 5000,
92
+ } = options
93
+
94
+ // Create stable key from image IDs
95
+ const imageIds = useMemo(() => {
96
+ return images.slice(0, maxToLoad).map(img => img.id).join(',')
97
+ }, [images, maxToLoad])
98
+
99
+ // Track dimensions in a map by ID (stable across re-renders)
100
+ const dimensionsRef = useRef(new Map<string, { width: number; height: number; loaded: boolean }>())
101
+ const [version, setVersion] = useState(0) // Force re-render when dimensions load
102
+ const [isLoading, setIsLoading] = useState(true)
103
+ const loadingRef = useRef(false)
104
+ const prevImageIdsRef = useRef('')
105
+
106
+ // Initialize dimensions from props or cache
107
+ useEffect(() => {
108
+ const targetImages = images.slice(0, maxToLoad)
109
+ const idsChanged = prevImageIdsRef.current !== imageIds
110
+ prevImageIdsRef.current = imageIds
111
+
112
+ // Update dimensions map with any new images
113
+ for (const img of targetImages) {
114
+ if (!dimensionsRef.current.has(img.id)) {
115
+ // Check cache first
116
+ const src = img.thumbnail || img.src
117
+ const cached = dimensionsCache.get(src)
118
+
119
+ if (cached) {
120
+ dimensionsRef.current.set(img.id, { ...cached, loaded: true })
121
+ } else if (img.width && img.height && skipIfPresent) {
122
+ dimensionsRef.current.set(img.id, { width: img.width, height: img.height, loaded: true })
123
+ } else {
124
+ dimensionsRef.current.set(img.id, { width: 0, height: 0, loaded: false })
125
+ }
126
+ }
127
+ }
128
+
129
+ // Find images that need loading
130
+ const toLoad = targetImages.filter(img => {
131
+ const dims = dimensionsRef.current.get(img.id)
132
+ return !dims?.loaded
133
+ })
134
+
135
+ // Nothing to load
136
+ if (toLoad.length === 0) {
137
+ setIsLoading(false)
138
+ if (idsChanged) setVersion(v => v + 1)
139
+ return
140
+ }
141
+
142
+ // Already loading
143
+ if (loadingRef.current) return
144
+
145
+ loadingRef.current = true
146
+ setIsLoading(true)
147
+
148
+ // Load dimensions
149
+ Promise.all(
150
+ toLoad.map(async (img) => {
151
+ try {
152
+ const src = img.thumbnail || img.src
153
+ const dims = await loadImageDimensions(src, timeout)
154
+ dimensionsRef.current.set(img.id, { ...dims, loaded: true })
155
+ return { id: img.id, success: true }
156
+ } catch {
157
+ // Use default dimensions on error
158
+ dimensionsRef.current.set(img.id, { width: 1600, height: 900, loaded: true })
159
+ return { id: img.id, success: false }
160
+ }
161
+ })
162
+ ).then(() => {
163
+ loadingRef.current = false
164
+ setIsLoading(false)
165
+ setVersion(v => v + 1)
166
+ })
167
+ }, [imageIds, images, maxToLoad, skipIfPresent, timeout])
168
+
169
+ // Build result array
170
+ const result = useMemo((): ImageWithDimensions[] => {
171
+ return images.slice(0, maxToLoad).map(img => {
172
+ const dims = dimensionsRef.current.get(img.id)
173
+ return {
174
+ ...img,
175
+ width: dims?.width || img.width || 0,
176
+ height: dims?.height || img.height || 0,
177
+ loaded: dims?.loaded || false,
178
+ }
179
+ })
180
+ // eslint-disable-next-line react-hooks/exhaustive-deps
181
+ }, [imageIds, version])
182
+
183
+ // Also return all images (not just maxToLoad) with available dimensions
184
+ const allImages = useMemo((): ImageWithDimensions[] => {
185
+ return images.map(img => {
186
+ const dims = dimensionsRef.current.get(img.id)
187
+ return {
188
+ ...img,
189
+ width: dims?.width || img.width || 0,
190
+ height: dims?.height || img.height || 0,
191
+ loaded: dims?.loaded || Boolean(img.width && img.height),
192
+ }
193
+ })
194
+ // eslint-disable-next-line react-hooks/exhaustive-deps
195
+ }, [images, version])
196
+
197
+ const loadedCount = result.filter(img => img.loaded).length
198
+ const targetCount = Math.min(images.length, maxToLoad)
199
+
200
+ const isReady = !isLoading && loadedCount >= targetCount
201
+
202
+ return {
203
+ images: allImages,
204
+ isReady,
205
+ isLoading,
206
+ loadedCount,
207
+ }
208
+ }
209
+
210
+ /**
211
+ * Clear dimensions cache
212
+ */
213
+ export function clearDimensionsCache(): void {
214
+ dimensionsCache.clear()
215
+ }
216
+
217
+ /**
218
+ * Get cached dimensions for an image
219
+ */
220
+ export function getCachedDimensions(
221
+ src: string
222
+ ): { width: number; height: number } | null {
223
+ return dimensionsCache.get(src) || null
224
+ }
@@ -0,0 +1,239 @@
1
+ 'use client'
2
+
3
+ import { useCallback, useRef, useState } from 'react';
4
+
5
+ export interface PinchZoomState {
6
+ scale: number
7
+ x: number
8
+ y: number
9
+ }
10
+
11
+ export interface UsePinchZoomOptions {
12
+ minScale?: number
13
+ maxScale?: number
14
+ onZoomChange?: (zoomed: boolean) => void
15
+ }
16
+
17
+ export interface UsePinchZoomReturn {
18
+ state: PinchZoomState
19
+ isZoomed: boolean
20
+ handlers: {
21
+ onTouchStart: (e: React.TouchEvent) => void
22
+ onTouchMove: (e: React.TouchEvent) => void
23
+ onTouchEnd: (e: React.TouchEvent) => void
24
+ }
25
+ reset: () => void
26
+ zoomTo: (scale: number, x?: number, y?: number) => void
27
+ }
28
+
29
+ const INITIAL_STATE: PinchZoomState = { scale: 1, x: 0, y: 0 }
30
+
31
+ /**
32
+ * Hook for pinch-to-zoom functionality on touch devices
33
+ */
34
+ export function usePinchZoom(options: UsePinchZoomOptions = {}): UsePinchZoomReturn {
35
+ const { minScale = 1, maxScale = 3, onZoomChange } = options
36
+
37
+ const [state, setState] = useState<PinchZoomState>(INITIAL_STATE)
38
+
39
+ // Track touch state and container size
40
+ const touchRef = useRef<{
41
+ initialDistance: number
42
+ initialScale: number
43
+ initialX: number
44
+ initialY: number
45
+ centerX: number
46
+ centerY: number
47
+ isPinching: boolean
48
+ lastTap: number
49
+ containerWidth: number
50
+ containerHeight: number
51
+ }>({
52
+ initialDistance: 0,
53
+ initialScale: 1,
54
+ initialX: 0,
55
+ initialY: 0,
56
+ centerX: 0,
57
+ centerY: 0,
58
+ isPinching: false,
59
+ lastTap: 0,
60
+ containerWidth: 0,
61
+ containerHeight: 0,
62
+ })
63
+
64
+ const isZoomed = state.scale > 1.05
65
+
66
+ // Calculate distance between two touch points
67
+ const getDistance = useCallback((touches: React.TouchList): number => {
68
+ const a = touches[0]
69
+ const b = touches[1]
70
+ if (!a || !b) return 0
71
+ const dx = a.clientX - b.clientX
72
+ const dy = a.clientY - b.clientY
73
+ return Math.sqrt(dx * dx + dy * dy)
74
+ }, [])
75
+
76
+ // Get center point between two touches
77
+ const getCenter = useCallback((touches: React.TouchList): { x: number; y: number } => {
78
+ const a = touches[0]
79
+ const b = touches[1]
80
+ if (!a) return { x: 0, y: 0 }
81
+ if (!b) return { x: a.clientX, y: a.clientY }
82
+ return {
83
+ x: (a.clientX + b.clientX) / 2,
84
+ y: (a.clientY + b.clientY) / 2,
85
+ }
86
+ }, [])
87
+
88
+ // Calculate max pan based on container size and scale
89
+ const getMaxPan = useCallback((scale: number) => {
90
+ const { containerWidth, containerHeight } = touchRef.current
91
+ // When zoomed, allow panning up to half the "extra" size
92
+ // At 2x zoom on 400px container: can pan 200px in each direction
93
+ return {
94
+ x: (containerWidth * (scale - 1)) / 2,
95
+ y: (containerHeight * (scale - 1)) / 2,
96
+ }
97
+ }, [])
98
+
99
+ const handleTouchStart = useCallback((e: React.TouchEvent) => {
100
+ const touches = e.touches
101
+
102
+ // Store container dimensions from the element
103
+ const rect = e.currentTarget.getBoundingClientRect()
104
+ touchRef.current.containerWidth = rect.width
105
+ touchRef.current.containerHeight = rect.height
106
+
107
+ // Double tap to zoom
108
+ const touch0 = touches[0]
109
+ if (touches.length === 1 && touch0) {
110
+ const now = Date.now()
111
+ const timeSinceLastTap = now - touchRef.current.lastTap
112
+
113
+ if (timeSinceLastTap < 300) {
114
+ // Double tap detected
115
+ e.preventDefault()
116
+ if (isZoomed) {
117
+ setState(INITIAL_STATE)
118
+ onZoomChange?.(false)
119
+ } else {
120
+ const x = touch0.clientX - rect.left - rect.width / 2
121
+ const y = touch0.clientY - rect.top - rect.height / 2
122
+ setState({ scale: 2, x: -x * 0.5, y: -y * 0.5 })
123
+ onZoomChange?.(true)
124
+ }
125
+ touchRef.current.lastTap = 0
126
+ return
127
+ }
128
+
129
+ touchRef.current.lastTap = now
130
+
131
+ // Single finger - start pan (if zoomed)
132
+ if (isZoomed) {
133
+ touchRef.current.initialX = touch0.clientX - state.x
134
+ touchRef.current.initialY = touch0.clientY - state.y
135
+ }
136
+ }
137
+
138
+ // Two finger pinch
139
+ if (touches.length === 2) {
140
+ e.preventDefault()
141
+ touchRef.current.isPinching = true
142
+ touchRef.current.initialDistance = getDistance(touches)
143
+ touchRef.current.initialScale = state.scale
144
+
145
+ const center = getCenter(touches)
146
+ touchRef.current.centerX = center.x
147
+ touchRef.current.centerY = center.y
148
+ touchRef.current.initialX = state.x
149
+ touchRef.current.initialY = state.y
150
+ }
151
+ }, [isZoomed, state, getDistance, getCenter, onZoomChange])
152
+
153
+ const handleTouchMove = useCallback((e: React.TouchEvent) => {
154
+ const touches = e.touches
155
+
156
+ // Pinch zoom
157
+ if (touches.length === 2 && touchRef.current.isPinching) {
158
+ e.preventDefault()
159
+
160
+ const currentDistance = getDistance(touches)
161
+ const scaleDelta = currentDistance / touchRef.current.initialDistance
162
+ let newScale = touchRef.current.initialScale * scaleDelta
163
+
164
+ // Clamp scale
165
+ newScale = Math.max(minScale, Math.min(maxScale, newScale))
166
+
167
+ // Calculate new position to zoom toward center
168
+ const center = getCenter(touches)
169
+ const dx = center.x - touchRef.current.centerX
170
+ const dy = center.y - touchRef.current.centerY
171
+
172
+ // Apply pan limits
173
+ const maxPan = getMaxPan(newScale)
174
+ const newX = Math.max(-maxPan.x, Math.min(maxPan.x, touchRef.current.initialX + dx))
175
+ const newY = Math.max(-maxPan.y, Math.min(maxPan.y, touchRef.current.initialY + dy))
176
+
177
+ setState({
178
+ scale: newScale,
179
+ x: newX,
180
+ y: newY,
181
+ })
182
+
183
+ if (newScale > 1.05 !== isZoomed) {
184
+ onZoomChange?.(newScale > 1.05)
185
+ }
186
+ }
187
+
188
+ // Pan when zoomed (single finger)
189
+ const panTouch = touches[0]
190
+ if (touches.length === 1 && panTouch && isZoomed && !touchRef.current.isPinching) {
191
+ e.preventDefault()
192
+
193
+ const maxPan = getMaxPan(state.scale)
194
+ const newX = panTouch.clientX - touchRef.current.initialX
195
+ const newY = panTouch.clientY - touchRef.current.initialY
196
+
197
+ setState((prev) => ({
198
+ ...prev,
199
+ x: Math.max(-maxPan.x, Math.min(maxPan.x, newX)),
200
+ y: Math.max(-maxPan.y, Math.min(maxPan.y, newY)),
201
+ }))
202
+ }
203
+ }, [isZoomed, state.scale, minScale, maxScale, getDistance, getCenter, getMaxPan, onZoomChange])
204
+
205
+ const handleTouchEnd = useCallback((e: React.TouchEvent) => {
206
+ if (e.touches.length < 2) {
207
+ touchRef.current.isPinching = false
208
+ }
209
+
210
+ // Snap to 1 if close
211
+ if (state.scale < 1.1 && state.scale > 0.9) {
212
+ setState(INITIAL_STATE)
213
+ onZoomChange?.(false)
214
+ }
215
+ }, [state.scale, onZoomChange])
216
+
217
+ const reset = useCallback(() => {
218
+ setState(INITIAL_STATE)
219
+ onZoomChange?.(false)
220
+ }, [onZoomChange])
221
+
222
+ const zoomTo = useCallback((scale: number, x = 0, y = 0) => {
223
+ const clampedScale = Math.max(minScale, Math.min(maxScale, scale))
224
+ setState({ scale: clampedScale, x, y })
225
+ onZoomChange?.(clampedScale > 1.05)
226
+ }, [minScale, maxScale, onZoomChange])
227
+
228
+ return {
229
+ state,
230
+ isZoomed,
231
+ handlers: {
232
+ onTouchStart: handleTouchStart,
233
+ onTouchMove: handleTouchMove,
234
+ onTouchEnd: handleTouchEnd,
235
+ },
236
+ reset,
237
+ zoomTo,
238
+ }
239
+ }
@@ -0,0 +1,119 @@
1
+ 'use client'
2
+
3
+ import { useEffect, useRef } from 'react';
4
+
5
+ import type { GalleryMediaItem } from '../types'
6
+ import { normalizeImageUrl } from '../utils'
7
+
8
+ /** Max number of URLs to remember as "already preloaded" before evicting oldest */
9
+ const MAX_PRELOAD_MEMORY = 200
10
+
11
+ /**
12
+ * Preload adjacent images for smoother navigation
13
+ * Preloads prev and next images when current index changes
14
+ */
15
+ export function usePreloadImages(
16
+ images: GalleryMediaItem[],
17
+ currentIndex: number,
18
+ preloadCount: number = 1
19
+ ): void {
20
+ // Insertion-ordered Set, used as a bounded LRU to avoid unbounded growth
21
+ const preloadedRef = useRef<Set<string>>(new Set())
22
+
23
+ useEffect(() => {
24
+ if (images.length === 0) return
25
+
26
+ const toPreload: string[] = []
27
+
28
+ // Collect indices to preload (current + adjacent)
29
+ for (let offset = -preloadCount; offset <= preloadCount; offset++) {
30
+ if (offset === 0) continue // Skip current, it's already loading
31
+
32
+ const index = (currentIndex + offset + images.length) % images.length
33
+ const image = images[index]
34
+ if (!image) continue
35
+
36
+ // Videos are not preloadable via Image(); skip them
37
+ if (image.type === 'video') continue
38
+
39
+ const src = normalizeImageUrl(image.src)
40
+ if (src && !preloadedRef.current.has(src)) {
41
+ toPreload.push(src)
42
+ }
43
+ }
44
+
45
+ if (toPreload.length === 0) return
46
+
47
+ const memory = preloadedRef.current
48
+ const loaders: HTMLImageElement[] = []
49
+
50
+ toPreload.forEach((src) => {
51
+ const img = new Image()
52
+ // Release the loader once finished so it can be GC'd
53
+ const cleanup = () => {
54
+ img.onload = null
55
+ img.onerror = null
56
+ }
57
+ img.onload = cleanup
58
+ img.onerror = cleanup
59
+ img.src = src
60
+ loaders.push(img)
61
+
62
+ memory.add(src)
63
+ // Evict oldest entries to keep the Set bounded
64
+ if (memory.size > MAX_PRELOAD_MEMORY) {
65
+ const oldest = memory.values().next().value
66
+ if (oldest !== undefined) memory.delete(oldest)
67
+ }
68
+ })
69
+
70
+ // Cancel in-flight loaders if the effect re-runs before they settle
71
+ return () => {
72
+ loaders.forEach((img) => {
73
+ img.onload = null
74
+ img.onerror = null
75
+ img.src = ''
76
+ })
77
+ }
78
+ }, [images, currentIndex, preloadCount])
79
+ }
80
+
81
+ /**
82
+ * Preload specific image URLs
83
+ */
84
+ export function preloadImage(src: string): Promise<void> {
85
+ return new Promise((resolve, reject) => {
86
+ const img = new Image()
87
+ const cleanup = () => {
88
+ img.onload = null
89
+ img.onerror = null
90
+ }
91
+ img.onload = () => {
92
+ cleanup()
93
+ resolve()
94
+ }
95
+ img.onerror = () => {
96
+ cleanup()
97
+ reject(new Error(`Failed to preload image: ${src}`))
98
+ }
99
+ img.src = normalizeImageUrl(src)
100
+ })
101
+ }
102
+
103
+ /**
104
+ * Preload multiple images with optional concurrency limit
105
+ */
106
+ export async function preloadImages(
107
+ urls: string[],
108
+ concurrency: number = 3
109
+ ): Promise<void> {
110
+ const chunks: string[][] = []
111
+
112
+ for (let i = 0; i < urls.length; i += concurrency) {
113
+ chunks.push(urls.slice(i, i + concurrency))
114
+ }
115
+
116
+ for (const chunk of chunks) {
117
+ await Promise.allSettled(chunk.map(preloadImage))
118
+ }
119
+ }
@@ -0,0 +1,87 @@
1
+ 'use client'
2
+
3
+ import { useCallback, useState } from 'react';
4
+
5
+ import type { TouchEvent } from 'react'
6
+
7
+ export interface UseSwipeOptions {
8
+ /** Minimum swipe distance in pixels */
9
+ minDistance?: number
10
+ /** Callback when swiped left */
11
+ onSwipeLeft?: () => void
12
+ /** Callback when swiped right */
13
+ onSwipeRight?: () => void
14
+ /** Callback when swiped up */
15
+ onSwipeUp?: () => void
16
+ /** Callback when swiped down */
17
+ onSwipeDown?: () => void
18
+ }
19
+
20
+ interface TouchPosition {
21
+ x: number
22
+ y: number
23
+ }
24
+
25
+ /**
26
+ * Hook for handling swipe gestures
27
+ */
28
+ export function useSwipe({
29
+ minDistance = 50,
30
+ onSwipeLeft,
31
+ onSwipeRight,
32
+ onSwipeUp,
33
+ onSwipeDown,
34
+ }: UseSwipeOptions = {}) {
35
+ const [touchStart, setTouchStart] = useState<TouchPosition | null>(null)
36
+ const [touchEnd, setTouchEnd] = useState<TouchPosition | null>(null)
37
+
38
+ const handleTouchStart = useCallback((e: TouchEvent) => {
39
+ setTouchEnd(null)
40
+ const touch = e.targetTouches[0]
41
+ if (touch) {
42
+ setTouchStart({ x: touch.clientX, y: touch.clientY })
43
+ }
44
+ }, [])
45
+
46
+ const handleTouchMove = useCallback((e: TouchEvent) => {
47
+ const touch = e.targetTouches[0]
48
+ if (touch) {
49
+ setTouchEnd({ x: touch.clientX, y: touch.clientY })
50
+ }
51
+ }, [])
52
+
53
+ const handleTouchEnd = useCallback(() => {
54
+ if (!touchStart || !touchEnd) return
55
+
56
+ const deltaX = touchStart.x - touchEnd.x
57
+ const deltaY = touchStart.y - touchEnd.y
58
+ const absX = Math.abs(deltaX)
59
+ const absY = Math.abs(deltaY)
60
+
61
+ // Determine if horizontal or vertical swipe
62
+ if (absX > absY && absX > minDistance) {
63
+ // Horizontal swipe
64
+ if (deltaX > 0) {
65
+ onSwipeLeft?.()
66
+ } else {
67
+ onSwipeRight?.()
68
+ }
69
+ } else if (absY > absX && absY > minDistance) {
70
+ // Vertical swipe
71
+ if (deltaY > 0) {
72
+ onSwipeUp?.()
73
+ } else {
74
+ onSwipeDown?.()
75
+ }
76
+ }
77
+
78
+ setTouchStart(null)
79
+ setTouchEnd(null)
80
+ }, [touchStart, touchEnd, minDistance, onSwipeLeft, onSwipeRight, onSwipeUp, onSwipeDown])
81
+
82
+ return {
83
+ onTouchStart: handleTouchStart,
84
+ onTouchMove: handleTouchMove,
85
+ onTouchEnd: handleTouchEnd,
86
+ }
87
+ }