All checks were successful
CI / skip-ci-check (pull_request) Successful in 1m50s
CI / lint-and-type-check (pull_request) Successful in 2m28s
CI / python-lint (pull_request) Successful in 2m14s
CI / test-backend (pull_request) Successful in 4m8s
CI / build (pull_request) Successful in 4m55s
CI / secret-scanning (pull_request) Successful in 1m58s
CI / dependency-scan (pull_request) Successful in 1m55s
CI / sast-scan (pull_request) Successful in 3m2s
CI / workflow-summary (pull_request) Successful in 1m49s
This commit enhances the CI workflow by adding retry logic for package installation in the Debian environment. It addresses transient issues with Debian mirror sync by allowing up to three attempts to install necessary packages, improving the reliability of the CI process. Additionally, it cleans the apt cache before retrying to ensure a fresh attempt. Also, it removes unused local patterns configuration from the Next.js setup and adds an unoptimized prop to the PhotoGrid component for better image handling based on the URL state.
919 lines
32 KiB
TypeScript
919 lines
32 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;
|
|
|
|
// 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: isVideoPhoto })}
|
|
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 < 9}
|
|
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-0 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-0 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-0 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-0 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-0 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 bg-white px-3 py-2 text-sm shadow-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-gray-900"
|
|
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 : '/'}
|
|
/>
|
|
</TooltipProvider>
|
|
);
|
|
}
|
|
|
|
|
|
|