- Smart albums: "Last 7 days" and "Last 30 days" filter by date_imported - Timeline scrubber: fixed right-edge year bar with click-to-jump navigation - Soft delete: trash photos from lightbox; restore or permanently delete from Trash view - EXIF columns: iso, aperture, shutter_speed, focal_length, lens_model - Bulk actions: star/unstar and add-to-album with multi-select - Download original button in lightbox toolbar - EXIF detail expansion in detail panel Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
438 lines
16 KiB
React
438 lines
16 KiB
React
import { useState, useEffect, useRef, useCallback } from 'react';
|
|
import { fetchPhotos, fetchLibraryStats, fetchVideoStats, bulkStar, bulkAddToCollection, deletePhoto } from './api/photos.js';
|
|
import Timeline from './components/Timeline.jsx';
|
|
import Lightbox from './components/Lightbox.jsx';
|
|
import FilterSidebar from './components/FilterSidebar.jsx';
|
|
import IngestBadge from './components/IngestBadge.jsx';
|
|
import DuplicatesView from './components/DuplicatesView.jsx';
|
|
import SettingsPanel from './components/SettingsPanel.jsx';
|
|
import TimelineScrubber from './components/TimelineScrubber.jsx';
|
|
|
|
import { lazy, Suspense } from 'react';
|
|
const MapView = lazy(() => import('./components/MapView.jsx'));
|
|
const MemoriesView = lazy(() => import('./components/MemoriesView.jsx'));
|
|
const VideosView = lazy(() => import('./components/VideosView.jsx'));
|
|
const AlbumsView = lazy(() => import('./components/AlbumsView.jsx'));
|
|
const TrashView = lazy(() => import('./components/TrashView.jsx'));
|
|
|
|
const EMPTY_FILTERS = { q: '', from: '', to: '', tagId: '', collectionId: '', starred: '', city: '', importedFrom: '' };
|
|
const EMPTY_VIDEO_FILTERS = { q: '', from: '', to: '', tagId: '', collectionId: '', starred: '', city: '' };
|
|
const SIDEBAR_W = 210;
|
|
|
|
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);
|
|
|
|
const [filters, setFilters] = useState(EMPTY_FILTERS);
|
|
const [stats, setStats] = useState(null);
|
|
const [videoFilters, setVideoFilters] = useState(EMPTY_VIDEO_FILTERS);
|
|
const [videoStats, setVideoStats] = useState(null);
|
|
|
|
const [lbPhotos, setLbPhotos] = useState(null);
|
|
const [lbIndex, setLbIndex] = useState(null);
|
|
const [showSettings, setShowSettings] = useState(false);
|
|
const [selected, setSelected] = useState(new Set());
|
|
|
|
const loadingRef = useRef(false);
|
|
const sentinelRef = useRef(null);
|
|
|
|
const loadStats = useCallback(() => {
|
|
fetchLibraryStats().then(setStats).catch(() => {});
|
|
}, []);
|
|
|
|
useEffect(() => { loadStats(); }, []);
|
|
|
|
useEffect(() => {
|
|
if (view === 'videos') {
|
|
fetchVideoStats().then(setVideoStats).catch(() => {});
|
|
}
|
|
}, [view]);
|
|
|
|
// 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 toggleSelect(photoId) {
|
|
setSelected(prev => {
|
|
const next = new Set(prev);
|
|
next.has(photoId) ? next.delete(photoId) : next.add(photoId);
|
|
return next;
|
|
});
|
|
}
|
|
|
|
function clearSelection() { setSelected(new Set()); }
|
|
|
|
async function handleBulkStar(starred) {
|
|
const ids = [...selected];
|
|
try {
|
|
await bulkStar(ids, starred);
|
|
const idSet = new Set(ids);
|
|
setPhotos(prev => prev.map(p => idSet.has(p.id) ? { ...p, starred } : p));
|
|
clearSelection();
|
|
loadStats();
|
|
} catch {}
|
|
}
|
|
|
|
async function handleBulkAddToCollection(collectionId) {
|
|
if (!collectionId) return;
|
|
try {
|
|
await bulkAddToCollection([...selected], Number(collectionId));
|
|
clearSelection();
|
|
} catch {}
|
|
}
|
|
|
|
function handleSelectAlbum({ type, collectionId, year, city, camera, days }) {
|
|
const f = { ...EMPTY_FILTERS };
|
|
if (type === 'collection') f.collectionId = String(collectionId);
|
|
else if (type === 'year') { f.from = `${year}-01-01`; f.to = `${year}-12-31`; }
|
|
else if (type === 'city') f.city = city;
|
|
else if (type === 'camera') f.q = camera;
|
|
else if (type === 'recent') {
|
|
const d = new Date();
|
|
d.setDate(d.getDate() - days);
|
|
f.importedFrom = d.toISOString().slice(0, 10);
|
|
}
|
|
setFilters(f);
|
|
setView('library');
|
|
clearSelection();
|
|
}
|
|
|
|
async function handleDeletePhoto(photoId) {
|
|
try {
|
|
await deletePhoto(photoId);
|
|
setPhotos(prev => prev.filter(p => p.id !== photoId));
|
|
if (lbPhotos) {
|
|
const next = lbPhotos.filter(p => p.id !== photoId);
|
|
if (next.length === 0) { setLbPhotos(null); setLbIndex(null); }
|
|
else { setLbPhotos(next); setLbIndex(i => Math.min(i, next.length - 1)); }
|
|
}
|
|
loadStats();
|
|
} catch {}
|
|
}
|
|
|
|
function openLightbox(photosArr, idx) {
|
|
setLbPhotos(photosArr);
|
|
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 || filters.city || filters.importedFrom;
|
|
|
|
return (
|
|
<>
|
|
{/* ── Header ── */}
|
|
<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 }}>
|
|
<Tab active={view === 'library'} onClick={() => { setView('library'); clearSelection(); }}>
|
|
Library
|
|
{total !== null && (
|
|
<span style={{ marginLeft: 6, fontSize: 12, color: view === 'library' ? '#777' : '#666' }}>
|
|
{total.toLocaleString()}
|
|
</span>
|
|
)}
|
|
</Tab>
|
|
<Tab active={view === 'albums'} onClick={() => { setView('albums'); clearSelection(); }}>Albums</Tab>
|
|
<Tab active={view === 'videos'} onClick={() => { setView('videos'); clearSelection(); }}>Videos</Tab>
|
|
<Tab active={view === 'memories'} onClick={() => { setView('memories'); clearSelection(); }}>Memories</Tab>
|
|
<Tab active={view === 'map'} onClick={() => { setView('map'); clearSelection(); }}>Map</Tab>
|
|
<Tab active={view === 'duplicates'} onClick={() => { setView('duplicates'); clearSelection(); }}>
|
|
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>
|
|
<Tab active={view === 'trash'} onClick={() => { setView('trash'); clearSelection(); }}>Trash</Tab>
|
|
</nav>
|
|
|
|
{/* Right side: ingest badge + gear */}
|
|
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'flex-end', gap: 10 }}>
|
|
<IngestBadge onScanComplete={handleScanComplete} />
|
|
<button
|
|
onClick={() => setShowSettings(s => !s)}
|
|
title="Settings"
|
|
style={{
|
|
background: showSettings ? '#1e2a3a' : 'transparent',
|
|
border: 'none', borderRadius: 6,
|
|
color: showSettings ? '#7ab8f5' : '#888',
|
|
cursor: 'pointer', fontSize: 20,
|
|
width: 38, height: 38,
|
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
|
}}
|
|
>
|
|
⚙
|
|
</button>
|
|
</div>
|
|
</header>
|
|
|
|
{/* ── Body (sidebar + content) ── */}
|
|
<div style={{ display: 'flex', alignItems: 'flex-start' }}>
|
|
|
|
{/* Sidebar — library and videos views */}
|
|
{(view === 'library' || view === 'videos') && (
|
|
<aside style={{
|
|
position: 'sticky', top: 52,
|
|
width: SIDEBAR_W, flexShrink: 0,
|
|
height: 'calc(100vh - 52px)', overflowY: 'auto',
|
|
borderRight: '1px solid #141414', background: '#0a0a0a',
|
|
}}>
|
|
{view === 'library' && (
|
|
<FilterSidebar
|
|
filters={filters}
|
|
onChange={setFilters}
|
|
stats={stats}
|
|
total={total}
|
|
/>
|
|
)}
|
|
{view === 'videos' && (
|
|
<FilterSidebar
|
|
filters={videoFilters}
|
|
onChange={setVideoFilters}
|
|
stats={videoStats}
|
|
total={videoStats?.total}
|
|
allLabel="All Videos"
|
|
/>
|
|
)}
|
|
</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>
|
|
)}
|
|
{photos.length === 0 && !loading && !error && (
|
|
<div style={{ textAlign: 'center', padding: '80px 24px', color: '#777' }}>
|
|
{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)}
|
|
selected={selected}
|
|
onToggle={toggleSelect}
|
|
selectionActive={selected.size > 0}
|
|
/>
|
|
<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: '#555', fontSize: 12 }}>
|
|
— {total?.toLocaleString()} photo{total === 1 ? '' : 's'} —
|
|
</p>
|
|
)}
|
|
</>
|
|
)}
|
|
|
|
{/* Albums */}
|
|
{view === 'albums' && (
|
|
<Suspense fallback={<p style={{ padding: 24, color: '#777', fontSize: 13 }}>Loading…</p>}>
|
|
<AlbumsView onSelectAlbum={handleSelectAlbum} />
|
|
</Suspense>
|
|
)}
|
|
|
|
{/* Videos */}
|
|
{view === 'videos' && (
|
|
<Suspense fallback={<p style={{ padding: 24, color: '#777', fontSize: 13 }}>Loading…</p>}>
|
|
<VideosView filters={videoFilters} />
|
|
</Suspense>
|
|
)}
|
|
|
|
{/* Memories */}
|
|
{view === 'memories' && (
|
|
<Suspense fallback={<p style={{ padding: 24, color: '#777', fontSize: 13 }}>Loading…</p>}>
|
|
<MemoriesView />
|
|
</Suspense>
|
|
)}
|
|
|
|
{/* Map */}
|
|
{view === 'map' && (
|
|
<Suspense fallback={<p style={{ padding: 24, color: '#777', 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))
|
|
}
|
|
/>
|
|
)}
|
|
|
|
{/* Trash */}
|
|
{view === 'trash' && (
|
|
<Suspense fallback={<p style={{ padding: 24, color: '#777', fontSize: 13 }}>Loading…</p>}>
|
|
<TrashView />
|
|
</Suspense>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Timeline scrubber — right-edge year nav for library */}
|
|
{view === 'library' && (
|
|
<TimelineScrubber years={stats?.years ?? []} />
|
|
)}
|
|
|
|
{/* Bulk action bar */}
|
|
{selected.size > 0 && view === 'library' && (
|
|
<div style={{
|
|
position: 'fixed', bottom: 20, left: '50%', transform: 'translateX(-50%)',
|
|
background: '#1c1c1c', border: '1px solid #2e2e2e', borderRadius: 12,
|
|
padding: '9px 16px', display: 'flex', alignItems: 'center', gap: 10,
|
|
boxShadow: '0 8px 32px rgba(0,0,0,0.7)',
|
|
zIndex: 500, whiteSpace: 'nowrap',
|
|
}}>
|
|
<span style={{ fontSize: 13, color: '#ccc', fontWeight: 600 }}>
|
|
{selected.size} selected
|
|
</span>
|
|
<div style={{ width: 1, height: 18, background: '#333', flexShrink: 0 }} />
|
|
<button onClick={() => handleBulkStar(true)} style={bulkBtn}>★ Star</button>
|
|
<button onClick={() => handleBulkStar(false)} style={bulkBtn}>☆ Unstar</button>
|
|
{stats?.collections?.length > 0 && (
|
|
<select
|
|
defaultValue=""
|
|
onChange={e => { handleBulkAddToCollection(e.target.value); e.target.value = ''; }}
|
|
style={{
|
|
background: '#111', border: '1px solid #333', borderRadius: 6,
|
|
color: '#ccc', cursor: 'pointer', fontSize: 12, padding: '5px 8px',
|
|
}}
|
|
>
|
|
<option value="">Add to album…</option>
|
|
{stats.collections.map(c => (
|
|
<option key={c.id} value={c.id}>{c.name}</option>
|
|
))}
|
|
</select>
|
|
)}
|
|
<div style={{ width: 1, height: 18, background: '#333', flexShrink: 0 }} />
|
|
<button onClick={clearSelection} style={{ ...bulkBtn, color: '#666' }}>✕ Clear</button>
|
|
</div>
|
|
)}
|
|
|
|
{/* Settings panel */}
|
|
{showSettings && <SettingsPanel onClose={() => setShowSettings(false)} />}
|
|
|
|
{/* Lightbox */}
|
|
{lbPhotos !== null && (
|
|
<Lightbox
|
|
photos={lbPhotos}
|
|
index={lbIndex}
|
|
onClose={() => { setLbPhotos(null); setLbIndex(null); }}
|
|
onChange={setLbIndex}
|
|
onStarChange={handleStarChange}
|
|
onDelete={handleDeletePhoto}
|
|
/>
|
|
)}
|
|
</>
|
|
);
|
|
}
|
|
|
|
const bulkBtn = {
|
|
background: 'transparent', border: '1px solid #333', borderRadius: 6,
|
|
color: '#ccc', cursor: 'pointer', fontSize: 12, padding: '5px 11px',
|
|
};
|
|
|
|
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>
|
|
);
|
|
}
|