Merge feat/post-location-override into main

Post-form location override: search + map + drag pin (U1-U6), hardened by a
multi-agent code review — server-side cleanCoordinate() guard, strict
coordinate parsing, three closed gate-bypass paths, pin removal on blanked
fields, visible geocode failures.

Also carries maplibre's stylesheet moved to a lazy <link> at panel-open
(post-form.css 92,244 -> 26,784 raw; 14,528 -> 5,631 gzip), the test-entry
leak fix into real trip content, the GPX leg-connection fix, and the U+200E
coordinate strip.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-24 23:48:00 +02:00
co-authored by Claude Opus 5
14 changed files with 1292 additions and 74 deletions
+1
View File
@@ -5,4 +5,5 @@ date: '2025-09-01'
date_start: '2025-09-01'
date_end: '2025-09-08'
cover_image: ''
tagline: '600 km of Tuscan gravel — hill towns, aperitivi, and relentless climbing'
---
+85
View File
@@ -3,6 +3,7 @@ namespace Grav\Plugin;
use Grav\Common\Cache;
use Grav\Common\Data\ValidationException;
use Grav\Common\Page\Interfaces\PageInterface;
use Grav\Common\Plugin;
use RocketTheme\Toolbox\Event\Event;
@@ -43,6 +44,16 @@ class CacheOnSavePlugin extends Plugin
// (priority 0) has created the page and copied the uploaded files —
// we reorder those files, then clear the page-tree cache.
'onFormProcessed' => ['onFormProcessed', -100],
// Fired by the api plugin (PagesController) on every Admin2-driven
// create/update/translate, right before $page->save().
// onFormValidationProcessed (above) does not fire for Admin2 saves,
// so BOTH hooks are needed to cover every path lat/lng can arrive
// through: this one for Admin2/API, that one for the public /post
// form. (Until the location-override panel shipped, the public
// form's lat/lng inputs were CSS-hidden and GPS-filled, making
// Admin2 the only realistic hand-entry route. They are now visible
// and directly editable, so the /post path is the primary one.)
'onAdminSave' => ['onAdminSave', 0],
];
}
@@ -74,6 +85,7 @@ class CacheOnSavePlugin extends Plugin
}
$form->setData('parent', $this->resolveDailiesParent($activeTrip));
$this->sanitizeCoordinates($form);
// One shared /post form drives both create and edit (KTD1). add-page-by-form
// reads overwrite_mode from the /post page header's pageconfig (not form
@@ -149,6 +161,79 @@ class CacheOnSavePlugin extends Plugin
return '/' . $trip . '/dailies';
}
/**
* Strip invisible Unicode formatting characters (bidi marks, zero-width
* joiners, etc. — Unicode category "Cf") from a pasted lat/lng value, then
* validate the result is a real coordinate.
*
* Root cause this guards against: coordinates copied from a maps app can
* carry an invisible mark (e.g. U+200E LEFT-TO-RIGHT MARK) that neither
* JS `.trim()` nor PHP's numeric-string coercion strip. Twig's
* `number_format` filter then silently float-casts the corrupted string
* to 0.0 (PHP does not raise a warning), placing the entry at Null Island
* instead of failing loudly.
*
* Coordinates are optional (some entries intentionally have none), so a
* blank value cleans to '' with no error — only a non-blank value that
* still fails to parse as an in-range decimal after cleaning is rejected.
*
* @return string the cleaned value to write back
* @throws ValidationException if non-blank but still invalid after cleaning
*/
private function cleanCoordinate(string $field, string $raw, float $bound): string
{
$clean = trim(preg_replace('/\p{Cf}/u', '', $raw) ?? $raw);
if ($clean === '') {
return $clean;
}
if (!is_numeric($clean) || abs((float) $clean) > $bound) {
throw new ValidationException(sprintf(
'%s "%s" is not a valid coordinate — check for stray characters from pasting.',
$field === 'lat' ? 'Latitude' : 'Longitude',
$raw
));
}
return $clean;
}
/** Post-form entry point (see cleanCoordinate) — 'lat'/'lng' as top-level form fields. */
private function sanitizeCoordinates($form): void
{
foreach (['lat' => 90.0, 'lng' => 180.0] as $field => $bound) {
$raw = $form->value($field);
if (!is_string($raw)) {
continue;
}
$form->setData($field, $this->cleanCoordinate($field, $raw, $bound));
}
}
/**
* Admin2/API entry point (see cleanCoordinate) — 'header.lat'/'header.lng'
* on the Page object the api plugin is about to save. Fires on every
* create/update/translate (PagesController::create/update/translatePage),
* so this also re-validates already-clean values on every subsequent edit
* — harmless, since a clean value round-trips unchanged.
*/
public function onAdminSave(Event $event): void
{
$page = $event['page'] ?? $event['object'] ?? null;
if (!$page instanceof PageInterface) {
return;
}
$header = $page->header();
if (!$header) {
return;
}
foreach (['lat' => 90.0, 'lng' => 180.0] as $field => $bound) {
$raw = $header->{$field} ?? null;
if (!is_string($raw)) {
continue; // unset, null, or already a native number — nothing to clean
}
$header->{$field} = $this->cleanCoordinate($field, $raw, $bound);
}
}
/**
* The photo order the user arranged in the form, sent explicitly by
* post-form.js as a JSON array of filenames in the dedicated
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
-4
View File
@@ -890,10 +890,6 @@ body::after {
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 */
.post-form-wrap .form-field { margin-bottom: var(--space-5); }
.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 */
import { MAP_STYLE } from './src/map-style.js';
(function (global) {
var ACCENT = '#2A8C73';
var ACCENT_DIM = '#155244';
var MAP_STYLE = 'https://basemaps.cartocdn.com/gl/dark-matter-gl-style/style.json';
/* Build a GeoJSON LineString feature */
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
+125
View File
@@ -0,0 +1,125 @@
/*
* 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. Its
* STYLESHEET is lazy too see ensureMaplibreCss below. 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 }
var cssPromise = null; // in-flight/settled <link> load for maplibre's stylesheet
/**
* Inject maplibre-gl's stylesheet on demand, once.
*
* esbuild will not emit a <link> for a code-split chunk's CSS (R10), so the
* usual fix is a static `import 'maplibre-gl/dist/maplibre-gl.css'` up in
* post-form.js but that defeats the lazy import it accompanies: every /post
* load would then pay ~9 KB gzip of vendor CSS for a panel most submits never
* open. So package.json builds the vendor stylesheet as its own
* css-compiled/maplibre-gl.css and we <link> it here instead, in parallel with
* the engine's import(). Keeping the vendor file intact (rather than
* hand-picking the ~16 selectors this panel actually uses) means a maplibre
* upgrade can't silently un-style the map.
*
* The href is resolved from import.meta.url the bundle is ESM, so this is the
* URL of this chunk under `js/post/`, which makes the link correct under any
* Grav base path without threading a URL through the template. (The panel is
* built entirely in JS, so there is no Twig element to hang a data-attr on.)
*
* Resolves on error as well as load: a missing stylesheet must degrade to an
* unstyled-but-functional map, never block it. On error the dead <link> and the
* memo are dropped so a later retry re-attempts it, mirroring the map cache's
* clear-on-failure below.
*/
function ensureMaplibreCss() {
if (cssPromise) return cssPromise;
cssPromise = new Promise(function (resolve) {
var link = document.createElement('link');
link.rel = 'stylesheet';
link.href = new URL('../../css-compiled/maplibre-gl.css', import.meta.url).href;
link.onload = function () { resolve(); };
link.onerror = function () {
link.remove();
cssPromise = null;
resolve();
};
document.head.appendChild(link);
});
return cssPromise;
}
function buildPinElement() {
var el = document.createElement('div');
el.className = 'location-pin'; // visual styling lives in post-form.css
return el;
}
export function getOrCreateLocationMap(container, onDragEnd) {
if (cached && cached.container === container) {
return cached.promise;
}
// Stylesheet and engine fetch concurrently; awaiting the CSS before
// constructing the Map means maplibre never measures the container against
// half-applied styles.
var promise = Promise.all([
import('maplibre-gl'),
ensureMaplibreCss()
]).then(function (loaded) {
var mod = loaded[0];
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]);
},
clearPin: function () {
if (pinSet) { marker.remove(); pinSet = false; }
},
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';
+107
View File
@@ -519,3 +519,110 @@
/* SortableJS drag feedback. */
.photo-editor__cell.sortable-ghost { opacity: 0.4; }
.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 lookup button reuses style.css's existing .btn-action /
.form-status conventions unmodified. The relocated lat/lng fields do NOT
reuse .field-invalid the panel adds its own .location-field--mismatch /
.location-field-note pair below, because the mismatch state is advisory and
carries live aria-invalid / aria-describedby wiring that showError() does
not. Worth consolidating with .field-invalid if that ever gains the same. */
.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);
}
+313
View File
@@ -10,8 +10,16 @@
import EasyMDE from 'easymde';
import Sortable from 'sortablejs';
import 'easymde/dist/easymde.min.css';
// NOTE: maplibre-gl's CSS is deliberately NOT imported here. esbuild never emits
// a <link> for a dynamically-imported chunk's CSS (R10), and a static import
// would have folded 68 KB / ~9 KB gzip of vendor stylesheet — ~78% of it rules
// for controls this panel never creates — into post-form.css on every /post
// load. location-map.js instead injects a <link> to the separately-built
// css-compiled/maplibre-gl.css at the moment it lazy-imports the engine, so an
// ordinary GPS-only submit pays nothing for either half.
import './post-form.css';
import { apiSend, apiErrorMsg } from './api-utils.js';
import { getOrCreateLocationMap } from './location-map.js';
/* ── Markdown editor (EasyMDE) ───────────────────────────── */
function initEditor() {
@@ -473,6 +481,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;
@@ -512,6 +524,294 @@ 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;
// parseFloat is a PREFIX parser and must not be used here: '48abc' → 48,
// '48,85' → 48 (comma-decimal paste), '35.0116S' → +35.0116 (hemisphere
// suffix silently flipped to the wrong side of the equator). All three pass
// an isFinite + range check, clear the flag, and post the original string —
// the exact silent-corruption class this feature exists to end. Require the
// whole value to be a plain decimal instead. Not bare Number(): Number('')
// is 0, which would make a blank pair "valid" and pin it at Null Island.
function parseCoord(v) {
var s = String(v).trim();
if (!s || !/^[+-]?(\d+(\.\d*)?|\.\d+)$/.test(s)) return NaN;
return Number(s);
}
function syncFields() {
var lat = parseCoord(latEl.value);
var lng = parseCoord(lngEl.value);
var valid = isFinite(lat) && isFinite(lng) &&
lat >= -90 && lat <= 90 && lng >= -180 && lng <= 180;
if (valid) {
// Normalise to the same precision the GPS handler and onDragEnd
// write, so what the pin shows is exactly what gets submitted.
latEl.value = lat.toFixed(6);
lngEl.value = lng.toFixed(6);
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. Drop the pin too: leaving it behind
// would present a stale coordinate as if it were still the entry's.
clearMismatch();
if (mapHandle) mapHandle.clearPin();
}
}
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.');
// Still validate: syncFields tolerates a null mapHandle, and without
// this an offline traveller could open the panel and submit an
// invalid coordinate that was never flagged.
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';
// 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) {
// Without this a 4xx/5xx body (rate limit, upstream error) parses as
// JSON with no `results` key and the traveller is told their city
// does not exist — sending them off to hunt a spelling mistake that
// isn't there. Route real failures to the catch instead.
if (!r.ok) throw new Error('geocode http ' + r.status);
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 revised: the fields stay untouched on failure (that part of R8
// is the actual guarantee), but the failure is no longer invisible.
// Silence left the DOM byte-identical to the pre-click state — empty
// hint, enabled button — so a traveller on flaky mobile data could
// not tell "lookup failed" from "the button is broken". Distinct
// from the no-match message above, which means the service answered.
setHint('Couldnt reach the lookup service — check your connection and try again, or drag the pin on the map.');
}).then(function () {
clearTimeout(timeoutId);
lookupBtn.disabled = false;
lookupBtn.textContent = LOOKUP_LABEL;
});
});
// Validate whatever is already in the fields at boot. initDraft() restores
// data[lat]/data[lng] from localStorage before this runs, and edit-mode
// prefills them asynchronously — both by direct .value assignment, which
// fires no events. Without this the submit gate in initValidation (which
// keys on .location-field--mismatch) sees no flag and lets a restored
// invalid coordinate straight through.
syncFields();
}
/* 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.
@@ -596,6 +896,13 @@ function initValidation() {
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) {
e.preventDefault();
if (typeof firstInvalid.focus === 'function') firstInvalid.focus();
@@ -1190,6 +1497,11 @@ function initEditMode() {
editSetContent(d.content);
editSetText('lat', h.lat);
editSetText('lng', h.lng);
// Direct .value writes fire no events, so flag/pin state would stay
// stale — and an entry stored with an out-of-range coordinate would
// never be flagged. initLocationDetails() has already replaced the
// no-op by the time this async prefill resolves.
syncPinFromFields();
editSetText('location_city', h.location_city);
editSetText('location_country', h.location_country);
editSetText('weather_desc', h.weather_desc);
@@ -1238,6 +1550,7 @@ function boot() {
initPhotoConversion();
initDisclosure();
initGeo();
initLocationDetails();
initValidation();
}
+1 -1
View File
@@ -1,7 +1,7 @@
{
"private": true,
"scripts": {
"build": "node scripts/gen-weather-icons.js && esbuild js/src/main.js --bundle --minify --format=iife --outfile=js/main.js --loader:.woff2=file --loader:.woff=file --asset-names=../fonts/[name] && esbuild js/src/map.js --bundle --minify --format=iife --outfile=js/map.js && esbuild js/src/feed-actions.js --bundle --minify --format=iife --outfile=js/feed-actions.js && esbuild js/src/trip-publish.js --bundle --minify --format=iife --outfile=js/trip-publish.js && rm -rf js/post && esbuild js/src/post-form.js --bundle --minify --format=esm --splitting --outdir=js/post && mkdir -p css-compiled fonts && { mv js/main.css css-compiled/main.css 2>/dev/null || true; } && { mv js/map.css css-compiled/map.css 2>/dev/null || true; } && { mv js/post/post-form.css css-compiled/post-form.css 2>/dev/null || true; }"
"build": "node scripts/gen-weather-icons.js && esbuild js/src/main.js --bundle --minify --format=iife --outfile=js/main.js --loader:.woff2=file --loader:.woff=file --asset-names=../fonts/[name] && esbuild js/src/map.js --bundle --minify --format=iife --outfile=js/map.js && esbuild js/src/feed-actions.js --bundle --minify --format=iife --outfile=js/feed-actions.js && esbuild js/src/trip-publish.js --bundle --minify --format=iife --outfile=js/trip-publish.js && rm -rf js/post && esbuild js/src/post-form.js --bundle --minify --format=esm --splitting --outdir=js/post && mkdir -p css-compiled fonts && esbuild node_modules/maplibre-gl/dist/maplibre-gl.css --bundle --minify --outfile=css-compiled/maplibre-gl.css && { mv js/main.css css-compiled/main.css 2>/dev/null || true; } && { mv js/map.css css-compiled/map.css 2>/dev/null || true; } && { mv js/post/post-form.css css-compiled/post-form.css 2>/dev/null || true; }"
},
"dependencies": {
"@fontsource-variable/dm-sans": "latest",