@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 +6 -6
- package/src/tools/Gallery/components/index.ts +3 -0
- package/src/tools/Gallery/components/media/GalleryImage.tsx +22 -9
- package/src/tools/Gallery/components/preview/GalleryGrid.tsx +27 -5
- package/src/tools/Gallery/components/shared/ImageSpinner.tsx +37 -0
- package/src/tools/Gallery/components/shared/index.ts +1 -0
- package/src/tools/Gallery/components/thumbnails/GalleryThumbnails.tsx +23 -4
- package/src/tools/Gallery/components/thumbnails/GalleryThumbnailsVirtual.tsx +23 -4
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@djangocfg/ui-tools",
|
|
3
|
-
"version": "2.1.
|
|
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.
|
|
67
|
-
"@djangocfg/ui-core": "^2.1.
|
|
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.
|
|
99
|
+
"@djangocfg/i18n": "^2.1.138",
|
|
100
100
|
"@djangocfg/playground": "workspace:*",
|
|
101
|
-
"@djangocfg/typescript-config": "^2.1.
|
|
102
|
-
"@djangocfg/ui-core": "^2.1.
|
|
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(
|
|
30
|
+
const { isLoading, isLoaded, hasError } = useImageLoader(normalizedSrc, callbacks)
|
|
29
31
|
|
|
30
32
|
const backgroundStyle = useMemo(() =>
|
|
31
33
|
isLoaded && objectFit === 'cover' ? {
|
|
32
|
-
backgroundImage: `url(${
|
|
34
|
+
backgroundImage: `url(${normalizedSrc})`,
|
|
33
35
|
backgroundSize: 'cover',
|
|
34
36
|
backgroundPosition: 'center',
|
|
35
37
|
backgroundRepeat: 'no-repeat',
|
|
36
38
|
} : undefined
|
|
37
|
-
, [isLoaded,
|
|
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={
|
|
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-
|
|
378
|
-
//
|
|
379
|
-
|
|
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=
|
|
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=
|
|
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 */}
|