@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@djangocfg/ui-tools",
3
- "version": "2.1.136",
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.136",
67
- "@djangocfg/ui-core": "^2.1.136",
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.136",
99
+ "@djangocfg/i18n": "^2.1.138",
100
100
  "@djangocfg/playground": "workspace:*",
101
- "@djangocfg/typescript-config": "^2.1.136",
102
- "@djangocfg/ui-core": "^2.1.136",
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(image.src, callbacks)
30
+ const { isLoading, isLoaded, hasError } = useImageLoader(normalizedSrc, callbacks)
28
31
 
29
32
  const backgroundStyle = useMemo(() =>
30
33
  isLoaded && objectFit === 'cover' ? {
31
- backgroundImage: `url(${image.src})`,
34
+ backgroundImage: `url(${normalizedSrc})`,
32
35
  backgroundSize: 'cover',
33
36
  backgroundPosition: 'center',
34
37
  backgroundRepeat: 'no-repeat',
35
38
  } : undefined
36
- , [isLoaded, image.src, objectFit])
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={image.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-transform duration-300 group-hover:scale-105',
383
- // Staggered reveal animation
384
- staggerDelay > 0 && !isLoaded && 'opacity-0 scale-105',
385
- staggerDelay > 0 && isLoaded && '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'
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="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 */}
@@ -9,3 +9,8 @@ export type {
9
9
  ImageOrientation,
10
10
  AnalyzedImage,
11
11
  } from './imageAnalysis'
12
+
13
+ export {
14
+ normalizeImageUrl,
15
+ normalizeImageUrls,
16
+ } from './normalizeUrl'
@@ -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
+ }