feat: filter sidebar, ingest progress, starred photos
- Left filter sidebar: All Photos / Favorites / Date by Year / Tags / Collections with counts, See more/less toggles, and blue active-row highlight - SSE ingest badge in header: pulsing dot + filename + indexed count during scan; auto-refreshes the library when a scan finishes - Starred/Favorites: starred column on photos, star button in lightbox toolbar, Favorites row in sidebar, ?starred=1 filter on /api/photos - GET /api/library/stats endpoint (totals, years, tags, collections) - GET /api/ingest/stream SSE endpoint (polls ingest_status table every 1.5s) - Worker writes ingest_status (active, current file, scanned count) to SQLite Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
1 parent
4df32f9f32
commit
f0e237ab2b
10 files changed
+541
-127
No files matched your search
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"version": "0.0.1",
|
||||||
|
"configurations": [
|
||||||
|
{
|
||||||
|
"name": "luma-server",
|
||||||
|
"runtimeExecutable": "npm",
|
||||||
|
"runtimeArgs": ["run", "server"],
|
||||||
|
"port": 3000
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "luma-dev",
|
||||||
|
"runtimeExecutable": "npm",
|
||||||
|
"runtimeArgs": ["run", "dev:client"],
|
||||||
|
"port": 5173
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
+110
-69
@@ -1,15 +1,16 @@
|
|||||||
import { useState, useEffect, useRef } from 'react';
|
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||||
import { fetchPhotos, fetchTags, fetchCollections } from './api/photos.js';
|
import { fetchPhotos, fetchLibraryStats } from './api/photos.js';
|
||||||
import Timeline from './components/Timeline.jsx';
|
import Timeline from './components/Timeline.jsx';
|
||||||
import Lightbox from './components/Lightbox.jsx';
|
import Lightbox from './components/Lightbox.jsx';
|
||||||
import FilterBar from './components/FilterBar.jsx';
|
import FilterSidebar from './components/FilterSidebar.jsx';
|
||||||
|
import IngestBadge from './components/IngestBadge.jsx';
|
||||||
import DuplicatesView from './components/DuplicatesView.jsx';
|
import DuplicatesView from './components/DuplicatesView.jsx';
|
||||||
|
|
||||||
// Lazy-load MapView so Leaflet CSS doesn't bloat the initial bundle
|
|
||||||
import { lazy, Suspense } from 'react';
|
import { lazy, Suspense } from 'react';
|
||||||
const MapView = lazy(() => import('./components/MapView.jsx'));
|
const MapView = lazy(() => import('./components/MapView.jsx'));
|
||||||
|
|
||||||
const EMPTY_FILTERS = { q: '', from: '', to: '', tagId: '', collectionId: '' };
|
const EMPTY_FILTERS = { q: '', from: '', to: '', tagId: '', collectionId: '', starred: '' };
|
||||||
|
const SIDEBAR_W = 210;
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const [view, setView] = useState('library');
|
const [view, setView] = useState('library');
|
||||||
@@ -20,24 +21,21 @@ export default function App() {
|
|||||||
const [error, setError] = useState(null);
|
const [error, setError] = useState(null);
|
||||||
const [dupeCount, setDupeCount] = useState(0);
|
const [dupeCount, setDupeCount] = useState(0);
|
||||||
|
|
||||||
// Filter state
|
const [filters, setFilters] = useState(EMPTY_FILTERS);
|
||||||
const [filters, setFilters] = useState(EMPTY_FILTERS);
|
const [stats, setStats] = useState(null);
|
||||||
const [tags, setTags] = useState([]);
|
|
||||||
const [collections, setCollections] = useState([]);
|
|
||||||
|
|
||||||
// Unified lightbox
|
|
||||||
const [lbPhotos, setLbPhotos] = useState(null);
|
const [lbPhotos, setLbPhotos] = useState(null);
|
||||||
const [lbIndex, setLbIndex] = useState(null);
|
const [lbIndex, setLbIndex] = useState(null);
|
||||||
|
|
||||||
const loadingRef = useRef(false);
|
const loadingRef = useRef(false);
|
||||||
const sentinelRef = useRef(null);
|
const sentinelRef = useRef(null);
|
||||||
|
|
||||||
// Fetch dropdown data once
|
const loadStats = useCallback(() => {
|
||||||
useEffect(() => {
|
fetchLibraryStats().then(setStats).catch(() => {});
|
||||||
fetchTags().then(setTags).catch(() => {});
|
|
||||||
fetchCollections().then(setCollections).catch(() => {});
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => { loadStats(); }, []);
|
||||||
|
|
||||||
// Reset + reload when filters change (debounce text search)
|
// Reset + reload when filters change (debounce text search)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const delay = filters.q ? 300 : 0;
|
const delay = filters.q ? 300 : 0;
|
||||||
@@ -84,8 +82,28 @@ export default function App() {
|
|||||||
setLbIndex(idx);
|
setLbIndex(idx);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// When a photo's star state changes in the lightbox, patch it in the local list
|
||||||
|
function handleStarChange(updated) {
|
||||||
|
setPhotos(prev => prev.map(p => p.id === updated.id ? { ...p, starred: updated.starred } : p));
|
||||||
|
if (lbPhotos) {
|
||||||
|
setLbPhotos(prev => prev.map(p => p.id === updated.id ? { ...p, starred: updated.starred } : p));
|
||||||
|
}
|
||||||
|
loadStats(); // refresh sidebar count
|
||||||
|
}
|
||||||
|
|
||||||
|
// When ingest finishes, refresh photos + stats
|
||||||
|
function handleScanComplete() {
|
||||||
|
setPhotos([]);
|
||||||
|
setNextCursor(undefined);
|
||||||
|
loadPage(undefined, filters);
|
||||||
|
loadStats();
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasFilter = filters.q || filters.from || filters.to || filters.tagId || filters.collectionId || filters.starred;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
{/* ── Header ── */}
|
||||||
<header style={{
|
<header style={{
|
||||||
position: 'sticky', top: 0, zIndex: 100,
|
position: 'sticky', top: 0, zIndex: 100,
|
||||||
background: '#0f0f0f', borderBottom: '1px solid #1c1c1c',
|
background: '#0f0f0f', borderBottom: '1px solid #1c1c1c',
|
||||||
@@ -96,8 +114,8 @@ export default function App() {
|
|||||||
Luma
|
Luma
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
<nav style={{ display: 'flex', gap: 2, flex: 1 }}>
|
<nav style={{ display: 'flex', gap: 2 }}>
|
||||||
<Tab active={view === 'library'} onClick={() => setView('library')}>
|
<Tab active={view === 'library'} onClick={() => setView('library')}>
|
||||||
Library
|
Library
|
||||||
{total !== null && (
|
{total !== null && (
|
||||||
<span style={{ marginLeft: 6, fontSize: 12, color: view === 'library' ? '#555' : '#3a3a3a' }}>
|
<span style={{ marginLeft: 6, fontSize: 12, color: view === 'library' ? '#555' : '#3a3a3a' }}>
|
||||||
@@ -105,9 +123,7 @@ export default function App() {
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</Tab>
|
</Tab>
|
||||||
<Tab active={view === 'map'} onClick={() => setView('map')}>
|
<Tab active={view === 'map'} onClick={() => setView('map')}>Map</Tab>
|
||||||
Map
|
|
||||||
</Tab>
|
|
||||||
<Tab active={view === 'duplicates'} onClick={() => setView('duplicates')}>
|
<Tab active={view === 'duplicates'} onClick={() => setView('duplicates')}>
|
||||||
Duplicates
|
Duplicates
|
||||||
{dupeCount > 0 && (
|
{dupeCount > 0 && (
|
||||||
@@ -121,69 +137,94 @@ export default function App() {
|
|||||||
)}
|
)}
|
||||||
</Tab>
|
</Tab>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
|
{/* Ingest progress — right side */}
|
||||||
|
<div style={{ flex: 1, display: 'flex', justifyContent: 'flex-end' }}>
|
||||||
|
<IngestBadge onScanComplete={handleScanComplete} />
|
||||||
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{/* ── Library ── */}
|
{/* ── Body (sidebar + content) ── */}
|
||||||
{view === 'library' && (
|
<div style={{ display: 'flex', alignItems: 'flex-start' }}>
|
||||||
<>
|
|
||||||
<FilterBar
|
{/* Sidebar — only in library view */}
|
||||||
filters={filters}
|
{view === 'library' && (
|
||||||
onChange={setFilters}
|
<aside style={{
|
||||||
tags={tags}
|
position: 'sticky', top: 52,
|
||||||
collections={collections}
|
width: SIDEBAR_W, flexShrink: 0,
|
||||||
/>
|
height: 'calc(100vh - 52px)', overflowY: 'auto',
|
||||||
{error && (
|
borderRight: '1px solid #141414', background: '#0a0a0a',
|
||||||
<p style={{ color: '#f87171', padding: 24, textAlign: 'center', fontSize: 14 }}>
|
}}>
|
||||||
{error}
|
<FilterSidebar
|
||||||
</p>
|
filters={filters}
|
||||||
)}
|
onChange={setFilters}
|
||||||
{photos.length === 0 && !loading && !error && (
|
stats={stats}
|
||||||
<div style={{ textAlign: 'center', padding: '80px 24px', color: '#555' }}>
|
total={total}
|
||||||
{filters.q || filters.from || filters.to || filters.tagId || filters.collectionId ? (
|
/>
|
||||||
<p style={{ fontSize: 15 }}>No photos match these filters.</p>
|
</aside>
|
||||||
) : (
|
)}
|
||||||
<>
|
|
||||||
<p style={{ fontSize: 15, marginBottom: 8 }}>No photos indexed yet.</p>
|
{/* Main content */}
|
||||||
<p style={{ fontSize: 13 }}>Set <code>PHOTO_DIR</code> in <code>.env</code> and run <code>npm run worker</code>.</p>
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
</>
|
|
||||||
|
{/* Library */}
|
||||||
|
{view === 'library' && (
|
||||||
|
<>
|
||||||
|
{error && (
|
||||||
|
<p style={{ color: '#f87171', padding: 24, textAlign: 'center', fontSize: 14 }}>
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
{photos.length === 0 && !loading && !error && (
|
||||||
|
<div style={{ textAlign: 'center', padding: '80px 24px', color: '#555' }}>
|
||||||
|
{hasFilter ? (
|
||||||
|
<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>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
<Timeline photos={photos} onSelect={(idx) => openLightbox(photos, idx)} />
|
|
||||||
<div ref={sentinelRef} style={{ height: 1 }} />
|
{/* Map */}
|
||||||
{loading && <p style={{ textAlign: 'center', padding: 24, color: '#444', fontSize: 13 }}>Loading…</p>}
|
{view === 'map' && (
|
||||||
{nextCursor === null && photos.length > 0 && !loading && (
|
<Suspense fallback={<p style={{ padding: 24, color: '#555', fontSize: 13 }}>Loading map…</p>}>
|
||||||
<p style={{ textAlign: 'center', padding: 24, color: '#2a2a2a', fontSize: 12 }}>
|
<MapView onOpenPhoto={(photo) => openLightbox([photo], 0)} />
|
||||||
— {total?.toLocaleString()} photo{total === 1 ? '' : 's'} —
|
</Suspense>
|
||||||
</p>
|
|
||||||
)}
|
)}
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* ── Map ── */}
|
{/* Duplicates */}
|
||||||
{view === 'map' && (
|
{view === 'duplicates' && (
|
||||||
<Suspense fallback={<p style={{ padding: 24, color: '#555', fontSize: 13 }}>Loading map…</p>}>
|
<DuplicatesView
|
||||||
<MapView onOpenPhoto={(photo) => openLightbox([photo], 0)} />
|
onGroupCountChange={setDupeCount}
|
||||||
</Suspense>
|
onOpenPhoto={(photo, group) =>
|
||||||
)}
|
openLightbox(group, group.findIndex(p => p.id === photo.id))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* ── Duplicates ── */}
|
{/* Lightbox */}
|
||||||
{view === 'duplicates' && (
|
|
||||||
<DuplicatesView
|
|
||||||
onGroupCountChange={setDupeCount}
|
|
||||||
onOpenPhoto={(photo, group) =>
|
|
||||||
openLightbox(group, group.findIndex(p => p.id === photo.id))
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* ── Lightbox (shared) ── */}
|
|
||||||
{lbPhotos !== null && (
|
{lbPhotos !== null && (
|
||||||
<Lightbox
|
<Lightbox
|
||||||
photos={lbPhotos}
|
photos={lbPhotos}
|
||||||
index={lbIndex}
|
index={lbIndex}
|
||||||
onClose={() => { setLbPhotos(null); setLbIndex(null); }}
|
onClose={() => { setLbPhotos(null); setLbIndex(null); }}
|
||||||
onChange={setLbIndex}
|
onChange={setLbIndex}
|
||||||
|
onStarChange={handleStarChange}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ const json = (body) => ({
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Library
|
// Library
|
||||||
export function fetchPhotos({ cursor, limit = 100, q, from, to, tagId, collectionId } = {}) {
|
export function fetchPhotos({ cursor, limit = 100, q, from, to, tagId, collectionId, starred } = {}) {
|
||||||
const p = new URLSearchParams({ limit });
|
const p = new URLSearchParams({ limit });
|
||||||
if (cursor) p.set('cursor', cursor);
|
if (cursor) p.set('cursor', cursor);
|
||||||
if (q) p.set('q', q);
|
if (q) p.set('q', q);
|
||||||
@@ -18,6 +18,7 @@ export function fetchPhotos({ cursor, limit = 100, q, from, to, tagId, collectio
|
|||||||
if (to) p.set('to', to);
|
if (to) p.set('to', to);
|
||||||
if (tagId) p.set('tagId', tagId);
|
if (tagId) p.set('tagId', tagId);
|
||||||
if (collectionId) p.set('collectionId', collectionId);
|
if (collectionId) p.set('collectionId', collectionId);
|
||||||
|
if (starred) p.set('starred', starred);
|
||||||
return api(`/api/photos?${p}`);
|
return api(`/api/photos?${p}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -29,7 +30,19 @@ export function fetchMapPhotos() {
|
|||||||
return api('/api/photos/map');
|
return api('/api/photos/map');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function fetchLibraryStats() {
|
||||||
|
return api('/api/library/stats');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toggleStar(id, starred) {
|
||||||
|
return api(`/api/photos/${id}`, { method: 'PATCH', ...json({ starred }) });
|
||||||
|
}
|
||||||
|
|
||||||
// Tags
|
// Tags
|
||||||
|
export function fetchTags() {
|
||||||
|
return api('/api/tags');
|
||||||
|
}
|
||||||
|
|
||||||
export function addTag(photoId, name) {
|
export function addTag(photoId, name) {
|
||||||
return api(`/api/photos/${photoId}/tags`, { method: 'POST', ...json({ name }) });
|
return api(`/api/photos/${photoId}/tags`, { method: 'POST', ...json({ name }) });
|
||||||
}
|
}
|
||||||
@@ -55,11 +68,6 @@ export function removeFromCollection(photoId, collectionId) {
|
|||||||
return api(`/api/photos/${photoId}/collections/${collectionId}`, { method: 'DELETE' });
|
return api(`/api/photos/${photoId}/collections/${collectionId}`, { method: 'DELETE' });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Tags + collections (for filter dropdowns)
|
|
||||||
export function fetchTags() {
|
|
||||||
return api('/api/tags');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Duplicates
|
// Duplicates
|
||||||
export function fetchDuplicates() {
|
export function fetchDuplicates() {
|
||||||
return api('/api/duplicates');
|
return api('/api/duplicates');
|
||||||
|
|||||||
@@ -0,0 +1,194 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
const MAX_DEFAULT = 5;
|
||||||
|
|
||||||
|
export default function FilterSidebar({ filters, onChange, stats, total }) {
|
||||||
|
const [expanded, setExpanded] = useState({});
|
||||||
|
|
||||||
|
function toggle(section) {
|
||||||
|
setExpanded(e => ({ ...e, [section]: !e[section] }));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Derive active sidebar selection
|
||||||
|
const selectedYear = (filters.from && filters.to)
|
||||||
|
? filters.from.slice(0, 4)
|
||||||
|
: null;
|
||||||
|
const isStarred = filters.starred === '1';
|
||||||
|
const isAll = !isStarred && !selectedYear && !filters.tagId && !filters.collectionId;
|
||||||
|
|
||||||
|
function selectAll() {
|
||||||
|
onChange({ q: filters.q, from: '', to: '', tagId: '', collectionId: '', starred: '' });
|
||||||
|
}
|
||||||
|
function selectStarred() {
|
||||||
|
onChange({ q: filters.q, from: '', to: '', tagId: '', collectionId: '', starred: '1' });
|
||||||
|
}
|
||||||
|
function selectYear(year) {
|
||||||
|
onChange({ q: filters.q, from: `${year}-01-01`, to: `${year}-12-31`, tagId: '', collectionId: '', starred: '' });
|
||||||
|
}
|
||||||
|
function selectTag(id) {
|
||||||
|
onChange({ q: filters.q, from: '', to: '', tagId: String(id), collectionId: '', starred: '' });
|
||||||
|
}
|
||||||
|
function selectCollection(id) {
|
||||||
|
onChange({ q: filters.q, from: '', to: '', tagId: '', collectionId: String(id), starred: '' });
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<nav style={{ padding: '10px 0 24px', userSelect: 'none' }}>
|
||||||
|
|
||||||
|
{/* Search */}
|
||||||
|
<div style={{ padding: '0 14px 8px' }}>
|
||||||
|
<input
|
||||||
|
type="search"
|
||||||
|
placeholder="Search…"
|
||||||
|
value={filters.q}
|
||||||
|
onChange={e => onChange({ ...filters, q: e.target.value })}
|
||||||
|
style={{
|
||||||
|
width: '100%', background: '#1a1a1a', border: '1px solid #242424',
|
||||||
|
borderRadius: 7, color: '#ccc', fontSize: 12,
|
||||||
|
padding: '6px 10px', outline: 'none',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* All Photos */}
|
||||||
|
<Row
|
||||||
|
active={isAll && !filters.q}
|
||||||
|
onClick={selectAll}
|
||||||
|
icon="⊞"
|
||||||
|
label="All Photos"
|
||||||
|
count={stats?.total ?? total}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Favorites */}
|
||||||
|
{(stats?.starred > 0 || isStarred) && (
|
||||||
|
<Row
|
||||||
|
active={isStarred}
|
||||||
|
onClick={selectStarred}
|
||||||
|
icon="★"
|
||||||
|
iconColor="#f5c518"
|
||||||
|
label="Favorites"
|
||||||
|
count={stats?.starred}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Date Taken */}
|
||||||
|
{stats?.years?.length > 0 && (
|
||||||
|
<Section title="Date Taken">
|
||||||
|
{(expanded.years ? stats.years : stats.years.slice(0, MAX_DEFAULT)).map(({ year, count }) => (
|
||||||
|
<Row
|
||||||
|
key={year}
|
||||||
|
active={selectedYear === year}
|
||||||
|
onClick={() => selectYear(year)}
|
||||||
|
label={year}
|
||||||
|
count={count}
|
||||||
|
indent
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{stats.years.length > MAX_DEFAULT && (
|
||||||
|
<SeeMore expanded={expanded.years} onClick={() => toggle('years')} />
|
||||||
|
)}
|
||||||
|
</Section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Tags */}
|
||||||
|
{stats?.tags?.length > 0 && (
|
||||||
|
<Section title="Tags">
|
||||||
|
{(expanded.tags ? stats.tags : stats.tags.slice(0, MAX_DEFAULT)).map(tag => (
|
||||||
|
<Row
|
||||||
|
key={tag.id}
|
||||||
|
active={filters.tagId === String(tag.id)}
|
||||||
|
onClick={() => selectTag(tag.id)}
|
||||||
|
label={tag.name}
|
||||||
|
count={tag.count}
|
||||||
|
indent
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{stats.tags.length > MAX_DEFAULT && (
|
||||||
|
<SeeMore expanded={expanded.tags} onClick={() => toggle('tags')} />
|
||||||
|
)}
|
||||||
|
</Section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Collections */}
|
||||||
|
{stats?.collections?.length > 0 && (
|
||||||
|
<Section title="Collections">
|
||||||
|
{(expanded.collections ? stats.collections : stats.collections.slice(0, MAX_DEFAULT)).map(col => (
|
||||||
|
<Row
|
||||||
|
key={col.id}
|
||||||
|
active={filters.collectionId === String(col.id)}
|
||||||
|
onClick={() => selectCollection(col.id)}
|
||||||
|
label={col.name}
|
||||||
|
count={col.photo_count}
|
||||||
|
indent
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{stats.collections.length > MAX_DEFAULT && (
|
||||||
|
<SeeMore expanded={expanded.collections} onClick={() => toggle('collections')} />
|
||||||
|
)}
|
||||||
|
</Section>
|
||||||
|
)}
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Section({ title, children }) {
|
||||||
|
return (
|
||||||
|
<div style={{ marginTop: 16 }}>
|
||||||
|
<div style={{
|
||||||
|
fontSize: 10, fontWeight: 700, letterSpacing: '0.08em',
|
||||||
|
color: '#3a3a3a', textTransform: 'uppercase',
|
||||||
|
padding: '0 16px 4px',
|
||||||
|
}}>
|
||||||
|
{title}
|
||||||
|
</div>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Row({ active, onClick, icon, iconColor, label, count, indent }) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={onClick}
|
||||||
|
style={{
|
||||||
|
display: 'flex', alignItems: 'center', gap: 8,
|
||||||
|
width: '100%', padding: `5px ${indent ? '16px' : '14px'}`,
|
||||||
|
background: active ? '#1e2a3a' : 'transparent',
|
||||||
|
border: 'none', cursor: 'pointer',
|
||||||
|
color: active ? '#9fc8ff' : '#aaa',
|
||||||
|
fontSize: 13, textAlign: 'left',
|
||||||
|
borderLeft: active ? '2px solid #4d9eff' : '2px solid transparent',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{icon && (
|
||||||
|
<span style={{ fontSize: 12, flexShrink: 0, color: iconColor ?? 'inherit', lineHeight: 1 }}>
|
||||||
|
{icon}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
{count != null && (
|
||||||
|
<span style={{ fontSize: 11, color: active ? '#5a7a9a' : '#333', flexShrink: 0 }}>
|
||||||
|
{Number(count).toLocaleString()}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SeeMore({ expanded, onClick }) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={onClick}
|
||||||
|
style={{
|
||||||
|
display: 'flex', alignItems: 'center', gap: 5,
|
||||||
|
padding: '3px 16px 3px 20px',
|
||||||
|
background: 'transparent', border: 'none',
|
||||||
|
color: '#4d9eff', cursor: 'pointer', fontSize: 12,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{expanded ? 'See less ▲' : 'See more ▼'}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import { useState, useEffect, useRef } from 'react';
|
||||||
|
|
||||||
|
export default function IngestBadge({ onScanComplete }) {
|
||||||
|
const [status, setStatus] = useState(null);
|
||||||
|
const wasActive = useRef(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const es = new EventSource('/api/ingest/stream');
|
||||||
|
|
||||||
|
es.onmessage = (e) => {
|
||||||
|
const data = JSON.parse(e.data);
|
||||||
|
setStatus(data);
|
||||||
|
if (wasActive.current && !data.active) {
|
||||||
|
onScanComplete?.();
|
||||||
|
}
|
||||||
|
wasActive.current = !!data.active;
|
||||||
|
};
|
||||||
|
|
||||||
|
es.onerror = () => {
|
||||||
|
// Reconnect is handled automatically by EventSource
|
||||||
|
};
|
||||||
|
|
||||||
|
return () => es.close();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (!status?.active) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
display: 'flex', alignItems: 'center', gap: 7,
|
||||||
|
fontSize: 12, color: '#888',
|
||||||
|
overflow: 'hidden', maxWidth: 340,
|
||||||
|
}}>
|
||||||
|
<span style={{
|
||||||
|
width: 7, height: 7, borderRadius: '50%',
|
||||||
|
background: '#4d9eff', flexShrink: 0,
|
||||||
|
animation: 'pulse-dot 1.4s ease-in-out infinite',
|
||||||
|
}} />
|
||||||
|
<span style={{ whiteSpace: 'nowrap', color: '#aaa' }}>
|
||||||
|
Scanning · {status.scanned.toLocaleString()} indexed
|
||||||
|
</span>
|
||||||
|
{status.current && (
|
||||||
|
<span style={{
|
||||||
|
color: '#444', overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis', whiteSpace: 'nowrap',
|
||||||
|
fontSize: 11,
|
||||||
|
}}>
|
||||||
|
{status.current}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,8 +1,10 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
|
import { toggleStar } from '../api/photos.js';
|
||||||
import DetailPanel from './DetailPanel.jsx';
|
import DetailPanel from './DetailPanel.jsx';
|
||||||
|
|
||||||
export default function Lightbox({ photos, index, onClose, onChange }) {
|
export default function Lightbox({ photos, index, onClose, onChange, onStarChange }) {
|
||||||
const [showInfo, setShowInfo] = useState(false);
|
const [showInfo, setShowInfo] = useState(false);
|
||||||
|
const [starring, setStarring] = useState(false);
|
||||||
const photo = photos[index];
|
const photo = photos[index];
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -38,6 +40,23 @@ export default function Lightbox({ photos, index, onClose, onChange }) {
|
|||||||
onClick={e => e.stopPropagation()}
|
onClick={e => e.stopPropagation()}
|
||||||
style={{ position: 'absolute', top: 0, right: 0, display: 'flex', gap: 4, padding: 10, zIndex: 10 }}
|
style={{ position: 'absolute', top: 0, right: 0, display: 'flex', gap: 4, padding: 10, zIndex: 10 }}
|
||||||
>
|
>
|
||||||
|
<IconBtn
|
||||||
|
onClick={async () => {
|
||||||
|
if (starring) return;
|
||||||
|
setStarring(true);
|
||||||
|
try {
|
||||||
|
const updated = await toggleStar(photo.id, !photo.starred);
|
||||||
|
onStarChange?.(updated);
|
||||||
|
} finally {
|
||||||
|
setStarring(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
active={photo.starred}
|
||||||
|
title={photo.starred ? 'Unstar' : 'Star'}
|
||||||
|
style={{ color: photo.starred ? '#f5c518' : undefined }}
|
||||||
|
>
|
||||||
|
★
|
||||||
|
</IconBtn>
|
||||||
<IconBtn
|
<IconBtn
|
||||||
onClick={() => setShowInfo(s => !s)}
|
onClick={() => setShowInfo(s => !s)}
|
||||||
active={showInfo}
|
active={showInfo}
|
||||||
|
|||||||
@@ -16,6 +16,11 @@ body {
|
|||||||
min-height: 100dvh;
|
min-height: 100dvh;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@keyframes pulse-dot {
|
||||||
|
0%, 100% { opacity: 1; }
|
||||||
|
50% { opacity: 0.3; }
|
||||||
|
}
|
||||||
|
|
||||||
code {
|
code {
|
||||||
font-family: 'SF Mono', 'Fira Code', monospace;
|
font-family: 'SF Mono', 'Fira Code', monospace;
|
||||||
background: #1e1e1e;
|
background: #1e1e1e;
|
||||||
|
|||||||
+66
-27
@@ -36,6 +36,7 @@ function mapPhoto(p) {
|
|||||||
width: p.width,
|
width: p.width,
|
||||||
height: p.height,
|
height: p.height,
|
||||||
filesize: p.filesize,
|
filesize: p.filesize,
|
||||||
|
starred: p.starred === 1,
|
||||||
thumb_url: posterUrl(p.thumb_path),
|
thumb_url: posterUrl(p.thumb_path),
|
||||||
preview_url: posterUrl(p.preview_path),
|
preview_url: posterUrl(p.preview_path),
|
||||||
};
|
};
|
||||||
@@ -44,9 +45,7 @@ function mapPhoto(p) {
|
|||||||
function badRequest(res, msg) { res.status(400).json({ error: msg }); }
|
function badRequest(res, msg) { res.status(400).json({ error: msg }); }
|
||||||
function notFound(res) { res.status(404).json({ error: 'not found' }); }
|
function notFound(res) { res.status(404).json({ error: 'not found' }); }
|
||||||
|
|
||||||
// Build SQL parts for the photo list query based on active filters.
|
function buildFilter(q, from, to, tagId, collectionId, starred) {
|
||||||
// Returns { joinSql, whereSql, params } where params covers joins then where clauses.
|
|
||||||
function buildFilter(q, from, to, tagId, collectionId) {
|
|
||||||
const joinParts = [];
|
const joinParts = [];
|
||||||
const joinParams = [];
|
const joinParams = [];
|
||||||
const condParts = [];
|
const condParts = [];
|
||||||
@@ -64,13 +63,14 @@ function buildFilter(q, from, to, tagId, collectionId) {
|
|||||||
condParts.push('(p.filepath LIKE ? OR p.camera_model LIKE ?)');
|
condParts.push('(p.filepath LIKE ? OR p.camera_model LIKE ?)');
|
||||||
condParams.push(`%${q}%`, `%${q}%`);
|
condParams.push(`%${q}%`, `%${q}%`);
|
||||||
}
|
}
|
||||||
if (from) { condParts.push('p.date_taken >= ?'); condParams.push(from); }
|
if (from) { condParts.push('p.date_taken >= ?'); condParams.push(from); }
|
||||||
if (to) { condParts.push('p.date_taken <= ?'); condParams.push(to); }
|
if (to) { condParts.push('p.date_taken <= ?'); condParams.push(to); }
|
||||||
|
if (starred === '1') condParts.push('p.starred = 1');
|
||||||
|
|
||||||
return {
|
return {
|
||||||
joinSql: joinParts.join(' '),
|
joinSql: joinParts.join(' '),
|
||||||
whereSql: condParts.length ? `WHERE ${condParts.join(' AND ')}` : '',
|
whereSql: condParts.length ? `WHERE ${condParts.join(' AND ')}` : '',
|
||||||
params: [...joinParams, ...condParams],
|
params: [...joinParams, ...condParams],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -78,37 +78,67 @@ function buildFilter(q, from, to, tagId, collectionId) {
|
|||||||
|
|
||||||
app.use('/posters', express.static(posterDirAbs));
|
app.use('/posters', express.static(posterDirAbs));
|
||||||
|
|
||||||
|
// ── Ingest status (SSE) ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
app.get('/api/ingest/stream', (req, res) => {
|
||||||
|
res.writeHead(200, {
|
||||||
|
'Content-Type': 'text/event-stream',
|
||||||
|
'Cache-Control': 'no-cache',
|
||||||
|
'Connection': 'keep-alive',
|
||||||
|
'X-Accel-Buffering': 'no', // prevent nginx buffering
|
||||||
|
});
|
||||||
|
res.write('retry: 3000\n\n');
|
||||||
|
|
||||||
|
const send = () => {
|
||||||
|
const s = db.getIngestStatus();
|
||||||
|
res.write(`data: ${JSON.stringify(s)}\n\n`);
|
||||||
|
};
|
||||||
|
|
||||||
|
send();
|
||||||
|
const iv = setInterval(send, 1500);
|
||||||
|
req.on('close', () => clearInterval(iv));
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Library stats ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
app.get('/api/library/stats', (_req, res) => {
|
||||||
|
const starred = db.raw.prepare('SELECT COUNT(*) AS n FROM photos WHERE starred = 1').get().n;
|
||||||
|
const years = db.raw.prepare(
|
||||||
|
"SELECT strftime('%Y', date_taken) AS year, COUNT(*) AS count FROM photos WHERE date_taken IS NOT NULL GROUP BY year ORDER BY year DESC"
|
||||||
|
).all();
|
||||||
|
res.json({
|
||||||
|
total: db.count(),
|
||||||
|
starred,
|
||||||
|
years,
|
||||||
|
tags: db.listAllTags(),
|
||||||
|
collections: db.listCollections(),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// ── Photos ────────────────────────────────────────────────────────────────────
|
// ── Photos ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
app.get('/api/health', (_req, res) => {
|
app.get('/api/health', (_req, res) => res.json({ ok: true, photos: db.count() }));
|
||||||
res.json({ ok: true, photos: db.count() });
|
|
||||||
});
|
|
||||||
|
|
||||||
const SELECT_COLS = `
|
const SELECT_COLS = `
|
||||||
p.id, p.thumb_path, p.preview_path, p.date_taken, p.date_imported,
|
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
|
p.camera_model, p.gps_lat, p.gps_lon, p.width, p.height, p.filesize, p.starred
|
||||||
`;
|
`;
|
||||||
|
|
||||||
app.get('/api/photos', (req, res) => {
|
app.get('/api/photos', (req, res) => {
|
||||||
const limit = Math.min(parseInt(req.query.limit ?? '100', 10), 500);
|
const limit = Math.min(parseInt(req.query.limit ?? '100', 10), 500);
|
||||||
const cursor = req.query.cursor ?? null;
|
const cursor = req.query.cursor ?? null;
|
||||||
const { q, from, to, tagId, collectionId } = req.query;
|
const { q, from, to, tagId, collectionId, starred } = req.query;
|
||||||
|
|
||||||
const { joinSql, whereSql, params } = buildFilter(q, from, to, tagId, collectionId);
|
const { joinSql, whereSql, params } = buildFilter(q, from, to, tagId, collectionId, starred);
|
||||||
|
|
||||||
// Cursor condition — handles null dates correctly (NULLs sort last in DESC).
|
let cursorSql = '';
|
||||||
// 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 = [];
|
const cursorParams = [];
|
||||||
if (cursor) {
|
if (cursor) {
|
||||||
const pipeAt = cursor.lastIndexOf('|');
|
const pipeAt = cursor.lastIndexOf('|');
|
||||||
const cursorDate = cursor.slice(0, pipeAt);
|
const cursorDate = cursor.slice(0, pipeAt);
|
||||||
const cursorId = parseInt(cursor.slice(pipeAt + 1), 10);
|
const cursorId = parseInt(cursor.slice(pipeAt + 1), 10);
|
||||||
if (cursorDate === '') {
|
if (cursorDate === '') {
|
||||||
cursorSql = whereSql
|
cursorSql = whereSql ? 'AND (p.date_taken IS NULL AND p.id < ?)' : 'WHERE (p.date_taken IS NULL AND p.id < ?)';
|
||||||
? 'AND (p.date_taken IS NULL AND p.id < ?)'
|
|
||||||
: 'WHERE (p.date_taken IS NULL AND p.id < ?)';
|
|
||||||
cursorParams.push(cursorId);
|
cursorParams.push(cursorId);
|
||||||
} else {
|
} else {
|
||||||
const clause = '(p.date_taken < ? OR (p.date_taken = ? AND p.id < ?) OR p.date_taken IS NULL)';
|
const clause = '(p.date_taken < ? OR (p.date_taken = ? AND p.id < ?) OR p.date_taken IS NULL)';
|
||||||
@@ -118,13 +148,12 @@ app.get('/api/photos', (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const allParams = [...params, ...cursorParams, limit + 1];
|
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 sql = `SELECT ${SELECT_COLS} FROM photos p ${joinSql} ${whereSql} ${cursorSql}
|
const countSql = `SELECT COUNT(*) AS n FROM photos p ${joinSql} ${whereSql}`;
|
||||||
ORDER BY p.date_taken DESC, p.id DESC LIMIT ?`;
|
const total = db.raw.prepare(countSql).get(params).n;
|
||||||
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 hasMore = rows.length > limit;
|
||||||
const page = rows.slice(0, limit);
|
const page = rows.slice(0, limit);
|
||||||
@@ -161,6 +190,16 @@ app.get('/api/photos/:id', (req, res) => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.patch('/api/photos/:id', (req, res) => {
|
||||||
|
const id = parseInt(req.params.id, 10);
|
||||||
|
if ('starred' in req.body) {
|
||||||
|
db.raw.prepare('UPDATE photos SET starred = ? WHERE id = ?').run(req.body.starred ? 1 : 0, id);
|
||||||
|
}
|
||||||
|
const row = db.raw.prepare('SELECT * FROM photos WHERE id = ?').get(id);
|
||||||
|
if (!row) return notFound(res);
|
||||||
|
res.json(mapPhoto(row));
|
||||||
|
});
|
||||||
|
|
||||||
// ── Tags ──────────────────────────────────────────────────────────────────────
|
// ── Tags ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
app.get('/api/tags', (_req, res) => res.json(db.listAllTags()));
|
app.get('/api/tags', (_req, res) => res.json(db.listAllTags()));
|
||||||
|
|||||||
+46
-17
@@ -23,17 +23,18 @@ export function openDb(dbPath) {
|
|||||||
phash TEXT,
|
phash TEXT,
|
||||||
width INTEGER,
|
width INTEGER,
|
||||||
height INTEGER,
|
height INTEGER,
|
||||||
filesize INTEGER
|
filesize INTEGER,
|
||||||
|
starred INTEGER NOT NULL DEFAULT 0
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_photos_date_taken ON photos(date_taken);
|
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 INDEX IF NOT EXISTS idx_photos_phash ON photos(phash);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_photos_starred ON photos(starred);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS collections (
|
CREATE TABLE IF NOT EXISTS collections (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
name TEXT NOT NULL UNIQUE,
|
name TEXT NOT NULL UNIQUE,
|
||||||
description TEXT,
|
description TEXT,
|
||||||
poster_path TEXT,
|
|
||||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -62,10 +63,22 @@ export function openDb(dbPath) {
|
|||||||
PRIMARY KEY (photo_id_a, photo_id_b),
|
PRIMARY KEY (photo_id_a, photo_id_b),
|
||||||
CHECK (photo_id_a < photo_id_b)
|
CHECK (photo_id_a < photo_id_b)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS ingest_status (
|
||||||
|
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||||
|
active INTEGER NOT NULL DEFAULT 0,
|
||||||
|
current TEXT,
|
||||||
|
scanned INTEGER NOT NULL DEFAULT 0,
|
||||||
|
errors INTEGER NOT NULL DEFAULT 0,
|
||||||
|
started_at TEXT,
|
||||||
|
updated_at TEXT
|
||||||
|
);
|
||||||
`);
|
`);
|
||||||
|
|
||||||
|
// Migrations for columns added after initial schema
|
||||||
|
try { db.exec('ALTER TABLE photos ADD COLUMN starred INTEGER NOT NULL DEFAULT 0'); } catch {}
|
||||||
|
|
||||||
const s = {
|
const s = {
|
||||||
// Photos
|
|
||||||
insert: db.prepare(`
|
insert: db.prepare(`
|
||||||
INSERT OR IGNORE INTO photos
|
INSERT OR IGNORE INTO photos
|
||||||
(filepath, thumb_path, preview_path, date_taken, date_imported,
|
(filepath, thumb_path, preview_path, date_taken, date_imported,
|
||||||
@@ -78,7 +91,6 @@ export function openDb(dbPath) {
|
|||||||
remove: db.prepare('DELETE FROM photos WHERE filepath = ?'),
|
remove: db.prepare('DELETE FROM photos WHERE filepath = ?'),
|
||||||
count: db.prepare('SELECT COUNT(*) AS n FROM photos'),
|
count: db.prepare('SELECT COUNT(*) AS n FROM photos'),
|
||||||
|
|
||||||
// Tags
|
|
||||||
tagByName: db.prepare('SELECT id, name FROM tags WHERE name = ?'),
|
tagByName: db.prepare('SELECT id, name FROM tags WHERE name = ?'),
|
||||||
tagInsert: db.prepare('INSERT OR IGNORE INTO tags (name) VALUES (?)'),
|
tagInsert: db.prepare('INSERT OR IGNORE INTO tags (name) VALUES (?)'),
|
||||||
tagList: db.prepare(`
|
tagList: db.prepare(`
|
||||||
@@ -92,7 +104,6 @@ export function openDb(dbPath) {
|
|||||||
tagAttach: db.prepare('INSERT OR IGNORE INTO photo_tags (photo_id, tag_id) VALUES (?, ?)'),
|
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 = ?'),
|
tagDetach: db.prepare('DELETE FROM photo_tags WHERE photo_id = ? AND tag_id = ?'),
|
||||||
|
|
||||||
// Collections
|
|
||||||
colList: db.prepare(`
|
colList: db.prepare(`
|
||||||
SELECT c.id, c.name, c.description, c.created_at,
|
SELECT c.id, c.name, c.description, c.created_at,
|
||||||
COUNT(pc.photo_id) AS photo_count
|
COUNT(pc.photo_id) AS photo_count
|
||||||
@@ -107,13 +118,22 @@ export function openDb(dbPath) {
|
|||||||
colAdd: db.prepare('INSERT OR IGNORE INTO photo_collections (photo_id, collection_id) VALUES (?, ?)'),
|
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 = ?'),
|
colRemove: db.prepare('DELETE FROM photo_collections WHERE photo_id = ? AND collection_id = ?'),
|
||||||
|
|
||||||
// Duplicates
|
dupePairs: db.prepare(
|
||||||
dupePairs: db.prepare(
|
|
||||||
'SELECT photo_id_a, photo_id_b, distance FROM duplicate_pairs WHERE dismissed = 0'
|
'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'
|
ingestGet: db.prepare('SELECT * FROM ingest_status WHERE id = 1'),
|
||||||
),
|
ingestSet: db.prepare(`
|
||||||
|
INSERT INTO ingest_status (id, active, current, scanned, errors, started_at, updated_at)
|
||||||
|
VALUES (1, @active, @current, @scanned, @errors, @started_at, @updated_at)
|
||||||
|
ON CONFLICT(id) DO UPDATE SET
|
||||||
|
active = excluded.active,
|
||||||
|
current = excluded.current,
|
||||||
|
scanned = excluded.scanned,
|
||||||
|
errors = excluded.errors,
|
||||||
|
started_at = COALESCE(ingest_status.started_at, excluded.started_at),
|
||||||
|
updated_at = excluded.updated_at
|
||||||
|
`),
|
||||||
};
|
};
|
||||||
|
|
||||||
const addTagTx = db.transaction((photoId, tagName) => {
|
const addTagTx = db.transaction((photoId, tagName) => {
|
||||||
@@ -126,29 +146,25 @@ export function openDb(dbPath) {
|
|||||||
return {
|
return {
|
||||||
raw: db,
|
raw: db,
|
||||||
|
|
||||||
// Photos
|
|
||||||
insertPhoto: (row) => s.insert.run(row),
|
insertPhoto: (row) => s.insert.run(row),
|
||||||
hasPhoto: (fp) => !!s.exists.get(fp),
|
hasPhoto: (fp) => !!s.exists.get(fp),
|
||||||
removePhoto: (fp) => s.remove.run(fp),
|
removePhoto: (fp) => s.remove.run(fp),
|
||||||
count: () => s.count.get().n,
|
count: () => s.count.get().n,
|
||||||
|
|
||||||
// Tags
|
|
||||||
addTagToPhoto: (photoId, tagName) => addTagTx(photoId, tagName),
|
addTagToPhoto: (photoId, tagName) => addTagTx(photoId, tagName),
|
||||||
removeTagFromPhoto: (photoId, tagId) => s.tagDetach.run(photoId, tagId),
|
removeTagFromPhoto: (photoId, tagId) => s.tagDetach.run(photoId, tagId),
|
||||||
getPhotoTags: (photoId) => s.tagOfPhoto.all(photoId),
|
getPhotoTags: (photoId) => s.tagOfPhoto.all(photoId),
|
||||||
listAllTags: () => s.tagList.all(),
|
listAllTags: () => s.tagList.all(),
|
||||||
|
|
||||||
// Collections
|
|
||||||
listCollections: () => s.colList.all(),
|
listCollections: () => s.colList.all(),
|
||||||
createCollection: (name, desc) => {
|
createCollection: (name, desc) => {
|
||||||
const info = s.colInsert.run(name, desc ?? null);
|
const info = s.colInsert.run(name, desc ?? null);
|
||||||
return { id: info.lastInsertRowid, name, description: desc ?? null };
|
return { id: info.lastInsertRowid, name, description: desc ?? null };
|
||||||
},
|
},
|
||||||
addPhotoToCollection: (photoId, colId) => s.colAdd.run(photoId, colId),
|
addPhotoToCollection: (photoId, colId) => s.colAdd.run(photoId, colId),
|
||||||
removePhotoFromCollection:(photoId, colId) => s.colRemove.run(photoId, colId),
|
removePhotoFromCollection: (photoId, colId) => s.colRemove.run(photoId, colId),
|
||||||
getPhotoCollections: (photoId) => s.colOfPhoto.all(photoId),
|
getPhotoCollections: (photoId) => s.colOfPhoto.all(photoId),
|
||||||
|
|
||||||
// Duplicates
|
|
||||||
getDupePairs: () => s.dupePairs.all(),
|
getDupePairs: () => s.dupePairs.all(),
|
||||||
dismissDuplicates: (ids) => {
|
dismissDuplicates: (ids) => {
|
||||||
const ph = ids.map(() => '?').join(',');
|
const ph = ids.map(() => '?').join(',');
|
||||||
@@ -157,5 +173,18 @@ export function openDb(dbPath) {
|
|||||||
WHERE photo_id_a IN (${ph}) AND photo_id_b IN (${ph})`
|
WHERE photo_id_a IN (${ph}) AND photo_id_b IN (${ph})`
|
||||||
).run(...ids, ...ids);
|
).run(...ids, ...ids);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
getIngestStatus: () =>
|
||||||
|
s.ingestGet.get() ?? { active: 0, current: null, scanned: 0, errors: 0 },
|
||||||
|
|
||||||
|
setIngestStatus: ({ active, current = null, scanned = 0, errors = 0 }) =>
|
||||||
|
s.ingestSet.run({
|
||||||
|
active: active ? 1 : 0,
|
||||||
|
current,
|
||||||
|
scanned,
|
||||||
|
errors,
|
||||||
|
started_at: active ? new Date().toISOString() : null,
|
||||||
|
updated_at: new Date().toISOString(),
|
||||||
|
}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
+15
-6
@@ -13,22 +13,31 @@ const db = openDb(DB_PATH);
|
|||||||
console.log(`[worker] watching ${path.resolve(PHOTO_DIR)}`);
|
console.log(`[worker] watching ${path.resolve(PHOTO_DIR)}`);
|
||||||
console.log(`[worker] db at ${path.resolve(DB_PATH)} (${db.count()} photos indexed)`);
|
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 = [];
|
const queue = [];
|
||||||
let busy = false;
|
let busy = false;
|
||||||
|
let scanned = 0;
|
||||||
|
let errors = 0;
|
||||||
|
|
||||||
async function drain() {
|
async function drain() {
|
||||||
if (busy) return;
|
if (busy) return;
|
||||||
busy = true;
|
busy = true;
|
||||||
|
|
||||||
|
db.setIngestStatus({ active: true, scanned, errors });
|
||||||
|
|
||||||
while (queue.length > 0) {
|
while (queue.length > 0) {
|
||||||
const fp = queue.shift();
|
const fp = queue.shift();
|
||||||
try {
|
try {
|
||||||
await ingestFile(fp, db, POSTER_DIR);
|
await ingestFile(fp, db, POSTER_DIR);
|
||||||
|
scanned++;
|
||||||
|
db.setIngestStatus({ active: true, current: path.basename(fp), scanned, errors });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
errors++;
|
||||||
console.error(`[worker] ${path.basename(fp)}: ${err.message}`);
|
console.error(`[worker] ${path.basename(fp)}: ${err.message}`);
|
||||||
|
db.setIngestStatus({ active: true, current: path.basename(fp), scanned, errors });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
db.setIngestStatus({ active: false, current: null, scanned, errors });
|
||||||
busy = false;
|
busy = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -36,7 +45,7 @@ const watcher = chokidar.watch(PHOTO_DIR, {
|
|||||||
persistent: true,
|
persistent: true,
|
||||||
ignoreInitial: false,
|
ignoreInitial: false,
|
||||||
awaitWriteFinish: { stabilityThreshold: 2000, pollInterval: 100 },
|
awaitWriteFinish: { stabilityThreshold: 2000, pollInterval: 100 },
|
||||||
ignored: /(^|[/\\])\../, // skip dotfiles
|
ignored: /(^|[/\\])\../,
|
||||||
});
|
});
|
||||||
|
|
||||||
watcher.on('add', (fp) => {
|
watcher.on('add', (fp) => {
|
||||||
@@ -46,8 +55,7 @@ watcher.on('add', (fp) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
watcher.on('unlink', (fp) => {
|
watcher.on('unlink', (fp) => {
|
||||||
const resolved = path.resolve(fp);
|
db.removePhoto(path.resolve(fp));
|
||||||
db.removePhoto(resolved);
|
|
||||||
console.log(`[worker] removed ${path.basename(fp)}`);
|
console.log(`[worker] removed ${path.basename(fp)}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -55,6 +63,7 @@ watcher.on('error', (err) => console.error('[worker] watcher error:', err));
|
|||||||
|
|
||||||
process.on('SIGINT', () => {
|
process.on('SIGINT', () => {
|
||||||
console.log('\n[worker] shutting down');
|
console.log('\n[worker] shutting down');
|
||||||
|
db.setIngestStatus({ active: false, current: null, scanned, errors });
|
||||||
watcher.close();
|
watcher.close();
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
});
|
});
|
||||||
Reference in new issue
Block a user