punimtag/viewer-frontend/components/PhotoViewerClient.tsx
Tanya de2144be2a feat: Add new scripts and update project structure for database management and user authentication
This commit introduces several new scripts for managing database operations, including user creation, permission grants, and data migrations. It also adds new documentation files to guide users through the setup and configuration processes. Additionally, the project structure is updated to enhance organization and maintainability, ensuring a smoother development experience for contributors. These changes support the ongoing transition to a web-based architecture and improve overall project functionality.
2026-01-06 13:53:24 -05:00

1680 lines
55 KiB
TypeScript

'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<PhotoWithDetails>(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<NodeJS.Timeout | null>(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<number | null>(null);
const [isReported, setIsReported] = useState<boolean>(false);
const [reportStatus, setReportStatus] = useState<string | null>(null);
const [reportDialogPhotoId, setReportDialogPhotoId] = useState<number | null>(null);
const [reportDialogComment, setReportDialogComment] = useState('');
const [reportDialogError, setReportDialogError] = useState<string | null>(null);
const [favoritingPhotoId, setFavoritingPhotoId] = useState<number | null>(null);
const [isFavorited, setIsFavorited] = useState<boolean>(false);
const [showSignInRequiredDialog, setShowSignInRequiredDialog] = useState(false);
const [loginDialogOpen, setLoginDialogOpen] = useState(false);
const [registerDialogOpen, setRegisterDialogOpen] = useState(false);
const [showRegisteredMessage, setShowRegisteredMessage] = useState(false);
const imageRef = useRef<HTMLImageElement | null>(null);
const videoRef = useRef<HTMLVideoElement | null>(null);
const [isVideoPlaying, setIsVideoPlaying] = useState(false);
const videoAutoPlayAttemptedRef = useRef<number | null>(null);
const containerRef = useRef<HTMLDivElement | null>(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<HTMLImageElement>) => {
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<HTMLDivElement>) => {
// 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 personName = face.person
? `${face.person.firstName} ${face.person.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<HTMLDivElement>) => {
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.faces
?.map((face) => face.person)
.filter((person): person is Person => person !== null)
.map((person) => `${person.firstName} ${person.lastName}`.trim()) || [];
const tags = currentPhoto.photoTags?.map((pt) => pt.tag.tagName) || [];
const hasPrevious = allPhotos.length > 0 && currentIdx > 0;
const hasNext = allPhotos.length > 0 && currentIdx < allPhotos.length - 1;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black">
{/* Close Button */}
<Button
variant="ghost"
size="icon"
className="absolute top-4 right-4 z-10 text-white hover:bg-white/20"
onClick={handleClose}
aria-label="Close"
>
<X className="h-6 w-6" />
</Button>
{/* Play/Pause Button and Interval Selector */}
{allPhotos.length > 1 && (
<div className="absolute top-4 left-4 z-10 flex items-center gap-2">
<Button
variant="ghost"
size="icon"
className="text-white hover:bg-white/20 h-12 w-12"
onClick={toggleSlideshow}
aria-label={isPlaying ? 'Pause slideshow' : 'Play slideshow'}
>
{isPlaying ? (
<Pause className="h-8 w-8" />
) : (
<Play className="h-8 w-8" />
)}
</Button>
<Select
value={Math.round(currentInterval / 1000).toString()}
onValueChange={handleIntervalChange}
>
<SelectTrigger className="w-24 h-9 bg-black/50 border-white/20 text-white hover:bg-white/20">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="1">1s</SelectItem>
<SelectItem value="2">2s</SelectItem>
<SelectItem value="3">3s</SelectItem>
<SelectItem value="5">5s</SelectItem>
<SelectItem value="7">7s</SelectItem>
<SelectItem value="10">10s</SelectItem>
<SelectItem value="15">15s</SelectItem>
<SelectItem value="20">20s</SelectItem>
<SelectItem value="30">30s</SelectItem>
<SelectItem value="60">60s</SelectItem>
</SelectContent>
</Select>
</div>
)}
{/* Zoom Controls - Hide for videos */}
{!isVideo(currentPhoto) && (
<div
className="absolute bottom-4 left-1/2 transform -translate-x-1/2 z-30 flex items-center gap-2 bg-black/50 rounded-lg p-2 pointer-events-auto"
onClick={(e) => e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()}
onMouseUp={(e) => e.stopPropagation()}
>
<Button
variant="ghost"
size="icon"
className="text-white hover:bg-white/20 pointer-events-auto"
onClick={(e) => {
console.log('Zoom out button clicked');
handleZoomOut(e);
}}
disabled={zoom <= 0.5}
aria-label="Zoom out"
type="button"
>
<ZoomOut className="h-5 w-5 pointer-events-none" />
</Button>
<span className="text-white text-sm min-w-[60px] text-center pointer-events-none">
{Math.round(zoom * 100)}%
</span>
<Button
variant="ghost"
size="icon"
className="text-white hover:bg-white/20 pointer-events-auto"
onClick={(e) => {
console.log('Zoom in button clicked');
handleZoomIn(e);
}}
disabled={zoom >= 5}
aria-label="Zoom in"
type="button"
>
<ZoomIn className="h-5 w-5 pointer-events-none" />
</Button>
{zoom !== 1 && (
<Button
variant="ghost"
size="icon"
className="text-white hover:bg-white/20 ml-2 pointer-events-auto"
onClick={(e) => {
console.log('Reset zoom button clicked');
handleResetZoom(e);
}}
aria-label="Reset zoom"
type="button"
>
<RotateCcw className="h-5 w-5 pointer-events-none" />
</Button>
)}
</div>
)}
{/* Previous Button */}
{hasPrevious && (
<Button
variant="ghost"
size="icon"
className="absolute left-4 z-10 text-white hover:bg-white/20 disabled:opacity-30"
onClick={handlePrevious}
disabled={imageLoading}
aria-label="Previous photo"
>
<ChevronLeft className="h-8 w-8" />
</Button>
)}
{/* Next Button */}
{hasNext && (
<Button
variant="ghost"
size="icon"
className="absolute right-4 z-10 text-white hover:bg-white/20 disabled:opacity-30"
onClick={() => handleNext(false)}
disabled={imageLoading}
aria-label="Next photo"
>
<ChevronRight className="h-8 w-8" />
</Button>
)}
{/* Photo Container */}
<div className="relative h-full w-full flex items-center justify-center p-4" style={{ overflow: isVideo(currentPhoto) ? 'visible' : 'hidden' }}>
{isVideo(currentPhoto) ? (
// For videos, render in a completely isolated container with no event handlers
<div
className="relative h-full w-full max-h-[calc(90vh-80px)] max-w-full"
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<video
ref={videoRef}
key={currentPhoto.id}
src={getVideoSrc(currentPhoto)}
className="object-contain w-full h-full"
controls={true}
controlsList="nodownload"
style={{
display: 'block',
width: '100%',
height: '100%',
maxWidth: '100%',
maxHeight: '100%',
}}
onLoadedData={() => {
setImageLoading(false);
if (videoRef.current) {
videoRef.current.controls = true;
}
}}
onError={() => {
setImageLoading(false);
}}
onPlay={() => setIsVideoPlaying(true)}
onPause={() => setIsVideoPlaying(false)}
onEnded={() => setIsVideoPlaying(false)}
/>
</div>
) : (
<div
ref={containerRef}
className={`relative h-full w-full max-h-[90vh] max-w-full ${hoveredFace && !hoveredFace.personName && (!session || hasWriteAccess) ? 'cursor-pointer' : ''} ${zoom > 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 && (
<div className="absolute inset-0 flex items-center justify-center text-white z-10">
Loading...
</div>
)}
<div
style={{
transform: `scale(${zoom})`,
transformOrigin: 'center center',
transition: zoom === 1 ? 'transform 0.2s' : undefined,
}}
className="relative h-full w-full"
>
<Image
key={currentPhoto.id}
src={getImageSrc(currentPhoto, { watermark: !isLoggedIn })}
alt={currentPhoto.filename}
fill
className="object-contain"
priority
unoptimized={!isUrl(currentPhoto.path)}
sizes="100vw"
onLoad={handleImageLoad}
onError={handleImageError}
/>
</div>
</div>
)}
</div>
{/* Face Tooltip - Show for identified faces, or for unidentified faces if not signed in or has write access */}
{hoveredFace && hoveredFaceTooltip && (
<div
className="fixed z-[100] pointer-events-none"
style={{
left: `${hoveredFace.mouseX}px`,
top: `${hoveredFace.mouseY - 40}px`,
transform: 'translateX(-50%)',
}}
>
<div className="bg-white text-secondary rounded-md px-3 py-1.5 shadow-lg border border-gray-200">
<p className="text-sm font-medium whitespace-nowrap">
{hoveredFaceTooltip}
</p>
</div>
</div>
)}
{/* 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 (
<Button
variant="ghost"
size="icon"
className="absolute left-4 z-20 text-white hover:bg-white/20 bg-black/50"
style={{ bottom: bottomPosition }}
onClick={handleReportPhoto}
aria-label="Report inappropriate photo"
title="Report inappropriate photo"
>
<Flag className="h-5 w-5" />
</Button>
);
}
// 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 (
<Button
variant="ghost"
size="icon"
className={`absolute left-4 z-20 text-white hover:bg-white/20 ${buttonClass}`}
style={{ bottom: bottomPosition }}
onClick={handleReportPhoto}
disabled={reportingPhotoId === currentPhoto.id || isDismissed}
aria-label={tooltipText}
title={tooltipText}
>
<Flag className={`h-5 w-5 ${isPending || isReviewed ? 'fill-current' : ''}`} />
</Button>
);
})()}
{/* Download Button - Right Bottom Corner (next to favorite), above controls */}
{(() => {
const bottomPosition = isVideo(currentPhoto) ? '160px' : '96px';
return (
<Button
variant="ghost"
size="icon"
className="absolute right-16 z-20 text-white hover:bg-white/20 bg-black/50"
style={{ bottom: bottomPosition }}
onClick={handleDownloadPhoto}
aria-label="Download photo"
title="Download photo"
>
<Download className="h-5 w-5" />
</Button>
);
})()}
{/* 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 (
<Button
variant="ghost"
size="icon"
className="absolute right-4 z-20 text-white hover:bg-white/20 bg-black/50"
style={{ bottom: bottomPosition }}
onClick={handleToggleFavorite}
aria-label="Add to favorites"
title="Add to favorites (sign in required)"
>
<Heart className="h-5 w-5" />
</Button>
);
}
// Logged in - show button with favorite status
return (
<Button
variant="ghost"
size="icon"
className={`absolute right-4 z-20 text-white hover:bg-white/20 ${
isFavorited
? 'bg-red-600/70 hover:bg-red-600/90'
: 'bg-black/50'
}`}
style={{ bottom: bottomPosition }}
onClick={handleToggleFavorite}
disabled={favoritingPhotoId === currentPhoto.id}
aria-label={isFavorited ? 'Remove from favorites' : 'Add to favorites'}
title={isFavorited ? 'Remove from favorites' : 'Add to favorites'}
>
<Heart className={`h-5 w-5 ${isFavorited ? 'fill-current' : ''}`} />
</Button>
);
})()}
{/* Photo Info Overlay - Only covers text area, not video controls */}
<div
className="absolute left-0 right-0 text-white pointer-events-none"
style={{
bottom: isVideo(currentPhoto) ? '80px' : '0px', // Leave more space for video controls (controls bar is ~60-70px)
background: 'linear-gradient(to top, rgba(0,0,0,0.8) 0%, rgba(0,0,0,0.8) 40%, transparent 100%)',
zIndex: isVideo(currentPhoto) ? 1 : 10, // Lower z-index for videos so controls are on top
}}
>
<div className="container mx-auto p-6 pointer-events-auto">
<h2 className="text-xl font-semibold mb-2">{currentPhoto.filename}</h2>
{currentPhoto.dateTaken && (
<p className="text-sm text-gray-300 mb-2">
{new Date(currentPhoto.dateTaken).toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
})}
</p>
)}
{peopleNames.length > 0 && (
<p className="text-sm text-gray-300 mb-1">
<span className="font-medium">People: </span>
{peopleNames.join(', ')}
</p>
)}
{tags.length > 0 && (
<p className="text-sm text-gray-300">
<span className="font-medium">Tags: </span>
{tags.join(', ')}
</p>
)}
{allPhotos.length > 0 && (
<p className="text-sm text-gray-400 mt-2">
{currentIdx + 1} of {allPhotos.length}
</p>
)}
</div>
</div>
{/* Identify Face Dialog */}
{clickedFace && (
<IdentifyFaceDialog
open={isDialogOpen}
onOpenChange={(open) => {
setIsDialogOpen(open);
if (!open) {
setClickedFace(null);
}
}}
faceId={clickedFace.faceId}
existingPerson={clickedFace.person ? {
firstName: clickedFace.person.firstName,
lastName: clickedFace.person.lastName,
middleName: clickedFace.person.middleName,
maidenName: clickedFace.person.maidenName,
dateOfBirth: clickedFace.person.dateOfBirth,
} : null}
onSave={handleSaveFace}
/>
)}
{/* Report Comment Dialog */}
<Dialog
open={reportDialogPhotoId !== null}
onOpenChange={(open) => {
if (!open) {
resetReportDialog();
}
}}
>
<DialogContent className="sm:max-w-[500px]">
<DialogHeader>
<DialogTitle>Report Photo</DialogTitle>
<DialogDescription>
Optionally include a short comment to help administrators understand the issue.
</DialogDescription>
</DialogHeader>
<div className="py-4">
<label htmlFor="report-comment" className="text-sm font-medium text-secondary">
Comment (optional)
</label>
<textarea
id="report-comment"
value={reportDialogComment}
onChange={(event) => setReportDialogComment(event.target.value)}
maxLength={REPORT_COMMENT_MAX_LENGTH}
className="mt-2 w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm shadow-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-gray-900 dark:bg-gray-800 dark:border-gray-600 dark:text-white"
rows={4}
placeholder="Add a short note about why this photo should be reviewed..."
/>
<div className="mt-1 flex justify-between text-xs text-gray-500">
<span>{`${reportDialogComment.length}/${REPORT_COMMENT_MAX_LENGTH} characters`}</span>
{reportDialogError && <span className="text-red-600">{reportDialogError}</span>}
</div>
</div>
<DialogFooter className="gap-2 sm:gap-0">
<Button
variant="outline"
onClick={() => {
resetReportDialog();
}}
>
Cancel
</Button>
<Button
onClick={handleSubmitReport}
disabled={
reportDialogPhotoId === null || reportingPhotoId === reportDialogPhotoId
}
>
{reportDialogPhotoId !== null && reportingPhotoId === reportDialogPhotoId
? 'Reporting...'
: 'Report photo'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Sign In Required Dialog for Report */}
<Dialog open={showSignInRequiredDialog} onOpenChange={setShowSignInRequiredDialog}>
<DialogContent className="sm:max-w-[500px]">
<DialogHeader>
<DialogTitle>Sign In Required</DialogTitle>
<DialogDescription>
You need to be signed in to report photos. Your reports will be reviewed by administrators.
</DialogDescription>
</DialogHeader>
<div className="py-4">
<p className="text-sm text-gray-600 mb-4">
Please sign in or create an account to continue.
</p>
<div className="flex gap-2">
<Button
onClick={() => {
setLoginDialogOpen(true);
}}
className="flex-1"
>
Sign in
</Button>
<Button
variant="outline"
onClick={() => {
setRegisterDialogOpen(true);
}}
className="flex-1"
>
Register
</Button>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setShowSignInRequiredDialog(false)}>
Cancel
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Login Dialog */}
<LoginDialog
open={loginDialogOpen}
onOpenChange={(open) => {
setLoginDialogOpen(open);
if (!open) {
setShowRegisteredMessage(false);
}
}}
onSuccess={async () => {
await update();
router.refresh();
setShowSignInRequiredDialog(false);
}}
onOpenRegister={() => {
setLoginDialogOpen(false);
setRegisterDialogOpen(true);
}}
registered={showRegisteredMessage}
callbackUrl={typeof window !== 'undefined' ? window.location.pathname + window.location.search : '/'}
/>
{/* Register Dialog */}
<RegisterDialog
open={registerDialogOpen}
onOpenChange={(open) => {
setRegisterDialogOpen(open);
if (!open) {
setShowRegisteredMessage(false);
}
}}
onSuccess={async () => {
await update();
router.refresh();
setShowSignInRequiredDialog(false);
}}
onOpenLogin={() => {
setShowRegisteredMessage(true);
setRegisterDialogOpen(false);
setLoginDialogOpen(true);
}}
callbackUrl={typeof window !== 'undefined' ? window.location.pathname + window.location.search : '/'}
/>
</div>
);
}