@djangocfg/ui-tools 2.1.119 → 2.1.121

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 (67) hide show
  1. package/dist/JsonTree-G2TPWQ4C.mjs +4 -0
  2. package/dist/{JsonTree-6RYAOPSS.mjs.map → JsonTree-G2TPWQ4C.mjs.map} +1 -1
  3. package/dist/JsonTree-TWXUBBIG.cjs +10 -0
  4. package/dist/{JsonTree-7OH6CIHT.cjs.map → JsonTree-TWXUBBIG.cjs.map} +1 -1
  5. package/dist/{Mermaid.client-PNXEC6YL.cjs → Mermaid.client-AF4WOQZR.cjs} +9 -11
  6. package/dist/Mermaid.client-AF4WOQZR.cjs.map +1 -0
  7. package/dist/{Mermaid.client-OKACITCW.mjs → Mermaid.client-W4QXJX7Q.mjs} +9 -11
  8. package/dist/Mermaid.client-W4QXJX7Q.mjs.map +1 -0
  9. package/dist/{PlaygroundLayout-SYMEAG3J.cjs → PlaygroundLayout-RZMJWH3Y.cjs} +25 -25
  10. package/dist/{PlaygroundLayout-SYMEAG3J.cjs.map → PlaygroundLayout-RZMJWH3Y.cjs.map} +1 -1
  11. package/dist/{PlaygroundLayout-UQRBU5RH.mjs → PlaygroundLayout-UQABCZ6K.mjs} +4 -4
  12. package/dist/{PlaygroundLayout-UQRBU5RH.mjs.map → PlaygroundLayout-UQABCZ6K.mjs.map} +1 -1
  13. package/dist/{chunk-UOMPPIED.mjs → chunk-4G4UGMOP.mjs} +3 -3
  14. package/dist/chunk-4G4UGMOP.mjs.map +1 -0
  15. package/dist/{chunk-47T5ECYV.cjs → chunk-CY3CQS26.cjs} +3 -3
  16. package/dist/chunk-CY3CQS26.cjs.map +1 -0
  17. package/dist/{chunk-5QT3QYFZ.cjs → chunk-EGYUND4E.cjs} +2 -2
  18. package/dist/chunk-EGYUND4E.cjs.map +1 -0
  19. package/dist/{chunk-DI3HUXHK.cjs → chunk-OYLQZT62.cjs} +2 -2
  20. package/dist/chunk-OYLQZT62.cjs.map +1 -0
  21. package/dist/{chunk-W6YHQI4F.mjs → chunk-OYYCGIBF.mjs} +2 -2
  22. package/dist/chunk-OYYCGIBF.mjs.map +1 -0
  23. package/dist/{chunk-G6PRZP5I.mjs → chunk-XZZ22EHP.mjs} +2 -2
  24. package/dist/chunk-XZZ22EHP.mjs.map +1 -0
  25. package/dist/{components-EASJYK45.mjs → components-4YSJ5ALL.mjs} +3 -3
  26. package/dist/{components-CJ2IB65O.cjs.map → components-4YSJ5ALL.mjs.map} +1 -1
  27. package/dist/{components-CJ2IB65O.cjs → components-BSTP3VLD.cjs} +7 -7
  28. package/dist/{components-EASJYK45.mjs.map → components-BSTP3VLD.cjs.map} +1 -1
  29. package/dist/index.cjs +27 -27
  30. package/dist/index.cjs.map +1 -1
  31. package/dist/index.d.cts +1 -1
  32. package/dist/index.d.ts +1 -1
  33. package/dist/index.mjs +10 -10
  34. package/dist/index.mjs.map +1 -1
  35. package/package.json +9 -12
  36. package/src/tools/AudioPlayer/AudioPlayer.story.tsx +102 -0
  37. package/src/tools/Gallery/Gallery.story.tsx +237 -0
  38. package/src/tools/Gallery/components/compact/GalleryCompact.tsx +105 -10
  39. package/src/tools/Gallery/components/compact/index.ts +1 -1
  40. package/src/tools/Gallery/components/index.ts +1 -1
  41. package/src/tools/Gallery/components/media/GalleryImage.tsx +2 -2
  42. package/src/tools/Gallery/components/preview/GalleryCarousel.tsx +86 -1
  43. package/src/tools/Gallery/index.ts +1 -1
  44. package/src/tools/JsonForm/JsonForm.story.tsx +134 -0
  45. package/src/tools/JsonTree/JsonTree.story.tsx +140 -0
  46. package/src/tools/JsonTree/index.tsx +1 -1
  47. package/src/tools/LottiePlayer/LottiePlayer.story.tsx +95 -0
  48. package/src/tools/Map/Map.story.tsx +300 -0
  49. package/src/tools/Mermaid/Mermaid.story.tsx +131 -0
  50. package/src/tools/Mermaid/hooks/useMermaidCleanup.ts +2 -5
  51. package/src/tools/Mermaid/hooks/useMermaidRenderer.ts +7 -1
  52. package/src/tools/Mermaid/hooks/useMermaidValidation.ts +4 -2
  53. package/src/tools/Mermaid/index.tsx +1 -1
  54. package/src/tools/PrettyCode/PrettyCode.story.tsx +116 -0
  55. package/src/tools/PrettyCode/index.tsx +1 -1
  56. package/src/tools/VideoPlayer/VideoPlayer.story.tsx +87 -0
  57. package/src/tools/VideoPlayer/utils/resolvers.ts +2 -2
  58. package/dist/JsonTree-6RYAOPSS.mjs +0 -4
  59. package/dist/JsonTree-7OH6CIHT.cjs +0 -10
  60. package/dist/Mermaid.client-OKACITCW.mjs.map +0 -1
  61. package/dist/Mermaid.client-PNXEC6YL.cjs.map +0 -1
  62. package/dist/chunk-47T5ECYV.cjs.map +0 -1
  63. package/dist/chunk-5QT3QYFZ.cjs.map +0 -1
  64. package/dist/chunk-DI3HUXHK.cjs.map +0 -1
  65. package/dist/chunk-G6PRZP5I.mjs.map +0 -1
  66. package/dist/chunk-UOMPPIED.mjs.map +0 -1
  67. package/dist/chunk-W6YHQI4F.mjs.map +0 -1
@@ -0,0 +1,237 @@
1
+ import { defineStory, useBoolean, useSelect, useNumber } from '@djangocfg/playground';
2
+ import { Gallery, GalleryCompact, type GalleryMediaItem, type GalleryAspectRatio } from './index';
3
+
4
+ // Sample images for testing
5
+ const SAMPLE_IMAGES: GalleryMediaItem[] = [
6
+ {
7
+ id: '1',
8
+ src: 'https://images.unsplash.com/photo-1494976388531-d1058494cdd8?w=800',
9
+ thumbnail: 'https://images.unsplash.com/photo-1494976388531-d1058494cdd8?w=200',
10
+ alt: 'Classic car',
11
+ },
12
+ {
13
+ id: '2',
14
+ src: 'https://images.unsplash.com/photo-1503376780353-7e6692767b70?w=800',
15
+ thumbnail: 'https://images.unsplash.com/photo-1503376780353-7e6692767b70?w=200',
16
+ alt: 'Porsche',
17
+ },
18
+ {
19
+ id: '3',
20
+ src: 'https://images.unsplash.com/photo-1542362567-b07e54358753?w=800',
21
+ thumbnail: 'https://images.unsplash.com/photo-1542362567-b07e54358753?w=200',
22
+ alt: 'BMW',
23
+ },
24
+ {
25
+ id: '4',
26
+ src: 'https://images.unsplash.com/photo-1555215695-3004980ad54e?w=800',
27
+ thumbnail: 'https://images.unsplash.com/photo-1555215695-3004980ad54e?w=200',
28
+ alt: 'Mercedes',
29
+ },
30
+ {
31
+ id: '5',
32
+ src: 'https://images.unsplash.com/photo-1617531653332-bd46c24f2068?w=800',
33
+ thumbnail: 'https://images.unsplash.com/photo-1617531653332-bd46c24f2068?w=200',
34
+ alt: 'Tesla',
35
+ },
36
+ ];
37
+
38
+ export default defineStory({
39
+ title: 'Tools/Gallery',
40
+ component: Gallery,
41
+ description: 'Full-featured gallery with lightbox, thumbnails, and keyboard navigation.',
42
+ });
43
+
44
+ export const CarouselMode = () => (
45
+ <div className="max-w-4xl">
46
+ <Gallery
47
+ images={SAMPLE_IMAGES}
48
+ previewMode="carousel"
49
+ showThumbnails
50
+ showControls
51
+ showCounter
52
+ enableLightbox
53
+ enableKeyboard
54
+ aspectRatio={16 / 9}
55
+ />
56
+ </div>
57
+ );
58
+
59
+ export const GridMode = () => (
60
+ <div className="max-w-4xl">
61
+ <Gallery
62
+ images={SAMPLE_IMAGES}
63
+ previewMode="grid"
64
+ previewCount={5}
65
+ enableLightbox
66
+ aspectRatio={16 / 9}
67
+ />
68
+ </div>
69
+ );
70
+
71
+ export const WithoutThumbnails = () => (
72
+ <div className="max-w-4xl">
73
+ <Gallery
74
+ images={SAMPLE_IMAGES}
75
+ previewMode="carousel"
76
+ showThumbnails={false}
77
+ showControls
78
+ enableLightbox
79
+ aspectRatio={16 / 9}
80
+ />
81
+ </div>
82
+ );
83
+
84
+ export const Compact = {
85
+ render: (args: {
86
+ showDots: boolean;
87
+ showArrows: boolean;
88
+ enableZoom: boolean;
89
+ showCounter: boolean;
90
+ }) => (
91
+ <div className="grid grid-cols-1 md:grid-cols-3 gap-6">
92
+ <div className="rounded-xl border border-border overflow-hidden">
93
+ <div className="aspect-[4/3]">
94
+ <GalleryCompact
95
+ images={SAMPLE_IMAGES}
96
+ showDots={args.showDots}
97
+ showArrows={args.showArrows}
98
+ enableZoom={args.enableZoom}
99
+ showCounter={args.showCounter}
100
+ />
101
+ </div>
102
+ <div className="p-4">
103
+ <h3 className="font-medium">Vehicle Title</h3>
104
+ <p className="text-sm text-muted-foreground">$25,000</p>
105
+ </div>
106
+ </div>
107
+ </div>
108
+ ),
109
+ args: {
110
+ showDots: true,
111
+ showArrows: true,
112
+ enableZoom: true,
113
+ showCounter: false,
114
+ },
115
+ argTypes: {
116
+ showDots: { control: 'boolean', description: 'Show navigation dots' },
117
+ showArrows: { control: 'boolean', description: 'Show arrow buttons' },
118
+ enableZoom: { control: 'boolean', description: 'Enable zoom on hover' },
119
+ showCounter: { control: 'boolean', description: 'Show image counter' },
120
+ },
121
+ };
122
+
123
+ export const SingleImage = () => (
124
+ <div className="w-64">
125
+ <div className="rounded-xl border border-border overflow-hidden">
126
+ <div className="aspect-[4/3]">
127
+ <GalleryCompact images={SAMPLE_IMAGES.slice(0, 1)} enableZoom />
128
+ </div>
129
+ </div>
130
+ </div>
131
+ );
132
+
133
+ export const EmptyState = () => (
134
+ <div className="w-64">
135
+ <div className="rounded-xl border border-border overflow-hidden">
136
+ <div className="aspect-[4/3]">
137
+ <GalleryCompact images={[]} />
138
+ </div>
139
+ </div>
140
+ </div>
141
+ );
142
+
143
+ /**
144
+ * Interactive story using fixture hooks
145
+ * Controls appear automatically in the right sidebar
146
+ */
147
+ export const Interactive = () => {
148
+ // These hooks auto-register controls in the Properties panel
149
+ const [showDots] = useBoolean('showDots', {
150
+ defaultValue: true,
151
+ label: 'Show Dots',
152
+ description: 'Display navigation dots at the bottom',
153
+ });
154
+
155
+ const [showArrows] = useBoolean('showArrows', {
156
+ defaultValue: true,
157
+ label: 'Show Arrows',
158
+ description: 'Display navigation arrows on hover',
159
+ });
160
+
161
+ const [enableZoom] = useBoolean('enableZoom', {
162
+ defaultValue: true,
163
+ label: 'Enable Zoom',
164
+ description: 'Zoom effect on image hover',
165
+ });
166
+
167
+ const [showCounter] = useBoolean('showCounter', {
168
+ defaultValue: false,
169
+ label: 'Show Counter',
170
+ description: 'Display image counter badge',
171
+ });
172
+
173
+ const [maxDots] = useNumber('maxDots', {
174
+ defaultValue: 5,
175
+ min: 1,
176
+ max: 10,
177
+ label: 'Max Dots',
178
+ description: 'Maximum number of dots to display',
179
+ });
180
+
181
+ const [previewMode] = useSelect('previewMode', {
182
+ options: ['carousel', 'grid'] as const,
183
+ defaultValue: 'carousel',
184
+ label: 'Preview Mode',
185
+ description: 'Gallery preview display mode',
186
+ });
187
+
188
+ const [aspectRatio] = useSelect('aspectRatio', {
189
+ options: ['auto', '16/9', '4/3', '3/2', '1/1'] as const,
190
+ defaultValue: '4/3' as GalleryAspectRatio,
191
+ label: 'Aspect Ratio',
192
+ description: 'Image container aspect ratio',
193
+ });
194
+
195
+ return (
196
+ <div className="space-y-8">
197
+ {/* GalleryCompact */}
198
+ <div>
199
+ <h3 className="text-lg font-semibold mb-4">GalleryCompact</h3>
200
+ <div className="grid grid-cols-1 md:grid-cols-3 gap-6">
201
+ <div className="rounded-xl border border-border overflow-hidden">
202
+ <GalleryCompact
203
+ images={SAMPLE_IMAGES}
204
+ aspectRatio={aspectRatio}
205
+ showDots={showDots}
206
+ showArrows={showArrows}
207
+ enableZoom={enableZoom}
208
+ showCounter={showCounter}
209
+ maxDots={maxDots}
210
+ />
211
+ <div className="p-4">
212
+ <h3 className="font-medium">Interactive Card</h3>
213
+ <p className="text-sm text-muted-foreground">Use controls on the right →</p>
214
+ </div>
215
+ </div>
216
+ </div>
217
+ </div>
218
+
219
+ {/* Full Gallery */}
220
+ <div>
221
+ <h3 className="text-lg font-semibold mb-4">Full Gallery ({previewMode})</h3>
222
+ <div className="max-w-4xl">
223
+ <Gallery
224
+ images={SAMPLE_IMAGES}
225
+ previewMode={previewMode}
226
+ showThumbnails
227
+ showControls
228
+ showCounter={showCounter}
229
+ enableLightbox
230
+ enableKeyboard
231
+ aspectRatio={16 / 9}
232
+ />
233
+ </div>
234
+ </div>
235
+ </div>
236
+ );
237
+ };
@@ -12,9 +12,14 @@ import { ChevronLeft, ChevronRight, ImageOff } from 'lucide-react'
12
12
  import { GalleryMedia } from '../media'
13
13
  import type { GalleryMediaItem } from '../../types'
14
14
 
15
+ /** Preset aspect ratios for common use cases */
16
+ export type GalleryAspectRatio = '16/9' | '4/3' | '3/2' | '1/1' | 'auto'
17
+
15
18
  export interface GalleryCompactProps {
16
19
  /** Array of images to display */
17
20
  images: GalleryMediaItem[]
21
+ /** Aspect ratio preset or 'auto' to fill parent (default: 'auto') */
22
+ aspectRatio?: GalleryAspectRatio
18
23
  /** Show dots indicator (default: true) */
19
24
  showDots?: boolean
20
25
  /** Max dots to show (default: 5) */
@@ -43,8 +48,18 @@ export interface GalleryCompactProps {
43
48
  * - Fills parent container
44
49
  * - Stops event propagation on navigation
45
50
  */
51
+ /** Map aspect ratio presets to CSS values */
52
+ const aspectRatioMap: Record<GalleryAspectRatio, string | undefined> = {
53
+ '16/9': '16 / 9',
54
+ '4/3': '4 / 3',
55
+ '3/2': '3 / 2',
56
+ '1/1': '1 / 1',
57
+ 'auto': undefined,
58
+ }
59
+
46
60
  export const GalleryCompact = memo(function GalleryCompact({
47
61
  images,
62
+ aspectRatio = 'auto',
48
63
  showDots = true,
49
64
  maxDots = 5,
50
65
  showCounter = false,
@@ -56,9 +71,23 @@ export const GalleryCompact = memo(function GalleryCompact({
56
71
  const [api, setApi] = useState<CarouselApi>()
57
72
  const [currentIndex, setCurrentIndex] = useState(0)
58
73
  const [isHovered, setIsHovered] = useState(false)
74
+ // Track if component is mounted (client-side) to avoid hydration mismatches
75
+ const [isMounted, setIsMounted] = useState(false)
76
+
77
+ // Compute aspect ratio style - if set, we control height via aspect-ratio, otherwise fill parent
78
+ const aspectRatioStyle = aspectRatioMap[aspectRatio]
79
+ const containerStyle = aspectRatioStyle ? { aspectRatio: aspectRatioStyle } : undefined
80
+ const hasFixedAspect = aspectRatio !== 'auto'
81
+ const containerClass = hasFixedAspect ? 'relative w-full' : 'relative w-full h-full'
82
+
83
+ useEffect(() => {
84
+ setIsMounted(true)
85
+ }, [])
59
86
 
60
87
  const total = images.length
61
88
  const hasMultiple = total > 1
89
+ // Stable key for carousel reset on images change
90
+ const carouselKey = images[0]?.id ?? 'empty'
62
91
 
63
92
  // Determine which images should be loaded (current + neighbors for smooth transition)
64
93
  const loadedIndices = useMemo(() => {
@@ -90,23 +119,27 @@ export const GalleryCompact = memo(function GalleryCompact({
90
119
  }
91
120
  }, [api])
92
121
 
93
- // Navigation handlers
122
+ // Navigation handlers - stop all propagation to parent Link
123
+ const stopEvent = useCallback((e: React.MouseEvent | React.PointerEvent) => {
124
+ e.preventDefault()
125
+ e.stopPropagation()
126
+ e.nativeEvent.stopImmediatePropagation()
127
+ }, [])
128
+
94
129
  const handlePrev = useCallback(
95
130
  (e: React.MouseEvent) => {
96
- e.preventDefault()
97
- e.stopPropagation()
131
+ stopEvent(e)
98
132
  api?.scrollPrev()
99
133
  },
100
- [api]
134
+ [api, stopEvent]
101
135
  )
102
136
 
103
137
  const handleNext = useCallback(
104
138
  (e: React.MouseEvent) => {
105
- e.preventDefault()
106
- e.stopPropagation()
139
+ stopEvent(e)
107
140
  api?.scrollNext()
108
141
  },
109
- [api]
142
+ [api, stopEvent]
110
143
  )
111
144
 
112
145
  // Dot click handler
@@ -134,7 +167,10 @@ export const GalleryCompact = memo(function GalleryCompact({
134
167
  // Empty state
135
168
  if (total === 0) {
136
169
  return (
137
- <div className={cn('relative w-full h-full bg-muted flex items-center justify-center', className)}>
170
+ <div
171
+ className={cn(containerClass, 'bg-muted flex items-center justify-center', className)}
172
+ style={containerStyle}
173
+ >
138
174
  <ImageOff className="w-8 h-8 text-muted-foreground/50" />
139
175
  </div>
140
176
  )
@@ -144,7 +180,8 @@ export const GalleryCompact = memo(function GalleryCompact({
144
180
  if (!hasMultiple) {
145
181
  return (
146
182
  <div
147
- className={cn('relative w-full h-full overflow-hidden group', className)}
183
+ className={cn(containerClass, 'overflow-hidden group', className)}
184
+ style={containerStyle}
148
185
  onClick={onClick}
149
186
  >
150
187
  <div className={cn(
@@ -165,14 +202,68 @@ export const GalleryCompact = memo(function GalleryCompact({
165
202
  const visibleDots = images.slice(0, maxDots).map((_, i) => i)
166
203
  const remainingCount = total > maxDots ? total - maxDots : 0
167
204
 
205
+ // SSR fallback - render first image only to avoid hydration mismatch
206
+ // The carousel requires DOM access and will cause issues during SSR
207
+ if (!isMounted) {
208
+ return (
209
+ <div
210
+ className={cn(containerClass, 'overflow-hidden', className)}
211
+ style={containerStyle}
212
+ onClick={onClick}
213
+ >
214
+ <GalleryMedia
215
+ media={images[0]}
216
+ className="w-full h-full"
217
+ priority
218
+ />
219
+ {/* Show dots indicator placeholder for consistent layout */}
220
+ {showDots && (
221
+ <div
222
+ className={cn(
223
+ 'absolute bottom-3 left-1/2 -translate-x-1/2 z-10',
224
+ 'flex items-center gap-1.5',
225
+ 'px-2 py-1 rounded-full',
226
+ 'bg-black/30 backdrop-blur-sm'
227
+ )}
228
+ >
229
+ {visibleDots.map((index) => (
230
+ <div
231
+ key={index}
232
+ className={cn(
233
+ 'rounded-full',
234
+ index === 0
235
+ ? 'w-2 h-2 bg-white shadow-[0_0_4px_rgba(255,255,255,0.8)]'
236
+ : 'w-1.5 h-1.5 bg-white/50'
237
+ )}
238
+ />
239
+ ))}
240
+ {remainingCount > 0 && (
241
+ <span className="text-white/80 text-[10px] font-medium ml-0.5">
242
+ +{remainingCount}
243
+ </span>
244
+ )}
245
+ </div>
246
+ )}
247
+ {/* Counter badge placeholder */}
248
+ {showCounter && (
249
+ <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">
250
+ 1 / {total}
251
+ </div>
252
+ )}
253
+ </div>
254
+ )
255
+ }
256
+
168
257
  return (
169
258
  <div
170
- className={cn('relative w-full h-full group/gallery', className)}
259
+ className={cn(containerClass, 'group/gallery', className)}
260
+ style={containerStyle}
171
261
  onClick={handleClick}
172
262
  onMouseEnter={() => setIsHovered(true)}
173
263
  onMouseLeave={() => setIsHovered(false)}
174
264
  >
175
265
  <Carousel
266
+ key={carouselKey}
176
267
  setApi={setApi}
177
268
  opts={{
178
269
  loop: true,
@@ -226,6 +317,8 @@ export const GalleryCompact = memo(function GalleryCompact({
226
317
  'focus:outline-none focus:ring-2 focus:ring-white/50'
227
318
  )}
228
319
  onClick={handlePrev}
320
+ onMouseDown={stopEvent}
321
+ onPointerDown={stopEvent}
229
322
  aria-label="Previous image"
230
323
  >
231
324
  <ChevronLeft className="w-4 h-4" />
@@ -246,6 +339,8 @@ export const GalleryCompact = memo(function GalleryCompact({
246
339
  'focus:outline-none focus:ring-2 focus:ring-white/50'
247
340
  )}
248
341
  onClick={handleNext}
342
+ onMouseDown={stopEvent}
343
+ onPointerDown={stopEvent}
249
344
  aria-label="Next image"
250
345
  >
251
346
  <ChevronRight className="w-4 h-4" />
@@ -1 +1 @@
1
- export { GalleryCompact, type GalleryCompactProps } from './GalleryCompact'
1
+ export { GalleryCompact, type GalleryCompactProps, type GalleryAspectRatio } from './GalleryCompact'
@@ -2,7 +2,7 @@
2
2
  export { Gallery } from './Gallery'
3
3
 
4
4
  // Compact mode (for cards)
5
- export { GalleryCompact, type GalleryCompactProps } from './compact'
5
+ export { GalleryCompact, type GalleryCompactProps, type GalleryAspectRatio } from './compact'
6
6
 
7
7
  // Preview modes
8
8
  export { GalleryCarousel } from './preview'
@@ -43,7 +43,7 @@ export const GalleryImage = memo(function GalleryImage({
43
43
 
44
44
  return (
45
45
  <div
46
- className={cn('relative overflow-hidden', className)}
46
+ className={cn('relative w-full h-full overflow-hidden', className)}
47
47
  onClick={onClick}
48
48
  >
49
49
  {/* Loading skeleton */}
@@ -56,7 +56,7 @@ export const GalleryImage = memo(function GalleryImage({
56
56
  <img
57
57
  src={image.src}
58
58
  alt={image.alt || 'Gallery image'}
59
- className="w-full h-full object-cover animate-in fade-in-0 duration-300"
59
+ className="absolute inset-0 w-full h-full object-cover animate-in fade-in-0 duration-300"
60
60
  loading={priority ? 'eager' : 'lazy'}
61
61
  decoding="async"
62
62
  />
@@ -1,7 +1,7 @@
1
1
  'use client'
2
2
 
3
3
  import * as React from 'react'
4
- import { memo, useCallback, useEffect, useMemo, useRef } from 'react'
4
+ import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
5
5
  import { cn } from '@djangocfg/ui-core/lib'
6
6
  import {
7
7
  Carousel,
@@ -65,6 +65,14 @@ export const GalleryCarousel = memo(function GalleryCarousel({
65
65
  className,
66
66
  }: GalleryCarouselProps) {
67
67
  const [api, setApi] = React.useState<CarouselApi>()
68
+ // Track if component is mounted (client-side) to avoid hydration mismatches
69
+ const [isMounted, setIsMounted] = useState(false)
70
+ // Stable key for carousel reset on images change
71
+ const carouselKey = images[0]?.id ?? 'empty'
72
+
73
+ useEffect(() => {
74
+ setIsMounted(true)
75
+ }, [])
68
76
 
69
77
  // Notify parent when API is ready
70
78
  useEffect(() => {
@@ -161,9 +169,86 @@ export const GalleryCarousel = memo(function GalleryCarousel({
161
169
  pointerStartRef.current = null
162
170
  }, [enableLightbox, onLightboxOpen])
163
171
 
172
+ // SSR fallback - render first image only to avoid hydration mismatch
173
+ // The carousel requires DOM access and will cause issues during SSR
174
+ if (!isMounted) {
175
+ return (
176
+ <div className={cn('space-y-3', className)}>
177
+ <div className="relative rounded-xl overflow-hidden">
178
+ <div
179
+ className="relative bg-muted"
180
+ style={{ aspectRatio }}
181
+ >
182
+ <GalleryMedia
183
+ media={images[0]}
184
+ className="w-full h-full"
185
+ priority
186
+ />
187
+ {/* Dark overlay */}
188
+ <div className="absolute inset-0 bg-gradient-to-t from-black/40 via-transparent to-black/20 pointer-events-none" />
189
+ </div>
190
+
191
+ {/* Counter */}
192
+ {showCounter && hasMultiple && (
193
+ <div
194
+ className={cn(
195
+ 'absolute bottom-3 right-3 z-10',
196
+ 'bg-black/60 backdrop-blur-sm text-white px-3 py-1.5 rounded-full',
197
+ 'text-sm font-medium'
198
+ )}
199
+ >
200
+ 1 / {total}
201
+ </div>
202
+ )}
203
+
204
+ {/* Mobile dots */}
205
+ {hasMultiple && (
206
+ <div className="absolute bottom-3 left-1/2 -translate-x-1/2 flex gap-2 md:hidden z-10">
207
+ {mobileDots.map((index) => (
208
+ <div
209
+ key={index}
210
+ className={cn(
211
+ 'rounded-full',
212
+ index === 0
213
+ ? 'w-2.5 h-2.5 bg-white shadow-[0_0_4px_rgba(255,255,255,0.8)]'
214
+ : 'w-2 h-2 bg-white/40'
215
+ )}
216
+ />
217
+ ))}
218
+ {remainingCount > 0 && (
219
+ <span className="text-white/70 text-xs ml-1">+{remainingCount}</span>
220
+ )}
221
+ </div>
222
+ )}
223
+ </div>
224
+
225
+ {/* Thumbnails placeholder */}
226
+ {showThumbnails && hasMultiple && (
227
+ <div className="hidden md:flex gap-2">
228
+ {images.slice(0, 8).map((image, index) => (
229
+ <div
230
+ key={image.id}
231
+ className={cn(
232
+ 'w-16 h-16 rounded-lg overflow-hidden bg-muted flex-shrink-0',
233
+ index === 0 && 'ring-2 ring-primary'
234
+ )}
235
+ >
236
+ <GalleryMedia
237
+ media={image}
238
+ className="w-full h-full"
239
+ />
240
+ </div>
241
+ ))}
242
+ </div>
243
+ )}
244
+ </div>
245
+ )
246
+ }
247
+
164
248
  return (
165
249
  <div className={cn('space-y-3', className)}>
166
250
  <Carousel
251
+ key={carouselKey}
167
252
  setApi={setApi}
168
253
  opts={{
169
254
  loop: true,
@@ -10,7 +10,7 @@ export {
10
10
  GalleryThumbnailsVirtual,
11
11
  GalleryLightbox,
12
12
  } from './components'
13
- export type { GalleryCompactProps, GalleryGridProps, GalleryGridLayout } from './components'
13
+ export type { GalleryCompactProps, GalleryAspectRatio, GalleryGridProps, GalleryGridLayout } from './components'
14
14
 
15
15
  // Hooks
16
16
  export {