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:
1 parent
f8b32dd440
commit
51c752602a
11 files changed
+592
-25
No files matched your search
+103
-7
@@ -1,5 +1,5 @@
|
|||||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
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 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';
|
||||||
@@ -11,6 +11,7 @@ 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 EMPTY_FILTERS = { q: '', from: '', to: '', tagId: '', collectionId: '', starred: '', city: '' };
|
const EMPTY_FILTERS = { q: '', from: '', to: '', tagId: '', collectionId: '', starred: '', city: '' };
|
||||||
const EMPTY_VIDEO_FILTERS = { q: '', from: '', to: '', tagId: '', collectionId: '', starred: '', city: '' };
|
const EMPTY_VIDEO_FILTERS = { q: '', from: '', to: '', tagId: '', collectionId: '', starred: '', city: '' };
|
||||||
@@ -33,6 +34,7 @@ export default function App() {
|
|||||||
const [lbPhotos, setLbPhotos] = useState(null);
|
const [lbPhotos, setLbPhotos] = useState(null);
|
||||||
const [lbIndex, setLbIndex] = useState(null);
|
const [lbIndex, setLbIndex] = useState(null);
|
||||||
const [showSettings, setShowSettings] = useState(false);
|
const [showSettings, setShowSettings] = useState(false);
|
||||||
|
const [selected, setSelected] = useState(new Set());
|
||||||
|
|
||||||
const loadingRef = useRef(false);
|
const loadingRef = useRef(false);
|
||||||
const sentinelRef = useRef(null);
|
const sentinelRef = useRef(null);
|
||||||
@@ -90,6 +92,46 @@ export default function App() {
|
|||||||
return () => obs.disconnect();
|
return () => obs.disconnect();
|
||||||
}, [nextCursor, view, filters]);
|
}, [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) {
|
function openLightbox(photosArr, idx) {
|
||||||
setLbPhotos(photosArr);
|
setLbPhotos(photosArr);
|
||||||
setLbIndex(idx);
|
setLbIndex(idx);
|
||||||
@@ -128,7 +170,7 @@ export default function App() {
|
|||||||
</span>
|
</span>
|
||||||
|
|
||||||
<nav style={{ display: 'flex', gap: 2 }}>
|
<nav style={{ display: 'flex', gap: 2 }}>
|
||||||
<Tab active={view === 'library'} onClick={() => setView('library')}>
|
<Tab active={view === 'library'} onClick={() => { setView('library'); clearSelection(); }}>
|
||||||
Library
|
Library
|
||||||
{total !== null && (
|
{total !== null && (
|
||||||
<span style={{ marginLeft: 6, fontSize: 12, color: view === 'library' ? '#777' : '#666' }}>
|
<span style={{ marginLeft: 6, fontSize: 12, color: view === 'library' ? '#777' : '#666' }}>
|
||||||
@@ -136,10 +178,11 @@ export default function App() {
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</Tab>
|
</Tab>
|
||||||
<Tab active={view === 'videos'} onClick={() => setView('videos')}>Videos</Tab>
|
<Tab active={view === 'albums'} onClick={() => { setView('albums'); clearSelection(); }}>Albums</Tab>
|
||||||
<Tab active={view === 'memories'} onClick={() => setView('memories')}>Memories</Tab>
|
<Tab active={view === 'videos'} onClick={() => { setView('videos'); clearSelection(); }}>Videos</Tab>
|
||||||
<Tab active={view === 'map'} onClick={() => setView('map')}>Map</Tab>
|
<Tab active={view === 'memories'} onClick={() => { setView('memories'); clearSelection(); }}>Memories</Tab>
|
||||||
<Tab active={view === 'duplicates'} onClick={() => setView('duplicates')}>
|
<Tab active={view === 'map'} onClick={() => { setView('map'); clearSelection(); }}>Map</Tab>
|
||||||
|
<Tab active={view === 'duplicates'} onClick={() => { setView('duplicates'); clearSelection(); }}>
|
||||||
Duplicates
|
Duplicates
|
||||||
{dupeCount > 0 && (
|
{dupeCount > 0 && (
|
||||||
<span style={{
|
<span style={{
|
||||||
@@ -227,7 +270,13 @@ export default function App() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<Timeline photos={photos} onSelect={(idx) => openLightbox(photos, idx)} />
|
<Timeline
|
||||||
|
photos={photos}
|
||||||
|
onSelect={(idx) => openLightbox(photos, idx)}
|
||||||
|
selected={selected}
|
||||||
|
onToggle={toggleSelect}
|
||||||
|
selectionActive={selected.size > 0}
|
||||||
|
/>
|
||||||
<div ref={sentinelRef} style={{ height: 1 }} />
|
<div ref={sentinelRef} style={{ height: 1 }} />
|
||||||
{loading && <p style={{ textAlign: 'center', padding: 24, color: '#444', fontSize: 13 }}>Loading…</p>}
|
{loading && <p style={{ textAlign: 'center', padding: 24, color: '#444', fontSize: 13 }}>Loading…</p>}
|
||||||
{nextCursor === null && photos.length > 0 && !loading && (
|
{nextCursor === null && photos.length > 0 && !loading && (
|
||||||
@@ -238,6 +287,13 @@ export default function App() {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Albums */}
|
||||||
|
{view === 'albums' && (
|
||||||
|
<Suspense fallback={<p style={{ padding: 24, color: '#777', fontSize: 13 }}>Loading…</p>}>
|
||||||
|
<AlbumsView onSelectAlbum={handleSelectAlbum} />
|
||||||
|
</Suspense>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Videos */}
|
{/* Videos */}
|
||||||
{view === 'videos' && (
|
{view === 'videos' && (
|
||||||
<Suspense fallback={<p style={{ padding: 24, color: '#777', fontSize: 13 }}>Loading…</p>}>
|
<Suspense fallback={<p style={{ padding: 24, color: '#777', fontSize: 13 }}>Loading…</p>}>
|
||||||
@@ -271,6 +327,41 @@ export default function App() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Bulk action bar */}
|
||||||
|
{selected.size > 0 && view === 'library' && (
|
||||||
|
<div style={{
|
||||||
|
position: 'fixed', bottom: 20, left: '50%', transform: 'translateX(-50%)',
|
||||||
|
background: '#1c1c1c', border: '1px solid #2e2e2e', borderRadius: 12,
|
||||||
|
padding: '9px 16px', display: 'flex', alignItems: 'center', gap: 10,
|
||||||
|
boxShadow: '0 8px 32px rgba(0,0,0,0.7)',
|
||||||
|
zIndex: 500, whiteSpace: 'nowrap',
|
||||||
|
}}>
|
||||||
|
<span style={{ fontSize: 13, color: '#ccc', fontWeight: 600 }}>
|
||||||
|
{selected.size} selected
|
||||||
|
</span>
|
||||||
|
<div style={{ width: 1, height: 18, background: '#333', flexShrink: 0 }} />
|
||||||
|
<button onClick={() => handleBulkStar(true)} style={bulkBtn}>★ Star</button>
|
||||||
|
<button onClick={() => handleBulkStar(false)} style={bulkBtn}>☆ Unstar</button>
|
||||||
|
{stats?.collections?.length > 0 && (
|
||||||
|
<select
|
||||||
|
defaultValue=""
|
||||||
|
onChange={e => { handleBulkAddToCollection(e.target.value); e.target.value = ''; }}
|
||||||
|
style={{
|
||||||
|
background: '#111', border: '1px solid #333', borderRadius: 6,
|
||||||
|
color: '#ccc', cursor: 'pointer', fontSize: 12, padding: '5px 8px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value="">Add to album…</option>
|
||||||
|
{stats.collections.map(c => (
|
||||||
|
<option key={c.id} value={c.id}>{c.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
|
<div style={{ width: 1, height: 18, background: '#333', flexShrink: 0 }} />
|
||||||
|
<button onClick={clearSelection} style={{ ...bulkBtn, color: '#666' }}>✕ Clear</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Settings panel */}
|
{/* Settings panel */}
|
||||||
{showSettings && <SettingsPanel onClose={() => setShowSettings(false)} />}
|
{showSettings && <SettingsPanel onClose={() => 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 }) {
|
function Tab({ active, onClick, children }) {
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -130,6 +130,28 @@ export function fetchMemories() {
|
|||||||
return api(`/api/memories/today?date=${date}`);
|
return api(`/api/memories/today?date=${date}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Albums
|
||||||
|
export function fetchAlbums() {
|
||||||
|
return api('/api/albums');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renameCollection(id, name) {
|
||||||
|
return api(`/api/collections/${id}`, { method: 'PATCH', ...json({ name }) });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteCollection(id) {
|
||||||
|
return api(`/api/collections/${id}`, { method: 'DELETE' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bulk operations
|
||||||
|
export function bulkStar(ids, starred) {
|
||||||
|
return api('/api/photos/bulk', { method: 'PATCH', ...json({ ids, starred }) });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function bulkAddToCollection(ids, collectionId) {
|
||||||
|
return api('/api/photos/bulk/collections', { method: 'POST', ...json({ ids, collectionId }) });
|
||||||
|
}
|
||||||
|
|
||||||
// Duplicates
|
// Duplicates
|
||||||
export function fetchDuplicates() {
|
export function fetchDuplicates() {
|
||||||
return api('/api/duplicates');
|
return api('/api/duplicates');
|
||||||
|
|||||||
@@ -0,0 +1,261 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { fetchAlbums, createCollection, deleteCollection } from '../api/photos.js';
|
||||||
|
|
||||||
|
export default function AlbumsView({ onSelectAlbum }) {
|
||||||
|
const [albums, setAlbums] = useState(null);
|
||||||
|
const [creating, setCreating] = useState(false);
|
||||||
|
const [newName, setNewName] = useState('');
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => { load(); }, []);
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
try { setAlbums(await fetchAlbums()); } catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleCreate(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
const name = newName.trim();
|
||||||
|
if (!name || busy) return;
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
await createCollection(name);
|
||||||
|
setNewName('');
|
||||||
|
setCreating(false);
|
||||||
|
await load();
|
||||||
|
} catch {}
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete(id, name) {
|
||||||
|
if (!confirm(`Delete "${name}"? Photos won't be deleted.`)) return;
|
||||||
|
try { await deleteCollection(id); await load(); } catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!albums) return <p style={{ padding: 24, color: '#777', fontSize: 13 }}>Loading…</p>;
|
||||||
|
|
||||||
|
const { manual, auto } = albums;
|
||||||
|
const hasAuto = auto.years.length > 0 || auto.cities.length > 0 || auto.cameras.length > 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ padding: '28px 24px', maxWidth: 1100, margin: '0 auto' }}>
|
||||||
|
|
||||||
|
{/* My Albums */}
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 16 }}>
|
||||||
|
<h2 style={{ fontSize: 18, fontWeight: 700, color: '#ddd', margin: 0 }}>My Albums</h2>
|
||||||
|
<button
|
||||||
|
onClick={() => { setCreating(c => !c); setNewName(''); }}
|
||||||
|
style={newAlbumBtn}
|
||||||
|
>
|
||||||
|
{creating ? 'Cancel' : '+ New Album'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{creating && (
|
||||||
|
<form onSubmit={handleCreate} style={{ marginBottom: 20, display: 'flex', gap: 8, maxWidth: 380 }}>
|
||||||
|
<input
|
||||||
|
autoFocus
|
||||||
|
value={newName}
|
||||||
|
onChange={e => setNewName(e.target.value)}
|
||||||
|
placeholder="Album name…"
|
||||||
|
maxLength={200}
|
||||||
|
style={inputSt}
|
||||||
|
/>
|
||||||
|
<button type="submit" disabled={!newName.trim() || busy} style={submitSt}>
|
||||||
|
Create
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{manual.length === 0 && !creating && (
|
||||||
|
<p style={{ color: '#555', fontSize: 13, marginBottom: 32 }}>
|
||||||
|
No albums yet — create one to start organizing your photos.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{manual.length > 0 && (
|
||||||
|
<AlbumGrid>
|
||||||
|
{manual.map(col => (
|
||||||
|
<AlbumCard
|
||||||
|
key={col.id}
|
||||||
|
cover={col.cover_url}
|
||||||
|
title={col.name}
|
||||||
|
count={col.photo_count}
|
||||||
|
onClick={() => onSelectAlbum({ type: 'collection', collectionId: col.id, name: col.name })}
|
||||||
|
onDelete={() => handleDelete(col.id, col.name)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</AlbumGrid>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Smart Albums */}
|
||||||
|
{hasAuto && (
|
||||||
|
<>
|
||||||
|
<div style={{ marginTop: 48, marginBottom: 16 }}>
|
||||||
|
<h2 style={{ fontSize: 18, fontWeight: 700, color: '#ddd', margin: 0 }}>Smart Albums</h2>
|
||||||
|
<p style={{ fontSize: 12, color: '#555', marginTop: 4 }}>
|
||||||
|
Auto-generated from your library
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{auto.years.length > 0 && (
|
||||||
|
<>
|
||||||
|
<SubHeader>Years</SubHeader>
|
||||||
|
<AlbumGrid>
|
||||||
|
{auto.years.map(r => (
|
||||||
|
<AlbumCard
|
||||||
|
key={r.year}
|
||||||
|
cover={r.cover_url}
|
||||||
|
title={r.year}
|
||||||
|
count={r.count}
|
||||||
|
onClick={() => onSelectAlbum({ type: 'year', year: r.year, name: r.year })}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</AlbumGrid>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{auto.cities.length > 0 && (
|
||||||
|
<>
|
||||||
|
<SubHeader>Places</SubHeader>
|
||||||
|
<AlbumGrid>
|
||||||
|
{auto.cities.map(r => (
|
||||||
|
<AlbumCard
|
||||||
|
key={r.city}
|
||||||
|
cover={r.cover_url}
|
||||||
|
title={r.city}
|
||||||
|
subtitle={r.country}
|
||||||
|
count={r.count}
|
||||||
|
onClick={() => onSelectAlbum({ type: 'city', city: r.city, name: r.city })}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</AlbumGrid>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{auto.cameras.length > 0 && (
|
||||||
|
<>
|
||||||
|
<SubHeader>Cameras</SubHeader>
|
||||||
|
<AlbumGrid>
|
||||||
|
{auto.cameras.map(r => (
|
||||||
|
<AlbumCard
|
||||||
|
key={r.camera}
|
||||||
|
cover={r.cover_url}
|
||||||
|
title={r.camera}
|
||||||
|
count={r.count}
|
||||||
|
onClick={() => onSelectAlbum({ type: 'camera', camera: r.camera, name: r.camera })}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</AlbumGrid>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SubHeader({ children }) {
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
fontSize: 11, fontWeight: 700, color: '#555',
|
||||||
|
textTransform: 'uppercase', letterSpacing: '0.08em',
|
||||||
|
marginTop: 28, marginBottom: 12,
|
||||||
|
}}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlbumGrid({ children }) {
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: 'repeat(auto-fill, minmax(175px, 1fr))',
|
||||||
|
gap: 18,
|
||||||
|
marginBottom: 8,
|
||||||
|
}}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlbumCard({ cover, title, subtitle, count, onClick, onDelete }) {
|
||||||
|
const [hovered, setHovered] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
onClick={onClick}
|
||||||
|
onMouseEnter={() => setHovered(true)}
|
||||||
|
onMouseLeave={() => setHovered(false)}
|
||||||
|
style={{ cursor: 'pointer', userSelect: 'none' }}
|
||||||
|
>
|
||||||
|
<div style={{
|
||||||
|
aspectRatio: '1',
|
||||||
|
borderRadius: 10,
|
||||||
|
overflow: 'hidden',
|
||||||
|
background: '#1a1a1a',
|
||||||
|
position: 'relative',
|
||||||
|
marginBottom: 9,
|
||||||
|
boxShadow: hovered ? '0 6px 24px rgba(0,0,0,0.55)' : '0 2px 10px rgba(0,0,0,0.35)',
|
||||||
|
transform: hovered ? 'scale(1.025)' : 'scale(1)',
|
||||||
|
transition: 'box-shadow 0.18s, transform 0.18s',
|
||||||
|
}}>
|
||||||
|
{cover ? (
|
||||||
|
<img
|
||||||
|
src={cover}
|
||||||
|
alt=""
|
||||||
|
style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div style={{
|
||||||
|
width: '100%', height: '100%',
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
fontSize: 36, color: '#252525',
|
||||||
|
}}>
|
||||||
|
▪
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{onDelete && hovered && (
|
||||||
|
<button
|
||||||
|
onClick={e => { e.stopPropagation(); onDelete(); }}
|
||||||
|
title="Delete album"
|
||||||
|
style={{
|
||||||
|
position: 'absolute', top: 7, right: 7,
|
||||||
|
background: 'rgba(0,0,0,0.72)', border: 'none',
|
||||||
|
borderRadius: 5, color: '#bbb', cursor: 'pointer',
|
||||||
|
width: 26, height: 26, fontSize: 13,
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ fontSize: 13, fontWeight: 600, color: '#ccc', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||||
|
{title}
|
||||||
|
</div>
|
||||||
|
{subtitle && (
|
||||||
|
<div style={{ fontSize: 11, color: '#555', marginTop: 1 }}>{subtitle}</div>
|
||||||
|
)}
|
||||||
|
<div style={{ fontSize: 12, color: '#444', marginTop: 2 }}>
|
||||||
|
{(count ?? 0).toLocaleString()} photo{count === 1 ? '' : 's'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const newAlbumBtn = {
|
||||||
|
background: 'transparent', border: '1px solid #2a2a2a', borderRadius: 7,
|
||||||
|
color: '#7ab8f5', cursor: 'pointer', fontSize: 12, padding: '6px 14px',
|
||||||
|
};
|
||||||
|
const inputSt = {
|
||||||
|
flex: 1, background: '#1a1a1a', border: '1px solid #272727',
|
||||||
|
borderRadius: 7, color: '#ccc', fontSize: 13, padding: '7px 12px', outline: 'none',
|
||||||
|
};
|
||||||
|
const submitSt = {
|
||||||
|
background: '#4d9eff', border: 'none', borderRadius: 7,
|
||||||
|
color: '#fff', cursor: 'pointer', fontSize: 13, padding: '7px 16px', fontWeight: 600,
|
||||||
|
};
|
||||||
@@ -42,9 +42,15 @@ export default function DetailPanel({ photoId }) {
|
|||||||
)}
|
)}
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
{photo.camera_model && (
|
{(photo.camera_model || photo.lens_model || photo.focal_length != null ||
|
||||||
|
photo.aperture != null || photo.shutter_speed || photo.iso != null) && (
|
||||||
<Section label="Camera">
|
<Section label="Camera">
|
||||||
<Row k="Model" v={photo.camera_model} />
|
{photo.camera_model && <Row k="Model" v={photo.camera_model} />}
|
||||||
|
{photo.lens_model && <Row k="Lens" v={photo.lens_model} />}
|
||||||
|
{photo.focal_length != null && <Row k="Focal" v={`${photo.focal_length}mm`} />}
|
||||||
|
{photo.aperture != null && <Row k="Aperture" v={`ƒ/${photo.aperture}`} />}
|
||||||
|
{photo.shutter_speed && <Row k="Shutter" v={photo.shutter_speed} />}
|
||||||
|
{photo.iso != null && <Row k="ISO" v={`ISO ${photo.iso}`} />}
|
||||||
</Section>
|
</Section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -57,6 +57,21 @@ export default function Lightbox({ photos, index, onClose, onChange, onStarChang
|
|||||||
>
|
>
|
||||||
★
|
★
|
||||||
</IconBtn>
|
</IconBtn>
|
||||||
|
<a
|
||||||
|
href={`/api/photos/${photo.id}/download`}
|
||||||
|
download
|
||||||
|
title="Download original"
|
||||||
|
style={{
|
||||||
|
background: 'rgba(255,255,255,0.06)',
|
||||||
|
border: '1px solid rgba(255,255,255,0.1)',
|
||||||
|
borderRadius: 6, color: '#888', cursor: 'pointer',
|
||||||
|
width: 32, height: 32,
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
fontSize: 15, textDecoration: 'none', flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
↓
|
||||||
|
</a>
|
||||||
<IconBtn
|
<IconBtn
|
||||||
onClick={() => setShowInfo(s => !s)}
|
onClick={() => setShowInfo(s => !s)}
|
||||||
active={showInfo}
|
active={showInfo}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import PhotoCard from './PhotoCard.jsx';
|
import PhotoCard from './PhotoCard.jsx';
|
||||||
|
|
||||||
export default function MonthGroup({ label, photos, startIdx, onSelect }) {
|
export default function MonthGroup({ label, photos, startIdx, onSelect, selected, onToggle, selectionActive }) {
|
||||||
return (
|
return (
|
||||||
<section style={{ marginBottom: 8 }}>
|
<section style={{ marginBottom: 8 }}>
|
||||||
<h2 style={{
|
<h2 style={{
|
||||||
@@ -28,6 +28,9 @@ export default function MonthGroup({ label, photos, startIdx, onSelect }) {
|
|||||||
key={photo.id}
|
key={photo.id}
|
||||||
photo={photo}
|
photo={photo}
|
||||||
onClick={() => onSelect(startIdx + i)}
|
onClick={() => onSelect(startIdx + i)}
|
||||||
|
isSelected={selected?.has(photo.id) ?? false}
|
||||||
|
onToggle={onToggle}
|
||||||
|
selectionActive={selectionActive}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,23 +1,61 @@
|
|||||||
import { useState } from 'react';
|
import { useState, memo } from 'react';
|
||||||
|
|
||||||
export default function PhotoCard({ photo, onClick }) {
|
function PhotoCard({ photo, onClick, isSelected, onToggle, selectionActive }) {
|
||||||
const [loaded, setLoaded] = useState(false);
|
const [loaded, setLoaded] = useState(false);
|
||||||
const [error, setError] = useState(false);
|
const [error, setError] = useState(false);
|
||||||
|
const [hovered, setHovered] = useState(false);
|
||||||
|
|
||||||
|
function handleClick() {
|
||||||
|
if (selectionActive) {
|
||||||
|
onToggle?.(photo.id);
|
||||||
|
} else {
|
||||||
|
onClick();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const showCheck = (hovered || isSelected || selectionActive) && onToggle;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
role="button"
|
role="button"
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
onClick={onClick}
|
onClick={handleClick}
|
||||||
onKeyDown={e => e.key === 'Enter' && onClick()}
|
onKeyDown={e => e.key === 'Enter' && handleClick()}
|
||||||
|
onMouseEnter={() => setHovered(true)}
|
||||||
|
onMouseLeave={() => setHovered(false)}
|
||||||
style={{
|
style={{
|
||||||
aspectRatio: '1',
|
aspectRatio: '1',
|
||||||
background: '#181818',
|
background: '#181818',
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
position: 'relative',
|
position: 'relative',
|
||||||
|
outline: isSelected ? '2px solid #4d9eff' : 'none',
|
||||||
|
outlineOffset: '-2px',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
{/* Checkbox */}
|
||||||
|
{showCheck && (
|
||||||
|
<div
|
||||||
|
onClick={e => { e.stopPropagation(); onToggle(photo.id); }}
|
||||||
|
style={{
|
||||||
|
position: 'absolute', top: 6, left: 6, zIndex: 2,
|
||||||
|
width: 20, height: 20, borderRadius: '50%',
|
||||||
|
background: isSelected ? '#4d9eff' : 'rgba(0,0,0,0.55)',
|
||||||
|
border: isSelected ? 'none' : '1.5px solid rgba(255,255,255,0.75)',
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
fontSize: 11, color: '#fff',
|
||||||
|
transition: 'background 0.12s',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isSelected && '✓'}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Selection tint */}
|
||||||
|
{isSelected && (
|
||||||
|
<div style={{ position: 'absolute', inset: 0, background: 'rgba(77,158,255,0.18)', zIndex: 1 }} />
|
||||||
|
)}
|
||||||
|
|
||||||
{photo.thumb_url && !error ? (
|
{photo.thumb_url && !error ? (
|
||||||
<img
|
<img
|
||||||
src={photo.thumb_url}
|
src={photo.thumb_url}
|
||||||
@@ -27,12 +65,8 @@ export default function PhotoCard({ photo, onClick }) {
|
|||||||
onLoad={() => setLoaded(true)}
|
onLoad={() => setLoaded(true)}
|
||||||
onError={() => setError(true)}
|
onError={() => setError(true)}
|
||||||
style={{
|
style={{
|
||||||
width: '100%',
|
width: '100%', height: '100%', objectFit: 'cover', display: 'block',
|
||||||
height: '100%',
|
opacity: loaded ? 1 : 0, transition: 'opacity 0.15s ease',
|
||||||
objectFit: 'cover',
|
|
||||||
display: 'block',
|
|
||||||
opacity: loaded ? 1 : 0,
|
|
||||||
transition: 'opacity 0.15s ease',
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
@@ -47,3 +81,5 @@ export default function PhotoCard({ photo, onClick }) {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default memo(PhotoCard);
|
||||||
@@ -22,7 +22,7 @@ function monthLabel(dateTaken) {
|
|||||||
return new Date(dateTaken).toLocaleDateString('en-US', { month: 'long', year: 'numeric' });
|
return new Date(dateTaken).toLocaleDateString('en-US', { month: 'long', year: 'numeric' });
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Timeline({ photos, onSelect }) {
|
export default function Timeline({ photos, onSelect, selected, onToggle, selectionActive }) {
|
||||||
if (!photos.length) return null;
|
if (!photos.length) return null;
|
||||||
|
|
||||||
const groups = groupByMonth(photos);
|
const groups = groupByMonth(photos);
|
||||||
@@ -40,6 +40,9 @@ export default function Timeline({ photos, onSelect }) {
|
|||||||
photos={group.photos}
|
photos={group.photos}
|
||||||
startIdx={startIdx}
|
startIdx={startIdx}
|
||||||
onSelect={onSelect}
|
onSelect={onSelect}
|
||||||
|
selected={selected}
|
||||||
|
onToggle={onToggle}
|
||||||
|
selectionActive={selectionActive}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
+103
-1
@@ -156,6 +156,11 @@ function mapPhoto(p) {
|
|||||||
preview_url: posterUrl(p.preview_path),
|
preview_url: posterUrl(p.preview_path),
|
||||||
media_type: p.media_type ?? 'photo',
|
media_type: p.media_type ?? 'photo',
|
||||||
duration: p.duration ?? null,
|
duration: p.duration ?? null,
|
||||||
|
iso: p.iso ?? null,
|
||||||
|
aperture: p.aperture ?? null,
|
||||||
|
shutter_speed: p.shutter_speed ?? null,
|
||||||
|
focal_length: p.focal_length ?? null,
|
||||||
|
lens_model: p.lens_model ?? null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -251,7 +256,8 @@ app.get('/api/health', (_req, res) => res.json({ ok: true, photos: db.count() })
|
|||||||
const SELECT_COLS = `
|
const SELECT_COLS = `
|
||||||
p.id, p.thumb_path, p.preview_path, p.date_taken, p.date_imported,
|
p.id, p.thumb_path, p.preview_path, p.date_taken, p.date_imported,
|
||||||
p.camera_model, p.gps_lat, p.gps_lon, p.width, p.height, p.filesize,
|
p.camera_model, p.gps_lat, p.gps_lon, p.width, p.height, p.filesize,
|
||||||
p.starred, p.place_city, p.place_country, p.media_type, p.duration
|
p.starred, p.place_city, p.place_country, p.media_type, p.duration,
|
||||||
|
p.iso, p.aperture, p.shutter_speed, p.focal_length, p.lens_model
|
||||||
`;
|
`;
|
||||||
|
|
||||||
app.get('/api/photos', (req, res) => {
|
app.get('/api/photos', (req, res) => {
|
||||||
@@ -311,6 +317,14 @@ app.get('/api/photos/map', (_req, res) => {
|
|||||||
})));
|
})));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.get('/api/photos/:id/download', (req, res) => {
|
||||||
|
const row = db.raw.prepare('SELECT filepath FROM photos WHERE id = ?').get(req.params.id);
|
||||||
|
if (!row) return notFound(res);
|
||||||
|
if (!fs.existsSync(row.filepath)) return notFound(res);
|
||||||
|
res.setHeader('Content-Disposition', `attachment; filename="${path.basename(row.filepath)}"`);
|
||||||
|
fs.createReadStream(row.filepath).pipe(res);
|
||||||
|
});
|
||||||
|
|
||||||
app.get('/api/photos/:id', (req, res) => {
|
app.get('/api/photos/:id', (req, res) => {
|
||||||
const row = db.raw.prepare('SELECT * FROM photos WHERE id = ?').get(req.params.id);
|
const row = db.raw.prepare('SELECT * FROM photos WHERE id = ?').get(req.params.id);
|
||||||
if (!row) return notFound(res);
|
if (!row) return notFound(res);
|
||||||
@@ -323,6 +337,16 @@ app.get('/api/photos/:id', (req, res) => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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');
|
||||||
|
if ('starred' in req.body) {
|
||||||
|
const ph = ids.map(() => '?').join(',');
|
||||||
|
db.raw.prepare(`UPDATE photos SET starred = ? WHERE id IN (${ph})`).run(req.body.starred ? 1 : 0, ...ids);
|
||||||
|
}
|
||||||
|
res.sendStatus(204);
|
||||||
|
});
|
||||||
|
|
||||||
app.patch('/api/photos/:id', (req, res) => {
|
app.patch('/api/photos/:id', (req, res) => {
|
||||||
const id = parseInt(req.params.id, 10);
|
const id = parseInt(req.params.id, 10);
|
||||||
if ('starred' in req.body) {
|
if ('starred' in req.body) {
|
||||||
@@ -364,6 +388,84 @@ app.post('/api/collections', (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.patch('/api/collections/:id', (req, res) => {
|
||||||
|
const name = (req.body.name ?? '').trim().slice(0, 200);
|
||||||
|
if (!name) return badRequest(res, 'name required');
|
||||||
|
try {
|
||||||
|
db.raw.prepare('UPDATE collections SET name = ? WHERE id = ?').run(name, parseInt(req.params.id, 10));
|
||||||
|
res.sendStatus(204);
|
||||||
|
} catch (e) {
|
||||||
|
if (e.message.includes('UNIQUE')) return badRequest(res, 'collection already exists');
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.delete('/api/collections/:id', (req, res) => {
|
||||||
|
db.raw.prepare('DELETE FROM collections WHERE id = ?').run(parseInt(req.params.id, 10));
|
||||||
|
res.sendStatus(204);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Albums (manual + smart auto-albums) ───────────────────────────────────────
|
||||||
|
|
||||||
|
app.get('/api/albums', (_req, res) => {
|
||||||
|
const manual = db.raw.prepare(`
|
||||||
|
SELECT c.id, c.name, c.description, c.created_at,
|
||||||
|
COUNT(pc.photo_id) AS photo_count,
|
||||||
|
(SELECT p.thumb_path FROM photo_collections pc2 JOIN photos p ON p.id = pc2.photo_id
|
||||||
|
WHERE pc2.collection_id = c.id ORDER BY p.date_taken DESC, p.id DESC LIMIT 1) AS cover_path
|
||||||
|
FROM collections c LEFT JOIN photo_collections pc ON pc.collection_id = c.id
|
||||||
|
GROUP BY c.id ORDER BY c.name
|
||||||
|
`).all();
|
||||||
|
|
||||||
|
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')
|
||||||
|
) WHERE rn = 1 ORDER BY year DESC
|
||||||
|
`).all();
|
||||||
|
|
||||||
|
const cities = db.raw.prepare(`
|
||||||
|
SELECT city, country, count, cover_path FROM (
|
||||||
|
SELECT place_city AS city, place_country AS country,
|
||||||
|
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')
|
||||||
|
) WHERE rn = 1 ORDER BY count DESC LIMIT 50
|
||||||
|
`).all();
|
||||||
|
|
||||||
|
const cameras = db.raw.prepare(`
|
||||||
|
SELECT camera, count, cover_path FROM (
|
||||||
|
SELECT camera_model AS camera,
|
||||||
|
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')
|
||||||
|
) WHERE rn = 1 ORDER BY count DESC
|
||||||
|
`).all();
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
manual: manual.map(c => ({ ...c, cover_url: posterUrl(c.cover_path), cover_path: undefined })),
|
||||||
|
auto: {
|
||||||
|
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) })),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/photos/bulk/collections', (req, res) => {
|
||||||
|
const ids = Array.isArray(req.body.ids) ? req.body.ids.map(Number).filter(Boolean) : [];
|
||||||
|
const colId = parseInt(req.body.collectionId, 10);
|
||||||
|
if (!ids.length || !colId) return badRequest(res, 'ids and collectionId required');
|
||||||
|
const stmt = db.raw.prepare('INSERT OR IGNORE INTO photo_collections (photo_id, collection_id) VALUES (?, ?)');
|
||||||
|
db.raw.transaction(() => { for (const id of ids) stmt.run(id, colId); })();
|
||||||
|
res.sendStatus(204);
|
||||||
|
});
|
||||||
|
|
||||||
app.post('/api/photos/:id/collections', (req, res) => {
|
app.post('/api/photos/:id/collections', (req, res) => {
|
||||||
const colId = parseInt(req.body.collectionId, 10);
|
const colId = parseInt(req.body.collectionId, 10);
|
||||||
if (!colId) return badRequest(res, 'collectionId required');
|
if (!colId) return badRequest(res, 'collectionId required');
|
||||||
|
|||||||
+9
-2
@@ -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 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 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 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_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_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)');
|
||||||
@@ -128,11 +133,13 @@ export function openDb(dbPath) {
|
|||||||
INSERT OR IGNORE INTO photos
|
INSERT OR IGNORE INTO photos
|
||||||
(filepath, thumb_path, preview_path, date_taken, date_imported,
|
(filepath, thumb_path, preview_path, date_taken, date_imported,
|
||||||
camera_model, gps_lat, gps_lon, phash, width, height, filesize,
|
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
|
VALUES
|
||||||
(@filepath, @thumb_path, @preview_path, @date_taken, @date_imported,
|
(@filepath, @thumb_path, @preview_path, @date_taken, @date_imported,
|
||||||
@camera_model, @gps_lat, @gps_lon, @phash, @width, @height, @filesize,
|
@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'),
|
exists: db.prepare('SELECT 1 FROM photos WHERE filepath = ? LIMIT 1'),
|
||||||
remove: db.prepare('DELETE FROM photos WHERE filepath = ?'),
|
remove: db.prepare('DELETE FROM photos WHERE filepath = ?'),
|
||||||
|
|||||||
+17
-1
@@ -28,6 +28,11 @@ export async function ingestFile(filepath, db, posterDir, settings = {}) {
|
|||||||
return ingestPhoto(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 ───────────────────────────────────────────────────────────────
|
// ── Photo ingest ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
async function ingestPhoto(filepath, db, posterDir, settings) {
|
async function ingestPhoto(filepath, db, posterDir, settings) {
|
||||||
@@ -43,12 +48,18 @@ async function ingestPhoto(filepath, db, posterDir, settings) {
|
|||||||
let exif = {};
|
let exif = {};
|
||||||
try {
|
try {
|
||||||
exif = await exifr.parse(filepath, {
|
exif = await exifr.parse(filepath, {
|
||||||
pick: ['DateTimeOriginal', 'Make', 'Model', 'Orientation'],
|
pick: ['DateTimeOriginal', 'Make', 'Model', 'Orientation',
|
||||||
|
'ISO', 'FNumber', 'ExposureTime', 'FocalLength', 'LensModel'],
|
||||||
}) ?? {};
|
}) ?? {};
|
||||||
} catch {}
|
} catch {}
|
||||||
|
|
||||||
const dateTaken = exif.DateTimeOriginal ?? null;
|
const dateTaken = exif.DateTimeOriginal ?? null;
|
||||||
const cameraModel = [exif.Make, exif.Model].filter(Boolean).join(' ') || 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;
|
let gpsLat = null, gpsLon = null;
|
||||||
try {
|
try {
|
||||||
@@ -109,6 +120,11 @@ async function ingestPhoto(filepath, db, posterDir, settings) {
|
|||||||
place_country: placeCountry,
|
place_country: placeCountry,
|
||||||
media_type: 'photo',
|
media_type: 'photo',
|
||||||
duration: null,
|
duration: null,
|
||||||
|
iso,
|
||||||
|
aperture,
|
||||||
|
shutter_speed: shutterSpeed,
|
||||||
|
focal_length: focalLength,
|
||||||
|
lens_model: lensModel,
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log(`[ingest] ${path.basename(filepath)}`);
|
console.log(`[ingest] ${path.basename(filepath)}`);
|
||||||
|
|||||||
Reference in new issue
Block a user