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

Binary file not shown.
+199 -5
View File
@@ -1,11 +1,16 @@
from __future__ import annotations
import asyncio
import json
import logging
import os
import re
import subprocess
import tempfile
import time
import urllib.error
import urllib.parse
import urllib.request
from datetime import datetime
from pathlib import Path
from typing import Annotated, Any
@@ -16,11 +21,29 @@ from fastapi.staticfiles import StaticFiles
APP_NAME = os.getenv("APP_NAME", "Photo Date Editor")
APP_URL = os.getenv("APP_URL", "http://localhost:8080")
APP_VERSION = "0.3.0"
APP_VERSION = "0.4.0"
MAX_UPLOAD_MB = int(os.getenv("MAX_UPLOAD_MB", "150"))
MAX_UPLOAD_BYTES = MAX_UPLOAD_MB * 1024 * 1024
LOG_LEVEL = os.getenv("LOG_LEVEL", "INFO").upper()
MAP_TILE_URL = os.getenv("MAP_TILE_URL", "https://tile.openstreetmap.org/{z}/{x}/{y}.png")
MAP_ATTRIBUTION = os.getenv(
"MAP_ATTRIBUTION",
'&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap contributors</a>',
)
DEFAULT_MAP_LAT = float(os.getenv("DEFAULT_MAP_LAT", "64.5"))
DEFAULT_MAP_LON = float(os.getenv("DEFAULT_MAP_LON", "11.0"))
DEFAULT_MAP_ZOOM = int(os.getenv("DEFAULT_MAP_ZOOM", "5"))
GEOCODER_URL = os.getenv("GEOCODER_URL", "https://nominatim.openstreetmap.org/search")
GEOCODER_USER_AGENT = os.getenv(
"GEOCODER_USER_AGENT",
f"{APP_NAME.replace(' ', '')}/{APP_VERSION} (+{APP_URL})",
)
_geocode_lock = asyncio.Lock()
_geocode_cache: dict[str, list[dict[str, Any]]] = {}
_last_geocode_request = 0.0
logging.basicConfig(
level=getattr(logging, LOG_LEVEL, logging.INFO),
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
@@ -39,15 +62,113 @@ def health() -> dict[str, str]:
@app.get("/api/config")
def config() -> dict[str, str | int]:
def config() -> dict[str, str | int | float]:
return {
"appName": APP_NAME,
"appUrl": APP_URL,
"version": APP_VERSION,
"maxUploadMb": MAX_UPLOAD_MB,
"mapTileUrl": MAP_TILE_URL,
"mapAttribution": MAP_ATTRIBUTION,
"defaultMapLat": DEFAULT_MAP_LAT,
"defaultMapLon": DEFAULT_MAP_LON,
"defaultMapZoom": DEFAULT_MAP_ZOOM,
}
def _fetch_geocode_results(query: str) -> list[dict[str, Any]]:
parameters = urllib.parse.urlencode(
{
"q": query,
"format": "jsonv2",
"addressdetails": "1",
"limit": "5",
}
)
request = urllib.request.Request(
f"{GEOCODER_URL}?{parameters}",
headers={
"User-Agent": GEOCODER_USER_AGENT,
"Accept": "application/json",
},
)
with urllib.request.urlopen(request, timeout=20) as response:
raw = json.loads(response.read().decode("utf-8"))
results: list[dict[str, Any]] = []
for item in raw if isinstance(raw, list) else []:
try:
latitude = float(item["lat"])
longitude = float(item["lon"])
except (KeyError, TypeError, ValueError):
continue
address = item.get("address") if isinstance(item.get("address"), dict) else {}
results.append(
{
"label": str(item.get("display_name") or "").strip(),
"latitude": latitude,
"longitude": longitude,
"type": str(item.get("type") or item.get("category") or "place"),
"address": {
key: str(address[key])
for key in (
"house_number",
"road",
"neighbourhood",
"suburb",
"city",
"town",
"village",
"municipality",
"county",
"state",
"postcode",
"country",
"country_code",
)
if address.get(key)
},
}
)
return results
@app.get("/api/geocode")
async def geocode(q: str) -> dict[str, list[dict[str, Any]]]:
global _last_geocode_request
query = " ".join(q.split())
if len(query) < 3:
raise HTTPException(status_code=422, detail="Enter at least three characters to search.")
if len(query) > 200:
raise HTTPException(status_code=422, detail="The address search is too long.")
cache_key = query.casefold()
if cache_key in _geocode_cache:
return {"results": _geocode_cache[cache_key]}
async with _geocode_lock:
if cache_key in _geocode_cache:
return {"results": _geocode_cache[cache_key]}
delay = 1.0 - (time.monotonic() - _last_geocode_request)
if delay > 0:
await asyncio.sleep(delay)
try:
results = await asyncio.to_thread(_fetch_geocode_results, query)
except (urllib.error.URLError, TimeoutError, json.JSONDecodeError) as exc:
logger.warning("Geocoding request failed: %s", exc)
raise HTTPException(status_code=502, detail="The address search service could not be reached.") from exc
finally:
_last_geocode_request = time.monotonic()
if len(_geocode_cache) >= 200:
_geocode_cache.pop(next(iter(_geocode_cache)))
_geocode_cache[cache_key] = results
return {"results": results}
def _normalise_datetime(
precision: str,
year: int,
@@ -149,7 +270,20 @@ def _as_keywords(*values: Any) -> list[str]:
return output
def _metadata_values(metadata: dict[str, Any]) -> tuple[bool, bool, dict[str, str]]:
def _signed_coordinate(value: Any, reference: Any, negative_letter: str) -> float | None:
try:
coordinate = float(value)
except (TypeError, ValueError):
return None
if coordinate < 0:
return coordinate
reference_text = _as_text(reference).upper()
if reference_text == negative_letter or reference_text == "1":
return -coordinate
return coordinate
def _metadata_values(metadata: dict[str, Any]) -> tuple[bool, bool, dict[str, Any]]:
date_text = _as_text(
_first_value(
metadata,
@@ -169,6 +303,11 @@ def _metadata_values(metadata: dict[str, Any]) -> tuple[bool, bool, dict[str, st
)
)
keywords = _as_keywords(metadata.get("Subject"), metadata.get("Keywords"))
latitude = _signed_coordinate(metadata.get("GPSLatitude"), metadata.get("GPSLatitudeRef"), "S")
longitude = _signed_coordinate(metadata.get("GPSLongitude"), metadata.get("GPSLongitudeRef"), "W")
location_name = _as_text(
_first_value(metadata, "Location", "LocationShownLocationName", "City")
)
precision = "exact"
time_mode = "known"
@@ -209,14 +348,18 @@ def _metadata_values(metadata: dict[str, Any]) -> tuple[bool, bool, dict[str, st
"time": parsed_date.strftime("%H:%M:%S") if parsed_date else "12:00:00",
"description": description,
"keywords": ", ".join(keywords),
"latitude": f"{latitude:.8f}" if latitude is not None else "",
"longitude": f"{longitude:.8f}" if longitude is not None else "",
"locationName": location_name,
"locationDirty": False,
}
has_metadata = bool(parsed_date or description or keywords or marker)
has_metadata = bool(parsed_date or description or keywords or marker or latitude is not None or longitude is not None or location_name)
edited_by_app = bool(marker)
return has_metadata, edited_by_app, values
@app.post("/api/metadata")
async def read_metadata(file: Annotated[UploadFile, File(...)]) -> dict[str, bool | dict[str, str]]:
async def read_metadata(file: Annotated[UploadFile, File(...)]) -> dict[str, bool | dict[str, Any]]:
filename = file.filename or "photo.jpg"
suffix = Path(filename).suffix.lower()
if suffix not in {".jpg", ".jpeg"}:
@@ -230,6 +373,7 @@ async def read_metadata(file: Annotated[UploadFile, File(...)]) -> dict[str, boo
"exiftool",
"-json",
"-s",
"-n",
"-DateTimeOriginal",
"-CreateDate",
"-ModifyDate",
@@ -240,6 +384,13 @@ async def read_metadata(file: Annotated[UploadFile, File(...)]) -> dict[str, boo
"-Caption-Abstract",
"-Subject",
"-Keywords",
"-GPSLatitude",
"-GPSLatitudeRef",
"-GPSLongitude",
"-GPSLongitudeRef",
"-Location",
"-LocationShownLocationName",
"-City",
str(temp_path),
],
capture_output=True,
@@ -270,6 +421,10 @@ async def process_photo(
time_value: Annotated[str | None, Form()] = None,
description: Annotated[str, Form()] = "",
keywords_json: Annotated[str, Form()] = "[]",
gps_action: Annotated[str, Form()] = "preserve",
latitude: Annotated[float | None, Form()] = None,
longitude: Annotated[float | None, Form()] = None,
location_name: Annotated[str, Form()] = "",
) -> Response:
filename = file.filename or "photo.jpg"
suffix = Path(filename).suffix.lower()
@@ -293,6 +448,14 @@ async def process_photo(
except (json.JSONDecodeError, ValueError) as exc:
raise HTTPException(status_code=422, detail="Keywords must be a JSON array.") from exc
if gps_action not in {"preserve", "set", "clear"}:
raise HTTPException(status_code=422, detail="Unsupported GPS action.")
if gps_action == "set":
if latitude is None or longitude is None:
raise HTTPException(status_code=422, detail="Both latitude and longitude are required.")
if not -90 <= latitude <= 90 or not -180 <= longitude <= 180:
raise HTTPException(status_code=422, detail="The GPS coordinates are outside the valid range.")
with tempfile.TemporaryDirectory(prefix="photo-date-editor-") as temp_dir:
temp_path = Path(temp_dir) / f"working{suffix}"
await _save_upload(file, temp_path)
@@ -301,6 +464,7 @@ async def process_photo(
"exiftool",
"-overwrite_original",
"-m",
"-n",
f"-EXIF:DateTimeOriginal={exif_datetime}",
f"-EXIF:CreateDate={exif_datetime}",
f"-EXIF:ModifyDate={exif_datetime}",
@@ -330,6 +494,36 @@ async def process_photo(
command.append(f"-XMP-dc:Subject+={keyword}")
command.append(f"-IPTC:Keywords+={keyword}")
if gps_action == "clear":
command.extend(
[
"-EXIF:GPSLatitude=",
"-EXIF:GPSLatitudeRef=",
"-EXIF:GPSLongitude=",
"-EXIF:GPSLongitudeRef=",
"-XMP-exif:GPSLatitude=",
"-XMP-exif:GPSLongitude=",
"-XMP-iptcCore:Location=",
]
)
elif gps_action == "set" and latitude is not None and longitude is not None:
latitude_ref = "N" if latitude >= 0 else "S"
longitude_ref = "E" if longitude >= 0 else "W"
command.extend(
[
f"-EXIF:GPSLatitude={abs(latitude):.8f}",
f"-EXIF:GPSLatitudeRef={latitude_ref}",
f"-EXIF:GPSLongitude={abs(longitude):.8f}",
f"-EXIF:GPSLongitudeRef={longitude_ref}",
f"-XMP-exif:GPSLatitude={latitude:.8f}",
f"-XMP-exif:GPSLongitude={longitude:.8f}",
"-XMP-iptcCore:Location=",
]
)
clean_location_name = location_name.strip()
if clean_location_name:
command.append(f"-XMP-iptcCore:Location={clean_location_name}")
command.append(str(temp_path))
logger.info("Processing %s with precision %s", filename, precision)
+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; }