ImageWithFallback: ai code enhance

This commit is contained in:
2025-11-12 15:36:40 +03:00
parent e1199314ee
commit bfb7c86901
3 changed files with 189 additions and 173 deletions

View File

@@ -23,7 +23,7 @@
}
.quantityButton {
padding: 0px;
padding: 0;
width: 25px;
height: 32px;
display: flex;
@@ -42,7 +42,7 @@
}
.removeButton {
padding: 4px 0px;
padding: 4px 0;
height: 32px;
display: flex;
align-items: center;

View File

@@ -1,4 +1,11 @@
/* ImageWithFallback component styles */
/**
* ImageWithFallback Component Styles
*
* This stylesheet contains all the styles for the ImageWithFallback component,
* including loading states, error states, and animations.
*/
/* Loading container - used for both the placeholder and the actual loading state */
.loadingContainer {
background-color: #f8f9fa;
overflow: hidden;
@@ -8,52 +15,6 @@
justify-content: center;
}
.loadingSpinner {
animation: spin 1s linear infinite;
color: #6c757d;
}
.loadingSpinner circle:first-child {
animation: dash 1.5s ease-in-out infinite;
}
.loadingText {
margin-top: 8px;
font-size: 11px;
color: #6c757d;
font-weight: 500;
}
.placeholderIcon {
color: #6c757d;
opacity: 0.7;
}
/* Spinner animation */
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@keyframes dash {
0% {
stroke-dasharray: 1, 150;
stroke-dashoffset: 0;
}
50% {
stroke-dasharray: 90, 150;
stroke-dashoffset: -35;
}
100% {
stroke-dasharray: 90, 150;
stroke-dashoffset: -124;
}
}
/* Loading state transitions */
.loadingState {
transition: opacity 0.3s ease-in-out;
@@ -61,74 +22,25 @@
z-index: 2;
}
/* Error state */
/* Error state - applied when image fails to load */
.errorState {
background-color: #f8d7da;
color: #721c24;
}
/* Success state */
.successState {
background-color: #d4edda;
color: #155724;
}
/* Responsive adjustments */
@media (max-width: 768px) {
.loadingText {
font-size: 10px;
}
.placeholderIcon {
width: 24px;
height: 24px;
}
border-radius: 4px;
}
/* Dark theme support */
@media (prefers-color-scheme: dark) {
/* .loadingContainer {
.loadingContainer {
background-color: #343a40;
} */
.loadingText {
color: #adb5bd;
}
.placeholderIcon {
color: #adb5bd;
}
.loadingState {
background-color: #343a40;
}
/* HTML: <div class="loader"></div> */
.loader {
width: 30px;
aspect-ratio: 1;
display: grid;
border-radius: 50%;
background:
linear-gradient(0deg, rgb(0 0 0/50%) 30%, #0000 0 70%, rgb(0 0 0/100%) 0)
50%/8% 100%,
linear-gradient(90deg, rgb(0 0 0/25%) 30%, #0000 0 70%, rgb(0 0 0/75%) 0)
50%/100% 8%;
background-repeat: no-repeat;
animation: l23 1s infinite steps(12);
}
.loader::before,
.loader::after {
content: "";
grid-area: 1/1;
border-radius: 50%;
background: inherit;
opacity: 0.915;
transform: rotate(30deg);
}
.loader::after {
opacity: 0.83;
transform: rotate(60deg);
}
@keyframes l23 {
100% {
transform: rotate(1turn);
.errorState {
background-color: #45232a;
color: #f8d7da;
}
}

View File

@@ -1,40 +1,31 @@
import { Image, ImageProps, Skeleton } from "antd";
import { CSSProperties, useCallback, useEffect, useRef, useState } from "react";
import {
CSSProperties,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import styles from "./ImageWithFallback.module.css";
interface ImageWithFallbackProps extends ImageProps {
fallbackSrc: string;
borderRadius?: number | string;
priority?: boolean;
lazy?: boolean;
placeholder?: "blur" | "empty";
blurDataURL?: string;
width?: number | string;
height?: number | string;
loadingContainerStyle?: CSSProperties;
}
const ImageWithFallback = ({
fallbackSrc,
src,
borderRadius,
style,
alt,
priority = false,
/**
* Custom hook for handling lazy loading with Intersection Observer
* @param options Configuration options for the hook
* @returns Object containing ref and isInView state
*/
const useLazyLoading = ({
lazy = true,
width,
height,
loadingContainerStyle,
...rest
}: ImageWithFallbackProps) => {
const [imgSrc, setImgSrc] = useState(src || fallbackSrc);
const [isLoading, setIsLoading] = useState(true);
const [isInView, setIsInView] = useState(false);
const [hasError, setHasError] = useState(false);
const imageRef = useRef<HTMLDivElement>(null);
priority = false,
}: {
lazy?: boolean;
priority?: boolean;
}) => {
const [isInView, setIsInView] = useState<boolean>(!lazy || priority);
const ref = useRef<HTMLDivElement>(null);
// Intersection Observer for lazy loading
useEffect(() => {
// Skip lazy loading if priority is true or lazy is false
if (lazy && !priority) {
const observer = new IntersectionObserver(
([entry]) => {
@@ -49,39 +40,160 @@ const ImageWithFallback = ({
},
);
if (imageRef.current) {
observer.observe(imageRef.current);
const currentRef = ref.current;
if (currentRef) {
observer.observe(currentRef);
}
return () => observer.disconnect();
// Cleanup function
return () => {
if (currentRef) {
observer.unobserve(currentRef);
}
observer.disconnect();
};
} else {
setIsInView(true);
}
}, [lazy, priority]);
return { ref, isInView };
};
/**
* Props for the ImageWithFallback component
* @extends ImageProps from Ant Design's Image component
*/
interface ImageWithFallbackProps extends ImageProps {
/** URL for the fallback image to display when the main image fails to load */
fallbackSrc: string;
/** Border radius for the image (can be number in pixels or string like '8px' or '50%') */
borderRadius?: number | string;
/** If true, loads the image immediately without lazy loading */
priority?: boolean;
/** If true (default), enables lazy loading of the image */
lazy?: boolean;
/** Type of placeholder to show while loading (not fully implemented) */
placeholder?: "blur" | "empty";
/** Base64 data URL for blur placeholder (not fully implemented) */
blurDataURL?: string;
/** Width of the image (number for pixels, string for other units) */
width?: number | string;
/** Height of the image (number for pixels, string for other units) */
height?: number | string;
/** Additional styles for the loading container */
loadingContainerStyle?: CSSProperties;
}
/**
* Enhanced image component with fallback support, lazy loading, and loading states
*
* Features:
* - Displays a fallback image when the main image fails to load
* - Supports lazy loading using Intersection Observer API
* - Shows loading skeleton while the image is loading
* - Handles various styling options including border radius
* - Optimized with React.memo to prevent unnecessary re-renders
*/
const ImageWithFallback = ({
fallbackSrc,
src,
borderRadius,
style,
alt = "Image", // Default alt text for better accessibility
priority = false,
lazy = true,
width,
height,
loadingContainerStyle,
...rest
}: ImageWithFallbackProps) => {
const [imgSrc, setImgSrc] = useState<string>(src || fallbackSrc);
const [isLoading, setIsLoading] = useState<boolean>(true);
const [hasError, setHasError] = useState<boolean>(false);
// Use the custom lazy loading hook
const { ref: imageRef, isInView } = useLazyLoading({ lazy, priority });
// Default width value for consistency
const defaultWidth = 90;
/**
* Handle image load error
* Sets the fallback image and updates loading/error states
*/
const handleError = useCallback(() => {
setImgSrc(fallbackSrc);
setHasError(true);
setIsLoading(false);
}, [fallbackSrc]);
/**
* Handle successful image load
* Updates loading and error states
*/
const handleLoad = useCallback(() => {
setIsLoading(false);
setHasError(false);
}, []);
// Update imgSrc when src prop changes
// Update imgSrc when src or fallbackSrc props change
useEffect(() => {
setImgSrc(src || fallbackSrc);
setIsLoading(true);
setHasError(false);
}, [src, fallbackSrc]);
// Combine custom style with borderRadius if provided
const combinedStyle = {
// Memoize combined style to prevent unnecessary re-renders
const combinedStyle: CSSProperties = useMemo(
() => ({
...style,
...(borderRadius && { borderRadius }),
};
}),
[style, borderRadius],
);
/**
* Memoized skeleton loader component
*/
const SkeletonLoader = useMemo(
() => (
<Skeleton.Image
active
style={{
border: "none",
height,
width: width || defaultWidth,
...loadingContainerStyle,
}}
/>
),
[height, width, defaultWidth, loadingContainerStyle],
);
// Memoize loading container styles for better performance
const loadingContainerStyles: CSSProperties = useMemo(
() => ({
zIndex: 1,
borderRadius: borderRadius || "8px",
height,
width: width || defaultWidth,
textAlign: "center",
...loadingContainerStyle,
}),
[borderRadius, height, width, defaultWidth, loadingContainerStyle],
);
// Memoize image styles for better performance
const imageStyles: CSSProperties = useMemo(
() => ({
opacity: isLoading ? 0 : 1,
transition: "opacity 0.3s ease-in-out",
objectFit: "cover",
...combinedStyle,
}),
[isLoading, combinedStyle],
);
// Don't render the image until it's in view (for lazy loading)
if (lazy && !priority && !isInView) {
@@ -92,41 +204,35 @@ const ImageWithFallback = ({
style={{
...combinedStyle,
height,
width: width || 90,
width: width || defaultWidth,
}}
aria-label={`Loading ${alt}`}
role="progressbar"
>
<Skeleton.Image
active
style={{
border: "none",
height,
width: width || 90,
...loadingContainerStyle,
}}
/>
{SkeletonLoader}
</div>
);
}
return (
<div ref={imageRef} style={{ position: "relative", height }}>
<div
ref={imageRef}
style={{ position: "relative", height }}
className={hasError ? styles.errorState : undefined}
data-testid="image-with-fallback-container"
>
{/* Loading state */}
{isLoading && !hasError && (
<div
className={`${styles.loadingContainer} ${styles.loadingState}`}
style={{
zIndex: 1,
borderRadius: borderRadius || "8px",
height,
width: width || 90,
textAlign: "center",
...loadingContainerStyle,
}}
style={loadingContainerStyles}
aria-hidden="true"
>
<Skeleton.Image
active
style={{
height,
width: width || 90,
width: width || defaultWidth,
display: "flex",
alignItems: "center",
justifyContent: "center",
@@ -140,20 +246,18 @@ const ImageWithFallback = ({
</div>
)}
{/* Main image */}
<Image
src={imgSrc}
onError={handleError}
onLoad={handleLoad}
style={{
opacity: isLoading ? 0 : 1,
transition: "opacity 0.3s ease-in-out",
objectFit: "cover",
...combinedStyle,
}}
alt={alt || ""}
style={imageStyles}
alt={alt}
width={width}
height={height}
preview={false}
aria-hidden={isLoading}
data-testid="image-with-fallback"
{...rest}
/>
</div>