@djangocfg/ui-tools 2.1.137 → 2.1.138

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.137",
3
+ "version": "2.1.138",
4
4
  "description": "Heavy React tools with lazy loading - for Electron, Vite, CRA, Next.js apps",
5
5
  "keywords": [
6
6
  "ui-tools",
@@ -63,8 +63,8 @@
63
63
  "check": "tsc --noEmit"
64
64
  },
65
65
  "peerDependencies": {
66
- "@djangocfg/i18n": "^2.1.137",
67
- "@djangocfg/ui-core": "^2.1.137",
66
+ "@djangocfg/i18n": "^2.1.138",
67
+ "@djangocfg/ui-core": "^2.1.138",
68
68
  "lucide-react": "^0.545.0",
69
69
  "react": "^19.0.0",
70
70
  "react-dom": "^19.0.0",
@@ -96,10 +96,10 @@
96
96
  "@maplibre/maplibre-gl-geocoder": "^1.7.0"
97
97
  },
98
98
  "devDependencies": {
99
- "@djangocfg/i18n": "^2.1.137",
99
+ "@djangocfg/i18n": "^2.1.138",
100
100
  "@djangocfg/playground": "workspace:*",
101
- "@djangocfg/typescript-config": "^2.1.137",
102
- "@djangocfg/ui-core": "^2.1.137",
101
+ "@djangocfg/typescript-config": "^2.1.138",
102
+ "@djangocfg/ui-core": "^2.1.138",
103
103
  "@types/mapbox__mapbox-gl-draw": "^1.4.8",
104
104
  "@types/node": "^24.7.2",
105
105
  "@types/react": "^19.1.0",
@@ -16,3 +16,6 @@ export { GalleryImage, GalleryVideo, GalleryMedia, type GalleryMediaProps } from
16
16
 
17
17
  // Thumbnails
18
18
  export { GalleryThumbnails, GalleryThumbnailsVirtual } from './thumbnails'
19
+
20
+ // Shared components
21
+ export { ImageSpinner, type ImageSpinnerProps } from './shared'
@@ -7,6 +7,7 @@ import { useImageLoader } from '@djangocfg/ui-core/hooks'
7
7
  import { ImageOff } from 'lucide-react'
8
8
  import type { GalleryImageProps } from '../../types'
9
9
  import { normalizeImageUrl } from '../../utils'
10
+ import { ImageSpinner } from '../shared'
10
11
 
11
12
  /**
12
13
  * GalleryImage - Single image with loading state and error handling
@@ -24,17 +25,18 @@ export const GalleryImage = memo(function GalleryImage({
24
25
  }: GalleryImageProps) {
25
26
  const t = useTypedT<I18nTranslations>()
26
27
  const failedToLoadText = useMemo(() => t('tools.image.failedToLoad'), [t])
28
+ const normalizedSrc = useMemo(() => normalizeImageUrl(image.src), [image.src])
27
29
  const callbacks = useMemo(() => ({ onLoad, onError }), [onLoad, onError])
28
- const { isLoading, isLoaded, hasError } = useImageLoader(image.src, callbacks)
30
+ const { isLoading, isLoaded, hasError } = useImageLoader(normalizedSrc, callbacks)
29
31
 
30
32
  const backgroundStyle = useMemo(() =>
31
33
  isLoaded && objectFit === 'cover' ? {
32
- backgroundImage: `url(${image.src})`,
34
+ backgroundImage: `url(${normalizedSrc})`,
33
35
  backgroundSize: 'cover',
34
36
  backgroundPosition: 'center',
35
37
  backgroundRepeat: 'no-repeat',
36
38
  } : undefined
37
- , [isLoaded, image.src, objectFit])
39
+ , [isLoaded, normalizedSrc, objectFit])
38
40
 
39
41
  if (hasError) {
40
42
  return (
@@ -57,15 +59,20 @@ export const GalleryImage = memo(function GalleryImage({
57
59
  return (
58
60
  <div
59
61
  className={cn(
60
- 'w-full h-full flex items-center justify-center',
61
- isLoading && 'bg-muted animate-pulse',
62
+ 'relative w-full h-full flex items-center justify-center bg-muted',
62
63
  className
63
64
  )}
64
65
  onClick={onClick}
65
66
  >
67
+ {/* Loading spinner */}
68
+ {isLoading && showLoading && (
69
+ <div className="absolute inset-0 flex items-center justify-center">
70
+ <ImageSpinner size="lg" />
71
+ </div>
72
+ )}
66
73
  {isLoaded && (
67
74
  <img
68
- src={image.src}
75
+ src={normalizedSrc}
69
76
  alt={image.alt || 'Gallery image'}
70
77
  className="max-w-full max-h-full object-contain animate-in fade-in-0 duration-300"
71
78
  />
@@ -78,8 +85,7 @@ export const GalleryImage = memo(function GalleryImage({
78
85
  return (
79
86
  <div
80
87
  className={cn(
81
- 'w-full h-full',
82
- isLoading && 'bg-muted animate-pulse',
88
+ 'relative w-full h-full bg-muted',
83
89
  isLoaded && 'animate-in fade-in-0 duration-300',
84
90
  className
85
91
  )}
@@ -87,6 +93,13 @@ export const GalleryImage = memo(function GalleryImage({
87
93
  onClick={onClick}
88
94
  role="img"
89
95
  aria-label={image.alt || 'Gallery image'}
90
- />
96
+ >
97
+ {/* Loading spinner */}
98
+ {isLoading && showLoading && (
99
+ <div className="absolute inset-0 flex items-center justify-center">
100
+ <ImageSpinner size="md" />
101
+ </div>
102
+ )}
103
+ </div>
91
104
  )
92
105
  })
@@ -1,10 +1,11 @@
1
1
  'use client'
2
2
 
3
- import { memo, useCallback, useMemo } from 'react'
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
6
  import type { GalleryMediaItem } from '../../types'
7
7
  import { normalizeImageUrl } from '../../utils'
8
+ import { ImageSpinner } from '../shared'
8
9
 
9
10
  export type GalleryGridLayout =
10
11
  | 'auto'
@@ -348,10 +349,21 @@ const GridItem = memo(function GridItem({
348
349
  showBadge = false,
349
350
  badgeCount = 0,
350
351
  }: GridItemProps) {
352
+ const [isLoaded, setIsLoaded] = useState(false)
353
+ const [hasError, setHasError] = useState(false)
354
+
351
355
  const handleClick = useCallback(() => {
352
356
  onClick(index)
353
357
  }, [onClick, index])
354
358
 
359
+ const handleLoad = useCallback(() => {
360
+ setIsLoaded(true)
361
+ }, [])
362
+
363
+ const handleError = useCallback(() => {
364
+ setHasError(true)
365
+ }, [])
366
+
355
367
  if (!image) return null
356
368
 
357
369
  const isVideo = image.type === 'video'
@@ -370,16 +382,26 @@ const GridItem = memo(function GridItem({
370
382
  onClick={handleClick}
371
383
  aria-label={`View image ${index + 1}`}
372
384
  >
385
+ {/* Loading spinner - shows until image loads */}
386
+ {!isLoaded && !hasError && (
387
+ <div className="absolute inset-0 flex items-center justify-center z-10">
388
+ <ImageSpinner size="md" />
389
+ </div>
390
+ )}
391
+
373
392
  <img
374
393
  src={normalizeImageUrl(image.thumbnail || image.src)}
375
394
  alt={image.alt || `Image ${index + 1}`}
376
395
  className={cn(
377
- 'w-full h-full object-cover transition-transform duration-300 group-hover:scale-105',
378
- // Staggered reveal animation with CSS only (no JS state needed)
379
- staggerDelay > 0 && 'animate-in fade-in zoom-in-95 duration-300 fill-mode-both'
396
+ 'w-full h-full object-cover transition-all duration-300 group-hover:scale-105',
397
+ // Hide until loaded, then fade in
398
+ !isLoaded && 'opacity-0',
399
+ isLoaded && 'opacity-100'
380
400
  )}
381
- style={staggerDelay > 0 ? { animationDelay } : undefined}
401
+ style={staggerDelay > 0 && isLoaded ? { animationDelay } : undefined}
382
402
  loading={index === 0 ? 'eager' : 'lazy'}
403
+ onLoad={handleLoad}
404
+ onError={handleError}
383
405
  />
384
406
 
385
407
  {/* Hover overlay */}
@@ -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'
@@ -1,8 +1,10 @@
1
1
  'use client'
2
2
 
3
- import { memo, useRef, useEffect, useCallback, Suspense, lazy } from 'react'
3
+ import { memo, useRef, useEffect, useCallback, Suspense, lazy, useState } from 'react'
4
4
  import { cn } from '@djangocfg/ui-core/lib'
5
5
  import type { GalleryThumbnailsProps } from '../../types'
6
+ import { normalizeImageUrl } from '../../utils'
7
+ import { ImageSpinner } from '../shared'
6
8
 
7
9
  // Lazy load virtualized thumbnails - only for 50+ images
8
10
  const GalleryThumbnailsVirtual = lazy(() =>
@@ -135,16 +137,22 @@ const ThumbnailButton = memo(
135
137
  onClick,
136
138
  ref,
137
139
  }: ThumbnailButtonProps & { ref?: React.Ref<HTMLButtonElement> }) {
140
+ const [isLoaded, setIsLoaded] = useState(false)
141
+
138
142
  const handleClick = useCallback(() => {
139
143
  onClick(index)
140
144
  }, [onClick, index])
141
145
 
146
+ const handleLoad = useCallback(() => {
147
+ setIsLoaded(true)
148
+ }, [])
149
+
142
150
  return (
143
151
  <button
144
152
  ref={ref}
145
153
  type="button"
146
154
  className={cn(
147
- 'relative flex-shrink-0 rounded-lg overflow-hidden',
155
+ 'relative flex-shrink-0 rounded-lg overflow-hidden bg-muted',
148
156
  'border-2 transition-all duration-200',
149
157
  'focus:outline-none focus-visible:ring-2 focus-visible:ring-primary',
150
158
  SIZES[size],
@@ -156,11 +164,22 @@ const ThumbnailButton = memo(
156
164
  aria-label={`View image ${index + 1}`}
157
165
  aria-current={isActive ? 'true' : undefined}
158
166
  >
167
+ {/* Loading spinner */}
168
+ {!isLoaded && (
169
+ <div className="absolute inset-0 flex items-center justify-center">
170
+ <ImageSpinner size="sm" />
171
+ </div>
172
+ )}
173
+
159
174
  <img
160
- src={image.thumbnail || image.src}
175
+ src={normalizeImageUrl(image.thumbnail || image.src)}
161
176
  alt={image.alt || `Thumbnail ${index + 1}`}
162
- className="w-full h-full object-cover"
177
+ className={cn(
178
+ 'w-full h-full object-cover transition-opacity duration-200',
179
+ isLoaded ? 'opacity-100' : 'opacity-0'
180
+ )}
163
181
  loading="lazy"
182
+ onLoad={handleLoad}
164
183
  />
165
184
 
166
185
  {/* Active overlay */}
@@ -1,9 +1,11 @@
1
1
  'use client'
2
2
 
3
- import { memo, useCallback, useEffect } from 'react'
3
+ import { memo, useCallback, useEffect, useState } from 'react'
4
4
  import { cn } from '@djangocfg/ui-core/lib'
5
5
  import { useVirtualList } from '../../hooks/useVirtualList'
6
6
  import type { GalleryThumbnailsProps } from '../../types'
7
+ import { normalizeImageUrl } from '../../utils'
8
+ import { ImageSpinner } from '../shared'
7
9
 
8
10
  const SIZES = {
9
11
  sm: { width: 56, height: 40 },
@@ -98,15 +100,21 @@ const VirtualThumbnail = memo(function VirtualThumbnail({
98
100
  height,
99
101
  onClick,
100
102
  }: VirtualThumbnailProps) {
103
+ const [isLoaded, setIsLoaded] = useState(false)
104
+
101
105
  const handleClick = useCallback(() => {
102
106
  onClick(index)
103
107
  }, [onClick, index])
104
108
 
109
+ const handleLoad = useCallback(() => {
110
+ setIsLoaded(true)
111
+ }, [])
112
+
105
113
  return (
106
114
  <button
107
115
  type="button"
108
116
  className={cn(
109
- 'absolute top-0 rounded-lg overflow-hidden',
117
+ 'absolute top-0 rounded-lg overflow-hidden bg-muted',
110
118
  'border-2 transition-all duration-200',
111
119
  'focus:outline-none focus-visible:ring-2 focus-visible:ring-primary',
112
120
  isActive
@@ -122,11 +130,22 @@ const VirtualThumbnail = memo(function VirtualThumbnail({
122
130
  aria-label={`View image ${index + 1}`}
123
131
  aria-current={isActive ? 'true' : undefined}
124
132
  >
133
+ {/* Loading spinner */}
134
+ {!isLoaded && (
135
+ <div className="absolute inset-0 flex items-center justify-center">
136
+ <ImageSpinner size="sm" />
137
+ </div>
138
+ )}
139
+
125
140
  <img
126
- src={image.thumbnail || image.src}
141
+ src={normalizeImageUrl(image.thumbnail || image.src)}
127
142
  alt={image.alt || `Thumbnail ${index + 1}`}
128
- className="w-full h-full object-cover"
143
+ className={cn(
144
+ 'w-full h-full object-cover transition-opacity duration-200',
145
+ isLoaded ? 'opacity-100' : 'opacity-0'
146
+ )}
129
147
  loading="lazy"
148
+ onLoad={handleLoad}
130
149
  />
131
150
 
132
151
  {/* Active overlay */}