Files
punimtag/viewer-frontend/app/HomePageContent.tsx
T

1159 lines
41 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);
/** Scroll Y captured when the photo modal opens — never overwrite with 0 while locked. */
const scrollYBeforeModalRef = useRef<number | null>(null);
const prevFiltersSigRef = useRef<string | null>(null);
const photosCountRef = useRef(0);
// 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;
photosCountRef.current = photos.length;
// 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 / hard navigation
useEffect(() => {
if (scrollRestoredRef.current) return;
if (photoParam) return; // modal owns scroll lock while open
const scrollY = sessionStorage.getItem('homePageScrollY');
if (!scrollY) {
scrollRestoredRef.current = true;
return;
}
if (loading) return;
if (!photosInitializedRef.current && photos.length === 0) return;
photosInitializedRef.current = true;
requestAnimationFrame(() => {
requestAnimationFrame(() => {
if (!scrollRestoredRef.current && !photoParam) {
window.scrollTo({ top: parseInt(scrollY, 10), behavior: 'instant' });
scrollRestoredRef.current = true;
}
});
});
}, [loading, photos.length, photoParam]);
// Save scroll while browsing the grid (not while the modal locks the body)
useEffect(() => {
const handleScroll = () => {
if (scrollYBeforeModalRef.current !== null) return;
sessionStorage.setItem('homePageScrollY', window.scrollY.toString());
};
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);
};
}, []);
// Freeze page scroll under the photo modal; restore exact Y on close
useEffect(() => {
if (!photoParam) return;
// Prefer the value PhotoGrid saved at click time — window.scrollY is often
// already 0 by the time this effect runs after the route update.
const stored = parseInt(sessionStorage.getItem('homePageScrollY') || '0', 10);
const y = window.scrollY > 0 ? window.scrollY : stored;
scrollYBeforeModalRef.current = y;
sessionStorage.setItem('homePageScrollY', String(y));
scrollRestoredRef.current = false;
const { position, top, width, overflow } = document.body.style;
document.body.style.position = 'fixed';
document.body.style.top = `-${y}px`;
document.body.style.width = '100%';
document.body.style.overflow = 'hidden';
return () => {
document.body.style.position = position;
document.body.style.top = top;
document.body.style.width = width;
document.body.style.overflow = overflow;
scrollYBeforeModalRef.current = null;
requestAnimationFrame(() => {
window.scrollTo({ top: y, behavior: 'instant' });
sessionStorage.setItem('homePageScrollY', String(y));
scrollRestoredRef.current = true;
});
};
}, [photoParam]);
// 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 = () => {
// Skip one modal effect run (avoids racing with stale params during close)
isClosingModalRef.current = true;
const restoreY =
scrollYBeforeModalRef.current ??
parseInt(sessionStorage.getItem('homePageScrollY') || '0', 10);
// Clear modal state immediately (no reload, instant close)
setModalPhoto(null);
setModalPhotos([]);
setModalIndex(0);
// Must use Next.js router so useSearchParams() updates. Raw history.replaceState
// leaves the router thinking ?photo=… is still active, so router.push for the same
// photo is a no-op and the modal never reopens.
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()}` : '/';
router.replace(newUrl, { scroll: false });
// Belt-and-suspenders: modal effect cleanup also restores, but Next can
// still nudge scroll — pin it again after the URL settles.
requestAnimationFrame(() => {
window.scrollTo({ top: restoreY, behavior: 'instant' });
sessionStorage.setItem('homePageScrollY', String(restoreY));
});
setTimeout(() => {
isClosingModalRef.current = false;
window.scrollTo({ top: restoreY, behavior: 'instant' });
}, 100);
};
// Fetch / reset photos only when filters actually change — not when the parent
// re-renders with a new initialPhotos reference (opening/closing ?photo= modal).
const filtersSig = JSON.stringify({
people: filters.people,
peopleMode: filters.peopleMode ?? 'any',
tags: filters.tags,
tagsMode: filters.tagsMode ?? 'any',
dateFrom: filters.dateFrom?.toISOString().slice(0, 10) ?? '',
dateTo: filters.dateTo?.toISOString().slice(0, 10) ?? '',
mediaType: filters.mediaType ?? 'all',
favoritesOnly: !!filters.favoritesOnly,
});
useEffect(() => {
const filtersChanged =
prevFiltersSigRef.current === null || prevFiltersSigRef.current !== filtersSig;
prevFiltersSigRef.current = filtersSig;
if (!hasActiveFilters) {
if (filtersChanged) {
setPhotos(initialPhotos);
setPage(1);
pageRef.current = 1;
isLoadingRef.current = false;
photosInitializedRef.current = true;
} else {
// Keep infinitely scrolled pages; only refresh the total badge
photosInitializedRef.current = true;
}
fetch('/api/search?page=1&pageSize=1')
.then((res) => res.json())
.then((data) => {
setTotal(data.total);
const loaded = filtersChanged ? initialPhotos.length : photosCountRef.current;
setHasMore(loaded < data.total);
})
.catch(() => {
if (filtersChanged) {
setTotal(initialPhotos.length);
setHasMore(false);
}
});
return;
}
if (!filtersChanged) 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();
}, [filtersSig, hasActiveFilters, initialPhotos, filters]);
// 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 flex-col md:flex-row gap-4">
<CollapsibleSearch
people={people}
tags={tags}
filters={filters}
onFiltersChange={setFilters}
/>
<div className="flex-1 min-w-0">
{loading ? (
<div role="status" aria-label="Loading photos" aria-busy="true">
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4">
{Array.from({ length: 15 }).map((_, i) => (
<div
key={i}
className="aspect-square rounded-lg bg-muted animate-pulse"
/>
))}
</div>
</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} role="status" aria-label={loadingMore ? 'Loading more photos' : undefined} className="h-10 flex items-center justify-center">
{loadingMore && (
<Loader2 className="h-6 w-6 animate-spin text-gray-400 dark:text-gray-500" />
)}
</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([]);
}}
/>
</>
);
}