@djangocfg/widget-visual 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +41 -0
  3. package/package.json +106 -0
  4. package/src/design/ColorPalette/ColorPalette.tsx +129 -0
  5. package/src/design/ColorPalette/README.md +34 -0
  6. package/src/design/ColorPalette/color-palette.stories.tsx +95 -0
  7. package/src/design/ColorPalette/components/Swatch.tsx +102 -0
  8. package/src/design/ColorPalette/hooks/useCopyToClipboard.ts +41 -0
  9. package/src/design/ColorPalette/index.ts +12 -0
  10. package/src/design/ColorPalette/lazy.tsx +21 -0
  11. package/src/design/ColorPalette/types.ts +63 -0
  12. package/src/design/ColorPalette/utils.ts +114 -0
  13. package/src/design/ColorPicker/ColorPicker.tsx +257 -0
  14. package/src/design/ColorPicker/color-picker.stories.tsx +113 -0
  15. package/src/design/ColorPicker/context/ColorPickerContext.tsx +28 -0
  16. package/src/design/ColorPicker/context/ColorPickerStore.tsx +166 -0
  17. package/src/design/ColorPicker/context/index.ts +2 -0
  18. package/src/design/ColorPicker/index.ts +15 -0
  19. package/src/design/ColorPicker/lazy.tsx +24 -0
  20. package/src/design/ColorPicker/lib/color-utils.ts +323 -0
  21. package/src/design/ColorPicker/parts/ColorPickerAlphaSlider.tsx +72 -0
  22. package/src/design/ColorPicker/parts/ColorPickerArea.tsx +155 -0
  23. package/src/design/ColorPicker/parts/ColorPickerEyeDropper.tsx +73 -0
  24. package/src/design/ColorPicker/parts/ColorPickerFormatSelect.tsx +64 -0
  25. package/src/design/ColorPicker/parts/ColorPickerHueSlider.tsx +64 -0
  26. package/src/design/ColorPicker/parts/ColorPickerInput.tsx +512 -0
  27. package/src/design/ColorPicker/parts/ColorPickerSwatch.tsx +63 -0
  28. package/src/design/ColorPicker/parts/index.ts +7 -0
  29. package/src/design/ColorPicker/types.ts +77 -0
  30. package/src/gallery/components/Gallery.tsx +182 -0
  31. package/src/gallery/components/compact/GalleryCompact.tsx +396 -0
  32. package/src/gallery/components/compact/index.ts +1 -0
  33. package/src/gallery/components/index.ts +21 -0
  34. package/src/gallery/components/lightbox/GalleryLightbox.tsx +426 -0
  35. package/src/gallery/components/lightbox/index.ts +1 -0
  36. package/src/gallery/components/media/GalleryImage.tsx +105 -0
  37. package/src/gallery/components/media/GalleryMedia.tsx +70 -0
  38. package/src/gallery/components/media/GalleryVideo.tsx +241 -0
  39. package/src/gallery/components/media/index.ts +3 -0
  40. package/src/gallery/components/preview/GalleryCarousel.tsx +374 -0
  41. package/src/gallery/components/preview/GalleryGrid.tsx +519 -0
  42. package/src/gallery/components/preview/index.ts +2 -0
  43. package/src/gallery/components/shared/ImageSpinner.tsx +37 -0
  44. package/src/gallery/components/shared/index.ts +1 -0
  45. package/src/gallery/components/thumbnails/GalleryThumbnails.tsx +198 -0
  46. package/src/gallery/components/thumbnails/GalleryThumbnailsVirtual.tsx +164 -0
  47. package/src/gallery/components/thumbnails/index.ts +2 -0
  48. package/src/gallery/gallery.stories.tsx +182 -0
  49. package/src/gallery/hooks/index.ts +23 -0
  50. package/src/gallery/hooks/useGallery.ts +138 -0
  51. package/src/gallery/hooks/useImageDimensions.ts +224 -0
  52. package/src/gallery/hooks/usePinchZoom.ts +239 -0
  53. package/src/gallery/hooks/usePreloadImages.ts +119 -0
  54. package/src/gallery/hooks/useSwipe.ts +87 -0
  55. package/src/gallery/hooks/useVirtualList.ts +129 -0
  56. package/src/gallery/hooks/useZoom.ts +321 -0
  57. package/src/gallery/index.ts +66 -0
  58. package/src/gallery/thumbnails.stories.tsx +127 -0
  59. package/src/gallery/types.ts +185 -0
  60. package/src/gallery/utils/imageAnalysis.ts +52 -0
  61. package/src/gallery/utils/index.ts +16 -0
  62. package/src/gallery/utils/normalizeUrl.ts +31 -0
  63. package/src/index.ts +18 -0
  64. package/src/lottie/LottiePlayer.client.tsx +319 -0
  65. package/src/lottie/index.tsx +66 -0
  66. package/src/lottie/lazy.tsx +65 -0
  67. package/src/lottie/lottie-player.stories.tsx +173 -0
  68. package/src/lottie/types.ts +138 -0
  69. package/src/lottie/useLottie.ts +187 -0
  70. package/src/lottie/usePrefersReducedMotion.ts +46 -0
  71. package/src/marquee/Marquee.tsx +637 -0
  72. package/src/marquee/index.ts +7 -0
  73. package/src/marquee/lazy.tsx +14 -0
  74. package/src/marquee/marquee.stories.tsx +118 -0
  75. package/src/marquee/types.ts +40 -0
  76. package/src/qrcode/QRCode.tsx +468 -0
  77. package/src/qrcode/index.ts +10 -0
  78. package/src/qrcode/lazy.tsx +19 -0
  79. package/src/qrcode/qr-code.stories.tsx +102 -0
  80. package/src/qrcode/types.ts +57 -0
@@ -0,0 +1,519 @@
1
+ 'use client'
2
+
3
+ import { memo, useCallback, useEffect, useMemo, useState } from 'react'
4
+ import { cn } from '@djangocfg/ui-core/lib'
5
+ import { Play } from 'lucide-react'
6
+ import type { GalleryMediaItem } from '../../types'
7
+ import { normalizeImageUrl } from '../../utils'
8
+ import { ImageSpinner } from '../shared'
9
+
10
+ export type GalleryGridLayout =
11
+ | 'auto'
12
+ | 'single'
13
+ | 'two-cols'
14
+ | 'hero-left'
15
+ | 'grid-2x2'
16
+ | 'mosaic-5'
17
+
18
+ export interface GalleryGridProps {
19
+ /** Array of images to display */
20
+ images: GalleryMediaItem[]
21
+ /** Maximum images to show in grid */
22
+ maxVisible?: number
23
+ /** Grid layout (auto picks based on count) */
24
+ layout?: GalleryGridLayout
25
+ /** Aspect ratio for the grid container */
26
+ aspectRatio?: number
27
+ /** Gap between grid items (tailwind spacing) */
28
+ gap?: 1 | 2 | 3 | 4
29
+ /** Border radius */
30
+ rounded?: 'none' | 'sm' | 'md' | 'lg' | 'xl' | '2xl'
31
+ /** Callback when image is clicked */
32
+ onImageClick?: (index: number) => void
33
+ /** Show "+N more" badge */
34
+ showMoreBadge?: boolean
35
+ /** Show loading skeleton */
36
+ loading?: boolean
37
+ /** Stagger delay between image reveals (ms), 0 to disable */
38
+ staggerDelay?: number
39
+ /** Additional CSS class */
40
+ className?: string
41
+ }
42
+
43
+ const GAP_CLASSES = {
44
+ 1: 'gap-1',
45
+ 2: 'gap-2',
46
+ 3: 'gap-3',
47
+ 4: 'gap-4',
48
+ } as const
49
+
50
+ const ROUNDED_CLASSES = {
51
+ none: 'rounded-none',
52
+ sm: 'rounded-sm',
53
+ md: 'rounded-md',
54
+ lg: 'rounded-lg',
55
+ xl: 'rounded-xl',
56
+ '2xl': 'rounded-2xl',
57
+ } as const
58
+
59
+ /**
60
+ * Get layout based on image count
61
+ */
62
+ function getLayoutForCount(count: number): GalleryGridLayout {
63
+ if (count === 1) return 'single'
64
+ if (count === 2) return 'two-cols'
65
+ if (count === 3) return 'hero-left'
66
+ if (count === 4) return 'grid-2x2'
67
+ return 'mosaic-5'
68
+ }
69
+
70
+ /**
71
+ * GalleryGrid - Fixed layout grid for property images
72
+ *
73
+ * Airbnb-style layouts based on image count:
74
+ * - 1 image: full width
75
+ * - 2 images: side by side
76
+ * - 3 images: hero left + 2 stacked right
77
+ * - 4 images: 2x2 grid
78
+ * - 5+ images: hero left + 2x2 grid right
79
+ */
80
+ export const GalleryGrid = memo(function GalleryGrid({
81
+ images,
82
+ maxVisible = 5,
83
+ layout = 'auto',
84
+ aspectRatio = 16 / 9,
85
+ gap = 2,
86
+ rounded = 'xl',
87
+ onImageClick,
88
+ showMoreBadge = true,
89
+ loading = false,
90
+ staggerDelay = 75,
91
+ className,
92
+ }: GalleryGridProps) {
93
+ const visibleImages = useMemo(() => {
94
+ return images.slice(0, maxVisible)
95
+ }, [images, maxVisible])
96
+
97
+ const currentLayout = useMemo((): GalleryGridLayout => {
98
+ if (layout !== 'auto') return layout
99
+ return getLayoutForCount(visibleImages.length || maxVisible)
100
+ }, [layout, visibleImages.length, maxVisible])
101
+
102
+ const remainingCount = useMemo(() => {
103
+ return Math.max(0, images.length - maxVisible)
104
+ }, [images.length, maxVisible])
105
+
106
+ const handleClick = useCallback(
107
+ (index: number) => {
108
+ onImageClick?.(index)
109
+ },
110
+ [onImageClick]
111
+ )
112
+
113
+ // Show skeleton when loading or no images yet
114
+ if (loading || images.length === 0) {
115
+ return (
116
+ <div
117
+ className={cn(
118
+ 'relative overflow-hidden',
119
+ ROUNDED_CLASSES[rounded],
120
+ className
121
+ )}
122
+ style={{ aspectRatio }}
123
+ >
124
+ <div className="absolute inset-0">
125
+ <GridSkeleton layout={currentLayout} gap={gap} />
126
+ </div>
127
+ </div>
128
+ )
129
+ }
130
+
131
+ return (
132
+ <div
133
+ className={cn(
134
+ 'relative overflow-hidden',
135
+ ROUNDED_CLASSES[rounded],
136
+ className
137
+ )}
138
+ style={{ aspectRatio }}
139
+ >
140
+ <div className="absolute inset-0">
141
+ <GridLayout
142
+ layout={currentLayout}
143
+ images={visibleImages}
144
+ gap={gap}
145
+ remainingCount={remainingCount}
146
+ showMoreBadge={showMoreBadge}
147
+ staggerDelay={staggerDelay}
148
+ onImageClick={handleClick}
149
+ />
150
+ </div>
151
+ </div>
152
+ )
153
+ })
154
+
155
+ // Grid layout renderer
156
+ interface GridLayoutProps {
157
+ layout: GalleryGridLayout
158
+ images: GalleryMediaItem[]
159
+ gap: 1 | 2 | 3 | 4
160
+ remainingCount: number
161
+ showMoreBadge: boolean
162
+ staggerDelay: number
163
+ onImageClick: (index: number) => void
164
+ }
165
+
166
+ const GridLayout = memo(function GridLayout({
167
+ layout,
168
+ images,
169
+ gap,
170
+ remainingCount,
171
+ showMoreBadge,
172
+ staggerDelay,
173
+ onImageClick,
174
+ }: GridLayoutProps) {
175
+ if (!images || images.length === 0) return null
176
+
177
+ // Guarded non-empty above; bound once so every layout can index it safely.
178
+ const firstImage = images[0]
179
+ if (!firstImage) return null
180
+
181
+ switch (layout) {
182
+ case 'single':
183
+ return (
184
+ <GridItem
185
+ image={firstImage}
186
+ index={0}
187
+ staggerDelay={staggerDelay}
188
+ onClick={onImageClick}
189
+ className="w-full h-full"
190
+ />
191
+ )
192
+
193
+ case 'two-cols':
194
+ return (
195
+ <div className={cn('grid grid-cols-2 h-full', GAP_CLASSES[gap])}>
196
+ {images.slice(0, 2).map((image, index) => (
197
+ <GridItem
198
+ key={image.id}
199
+ image={image}
200
+ index={index}
201
+ staggerDelay={staggerDelay}
202
+ onClick={onImageClick}
203
+ showBadge={showMoreBadge && index === 1 && remainingCount > 0}
204
+ badgeCount={remainingCount}
205
+ />
206
+ ))}
207
+ </div>
208
+ )
209
+
210
+ case 'hero-left':
211
+ return (
212
+ <div
213
+ className={cn('h-full', GAP_CLASSES[gap])}
214
+ style={{
215
+ display: 'grid',
216
+ gridTemplateColumns: '1fr 1fr',
217
+ }}
218
+ >
219
+ <GridItem
220
+ image={firstImage}
221
+ index={0}
222
+ staggerDelay={staggerDelay}
223
+ onClick={onImageClick}
224
+ />
225
+ <div
226
+ className={cn('h-full overflow-hidden', GAP_CLASSES[gap])}
227
+ style={{
228
+ display: 'grid',
229
+ gridTemplateRows: '1fr 1fr',
230
+ }}
231
+ >
232
+ {images.slice(1, 3).map((image, index) => (
233
+ <GridItem
234
+ key={image.id}
235
+ image={image}
236
+ index={index + 1}
237
+ staggerDelay={staggerDelay}
238
+ onClick={onImageClick}
239
+ showBadge={showMoreBadge && index === 1 && remainingCount > 0}
240
+ badgeCount={remainingCount}
241
+ />
242
+ ))}
243
+ </div>
244
+ </div>
245
+ )
246
+
247
+ case 'grid-2x2':
248
+ return (
249
+ <div className={cn('grid grid-cols-2 grid-rows-2 h-full', GAP_CLASSES[gap])}>
250
+ {images.slice(0, 4).map((image, index) => (
251
+ <GridItem
252
+ key={image.id}
253
+ image={image}
254
+ index={index}
255
+ staggerDelay={staggerDelay}
256
+ onClick={onImageClick}
257
+ showBadge={showMoreBadge && index === 3 && remainingCount > 0}
258
+ badgeCount={remainingCount}
259
+ />
260
+ ))}
261
+ </div>
262
+ )
263
+
264
+ case 'mosaic-5':
265
+ default:
266
+ // Airbnb-style: hero (50%) + 2x2 grid (50%) using single grid with areas
267
+ return (
268
+ <div
269
+ className={cn('h-full', GAP_CLASSES[gap])}
270
+ style={{
271
+ display: 'grid',
272
+ gridTemplateColumns: '1fr 1fr 1fr 1fr',
273
+ gridTemplateRows: '1fr 1fr',
274
+ gridTemplateAreas: `
275
+ "hero hero img2 img3"
276
+ "hero hero img4 img5"
277
+ `,
278
+ }}
279
+ >
280
+ <GridItem
281
+ image={firstImage}
282
+ index={0}
283
+ staggerDelay={staggerDelay}
284
+ onClick={onImageClick}
285
+ style={{ gridArea: 'hero' }}
286
+ />
287
+ {images[1] && (
288
+ <GridItem
289
+ key={images[1].id}
290
+ image={images[1]}
291
+ index={1}
292
+ staggerDelay={staggerDelay}
293
+ onClick={onImageClick}
294
+ style={{ gridArea: 'img2' }}
295
+ />
296
+ )}
297
+ {images[2] && (
298
+ <GridItem
299
+ key={images[2].id}
300
+ image={images[2]}
301
+ index={2}
302
+ staggerDelay={staggerDelay}
303
+ onClick={onImageClick}
304
+ style={{ gridArea: 'img3' }}
305
+ />
306
+ )}
307
+ {images[3] && (
308
+ <GridItem
309
+ key={images[3].id}
310
+ image={images[3]}
311
+ index={3}
312
+ staggerDelay={staggerDelay}
313
+ onClick={onImageClick}
314
+ style={{ gridArea: 'img4' }}
315
+ />
316
+ )}
317
+ {images[4] && (
318
+ <GridItem
319
+ key={images[4].id}
320
+ image={images[4]}
321
+ index={4}
322
+ staggerDelay={staggerDelay}
323
+ onClick={onImageClick}
324
+ showBadge={showMoreBadge && remainingCount > 0}
325
+ badgeCount={remainingCount}
326
+ style={{ gridArea: 'img5' }}
327
+ />
328
+ )}
329
+ </div>
330
+ )
331
+ }
332
+ })
333
+
334
+ // Single grid item
335
+ interface GridItemProps {
336
+ image: GalleryMediaItem
337
+ index: number
338
+ staggerDelay: number
339
+ onClick: (index: number) => void
340
+ className?: string
341
+ style?: React.CSSProperties
342
+ showBadge?: boolean
343
+ badgeCount?: number
344
+ }
345
+
346
+ const GridItem = memo(function GridItem({
347
+ image,
348
+ index,
349
+ staggerDelay,
350
+ onClick,
351
+ className,
352
+ style,
353
+ showBadge = false,
354
+ badgeCount = 0,
355
+ }: GridItemProps) {
356
+ const [isLoaded, setIsLoaded] = useState(false)
357
+ const [hasError, setHasError] = useState(false)
358
+
359
+ // Reset loading state when image source changes
360
+ const imageSrc = image?.thumbnail || image?.src
361
+ useEffect(() => {
362
+ setIsLoaded(false)
363
+ setHasError(false)
364
+ }, [imageSrc])
365
+
366
+ const handleClick = useCallback(() => {
367
+ onClick(index)
368
+ }, [onClick, index])
369
+
370
+ const handleLoad = useCallback(() => {
371
+ setIsLoaded(true)
372
+ }, [])
373
+
374
+ const handleError = useCallback(() => {
375
+ setHasError(true)
376
+ }, [])
377
+
378
+ if (!image) return null
379
+
380
+ const isVideo = image.type === 'video'
381
+ const animationDelay = staggerDelay > 0 ? `${index * staggerDelay}ms` : '0ms'
382
+
383
+ return (
384
+ <button
385
+ type="button"
386
+ className={cn(
387
+ 'relative overflow-hidden bg-muted h-full w-full min-w-0 min-h-0',
388
+ 'focus:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-inset',
389
+ 'group',
390
+ className
391
+ )}
392
+ style={style}
393
+ onClick={handleClick}
394
+ aria-label={`View image ${index + 1}`}
395
+ >
396
+ {/* Loading spinner - shows until image loads */}
397
+ {!isLoaded && !hasError && (
398
+ <div className="absolute inset-0 flex items-center justify-center z-10">
399
+ <ImageSpinner size="md" />
400
+ </div>
401
+ )}
402
+
403
+ <img
404
+ src={normalizeImageUrl(image.thumbnail || image.src)}
405
+ alt={image.alt || `Image ${index + 1}`}
406
+ className={cn(
407
+ 'w-full h-full object-cover transition-all duration-300 group-hover:scale-105',
408
+ // Hide until loaded, then fade in
409
+ !isLoaded && 'opacity-0',
410
+ isLoaded && 'opacity-100'
411
+ )}
412
+ style={staggerDelay > 0 && isLoaded ? { animationDelay } : undefined}
413
+ loading={index === 0 ? 'eager' : 'lazy'}
414
+ onLoad={handleLoad}
415
+ onError={handleError}
416
+ />
417
+
418
+ {/* Hover overlay */}
419
+ <div className="absolute inset-0 bg-black/0 group-hover:bg-black/10 transition-colors" />
420
+
421
+ {/* Video indicator */}
422
+ {isVideo && (
423
+ <div className="absolute inset-0 flex items-center justify-center">
424
+ <div className="w-12 h-12 rounded-full bg-black/60 flex items-center justify-center">
425
+ <Play className="w-6 h-6 text-white ml-0.5" />
426
+ </div>
427
+ </div>
428
+ )}
429
+
430
+ {/* "+N more" badge */}
431
+ {showBadge && badgeCount > 0 && (
432
+ <div className="absolute inset-0 bg-black/50 flex items-center justify-center">
433
+ <span className="text-white text-xl font-semibold">
434
+ +{badgeCount}
435
+ </span>
436
+ </div>
437
+ )}
438
+ </button>
439
+ )
440
+ })
441
+
442
+ // Skeleton component matching grid layouts
443
+ interface GridSkeletonProps {
444
+ layout: GalleryGridLayout
445
+ gap: 1 | 2 | 3 | 4
446
+ }
447
+
448
+ const GridSkeleton = memo(function GridSkeleton({ layout, gap }: GridSkeletonProps) {
449
+ const skeletonCell = "bg-muted animate-pulse"
450
+
451
+ switch (layout) {
452
+ case 'single':
453
+ return <div className={cn('h-full w-full', skeletonCell)} />
454
+
455
+ case 'two-cols':
456
+ return (
457
+ <div className={cn('grid grid-cols-2 h-full', GAP_CLASSES[gap])}>
458
+ <div className={skeletonCell} />
459
+ <div className={skeletonCell} />
460
+ </div>
461
+ )
462
+
463
+ case 'hero-left':
464
+ return (
465
+ <div
466
+ className={cn('h-full', GAP_CLASSES[gap])}
467
+ style={{
468
+ display: 'grid',
469
+ gridTemplateColumns: '1fr 1fr',
470
+ }}
471
+ >
472
+ <div className={skeletonCell} />
473
+ <div
474
+ className={cn('h-full', GAP_CLASSES[gap])}
475
+ style={{
476
+ display: 'grid',
477
+ gridTemplateRows: '1fr 1fr',
478
+ }}
479
+ >
480
+ <div className={skeletonCell} />
481
+ <div className={skeletonCell} />
482
+ </div>
483
+ </div>
484
+ )
485
+
486
+ case 'grid-2x2':
487
+ return (
488
+ <div className={cn('grid grid-cols-2 grid-rows-2 h-full', GAP_CLASSES[gap])}>
489
+ <div className={skeletonCell} />
490
+ <div className={skeletonCell} />
491
+ <div className={skeletonCell} />
492
+ <div className={skeletonCell} />
493
+ </div>
494
+ )
495
+
496
+ case 'mosaic-5':
497
+ default:
498
+ return (
499
+ <div
500
+ className={cn('h-full', GAP_CLASSES[gap])}
501
+ style={{
502
+ display: 'grid',
503
+ gridTemplateColumns: '1fr 1fr 1fr 1fr',
504
+ gridTemplateRows: '1fr 1fr',
505
+ gridTemplateAreas: `
506
+ "hero hero img2 img3"
507
+ "hero hero img4 img5"
508
+ `,
509
+ }}
510
+ >
511
+ <div className={skeletonCell} style={{ gridArea: 'hero' }} />
512
+ <div className={skeletonCell} style={{ gridArea: 'img2' }} />
513
+ <div className={skeletonCell} style={{ gridArea: 'img3' }} />
514
+ <div className={skeletonCell} style={{ gridArea: 'img4' }} />
515
+ <div className={skeletonCell} style={{ gridArea: 'img5' }} />
516
+ </div>
517
+ )
518
+ }
519
+ })
@@ -0,0 +1,2 @@
1
+ export { GalleryCarousel } from './GalleryCarousel'
2
+ export { GalleryGrid, type GalleryGridProps, type GalleryGridLayout } from './GalleryGrid'
@@ -0,0 +1,37 @@
1
+ 'use client'
2
+
3
+ import { memo } from 'react'
4
+ import { cn } from '@djangocfg/ui-core/lib'
5
+
6
+ export interface ImageSpinnerProps {
7
+ /** Size variant */
8
+ size?: 'sm' | 'md' | 'lg'
9
+ /** Additional class */
10
+ className?: string
11
+ }
12
+
13
+ const SIZE_CLASSES = {
14
+ sm: 'w-4 h-4 border-[1.5px]',
15
+ md: 'w-6 h-6 border-2',
16
+ lg: 'w-8 h-8 border-2',
17
+ } as const
18
+
19
+ /**
20
+ * Minimal loading spinner for images
21
+ * Adapts to dark/light theme via muted-foreground
22
+ */
23
+ export const ImageSpinner = memo(function ImageSpinner({
24
+ size = 'md',
25
+ className,
26
+ }: ImageSpinnerProps) {
27
+ return (
28
+ <div
29
+ className={cn(
30
+ 'rounded-full animate-spin',
31
+ 'border-muted-foreground/20 border-t-muted-foreground/60',
32
+ SIZE_CLASSES[size],
33
+ className
34
+ )}
35
+ />
36
+ )
37
+ })
@@ -0,0 +1 @@
1
+ export { ImageSpinner, type ImageSpinnerProps } from './ImageSpinner'