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,9 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
data
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
*.log
|
||||||
|
.git
|
||||||
|
.github
|
||||||
|
photos
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
# Path to your photo library (read-only mount in production)
|
||||||
|
PHOTO_DIR=./photos
|
||||||
|
|
||||||
|
# Where the SQLite DB and generated posters live (writable volume)
|
||||||
|
DB_PATH=./data/library.db
|
||||||
|
POSTER_DIR=./data/posters
|
||||||
|
|
||||||
|
# API server port
|
||||||
|
PORT=3000
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
name: CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
pull_request:
|
||||||
|
branches: [main]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
name: Build & test
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '22'
|
||||||
|
cache: 'npm'
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Build client
|
||||||
|
run: npm run build
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
name: Docker
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
tags: ['v*.*.*']
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-push:
|
||||||
|
name: Build & push image
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
packages: write
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- name: Log in to GitHub Container Registry
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: ghcr.io
|
||||||
|
username: ${{ github.actor }}
|
||||||
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Extract metadata
|
||||||
|
id: meta
|
||||||
|
uses: docker/metadata-action@v5
|
||||||
|
with:
|
||||||
|
images: ghcr.io/${{ github.repository }}
|
||||||
|
tags: |
|
||||||
|
type=ref,event=branch
|
||||||
|
type=semver,pattern={{version}}
|
||||||
|
type=semver,pattern={{major}}.{{minor}}
|
||||||
|
type=sha,prefix=sha-
|
||||||
|
|
||||||
|
- name: Build and push
|
||||||
|
uses: docker/build-push-action@v6
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
push: true
|
||||||
|
platforms: linux/amd64
|
||||||
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
|
cache-from: type=gha
|
||||||
|
cache-to: type=gha,mode=max
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
node_modules/
|
node_modules/
|
||||||
dist/
|
dist/
|
||||||
.env
|
.env
|
||||||
|
data/
|
||||||
|
photos/
|
||||||
*.db
|
*.db
|
||||||
*.db-shm
|
*.db-shm
|
||||||
*.db-wal
|
*.db-wal
|
||||||
|
|||||||
+40
@@ -0,0 +1,40 @@
|
|||||||
|
# ── Stage 1: build client ────────────────────────────────────────────────────
|
||||||
|
FROM node:22-alpine AS builder
|
||||||
|
|
||||||
|
# Build tools needed by better-sqlite3 native addon
|
||||||
|
RUN apk add --no-cache python3 make g++
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package*.json ./
|
||||||
|
RUN npm ci
|
||||||
|
COPY . .
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# ── Stage 2: runtime ─────────────────────────────────────────────────────────
|
||||||
|
FROM node:22-alpine
|
||||||
|
|
||||||
|
RUN apk add --no-cache python3 make g++
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY package*.json ./
|
||||||
|
# Reinstall in the runtime image so native addons compile for this arch
|
||||||
|
RUN npm ci --omit=dev
|
||||||
|
|
||||||
|
COPY --from=builder /app/dist ./dist
|
||||||
|
COPY worker ./worker
|
||||||
|
COPY server ./server
|
||||||
|
|
||||||
|
ENV PORT=3000 \
|
||||||
|
DB_PATH=/data/library.db \
|
||||||
|
POSTER_DIR=/data/posters \
|
||||||
|
PHOTO_DIR=/photos \
|
||||||
|
NODE_ENV=production
|
||||||
|
|
||||||
|
EXPOSE 3000
|
||||||
|
|
||||||
|
# /data — SQLite database + generated thumbnails (mount a named volume)
|
||||||
|
# /photos — read-only source photo directory (bind-mount from host)
|
||||||
|
VOLUME ["/data", "/photos"]
|
||||||
|
|
||||||
|
CMD ["node", "server/index.js"]
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>shpm</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.jsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 }) });
|
||||||
|
}
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { addToCollection, removeFromCollection, fetchCollections, createCollection } from '../api/photos.js';
|
||||||
|
import { Chip, Btn, iStyle } from './TagEditor.jsx';
|
||||||
|
|
||||||
|
export default function CollectionEditor({ photoId, initialCollections }) {
|
||||||
|
const [collections, setCollections] = useState(initialCollections);
|
||||||
|
const [all, setAll] = useState([]);
|
||||||
|
const [mode, setMode] = useState('select'); // 'select' | 'create'
|
||||||
|
const [selectedId, setSelectedId] = useState('');
|
||||||
|
const [newName, setNewName] = useState('');
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchCollections().then(setAll).catch(() => {});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const currentIds = new Set(collections.map(c => c.id));
|
||||||
|
const available = all.filter(c => !currentIds.has(c.id));
|
||||||
|
|
||||||
|
async function handleAdd() {
|
||||||
|
if (busy) return;
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
if (mode === 'select') {
|
||||||
|
if (!selectedId) return;
|
||||||
|
const col = all.find(c => c.id === parseInt(selectedId, 10));
|
||||||
|
await addToCollection(photoId, col.id);
|
||||||
|
setCollections(prev => [...prev, col]);
|
||||||
|
setSelectedId('');
|
||||||
|
} else {
|
||||||
|
const name = newName.trim();
|
||||||
|
if (!name) return;
|
||||||
|
const col = await createCollection(name);
|
||||||
|
setAll(prev => [...prev, col].sort((a, b) => a.name.localeCompare(b.name)));
|
||||||
|
await addToCollection(photoId, col.id);
|
||||||
|
setCollections(prev => [...prev, col]);
|
||||||
|
setNewName('');
|
||||||
|
setMode('select');
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleRemove(colId) {
|
||||||
|
setCollections(prev => prev.filter(c => c.id !== colId));
|
||||||
|
try {
|
||||||
|
await removeFromCollection(photoId, colId);
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{collections.length > 0 && (
|
||||||
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 5, marginBottom: 10 }}>
|
||||||
|
{collections.map(c => (
|
||||||
|
<Chip key={c.id} label={c.name} color="green" onRemove={() => handleRemove(c.id)} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{mode === 'select' ? (
|
||||||
|
<div style={{ display: 'flex', gap: 5 }}>
|
||||||
|
<select
|
||||||
|
value={selectedId}
|
||||||
|
onChange={e => setSelectedId(e.target.value)}
|
||||||
|
style={{ ...iStyle, cursor: 'pointer' }}
|
||||||
|
>
|
||||||
|
<option value="">Add to collection…</option>
|
||||||
|
{available.map(c => (
|
||||||
|
<option key={c.id} value={c.id}>{c.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
{selectedId && <Btn onClick={handleAdd} disabled={busy}>+</Btn>}
|
||||||
|
<Btn
|
||||||
|
type="button"
|
||||||
|
onClick={() => setMode('create')}
|
||||||
|
style={{ width: 'auto', padding: '0 10px', fontSize: 11 }}
|
||||||
|
>
|
||||||
|
New
|
||||||
|
</Btn>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div style={{ display: 'flex', gap: 5 }}>
|
||||||
|
<input
|
||||||
|
autoFocus
|
||||||
|
value={newName}
|
||||||
|
onChange={e => setNewName(e.target.value)}
|
||||||
|
onKeyDown={e => { if (e.key === 'Enter') handleAdd(); if (e.key === 'Escape') setMode('select'); }}
|
||||||
|
placeholder="Collection name…"
|
||||||
|
maxLength={200}
|
||||||
|
style={iStyle}
|
||||||
|
/>
|
||||||
|
<Btn onClick={handleAdd} disabled={!newName.trim() || busy}>+</Btn>
|
||||||
|
<Btn type="button" onClick={() => setMode('select')}>×</Btn>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { fetchPhotoDetail } from '../api/photos.js';
|
||||||
|
import TagEditor from './TagEditor.jsx';
|
||||||
|
import CollectionEditor from './CollectionEditor.jsx';
|
||||||
|
|
||||||
|
export default function DetailPanel({ photoId }) {
|
||||||
|
const [photo, setPhoto] = useState(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setPhoto(null);
|
||||||
|
setLoading(true);
|
||||||
|
fetchPhotoDetail(photoId)
|
||||||
|
.then(setPhoto)
|
||||||
|
.catch(() => {})
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, [photoId]);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return <div style={panelPad}><span style={{ color: '#444', fontSize: 13 }}>Loading…</span></div>;
|
||||||
|
}
|
||||||
|
if (!photo) return null;
|
||||||
|
|
||||||
|
const filename = photo.filepath?.split('/').pop();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ padding: '14px 16px 32px', fontSize: 13, color: '#ccc', lineHeight: 1.5 }}>
|
||||||
|
|
||||||
|
<Section label="File">
|
||||||
|
{filename && <Row k="Name" v={filename} mono />}
|
||||||
|
{photo.date_taken && (
|
||||||
|
<Row k="Taken" v={new Date(photo.date_taken).toLocaleDateString('en-US', { dateStyle: 'full' })} />
|
||||||
|
)}
|
||||||
|
{photo.date_imported && (
|
||||||
|
<Row k="Added" v={new Date(photo.date_imported).toLocaleDateString('en-US', { dateStyle: 'medium' })} />
|
||||||
|
)}
|
||||||
|
{photo.width && photo.height && (
|
||||||
|
<Row k="Pixels" v={`${photo.width.toLocaleString()} × ${photo.height.toLocaleString()}`} />
|
||||||
|
)}
|
||||||
|
{photo.filesize && (
|
||||||
|
<Row k="Size" v={formatBytes(photo.filesize)} />
|
||||||
|
)}
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
{photo.camera_model && (
|
||||||
|
<Section label="Camera">
|
||||||
|
<Row k="Model" v={photo.camera_model} />
|
||||||
|
</Section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{photo.gps_lat != null && (
|
||||||
|
<Section label="Location">
|
||||||
|
<Row k="GPS" v={`${photo.gps_lat.toFixed(5)}, ${photo.gps_lon.toFixed(5)}`} />
|
||||||
|
<a
|
||||||
|
href={`https://maps.google.com/?q=${photo.gps_lat},${photo.gps_lon}`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
style={{ fontSize: 12, color: '#5a9', marginTop: 4, display: 'inline-block' }}
|
||||||
|
>
|
||||||
|
Open in Maps ↗
|
||||||
|
</a>
|
||||||
|
</Section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Section label="Tags">
|
||||||
|
<TagEditor photoId={photoId} initialTags={photo.tags ?? []} />
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section label="Collections">
|
||||||
|
<CollectionEditor photoId={photoId} initialCollections={photo.collections ?? []} />
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Section({ label, children }) {
|
||||||
|
return (
|
||||||
|
<div style={{ marginBottom: 24 }}>
|
||||||
|
<div style={{ fontSize: 10, fontWeight: 700, color: '#3a3a3a', textTransform: 'uppercase', letterSpacing: 1, marginBottom: 10 }}>
|
||||||
|
{label}
|
||||||
|
</div>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Row({ k, v, mono }) {
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', gap: 8, marginBottom: 5 }}>
|
||||||
|
<span style={{ color: '#555', flexShrink: 0, width: 60, fontSize: 12 }}>{k}</span>
|
||||||
|
<span style={{
|
||||||
|
color: '#aaa',
|
||||||
|
fontFamily: mono ? "'SF Mono', 'Fira Code', monospace" : undefined,
|
||||||
|
fontSize: mono ? 11 : 13,
|
||||||
|
wordBreak: 'break-all',
|
||||||
|
}}>
|
||||||
|
{v}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatBytes(b) {
|
||||||
|
if (b < 1024) return `${b} B`;
|
||||||
|
if (b < 1048576) return `${(b / 1024).toFixed(1)} KB`;
|
||||||
|
return `${(b / 1048576).toFixed(1)} MB`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const panelPad = { padding: '14px 16px' };
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
function fmtSize(b) {
|
||||||
|
if (!b) return null;
|
||||||
|
if (b < 1048576) return `${(b / 1024).toFixed(0)} KB`;
|
||||||
|
return `${(b / 1048576).toFixed(1)} MB`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtDate(d) {
|
||||||
|
if (!d) return 'Undated';
|
||||||
|
return new Date(d).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DuplicateGroup({ photos, onDismiss, onOpen }) {
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
background: '#111',
|
||||||
|
border: '1px solid #1d1d1d',
|
||||||
|
borderRadius: 8,
|
||||||
|
overflow: 'hidden',
|
||||||
|
}}>
|
||||||
|
{/* Photo row */}
|
||||||
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 1, background: '#0c0c0c' }}>
|
||||||
|
{photos.map((photo, i) => (
|
||||||
|
<div
|
||||||
|
key={photo.id}
|
||||||
|
style={{ flex: '1 1 200px', minWidth: 160, maxWidth: 320, display: 'flex', flexDirection: 'column' }}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
onClick={() => onOpen(photo)}
|
||||||
|
style={{
|
||||||
|
aspectRatio: '4/3',
|
||||||
|
cursor: 'pointer',
|
||||||
|
background: '#0c0c0c',
|
||||||
|
overflow: 'hidden',
|
||||||
|
position: 'relative',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{photo.thumb_url ? (
|
||||||
|
<img
|
||||||
|
src={photo.thumb_url}
|
||||||
|
alt=""
|
||||||
|
loading="lazy"
|
||||||
|
style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div style={{ width: '100%', height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#222', fontSize: 24 }}>▪</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Index badge */}
|
||||||
|
<span style={{
|
||||||
|
position: 'absolute', top: 6, left: 6,
|
||||||
|
background: 'rgba(0,0,0,0.6)', borderRadius: 4,
|
||||||
|
fontSize: 10, color: '#888', padding: '2px 5px',
|
||||||
|
}}>
|
||||||
|
{i + 1}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Meta */}
|
||||||
|
<div style={{ padding: '7px 10px 8px', fontSize: 12, color: '#555', display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||||
|
<span style={{ color: '#888' }}>{fmtDate(photo.date_taken)}</span>
|
||||||
|
<span>
|
||||||
|
{[fmtSize(photo.filesize), photo.width && `${photo.width}×${photo.height}`].filter(Boolean).join(' · ')}
|
||||||
|
</span>
|
||||||
|
{photo.camera_model && (
|
||||||
|
<span style={{ color: '#3a3a3a', fontSize: 11, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||||
|
{photo.camera_model}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div style={{
|
||||||
|
padding: '8px 14px',
|
||||||
|
borderTop: '1px solid #1a1a1a',
|
||||||
|
display: 'flex', alignItems: 'center', gap: 12,
|
||||||
|
}}>
|
||||||
|
<span style={{ fontSize: 12, color: '#333', flex: 1 }}>
|
||||||
|
{photos.length} similar photos
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={onDismiss}
|
||||||
|
style={{
|
||||||
|
background: 'transparent',
|
||||||
|
border: '1px solid #2a2a2a',
|
||||||
|
borderRadius: 5,
|
||||||
|
color: '#666',
|
||||||
|
cursor: 'pointer',
|
||||||
|
padding: '4px 12px',
|
||||||
|
fontSize: 12,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Not duplicates
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { fetchDuplicates, scanDuplicates, dismissGroup } from '../api/photos.js';
|
||||||
|
import DuplicateGroup from './DuplicateGroup.jsx';
|
||||||
|
|
||||||
|
export default function DuplicatesView({ onOpenPhoto, onGroupCountChange }) {
|
||||||
|
const [groups, setGroups] = useState(null);
|
||||||
|
const [scanning, setScanning] = useState(false);
|
||||||
|
const [scanResult, setScanResult] = useState(null);
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
|
||||||
|
useEffect(() => { load(); }, []);
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
try {
|
||||||
|
const data = await fetchDuplicates();
|
||||||
|
setGroups(data.groups);
|
||||||
|
onGroupCountChange?.(data.total);
|
||||||
|
} catch (e) {
|
||||||
|
setError(e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleScan() {
|
||||||
|
setScanning(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const result = await scanDuplicates();
|
||||||
|
setScanResult(result);
|
||||||
|
await load();
|
||||||
|
} catch (e) {
|
||||||
|
setError(e.message);
|
||||||
|
}
|
||||||
|
setScanning(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDismiss(photoIds) {
|
||||||
|
try {
|
||||||
|
await dismissGroup(photoIds);
|
||||||
|
setGroups(prev => {
|
||||||
|
const next = prev.filter(g => {
|
||||||
|
const s = new Set(g.map(p => p.id));
|
||||||
|
return !photoIds.every(id => s.has(id));
|
||||||
|
});
|
||||||
|
onGroupCountChange?.(next.length);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ padding: '24px 20px', maxWidth: 1100, margin: '0 auto' }}>
|
||||||
|
{/* Toolbar */}
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 14, marginBottom: 28, flexWrap: 'wrap' }}>
|
||||||
|
<h1 style={{ fontSize: 15, fontWeight: 600, color: '#999', margin: 0 }}>Similar photos</h1>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={handleScan}
|
||||||
|
disabled={scanning}
|
||||||
|
style={{
|
||||||
|
background: '#1a1a1a', border: '1px solid #2a2a2a', borderRadius: 6,
|
||||||
|
color: scanning ? '#444' : '#aaa', cursor: scanning ? 'default' : 'pointer',
|
||||||
|
padding: '5px 14px', fontSize: 13,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{scanning ? 'Scanning…' : 'Scan library'}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{scanResult && (
|
||||||
|
<span style={{ fontSize: 12, color: '#555' }}>
|
||||||
|
{scanResult.newPairs === 0
|
||||||
|
? 'No new pairs'
|
||||||
|
: `${scanResult.newPairs} new pair${scanResult.newPairs !== 1 ? 's' : ''} found`}
|
||||||
|
{' · '}{scanResult.photos.toLocaleString()} photos · {scanResult.duration}ms
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<p style={{ color: '#f87171', fontSize: 13, marginBottom: 16 }}>{error}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{groups === null && (
|
||||||
|
<p style={{ color: '#555', fontSize: 13 }}>Loading…</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{groups !== null && groups.length === 0 && (
|
||||||
|
<div style={{ textAlign: 'center', paddingTop: 60, color: '#555' }}>
|
||||||
|
<p style={{ fontSize: 15, marginBottom: 8 }}>No similar photos to review.</p>
|
||||||
|
<p style={{ fontSize: 13 }}>Click "Scan library" to compare perceptual hashes across your library.</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{groups !== null && groups.length > 0 && (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||||
|
<p style={{ fontSize: 12, color: '#444', marginBottom: 4 }}>
|
||||||
|
{groups.length} group{groups.length !== 1 ? 's' : ''} — click a photo to inspect it, "Not duplicates" to dismiss
|
||||||
|
</p>
|
||||||
|
{groups.map((group, i) => (
|
||||||
|
<DuplicateGroup
|
||||||
|
key={i}
|
||||||
|
photos={group}
|
||||||
|
onDismiss={() => handleDismiss(group.map(p => p.id))}
|
||||||
|
onOpen={(photo) => onOpenPhoto(photo, group)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
const EMPTY = { q: '', from: '', to: '', tagId: '', collectionId: '' };
|
||||||
|
|
||||||
|
function isActive(f) {
|
||||||
|
return f.q || f.from || f.to || f.tagId || f.collectionId;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function FilterBar({ filters, onChange, tags, collections }) {
|
||||||
|
const s = { // shared input style
|
||||||
|
background: '#111', border: '1px solid #222', borderRadius: 5,
|
||||||
|
color: '#ccc', fontSize: 12, padding: '4px 8px',
|
||||||
|
outline: 'none',
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
display: 'flex', alignItems: 'center', gap: 6,
|
||||||
|
padding: '6px 20px', borderBottom: '1px solid #141414',
|
||||||
|
background: '#0c0c0c', flexWrap: 'wrap',
|
||||||
|
}}>
|
||||||
|
<input
|
||||||
|
type="search"
|
||||||
|
placeholder="Search…"
|
||||||
|
value={filters.q}
|
||||||
|
onChange={e => onChange({ ...filters, q: e.target.value })}
|
||||||
|
style={{ ...s, width: 160 }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={filters.from}
|
||||||
|
onChange={e => onChange({ ...filters, from: e.target.value })}
|
||||||
|
style={{ ...s, colorScheme: 'dark' }}
|
||||||
|
title="From date"
|
||||||
|
/>
|
||||||
|
<span style={{ color: '#333', fontSize: 11 }}>–</span>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={filters.to}
|
||||||
|
onChange={e => onChange({ ...filters, to: e.target.value })}
|
||||||
|
style={{ ...s, colorScheme: 'dark' }}
|
||||||
|
title="To date"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{tags.length > 0 && (
|
||||||
|
<select
|
||||||
|
value={filters.tagId}
|
||||||
|
onChange={e => onChange({ ...filters, tagId: e.target.value })}
|
||||||
|
style={{ ...s }}
|
||||||
|
>
|
||||||
|
<option value="">All tags</option>
|
||||||
|
{tags.map(t => (
|
||||||
|
<option key={t.id} value={t.id}>{t.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{collections.length > 0 && (
|
||||||
|
<select
|
||||||
|
value={filters.collectionId}
|
||||||
|
onChange={e => onChange({ ...filters, collectionId: e.target.value })}
|
||||||
|
style={{ ...s }}
|
||||||
|
>
|
||||||
|
<option value="">All collections</option>
|
||||||
|
{collections.map(c => (
|
||||||
|
<option key={c.id} value={c.id}>{c.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isActive(filters) && (
|
||||||
|
<button
|
||||||
|
onClick={() => onChange(EMPTY)}
|
||||||
|
style={{
|
||||||
|
background: 'transparent', border: '1px solid #2a2a2a',
|
||||||
|
borderRadius: 5, color: '#555', cursor: 'pointer',
|
||||||
|
padding: '4px 10px', fontSize: 11,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Clear
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
import { useEffect, useState, useMemo } from 'react';
|
||||||
|
import { MapContainer, TileLayer, Marker, Popup } from 'react-leaflet';
|
||||||
|
import MarkerClusterGroup from 'react-leaflet-cluster';
|
||||||
|
import L from 'leaflet';
|
||||||
|
import 'leaflet/dist/leaflet.css';
|
||||||
|
import { fetchMapPhotos } from '../api/photos.js';
|
||||||
|
|
||||||
|
// Custom dot icon — avoids Leaflet's broken default asset URLs in Vite
|
||||||
|
const dotIcon = (color = '#4d9eff') =>
|
||||||
|
L.divIcon({
|
||||||
|
className: '',
|
||||||
|
html: `<div style="
|
||||||
|
width:12px;height:12px;border-radius:50%;
|
||||||
|
background:${color};border:2px solid rgba(255,255,255,0.85);
|
||||||
|
box-shadow:0 1px 4px rgba(0,0,0,0.5);
|
||||||
|
"></div>`,
|
||||||
|
iconSize: [12, 12],
|
||||||
|
iconAnchor: [6, 6],
|
||||||
|
popupAnchor: [0, -8],
|
||||||
|
});
|
||||||
|
|
||||||
|
const ICON = dotIcon();
|
||||||
|
|
||||||
|
function centroid(points) {
|
||||||
|
const lat = points.reduce((s, p) => s + p.lat, 0) / points.length;
|
||||||
|
const lon = points.reduce((s, p) => s + p.lon, 0) / points.length;
|
||||||
|
return [lat, lon];
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function MapView({ onOpenPhoto }) {
|
||||||
|
const [points, setPoints] = useState(null);
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchMapPhotos()
|
||||||
|
.then(setPoints)
|
||||||
|
.catch(e => setError(e.message));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const center = useMemo(() => {
|
||||||
|
if (!points?.length) return [20, 0];
|
||||||
|
return centroid(points);
|
||||||
|
}, [points]);
|
||||||
|
|
||||||
|
if (error) return (
|
||||||
|
<p style={{ color: '#f87171', padding: 24, fontSize: 13 }}>{error}</p>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (points === null) return (
|
||||||
|
<p style={{ color: '#555', padding: 24, fontSize: 13 }}>Loading map…</p>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (points.length === 0) return (
|
||||||
|
<div style={{ textAlign: 'center', paddingTop: 60, color: '#555' }}>
|
||||||
|
<p style={{ fontSize: 15, marginBottom: 8 }}>No geotagged photos.</p>
|
||||||
|
<p style={{ fontSize: 13 }}>Photos with GPS metadata will appear here once indexed.</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ height: 'calc(100vh - 52px)' }}>
|
||||||
|
<MapContainer
|
||||||
|
center={center}
|
||||||
|
zoom={4}
|
||||||
|
style={{ height: '100%', width: '100%', background: '#1a1a1a' }}
|
||||||
|
>
|
||||||
|
<TileLayer
|
||||||
|
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
||||||
|
attribution='© <a href="https://www.openstreetmap.org/copyright" target="_blank">OpenStreetMap</a>'
|
||||||
|
maxZoom={19}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<MarkerClusterGroup chunkedLoading>
|
||||||
|
{points.map(p => (
|
||||||
|
<Marker key={p.id} position={[p.lat, p.lon]} icon={ICON}>
|
||||||
|
<Popup maxWidth={220} className="luma-popup">
|
||||||
|
<div style={{ margin: 0, padding: 0 }}>
|
||||||
|
{p.thumb_url && (
|
||||||
|
<img
|
||||||
|
src={p.thumb_url}
|
||||||
|
alt=""
|
||||||
|
onClick={() => onOpenPhoto(p)}
|
||||||
|
style={{
|
||||||
|
width: '100%', maxWidth: 200, display: 'block',
|
||||||
|
cursor: 'pointer', borderRadius: 3,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{p.date_taken && (
|
||||||
|
<div style={{ fontSize: 11, color: '#888', marginTop: 4, textAlign: 'center' }}>
|
||||||
|
{new Date(p.date_taken).toLocaleDateString('en-US', {
|
||||||
|
month: 'short', day: 'numeric', year: 'numeric',
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Popup>
|
||||||
|
</Marker>
|
||||||
|
))}
|
||||||
|
</MarkerClusterGroup>
|
||||||
|
</MapContainer>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import PhotoCard from './PhotoCard.jsx';
|
||||||
|
|
||||||
|
export default function MonthGroup({ label, photos, startIdx, onSelect }) {
|
||||||
|
return (
|
||||||
|
<section style={{ marginBottom: 8 }}>
|
||||||
|
<h2 style={{
|
||||||
|
padding: '18px 6px 8px',
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: 600,
|
||||||
|
color: '#666',
|
||||||
|
letterSpacing: 0.4,
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
userSelect: 'none',
|
||||||
|
}}>
|
||||||
|
{label}
|
||||||
|
<span style={{ marginLeft: 8, color: '#3a3a3a', fontWeight: 400 }}>
|
||||||
|
{photos.length.toLocaleString()}
|
||||||
|
</span>
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div style={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: 'repeat(auto-fill, minmax(160px, 1fr))',
|
||||||
|
gap: 3,
|
||||||
|
}}>
|
||||||
|
{photos.map((photo, i) => (
|
||||||
|
<PhotoCard
|
||||||
|
key={photo.id}
|
||||||
|
photo={photo}
|
||||||
|
onClick={() => onSelect(startIdx + i)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
export default function PhotoCard({ photo, onClick }) {
|
||||||
|
const [loaded, setLoaded] = useState(false);
|
||||||
|
const [error, setError] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
onClick={onClick}
|
||||||
|
onKeyDown={e => e.key === 'Enter' && onClick()}
|
||||||
|
style={{
|
||||||
|
aspectRatio: '1',
|
||||||
|
background: '#181818',
|
||||||
|
cursor: 'pointer',
|
||||||
|
overflow: 'hidden',
|
||||||
|
position: 'relative',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{photo.thumb_url && !error ? (
|
||||||
|
<img
|
||||||
|
src={photo.thumb_url}
|
||||||
|
alt=""
|
||||||
|
loading="lazy"
|
||||||
|
decoding="async"
|
||||||
|
onLoad={() => setLoaded(true)}
|
||||||
|
onError={() => setError(true)}
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
objectFit: 'cover',
|
||||||
|
display: 'block',
|
||||||
|
opacity: loaded ? 1 : 0,
|
||||||
|
transition: 'opacity 0.15s ease',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div style={{
|
||||||
|
width: '100%', height: '100%',
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
color: '#2a2a2a', fontSize: 20,
|
||||||
|
}}>
|
||||||
|
▪
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
import { useState, useRef } from 'react';
|
||||||
|
import { addTag, removeTag } from '../api/photos.js';
|
||||||
|
|
||||||
|
export default function TagEditor({ photoId, initialTags }) {
|
||||||
|
const [tags, setTags] = useState(initialTags);
|
||||||
|
const [input, setInput] = useState('');
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const inputRef = useRef(null);
|
||||||
|
|
||||||
|
async function handleAdd(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
const name = input.trim();
|
||||||
|
if (!name || busy) return;
|
||||||
|
if (tags.some(t => t.name === name)) { setInput(''); return; }
|
||||||
|
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
const tag = await addTag(photoId, name);
|
||||||
|
setTags(prev => [...prev, tag].sort((a, b) => a.name.localeCompare(b.name)));
|
||||||
|
setInput('');
|
||||||
|
} catch {}
|
||||||
|
setBusy(false);
|
||||||
|
inputRef.current?.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleRemove(tagId) {
|
||||||
|
setTags(prev => prev.filter(t => t.id !== tagId)); // optimistic
|
||||||
|
try {
|
||||||
|
await removeTag(photoId, tagId);
|
||||||
|
} catch {
|
||||||
|
// On failure re-fetch would be ideal; for now the optimistic update stays
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{tags.length > 0 && (
|
||||||
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 5, marginBottom: 10 }}>
|
||||||
|
{tags.map(t => (
|
||||||
|
<Chip key={t.id} label={t.name} color="neutral" onRemove={() => handleRemove(t.id)} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<form onSubmit={handleAdd} style={{ display: 'flex', gap: 5 }}>
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
value={input}
|
||||||
|
onChange={e => setInput(e.target.value)}
|
||||||
|
onKeyDown={e => e.key === 'Enter' && handleAdd(e)}
|
||||||
|
placeholder="Add tag…"
|
||||||
|
disabled={busy}
|
||||||
|
maxLength={100}
|
||||||
|
style={iStyle}
|
||||||
|
/>
|
||||||
|
<Btn type="submit" disabled={!input.trim() || busy}>+</Btn>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── shared with CollectionEditor ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function Chip({ label, onRemove, color = 'neutral' }) {
|
||||||
|
const colors = {
|
||||||
|
neutral: { bg: '#222', border: '#333', text: '#ccc' },
|
||||||
|
green: { bg: '#1a2a1a', border: '#2a3a2a', text: '#8c8' },
|
||||||
|
};
|
||||||
|
const c = colors[color] ?? colors.neutral;
|
||||||
|
return (
|
||||||
|
<span style={{
|
||||||
|
display: 'inline-flex', alignItems: 'center', gap: 4,
|
||||||
|
background: c.bg, border: `1px solid ${c.border}`,
|
||||||
|
borderRadius: 4, padding: '3px 7px',
|
||||||
|
fontSize: 12, color: c.text,
|
||||||
|
}}>
|
||||||
|
{label}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onRemove}
|
||||||
|
style={{ background: 'none', border: 'none', color: '#555', cursor: 'pointer', fontSize: 15, lineHeight: 1, padding: 0, marginLeft: 1 }}
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const iStyle = {
|
||||||
|
flex: 1,
|
||||||
|
background: '#181818', border: '1px solid #2a2a2a', borderRadius: 4,
|
||||||
|
padding: '5px 8px', color: '#ccc', fontSize: 12, outline: 'none',
|
||||||
|
minWidth: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function Btn({ children, style: overrides, disabled, ...props }) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
disabled={disabled}
|
||||||
|
{...props}
|
||||||
|
style={{
|
||||||
|
background: '#222', border: '1px solid #333', borderRadius: 4,
|
||||||
|
color: '#999', fontSize: 16, cursor: 'pointer',
|
||||||
|
width: 30, height: 30, flexShrink: 0,
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
opacity: disabled ? 0.4 : 1,
|
||||||
|
...overrides,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
*, *::before, *::after {
|
||||||
|
box-sizing: border-box;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
background: #0f0f0f;
|
||||||
|
color: #e0e0e0;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
|
||||||
|
min-height: 100dvh;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
}
|
||||||
|
|
||||||
|
#root {
|
||||||
|
min-height: 100dvh;
|
||||||
|
}
|
||||||
|
|
||||||
|
code {
|
||||||
|
font-family: 'SF Mono', 'Fira Code', monospace;
|
||||||
|
background: #1e1e1e;
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.9em;
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { StrictMode } from 'react';
|
||||||
|
import { createRoot } from 'react-dom/client';
|
||||||
|
import './index.css';
|
||||||
|
import App from './App.jsx';
|
||||||
|
|
||||||
|
createRoot(document.getElementById('root')).render(
|
||||||
|
<StrictMode>
|
||||||
|
<App />
|
||||||
|
</StrictMode>
|
||||||
|
);
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
services:
|
||||||
|
|
||||||
|
server:
|
||||||
|
build: .
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "3000:3000"
|
||||||
|
environment:
|
||||||
|
PORT: "3000"
|
||||||
|
DB_PATH: /data/library.db
|
||||||
|
POSTER_DIR: /data/posters
|
||||||
|
NODE_ENV: production
|
||||||
|
volumes:
|
||||||
|
- luma_data:/data
|
||||||
|
- /your/photo/directory:/photos:ro # ← change this path
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "wget", "-qO-", "http://localhost:3000/api/health"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
|
start_period: 10s
|
||||||
|
|
||||||
|
worker:
|
||||||
|
build: .
|
||||||
|
restart: unless-stopped
|
||||||
|
command: ["node", "worker/index.js"]
|
||||||
|
environment:
|
||||||
|
DB_PATH: /data/library.db
|
||||||
|
POSTER_DIR: /data/posters
|
||||||
|
PHOTO_DIR: /photos
|
||||||
|
volumes:
|
||||||
|
- luma_data:/data
|
||||||
|
- /your/photo/directory:/photos:ro # ← same path as server
|
||||||
|
depends_on:
|
||||||
|
server:
|
||||||
|
condition: service_healthy
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
luma_data:
|
||||||
Generated
+2618
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,32 @@
|
|||||||
|
{
|
||||||
|
"name": "luma",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"description": "Luma — self-hosted photo manager",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"worker": "node worker/index.js",
|
||||||
|
"server": "node server/index.js",
|
||||||
|
"dev:worker": "node --watch worker/index.js",
|
||||||
|
"dev:server": "node --watch server/index.js",
|
||||||
|
"dev:client": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"better-sqlite3": "^13.0.3",
|
||||||
|
"chokidar": "^3.6.0",
|
||||||
|
"dotenv": "^16.4.5",
|
||||||
|
"exifr": "^7.1.3",
|
||||||
|
"express": "^4.19.2",
|
||||||
|
"leaflet": "^1.9.4",
|
||||||
|
"react": "^18.3.1",
|
||||||
|
"react-dom": "^18.3.1",
|
||||||
|
"react-leaflet": "^4.2.1",
|
||||||
|
"react-leaflet-cluster": "^2.1.0",
|
||||||
|
"sharp": "^0.35.3"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@vitejs/plugin-react": "^6.0.5",
|
||||||
|
"vite": "^8.2.1"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import { hammingDistance } from '../worker/hash.js';
|
||||||
|
|
||||||
|
const THRESHOLD = parseInt(process.env.DEDUPE_THRESHOLD ?? '10', 10);
|
||||||
|
|
||||||
|
// Run pairwise pHash comparison and store new similar pairs.
|
||||||
|
export function scan(db) {
|
||||||
|
const start = Date.now();
|
||||||
|
|
||||||
|
const photos = db.raw.prepare(
|
||||||
|
"SELECT id, phash FROM photos WHERE phash IS NOT NULL AND phash != '0000000000000000'"
|
||||||
|
).all();
|
||||||
|
|
||||||
|
const insert = db.raw.prepare(
|
||||||
|
'INSERT OR IGNORE INTO duplicate_pairs (photo_id_a, photo_id_b, distance) VALUES (?, ?, ?)'
|
||||||
|
);
|
||||||
|
|
||||||
|
let newPairs = 0;
|
||||||
|
db.raw.transaction(() => {
|
||||||
|
for (let i = 0; i < photos.length; i++) {
|
||||||
|
for (let j = i + 1; j < photos.length; j++) {
|
||||||
|
const dist = hammingDistance(photos[i].phash, photos[j].phash);
|
||||||
|
if (dist <= THRESHOLD) {
|
||||||
|
const r = insert.run(photos[i].id, photos[j].id, dist);
|
||||||
|
if (r.changes) newPairs++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
return { photos: photos.length, newPairs, duration: Date.now() - start };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build connected-component groups from non-dismissed pairs.
|
||||||
|
// Returns an array of photo-row arrays, largest groups first.
|
||||||
|
export function getGroups(db) {
|
||||||
|
const pairs = db.getDupePairs();
|
||||||
|
if (!pairs.length) return [];
|
||||||
|
|
||||||
|
// Union-Find with path compression
|
||||||
|
const parent = new Map();
|
||||||
|
|
||||||
|
function find(x) {
|
||||||
|
if (!parent.has(x)) parent.set(x, x);
|
||||||
|
if (parent.get(x) !== x) parent.set(x, find(parent.get(x)));
|
||||||
|
return parent.get(x);
|
||||||
|
}
|
||||||
|
|
||||||
|
function union(a, b) {
|
||||||
|
parent.set(find(a), find(b));
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const { photo_id_a, photo_id_b } of pairs) {
|
||||||
|
union(photo_id_a, photo_id_b);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normalize roots (path compression may have left stale entries)
|
||||||
|
for (const id of [...parent.keys()]) find(id);
|
||||||
|
|
||||||
|
// Group IDs by root
|
||||||
|
const groups = new Map();
|
||||||
|
for (const id of parent.keys()) {
|
||||||
|
const root = find(id);
|
||||||
|
if (!groups.has(root)) groups.set(root, []);
|
||||||
|
groups.get(root).push(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch photo details for all IDs
|
||||||
|
const allIds = [...parent.keys()];
|
||||||
|
const rows = db.raw.prepare(
|
||||||
|
`SELECT id, thumb_path, preview_path, date_taken, filesize, width, height, camera_model
|
||||||
|
FROM photos WHERE id IN (${allIds.map(() => '?').join(',')})`
|
||||||
|
).all(allIds);
|
||||||
|
|
||||||
|
const photoMap = new Map(rows.map(r => [r.id, r]));
|
||||||
|
|
||||||
|
return [...groups.values()]
|
||||||
|
.filter(ids => ids.length >= 2)
|
||||||
|
.map(ids => ids.map(id => photoMap.get(id)).filter(Boolean))
|
||||||
|
.sort((a, b) => b.length - a.length);
|
||||||
|
}
|
||||||
+248
@@ -0,0 +1,248 @@
|
|||||||
|
import 'dotenv/config';
|
||||||
|
import express from 'express';
|
||||||
|
import path from 'path';
|
||||||
|
import { fileURLToPath } from 'url';
|
||||||
|
import { openDb } from '../worker/db.js';
|
||||||
|
import { scan, getGroups } from './dedupe.js';
|
||||||
|
|
||||||
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
const PORT = parseInt(process.env.PORT ?? '3000', 10);
|
||||||
|
const DB_PATH = process.env.DB_PATH ?? './data/library.db';
|
||||||
|
const POSTER_DIR = process.env.POSTER_DIR ?? './data/posters';
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
const db = openDb(DB_PATH);
|
||||||
|
const posterDirAbs = path.resolve(POSTER_DIR);
|
||||||
|
|
||||||
|
app.use(express.json());
|
||||||
|
|
||||||
|
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function posterUrl(storedPath) {
|
||||||
|
if (!storedPath) return null;
|
||||||
|
const rel = path.relative(posterDirAbs, path.resolve(storedPath));
|
||||||
|
if (rel.startsWith('..')) return null;
|
||||||
|
return '/posters/' + rel.replace(/\\/g, '/');
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapPhoto(p) {
|
||||||
|
return {
|
||||||
|
id: p.id,
|
||||||
|
date_taken: p.date_taken,
|
||||||
|
date_imported: p.date_imported,
|
||||||
|
camera_model: p.camera_model,
|
||||||
|
gps_lat: p.gps_lat,
|
||||||
|
gps_lon: p.gps_lon,
|
||||||
|
width: p.width,
|
||||||
|
height: p.height,
|
||||||
|
filesize: p.filesize,
|
||||||
|
thumb_url: posterUrl(p.thumb_path),
|
||||||
|
preview_url: posterUrl(p.preview_path),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function badRequest(res, msg) { res.status(400).json({ error: msg }); }
|
||||||
|
function notFound(res) { res.status(404).json({ error: 'not found' }); }
|
||||||
|
|
||||||
|
// Build SQL parts for the photo list query based on active filters.
|
||||||
|
// Returns { joinSql, whereSql, params } where params covers joins then where clauses.
|
||||||
|
function buildFilter(q, from, to, tagId, collectionId) {
|
||||||
|
const joinParts = [];
|
||||||
|
const joinParams = [];
|
||||||
|
const condParts = [];
|
||||||
|
const condParams = [];
|
||||||
|
|
||||||
|
if (tagId) {
|
||||||
|
joinParts.push('JOIN photo_tags _pt ON _pt.photo_id = p.id AND _pt.tag_id = ?');
|
||||||
|
joinParams.push(parseInt(tagId, 10));
|
||||||
|
}
|
||||||
|
if (collectionId) {
|
||||||
|
joinParts.push('JOIN photo_collections _pc ON _pc.photo_id = p.id AND _pc.collection_id = ?');
|
||||||
|
joinParams.push(parseInt(collectionId, 10));
|
||||||
|
}
|
||||||
|
if (q) {
|
||||||
|
condParts.push('(p.filepath LIKE ? OR p.camera_model LIKE ?)');
|
||||||
|
condParams.push(`%${q}%`, `%${q}%`);
|
||||||
|
}
|
||||||
|
if (from) { condParts.push('p.date_taken >= ?'); condParams.push(from); }
|
||||||
|
if (to) { condParts.push('p.date_taken <= ?'); condParams.push(to); }
|
||||||
|
|
||||||
|
return {
|
||||||
|
joinSql: joinParts.join(' '),
|
||||||
|
whereSql: condParts.length ? `WHERE ${condParts.join(' AND ')}` : '',
|
||||||
|
params: [...joinParams, ...condParams],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Static ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
app.use('/posters', express.static(posterDirAbs));
|
||||||
|
|
||||||
|
// ── Photos ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
app.get('/api/health', (_req, res) => {
|
||||||
|
res.json({ ok: true, photos: db.count() });
|
||||||
|
});
|
||||||
|
|
||||||
|
const SELECT_COLS = `
|
||||||
|
p.id, p.thumb_path, p.preview_path, p.date_taken, p.date_imported,
|
||||||
|
p.camera_model, p.gps_lat, p.gps_lon, p.width, p.height, p.filesize
|
||||||
|
`;
|
||||||
|
|
||||||
|
app.get('/api/photos', (req, res) => {
|
||||||
|
const limit = Math.min(parseInt(req.query.limit ?? '100', 10), 500);
|
||||||
|
const cursor = req.query.cursor ?? null;
|
||||||
|
const { q, from, to, tagId, collectionId } = req.query;
|
||||||
|
|
||||||
|
const { joinSql, whereSql, params } = buildFilter(q, from, to, tagId, collectionId);
|
||||||
|
|
||||||
|
// Cursor condition — handles null dates correctly (NULLs sort last in DESC).
|
||||||
|
// When cursor_date is a real date: include photos before it AND all null-dated ones.
|
||||||
|
// When cursor_date is empty (we're in the null section): only null-dated with lower id.
|
||||||
|
let cursorSql = '';
|
||||||
|
const cursorParams = [];
|
||||||
|
if (cursor) {
|
||||||
|
const pipeAt = cursor.lastIndexOf('|');
|
||||||
|
const cursorDate = cursor.slice(0, pipeAt);
|
||||||
|
const cursorId = parseInt(cursor.slice(pipeAt + 1), 10);
|
||||||
|
if (cursorDate === '') {
|
||||||
|
cursorSql = whereSql
|
||||||
|
? 'AND (p.date_taken IS NULL AND p.id < ?)'
|
||||||
|
: 'WHERE (p.date_taken IS NULL AND p.id < ?)';
|
||||||
|
cursorParams.push(cursorId);
|
||||||
|
} else {
|
||||||
|
const clause = '(p.date_taken < ? OR (p.date_taken = ? AND p.id < ?) OR p.date_taken IS NULL)';
|
||||||
|
cursorSql = whereSql ? `AND ${clause}` : `WHERE ${clause}`;
|
||||||
|
cursorParams.push(cursorDate, cursorDate, cursorId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const allParams = [...params, ...cursorParams, limit + 1];
|
||||||
|
|
||||||
|
const sql = `SELECT ${SELECT_COLS} FROM photos p ${joinSql} ${whereSql} ${cursorSql}
|
||||||
|
ORDER BY p.date_taken DESC, p.id DESC LIMIT ?`;
|
||||||
|
const rows = db.raw.prepare(sql).all(allParams);
|
||||||
|
|
||||||
|
const countSql = `SELECT COUNT(*) AS n FROM photos p ${joinSql} ${whereSql}`;
|
||||||
|
const total = db.raw.prepare(countSql).get(params).n;
|
||||||
|
|
||||||
|
const hasMore = rows.length > limit;
|
||||||
|
const page = rows.slice(0, limit);
|
||||||
|
const last = page[page.length - 1];
|
||||||
|
const nextCursor = hasMore && last ? `${last.date_taken ?? ''}|${last.id}` : null;
|
||||||
|
|
||||||
|
res.json({ photos: page.map(mapPhoto), nextCursor, total });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/photos/map', (_req, res) => {
|
||||||
|
const rows = db.raw.prepare(`
|
||||||
|
SELECT id, gps_lat, gps_lon, thumb_path, preview_path, date_taken
|
||||||
|
FROM photos WHERE gps_lat IS NOT NULL AND gps_lon IS NOT NULL
|
||||||
|
`).all();
|
||||||
|
res.json(rows.map(r => ({
|
||||||
|
id: r.id,
|
||||||
|
lat: r.gps_lat,
|
||||||
|
lon: r.gps_lon,
|
||||||
|
date_taken: r.date_taken,
|
||||||
|
thumb_url: posterUrl(r.thumb_path),
|
||||||
|
preview_url: posterUrl(r.preview_path),
|
||||||
|
})));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/photos/:id', (req, res) => {
|
||||||
|
const row = db.raw.prepare('SELECT * FROM photos WHERE id = ?').get(req.params.id);
|
||||||
|
if (!row) return notFound(res);
|
||||||
|
res.json({
|
||||||
|
...mapPhoto(row),
|
||||||
|
filepath: row.filepath,
|
||||||
|
phash: row.phash,
|
||||||
|
tags: db.getPhotoTags(row.id),
|
||||||
|
collections: db.getPhotoCollections(row.id),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Tags ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
app.get('/api/tags', (_req, res) => res.json(db.listAllTags()));
|
||||||
|
|
||||||
|
app.post('/api/photos/:id/tags', (req, res) => {
|
||||||
|
const photoId = parseInt(req.params.id, 10);
|
||||||
|
const name = (req.body.name ?? '').trim().slice(0, 100);
|
||||||
|
if (!name) return badRequest(res, 'name required');
|
||||||
|
res.status(201).json(db.addTagToPhoto(photoId, name));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.delete('/api/photos/:id/tags/:tagId', (req, res) => {
|
||||||
|
db.removeTagFromPhoto(parseInt(req.params.id, 10), parseInt(req.params.tagId, 10));
|
||||||
|
res.sendStatus(204);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Collections ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
app.get('/api/collections', (_req, res) => res.json(db.listCollections()));
|
||||||
|
|
||||||
|
app.post('/api/collections', (req, res) => {
|
||||||
|
const name = (req.body.name ?? '').trim().slice(0, 200);
|
||||||
|
if (!name) return badRequest(res, 'name required');
|
||||||
|
try {
|
||||||
|
res.status(201).json(db.createCollection(name, req.body.description));
|
||||||
|
} catch (e) {
|
||||||
|
if (e.message.includes('UNIQUE')) return badRequest(res, 'collection already exists');
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/photos/:id/collections', (req, res) => {
|
||||||
|
const colId = parseInt(req.body.collectionId, 10);
|
||||||
|
if (!colId) return badRequest(res, 'collectionId required');
|
||||||
|
db.addPhotoToCollection(parseInt(req.params.id, 10), colId);
|
||||||
|
res.sendStatus(204);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.delete('/api/photos/:id/collections/:colId', (req, res) => {
|
||||||
|
db.removePhotoFromCollection(parseInt(req.params.id, 10), parseInt(req.params.colId, 10));
|
||||||
|
res.sendStatus(204);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Duplicates ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
app.post('/api/dedupe/scan', (_req, res) => {
|
||||||
|
try { res.json(scan(db)); }
|
||||||
|
catch (e) { res.status(500).json({ error: e.message }); }
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/duplicates', (_req, res) => {
|
||||||
|
const rawGroups = getGroups(db);
|
||||||
|
const groups = rawGroups.map(group =>
|
||||||
|
group.map(p => ({
|
||||||
|
id: p.id,
|
||||||
|
date_taken: p.date_taken,
|
||||||
|
filesize: p.filesize,
|
||||||
|
width: p.width,
|
||||||
|
height: p.height,
|
||||||
|
camera_model: p.camera_model,
|
||||||
|
thumb_url: posterUrl(p.thumb_path),
|
||||||
|
preview_url: posterUrl(p.preview_path),
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
res.json({ groups, total: groups.length });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/duplicates/dismiss', (req, res) => {
|
||||||
|
const ids = req.body.photoIds;
|
||||||
|
if (!Array.isArray(ids) || ids.length < 2) return badRequest(res, 'photoIds must be array of ≥2');
|
||||||
|
db.dismissDuplicates(ids.map(Number));
|
||||||
|
res.sendStatus(204);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── SPA fallback ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const distPath = path.resolve(__dirname, '../dist');
|
||||||
|
app.use(express.static(distPath));
|
||||||
|
app.get(/^(?!\/api|\/posters)/, (_req, res) => {
|
||||||
|
res.sendFile(path.join(distPath, 'index.html'));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.listen(PORT, () => {
|
||||||
|
console.log(`[server] http://localhost:${PORT} (${db.count()} photos)`);
|
||||||
|
});
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { defineConfig } from 'vite';
|
||||||
|
import react from '@vitejs/plugin-react';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
root: 'client',
|
||||||
|
plugins: [react()],
|
||||||
|
server: {
|
||||||
|
port: 5173,
|
||||||
|
proxy: {
|
||||||
|
'/api': 'http://localhost:3000',
|
||||||
|
'/posters': 'http://localhost:3000',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
build: {
|
||||||
|
outDir: '../dist',
|
||||||
|
emptyOutDir: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
+161
@@ -0,0 +1,161 @@
|
|||||||
|
import Database from 'better-sqlite3';
|
||||||
|
import path from 'path';
|
||||||
|
import fs from 'fs';
|
||||||
|
|
||||||
|
export function openDb(dbPath) {
|
||||||
|
fs.mkdirSync(path.dirname(path.resolve(dbPath)), { recursive: true });
|
||||||
|
|
||||||
|
const db = new Database(dbPath);
|
||||||
|
db.pragma('journal_mode = WAL');
|
||||||
|
db.pragma('foreign_keys = ON');
|
||||||
|
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS photos (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
filepath TEXT NOT NULL UNIQUE,
|
||||||
|
thumb_path TEXT,
|
||||||
|
preview_path TEXT,
|
||||||
|
date_taken TEXT,
|
||||||
|
date_imported TEXT NOT NULL,
|
||||||
|
camera_model TEXT,
|
||||||
|
gps_lat REAL,
|
||||||
|
gps_lon REAL,
|
||||||
|
phash TEXT,
|
||||||
|
width INTEGER,
|
||||||
|
height INTEGER,
|
||||||
|
filesize INTEGER
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_photos_date_taken ON photos(date_taken);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_photos_phash ON photos(phash);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS collections (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
name TEXT NOT NULL UNIQUE,
|
||||||
|
description TEXT,
|
||||||
|
poster_path TEXT,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS photo_collections (
|
||||||
|
photo_id INTEGER NOT NULL REFERENCES photos(id) ON DELETE CASCADE,
|
||||||
|
collection_id INTEGER NOT NULL REFERENCES collections(id) ON DELETE CASCADE,
|
||||||
|
PRIMARY KEY (photo_id, collection_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS tags (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
name TEXT NOT NULL UNIQUE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS photo_tags (
|
||||||
|
photo_id INTEGER NOT NULL REFERENCES photos(id) ON DELETE CASCADE,
|
||||||
|
tag_id INTEGER NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
|
||||||
|
PRIMARY KEY (photo_id, tag_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS duplicate_pairs (
|
||||||
|
photo_id_a INTEGER NOT NULL REFERENCES photos(id) ON DELETE CASCADE,
|
||||||
|
photo_id_b INTEGER NOT NULL REFERENCES photos(id) ON DELETE CASCADE,
|
||||||
|
distance INTEGER NOT NULL,
|
||||||
|
dismissed INTEGER NOT NULL DEFAULT 0,
|
||||||
|
PRIMARY KEY (photo_id_a, photo_id_b),
|
||||||
|
CHECK (photo_id_a < photo_id_b)
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
|
||||||
|
const s = {
|
||||||
|
// Photos
|
||||||
|
insert: db.prepare(`
|
||||||
|
INSERT OR IGNORE INTO photos
|
||||||
|
(filepath, thumb_path, preview_path, date_taken, date_imported,
|
||||||
|
camera_model, gps_lat, gps_lon, phash, width, height, filesize)
|
||||||
|
VALUES
|
||||||
|
(@filepath, @thumb_path, @preview_path, @date_taken, @date_imported,
|
||||||
|
@camera_model, @gps_lat, @gps_lon, @phash, @width, @height, @filesize)
|
||||||
|
`),
|
||||||
|
exists: db.prepare('SELECT 1 FROM photos WHERE filepath = ? LIMIT 1'),
|
||||||
|
remove: db.prepare('DELETE FROM photos WHERE filepath = ?'),
|
||||||
|
count: db.prepare('SELECT COUNT(*) AS n FROM photos'),
|
||||||
|
|
||||||
|
// Tags
|
||||||
|
tagByName: db.prepare('SELECT id, name FROM tags WHERE name = ?'),
|
||||||
|
tagInsert: db.prepare('INSERT OR IGNORE INTO tags (name) VALUES (?)'),
|
||||||
|
tagList: db.prepare(`
|
||||||
|
SELECT t.id, t.name, COUNT(pt.photo_id) AS count
|
||||||
|
FROM tags t LEFT JOIN photo_tags pt ON pt.tag_id = t.id
|
||||||
|
GROUP BY t.id ORDER BY t.name
|
||||||
|
`),
|
||||||
|
tagOfPhoto: db.prepare(
|
||||||
|
'SELECT t.id, t.name FROM tags t JOIN photo_tags pt ON pt.tag_id = t.id WHERE pt.photo_id = ? ORDER BY t.name'
|
||||||
|
),
|
||||||
|
tagAttach: db.prepare('INSERT OR IGNORE INTO photo_tags (photo_id, tag_id) VALUES (?, ?)'),
|
||||||
|
tagDetach: db.prepare('DELETE FROM photo_tags WHERE photo_id = ? AND tag_id = ?'),
|
||||||
|
|
||||||
|
// Collections
|
||||||
|
colList: db.prepare(`
|
||||||
|
SELECT c.id, c.name, c.description, c.created_at,
|
||||||
|
COUNT(pc.photo_id) AS photo_count
|
||||||
|
FROM collections c
|
||||||
|
LEFT JOIN photo_collections pc ON pc.collection_id = c.id
|
||||||
|
GROUP BY c.id ORDER BY c.name
|
||||||
|
`),
|
||||||
|
colInsert: db.prepare('INSERT INTO collections (name, description) VALUES (?, ?)'),
|
||||||
|
colOfPhoto: db.prepare(
|
||||||
|
'SELECT c.id, c.name FROM collections c JOIN photo_collections pc ON pc.collection_id = c.id WHERE pc.photo_id = ? ORDER BY c.name'
|
||||||
|
),
|
||||||
|
colAdd: db.prepare('INSERT OR IGNORE INTO photo_collections (photo_id, collection_id) VALUES (?, ?)'),
|
||||||
|
colRemove: db.prepare('DELETE FROM photo_collections WHERE photo_id = ? AND collection_id = ?'),
|
||||||
|
|
||||||
|
// Duplicates
|
||||||
|
dupePairs: db.prepare(
|
||||||
|
'SELECT photo_id_a, photo_id_b, distance FROM duplicate_pairs WHERE dismissed = 0'
|
||||||
|
),
|
||||||
|
dupePairCount: db.prepare(
|
||||||
|
'SELECT COUNT(*) AS n FROM duplicate_pairs WHERE dismissed = 0'
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
const addTagTx = db.transaction((photoId, tagName) => {
|
||||||
|
s.tagInsert.run(tagName);
|
||||||
|
const tag = s.tagByName.get(tagName);
|
||||||
|
s.tagAttach.run(photoId, tag.id);
|
||||||
|
return tag;
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
raw: db,
|
||||||
|
|
||||||
|
// Photos
|
||||||
|
insertPhoto: (row) => s.insert.run(row),
|
||||||
|
hasPhoto: (fp) => !!s.exists.get(fp),
|
||||||
|
removePhoto: (fp) => s.remove.run(fp),
|
||||||
|
count: () => s.count.get().n,
|
||||||
|
|
||||||
|
// Tags
|
||||||
|
addTagToPhoto: (photoId, tagName) => addTagTx(photoId, tagName),
|
||||||
|
removeTagFromPhoto: (photoId, tagId) => s.tagDetach.run(photoId, tagId),
|
||||||
|
getPhotoTags: (photoId) => s.tagOfPhoto.all(photoId),
|
||||||
|
listAllTags: () => s.tagList.all(),
|
||||||
|
|
||||||
|
// Collections
|
||||||
|
listCollections: () => s.colList.all(),
|
||||||
|
createCollection: (name, desc) => {
|
||||||
|
const info = s.colInsert.run(name, desc ?? null);
|
||||||
|
return { id: info.lastInsertRowid, name, description: desc ?? null };
|
||||||
|
},
|
||||||
|
addPhotoToCollection: (photoId, colId) => s.colAdd.run(photoId, colId),
|
||||||
|
removePhotoFromCollection:(photoId, colId) => s.colRemove.run(photoId, colId),
|
||||||
|
getPhotoCollections: (photoId) => s.colOfPhoto.all(photoId),
|
||||||
|
|
||||||
|
// Duplicates
|
||||||
|
getDupePairs: () => s.dupePairs.all(),
|
||||||
|
dismissDuplicates: (ids) => {
|
||||||
|
const ph = ids.map(() => '?').join(',');
|
||||||
|
db.prepare(
|
||||||
|
`UPDATE duplicate_pairs SET dismissed = 1
|
||||||
|
WHERE photo_id_a IN (${ph}) AND photo_id_b IN (${ph})`
|
||||||
|
).run(...ids, ...ids);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import sharp from 'sharp';
|
||||||
|
|
||||||
|
// dHash (difference hash): 9×8 → 64-bit comparison → 16-char hex string.
|
||||||
|
// Two images with Hamming distance ≤ 10 are near-duplicates.
|
||||||
|
export async function computeDHash(filepath) {
|
||||||
|
const { data } = await sharp(filepath)
|
||||||
|
.resize(9, 8, { fit: 'fill' })
|
||||||
|
.greyscale()
|
||||||
|
.raw()
|
||||||
|
.toBuffer({ resolveWithObject: true });
|
||||||
|
|
||||||
|
let hash = 0n;
|
||||||
|
for (let row = 0; row < 8; row++) {
|
||||||
|
for (let col = 0; col < 8; col++) {
|
||||||
|
const left = data[row * 9 + col];
|
||||||
|
const right = data[row * 9 + col + 1];
|
||||||
|
hash = (hash << 1n) | (left < right ? 1n : 0n);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return hash.toString(16).padStart(16, '0');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hammingDistance(a, b) {
|
||||||
|
let xor = BigInt('0x' + a) ^ BigInt('0x' + b);
|
||||||
|
let dist = 0;
|
||||||
|
while (xor > 0n) {
|
||||||
|
dist += Number(xor & 1n);
|
||||||
|
xor >>= 1n;
|
||||||
|
}
|
||||||
|
return dist;
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import 'dotenv/config';
|
||||||
|
import chokidar from 'chokidar';
|
||||||
|
import path from 'path';
|
||||||
|
import { openDb } from './db.js';
|
||||||
|
import { ingestFile, isSupported } from './ingest.js';
|
||||||
|
|
||||||
|
const PHOTO_DIR = process.env.PHOTO_DIR ?? './photos';
|
||||||
|
const DB_PATH = process.env.DB_PATH ?? './data/library.db';
|
||||||
|
const POSTER_DIR = process.env.POSTER_DIR ?? './data/posters';
|
||||||
|
|
||||||
|
const db = openDb(DB_PATH);
|
||||||
|
|
||||||
|
console.log(`[worker] watching ${path.resolve(PHOTO_DIR)}`);
|
||||||
|
console.log(`[worker] db at ${path.resolve(DB_PATH)} (${db.count()} photos indexed)`);
|
||||||
|
|
||||||
|
// Serial queue: chokidar fires many 'add' events at once on startup;
|
||||||
|
// process them one at a time to keep memory and I/O pressure low.
|
||||||
|
const queue = [];
|
||||||
|
let busy = false;
|
||||||
|
|
||||||
|
async function drain() {
|
||||||
|
if (busy) return;
|
||||||
|
busy = true;
|
||||||
|
while (queue.length > 0) {
|
||||||
|
const fp = queue.shift();
|
||||||
|
try {
|
||||||
|
await ingestFile(fp, db, POSTER_DIR);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`[worker] ${path.basename(fp)}: ${err.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
busy = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const watcher = chokidar.watch(PHOTO_DIR, {
|
||||||
|
persistent: true,
|
||||||
|
ignoreInitial: false,
|
||||||
|
awaitWriteFinish: { stabilityThreshold: 2000, pollInterval: 100 },
|
||||||
|
ignored: /(^|[/\\])\../, // skip dotfiles
|
||||||
|
});
|
||||||
|
|
||||||
|
watcher.on('add', (fp) => {
|
||||||
|
if (!isSupported(fp)) return;
|
||||||
|
queue.push(path.resolve(fp));
|
||||||
|
drain();
|
||||||
|
});
|
||||||
|
|
||||||
|
watcher.on('unlink', (fp) => {
|
||||||
|
const resolved = path.resolve(fp);
|
||||||
|
db.removePhoto(resolved);
|
||||||
|
console.log(`[worker] removed ${path.basename(fp)}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
watcher.on('error', (err) => console.error('[worker] watcher error:', err));
|
||||||
|
|
||||||
|
process.on('SIGINT', () => {
|
||||||
|
console.log('\n[worker] shutting down');
|
||||||
|
watcher.close();
|
||||||
|
process.exit(0);
|
||||||
|
});
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
import path from 'path';
|
||||||
|
import fs from 'fs';
|
||||||
|
import crypto from 'crypto';
|
||||||
|
import sharp from 'sharp';
|
||||||
|
import exifr from 'exifr';
|
||||||
|
import { computeDHash } from './hash.js';
|
||||||
|
|
||||||
|
const SUPPORTED = new Set([
|
||||||
|
'.jpg', '.jpeg', '.png', '.webp', '.heic', '.heif',
|
||||||
|
'.tiff', '.tif', '.gif', '.avif',
|
||||||
|
]);
|
||||||
|
|
||||||
|
export function isSupported(filepath) {
|
||||||
|
return SUPPORTED.has(path.extname(filepath).toLowerCase());
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function ingestFile(filepath, db, posterDir) {
|
||||||
|
if (db.hasPhoto(filepath)) return;
|
||||||
|
|
||||||
|
const stat = fs.statSync(filepath);
|
||||||
|
|
||||||
|
let exif = {};
|
||||||
|
try {
|
||||||
|
exif = await exifr.parse(filepath, {
|
||||||
|
pick: ['DateTimeOriginal', 'Make', 'Model', 'Orientation'],
|
||||||
|
gps: true,
|
||||||
|
}) ?? {};
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
const dateTaken = exif.DateTimeOriginal ?? null;
|
||||||
|
const cameraModel = [exif.Make, exif.Model].filter(Boolean).join(' ') || null;
|
||||||
|
const gpsLat = exif.latitude ?? null;
|
||||||
|
const gpsLon = exif.longitude ?? null;
|
||||||
|
|
||||||
|
const { dir, thumb, preview } = posterPaths(filepath, posterDir, dateTaken ?? stat.mtime);
|
||||||
|
fs.mkdirSync(dir, { recursive: true });
|
||||||
|
|
||||||
|
let width = null, height = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const meta = await sharp(filepath, { failOn: 'none' }).metadata();
|
||||||
|
width = meta.width;
|
||||||
|
height = meta.height;
|
||||||
|
|
||||||
|
await sharp(filepath, { failOn: 'none' })
|
||||||
|
.rotate()
|
||||||
|
.resize(400, 400, { fit: 'inside', withoutEnlargement: true })
|
||||||
|
.jpeg({ quality: 80 })
|
||||||
|
.toFile(thumb);
|
||||||
|
|
||||||
|
await sharp(filepath, { failOn: 'none' })
|
||||||
|
.rotate()
|
||||||
|
.resize(1600, 1600, { fit: 'inside', withoutEnlargement: true })
|
||||||
|
.jpeg({ quality: 85 })
|
||||||
|
.toFile(preview);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`[ingest] poster failed ${path.basename(filepath)}: ${err.message}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let phash = null;
|
||||||
|
try { phash = await computeDHash(filepath); } catch {}
|
||||||
|
|
||||||
|
db.insertPhoto({
|
||||||
|
filepath,
|
||||||
|
thumb_path: thumb,
|
||||||
|
preview_path: preview,
|
||||||
|
date_taken: dateTaken ? new Date(dateTaken).toISOString() : null,
|
||||||
|
date_imported: new Date().toISOString(),
|
||||||
|
camera_model: cameraModel,
|
||||||
|
gps_lat: gpsLat,
|
||||||
|
gps_lon: gpsLon,
|
||||||
|
phash,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
filesize: stat.size,
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`[ingest] ${path.basename(filepath)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function posterPaths(filepath, posterDir, date) {
|
||||||
|
const id = crypto.createHash('sha1').update(filepath).digest('hex').slice(0, 10);
|
||||||
|
const stem = path.basename(filepath, path.extname(filepath));
|
||||||
|
const name = `${id}_${stem}`;
|
||||||
|
const d = new Date(date);
|
||||||
|
const folder = `${d.getFullYear()}/${String(d.getMonth() + 1).padStart(2, '0')}`;
|
||||||
|
const dir = path.join(posterDir, folder);
|
||||||
|
return {
|
||||||
|
dir,
|
||||||
|
thumb: path.join(dir, `${name}_thumb.jpg`),
|
||||||
|
preview: path.join(dir, `${name}_preview.jpg`),
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in new issue
Block a user