feat: Albums view, auto albums, EXIF details, download, bulk select

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 <noreply@anthropic.com>
This commit is contained in:
nmemmertandClaude Sonnet 4.6 committed 2026-08-18 15:02:52 -04:00
1 parent f8b32dd440
commit 51c752602a
11 files changed
+598 -31

No files matched your search

+9 -2
View File
@@ -115,6 +115,11 @@ export function openDb(dbPath) {
try { db.exec('ALTER TABLE photos ADD COLUMN place_country TEXT'); } catch {}
try { db.exec("ALTER TABLE photos ADD COLUMN media_type TEXT NOT NULL DEFAULT 'photo'"); } catch {}
try { db.exec('ALTER TABLE photos ADD COLUMN duration REAL'); } catch {}
try { db.exec('ALTER TABLE photos ADD COLUMN iso INTEGER'); } catch {}
try { db.exec('ALTER TABLE photos ADD COLUMN aperture REAL'); } 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 lens_model TEXT'); } catch {}
db.exec('CREATE INDEX IF NOT EXISTS idx_photos_starred ON photos(starred)');
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)');
@@ -128,11 +133,13 @@ export function openDb(dbPath) {
INSERT OR IGNORE INTO photos
(filepath, thumb_path, preview_path, date_taken, date_imported,
camera_model, gps_lat, gps_lon, phash, width, height, filesize,
place_city, place_country, media_type, duration)
place_city, place_country, media_type, duration,
iso, aperture, shutter_speed, focal_length, lens_model)
VALUES
(@filepath, @thumb_path, @preview_path, @date_taken, @date_imported,
@camera_model, @gps_lat, @gps_lon, @phash, @width, @height, @filesize,
@place_city, @place_country, @media_type, @duration)
@place_city, @place_country, @media_type, @duration,
@iso, @aperture, @shutter_speed, @focal_length, @lens_model)
`),
exists: db.prepare('SELECT 1 FROM photos WHERE filepath = ? LIMIT 1'),
remove: db.prepare('DELETE FROM photos WHERE filepath = ?'),
+19 -3
View File
@@ -28,6 +28,11 @@ export async function ingestFile(filepath, db, posterDir, settings = {}) {
return ingestPhoto(filepath, db, posterDir, settings);
}
function fmtShutter(t) {
if (t >= 1) return `${t}s`;
return `1/${Math.round(1 / t)}s`;
}
// ── Photo ingest ───────────────────────────────────────────────────────────────
async function ingestPhoto(filepath, db, posterDir, settings) {
@@ -43,12 +48,18 @@ async function ingestPhoto(filepath, db, posterDir, settings) {
let exif = {};
try {
exif = await exifr.parse(filepath, {
pick: ['DateTimeOriginal', 'Make', 'Model', 'Orientation'],
pick: ['DateTimeOriginal', 'Make', 'Model', 'Orientation',
'ISO', 'FNumber', 'ExposureTime', 'FocalLength', 'LensModel'],
}) ?? {};
} catch {}
const dateTaken = exif.DateTimeOriginal ?? null;
const cameraModel = [exif.Make, exif.Model].filter(Boolean).join(' ') || null;
const dateTaken = exif.DateTimeOriginal ?? null;
const cameraModel = [exif.Make, exif.Model].filter(Boolean).join(' ') || null;
const iso = exif.ISO ?? null;
const aperture = exif.FNumber != null ? Math.round(exif.FNumber * 10) / 10 : null;
const focalLength = exif.FocalLength != null ? Math.round(exif.FocalLength) : null;
const lensModel = exif.LensModel ?? null;
const shutterSpeed = exif.ExposureTime != null ? fmtShutter(exif.ExposureTime) : null;
let gpsLat = null, gpsLon = null;
try {
@@ -109,6 +120,11 @@ async function ingestPhoto(filepath, db, posterDir, settings) {
place_country: placeCountry,
media_type: 'photo',
duration: null,
iso,
aperture,
shutter_speed: shutterSpeed,
focal_length: focalLength,
lens_model: lensModel,
});
console.log(`[ingest] ${path.basename(filepath)}`);