diff --git a/client/src/App.jsx b/client/src/App.jsx index edaca26..9ef2e1a 100644 --- a/client/src/App.jsx +++ b/client/src/App.jsx @@ -10,7 +10,7 @@ import SettingsPanel from './components/SettingsPanel.jsx'; import { lazy, Suspense } from 'react'; const MapView = lazy(() => import('./components/MapView.jsx')); -const EMPTY_FILTERS = { q: '', from: '', to: '', tagId: '', collectionId: '', starred: '' }; +const EMPTY_FILTERS = { q: '', from: '', to: '', tagId: '', collectionId: '', starred: '', city: '' }; const SIDEBAR_W = 210; export default function App() { @@ -101,7 +101,7 @@ export default function App() { loadStats(); } - const hasFilter = filters.q || filters.from || filters.to || filters.tagId || filters.collectionId || filters.starred; + const hasFilter = filters.q || filters.from || filters.to || filters.tagId || filters.collectionId || filters.starred || filters.city; return ( <> diff --git a/client/src/api/photos.js b/client/src/api/photos.js index 87d20c7..9f6d0bb 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 } = {}) { +export function fetchPhotos({ cursor, limit = 100, q, from, to, tagId, collectionId, starred, city } = {}) { const p = new URLSearchParams({ limit }); if (cursor) p.set('cursor', cursor); if (q) p.set('q', q); @@ -19,9 +19,14 @@ export function fetchPhotos({ cursor, limit = 100, q, from, to, tagId, collectio if (tagId) p.set('tagId', tagId); if (collectionId) p.set('collectionId', collectionId); if (starred) p.set('starred', starred); + if (city) p.set('city', city); return api(`/api/photos?${p}`); } +export function fetchDirBrowser(dirPath) { + return api(`/api/fs/browse?path=${encodeURIComponent(dirPath)}`); +} + export function fetchPhotoDetail(id) { return api(`/api/photos/${id}`); } diff --git a/client/src/components/FilterSidebar.jsx b/client/src/components/FilterSidebar.jsx index 84ec792..3ddb225 100644 --- a/client/src/components/FilterSidebar.jsx +++ b/client/src/components/FilterSidebar.jsx @@ -9,27 +9,27 @@ export default function FilterSidebar({ filters, onChange, stats, total }) { setExpanded(e => ({ ...e, [section]: !e[section] })); } - // Derive active sidebar selection - const selectedYear = (filters.from && filters.to) - ? filters.from.slice(0, 4) - : null; - const isStarred = filters.starred === '1'; - const isAll = !isStarred && !selectedYear && !filters.tagId && !filters.collectionId; + const selectedYear = (filters.from && filters.to) ? filters.from.slice(0, 4) : null; + const isStarred = filters.starred === '1'; + const isAll = !isStarred && !selectedYear && !filters.tagId && !filters.collectionId && !filters.city; function selectAll() { - onChange({ q: filters.q, from: '', to: '', tagId: '', collectionId: '', starred: '' }); + onChange({ q: filters.q, from: '', to: '', tagId: '', collectionId: '', starred: '', city: '' }); } function selectStarred() { - onChange({ q: filters.q, from: '', to: '', tagId: '', collectionId: '', starred: '1' }); + onChange({ q: filters.q, from: '', to: '', tagId: '', collectionId: '', starred: '1', city: '' }); } function selectYear(year) { - onChange({ q: filters.q, from: `${year}-01-01`, to: `${year}-12-31`, tagId: '', collectionId: '', starred: '' }); + onChange({ q: filters.q, from: `${year}-01-01`, to: `${year}-12-31`, tagId: '', collectionId: '', starred: '', city: '' }); } function selectTag(id) { - onChange({ q: filters.q, from: '', to: '', tagId: String(id), collectionId: '', starred: '' }); + onChange({ q: filters.q, from: '', to: '', tagId: String(id), collectionId: '', starred: '', city: '' }); } function selectCollection(id) { - onChange({ q: filters.q, from: '', to: '', tagId: '', collectionId: String(id), starred: '' }); + onChange({ q: filters.q, from: '', to: '', tagId: '', collectionId: String(id), starred: '', city: '' }); + } + function selectCity(city) { + onChange({ q: filters.q, from: '', to: '', tagId: '', collectionId: '', starred: '', city }); } return ( @@ -71,6 +71,26 @@ export default function FilterSidebar({ filters, onChange, stats, total }) { /> )} + {/* Places */} + {stats?.cities?.length > 0 && ( +
+ {(expanded.cities ? stats.cities : stats.cities.slice(0, MAX_DEFAULT)).map(({ city, country, count }) => ( + selectCity(city)} + label={city} + sublabel={country} + count={count} + indent + /> + ))} + {stats.cities.length > MAX_DEFAULT && ( + toggle('cities')} /> + )} +
+ )} + {/* Date Taken */} {stats?.years?.length > 0 && (
@@ -146,7 +166,7 @@ function Section({ title, children }) { ); } -function Row({ active, onClick, icon, iconColor, label, count, indent }) { +function Row({ active, onClick, icon, iconColor, label, sublabel, count, indent }) { return ( + )} + + {browseLoading ? '…' : (browseData?.path ?? '')} + + + + + + {/* Directory list */} +
+ {!browseLoading && browseData?.dirs?.length === 0 && ( +

+ No subdirectories +

+ )} + {browseData?.dirs?.map(name => { + const full = (browseData.path === '/' ? '' : browseData.path) + '/' + name; + return ( + + ); + })} +
+ + )}
{/* ── Ingestion ── */} diff --git a/server/index.js b/server/index.js index 657b5de..a99587a 100644 --- a/server/index.js +++ b/server/index.js @@ -1,6 +1,7 @@ import 'dotenv/config'; import express from 'express'; import path from 'path'; +import fs from 'fs'; import { fileURLToPath } from 'url'; import { openDb } from '../worker/db.js'; import { scan, getGroups } from './dedupe.js'; @@ -37,6 +38,8 @@ function mapPhoto(p) { height: p.height, filesize: p.filesize, starred: p.starred === 1, + place_city: p.place_city ?? null, + place_country: p.place_country ?? null, thumb_url: posterUrl(p.thumb_path), preview_url: posterUrl(p.preview_path), }; @@ -45,7 +48,7 @@ function mapPhoto(p) { 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) { +function buildFilter(q, from, to, tagId, collectionId, starred, city) { const joinParts = []; const joinParams = []; const condParts = []; @@ -66,6 +69,7 @@ function buildFilter(q, from, to, tagId, collectionId, starred) { if (from) { condParts.push('p.date_taken >= ?'); condParams.push(from); } if (to) { condParts.push('p.date_taken <= ?'); condParams.push(to); } if (starred === '1') condParts.push('p.starred = 1'); + if (city) { condParts.push('p.place_city = ?'); condParams.push(city); } return { joinSql: joinParts.join(' '), @@ -106,10 +110,14 @@ app.get('/api/library/stats', (_req, res) => { 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" ).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" + ).all(); res.json({ total: db.count(), starred, years, + cities, tags: db.listAllTags(), collections: db.listCollections(), }); @@ -121,15 +129,16 @@ app.get('/api/health', (_req, res) => res.json({ ok: true, photos: db.count() }) const SELECT_COLS = ` 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.starred + p.camera_model, p.gps_lat, p.gps_lon, p.width, p.height, p.filesize, + p.starred, p.place_city, p.place_country `; 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 } = req.query; + const { q, from, to, tagId, collectionId, starred, city } = req.query; - const { joinSql, whereSql, params } = buildFilter(q, from, to, tagId, collectionId, starred); + const { joinSql, whereSql, params } = buildFilter(q, from, to, tagId, collectionId, starred, city); let cursorSql = ''; const cursorParams = []; @@ -318,6 +327,28 @@ app.post('/api/admin/clear-dismissals', (_req, res) => { res.sendStatus(204); }); +// ── Filesystem browser ──────────────────────────────────────────────────────── + +app.get('/api/fs/browse', (req, res) => { + const raw = (req.query.path ?? '/photos').replace(/\0/g, ''); + const dir = path.resolve('/', raw); // normalize — prevents traversal via ../../ + if (!fs.existsSync(dir) || !fs.statSync(dir).isDirectory()) { + return res.status(404).json({ error: 'not a directory' }); + } + let entries; + try { + entries = fs.readdirSync(dir, { withFileTypes: true }); + } catch { + return res.status(403).json({ error: 'permission denied' }); + } + const dirs = entries + .filter(e => e.isDirectory() && !e.name.startsWith('.')) + .map(e => e.name) + .sort((a, b) => a.localeCompare(b)); + const parent = dir === '/' ? null : path.dirname(dir); + res.json({ path: dir, parent, dirs }); +}); + // ── SPA fallback ────────────────────────────────────────────────────────────── const distPath = path.resolve(__dirname, '../dist'); diff --git a/worker/db.js b/worker/db.js index 8c47cb2..daf258e 100644 --- a/worker/db.js +++ b/worker/db.js @@ -32,7 +32,9 @@ export function openDb(dbPath) { width INTEGER, height INTEGER, filesize INTEGER, - starred INTEGER NOT NULL DEFAULT 0 + starred INTEGER NOT NULL DEFAULT 0, + place_city TEXT, + place_country TEXT ); CREATE INDEX IF NOT EXISTS idx_photos_date_taken ON photos(date_taken); @@ -96,7 +98,10 @@ export function openDb(dbPath) { // Column migrations — run before any indexes that depend on the new column try { db.exec('ALTER TABLE photos ADD COLUMN starred INTEGER NOT NULL DEFAULT 0'); } catch {} - db.exec('CREATE INDEX IF NOT EXISTS idx_photos_starred ON photos(starred)'); + try { db.exec('ALTER TABLE photos ADD COLUMN place_city TEXT'); } catch {} + try { db.exec('ALTER TABLE photos ADD COLUMN place_country 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)'); // Seed default settings const seedSetting = db.prepare('INSERT OR IGNORE INTO settings (key, value) VALUES (?, ?)'); @@ -106,10 +111,12 @@ export function openDb(dbPath) { insert: db.prepare(` INSERT OR IGNORE INTO photos (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) VALUES (@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) `), exists: db.prepare('SELECT 1 FROM photos WHERE filepath = ? LIMIT 1'), remove: db.prepare('DELETE FROM photos WHERE filepath = ?'), diff --git a/worker/geocode.js b/worker/geocode.js new file mode 100644 index 0000000..9b219c5 --- /dev/null +++ b/worker/geocode.js @@ -0,0 +1,36 @@ +const cache = new Map(); +let lastCallMs = 0; + +export async function reverseGeocode(lat, lon) { + const key = `${lat.toFixed(3)},${lon.toFixed(3)}`; + if (cache.has(key)) return cache.get(key); + + // Nominatim ToS: max 1 request/second + const wait = 1100 - (Date.now() - lastCallMs); + if (wait > 0) await new Promise(r => setTimeout(r, wait)); + lastCallMs = Date.now(); + + try { + const url = `https://nominatim.openstreetmap.org/reverse?lat=${lat}&lon=${lon}&format=json&zoom=10`; + const res = await fetch(url, { + headers: { 'User-Agent': 'Luma/1.0 self-hosted-photo-manager' }, + signal: AbortSignal.timeout(8000), + }); + if (!res.ok) throw new Error(res.status); + const data = await res.json(); + const place = { + city: data.address?.city + ?? data.address?.town + ?? data.address?.village + ?? data.address?.county + ?? null, + country: data.address?.country ?? null, + }; + cache.set(key, place); + return place; + } catch (err) { + console.warn(`[geocode] ${lat.toFixed(4)},${lon.toFixed(4)}: ${err.message}`); + cache.set(key, null); + return null; + } +} diff --git a/worker/ingest.js b/worker/ingest.js index 8d6d2b7..025e275 100644 --- a/worker/ingest.js +++ b/worker/ingest.js @@ -4,6 +4,7 @@ import crypto from 'crypto'; import sharp from 'sharp'; import exifr from 'exifr'; import { computeDHash } from './hash.js'; +import { reverseGeocode } from './geocode.js'; const SUPPORTED = new Set([ '.jpg', '.jpeg', '.png', '.webp', '.heic', '.heif', @@ -37,6 +38,13 @@ export async function ingestFile(filepath, db, posterDir, settings = {}) { const gpsLat = exif.latitude ?? null; const gpsLon = exif.longitude ?? null; + let placeCity = null, placeCountry = null; + if (gpsLat !== null && gpsLon !== null) { + const place = await reverseGeocode(gpsLat, gpsLon); + placeCity = place?.city ?? null; + placeCountry = place?.country ?? null; + } + const { dir, thumb, preview } = posterPaths(filepath, posterDir, dateTaken ?? stat.mtime); fs.mkdirSync(dir, { recursive: true }); @@ -78,7 +86,9 @@ export async function ingestFile(filepath, db, posterDir, settings = {}) { phash, width, height, - filesize: stat.size, + filesize: stat.size, + place_city: placeCity, + place_country: placeCountry, }); console.log(`[ingest] ${path.basename(filepath)}`);