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,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 { 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}
|
||||
|
||||
Reference in new issue
Block a user