import { useState, useEffect, useRef, useCallback } from 'react'; import { fetchPhotos, fetchLibraryStats, fetchVideoStats, bulkStar, bulkAddToCollection, deletePhoto } 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 SettingsPanel from './components/SettingsPanel.jsx'; import TimelineScrubber from './components/TimelineScrubber.jsx'; import { lazy, Suspense } from 'react'; const MapView = lazy(() => import('./components/MapView.jsx')); const MemoriesView = lazy(() => import('./components/MemoriesView.jsx')); const VideosView = lazy(() => import('./components/VideosView.jsx')); const AlbumsView = lazy(() => import('./components/AlbumsView.jsx')); const TrashView = lazy(() => import('./components/TrashView.jsx')); const EMPTY_FILTERS = { q: '', from: '', to: '', tagId: '', collectionId: '', starred: '', city: '', importedFrom: '' }; const EMPTY_VIDEO_FILTERS = { q: '', from: '', to: '', tagId: '', collectionId: '', starred: '', city: '' }; 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 [videoFilters, setVideoFilters] = useState(EMPTY_VIDEO_FILTERS); const [videoStats, setVideoStats] = useState(null); const [lbPhotos, setLbPhotos] = useState(null); const [lbIndex, setLbIndex] = useState(null); const [showSettings, setShowSettings] = useState(false); const [selected, setSelected] = useState(new Set()); const loadingRef = useRef(false); const sentinelRef = useRef(null); const loadStats = useCallback(() => { fetchLibraryStats().then(setStats).catch(() => {}); }, []); useEffect(() => { loadStats(); }, []); useEffect(() => { if (view === 'videos') { fetchVideoStats().then(setVideoStats).catch(() => {}); } }, [view]); // 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 toggleSelect(photoId) { setSelected(prev => { const next = new Set(prev); next.has(photoId) ? next.delete(photoId) : next.add(photoId); return next; }); } function clearSelection() { setSelected(new Set()); } async function handleBulkStar(starred) { const ids = [...selected]; try { await bulkStar(ids, starred); const idSet = new Set(ids); setPhotos(prev => prev.map(p => idSet.has(p.id) ? { ...p, starred } : p)); clearSelection(); loadStats(); } catch {} } async function handleBulkAddToCollection(collectionId) { if (!collectionId) return; try { await bulkAddToCollection([...selected], Number(collectionId)); clearSelection(); } catch {} } function handleSelectAlbum({ type, collectionId, year, city, camera, days }) { const f = { ...EMPTY_FILTERS }; if (type === 'collection') f.collectionId = String(collectionId); else if (type === 'year') { f.from = `${year}-01-01`; f.to = `${year}-12-31`; } else if (type === 'city') f.city = city; else if (type === 'camera') f.q = camera; else if (type === 'recent') { const d = new Date(); d.setDate(d.getDate() - days); f.importedFrom = d.toISOString().slice(0, 10); } setFilters(f); setView('library'); clearSelection(); } async function handleDeletePhoto(photoId) { try { await deletePhoto(photoId); setPhotos(prev => prev.filter(p => p.id !== photoId)); if (lbPhotos) { const next = lbPhotos.filter(p => p.id !== photoId); if (next.length === 0) { setLbPhotos(null); setLbIndex(null); } else { setLbPhotos(next); setLbIndex(i => Math.min(i, next.length - 1)); } } loadStats(); } catch {} } 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 || filters.city || filters.importedFrom; return ( <> {/* ── Header ── */}
Luma {/* Right side: ingest badge + gear */}
{/* ── Body (sidebar + content) ── */}
{/* Sidebar — library and videos views */} {(view === 'library' || view === 'videos') && ( )} {/* 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)} selected={selected} onToggle={toggleSelect} selectionActive={selected.size > 0} />
{loading &&

Loading…

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

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

)} )} {/* Albums */} {view === 'albums' && ( Loading…

}>
)} {/* Videos */} {view === 'videos' && ( Loading…

}>
)} {/* Memories */} {view === 'memories' && ( Loading…

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

}> openLightbox([photo], 0)} />
)} {/* Duplicates */} {view === 'duplicates' && ( openLightbox(group, group.findIndex(p => p.id === photo.id)) } /> )} {/* Trash */} {view === 'trash' && ( Loading…

}>
)}
{/* Timeline scrubber — right-edge year nav for library */} {view === 'library' && ( )} {/* Bulk action bar */} {selected.size > 0 && view === 'library' && (
{selected.size} selected
{stats?.collections?.length > 0 && ( )}
)} {/* Settings panel */} {showSettings && setShowSettings(false)} />} {/* Lightbox */} {lbPhotos !== null && ( { setLbPhotos(null); setLbIndex(null); }} onChange={setLbIndex} onStarChange={handleStarChange} onDelete={handleDeletePhoto} /> )} ); } const bulkBtn = { background: 'transparent', border: '1px solid #333', borderRadius: 6, color: '#ccc', cursor: 'pointer', fontSize: 12, padding: '5px 11px', }; function Tab({ active, onClick, children }) { return ( ); }