Compare commits

..
17 Commits
Author SHA1 Message Date
m038andClaude Opus 5 1d4bf4e929 Fix route name spelling: Vestkustryten -> Vestkystruten
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 23:18:16 +02:00
m038andClaude Opus 5 1b9e51baf7 Turn off travelling so home shows the between-trips state everywhere
travelling was only set to false in user/env/localhost/config/site.yaml,
which Grav reads only when the request hostname is literally "localhost",
and which is git-ignored. So the home page still rendered Denmark 2026 as
an active trip on every other hostname, and the setting could never reach
production. Set it in the tracked config instead.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 23:12:53 +02:00
m038andClaude Opus 5 e81c73a9ba Fix story scrolly text rendering; story/trip blueprint media upload
Scrolly-section text never rendered: the inline Scrollama block ran before
the main.js bundle published window.scrollama, so it always hit its guard
and the panel text stayed display:none, leaving a blank column beside the
pinned image. Defer to DOMContentLoaded, and build the step panels before
the scrollama check so a missing bundle costs the animation, not the text.

Also fixes an invalid calc(-(...)) that dropped the mobile scrolly offset,
and drops the duplicate inline back-to-top in favour of main.js's version
(which pushes a history entry).

Blueprint changes carried over from an earlier session: story.yaml and
trip.yaml now extend the default page form so both can upload media.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 23:12:45 +02:00
m038andClaude Opus 5 0ebb770bc7 Add Vestkustryten reflection story; correct Denmark 2026 trip facts
New story for the Denmark 2026 trip with 9 photos, plus trip.md updates:
route length 560 → 650 km and end date 2026-07-20 → 2026-07-18.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 23:07:03 +02:00
m038andClaude Opus 5 dd19995973 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>
2026-07-24 23:48:00 +02:00
m038andClaude Opus 5 8a5cc5246c Merge branch 'main' into feat/post-location-override
Picks up the two fixes committed via the main checkout's submodule git dir:
the leg-connection map fix and the U+200E coordinate strip. The outer repo is
one directory (~/Projects is a symlink to ~/Nextcloud/Projects), but a
worktree gets its own submodule git dir — .git/worktrees/<name>/modules/user
vs .git/modules/user — so those commits were not reachable here until a local
fetch. No push was needed, so git-sync did not deploy.

maplibre-utils.js auto-merged, keeping main's prevNear leg logic alongside
this branch's MAP_STYLE extraction. js/map.js conflicted as a generated
bundle and was resolved by rebuilding, not hand-merging.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-24 23:44:37 +02:00
m038andClaude Opus 5 7903432634 Merge branch 'main' into feat/post-location-override
Brings the branch up to user/main, which was ahead on both content and
theme fixes. Notably e17a5dc ("block submit on unfinished photo uploads;
un-squeeze EXIF portraits in lightbox") — the branch point predated it, and
its absence is why UG1, UG2 and LD1 failed here: the upload gate and the
oriented-derivative slide dims those specs assert simply were not on this
branch. Also picks up denmark-2026 `published: true`, so the local testing
flip is no longer needed.

js/src/post-form.js auto-merged cleanly, keeping both main's upload gate and
this branch's location-override panel + lazy maplibre CSS link. The only
conflict was js/post/post-form.js, a generated bundle — resolved by
rebuilding via make build-assets rather than hand-merging minified output.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-24 23:36:41 +02:00
m038andClaude Opus 5 285ae37209 fix(content): strip U+200E from the Jerup entry's coordinates
lat/lng carried a leading LEFT-TO-RIGHT MARK (e2 80 8e), pasted in from a
system that decorates coordinates for bidi text. It renders invisibly, so
the values looked correct while parseFloat saw a non-numeric prefix.

This is the exact input cleanCoordinate() now rejects server-side; the
scan for further occurrences across pages/ comes back empty, so this was
the only affected entry.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-24 23:27:09 +02:00
m038andClaude Opus 5 47ec72fa1d fix(map): connect legs whose endpoints sit on different GPX files
intelligent_gpx mode required BOTH endpoints of a leg to be near the SAME
GPX file before treating it as covered. This trip's routes are one
contiguous chain recorded a day per file (…-to-X.gpx, X-to-….gpx), so any
leg spanning a file boundary — an unblogged intermediate stop — read as
uncovered and got a straight connector drawn over an already-GPX-covered
path.

Each endpoint now only has to be near some file. The trade is that a real
gap (flight, train transfer) whose endpoints each sit on their own track
also reads as covered and gets no connector; that case is not detectable
from proximity alone, and `force_connect: true` on the entry after the gap
already handles it.

js/map.js is the rebuilt bundle (make build-assets), not a hand-edit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-24 23:27:01 +02:00
m038 2b91aa30c3 fix(demo): stop demo-load reverting the real italy-2025 trip
docs/demo/trips/italy-2025/ shares its slug with a real trip (deliberately —
the fixture supplies that trip's GPX and dailies), so demo-load copies this
trip.md straight over the live page. The fixture was a snapshot from before the
trip gained a tagline, so every `make test` silently deleted that tagline from
real content, showing up as an unexplained dirty submodule afterwards.

Re-sync it byte-identically to the live page. The note about keeping colliding
fixtures in sync goes in the Makefile recipe, not here — anything added to this
file lands in real content on the next demo-load.
2026-07-24 22:33:27 +02:00
m038 e0e68a9ac3 perf(post): lazy-link maplibre's stylesheet instead of bundling it
The engine was already a lazy import(), but its CSS was a static one — the
usual workaround for esbuild never emitting a <link> for a code-split chunk's
stylesheet (R10). That folded the whole vendor sheet into post-form.css, so
every /post load paid for a panel most submits never open, and ~78% of those
bytes were rules for controls this map never creates (popups, geolocate, zoom,
compass, fullscreen, terrain, scale).

Build the vendor sheet as its own css-compiled/maplibre-gl.css and have
location-map.js <link> it at panel-open, concurrently with the engine's
import(). Keeping the file intact rather than hand-picking the ~16 selectors
in use means a maplibre upgrade can't silently un-style the map.

  post-form.css  92,244 → 26,784 raw   (14,528 → 5,631 gzip)

The href resolves from import.meta.url, so it is correct under any Grav base
path without threading a URL through the template — the panel is built entirely
in JS, so there is no element to hang a data-attr on. A failed stylesheet load
resolves rather than rejects: the map degrades to unstyled, never to absent.

The existing lazy-load guard spec already matched every maplibre-gl URL, so it
covers the stylesheet too; its message now says so, and a new test asserts the
other half — that opening the panel does link and apply the sheet.
2026-07-24 22:33:19 +02:00
m038andClaude Opus 5 e873a9cb23 fix(review): land cleanCoordinate server-side guard; surface geocode failures
Two code-review follow-ups.

cleanCoordinate() — the server-side coordinate sanitizer the design doc and
plan both describe as already shipped — had never actually been committed; it
existed only as uncommitted work in another checkout, so this branch had no
server-side validation of lat/lng at all (the blueprint fields are plain
`type: text` with no `validate:` key). Landing it here makes the spec's stated
safety net real. Also corrected its onAdminSave comment, which justified that
hook by saying the public form's lat/lng inputs are CSS-hidden and GPS-filled
— true before this feature, inverted by it. Both hooks are needed: this branch
makes /post the primary hand-entry path, not Admin2.

The geocode lookup swallowed every failure and reset the button, leaving the
DOM byte-identical to the pre-click state — a traveller on flaky mobile data
could not distinguish a failed lookup from a broken button. It now shows a
distinct hint, and checks r.ok first so a 4xx/5xx body no longer parses as
"no results" and tells the traveller their city does not exist. R8's actual
guarantee (fields untouched on failure) is preserved.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-24 21:48:20 +02:00
m038andClaude Opus 5 a5993b2091 fix(review): strict coordinate parse, close the gate bypass, drop the stale pin
Code-review fixes to the location-override panel.

parseFloat is a prefix parser, so '48abc', '48,85' (comma-decimal paste) and
'35.0116S' (hemisphere suffix, silently flipped to the wrong side of the
equator) all passed the isFinite + range check, cleared the mismatch flag and
were POSTed verbatim — the same silent-corruption class this feature exists to
end. Replaced with a whole-value decimal check, and the valid branch now
normalises to the 6dp the GPS handler and onDragEnd already write, so the pin
and the submitted value cannot disagree.

The submit gate keyed on the .location-field--mismatch class, which was only
ever set by syncFields() on blur / debounced input / toggle-open-success / GPS
— never at init. Three paths therefore reached the server unflagged: a draft
restored by initDraft() (which runs before initLocationDetails() and assigns
.value directly, firing no events), an edit-mode async prefill, and an open
panel whose maplibre chunk failed to load. syncFields() now runs once at the
end of initLocationDetails(), in the toggle handler's catch arm, and after the
edit prefill writes lat/lng.

Blanking both fields left the marker behind, presenting a stale coordinate as
if it were still the entry's — added clearPin() to the map handle and call it.
Removed hasPin(), which had no caller. Corrected two comments that named the
wrong stylesheet and claimed .field-invalid reuse the code does not do.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-24 21:34:17 +02:00
m038andClaude Sonnet 5 13c76b29a8 fix(post): close location-map race, block submit on bad coords
Code review (4 independent reviewers) converged on the same bug: the
maplibre-gl singleton cached its handle only after import() resolved,
so a fast close/reopen of the "More location details" panel could
race two Map instances onto one container. Cache the in-flight promise
synchronously instead, and propagate/handle import rejection so a
failed map load surfaces a hint instead of hanging silently.

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

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

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

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-24 20:03:01 +02:00
m038 797b472a60 feat(post): add "More location details" panel — search, map, sync
Closed-by-default disclosure below City/Country holding a city/country
geocode lookup (Open-Meteo, city-only query with client-side country
ranking), a single-marker draggable MapLibre preview (lazy-loaded via
location-map.js), and the relocated lat/lng fields. GPS button, search
pick, pin drag, and typed values all stay in sync via syncPinFromFields(),
with a visual mismatch flag for unparseable typed input.

Built entirely in JS (mirrors the existing 'More options' disclosure
pattern) so post-form.html.twig needs no template change.
2026-07-24 19:38:24 +02:00
m038 52e9fbadf2 refactor(map): extract MAP_STYLE into a shared map-style.js module
Single source of truth for the MapLibre style URL, shared between
maplibre-utils.js (multi-marker/GPX maps) and the new location-map.js
preview module — no behavior change.
2026-07-24 19:38:10 +02:00
m038 a00690fbde feat(post): unhide lat/lng fields, style the location-details panel
Removes the CSS rule hiding data[lat]/data[lng] and adds styling for the
new 'More location details' disclosure, search results list, map preview
container, and mismatch-flag state.
2026-07-24 19:38:05 +02:00
30 changed files with 1469 additions and 136 deletions
+1 -1
View File
@@ -6,5 +6,5 @@ metadata:
description: 'Into the East — travel journal' description: 'Into the East — travel journal'
description: 'A travel blog by Mischa' description: 'A travel blog by Mischa'
active_trip: /trips/denmark-2026 active_trip: /trips/denmark-2026
travelling: true travelling: false
owner_username: mischa owner_username: mischa
+1
View File
@@ -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'
--- ---
@@ -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 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' title: 'The dunes of Jerup'
date: '2026-07-18 09:08' date: '2026-07-18 09:08'
lat: 57.54421 lat: 57.54421
lng: 10.43837 lng: 10.43837
location_city: Jerup location_city: Jerup
location_country: Denmark location_country: Denmark
weather_desc: Cloudy weather_desc: Cloudy
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 MiB

@@ -0,0 +1,49 @@
---
force_connect: false
published: true
featured: true
title: 'Vestkystruten - 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=""]
BerlinPrague: too challenging for a rookie. BerlinCopenhagen: 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 -3
View File
@@ -1,11 +1,11 @@
--- ---
title: 'Vestkustryten - Denmark 2026' title: 'Vestkystruten - Denmark 2026'
template: trip template: trip
date: '2026-07-10' date: '2026-07-10'
date_start: '2026-07-10' date_start: '2026-07-10'
date_end: '2026-07-20' date_end: '2026-07-18'
cover_image: bikepacking_denmark.jpeg 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 published: true
autoconnect: intelligent_gpx autoconnect: intelligent_gpx
--- ---
+85
View File
@@ -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
+64 -12
View File
@@ -1,5 +1,12 @@
title: 'Story' title: 'Story'
# Inherits Grav's default page form — this is what supplies the page-media
# uploader, plus the standard Options and Advanced tabs. Without it the form is
# standalone and has no way to upload images (see entry.yaml, same pattern).
'@extends':
type: default
context: blueprints://pages
form: form:
fields: fields:
tabs: tabs:
@@ -8,6 +15,7 @@ form:
fields: fields:
content: content:
ordering@: 0
type: tab type: tab
title: Content title: Content
fields: fields:
@@ -34,7 +42,7 @@ form:
type: text type: text
label: Hero Image label: Hero Image
placeholder: 'hero.jpg' placeholder: 'hero.jpg'
help: 'Filename of the hero image (upload via Media tab)' help: 'Filename of an image uploaded in the Images field below'
header.hero_alt: header.hero_alt:
type: text type: text
@@ -44,10 +52,53 @@ form:
content: content:
type: markdown type: markdown
label: Content label: Content
help: 'Prose renders as a normal reading column. For layout blocks (scrolly sections, galleries, pull quotes) use the shortcodes listed on the Blocks tab.'
validate: validate:
required: true required: true
header.media_order:
type: pagemedia
label: Images
help: 'Upload every image the story needs here first — shortcodes refer to them by bare filename (photo-1.jpg), never by path.'
blocks:
ordering@: 1
type: tab
title: Blocks
fields:
blocks_intro:
type: spacer
text: "<p>Paste these into the <strong>Content</strong> field. Image parameters take a <strong>bare filename</strong> of an image uploaded in the <strong>Images</strong> field on the Content tab. The hero image is not a block \u2014 it has its own field on the Content tab.</p>"
blocks_scrolly:
type: spacer
text: "<p style=\"margin:1.4em 0 0.4em\"><strong>Scrolly section \u2014 text panels scroll over a pinned image</strong></p><pre style=\"white-space:pre-wrap;word-break:break-word;padding:0.6em 0.8em;border-radius:6px;background:rgba(127,127,127,0.12)\">[scrolly-section image=\"hero.jpg\" alt=\"What the image shows\" caption=\"Optional caption\"]\nFirst panel.\n\n---\n\nSecond panel. A --- starts a new panel.\n[/scrolly-section]</pre>"
blocks_pullquote:
type: spacer
text: "<p style=\"margin:1.4em 0 0.4em\"><strong>Pull quote \u2014 large extracted quote, image optional</strong></p><pre style=\"white-space:pre-wrap;word-break:break-word;padding:0.6em 0.8em;border-radius:6px;background:rgba(127,127,127,0.12)\">[pull-quote image=\"photo-1.jpg\" alt=\"What the image shows\"]\nThe quote itself. Markdown works in here.\n[/pull-quote]</pre><p>Drop <em>image</em> and <em>alt</em> for the plain no-image variant.</p>"
blocks_chapter:
type: spacer
text: "<p style=\"margin:1.4em 0 0.4em\"><strong>Chapter break \u2014 section transition over a background image</strong></p><pre style=\"white-space:pre-wrap;word-break:break-word;padding:0.6em 0.8em;border-radius:6px;background:rgba(127,127,127,0.12)\">[chapter-break image=\"photo-1.jpg\" title=\"After Dark\" number=\"II\" alt=\"What the image shows\" /]</pre><p><em>number</em> is optional.</p>"
blocks_gallery:
type: spacer
text: "<p style=\"margin:1.4em 0 0.4em\"><strong>Snap gallery \u2014 swipeable carousel</strong></p><pre style=\"white-space:pre-wrap;word-break:break-word;padding:0.6em 0.8em;border-radius:6px;background:rgba(127,127,127,0.12)\">[snap-gallery images=\"hero.jpg,photo-1.jpg\" captions=\"First caption,Second caption\" alts=\"First alt,Second alt\" /]</pre><p><strong>No commas inside captions.</strong> The three lists are split on commas and zipped by position, so one comma in a caption shifts every caption after it. Use em dashes or semicolons.</p>"
blocks_fullbleed:
type: spacer
text: "<p style=\"margin:1.4em 0 0.4em\"><strong>Full bleed \u2014 single edge-to-edge image as a visual pause</strong></p><pre style=\"white-space:pre-wrap;word-break:break-word;padding:0.6em 0.8em;border-radius:6px;background:rgba(127,127,127,0.12)\">[full-bleed image=\"photo-2.jpg\" alt=\"What the image shows\" caption=\"Optional\" credit=\"Optional\" /]</pre>"
blocks_imagecaption:
type: spacer
text: "<p style=\"margin:1.4em 0 0.4em\"><strong>Image with caption \u2014 photo at a chosen width</strong></p><pre style=\"white-space:pre-wrap;word-break:break-word;padding:0.6em 0.8em;border-radius:6px;background:rgba(127,127,127,0.12)\">[image-caption image=\"photo-2.jpg\" alt=\"What the image shows\" caption=\"Optional\" credit=\"Optional\" width=\"column\" /]</pre><p><em>width</em>: column (default), full, or bleed.</p>"
blocks_gotchas:
type: spacer
text: "<p style=\"margin:1.4em 0 0.4em\"><strong>Two things that bite</strong></p><p>Self-closing tags need the space before <code>/]</code>. And a mistyped shortcode fails silently \u2014 it renders as literal text or vanishes, with no warning. Preview the page after adding each block.</p>"
location: location:
ordering@: 2
type: tab type: tab
title: Location title: Location
fields: fields:
@@ -97,21 +148,13 @@ form:
validate: validate:
type: bool type: bool
# Published now comes from the inherited Options tab — defining it here
# too would render two toggles bound to the same header key.
publishing: publishing:
ordering@: 3
type: tab type: tab
title: Publishing title: Publishing
fields: fields:
header.published:
type: toggle
label: Published
highlight: 1
default: 1
options:
1: 'Yes'
0: 'No'
validate:
type: bool
header.featured: header.featured:
type: toggle type: toggle
label: Featured highlight label: Featured highlight
@@ -123,3 +166,12 @@ form:
0: 'No' 0: 'No'
validate: validate:
type: bool type: bool
# Drop the inherited Date field — Story defines its own required
# "Start Date" on the Content tab, and both bind to header.date.
options:
fields:
publishing:
fields:
header.date:
unset@: true
+20 -14
View File
@@ -1,5 +1,13 @@
title: 'Trip' title: 'Trip'
# Inherits Grav's default page form — this is what supplies the page-media
# uploader (used for cover images and GPX files), plus the standard Options and
# Advanced tabs. Without it the form is standalone and can only *select*
# existing media, never upload any.
'@extends':
type: default
context: blueprints://pages
form: form:
fields: fields:
tabs: tabs:
@@ -8,6 +16,7 @@ form:
fields: fields:
content: content:
ordering@: 0
type: tab type: tab
title: Content title: Content
fields: fields:
@@ -21,7 +30,15 @@ form:
type: markdown type: markdown
label: Description label: Description
# Inherited from the default blueprint; relabelled because this is
# also where GPX files and cover images get uploaded.
header.media_order:
type: pagemedia
label: Images and GPX files
help: 'Upload cover images and .gpx route files here. GPX tracks are auto-detected on the map — no manual linking. The Cover Image field below picks from what is uploaded here.'
trip: trip:
ordering@: 1
type: tab type: tab
title: Trip title: Trip
fields: fields:
@@ -78,17 +95,6 @@ form:
'manual': 'Manual — force connect only' 'manual': 'Manual — force connect only'
'intelligent_gpx': 'Intelligent GPX — suppress where route is covered' 'intelligent_gpx': 'Intelligent GPX — suppress where route is covered'
publishing: # No Publishing tab — it held only the Published toggle, which now comes
type: tab # from the inherited Options tab. Defining it here too would render two
title: Publishing # toggles bound to the same header key.
fields:
header.published:
type: toggle
label: Published
highlight: 1
default: 1
options:
1: 'Yes'
0: 'No'
validate:
type: bool
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1 -5
View File
@@ -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 {
@@ -2090,7 +2086,7 @@ body::after {
.scrolly-step:last-child { padding-bottom: 50vh; } .scrolly-step:last-child { padding-bottom: 50vh; }
@media (max-width: 768px), (pointer: coarse) { @media (max-width: 768px), (pointer: coarse) {
.scrolly { display: block; } .scrolly { display: block; }
.scrolly__steps { margin-top: calc(-(100vh - var(--site-header-height))); position: relative; z-index: 1; } .scrolly__steps { margin-top: calc(-1 * (100vh - var(--site-header-height))); position: relative; z-index: 1; }
.scrolly-step { min-height: 80vh; padding: var(--space-8) var(--space-6); align-items: center; justify-content: center; } .scrolly-step { min-height: 80vh; padding: var(--space-8) var(--space-6); align-items: center; justify-content: center; }
.scrolly-step:last-child { padding-bottom: 50vh; } .scrolly-step:last-child { padding-bottom: 50vh; }
} }
File diff suppressed because one or more lines are too long
+19 -7
View File
@@ -1,8 +1,9 @@
/* Shared MapLibre GL utilities — loaded by map.html.twig, dailies.html.twig, home.html.twig */ /* Shared MapLibre GL utilities — loaded by map.html.twig, dailies.html.twig, home.html.twig */
import { MAP_STYLE } from './src/map-style.js';
(function (global) { (function (global) {
var ACCENT = '#2A8C73'; var ACCENT = '#2A8C73';
var ACCENT_DIM = '#155244'; var ACCENT_DIM = '#155244';
var MAP_STYLE = 'https://basemaps.cartocdn.com/gl/dark-matter-gl-style/style.json';
/* Build a GeoJSON LineString feature */ /* Build a GeoJSON LineString feature */
function lineFeature(coords) { function lineFeature(coords) {
@@ -260,14 +261,25 @@
connect = true; /* no GPX present → connect all */ connect = true; /* no GPX present → connect all */
} else { } else {
var prev = entries[i - 1]; 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++) { for (var f = 0; f < trackpointsPerFile.length; f++) {
if (isNearTrack(parseFloat(prev.lat), parseFloat(prev.lng), trackpointsPerFile[f], 10) && if (!prevNear && isNearTrack(parseFloat(prev.lat), parseFloat(prev.lng), trackpointsPerFile[f], 10)) prevNear = true;
isNearTrack(parseFloat(e.lat), parseFloat(e.lng), trackpointsPerFile[f], 10)) { if (!currNear && isNearTrack(parseFloat(e.lat), parseFloat(e.lng), trackpointsPerFile[f], 10)) currNear = true;
covered = true; break; if (prevNear && currNear) break;
} }
} connect = !(prevNear && currNear);
connect = !covered;
} }
} }
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. */ /* 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);
}
+313
View File
@@ -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('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) ───────────── /* ── 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 -1
View File
@@ -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",
+19 -22
View File
@@ -132,23 +132,11 @@
update(); update();
})(); })();
/* ── Back to top button ─────────────────────────────────── */ /* ── Back to top button ───────────────────────────────────
(function () { Owned by initBackToTop('story-totop') in js/src/main.js, called from its
var btn = document.getElementById('story-totop'); DOMContentLoaded boot. An inline copy used to live here and double-bound
if (!btn) return; both the click and scroll handlers; it also lacked the history.pushState
var threshold = window.innerHeight * 0.8; that makes Back return to the reading position. Do not re-add it. */
var shown = false;
btn.addEventListener('click', function () {
window.scrollTo({ top: 0, behavior: 'smooth' });
});
window.addEventListener('scroll', function () {
var shouldShow = window.scrollY > threshold;
if (shouldShow !== shown) {
shown = shouldShow;
btn.classList.toggle('is-visible', shown);
}
}, { passive: true });
})();
/* ── ChapterBreak scroll-reveal ─────────────────────────── */ /* ── ChapterBreak scroll-reveal ─────────────────────────── */
(function () { (function () {
@@ -216,11 +204,17 @@
update(); update();
})(); })();
/* ── ScrollySection (Scrollama) ──────────────────────────── */ /* ── ScrollySection (Scrollama) ────────────────────────────
(function () { Deferred to DOMContentLoaded on purpose: `scrollama` is published by the
js/main.js bundle, which Grav emits via assets.js('bottom') *after* this
inline script (base.html.twig renders block content first). Running inline
would hit the scrollama guard every time, leaving .scrolly__steps-content
hidden and the text column blank. DOMContentLoaded fires only after all
classic scripts have executed, so scrollama is available by then. */
document.addEventListener('DOMContentLoaded', function () {
var reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches; var reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var sections = document.querySelectorAll('.scrolly'); var sections = document.querySelectorAll('.scrolly');
if (!sections.length || typeof scrollama === 'undefined') return; if (!sections.length) return;
var panOffsets = ['50% 40%', '50% 50%', '50% 60%', '50% 45%', '50% 55%']; var panOffsets = ['50% 40%', '50% 50%', '50% 60%', '50% 45%', '50% 55%'];
@@ -252,7 +246,10 @@
stepsWrap.appendChild(step); stepsWrap.appendChild(step);
}); });
if (reduced) { /* Step-building above must never be gated on scrollama: the slot content is
display:none in CSS, so bailing before this point loses the text entirely.
Degrade to plain visible panels instead — no pinning, but readable. */
if (reduced || typeof scrollama === 'undefined') {
section.querySelectorAll('.scrolly-step').forEach(function (s) { s.classList.add('is-active'); }); section.querySelectorAll('.scrolly-step').forEach(function (s) { s.classList.add('is-active'); });
return; return;
} }
@@ -281,6 +278,6 @@
if (d.direction === 'up') d.element.classList.remove('is-active'); if (d.direction === 'up') d.element.classList.remove('is-active');
}); });
}); });
})(); });
</script> </script>
{% endblock %} {% endblock %}