Compare commits

..
4 Commits
Author SHA1 Message Date
m038andClaude Sonnet 5 13c76b29a8 fix(post): close location-map race, block submit on bad coords
Code review (4 independent reviewers) converged on the same bug: the
maplibre-gl singleton cached its handle only after import() resolved,
so a fast close/reopen of the "More location details" panel could
race two Map instances onto one container. Cache the in-flight promise
synchronously instead, and propagate/handle import rejection so a
failed map load surfaces a hint instead of hanging silently.

Also closes a submit-time hole the adversarial pass found: the
mismatch flag on lat/lng was purely cosmetic and never blocked
form submission, so out-of-range coordinates could be saved. The
flag now gates submit like the other required fields, and clears
itself when both fields are blanked back out instead of sticking.

The geocode fetch gets a 10s timeout via AbortController so a hung
response can't leave the lookup button disabled forever.

Also moves the location-details CSS out of the site-wide style.css
into post-form's own code-split stylesheet, since none of it is used
outside the post form.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-24 20:03:01 +02:00
m038 797b472a60 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.
2026-07-24 19:38:24 +02:00
m038 52e9fbadf2 refactor(map): extract MAP_STYLE into a shared map-style.js module
Single source of truth for the MapLibre style URL, shared between
maplibre-utils.js (multi-marker/GPX maps) and the new location-map.js
preview module — no behavior change.
2026-07-24 19:38:10 +02:00
m038 a00690fbde feat(post): unhide lat/lng fields, style the location-details panel
Removes the CSS rule hiding data[lat]/data[lng] and adds styling for the
new 'More location details' disclosure, search results list, map preview
container, and mismatch-flag state.
2026-07-24 19:38:05 +02:00
10 changed files with 1101 additions and 76 deletions
File diff suppressed because one or more lines are too long
-4
View File
@@ -833,10 +833,6 @@ body::after {
color: var(--color-ink); color: var(--color-ink);
} }
/* Hide GPS coordinate fields — filled by JS, not user-facing */
.post-form-wrap .form-field:has(input[name="data[lat]"]),
.post-form-wrap .form-field:has(input[name="data[lng]"]) { display: none !important; }
/* Grav form field inputs */ /* Grav form field inputs */
.post-form-wrap .form-field { margin-bottom: var(--space-5); } .post-form-wrap .form-field { margin-bottom: var(--space-5); }
.post-form-wrap .form-label label { .post-form-wrap .form-label label {
File diff suppressed because one or more lines are too long
+2 -1
View File
@@ -1,8 +1,9 @@
/* Shared MapLibre GL utilities — loaded by map.html.twig, dailies.html.twig, home.html.twig */ /* Shared MapLibre GL utilities — loaded by map.html.twig, dailies.html.twig, home.html.twig */
import { MAP_STYLE } from './src/map-style.js';
(function (global) { (function (global) {
var ACCENT = '#2A8C73'; var ACCENT = '#2A8C73';
var ACCENT_DIM = '#155244'; var ACCENT_DIM = '#155244';
var MAP_STYLE = 'https://basemaps.cartocdn.com/gl/dark-matter-gl-style/style.json';
/* Build a GeoJSON LineString feature */ /* Build a GeoJSON LineString feature */
function lineFeature(coords) { function lineFeature(coords) {
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+74
View File
@@ -0,0 +1,74 @@
/*
* 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).
*
* The cache stores the in-flight PROMISE, not just the resolved handle
* written synchronously before import() settles. A close/reopen of the panel
* while the maplibre-gl chunk is still loading would otherwise re-enter this
* function and race a second import().then() into building a second Map
* against the same container (caught in code review confirmed independently
* by four reviewers).
*/
import { MAP_STYLE } from './map-style.js';
var cached = null; // { container, promise }
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 cached.promise;
}
var promise = 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());
});
return {
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(); }
};
}).catch(function (err) {
// Leave the cache clear so a later retry (e.g. after transient network
// failure) re-attempts the import instead of permanently returning a
// rejected promise for this container.
if (cached && cached.container === container) cached = null;
throw err;
});
cached = { container: container, promise: promise };
return promise;
}
+4
View File
@@ -0,0 +1,4 @@
// Shared MapLibre style URL — single source of truth for maplibre-utils.js
// (multi-marker/GPX maps) and location-map.js (post-form pin preview), so the
// two never drift apart (KTD1).
export const MAP_STYLE = 'https://basemaps.cartocdn.com/gl/dark-matter-gl-style/style.json';
+104
View File
@@ -519,3 +519,107 @@
/* SortableJS drag feedback. */ /* SortableJS drag feedback. */
.photo-editor__cell.sortable-ghost { opacity: 0.4; } .photo-editor__cell.sortable-ghost { opacity: 0.4; }
.photo-editor__cell.sortable-chosen { outline: 2px solid var(--color-accent); } .photo-editor__cell.sortable-chosen { outline: 2px solid var(--color-accent); }
/* "More location details" disclosure search + map preview for setting an
entry's coordinates without live GPS. Mirrors .more-options's disclosure
look (above); the lat/lng fields (relocated here by JS) and the lookup
button reuse style.css's existing .btn-action/.form-status/.field-invalid
conventions unmodified. */
.location-details {
margin-bottom: var(--space-5);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background: var(--color-canvas);
}
.location-details__summary {
cursor: pointer;
padding: 0.875rem 1rem;
min-height: 44px;
display: flex;
align-items: center;
font-family: var(--font-ui);
font-size: var(--text-sm);
font-weight: 600;
color: var(--color-ink);
list-style: none;
user-select: none;
}
.location-details__summary::-webkit-details-marker { display: none; }
.location-details__summary::before {
content: '▸';
margin-right: var(--space-2);
color: var(--color-ink-muted);
transition: transform 0.15s;
}
.location-details[open] .location-details__summary::before { transform: rotate(90deg); }
.location-details[open] .location-details__summary { border-bottom: 1px solid var(--color-border); }
.location-details > .form-field { padding: 0 1rem; }
.location-details > .form-field:first-of-type { padding-top: var(--space-4); }
.location-details > .form-field:last-of-type { padding-bottom: var(--space-2); }
.location-details__body { padding: 1rem; }
.location-search-row { display: flex; gap: var(--space-3); align-items: center; flex-wrap: wrap; }
.location-search-hint {
font-size: var(--text-sm);
color: var(--color-ink-muted);
margin-top: var(--space-2);
}
.location-search-hint:empty { display: none; }
.location-search-results {
list-style: none;
margin: var(--space-3) 0 0;
padding: 0;
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
overflow: hidden;
}
.location-search-results:empty { display: none; margin: 0; border: none; }
.location-search-results li + li { border-top: 1px solid var(--color-border); }
.location-search-results button {
display: block;
width: 100%;
text-align: left;
padding: 0.75rem 1rem;
min-height: 44px;
background: var(--color-canvas);
border: none;
font-family: var(--font-ui);
font-size: var(--text-sm);
color: var(--color-ink);
cursor: pointer;
}
.location-search-results button:hover,
.location-search-results button:focus-visible { background: var(--color-paper); }
.location-map {
position: relative;
width: 100%;
height: 240px;
margin-top: var(--space-4);
border-radius: var(--radius-md);
overflow: hidden;
background: var(--color-paper);
}
.location-map .maplibregl-canvas { border-radius: var(--radius-md); }
.location-pin {
width: 44px;
height: 44px;
border-radius: 50%;
background: var(--color-accent);
border: 3px solid #fff;
box-shadow: 0 1px 6px rgba(0, 0, 0, 0.5);
cursor: grab;
}
.location-pin:active { cursor: grabbing; }
/* Mismatch flag: a typed lat/lng that doesn't (yet) parse to a valid pin. */
.location-field--mismatch { border-color: var(--color-error) !important; outline-color: var(--color-error) !important; }
.location-field-note {
display: block;
font-size: var(--text-sm);
color: var(--color-error);
margin-top: var(--space-1);
}
+261
View File
@@ -10,8 +10,12 @@
import EasyMDE from 'easymde'; import EasyMDE from 'easymde';
import Sortable from 'sortablejs'; import Sortable from 'sortablejs';
import 'easymde/dist/easymde.min.css'; 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 './post-form.css';
import { apiSend, apiErrorMsg } from './api-utils.js'; import { apiSend, apiErrorMsg } from './api-utils.js';
import { getOrCreateLocationMap } from './location-map.js';
/* ── Markdown editor (EasyMDE) ───────────────────────────── */ /* ── Markdown editor (EasyMDE) ───────────────────────────── */
function initEditor() { function initEditor() {
@@ -441,6 +445,10 @@ function initGeo() {
setStatus(locStatus, '✓ Location captured · ' + lat + ', ' + lng, 'ok'); setStatus(locStatus, '✓ Location captured · ' + lat + ', ' + lng, 'ok');
syncWeatherEnabled(); syncWeatherEnabled();
reverseGeocode(lat, lng); // fill City/Country in the background 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) { }, function (err) {
locBtn.classList.remove('is-loading'); locBtn.classList.remove('is-loading');
locBtn.disabled = false; locBtn.disabled = false;
@@ -480,6 +488,251 @@ 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();
} else {
// Both blanked back out after being flagged — nothing left to submit,
// so the flag no longer applies.
clearMismatch();
}
}
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();
}).catch(function () {
setHint('Map preview unavailable — you can still enter coordinates directly.');
});
});
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';
// Bound the request so a hung/never-resolving response can't leave the
// button stuck disabled on "Searching…" forever.
var controller = new AbortController();
var timeoutId = setTimeout(function () { controller.abort(); }, 10000);
fetch(url, { signal: controller.signal }).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 (including our own timeout abort) degrades
// silently — fields untouched.
}).then(function () {
clearTimeout(timeoutId);
lookupBtn.disabled = false;
lookupBtn.textContent = LOOKUP_LABEL;
});
});
}
/* Blocking required-field validation (U5, R19) /* Blocking required-field validation (U5, R19)
* Replaces the template's inline validator. Reads the EasyMDE-synced content * Replaces the template's inline validator. Reads the EasyMDE-synced content
* value (initEditor keeps the textarea current) and shows per-field messages. * value (initEditor keeps the textarea current) and shows per-field messages.
@@ -564,6 +817,13 @@ function initValidation() {
if (!firstInvalid) firstInvalid = el; if (!firstInvalid) firstInvalid = el;
} }
}); });
// A flagged-but-unresolved lat/lng (typed garbage, never fixed or
// cleared) must not reach the server — the mismatch styling alone
// doesn't block submission (caught in code review).
var mismatchEl = form.querySelector('.location-field--mismatch');
if (mismatchEl && !firstInvalid) firstInvalid = mismatchEl;
if (firstInvalid) { if (firstInvalid) {
e.preventDefault(); e.preventDefault();
if (typeof firstInvalid.focus === 'function') firstInvalid.focus(); if (typeof firstInvalid.focus === 'function') firstInvalid.focus();
@@ -1206,6 +1466,7 @@ function boot() {
initPhotoConversion(); initPhotoConversion();
initDisclosure(); initDisclosure();
initGeo(); initGeo();
initLocationDetails();
initValidation(); initValidation();
} }