diff --git a/frontend/src/components/LazyImage/LazyImage.test.tsx b/frontend/src/components/LazyImage/LazyImage.test.tsx new file mode 100644 index 000000000..e69de29bb diff --git a/frontend/src/components/LazyImage/LazyImage.tsx b/frontend/src/components/LazyImage/LazyImage.tsx new file mode 100644 index 000000000..787e060e6 --- /dev/null +++ b/frontend/src/components/LazyImage/LazyImage.tsx @@ -0,0 +1,60 @@ +import { useRef, useState } from "react"; +import { useIntersectionObserver } from "../../hooks/useIntersectionObserver"; + +interface LazyImageProps { + src: string; + alt: string; + className?: string; + rootMargin?: string; + threshold?: number; +} + +export function LazyImage({ + src, + alt, + className = "", + rootMargin = "200px", + threshold = 0.1, +}: LazyImageProps) { + + const containerRef = useRef(null); + const isVisible = useIntersectionObserver(containerRef, { + rootMargin, + threshold, + }); + + const [isLoaded, setIsLoaded] = useState(false); + const [hasError, setHasError] = useState(false); + + return ( +
+ {/* Skeleton loader */} + {!isLoaded && !hasError && ( +
+ )} + + {/* Image */} + {isVisible && !hasError && ( + {alt} setIsLoaded(true)} + onError={() => setHasError(true)} + /> + )} + + {/* Error state */} + {hasError && ( +
+ Failed to load image +
+ )} +
+ ); +} diff --git a/frontend/src/components/Media/ImageCard.tsx b/frontend/src/components/Media/ImageCard.tsx index 0cc6a715a..ec9a032d1 100644 --- a/frontend/src/components/Media/ImageCard.tsx +++ b/frontend/src/components/Media/ImageCard.tsx @@ -7,6 +7,7 @@ import { Image } from '@/types/Media'; import { ImageTags } from './ImageTags'; import { convertFileSrc } from '@tauri-apps/api/core'; import { useToggleFav } from '@/hooks/useToggleFav'; +import { LazyImage } from "../LazyImage/LazyImage"; interface ImageCardViewProps { image: Image; @@ -54,11 +55,17 @@ export function ImageCard({ )} - {'Sample = ({ : 'opacity-70 hover:opacity-100' } cursor-pointer transition-all duration-200 hover:scale-105`} > - {`thumbnail-${index}`} { - const img = e.target as HTMLImageElement; - img.onerror = null; - img.src = '/placeholder.svg'; - }} - /> + +
))} diff --git a/frontend/src/hooks/useIntersectionObserver.ts b/frontend/src/hooks/useIntersectionObserver.ts new file mode 100644 index 000000000..2cbd3d5c6 --- /dev/null +++ b/frontend/src/hooks/useIntersectionObserver.ts @@ -0,0 +1,42 @@ +import { useEffect, useState, RefObject } from "react"; + +interface IntersectionOptions { + root?: Element | null; + rootMargin?: string; + threshold?: number; +} + +/** + * Hook to detect when an element enters the viewport + */ +export function useIntersectionObserver( + ref: RefObject, + options: IntersectionOptions = {} +) { + const [isVisible, setIsVisible] = useState(false); + + useEffect(() => { + const element = ref.current; + if (!element || isVisible) return; + + const observer = new IntersectionObserver( + ([entry]) => { + if (entry.isIntersecting) { + setIsVisible(true); + observer.unobserve(element); + } + }, + { + root: options.root ?? null, + rootMargin: options.rootMargin ?? "200px", + threshold: options.threshold ?? 0.1, + } + ); + + observer.observe(element); + + return () => observer.disconnect(); + }, [ref, isVisible, options.root, options.rootMargin, options.threshold]); + + return isVisible; +}