Phase 1:
- S0: Fix open redirect (safeNavigate), delete AuthContext/legacy auth, encrypt API keys, gitignore .env files
- S1: Split client.ts god object into 5 modules, unify toast system, delete unused Sidebar
- S2: Add glass button variant, migrate 32 z-index to SUMI tokens, fix card dark mode
- S3: Skip nav link, aria-hidden on icons, focus-visible ring fixes, alt attrs, aria-live regions
- S4: React.memo on list items, fix key={index}, loading=lazy on images
- S5: Branded loading screen, page transitions respect reduced-motion, LikeButton micro-interaction, i18n sidebar/header
Phase 2 Sprint 6:
- Wire Tailwind shadow utilities to SUMI tokens in @theme block (fixes 50+ files)
- Define shadow-card/shadow-card-hover tokens
- Remove dark:shadow-none workarounds from card.tsx (SUMI handles per-theme shadows)
Co-authored-by: Cursor <cursoragent@cursor.com>
230 lines
6.2 KiB
TypeScript
230 lines
6.2 KiB
TypeScript
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-[var(--sumi-z-popover)] 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-foreground 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-foreground 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-foreground 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-foreground 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>
|
|
);
|
|
};
|