Files
punimtag/viewer-frontend/components/PhotoGrid.tsx
T

927 lines
33 KiB
TypeScript

'use client';
import { useState, useRef, useCallback, useEffect, useMemo } from 'react';
import { useRouter } from 'next/navigation';
import { useSession } from 'next-auth/react';
import { Photo, Person } from '@prisma/client';
import Image from 'next/image';
import { Check, Flag, Play, Heart, Download } from 'lucide-react';
import { Button } from '@/components/ui/button';
import {
TooltipProvider,
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip';
import * as TooltipPrimitive from '@radix-ui/react-tooltip';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { parseFaceLocation, isPointInFace } from '@/lib/face-utils';
import { isUrl, isVideo, getImageSrc } from '@/lib/photo-utils';
import { LoginDialog } from '@/components/LoginDialog';
import { RegisterDialog } from '@/components/RegisterDialog';
interface FaceWithLocation {
id: number;
personId: number | null;
location: string;
person: Person | null;
}
interface PhotoWithPeople extends Photo {
faces?: FaceWithLocation[];
}
interface PhotoGridProps {
photos: PhotoWithPeople[];
selectionMode?: boolean;
selectedPhotoIds?: number[];
onToggleSelect?: (photoId: number) => void;
refreshFavoritesKey?: number;
}
/**
* Gets unique people names from photo faces
*/
function getPeopleNames(photo: PhotoWithPeople): string[] {
if (!photo.faces) return [];
const people = photo.faces
.map((face) => face.person)
.filter((person): person is Person => person !== null)
.map((person: any) => {
// Handle both camelCase and snake_case
const firstName = person.firstName || person.first_name || '';
const lastName = person.lastName || person.last_name || '';
return `${firstName} ${lastName}`.trim();
});
// Remove duplicates
return Array.from(new Set(people));
}
const REPORT_COMMENT_MAX_LENGTH = 300;
const getPhotoFilename = (photo: Photo) => {
if (photo?.filename) {
return photo.filename;
}
if (photo?.path) {
const segments = photo.path.split(/[/\\]/);
const lastSegment = segments.pop();
if (lastSegment) {
return lastSegment;
}
}
return `photo-${photo?.id ?? 'download'}.jpg`;
};
const getPhotoDownloadUrl = (
photo: Photo,
options?: { forceProxy?: boolean; watermark?: boolean }
) => {
const path = photo.path || '';
const isExternal = path.startsWith('http://') || path.startsWith('https://');
if (isExternal && !options?.forceProxy) {
return path;
}
const params = new URLSearchParams();
if (options?.watermark) {
params.set('watermark', 'true');
}
const query = params.toString();
return `/api/photos/${photo.id}/image${query ? `?${query}` : ''}`;
};
export function PhotoGrid({
photos,
selectionMode = false,
selectedPhotoIds = [],
onToggleSelect,
refreshFavoritesKey = 0,
}: PhotoGridProps) {
const router = useRouter();
const { data: session, update } = useSession();
const isLoggedIn = Boolean(session);
const hasWriteAccess = session?.user?.hasWriteAccess === true;
// Normalize photos: ensure faces is always available (handle Face vs faces)
const normalizePhoto = (photo: PhotoWithPeople): PhotoWithPeople => {
const normalized = { ...photo };
// If photo has Face (capital F) but no faces (lowercase), convert it
if (!normalized.faces && (normalized as any).Face) {
normalized.faces = (normalized as any).Face.map((face: any) => ({
id: face.id,
personId: face.person_id || face.personId,
location: face.location,
person: face.Person ? {
id: face.Person.id,
firstName: face.Person.first_name,
lastName: face.Person.last_name,
middleName: face.Person.middle_name,
maidenName: face.Person.maiden_name,
dateOfBirth: face.Person.date_of_birth,
} : null,
}));
}
return normalized;
};
// Normalize all photos
const normalizedPhotos = useMemo(() => {
return photos.map(normalizePhoto);
}, [photos]);
const [hoveredFace, setHoveredFace] = useState<{
photoId: number;
faceId: number;
personId: number | null;
personName: string | null;
} | null>(null);
const [reportingPhotoId, setReportingPhotoId] = useState<number | null>(null);
const [reportedPhotos, setReportedPhotos] = useState<Map<number, { status: string }>>(new Map());
const [favoritingPhotoId, setFavoritingPhotoId] = useState<number | null>(null);
const [favoritedPhotos, setFavoritedPhotos] = useState<Map<number, boolean>>(new Map());
const [showSignInRequiredDialog, setShowSignInRequiredDialog] = useState(false);
const [loginDialogOpen, setLoginDialogOpen] = useState(false);
const [registerDialogOpen, setRegisterDialogOpen] = useState(false);
const [showRegisteredMessage, setShowRegisteredMessage] = useState(false);
const [reportDialogPhotoId, setReportDialogPhotoId] = useState<number | null>(null);
const [reportDialogComment, setReportDialogComment] = useState('');
const [reportDialogError, setReportDialogError] = useState<string | null>(null);
const imageRefs = useRef<Map<number, { naturalWidth: number; naturalHeight: number }>>(new Map());
const handleMouseMove = useCallback((
e: React.MouseEvent<HTMLDivElement | HTMLButtonElement>,
photo: PhotoWithPeople
) => {
// Skip face detection for videos
if (isVideo(photo)) {
setHoveredFace(null);
return;
}
if (!photo.faces || photo.faces.length === 0) {
setHoveredFace(null);
return;
}
const container = e.currentTarget;
const rect = container.getBoundingClientRect();
const mouseX = e.clientX - rect.left;
const mouseY = e.clientY - rect.top;
// Get image dimensions from cache
const imageData = imageRefs.current.get(photo.id);
if (!imageData) {
setHoveredFace(null);
return;
}
const { naturalWidth, naturalHeight } = imageData;
const containerWidth = rect.width;
const containerHeight = rect.height;
// Check each face to see if mouse is over it
for (const face of photo.faces) {
const location = parseFaceLocation(face.location);
if (!location) continue;
if (
isPointInFace(
mouseX,
mouseY,
location,
naturalWidth,
naturalHeight,
containerWidth,
containerHeight
)
) {
// Face detected!
const person = face.person as any; // Handle both camelCase and snake_case
const personName = person
? `${person.firstName || person.first_name || ''} ${person.lastName || person.last_name || ''}`.trim()
: null;
setHoveredFace({
photoId: photo.id,
faceId: face.id,
personId: face.personId,
personName: personName || null,
});
return;
}
}
// No face detected
setHoveredFace(null);
}, []);
const handleImageLoad = useCallback((photoId: number, img: HTMLImageElement) => {
imageRefs.current.set(photoId, {
naturalWidth: img.naturalWidth,
naturalHeight: img.naturalHeight,
});
}, []);
const handleDownloadPhoto = useCallback((event: React.MouseEvent, photo: Photo) => {
event.stopPropagation();
const link = document.createElement('a');
link.href = getPhotoDownloadUrl(photo, { watermark: !isLoggedIn });
link.download = getPhotoFilename(photo);
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
}, [isLoggedIn]);
// Remove duplicates by ID to prevent React key errors
// Memoized to prevent recalculation on every render
// Must be called before any early returns to maintain hooks order
const uniquePhotos = useMemo(() => {
return normalizedPhotos.filter((photo, index, self) =>
index === self.findIndex((p) => p.id === photo.id)
);
}, [normalizedPhotos]);
// Fetch report status for all photos when component mounts or photos change
// Uses batch API to reduce N+1 query problem
// Must be called before any early returns to maintain hooks order
useEffect(() => {
if (!session?.user?.id) {
setReportedPhotos(new Map());
return;
}
const fetchReportStatuses = async () => {
const photoIds = uniquePhotos.map(p => p.id);
if (photoIds.length === 0) {
return;
}
try {
// Batch API call - single request for all photos
const response = await fetch('/api/photos/reports/batch', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ photoIds }),
});
if (!response.ok) {
throw new Error('Failed to fetch report statuses');
}
const data = await response.json();
const statusMap = new Map<number, { status: string }>();
// Process batch results
if (data.results) {
for (const [photoIdStr, result] of Object.entries(data.results)) {
const photoId = parseInt(photoIdStr, 10);
const reportData = result as { reported: boolean; status?: string };
if (reportData.reported && reportData.status) {
statusMap.set(photoId, { status: reportData.status });
}
}
}
setReportedPhotos(statusMap);
} catch (error) {
console.error('Error fetching batch report statuses:', error);
// Fallback: set empty map on error
setReportedPhotos(new Map());
}
};
fetchReportStatuses();
}, [uniquePhotos, session?.user?.id]);
// Fetch favorite status for all photos when component mounts or photos change
// Uses batch API to reduce N+1 query problem
useEffect(() => {
if (!session?.user?.id) {
setFavoritedPhotos(new Map());
return;
}
const fetchFavoriteStatuses = async () => {
const photoIds = uniquePhotos.map(p => p.id);
if (photoIds.length === 0) {
return;
}
try {
// Batch API call - single request for all photos
const response = await fetch('/api/photos/favorites/batch', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ photoIds }),
});
if (!response.ok) {
throw new Error('Failed to fetch favorite statuses');
}
const data = await response.json();
const favoriteMap = new Map<number, boolean>();
// Process batch results
if (data.results) {
for (const [photoIdStr, isFavorited] of Object.entries(data.results)) {
const photoId = parseInt(photoIdStr, 10);
favoriteMap.set(photoId, isFavorited as boolean);
}
}
setFavoritedPhotos(favoriteMap);
} catch (error) {
console.error('Error fetching batch favorite statuses:', error);
// Fallback: set empty map on error
setFavoritedPhotos(new Map());
}
};
fetchFavoriteStatuses();
}, [uniquePhotos, session?.user?.id, refreshFavoritesKey]);
// Filter out videos for slideshow navigation (only images)
// Note: This is only used for slideshow context, not for navigation
// Memoized to maintain consistent hook order
const imageOnlyPhotos = useMemo(() => {
return uniquePhotos.filter((p) => !isVideo(p));
}, [uniquePhotos]);
const handlePhotoClick = (photoId: number, index: number) => {
const photo = uniquePhotos.find((p) => p.id === photoId);
if (!photo) return;
// Use the full photos list (including videos) for navigation
// This ensures consistent navigation whether clicking a photo or video
const allPhotoIds = uniquePhotos.map((p) => p.id).join(',');
const photoIndex = uniquePhotos.findIndex((p) => p.id === photoId);
if (photoIndex === -1) return;
// Capture scroll before the URL change — Next can reset window.scrollY to 0
// before the home page modal effect runs.
try {
sessionStorage.setItem('homePageScrollY', String(window.scrollY));
} catch {
/* ignore quota / private mode */
}
// Update URL with photo query param while preserving existing params (filters, etc.)
const params = new URLSearchParams(window.location.search);
params.set('photo', photoId.toString());
params.set('photos', allPhotoIds);
params.set('index', photoIndex.toString());
router.push(`/?${params.toString()}`, { scroll: false });
};
const handlePhotoInteraction = (photoId: number, index: number) => {
if (selectionMode && onToggleSelect) {
onToggleSelect(photoId);
return;
}
handlePhotoClick(photoId, index);
};
const resetReportDialog = () => {
setReportDialogPhotoId(null);
setReportDialogComment('');
setReportDialogError(null);
};
const handleUndoReport = async (photoId: number) => {
const reportInfo = reportedPhotos.get(photoId);
const isReported = reportInfo && reportInfo.status === 'pending';
if (!isReported || reportingPhotoId === photoId) {
return;
}
setReportingPhotoId(photoId);
try {
const response = await fetch(`/api/photos/${photoId}/report`, {
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
},
});
if (!response.ok) {
const error = await response.json();
if (response.status === 401) {
alert('Please sign in to report photos');
} else if (response.status === 403) {
alert('Cannot undo report that has already been reviewed');
} else if (response.status === 404) {
alert('Report not found');
} else {
alert(error.error || 'Failed to undo report');
}
return;
}
const newMap = new Map(reportedPhotos);
newMap.delete(photoId);
setReportedPhotos(newMap);
alert('Report undone successfully.');
} catch (error) {
console.error('Error undoing photo report:', error);
alert('Failed to undo report. Please try again.');
} finally {
setReportingPhotoId(null);
}
};
const handleReportButtonClick = async (e: React.MouseEvent, photoId: number) => {
e.stopPropagation(); // Prevent photo click from firing
if (!session) {
setShowSignInRequiredDialog(true);
return;
}
if (reportingPhotoId === photoId) return; // Already processing
const reportInfo = reportedPhotos.get(photoId);
const isPending = reportInfo && reportInfo.status === 'pending';
const isDismissed = reportInfo && reportInfo.status === 'dismissed';
if (isDismissed) {
alert('This report was dismissed by an administrator and cannot be resubmitted.');
return;
}
if (isPending) {
await handleUndoReport(photoId);
return;
}
setReportDialogPhotoId(photoId);
setReportDialogComment('');
setReportDialogError(null);
};
const handleSubmitReport = async () => {
if (reportDialogPhotoId === null) {
return;
}
const trimmedComment = reportDialogComment.trim();
if (trimmedComment.length > REPORT_COMMENT_MAX_LENGTH) {
setReportDialogError(`Comment must be ${REPORT_COMMENT_MAX_LENGTH} characters or less.`);
return;
}
setReportDialogError(null);
setReportingPhotoId(reportDialogPhotoId);
try {
const response = await fetch(`/api/photos/${reportDialogPhotoId}/report`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
comment: trimmedComment.length > 0 ? trimmedComment : null,
}),
});
if (!response.ok) {
const error = await response.json().catch(() => null);
if (response.status === 401) {
setShowSignInRequiredDialog(true);
} else if (response.status === 403) {
alert(error?.error || 'Cannot re-report this photo.');
} else if (response.status === 409) {
alert('You have already reported this photo');
} else if (response.status === 400) {
setReportDialogError(error?.error || 'Invalid comment');
return;
} else {
alert(error?.error || 'Failed to report photo. Please try again.');
}
return;
}
const newMap = new Map(reportedPhotos);
newMap.set(reportDialogPhotoId, { status: 'pending' });
setReportedPhotos(newMap);
const previousReport = reportedPhotos.get(reportDialogPhotoId);
alert(
previousReport && previousReport.status === 'reviewed'
? 'Photo re-reported successfully. Thank you for your report.'
: 'Photo reported successfully. Thank you for your report.'
);
resetReportDialog();
} catch (error) {
console.error('Error reporting photo:', error);
alert('Failed to create report. Please try again.');
} finally {
setReportingPhotoId(null);
}
};
const handleToggleFavorite = async (e: React.MouseEvent, photoId: number) => {
e.stopPropagation(); // Prevent photo click from firing
if (!session) {
setShowSignInRequiredDialog(true);
return;
}
if (favoritingPhotoId === photoId) return; // Already processing
setFavoritingPhotoId(photoId);
try {
const response = await fetch(`/api/photos/${photoId}/favorite`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
});
if (!response.ok) {
const error = await response.json();
if (response.status === 401) {
setShowSignInRequiredDialog(true);
} else {
alert(error.error || 'Failed to toggle favorite');
}
return;
}
const data = await response.json();
const newMap = new Map(favoritedPhotos);
newMap.set(photoId, data.favorited);
setFavoritedPhotos(newMap);
} catch (error) {
console.error('Error toggling favorite:', error);
alert('Failed to toggle favorite. Please try again.');
} finally {
setFavoritingPhotoId(null);
}
};
return (
<TooltipProvider delayDuration={200}>
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5">
{uniquePhotos.map((photo, index) => {
const hoveredFaceForPhoto = hoveredFace?.photoId === photo.id ? hoveredFace : null;
const isSelected = selectionMode && selectedPhotoIds.includes(photo.id);
// Determine tooltip text while respecting auth visibility rules
let tooltipText: string = photo.filename; // Default fallback
const isVideoPhoto = isVideo(photo);
if (isVideoPhoto) {
tooltipText = `Video: ${photo.filename}`;
} else if (hoveredFaceForPhoto) {
// Hovering over a specific face
if (hoveredFaceForPhoto.personName) {
// Face is identified - show person name (only if logged in)
tooltipText = isLoggedIn ? hoveredFaceForPhoto.personName : photo.filename;
} else {
// Face is not identified - show "Identify" if user has write access or is not logged in
tooltipText = (!session || hasWriteAccess) ? 'Identify' : photo.filename;
}
} else if (isLoggedIn) {
// Hovering over photo (not a face) - show "People: " + names
const peopleNames = getPeopleNames(photo);
tooltipText = peopleNames.length > 0
? `People: ${peopleNames.join(', ')}`
: photo.filename;
}
return (
<TooltipPrimitive.Root key={photo.id} delayDuration={200}>
<div className="group relative aspect-square">
<TooltipTrigger asChild>
<button
type="button"
onClick={() => handlePhotoInteraction(photo.id, index)}
aria-pressed={isSelected}
className={`relative w-full h-full overflow-hidden rounded-lg bg-gray-100 cursor-pointer outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-gray-900 ${isSelected ? 'ring-2 ring-blue-500 ring-offset-2' : ''}`}
onMouseMove={(e) => !isVideoPhoto && handleMouseMove(e, photo)}
onMouseLeave={() => setHoveredFace(null)}
>
<Image
src={getImageSrc(photo, { watermark: !isLoggedIn, thumbnail: true })}
alt={photo.filename}
fill
className="object-contain bg-black/5 transition-transform duration-300 group-hover:scale-105"
sizes="(max-width: 640px) 50vw, (max-width: 1024px) 33vw, 20vw"
priority={index < 4}
unoptimized={!isUrl(photo.path)}
onLoad={(e) => !isVideoPhoto && handleImageLoad(photo.id, e.currentTarget)}
/>
<div className="absolute inset-0 bg-black/0 transition-colors group-hover:bg-black/10" />
{/* Video play icon overlay */}
{isVideoPhoto && (
<div className="absolute inset-0 flex items-center justify-center bg-black/20 group-hover:bg-black/30 transition-colors">
<div className="rounded-full bg-white/90 p-3 shadow-lg group-hover:bg-white transition-colors">
<Play className="h-6 w-6 text-secondary fill-secondary ml-1" />
</div>
</div>
)}
{selectionMode && (
<>
<div
className={`absolute inset-0 rounded-lg border-2 transition-colors pointer-events-none ${isSelected ? 'border-orange-600' : 'border-transparent'}`}
/>
<div
className={`absolute right-2 top-2 z-10 rounded-full border border-white/50 p-1 text-white transition-colors ${isSelected ? 'bg-orange-600' : 'bg-black/60'}`}
>
<Check className="h-4 w-4" />
</div>
</>
)}
</button>
</TooltipTrigger>
{/* Download Button - Top Left Corner */}
<button
type="button"
onClick={(e) => handleDownloadPhoto(e, photo)}
className="absolute left-2 top-2 z-10 p-1.5 rounded-full text-white opacity-90 sm:opacity-0 sm:group-hover:opacity-100 transition-opacity bg-black/50 hover:bg-black/70"
aria-label="Download photo"
title="Download photo"
>
<Download className="h-4 w-4" />
</button>
{/* Report Button - Left Bottom Corner - Show always */}
{(() => {
if (!session) {
// Not logged in - show basic report button
return (
<button
type="button"
onClick={(e) => handleReportButtonClick(e, photo.id)}
className="absolute left-2 bottom-2 z-10 p-1.5 rounded-full text-white opacity-90 sm:opacity-0 sm:group-hover:opacity-100 transition-opacity bg-black/50 hover:bg-black/70"
aria-label="Report inappropriate photo"
title="Report inappropriate photo"
>
<Flag className="h-4 w-4" />
</button>
);
}
// Logged in - show button with status
const reportInfo = reportedPhotos.get(photo.id);
const isReported = reportInfo && reportInfo.status === 'pending';
const isReviewed = reportInfo && reportInfo.status === 'reviewed';
const isDismissed = reportInfo && reportInfo.status === 'dismissed';
let tooltipText: string;
let buttonClass: string;
if (isReported) {
tooltipText = 'Reported as inappropriate. Click to undo';
buttonClass = 'bg-red-600/70 hover:bg-red-600/90';
} else if (isReviewed) {
tooltipText = 'Report reviewed and kept. Click to report again';
buttonClass = 'bg-green-600/70 hover:bg-green-600/90';
} else if (isDismissed) {
tooltipText = 'Report dismissed';
buttonClass = 'bg-gray-600/70 hover:bg-gray-600/90';
} else {
tooltipText = 'Report inappropriate photo';
buttonClass = 'bg-black/50 hover:bg-black/70';
}
return (
<button
type="button"
onClick={(e) => handleReportButtonClick(e, photo.id)}
disabled={reportingPhotoId === photo.id || isDismissed}
className={`absolute left-2 bottom-2 z-10 p-1.5 rounded-full text-white opacity-90 sm:opacity-0 sm:group-hover:opacity-100 transition-opacity disabled:opacity-50 disabled:cursor-not-allowed ${buttonClass}`}
aria-label={tooltipText}
title={tooltipText}
>
<Flag className={`h-4 w-4 ${isReported || isReviewed ? 'fill-current' : ''}`} />
</button>
);
})()}
{/* Favorite Button - Right Bottom Corner - Show always */}
{(() => {
if (!session) {
// Not logged in - show basic favorite button
return (
<button
type="button"
onClick={(e) => handleToggleFavorite(e, photo.id)}
className="absolute right-2 bottom-2 z-10 p-1.5 rounded-full text-white opacity-90 sm:opacity-0 sm:group-hover:opacity-100 transition-opacity bg-black/50 hover:bg-black/70"
aria-label="Add to favorites"
title="Add to favorites (sign in required)"
>
<Heart className="h-4 w-4" />
</button>
);
}
// Logged in - show button with favorite status
const isFavorited = favoritedPhotos.get(photo.id) || false;
return (
<button
type="button"
onClick={(e) => handleToggleFavorite(e, photo.id)}
disabled={favoritingPhotoId === photo.id}
className={`absolute right-2 bottom-2 z-10 p-1.5 rounded-full text-white opacity-90 sm:opacity-0 sm:group-hover:opacity-100 transition-opacity disabled:opacity-50 disabled:cursor-not-allowed ${
isFavorited
? 'bg-red-600/70 hover:bg-red-600/90'
: 'bg-black/50 hover:bg-black/70'
}`}
aria-label={isFavorited ? 'Remove from favorites' : 'Add to favorites'}
title={isFavorited ? 'Remove from favorites' : 'Add to favorites'}
>
<Heart className={`h-4 w-4 ${isFavorited ? 'fill-current' : ''}`} />
</button>
);
})()}
</div>
<TooltipContent
side="bottom"
sideOffset={5}
className="max-w-xs bg-blue-400 text-white z-[9999]"
arrowColor="blue-400"
>
<p className="text-sm font-medium">{tooltipText || photo.filename}</p>
</TooltipContent>
</TooltipPrimitive.Root>
);
})}
</div>
{/* 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 dark:border-gray-700 bg-white dark:bg-gray-900 text-secondary px-3 py-2 text-sm shadow-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-gray-900 dark:focus-visible:ring-gray-100"
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 dark:text-gray-400">
<span>{`${reportDialogComment.length}/${REPORT_COMMENT_MAX_LENGTH} characters`}</span>
{reportDialogError && <span role="alert" className="text-red-600 dark:text-red-400">{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 dark:text-gray-400 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 : '/'}
/>
</TooltipProvider>
);
}