'use client'; import { useState, useEffect, useRef, useCallback } from 'react'; import { useRouter } from 'next/navigation'; import { useSession } from 'next-auth/react'; import Image from 'next/image'; import { Photo, Person } from '@prisma/client'; import { ChevronLeft, ChevronRight, X, Play, Pause, ZoomIn, ZoomOut, RotateCcw, Flag, Heart, Download } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from '@/components/ui/dialog'; import { parseFaceLocation, isPointInFaceWithFit } from '@/lib/face-utils'; import { isUrl, isVideo, getImageSrc, getVideoSrc } from '@/lib/photo-utils'; import { IdentifyFaceDialog } from '@/components/IdentifyFaceDialog'; import { LoginDialog } from '@/components/LoginDialog'; import { RegisterDialog } from '@/components/RegisterDialog'; interface FaceWithLocation { id: number; personId: number | null; location: string; person: Person | null; } interface PhotoWithDetails extends Photo { faces?: FaceWithLocation[]; photoTags?: Array<{ tag: { tagName: string; }; }>; } interface PhotoViewerClientProps { initialPhoto: PhotoWithDetails; allPhotos: PhotoWithDetails[]; currentIndex: number; onClose?: () => void; autoPlay?: boolean; slideInterval?: number; // in milliseconds } const REPORT_COMMENT_MAX_LENGTH = 300; const getPhotoFilename = (photo: Photo) => { if (photo?.filename) { return photo.filename; } if (photo?.path) { const segments = photo.path.split(/[/\\]/); const lastSegment = segments.pop(); if (lastSegment) { return lastSegment; } } return `photo-${photo?.id ?? 'download'}.jpg`; }; const getPhotoDownloadUrl = ( photo: Photo, options?: { forceProxy?: boolean; watermark?: boolean } ) => { const path = photo.path || ''; const isExternal = path.startsWith('http://') || path.startsWith('https://'); if (isExternal && !options?.forceProxy) { return path; } const params = new URLSearchParams(); if (options?.watermark) { params.set('watermark', 'true'); } const query = params.toString(); return `/api/photos/${photo.id}/image${query ? `?${query}` : ''}`; }; export function PhotoViewerClient({ initialPhoto, allPhotos, currentIndex, onClose, autoPlay = false, slideInterval = 5000, // 5 seconds default }: PhotoViewerClientProps) { const router = useRouter(); const { data: session, update } = useSession(); const isLoggedIn = Boolean(session); // Check if user has write access const hasWriteAccess = session?.user?.hasWriteAccess === true; // Debug logging useEffect(() => { if (session) { console.log('[PhotoViewerClient] Session:', { email: session.user?.email, hasWriteAccess: session.user?.hasWriteAccess, isAdmin: session.user?.isAdmin, computedHasWriteAccess: hasWriteAccess, }); } }, [session, hasWriteAccess]); // Normalize photo data: ensure faces is always available (handle Face vs faces) const normalizePhoto = (photo: PhotoWithDetails): PhotoWithDetails => { const normalized = { ...photo }; // If photo has Face (capital F) but no faces (lowercase), convert it if (!normalized.faces && (normalized as any).Face) { normalized.faces = (normalized as any).Face.map((face: any) => ({ id: face.id, personId: face.person_id || face.personId, location: face.location, person: face.Person ? { id: face.Person.id, firstName: face.Person.first_name, lastName: face.Person.last_name, middleName: face.Person.middle_name, maidenName: face.Person.maiden_name, dateOfBirth: face.Person.date_of_birth, } : null, })); } return normalized; }; const [currentPhoto, setCurrentPhoto] = useState(normalizePhoto(initialPhoto)); const [currentIdx, setCurrentIdx] = useState(currentIndex); const [imageLoading, setImageLoading] = useState(false); const [isPlaying, setIsPlaying] = useState(autoPlay); const [currentInterval, setCurrentInterval] = useState(slideInterval); const slideTimerRef = useRef(null); const [zoom, setZoom] = useState(1); const [panX, setPanX] = useState(0); const [panY, setPanY] = useState(0); const [isDragging, setIsDragging] = useState(false); const [dragStart, setDragStart] = useState({ x: 0, y: 0 }); const [hoveredFace, setHoveredFace] = useState<{ faceId: number; personId: number | null; personName: string | null; mouseX: number; mouseY: number; } | null>(null); const [clickedFace, setClickedFace] = useState<{ faceId: number; person: Person | null; } | null>(null); const [isDialogOpen, setIsDialogOpen] = useState(false); const [reportingPhotoId, setReportingPhotoId] = useState(null); const [isReported, setIsReported] = useState(false); const [reportStatus, setReportStatus] = useState(null); const [reportDialogPhotoId, setReportDialogPhotoId] = useState(null); const [reportDialogComment, setReportDialogComment] = useState(''); const [reportDialogError, setReportDialogError] = useState(null); const [favoritingPhotoId, setFavoritingPhotoId] = useState(null); const [isFavorited, setIsFavorited] = useState(false); const [showSignInRequiredDialog, setShowSignInRequiredDialog] = useState(false); const [loginDialogOpen, setLoginDialogOpen] = useState(false); const [registerDialogOpen, setRegisterDialogOpen] = useState(false); const [showRegisteredMessage, setShowRegisteredMessage] = useState(false); const imageRef = useRef(null); const videoRef = useRef(null); const [isVideoPlaying, setIsVideoPlaying] = useState(false); const videoAutoPlayAttemptedRef = useRef(null); const containerRef = useRef(null); const hoveredFaceTooltip = hoveredFace ? hoveredFace.personName ? (isLoggedIn ? hoveredFace.personName : null) : (!session || hasWriteAccess ? 'Identify' : null) : null; // Debug: Log hoveredFace state changes useEffect(() => { console.log('[PhotoViewerClient] hoveredFace state changed:', { hoveredFace, hasHoveredFace: !!hoveredFace, tooltip: hoveredFaceTooltip, }); }, [hoveredFace, hoveredFaceTooltip]); // Debug: Log tooltip calculation useEffect(() => { if (hoveredFace) { console.log('[PhotoViewerClient] Tooltip calculation:', { hoveredFace, hasPersonName: !!hoveredFace.personName, personName: hoveredFace.personName, isLoggedIn, hasSession: !!session, hasWriteAccess, tooltip: hoveredFaceTooltip, tooltipLogic: hoveredFace.personName ? `personName exists, isLoggedIn=${isLoggedIn} → ${isLoggedIn ? hoveredFace.personName : 'null'}` : `no personName, !session=${!session} || hasWriteAccess=${hasWriteAccess} → ${(!session || hasWriteAccess) ? 'Identify' : 'null'}`, }); } }, [hoveredFace, hoveredFaceTooltip, isLoggedIn, session, hasWriteAccess]); // Update current photo when index changes (client-side navigation) useEffect(() => { if (allPhotos.length > 0 && currentIdx >= 0 && currentIdx < allPhotos.length) { const newPhoto = allPhotos[currentIdx]; if (newPhoto && newPhoto.id !== currentPhoto.id) { setImageLoading(true); const normalizedPhoto = normalizePhoto(newPhoto); setCurrentPhoto(normalizedPhoto); setHoveredFace(null); // Reset face detection when photo changes imageRef.current = null; // Reset image ref // Reset video state when photo changes setIsVideoPlaying(false); videoAutoPlayAttemptedRef.current = null; if (videoRef.current) { videoRef.current.pause(); videoRef.current.currentTime = 0; } // Reset zoom and pan when photo changes setZoom(1); setPanX(0); setPanY(0); } } }, [currentIdx, allPhotos, currentPhoto.id]); // Debug: Log photo data structure when currentPhoto changes useEffect(() => { console.log('[PhotoViewerClient] Current photo changed:', { photoId: currentPhoto.id, filename: currentPhoto.filename, hasFaces: !!currentPhoto.faces, facesCount: currentPhoto.faces?.length || 0, faces: currentPhoto.faces, facesStructure: currentPhoto.faces?.map((face, idx) => { const person = face.person as any; // Use any to check both camelCase and snake_case return { index: idx, id: face.id, personId: face.personId, hasLocation: !!face.location, location: face.location, hasPerson: !!face.person, person: face.person ? { id: person.id, // Check both camelCase and snake_case firstName: person.firstName || person.first_name, lastName: person.lastName || person.last_name, // Show raw person object to see actual structure rawPerson: person, } : null, }; }), }); }, [currentPhoto.id, currentPhoto.faces]); // Auto-play videos when navigated to (only once per photo) useEffect(() => { if (isVideo(currentPhoto) && videoRef.current && videoAutoPlayAttemptedRef.current !== currentPhoto.id) { // Mark that we've attempted auto-play for this photo videoAutoPlayAttemptedRef.current = currentPhoto.id; // Ensure controls are enabled if (videoRef.current) { videoRef.current.controls = true; } // Small delay to ensure video element is ready const timer = setTimeout(() => { if (videoRef.current && videoAutoPlayAttemptedRef.current === currentPhoto.id) { videoRef.current.play().catch((error) => { // Autoplay may fail due to browser policies, that's okay console.log('Video autoplay prevented:', error); }); } }, 100); return () => clearTimeout(timer); } }, [currentPhoto]); // Check report status when photo changes or session changes useEffect(() => { const checkReportStatus = async () => { if (!session?.user?.id || !currentPhoto) { setIsReported(false); setReportStatus(null); return; } try { const response = await fetch(`/api/photos/${currentPhoto.id}/report`); if (response.ok) { const data = await response.json(); if (data.reported && data.status === 'pending') { setIsReported(true); setReportStatus(data.status); } else { setIsReported(false); setReportStatus(data.status || null); } } else { setIsReported(false); setReportStatus(null); } } catch (error) { console.error('Error checking report status:', error); setIsReported(false); setReportStatus(null); } }; checkReportStatus(); }, [currentPhoto.id, session?.user?.id]); // Check favorite status when photo changes or session changes useEffect(() => { const checkFavoriteStatus = async () => { if (!session?.user?.id || !currentPhoto) { setIsFavorited(false); return; } try { const response = await fetch(`/api/photos/${currentPhoto.id}/favorite`); if (response.ok) { const data = await response.json(); setIsFavorited(data.favorited || false); } else { setIsFavorited(false); } } catch (error) { console.error('Error checking favorite status:', error); setIsFavorited(false); } }; checkFavoriteStatus(); }, [currentPhoto.id, session?.user?.id]); // Keyboard navigation useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { if (e.key === 'ArrowLeft') { handlePrevious(); } else if (e.key === 'ArrowRight') { handleNext(false); } else if (e.key === 'Escape') { handleClose(); } else if (e.key === '+' || e.key === '=') { if (e.ctrlKey || e.metaKey) { e.preventDefault(); handleZoomIn(); } } else if (e.key === '-' || e.key === '_') { if (e.ctrlKey || e.metaKey) { e.preventDefault(); handleZoomOut(); } } else if (e.key === '0' && (e.ctrlKey || e.metaKey)) { e.preventDefault(); handleResetZoom(); } }; window.addEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown); }, [currentIdx, allPhotos.length]); const handlePrevious = () => { // Stop slideshow when user manually navigates if (isPlaying) { setIsPlaying(false); } if (allPhotos.length > 0 && currentIdx > 0) { const newIdx = currentIdx - 1; setCurrentIdx(newIdx); // Update URL if in modal mode (onClose is provided) if (onClose && allPhotos[newIdx]) { const photoIds = allPhotos.map((p) => p.id).join(','); // Preserve existing query params (filters, etc.) const params = new URLSearchParams(window.location.search); params.set('photo', allPhotos[newIdx].id.toString()); params.set('photos', photoIds); params.set('index', newIdx.toString()); router.replace(`/?${params.toString()}`, { scroll: false }); } } }; const handleNext = (fromSlideshow = false) => { // Stop slideshow when user manually navigates (unless it's from the slideshow itself) if (!fromSlideshow && isPlaying) { setIsPlaying(false); } if (allPhotos.length > 0 && currentIdx < allPhotos.length - 1) { // Find next non-video photo for slideshow let newIdx = currentIdx + 1; if (fromSlideshow) { // Skip videos in slideshow mode while (newIdx < allPhotos.length && isVideo(allPhotos[newIdx])) { newIdx++; } // If we've reached the end (only videos remaining), stop slideshow if (newIdx >= allPhotos.length) { setIsPlaying(false); return; } } setCurrentIdx(newIdx); // Update URL if in modal mode (onClose is provided) if (onClose && allPhotos[newIdx]) { const photoIds = allPhotos.map((p) => p.id).join(','); // Preserve existing query params (filters, etc.) const params = new URLSearchParams(window.location.search); params.set('photo', allPhotos[newIdx].id.toString()); params.set('photos', photoIds); params.set('index', newIdx.toString()); router.replace(`/?${params.toString()}`, { scroll: false }); } } else if (allPhotos.length > 0 && currentIdx === allPhotos.length - 1 && isPlaying) { // If at the end and playing, stop the slideshow setIsPlaying(false); } }; // Auto-advance slideshow useEffect(() => { // If slideshow is playing and current photo is a video, skip to next non-video immediately if (isPlaying && isVideo(currentPhoto) && allPhotos.length > 0) { // Find next non-video photo let nextIdx = currentIdx + 1; while (nextIdx < allPhotos.length && isVideo(allPhotos[nextIdx])) { nextIdx++; } // If we found a non-video photo, advance to it if (nextIdx < allPhotos.length) { setCurrentIdx(nextIdx); if (onClose && allPhotos[nextIdx]) { const photoIds = allPhotos.map((p) => p.id).join(','); const params = new URLSearchParams(window.location.search); params.set('photo', allPhotos[nextIdx].id.toString()); params.set('photos', photoIds); params.set('index', nextIdx.toString()); router.replace(`/?${params.toString()}`, { scroll: false }); } return; } else { // No more non-video photos, stop slideshow setIsPlaying(false); return; } } if (isPlaying && !imageLoading && allPhotos.length > 0 && currentIdx < allPhotos.length - 1 && !isVideo(currentPhoto)) { slideTimerRef.current = setTimeout(() => { handleNext(true); // Pass true to indicate this is from slideshow }, currentInterval); } else { if (slideTimerRef.current) { clearTimeout(slideTimerRef.current); slideTimerRef.current = null; } } return () => { if (slideTimerRef.current) { clearTimeout(slideTimerRef.current); } }; }, [isPlaying, currentIdx, imageLoading, allPhotos, currentInterval, currentPhoto, onClose, router]); const toggleSlideshow = () => { setIsPlaying(!isPlaying); }; // Zoom functions const handleZoomIn = (e?: React.MouseEvent) => { if (e) { e.stopPropagation(); e.preventDefault(); } // Skip zoom for videos if (isVideo(currentPhoto)) { return; } // Pause slideshow when user zooms if (isPlaying) { setIsPlaying(false); } setZoom((prev) => { const newZoom = Math.min(prev + 0.25, 5); // Max zoom 5x console.log('Zoom in:', newZoom); return newZoom; }); }; const handleZoomOut = (e?: React.MouseEvent) => { if (e) { e.stopPropagation(); e.preventDefault(); } // Skip zoom for videos if (isVideo(currentPhoto)) { return; } // Pause slideshow when user zooms if (isPlaying) { setIsPlaying(false); } setZoom((prev) => { const newZoom = Math.max(prev - 0.25, 0.5); // Min zoom 0.5x console.log('Zoom out:', newZoom); if (newZoom === 1) { // Reset pan when zoom returns to 1 setPanX(0); setPanY(0); } return newZoom; }); }; const handleResetZoom = (e?: React.MouseEvent) => { if (e) { e.stopPropagation(); e.preventDefault(); } // Skip zoom for videos if (isVideo(currentPhoto)) { return; } console.log('Reset zoom'); setZoom(1); setPanX(0); setPanY(0); }; // Mouse wheel zoom useEffect(() => { const handleWheel = (e: WheelEvent) => { // Skip zoom for videos if (isVideo(currentPhoto)) { return; } if (e.ctrlKey || e.metaKey) { e.preventDefault(); if (e.deltaY < 0) { handleZoomIn(); } else { handleZoomOut(); } } }; const container = containerRef.current; if (container) { container.addEventListener('wheel', handleWheel, { passive: false }); return () => container.removeEventListener('wheel', handleWheel); } }, [currentPhoto]); // Pan when zoomed const handleMouseMovePan = useCallback((e: React.MouseEvent) => { if (isDragging && zoom > 1) { setPanX(e.clientX - dragStart.x); setPanY(e.clientY - dragStart.y); } }, [isDragging, zoom, dragStart]); const handleMouseDown = (e: React.MouseEvent) => { if (zoom > 1 && e.button === 0) { e.preventDefault(); setIsDragging(true); setDragStart({ x: e.clientX - panX, y: e.clientY - panY }); } }; const handleMouseUp = useCallback(() => { setIsDragging(false); }, []); // Global mouse up handler for dragging useEffect(() => { if (isDragging) { const handleGlobalMouseUp = () => { setIsDragging(false); }; window.addEventListener('mouseup', handleGlobalMouseUp); return () => window.removeEventListener('mouseup', handleGlobalMouseUp); } }, [isDragging]); // Update interval handler const handleIntervalChange = (value: string) => { const newInterval = parseInt(value, 10) * 1000; // Convert seconds to milliseconds setCurrentInterval(newInterval); }; const handleClose = () => { // Stop slideshow when closing setIsPlaying(false); if (slideTimerRef.current) { clearTimeout(slideTimerRef.current); slideTimerRef.current = null; } if (onClose) { // Use provided callback (for modal mode) onClose(); } else { // Fallback to router.back() for route-based navigation router.back(); } }; const handleImageLoad = (e: React.SyntheticEvent) => { setImageLoading(false); imageRef.current = e.currentTarget; console.log('[PhotoViewerClient] Image loaded, imageRef set:', { hasImageRef: !!imageRef.current, naturalWidth: imageRef.current?.naturalWidth, naturalHeight: imageRef.current?.naturalHeight, src: imageRef.current?.src, currentPhotoId: currentPhoto.id, }); }; const handleImageError = () => { setImageLoading(false); }; const handleDownloadPhoto = useCallback(() => { const link = document.createElement('a'); link.href = getPhotoDownloadUrl(currentPhoto, { watermark: !isLoggedIn }); link.download = getPhotoFilename(currentPhoto); document.body.appendChild(link); link.click(); document.body.removeChild(link); }, [currentPhoto, isLoggedIn]); const findFaceAtPoint = useCallback((x: number, y: number) => { console.log('[PhotoViewerClient] findFaceAtPoint called:', { x, y, hasFaces: !!currentPhoto.faces, facesCount: currentPhoto.faces?.length || 0, hasImageRef: !!imageRef.current, hasContainerRef: !!containerRef.current, }); if (!currentPhoto.faces || currentPhoto.faces.length === 0) { console.log('[PhotoViewerClient] findFaceAtPoint: No faces in photo'); return null; } if (!imageRef.current || !containerRef.current) { console.log('[PhotoViewerClient] findFaceAtPoint: Missing refs', { imageRef: !!imageRef.current, containerRef: !!containerRef.current, }); return null; } const container = containerRef.current; const rect = container.getBoundingClientRect(); const mouseX = x - rect.left; const mouseY = y - rect.top; const img = imageRef.current; const naturalWidth = img.naturalWidth; const naturalHeight = img.naturalHeight; const containerWidth = rect.width; const containerHeight = rect.height; console.log('[PhotoViewerClient] findFaceAtPoint: Image dimensions:', { naturalWidth, naturalHeight, containerWidth, containerHeight, mouseX, mouseY, }); if (!naturalWidth || !naturalHeight) { console.log('[PhotoViewerClient] findFaceAtPoint: Invalid image dimensions'); return null; } // Check each face to see if point is over it for (const face of currentPhoto.faces) { if (!face.location) { console.log('[PhotoViewerClient] findFaceAtPoint: Face missing location', { faceId: face.id }); continue; } const location = parseFaceLocation(face.location); if (!location) { console.log('[PhotoViewerClient] findFaceAtPoint: Failed to parse location', { faceId: face.id, location: face.location }); continue; } const isInFace = isPointInFaceWithFit( mouseX, mouseY, location, naturalWidth, naturalHeight, containerWidth, containerHeight, 'contain' // PhotoViewer uses object-contain ); if (isInFace) { const person = face.person as any; // Use any to check both camelCase and snake_case const firstName = person?.firstName || person?.first_name; const lastName = person?.lastName || person?.last_name; const personName = firstName && lastName ? `${firstName} ${lastName}` : null; console.log('[PhotoViewerClient] findFaceAtPoint: Face found!', { faceId: face.id, personId: face.personId, hasPerson: !!face.person, personName, personRaw: person, // Show raw person to see actual structure }); return face; } } console.log('[PhotoViewerClient] findFaceAtPoint: No face found at point'); return null; }, [currentPhoto.faces]); const handleMouseMove = useCallback((e: React.MouseEvent) => { // Skip face detection for videos if (isVideo(currentPhoto)) { return; } // Handle pan if dragging and zoomed if (isDragging && zoom > 1) { handleMouseMovePan(e); return; } const face = findFaceAtPoint(e.clientX, e.clientY); if (face) { const person = face.person as any; const firstName = person?.first_name || person?.firstName; const lastName = person?.last_name || person?.lastName; const personName = firstName && lastName ? `${firstName} ${lastName}`.trim() : null; console.log('[PhotoViewerClient] handleMouseMove: Face detected on hover', { faceId: face.id, personId: face.personId, personName, mouseX: e.clientX, mouseY: e.clientY, }); // Update hovered face (or set if different face) setHoveredFace((prev) => { // If same face, just update position if (prev && prev.faceId === face.id) { return { ...prev, mouseX: e.clientX, mouseY: e.clientY, }; } // New face detected return { faceId: face.id, personId: face.personId, personName, mouseX: e.clientX, mouseY: e.clientY, }; }); } else { // Only log when clearing hoveredFace to avoid spam if (hoveredFace) { console.log('[PhotoViewerClient] handleMouseMove: No face detected, clearing hoveredFace'); } setHoveredFace(null); } }, [findFaceAtPoint, isDragging, zoom, currentPhoto, hoveredFace]); const handleClick = useCallback((e: React.MouseEvent) => { console.log('[PhotoViewerClient] handleClick called:', { isVideo: isVideo(currentPhoto), clientX: e.clientX, clientY: e.clientY, hasSession: !!session, hasWriteAccess, isDragging, zoom, }); // Handle video play/pause on click if (isVideo(currentPhoto)) { if (videoRef.current) { if (isVideoPlaying) { videoRef.current.pause(); setIsVideoPlaying(false); } else { videoRef.current.play(); setIsVideoPlaying(true); } } return; } const face = findFaceAtPoint(e.clientX, e.clientY); // Only allow clicking on unidentified faces (when tooltip shows "Identify") // Click is allowed if: face exists, face is NOT identified, and user has write access (or is not signed in) const isUnidentified = face && !face.person; const canClick = isUnidentified && (!session || hasWriteAccess); console.log('[PhotoViewerClient] handleClick: Face detection result:', { foundFace: !!face, faceId: face?.id, facePersonId: face?.personId, faceHasPerson: !!face?.person, isUnidentified, session: !!session, hasWriteAccess, canClick, conditionBreakdown: face ? { 'face exists': !!face, 'face.person (identified)': !!face.person, 'isUnidentified': isUnidentified, '!session': !session, 'hasWriteAccess': hasWriteAccess, 'canClick': canClick, } : null, }); if (canClick) { console.log('[PhotoViewerClient] handleClick: Opening identify dialog', { faceId: face.id, person: face.person, }); setClickedFace({ faceId: face.id, person: face.person, }); setIsDialogOpen(true); } else { console.log('[PhotoViewerClient] handleClick: Click blocked', { reason: !face ? 'no face found' : face?.person ? 'face already identified' : 'insufficient permissions', }); } }, [findFaceAtPoint, session, hasWriteAccess, currentPhoto, isVideoPlaying, isDragging, zoom]); const handleSaveFace = async (data: { personId?: number; firstName?: string; lastName?: string; middleName?: string; maidenName?: string; dateOfBirth?: Date; }) => { if (!clickedFace) return; const response = await fetch(`/api/faces/${clickedFace.faceId}/identify`, { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify(data), }); if (!response.ok) { const error = await response.json(); if (response.status === 401) { // Authentication error - the dialog should handle this throw new Error('Please sign in to identify faces'); } throw new Error(error.error || 'Failed to save face identification'); } const result = await response.json(); // Success - identification is pending approval // Note: We don't refresh the photo data since it's pending approval // The face won't show the identification until approved by admin }; const resetReportDialog = () => { setReportDialogPhotoId(null); setReportDialogComment(''); setReportDialogError(null); }; const handleUndoReport = async (photoId: number) => { if (reportingPhotoId === photoId) { return; } setReportingPhotoId(photoId); try { const response = await fetch(`/api/photos/${photoId}/report`, { method: 'DELETE', headers: { 'Content-Type': 'application/json', }, }); if (!response.ok) { const error = await response.json(); if (response.status === 401) { alert('Please sign in to report photos'); } else if (response.status === 403) { alert('Cannot undo report that has already been reviewed'); } else if (response.status === 404) { alert('Report not found'); } else { alert(error.error || 'Failed to undo report'); } return; } setIsReported(false); setReportStatus(null); alert('Report undone successfully.'); } catch (error) { console.error('Error undoing report:', error); alert('Failed to undo report. Please try again.'); } finally { setReportingPhotoId(null); } }; const handleReportPhoto = async () => { // Check if user is logged in if (!session) { setShowSignInRequiredDialog(true); return; } if (reportingPhotoId === currentPhoto.id) return; // Already processing const isPending = isReported && reportStatus === 'pending'; const isDismissed = isReported && reportStatus === 'dismissed'; if (isDismissed) { alert('This report was dismissed by an administrator and cannot be resubmitted.'); return; } if (isPending) { await handleUndoReport(currentPhoto.id); return; } // Open dialog to enter comment setReportDialogPhotoId(currentPhoto.id); setReportDialogComment(''); setReportDialogError(null); }; const handleSubmitReport = async () => { if (reportDialogPhotoId === null) { return; } const trimmedComment = reportDialogComment.trim(); if (trimmedComment.length > REPORT_COMMENT_MAX_LENGTH) { setReportDialogError(`Comment must be ${REPORT_COMMENT_MAX_LENGTH} characters or less.`); return; } setReportDialogError(null); setReportingPhotoId(reportDialogPhotoId); try { const response = await fetch(`/api/photos/${reportDialogPhotoId}/report`, { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ comment: trimmedComment.length > 0 ? trimmedComment : null, }), }); if (!response.ok) { const error = await response.json().catch(() => null); if (response.status === 401) { setShowSignInRequiredDialog(true); } else if (response.status === 403) { alert(error?.error || 'Cannot re-report this photo.'); } else if (response.status === 409) { alert('You have already reported this photo'); } else if (response.status === 400) { setReportDialogError(error?.error || 'Invalid comment'); return; } else { alert(error?.error || 'Failed to report photo. Please try again.'); } return; } setIsReported(true); setReportStatus('pending'); const wasReReported = isReported && reportStatus === 'reviewed'; alert( wasReReported ? 'Photo re-reported successfully. Thank you for your report.' : 'Photo reported successfully. Thank you for your report.' ); resetReportDialog(); } catch (error) { console.error('Error reporting photo:', error); alert('Failed to create report. Please try again.'); } finally { setReportingPhotoId(null); } }; const handleToggleFavorite = async () => { // Check if user is logged in if (!session) { setShowSignInRequiredDialog(true); return; } if (favoritingPhotoId === currentPhoto.id) return; // Already processing setFavoritingPhotoId(currentPhoto.id); try { const response = await fetch(`/api/photos/${currentPhoto.id}/favorite`, { method: 'POST', headers: { 'Content-Type': 'application/json', }, }); if (!response.ok) { const error = await response.json(); if (response.status === 401) { setShowSignInRequiredDialog(true); } else { alert(error.error || 'Failed to toggle favorite'); } return; } const data = await response.json(); setIsFavorited(data.favorited); } catch (error) { console.error('Error toggling favorite:', error); alert('Failed to toggle favorite. Please try again.'); } finally { setFavoritingPhotoId(null); } }; const peopleNames = (currentPhoto as any).faces ?.map((face: any) => face.Person) .filter((person: any): person is Person => person != null) .map((person: Person) => `${person.first_name} ${person.last_name}`.trim()) || []; const tags = (currentPhoto as any).PhotoTagLinkage?.map((pt: any) => pt.Tag.tag_name) || []; const hasPrevious = allPhotos.length > 0 && currentIdx > 0; const hasNext = allPhotos.length > 0 && currentIdx < allPhotos.length - 1; return (
{/* Close Button */} {/* Play/Pause Button and Interval Selector */} {allPhotos.length > 1 && (
)} {/* Zoom Controls - Hide for videos */} {!isVideo(currentPhoto) && (
e.stopPropagation()} onMouseDown={(e) => e.stopPropagation()} onMouseUp={(e) => e.stopPropagation()} > {Math.round(zoom * 100)}% {zoom !== 1 && ( )}
)} {/* Previous Button */} {hasPrevious && ( )} {/* Next Button */} {hasNext && ( )} {/* Photo Container */}
{isVideo(currentPhoto) ? ( // For videos, render in a completely isolated container with no event handlers
) : (
1 ? 'cursor-grab active:cursor-grabbing' : ''}`} style={{ transform: zoom > 1 ? `translate(${panX}px, ${panY}px)` : undefined, transition: zoom === 1 ? 'transform 0.2s' : undefined, }} onMouseMove={handleMouseMove} onMouseLeave={() => { console.log('[PhotoViewerClient] Mouse left container, clearing hoveredFace'); setHoveredFace(null); setIsDragging(false); }} onMouseDown={handleMouseDown} onMouseUp={handleMouseUp} onClick={(e) => { // Don't handle click if it's on a button or zoom controls const target = e.target as HTMLElement; const isButton = target.closest('button'); const isZoomControl = target.closest('[aria-label*="Zoom"]') || target.closest('[aria-label*="Reset zoom"]'); console.log('[PhotoViewerClient] Container onClick:', { isButton: !!isButton, isZoomControl: !!isZoomControl, isDragging, zoom, willHandleClick: !isButton && !isZoomControl && (!isDragging || zoom === 1), }); if (isButton || isZoomControl) { return; } // For images, only handle click if not dragging if (!isDragging || zoom === 1) { handleClick(e); } else { console.log('[PhotoViewerClient] Click ignored due to dragging/zoom state'); } }} > {imageLoading && (
Loading...
)}
{currentPhoto.filename}
)}
{/* Face Tooltip - Show for identified faces, or for unidentified faces if not signed in or has write access */} {hoveredFace && hoveredFaceTooltip && (

{hoveredFaceTooltip}

)} {/* Report Button - Left Bottom Corner - Aligned with filename, above video controls */} {(() => { // Position based on whether it's a video (to align with filename above controls) // For videos: position above the controls area (controls are ~60-70px, plus padding) const bottomPosition = isVideo(currentPhoto) ? '160px' : '96px'; // 160px for videos to be well above controls, 96px (bottom-24) for images if (!session) { // Not logged in - show basic report button return ( ); } // Logged in - show button with status const isPending = isReported && reportStatus === 'pending'; const isReviewed = isReported && reportStatus === 'reviewed'; const isDismissed = isReported && reportStatus === 'dismissed'; let tooltipText: string; let buttonClass: string; if (isPending) { tooltipText = 'Reported as inappropriate. Click to undo'; buttonClass = 'bg-red-600/70 hover:bg-red-600/90'; } else if (isReviewed) { tooltipText = 'Report reviewed and kept. Click to report again'; buttonClass = 'bg-green-600/70 hover:bg-green-600/90'; } else if (isDismissed) { tooltipText = 'Report dismissed'; buttonClass = 'bg-gray-600/70 hover:bg-gray-600/90'; } else { tooltipText = 'Report inappropriate photo'; buttonClass = 'bg-black/50'; } return ( ); })()} {/* Download Button - Right Bottom Corner (next to favorite), above controls */} {(() => { const bottomPosition = isVideo(currentPhoto) ? '160px' : '96px'; return ( ); })()} {/* Favorite Button - Right Bottom Corner - Aligned with filename, above video controls */} {(() => { // Position based on whether it's a video (to align with filename above controls) const bottomPosition = isVideo(currentPhoto) ? '160px' : '96px'; if (!session) { // Not logged in - show basic favorite button return ( ); } // Logged in - show button with favorite status return ( ); })()} {/* Photo Info Overlay - Only covers text area, not video controls */}

{currentPhoto.filename}

{currentPhoto.date_taken && (

{new Date(currentPhoto.date_taken).toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric', })}

)} {peopleNames.length > 0 && (

People: {peopleNames.join(', ')}

)} {tags.length > 0 && (

Tags: {tags.join(', ')}

)} {allPhotos.length > 0 && (

{currentIdx + 1} of {allPhotos.length}

)}
{/* Identify Face Dialog */} {clickedFace && ( { setIsDialogOpen(open); if (!open) { setClickedFace(null); } }} faceId={clickedFace.faceId} existingPerson={clickedFace.person ? { firstName: (clickedFace.person as any).first_name || (clickedFace.person as any).firstName, lastName: (clickedFace.person as any).last_name || (clickedFace.person as any).lastName, middleName: (clickedFace.person as any).middle_name || (clickedFace.person as any).middleName, maidenName: (clickedFace.person as any).maiden_name || (clickedFace.person as any).maidenName, dateOfBirth: (clickedFace.person as any).date_of_birth || (clickedFace.person as any).dateOfBirth, } : null} onSave={handleSaveFace} /> )} {/* Report Comment Dialog */} { if (!open) { resetReportDialog(); } }} > Report Photo Optionally include a short comment to help administrators understand the issue.