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

+48
View File
@@ -0,0 +1,48 @@
import MonthGroup from './MonthGroup.jsx';
function groupByMonth(photos) {
const groups = [];
let current = null;
for (const photo of photos) {
const key = photo.date_taken ? photo.date_taken.slice(0, 7) : 'undated';
if (!current || current.key !== key) {
current = { key, label: monthLabel(photo.date_taken), photos: [] };
groups.push(current);
}
current.photos.push(photo);
}
return groups;
}
function monthLabel(dateTaken) {
if (!dateTaken) return 'Undated';
return new Date(dateTaken).toLocaleDateString('en-US', { month: 'long', year: 'numeric' });
}
export default function Timeline({ photos, onSelect }) {
if (!photos.length) return null;
const groups = groupByMonth(photos);
let globalIdx = 0;
return (
<main style={{ padding: '0 3px' }}>
{groups.map(group => {
const startIdx = globalIdx;
globalIdx += group.photos.length;
return (
<MonthGroup
key={group.key}
label={group.label}
photos={group.photos}
startIdx={startIdx}
onSelect={onSelect}
/>
);
})}
</main>
);
}