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

+74
View File
@@ -0,0 +1,74 @@
async function api(path, options) {
const res = await fetch(path, options);
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
return res.status === 204 ? null : res.json();
}
const json = (body) => ({
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
// Library
export function fetchPhotos({ cursor, limit = 100, q, from, to, tagId, collectionId } = {}) {
const p = new URLSearchParams({ limit });
if (cursor) p.set('cursor', cursor);
if (q) p.set('q', q);
if (from) p.set('from', from);
if (to) p.set('to', to);
if (tagId) p.set('tagId', tagId);
if (collectionId) p.set('collectionId', collectionId);
return api(`/api/photos?${p}`);
}
export function fetchPhotoDetail(id) {
return api(`/api/photos/${id}`);
}
export function fetchMapPhotos() {
return api('/api/photos/map');
}
// Tags
export function addTag(photoId, name) {
return api(`/api/photos/${photoId}/tags`, { method: 'POST', ...json({ name }) });
}
export function removeTag(photoId, tagId) {
return api(`/api/photos/${photoId}/tags/${tagId}`, { method: 'DELETE' });
}
// Collections
export function fetchCollections() {
return api('/api/collections');
}
export function createCollection(name, description) {
return api('/api/collections', { method: 'POST', ...json({ name, description }) });
}
export function addToCollection(photoId, collectionId) {
return api(`/api/photos/${photoId}/collections`, { method: 'POST', ...json({ collectionId }) });
}
export function removeFromCollection(photoId, collectionId) {
return api(`/api/photos/${photoId}/collections/${collectionId}`, { method: 'DELETE' });
}
// Tags + collections (for filter dropdowns)
export function fetchTags() {
return api('/api/tags');
}
// Duplicates
export function fetchDuplicates() {
return api('/api/duplicates');
}
export function scanDuplicates() {
return api('/api/dedupe/scan', { method: 'POST' });
}
export function dismissGroup(photoIds) {
return api('/api/duplicates/dismiss', { method: 'POST', ...json({ photoIds }) });
}