feat: places (reverse geocoding) + folder browser

Places:
- worker/geocode.js: Nominatim reverse geocoder, 1 req/sec rate limit,
  in-memory cache keyed to 3-decimal-place coordinates
- Ingest calls reverseGeocode() for any photo with GPS data; stores
  place_city and place_country on the photo row
- DB migrations add place_city / place_country columns + index on place_city
- Stats API returns cities grouped by count
- buildFilter() supports ?city= parameter
- FilterSidebar shows a Places section with city list + country sublabel

Folder browser:
- GET /api/fs/browse?path=… lists subdirectories (path-normalized to prevent traversal)
- Settings panel: 📁 button opens inline directory browser below the add
  input — navigate with up arrow / directory clicks, Select fills the input

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
nmemmertandClaude Sonnet 4.6 committed 2026-08-18 08:27:41 -04:00
1 parent 64f7906b22
commit 3429ec1d80
8 files changed
+264 -25

No files matched your search

+37 -12
View File
@@ -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 && (
<Section title="Places">
{(expanded.cities ? stats.cities : stats.cities.slice(0, MAX_DEFAULT)).map(({ city, country, count }) => (
<Row
key={city}
active={filters.city === city}
onClick={() => selectCity(city)}
label={city}
sublabel={country}
count={count}
indent
/>
))}
{stats.cities.length > MAX_DEFAULT && (
<SeeMore expanded={expanded.cities} onClick={() => toggle('cities')} />
)}
</Section>
)}
{/* Date Taken */}
{stats?.years?.length > 0 && (
<Section title="Date Taken">
@@ -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 (
<button
onClick={onClick}
@@ -167,6 +187,11 @@ function Row({ active, onClick, icon, iconColor, label, count, indent }) {
)}
<span style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{label}
{sublabel && (
<span style={{ marginLeft: 5, fontSize: 10, color: active ? '#4a6a8a' : '#2a2a2a' }}>
{sublabel}
</span>
)}
</span>
{count != null && (
<span style={{ fontSize: 11, color: active ? '#5a7a9a' : '#333', flexShrink: 0 }}>
+126 -1
View File
@@ -2,7 +2,7 @@ import { useState, useEffect } from 'react';
import {
fetchSettings, patchSettings,
fetchWatchFolders, addWatchFolder, removeWatchFolder, toggleWatchFolder,
clearDismissals,
clearDismissals, fetchDirBrowser,
} from '../api/photos.js';
export default function SettingsPanel({ onClose }) {
@@ -12,6 +12,11 @@ export default function SettingsPanel({ onClose }) {
const [saving, setSaving] = useState(false);
const [cleared, setCleared] = useState(false);
// Folder browser
const [browseOpen, setBrowseOpen] = useState(false);
const [browseData, setBrowseData] = useState(null); // { path, parent, dirs }
const [browseLoading, setBrowseLoading] = useState(false);
useEffect(() => {
fetchSettings().then(setSettings);
fetchWatchFolders().then(setFolders);
@@ -38,6 +43,32 @@ export default function SettingsPanel({ onClose }) {
setNewPath('');
}
async function openBrowser() {
setBrowseOpen(true);
setBrowseLoading(true);
try {
const data = await fetchDirBrowser('/photos');
setBrowseData(data);
} catch {
setBrowseData({ path: '/', parent: null, dirs: [] });
}
setBrowseLoading(false);
}
async function browseInto(dirPath) {
setBrowseLoading(true);
try {
const data = await fetchDirBrowser(dirPath);
setBrowseData(data);
} catch {}
setBrowseLoading(false);
}
function selectBrowsed() {
if (browseData?.path) setNewPath(browseData.path);
setBrowseOpen(false);
}
async function handleRemoveFolder(id) {
await removeWatchFolder(id);
setFolders(f => f.filter(x => x.id !== id));
@@ -144,6 +175,18 @@ export default function SettingsPanel({ onClose }) {
padding: '5px 8px', outline: 'none', fontFamily: 'monospace',
}}
/>
<button
onClick={openBrowser}
title="Browse filesystem"
style={{
background: browseOpen ? '#1e2f45' : '#181818',
border: '1px solid ' + (browseOpen ? '#2a4a6a' : '#222'),
borderRadius: 5, color: browseOpen ? '#7ab8f5' : '#555',
cursor: 'pointer', padding: '5px 9px', fontSize: 13,
}}
>
📁
</button>
<button
onClick={handleAddFolder}
disabled={!newPath.trim()}
@@ -158,6 +201,88 @@ export default function SettingsPanel({ onClose }) {
Add
</button>
</div>
{/* Inline folder browser */}
{browseOpen && (
<div style={{
margin: '4px 18px 8px',
background: '#0d0d0d', border: '1px solid #222',
borderRadius: 6, overflow: 'hidden',
}}>
{/* Breadcrumb + up */}
<div style={{
display: 'flex', alignItems: 'center', gap: 6,
padding: '6px 10px', borderBottom: '1px solid #1a1a1a',
background: '#131313',
}}>
{browseData?.parent !== null && browseData?.parent !== undefined && (
<button
onClick={() => browseInto(browseData.parent)}
style={{
background: 'none', border: 'none', color: '#4d9eff',
cursor: 'pointer', fontSize: 14, lineHeight: 1, padding: 0, flexShrink: 0,
}}
title="Up"
>↑</button>
)}
<span style={{
flex: 1, fontSize: 11, color: '#666', fontFamily: 'monospace',
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
direction: 'rtl', textAlign: 'left',
}}>
{browseLoading ? '…' : (browseData?.path ?? '')}
</span>
<button
onClick={selectBrowsed}
title="Use this path"
style={{
background: '#1e2f45', border: '1px solid #2a4a6a',
borderRadius: 4, color: '#7ab8f5',
cursor: 'pointer', fontSize: 11, padding: '2px 8px', flexShrink: 0,
}}
>
Select
</button>
<button
onClick={() => setBrowseOpen(false)}
style={{
background: 'none', border: 'none', color: '#444',
cursor: 'pointer', fontSize: 14, lineHeight: 1, padding: 0, flexShrink: 0,
}}
>✕</button>
</div>
{/* Directory list */}
<div style={{ maxHeight: 180, overflowY: 'auto' }}>
{!browseLoading && browseData?.dirs?.length === 0 && (
<p style={{ padding: '10px 12px', color: '#333', fontSize: 12, margin: 0 }}>
No subdirectories
</p>
)}
{browseData?.dirs?.map(name => {
const full = (browseData.path === '/' ? '' : browseData.path) + '/' + name;
return (
<button
key={name}
onClick={() => browseInto(full)}
style={{
display: 'flex', alignItems: 'center', gap: 7,
width: '100%', padding: '5px 10px',
background: 'none', border: 'none', cursor: 'pointer',
color: '#bbb', fontSize: 12, textAlign: 'left',
fontFamily: 'monospace',
}}
onMouseEnter={e => e.currentTarget.style.background = '#1a1a1a'}
onMouseLeave={e => e.currentTarget.style.background = 'none'}
>
<span style={{ color: '#4d9eff', flexShrink: 0 }}>📁</span>
{name}
</button>
);
})}
</div>
</div>
)}
</Section>
{/* ── Ingestion ── */}