From ce552d78471d66750281c0bbee1b4c829d77da5e Mon Sep 17 00:00:00 2001 From: Ayyub2006 Date: Thu, 8 Jan 2026 23:57:42 +0530 Subject: [PATCH 1/6] eat: add intersection observer hook for lazy loading --- frontend/src/hooks/useIntersectionObserver.ts | 42 +++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 frontend/src/hooks/useIntersectionObserver.ts diff --git a/frontend/src/hooks/useIntersectionObserver.ts b/frontend/src/hooks/useIntersectionObserver.ts new file mode 100644 index 000000000..3c153efe8 --- /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; +} From a1aeee5ae06b3d6c90f2bb6d93a2c2539b7d811a Mon Sep 17 00:00:00 2001 From: Ayyub2006 Date: Fri, 9 Jan 2026 00:10:27 +0530 Subject: [PATCH 2/6] feat: use LazyImage in ImageCard for performance --- frontend/src/components/Media/ImageCard.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/frontend/src/components/Media/ImageCard.tsx b/frontend/src/components/Media/ImageCard.tsx index 0cc6a715a..e75ccb99c 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,7 +55,7 @@ export function ImageCard({ )} - Date: Fri, 9 Jan 2026 00:18:48 +0530 Subject: [PATCH 3/6] fix: allow nullable element refs in intersection observer hook --- frontend/src/hooks/useIntersectionObserver.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/hooks/useIntersectionObserver.ts b/frontend/src/hooks/useIntersectionObserver.ts index 3c153efe8..2cbd3d5c6 100644 --- a/frontend/src/hooks/useIntersectionObserver.ts +++ b/frontend/src/hooks/useIntersectionObserver.ts @@ -10,7 +10,7 @@ interface IntersectionOptions { * Hook to detect when an element enters the viewport */ export function useIntersectionObserver( - ref: RefObject, + ref: RefObject, options: IntersectionOptions = {} ) { const [isVisible, setIsVisible] = useState(false); From 4d92e30d8725f4d77a6b61a7e4ffea39752c498f Mon Sep 17 00:00:00 2001 From: Ayyub2006 Date: Fri, 9 Jan 2026 00:18:58 +0530 Subject: [PATCH 4/6] feat: add LazyImage component with skeleton and error handling --- .../components/LazyImage/LazyImage.test.tsx | 0 .../src/components/LazyImage/LazyImage.tsx | 61 +++++++++++++++++++ 2 files changed, 61 insertions(+) create mode 100644 frontend/src/components/LazyImage/LazyImage.test.tsx create mode 100644 frontend/src/components/LazyImage/LazyImage.tsx 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..940cff543 --- /dev/null +++ b/frontend/src/components/LazyImage/LazyImage.tsx @@ -0,0 +1,61 @@ +import { useRef, useState } from "react"; +import { useIntersectionObserver } from "../../hooks/useIntersectionObserver"; + +interface LazyImageProps { + src: string; + alt: string; + className?: string; + placeholder?: string; + rootMargin?: string; + threshold?: number; +} + +export function LazyImage({ + src, + alt, + className = "", + placeholder, + 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 +
+ )} +
+ ); +} From a992596fbf12ba71e182f5f4ed2cd10c7a51371c Mon Sep 17 00:00:00 2001 From: Ayyub2006 Date: Fri, 9 Jan 2026 00:26:11 +0530 Subject: [PATCH 5/6] feat: lazy load thumbnails using LazyImage --- frontend/src/components/Media/MediaThumbnails.tsx | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/frontend/src/components/Media/MediaThumbnails.tsx b/frontend/src/components/Media/MediaThumbnails.tsx index b92e646ef..c8275b39a 100644 --- a/frontend/src/components/Media/MediaThumbnails.tsx +++ b/frontend/src/components/Media/MediaThumbnails.tsx @@ -1,5 +1,6 @@ import React, { useRef, useEffect } from 'react'; import { convertFileSrc } from '@tauri-apps/api/core'; +import { LazyImage } from "../LazyImage/LazyImage"; interface MediaThumbnailsProps { images: Array<{ @@ -116,15 +117,11 @@ export const MediaThumbnails: React.FC = ({ : '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'; - }} + />
))} From 9300088e9ca994c29c651c4e56f49709070c9045 Mon Sep 17 00:00:00 2001 From: Ayyub2006 Date: Fri, 9 Jan 2026 00:41:29 +0530 Subject: [PATCH 6/6] fix: address review comments for accessibility and cleanup --- frontend/src/components/LazyImage/LazyImage.tsx | 3 +-- frontend/src/components/Media/ImageCard.tsx | 8 +++++++- frontend/src/components/Media/MediaThumbnails.tsx | 10 +++++----- 3 files changed, 13 insertions(+), 8 deletions(-) diff --git a/frontend/src/components/LazyImage/LazyImage.tsx b/frontend/src/components/LazyImage/LazyImage.tsx index 940cff543..787e060e6 100644 --- a/frontend/src/components/LazyImage/LazyImage.tsx +++ b/frontend/src/components/LazyImage/LazyImage.tsx @@ -5,7 +5,6 @@ interface LazyImageProps { src: string; alt: string; className?: string; - placeholder?: string; rootMargin?: string; threshold?: number; } @@ -14,10 +13,10 @@ export function LazyImage({ src, alt, className = "", - placeholder, rootMargin = "200px", threshold = 0.1, }: LazyImageProps) { + const containerRef = useRef(null); const isVisible = useIntersectionObserver(containerRef, { rootMargin, diff --git a/frontend/src/components/Media/ImageCard.tsx b/frontend/src/components/Media/ImageCard.tsx index e75ccb99c..ec9a032d1 100644 --- a/frontend/src/components/Media/ImageCard.tsx +++ b/frontend/src/components/Media/ImageCard.tsx @@ -59,7 +59,13 @@ export function ImageCard({ src={convertFileSrc( image.thumbnailPath || image.path || '/placeholder.svg', )} - alt={'Sample Title'} + alt={ + image.path + ? image.path.split('/').pop() ?? 'Image thumbnail' + : 'Image thumbnail' +} + + className={cn( 'h-full w-full object-cover transition-transform group-hover:scale-105', isSelected ? 'opacity-95' : '', diff --git a/frontend/src/components/Media/MediaThumbnails.tsx b/frontend/src/components/Media/MediaThumbnails.tsx index c8275b39a..22cf70c2f 100644 --- a/frontend/src/components/Media/MediaThumbnails.tsx +++ b/frontend/src/components/Media/MediaThumbnails.tsx @@ -118,11 +118,11 @@ export const MediaThumbnails: React.FC = ({ } cursor-pointer transition-all duration-200 hover:scale-105`} > + src={convertFileSrc(image.thumbnailPath) || '/placeholder.svg'} + alt={`thumbnail-${index}`} + className="h-full w-full object-cover" +/> + ))}