Fix map loading and tab navigation v0.4.1
This commit is contained in:
+3
-2
@@ -11,11 +11,12 @@ MAX_UPLOAD_MB=150
|
||||
LOG_LEVEL=INFO
|
||||
|
||||
# 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
|
||||
DEFAULT_MAP_LAT=64.5
|
||||
DEFAULT_MAP_LON=11.0
|
||||
DEFAULT_MAP_ZOOM=5
|
||||
GEOCODER_URL=https://nominatim.openstreetmap.org/search
|
||||
# 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
|
||||
|
||||
## 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
|
||||
|
||||
- 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.
|
||||
- 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.
|
||||
- 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.
|
||||
|
||||
## 0.3.0
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONUNBUFFERED=1
|
||||
|
||||
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/*
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# 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.
|
||||
|
||||
@@ -48,7 +48,7 @@ MAX_UPLOAD_MB=150
|
||||
LOG_LEVEL=INFO
|
||||
|
||||
# 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
|
||||
DEFAULT_MAP_LAT=64.5
|
||||
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.
|
||||
|
||||
## 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:
|
||||
|
||||
@@ -117,7 +117,7 @@ The chosen precision is also written into XMP Photoshop Instructions.
|
||||
- `Left` / `Right`: Previous / 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
|
||||
- `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
|
||||
@@ -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.
|
||||
|
||||
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
|
||||
|
||||
@@ -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.
|
||||
- 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.
|
||||
- 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.
|
||||
|
||||
## Planned format expansion
|
||||
|
||||
+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