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:
1 parent
64f7906b22
commit
3429ec1d80
8 files changed
+264
-25
No files matched your search
@@ -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 }}>
|
||||
|
||||
@@ -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 ── */}
|
||||
|
||||
Reference in new issue
Block a user