Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0ebb770bc7 | ||
|
|
dd19995973 | ||
|
|
8a5cc5246c | ||
|
|
7903432634 | ||
|
|
285ae37209 | ||
|
|
47ec72fa1d | ||
|
|
2b91aa30c3 | ||
|
|
e0e68a9ac3 | ||
|
|
e873a9cb23 | ||
|
|
a5993b2091 | ||
|
|
13c76b29a8 | ||
|
|
797b472a60 | ||
|
|
52e9fbadf2 | ||
|
|
a00690fbde |
@@ -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'
|
||||
---
|
||||
|
||||
@@ -22,8 +22,8 @@ photos:
|
||||
path: user/pages/01.trips/denmark-2026/01.dailies/2026-07-18-09-08-the-dunes-of-jerup/IMG_2178.jpg
|
||||
title: 'The dunes of Jerup'
|
||||
date: '2026-07-18 09:08'
|
||||
lat: 57.54421
|
||||
lng: 10.43837
|
||||
lat: 57.54421
|
||||
lng: 10.43837
|
||||
location_city: Jerup
|
||||
location_country: Denmark
|
||||
weather_desc: Cloudy
|
||||
|
||||
|
After Width: | Height: | Size: 3.3 MiB |
|
After Width: | Height: | Size: 6.5 MiB |
|
After Width: | Height: | Size: 2.2 MiB |
|
After Width: | Height: | Size: 1.0 MiB |
|
After Width: | Height: | Size: 2.5 MiB |
|
After Width: | Height: | Size: 1.8 MiB |
|
After Width: | Height: | Size: 3.4 MiB |
|
After Width: | Height: | Size: 6.5 MiB |
|
After Width: | Height: | Size: 4.7 MiB |
@@ -0,0 +1,49 @@
|
||||
---
|
||||
force_connect: false
|
||||
published: true
|
||||
featured: true
|
||||
title: 'Vestkustryten - a reflection'
|
||||
date: '29-07-2026 19:51'
|
||||
end_date: '29-07-2026 19:51'
|
||||
transport_mode: bicycle
|
||||
hero_image: promenade-and-bike.jpg
|
||||
hero_alt: 'Hidden sign of Vestkustryten 1 in the dune grass'
|
||||
---
|
||||
|
||||
A week and a half after my last day in the saddle, I'm ready to face my own commitment: one journal entry per day, one story per trip. Instead of a highlight, though, a reflection feels more fitting. Why? Because this trip was more than a holiday. It was a confirmation that I actually enjoy cycling — and the sum of every trip that came before it, including the ones I didn't always enjoy.
|
||||
|
||||
[chapter-break image="gravel-road-through-dunes-bw.jpg" title="Rookie mistakes" number="I" alt="" /]
|
||||
|
||||
[scrolly-section image="bike-on-the-beach.jpg" alt=""]
|
||||
|
||||
Berlin–Prague: too challenging for a rookie. Berlin–Copenhagen: an accident brought trouble. A roundtrip in Tuscany: nine days of hills that nearly killed me. Vestkystruten 2026: a grand success, built entirely on those past failures and everything they taught me.
|
||||
|
||||
---
|
||||
|
||||
When you fall off your bike, you get up and ride again. And believe me — I know what I'm talking about when it comes to falling ;)
|
||||
|
||||
[/scrolly-section]
|
||||
|
||||
[chapter-break image="route-coast-straight.jpg" title="Riding Free" number="3" alt="" /]
|
||||
|
||||
My goal was to cycle free: no strict daily plan, just listening to my body and my surroundings. What I learned is that cycling is fun the moment you allow yourself to enjoy it — to take the break you need, eat the snack your body's asking for, and let the path be the point instead of the destination. Once you spend several days travelling under your own power, you realise it isn't like a flight or a train. Those get you to somewhere. The bike is a companion that carries you along the way to whatever it is you're looking for.
|
||||
|
||||
[chapter-break image="blue-clouds-on-beach.jpg" title="Everything You Miss" number="2" alt="" /]
|
||||
|
||||
[scrolly-section image="beautiful-dune-grass.jpg" alt=""]
|
||||
|
||||
I should have stopped more often in the cute little towns. I should have paused longer to soak up the nature. I could have spent more time swimming in the sea. These are all thoughts that surfaced while riding, or afterwards, reflecting at the end of a day in the saddle.
|
||||
|
||||
---
|
||||
|
||||
All valid — and all something to make peace with. You'll always miss a lovely little spot or the next cool thing. But everything you miss because you were somewhere else is also space you opened up for a different experience, another adventure.
|
||||
|
||||
[/scrolly-section]
|
||||
|
||||
[scrolly-section image="crazy-eyes.jpg" alt="Crazy eyes"]
|
||||
|
||||
About 650 kilometers in 8 days, and I look back with pride — not at the distance, but at how far I've come since those earlier trips, all of it adding up to an amazing holiday in Denmark. That's it. Denmark is awesome, my bike is awesome, and I can't wait for wherever the next one takes me.
|
||||
|
||||
[/scrolly-section]
|
||||
|
||||
[snap-gallery images="sea-grass-sign-route-1.jpg" captions="Vestkystruten - check" /]
|
||||
@@ -3,9 +3,9 @@ title: 'Vestkustryten - Denmark 2026'
|
||||
template: trip
|
||||
date: '2026-07-10'
|
||||
date_start: '2026-07-10'
|
||||
date_end: '2026-07-20'
|
||||
date_end: '2026-07-18'
|
||||
cover_image: bikepacking_denmark.jpeg
|
||||
tagline: "560 km up Denmark's wild North Sea coast to where two seas meet"
|
||||
tagline: "650 km up Denmark's wild North Sea coast to where two seas meet"
|
||||
published: true
|
||||
autoconnect: intelligent_gpx
|
||||
---
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
@@ -260,14 +261,25 @@
|
||||
connect = true; /* no GPX present → connect all */
|
||||
} else {
|
||||
var prev = entries[i - 1];
|
||||
var covered = false;
|
||||
// Each endpoint just needs to be near SOME GPX file, not necessarily
|
||||
// the same one — a leg can legitimately span two consecutive files
|
||||
// (e.g. an unblogged intermediate stop splits one route in two).
|
||||
// Requiring one shared file used to draw a straight connector straight
|
||||
// over an already-GPX-covered path, since this trip's routes are one
|
||||
// contiguous chain recorded a day per file (…-to-X.gpx, X-to-….gpx).
|
||||
//
|
||||
// The trade: two endpoints each near a DIFFERENT track now read as
|
||||
// covered even when there is a real gap between them (a flight or a
|
||||
// train transfer), so no connector is drawn across it. That case is
|
||||
// not detectable from proximity alone — mark the entry after the gap
|
||||
// `force_connect: true` and the branch above handles it.
|
||||
var prevNear = false, currNear = false;
|
||||
for (var f = 0; f < trackpointsPerFile.length; f++) {
|
||||
if (isNearTrack(parseFloat(prev.lat), parseFloat(prev.lng), trackpointsPerFile[f], 10) &&
|
||||
isNearTrack(parseFloat(e.lat), parseFloat(e.lng), trackpointsPerFile[f], 10)) {
|
||||
covered = true; break;
|
||||
if (!prevNear && isNearTrack(parseFloat(prev.lat), parseFloat(prev.lng), trackpointsPerFile[f], 10)) prevNear = true;
|
||||
if (!currNear && isNearTrack(parseFloat(e.lat), parseFloat(e.lng), trackpointsPerFile[f], 10)) currNear = true;
|
||||
if (prevNear && currNear) break;
|
||||
}
|
||||
}
|
||||
connect = !covered;
|
||||
connect = !(prevNear && currNear);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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. */
|
||||
.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);
|
||||
}
|
||||
|
||||
@@ -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('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) ─────────────
|
||||
* 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,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",
|
||||
|
||||