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:
nmemmertandClaude Sonnet 4.6 committed 2026-08-17 17:04:01 -04:00
1 parent 4df32f9f32
commit f0e237ab2b
10 files changed
+541 -127

No files matched your search

+110 -69
View File
@@ -1,15 +1,16 @@
import { useState, useEffect, useRef } from 'react';
import { fetchPhotos, fetchTags, fetchCollections } from './api/photos.js';
import { useState, useEffect, useRef, useCallback } from 'react';
import { fetchPhotos, fetchLibraryStats } from './api/photos.js';
import Timeline from './components/Timeline.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';
// 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: '' };
const EMPTY_FILTERS = { q: '', from: '', to: '', tagId: '', collectionId: '', starred: '' };
const SIDEBAR_W = 210;
export default function App() {
const [view, setView] = useState('library');
@@ -20,24 +21,21 @@ export default function App() {
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([]);
const [filters, setFilters] = useState(EMPTY_FILTERS);
const [stats, setStats] = useState(null);
// 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(() => {});
const loadStats = useCallback(() => {
fetchLibraryStats().then(setStats).catch(() => {});
}, []);
useEffect(() => { loadStats(); }, []);
// Reset + reload when filters change (debounce text search)
useEffect(() => {
const delay = filters.q ? 300 : 0;
@@ -84,8 +82,28 @@ export default function App() {
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 (
<>
{/* ── Header ── */}
<header style={{
position: 'sticky', top: 0, zIndex: 100,
background: '#0f0f0f', borderBottom: '1px solid #1c1c1c',
@@ -96,8 +114,8 @@ export default function App() {
Luma
</span>
<nav style={{ display: 'flex', gap: 2, flex: 1 }}>
<Tab active={view === 'library'} onClick={() => setView('library')}>
<nav style={{ display: 'flex', gap: 2 }}>
<Tab active={view === 'library'} onClick={() => setView('library')}>
Library
{total !== null && (
<span style={{ marginLeft: 6, fontSize: 12, color: view === 'library' ? '#555' : '#3a3a3a' }}>
@@ -105,9 +123,7 @@ export default function App() {
</span>
)}
</Tab>
<Tab active={view === 'map'} onClick={() => setView('map')}>
Map
</Tab>
<Tab active={view === 'map'} onClick={() => setView('map')}>Map</Tab>
<Tab active={view === 'duplicates'} onClick={() => setView('duplicates')}>
Duplicates
{dupeCount > 0 && (
@@ -121,69 +137,94 @@ export default function App() {
)}
</Tab>
</nav>
{/* Ingest progress — right side */}
<div style={{ flex: 1, display: 'flex', justifyContent: 'flex-end' }}>
<IngestBadge onScanComplete={handleScanComplete} />
</div>
</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>
</>
{/* ── Body (sidebar + content) ── */}
<div style={{ display: 'flex', alignItems: 'flex-start' }}>
{/* Sidebar — only in library view */}
{view === 'library' && (
<aside style={{
position: 'sticky', top: 52,
width: SIDEBAR_W, flexShrink: 0,
height: 'calc(100vh - 52px)', overflowY: 'auto',
borderRight: '1px solid #141414', background: '#0a0a0a',
}}>
<FilterSidebar
filters={filters}
onChange={setFilters}
stats={stats}
total={total}
/>
</aside>
)}
{/* Main content */}
<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 }} />
{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>
)}
</>
)}
{/* ── 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))
}
/>
)}
</div>
</div>
{/* ── Duplicates ── */}
{view === 'duplicates' && (
<DuplicatesView
onGroupCountChange={setDupeCount}
onOpenPhoto={(photo, group) =>
openLightbox(group, group.findIndex(p => p.id === photo.id))
}
/>
)}
{/* ── Lightbox (shared) ── */}
{/* Lightbox */}
{lbPhotos !== null && (
<Lightbox
photos={lbPhotos}
index={lbIndex}
onClose={() => { setLbPhotos(null); setLbIndex(null); }}
onChange={setLbIndex}
onStarChange={handleStarChange}
/>
)}
</>
+14 -6
View File
@@ -10,7 +10,7 @@ const json = (body) => ({
});
// 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 });
if (cursor) p.set('cursor', cursor);
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 (tagId) p.set('tagId', tagId);
if (collectionId) p.set('collectionId', collectionId);
if (starred) p.set('starred', starred);
return api(`/api/photos?${p}`);
}
@@ -29,7 +30,19 @@ export function fetchMapPhotos() {
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
export function fetchTags() {
return api('/api/tags');
}
export function addTag(photoId, 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' });
}
// Tags + collections (for filter dropdowns)
export function fetchTags() {
return api('/api/tags');
}
// Duplicates
export function fetchDuplicates() {
return api('/api/duplicates');
+194
View File
@@ -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>
);
}
+53
View File
@@ -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>
);
}
+21 -2
View File
@@ -1,8 +1,10 @@
import { useState, useEffect } from 'react';
import { toggleStar } from '../api/photos.js';
import DetailPanel from './DetailPanel.jsx';
export default function Lightbox({ photos, index, onClose, onChange }) {
const [showInfo, setShowInfo] = useState(false);
export default function Lightbox({ photos, index, onClose, onChange, onStarChange }) {
const [showInfo, setShowInfo] = useState(false);
const [starring, setStarring] = useState(false);
const photo = photos[index];
useEffect(() => {
@@ -38,6 +40,23 @@ export default function Lightbox({ photos, index, onClose, onChange }) {
onClick={e => e.stopPropagation()}
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
onClick={() => setShowInfo(s => !s)}
active={showInfo}
+5
View File
@@ -16,6 +16,11 @@ body {
min-height: 100dvh;
}
@keyframes pulse-dot {
0%, 100% { opacity: 1; }
50% { opacity: 0.3; }
}
code {
font-family: 'SF Mono', 'Fira Code', monospace;
background: #1e1e1e;