Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1d4bf4e929 | ||
|
|
1b9e51baf7 | ||
|
|
e81c73a9ba | ||
|
|
0ebb770bc7 | ||
|
|
dd19995973 | ||
|
|
8a5cc5246c | ||
|
|
7903432634 | ||
|
|
285ae37209 | ||
|
|
47ec72fa1d | ||
|
|
2b91aa30c3 | ||
|
|
e0e68a9ac3 | ||
|
|
e873a9cb23 | ||
|
|
a5993b2091 | ||
|
|
13c76b29a8 | ||
|
|
797b472a60 | ||
|
|
52e9fbadf2 | ||
|
|
a00690fbde | ||
|
|
4721af6452 | ||
|
|
1f1001df07 | ||
|
|
bf34d698aa | ||
|
|
3b89e207ef | ||
|
|
723b77ae0d | ||
|
|
c6402aa320 | ||
|
|
55bc890c32 | ||
|
|
1f638897e6 | ||
|
|
5727766ba8 | ||
|
|
b412a6e782 | ||
|
|
01487e3672 | ||
|
|
93f0a03b84 | ||
|
|
005dc4a157 | ||
|
|
c407ea010f | ||
|
|
894593d74f | ||
|
|
259130cd3c | ||
|
|
790c3deb26 | ||
|
|
36211a96ef | ||
|
|
e17a5dc972 | ||
|
|
f72ca2f024 | ||
|
|
2112df3b7f | ||
|
|
945732aade | ||
|
|
5ebf925ca7 | ||
|
|
a6749547bd | ||
|
|
35d82b0484 | ||
|
|
d4b5050616 | ||
|
|
e02c10e67a | ||
|
|
874645db32 | ||
|
|
02fa4e94a7 | ||
|
|
df0e00937e | ||
|
|
fae364585c | ||
|
|
3cb50a298a | ||
|
|
125f9e6131 | ||
|
|
4a40dc9c18 | ||
|
|
f4ab73070b |
@@ -5,10 +5,6 @@ author:
|
||||
metadata:
|
||||
description: 'Into the East — travel journal'
|
||||
description: 'A travel blog by Mischa'
|
||||
active_trip: /trips/us-canada-mex-2024
|
||||
active_trip: /trips/denmark-2026
|
||||
travelling: false
|
||||
# Single source of truth for the site owner's account username. Backs both the
|
||||
# front-end edit/delete UI gate and the server-side scope guards (KTD8): only
|
||||
# this user (not merely any authenticated/super-admin account) may edit, delete,
|
||||
# or see drafts on the active trip's feed.
|
||||
owner_username: mischa
|
||||
|
||||
@@ -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'
|
||||
---
|
||||
|
||||
@@ -4,13 +4,14 @@ date: '2023-09-19 06:04'
|
||||
template: entry
|
||||
transport_mode: car
|
||||
published: true
|
||||
hero_image: 'photo-1.jpg'
|
||||
hero_image: photo-1.jpg
|
||||
lat: '37.755579'
|
||||
lng: '73.271513'
|
||||
location_city: 'Alichur'
|
||||
location_country: 'Tajikistan'
|
||||
location_city: Alichur
|
||||
location_country: Tajikistan
|
||||
weather_temp_c: '15'
|
||||
weather_desc: 'windy'
|
||||
weather_desc: windy
|
||||
featured: true
|
||||
---
|
||||
|
||||
11-09 - In this village live just a few hundred people and there is a small military base. There is a school and a mosque, a very hard to find shop (we did not succeed) and some water wells. People drive old, rugged, Soviet UAZs, its the only affordable machine that can survive the rocky roads.
|
||||
|
||||
@@ -5,4 +5,5 @@ date: '2023-08-28'
|
||||
date_start: '2023-08-28'
|
||||
date_end: '2023-10-18'
|
||||
cover_image: ''
|
||||
tagline: 'Seven weeks overland across Central Asia — the Pamir Highway, plov, and endless steppe'
|
||||
---
|
||||
|
||||
|
After Width: | Height: | Size: 4.4 MiB |
|
After Width: | Height: | Size: 3.9 MiB |
@@ -0,0 +1,29 @@
|
||||
---
|
||||
photos:
|
||||
IMG_1959.jpg:
|
||||
name: IMG_1959.jpg
|
||||
type: image/jpeg
|
||||
size: 4104001
|
||||
path: user/pages/01.trips/denmark-2026/01.dailies/2026-07-10-08-20-the-start/IMG_1959.jpg
|
||||
IMG_1958.jpg:
|
||||
name: IMG_1958.jpg
|
||||
type: image/jpeg
|
||||
size: 4602898
|
||||
path: user/pages/01.trips/denmark-2026/01.dailies/2026-07-10-08-20-the-start/IMG_1958.jpg
|
||||
title: 'The start…. '
|
||||
date: '2026-07-10 08:20'
|
||||
lat: null
|
||||
lng: null
|
||||
location_city: Berlin
|
||||
location_country: Germany
|
||||
weather_desc: Cloudy
|
||||
weather_temp_c: 20
|
||||
transport_mode: train
|
||||
edit_path: null
|
||||
published: true
|
||||
force_connect: false
|
||||
featured: false
|
||||
---
|
||||
|
||||
The start was surprisingly smooth - the real wake-up was the crowd of odd characters hanging around S Gesundbrunnen at 6am. Deutsche Bahn did surprisingly well: just a 10-minute delay and a platform change.
|
||||
The adventure has started. Denmark, here I come!
|
||||
|
After Width: | Height: | Size: 4.2 MiB |
|
After Width: | Height: | Size: 2.5 MiB |
|
After Width: | Height: | Size: 3.0 MiB |
|
After Width: | Height: | Size: 4.5 MiB |
|
After Width: | Height: | Size: 4.7 MiB |
|
After Width: | Height: | Size: 3.9 MiB |
@@ -0,0 +1,49 @@
|
||||
---
|
||||
photos:
|
||||
IMG_1972.jpg:
|
||||
name: IMG_1972.jpg
|
||||
type: image/jpeg
|
||||
size: 2638016
|
||||
path: user/pages/01.trips/denmark-2026/01.dailies/2026-07-10-22-34-a-day-full-of-firsts/IMG_1972.jpg
|
||||
IMG_1969.jpg:
|
||||
name: IMG_1969.jpg
|
||||
type: image/jpeg
|
||||
size: 4379241
|
||||
path: user/pages/01.trips/denmark-2026/01.dailies/2026-07-10-22-34-a-day-full-of-firsts/IMG_1969.jpg
|
||||
IMG_1977.jpg:
|
||||
name: IMG_1977.jpg
|
||||
type: image/jpeg
|
||||
size: 3095975
|
||||
path: user/pages/01.trips/denmark-2026/01.dailies/2026-07-10-22-34-a-day-full-of-firsts/IMG_1977.jpg
|
||||
IMG_1983.jpg:
|
||||
name: IMG_1983.jpg
|
||||
type: image/jpeg
|
||||
size: 4693570
|
||||
path: user/pages/01.trips/denmark-2026/01.dailies/2026-07-10-22-34-a-day-full-of-firsts/IMG_1983.jpg
|
||||
IMG_1999.jpg:
|
||||
name: IMG_1999.jpg
|
||||
type: image/jpeg
|
||||
size: 4133238
|
||||
path: user/pages/01.trips/denmark-2026/01.dailies/2026-07-10-22-34-a-day-full-of-firsts/IMG_1999.jpg
|
||||
IMG_1994.jpg:
|
||||
name: IMG_1994.jpg
|
||||
type: image/jpeg
|
||||
size: 4979702
|
||||
path: user/pages/01.trips/denmark-2026/01.dailies/2026-07-10-22-34-a-day-full-of-firsts/IMG_1994.jpg
|
||||
title: 'A day full of firsts'
|
||||
date: '2026-07-10 22:34'
|
||||
lat: '55.394911'
|
||||
lng: '8.712669'
|
||||
location_city: Esbjerg
|
||||
location_country: Denmark
|
||||
weather_desc: Sunny
|
||||
weather_temp_c: 18
|
||||
transport_mode: bicycle
|
||||
edit_path: null
|
||||
published: true
|
||||
force_connect: false
|
||||
featured: false
|
||||
---
|
||||
|
||||
So what did I actually do? I visited the northernmost point of the German mainland, then cycled across a border on an international trip—last year I took the ferry, lame huh? I saw Ribe, the oldest town in Denmark, and tried the Danish shelter system for the first time. It’s awesome.
|
||||
But the most important part: no daily destination. Just a rough idea and a shelter spot to sleep in when I’m tired. No anxiety, no hotel needed—though probably no showers in the upcoming days either. :D
|
||||
|
After Width: | Height: | Size: 2.1 MiB |
|
After Width: | Height: | Size: 2.9 MiB |
|
After Width: | Height: | Size: 2.1 MiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 4.7 MiB |
@@ -0,0 +1,24 @@
|
||||
---
|
||||
photos: ''
|
||||
title: 'Internet too slow in Denmark…?'
|
||||
date: '2026-07-11 20:35'
|
||||
lat: '55.682921'
|
||||
lng: '8.183712'
|
||||
location_city: Varde
|
||||
location_country: Denmark
|
||||
weather_desc: Cloudy
|
||||
weather_temp_c: 19
|
||||
transport_mode: bicycle
|
||||
edit_path: /trips/denmark-2026/dailies/2026-07-11-20-35-internet-too-slow-in-denmark/entry.md
|
||||
published: true
|
||||
force_connect: false
|
||||
featured: false
|
||||
---
|
||||
|
||||
Sometimes yes, I’m writing this while lying in my tent: 98km done and I’m knackered. Uploading pictures keeps failing...
|
||||
|
||||
A quick coffee break at a hip specialty coffee place in Esbjerg made the morning complete and injected the fuel for a special detour today l. Namely, to reach a special geographical spot—the most western point of Denmark. My goal is to visit all the corners, and this trip should complete that.
|
||||
|
||||
I’d actually wanted to stop at 70km, since I’d already hit my goal for the day. But a quick food stop got me some fuel - a hot dog and a Starbucks triple espresso - and with a bit of wind at my back, voilà, here we are. Funny enough, some of the same people from last night’s shelter are here too.
|
||||
|
||||
Note to self: that starbucks triple espresso is great fuel, don’t be ashamed.
|
||||
|
After Width: | Height: | Size: 3.8 MiB |
|
After Width: | Height: | Size: 4.2 MiB |
|
After Width: | Height: | Size: 3.7 MiB |
|
After Width: | Height: | Size: 5.2 MiB |
@@ -0,0 +1,43 @@
|
||||
---
|
||||
photos:
|
||||
IMG_2035.jpg:
|
||||
name: IMG_2035.jpg
|
||||
type: image/jpeg
|
||||
size: 4025475
|
||||
path: user/pages/01.trips/denmark-2026/01.dailies/2026-07-12-20-28-fairytale-landscapes-and-the-financial-reality-check/IMG_2035.jpg
|
||||
IMG_2037.jpg:
|
||||
name: IMG_2037.jpg
|
||||
type: image/jpeg
|
||||
size: 4355388
|
||||
path: user/pages/01.trips/denmark-2026/01.dailies/2026-07-12-20-28-fairytale-landscapes-and-the-financial-reality-check/IMG_2037.jpg
|
||||
IMG_2041.jpg:
|
||||
name: IMG_2041.jpg
|
||||
type: image/jpeg
|
||||
size: 3848089
|
||||
path: user/pages/01.trips/denmark-2026/01.dailies/2026-07-12-20-28-fairytale-landscapes-and-the-financial-reality-check/IMG_2041.jpg
|
||||
IMG_2042.jpg:
|
||||
name: IMG_2042.jpg
|
||||
type: image/jpeg
|
||||
size: 5405714
|
||||
path: user/pages/01.trips/denmark-2026/01.dailies/2026-07-12-20-28-fairytale-landscapes-and-the-financial-reality-check/IMG_2042.jpg
|
||||
title: 'Fairytale landscapes and the financial reality check'
|
||||
date: '2026-07-12 20:28'
|
||||
lat: '56.143163'
|
||||
lng: '8.176442'
|
||||
location_city: 'Ringkobing-Skjern Kommune'
|
||||
location_country: Denmark
|
||||
weather_desc: Sunny
|
||||
weather_temp_c: 24
|
||||
transport_mode: bicycle
|
||||
edit_path: null
|
||||
published: true
|
||||
force_connect: false
|
||||
featured: false
|
||||
---
|
||||
|
||||
A day through the dunes: more offroad gravel than the gorgeous paved cycling paths Denmark is famous for - those are saved for Route 475. Route 1 is hardcore. Well, okay, it’s not all that crazy, but I was happy with my new 45mm tires. Smooth riding, and enough grip.
|
||||
|
||||
The route went through a kind of dunish desert, all grass and sand. Scattered all over were little houses - holiday homes for the Danes, I assume. Even though some of them
|
||||
looked legit.
|
||||
|
||||
The addiction to gentrified coffee was too great, so I stopped at a hip place in a little surf town. I’m glad my in-head currency conversion isn’t very fast: €13.60 for a coffee and a cake. Denmark makes you feel poor - which makes the tent feel quite fitting.
|
||||
@@ -0,0 +1,20 @@
|
||||
---
|
||||
photos: ''
|
||||
title: 'Cold Hawaii…'
|
||||
date: '2026-07-14 22:16'
|
||||
lat: '57.033053'
|
||||
lng: '8.479737'
|
||||
location_city: Klittmøller
|
||||
location_country: Denmark
|
||||
weather_desc: Sunny
|
||||
weather_temp_c: 22
|
||||
transport_mode: bicycle
|
||||
edit_path: /trips/denmark-2026/dailies/2026-07-14-22-16-cold-hawaii/entry.md
|
||||
published: true
|
||||
force_connect: false
|
||||
featured: false
|
||||
---
|
||||
|
||||
Today was a mandatory take-it-easy day. I’ve reached 350km of the 620km in four days — pretty powerful, maybe also too much. I’ve noticed that when you’re on the bike, you’re in bike mode: I take a picture here and there, but you can’t stop every 10km either. Too many breaks can be devastating.
|
||||
So I arrived in Klitmøller, aka Cold Hawaii, because — guess what — you can surf here. I assumed it would just be downwind stuff, but apparently there can be some decent waves. That’s the vibe of this town, same as Hvide Sande: laid back, calm, and lots of people with surfboard carriers on their bikes. Danes on bikes can be as impressive as the Dutch — we’ve found our counterpart!
|
||||
With just 38km today I planned a relaxing afternoon: laundry in a proper machine, a warm shower (first since I left home), and some bike maintenance and cleaning. But… my anti-mosquito spray had a surprise for me. All the shaking had worked the cap loose, and the DEET leaked slowly into a plastic bag — which it promptly dissolved, leaving gooey, sticky plastic all over my stuff. A challenge too great for a relaxing afternoon. I did minimal damage control, then had dinner and a beer with a view of the sea.
|
||||
|
After Width: | Height: | Size: 3.3 MiB |
|
After Width: | Height: | Size: 3.8 MiB |
|
After Width: | Height: | Size: 4.3 MiB |
|
After Width: | Height: | Size: 696 KiB |
|
After Width: | Height: | Size: 3.0 MiB |
|
After Width: | Height: | Size: 3.4 MiB |
|
After Width: | Height: | Size: 4.5 MiB |
|
After Width: | Height: | Size: 3.0 MiB |
|
After Width: | Height: | Size: 4.1 MiB |
|
After Width: | Height: | Size: 2.7 MiB |
@@ -0,0 +1,51 @@
|
||||
---
|
||||
photos:
|
||||
IMG_2123.jpg:
|
||||
name: IMG_2123.jpg
|
||||
type: image/jpeg
|
||||
size: 2867530
|
||||
path: user/pages/01.trips/denmark-2026/01.dailies/2026-07-15-20-35-a-fresh-feeling/IMG_2123.jpg
|
||||
IMG_2098.jpg:
|
||||
name: IMG_2098.jpg
|
||||
type: image/jpeg
|
||||
size: 3141614
|
||||
path: user/pages/01.trips/denmark-2026/01.dailies/2026-07-15-20-35-a-fresh-feeling/IMG_2098.jpg
|
||||
IMG_2099.jpg:
|
||||
name: IMG_2099.jpg
|
||||
type: image/jpeg
|
||||
size: 3609643
|
||||
path: user/pages/01.trips/denmark-2026/01.dailies/2026-07-15-20-35-a-fresh-feeling/IMG_2099.jpg
|
||||
IMG_2103.jpg:
|
||||
name: IMG_2103.jpg
|
||||
type: image/jpeg
|
||||
size: 4732533
|
||||
path: user/pages/01.trips/denmark-2026/01.dailies/2026-07-15-20-35-a-fresh-feeling/IMG_2103.jpg
|
||||
IMG_2107.jpg:
|
||||
name: IMG_2107.jpg
|
||||
type: image/jpeg
|
||||
size: 3166448
|
||||
path: user/pages/01.trips/denmark-2026/01.dailies/2026-07-15-20-35-a-fresh-feeling/IMG_2107.jpg
|
||||
IMG_2108.jpg:
|
||||
name: IMG_2108.jpg
|
||||
type: image/jpeg
|
||||
size: 4289987
|
||||
path: user/pages/01.trips/denmark-2026/01.dailies/2026-07-15-20-35-a-fresh-feeling/IMG_2108.jpg
|
||||
title: 'A fresh feeling'
|
||||
date: '2026-07-15 20:35'
|
||||
lat: '57.136715'
|
||||
lng: '9.327441'
|
||||
location_city: Abybro
|
||||
location_country: Denmark
|
||||
weather_desc: Sunny
|
||||
weather_temp_c: 22
|
||||
transport_mode: bicycle
|
||||
edit_path: null
|
||||
published: true
|
||||
force_connect: false
|
||||
featured: false
|
||||
---
|
||||
|
||||
Do you think showering twice in one day counts as pre-showering for the days ahead? I’ll let you know in a couple of days. But the morning started clean — freshness and mist in the air, and my body fully recovered after being pushed hard. Another 80km, which leaves about 140km to go.
|
||||
Goal change: finish the route early and carry on down the east coast. My train back leaves from Aalborg (not the far north anyway), and I don’t mind more of Denmark.
|
||||
The legs felt rested and just kept plowing through. I glanced at the navigation and 50km was already done — I’d even cycled past Bulbjerg. Too far to turn back, alas. I powered up with another triple-shot canned espresso (I feel genuinely bad for liking this stuff as a coffee aficionado, but it works), and before I knew it I was at my home for the night.
|
||||
Then: my first dinner ever cooked on my little camp stove. Tonight’s menu — first course, pan-fried tomatoes with herbes de Provence and smoky salt: honestly delish, well worth bringing back for a dinner party at home. Main was a tuna pasta with herbs and smoky salt — that one was fine. And for dessert, gas-grilled marshmallows: my poor, not-so-outdoorsy, lazy man’s version of a campfire roast. Three courses on one tiny burner. Now I’m satisfied and heading to bed to charge up for tomorrow.
|
||||
|
After Width: | Height: | Size: 2.6 MiB |
|
After Width: | Height: | Size: 3.0 MiB |
|
After Width: | Height: | Size: 5.6 MiB |
|
After Width: | Height: | Size: 2.8 MiB |
|
After Width: | Height: | Size: 2.9 MiB |
@@ -0,0 +1,46 @@
|
||||
---
|
||||
photos:
|
||||
IMG_2153.jpg:
|
||||
name: IMG_2153.jpg
|
||||
type: image/jpeg
|
||||
size: 2723140
|
||||
path: user/pages/01.trips/denmark-2026/01.dailies/2026-07-17-15-30-the-lies-the-north-and-the-reward/IMG_2153.jpg
|
||||
IMG_2155.jpg:
|
||||
name: IMG_2155.jpg
|
||||
type: image/jpeg
|
||||
size: 3159543
|
||||
path: user/pages/01.trips/denmark-2026/01.dailies/2026-07-17-15-30-the-lies-the-north-and-the-reward/IMG_2155.jpg
|
||||
IMG_2159.jpg:
|
||||
name: IMG_2159.jpg
|
||||
type: image/jpeg
|
||||
size: 3047815
|
||||
path: user/pages/01.trips/denmark-2026/01.dailies/2026-07-17-15-30-the-lies-the-north-and-the-reward/IMG_2159.jpg
|
||||
IMG_2157.jpg:
|
||||
name: IMG_2157.jpg
|
||||
type: image/jpeg
|
||||
size: 5889404
|
||||
path: user/pages/01.trips/denmark-2026/01.dailies/2026-07-17-15-30-the-lies-the-north-and-the-reward/IMG_2157.jpg
|
||||
IMG_2158.jpg:
|
||||
name: IMG_2158.jpg
|
||||
type: image/jpeg
|
||||
size: 2970861
|
||||
path: user/pages/01.trips/denmark-2026/01.dailies/2026-07-17-15-30-the-lies-the-north-and-the-reward/IMG_2158.jpg
|
||||
title: 'The lies, the north and the reward '
|
||||
date: '2026-07-17 15:30'
|
||||
lat: '57.725018'
|
||||
lng: '10.596427'
|
||||
location_city: Skagen
|
||||
location_country: Denmark
|
||||
weather_desc: 'Partly cloudy'
|
||||
weather_temp_c: 26
|
||||
transport_mode: bicycle
|
||||
edit_path: null
|
||||
published: true
|
||||
force_connect: false
|
||||
featured: false
|
||||
---
|
||||
|
||||
Time flies when you’ve got a routine and you’re just riding. Yesterday’s post inspiration got lost at the Stranden shelter — two minutes from the beach, zero connectivity. Today was all about the goal. Must… reach… the north….
|
||||
Yesterday I was pushing hard, telling myself each extra kilometer meant one less tomorrow. Then reality hit. My trusty Wahoo — which has navigated me without a single hiccup (knock on wood) — wasn’t showing the true countdown. Turns out I had an extra 11km to reach the top. Whatever that means, Mr Wahoo.
|
||||
Once I could smell success in the air, another painful decision: push my bike 1km through the sand, or leave it behind? I’ll say nothing and let the pictures speak for themselves.
|
||||
Now, a well-earned reward — an iskaffe and a lille citrontærte — and I start all over again? Not really. I’ll continue down Route 1, then branch off onto number 5, the east coast route. Two days left before I have to catch my train, and cycling is cheaper than hovering around a Danish town for fun. Onwards to Aalborg!
|
||||
|
After Width: | Height: | Size: 2.0 MiB |
|
After Width: | Height: | Size: 3.3 MiB |
|
After Width: | Height: | Size: 2.5 MiB |
|
After Width: | Height: | Size: 5.8 MiB |
@@ -0,0 +1,40 @@
|
||||
---
|
||||
photos:
|
||||
IMG_2168.jpg:
|
||||
name: IMG_2168.jpg
|
||||
type: image/jpeg
|
||||
size: 2125806
|
||||
path: user/pages/01.trips/denmark-2026/01.dailies/2026-07-18-09-08-the-dunes-of-jerup/IMG_2168.jpg
|
||||
IMG_2173.jpg:
|
||||
name: IMG_2173.jpg
|
||||
type: image/jpeg
|
||||
size: 3497959
|
||||
path: user/pages/01.trips/denmark-2026/01.dailies/2026-07-18-09-08-the-dunes-of-jerup/IMG_2173.jpg
|
||||
IMG_2175.jpg:
|
||||
name: IMG_2175.jpg
|
||||
type: image/jpeg
|
||||
size: 2672034
|
||||
path: user/pages/01.trips/denmark-2026/01.dailies/2026-07-18-09-08-the-dunes-of-jerup/IMG_2175.jpg
|
||||
IMG_2178.jpg:
|
||||
name: IMG_2178.jpg
|
||||
type: image/jpeg
|
||||
size: 6060397
|
||||
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
|
||||
location_city: Jerup
|
||||
location_country: Denmark
|
||||
weather_desc: Cloudy
|
||||
weather_temp_c: 18
|
||||
transport_mode: bicycle
|
||||
published: true
|
||||
force_connect: false
|
||||
featured: false
|
||||
---
|
||||
|
||||
After my reward I continued the route on the east coast, and while looking for a shelter I stumbled upon Jerup Shelterplads — the closest one to the beach I’d seen on the whole trip. The east coast probably isn’t as wild as the west. Hidden in the dunes, I saw the wooden structures pop up, the outlines of half a dozen camper vans, and the lively sound of people chatting and children playing. No silent retreat in nature this night, and I was happy about it. The weather looked slightly dramatic — rain and lots of lightning — though fortunately, as is common in Denmark, it settled into light showers and some thunder around 02:00.
|
||||
My neighbours were a jolly bunch of Danish-Indonesians, delighted to learn more about Indonesian people living in the Netherlands. They were from Aceh, practically the opposite end of the country from where most Indonesian-Dutch families come from: the Maluku islands. One of them also knew about the Javanese diaspora in Suriname, and we had a little conversation about that too. Unfortunately their limited English and my practically non-existent Bahasa Indonesia wouldn’t let us go further into such an interesting subject.
|
||||
Jerup beach was my favourite shelter so far — each tent could hide in its own little spot in the dunes, barely 20 meters from the water. A last coffee on the beach, and the forecast looked very rainy. It started as I was packing up the last bits of the tent.
|
||||
From Tolne I took the train to Aalborg, where I’ll slowly assimilate back into the urban setting after eight days away from it.
|
||||
|
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: '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=""]
|
||||
|
||||
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" /]
|
||||
@@ -1,12 +1,13 @@
|
||||
---
|
||||
title: 'Vestkustryten - Denmark 2026'
|
||||
title: 'Vestkystruten - Denmark 2026'
|
||||
template: trip
|
||||
date: '2026-07-10'
|
||||
date_start: '2026-07-10'
|
||||
date_end: '2026-07-20'
|
||||
cover_image: 'bikepacking_denmark.jpeg'
|
||||
tagline: '10 days along the West Coast to the top of Denmark'
|
||||
published: false
|
||||
date_end: '2026-07-18'
|
||||
cover_image: bikepacking_denmark.jpeg
|
||||
tagline: "650 km up Denmark's wild North Sea coast to where two seas meet"
|
||||
published: true
|
||||
autoconnect: intelligent_gpx
|
||||
---
|
||||
|
||||
A spontaneous adventure up the spectacular North Sea coast of Denmark, all the way to the northern tip where the North Sea and the Baltic meet. Roughly 560 km of cute little towns and strong winds!
|
||||
|
||||
@@ -3,13 +3,14 @@ title: '600km of Tuscany Begins with an Aperitif'
|
||||
date: '2025-10-11 17:41'
|
||||
template: entry
|
||||
published: true
|
||||
hero_image: 'photo-1.jpg'
|
||||
hero_image: photo-1.jpg
|
||||
lat: '43.0183'
|
||||
lng: '10.6059'
|
||||
location_city: 'Venturina Terme'
|
||||
location_country: 'Italy'
|
||||
location_country: Italy
|
||||
weather_temp_c: '18'
|
||||
weather_desc: 'partly cloudy'
|
||||
featured: true
|
||||
---
|
||||
|
||||
A new adventure started today, The Grand Escape Tuscany. A ~600km no-race cycling tour through the beautiful lands of Tuscany, passing by Orbetello, Sorano, Pienza, Siena, Firenze and Volterra.
|
||||
|
||||
@@ -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'
|
||||
---
|
||||
|
||||
@@ -5,4 +5,5 @@ date: '2024-05-28'
|
||||
date_start: '2024-05-28'
|
||||
date_end: '2024-05-28'
|
||||
cover_image: ''
|
||||
tagline: 'Gelato and Venetian walls on Slovenia''s slip of Adriatic coast'
|
||||
---
|
||||
|
||||
@@ -3,13 +3,14 @@ title: 'Windmills, Craft Beer and Twin Peaks at Dusk'
|
||||
date: '2024-07-21 19:07'
|
||||
template: entry
|
||||
published: true
|
||||
hero_image: 'photo-1.jpg'
|
||||
hero_image: photo-1.jpg
|
||||
lat: '37.765049'
|
||||
lng: '-122.508320'
|
||||
location_city: 'San Francisco'
|
||||
location_country: 'USA'
|
||||
location_country: USA
|
||||
weather_temp_c: '18'
|
||||
weather_desc: 'partly cloudy'
|
||||
featured: true
|
||||
---
|
||||
|
||||
19.7 - Exploring the SF Sunset district... Started the day with a Large Mocha and Hazulnut Chocolate Croissant, because sugar free is not the theme for the upcoming days. Afterwards I visited the beautiful windmills in the Golden Gate park and had a nice afternoon drink at the Sunset Reservoir Brewing Company, great beers and a great playlist 🕺
|
||||
|
||||
@@ -5,4 +5,5 @@ date: '2024-07-21'
|
||||
date_start: '2024-07-21'
|
||||
date_end: '2024-08-07'
|
||||
cover_image: ''
|
||||
tagline: 'Three weeks across North America — Highway 1, craft beer, Niagara, and Mexico City'
|
||||
---
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
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:
|
||||
fields:
|
||||
tabs:
|
||||
@@ -8,6 +15,7 @@ form:
|
||||
fields:
|
||||
|
||||
content:
|
||||
ordering@: 0
|
||||
type: tab
|
||||
title: Content
|
||||
fields:
|
||||
@@ -34,7 +42,7 @@ form:
|
||||
type: text
|
||||
label: Hero Image
|
||||
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:
|
||||
type: text
|
||||
@@ -44,10 +52,53 @@ form:
|
||||
content:
|
||||
type: markdown
|
||||
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:
|
||||
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:
|
||||
ordering@: 2
|
||||
type: tab
|
||||
title: Location
|
||||
fields:
|
||||
@@ -97,21 +148,13 @@ form:
|
||||
validate:
|
||||
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:
|
||||
ordering@: 3
|
||||
type: tab
|
||||
title: Publishing
|
||||
fields:
|
||||
header.published:
|
||||
type: toggle
|
||||
label: Published
|
||||
highlight: 1
|
||||
default: 1
|
||||
options:
|
||||
1: 'Yes'
|
||||
0: 'No'
|
||||
validate:
|
||||
type: bool
|
||||
|
||||
header.featured:
|
||||
type: toggle
|
||||
label: Featured highlight
|
||||
@@ -123,3 +166,12 @@ form:
|
||||
0: 'No'
|
||||
validate:
|
||||
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
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
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:
|
||||
fields:
|
||||
tabs:
|
||||
@@ -8,6 +16,7 @@ form:
|
||||
fields:
|
||||
|
||||
content:
|
||||
ordering@: 0
|
||||
type: tab
|
||||
title: Content
|
||||
fields:
|
||||
@@ -21,7 +30,15 @@ form:
|
||||
type: markdown
|
||||
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:
|
||||
ordering@: 1
|
||||
type: tab
|
||||
title: Trip
|
||||
fields:
|
||||
@@ -78,17 +95,6 @@ form:
|
||||
'manual': 'Manual — force connect only'
|
||||
'intelligent_gpx': 'Intelligent GPX — suppress where route is covered'
|
||||
|
||||
publishing:
|
||||
type: tab
|
||||
title: Publishing
|
||||
fields:
|
||||
header.published:
|
||||
type: toggle
|
||||
label: Published
|
||||
highlight: 1
|
||||
default: 1
|
||||
options:
|
||||
1: 'Yes'
|
||||
0: 'No'
|
||||
validate:
|
||||
type: bool
|
||||
# No Publishing tab — it held only the Published toggle, which now comes
|
||||
# from the inherited Options tab. Defining it here too would render two
|
||||
# toggles bound to the same header key.
|
||||
|
||||
@@ -791,8 +791,65 @@ body::after {
|
||||
|
||||
.feed-map-link:hover { color: var(--color-accent-hover); }
|
||||
|
||||
/* ── Feedback / notification messages ────────────────────────────────────────── */
|
||||
|
||||
/* Grav renders feedback two ways and the theme styled neither globally:
|
||||
- Form/flash messages as <div class="notices success|error|warning green|red|yellow">
|
||||
(Form plugin form-messages.html.twig; also markdown-notices output).
|
||||
- Plain flash messages as #messages .toast .toast-error|success|warning (quark
|
||||
partials/messages.html.twig, resolved since this theme has none of its own).
|
||||
Without these rules they fell back to light admonition boxes that clash with the
|
||||
dark theme (first noticed on /login). Kept on-theme with a status-coloured
|
||||
left border. */
|
||||
.notices,
|
||||
#messages .toast {
|
||||
padding: 1rem 1.1rem;
|
||||
border-radius: var(--radius-md);
|
||||
font-size: var(--text-base);
|
||||
margin: 0 0 var(--space-5);
|
||||
background: var(--color-canvas);
|
||||
color: var(--color-ink);
|
||||
border: 1px solid var(--color-border);
|
||||
border-left: 4px solid var(--color-accent);
|
||||
}
|
||||
.notices p,
|
||||
#messages .toast p { margin: 0; }
|
||||
|
||||
/* markdown-notices sets bright per-colour backgrounds on .notices.red / .yellow /
|
||||
.green / .blue at (0,2,0). Override each variant at equal specificity (this
|
||||
sheet loads later) so the dark canvas + cream text always win — only the
|
||||
left-border colour signals status. */
|
||||
.notices.error, .notices.red,
|
||||
.notices.success, .notices.green,
|
||||
.notices.warning, .notices.yellow,
|
||||
.notices.blue,
|
||||
#messages .toast-error,
|
||||
#messages .toast-success,
|
||||
#messages .toast-warning {
|
||||
background: var(--color-canvas);
|
||||
color: var(--color-ink);
|
||||
border-left-width: 4px;
|
||||
border-left-style: solid;
|
||||
}
|
||||
.notices.error, .notices.red,
|
||||
#messages .toast-error { border-left-color: var(--color-error); }
|
||||
.notices.success, .notices.green,
|
||||
#messages .toast-success { border-left-color: var(--color-accent); }
|
||||
.notices.warning, .notices.yellow,
|
||||
#messages .toast-warning { border-left-color: #c98a2e; }
|
||||
.notices.blue { border-left-color: var(--color-accent); }
|
||||
|
||||
/* ── Login form ─────────────────────────────────────────────────────────────── */
|
||||
|
||||
/* Neutralise the Login plugin's built-in white box (#grav-login in
|
||||
plugin://login/css/login.css) so the wrapper matches the dark theme. */
|
||||
#grav-login {
|
||||
background: var(--color-canvas);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--color-ink);
|
||||
}
|
||||
|
||||
.login-form { max-width: 400px; margin: var(--space-8) auto; padding: 0 var(--space-4); }
|
||||
.login-form .form-field { margin-bottom: var(--space-5); }
|
||||
.login-form .form-label label { display: block; font-size: var(--text-sm); font-weight: 600; margin-bottom: var(--space-2); }
|
||||
@@ -833,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 {
|
||||
@@ -2033,7 +2086,7 @@ body::after {
|
||||
.scrolly-step:last-child { padding-bottom: 50vh; }
|
||||
@media (max-width: 768px), (pointer: coarse) {
|
||||
.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:last-child { padding-bottom: 50vh; }
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
@@ -235,6 +243,38 @@ function initPhotoConversion() {
|
||||
}
|
||||
}, true);
|
||||
|
||||
// Complete upload gate (BUG 2026-07-09: fast create submit lost the photo).
|
||||
// The form plugin's own submit guard (filepond-handler.js) only blocks the
|
||||
// PROCESSING / PROCESSING_QUEUED states. A file still being read (LOADING —
|
||||
// the "too quick" click) or one whose upload FAILED (PROCESSING_ERROR)
|
||||
// slips through it and the entry is saved without the photo — silently,
|
||||
// because an errored thumbnail still satisfies the ≥1-photo count. Block
|
||||
// submit unless every FilePond item reached PROCESSING_COMPLETE, and say
|
||||
// which case blocked it.
|
||||
form.addEventListener('submit', function (e) {
|
||||
if (!orderPond) return;
|
||||
var files = orderPond.getFiles();
|
||||
if (!files.length) return; // the ≥1-photo rule is initValidation's job
|
||||
var statuses = (window.FilePond && window.FilePond.FileStatus) || {};
|
||||
var pending = 0;
|
||||
var failed = 0;
|
||||
files.forEach(function (f) {
|
||||
if (f.status === statuses.PROCESSING_COMPLETE) return;
|
||||
if (f.status === statuses.PROCESSING_ERROR || f.status === statuses.LOAD_ERROR) failed++;
|
||||
else pending++; // LOADING, INIT, IDLE, QUEUED, PROCESSING, …
|
||||
});
|
||||
if (!pending && !failed) return;
|
||||
e.preventDefault();
|
||||
if (collapse) collapse.details.open = true; // reveal the item states
|
||||
setStatus(failed
|
||||
? 'A photo failed to upload — remove it (tap its ✕) and re-add it before posting.'
|
||||
: 'Photos are still uploading — hang on a moment.', 'err');
|
||||
var el = photoStatusEl();
|
||||
if (el && typeof el.scrollIntoView === 'function') {
|
||||
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
}
|
||||
}, true);
|
||||
|
||||
// FilePond hook: reject a HEIC item, convert it to JPEG via the lazy heic-to
|
||||
// chunk (KTD4), then re-add the JPEG through pond.addFile() so FilePond
|
||||
// uploads and page-attaches it via its own (correct) contract.
|
||||
@@ -441,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;
|
||||
@@ -480,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.
|
||||
@@ -564,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();
|
||||
@@ -1158,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);
|
||||
@@ -1206,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",
|
||||
|
||||
@@ -206,7 +206,7 @@
|
||||
{% endif %}
|
||||
|
||||
<div class="home-highlights-cta-wrap">
|
||||
<a class="home-highlights-cta" href="/trips">Explore all past trips →</a>
|
||||
<a class="home-highlights-cta" href="/trips">Explore all trips →</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -26,10 +26,11 @@
|
||||
</button>
|
||||
<nav class="site-nav" id="site-nav" aria-label="Main navigation">
|
||||
<a href="{{ base_url_absolute }}"{% if page.template == 'home' %} aria-current="page"{% endif %}>Home</a>
|
||||
<a href="{{ base_url_absolute }}/trips"{% if page.template == 'trips' %} aria-current="page"{% endif %}>Past Trips</a>
|
||||
<a href="{{ base_url_absolute }}/trips"{% if page.template == 'trips' %} aria-current="page"{% endif %}>Trips</a>
|
||||
{% if grav.user.authenticated %}
|
||||
<a href="{{ base_url_absolute }}/post"{% if page.template == 'post-form' %} aria-current="page"{% endif %}>New Post</a>
|
||||
<a href="{{ base_url_absolute }}/gpx-manager"{% if page.template == 'gpx-manager' %} aria-current="page"{% endif %}>GPX Manager</a>
|
||||
<a href="{{ url(uri.addNonce((uri.path)|trim('/') ~ '/task' ~ config.system.param_sep ~ 'login.logout', 'logout-form', 'logout-nonce'))|e }}">Logout</a>
|
||||
{% endif %}
|
||||
</nav>
|
||||
{% endblock %}
|
||||
|
||||
@@ -38,15 +38,32 @@
|
||||
|
||||
{% set images = entry.media.images %}
|
||||
{% if images|length > 0 %}
|
||||
{% set firstImg = images|first %}
|
||||
{% set wrapRatio = firstImg.height > firstImg.width ? '4 / 5' : '4 / 3' %}
|
||||
{#
|
||||
Lightbox slides link to a 2000px fit-within DERIVATIVE, and the
|
||||
data-pswp-* dims are measured from that derivative's saved file — never
|
||||
from `img.width`/`img.height`, which are the ORIGINAL's raw stored pixels
|
||||
with EXIF orientation ignored. Originals from iPhones are often stored
|
||||
rotated (landscape pixels + EXIF "rotate 90°"), so raw dims disagree with
|
||||
what the browser renders and PhotoSwipe squeezes portraits into landscape
|
||||
boxes (BUG 2026-07-09, covered by tests/ui/post/lightbox-dims.spec.js).
|
||||
The derivative is re-encoded (EXIF stripped, orientation baked in when
|
||||
php-exif is available), so its measured dims always match its rendering.
|
||||
Note: .path()/.url each finalize+reset the medium's operation queue,
|
||||
hence the repeated cropResize() calls — the derivative file itself is
|
||||
cached. path() needs explicit parens: Medium is ArrayAccess and carries a
|
||||
'path' ITEM (the page folder), which shadows the method in Twig's
|
||||
attribute resolution and hands getimagesize a directory.
|
||||
#}
|
||||
{% set firstDims = (images|first).cropResize(2000, 2000).path()|getimagesize %}
|
||||
{% set wrapRatio = firstDims[1] > firstDims[0] ? '4 / 5' : '4 / 3' %}
|
||||
<div class="journal-photo-wrap" style="aspect-ratio: {{ wrapRatio }}">
|
||||
<div class="journal-photo-strip pswp-gallery" id="gallery-{{ entry.slug }}" data-slides="{{ images|length }}">
|
||||
{% for img in images %}
|
||||
{% set slideDims = img.cropResize(2000, 2000).path()|getimagesize %}
|
||||
<a class="journal-photo-slide"
|
||||
href="{{ img.url }}"
|
||||
data-pswp-width="{{ img.width }}"
|
||||
data-pswp-height="{{ img.height }}"
|
||||
href="{{ img.cropResize(2000, 2000).url }}"
|
||||
data-pswp-width="{{ slideDims[0] }}"
|
||||
data-pswp-height="{{ slideDims[1] }}"
|
||||
style="--thumb: url('{{ img.cropResize(900, 675).url }}')"
|
||||
target="_blank">
|
||||
<img src="{{ img.cropResize(900, 675).url }}" alt="{{ entry.title }}" loading="lazy">
|
||||
|
||||
@@ -23,12 +23,18 @@
|
||||
#}
|
||||
<div class="home-map-col">
|
||||
<div class="home-map" id="{{ map_id }}">
|
||||
{# Same gate as the init script below: without entries the map never
|
||||
initialises and nothing wires the button's click handler, so on
|
||||
mobile (the only viewport where it shows) it would float as a dead
|
||||
control over an empty map area (home pre-departure state). #}
|
||||
{% if entries|length > 0 %}
|
||||
<button class="feed-map-fullscreen-btn" id="{{ map_id }}-fullscreen" aria-label="Expand map">
|
||||
<svg class="feed-map-fs-open" aria-hidden="true" width="14" height="14" viewBox="0 0 14 14" fill="currentColor">
|
||||
<path d="M0 0v4h1.5V1.5H4V0z M14 0H10v1.5h2.5V4H14z M0 14v-4h1.5v2.5H4V14z M14 14H10v-1.5h2.5V10H14z"/>
|
||||
</svg>
|
||||
<span class="feed-map-fs-close" aria-hidden="true">✕</span>
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -132,23 +132,11 @@
|
||||
update();
|
||||
})();
|
||||
|
||||
/* ── Back to top button ─────────────────────────────────── */
|
||||
(function () {
|
||||
var btn = document.getElementById('story-totop');
|
||||
if (!btn) return;
|
||||
var threshold = window.innerHeight * 0.8;
|
||||
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 });
|
||||
})();
|
||||
/* ── Back to top button ───────────────────────────────────
|
||||
Owned by initBackToTop('story-totop') in js/src/main.js, called from its
|
||||
DOMContentLoaded boot. An inline copy used to live here and double-bound
|
||||
both the click and scroll handlers; it also lacked the history.pushState
|
||||
that makes Back return to the reading position. Do not re-add it. */
|
||||
|
||||
/* ── ChapterBreak scroll-reveal ─────────────────────────── */
|
||||
(function () {
|
||||
@@ -216,11 +204,17 @@
|
||||
update();
|
||||
})();
|
||||
|
||||
/* ── ScrollySection (Scrollama) ──────────────────────────── */
|
||||
(function () {
|
||||
/* ── ScrollySection (Scrollama) ────────────────────────────
|
||||
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 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%'];
|
||||
|
||||
@@ -252,7 +246,10 @@
|
||||
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'); });
|
||||
return;
|
||||
}
|
||||
@@ -281,6 +278,6 @@
|
||||
if (d.direction === 'up') d.element.classList.remove('is-active');
|
||||
});
|
||||
});
|
||||
})();
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
{% block content %}
|
||||
{% import 'macros/cover.html.twig' as cover %}
|
||||
<h1 class="trips-heading">Past Trips</h1>
|
||||
<h1 class="trips-heading">Trips</h1>
|
||||
{# Owner gate (R1): broader than trip.html.twig's owner_can_edit — publishing
|
||||
works on ANY trip, not just the active one, so it's just owner identity. #}
|
||||
{% set is_owner = grav.user.authenticated
|
||||
|
||||