/** * ImagePreview Component * Full-screen image preview with left/right swipe navigation * * Requirements: 4.6, 4.14 */ import React, { useState, useEffect, useCallback, useRef } from 'react'; import { Icon } from '@iconify/react'; import type { TransactionImage } from '../../../types'; import './ImagePreview.css'; export interface ImagePreviewProps { images: TransactionImage[]; initialIndex: number; open: boolean; onClose: () => void; } export const ImagePreview: React.FC = ({ images, initialIndex, open, onClose, }) => { const [currentIndex, setCurrentIndex] = useState(initialIndex); const [touchStart, setTouchStart] = useState(null); const [touchEnd, setTouchEnd] = useState(null); const imageContainerRef = useRef(null); // Minimum swipe distance (in px) to trigger navigation const minSwipeDistance = 50; // Reset index when initialIndex changes useEffect(() => { setCurrentIndex(initialIndex); }, [initialIndex]); // Prevent body scroll when modal is open useEffect(() => { if (open) { document.body.style.overflow = 'hidden'; } else { document.body.style.overflow = ''; } return () => { document.body.style.overflow = ''; }; }, [open]); // Keyboard navigation useEffect(() => { if (!open) return; const handleKeyDown = (e: KeyboardEvent) => { if (e.key === 'Escape') { onClose(); } else if (e.key === 'ArrowLeft') { handlePrevious(); } else if (e.key === 'ArrowRight') { handleNext(); } }; window.addEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown); }, [open, currentIndex, images.length]); const handlePrevious = useCallback(() => { setCurrentIndex((prev) => (prev > 0 ? prev - 1 : images.length - 1)); }, [images.length]); const handleNext = useCallback(() => { setCurrentIndex((prev) => (prev < images.length - 1 ? prev + 1 : 0)); }, [images.length]); // Touch event handlers for swipe navigation const onTouchStart = (e: React.TouchEvent) => { setTouchEnd(null); setTouchStart(e.targetTouches[0].clientX); }; const onTouchMove = (e: React.TouchEvent) => { setTouchEnd(e.targetTouches[0].clientX); }; const onTouchEnd = () => { if (!touchStart || !touchEnd) return; const distance = touchStart - touchEnd; const isLeftSwipe = distance > minSwipeDistance; const isRightSwipe = distance < -minSwipeDistance; if (isLeftSwipe) { handleNext(); } else if (isRightSwipe) { handlePrevious(); } setTouchStart(null); setTouchEnd(null); }; // Mouse drag handlers for desktop swipe const [isDragging, setIsDragging] = useState(false); const [dragStart, setDragStart] = useState(null); const [dragEnd, setDragEnd] = useState(null); const onMouseDown = (e: React.MouseEvent) => { setIsDragging(true); setDragEnd(null); setDragStart(e.clientX); }; const onMouseMove = (e: React.MouseEvent) => { if (!isDragging) return; setDragEnd(e.clientX); }; const onMouseUp = () => { if (!isDragging || !dragStart || !dragEnd) { setIsDragging(false); return; } const distance = dragStart - dragEnd; const isLeftSwipe = distance > minSwipeDistance; const isRightSwipe = distance < -minSwipeDistance; if (isLeftSwipe) { handleNext(); } else if (isRightSwipe) { handlePrevious(); } setIsDragging(false); setDragStart(null); setDragEnd(null); }; const onMouseLeave = () => { if (isDragging) { setIsDragging(false); setDragStart(null); setDragEnd(null); } }; if (!open || images.length === 0) { return null; } const currentImage = images[currentIndex]; const imageUrl = `${import.meta.env.VITE_API_BASE_URL || 'http://localhost:2612/api/v1'}/images/${currentImage.id}`; return (
{/* Close button */} {/* Image counter */}
{currentIndex + 1} / {images.length}
{/* Previous button */} {images.length > 1 && ( )} {/* Image container */}
e.stopPropagation()} onTouchStart={onTouchStart} onTouchMove={onTouchMove} onTouchEnd={onTouchEnd} onMouseDown={onMouseDown} onMouseMove={onMouseMove} onMouseUp={onMouseUp} onMouseLeave={onMouseLeave} > {currentImage.fileName}
{/* Next button */} {images.length > 1 && ( )} {/* Image info */}
{currentImage.fileName} {(currentImage.fileSize / 1024).toFixed(1)} KB
); }; export default ImagePreview;