Some checks failed
CI / skip-ci-check (push) Successful in 1m27s
CI / skip-ci-check (pull_request) Successful in 1m26s
CI / python-lint (push) Has been cancelled
CI / test-backend (push) Has been cancelled
CI / build (push) Has been cancelled
CI / secret-scanning (push) Has been cancelled
CI / dependency-scan (push) Has been cancelled
CI / sast-scan (push) Has been cancelled
CI / workflow-summary (push) Has been cancelled
CI / lint-and-type-check (push) Has been cancelled
CI / lint-and-type-check (pull_request) Successful in 2m5s
CI / python-lint (pull_request) Successful in 1m52s
CI / test-backend (pull_request) Successful in 2m54s
CI / build (pull_request) Successful in 2m23s
CI / secret-scanning (pull_request) Successful in 1m40s
CI / dependency-scan (pull_request) Successful in 1m32s
CI / sast-scan (pull_request) Successful in 2m41s
CI / workflow-summary (pull_request) Successful in 1m26s
This commit modifies the null check in the `PhotoViewerClient` component to use `!=` instead of `!==`, ensuring that the filter correctly identifies non-null persons. This change enhances type safety and maintains consistency in handling potential null values.
1681 lines
55 KiB
TypeScript
1681 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 person = face.person as any;
|
|
const firstName = person?.first_name || person?.firstName;
|
|
const lastName = person?.last_name || person?.lastName;
|
|
const personName = firstName && lastName ? `${firstName} ${lastName}`.trim() : null;
|
|
|
|
console.log('[PhotoViewerClient] handleMouseMove: Face detected on hover', {
|
|
faceId: face.id,
|
|
personId: face.personId,
|
|
personName,
|
|
mouseX: e.clientX,
|
|
mouseY: e.clientY,
|
|
});
|
|
|
|
// Update hovered face (or set if different face)
|
|
setHoveredFace((prev) => {
|
|
// If same face, just update position
|
|
if (prev && prev.faceId === face.id) {
|
|
return {
|
|
...prev,
|
|
mouseX: e.clientX,
|
|
mouseY: e.clientY,
|
|
};
|
|
}
|
|
// New face detected
|
|
return {
|
|
faceId: face.id,
|
|
personId: face.personId,
|
|
personName,
|
|
mouseX: e.clientX,
|
|
mouseY: e.clientY,
|
|
};
|
|
});
|
|
} else {
|
|
// Only log when clearing hoveredFace to avoid spam
|
|
if (hoveredFace) {
|
|
console.log('[PhotoViewerClient] handleMouseMove: No face detected, clearing hoveredFace');
|
|
}
|
|
setHoveredFace(null);
|
|
}
|
|
}, [findFaceAtPoint, isDragging, zoom, currentPhoto, hoveredFace]);
|
|
|
|
const handleClick = useCallback((e: React.MouseEvent<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 as any).faces
|
|
?.map((face: any) => face.Person)
|
|
.filter((person: any): person is Person => person != null)
|
|
.map((person: Person) => `${person.first_name} ${person.last_name}`.trim()) || [];
|
|
|
|
const tags = (currentPhoto as any).PhotoTagLinkage?.map((pt: any) => pt.Tag.tag_name) || [];
|
|
|
|
const hasPrevious = allPhotos.length > 0 && currentIdx > 0;
|
|
const hasNext = allPhotos.length > 0 && currentIdx < allPhotos.length - 1;
|
|
|
|
return (
|
|
<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.date_taken && (
|
|
<p className="text-sm text-gray-300 mb-2">
|
|
{new Date(currentPhoto.date_taken).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 as any).first_name || (clickedFace.person as any).firstName,
|
|
lastName: (clickedFace.person as any).last_name || (clickedFace.person as any).lastName,
|
|
middleName: (clickedFace.person as any).middle_name || (clickedFace.person as any).middleName,
|
|
maidenName: (clickedFace.person as any).maiden_name || (clickedFace.person as any).maidenName,
|
|
dateOfBirth: (clickedFace.person as any).date_of_birth || (clickedFace.person as any).dateOfBirth,
|
|
} : null}
|
|
onSave={handleSaveFace}
|
|
/>
|
|
)}
|
|
|
|
{/* Report Comment Dialog */}
|
|
<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>
|
|
);
|
|
}
|
|
|