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:
1 parent
51c752602a
commit
d35836131a
10 files changed
+363
-28
No files matched your search
+39
-5
@@ -1,19 +1,21 @@
|
|||||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
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 Timeline from './components/Timeline.jsx';
|
||||||
import Lightbox from './components/Lightbox.jsx';
|
import Lightbox from './components/Lightbox.jsx';
|
||||||
import FilterSidebar from './components/FilterSidebar.jsx';
|
import FilterSidebar from './components/FilterSidebar.jsx';
|
||||||
import IngestBadge from './components/IngestBadge.jsx';
|
import IngestBadge from './components/IngestBadge.jsx';
|
||||||
import DuplicatesView from './components/DuplicatesView.jsx';
|
import DuplicatesView from './components/DuplicatesView.jsx';
|
||||||
import SettingsPanel from './components/SettingsPanel.jsx';
|
import SettingsPanel from './components/SettingsPanel.jsx';
|
||||||
|
import TimelineScrubber from './components/TimelineScrubber.jsx';
|
||||||
|
|
||||||
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 MemoriesView = lazy(() => import('./components/MemoriesView.jsx'));
|
const MemoriesView = lazy(() => import('./components/MemoriesView.jsx'));
|
||||||
const VideosView = lazy(() => import('./components/VideosView.jsx'));
|
const VideosView = lazy(() => import('./components/VideosView.jsx'));
|
||||||
const AlbumsView = lazy(() => import('./components/AlbumsView.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 EMPTY_VIDEO_FILTERS = { q: '', from: '', to: '', tagId: '', collectionId: '', starred: '', city: '' };
|
||||||
const SIDEBAR_W = 210;
|
const SIDEBAR_W = 210;
|
||||||
|
|
||||||
@@ -121,17 +123,35 @@ export default function App() {
|
|||||||
} catch {}
|
} catch {}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleSelectAlbum({ type, collectionId, year, city, camera }) {
|
function handleSelectAlbum({ type, collectionId, year, city, camera, days }) {
|
||||||
const f = { q: '', from: '', to: '', tagId: '', collectionId: '', starred: '', city: '' };
|
const f = { ...EMPTY_FILTERS };
|
||||||
if (type === 'collection') f.collectionId = String(collectionId);
|
if (type === 'collection') f.collectionId = String(collectionId);
|
||||||
else if (type === 'year') { f.from = `${year}-01-01`; f.to = `${year}-12-31`; }
|
else if (type === 'year') { f.from = `${year}-01-01`; f.to = `${year}-12-31`; }
|
||||||
else if (type === 'city') f.city = city;
|
else if (type === 'city') f.city = city;
|
||||||
else if (type === 'camera') f.q = camera;
|
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);
|
setFilters(f);
|
||||||
setView('library');
|
setView('library');
|
||||||
clearSelection();
|
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) {
|
function openLightbox(photosArr, idx) {
|
||||||
setLbPhotos(photosArr);
|
setLbPhotos(photosArr);
|
||||||
setLbIndex(idx);
|
setLbIndex(idx);
|
||||||
@@ -154,7 +174,7 @@ export default function App() {
|
|||||||
loadStats();
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -194,6 +214,7 @@ export default function App() {
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</Tab>
|
</Tab>
|
||||||
|
<Tab active={view === 'trash'} onClick={() => { setView('trash'); clearSelection(); }}>Trash</Tab>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
{/* Right side: ingest badge + gear */}
|
{/* 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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Timeline scrubber — right-edge year nav for library */}
|
||||||
|
{view === 'library' && (
|
||||||
|
<TimelineScrubber years={stats?.years ?? []} />
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Bulk action bar */}
|
{/* Bulk action bar */}
|
||||||
{selected.size > 0 && view === 'library' && (
|
{selected.size > 0 && view === 'library' && (
|
||||||
<div style={{
|
<div style={{
|
||||||
@@ -373,6 +406,7 @@ export default function App() {
|
|||||||
onClose={() => { setLbPhotos(null); setLbIndex(null); }}
|
onClose={() => { setLbPhotos(null); setLbIndex(null); }}
|
||||||
onChange={setLbIndex}
|
onChange={setLbIndex}
|
||||||
onStarChange={handleStarChange}
|
onStarChange={handleStarChange}
|
||||||
|
onDelete={handleDeletePhoto}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ const json = (body) => ({
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Library
|
// 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 });
|
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);
|
||||||
@@ -20,6 +20,7 @@ export function fetchPhotos({ cursor, limit = 100, q, from, to, tagId, collectio
|
|||||||
if (collectionId) p.set('collectionId', collectionId);
|
if (collectionId) p.set('collectionId', collectionId);
|
||||||
if (starred) p.set('starred', starred);
|
if (starred) p.set('starred', starred);
|
||||||
if (city) p.set('city', city);
|
if (city) p.set('city', city);
|
||||||
|
if (importedFrom) p.set('importedFrom', importedFrom);
|
||||||
return api(`/api/photos?${p}`);
|
return api(`/api/photos?${p}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -143,6 +144,23 @@ export function deleteCollection(id) {
|
|||||||
return api(`/api/collections/${id}`, { method: 'DELETE' });
|
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
|
// Bulk operations
|
||||||
export function bulkStar(ids, starred) {
|
export function bulkStar(ids, starred) {
|
||||||
return api('/api/photos/bulk', { method: 'PATCH', ...json({ ids, starred }) });
|
return api('/api/photos/bulk', { method: 'PATCH', ...json({ ids, starred }) });
|
||||||
|
|||||||
@@ -98,6 +98,23 @@ export default function AlbumsView({ onSelectAlbum }) {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</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 && (
|
{auto.years.length > 0 && (
|
||||||
<>
|
<>
|
||||||
<SubHeader>Years</SubHeader>
|
<SubHeader>Years</SubHeader>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { useState, useEffect } from 'react';
|
|||||||
import { toggleStar } from '../api/photos.js';
|
import { toggleStar } from '../api/photos.js';
|
||||||
import DetailPanel from './DetailPanel.jsx';
|
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 [showInfo, setShowInfo] = useState(false);
|
||||||
const [starring, setStarring] = useState(false);
|
const [starring, setStarring] = useState(false);
|
||||||
const photo = photos[index];
|
const photo = photos[index];
|
||||||
@@ -72,6 +72,17 @@ export default function Lightbox({ photos, index, onClose, onChange, onStarChang
|
|||||||
>
|
>
|
||||||
↓
|
↓
|
||||||
</a>
|
</a>
|
||||||
|
{onDelete && (
|
||||||
|
<IconBtn
|
||||||
|
onClick={() => {
|
||||||
|
if (confirm('Move this photo to Trash?')) onDelete(photo.id);
|
||||||
|
}}
|
||||||
|
title="Move to Trash"
|
||||||
|
style={{ color: '#f87171' }}
|
||||||
|
>
|
||||||
|
🗑
|
||||||
|
</IconBtn>
|
||||||
|
)}
|
||||||
<IconBtn
|
<IconBtn
|
||||||
onClick={() => setShowInfo(s => !s)}
|
onClick={() => setShowInfo(s => !s)}
|
||||||
active={showInfo}
|
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 (
|
return (
|
||||||
<button
|
<button
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
@@ -138,6 +149,7 @@ function IconBtn({ onClick, active, title, children }) {
|
|||||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
transition: 'background 0.1s',
|
transition: 'background 0.1s',
|
||||||
|
...extraStyle,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import PhotoCard from './PhotoCard.jsx';
|
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 (
|
return (
|
||||||
<section style={{ marginBottom: 8 }}>
|
<section id={yearId} style={{ marginBottom: 8 }}>
|
||||||
<h2 style={{
|
<h2 style={{
|
||||||
padding: '18px 6px 8px',
|
padding: '18px 6px 8px',
|
||||||
fontSize: 13,
|
fontSize: 13,
|
||||||
|
|||||||
@@ -27,12 +27,16 @@ export default function Timeline({ photos, onSelect, selected, onToggle, selecti
|
|||||||
|
|
||||||
const groups = groupByMonth(photos);
|
const groups = groupByMonth(photos);
|
||||||
let globalIdx = 0;
|
let globalIdx = 0;
|
||||||
|
const seenYears = new Set();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main style={{ padding: '0 3px' }}>
|
<main style={{ padding: '0 3px' }}>
|
||||||
{groups.map(group => {
|
{groups.map(group => {
|
||||||
const startIdx = globalIdx;
|
const startIdx = globalIdx;
|
||||||
globalIdx += group.photos.length;
|
globalIdx += group.photos.length;
|
||||||
|
const year = group.key.slice(0, 4);
|
||||||
|
const isFirstOfYear = !seenYears.has(year);
|
||||||
|
if (isFirstOfYear) seenYears.add(year);
|
||||||
return (
|
return (
|
||||||
<MonthGroup
|
<MonthGroup
|
||||||
key={group.key}
|
key={group.key}
|
||||||
@@ -43,6 +47,7 @@ export default function Timeline({ photos, onSelect, selected, onToggle, selecti
|
|||||||
selected={selected}
|
selected={selected}
|
||||||
onToggle={onToggle}
|
onToggle={onToggle}
|
||||||
selectionActive={selectionActive}
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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',
|
||||||
|
});
|
||||||
+53
-18
@@ -174,10 +174,10 @@ function videoMime(fp) { return VIDEO_MIME[path.extname(fp).toLowerCase()] ?? 'v
|
|||||||
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' }); }
|
||||||
|
|
||||||
function buildFilter(q, from, to, tagId, collectionId, starred, city) {
|
function buildFilter(q, from, to, tagId, collectionId, starred, city, importedFrom) {
|
||||||
const joinParts = [];
|
const joinParts = [];
|
||||||
const joinParams = [];
|
const joinParams = [];
|
||||||
const condParts = [];
|
const condParts = ['p.deleted = 0'];
|
||||||
const condParams = [];
|
const condParams = [];
|
||||||
|
|
||||||
if (tagId) {
|
if (tagId) {
|
||||||
@@ -194,12 +194,13 @@ function buildFilter(q, from, to, tagId, collectionId, starred, city) {
|
|||||||
}
|
}
|
||||||
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 (importedFrom) { condParts.push('p.date_imported >= ?'); condParams.push(importedFrom); }
|
||||||
if (starred === '1') condParts.push('p.starred = 1');
|
if (starred === '1') condParts.push('p.starred = 1');
|
||||||
if (city) { condParts.push('p.place_city = ?'); condParams.push(city); }
|
if (city) { condParts.push('p.place_city = ?'); condParams.push(city); }
|
||||||
|
|
||||||
return {
|
return {
|
||||||
joinSql: joinParts.join(' '),
|
joinSql: joinParts.join(' '),
|
||||||
whereSql: condParts.length ? `WHERE ${condParts.join(' AND ')}` : '',
|
whereSql: `WHERE ${condParts.join(' AND ')}`,
|
||||||
params: [...joinParams, ...condParams],
|
params: [...joinParams, ...condParams],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -232,15 +233,15 @@ app.get('/api/ingest/stream', (req, res) => {
|
|||||||
// ── Library stats ─────────────────────────────────────────────────────────────
|
// ── Library stats ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
app.get('/api/library/stats', (_req, res) => {
|
app.get('/api/library/stats', (_req, res) => {
|
||||||
const starred = db.raw.prepare('SELECT COUNT(*) AS n FROM photos WHERE starred = 1').get().n;
|
const starred = db.raw.prepare('SELECT COUNT(*) AS n FROM photos WHERE starred = 1 AND deleted = 0').get().n;
|
||||||
const years = db.raw.prepare(
|
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"
|
"SELECT strftime('%Y', date_taken) AS year, COUNT(*) AS count FROM photos WHERE date_taken IS NOT NULL AND deleted = 0 GROUP BY year ORDER BY year DESC"
|
||||||
).all();
|
).all();
|
||||||
const cities = db.raw.prepare(
|
const cities = db.raw.prepare(
|
||||||
"SELECT place_city AS city, place_country AS country, COUNT(*) AS count FROM photos WHERE place_city IS NOT NULL GROUP BY place_city ORDER BY count DESC LIMIT 100"
|
"SELECT place_city AS city, place_country AS country, COUNT(*) AS count FROM photos WHERE place_city IS NOT NULL AND deleted = 0 GROUP BY place_city ORDER BY count DESC LIMIT 100"
|
||||||
).all();
|
).all();
|
||||||
res.json({
|
res.json({
|
||||||
total: db.count(),
|
total: db.raw.prepare('SELECT COUNT(*) AS n FROM photos WHERE deleted = 0').get().n,
|
||||||
starred,
|
starred,
|
||||||
years,
|
years,
|
||||||
cities,
|
cities,
|
||||||
@@ -263,11 +264,11 @@ const SELECT_COLS = `
|
|||||||
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, starred, city } = req.query;
|
const { q, from, to, tagId, collectionId, starred, city, importedFrom } = req.query;
|
||||||
|
|
||||||
const { joinSql, whereSql, params } = buildFilter(q, from, to, tagId, collectionId, starred, city);
|
const { joinSql, whereSql, params } = buildFilter(q, from, to, tagId, collectionId, starred, city, importedFrom);
|
||||||
|
|
||||||
// Exclude videos from the photo library — they have their own tab
|
// Exclude videos and deleted from the photo library
|
||||||
const photoOnlyClause = "(p.media_type IS NULL OR p.media_type = 'photo')";
|
const photoOnlyClause = "(p.media_type IS NULL OR p.media_type = 'photo')";
|
||||||
const fullWhere = whereSql ? `${whereSql} AND ${photoOnlyClause}` : `WHERE ${photoOnlyClause}`;
|
const fullWhere = whereSql ? `${whereSql} AND ${photoOnlyClause}` : `WHERE ${photoOnlyClause}`;
|
||||||
|
|
||||||
@@ -305,7 +306,7 @@ app.get('/api/photos', (req, res) => {
|
|||||||
app.get('/api/photos/map', (_req, res) => {
|
app.get('/api/photos/map', (_req, res) => {
|
||||||
const rows = db.raw.prepare(`
|
const rows = db.raw.prepare(`
|
||||||
SELECT id, gps_lat, gps_lon, thumb_path, preview_path, date_taken
|
SELECT id, gps_lat, gps_lon, thumb_path, preview_path, date_taken
|
||||||
FROM photos WHERE gps_lat IS NOT NULL AND gps_lon IS NOT NULL
|
FROM photos WHERE gps_lat IS NOT NULL AND gps_lon IS NOT NULL AND deleted = 0
|
||||||
`).all();
|
`).all();
|
||||||
res.json(rows.map(r => ({
|
res.json(rows.map(r => ({
|
||||||
id: r.id,
|
id: r.id,
|
||||||
@@ -337,6 +338,28 @@ app.get('/api/photos/:id', (req, res) => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.delete('/api/photos/:id', (req, res) => {
|
||||||
|
db.raw.prepare('UPDATE photos SET deleted = 1 WHERE id = ?').run(parseInt(req.params.id, 10));
|
||||||
|
res.sendStatus(204);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/photos/:id/restore', (req, res) => {
|
||||||
|
db.raw.prepare('UPDATE photos SET deleted = 0 WHERE id = ?').run(parseInt(req.params.id, 10));
|
||||||
|
res.sendStatus(204);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.delete('/api/photos/:id/permanent', (req, res) => {
|
||||||
|
db.raw.prepare('DELETE FROM photos WHERE id = ?').run(parseInt(req.params.id, 10));
|
||||||
|
res.sendStatus(204);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/trash', (_req, res) => {
|
||||||
|
const rows = db.raw.prepare(
|
||||||
|
`SELECT ${SELECT_COLS} FROM photos p WHERE p.deleted = 1 ORDER BY p.date_taken DESC, p.id DESC`
|
||||||
|
).all();
|
||||||
|
res.json({ photos: rows.map(mapPhoto), total: rows.length });
|
||||||
|
});
|
||||||
|
|
||||||
app.patch('/api/photos/bulk', (req, res) => {
|
app.patch('/api/photos/bulk', (req, res) => {
|
||||||
const ids = Array.isArray(req.body.ids) ? req.body.ids.map(Number).filter(Boolean) : [];
|
const ids = Array.isArray(req.body.ids) ? req.body.ids.map(Number).filter(Boolean) : [];
|
||||||
if (!ids.length) return badRequest(res, 'ids required');
|
if (!ids.length) return badRequest(res, 'ids required');
|
||||||
@@ -417,13 +440,15 @@ app.get('/api/albums', (_req, res) => {
|
|||||||
GROUP BY c.id ORDER BY c.name
|
GROUP BY c.id ORDER BY c.name
|
||||||
`).all();
|
`).all();
|
||||||
|
|
||||||
|
const PHOTO_ONLY = "deleted = 0 AND (media_type IS NULL OR media_type = 'photo')";
|
||||||
|
|
||||||
const years = db.raw.prepare(`
|
const years = db.raw.prepare(`
|
||||||
SELECT year, count, cover_path FROM (
|
SELECT year, count, cover_path FROM (
|
||||||
SELECT strftime('%Y', date_taken) AS year,
|
SELECT strftime('%Y', date_taken) AS year,
|
||||||
COUNT(*) OVER (PARTITION BY strftime('%Y', date_taken)) AS count,
|
COUNT(*) OVER (PARTITION BY strftime('%Y', date_taken)) AS count,
|
||||||
thumb_path AS cover_path,
|
thumb_path AS cover_path,
|
||||||
ROW_NUMBER() OVER (PARTITION BY strftime('%Y', date_taken) ORDER BY date_taken DESC, id DESC) AS rn
|
ROW_NUMBER() OVER (PARTITION BY strftime('%Y', date_taken) ORDER BY date_taken DESC, id DESC) AS rn
|
||||||
FROM photos WHERE date_taken IS NOT NULL AND (media_type IS NULL OR media_type = 'photo')
|
FROM photos WHERE date_taken IS NOT NULL AND ${PHOTO_ONLY}
|
||||||
) WHERE rn = 1 ORDER BY year DESC
|
) WHERE rn = 1 ORDER BY year DESC
|
||||||
`).all();
|
`).all();
|
||||||
|
|
||||||
@@ -433,7 +458,7 @@ app.get('/api/albums', (_req, res) => {
|
|||||||
COUNT(*) OVER (PARTITION BY place_city) AS count,
|
COUNT(*) OVER (PARTITION BY place_city) AS count,
|
||||||
thumb_path AS cover_path,
|
thumb_path AS cover_path,
|
||||||
ROW_NUMBER() OVER (PARTITION BY place_city ORDER BY date_taken DESC, id DESC) AS rn
|
ROW_NUMBER() OVER (PARTITION BY place_city ORDER BY date_taken DESC, id DESC) AS rn
|
||||||
FROM photos WHERE place_city IS NOT NULL AND (media_type IS NULL OR media_type = 'photo')
|
FROM photos WHERE place_city IS NOT NULL AND ${PHOTO_ONLY}
|
||||||
) WHERE rn = 1 ORDER BY count DESC LIMIT 50
|
) WHERE rn = 1 ORDER BY count DESC LIMIT 50
|
||||||
`).all();
|
`).all();
|
||||||
|
|
||||||
@@ -443,13 +468,22 @@ app.get('/api/albums', (_req, res) => {
|
|||||||
COUNT(*) OVER (PARTITION BY camera_model) AS count,
|
COUNT(*) OVER (PARTITION BY camera_model) AS count,
|
||||||
thumb_path AS cover_path,
|
thumb_path AS cover_path,
|
||||||
ROW_NUMBER() OVER (PARTITION BY camera_model ORDER BY date_taken DESC, id DESC) AS rn
|
ROW_NUMBER() OVER (PARTITION BY camera_model ORDER BY date_taken DESC, id DESC) AS rn
|
||||||
FROM photos WHERE camera_model IS NOT NULL AND (media_type IS NULL OR media_type = 'photo')
|
FROM photos WHERE camera_model IS NOT NULL AND ${PHOTO_ONLY}
|
||||||
) WHERE rn = 1 ORDER BY count DESC
|
) WHERE rn = 1 ORDER BY count DESC
|
||||||
`).all();
|
`).all();
|
||||||
|
|
||||||
|
const recent = [7, 30].map(days => {
|
||||||
|
const since = new Date(Date.now() - days * 864e5).toISOString();
|
||||||
|
const count = db.raw.prepare(`SELECT COUNT(*) AS n FROM photos WHERE date_imported >= ? AND ${PHOTO_ONLY}`).get(since).n;
|
||||||
|
if (!count) return null;
|
||||||
|
const cover = db.raw.prepare(`SELECT thumb_path FROM photos WHERE date_imported >= ? AND ${PHOTO_ONLY} ORDER BY date_imported DESC LIMIT 1`).get(since);
|
||||||
|
return { days, label: days === 7 ? 'Last 7 days' : 'Last 30 days', count, cover_url: posterUrl(cover?.thumb_path) };
|
||||||
|
}).filter(Boolean);
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
manual: manual.map(c => ({ ...c, cover_url: posterUrl(c.cover_path), cover_path: undefined })),
|
manual: manual.map(c => ({ ...c, cover_url: posterUrl(c.cover_path), cover_path: undefined })),
|
||||||
auto: {
|
auto: {
|
||||||
|
recent,
|
||||||
years: years.map(r => ({ year: r.year, count: r.count, cover_url: posterUrl(r.cover_path) })),
|
years: years.map(r => ({ year: r.year, count: r.count, cover_url: posterUrl(r.cover_path) })),
|
||||||
cities: cities.map(r => ({ city: r.city, country: r.country, count: r.count, cover_url: posterUrl(r.cover_path) })),
|
cities: cities.map(r => ({ city: r.city, country: r.country, count: r.count, cover_url: posterUrl(r.cover_path) })),
|
||||||
cameras: cameras.map(r => ({ camera: r.camera, count: r.count, cover_url: posterUrl(r.cover_path) })),
|
cameras: cameras.map(r => ({ camera: r.camera, count: r.count, cover_url: posterUrl(r.cover_path) })),
|
||||||
@@ -618,19 +652,19 @@ app.post('/api/admin/clear-dismissals', (_req, res) => {
|
|||||||
// ── Videos ────────────────────────────────────────────────────────────────────
|
// ── Videos ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
app.get('/api/videos/stats', (_req, res) => {
|
app.get('/api/videos/stats', (_req, res) => {
|
||||||
const total = db.raw.prepare("SELECT COUNT(*) AS n FROM photos WHERE media_type = 'video'").get().n;
|
const total = db.raw.prepare("SELECT COUNT(*) AS n FROM photos WHERE media_type = 'video' AND deleted = 0").get().n;
|
||||||
const years = db.raw.prepare(
|
const years = db.raw.prepare(
|
||||||
"SELECT strftime('%Y', date_taken) AS year, COUNT(*) AS count FROM photos WHERE media_type = 'video' AND date_taken IS NOT NULL GROUP BY year ORDER BY year DESC"
|
"SELECT strftime('%Y', date_taken) AS year, COUNT(*) AS count FROM photos WHERE media_type = 'video' AND deleted = 0 AND date_taken IS NOT NULL GROUP BY year ORDER BY year DESC"
|
||||||
).all();
|
).all();
|
||||||
const cities = db.raw.prepare(
|
const cities = db.raw.prepare(
|
||||||
"SELECT place_city AS city, place_country AS country, COUNT(*) AS count FROM photos WHERE media_type = 'video' AND place_city IS NOT NULL GROUP BY place_city ORDER BY count DESC LIMIT 100"
|
"SELECT place_city AS city, place_country AS country, COUNT(*) AS count FROM photos WHERE media_type = 'video' AND deleted = 0 AND place_city IS NOT NULL GROUP BY place_city ORDER BY count DESC LIMIT 100"
|
||||||
).all();
|
).all();
|
||||||
res.json({ total, years, cities, starred: 0, tags: [], collections: [] });
|
res.json({ total, years, cities, starred: 0, tags: [], collections: [] });
|
||||||
});
|
});
|
||||||
|
|
||||||
app.get('/api/videos', (req, res) => {
|
app.get('/api/videos', (req, res) => {
|
||||||
const { from, to, city } = req.query;
|
const { from, to, city } = req.query;
|
||||||
const conditions = ["p.media_type = 'video'"];
|
const conditions = ["p.media_type = 'video'", "p.deleted = 0"];
|
||||||
const params = [];
|
const params = [];
|
||||||
if (from) { conditions.push('p.date_taken >= ?'); params.push(from); }
|
if (from) { conditions.push('p.date_taken >= ?'); params.push(from); }
|
||||||
if (to) { conditions.push('p.date_taken <= ?'); params.push(to); }
|
if (to) { conditions.push('p.date_taken <= ?'); params.push(to); }
|
||||||
@@ -680,6 +714,7 @@ app.get('/api/memories/today', (req, res) => {
|
|||||||
SELECT ${SELECT_COLS}
|
SELECT ${SELECT_COLS}
|
||||||
FROM photos p
|
FROM photos p
|
||||||
WHERE p.date_taken IS NOT NULL
|
WHERE p.date_taken IS NOT NULL
|
||||||
|
AND p.deleted = 0
|
||||||
AND strftime('%m-%d', p.date_taken) = ?
|
AND strftime('%m-%d', p.date_taken) = ?
|
||||||
ORDER BY p.date_taken ASC
|
ORDER BY p.date_taken ASC
|
||||||
`).all(monthDay);
|
`).all(monthDay);
|
||||||
|
|||||||
@@ -120,7 +120,9 @@ export function openDb(dbPath) {
|
|||||||
try { db.exec('ALTER TABLE photos ADD COLUMN shutter_speed TEXT'); } catch {}
|
try { db.exec('ALTER TABLE photos ADD COLUMN shutter_speed TEXT'); } catch {}
|
||||||
try { db.exec('ALTER TABLE photos ADD COLUMN focal_length REAL'); } catch {}
|
try { db.exec('ALTER TABLE photos ADD COLUMN focal_length REAL'); } catch {}
|
||||||
try { db.exec('ALTER TABLE photos ADD COLUMN lens_model TEXT'); } catch {}
|
try { db.exec('ALTER TABLE photos ADD COLUMN lens_model TEXT'); } catch {}
|
||||||
|
try { db.exec('ALTER TABLE photos ADD COLUMN deleted INTEGER NOT NULL DEFAULT 0'); } catch {}
|
||||||
db.exec('CREATE INDEX IF NOT EXISTS idx_photos_starred ON photos(starred)');
|
db.exec('CREATE INDEX IF NOT EXISTS idx_photos_starred ON photos(starred)');
|
||||||
|
db.exec('CREATE INDEX IF NOT EXISTS idx_photos_deleted ON photos(deleted)');
|
||||||
db.exec('CREATE INDEX IF NOT EXISTS idx_photos_place_city ON photos(place_city)');
|
db.exec('CREATE INDEX IF NOT EXISTS idx_photos_place_city ON photos(place_city)');
|
||||||
db.exec('CREATE INDEX IF NOT EXISTS idx_photos_media_type ON photos(media_type)');
|
db.exec('CREATE INDEX IF NOT EXISTS idx_photos_media_type ON photos(media_type)');
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user