927 lines
33 KiB
TypeScript
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>
|
|
);
|
|
}
|
|
|
|
|
|
|