feat: initial Luma implementation

Full self-hosted photo manager with:
- Ingestion worker (chokidar + sharp + exifr, dHash perceptual hashing)
- SQLite schema with WAL mode (photos, tags, collections, duplicate_pairs)
- Express API with keyset pagination, tag/collection/search/date filters
- React + Vite UI: timeline, lightbox, detail panel, tag/collection editors
- Duplicate detection (Union-Find grouping) with review + dismiss UI
- Map view (Leaflet + OpenStreetMap, marker clustering)
- Docker multi-stage build + Compose with inline env config
- GitHub Actions: CI build check + ghcr.io image publish on main/tags

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
nmemmertandClaude Sonnet 4.6 committed 2026-08-17 16:33:55 -04:00
1 parent 03e6d6b628
commit e0f4e8ff0d
32 files changed
+4845

No files matched your search

+209
View File
@@ -0,0 +1,209 @@
import { useState, useEffect, useRef } from 'react';
import { fetchPhotos, fetchTags, fetchCollections } from './api/photos.js';
import Timeline from './components/Timeline.jsx';
import Lightbox from './components/Lightbox.jsx';
import FilterBar from './components/FilterBar.jsx';
import DuplicatesView from './components/DuplicatesView.jsx';
// Lazy-load MapView so Leaflet CSS doesn't bloat the initial bundle
import { lazy, Suspense } from 'react';
const MapView = lazy(() => import('./components/MapView.jsx'));
const EMPTY_FILTERS = { q: '', from: '', to: '', tagId: '', collectionId: '' };
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);
// Filter state
const [filters, setFilters] = useState(EMPTY_FILTERS);
const [tags, setTags] = useState([]);
const [collections, setCollections] = useState([]);
// Unified lightbox
const [lbPhotos, setLbPhotos] = useState(null);
const [lbIndex, setLbIndex] = useState(null);
const loadingRef = useRef(false);
const sentinelRef = useRef(null);
// Fetch dropdown data once
useEffect(() => {
fetchTags().then(setTags).catch(() => {});
fetchCollections().then(setCollections).catch(() => {});
}, []);
// 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);
}
return (
<>
<header style={{
position: 'sticky', top: 0, zIndex: 100,
background: '#0f0f0f', borderBottom: '1px solid #1c1c1c',
height: 52, padding: '0 20px',
display: 'flex', alignItems: 'center', gap: 0,
}}>
<span style={{ fontSize: 16, fontWeight: 700, color: '#fff', marginRight: 20, letterSpacing: -0.3 }}>
Luma
</span>
<nav style={{ display: 'flex', gap: 2, flex: 1 }}>
<Tab active={view === 'library'} onClick={() => setView('library')}>
Library
{total !== null && (
<span style={{ marginLeft: 6, fontSize: 12, color: view === 'library' ? '#555' : '#3a3a3a' }}>
{total.toLocaleString()}
</span>
)}
</Tab>
<Tab active={view === 'map'} onClick={() => setView('map')}>
Map
</Tab>
<Tab active={view === 'duplicates'} onClick={() => setView('duplicates')}>
Duplicates
{dupeCount > 0 && (
<span style={{
marginLeft: 6, background: '#b8860b', color: '#fff',
borderRadius: 10, fontSize: 10, fontWeight: 700,
padding: '1px 6px', lineHeight: '16px',
}}>
{dupeCount}
</span>
)}
</Tab>
</nav>
</header>
{/* ── Library ── */}
{view === 'library' && (
<>
<FilterBar
filters={filters}
onChange={setFilters}
tags={tags}
collections={collections}
/>
{error && (
<p style={{ color: '#f87171', padding: 24, textAlign: 'center', fontSize: 14 }}>
{error}
</p>
)}
{photos.length === 0 && !loading && !error && (
<div style={{ textAlign: 'center', padding: '80px 24px', color: '#555' }}>
{filters.q || filters.from || filters.to || filters.tagId || filters.collectionId ? (
<p style={{ fontSize: 15 }}>No photos match these filters.</p>
) : (
<>
<p style={{ fontSize: 15, marginBottom: 8 }}>No photos indexed yet.</p>
<p style={{ fontSize: 13 }}>Set <code>PHOTO_DIR</code> in <code>.env</code> and run <code>npm run worker</code>.</p>
</>
)}
</div>
)}
<Timeline photos={photos} onSelect={(idx) => openLightbox(photos, idx)} />
<div ref={sentinelRef} style={{ height: 1 }} />
{loading && <p style={{ textAlign: 'center', padding: 24, color: '#444', fontSize: 13 }}>Loading…</p>}
{nextCursor === null && photos.length > 0 && !loading && (
<p style={{ textAlign: 'center', padding: 24, color: '#2a2a2a', fontSize: 12 }}>
— {total?.toLocaleString()} photo{total === 1 ? '' : 's'} —
</p>
)}
</>
)}
{/* ── Map ── */}
{view === 'map' && (
<Suspense fallback={<p style={{ padding: 24, color: '#555', fontSize: 13 }}>Loading map…</p>}>
<MapView onOpenPhoto={(photo) => openLightbox([photo], 0)} />
</Suspense>
)}
{/* ── Duplicates ── */}
{view === 'duplicates' && (
<DuplicatesView
onGroupCountChange={setDupeCount}
onOpenPhoto={(photo, group) =>
openLightbox(group, group.findIndex(p => p.id === photo.id))
}
/>
)}
{/* ── Lightbox (shared) ── */}
{lbPhotos !== null && (
<Lightbox
photos={lbPhotos}
index={lbIndex}
onClose={() => { setLbPhotos(null); setLbIndex(null); }}
onChange={setLbIndex}
/>
)}
</>
);
}
function Tab({ active, onClick, children }) {
return (
<button
onClick={onClick}
style={{
background: active ? '#1e1e1e' : 'transparent',
border: 'none', borderRadius: 6,
color: active ? '#e0e0e0' : '#666',
cursor: 'pointer', padding: '5px 12px', fontSize: 13,
fontWeight: active ? 600 : 400,
display: 'flex', alignItems: 'center',
}}
>
{children}
</button>
);
}