Fix map loading and tab navigation v0.4.1

This commit is contained in:
dmo committed 2026-07-23 09:50:45 +02:00
1 parent e290bd1fff
commit 9b7d302064
9 files changed
+148 -47

No files matched your search

+63 -3
View File
@@ -11,6 +11,7 @@ import time
import urllib.error
import urllib.parse
import urllib.request
from collections import OrderedDict
from datetime import datetime
from pathlib import Path
from typing import Annotated, Any
@@ -21,12 +22,15 @@ 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.4.0"
APP_VERSION = "0.4.1"
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_TILE_SOURCE_URL = os.getenv(
"MAP_TILE_SOURCE_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>',
@@ -43,6 +47,9 @@ GEOCODER_USER_AGENT = os.getenv(
_geocode_lock = asyncio.Lock()
_geocode_cache: dict[str, list[dict[str, Any]]] = {}
_last_geocode_request = 0.0
_tile_cache: OrderedDict[str, tuple[bytes, str]] = OrderedDict()
_tile_cache_limit = 512
_tile_lock = asyncio.Lock()
logging.basicConfig(
level=getattr(logging, LOG_LEVEL, logging.INFO),
@@ -68,7 +75,7 @@ def config() -> dict[str, str | int | float]:
"appUrl": APP_URL,
"version": APP_VERSION,
"maxUploadMb": MAX_UPLOAD_MB,
"mapTileUrl": MAP_TILE_URL,
"mapTileUrl": "/api/map/tiles/{z}/{x}/{y}.png",
"mapAttribution": MAP_ATTRIBUTION,
"defaultMapLat": DEFAULT_MAP_LAT,
"defaultMapLon": DEFAULT_MAP_LON,
@@ -77,6 +84,59 @@ def config() -> dict[str, str | int | float]:
def _fetch_map_tile(z: int, x: int, y: int) -> tuple[bytes, str]:
tile_url = (
MAP_TILE_SOURCE_URL
.replace("{z}", str(z))
.replace("{x}", str(x))
.replace("{y}", str(y))
)
request = urllib.request.Request(
tile_url,
headers={
"User-Agent": GEOCODER_USER_AGENT,
"Accept": "image/avif,image/webp,image/png,image/*,*/*;q=0.8",
},
)
with urllib.request.urlopen(request, timeout=20) as response:
content_type = response.headers.get_content_type() or "image/png"
return response.read(), content_type
@app.get("/api/map/tiles/{z}/{x}/{y}.png", include_in_schema=False)
async def map_tile(z: int, x: int, y: int) -> Response:
if not 0 <= z <= 19:
raise HTTPException(status_code=404, detail="Unsupported map zoom level.")
limit = 1 << z
if not 0 <= x < limit or not 0 <= y < limit:
raise HTTPException(status_code=404, detail="Invalid map tile coordinates.")
cache_key = f"{z}/{x}/{y}"
cached = _tile_cache.get(cache_key)
if cached is not None:
_tile_cache.move_to_end(cache_key)
payload, content_type = cached
return Response(payload, media_type=content_type, headers={"Cache-Control": "public, max-age=86400"})
async with _tile_lock:
cached = _tile_cache.get(cache_key)
if cached is None:
try:
cached = await asyncio.to_thread(_fetch_map_tile, z, x, y)
except (urllib.error.URLError, TimeoutError) as exc:
logger.warning("Map tile request failed for %s: %s", cache_key, exc)
raise HTTPException(status_code=502, detail="The map tile service could not be reached.") from exc
_tile_cache[cache_key] = cached
if len(_tile_cache) > _tile_cache_limit:
_tile_cache.popitem(last=False)
else:
_tile_cache.move_to_end(cache_key)
payload, content_type = cached
return Response(payload, media_type=content_type, headers={"Cache-Control": "public, max-age=86400"})
def _fetch_geocode_results(query: str) -> list[dict[str, Any]]:
parameters = urllib.parse.urlencode(
{
+49 -24
View File
@@ -63,6 +63,7 @@ const elements = {
addressSearchButton: $('#address-search-button'),
addressResults: $('#address-results'),
locationMap: $('#location-map'),
mapStatus: $('#map-status'),
latitude: $('#latitude-input'),
longitude: $('#longitude-input'),
locationName: $('#location-name-input'),
@@ -609,7 +610,13 @@ function activeTabIndex() {
return $$('.tab').findIndex((tab) => tab.classList.contains('active'));
}
function activateTab(tabName, focusTab = false) {
function focusFirstControl(tabName) {
const panel = document.querySelector(`.tab-panel[data-panel="${tabName}"]`);
const first = panel?.querySelector('input:not([disabled]), select:not([disabled]), textarea:not([disabled]), button:not([disabled]), [tabindex]:not([tabindex="-1"])');
first?.focus();
}
function activateTab(tabName, focusMode = 'none') {
const tabs = $$('.tab');
const target = tabs.find((tab) => tab.dataset.tab === tabName);
if (!target) return;
@@ -620,22 +627,28 @@ function activateTab(tabName, focusTab = false) {
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);
if (focusMode === 'field') focusFirstControl(tabName);
else if (focusMode === 'tab') target.focus();
}, 0);
} else if (focusMode === 'field') {
window.setTimeout(() => focusFirstControl(tabName), 0);
} else if (focusMode === 'tab') {
target.focus();
}
}
function cycleTabs(delta) {
function cycleTabs(delta, focusMode = 'field') {
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);
activateTab(tabs[next].dataset.tab, focusMode);
}
function markLocationDirty() {
@@ -643,21 +656,39 @@ function markLocationDirty() {
captureCurrentValues();
}
function setMapStatus(message, isError = false) {
if (!elements.mapStatus) return;
elements.mapStatus.textContent = message;
elements.mapStatus.classList.toggle('error', isError);
}
function initialiseMap() {
if (state.map || typeof window.L === 'undefined' || !elements.locationMap) return;
if (state.map || !elements.locationMap) return;
if (typeof window.L === 'undefined') {
setMapStatus('Map library failed to load. Rebuild the container and hard-refresh the page.', true);
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);
try {
state.map = L.map(elements.locationMap, { zoomControl: true }).setView(
[Number(config.defaultMapLat ?? 64.5), Number(config.defaultMapLon ?? 11.0)],
Number(config.defaultMapZoom ?? 5),
);
const layer = L.tileLayer(config.mapTileUrl || '/api/map/tiles/{z}/{x}/{y}.png', {
maxZoom: 19,
attribution: config.mapAttribution || '&copy; OpenStreetMap contributors',
}).addTo(state.map);
layer.on('loading', () => setMapStatus('Loading map…'));
layer.on('load', () => setMapStatus('Click the map to place a pin.'));
layer.on('tileerror', () => setMapStatus('Map tiles could not be loaded. Address search and manual coordinates still work.', true));
state.map.on('click', (event) => {
setLocation(event.latlng.lat, event.latlng.lng, '', true, false);
});
syncMapFromFields(false);
} catch (error) {
state.map = null;
setMapStatus(error.message || 'The map could not be initialized.', true);
}
}
function setMarker(latitude, longitude, center = false) {
@@ -786,12 +817,6 @@ for (const radio of $$('input[name="precision"]')) radio.addEventListener('chang
for (const tab of $$('.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');
@@ -804,7 +829,7 @@ window.addEventListener('keydown', (event) => {
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;
event.preventDefault(); event.stopPropagation(); cycleTabs(-1, 'field'); return;
}
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 's') {
event.preventDefault(); savePhoto(false); return;
+10 -9
View File
@@ -124,14 +124,14 @@
<aside class="sidebar right-panel">
<div class="tabs" role="tablist">
<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>
<button id="tab-date" class="tab active" data-tab="date" role="tab" aria-selected="true" aria-controls="panel-date">Date &amp; time</button>
<button id="tab-details" class="tab" data-tab="details" role="tab" aria-selected="false" aria-controls="panel-details" tabindex="-1">Details</button>
<button id="tab-keywords" class="tab" data-tab="keywords" role="tab" aria-selected="false" aria-controls="panel-keywords" tabindex="-1">Keywords</button>
<button id="tab-location" class="tab" data-tab="location" role="tab" aria-selected="false" aria-controls="panel-location" tabindex="-1">Location</button>
</div>
<form id="metadata-form" autocomplete="off">
<section class="tab-panel active" data-panel="date">
<section id="panel-date" class="tab-panel active" data-panel="date" role="tabpanel" aria-labelledby="tab-date">
<fieldset>
<legend>Date precision</legend>
<label class="radio-row"><input type="radio" name="precision" value="exact" checked> <span>Exact date</span></label>
@@ -180,7 +180,7 @@
</div>
</section>
<section class="tab-panel" data-panel="details">
<section id="panel-details" class="tab-panel" data-panel="details" role="tabpanel" aria-labelledby="tab-details">
<label class="field">
<span>Description</span>
<textarea id="description-input" class="input textarea" rows="7" maxlength="2000" placeholder="Who, where, occasion, or other context…"></textarea>
@@ -188,7 +188,7 @@
<div class="helper-row"><span>Written to EXIF, XMP and IPTC</span><span id="description-count">0 / 2000</span></div>
</section>
<section class="tab-panel" data-panel="keywords">
<section id="panel-keywords" class="tab-panel" data-panel="keywords" role="tabpanel" aria-labelledby="tab-keywords">
<label class="field">
<span>People and keywords</span>
<textarea id="keywords-input" class="input textarea" rows="7" placeholder="Daniel, family, birthday, Bergen"></textarea>
@@ -196,7 +196,7 @@
<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">
<section id="panel-location" class="tab-panel" data-panel="location" role="tabpanel" aria-labelledby="tab-location">
<div class="location-search">
<label class="field location-search-field">
<span>Search for a place or address</span>
@@ -208,6 +208,7 @@
<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 id="map-status" class="field-help map-status" role="status">Open this tab to load the map.</p>
<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">
@@ -255,7 +256,7 @@
<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>
<div><dt>Shift+Tab</dt><dd>Open the previous metadata tab and focus its first field</dd></div>
</dl>
</dialog>
+1 -1
View File
@@ -1,4 +1,4 @@
const CACHE_NAME = 'photo-date-editor-v4';
const CACHE_NAME = 'photo-date-editor-v4-1';
const SHELL = ['/', '/styles.css', '/app.js', '/manifest.webmanifest', '/vendor/leaflet/leaflet.css', '/vendor/leaflet/leaflet.js'];
self.addEventListener('install', (event) => {
+2
View File
@@ -137,3 +137,5 @@ fieldset { border: 0; padding: 0; margin: 0 0 22px; } legend { font-weight: 650;
.tab { font-size: 12px; padding-inline: 5px; }
}
.search-privacy { margin: 7px 0 0; }
.map-status { margin-top: 8px; min-height: 1.2em; }
.map-status.error { color: var(--danger); }