'use client'; import { useState, useEffect, useRef } from 'react'; import { useSearchParams, useRouter } from 'next/navigation'; import { useSession } from 'next-auth/react'; import { Person, Tag, Photo } from '@prisma/client'; import { CollapsibleSearch } from '@/components/search/CollapsibleSearch'; import { PhotoGrid } from '@/components/PhotoGrid'; import { PhotoViewerClient } from '@/components/PhotoViewerClient'; import { SearchFilters } from '@/components/search/FilterPanel'; import { Loader2, CheckSquare, Square } from 'lucide-react'; import { TagSelectionDialog } from '@/components/TagSelectionDialog'; import { PageHeader } from '@/components/PageHeader'; import JSZip from 'jszip'; interface HomePageContentProps { initialPhotos: Photo[]; people: Person[]; tags: Tag[]; } export function HomePageContent({ initialPhotos, people, tags }: HomePageContentProps) { const router = useRouter(); const searchParams = useSearchParams(); const { data: session } = useSession(); const isLoggedIn = Boolean(session); // Initialize filters from URL params to persist state across navigation const [filters, setFilters] = useState(() => { const peopleParam = searchParams.get('people'); const tagsParam = searchParams.get('tags'); const dateFromParam = searchParams.get('dateFrom'); const dateToParam = searchParams.get('dateTo'); const peopleModeParam = searchParams.get('peopleMode'); const tagsModeParam = searchParams.get('tagsMode'); const mediaTypeParam = searchParams.get('mediaType'); const favoritesOnlyParam = searchParams.get('favoritesOnly'); return { people: peopleParam ? peopleParam.split(',').map(Number).filter(Boolean) : [], tags: tagsParam ? tagsParam.split(',').map(Number).filter(Boolean) : [], dateFrom: dateFromParam ? new Date(dateFromParam) : undefined, dateTo: dateToParam ? new Date(dateToParam) : undefined, peopleMode: peopleModeParam === 'all' ? 'all' : 'any', tagsMode: tagsModeParam === 'all' ? 'all' : 'any', mediaType: (mediaTypeParam as 'all' | 'photos' | 'videos') || 'all', favoritesOnly: favoritesOnlyParam === 'true', }; }); // Check if we have active filters from URL on initial load const hasInitialFilters = Boolean( filters.people.length > 0 || filters.tags.length > 0 || filters.dateFrom || filters.dateTo || (filters.mediaType && filters.mediaType !== 'all') || filters.favoritesOnly === true ); // Only use initialPhotos if there are no filters from URL const [photos, setPhotos] = useState(hasInitialFilters ? [] : initialPhotos); const [loading, setLoading] = useState(hasInitialFilters); // Start loading if filters are active const [loadingMore, setLoadingMore] = useState(false); const [total, setTotal] = useState(0); const [page, setPage] = useState(1); const [hasMore, setHasMore] = useState(initialPhotos.length === 30); const observerTarget = useRef(null); const pageRef = useRef(1); const isLoadingRef = useRef(false); const scrollRestoredRef = useRef(false); const isInitialMount = useRef(true); const photosInitializedRef = useRef(false); const isClosingModalRef = useRef(false); // Modal state - read photo query param const photoParam = searchParams.get('photo'); const photosParam = searchParams.get('photos'); const indexParam = searchParams.get('index'); const autoplayParam = searchParams.get('autoplay') === 'true'; const [modalPhoto, setModalPhoto] = useState(null); const [modalPhotos, setModalPhotos] = useState([]); const [modalIndex, setModalIndex] = useState(0); const [modalLoading, setModalLoading] = useState(false); const [selectionMode, setSelectionMode] = useState(false); const [selectedPhotoIds, setSelectedPhotoIds] = useState([]); const [isPreparingDownload, setIsPreparingDownload] = useState(false); const [tagDialogOpen, setTagDialogOpen] = useState(false); const [isBulkFavoriting, setIsBulkFavoriting] = useState(false); const [refreshFavoritesKey, setRefreshFavoritesKey] = useState(0); const hasActiveFilters = filters.people.length > 0 || filters.tags.length > 0 || filters.dateFrom || filters.dateTo || (filters.mediaType && filters.mediaType !== 'all') || filters.favoritesOnly === true; // Update URL when filters change (without page reload) // Skip on initial mount since filters are already initialized from URL useEffect(() => { // Skip URL update on initial mount if (isInitialMount.current) { isInitialMount.current = false; return; } // Skip if we're closing the modal (to prevent reload) if (isClosingModalRef.current) { return; } const params = new URLSearchParams(); if (filters.people.length > 0) { params.set('people', filters.people.join(',')); if (filters.peopleMode && filters.peopleMode !== 'any') { params.set('peopleMode', filters.peopleMode); } } if (filters.tags.length > 0) { params.set('tags', filters.tags.join(',')); if (filters.tagsMode && filters.tagsMode !== 'any') { params.set('tagsMode', filters.tagsMode); } } if (filters.dateFrom) { params.set('dateFrom', filters.dateFrom.toISOString().split('T')[0]); } if (filters.dateTo) { params.set('dateTo', filters.dateTo.toISOString().split('T')[0]); } if (filters.mediaType && filters.mediaType !== 'all') { params.set('mediaType', filters.mediaType); } if (filters.favoritesOnly) { params.set('favoritesOnly', 'true'); } const newUrl = params.toString() ? `/?${params.toString()}` : '/'; router.replace(newUrl, { scroll: false }); // Clear saved scroll position when filters change (user is starting a new search) sessionStorage.removeItem('homePageScrollY'); scrollRestoredRef.current = false; photosInitializedRef.current = false; // Reset photos initialization flag }, [filters, router]); // Restore scroll position when returning from photo viewer // Wait for photos to be loaded and rendered before restoring scroll to prevent flash useEffect(() => { if (scrollRestoredRef.current) return; const scrollY = sessionStorage.getItem('homePageScrollY'); if (!scrollY) { scrollRestoredRef.current = true; return; } // Wait for loading to complete if (loading) return; // Wait for photos to be initialized (either from initial state or fetched) // This prevents flash by ensuring we only restore after everything is stable if (!photosInitializedRef.current && photos.length === 0) { // Photos not ready yet, wait return; } photosInitializedRef.current = true; // Restore scroll after DOM is fully rendered // Use multiple animation frames to ensure all images are laid out requestAnimationFrame(() => { requestAnimationFrame(() => { if (!scrollRestoredRef.current) { window.scrollTo({ top: parseInt(scrollY, 10), behavior: 'instant' }); scrollRestoredRef.current = true; } }); }); }, [loading, photos.length]); // Save scroll position before navigating away useEffect(() => { const handleScroll = () => { sessionStorage.setItem('homePageScrollY', window.scrollY.toString()); }; // Throttle scroll events let timeoutId: NodeJS.Timeout; const throttledScroll = () => { clearTimeout(timeoutId); timeoutId = setTimeout(handleScroll, 100); }; window.addEventListener('scroll', throttledScroll, { passive: true }); return () => { window.removeEventListener('scroll', throttledScroll); clearTimeout(timeoutId); }; }, []); // Handle photo modal - use existing photos data, only fetch if not available useEffect(() => { // Skip if we're intentionally closing the modal if (isClosingModalRef.current) { isClosingModalRef.current = false; return; } if (!photoParam) { setModalPhoto(null); setModalPhotos([]); return; } const photoId = parseInt(photoParam, 10); if (isNaN(photoId)) return; // If we already have this photo in modalPhotos, just update the index - no fetch needed! if (modalPhotos.length > 0) { const existingModalPhoto = modalPhotos.find((p) => p.id === photoId); if (existingModalPhoto) { console.log('[HomePageContent] Using existing modal photo:', { photoId: existingModalPhoto.id, hasFaces: !!existingModalPhoto.faces, hasFace: !!existingModalPhoto.Face, facesCount: existingModalPhoto.faces?.length || existingModalPhoto.Face?.length || 0, photoKeys: Object.keys(existingModalPhoto), }); // Photo is already in modal list, just update index - instant, no API calls! const photoIds = photosParam ? photosParam.split(',').map(Number).filter(Boolean) : []; const parsedIndex = indexParam ? parseInt(indexParam, 10) : 0; if (photoIds.length > 0 && !isNaN(parsedIndex) && parsedIndex >= 0 && parsedIndex < modalPhotos.length) { setModalIndex(parsedIndex); setModalPhoto(existingModalPhoto); return; // Skip all fetching! } } } // First, try to find the photo in the already-loaded photos const existingPhoto = photos.find((p) => p.id === photoId); if (existingPhoto) { // Photo is already loaded, use it directly - no database access! console.log('[HomePageContent] Using existing photo from photos array:', { photoId: existingPhoto.id, hasFaces: !!(existingPhoto as any).faces, hasFace: !!(existingPhoto as any).Face, facesCount: (existingPhoto as any).faces?.length || (existingPhoto as any).Face?.length || 0, photoKeys: Object.keys(existingPhoto), }); setModalPhoto(existingPhoto); // If we have a photo list context, use existing photos if (photosParam && indexParam) { const photoIds = photosParam.split(',').map(Number).filter(Boolean); const parsedIndex = parseInt(indexParam, 10); if (photoIds.length > 0 && !isNaN(parsedIndex)) { // Check if modalPhotos already has all these photos in the right order const currentPhotoIds = modalPhotos.map((p) => p.id); const needsRebuild = currentPhotoIds.length !== photoIds.length || currentPhotoIds.some((id, idx) => id !== photoIds[idx]); if (needsRebuild) { // Build photo list from existing photos - no API calls! const photoMap = new Map(photos.map((p) => [p.id, p])); const orderedPhotos = photoIds .map((id) => photoMap.get(id)) .filter(Boolean) as typeof photos; setModalPhotos(orderedPhotos); } setModalIndex(parsedIndex); } else { if (modalPhotos.length !== 1 || modalPhotos[0]?.id !== existingPhoto.id) { setModalPhotos([existingPhoto]); } setModalIndex(0); } } else { if (modalPhotos.length !== 1 || modalPhotos[0]?.id !== existingPhoto.id) { setModalPhotos([existingPhoto]); } setModalIndex(0); } setModalLoading(false); return; } // Photo not in loaded list, need to fetch it (should be rare) const fetchPhotoData = async () => { setModalLoading(true); try { const photoResponse = await fetch(`/api/photos/${photoId}`); if (!photoResponse.ok) throw new Error('Failed to fetch photo'); const photoData = await photoResponse.json(); // Serialize the photo (handle Decimal fields) console.log('[HomePageContent] Photo data from API:', { photoId: photoData.id, hasFaces: !!photoData.faces, facesCount: photoData.faces?.length || 0, facesRaw: photoData.faces, photoDataKeys: Object.keys(photoData), }); const serializedPhoto = { ...photoData, faces: photoData.faces?.map((face: any) => ({ ...face, confidence: face.confidence ? Number(face.confidence) : 0, qualityScore: face.qualityScore ? Number(face.qualityScore) : 0, faceConfidence: face.faceConfidence ? Number(face.faceConfidence) : 0, yawAngle: face.yawAngle ? Number(face.yawAngle) : null, pitchAngle: face.pitchAngle ? Number(face.pitchAngle) : null, rollAngle: face.rollAngle ? Number(face.rollAngle) : null, })), }; console.log('[HomePageContent] Serialized photo:', { photoId: serializedPhoto.id, hasFaces: !!serializedPhoto.faces, facesCount: serializedPhoto.faces?.length || 0, faces: serializedPhoto.faces, }); setModalPhoto(serializedPhoto); // For navigation, try to use existing photos first, then fetch missing ones if (photosParam && indexParam) { const photoIds = photosParam.split(',').map(Number).filter(Boolean); const parsedIndex = parseInt(indexParam, 10); if (photoIds.length > 0 && !isNaN(parsedIndex)) { const photoMap = new Map(photos.map((p) => [p.id, p])); const missingIds = photoIds.filter((id) => !photoMap.has(id)); // Fetch only missing photos let fetchedPhotos: any[] = []; if (missingIds.length > 0) { const photoPromises = missingIds.map((id) => fetch(`/api/photos/${id}`).then((res) => res.json()) ); const fetchedData = await Promise.all(photoPromises); fetchedPhotos = fetchedData.map((p: any) => ({ ...p, faces: p.faces?.map((face: any) => ({ ...face, confidence: face.confidence ? Number(face.confidence) : 0, qualityScore: face.qualityScore ? Number(face.qualityScore) : 0, faceConfidence: face.faceConfidence ? Number(face.faceConfidence) : 0, yawAngle: face.yawAngle ? Number(face.yawAngle) : null, pitchAngle: face.pitchAngle ? Number(face.pitchAngle) : null, rollAngle: face.rollAngle ? Number(face.rollAngle) : null, })), })); } // Combine existing and fetched photos fetchedPhotos.forEach((p) => photoMap.set(p.id, p)); // Include all photos (videos and images) for navigation const orderedPhotos = photoIds .map((id) => photoMap.get(id)) .filter(Boolean) as typeof photos; setModalPhotos(orderedPhotos); // Use the original index (videos are included in navigation) setModalIndex(Math.min(parsedIndex, orderedPhotos.length - 1)); } else { setModalPhotos([serializedPhoto]); setModalIndex(0); } } else { setModalPhotos([serializedPhoto]); setModalIndex(0); } } catch (error) { console.error('Error fetching photo data:', error); } finally { setModalLoading(false); } }; fetchPhotoData(); // eslint-disable-next-line react-hooks/exhaustive-deps }, [photoParam, photosParam, indexParam]); // Only depend on URL params - photos is accessed but we check modalPhotos first // Handle starting slideshow const handleStartSlideshow = () => { if (photos.length === 0) return; // Filter out videos from slideshow (only show images) const imagePhotos = photos.filter((p) => p.media_type !== 'video'); if (imagePhotos.length === 0) return; // Set first image as modal photo setModalPhoto(imagePhotos[0]); setModalPhotos(imagePhotos); setModalIndex(0); // Update URL to open first photo with autoPlay const params = new URLSearchParams(window.location.search); params.set('photo', imagePhotos[0].id.toString()); params.set('photos', imagePhotos.map((p) => p.id).join(',')); params.set('index', '0'); params.set('autoplay', 'true'); router.replace(`/?${params.toString()}`, { scroll: false }); }; const handleToggleSelectionMode = () => { setSelectionMode((prev) => { if (prev) { setSelectedPhotoIds([]); } return !prev; }); }; const handleTogglePhotoSelection = (photoId: number) => { setSelectedPhotoIds((prev) => { if (prev.includes(photoId)) { return prev.filter((id) => id !== photoId); } return [...prev, photoId]; }); }; const handleSelectAll = () => { const allPhotoIds = photos.map((photo) => photo.id); setSelectedPhotoIds(allPhotoIds); }; const handleClearAll = () => { setSelectedPhotoIds([]); }; const getPhotoFilename = (photo: Photo) => { if (photo.filename?.trim()) { return photo.filename.trim(); } const path = photo.path || ''; if (path) { const segments = path.split(/[/\\]/); const lastSegment = segments.pop(); if (lastSegment) { return lastSegment; } } return `photo-${photo.id}.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}` : ''}`; }; const downloadPhotosAsZip = async ( photoIds: number[], photoMap: Map ) => { const zip = new JSZip(); let filesAdded = 0; for (const photoId of photoIds) { const photo = photoMap.get(photoId); if (!photo) continue; const response = await fetch( getPhotoDownloadUrl(photo, { forceProxy: true, watermark: !isLoggedIn, }) ); if (!response.ok) { throw new Error(`Failed to download photo ${photoId}`); } const blob = await response.blob(); zip.file(getPhotoFilename(photo), blob); filesAdded += 1; } if (filesAdded === 0) { throw new Error('No photos available to download.'); } const zipBlob = await zip.generateAsync({ type: 'blob' }); const url = URL.createObjectURL(zipBlob); const link = document.createElement('a'); link.href = url; link.download = `photos-${new Date().toISOString().split('T')[0]}.zip`; document.body.appendChild(link); link.click(); document.body.removeChild(link); URL.revokeObjectURL(url); }; const handleDownloadSelected = async () => { if ( selectedPhotoIds.length === 0 || typeof window === 'undefined' || isPreparingDownload ) { return; } const photoMap = new Map(photos.map((photo) => [photo.id, photo])); if (selectedPhotoIds.length === 1) { const photo = photoMap.get(selectedPhotoIds[0]); if (!photo) { return; } 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); return; } try { setIsPreparingDownload(true); await downloadPhotosAsZip(selectedPhotoIds, photoMap); } catch (error) { console.error('Error downloading selected photos:', error); alert('Failed to download selected photos. Please try again.'); } finally { setIsPreparingDownload(false); } }; const handleTagSelected = () => { if (selectedPhotoIds.length === 0) { return; } setTagDialogOpen(true); }; const handleBulkFavorite = async () => { if (selectedPhotoIds.length === 0 || !isLoggedIn || isBulkFavoriting) { return; } setIsBulkFavoriting(true); try { const response = await fetch('/api/photos/favorites/bulk', { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ photoIds: selectedPhotoIds, action: 'add', // Always add to favorites (skip if already favorited) }), }); if (!response.ok) { const error = await response.json(); if (response.status === 401) { alert('Please sign in to favorite photos'); } else { alert(error.error || 'Failed to update favorites'); } return; } const data = await response.json(); // Trigger PhotoGrid to refetch favorite statuses setRefreshFavoritesKey(prev => prev + 1); } catch (error) { console.error('Error bulk favoriting photos:', error); alert('Failed to update favorites. Please try again.'); } finally { setIsBulkFavoriting(false); } }; useEffect(() => { if (selectedPhotoIds.length === 0) { return; } const availableIds = new Set(photos.map((photo) => photo.id)); setSelectedPhotoIds((prev) => { const filtered = prev.filter((id) => availableIds.has(id)); return filtered.length === prev.length ? prev : filtered; }); }, [photos, selectedPhotoIds.length]); useEffect(() => { if (tagDialogOpen && selectedPhotoIds.length === 0) { setTagDialogOpen(false); } }, [tagDialogOpen, selectedPhotoIds.length]); // Handle closing the modal const handleCloseModal = () => { // Set flag to prevent useEffect from running isClosingModalRef.current = true; // Clear modal state immediately (no reload, instant close) setModalPhoto(null); setModalPhotos([]); setModalIndex(0); // Update URL directly using history API to avoid triggering Next.js router effects // This prevents any reload or re-fetch when closing const params = new URLSearchParams(window.location.search); params.delete('photo'); params.delete('photos'); params.delete('index'); params.delete('autoplay'); const newUrl = params.toString() ? `/?${params.toString()}` : '/'; // Use window.history directly to avoid Next.js router processing window.history.replaceState( { ...window.history.state, as: newUrl, url: newUrl }, '', newUrl ); // Reset flag after a short delay to allow effects to see it setTimeout(() => { isClosingModalRef.current = false; }, 100); }; // Fetch photos when filters change (reset to page 1) useEffect(() => { // If no filters, use initial photos and fetch total count if (!hasActiveFilters) { // Only update photos if they're different to prevent unnecessary re-renders const photosChanged = photos.length !== initialPhotos.length || photos.some((p, i) => p.id !== initialPhotos[i]?.id); if (photosChanged) { setPhotos(initialPhotos); photosInitializedRef.current = true; } else if (photos.length > 0) { // Photos are already set correctly photosInitializedRef.current = true; } setPage(1); pageRef.current = 1; isLoadingRef.current = false; // Fetch total count for display (use search API with no filters) fetch('/api/search?page=1&pageSize=1') .then((res) => res.json()) .then((data) => { setTotal(data.total); setHasMore(initialPhotos.length < data.total); }) .catch(() => { setTotal(initialPhotos.length); setHasMore(false); }); return; } const fetchPhotos = async () => { setLoading(true); try { const params = new URLSearchParams(); if (filters.people.length > 0) { params.set('people', filters.people.join(',')); if (filters.peopleMode) { params.set('peopleMode', filters.peopleMode); } } if (filters.tags.length > 0) { params.set('tags', filters.tags.join(',')); if (filters.tagsMode) { params.set('tagsMode', filters.tagsMode); } } if (filters.dateFrom) { params.set('dateFrom', filters.dateFrom.toISOString().split('T')[0]); } if (filters.dateTo) { params.set('dateTo', filters.dateTo.toISOString().split('T')[0]); } if (filters.mediaType && filters.mediaType !== 'all') { params.set('mediaType', filters.mediaType); } if (filters.favoritesOnly) { params.set('favoritesOnly', 'true'); } params.set('page', '1'); params.set('pageSize', '30'); const response = await fetch(`/api/search?${params.toString()}`); if (!response.ok) throw new Error('Failed to search photos'); const data = await response.json(); setPhotos(data.photos); photosInitializedRef.current = true; setTotal(data.total); setHasMore(data.photos.length < data.total); setPage(1); pageRef.current = 1; isLoadingRef.current = false; } catch (error) { console.error('Error searching photos:', error); } finally { setLoading(false); } }; fetchPhotos(); }, [filters, hasActiveFilters, initialPhotos]); // Infinite scroll observer useEffect(() => { const observer = new IntersectionObserver( (entries) => { // Don't load if we've already loaded all photos if (photos.length >= total && total > 0) { setHasMore(false); return; } if (entries[0].isIntersecting && hasMore && !loadingMore && !loading && photos.length < total && !isLoadingRef.current) { const fetchMore = async () => { if (isLoadingRef.current) { console.log('Already loading, skipping observer trigger'); return; } isLoadingRef.current = true; setLoadingMore(true); const nextPage = pageRef.current + 1; pageRef.current = nextPage; console.log('Observer triggered - loading page', nextPage, { currentPhotos: photos.length, total }); try { const params = new URLSearchParams(); if (filters.people.length > 0) { params.set('people', filters.people.join(',')); if (filters.peopleMode) { params.set('peopleMode', filters.peopleMode); } } if (filters.tags.length > 0) { params.set('tags', filters.tags.join(',')); if (filters.tagsMode) { params.set('tagsMode', filters.tagsMode); } } if (filters.dateFrom) { params.set('dateFrom', filters.dateFrom.toISOString().split('T')[0]); } if (filters.dateTo) { params.set('dateTo', filters.dateTo.toISOString().split('T')[0]); } if (filters.mediaType && filters.mediaType !== 'all') { params.set('mediaType', filters.mediaType); } if (filters.favoritesOnly) { params.set('favoritesOnly', 'true'); } params.set('page', nextPage.toString()); params.set('pageSize', '30'); const response = await fetch(`/api/search?${params.toString()}`); if (!response.ok) throw new Error('Failed to load more photos'); const data = await response.json(); // If we got 0 photos, we've reached the end if (data.photos.length === 0) { console.log('Got 0 photos, reached the end. Setting hasMore to false'); setHasMore(false); return; } setPhotos((prev) => { // Filter out duplicates by photo ID const existingIds = new Set(prev.map((p) => p.id)); const uniqueNewPhotos = data.photos.filter((p: Photo) => !existingIds.has(p.id)); const newPhotos = [...prev, ...uniqueNewPhotos]; // Stop loading if we've loaded all photos or got no new photos const hasMorePhotos = newPhotos.length < data.total && uniqueNewPhotos.length > 0; console.log('Loading page', nextPage, { prevCount: prev.length, newCount: data.photos.length, uniqueNew: uniqueNewPhotos.length, totalNow: newPhotos.length, totalExpected: data.total, hasMore: hasMorePhotos, loadedAll: newPhotos.length >= data.total }); // Always set hasMore to false if we've loaded all photos or got no new unique photos if (newPhotos.length >= data.total || uniqueNewPhotos.length === 0) { console.log('All photos loaded or no new photos! Setting hasMore to false', { newPhotos: newPhotos.length, total: data.total, uniqueNew: uniqueNewPhotos.length }); setHasMore(false); } else { setHasMore(hasMorePhotos); } return newPhotos; }); setPage(nextPage); } catch (error) { console.error('Error loading more photos:', error); setHasMore(false); // Stop on error } finally { setLoadingMore(false); isLoadingRef.current = false; } }; fetchMore(); } else { // If we have all photos, make sure hasMore is false if (photos.length >= total && total > 0) { console.log('Observer: Already have all photos, setting hasMore to false', { photos: photos.length, total }); setHasMore(false); } } }, { threshold: 0.1 } ); const currentTarget = observerTarget.current; if (currentTarget) { observer.observe(currentTarget); } return () => { if (currentTarget) { observer.unobserve(currentTarget); } }; }, [hasMore, loadingMore, loading, filters, photos.length, total]); // Ensure we load the last page when we're close to the end useEffect(() => { // Don't run if we've already loaded all photos if (photos.length >= total && total > 0) { if (hasMore) { console.log('All photos loaded, setting hasMore to false', { photos: photos.length, total }); setHasMore(false); } return; } // If we're very close to the end (1-5 photos remaining), load immediately const remaining = total - photos.length; if (remaining > 0 && remaining <= 5 && !loadingMore && !loading && !isLoadingRef.current && hasMore) { console.log('Very close to end, loading remaining photos immediately', { remaining, photos: photos.length, total }); const fetchRemaining = async () => { isLoadingRef.current = true; setLoadingMore(true); const nextPage = pageRef.current + 1; pageRef.current = nextPage; try { const params = new URLSearchParams(); if (filters.people.length > 0) { params.set('people', filters.people.join(',')); if (filters.peopleMode) { params.set('peopleMode', filters.peopleMode); } } if (filters.tags.length > 0) { params.set('tags', filters.tags.join(',')); if (filters.tagsMode) { params.set('tagsMode', filters.tagsMode); } } if (filters.dateFrom) { params.set('dateFrom', filters.dateFrom.toISOString().split('T')[0]); } if (filters.dateTo) { params.set('dateTo', filters.dateTo.toISOString().split('T')[0]); } if (filters.mediaType && filters.mediaType !== 'all') { params.set('mediaType', filters.mediaType); } if (filters.favoritesOnly) { params.set('favoritesOnly', 'true'); } params.set('page', nextPage.toString()); params.set('pageSize', '30'); const response = await fetch(`/api/search?${params.toString()}`); if (!response.ok) throw new Error('Failed to load more photos'); const data = await response.json(); if (data.photos.length === 0) { console.log('Got 0 photos, reached the end'); setHasMore(false); return; } setPhotos((prev) => { const existingIds = new Set(prev.map((p) => p.id)); const uniqueNewPhotos = data.photos.filter((p: Photo) => !existingIds.has(p.id)); const newPhotos = [...prev, ...uniqueNewPhotos]; const allLoaded = newPhotos.length >= data.total; const noNewPhotos = uniqueNewPhotos.length === 0; console.log('Loaded remaining photos:', { prevCount: prev.length, newCount: data.photos.length, uniqueNew: uniqueNewPhotos.length, totalNow: newPhotos.length, totalExpected: data.total, allLoaded, noNewPhotos }); if (allLoaded || noNewPhotos) { console.log('All photos loaded or no new photos, stopping'); setHasMore(false); } else { setHasMore(newPhotos.length < data.total && uniqueNewPhotos.length > 0); } return newPhotos; }); setPage(nextPage); } catch (error) { console.error('Error loading remaining photos:', error); setHasMore(false); } finally { setLoadingMore(false); isLoadingRef.current = false; } }; // Small delay to avoid race conditions const timeoutId = setTimeout(() => { if (!isLoadingRef.current && !loadingMore && !loading && photos.length < total) { fetchRemaining(); } }, 50); return () => clearTimeout(timeoutId); } }, [photos.length, total, hasMore, loadingMore, loading, filters]); return ( <>
{loading ? (
) : ( <>
{hasActiveFilters ? ( `Found ${total} photo${total !== 1 ? 's' : ''} - Showing ${photos.length}` ) : ( total > 0 ? ( `Showing ${photos.length} of ${total} photo${total !== 1 ? 's' : ''}` ) : ( `Showing ${photos.length} photo${photos.length !== 1 ? 's' : ''}` ) )}
{selectionMode && (
)}
{/* Infinite scroll sentinel */}
{loadingMore && ( )}
{!hasMore && photos.length > 0 && (
No more photos to load
)} )}
{/* Photo Modal Overlay */} {photoParam && modalPhoto && !modalLoading && ( )} {photoParam && modalLoading && (
)} { setSelectionMode(false); setSelectedPhotoIds([]); }} /> ); }