@djangocfg/ui-tools 2.1.110 → 2.1.111

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,182 @@
1
+ 'use client'
2
+
3
+ import { memo, useCallback, useMemo, Suspense, lazy } from 'react'
4
+ import { cn } from '@djangocfg/ui-core/lib'
5
+ import { useTypedT, type I18nTranslations } from '@djangocfg/i18n'
6
+ import { ImageOff } from 'lucide-react'
7
+ import { useGallery } from '../hooks/useGallery'
8
+ import { GalleryGrid } from './GalleryGrid'
9
+ import { GalleryCarousel } from './GalleryCarousel'
10
+ import type { GalleryProps } from '../types'
11
+
12
+ // Lazy load lightbox - only loaded when user opens it
13
+ const GalleryLightbox = lazy(() =>
14
+ import('./GalleryLightbox').then((mod) => ({ default: mod.GalleryLightbox }))
15
+ )
16
+
17
+ /**
18
+ * Gallery - Complete image gallery with carousel/grid preview and lightbox
19
+ *
20
+ * Features:
21
+ * - Two preview modes: carousel (default) or grid
22
+ * - Smooth slide transitions (embla-carousel)
23
+ * - Touch/swipe support
24
+ * - Keyboard navigation
25
+ * - Fullscreen lightbox
26
+ * - Dark overlay for controls
27
+ */
28
+ export const Gallery = memo(function Gallery({
29
+ images,
30
+ initialIndex = 0,
31
+ previewMode = 'carousel',
32
+ previewCount = 5,
33
+ showThumbnails = true,
34
+ showControls = true,
35
+ showCounter = true,
36
+ aspectRatio = 16 / 9,
37
+ enableLightbox = true,
38
+ enableKeyboard = true,
39
+ onImageChange,
40
+ onLightboxOpen,
41
+ onLightboxClose,
42
+ emptyState,
43
+ className,
44
+ }: GalleryProps) {
45
+ const t = useTypedT<I18nTranslations>()
46
+ const noImagesText = useMemo(() => t('tools.gallery.noImages'), [t])
47
+
48
+ const gallery = useGallery({
49
+ images,
50
+ initialIndex,
51
+ loop: true,
52
+ onImageChange,
53
+ })
54
+
55
+ const {
56
+ currentIndex,
57
+ total,
58
+ hasMultiple,
59
+ isLightboxOpen,
60
+ goTo,
61
+ openLightbox,
62
+ closeLightbox,
63
+ setLoading,
64
+ } = gallery
65
+
66
+ // Lightbox callbacks
67
+ const handleLightboxOpen = useCallback(() => {
68
+ openLightbox()
69
+ onLightboxOpen?.()
70
+ }, [openLightbox, onLightboxOpen])
71
+
72
+ const handleLightboxClose = useCallback(() => {
73
+ closeLightbox()
74
+ onLightboxClose?.()
75
+ }, [closeLightbox, onLightboxClose])
76
+
77
+ // Open lightbox at specific index (for grid mode)
78
+ const handleGridImageClick = useCallback(
79
+ (index: number) => {
80
+ goTo(index)
81
+ openLightbox()
82
+ onLightboxOpen?.()
83
+ },
84
+ [goTo, openLightbox, onLightboxOpen]
85
+ )
86
+
87
+ // Current image load handler
88
+ const handleCurrentImageLoad = useCallback(() => {
89
+ setLoading(false)
90
+ }, [setLoading])
91
+
92
+ // Empty state
93
+ if (images.length === 0) {
94
+ if (emptyState) {
95
+ return <>{emptyState}</>
96
+ }
97
+
98
+ return (
99
+ <div
100
+ className={cn(
101
+ 'bg-muted rounded-xl flex items-center justify-center',
102
+ className
103
+ )}
104
+ style={{ aspectRatio }}
105
+ >
106
+ <div className="text-center text-muted-foreground p-8">
107
+ <ImageOff className="w-12 h-12 mx-auto mb-3 opacity-50" />
108
+ <p>{noImagesText}</p>
109
+ </div>
110
+ </div>
111
+ )
112
+ }
113
+
114
+ // Grid mode
115
+ if (previewMode === 'grid') {
116
+ return (
117
+ <div className={className}>
118
+ <GalleryGrid
119
+ images={images}
120
+ maxVisible={previewCount}
121
+ aspectRatio={aspectRatio}
122
+ gap={2}
123
+ rounded="xl"
124
+ onImageClick={enableLightbox ? handleGridImageClick : undefined}
125
+ showMoreBadge
126
+ />
127
+
128
+ {/* Lightbox - lazy loaded */}
129
+ {enableLightbox && isLightboxOpen && (
130
+ <Suspense fallback={null}>
131
+ <GalleryLightbox
132
+ open={isLightboxOpen}
133
+ onClose={handleLightboxClose}
134
+ images={images}
135
+ currentIndex={currentIndex}
136
+ onIndexChange={goTo}
137
+ showThumbnails={showThumbnails}
138
+ />
139
+ </Suspense>
140
+ )}
141
+ </div>
142
+ )
143
+ }
144
+
145
+ // Carousel mode (default)
146
+ return (
147
+ <>
148
+ <GalleryCarousel
149
+ images={images}
150
+ currentIndex={currentIndex}
151
+ total={total}
152
+ hasMultiple={hasMultiple}
153
+ initialIndex={initialIndex}
154
+ aspectRatio={aspectRatio}
155
+ showControls={showControls}
156
+ showCounter={showCounter}
157
+ showThumbnails={showThumbnails}
158
+ enableLightbox={enableLightbox}
159
+ enableKeyboard={enableKeyboard}
160
+ isLightboxOpen={isLightboxOpen}
161
+ onIndexChange={goTo}
162
+ onLightboxOpen={handleLightboxOpen}
163
+ onImageLoad={handleCurrentImageLoad}
164
+ className={className}
165
+ />
166
+
167
+ {/* Lightbox - lazy loaded */}
168
+ {enableLightbox && isLightboxOpen && (
169
+ <Suspense fallback={null}>
170
+ <GalleryLightbox
171
+ open={isLightboxOpen}
172
+ onClose={handleLightboxClose}
173
+ images={images}
174
+ currentIndex={currentIndex}
175
+ onIndexChange={goTo}
176
+ showThumbnails={showThumbnails}
177
+ />
178
+ </Suspense>
179
+ )}
180
+ </>
181
+ )
182
+ })
@@ -0,0 +1,251 @@
1
+ 'use client'
2
+
3
+ import * as React from 'react'
4
+ import { memo, useCallback, useEffect, useMemo } from 'react'
5
+ import { cn } from '@djangocfg/ui-core/lib'
6
+ import {
7
+ Carousel,
8
+ CarouselContent,
9
+ CarouselItem,
10
+ CarouselPrevious,
11
+ CarouselNext,
12
+ type CarouselApi,
13
+ } from '@djangocfg/ui-core/components'
14
+ import { ZoomIn } from 'lucide-react'
15
+ import { GalleryMedia } from './GalleryMedia'
16
+ import { GalleryThumbnails } from './GalleryThumbnails'
17
+ import type { GalleryMediaItem } from '../types'
18
+
19
+ export interface GalleryCarouselProps {
20
+ images: GalleryMediaItem[]
21
+ currentIndex: number
22
+ total: number
23
+ hasMultiple: boolean
24
+ initialIndex?: number
25
+ aspectRatio?: number
26
+ showControls?: boolean
27
+ showCounter?: boolean
28
+ showThumbnails?: boolean
29
+ enableLightbox?: boolean
30
+ enableKeyboard?: boolean
31
+ isLightboxOpen?: boolean
32
+ onApiChange?: (api: CarouselApi | undefined) => void
33
+ onIndexChange: (index: number) => void
34
+ onLightboxOpen?: () => void
35
+ onImageLoad?: () => void
36
+ className?: string
37
+ }
38
+
39
+ /**
40
+ * GalleryCarousel - Embla-based carousel for gallery images
41
+ *
42
+ * Features:
43
+ * - Smooth slide transitions
44
+ * - Touch/swipe support
45
+ * - Keyboard navigation
46
+ * - Mobile dots + desktop thumbnails
47
+ */
48
+ export const GalleryCarousel = memo(function GalleryCarousel({
49
+ images,
50
+ currentIndex,
51
+ total,
52
+ hasMultiple,
53
+ initialIndex = 0,
54
+ aspectRatio = 16 / 9,
55
+ showControls = true,
56
+ showCounter = true,
57
+ showThumbnails = true,
58
+ enableLightbox = true,
59
+ enableKeyboard = true,
60
+ isLightboxOpen = false,
61
+ onApiChange,
62
+ onIndexChange,
63
+ onLightboxOpen,
64
+ onImageLoad,
65
+ className,
66
+ }: GalleryCarouselProps) {
67
+ const [api, setApi] = React.useState<CarouselApi>()
68
+
69
+ // Notify parent when API is ready
70
+ useEffect(() => {
71
+ onApiChange?.(api)
72
+ }, [api, onApiChange])
73
+
74
+ // Mobile dots (max 5)
75
+ const mobileDots = useMemo(() => {
76
+ return images.slice(0, 5).map((_, index) => index)
77
+ }, [images])
78
+
79
+ const remainingCount = useMemo(() => {
80
+ return images.length > 5 ? images.length - 5 : 0
81
+ }, [images.length])
82
+
83
+ // Sync carousel with external state
84
+ useEffect(() => {
85
+ if (!api) return
86
+ api.scrollTo(currentIndex)
87
+ }, [api, currentIndex])
88
+
89
+ // Listen to carousel changes
90
+ useEffect(() => {
91
+ if (!api) return
92
+
93
+ const onSelect = () => {
94
+ const index = api.selectedScrollSnap()
95
+ if (index !== currentIndex) {
96
+ onIndexChange(index)
97
+ }
98
+ }
99
+
100
+ api.on('select', onSelect)
101
+ return () => {
102
+ api.off('select', onSelect)
103
+ }
104
+ }, [api, currentIndex, onIndexChange])
105
+
106
+ // Keyboard navigation
107
+ useEffect(() => {
108
+ if (!enableKeyboard || isLightboxOpen || !api) return
109
+
110
+ const handleKeyDown = (e: KeyboardEvent) => {
111
+ if (e.key === 'ArrowLeft' && hasMultiple) {
112
+ api.scrollPrev()
113
+ } else if (e.key === 'ArrowRight' && hasMultiple) {
114
+ api.scrollNext()
115
+ }
116
+ }
117
+
118
+ window.addEventListener('keydown', handleKeyDown)
119
+ return () => window.removeEventListener('keydown', handleKeyDown)
120
+ }, [enableKeyboard, isLightboxOpen, hasMultiple, api])
121
+
122
+ // Dot click handler
123
+ const handleDotClick = useCallback(
124
+ (e: React.MouseEvent, index: number) => {
125
+ e.stopPropagation()
126
+ api?.scrollTo(index)
127
+ },
128
+ [api]
129
+ )
130
+
131
+ // Scroll to index
132
+ const handleScrollTo = useCallback(
133
+ (index: number) => {
134
+ api?.scrollTo(index)
135
+ },
136
+ [api]
137
+ )
138
+
139
+ return (
140
+ <div className={cn('space-y-3', className)}>
141
+ <Carousel
142
+ setApi={setApi}
143
+ opts={{
144
+ loop: true,
145
+ startIndex: initialIndex,
146
+ }}
147
+ className="relative rounded-xl overflow-hidden"
148
+ >
149
+ <CarouselContent className="-ml-0">
150
+ {images.map((image, index) => (
151
+ <CarouselItem key={image.id} className="pl-0">
152
+ <div
153
+ className="relative bg-muted cursor-pointer"
154
+ style={{ aspectRatio }}
155
+ onClick={enableLightbox ? onLightboxOpen : undefined}
156
+ >
157
+ <GalleryMedia
158
+ media={image}
159
+ className="w-full h-full"
160
+ onLoad={index === currentIndex ? onImageLoad : undefined}
161
+ priority={index === 0}
162
+ />
163
+
164
+ {/* Dark overlay */}
165
+ <div className="absolute inset-0 bg-gradient-to-t from-black/40 via-transparent to-black/20 pointer-events-none" />
166
+
167
+ {/* Lightbox indicator */}
168
+ {enableLightbox && (
169
+ <div className="absolute inset-0 flex items-center justify-center opacity-0 hover:opacity-100 transition-opacity">
170
+ <div className="bg-black/60 backdrop-blur-sm rounded-full p-3 shadow-lg border border-white/20">
171
+ <ZoomIn className="w-6 h-6 text-white" />
172
+ </div>
173
+ </div>
174
+ )}
175
+ </div>
176
+ </CarouselItem>
177
+ ))}
178
+ </CarouselContent>
179
+
180
+ {/* Navigation Controls */}
181
+ {showControls && hasMultiple && (
182
+ <>
183
+ <CarouselPrevious
184
+ className={cn(
185
+ 'absolute left-3 top-1/2 -translate-y-1/2',
186
+ 'w-10 h-10 rounded-full',
187
+ 'bg-black/50 hover:bg-black/70 text-white backdrop-blur-sm border-white/20',
188
+ 'disabled:opacity-30'
189
+ )}
190
+ />
191
+ <CarouselNext
192
+ className={cn(
193
+ 'absolute right-3 top-1/2 -translate-y-1/2',
194
+ 'w-10 h-10 rounded-full',
195
+ 'bg-black/50 hover:bg-black/70 text-white backdrop-blur-sm border-white/20',
196
+ 'disabled:opacity-30'
197
+ )}
198
+ />
199
+ </>
200
+ )}
201
+
202
+ {/* Counter */}
203
+ {showCounter && hasMultiple && (
204
+ <div
205
+ className={cn(
206
+ 'absolute bottom-3 right-3 z-10',
207
+ 'bg-black/60 backdrop-blur-sm text-white px-3 py-1.5 rounded-full',
208
+ 'text-sm font-medium'
209
+ )}
210
+ >
211
+ {currentIndex + 1} / {total}
212
+ </div>
213
+ )}
214
+
215
+ {/* Mobile dots */}
216
+ {hasMultiple && (
217
+ <div className="absolute bottom-3 left-1/2 -translate-x-1/2 flex gap-2 md:hidden z-10">
218
+ {mobileDots.map((index) => (
219
+ <button
220
+ key={index}
221
+ type="button"
222
+ className={cn(
223
+ 'rounded-full transition-all',
224
+ index === currentIndex
225
+ ? 'w-2.5 h-2.5 bg-white shadow-[0_0_4px_rgba(255,255,255,0.8)]'
226
+ : 'w-2 h-2 bg-white/40'
227
+ )}
228
+ onClick={(e) => handleDotClick(e, index)}
229
+ aria-label={`Go to image ${index + 1}`}
230
+ />
231
+ ))}
232
+ {remainingCount > 0 && (
233
+ <span className="text-white/70 text-xs ml-1">+{remainingCount}</span>
234
+ )}
235
+ </div>
236
+ )}
237
+ </Carousel>
238
+
239
+ {/* Thumbnails */}
240
+ {showThumbnails && hasMultiple && (
241
+ <GalleryThumbnails
242
+ images={images}
243
+ currentIndex={currentIndex}
244
+ onSelect={handleScrollTo}
245
+ size="md"
246
+ className="hidden md:flex"
247
+ />
248
+ )}
249
+ </div>
250
+ )
251
+ })
@@ -0,0 +1,173 @@
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
+ })