veza/apps/web/src/components/ui/ImageViewerModal.tsx

231 lines
6.2 KiB
TypeScript
Raw Normal View History

import React, { useCallback, useEffect, useState } from 'react';
import { X, ChevronLeft, ChevronRight, Download } from 'lucide-react';
import { cn } from '@/lib/utils';
/**
* ImageViewerModalProps - Propriétés du composant ImageViewerModal
*
* @interface ImageViewerModalProps
*/
interface ImageViewerModalProps {
/**
* URL de l'image à afficher
*/
src: string;
/**
* Texte alternatif de l'image
*/
alt?: string;
/**
* Fonction appelée pour fermer le visualiseur
*/
onClose: () => void;
/**
* Fonction appelée pour passer à l'image suivante
*/
onNext?: () => void;
/**
* Fonction appelée pour passer à l'image précédente
*/
onPrev?: () => void;
/**
* Si `true`, affiche le bouton suivant
*/
hasNext?: boolean;
/**
* Si `true`, affiche le bouton précédent
*/
hasPrev?: boolean;
/**
* Index courant dans la galerie (1-based pour l'affichage)
*/
currentIndex?: number;
/**
* Nombre total d'images dans la galerie
*/
totalImages?: number;
}
/**
* ImageViewerModal - Composant de visualisation d'image en plein écran
*
* Composant modal pour afficher une image en plein écran avec :
* - Navigation entre images (précédent/suivant) via boutons et clavier (/)
* - Téléchargement de l'image
* - Fermeture avec bouton ou Escape
* - Fond sombre avec backdrop blur
* - Zoom toggle (clic sur l'image : fit natural size)
* - Compteur d'images en mode galerie
* - Skeleton de chargement pendant le téléchargement de l'image
*
* @example
* ```tsx
* // Visualiseur simple
* <ImageViewerModal
* src={imageUrl}
* alt="Description"
* onClose={() => setShowViewer(false)}
* />
* ```
*
* @example
* ```tsx
* // Avec navigation
* <ImageViewerModal
* src={images[currentIndex]}
* alt={`Image ${currentIndex + 1}`}
* onClose={() => setShowViewer(false)}
* onNext={() => setCurrentIndex(i => i + 1)}
* onPrev={() => setCurrentIndex(i => i - 1)}
* hasNext={currentIndex < images.length - 1}
* hasPrev={currentIndex > 0}
* currentIndex={currentIndex + 1}
* totalImages={images.length}
* />
* ```
*
* @component
* @param {ImageViewerModalProps} props - Propriétés du composant
* @returns {JSX.Element} Modal plein écran avec image et contrôles
*/
export const ImageViewerModal: React.FC<ImageViewerModalProps> = ({
src,
alt,
onClose,
onNext,
onPrev,
hasNext,
hasPrev,
currentIndex,
totalImages,
}) => {
const [zoomed, setZoomed] = useState(false);
const [imageLoaded, setImageLoaded] = useState(false);
// Reset zoom and loading state when image source changes
useEffect(() => {
setZoomed(false);
setImageLoaded(false);
}, [src]);
// Keyboard navigation: Escape, ArrowLeft, ArrowRight
const handleKeyDown = useCallback(
(e: KeyboardEvent) => {
switch (e.key) {
case 'Escape':
onClose();
break;
case 'ArrowLeft':
if (hasPrev && onPrev) onPrev();
break;
case 'ArrowRight':
if (hasNext && onNext) onNext();
break;
}
},
[onClose, onNext, onPrev, hasNext, hasPrev],
);
useEffect(() => {
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [handleKeyDown]);
const showCounter =
currentIndex !== undefined &&
totalImages !== undefined &&
totalImages > 1;
return (
<div
className="fixed inset-0 z-[500] bg-black/95 backdrop-blur-xl flex items-center justify-center animate-fadeIn"
role="dialog"
aria-modal="true"
aria-label={alt || 'Image viewer'}
>
{/* Toolbar */}
<div className="absolute top-0 left-0 right-0 p-4 flex justify-between items-center z-10 bg-gradient-to-b from-black/50 to-transparent">
<span className="text-white/50 text-sm font-mono">
{alt || 'Image Preview'}
</span>
<div className="flex gap-4">
<a
href={src}
download
className="p-2 bg-white/10 rounded-full hover:bg-white/20 text-white transition-colors"
title="Download"
>
<Download className="w-5 h-5" />
</a>
<button
onClick={onClose}
className="p-2 bg-white/10 rounded-full hover:bg-white/20 text-white transition-colors"
title="Close"
>
<X className="w-5 h-5" />
</button>
</div>
</div>
{/* Navigation */}
{hasPrev && (
<button
onClick={onPrev}
className="absolute left-4 p-4 bg-white/10 rounded-full hover:bg-white/20 text-white transition-colors z-10"
aria-label="Previous image"
>
<ChevronLeft className="w-8 h-8" />
</button>
)}
{hasNext && (
<button
onClick={onNext}
className="absolute right-4 p-4 bg-white/10 rounded-full hover:bg-white/20 text-white transition-colors z-10"
aria-label="Next image"
>
<ChevronRight className="w-8 h-8" />
</button>
)}
{/* Image with loading skeleton and zoom */}
<div className="w-full h-full p-4 md:p-10 flex items-center justify-center overflow-auto">
{/* Loading skeleton */}
{!imageLoaded && (
<div className="absolute inset-0 flex items-center justify-center">
<div className="w-64 h-48 rounded-lg bg-white/5 animate-pulse" />
</div>
)}
<img
src={src}
alt={alt}
onLoad={() => setImageLoaded(true)}
onClick={() => setZoomed((z) => !z)}
className={cn(
'shadow-2xl rounded-lg transition-all duration-200',
imageLoaded ? 'opacity-100' : 'opacity-0',
zoomed
? 'object-none cursor-zoom-out scale-150'
: 'object-contain cursor-zoom-in max-w-full max-h-full',
)}
/>
</div>
{/* Image counter */}
{showCounter && (
<span className="absolute bottom-4 left-1/2 -translate-x-1/2 text-sm text-foreground/80 bg-background/50 backdrop-blur-sm px-3 py-1 rounded-full">
{currentIndex} / {totalImages}
</span>
)}
</div>
);
};