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() {
}>
+
+
+ )}
+
{/* Videos */}
{view === 'videos' && (
Loading…}>
@@ -271,6 +327,41 @@ export default function App() {
+ {/* Bulk action bar */}
+ {selected.size > 0 && view === 'library' && (
+
+
+ {selected.size} selected
+
+
+
+
+ {stats?.collections?.length > 0 && (
+
+ )}
+
+
+
+ )}
+
{/* Settings panel */}
{showSettings && setShowSettings(false)} />}
@@ -288,6 +379,11 @@ export default function App() {
);
}
+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 (