import { useState, useEffect, useRef, useCallback } from 'react';
import { fetchPhotos, fetchLibraryStats } from './api/photos.js';
import Timeline from './components/Timeline.jsx';
import Lightbox from './components/Lightbox.jsx';
import FilterSidebar from './components/FilterSidebar.jsx';
import IngestBadge from './components/IngestBadge.jsx';
import DuplicatesView from './components/DuplicatesView.jsx';
import { lazy, Suspense } from 'react';
const MapView = lazy(() => import('./components/MapView.jsx'));
const EMPTY_FILTERS = { q: '', from: '', to: '', tagId: '', collectionId: '', starred: '' };
const SIDEBAR_W = 210;
export default function App() {
const [view, setView] = useState('library');
const [photos, setPhotos] = useState([]);
const [nextCursor, setNextCursor] = useState(undefined);
const [loading, setLoading] = useState(false);
const [total, setTotal] = useState(null);
const [error, setError] = useState(null);
const [dupeCount, setDupeCount] = useState(0);
const [filters, setFilters] = useState(EMPTY_FILTERS);
const [stats, setStats] = useState(null);
const [lbPhotos, setLbPhotos] = useState(null);
const [lbIndex, setLbIndex] = useState(null);
const loadingRef = useRef(false);
const sentinelRef = useRef(null);
const loadStats = useCallback(() => {
fetchLibraryStats().then(setStats).catch(() => {});
}, []);
useEffect(() => { loadStats(); }, []);
// Reset + reload when filters change (debounce text search)
useEffect(() => {
const delay = filters.q ? 300 : 0;
const timer = setTimeout(() => {
setPhotos([]);
setNextCursor(undefined);
loadPage(undefined, filters);
}, delay);
return () => clearTimeout(timer);
}, [filters]);
async function loadPage(cursor, activeFilters) {
if (loadingRef.current) return;
loadingRef.current = true;
setLoading(true);
setError(null);
try {
const data = await fetchPhotos({ cursor, ...activeFilters });
setPhotos(prev => cursor !== undefined ? [...prev, ...data.photos] : data.photos);
setNextCursor(data.nextCursor ?? null);
setTotal(data.total);
} catch (e) {
setError(e.message);
}
loadingRef.current = false;
setLoading(false);
}
// Infinite scroll sentinel
useEffect(() => {
if (!sentinelRef.current || !nextCursor || view !== 'library') return;
const el = sentinelRef.current;
const cur = nextCursor;
const obs = new IntersectionObserver(
([entry]) => { if (entry.isIntersecting) loadPage(cur, filters); },
{ rootMargin: '600px' }
);
obs.observe(el);
return () => obs.disconnect();
}, [nextCursor, view, filters]);
function openLightbox(photosArr, idx) {
setLbPhotos(photosArr);
setLbIndex(idx);
}
// When a photo's star state changes in the lightbox, patch it in the local list
function handleStarChange(updated) {
setPhotos(prev => prev.map(p => p.id === updated.id ? { ...p, starred: updated.starred } : p));
if (lbPhotos) {
setLbPhotos(prev => prev.map(p => p.id === updated.id ? { ...p, starred: updated.starred } : p));
}
loadStats(); // refresh sidebar count
}
// When ingest finishes, refresh photos + stats
function handleScanComplete() {
setPhotos([]);
setNextCursor(undefined);
loadPage(undefined, filters);
loadStats();
}
const hasFilter = filters.q || filters.from || filters.to || filters.tagId || filters.collectionId || filters.starred;
return (
<>
{/* ── Header ── */}
{error}
)} {photos.length === 0 && !loading && !error && (No photos match these filters.
) : ( <>No photos indexed yet.
Set PHOTO_DIR in .env and run npm run worker.
Loading…
} {nextCursor === null && photos.length > 0 && !loading && (— {total?.toLocaleString()} photo{total === 1 ? '' : 's'} —
)} > )} {/* Map */} {view === 'map' && (