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 ── */}
Luma {/* Ingest progress — right side */}
{/* ── Body (sidebar + content) ── */}
{/* Sidebar — only in library view */} {view === 'library' && ( )} {/* Main content */}
{/* Library */} {view === 'library' && ( <> {error && (

{error}

)} {photos.length === 0 && !loading && !error && (
{hasFilter ? (

No photos match these filters.

) : ( <>

No photos indexed yet.

Set PHOTO_DIR in .env and run npm run worker.

)}
)} openLightbox(photos, idx)} />
{loading &&

Loading…

} {nextCursor === null && photos.length > 0 && !loading && (

— {total?.toLocaleString()} photo{total === 1 ? '' : 's'} —

)} )} {/* Map */} {view === 'map' && ( Loading map…

}> openLightbox([photo], 0)} />
)} {/* Duplicates */} {view === 'duplicates' && ( openLightbox(group, group.findIndex(p => p.id === photo.id)) } /> )}
{/* Lightbox */} {lbPhotos !== null && ( { setLbPhotos(null); setLbIndex(null); }} onChange={setLbIndex} onStarChange={handleStarChange} /> )} ); } function Tab({ active, onClick, children }) { return ( ); }