feat: recently added albums, timeline scrubber, soft delete & trash

- 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>
This commit is contained in:
nmemmertandClaude Sonnet 4.6 committed 2026-08-18 15:14:59 -04:00
1 parent 51c752602a
commit d35836131a
10 files changed
+367 -32

No files matched your search

+41 -7
View File
@@ -1,19 +1,21 @@
import { useState, useEffect, useRef, useCallback } from 'react';
import { fetchPhotos, fetchLibraryStats, fetchVideoStats, bulkStar, bulkAddToCollection } from './api/photos.js';
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: '' };
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;
@@ -121,17 +123,35 @@ export default function App() {
} catch {}
}
function handleSelectAlbum({ type, collectionId, year, city, camera }) {
const f = { q: '', from: '', to: '', tagId: '', collectionId: '', starred: '', city: '' };
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 === '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);
@@ -154,7 +174,7 @@ export default function App() {
loadStats();
}
const hasFilter = filters.q || filters.from || filters.to || filters.tagId || filters.collectionId || filters.starred || filters.city;
const hasFilter = filters.q || filters.from || filters.to || filters.tagId || filters.collectionId || filters.starred || filters.city || filters.importedFrom;
return (
<>
@@ -194,6 +214,7 @@ export default function App() {
</span>
)}
</Tab>
<Tab active={view === 'trash'} onClick={() => { setView('trash'); clearSelection(); }}>Trash</Tab>
</nav>
{/* Right side: ingest badge + gear */}
@@ -324,9 +345,21 @@ export default function App() {
}
/>
)}
{/* 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={{
@@ -373,6 +406,7 @@ export default function App() {
onClose={() => { setLbPhotos(null); setLbIndex(null); }}
onChange={setLbIndex}
onStarChange={handleStarChange}
onDelete={handleDeletePhoto}
/>
)}
</>
+19 -1
View File
@@ -10,7 +10,7 @@ const json = (body) => ({
});
// Library
export function fetchPhotos({ cursor, limit = 100, q, from, to, tagId, collectionId, starred, city } = {}) {
export function fetchPhotos({ cursor, limit = 100, q, from, to, tagId, collectionId, starred, city, importedFrom } = {}) {
const p = new URLSearchParams({ limit });
if (cursor) p.set('cursor', cursor);
if (q) p.set('q', q);
@@ -20,6 +20,7 @@ export function fetchPhotos({ cursor, limit = 100, q, from, to, tagId, collectio
if (collectionId) p.set('collectionId', collectionId);
if (starred) p.set('starred', starred);
if (city) p.set('city', city);
if (importedFrom) p.set('importedFrom', importedFrom);
return api(`/api/photos?${p}`);
}
@@ -143,6 +144,23 @@ export function deleteCollection(id) {
return api(`/api/collections/${id}`, { method: 'DELETE' });
}
// Trash / delete
export function deletePhoto(id) {
return api(`/api/photos/${id}`, { method: 'DELETE' });
}
export function restorePhoto(id) {
return api(`/api/photos/${id}/restore`, { method: 'POST' });
}
export function permanentDeletePhoto(id) {
return api(`/api/photos/${id}/permanent`, { method: 'DELETE' });
}
export function fetchTrash() {
return api('/api/trash');
}
// Bulk operations
export function bulkStar(ids, starred) {
return api('/api/photos/bulk', { method: 'PATCH', ...json({ ids, starred }) });
+17
View File
@@ -98,6 +98,23 @@ export default function AlbumsView({ onSelectAlbum }) {
</p>
</div>
{auto.recent?.length > 0 && (
<>
<SubHeader>Recently Added</SubHeader>
<AlbumGrid>
{auto.recent.map(r => (
<AlbumCard
key={r.days}
cover={r.cover_url}
title={r.label}
count={r.count}
onClick={() => onSelectAlbum({ type: 'recent', days: r.days, name: r.label })}
/>
))}
</AlbumGrid>
</>
)}
{auto.years.length > 0 && (
<>
<SubHeader>Years</SubHeader>
+14 -2
View File
@@ -2,7 +2,7 @@ import { useState, useEffect } from 'react';
import { toggleStar } from '../api/photos.js';
import DetailPanel from './DetailPanel.jsx';
export default function Lightbox({ photos, index, onClose, onChange, onStarChange }) {
export default function Lightbox({ photos, index, onClose, onChange, onStarChange, onDelete }) {
const [showInfo, setShowInfo] = useState(false);
const [starring, setStarring] = useState(false);
const photo = photos[index];
@@ -72,6 +72,17 @@ export default function Lightbox({ photos, index, onClose, onChange, onStarChang
>
↓
</a>
{onDelete && (
<IconBtn
onClick={() => {
if (confirm('Move this photo to Trash?')) onDelete(photo.id);
}}
title="Move to Trash"
style={{ color: '#f87171' }}
>
🗑
</IconBtn>
)}
<IconBtn
onClick={() => setShowInfo(s => !s)}
active={showInfo}
@@ -123,7 +134,7 @@ export default function Lightbox({ photos, index, onClose, onChange, onStarChang
);
}
function IconBtn({ onClick, active, title, children }) {
function IconBtn({ onClick, active, title, style: extraStyle, children }) {
return (
<button
onClick={onClick}
@@ -138,6 +149,7 @@ function IconBtn({ onClick, active, title, children }) {
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: 14,
transition: 'background 0.1s',
...extraStyle,
}}
>
{children}
+2 -2
View File
@@ -1,8 +1,8 @@
import PhotoCard from './PhotoCard.jsx';
export default function MonthGroup({ label, photos, startIdx, onSelect, selected, onToggle, selectionActive }) {
export default function MonthGroup({ label, photos, startIdx, onSelect, selected, onToggle, selectionActive, yearId }) {
return (
<section style={{ marginBottom: 8 }}>
<section id={yearId} style={{ marginBottom: 8 }}>
<h2 style={{
padding: '18px 6px 8px',
fontSize: 13,
+5
View File
@@ -27,12 +27,16 @@ export default function Timeline({ photos, onSelect, selected, onToggle, selecti
const groups = groupByMonth(photos);
let globalIdx = 0;
const seenYears = new Set();
return (
<main style={{ padding: '0 3px' }}>
{groups.map(group => {
const startIdx = globalIdx;
globalIdx += group.photos.length;
const year = group.key.slice(0, 4);
const isFirstOfYear = !seenYears.has(year);
if (isFirstOfYear) seenYears.add(year);
return (
<MonthGroup
key={group.key}
@@ -43,6 +47,7 @@ export default function Timeline({ photos, onSelect, selected, onToggle, selecti
selected={selected}
onToggle={onToggle}
selectionActive={selectionActive}
yearId={isFirstOfYear ? `year-${year}` : undefined}
/>
);
})}
@@ -0,0 +1,75 @@
import { useState, useEffect } from 'react';
export default function TimelineScrubber({ years }) {
const [activeYear, setActiveYear] = useState(null);
// Track which year is currently visible at the top of the viewport
useEffect(() => {
function onScroll() {
const header = 52;
for (const { year } of years) {
const el = document.getElementById(`year-${year}`);
if (!el) continue;
const rect = el.getBoundingClientRect();
if (rect.top <= header + 40) setActiveYear(year);
}
}
window.addEventListener('scroll', onScroll, { passive: true });
return () => window.removeEventListener('scroll', onScroll);
}, [years]);
if (!years?.length || years.length < 2) return null;
function scrollToYear(year) {
const el = document.getElementById(`year-${year}`);
if (!el) return;
const top = el.getBoundingClientRect().top + window.scrollY - 64;
window.scrollTo({ top, behavior: 'smooth' });
}
return (
<div style={{
position: 'fixed',
right: 0,
top: 52,
bottom: 0,
width: 38,
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
alignItems: 'center',
zIndex: 50,
pointerEvents: 'none',
}}>
<div style={{ pointerEvents: 'auto', display: 'flex', flexDirection: 'column', gap: 0 }}>
{years.map(({ year }) => {
const active = year === activeYear;
return (
<button
key={year}
onClick={() => scrollToYear(year)}
title={year}
style={{
background: 'none',
border: 'none',
padding: '2px 6px',
color: active ? '#9fc8ff' : '#3a3a3a',
cursor: 'pointer',
fontSize: 10,
fontFamily: "'SF Mono', 'Fira Code', monospace",
fontWeight: active ? 700 : 400,
lineHeight: 1.6,
textAlign: 'right',
transition: 'color 0.12s',
}}
onMouseEnter={e => { if (!active) e.currentTarget.style.color = '#888'; }}
onMouseLeave={e => { if (!active) e.currentTarget.style.color = '#3a3a3a'; }}
>
{year}
</button>
);
})}
</div>
</div>
);
}
+137
View File
@@ -0,0 +1,137 @@
import { useState, useEffect } from 'react';
import { fetchTrash, restorePhoto, permanentDeletePhoto } from '../api/photos.js';
export default function TrashView() {
const [photos, setPhotos] = useState(null);
useEffect(() => { load(); }, []);
async function load() {
try { const d = await fetchTrash(); setPhotos(d.photos); }
catch { setPhotos([]); }
}
async function handleRestore(id) {
await restorePhoto(id);
setPhotos(prev => prev.filter(p => p.id !== id));
}
async function handleDelete(id) {
if (!confirm('Permanently remove this photo from the library? This cannot be undone.')) return;
await permanentDeletePhoto(id);
setPhotos(prev => prev.filter(p => p.id !== id));
}
async function handleRestoreAll() {
if (!photos?.length) return;
await Promise.all(photos.map(p => restorePhoto(p.id)));
setPhotos([]);
}
async function handleDeleteAll() {
if (!photos?.length) return;
if (!confirm(`Permanently remove all ${photos.length} photos from the library? This cannot be undone.`)) return;
await Promise.all(photos.map(p => permanentDeletePhoto(p.id)));
setPhotos([]);
}
if (!photos) return <p style={{ padding: 24, color: '#777', fontSize: 13 }}>Loading…</p>;
return (
<div style={{ padding: '28px 24px', maxWidth: 1100, margin: '0 auto' }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 8 }}>
<div>
<h2 style={{ fontSize: 18, fontWeight: 700, color: '#ddd', margin: 0 }}>Trash</h2>
<p style={{ fontSize: 12, color: '#555', marginTop: 4 }}>
Trashed photos are hidden from your library but not deleted from disk.
</p>
</div>
{photos.length > 0 && (
<div style={{ display: 'flex', gap: 8 }}>
<button onClick={handleRestoreAll} style={actionBtn('#2a3a2a', '#5a9')}>
Restore all
</button>
<button onClick={handleDeleteAll} style={actionBtn('#3a1a1a', '#f87171')}>
Delete all forever
</button>
</div>
)}
</div>
{photos.length === 0 ? (
<div style={{ textAlign: 'center', paddingTop: 80, color: '#444' }}>
<div style={{ fontSize: 40, marginBottom: 14, opacity: 0.4 }}>🗑</div>
<p style={{ fontSize: 14 }}>Trash is empty</p>
</div>
) : (
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(200px, 1fr))',
gap: 12,
marginTop: 24,
}}>
{photos.map(photo => (
<TrashCard
key={photo.id}
photo={photo}
onRestore={() => handleRestore(photo.id)}
onDelete={() => handleDelete(photo.id)}
/>
))}
</div>
)}
</div>
);
}
function TrashCard({ photo, onRestore, onDelete }) {
const [hovered, setHovered] = useState(false);
return (
<div
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
style={{ position: 'relative', borderRadius: 8, overflow: 'hidden', background: '#141414' }}
>
<div style={{ aspectRatio: '1', position: 'relative' }}>
{photo.thumb_url ? (
<img
src={photo.thumb_url}
alt=""
style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block', opacity: 0.55 }}
/>
) : (
<div style={{ width: '100%', height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#2a2a2a', fontSize: 24 }}>▪</div>
)}
{hovered && (
<div style={{
position: 'absolute', inset: 0,
display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 8,
background: 'rgba(0,0,0,0.6)',
}}>
<button onClick={onRestore} style={overlayBtn('#5a9')}>Restore</button>
<button onClick={onDelete} style={overlayBtn('#f87171')}>Delete forever</button>
</div>
)}
</div>
<div style={{ padding: '7px 10px', fontSize: 11, color: '#555' }}>
{photo.date_taken
? new Date(photo.date_taken).toLocaleDateString('en-US', { dateStyle: 'medium' })
: 'Unknown date'}
</div>
</div>
);
}
const overlayBtn = (color) => ({
background: 'rgba(0,0,0,0.7)', border: `1px solid ${color}`,
borderRadius: 6, color, cursor: 'pointer', fontSize: 12,
padding: '6px 18px', fontWeight: 600,
});
const actionBtn = (bg, color) => ({
background: bg, border: `1px solid ${color}33`,
borderRadius: 7, color, cursor: 'pointer', fontSize: 12,
padding: '6px 14px',
});