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:
1 parent
03e6d6b628
commit
e0f4e8ff0d
32 files changed
+4845
No files matched your search
@@ -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 }) });
|
||||
}
|
||||
Reference in new issue
Block a user