import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import Button from '@/app/components/base/button' import Loading from '@/app/components/base/loading' import { formatFileSize } from '@/utils/format' import { RiArrowLeftLine, RiArrowRightLine, RiCloseLine, RiRefreshLine } from '@remixicon/react' import { createPortal } from 'react-dom' import { useHotkeys } from 'react-hotkeys-hook' type CachedImage = { blobUrl?: string status: 'loading' | 'loaded' | 'error' width: number height: number } const imageCache = new Map() export type ImageInfo = { url: string name: string size: number } type ImagePreviewerProps = { images: ImageInfo[] initialIndex?: number onClose: () => void } const ImagePreviewer = ({ images, initialIndex = 0, onClose, }: ImagePreviewerProps) => { const [currentIndex, setCurrentIndex] = useState(initialIndex) const [cachedImages, setCachedImages] = useState>(() => { return images.reduce((acc, image) => { acc[image.url] = { status: 'loading', width: 0, height: 0, } return acc }, {} as Record) }) const isMounted = useRef(false) const fetchImage = useCallback(async (image: ImageInfo) => { const { url } = image // Skip if already cached if (imageCache.has(url)) return try { const res = await fetch(url) if (!res.ok) throw new Error(`Failed to load: ${url}`) const blob = await res.blob() const blobUrl = URL.createObjectURL(blob) const img = new Image() img.src = blobUrl img.onload = () => { if (!isMounted.current) return imageCache.set(url, { blobUrl, status: 'loaded', width: img.naturalWidth, height: img.naturalHeight, }) setCachedImages((prev) => { return { ...prev, [url]: { blobUrl, status: 'loaded', width: img.naturalWidth, height: img.naturalHeight, }, } }) } } catch { if (isMounted.current) { setCachedImages((prev) => { return { ...prev, [url]: { status: 'error', width: 0, height: 0, }, } }) } } }, []) useEffect(() => { isMounted.current = true images.forEach((image) => { fetchImage(image) }) return () => { isMounted.current = false // Cleanup released blob URLs not in current list imageCache.forEach(({ blobUrl }, key) => { if (blobUrl) URL.revokeObjectURL(blobUrl) imageCache.delete(key) }) } }, []) const currentImage = useMemo(() => { return images[currentIndex] }, [images, currentIndex]) const prevImage = useCallback(() => { if (currentIndex === 0) return setCurrentIndex(prevIndex => prevIndex - 1) }, [currentIndex]) const nextImage = useCallback(() => { if (currentIndex === images.length - 1) return setCurrentIndex(prevIndex => prevIndex + 1) }, [currentIndex, images.length]) const retryImage = useCallback((image: ImageInfo) => { setCachedImages((prev) => { return { ...prev, [image.url]: { ...prev[image.url], status: 'loading', }, } }) fetchImage(image) }, [fetchImage]) useHotkeys('esc', onClose) useHotkeys('left', prevImage) useHotkeys('right', nextImage) return createPortal(
e.stopPropagation()} tabIndex={-1} >
Esc
{cachedImages[currentImage.url].status === 'loading' && ( )} {cachedImages[currentImage.url].status === 'error' && (
{`Failed to load image: ${currentImage.url}. Please try again.`}
)} {cachedImages[currentImage.url].status === 'loaded' && (
{currentImage.name}
{currentImage.name} · {`${cachedImages[currentImage.url].width} ×  ${cachedImages[currentImage.url].height}`} · {formatFileSize(currentImage.size)}
)}
, document.body, ) } export default ImagePreviewer