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:
@@ -5,4 +5,5 @@ date: '2025-09-01'
|
|||||||
date_start: '2025-09-01'
|
date_start: '2025-09-01'
|
||||||
date_end: '2025-09-08'
|
date_end: '2025-09-08'
|
||||||
cover_image: ''
|
cover_image: ''
|
||||||
|
tagline: '600 km of Tuscan gravel — hill towns, aperitivi, and relentless climbing'
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ namespace Grav\Plugin;
|
|||||||
|
|
||||||
use Grav\Common\Cache;
|
use Grav\Common\Cache;
|
||||||
use Grav\Common\Data\ValidationException;
|
use Grav\Common\Data\ValidationException;
|
||||||
|
use Grav\Common\Page\Interfaces\PageInterface;
|
||||||
use Grav\Common\Plugin;
|
use Grav\Common\Plugin;
|
||||||
use RocketTheme\Toolbox\Event\Event;
|
use RocketTheme\Toolbox\Event\Event;
|
||||||
|
|
||||||
@@ -43,6 +44,16 @@ class CacheOnSavePlugin extends Plugin
|
|||||||
// (priority 0) has created the page and copied the uploaded files —
|
// (priority 0) has created the page and copied the uploaded files —
|
||||||
// we reorder those files, then clear the page-tree cache.
|
// we reorder those files, then clear the page-tree cache.
|
||||||
'onFormProcessed' => ['onFormProcessed', -100],
|
'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));
|
$form->setData('parent', $this->resolveDailiesParent($activeTrip));
|
||||||
|
$this->sanitizeCoordinates($form);
|
||||||
|
|
||||||
// One shared /post form drives both create and edit (KTD1). add-page-by-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
|
// reads overwrite_mode from the /post page header's pageconfig (not form
|
||||||
@@ -149,6 +161,79 @@ class CacheOnSavePlugin extends Plugin
|
|||||||
return '/' . $trip . '/dailies';
|
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
|
* The photo order the user arranged in the form, sent explicitly by
|
||||||
* post-form.js as a JSON array of filenames in the dedicated
|
* 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
@@ -890,10 +890,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
@@ -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
@@ -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;
|
||||||
|
}
|
||||||
@@ -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';
|
||||||
@@ -519,3 +519,110 @@
|
|||||||
/* 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 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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -10,8 +10,16 @@
|
|||||||
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';
|
||||||
|
// 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 './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() {
|
||||||
@@ -473,6 +481,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;
|
||||||
@@ -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('Couldn’t 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) ─────────────
|
/* ── 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.
|
||||||
@@ -596,6 +896,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();
|
||||||
@@ -1190,6 +1497,11 @@ function initEditMode() {
|
|||||||
editSetContent(d.content);
|
editSetContent(d.content);
|
||||||
editSetText('lat', h.lat);
|
editSetText('lat', h.lat);
|
||||||
editSetText('lng', h.lng);
|
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_city', h.location_city);
|
||||||
editSetText('location_country', h.location_country);
|
editSetText('location_country', h.location_country);
|
||||||
editSetText('weather_desc', h.weather_desc);
|
editSetText('weather_desc', h.weather_desc);
|
||||||
@@ -1238,6 +1550,7 @@ function boot() {
|
|||||||
initPhotoConversion();
|
initPhotoConversion();
|
||||||
initDisclosure();
|
initDisclosure();
|
||||||
initGeo();
|
initGeo();
|
||||||
|
initLocationDetails();
|
||||||
initValidation();
|
initValidation();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"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": {
|
"dependencies": {
|
||||||
"@fontsource-variable/dm-sans": "latest",
|
"@fontsource-variable/dm-sans": "latest",
|
||||||
|
|||||||
Reference in New Issue
Block a user