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

+145
View File
@@ -0,0 +1,145 @@
import { useState, useEffect } from 'react';
import DetailPanel from './DetailPanel.jsx';
export default function Lightbox({ photos, index, onClose, onChange }) {
const [showInfo, setShowInfo] = useState(false);
const photo = photos[index];
useEffect(() => {
function onKey(e) {
// Don't hijack shortcuts while typing in the detail panel
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA' || e.target.tagName === 'SELECT') return;
if (e.key === 'Escape') onClose();
if (e.key === 'ArrowLeft' && index > 0) onChange(index - 1);
if (e.key === 'ArrowRight' && index < photos.length - 1) onChange(index + 1);
if (e.key === 'i') setShowInfo(s => !s);
}
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [index, photos.length, onClose, onChange]);
useEffect(() => {
document.body.style.overflow = 'hidden';
return () => { document.body.style.overflow = ''; };
}, []);
if (!photo) return null;
return (
<div style={{ position: 'fixed', inset: 0, zIndex: 1000, display: 'flex', background: '#080808' }}>
{/* ── Image area ── */}
<div
onClick={onClose}
style={{ flex: 1, position: 'relative', display: 'flex', alignItems: 'center', justifyContent: 'center', overflow: 'hidden', minWidth: 0 }}
>
{/* Toolbar */}
<div
onClick={e => e.stopPropagation()}
style={{ position: 'absolute', top: 0, right: 0, display: 'flex', gap: 4, padding: 10, zIndex: 10 }}
>
<IconBtn
onClick={() => setShowInfo(s => !s)}
active={showInfo}
title="Info [i]"
>
<InfoIcon />
</IconBtn>
<IconBtn onClick={onClose} title="Close [Esc]">✕</IconBtn>
</div>
{/* Left nav */}
{index > 0 && (
<NavBtn side="left" onClick={e => { e.stopPropagation(); onChange(index - 1); }}>‹</NavBtn>
)}
{/* Right nav */}
{index < photos.length - 1 && (
<NavBtn side="right" onClick={e => { e.stopPropagation(); onChange(index + 1); }}>›</NavBtn>
)}
<img
key={photo.id}
src={photo.preview_url || photo.thumb_url}
alt=""
onClick={e => e.stopPropagation()}
style={{
maxWidth: '100%',
maxHeight: 'calc(100vh - 20px)',
objectFit: 'contain',
display: 'block',
}}
/>
</div>
{/* ── Detail panel ── */}
{showInfo && (
<div style={{
width: 320,
flexShrink: 0,
background: '#111',
borderLeft: '1px solid #1d1d1d',
overflowY: 'auto',
display: 'flex',
flexDirection: 'column',
}}>
<DetailPanel photoId={photo.id} />
</div>
)}
</div>
);
}
function IconBtn({ onClick, active, title, children }) {
return (
<button
onClick={onClick}
title={title}
style={{
background: active ? 'rgba(255,255,255,0.15)' : 'rgba(255,255,255,0.06)',
border: '1px solid rgba(255,255,255,0.1)',
borderRadius: 6,
color: active ? '#fff' : '#888',
cursor: 'pointer',
width: 32, height: 32,
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: 14,
transition: 'background 0.1s',
}}
>
{children}
</button>
);
}
function NavBtn({ side, onClick, children }) {
return (
<button
onClick={onClick}
style={{
position: 'absolute', top: '50%', transform: 'translateY(-50%)',
[side]: 10,
background: 'rgba(0,0,0,0.5)',
border: '1px solid rgba(255,255,255,0.08)',
borderRadius: '50%',
width: 44, height: 44,
fontSize: 24, color: '#ccc',
cursor: 'pointer',
display: 'flex', alignItems: 'center', justifyContent: 'center',
lineHeight: 1,
zIndex: 5,
}}
>
{children}
</button>
);
}
function InfoIcon() {
return (
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round">
<circle cx="7" cy="7" r="6" />
<line x1="7" y1="6" x2="7" y2="10" />
<circle cx="7" cy="3.5" r="0.75" fill="currentColor" stroke="none" />
</svg>
);
}