punimtag/viewer-frontend/app/HomePageContent.tsx
Tanya b6a9765315
Some checks failed
CI / skip-ci-check (push) Successful in 1m27s
CI / skip-ci-check (pull_request) Successful in 1m27s
CI / lint-and-type-check (pull_request) Has been cancelled
CI / python-lint (pull_request) Has been cancelled
CI / test-backend (pull_request) Has been cancelled
CI / build (pull_request) Has been cancelled
CI / secret-scanning (pull_request) Has been cancelled
CI / dependency-scan (pull_request) Has been cancelled
CI / sast-scan (pull_request) Has been cancelled
CI / workflow-summary (pull_request) Has been cancelled
CI / lint-and-type-check (push) Successful in 2m4s
CI / python-lint (push) Successful in 1m53s
CI / test-backend (push) Successful in 2m37s
CI / build (push) Failing after 2m13s
CI / secret-scanning (push) Successful in 1m40s
CI / dependency-scan (push) Successful in 1m34s
CI / sast-scan (push) Successful in 2m42s
CI / workflow-summary (push) Successful in 1m26s
chore: Update project configuration and enhance code quality
This commit modifies the `.gitignore` file to exclude Python library directories while ensuring the viewer-frontend's `lib` directory is not ignored. It also updates the `package.json` to activate the virtual environment during backend tests, improving the testing process. Additionally, the CI workflow is enhanced to prevent duplicate runs for branches with open pull requests. Various components in the viewer frontend are updated to ensure consistent naming conventions and improve type safety. These changes contribute to a cleaner codebase and a more efficient development workflow.
2026-01-07 12:29:17 -05:00

1101 lines
39 KiB
TypeScript

'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<SearchFilters>(() => {
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<Photo[]>(hasInitialFilters ? [] : initialPhotos);
const [loading, setLoading] = useState<boolean>(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<HTMLDivElement>(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<any>(null);
const [modalPhotos, setModalPhotos] = useState<any[]>([]);
const [modalIndex, setModalIndex] = useState(0);
const [modalLoading, setModalLoading] = useState(false);
const [selectionMode, setSelectionMode] = useState(false);
const [selectedPhotoIds, setSelectedPhotoIds] = useState<number[]>([]);
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<number, Photo>
) => {
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 (
<>
<PageHeader
photosCount={photos.length}
isLoggedIn={isLoggedIn}
selectedPhotoIds={selectedPhotoIds}
selectionMode={selectionMode}
isBulkFavoriting={isBulkFavoriting}
isPreparingDownload={isPreparingDownload}
onStartSlideshow={handleStartSlideshow}
onTagSelected={handleTagSelected}
onBulkFavorite={handleBulkFavorite}
onDownloadSelected={handleDownloadSelected}
onToggleSelectionMode={handleToggleSelectionMode}
/>
<div className="flex gap-4">
<CollapsibleSearch
people={people}
tags={tags}
filters={filters}
onFiltersChange={setFilters}
/>
<div className="flex-1 min-w-0">
{loading ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="h-8 w-8 animate-spin text-gray-400" />
</div>
) : (
<>
<div className="mb-4">
<div className="flex items-center justify-between gap-4">
<div className="text-sm text-gray-600 dark:text-gray-400">
{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' : ''}`
)
)}
</div>
{selectionMode && (
<div className="flex items-center gap-2">
<button
onClick={handleSelectAll}
className="p-2 text-secondary hover:text-secondary/80 hover:bg-secondary/10 rounded transition-colors"
title="Select all photos"
aria-label="Select all photos"
>
<CheckSquare className="h-5 w-5" />
</button>
<button
onClick={handleClearAll}
className="p-2 text-secondary hover:text-secondary/80 hover:bg-secondary/10 rounded transition-colors"
title="Clear selection"
aria-label="Clear selection"
>
<Square className="h-5 w-5" />
</button>
</div>
)}
</div>
</div>
<PhotoGrid
photos={photos}
selectionMode={selectionMode}
selectedPhotoIds={selectedPhotoIds}
onToggleSelect={handleTogglePhotoSelection}
refreshFavoritesKey={refreshFavoritesKey}
/>
{/* Infinite scroll sentinel */}
<div ref={observerTarget} className="h-10 flex items-center justify-center">
{loadingMore && (
<Loader2 className="h-6 w-6 animate-spin text-gray-400" />
)}
</div>
{!hasMore && photos.length > 0 && (
<div className="text-center py-8 text-sm text-gray-500 dark:text-gray-400">
No more photos to load
</div>
)}
</>
)}
</div>
</div>
{/* Photo Modal Overlay */}
{photoParam && modalPhoto && !modalLoading && (
<PhotoViewerClient
initialPhoto={modalPhoto}
allPhotos={modalPhotos}
currentIndex={modalIndex}
onClose={handleCloseModal}
autoPlay={autoplayParam}
/>
)}
{photoParam && modalLoading && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black">
<Loader2 className="h-8 w-8 animate-spin text-white" />
</div>
)}
<TagSelectionDialog
open={tagDialogOpen}
onOpenChange={setTagDialogOpen}
photoIds={selectedPhotoIds}
tags={tags}
onSuccess={() => {
setSelectionMode(false);
setSelectedPhotoIds([]);
}}
/>
</>
);
}