Fix map loading and tab navigation v0.4.1
This commit is contained in:
1 parent
e290bd1fff
commit
9b7d302064
9 files changed
+148
-47
No files matched your search
+63
-3
@@ -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",
|
||||
'© <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
@@ -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 || '© 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 || '© 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
@@ -124,14 +124,14 @@
|
||||
|
||||
<aside class="sidebar right-panel">
|
||||
<div class="tabs" role="tablist">
|
||||
<button class="tab active" data-tab="date" role="tab">Date & 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 & 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,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) => {
|
||||
|
||||
@@ -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); }
|
||||
Reference in new issue
Block a user