@djangocfg/ui-tools 2.1.136 → 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 +23 -9
- package/src/tools/Gallery/components/preview/GalleryGrid.tsx +20 -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/src/tools/Gallery/utils/index.ts +5 -0
- package/src/tools/Gallery/utils/normalizeUrl.ts +31 -0
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'
|
|
@@ -6,6 +6,8 @@ import { useTypedT, type I18nTranslations } from '@djangocfg/i18n'
|
|
|
6
6
|
import { useImageLoader } from '@djangocfg/ui-core/hooks'
|
|
7
7
|
import { ImageOff } from 'lucide-react'
|
|
8
8
|
import type { GalleryImageProps } from '../../types'
|
|
9
|
+
import { normalizeImageUrl } from '../../utils'
|
|
10
|
+
import { ImageSpinner } from '../shared'
|
|
9
11
|
|
|
10
12
|
/**
|
|
11
13
|
* GalleryImage - Single image with loading state and error handling
|
|
@@ -23,17 +25,18 @@ export const GalleryImage = memo(function GalleryImage({
|
|
|
23
25
|
}: GalleryImageProps) {
|
|
24
26
|
const t = useTypedT<I18nTranslations>()
|
|
25
27
|
const failedToLoadText = useMemo(() => t('tools.image.failedToLoad'), [t])
|
|
28
|
+
const normalizedSrc = useMemo(() => normalizeImageUrl(image.src), [image.src])
|
|
26
29
|
const callbacks = useMemo(() => ({ onLoad, onError }), [onLoad, onError])
|
|
27
|
-
const { isLoading, isLoaded, hasError } = useImageLoader(
|
|
30
|
+
const { isLoading, isLoaded, hasError } = useImageLoader(normalizedSrc, callbacks)
|
|
28
31
|
|
|
29
32
|
const backgroundStyle = useMemo(() =>
|
|
30
33
|
isLoaded && objectFit === 'cover' ? {
|
|
31
|
-
backgroundImage: `url(${
|
|
34
|
+
backgroundImage: `url(${normalizedSrc})`,
|
|
32
35
|
backgroundSize: 'cover',
|
|
33
36
|
backgroundPosition: 'center',
|
|
34
37
|
backgroundRepeat: 'no-repeat',
|
|
35
38
|
} : undefined
|
|
36
|
-
, [isLoaded,
|
|
39
|
+
, [isLoaded, normalizedSrc, objectFit])
|
|
37
40
|
|
|
38
41
|
if (hasError) {
|
|
39
42
|
return (
|
|
@@ -56,15 +59,20 @@ export const GalleryImage = memo(function GalleryImage({
|
|
|
56
59
|
return (
|
|
57
60
|
<div
|
|
58
61
|
className={cn(
|
|
59
|
-
'w-full h-full flex items-center justify-center',
|
|
60
|
-
isLoading && 'bg-muted animate-pulse',
|
|
62
|
+
'relative w-full h-full flex items-center justify-center bg-muted',
|
|
61
63
|
className
|
|
62
64
|
)}
|
|
63
65
|
onClick={onClick}
|
|
64
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
|
+
)}
|
|
65
73
|
{isLoaded && (
|
|
66
74
|
<img
|
|
67
|
-
src={
|
|
75
|
+
src={normalizedSrc}
|
|
68
76
|
alt={image.alt || 'Gallery image'}
|
|
69
77
|
className="max-w-full max-h-full object-contain animate-in fade-in-0 duration-300"
|
|
70
78
|
/>
|
|
@@ -77,8 +85,7 @@ export const GalleryImage = memo(function GalleryImage({
|
|
|
77
85
|
return (
|
|
78
86
|
<div
|
|
79
87
|
className={cn(
|
|
80
|
-
'w-full h-full',
|
|
81
|
-
isLoading && 'bg-muted animate-pulse',
|
|
88
|
+
'relative w-full h-full bg-muted',
|
|
82
89
|
isLoaded && 'animate-in fade-in-0 duration-300',
|
|
83
90
|
className
|
|
84
91
|
)}
|
|
@@ -86,6 +93,13 @@ export const GalleryImage = memo(function GalleryImage({
|
|
|
86
93
|
onClick={onClick}
|
|
87
94
|
role="img"
|
|
88
95
|
aria-label={image.alt || 'Gallery image'}
|
|
89
|
-
|
|
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>
|
|
90
104
|
)
|
|
91
105
|
})
|
|
@@ -4,6 +4,8 @@ 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
|
+
import { normalizeImageUrl } from '../../utils'
|
|
8
|
+
import { ImageSpinner } from '../shared'
|
|
7
9
|
|
|
8
10
|
export type GalleryGridLayout =
|
|
9
11
|
| 'auto'
|
|
@@ -348,6 +350,7 @@ const GridItem = memo(function GridItem({
|
|
|
348
350
|
badgeCount = 0,
|
|
349
351
|
}: GridItemProps) {
|
|
350
352
|
const [isLoaded, setIsLoaded] = useState(false)
|
|
353
|
+
const [hasError, setHasError] = useState(false)
|
|
351
354
|
|
|
352
355
|
const handleClick = useCallback(() => {
|
|
353
356
|
onClick(index)
|
|
@@ -357,6 +360,10 @@ const GridItem = memo(function GridItem({
|
|
|
357
360
|
setIsLoaded(true)
|
|
358
361
|
}, [])
|
|
359
362
|
|
|
363
|
+
const handleError = useCallback(() => {
|
|
364
|
+
setHasError(true)
|
|
365
|
+
}, [])
|
|
366
|
+
|
|
360
367
|
if (!image) return null
|
|
361
368
|
|
|
362
369
|
const isVideo = image.type === 'video'
|
|
@@ -375,18 +382,26 @@ const GridItem = memo(function GridItem({
|
|
|
375
382
|
onClick={handleClick}
|
|
376
383
|
aria-label={`View image ${index + 1}`}
|
|
377
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
|
+
|
|
378
392
|
<img
|
|
379
|
-
src={image.thumbnail || image.src}
|
|
393
|
+
src={normalizeImageUrl(image.thumbnail || image.src)}
|
|
380
394
|
alt={image.alt || `Image ${index + 1}`}
|
|
381
395
|
className={cn(
|
|
382
|
-
'w-full h-full object-cover transition-
|
|
383
|
-
//
|
|
384
|
-
|
|
385
|
-
|
|
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'
|
|
386
400
|
)}
|
|
387
401
|
style={staggerDelay > 0 && isLoaded ? { animationDelay } : undefined}
|
|
388
402
|
loading={index === 0 ? 'eager' : 'lazy'}
|
|
389
403
|
onLoad={handleLoad}
|
|
404
|
+
onError={handleError}
|
|
390
405
|
/>
|
|
391
406
|
|
|
392
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 */}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* URL normalization utilities for Gallery
|
|
3
|
+
*
|
|
4
|
+
* Handles Mixed Content issues by upgrading HTTP to HTTPS
|
|
5
|
+
* when the page is served over HTTPS.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Normalize image URL to prevent Mixed Content errors
|
|
10
|
+
* Upgrades HTTP to HTTPS when page is served over HTTPS
|
|
11
|
+
*/
|
|
12
|
+
export function normalizeImageUrl(url: string | undefined): string {
|
|
13
|
+
if (!url) return ''
|
|
14
|
+
|
|
15
|
+
// Check if we're on HTTPS page
|
|
16
|
+
const isSecurePage = typeof window !== 'undefined' && window.location.protocol === 'https:'
|
|
17
|
+
|
|
18
|
+
// Upgrade HTTP to HTTPS if on secure page
|
|
19
|
+
if (isSecurePage && url.startsWith('http://')) {
|
|
20
|
+
return url.replace('http://', 'https://')
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
return url
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Normalize array of URLs
|
|
28
|
+
*/
|
|
29
|
+
export function normalizeImageUrls(urls: (string | undefined)[]): string[] {
|
|
30
|
+
return urls.map(normalizeImageUrl).filter(Boolean) as string[]
|
|
31
|
+
}
|