Fix map loading and tab navigation v0.4.1
This commit is contained in:
1 parent
e290bd1fff
commit
9b7d302064
9 files changed
+137
-36
No files matched your search
+3
-2
@@ -11,11 +11,12 @@ MAX_UPLOAD_MB=150
|
|||||||
LOG_LEVEL=INFO
|
LOG_LEVEL=INFO
|
||||||
|
|
||||||
# Optional map/geocoder overrides. Existing .env files may omit these.
|
# Optional map/geocoder overrides. Existing .env files may omit these.
|
||||||
MAP_TILE_URL=https://tile.openstreetmap.org/{z}/{x}/{y}.png
|
# Map tiles are fetched by the backend and served same-origin to the browser.
|
||||||
|
MAP_TILE_SOURCE_URL=https://tile.openstreetmap.org/{z}/{x}/{y}.png
|
||||||
MAP_ATTRIBUTION=© OpenStreetMap contributors
|
MAP_ATTRIBUTION=© OpenStreetMap contributors
|
||||||
DEFAULT_MAP_LAT=64.5
|
DEFAULT_MAP_LAT=64.5
|
||||||
DEFAULT_MAP_LON=11.0
|
DEFAULT_MAP_LON=11.0
|
||||||
DEFAULT_MAP_ZOOM=5
|
DEFAULT_MAP_ZOOM=5
|
||||||
GEOCODER_URL=https://nominatim.openstreetmap.org/search
|
GEOCODER_URL=https://nominatim.openstreetmap.org/search
|
||||||
# Optional custom identification sent to the geocoder. The default uses APP_NAME, version and APP_URL.
|
# Optional custom identification sent to the geocoder. The default uses APP_NAME, version and APP_URL.
|
||||||
# GEOCODER_USER_AGENT=MorkPhotoDateEditor/0.4 (+https://edit.mork.fyi)
|
# GEOCODER_USER_AGENT=MorkPhotoDateEditor/0.4.1 (+https://edit.mork.fyi)
|
||||||
+13
-1
@@ -1,5 +1,17 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 0.4.1
|
||||||
|
|
||||||
|
- Fix `Shift+Tab` so it activates the previous metadata tab immediately and focuses that tab's first field.
|
||||||
|
- Keep Left/Right arrow keys dedicated to previous/next photo navigation.
|
||||||
|
- Remove the tab-header Arrow/Home/End behavior that conflicted with photo navigation and was awkward on compact Mac keyboards.
|
||||||
|
- Proxy OpenStreetMap tiles through the backend and serve them same-origin through Caddy, avoiding Brave third-party tile blocking.
|
||||||
|
- Add an in-memory map-tile cache and clear map loading/error status text.
|
||||||
|
- Add an explicit message when the Leaflet library fails to load instead of silently showing a dead map.
|
||||||
|
- Add `ca-certificates` explicitly to the container image for outbound HTTPS map and geocoder requests.
|
||||||
|
- Keep all map environment variables optional; existing `.env` files remain valid.
|
||||||
|
- Bump the application and service-worker cache version to 0.4.1.
|
||||||
|
|
||||||
## 0.4.0
|
## 0.4.0
|
||||||
|
|
||||||
- Add a dedicated Location tab with an interactive Leaflet map.
|
- Add a dedicated Location tab with an interactive Leaflet map.
|
||||||
@@ -13,7 +25,7 @@
|
|||||||
- Rate-limit uncached public geocoder calls to one request per second and cache results in memory.
|
- Rate-limit uncached public geocoder calls to one request per second and cache results in memory.
|
||||||
- Add `Shift+Tab` to cycle backward through right-side metadata tabs while keeping normal `Tab` field navigation.
|
- Add `Shift+Tab` to cycle backward through right-side metadata tabs while keeping normal `Tab` field navigation.
|
||||||
- Add arrow-key navigation when a tab header has focus.
|
- Add arrow-key navigation when a tab header has focus.
|
||||||
- Vendor Leaflet into the container at build time and avoid service-worker caching of third-party map tiles.
|
- Avoid service-worker caching of third-party map tiles and scripts.
|
||||||
- Bump the application and service-worker cache version to 0.4.0.
|
- Bump the application and service-worker cache version to 0.4.0.
|
||||||
|
|
||||||
## 0.3.0
|
## 0.3.0
|
||||||
|
|||||||
+1
-1
@@ -4,7 +4,7 @@ ENV PYTHONDONTWRITEBYTECODE=1 \
|
|||||||
PYTHONUNBUFFERED=1
|
PYTHONUNBUFFERED=1
|
||||||
|
|
||||||
RUN apt-get update \
|
RUN apt-get update \
|
||||||
&& apt-get install -y --no-install-recommends libimage-exiftool-perl \
|
&& apt-get install -y --no-install-recommends ca-certificates libimage-exiftool-perl \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Photo Date Editor
|
# Photo Date Editor
|
||||||
|
|
||||||
Version 0.4.0
|
Version 0.4.1
|
||||||
|
|
||||||
A self-hosted browser UI for manually dating scanned photographs. The browser receives temporary read/write access to a local computer or Chromebook folder, sends one JPEG at a time to the Docker backend for ExifTool processing, and overwrites the same local file after processing.
|
A self-hosted browser UI for manually dating scanned photographs. The browser receives temporary read/write access to a local computer or Chromebook folder, sends one JPEG at a time to the Docker backend for ExifTool processing, and overwrites the same local file after processing.
|
||||||
|
|
||||||
@@ -48,7 +48,7 @@ MAX_UPLOAD_MB=150
|
|||||||
LOG_LEVEL=INFO
|
LOG_LEVEL=INFO
|
||||||
|
|
||||||
# Optional map defaults and service overrides
|
# Optional map defaults and service overrides
|
||||||
MAP_TILE_URL=https://tile.openstreetmap.org/{z}/{x}/{y}.png
|
MAP_TILE_SOURCE_URL=https://tile.openstreetmap.org/{z}/{x}/{y}.png
|
||||||
MAP_ATTRIBUTION=© OpenStreetMap contributors
|
MAP_ATTRIBUTION=© OpenStreetMap contributors
|
||||||
DEFAULT_MAP_LAT=64.5
|
DEFAULT_MAP_LAT=64.5
|
||||||
DEFAULT_MAP_LON=11.0
|
DEFAULT_MAP_LON=11.0
|
||||||
@@ -58,7 +58,7 @@ GEOCODER_URL=https://nominatim.openstreetmap.org/search
|
|||||||
|
|
||||||
Open the configured HTTPS URL through Caddy, click **Open photo folder**, and grant read/write access.
|
Open the configured HTTPS URL through Caddy, click **Open photo folder**, and grant read/write access.
|
||||||
|
|
||||||
## Upgrade from 0.1, 0.2, or 0.3
|
## Upgrade from 0.1 through 0.4.0
|
||||||
|
|
||||||
Replace the project files with this version and rebuild:
|
Replace the project files with this version and rebuild:
|
||||||
|
|
||||||
@@ -117,7 +117,7 @@ The chosen precision is also written into XMP Photoshop Instructions.
|
|||||||
- `Left` / `Right`: Previous / next photo when not typing
|
- `Left` / `Right`: Previous / next photo when not typing
|
||||||
- `S`: Mark the photo as skipped and open the next photo when not typing
|
- `S`: Mark the photo as skipped and open the next photo when not typing
|
||||||
- `C`: Copy previous values when not typing
|
- `C`: Copy previous values when not typing
|
||||||
- `Shift+Tab`: Switch to the previous right-side metadata tab; normal `Tab` still advances through fields
|
- `Shift+Tab`: Open the previous right-side metadata tab and place focus in its first field; normal `Tab` still advances through fields
|
||||||
|
|
||||||
|
|
||||||
## Location behavior
|
## Location behavior
|
||||||
@@ -131,7 +131,7 @@ The **Location** tab supports four ways to set GPS metadata:
|
|||||||
|
|
||||||
On save, coordinates are written to EXIF and XMP GPS fields. The optional location label is written to XMP IPTC Core Location. Use **Clear location** and save to remove GPS coordinates and the app-managed location label.
|
On save, coordinates are written to EXIF and XMP GPS fields. The optional location label is written to XMP IPTC Core Location. Use **Clear location** and save to remove GPS coordinates and the app-managed location label.
|
||||||
|
|
||||||
Leaflet is downloaded into the container during the Docker build. The default map tiles and address search are external OpenStreetMap services. Search is user-triggered rather than autocomplete, proxied through the backend, rate-limited to one uncached request per second, and cached in memory. Both the tile and geocoder URLs are configurable in `.env` for later self-hosting or another provider.
|
The default map tiles and address search are external OpenStreetMap services. Both are requested by the Docker backend rather than directly by the browser. This avoids Brave third-party blocking and keeps all browser requests same-origin through Caddy. Tiles are cached in container memory. Search is user-triggered rather than autocomplete, rate-limited to one uncached request per second, and cached in memory. The tile source and geocoder URLs remain configurable in `.env` for later self-hosting or another provider.
|
||||||
|
|
||||||
## Important limitations
|
## Important limitations
|
||||||
|
|
||||||
@@ -140,7 +140,7 @@ Leaflet is downloaded into the container during the Docker build. The default ma
|
|||||||
- Saved and skipped progress is stored per browser and folder name. The app also reads its own XMP marker from edited files so saved metadata can be recognized on another browser.
|
- Saved and skipped progress is stored per browser and folder name. The app also reads its own XMP marker from edited files so saved metadata can be recognized on another browser.
|
||||||
- Overwriting a file through the browser generally changes its filesystem modified time to the time of the save. EXIF/XMP photo dates are independent of that filesystem timestamp.
|
- Overwriting a file through the browser generally changes its filesystem modified time to the time of the save. EXIF/XMP photo dates are independent of that filesystem timestamp.
|
||||||
- Preview rotation is visual only in this MVP; it does not rotate image pixels or write orientation metadata.
|
- Preview rotation is visual only in this MVP; it does not rotate image pixels or write orientation metadata.
|
||||||
- The default map and address search require internet access. Opening the map sends tile requests for the viewed area; address searches are sent to the configured geocoder.
|
- The Docker host needs outbound HTTPS access for the default map tiles and address search. Browser requests remain same-origin through the app.
|
||||||
- Test with copies first, then use your own normal backup routine for the originals.
|
- Test with copies first, then use your own normal backup routine for the originals.
|
||||||
|
|
||||||
## Planned format expansion
|
## Planned format expansion
|
||||||
|
|||||||
+63
-3
@@ -11,6 +11,7 @@ import time
|
|||||||
import urllib.error
|
import urllib.error
|
||||||
import urllib.parse
|
import urllib.parse
|
||||||
import urllib.request
|
import urllib.request
|
||||||
|
from collections import OrderedDict
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Annotated, Any
|
from typing import Annotated, Any
|
||||||
@@ -21,12 +22,15 @@ from fastapi.staticfiles import StaticFiles
|
|||||||
|
|
||||||
APP_NAME = os.getenv("APP_NAME", "Photo Date Editor")
|
APP_NAME = os.getenv("APP_NAME", "Photo Date Editor")
|
||||||
APP_URL = os.getenv("APP_URL", "http://localhost:8080")
|
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_MB = int(os.getenv("MAX_UPLOAD_MB", "150"))
|
||||||
MAX_UPLOAD_BYTES = MAX_UPLOAD_MB * 1024 * 1024
|
MAX_UPLOAD_BYTES = MAX_UPLOAD_MB * 1024 * 1024
|
||||||
LOG_LEVEL = os.getenv("LOG_LEVEL", "INFO").upper()
|
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 = os.getenv(
|
||||||
"MAP_ATTRIBUTION",
|
"MAP_ATTRIBUTION",
|
||||||
'© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap contributors</a>',
|
'© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap contributors</a>',
|
||||||
@@ -43,6 +47,9 @@ GEOCODER_USER_AGENT = os.getenv(
|
|||||||
_geocode_lock = asyncio.Lock()
|
_geocode_lock = asyncio.Lock()
|
||||||
_geocode_cache: dict[str, list[dict[str, Any]]] = {}
|
_geocode_cache: dict[str, list[dict[str, Any]]] = {}
|
||||||
_last_geocode_request = 0.0
|
_last_geocode_request = 0.0
|
||||||
|
_tile_cache: OrderedDict[str, tuple[bytes, str]] = OrderedDict()
|
||||||
|
_tile_cache_limit = 512
|
||||||
|
_tile_lock = asyncio.Lock()
|
||||||
|
|
||||||
logging.basicConfig(
|
logging.basicConfig(
|
||||||
level=getattr(logging, LOG_LEVEL, logging.INFO),
|
level=getattr(logging, LOG_LEVEL, logging.INFO),
|
||||||
@@ -68,7 +75,7 @@ def config() -> dict[str, str | int | float]:
|
|||||||
"appUrl": APP_URL,
|
"appUrl": APP_URL,
|
||||||
"version": APP_VERSION,
|
"version": APP_VERSION,
|
||||||
"maxUploadMb": MAX_UPLOAD_MB,
|
"maxUploadMb": MAX_UPLOAD_MB,
|
||||||
"mapTileUrl": MAP_TILE_URL,
|
"mapTileUrl": "/api/map/tiles/{z}/{x}/{y}.png",
|
||||||
"mapAttribution": MAP_ATTRIBUTION,
|
"mapAttribution": MAP_ATTRIBUTION,
|
||||||
"defaultMapLat": DEFAULT_MAP_LAT,
|
"defaultMapLat": DEFAULT_MAP_LAT,
|
||||||
"defaultMapLon": DEFAULT_MAP_LON,
|
"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]]:
|
def _fetch_geocode_results(query: str) -> list[dict[str, Any]]:
|
||||||
parameters = urllib.parse.urlencode(
|
parameters = urllib.parse.urlencode(
|
||||||
{
|
{
|
||||||
|
|||||||
+38
-13
@@ -63,6 +63,7 @@ const elements = {
|
|||||||
addressSearchButton: $('#address-search-button'),
|
addressSearchButton: $('#address-search-button'),
|
||||||
addressResults: $('#address-results'),
|
addressResults: $('#address-results'),
|
||||||
locationMap: $('#location-map'),
|
locationMap: $('#location-map'),
|
||||||
|
mapStatus: $('#map-status'),
|
||||||
latitude: $('#latitude-input'),
|
latitude: $('#latitude-input'),
|
||||||
longitude: $('#longitude-input'),
|
longitude: $('#longitude-input'),
|
||||||
locationName: $('#location-name-input'),
|
locationName: $('#location-name-input'),
|
||||||
@@ -609,7 +610,13 @@ function activeTabIndex() {
|
|||||||
return $$('.tab').findIndex((tab) => tab.classList.contains('active'));
|
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 tabs = $$('.tab');
|
||||||
const target = tabs.find((tab) => tab.dataset.tab === tabName);
|
const target = tabs.find((tab) => tab.dataset.tab === tabName);
|
||||||
if (!target) return;
|
if (!target) return;
|
||||||
@@ -620,22 +627,28 @@ function activateTab(tabName, focusTab = false) {
|
|||||||
item.tabIndex = active ? 0 : -1;
|
item.tabIndex = active ? 0 : -1;
|
||||||
});
|
});
|
||||||
$$('.tab-panel').forEach((panel) => panel.classList.toggle('active', panel.dataset.panel === tabName));
|
$$('.tab-panel').forEach((panel) => panel.classList.toggle('active', panel.dataset.panel === tabName));
|
||||||
if (focusTab) target.focus();
|
|
||||||
if (tabName === 'location') {
|
if (tabName === 'location') {
|
||||||
initialiseMap();
|
initialiseMap();
|
||||||
window.setTimeout(() => {
|
window.setTimeout(() => {
|
||||||
state.map?.invalidateSize();
|
state.map?.invalidateSize();
|
||||||
syncMapFromFields(false);
|
syncMapFromFields(false);
|
||||||
|
if (focusMode === 'field') focusFirstControl(tabName);
|
||||||
|
else if (focusMode === 'tab') target.focus();
|
||||||
}, 0);
|
}, 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');
|
const tabs = $$('.tab');
|
||||||
if (!tabs.length) return;
|
if (!tabs.length) return;
|
||||||
const current = Math.max(0, activeTabIndex());
|
const current = Math.max(0, activeTabIndex());
|
||||||
const next = (current + delta + tabs.length) % tabs.length;
|
const next = (current + delta + tabs.length) % tabs.length;
|
||||||
activateTab(tabs[next].dataset.tab, true);
|
activateTab(tabs[next].dataset.tab, focusMode);
|
||||||
}
|
}
|
||||||
|
|
||||||
function markLocationDirty() {
|
function markLocationDirty() {
|
||||||
@@ -643,21 +656,39 @@ function markLocationDirty() {
|
|||||||
captureCurrentValues();
|
captureCurrentValues();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function setMapStatus(message, isError = false) {
|
||||||
|
if (!elements.mapStatus) return;
|
||||||
|
elements.mapStatus.textContent = message;
|
||||||
|
elements.mapStatus.classList.toggle('error', isError);
|
||||||
|
}
|
||||||
|
|
||||||
function initialiseMap() {
|
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 || {};
|
const config = state.mapConfig || {};
|
||||||
|
try {
|
||||||
state.map = L.map(elements.locationMap, { zoomControl: true }).setView(
|
state.map = L.map(elements.locationMap, { zoomControl: true }).setView(
|
||||||
[Number(config.defaultMapLat ?? 64.5), Number(config.defaultMapLon ?? 11.0)],
|
[Number(config.defaultMapLat ?? 64.5), Number(config.defaultMapLon ?? 11.0)],
|
||||||
Number(config.defaultMapZoom ?? 5),
|
Number(config.defaultMapZoom ?? 5),
|
||||||
);
|
);
|
||||||
L.tileLayer(config.mapTileUrl || 'https://tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
const layer = L.tileLayer(config.mapTileUrl || '/api/map/tiles/{z}/{x}/{y}.png', {
|
||||||
maxZoom: 19,
|
maxZoom: 19,
|
||||||
attribution: config.mapAttribution || '© OpenStreetMap contributors',
|
attribution: config.mapAttribution || '© OpenStreetMap contributors',
|
||||||
}).addTo(state.map);
|
}).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) => {
|
state.map.on('click', (event) => {
|
||||||
setLocation(event.latlng.lat, event.latlng.lng, '', true, false);
|
setLocation(event.latlng.lat, event.latlng.lng, '', true, false);
|
||||||
});
|
});
|
||||||
syncMapFromFields(false);
|
syncMapFromFields(false);
|
||||||
|
} catch (error) {
|
||||||
|
state.map = null;
|
||||||
|
setMapStatus(error.message || 'The map could not be initialized.', true);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function setMarker(latitude, longitude, center = false) {
|
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')) {
|
for (const tab of $$('.tab')) {
|
||||||
tab.addEventListener('click', () => activateTab(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');
|
activateTab('date');
|
||||||
|
|
||||||
@@ -804,7 +829,7 @@ window.addEventListener('keydown', (event) => {
|
|||||||
const typing = ['INPUT', 'TEXTAREA', 'SELECT'].includes(tag);
|
const typing = ['INPUT', 'TEXTAREA', 'SELECT'].includes(tag);
|
||||||
|
|
||||||
if (event.key === 'Tab' && event.shiftKey && !event.ctrlKey && !event.altKey && !event.metaKey && document.activeElement?.closest('.right-panel')) {
|
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') {
|
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 's') {
|
||||||
event.preventDefault(); savePhoto(false); return;
|
event.preventDefault(); savePhoto(false); return;
|
||||||
|
|||||||
+10
-9
@@ -124,14 +124,14 @@
|
|||||||
|
|
||||||
<aside class="sidebar right-panel">
|
<aside class="sidebar right-panel">
|
||||||
<div class="tabs" role="tablist">
|
<div class="tabs" role="tablist">
|
||||||
<button class="tab active" data-tab="date" role="tab">Date & time</button>
|
<button id="tab-date" class="tab active" data-tab="date" role="tab" aria-selected="true" aria-controls="panel-date">Date & time</button>
|
||||||
<button class="tab" data-tab="details" role="tab">Details</button>
|
<button id="tab-details" class="tab" data-tab="details" role="tab" aria-selected="false" aria-controls="panel-details" tabindex="-1">Details</button>
|
||||||
<button class="tab" data-tab="keywords" role="tab">Keywords</button>
|
<button id="tab-keywords" class="tab" data-tab="keywords" role="tab" aria-selected="false" aria-controls="panel-keywords" tabindex="-1">Keywords</button>
|
||||||
<button class="tab" data-tab="location" role="tab">Location</button>
|
<button id="tab-location" class="tab" data-tab="location" role="tab" aria-selected="false" aria-controls="panel-location" tabindex="-1">Location</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form id="metadata-form" autocomplete="off">
|
<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>
|
<fieldset>
|
||||||
<legend>Date precision</legend>
|
<legend>Date precision</legend>
|
||||||
<label class="radio-row"><input type="radio" name="precision" value="exact" checked> <span>Exact date</span></label>
|
<label class="radio-row"><input type="radio" name="precision" value="exact" checked> <span>Exact date</span></label>
|
||||||
@@ -180,7 +180,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</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">
|
<label class="field">
|
||||||
<span>Description</span>
|
<span>Description</span>
|
||||||
<textarea id="description-input" class="input textarea" rows="7" maxlength="2000" placeholder="Who, where, occasion, or other context…"></textarea>
|
<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>
|
<div class="helper-row"><span>Written to EXIF, XMP and IPTC</span><span id="description-count">0 / 2000</span></div>
|
||||||
</section>
|
</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">
|
<label class="field">
|
||||||
<span>People and keywords</span>
|
<span>People and keywords</span>
|
||||||
<textarea id="keywords-input" class="input textarea" rows="7" placeholder="Daniel, family, birthday, Bergen"></textarea>
|
<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>
|
<p class="field-help">Separate entries with commas or new lines. Written as XMP Subject and IPTC Keywords.</p>
|
||||||
</section>
|
</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">
|
<div class="location-search">
|
||||||
<label class="field location-search-field">
|
<label class="field location-search-field">
|
||||||
<span>Search for a place or address</span>
|
<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="address-results" class="address-results" aria-live="polite"></div>
|
||||||
|
|
||||||
<div id="location-map" class="location-map" aria-label="Map for selecting photo location"></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>
|
<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">
|
<div class="field-grid coordinate-grid">
|
||||||
@@ -255,7 +256,7 @@
|
|||||||
<div><dt>← / →</dt><dd>Previous or next photo</dd></div>
|
<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>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>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>
|
</dl>
|
||||||
</dialog>
|
</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'];
|
const SHELL = ['/', '/styles.css', '/app.js', '/manifest.webmanifest', '/vendor/leaflet/leaflet.css', '/vendor/leaflet/leaflet.js'];
|
||||||
|
|
||||||
self.addEventListener('install', (event) => {
|
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; }
|
.tab { font-size: 12px; padding-inline: 5px; }
|
||||||
}
|
}
|
||||||
.search-privacy { margin: 7px 0 0; }
|
.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