Add geotagging and location map v0.4

This commit is contained in:
dmo committed 2026-07-23 09:44:07 +02:00
1 parent cbf0daf062
commit e290bd1fff
12 files changed
+578 -45

No files matched your search

+211 -9
View File
@@ -8,6 +8,10 @@ const state = {
rotation: 0,
processing: false,
selectionToken: 0,
mapConfig: null,
map: null,
mapMarker: null,
locationDirty: false,
};
const $ = (selector) => document.querySelector(selector);
@@ -55,6 +59,14 @@ const elements = {
description: $('#description-input'),
descriptionCount: $('#description-count'),
keywords: $('#keywords-input'),
addressSearch: $('#address-search'),
addressSearchButton: $('#address-search-button'),
addressResults: $('#address-results'),
locationMap: $('#location-map'),
latitude: $('#latitude-input'),
longitude: $('#longitude-input'),
locationName: $('#location-name-input'),
clearLocation: $('#clear-location'),
saveStatus: $('#save-status'),
shortcutsButton: $('#shortcuts-button'),
shortcutsDialog: $('#shortcuts-dialog'),
@@ -277,6 +289,7 @@ function formatMetadataSummary(values) {
if (values.description?.trim()) parts.push('description set');
const keywordCount = parseKeywords(values.keywords || '').length;
if (keywordCount) parts.push(`${keywordCount} keyword${keywordCount === 1 ? '' : 's'}`);
if (validCoordinates(values.latitude, values.longitude)) parts.push(values.locationName?.trim() || 'location set');
return parts.join(' · ');
}
@@ -392,12 +405,16 @@ function formValues() {
time: elements.time.value,
description: elements.description.value,
keywords: elements.keywords.value,
latitude: elements.latitude.value,
longitude: elements.longitude.value,
locationName: elements.locationName.value,
locationDirty: state.locationDirty,
};
}
function formHasUserInput() {
const values = formValues();
return Boolean(values.year || values.description.trim() || values.keywords.trim());
return Boolean(values.year || values.description.trim() || values.keywords.trim() || validCoordinates(values.latitude, values.longitude));
}
function captureCurrentValues() {
@@ -408,7 +425,8 @@ function captureCurrentValues() {
function restoreValues(values) {
const defaults = {
precision: 'exact', year: '', month: '1', day: '1', timeMode: 'unknown',
time: '12:00:00', description: '', keywords: '',
time: '12:00:00', description: '', keywords: '', latitude: '', longitude: '',
locationName: '', locationDirty: false,
};
const value = { ...defaults, ...(values || {}) };
const radio = $(`input[name="precision"][value="${CSS.escape(value.precision)}"]`);
@@ -420,9 +438,21 @@ function restoreValues(values) {
elements.time.value = value.time;
elements.description.value = value.description;
elements.keywords.value = value.keywords;
elements.latitude.value = value.latitude || '';
elements.longitude.value = value.longitude || '';
elements.locationName.value = value.locationName || '';
state.locationDirty = Boolean(value.locationDirty);
elements.timeField.classList.toggle('hidden', value.timeMode !== 'known');
elements.descriptionCount.textContent = `${elements.description.value.length} / 2000`;
updatePrecisionFields();
syncMapFromFields(false);
}
function validCoordinates(latitude, longitude) {
if (latitude === '' || longitude === '' || latitude === null || longitude === null) return false;
const lat = Number(latitude);
const lon = Number(longitude);
return Number.isFinite(lat) && Number.isFinite(lon) && lat >= -90 && lat <= 90 && lon >= -180 && lon <= 180;
}
function parseKeywords(value) {
@@ -463,6 +493,14 @@ async function savePhoto(moveNext) {
if (values.timeMode === 'known') payload.append('time_value', values.time);
payload.append('description', values.description);
payload.append('keywords_json', JSON.stringify(parseKeywords(values.keywords)));
const hasCoordinates = validCoordinates(values.latitude, values.longitude);
const gpsAction = values.locationDirty ? (hasCoordinates ? 'set' : 'clear') : 'preserve';
payload.append('gps_action', gpsAction);
if (gpsAction === 'set') {
payload.append('latitude', values.latitude);
payload.append('longitude', values.longitude);
payload.append('location_name', values.locationName);
}
const response = await fetch('/api/process', { method: 'POST', body: payload });
if (!response.ok) {
@@ -485,12 +523,14 @@ async function savePhoto(moveNext) {
photo.size = updatedFile.size;
photo.lastModified = updatedFile.lastModified;
photo.status = 'saved';
photo.values = values;
photo.fileValues = { ...values };
const savedValues = { ...values, locationDirty: false };
state.locationDirty = false;
photo.values = savedValues;
photo.fileValues = { ...savedValues };
photo.metadataLoaded = true;
photo.metadataPresent = true;
saveFolderState();
setSaveStatus('saved', 'Saved in place', formatMetadataSummary(values));
setSaveStatus('saved', 'Saved in place', formatMetadataSummary(savedValues));
showToast(`Saved ${photo.name}`);
updateProgress();
@@ -559,11 +599,157 @@ function copyPreviousValues() {
captureCurrentValues();
const previous = state.photos[state.currentIndex - 1].values || state.photos[state.currentIndex - 1].fileValues;
if (!previous) return showToast('The previous photo has no entered values yet.', true);
restoreValues({ ...previous });
restoreValues({ ...previous, locationDirty: true });
state.locationDirty = true;
captureCurrentValues();
showToast('Copied values from the previous photo.');
}
function activeTabIndex() {
return $$('.tab').findIndex((tab) => tab.classList.contains('active'));
}
function activateTab(tabName, focusTab = false) {
const tabs = $$('.tab');
const target = tabs.find((tab) => tab.dataset.tab === tabName);
if (!target) return;
tabs.forEach((item) => {
const active = item === target;
item.classList.toggle('active', active);
item.setAttribute('aria-selected', String(active));
item.tabIndex = active ? 0 : -1;
});
$$('.tab-panel').forEach((panel) => panel.classList.toggle('active', panel.dataset.panel === tabName));
if (focusTab) target.focus();
if (tabName === 'location') {
initialiseMap();
window.setTimeout(() => {
state.map?.invalidateSize();
syncMapFromFields(false);
}, 0);
}
}
function cycleTabs(delta) {
const tabs = $$('.tab');
if (!tabs.length) return;
const current = Math.max(0, activeTabIndex());
const next = (current + delta + tabs.length) % tabs.length;
activateTab(tabs[next].dataset.tab, true);
}
function markLocationDirty() {
state.locationDirty = true;
captureCurrentValues();
}
function initialiseMap() {
if (state.map || typeof window.L === 'undefined' || !elements.locationMap) return;
const config = state.mapConfig || {};
state.map = L.map(elements.locationMap, { zoomControl: true }).setView(
[Number(config.defaultMapLat ?? 64.5), Number(config.defaultMapLon ?? 11.0)],
Number(config.defaultMapZoom ?? 5),
);
L.tileLayer(config.mapTileUrl || 'https://tile.openstreetmap.org/{z}/{x}/{y}.png', {
maxZoom: 19,
attribution: config.mapAttribution || '&copy; OpenStreetMap contributors',
}).addTo(state.map);
state.map.on('click', (event) => {
setLocation(event.latlng.lat, event.latlng.lng, '', true, false);
});
syncMapFromFields(false);
}
function setMarker(latitude, longitude, center = false) {
if (!state.map || !validCoordinates(latitude, longitude)) return;
const lat = Number(latitude);
const lon = Number(longitude);
if (!state.mapMarker) {
state.mapMarker = L.marker([lat, lon], { draggable: true }).addTo(state.map);
state.mapMarker.on('dragend', () => {
const point = state.mapMarker.getLatLng();
setLocation(point.lat, point.lng, '', true, false);
});
} else {
state.mapMarker.setLatLng([lat, lon]);
}
if (center) state.map.setView([lat, lon], Math.max(state.map.getZoom(), 15));
}
function removeMarker() {
if (state.map && state.mapMarker) state.map.removeLayer(state.mapMarker);
state.mapMarker = null;
}
function setLocation(latitude, longitude, label = '', dirty = true, center = true) {
elements.latitude.value = Number(latitude).toFixed(6);
elements.longitude.value = Number(longitude).toFixed(6);
elements.locationName.value = label;
if (dirty) state.locationDirty = true;
setMarker(latitude, longitude, center);
captureCurrentValues();
}
function syncMapFromFields(center = false) {
if (!state.map) return;
if (validCoordinates(elements.latitude.value, elements.longitude.value)) {
setMarker(elements.latitude.value, elements.longitude.value, center);
} else {
removeMarker();
}
}
function clearLocation() {
elements.latitude.value = '';
elements.longitude.value = '';
elements.locationName.value = '';
state.locationDirty = true;
removeMarker();
captureCurrentValues();
showToast('Location cleared. Save the photo to remove GPS metadata.');
}
async function searchAddress() {
const query = elements.addressSearch.value.trim();
if (query.length < 3) {
showToast('Enter at least three characters to search.', true);
return;
}
elements.addressSearchButton.disabled = true;
elements.addressResults.innerHTML = '<div class="search-message">Searching…</div>';
try {
const response = await fetch(`/api/geocode?q=${encodeURIComponent(query)}`, { cache: 'no-store' });
if (!response.ok) {
let message = `Address search failed (${response.status}).`;
try { message = (await response.json()).detail || message; } catch {}
throw new Error(message);
}
const data = await response.json();
const results = Array.isArray(data.results) ? data.results : [];
elements.addressResults.innerHTML = '';
if (!results.length) {
elements.addressResults.innerHTML = '<div class="search-message">No matching places found.</div>';
return;
}
for (const result of results) {
const button = document.createElement('button');
button.type = 'button';
button.className = 'address-result';
button.innerHTML = `<strong>${escapeHtml(result.label)}</strong><span>${Number(result.latitude).toFixed(5)}, ${Number(result.longitude).toFixed(5)}</span>`;
button.addEventListener('click', () => {
setLocation(result.latitude, result.longitude, result.label, true, true);
elements.addressResults.innerHTML = '';
});
elements.addressResults.appendChild(button);
}
} catch (error) {
elements.addressResults.innerHTML = '';
showToast(error.message || 'Address search failed.', true);
} finally {
elements.addressSearchButton.disabled = false;
}
}
function applyView() {
elements.preview.style.transform = `scale(${state.zoom}) rotate(${state.rotation}deg)`;
}
@@ -579,6 +765,14 @@ elements.skip.addEventListener('click', skipCurrentPhoto);
elements.save.addEventListener('click', () => savePhoto(false));
elements.saveNext.addEventListener('click', () => savePhoto(true));
elements.copyPrevious.addEventListener('click', copyPreviousValues);
elements.addressSearchButton.addEventListener('click', searchAddress);
elements.addressSearch.addEventListener('keydown', (event) => { if (event.key === 'Enter') { event.preventDefault(); searchAddress(); } });
elements.clearLocation.addEventListener('click', clearLocation);
for (const input of [elements.latitude, elements.longitude]) {
input.addEventListener('input', () => { state.locationDirty = true; syncMapFromFields(false); });
input.addEventListener('change', () => syncMapFromFields(true));
}
elements.locationName.addEventListener('input', markLocationDirty);
elements.zoomOut.addEventListener('click', () => { state.zoom = Math.max(.25, state.zoom - .15); applyView(); });
elements.zoomIn.addEventListener('click', () => { state.zoom = Math.min(4, state.zoom + .15); applyView(); });
elements.fit.addEventListener('click', resetView);
@@ -591,11 +785,15 @@ elements.form.addEventListener('input', captureCurrentValues);
for (const radio of $$('input[name="precision"]')) radio.addEventListener('change', updatePrecisionFields);
for (const tab of $$('.tab')) {
tab.addEventListener('click', () => {
$$('.tab').forEach((item) => item.classList.toggle('active', item === tab));
$$('.tab-panel').forEach((panel) => panel.classList.toggle('active', panel.dataset.panel === tab.dataset.tab));
tab.addEventListener('click', () => activateTab(tab.dataset.tab));
tab.addEventListener('keydown', (event) => {
if (event.key === 'ArrowLeft') { event.preventDefault(); cycleTabs(-1); }
if (event.key === 'ArrowRight') { event.preventDefault(); cycleTabs(1); }
if (event.key === 'Home') { event.preventDefault(); activateTab($$('.tab')[0].dataset.tab, true); }
if (event.key === 'End') { const tabs = $$('.tab'); event.preventDefault(); activateTab(tabs[tabs.length - 1].dataset.tab, true); }
});
}
activateTab('date');
elements.shortcutsButton.addEventListener('click', () => elements.shortcutsDialog.showModal());
elements.closeShortcuts.addEventListener('click', () => elements.shortcutsDialog.close());
@@ -605,6 +803,9 @@ window.addEventListener('keydown', (event) => {
const tag = document.activeElement?.tagName;
const typing = ['INPUT', 'TEXTAREA', 'SELECT'].includes(tag);
if (event.key === 'Tab' && event.shiftKey && !event.ctrlKey && !event.altKey && !event.metaKey && document.activeElement?.closest('.right-panel')) {
event.preventDefault(); cycleTabs(-1); return;
}
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 's') {
event.preventDefault(); savePhoto(false); return;
}
@@ -625,6 +826,7 @@ async function initialise() {
const config = await response.json();
elements.appName.textContent = config.appName;
document.title = config.appName;
state.mapConfig = config;
}
} catch {}
+43
View File
@@ -6,6 +6,7 @@
<meta name="theme-color" content="#121820">
<title>Photo Date Editor</title>
<link rel="manifest" href="/manifest.webmanifest">
<link rel="stylesheet" href="/vendor/leaflet/leaflet.css">
<link rel="stylesheet" href="/styles.css">
</head>
<body>
@@ -126,6 +127,7 @@
<button class="tab active" data-tab="date" role="tab">Date &amp; time</button>
<button class="tab" data-tab="details" role="tab">Details</button>
<button class="tab" data-tab="keywords" role="tab">Keywords</button>
<button class="tab" data-tab="location" role="tab">Location</button>
</div>
<form id="metadata-form" autocomplete="off">
@@ -193,6 +195,45 @@
</label>
<p class="field-help">Separate entries with commas or new lines. Written as XMP Subject and IPTC Keywords.</p>
</section>
<section class="tab-panel" data-panel="location">
<div class="location-search">
<label class="field location-search-field">
<span>Search for a place or address</span>
<input id="address-search" class="input" type="search" placeholder="Cabin address, town, landmark…">
</label>
<button id="address-search-button" class="button" type="button">Search</button>
</div>
<p class="field-help search-privacy">Search queries are sent to the configured geocoder. You can place a pin manually instead.</p>
<div id="address-results" class="address-results" aria-live="polite"></div>
<div id="location-map" class="location-map" aria-label="Map for selecting photo location"></div>
<p class="field-help map-help">Click the map to place a pin, or drag the pin to fine-tune the position.</p>
<div class="field-grid coordinate-grid">
<label class="field">
<span>Latitude</span>
<input id="latitude-input" class="input" type="number" min="-90" max="90" step="0.000001" placeholder="60.391263">
</label>
<label class="field">
<span>Longitude</span>
<input id="longitude-input" class="input" type="number" min="-180" max="180" step="0.000001" placeholder="5.322054">
</label>
</div>
<label class="field">
<span>Location label</span>
<input id="location-name-input" class="input" type="text" maxlength="500" placeholder="Cabin, Voss, Norway">
</label>
<div class="location-actions">
<button id="clear-location" class="button ghost" type="button">Clear location</button>
</div>
<div class="info-card location-info">
<strong>What gets written</strong>
<p>Coordinates are written to EXIF and XMP GPS fields. The optional label is written to XMP Location.</p>
</div>
</section>
</form>
<section class="right-footer">
@@ -214,10 +255,12 @@
<div><dt>← / →</dt><dd>Previous or next photo</dd></div>
<div><dt>S</dt><dd>Mark skipped and open next photo</dd></div>
<div><dt>C</dt><dd>Copy previous photo's values</dd></div>
<div><dt>Shift+Tab</dt><dd>Switch to the previous right-side tab</dd></div>
</dl>
</dialog>
<div id="toast" class="toast" role="status" aria-live="polite"></div>
<script src="/vendor/leaflet/leaflet.js"></script>
<script src="/app.js" type="module"></script>
</body>
</html>
+9 -3
View File
@@ -1,5 +1,5 @@
const CACHE_NAME = 'photo-date-editor-v3';
const SHELL = ['/', '/styles.css', '/app.js', '/manifest.webmanifest'];
const CACHE_NAME = 'photo-date-editor-v4';
const SHELL = ['/', '/styles.css', '/app.js', '/manifest.webmanifest', '/vendor/leaflet/leaflet.css', '/vendor/leaflet/leaflet.js'];
self.addEventListener('install', (event) => {
event.waitUntil(caches.open(CACHE_NAME).then((cache) => cache.addAll(SHELL)));
@@ -14,7 +14,13 @@ self.addEventListener('activate', (event) => {
});
self.addEventListener('fetch', (event) => {
if (event.request.method !== 'GET' || new URL(event.request.url).pathname.startsWith('/api/')) return;
const url = new URL(event.request.url);
if (
event.request.method !== 'GET'
|| url.origin !== self.location.origin
|| url.pathname.startsWith('/api/')
) return;
event.respondWith(
fetch(event.request).then((response) => {
const copy = response.clone();
+26
View File
@@ -111,3 +111,29 @@ fieldset { border: 0; padding: 0; margin: 0 0 22px; } legend { font-weight: 650;
.topbar { height: auto; min-height: 70px; flex-wrap: wrap; gap: 10px; padding: 12px; }.brand p { display: none; }
.workspace { display: block; overflow: visible; }.left-panel, .right-panel { border: 0; }.left-panel { max-height: 480px; }.viewer-panel { min-height: 620px; }.right-panel { min-height: 600px; }
}
/* v0.4 location editor */
.tabs { grid-template-columns: repeat(4, 1fr); }
.location-search { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: end; gap: 8px; }
.location-search-field { margin-bottom: 0; }
.address-results { display: grid; gap: 6px; margin: 10px 0; max-height: 160px; overflow: auto; }
.address-result { width: 100%; border: 1px solid var(--line); border-radius: 7px; background: #10171f; padding: 9px 10px; text-align: left; cursor: pointer; }
.address-result:hover { border-color: var(--primary); background: #172131; }
.address-result strong, .address-result span { display: block; }
.address-result strong { font-size: 12px; line-height: 1.4; }
.address-result span { margin-top: 3px; color: var(--muted); font-size: 10px; }
.address-results .search-message { color: var(--muted); font-size: 11px; padding: 4px 1px; }
.location-map { height: 310px; min-height: 240px; border: 1px solid var(--line); border-radius: 9px; overflow: hidden; background: #0b1118; }
.location-map:focus-within { outline: 2px solid rgba(79,135,255,.35); }
.map-help { margin: 8px 0 15px; }
.coordinate-grid { grid-template-columns: 1fr 1fr; }
.location-actions { display: flex; justify-content: flex-end; margin: -3px 0 16px; }
.location-info { margin-top: 0; }
.leaflet-container { background: #0b1118; font-family: inherit; }
.leaflet-control-attribution { font-size: 9px; }
.leaflet-popup-content-wrapper, .leaflet-popup-tip { background: #18212b; color: var(--text); }
@media (max-width: 1100px) {
.tab { font-size: 12px; padding-inline: 5px; }
}
.search-privacy { margin: 7px 0 0; }