@djangocfg/ui-tools 2.1.116 → 2.1.119
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/package.json +16 -6
- package/src/tools/Gallery/components/Gallery.tsx +3 -3
- package/src/tools/Gallery/components/compact/GalleryCompact.tsx +298 -0
- package/src/tools/Gallery/components/compact/index.ts +1 -0
- package/src/tools/Gallery/components/index.ts +17 -12
- package/src/tools/Gallery/components/{GalleryLightbox.tsx → lightbox/GalleryLightbox.tsx} +6 -6
- package/src/tools/Gallery/components/lightbox/index.ts +1 -0
- package/src/tools/Gallery/components/{GalleryImage.tsx → media/GalleryImage.tsx} +1 -1
- package/src/tools/Gallery/components/{GalleryMedia.tsx → media/GalleryMedia.tsx} +1 -1
- package/src/tools/Gallery/components/{GalleryVideo.tsx → media/GalleryVideo.tsx} +1 -1
- package/src/tools/Gallery/components/media/index.ts +3 -0
- package/src/tools/Gallery/components/{GalleryCarousel.tsx → preview/GalleryCarousel.tsx} +32 -6
- package/src/tools/Gallery/components/{GalleryGrid.tsx → preview/GalleryGrid.tsx} +1 -1
- package/src/tools/Gallery/components/preview/index.ts +2 -0
- package/src/tools/Gallery/components/{GalleryThumbnails.tsx → thumbnails/GalleryThumbnails.tsx} +1 -1
- package/src/tools/Gallery/components/{GalleryThumbnailsVirtual.tsx → thumbnails/GalleryThumbnailsVirtual.tsx} +2 -2
- package/src/tools/Gallery/components/thumbnails/index.ts +2 -0
- package/src/tools/Gallery/components/GalleryCompact.tsx +0 -173
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@djangocfg/ui-tools",
|
|
3
|
-
"version": "2.1.
|
|
3
|
+
"version": "2.1.119",
|
|
4
4
|
"description": "Heavy React tools with lazy loading - for Electron, Vite, CRA, Next.js apps",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"ui-tools",
|
|
@@ -58,11 +58,12 @@
|
|
|
58
58
|
"build": "tsup",
|
|
59
59
|
"clean": "rm -rf dist",
|
|
60
60
|
"dev": "tsup --watch",
|
|
61
|
+
"playground": "vite --config playground/vite.config.ts",
|
|
61
62
|
"check": "tsc --noEmit"
|
|
62
63
|
},
|
|
63
64
|
"peerDependencies": {
|
|
64
|
-
"@djangocfg/i18n": "^2.1.
|
|
65
|
-
"@djangocfg/ui-core": "^2.1.
|
|
65
|
+
"@djangocfg/i18n": "^2.1.119",
|
|
66
|
+
"@djangocfg/ui-core": "^2.1.119",
|
|
66
67
|
"lucide-react": "^0.545.0",
|
|
67
68
|
"react": "^19.0.0",
|
|
68
69
|
"react-dom": "^19.0.0",
|
|
@@ -94,14 +95,23 @@
|
|
|
94
95
|
"@maplibre/maplibre-gl-geocoder": "^1.7.0"
|
|
95
96
|
},
|
|
96
97
|
"devDependencies": {
|
|
97
|
-
"@djangocfg/i18n": "^2.1.
|
|
98
|
-
"@djangocfg/typescript-config": "^2.1.
|
|
98
|
+
"@djangocfg/i18n": "^2.1.119",
|
|
99
|
+
"@djangocfg/typescript-config": "^2.1.119",
|
|
100
|
+
"@djangocfg/ui-core": "^2.1.119",
|
|
101
|
+
"@tailwindcss/postcss": "^4.0.0",
|
|
99
102
|
"@types/mapbox__mapbox-gl-draw": "^1.4.8",
|
|
100
103
|
"@types/node": "^24.7.2",
|
|
101
104
|
"@types/react": "^19.1.0",
|
|
102
105
|
"@types/react-dom": "^19.1.0",
|
|
106
|
+
"@vitejs/plugin-react": "^4.3.0",
|
|
107
|
+
"lucide-react": "^0.545.0",
|
|
108
|
+
"react": "^19.0.0",
|
|
109
|
+
"react-dom": "^19.0.0",
|
|
110
|
+
"tailwindcss": "^4.0.0",
|
|
103
111
|
"tsup": "^8.5.0",
|
|
104
|
-
"typescript": "^5.9.3"
|
|
112
|
+
"typescript": "^5.9.3",
|
|
113
|
+
"vite": "^6.3.0",
|
|
114
|
+
"autoprefixer": "^10.4.20"
|
|
105
115
|
},
|
|
106
116
|
"publishConfig": {
|
|
107
117
|
"access": "public"
|
|
@@ -5,13 +5,13 @@ import { cn } from '@djangocfg/ui-core/lib'
|
|
|
5
5
|
import { useTypedT, type I18nTranslations } from '@djangocfg/i18n'
|
|
6
6
|
import { ImageOff } from 'lucide-react'
|
|
7
7
|
import { useGallery } from '../hooks/useGallery'
|
|
8
|
-
import { GalleryGrid } from './
|
|
9
|
-
import { GalleryCarousel } from './
|
|
8
|
+
import { GalleryGrid } from './preview'
|
|
9
|
+
import { GalleryCarousel } from './preview'
|
|
10
10
|
import type { GalleryProps } from '../types'
|
|
11
11
|
|
|
12
12
|
// Lazy load lightbox - only loaded when user opens it
|
|
13
13
|
const GalleryLightbox = lazy(() =>
|
|
14
|
-
import('./
|
|
14
|
+
import('./lightbox').then((mod) => ({ default: mod.GalleryLightbox }))
|
|
15
15
|
)
|
|
16
16
|
|
|
17
17
|
/**
|
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { memo, useCallback, useEffect, useState, useMemo } from 'react'
|
|
4
|
+
import { cn } from '@djangocfg/ui-core/lib'
|
|
5
|
+
import {
|
|
6
|
+
Carousel,
|
|
7
|
+
CarouselContent,
|
|
8
|
+
CarouselItem,
|
|
9
|
+
type CarouselApi,
|
|
10
|
+
} from '@djangocfg/ui-core/components'
|
|
11
|
+
import { ChevronLeft, ChevronRight, ImageOff } from 'lucide-react'
|
|
12
|
+
import { GalleryMedia } from '../media'
|
|
13
|
+
import type { GalleryMediaItem } from '../../types'
|
|
14
|
+
|
|
15
|
+
export interface GalleryCompactProps {
|
|
16
|
+
/** Array of images to display */
|
|
17
|
+
images: GalleryMediaItem[]
|
|
18
|
+
/** Show dots indicator (default: true) */
|
|
19
|
+
showDots?: boolean
|
|
20
|
+
/** Max dots to show (default: 5) */
|
|
21
|
+
maxDots?: number
|
|
22
|
+
/** Show counter badge (default: false) */
|
|
23
|
+
showCounter?: boolean
|
|
24
|
+
/** Show navigation arrows on hover (default: true) */
|
|
25
|
+
showArrows?: boolean
|
|
26
|
+
/** Enable zoom effect on hover (default: true) */
|
|
27
|
+
enableZoom?: boolean
|
|
28
|
+
/** On image click callback */
|
|
29
|
+
onClick?: () => void
|
|
30
|
+
/** Additional CSS class */
|
|
31
|
+
className?: string
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* GalleryCompact - Minimal carousel for property/vehicle cards
|
|
36
|
+
*
|
|
37
|
+
* Features:
|
|
38
|
+
* - Simple swipe carousel
|
|
39
|
+
* - Dots indicator (mobile-style)
|
|
40
|
+
* - Navigation arrows on hover (desktop)
|
|
41
|
+
* - Hover zoom effect on images
|
|
42
|
+
* - Lazy loading - only loads active image + neighbors
|
|
43
|
+
* - Fills parent container
|
|
44
|
+
* - Stops event propagation on navigation
|
|
45
|
+
*/
|
|
46
|
+
export const GalleryCompact = memo(function GalleryCompact({
|
|
47
|
+
images,
|
|
48
|
+
showDots = true,
|
|
49
|
+
maxDots = 5,
|
|
50
|
+
showCounter = false,
|
|
51
|
+
showArrows = true,
|
|
52
|
+
enableZoom = true,
|
|
53
|
+
onClick,
|
|
54
|
+
className,
|
|
55
|
+
}: GalleryCompactProps) {
|
|
56
|
+
const [api, setApi] = useState<CarouselApi>()
|
|
57
|
+
const [currentIndex, setCurrentIndex] = useState(0)
|
|
58
|
+
const [isHovered, setIsHovered] = useState(false)
|
|
59
|
+
|
|
60
|
+
const total = images.length
|
|
61
|
+
const hasMultiple = total > 1
|
|
62
|
+
|
|
63
|
+
// Determine which images should be loaded (current + neighbors for smooth transition)
|
|
64
|
+
const loadedIndices = useMemo(() => {
|
|
65
|
+
if (total === 0) return new Set<number>()
|
|
66
|
+
const indices = new Set<number>()
|
|
67
|
+
// Always load first image
|
|
68
|
+
indices.add(0)
|
|
69
|
+
// Load current
|
|
70
|
+
indices.add(currentIndex)
|
|
71
|
+
// Load neighbors for smooth transitions
|
|
72
|
+
if (hasMultiple) {
|
|
73
|
+
indices.add((currentIndex - 1 + total) % total)
|
|
74
|
+
indices.add((currentIndex + 1) % total)
|
|
75
|
+
}
|
|
76
|
+
return indices
|
|
77
|
+
}, [currentIndex, total, hasMultiple])
|
|
78
|
+
|
|
79
|
+
// Listen to carousel changes
|
|
80
|
+
useEffect(() => {
|
|
81
|
+
if (!api) return
|
|
82
|
+
|
|
83
|
+
const onSelect = () => {
|
|
84
|
+
setCurrentIndex(api.selectedScrollSnap())
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
api.on('select', onSelect)
|
|
88
|
+
return () => {
|
|
89
|
+
api.off('select', onSelect)
|
|
90
|
+
}
|
|
91
|
+
}, [api])
|
|
92
|
+
|
|
93
|
+
// Navigation handlers
|
|
94
|
+
const handlePrev = useCallback(
|
|
95
|
+
(e: React.MouseEvent) => {
|
|
96
|
+
e.preventDefault()
|
|
97
|
+
e.stopPropagation()
|
|
98
|
+
api?.scrollPrev()
|
|
99
|
+
},
|
|
100
|
+
[api]
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
const handleNext = useCallback(
|
|
104
|
+
(e: React.MouseEvent) => {
|
|
105
|
+
e.preventDefault()
|
|
106
|
+
e.stopPropagation()
|
|
107
|
+
api?.scrollNext()
|
|
108
|
+
},
|
|
109
|
+
[api]
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
// Dot click handler
|
|
113
|
+
const handleDotClick = useCallback(
|
|
114
|
+
(e: React.MouseEvent, index: number) => {
|
|
115
|
+
e.preventDefault()
|
|
116
|
+
e.stopPropagation()
|
|
117
|
+
api?.scrollTo(index)
|
|
118
|
+
},
|
|
119
|
+
[api]
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
// Handle container click
|
|
123
|
+
const handleClick = useCallback(
|
|
124
|
+
(e: React.MouseEvent) => {
|
|
125
|
+
// Don't trigger if clicking on navigation elements
|
|
126
|
+
if ((e.target as HTMLElement).closest('[data-nav]')) {
|
|
127
|
+
return
|
|
128
|
+
}
|
|
129
|
+
onClick?.()
|
|
130
|
+
},
|
|
131
|
+
[onClick]
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
// Empty state
|
|
135
|
+
if (total === 0) {
|
|
136
|
+
return (
|
|
137
|
+
<div className={cn('relative w-full h-full bg-muted flex items-center justify-center', className)}>
|
|
138
|
+
<ImageOff className="w-8 h-8 text-muted-foreground/50" />
|
|
139
|
+
</div>
|
|
140
|
+
)
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Single image - no carousel needed
|
|
144
|
+
if (!hasMultiple) {
|
|
145
|
+
return (
|
|
146
|
+
<div
|
|
147
|
+
className={cn('relative w-full h-full overflow-hidden group', className)}
|
|
148
|
+
onClick={onClick}
|
|
149
|
+
>
|
|
150
|
+
<div className={cn(
|
|
151
|
+
'w-full h-full transition-transform duration-500 ease-out',
|
|
152
|
+
enableZoom && 'group-hover:scale-110'
|
|
153
|
+
)}>
|
|
154
|
+
<GalleryMedia
|
|
155
|
+
media={images[0]}
|
|
156
|
+
className="w-full h-full"
|
|
157
|
+
priority
|
|
158
|
+
/>
|
|
159
|
+
</div>
|
|
160
|
+
</div>
|
|
161
|
+
)
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Calculate visible dots
|
|
165
|
+
const visibleDots = images.slice(0, maxDots).map((_, i) => i)
|
|
166
|
+
const remainingCount = total > maxDots ? total - maxDots : 0
|
|
167
|
+
|
|
168
|
+
return (
|
|
169
|
+
<div
|
|
170
|
+
className={cn('relative w-full h-full group/gallery', className)}
|
|
171
|
+
onClick={handleClick}
|
|
172
|
+
onMouseEnter={() => setIsHovered(true)}
|
|
173
|
+
onMouseLeave={() => setIsHovered(false)}
|
|
174
|
+
>
|
|
175
|
+
<Carousel
|
|
176
|
+
setApi={setApi}
|
|
177
|
+
opts={{
|
|
178
|
+
loop: true,
|
|
179
|
+
}}
|
|
180
|
+
className="w-full h-full"
|
|
181
|
+
>
|
|
182
|
+
<CarouselContent className="-ml-0 h-full">
|
|
183
|
+
{images.map((image, index) => {
|
|
184
|
+
const shouldLoad = loadedIndices.has(index)
|
|
185
|
+
const isActive = index === currentIndex
|
|
186
|
+
|
|
187
|
+
return (
|
|
188
|
+
<CarouselItem key={image.id} className="pl-0 h-full overflow-hidden">
|
|
189
|
+
<div className={cn(
|
|
190
|
+
'w-full h-full transition-transform duration-500 ease-out',
|
|
191
|
+
enableZoom && isActive && isHovered && 'scale-110'
|
|
192
|
+
)}>
|
|
193
|
+
{shouldLoad ? (
|
|
194
|
+
<GalleryMedia
|
|
195
|
+
media={image}
|
|
196
|
+
className="w-full h-full"
|
|
197
|
+
priority={index === 0}
|
|
198
|
+
/>
|
|
199
|
+
) : (
|
|
200
|
+
// Placeholder for unloaded images
|
|
201
|
+
<div className="w-full h-full bg-muted animate-pulse" />
|
|
202
|
+
)}
|
|
203
|
+
</div>
|
|
204
|
+
</CarouselItem>
|
|
205
|
+
)
|
|
206
|
+
})}
|
|
207
|
+
</CarouselContent>
|
|
208
|
+
</Carousel>
|
|
209
|
+
|
|
210
|
+
{/* Navigation arrows - always visible but subtle, more prominent on hover */}
|
|
211
|
+
{showArrows && (
|
|
212
|
+
<>
|
|
213
|
+
<button
|
|
214
|
+
data-nav
|
|
215
|
+
type="button"
|
|
216
|
+
className={cn(
|
|
217
|
+
'absolute left-2 top-1/2 -translate-y-1/2 z-10',
|
|
218
|
+
'w-7 h-7 rounded-full',
|
|
219
|
+
'bg-black/30 backdrop-blur-sm text-white/80',
|
|
220
|
+
'flex items-center justify-center',
|
|
221
|
+
'transition-all duration-200',
|
|
222
|
+
// Always visible but subtle, more prominent on hover
|
|
223
|
+
'opacity-60 group-hover/gallery:opacity-100',
|
|
224
|
+
'hover:bg-black/60 hover:text-white hover:scale-110',
|
|
225
|
+
'active:scale-95',
|
|
226
|
+
'focus:outline-none focus:ring-2 focus:ring-white/50'
|
|
227
|
+
)}
|
|
228
|
+
onClick={handlePrev}
|
|
229
|
+
aria-label="Previous image"
|
|
230
|
+
>
|
|
231
|
+
<ChevronLeft className="w-4 h-4" />
|
|
232
|
+
</button>
|
|
233
|
+
<button
|
|
234
|
+
data-nav
|
|
235
|
+
type="button"
|
|
236
|
+
className={cn(
|
|
237
|
+
'absolute right-2 top-1/2 -translate-y-1/2 z-10',
|
|
238
|
+
'w-7 h-7 rounded-full',
|
|
239
|
+
'bg-black/30 backdrop-blur-sm text-white/80',
|
|
240
|
+
'flex items-center justify-center',
|
|
241
|
+
'transition-all duration-200',
|
|
242
|
+
// Always visible but subtle, more prominent on hover
|
|
243
|
+
'opacity-60 group-hover/gallery:opacity-100',
|
|
244
|
+
'hover:bg-black/60 hover:text-white hover:scale-110',
|
|
245
|
+
'active:scale-95',
|
|
246
|
+
'focus:outline-none focus:ring-2 focus:ring-white/50'
|
|
247
|
+
)}
|
|
248
|
+
onClick={handleNext}
|
|
249
|
+
aria-label="Next image"
|
|
250
|
+
>
|
|
251
|
+
<ChevronRight className="w-4 h-4" />
|
|
252
|
+
</button>
|
|
253
|
+
</>
|
|
254
|
+
)}
|
|
255
|
+
|
|
256
|
+
{/* Dots indicator */}
|
|
257
|
+
{showDots && (
|
|
258
|
+
<div
|
|
259
|
+
data-nav
|
|
260
|
+
className={cn(
|
|
261
|
+
'absolute bottom-3 left-1/2 -translate-x-1/2 z-10',
|
|
262
|
+
'flex items-center gap-1.5',
|
|
263
|
+
'px-2 py-1 rounded-full',
|
|
264
|
+
'bg-black/30 backdrop-blur-sm'
|
|
265
|
+
)}
|
|
266
|
+
onClick={(e) => e.stopPropagation()}
|
|
267
|
+
>
|
|
268
|
+
{visibleDots.map((index) => (
|
|
269
|
+
<button
|
|
270
|
+
key={index}
|
|
271
|
+
type="button"
|
|
272
|
+
className={cn(
|
|
273
|
+
'rounded-full transition-all duration-200',
|
|
274
|
+
index === currentIndex
|
|
275
|
+
? 'w-2 h-2 bg-white shadow-[0_0_4px_rgba(255,255,255,0.8)]'
|
|
276
|
+
: 'w-1.5 h-1.5 bg-white/50 hover:bg-white/80'
|
|
277
|
+
)}
|
|
278
|
+
onClick={(e) => handleDotClick(e, index)}
|
|
279
|
+
aria-label={`Go to image ${index + 1}`}
|
|
280
|
+
/>
|
|
281
|
+
))}
|
|
282
|
+
{remainingCount > 0 && (
|
|
283
|
+
<span className="text-white/80 text-[10px] font-medium ml-0.5">
|
|
284
|
+
+{remainingCount}
|
|
285
|
+
</span>
|
|
286
|
+
)}
|
|
287
|
+
</div>
|
|
288
|
+
)}
|
|
289
|
+
|
|
290
|
+
{/* Counter badge */}
|
|
291
|
+
{showCounter && (
|
|
292
|
+
<div className="absolute bottom-3 right-3 z-10 bg-black/50 backdrop-blur-sm text-white text-xs px-2 py-1 rounded-full font-medium">
|
|
293
|
+
{currentIndex + 1} / {total}
|
|
294
|
+
</div>
|
|
295
|
+
)}
|
|
296
|
+
</div>
|
|
297
|
+
)
|
|
298
|
+
})
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { GalleryCompact, type GalleryCompactProps } from './GalleryCompact'
|
|
@@ -1,13 +1,18 @@
|
|
|
1
|
+
// Main Gallery component
|
|
1
2
|
export { Gallery } from './Gallery'
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
export { GalleryCompact } from './
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
export
|
|
8
|
-
export {
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
export {
|
|
12
|
-
|
|
13
|
-
|
|
3
|
+
|
|
4
|
+
// Compact mode (for cards)
|
|
5
|
+
export { GalleryCompact, type GalleryCompactProps } from './compact'
|
|
6
|
+
|
|
7
|
+
// Preview modes
|
|
8
|
+
export { GalleryCarousel } from './preview'
|
|
9
|
+
export { GalleryGrid, type GalleryGridProps, type GalleryGridLayout } from './preview'
|
|
10
|
+
|
|
11
|
+
// Lightbox
|
|
12
|
+
export { GalleryLightbox } from './lightbox'
|
|
13
|
+
|
|
14
|
+
// Media components
|
|
15
|
+
export { GalleryImage, GalleryVideo, GalleryMedia, type GalleryMediaProps } from './media'
|
|
16
|
+
|
|
17
|
+
// Thumbnails
|
|
18
|
+
export { GalleryThumbnails, GalleryThumbnailsVirtual } from './thumbnails'
|
|
@@ -5,12 +5,12 @@ import { createPortal } from 'react-dom'
|
|
|
5
5
|
import { cn } from '@djangocfg/ui-core/lib'
|
|
6
6
|
import { useTypedT, type I18nTranslations } from '@djangocfg/i18n'
|
|
7
7
|
import { X, ChevronLeft, ChevronRight, Download, Share2, ZoomIn, ZoomOut } from 'lucide-react'
|
|
8
|
-
import { useSwipe } from '
|
|
9
|
-
import { usePreloadImages } from '
|
|
10
|
-
import { useZoom } from '
|
|
11
|
-
import { GalleryMedia } from '
|
|
12
|
-
import { GalleryThumbnails } from '
|
|
13
|
-
import type { GalleryLightboxProps } from '
|
|
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
14
|
|
|
15
15
|
/**
|
|
16
16
|
* GalleryLightbox - Fullscreen image viewer with zoom and navigation
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { GalleryLightbox } from './GalleryLightbox'
|
|
@@ -5,7 +5,7 @@ import { cn } from '@djangocfg/ui-core/lib'
|
|
|
5
5
|
import { useTypedT, type I18nTranslations } from '@djangocfg/i18n'
|
|
6
6
|
import { useImageLoader } from '@djangocfg/ui-core/hooks'
|
|
7
7
|
import { ImageOff } from 'lucide-react'
|
|
8
|
-
import type { GalleryImageProps } from '
|
|
8
|
+
import type { GalleryImageProps } from '../../types'
|
|
9
9
|
|
|
10
10
|
/**
|
|
11
11
|
* GalleryImage - Single image with loading state and error handling
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
import { memo } from 'react'
|
|
4
4
|
import { GalleryImage as GalleryImageComponent } from './GalleryImage'
|
|
5
5
|
import { GalleryVideo } from './GalleryVideo'
|
|
6
|
-
import type { GalleryMediaItem } from '
|
|
6
|
+
import type { GalleryMediaItem } from '../../types'
|
|
7
7
|
|
|
8
8
|
export interface GalleryMediaProps {
|
|
9
9
|
/** Media data */
|
|
@@ -4,7 +4,7 @@ import { memo, useCallback, useMemo, useRef, useState } from 'react'
|
|
|
4
4
|
import { cn } from '@djangocfg/ui-core/lib'
|
|
5
5
|
import { useTypedT, type I18nTranslations } from '@djangocfg/i18n'
|
|
6
6
|
import { Play, Pause, Volume2, VolumeX, Maximize, AlertCircle } from 'lucide-react'
|
|
7
|
-
import type { GalleryMediaItem } from '
|
|
7
|
+
import type { GalleryMediaItem } from '../../types'
|
|
8
8
|
|
|
9
9
|
export interface GalleryVideoProps {
|
|
10
10
|
/** Video data */
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
'use client'
|
|
2
2
|
|
|
3
3
|
import * as React from 'react'
|
|
4
|
-
import { memo, useCallback, useEffect, useMemo } from 'react'
|
|
4
|
+
import { memo, useCallback, useEffect, useMemo, useRef } from 'react'
|
|
5
5
|
import { cn } from '@djangocfg/ui-core/lib'
|
|
6
6
|
import {
|
|
7
7
|
Carousel,
|
|
@@ -12,9 +12,9 @@ import {
|
|
|
12
12
|
type CarouselApi,
|
|
13
13
|
} from '@djangocfg/ui-core/components'
|
|
14
14
|
import { ZoomIn } from 'lucide-react'
|
|
15
|
-
import { GalleryMedia } from '
|
|
16
|
-
import { GalleryThumbnails } from '
|
|
17
|
-
import type { GalleryMediaItem } from '
|
|
15
|
+
import { GalleryMedia } from '../media'
|
|
16
|
+
import { GalleryThumbnails } from '../thumbnails'
|
|
17
|
+
import type { GalleryMediaItem } from '../../types'
|
|
18
18
|
|
|
19
19
|
export interface GalleryCarouselProps {
|
|
20
20
|
images: GalleryMediaItem[]
|
|
@@ -136,6 +136,31 @@ export const GalleryCarousel = memo(function GalleryCarousel({
|
|
|
136
136
|
[api]
|
|
137
137
|
)
|
|
138
138
|
|
|
139
|
+
// Track pointer down position to distinguish click from drag
|
|
140
|
+
const pointerStartRef = useRef<{ x: number; y: number; time: number } | null>(null)
|
|
141
|
+
const CLICK_THRESHOLD = 10 // pixels
|
|
142
|
+
const CLICK_TIME_THRESHOLD = 300 // ms
|
|
143
|
+
|
|
144
|
+
const handlePointerDown = useCallback((e: React.PointerEvent) => {
|
|
145
|
+
pointerStartRef.current = { x: e.clientX, y: e.clientY, time: Date.now() }
|
|
146
|
+
}, [])
|
|
147
|
+
|
|
148
|
+
const handlePointerUp = useCallback((e: React.PointerEvent) => {
|
|
149
|
+
if (!pointerStartRef.current || !enableLightbox || !onLightboxOpen) return
|
|
150
|
+
|
|
151
|
+
const { x, y, time } = pointerStartRef.current
|
|
152
|
+
const dx = Math.abs(e.clientX - x)
|
|
153
|
+
const dy = Math.abs(e.clientY - y)
|
|
154
|
+
const dt = Date.now() - time
|
|
155
|
+
|
|
156
|
+
// Only trigger click if pointer didn't move much and was quick
|
|
157
|
+
if (dx < CLICK_THRESHOLD && dy < CLICK_THRESHOLD && dt < CLICK_TIME_THRESHOLD) {
|
|
158
|
+
onLightboxOpen()
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
pointerStartRef.current = null
|
|
162
|
+
}, [enableLightbox, onLightboxOpen])
|
|
163
|
+
|
|
139
164
|
return (
|
|
140
165
|
<div className={cn('space-y-3', className)}>
|
|
141
166
|
<Carousel
|
|
@@ -150,9 +175,10 @@ export const GalleryCarousel = memo(function GalleryCarousel({
|
|
|
150
175
|
{images.map((image, index) => (
|
|
151
176
|
<CarouselItem key={image.id} className="pl-0">
|
|
152
177
|
<div
|
|
153
|
-
className=
|
|
178
|
+
className={cn('relative bg-muted', enableLightbox && 'cursor-pointer')}
|
|
154
179
|
style={{ aspectRatio }}
|
|
155
|
-
|
|
180
|
+
onPointerDown={enableLightbox ? handlePointerDown : undefined}
|
|
181
|
+
onPointerUp={enableLightbox ? handlePointerUp : undefined}
|
|
156
182
|
>
|
|
157
183
|
<GalleryMedia
|
|
158
184
|
media={image}
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
import { memo, useCallback, useMemo, useState } from 'react'
|
|
4
4
|
import { cn } from '@djangocfg/ui-core/lib'
|
|
5
5
|
import { Play } from 'lucide-react'
|
|
6
|
-
import type { GalleryMediaItem } from '
|
|
6
|
+
import type { GalleryMediaItem } from '../../types'
|
|
7
7
|
|
|
8
8
|
export type GalleryGridLayout =
|
|
9
9
|
| 'auto'
|
package/src/tools/Gallery/components/{GalleryThumbnails.tsx → thumbnails/GalleryThumbnails.tsx}
RENAMED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import { memo, useRef, useEffect, useCallback, Suspense, lazy } from 'react'
|
|
4
4
|
import { cn } from '@djangocfg/ui-core/lib'
|
|
5
|
-
import type { GalleryThumbnailsProps } from '
|
|
5
|
+
import type { GalleryThumbnailsProps } from '../../types'
|
|
6
6
|
|
|
7
7
|
// Lazy load virtualized thumbnails - only for 50+ images
|
|
8
8
|
const GalleryThumbnailsVirtual = lazy(() =>
|
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
import { memo, useCallback, useEffect } from 'react'
|
|
4
4
|
import { cn } from '@djangocfg/ui-core/lib'
|
|
5
|
-
import { useVirtualList } from '
|
|
6
|
-
import type { GalleryThumbnailsProps } from '
|
|
5
|
+
import { useVirtualList } from '../../hooks/useVirtualList'
|
|
6
|
+
import type { GalleryThumbnailsProps } from '../../types'
|
|
7
7
|
|
|
8
8
|
const SIZES = {
|
|
9
9
|
sm: { width: 56, height: 40 },
|
|
@@ -1,173 +0,0 @@
|
|
|
1
|
-
'use client'
|
|
2
|
-
|
|
3
|
-
import { memo, useCallback, useEffect, useState } from 'react'
|
|
4
|
-
import { cn } from '@djangocfg/ui-core/lib'
|
|
5
|
-
import {
|
|
6
|
-
Carousel,
|
|
7
|
-
CarouselContent,
|
|
8
|
-
CarouselItem,
|
|
9
|
-
type CarouselApi,
|
|
10
|
-
} from '@djangocfg/ui-core/components'
|
|
11
|
-
import { ImageOff } from 'lucide-react'
|
|
12
|
-
import { GalleryMedia } from './GalleryMedia'
|
|
13
|
-
import type { GalleryMediaItem } from '../types'
|
|
14
|
-
|
|
15
|
-
export interface GalleryCompactProps {
|
|
16
|
-
/** Array of images to display */
|
|
17
|
-
images: GalleryMediaItem[]
|
|
18
|
-
/** Show dots indicator (default: true) */
|
|
19
|
-
showDots?: boolean
|
|
20
|
-
/** Max dots to show (default: 5) */
|
|
21
|
-
maxDots?: number
|
|
22
|
-
/** Show counter badge (default: false) */
|
|
23
|
-
showCounter?: boolean
|
|
24
|
-
/** On image click callback */
|
|
25
|
-
onClick?: () => void
|
|
26
|
-
/** Additional CSS class */
|
|
27
|
-
className?: string
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
/**
|
|
31
|
-
* GalleryCompact - Minimal carousel for property cards
|
|
32
|
-
*
|
|
33
|
-
* Features:
|
|
34
|
-
* - Simple swipe carousel
|
|
35
|
-
* - Dots indicator (mobile-style)
|
|
36
|
-
* - No arrows, thumbnails, or lightbox
|
|
37
|
-
* - Fills parent container
|
|
38
|
-
* - Stops event propagation on navigation
|
|
39
|
-
*/
|
|
40
|
-
export const GalleryCompact = memo(function GalleryCompact({
|
|
41
|
-
images,
|
|
42
|
-
showDots = true,
|
|
43
|
-
maxDots = 5,
|
|
44
|
-
showCounter = false,
|
|
45
|
-
onClick,
|
|
46
|
-
className,
|
|
47
|
-
}: GalleryCompactProps) {
|
|
48
|
-
const [api, setApi] = useState<CarouselApi>()
|
|
49
|
-
const [currentIndex, setCurrentIndex] = useState(0)
|
|
50
|
-
|
|
51
|
-
const total = images.length
|
|
52
|
-
const hasMultiple = total > 1
|
|
53
|
-
|
|
54
|
-
// Listen to carousel changes
|
|
55
|
-
useEffect(() => {
|
|
56
|
-
if (!api) return
|
|
57
|
-
|
|
58
|
-
const onSelect = () => {
|
|
59
|
-
setCurrentIndex(api.selectedScrollSnap())
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
api.on('select', onSelect)
|
|
63
|
-
return () => {
|
|
64
|
-
api.off('select', onSelect)
|
|
65
|
-
}
|
|
66
|
-
}, [api])
|
|
67
|
-
|
|
68
|
-
// Dot click handler - stops propagation to prevent card click
|
|
69
|
-
const handleDotClick = useCallback(
|
|
70
|
-
(e: React.MouseEvent, index: number) => {
|
|
71
|
-
e.preventDefault()
|
|
72
|
-
e.stopPropagation()
|
|
73
|
-
api?.scrollTo(index)
|
|
74
|
-
},
|
|
75
|
-
[api]
|
|
76
|
-
)
|
|
77
|
-
|
|
78
|
-
// Handle container click
|
|
79
|
-
const handleClick = useCallback(
|
|
80
|
-
(e: React.MouseEvent) => {
|
|
81
|
-
// Don't trigger if clicking on dots
|
|
82
|
-
if ((e.target as HTMLElement).closest('[data-dots]')) {
|
|
83
|
-
return
|
|
84
|
-
}
|
|
85
|
-
onClick?.()
|
|
86
|
-
},
|
|
87
|
-
[onClick]
|
|
88
|
-
)
|
|
89
|
-
|
|
90
|
-
// Empty state
|
|
91
|
-
if (total === 0) {
|
|
92
|
-
return (
|
|
93
|
-
<div className={cn('relative w-full h-full bg-muted flex items-center justify-center', className)}>
|
|
94
|
-
<ImageOff className="w-8 h-8 text-muted-foreground/50" />
|
|
95
|
-
</div>
|
|
96
|
-
)
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
// Single image - no carousel needed
|
|
100
|
-
if (!hasMultiple) {
|
|
101
|
-
return (
|
|
102
|
-
<div className={cn('relative w-full h-full', className)} onClick={onClick}>
|
|
103
|
-
<GalleryMedia
|
|
104
|
-
media={images[0]}
|
|
105
|
-
className="w-full h-full"
|
|
106
|
-
priority
|
|
107
|
-
/>
|
|
108
|
-
</div>
|
|
109
|
-
)
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
// Calculate visible dots
|
|
113
|
-
const visibleDots = images.slice(0, maxDots).map((_, i) => i)
|
|
114
|
-
const remainingCount = total > maxDots ? total - maxDots : 0
|
|
115
|
-
|
|
116
|
-
return (
|
|
117
|
-
<div className={cn('relative w-full h-full', className)} onClick={handleClick}>
|
|
118
|
-
<Carousel
|
|
119
|
-
setApi={setApi}
|
|
120
|
-
opts={{
|
|
121
|
-
loop: true,
|
|
122
|
-
}}
|
|
123
|
-
className="w-full h-full"
|
|
124
|
-
>
|
|
125
|
-
<CarouselContent className="-ml-0 h-full">
|
|
126
|
-
{images.map((image, index) => (
|
|
127
|
-
<CarouselItem key={image.id} className="pl-0 h-full">
|
|
128
|
-
<GalleryMedia
|
|
129
|
-
media={image}
|
|
130
|
-
className="w-full h-full"
|
|
131
|
-
priority={index === 0}
|
|
132
|
-
/>
|
|
133
|
-
</CarouselItem>
|
|
134
|
-
))}
|
|
135
|
-
</CarouselContent>
|
|
136
|
-
</Carousel>
|
|
137
|
-
|
|
138
|
-
{/* Dots indicator */}
|
|
139
|
-
{showDots && (
|
|
140
|
-
<div
|
|
141
|
-
data-dots
|
|
142
|
-
className="absolute bottom-3 left-1/2 -translate-x-1/2 flex items-center gap-1.5 z-10"
|
|
143
|
-
onClick={(e) => e.stopPropagation()}
|
|
144
|
-
>
|
|
145
|
-
{visibleDots.map((index) => (
|
|
146
|
-
<button
|
|
147
|
-
key={index}
|
|
148
|
-
type="button"
|
|
149
|
-
className={cn(
|
|
150
|
-
'rounded-full transition-all',
|
|
151
|
-
index === currentIndex
|
|
152
|
-
? 'w-2 h-2 bg-white shadow-[0_0_4px_rgba(255,255,255,0.8)]'
|
|
153
|
-
: 'w-1.5 h-1.5 bg-white/50 hover:bg-white/70'
|
|
154
|
-
)}
|
|
155
|
-
onClick={(e) => handleDotClick(e, index)}
|
|
156
|
-
aria-label={`Go to image ${index + 1}`}
|
|
157
|
-
/>
|
|
158
|
-
))}
|
|
159
|
-
{remainingCount > 0 && (
|
|
160
|
-
<span className="text-white/70 text-[10px] ml-0.5">+{remainingCount}</span>
|
|
161
|
-
)}
|
|
162
|
-
</div>
|
|
163
|
-
)}
|
|
164
|
-
|
|
165
|
-
{/* Counter badge */}
|
|
166
|
-
{showCounter && (
|
|
167
|
-
<div className="absolute bottom-3 right-3 z-10 bg-black/60 backdrop-blur-sm text-white text-xs px-2 py-1 rounded-full">
|
|
168
|
-
{currentIndex + 1} / {total}
|
|
169
|
-
</div>
|
|
170
|
-
)}
|
|
171
|
-
</div>
|
|
172
|
-
)
|
|
173
|
-
})
|