From 51c752602a20c0779837762d56162acd49df2280 Mon Sep 17 00:00:00 2001 From: nmemmert <67961292+nmemmert@users.noreply.github.com> Date: Tue, 18 Aug 2026 15:02:52 -0400 Subject: [PATCH] feat: Albums view, auto albums, EXIF details, download, bulk select MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Albums tab with manual album CRUD (create/delete, cover photo grid) and Smart Albums auto-generated by year, city, and camera model using SQLite window functions — no extra storage, computed from existing data. Lightbox gets a download-original button (streams raw file with Content-Disposition: attachment). DetailPanel Camera section expands to show ISO, aperture, shutter speed, focal length, and lens model extracted at ingest time; five new DB columns added via safe ALTER TABLE migration. Bulk selection: hover a photo to reveal a circle checkbox; selecting any photo activates a floating action bar for star/unstar and add-to-album across the whole selection. PhotoCard wrapped in React.memo to limit re-renders to only affected cards. Co-Authored-By: Claude Sonnet 4.6 --- client/src/App.jsx | 114 ++++++++++- client/src/api/photos.js | 22 +++ client/src/components/AlbumsView.jsx | 261 ++++++++++++++++++++++++++ client/src/components/DetailPanel.jsx | 10 +- client/src/components/Lightbox.jsx | 15 ++ client/src/components/MonthGroup.jsx | 5 +- client/src/components/PhotoCard.jsx | 60 ++++-- client/src/components/Timeline.jsx | 5 +- server/index.js | 104 +++++++++- worker/db.js | 11 +- worker/ingest.js | 22 ++- 11 files changed, 598 insertions(+), 31 deletions(-) create mode 100644 client/src/components/AlbumsView.jsx diff --git a/client/src/App.jsx b/client/src/App.jsx index e2c6f32..41bc190 100644 --- a/client/src/App.jsx +++ b/client/src/App.jsx @@ -1,5 +1,5 @@ import { useState, useEffect, useRef, useCallback } from 'react'; -import { fetchPhotos, fetchLibraryStats, fetchVideoStats } from './api/photos.js'; +import { fetchPhotos, fetchLibraryStats, fetchVideoStats, bulkStar, bulkAddToCollection } from './api/photos.js'; import Timeline from './components/Timeline.jsx'; import Lightbox from './components/Lightbox.jsx'; import FilterSidebar from './components/FilterSidebar.jsx'; @@ -11,6 +11,7 @@ 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 EMPTY_FILTERS = { q: '', from: '', to: '', tagId: '', collectionId: '', starred: '', city: '' }; const EMPTY_VIDEO_FILTERS = { q: '', from: '', to: '', tagId: '', collectionId: '', starred: '', city: '' }; @@ -30,9 +31,10 @@ export default function App() { const [videoFilters, setVideoFilters] = useState(EMPTY_VIDEO_FILTERS); const [videoStats, setVideoStats] = useState(null); - const [lbPhotos, setLbPhotos] = useState(null); - const [lbIndex, setLbIndex] = 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); @@ -90,6 +92,46 @@ export default function App() { 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 }) { + const f = { q: '', from: '', to: '', tagId: '', collectionId: '', starred: '', city: '' }; + 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; + setFilters(f); + setView('library'); + clearSelection(); + } + function openLightbox(photosArr, idx) { setLbPhotos(photosArr); setLbIndex(idx); @@ -128,7 +170,7 @@ export default function App() {