Files
thrilltrack-explorer/src/components/moderation/PhotoModal.tsx
pac7 bfba3baf7e Improve component stability and user experience with safety checks
Implement robust error handling, safety checks for data structures, and state management improvements across various components to prevent runtime errors and enhance user experience.

Replit-Commit-Author: Agent
Replit-Commit-Session-Id: a71e826a-1d38-4b6e-a34f-fbf5ba1f1b25
Replit-Commit-Checkpoint-Type: intermediate_checkpoint
2025-10-08 19:27:31 +00:00

176 lines
5.7 KiB
TypeScript

import { useState, useEffect, useRef } from 'react';
import { X, ChevronLeft, ChevronRight } from 'lucide-react';
import { Dialog, DialogContent } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { useIsMobile } from '@/hooks/use-mobile';
interface PhotoModalProps {
photos: Array<{
id: string;
url: string;
filename?: string;
caption?: string;
}>;
initialIndex: number;
isOpen: boolean;
onClose: () => void;
}
export function PhotoModal({ photos, initialIndex, isOpen, onClose }: PhotoModalProps) {
const isMobile = useIsMobile();
const [currentIndex, setCurrentIndex] = useState(initialIndex);
const [touchStart, setTouchStart] = useState<number | null>(null);
const [touchEnd, setTouchEnd] = useState<number | null>(null);
const imageRef = useRef<HTMLImageElement>(null);
// Safety check: ensure photos array exists and is not empty
if (!photos || photos.length === 0) {
return null;
}
// Clamp currentIndex to valid bounds
const safeIndex = Math.max(0, Math.min(currentIndex, photos.length - 1));
const currentPhoto = photos[safeIndex];
// Early return if currentPhoto is undefined
if (!currentPhoto) {
return null;
}
// Minimum swipe distance (in px)
const minSwipeDistance = 50;
const goToPrevious = () => {
setCurrentIndex((prev) => (prev > 0 ? prev - 1 : photos.length - 1));
};
const goToNext = () => {
setCurrentIndex((prev) => (prev < photos.length - 1 ? prev + 1 : 0));
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'ArrowLeft') goToPrevious();
if (e.key === 'ArrowRight') goToNext();
if (e.key === 'Escape') onClose();
};
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) {
goToNext();
} else if (isRightSwipe) {
goToPrevious();
}
};
useEffect(() => {
setCurrentIndex(initialIndex);
}, [initialIndex]);
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className={`max-w-7xl w-full p-0 [&>button]:hidden ${isMobile ? 'max-h-screen h-screen' : 'max-h-[90vh]'}`}>
<div
className="relative bg-black rounded-lg overflow-hidden touch-none"
onKeyDown={handleKeyDown}
onTouchStart={onTouchStart}
onTouchMove={onTouchMove}
onTouchEnd={onTouchEnd}
tabIndex={0}
>
{/* Close button */}
<Button
variant="ghost"
size={isMobile ? "default" : "sm"}
onClick={onClose}
className={`absolute z-20 text-white hover:bg-white/10 ${isMobile ? 'top-2 right-2 h-10 w-10 p-0' : 'top-4 right-4'}`}
>
<X className={isMobile ? "h-5 w-5" : "h-4 w-4"} />
</Button>
{/* Header */}
<div className={`absolute top-0 left-0 right-0 z-10 bg-gradient-to-b from-black/80 to-transparent ${isMobile ? 'p-3' : 'p-4'}`}>
<div className="text-white">
{currentPhoto?.caption && (
<h3 className={`font-medium ${isMobile ? 'text-sm pr-12' : ''}`}>
{currentPhoto.caption}
</h3>
)}
{photos.length > 1 && (
<p className={`text-white/70 ${isMobile ? 'text-xs' : 'text-sm'}`}>
{safeIndex + 1} of {photos.length}
</p>
)}
</div>
</div>
{/* Image */}
<div className={`flex items-center justify-center ${isMobile ? 'min-h-screen' : 'min-h-[400px] max-h-[80vh]'}`}>
<img
ref={imageRef}
src={currentPhoto?.url}
alt={currentPhoto?.caption || `Photo ${safeIndex + 1}`}
className="max-w-full max-h-full object-contain select-none"
loading="lazy"
draggable={false}
/>
</div>
{/* Navigation */}
{photos.length > 1 && !isMobile && (
<>
<Button
variant="ghost"
size="sm"
onClick={goToPrevious}
className="absolute left-4 top-1/2 -translate-y-1/2 text-white hover:bg-white/10"
>
<ChevronLeft className="h-6 w-6" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={goToNext}
className="absolute right-4 top-1/2 -translate-y-1/2 text-white hover:bg-white/10"
>
<ChevronRight className="h-6 w-6" />
</Button>
</>
)}
{/* Mobile navigation dots */}
{photos.length > 1 && isMobile && (
<div className="absolute bottom-4 left-0 right-0 flex justify-center gap-2 px-4">
{photos.map((_, idx) => (
<button
key={idx}
onClick={() => setCurrentIndex(idx)}
className={`h-2 rounded-full transition-all ${
idx === currentIndex
? 'w-8 bg-white'
: 'w-2 bg-white/50'
}`}
aria-label={`Go to photo ${idx + 1}`}
/>
))}
</div>
)}
</div>
</DialogContent>
</Dialog>
);
}