'use client'; import { useState, useRef, useCallback, useEffect, useMemo } from 'react'; import { useRouter } from 'next/navigation'; import { useSession } from 'next-auth/react'; import { Photo, Person } from '@prisma/client'; import Image from 'next/image'; import { Check, Flag, Play, Heart, Download } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { TooltipProvider, TooltipContent, TooltipTrigger, } from '@/components/ui/tooltip'; import * as TooltipPrimitive from '@radix-ui/react-tooltip'; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from '@/components/ui/dialog'; import { parseFaceLocation, isPointInFace } from '@/lib/face-utils'; import { isUrl, isVideo, getImageSrc } from '@/lib/photo-utils'; import { LoginDialog } from '@/components/LoginDialog'; import { RegisterDialog } from '@/components/RegisterDialog'; interface FaceWithLocation { id: number; personId: number | null; location: string; person: Person | null; } interface PhotoWithPeople extends Photo { faces?: FaceWithLocation[]; } interface PhotoGridProps { photos: PhotoWithPeople[]; selectionMode?: boolean; selectedPhotoIds?: number[]; onToggleSelect?: (photoId: number) => void; refreshFavoritesKey?: number; } /** * Gets unique people names from photo faces */ function getPeopleNames(photo: PhotoWithPeople): string[] { if (!photo.faces) return []; const people = photo.faces .map((face) => face.person) .filter((person): person is Person => person !== null) .map((person: any) => { // Handle both camelCase and snake_case const firstName = person.firstName || person.first_name || ''; const lastName = person.lastName || person.last_name || ''; return `${firstName} ${lastName}`.trim(); }); // Remove duplicates return Array.from(new Set(people)); } 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 PhotoGrid({ photos, selectionMode = false, selectedPhotoIds = [], onToggleSelect, refreshFavoritesKey = 0, }: PhotoGridProps) { const router = useRouter(); const { data: session, update } = useSession(); const isLoggedIn = Boolean(session); const hasWriteAccess = session?.user?.hasWriteAccess === true; // Normalize photos: ensure faces is always available (handle Face vs faces) const normalizePhoto = (photo: PhotoWithPeople): PhotoWithPeople => { 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; }; // Normalize all photos const normalizedPhotos = useMemo(() => { return photos.map(normalizePhoto); }, [photos]); const [hoveredFace, setHoveredFace] = useState<{ photoId: number; faceId: number; personId: number | null; personName: string | null; } | null>(null); const [reportingPhotoId, setReportingPhotoId] = useState(null); const [reportedPhotos, setReportedPhotos] = useState>(new Map()); const [favoritingPhotoId, setFavoritingPhotoId] = useState(null); const [favoritedPhotos, setFavoritedPhotos] = useState>(new Map()); const [showSignInRequiredDialog, setShowSignInRequiredDialog] = useState(false); const [loginDialogOpen, setLoginDialogOpen] = useState(false); const [registerDialogOpen, setRegisterDialogOpen] = useState(false); const [showRegisteredMessage, setShowRegisteredMessage] = useState(false); const [reportDialogPhotoId, setReportDialogPhotoId] = useState(null); const [reportDialogComment, setReportDialogComment] = useState(''); const [reportDialogError, setReportDialogError] = useState(null); const imageRefs = useRef>(new Map()); const handleMouseMove = useCallback(( e: React.MouseEvent, photo: PhotoWithPeople ) => { // Skip face detection for videos if (isVideo(photo)) { setHoveredFace(null); return; } if (!photo.faces || photo.faces.length === 0) { setHoveredFace(null); return; } const container = e.currentTarget; const rect = container.getBoundingClientRect(); const mouseX = e.clientX - rect.left; const mouseY = e.clientY - rect.top; // Get image dimensions from cache const imageData = imageRefs.current.get(photo.id); if (!imageData) { setHoveredFace(null); return; } const { naturalWidth, naturalHeight } = imageData; const containerWidth = rect.width; const containerHeight = rect.height; // Check each face to see if mouse is over it for (const face of photo.faces) { const location = parseFaceLocation(face.location); if (!location) continue; if ( isPointInFace( mouseX, mouseY, location, naturalWidth, naturalHeight, containerWidth, containerHeight ) ) { // Face detected! const person = face.person as any; // Handle both camelCase and snake_case const personName = person ? `${person.firstName || person.first_name || ''} ${person.lastName || person.last_name || ''}`.trim() : null; setHoveredFace({ photoId: photo.id, faceId: face.id, personId: face.personId, personName: personName || null, }); return; } } // No face detected setHoveredFace(null); }, []); const handleImageLoad = useCallback((photoId: number, img: HTMLImageElement) => { imageRefs.current.set(photoId, { naturalWidth: img.naturalWidth, naturalHeight: img.naturalHeight, }); }, []); const handleDownloadPhoto = useCallback((event: React.MouseEvent, photo: Photo) => { event.stopPropagation(); const link = document.createElement('a'); link.href = getPhotoDownloadUrl(photo, { watermark: !isLoggedIn }); link.download = getPhotoFilename(photo); document.body.appendChild(link); link.click(); document.body.removeChild(link); }, [isLoggedIn]); // Remove duplicates by ID to prevent React key errors // Memoized to prevent recalculation on every render // Must be called before any early returns to maintain hooks order const uniquePhotos = useMemo(() => { return normalizedPhotos.filter((photo, index, self) => index === self.findIndex((p) => p.id === photo.id) ); }, [normalizedPhotos]); // Fetch report status for all photos when component mounts or photos change // Uses batch API to reduce N+1 query problem // Must be called before any early returns to maintain hooks order useEffect(() => { if (!session?.user?.id) { setReportedPhotos(new Map()); return; } const fetchReportStatuses = async () => { const photoIds = uniquePhotos.map(p => p.id); if (photoIds.length === 0) { return; } try { // Batch API call - single request for all photos const response = await fetch('/api/photos/reports/batch', { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ photoIds }), }); if (!response.ok) { throw new Error('Failed to fetch report statuses'); } const data = await response.json(); const statusMap = new Map(); // Process batch results if (data.results) { for (const [photoIdStr, result] of Object.entries(data.results)) { const photoId = parseInt(photoIdStr, 10); const reportData = result as { reported: boolean; status?: string }; if (reportData.reported && reportData.status) { statusMap.set(photoId, { status: reportData.status }); } } } setReportedPhotos(statusMap); } catch (error) { console.error('Error fetching batch report statuses:', error); // Fallback: set empty map on error setReportedPhotos(new Map()); } }; fetchReportStatuses(); }, [uniquePhotos, session?.user?.id]); // Fetch favorite status for all photos when component mounts or photos change // Uses batch API to reduce N+1 query problem useEffect(() => { if (!session?.user?.id) { setFavoritedPhotos(new Map()); return; } const fetchFavoriteStatuses = async () => { const photoIds = uniquePhotos.map(p => p.id); if (photoIds.length === 0) { return; } try { // Batch API call - single request for all photos const response = await fetch('/api/photos/favorites/batch', { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ photoIds }), }); if (!response.ok) { throw new Error('Failed to fetch favorite statuses'); } const data = await response.json(); const favoriteMap = new Map(); // Process batch results if (data.results) { for (const [photoIdStr, isFavorited] of Object.entries(data.results)) { const photoId = parseInt(photoIdStr, 10); favoriteMap.set(photoId, isFavorited as boolean); } } setFavoritedPhotos(favoriteMap); } catch (error) { console.error('Error fetching batch favorite statuses:', error); // Fallback: set empty map on error setFavoritedPhotos(new Map()); } }; fetchFavoriteStatuses(); }, [uniquePhotos, session?.user?.id, refreshFavoritesKey]); // Filter out videos for slideshow navigation (only images) // Note: This is only used for slideshow context, not for navigation // Memoized to maintain consistent hook order const imageOnlyPhotos = useMemo(() => { return uniquePhotos.filter((p) => !isVideo(p)); }, [uniquePhotos]); const handlePhotoClick = (photoId: number, index: number) => { const photo = uniquePhotos.find((p) => p.id === photoId); if (!photo) return; // Use the full photos list (including videos) for navigation // This ensures consistent navigation whether clicking a photo or video const allPhotoIds = uniquePhotos.map((p) => p.id).join(','); const photoIndex = uniquePhotos.findIndex((p) => p.id === photoId); if (photoIndex === -1) return; // Update URL with photo query param while preserving existing params (filters, etc.) const params = new URLSearchParams(window.location.search); params.set('photo', photoId.toString()); params.set('photos', allPhotoIds); params.set('index', photoIndex.toString()); router.push(`/?${params.toString()}`, { scroll: false }); }; const handlePhotoInteraction = (photoId: number, index: number) => { if (selectionMode && onToggleSelect) { onToggleSelect(photoId); return; } handlePhotoClick(photoId, index); }; const resetReportDialog = () => { setReportDialogPhotoId(null); setReportDialogComment(''); setReportDialogError(null); }; const handleUndoReport = async (photoId: number) => { const reportInfo = reportedPhotos.get(photoId); const isReported = reportInfo && reportInfo.status === 'pending'; if (!isReported || 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; } const newMap = new Map(reportedPhotos); newMap.delete(photoId); setReportedPhotos(newMap); alert('Report undone successfully.'); } catch (error) { console.error('Error undoing photo report:', error); alert('Failed to undo report. Please try again.'); } finally { setReportingPhotoId(null); } }; const handleReportButtonClick = async (e: React.MouseEvent, photoId: number) => { e.stopPropagation(); // Prevent photo click from firing if (!session) { setShowSignInRequiredDialog(true); return; } if (reportingPhotoId === photoId) return; // Already processing const reportInfo = reportedPhotos.get(photoId); const isPending = reportInfo && reportInfo.status === 'pending'; const isDismissed = reportInfo && reportInfo.status === 'dismissed'; if (isDismissed) { alert('This report was dismissed by an administrator and cannot be resubmitted.'); return; } if (isPending) { await handleUndoReport(photoId); return; } setReportDialogPhotoId(photoId); 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; } const newMap = new Map(reportedPhotos); newMap.set(reportDialogPhotoId, { status: 'pending' }); setReportedPhotos(newMap); const previousReport = reportedPhotos.get(reportDialogPhotoId); alert( previousReport && previousReport.status === 'reviewed' ? '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 (e: React.MouseEvent, photoId: number) => { e.stopPropagation(); // Prevent photo click from firing if (!session) { setShowSignInRequiredDialog(true); return; } if (favoritingPhotoId === photoId) return; // Already processing setFavoritingPhotoId(photoId); try { const response = await fetch(`/api/photos/${photoId}/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(); const newMap = new Map(favoritedPhotos); newMap.set(photoId, data.favorited); setFavoritedPhotos(newMap); } catch (error) { console.error('Error toggling favorite:', error); alert('Failed to toggle favorite. Please try again.'); } finally { setFavoritingPhotoId(null); } }; return (
{uniquePhotos.map((photo, index) => { const hoveredFaceForPhoto = hoveredFace?.photoId === photo.id ? hoveredFace : null; const isSelected = selectionMode && selectedPhotoIds.includes(photo.id); // Determine tooltip text while respecting auth visibility rules let tooltipText: string = photo.filename; // Default fallback const isVideoPhoto = isVideo(photo); if (isVideoPhoto) { tooltipText = `Video: ${photo.filename}`; } else if (hoveredFaceForPhoto) { // Hovering over a specific face if (hoveredFaceForPhoto.personName) { // Face is identified - show person name (only if logged in) tooltipText = isLoggedIn ? hoveredFaceForPhoto.personName : photo.filename; } else { // Face is not identified - show "Identify" if user has write access or is not logged in tooltipText = (!session || hasWriteAccess) ? 'Identify' : photo.filename; } } else if (isLoggedIn) { // Hovering over photo (not a face) - show "People: " + names const peopleNames = getPeopleNames(photo); tooltipText = peopleNames.length > 0 ? `People: ${peopleNames.join(', ')}` : photo.filename; } return (
{/* Download Button - Top Left Corner */} {/* Report Button - Left Bottom Corner - Show always */} {(() => { if (!session) { // Not logged in - show basic report button return ( ); } // Logged in - show button with status const reportInfo = reportedPhotos.get(photo.id); const isReported = reportInfo && reportInfo.status === 'pending'; const isReviewed = reportInfo && reportInfo.status === 'reviewed'; const isDismissed = reportInfo && reportInfo.status === 'dismissed'; let tooltipText: string; let buttonClass: string; if (isReported) { 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 hover:bg-black/70'; } return ( ); })()} {/* Favorite Button - Right Bottom Corner - Show always */} {(() => { if (!session) { // Not logged in - show basic favorite button return ( ); } // Logged in - show button with favorite status const isFavorited = favoritedPhotos.get(photo.id) || false; return ( ); })()}

{tooltipText || photo.filename}

); })}
{/* Report Comment Dialog */} { if (!open) { resetReportDialog(); } }} > Report Photo Optionally include a short comment to help administrators understand the issue.