2 Commits
Author SHA1 Message Date
dmo e290bd1fff Add geotagging and location map v0.4 2026-07-23 09:44:07 +02:00
dmo cbf0daf062 Add status filtering and segmented progress v0.3 2026-07-23 01:13:15 +02:00
12 changed files with 697 additions and 69 deletions
+10
View File
@@ -9,3 +9,13 @@ APP_PORT=8080
APP_NAME=Photo Date Editor
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_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)
+3 -1
View File
@@ -1,2 +1,4 @@
.env
__pycache__/
*.py[cod]
.venv/
+27
View File
@@ -1,5 +1,32 @@
# Changelog
## 0.4.0
- Add a dedicated Location tab with an interactive Leaflet map.
- Add user-triggered address/place search through a backend geocoder endpoint.
- Add click-to-place and draggable map markers.
- Add editable decimal latitude and longitude fields plus an optional location label.
- Read existing EXIF/XMP GPS metadata and repopulate the map and fields.
- Write GPS coordinates to EXIF and XMP while preserving existing GPS unless the user changes it.
- Add explicit Clear location behavior that removes app-managed GPS and location tags on save.
- Add configurable tile, geocoder, and default map settings through optional environment variables.
- 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.
- Bump the application and service-worker cache version to 0.4.0.
## 0.3.0
- Add status filtering for All, Pending, Saved, Skipped, and Failed photos.
- Add sorting by filename or status; status order prioritizes Pending, then Failed, Skipped, and Saved.
- Make previous/next and Save & Next follow the currently filtered and sorted list.
- Replace the single blue progress fill with green Saved, yellow Skipped, and red Failed segments; Pending remains the grey remainder.
- Add accessible progress text with counts for every status.
- Document the proposed PNG, TIFF, HEIC/HEIF, DNG, Canon RAW, and Nikon RAW roadmap.
- Explicitly drop DWG and BMP alongside the previously excluded formats.
- Bump the service-worker cache and application version to 0.3.0.
## 0.2.0
- Read EXIF/XMP/IPTC metadata from the selected JPEG and populate the editor fields.
+33
View File
@@ -14,6 +14,39 @@ RUN pip install --no-cache-dir -r requirements.txt
COPY app/ ./
ARG LEAFLET_VERSION=1.9.4
RUN LEAFLET_VERSION="$LEAFLET_VERSION" python - <<'PY'
from pathlib import Path
import os
import urllib.request
version = os.environ["LEAFLET_VERSION"]
base = Path("/app/static/vendor/leaflet")
(base / "images").mkdir(parents=True, exist_ok=True)
files = {
"leaflet.css": "leaflet.css",
"leaflet.js": "leaflet.js",
"images/marker-icon.png": "images/marker-icon.png",
"images/marker-icon-2x.png": "images/marker-icon-2x.png",
"images/marker-shadow.png": "images/marker-shadow.png",
}
mirrors = (
f"https://unpkg.com/leaflet@{version}/dist",
f"https://cdn.jsdelivr.net/npm/leaflet@{version}/dist",
)
for destination, source in files.items():
target = base / destination
last_error = None
for mirror in mirrors:
try:
urllib.request.urlretrieve(f"{mirror}/{source}", target)
break
except Exception as exc: # build-time fallback to the second CDN
last_error = exc
else:
raise RuntimeError(f"Could not download Leaflet asset {source}: {last_error}")
PY
EXPOSE 8080
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8080", "--proxy-headers", "--forwarded-allow-ips=*"]
-24
View File
@@ -1,24 +0,0 @@
This is free and unencumbered software released into the public domain.
Anyone is free to copy, modify, publish, use, compile, sell, or
distribute this software, either in source code form or as a compiled
binary, for any purpose, commercial or non-commercial, and by any
means.
In jurisdictions that recognize copyright laws, the author or authors
of this software dedicate any and all copyright interest in the
software to the public domain. We make this dedication for the benefit
of the public at large and to the detriment of our heirs and
successors. We intend this dedication to be an overt act of
relinquishment in perpetuity of all present and future rights to this
software under copyright law.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR
OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE,
ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
OTHER DEALINGS IN THE SOFTWARE.
For more information, please refer to <https://unlicense.org/>
+49 -8
View File
@@ -1,6 +1,6 @@
# Photo Date Editor
Version 0.2.0
Version 0.4.0
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.
@@ -12,11 +12,14 @@ A self-hosted browser UI for manually dating scanned photographs. The browser re
- Exact date, month/year, year-only, approximate year
- Optional time; unknown time defaults to noon
- Description and keywords
- GPS geotagging with address search, map click, draggable pin, manual coordinates, and location removal
- In-place overwrite: no browser download and no `_original` file
- Saved/skipped/pending/failed state stored in browser local storage
- Filename search, status filtering, and filename/status sorting
- Segmented progress bar: saved is green, skipped is yellow, failed is red, and pending remains grey
- Existing metadata is read from the JPEG when a photo is selected
- Files previously edited by this app are recognized from their XMP marker, even in a different browser
- Keyboard navigation
- Keyboard navigation, including `Shift+Tab` between right-side tabs
## Requirements
@@ -43,11 +46,19 @@ APP_PORT=8080
APP_NAME=Photo Date Editor
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_ATTRIBUTION=&copy; OpenStreetMap contributors
DEFAULT_MAP_LAT=64.5
DEFAULT_MAP_LON=11.0
DEFAULT_MAP_ZOOM=5
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
## Upgrade from 0.1, 0.2, or 0.3
Replace the project files with this version and rebuild:
@@ -106,6 +117,21 @@ 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
## Location behavior
The **Location** tab supports four ways to set GPS metadata:
1. Search for an address or place and choose a result.
2. Click directly on the map.
3. Drag the existing marker.
4. Enter decimal latitude and longitude manually.
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.
## Important limitations
@@ -114,13 +140,28 @@ The chosen precision is also written into XMP Photoshop Instructions.
- 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.
- Test with copies first, then use your own normal backup routine for the originals.
## Planned format expansion
The backend already uses ExifTool, which is a good foundation for later format support. The next likely steps are:
Format support needs two separate capabilities: safe metadata writing and a preview the browser can display. ExifTool gives the backend a strong metadata foundation, while formats that browsers do not reliably preview will use a temporary server-generated preview without converting or replacing the original file.
1. HEIC/HEIF preview and metadata round-trip testing on ChromeOS.
2. Canon RAW (`CR2`, `CR3`) and Nikon RAW (`NEF`, `NRW`).
3. Sidecar XMP mode for RAW files, because modifying proprietary RAW containers directly is a different risk profile from JPEG.
4. Optional server-side folder mode for photos stored on NAS-mounted storage.
### Proposed order
1. **PNG** — direct browser preview and in-place EXIF/XMP/IPTC metadata writing.
2. **TIFF/TIF** — in-place metadata writing, with a server-generated preview where the browser cannot display the file directly.
3. **HEIC/HEIF/HIF** — in-place EXIF/XMP writing and a server-generated preview. This phase should use a recent ExifTool release and test normal, HDR, and motion-photo samples.
4. **DNG** — in-place metadata writing and preview extraction/rendering.
5. **Canon RAW**`CR2` and `CR3`, using per-format tag rules and embedded/server-generated previews.
6. **Nikon RAW**`NEF` and `NRW`, using per-format tag rules and embedded/server-generated previews.
`RAW` is not treated as one universal format. Each camera family will be enabled and tested explicitly.
### Not planned
- **DWG** — CAD drawing format rather than a scanned-photo format.
- **BMP/DIB** — ExifTool can read it but cannot write the metadata this app needs.
- **SVG, PDF, EPS, WebP, AVIF, EXR, XCF** — intentionally outside the scope of this photo workflow.
RAW support will remain **in-place** by default to match the JPEG workflow. Before enabling it, the app needs format-specific round-trip tests and clear warnings because proprietary camera originals deserve a stricter safety bar than scans.
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.2.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)
+272 -24
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);
@@ -20,9 +24,13 @@ const elements = {
folderName: $('#folder-name'),
folderAccess: $('#folder-access'),
progressCount: $('#progress-count'),
progressBar: $('#progress-bar'),
progressSaved: $('#progress-saved'),
progressSkipped: $('#progress-skipped'),
progressFailed: $('#progress-failed'),
photoCount: $('#photo-count'),
filter: $('#filter-input'),
statusFilter: $('#status-filter'),
sortSelect: $('#sort-select'),
fileList: $('#file-list'),
viewerEmpty: $('#viewer-empty'),
viewerContent: $('#viewer-content'),
@@ -51,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'),
@@ -163,6 +179,8 @@ async function scanFolder() {
elements.folderName.textContent = state.directoryHandle.name;
elements.folderAccess.textContent = 'Read/write access granted';
elements.filter.disabled = !photos.length;
elements.statusFilter.disabled = !photos.length;
elements.sortSelect.disabled = !photos.length;
elements.rescan.disabled = false;
elements.photoCount.textContent = String(photos.length);
@@ -202,15 +220,33 @@ function statusSymbol(status) {
return '';
}
function renderFileList() {
const filter = elements.filter.value.trim().toLowerCase();
const matching = state.photos
const statusSortOrder = { pending: 0, failed: 1, skipped: 2, saved: 3 };
function visiblePhotoEntries() {
const filenameFilter = elements.filter.value.trim().toLowerCase();
const statusFilter = elements.statusFilter.value;
const entries = state.photos
.map((photo, index) => ({ photo, index }))
.filter(({ photo }) => !filter || photo.name.toLowerCase().includes(filter));
.filter(({ photo }) => !filenameFilter || photo.name.toLowerCase().includes(filenameFilter))
.filter(({ photo }) => statusFilter === 'all' || photo.status === statusFilter);
if (elements.sortSelect.value === 'status') {
entries.sort((a, b) => {
const statusDifference = statusSortOrder[a.photo.status] - statusSortOrder[b.photo.status];
return statusDifference || naturalCompare(a.photo.name, b.photo.name);
});
} else {
entries.sort((a, b) => naturalCompare(a.photo.name, b.photo.name));
}
return entries;
}
function renderFileList() {
const matching = visiblePhotoEntries();
elements.fileList.innerHTML = '';
if (!matching.length) {
elements.fileList.innerHTML = '<div class="empty-list">No matching photos.</div>';
elements.fileList.innerHTML = '<div class="empty-list">No photos match the current filters.</div>';
return;
}
@@ -253,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(' · ');
}
@@ -330,9 +367,18 @@ async function selectPhoto(index, force = false) {
}
}
function adjacentVisibleIndex(delta) {
const visible = visiblePhotoEntries();
if (!visible.length) return null;
const position = visible.findIndex(({ index }) => index === state.currentIndex);
if (position === -1) return delta > 0 ? visible[0].index : visible[visible.length - 1].index;
const target = position + delta;
return target >= 0 && target < visible.length ? visible[target].index : null;
}
function updateNavigation() {
const hasPhoto = state.currentIndex >= 0;
elements.previous.disabled = !hasPhoto || state.currentIndex === 0 || state.processing;
elements.previous.disabled = !hasPhoto || adjacentVisibleIndex(-1) === null || state.processing;
elements.skip.disabled = !hasPhoto || state.processing;
elements.save.disabled = !hasPhoto || state.processing;
elements.saveNext.disabled = !hasPhoto || state.processing;
@@ -359,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() {
@@ -375,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)}"]`);
@@ -387,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) {
@@ -409,6 +472,7 @@ async function savePhoto(moveNext) {
catch (error) { showToast(error.message, true); return; }
const photo = state.photos[state.currentIndex];
const nextVisibleIndex = moveNext ? adjacentVisibleIndex(1) : null;
state.processing = true;
updateNavigation();
setSaveStatus('saving', 'Saving metadata…', photo.name);
@@ -429,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) {
@@ -451,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();
@@ -464,8 +538,8 @@ async function savePhoto(moveNext) {
if (oldThumb) URL.revokeObjectURL(oldThumb);
state.thumbUrls.delete(photo.name);
if (moveNext && state.currentIndex < state.photos.length - 1) {
await selectPhoto(state.currentIndex + 1, true);
if (moveNext && nextVisibleIndex !== null) {
await selectPhoto(nextVisibleIndex, true);
} else {
await selectPhoto(state.currentIndex, true);
}
@@ -485,29 +559,39 @@ async function savePhoto(moveNext) {
function skipCurrentPhoto() {
if (state.processing || state.currentIndex < 0) return;
const photo = state.photos[state.currentIndex];
const nextVisibleIndex = adjacentVisibleIndex(1);
captureCurrentValues();
photo.status = 'skipped';
saveFolderState();
updateProgress();
renderFileList();
showToast(`Skipped ${photo.name}`);
if (state.currentIndex < state.photos.length - 1) goRelative(1);
if (nextVisibleIndex !== null) selectPhoto(nextVisibleIndex);
else updatePhotoStatus(photo);
}
function updateProgress() {
const saved = state.photos.filter((photo) => photo.status === 'saved').length;
const skipped = state.photos.filter((photo) => photo.status === 'skipped').length;
const failed = state.photos.filter((photo) => photo.status === 'failed').length;
const pending = state.photos.filter((photo) => photo.status === 'pending').length;
const processed = saved + skipped;
const total = state.photos.length;
const percentage = (count) => total ? `${(count / total) * 100}%` : '0%';
elements.progressCount.textContent = `${processed} / ${total}`;
elements.progressBar.style.width = total ? `${(processed / total) * 100}%` : '0%';
elements.progressCount.title = `${saved} saved, ${skipped} skipped`;
elements.progressSaved.style.width = percentage(saved);
elements.progressSkipped.style.width = percentage(skipped);
elements.progressFailed.style.width = percentage(failed);
elements.progressCount.title = `${saved} saved, ${skipped} skipped, ${failed} failed, ${pending} pending`;
const track = elements.progressSaved.parentElement;
track.setAttribute('aria-valuenow', total ? String(Math.round((processed / total) * 100)) : '0');
track.setAttribute('aria-valuetext', `${saved} saved, ${skipped} skipped, ${failed} failed, ${pending} pending`);
}
function goRelative(delta) {
const target = state.currentIndex + delta;
if (target >= 0 && target < state.photos.length) selectPhoto(target);
const target = adjacentVisibleIndex(delta);
if (target !== null) selectPhoto(target);
}
function copyPreviousValues() {
@@ -515,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)`;
}
@@ -527,12 +757,22 @@ function resetView() { state.zoom = 1; state.rotation = 0; applyView(); }
for (const button of elements.openButtons) button.addEventListener('click', openFolder);
elements.rescan.addEventListener('click', scanFolder);
elements.filter.addEventListener('input', renderFileList);
elements.filter.addEventListener('input', () => { renderFileList(); updateNavigation(); });
elements.statusFilter.addEventListener('change', () => { renderFileList(); updateNavigation(); });
elements.sortSelect.addEventListener('change', () => { renderFileList(); updateNavigation(); });
elements.previous.addEventListener('click', () => goRelative(-1));
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);
@@ -545,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());
@@ -559,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;
}
@@ -579,6 +826,7 @@ async function initialise() {
const config = await response.json();
elements.appName.textContent = config.appName;
document.title = config.appName;
state.mapConfig = config;
}
} catch {}
+62 -2
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>
@@ -42,7 +43,11 @@
<span>Progress</span>
<span id="progress-count">0 / 0</span>
</div>
<div class="progress-track"><div id="progress-bar" class="progress-bar"></div></div>
<div class="progress-track" role="progressbar" aria-label="Photo processing progress" aria-valuemin="0" aria-valuemax="100">
<div id="progress-saved" class="progress-segment saved" title="Saved"></div>
<div id="progress-skipped" class="progress-segment skipped" title="Skipped"></div>
<div id="progress-failed" class="progress-segment failed" title="Failed"></div>
</div>
<div class="status-legend">
<span><i class="dot saved"></i>Saved</span>
<span><i class="dot skipped"></i>Skipped</span>
@@ -53,7 +58,20 @@
<section class="panel-section file-section">
<div class="section-heading"><span>Photos</span><span id="photo-count">0</span></div>
<input id="filter-input" class="input filter" type="search" placeholder="Filter filenames…" disabled>
<div class="file-controls">
<input id="filter-input" class="input filter filename-filter" type="search" placeholder="Filter filenames…" aria-label="Filter photos by filename" disabled>
<select id="status-filter" class="input filter" aria-label="Filter photos by status" disabled>
<option value="all">All statuses</option>
<option value="pending">Pending</option>
<option value="saved">Saved</option>
<option value="skipped">Skipped</option>
<option value="failed">Failed</option>
</select>
<select id="sort-select" class="input filter" aria-label="Sort photos" disabled>
<option value="filename">Sort: Filename</option>
<option value="status">Sort: Status</option>
</select>
</div>
<div id="file-list" class="file-list" aria-live="polite">
<div class="empty-list">Choose a folder containing JPG or JPEG photos.</div>
</div>
@@ -109,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">
@@ -176,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">
@@ -197,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-v2';
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();
+33 -2
View File
@@ -41,12 +41,17 @@ button { color: inherit; }
.folder-row strong, .folder-row span { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.folder-row strong { max-width: 185px; }
.folder-row span { color: var(--muted); font-size: 12px; margin-top: 4px; max-width: 185px; }
.progress-track { height: 7px; background: #2a3440; border-radius: 99px; overflow: hidden; }
.progress-bar { width: 0; height: 100%; background: var(--primary); transition: width .2s ease; }
.progress-track { height: 7px; display: flex; background: #2a3440; border-radius: 99px; overflow: hidden; }
.progress-segment { width: 0; height: 100%; flex: 0 0 auto; transition: width .2s ease; }
.progress-segment.saved { background: var(--success); }
.progress-segment.skipped { background: var(--warning); }
.progress-segment.failed { background: var(--danger); }
.status-legend { display: flex; gap: 12px; margin-top: 11px; color: var(--muted); font-size: 11px; }
.dot { display: inline-block; width: 7px; height: 7px; border-radius: 50%; margin-right: 5px; }
.dot.saved { background: var(--success); }.dot.skipped { background: #c49a5a; }.dot.pending { background: #748294; }.dot.failed { background: var(--danger); }
.file-section { flex: 1; display: flex; min-height: 0; flex-direction: column; }
.file-controls { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
.filename-filter { grid-column: 1 / -1; }
.file-list { flex: 1; min-height: 120px; overflow: auto; margin-top: 10px; padding-right: 3px; }
.empty-list { color: var(--muted); font-size: 13px; line-height: 1.55; padding: 15px 5px; }
.file-row { width: 100%; border: 0; border-radius: 8px; background: transparent; display: grid; grid-template-columns: 50px minmax(0, 1fr) 22px; align-items: center; gap: 10px; padding: 8px; text-align: left; cursor: pointer; }
@@ -106,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; }