diff --git a/client/src/App.jsx b/client/src/App.jsx index 41bc190..d4e087f 100644 --- a/client/src/App.jsx +++ b/client/src/App.jsx @@ -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() { )} + { setView('trash'); clearSelection(); }}>Trash {/* Right side: ingest badge + gear */} @@ -324,9 +345,21 @@ export default function App() { } /> )} + + {/* Trash */} + {view === 'trash' && ( + Loading…

}> + +
+ )} + {/* Timeline scrubber β€” right-edge year nav for library */} + {view === 'library' && ( + + )} + {/* Bulk action bar */} {selected.size > 0 && view === 'library' && (
{ setLbPhotos(null); setLbIndex(null); }} onChange={setLbIndex} onStarChange={handleStarChange} + onDelete={handleDeletePhoto} /> )} diff --git a/client/src/api/photos.js b/client/src/api/photos.js index 03fa471..aeba49d 100644 --- a/client/src/api/photos.js +++ b/client/src/api/photos.js @@ -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 }) }); diff --git a/client/src/components/AlbumsView.jsx b/client/src/components/AlbumsView.jsx index 3eb7ec7..b6c8e77 100644 --- a/client/src/components/AlbumsView.jsx +++ b/client/src/components/AlbumsView.jsx @@ -98,6 +98,23 @@ export default function AlbumsView({ onSelectAlbum }) {

+ {auto.recent?.length > 0 && ( + <> + Recently Added + + {auto.recent.map(r => ( + onSelectAlbum({ type: 'recent', days: r.days, name: r.label })} + /> + ))} + + + )} + {auto.years.length > 0 && ( <> Years diff --git a/client/src/components/Lightbox.jsx b/client/src/components/Lightbox.jsx index e79e6bc..acf7271 100644 --- a/client/src/components/Lightbox.jsx +++ b/client/src/components/Lightbox.jsx @@ -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 > ↓ + {onDelete && ( + { + if (confirm('Move this photo to Trash?')) onDelete(photo.id); + }} + title="Move to Trash" + style={{ color: '#f87171' }} + > + πŸ—‘ + + )} 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 ( + ); + })} + + + ); +} diff --git a/client/src/components/TrashView.jsx b/client/src/components/TrashView.jsx new file mode 100644 index 0000000..7eacaca --- /dev/null +++ b/client/src/components/TrashView.jsx @@ -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

Loading…

; + + return ( +
+ +
+
+

Trash

+

+ Trashed photos are hidden from your library but not deleted from disk. +

+
+ {photos.length > 0 && ( +
+ + +
+ )} +
+ + {photos.length === 0 ? ( +
+
πŸ—‘
+

Trash is empty

+
+ ) : ( +
+ {photos.map(photo => ( + handleRestore(photo.id)} + onDelete={() => handleDelete(photo.id)} + /> + ))} +
+ )} +
+ ); +} + +function TrashCard({ photo, onRestore, onDelete }) { + const [hovered, setHovered] = useState(false); + + return ( +
setHovered(true)} + onMouseLeave={() => setHovered(false)} + style={{ position: 'relative', borderRadius: 8, overflow: 'hidden', background: '#141414' }} + > +
+ {photo.thumb_url ? ( + + ) : ( +
β–ͺ
+ )} + {hovered && ( +
+ + +
+ )} +
+
+ {photo.date_taken + ? new Date(photo.date_taken).toLocaleDateString('en-US', { dateStyle: 'medium' }) + : 'Unknown date'} +
+
+ ); +} + +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', +}); diff --git a/server/index.js b/server/index.js index b737bcf..3dcaa59 100644 --- a/server/index.js +++ b/server/index.js @@ -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 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 joinParams = []; - const condParts = []; + const condParts = ['p.deleted = 0']; const condParams = []; if (tagId) { @@ -192,14 +192,15 @@ function buildFilter(q, from, to, tagId, collectionId, starred, city) { condParts.push('(p.filepath LIKE ? OR p.camera_model LIKE ?)'); condParams.push(`%${q}%`, `%${q}%`); } - if (from) { condParts.push('p.date_taken >= ?'); condParams.push(from); } - if (to) { condParts.push('p.date_taken <= ?'); condParams.push(to); } + if (from) { condParts.push('p.date_taken >= ?'); condParams.push(from); } + 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 (city) { condParts.push('p.place_city = ?'); condParams.push(city); } return { joinSql: joinParts.join(' '), - whereSql: condParts.length ? `WHERE ${condParts.join(' AND ')}` : '', + whereSql: `WHERE ${condParts.join(' AND ')}`, params: [...joinParams, ...condParams], }; } @@ -232,15 +233,15 @@ app.get('/api/ingest/stream', (req, res) => { // ── Library stats ───────────────────────────────────────────────────────────── 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( - "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(); 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(); res.json({ - total: db.count(), + total: db.raw.prepare('SELECT COUNT(*) AS n FROM photos WHERE deleted = 0').get().n, starred, years, cities, @@ -263,11 +264,11 @@ const SELECT_COLS = ` app.get('/api/photos', (req, res) => { const limit = Math.min(parseInt(req.query.limit ?? '100', 10), 500); 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 fullWhere = whereSql ? `${whereSql} AND ${photoOnlyClause}` : `WHERE ${photoOnlyClause}`; @@ -305,7 +306,7 @@ app.get('/api/photos', (req, res) => { app.get('/api/photos/map', (_req, res) => { const rows = db.raw.prepare(` 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(); res.json(rows.map(r => ({ 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) => { const ids = Array.isArray(req.body.ids) ? req.body.ids.map(Number).filter(Boolean) : []; 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 `).all(); + const PHOTO_ONLY = "deleted = 0 AND (media_type IS NULL OR media_type = 'photo')"; + const years = db.raw.prepare(` SELECT year, count, cover_path FROM ( SELECT strftime('%Y', date_taken) AS year, COUNT(*) OVER (PARTITION BY strftime('%Y', date_taken)) AS count, thumb_path AS cover_path, 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 `).all(); @@ -433,7 +458,7 @@ app.get('/api/albums', (_req, res) => { COUNT(*) OVER (PARTITION BY place_city) AS count, thumb_path AS cover_path, 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 `).all(); @@ -443,13 +468,22 @@ app.get('/api/albums', (_req, res) => { COUNT(*) OVER (PARTITION BY camera_model) AS count, thumb_path AS cover_path, 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 `).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({ manual: manual.map(c => ({ ...c, cover_url: posterUrl(c.cover_path), cover_path: undefined })), auto: { + recent, 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) })), 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 ──────────────────────────────────────────────────────────────────── 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( - "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(); 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(); res.json({ total, years, cities, starred: 0, tags: [], collections: [] }); }); app.get('/api/videos', (req, res) => { const { from, to, city } = req.query; - const conditions = ["p.media_type = 'video'"]; + const conditions = ["p.media_type = 'video'", "p.deleted = 0"]; const params = []; if (from) { conditions.push('p.date_taken >= ?'); params.push(from); } if (to) { conditions.push('p.date_taken <= ?'); params.push(to); } @@ -680,6 +714,7 @@ app.get('/api/memories/today', (req, res) => { SELECT ${SELECT_COLS} FROM photos p WHERE p.date_taken IS NOT NULL + AND p.deleted = 0 AND strftime('%m-%d', p.date_taken) = ? ORDER BY p.date_taken ASC `).all(monthDay); diff --git a/worker/db.js b/worker/db.js index 5567a9f..1e50711 100644 --- a/worker/db.js +++ b/worker/db.js @@ -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 focal_length REAL'); } 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_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_media_type ON photos(media_type)');