punimtag/viewer-frontend/components/PhotoViewerClient.tsx
Tanya 8f8aa33503
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
fix: Update null check in PhotoViewerClient component for improved type safety
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.
2026-01-07 14:28:34 -05:00

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>
);
}