feat(post): add "More location details" panel — search, map, sync

Closed-by-default disclosure below City/Country holding a city/country
geocode lookup (Open-Meteo, city-only query with client-side country
ranking), a single-marker draggable MapLibre preview (lazy-loaded via
location-map.js), and the relocated lat/lng fields. GPS button, search
pick, pin drag, and typed values all stay in sync via syncPinFromFields(),
with a visual mismatch flag for unparseable typed input.

Built entirely in JS (mirrors the existing 'More options' disclosure
pattern) so post-form.html.twig needs no template change.
This commit is contained in:
2026-07-24 19:38:24 +02:00
parent 52e9fbadf2
commit 797b472a60
5 changed files with 952 additions and 64 deletions
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+61
View File
@@ -0,0 +1,61 @@
/*
* Single-marker, draggable preview map for the post form's "More location
* details" panel. A dedicated sibling to maplibre-utils.js's initEntryMap
* rather than an extension of it — that engine is built for multi-marker,
* GPX-drawing, popup-bearing read-only maps, none of which this preview
* needs (KTD1).
*
* maplibre-gl itself is lazy-imported on first call (KTD3) so an ordinary
* GPS-only submit, where the panel is never opened, never fetches it. The
* created map/marker are cached in module scope and reused on every
* subsequent call — see the panel's toggle-open handler in post-form.js,
* which calls this on every open and then always calls the returned
* handle's resize() (the container sits under display:none while the panel
* is closed, so the first paint would otherwise get a zero-size canvas).
*/
import { MAP_STYLE } from './map-style.js';
var cached = null; // { container, handle }
function buildPinElement() {
var el = document.createElement('div');
el.className = 'location-pin'; // visual styling lives in style.css
return el;
}
export function getOrCreateLocationMap(container, onDragEnd) {
if (cached && cached.container === container) {
return Promise.resolve(cached.handle);
}
return import('maplibre-gl').then(function (mod) {
var maplibregl = mod.default || mod;
var map = new maplibregl.Map({
container: container,
style: MAP_STYLE,
center: [0, 20],
zoom: 2,
attributionControl: false
});
map.addControl(new maplibregl.AttributionControl({ compact: true }), 'bottom-left');
var marker = new maplibregl.Marker({ draggable: true, element: buildPinElement() });
var pinSet = false; // R12: no pin shown until a coordinate is first set
marker.on('dragend', function () {
if (onDragEnd) onDragEnd(marker.getLngLat());
});
var handle = {
setPin: function (lat, lng) {
marker.setLngLat([lng, lat]);
if (!pinSet) { marker.addTo(map); pinSet = true; }
map.panTo([lng, lat]);
},
hasPin: function () { return pinSet; },
resize: function () { map.resize(); }
};
cached = { container: container, handle: handle };
return handle;
});
}
+242
View File
@@ -10,8 +10,12 @@
import EasyMDE from 'easymde';
import Sortable from 'sortablejs';
import 'easymde/dist/easymde.min.css';
// maplibre-gl's CSS is a static import — a dynamically-imported chunk's CSS is
// never auto-linked (R10). The JS itself stays a lazy import (see location-map.js).
import 'maplibre-gl/dist/maplibre-gl.css';
import './post-form.css';
import { apiSend, apiErrorMsg } from './api-utils.js';
import { getOrCreateLocationMap } from './location-map.js';
/* ── Markdown editor (EasyMDE) ───────────────────────────── */
function initEditor() {
@@ -441,6 +445,10 @@ function initGeo() {
setStatus(locStatus, '✓ Location captured · ' + lat + ', ' + lng, 'ok');
syncWeatherEnabled();
reverseGeocode(lat, lng); // fill City/Country in the background
// R11/KTD4: only push a live pin update if the panel is already
// open — otherwise the toggle-open handler syncs it on next open.
var openLocationDetails = document.querySelector('.location-details');
if (openLocationDetails && openLocationDetails.open) syncPinFromFields();
}, function (err) {
locBtn.classList.remove('is-loading');
locBtn.disabled = false;
@@ -480,6 +488,239 @@ function initGeo() {
}
}
/* ── "More location details" panel (U2-U5) ─────────────────────
* Closed-by-default disclosure holding a city/country search-by-lookup, a
* single-marker MapLibre preview (lazy-loaded, see location-map.js — KTD1/
* KTD3), and the relocated lat/lng fields. Built entirely in JS (KTD6),
* mirroring initDisclosure()'s create-via-JS + relocate-wrapper approach, so
* the template needs no structural change.
*
* Exposed at module scope (reassigned once the panel exists) so initGeo()'s
* GPS success handler — defined earlier in this file, but only invoked later
* on user click, after boot() has run initLocationDetails() — can push a live
* pin update when the panel is already open (KTD4).
*/
var syncPinFromFields = function () {}; // no-op until the panel initializes
function debounce(fn, ms) {
var t = null;
return function () {
var args = arguments;
clearTimeout(t);
t = setTimeout(function () { fn.apply(null, args); }, ms);
};
}
function geocodeResultLabel(r) {
return [r.name, r.admin1, r.country].filter(Boolean).join(', ');
}
function initLocationDetails() {
var latEl = field('lat');
var lngEl = field('lng');
if (!latEl || !lngEl) return; // no-op guard: fields absent from the DOM
var latWrap = latEl.closest('.form-field');
var lngWrap = lngEl.closest('.form-field');
if (!latWrap || !lngWrap) return;
var cityEl = field('location_city');
var countryEl = field('location_country');
var countryWrap = countryEl ? countryEl.closest('.form-field') : null;
var anchor = countryWrap || latWrap;
// ── Shell (U2, R1-R3, KTD6) ──
var details = document.createElement('details');
details.className = 'location-details';
var summary = document.createElement('summary');
summary.className = 'location-details__summary';
summary.textContent = 'More location details';
details.appendChild(summary);
var body = document.createElement('div');
body.className = 'location-details__body';
var searchRow = document.createElement('div');
searchRow.className = 'location-search-row';
var lookupBtn = document.createElement('button');
lookupBtn.type = 'button';
lookupBtn.id = 'lookup-coords';
lookupBtn.className = 'btn-action';
var LOOKUP_LABEL = '🔍 Look up coordinates';
lookupBtn.textContent = LOOKUP_LABEL;
searchRow.appendChild(lookupBtn);
body.appendChild(searchRow);
var hint = document.createElement('p');
hint.id = 'location-search-hint';
hint.className = 'location-search-hint';
hint.setAttribute('role', 'status');
body.appendChild(hint);
var results = document.createElement('ul');
results.id = 'location-search-results';
results.className = 'location-search-results';
body.appendChild(results);
var mapContainer = document.createElement('div');
mapContainer.id = 'location-map';
mapContainer.className = 'location-map';
body.appendChild(mapContainer);
details.appendChild(body);
anchor.parentNode.insertBefore(details, anchor.nextSibling);
details.appendChild(latWrap);
details.appendChild(lngWrap);
// ── Mismatch flag (U5, R11/R13) ──
function fieldNoteId(el) { return el === latEl ? 'location-lat-note' : 'location-lng-note'; }
function setMismatch() {
[latEl, lngEl].forEach(function (el) {
el.classList.add('location-field--mismatch');
el.setAttribute('aria-invalid', 'true');
var id = fieldNoteId(el);
var note = document.getElementById(id);
if (!note) {
note = document.createElement('span');
note.id = id;
note.className = 'location-field-note';
note.setAttribute('role', 'status');
note.setAttribute('aria-live', 'polite');
note.textContent = 'Not reflected on the map yet.';
el.parentNode.insertBefore(note, el.nextSibling);
}
el.setAttribute('aria-describedby', id);
});
}
function clearMismatch() {
[latEl, lngEl].forEach(function (el) {
el.classList.remove('location-field--mismatch');
el.removeAttribute('aria-invalid');
el.removeAttribute('aria-describedby');
var note = document.getElementById(fieldNoteId(el));
if (note && note.parentNode) note.parentNode.removeChild(note);
});
}
// ── Map + sync (U4, U5, R9-R14, KTD1/KTD3/KTD4) ──
var mapHandle = null;
function syncFields() {
var lat = parseFloat(latEl.value);
var lng = parseFloat(lngEl.value);
var valid = isFinite(lat) && isFinite(lng) &&
lat >= -90 && lat <= 90 && lng >= -180 && lng <= 180;
if (valid) {
clearMismatch();
if (mapHandle) mapHandle.setPin(lat, lng);
} else if (latEl.value.trim() || lngEl.value.trim()) {
// Only flag once the traveller has actually typed something —
// fresh blank fields are "no pin yet" (R12), not a mismatch.
setMismatch();
}
}
syncPinFromFields = syncFields; // expose for initGeo()'s GPS handler
function onDragEnd(lngLat) {
// Drag writes straight into the fields; it does not call syncFields()
// back, avoiding a feedback loop (KTD4).
latEl.value = lngLat.lat.toFixed(6);
lngEl.value = lngLat.lng.toFixed(6);
clearMismatch();
}
details.addEventListener('toggle', function () {
if (!details.open) return;
getOrCreateLocationMap(mapContainer, onDragEnd).then(function (handle) {
mapHandle = handle;
handle.resize(); // fixes the zero-size canvas from painting while display:none
syncFields();
});
});
latEl.addEventListener('blur', syncFields);
lngEl.addEventListener('blur', syncFields);
var debouncedSync = debounce(syncFields, 400);
latEl.addEventListener('input', debouncedSync);
lngEl.addEventListener('input', debouncedSync);
// ── Search (U3, R4-R8, KTD2) ──
function setHint(msg) { hint.textContent = msg || ''; }
function hideResults() { results.innerHTML = ''; }
function showResults(list) {
list.forEach(function (r) {
var li = document.createElement('li');
var btn = document.createElement('button');
btn.type = 'button';
btn.textContent = geocodeResultLabel(r); // createElement + textContent — no innerHTML for API data (R7)
btn.addEventListener('click', function () {
latEl.value = Number(r.latitude).toFixed(6);
lngEl.value = Number(r.longitude).toFixed(6);
hideResults();
syncFields(); // R7: sets lat/lng + pin only — never writes City/Country
});
li.appendChild(btn);
results.appendChild(li);
});
}
lookupBtn.addEventListener('click', function () {
hideResults();
var city = cityEl ? cityEl.value.trim() : '';
var country = countryEl ? countryEl.value.trim() : '';
if (!city && !country) {
setHint('Enter a city or country first.');
return;
}
// R4: never concatenate Country into the query string — verified live,
// it silently breaks disambiguation (e.g. "Paris, Texas"). Query by
// City; Country only ranks results client-side, and is the query term
// only as a fallback when City itself is blank.
var query = city || country;
setHint('');
lookupBtn.disabled = true;
lookupBtn.textContent = 'Searching…';
var url = 'https://geocoding-api.open-meteo.com/v1/search?name=' +
encodeURIComponent(query) + '&count=10&language=en&format=json';
fetch(url).then(function (r) { return r.json(); }).then(function (data) {
var list = (data && data.results) || [];
if (!list.length) {
setHint('No matches — try adding a country, or drag the pin on the map.');
return;
}
if (country) {
var needle = country.toLowerCase();
// Match against admin1 (state/region — e.g. "Texas") as well as
// country ("United States"): Open-Meteo's `country` field alone
// never reflects a state/province, so a Country field typed as
// "Texas" would never rank the Texas Paris above the other
// US-state Paris matches without also checking admin1 (verified
// live — see the design doc's Paris/Texas disambiguation case).
var matches = function (r) {
return (r.admin1 || '').toLowerCase().indexOf(needle) !== -1 ||
(r.country || '').toLowerCase().indexOf(needle) !== -1;
};
list = list.slice().sort(function (a, b) {
var am = matches(a);
var bm = matches(b);
if (am === bm) return 0;
return am ? -1 : 1;
});
}
showResults(list);
}).catch(function () {
// R8: network failure degrades silently — fields untouched.
}).then(function () {
lookupBtn.disabled = false;
lookupBtn.textContent = LOOKUP_LABEL;
});
});
}
/* ── Blocking required-field validation (U5, R19) ─────────────
* Replaces the template's inline validator. Reads the EasyMDE-synced content
* value (initEditor keeps the textarea current) and shows per-field messages.
@@ -1206,6 +1447,7 @@ function boot() {
initPhotoConversion();
initDisclosure();
initGeo();
initLocationDetails();
initValidation();
}