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
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.
1101 lines
39 KiB
TypeScript
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([]);
|
|
}}
|
|
/>
|
|
</>
|
|
);
|
|
}
|
|
|