@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@djangocfg/ui-tools",
3
- "version": "2.1.116",
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.116",
65
- "@djangocfg/ui-core": "^2.1.116",
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.116",
98
- "@djangocfg/typescript-config": "^2.1.116",
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 './GalleryGrid'
9
- import { GalleryCarousel } from './GalleryCarousel'
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('./GalleryLightbox').then((mod) => ({ default: mod.GalleryLightbox }))
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
- 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'
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 '../hooks/useSwipe'
9
- import { usePreloadImages } from '../hooks/usePreloadImages'
10
- import { useZoom } from '../hooks/useZoom'
11
- import { GalleryMedia } from './GalleryMedia'
12
- import { GalleryThumbnails } from './GalleryThumbnails'
13
- import type { GalleryLightboxProps } from '../types'
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 '../types'
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 '../types'
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 '../types'
7
+ import type { GalleryMediaItem } from '../../types'
8
8
 
9
9
  export interface GalleryVideoProps {
10
10
  /** Video data */
@@ -0,0 +1,3 @@
1
+ export { GalleryImage } from './GalleryImage'
2
+ export { GalleryVideo } from './GalleryVideo'
3
+ export { GalleryMedia, type GalleryMediaProps } from './GalleryMedia'
@@ -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 './GalleryMedia'
16
- import { GalleryThumbnails } from './GalleryThumbnails'
17
- import type { GalleryMediaItem } from '../types'
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="relative bg-muted cursor-pointer"
178
+ className={cn('relative bg-muted', enableLightbox && 'cursor-pointer')}
154
179
  style={{ aspectRatio }}
155
- onClick={enableLightbox ? onLightboxOpen : undefined}
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 '../types'
6
+ import type { GalleryMediaItem } from '../../types'
7
7
 
8
8
  export type GalleryGridLayout =
9
9
  | 'auto'
@@ -0,0 +1,2 @@
1
+ export { GalleryCarousel } from './GalleryCarousel'
2
+ export { GalleryGrid, type GalleryGridProps, type GalleryGridLayout } from './GalleryGrid'
@@ -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 '../types'
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 '../hooks/useVirtualList'
6
- import type { GalleryThumbnailsProps } from '../types'
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 },
@@ -0,0 +1,2 @@
1
+ export { GalleryThumbnails } from './GalleryThumbnails'
2
+ export { GalleryThumbnailsVirtual } from './GalleryThumbnailsVirtual'
@@ -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
- })