1159 lines
41 KiB
TypeScript
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([]);
|
|
}}
|
|
/>
|
|
</>
|
|
);
|
|
}
|
|
|