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.
1680 lines
55 KiB
TypeScript
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>
|
|
);
|
|
}
|
|
|