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 | ||
|
|
543e8e3dce | ||
|
|
37b669424b | ||
|
|
25cee53718 | ||
|
|
5b4e31678e | ||
|
|
064f0f0c52 | ||
|
|
4cc0a18aaf | ||
|
|
087e05affa | ||
|
|
55da834396 | ||
|
|
c46bbd952f | ||
|
|
1dc9c9854c | ||
|
|
d4fcbecaf1 | ||
|
|
b3b4f774d0 | ||
|
|
034db29e66 | ||
|
|
536ca2085f | ||
|
|
21f0c8fdd5 | ||
|
|
ac88e8c003 | ||
|
|
7f6bf9e3f3 | ||
|
|
8db3ffeafc | ||
|
|
13656e311a | ||
|
|
ad72739b99 | ||
|
|
a7bda6ed39 | ||
|
|
7775a4ed6d | ||
|
|
7ffd75e8ec | ||
|
|
361a6b4d67 | ||
|
|
b317640dd0 | ||
|
|
9bcb0fe4db | ||
|
|
b5f169f25f | ||
|
|
a4432d897e | ||
|
|
4fea522d5c | ||
|
|
fcf52a0e44 | ||
|
|
32c3d8c065 | ||
|
|
05db592836 | ||
|
|
ff31683075 | ||
|
|
a9ec49e164 | ||
|
|
03323bcadf | ||
|
|
cb759746d0 | ||
|
|
525b684493 | ||
|
|
f8f03faa1d | ||
|
|
6e8eadbab5 | ||
|
|
ca15bd8bd7 | ||
|
|
1418dd275b |
@@ -5,6 +5,7 @@
|
||||
!/plugins/entry-actions/
|
||||
/data/
|
||||
/accounts/testrunner.yaml
|
||||
/accounts/tester.yaml
|
||||
/pages/01.trips/italy-2026-demo/
|
||||
/pages/02.post/*ui-test*/
|
||||
/config/plugins/git-sync.yaml
|
||||
@@ -12,4 +13,8 @@
|
||||
/config/security.yaml
|
||||
/config/security-private.php
|
||||
/config/versions.yaml
|
||||
# Per-host env override tree — holds LIVE secrets (JWT, CSRF salt, git-sync
|
||||
# token) + per-host overrides. Never commit/sync: git-sync stages anything
|
||||
# not gitignored, so an untracked /env/ would boomerang to Gitea.
|
||||
/env/
|
||||
/themes/intotheeast/node_modules/
|
||||
|
||||
@@ -49,7 +49,8 @@ invitations:
|
||||
popularity:
|
||||
enabled: true
|
||||
exclude_admin: true
|
||||
exclude_ips: { }
|
||||
exclude_ips:
|
||||
- 83.135.64.30
|
||||
history:
|
||||
daily: 30
|
||||
monthly: 12
|
||||
@@ -57,3 +58,4 @@ popularity:
|
||||
ignore:
|
||||
- '/test*'
|
||||
- /modular
|
||||
salt: 095e9f1559db5a7e97e73fc3c394412b3627ea553ddbb60e80a88191acbec9c8
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -76,7 +76,13 @@ pages:
|
||||
- rss
|
||||
- atom
|
||||
append_url_extension: null
|
||||
expires: 604800
|
||||
# expires: 0 → Cache-Control: max-age=0, so the browser (and any CDN) revalidates
|
||||
# each load instead of serving up to 7 days stale. With etag on, an unchanged
|
||||
# page returns a cheap 304; an edited/deleted/new entry shows immediately. This
|
||||
# matters because the owner edits the live site and entry media reuses filenames
|
||||
# (photo-N.jpg) across reorders. Server-side page cache (cache.enabled) is
|
||||
# unaffected and still does the heavy lifting.
|
||||
expires: 0
|
||||
cache_control: null
|
||||
last_modified: false
|
||||
etag: true
|
||||
|
||||
@@ -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,14 @@ date: '2026-09-01'
|
||||
date_start: '2026-09-01'
|
||||
date_end: '2026-09-08'
|
||||
cover_image: ''
|
||||
tagline: 'A week through the hill towns of southern Tuscany by bike'
|
||||
---
|
||||
|
||||
We set out from Campiglia with panniers full and no fixed plan beyond a rough
|
||||
line south through the Val d'Orcia. What followed was a week of gravel farm
|
||||
roads, long climbs into walled towns, and slow evenings that smelled of
|
||||
woodsmoke and ripe figs.
|
||||
|
||||
This is the running account of that ride — the villages that surprised us, the
|
||||
food that kept us pedalling, and the quiet stretches of road where the whole
|
||||
trip finally made sense.
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
<?php
|
||||
|
||||
// Auto-generated private secret. Do NOT commit to version control.
|
||||
// Used to sign and verify API JWTs. Regenerate by deleting this file; the
|
||||
// next request will write a new value (invalidating all existing tokens).
|
||||
|
||||
return 'b24a28b949941d16ef856a957f051831184a82ec15229dcd630183518bd0bca2';
|
||||
@@ -1,33 +0,0 @@
|
||||
enabled: true
|
||||
folders:
|
||||
- pages
|
||||
- config
|
||||
- themes
|
||||
local_repository: ''
|
||||
repository: 'https://git.gorinskat.nl/m038/intotheeast-com-content.git'
|
||||
no_user: false
|
||||
user: into-the-east-grav-sync
|
||||
webhook: /_git-sync
|
||||
webhook_enabled: 1
|
||||
webhook_secret: '/RX{_*lU]dF&y_Pd!h+EZWjMP1{Np>$z>\%+G''Jo'
|
||||
branch: main
|
||||
logging: false
|
||||
password: gitsync-def502005b5427cc6afd28ea66682788711e72b3e31bcb6bd302db4232e48a8783900ed76bf2cf891b3e4545dd925a5f5276fa683bf033797b5c6074f7ef0c520c455e34e27392be46615378e779cba61fe4a7f710fcbf157fb9709142794bce7d785e3738de84dd6887e0287f2923e96ae12ce446e4e61285a3ce48
|
||||
sync:
|
||||
direction: both
|
||||
on_save: true
|
||||
on_delete: true
|
||||
on_media: true
|
||||
cron_enable: false
|
||||
cron_at: '0 12,23 * * *'
|
||||
remote:
|
||||
name: origin
|
||||
branch: main
|
||||
git:
|
||||
author: gituser
|
||||
message: '(Grav GitSync) Automatic Commit'
|
||||
name: GitSync
|
||||
email: mischa@gorinskat.nl
|
||||
bin: git
|
||||
ignore: ''
|
||||
private_key: ''
|
||||
@@ -1,7 +0,0 @@
|
||||
<?php
|
||||
|
||||
// Auto-generated private secret. Do NOT commit to version control.
|
||||
// Used for CSRF nonce signing and admin rate-limit hashing. Regenerate by
|
||||
// deleting this file; the next request will write a new value.
|
||||
|
||||
return '7b82d740b30aafea06c98ee539ff5c2f1800cbd60f07754d131db7d80e687068';
|
||||
@@ -1,38 +0,0 @@
|
||||
# Production-only Grav config overrides.
|
||||
#
|
||||
# Deep-merged OVER the committed user/config/system.yaml via Grav's
|
||||
# per-environment config mechanism: on the server this file is deployed to
|
||||
# <webroot>/user/env/<hostname>/config/system.yaml
|
||||
# and Grav's `environment://config` stream (keyed on the request hostname)
|
||||
# layers it on top of `user://config`.
|
||||
#
|
||||
# These values are deliberately NOT in the committed system.yaml because they
|
||||
# would break local development (see CLAUDE.md §1 — dev keeps twig.cache:false
|
||||
# so theme edits take effect immediately). Prod is the only place they apply.
|
||||
#
|
||||
# Deploy with: make remote-apply-env-prod
|
||||
# The user/env/ tree is outside the content repo's tracked folders, so it is
|
||||
# NOT restored by content-push / git-sync / remote-fetch-content — re-run the
|
||||
# target above after any fresh install.
|
||||
twig:
|
||||
cache: true
|
||||
debug: false
|
||||
auto_reload: false
|
||||
|
||||
# Compression / connection handling.
|
||||
#
|
||||
# This host is not FastCGI (no fastcgi_finish_request()), so Grav's shutdown
|
||||
# "early connection close" falls back to emitting `Content-Encoding: identity`
|
||||
# to ask the webserver not to compress. But Apache's mod_deflate compresses
|
||||
# anyway and adds `Content-Encoding: gzip`, giving TWO conflicting headers —
|
||||
# the browser can't decode the body and renders raw gzip bytes (a garbage
|
||||
# page). Note: allow_webserver_gzip:true takes the SAME identity branch, so it
|
||||
# does not help. The real fix is to disable the early-close path, so Grav never
|
||||
# emits the bogus header and mod_deflate compresses cleanly (single header).
|
||||
debugger:
|
||||
shutdown:
|
||||
close_connection: false
|
||||
# Let the webserver own gzip; Grav does not compress or double-label.
|
||||
cache:
|
||||
gzip: false
|
||||
allow_webserver_gzip: false
|
||||
@@ -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.
|
||||
@@ -0,0 +1,13 @@
|
||||
---
|
||||
title: Journal
|
||||
template: default
|
||||
routable: false
|
||||
visible: false
|
||||
content:
|
||||
items: '@self.children'
|
||||
order:
|
||||
by: date
|
||||
dir: desc
|
||||
filter:
|
||||
published: true
|
||||
---
|
||||
@@ -0,0 +1,7 @@
|
||||
---
|
||||
title: Stories
|
||||
template: default
|
||||
routable: false
|
||||
visible: false
|
||||
published: true
|
||||
---
|
||||
|
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" /]
|
||||
|
After Width: | Height: | Size: 897 KiB |
@@ -0,0 +1,13 @@
|
||||
---
|
||||
title: 'Vestkystruten - Denmark 2026'
|
||||
template: trip
|
||||
date: '2026-07-10'
|
||||
date_start: '2026-07-10'
|
||||
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.
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
---
|
||||
title: Stories
|
||||
template: default
|
||||
routable: false
|
||||
visible: false
|
||||
template: stories
|
||||
published: true
|
||||
---
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
---
|
||||
title: 'Cycling Tuscany 2025'
|
||||
title: 'Tuscany Gravel 2025'
|
||||
template: trip
|
||||
date: '2025-10-11'
|
||||
date_start: '2025-10-11'
|
||||
date_end: '2025-10-16'
|
||||
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'
|
||||
---
|
||||
|
||||
@@ -1,16 +1,39 @@
|
||||
<?php
|
||||
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;
|
||||
|
||||
require_once __DIR__ . '/classes/EntryScopeGuard.php';
|
||||
require_once __DIR__ . '/classes/PhotoRenumberer.php';
|
||||
|
||||
use Grav\Plugin\Shared\EntryScopeGuard;
|
||||
use Grav\Plugin\Shared\PhotoRenumberer;
|
||||
|
||||
class CacheOnSavePlugin extends Plugin
|
||||
{
|
||||
/**
|
||||
* onFormProcessed fires once per `process:` action (add_page, upload, message,
|
||||
* reset — 4x for the post form). Photo reconciliation must run exactly once:
|
||||
* the first pass renames the kept photos to photo-01..NN, so a second pass with
|
||||
* the same manifest would see those renamed files as "unlisted" and delete
|
||||
* them. This latches after the first run (the plugin instance persists for the
|
||||
* request); the first fire is the `add_page` action, after add-page-by-form
|
||||
* (priority 0) has created the page and copied files, so files are present.
|
||||
*/
|
||||
private bool $photosReconciled = false;
|
||||
|
||||
/**
|
||||
* Same 4x-per-submit firing as $photosReconciled: onFormProcessed runs once
|
||||
* per process action. Clearing the page-tree cache is idempotent, but doing it
|
||||
* four times per post is wasted work (a full deleteAll() + system.yaml touch
|
||||
* each time). Latch it so the invalidation runs exactly once per submission.
|
||||
*/
|
||||
private bool $cacheInvalidated = false;
|
||||
|
||||
public static function getSubscribedEvents(): array
|
||||
{
|
||||
return [
|
||||
@@ -21,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],
|
||||
];
|
||||
}
|
||||
|
||||
@@ -52,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
|
||||
@@ -127,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
|
||||
@@ -161,71 +268,121 @@ class CacheOnSavePlugin extends Plugin
|
||||
return;
|
||||
}
|
||||
|
||||
// Reorder the just-copied photos to match the order the user arranged in
|
||||
// the form (FilePond drag). Best-effort: any failure logs and is skipped
|
||||
// so a post is never lost over cosmetics.
|
||||
try {
|
||||
$this->reorderPhotos();
|
||||
} catch (\Throwable $e) {
|
||||
$this->grav['log']->warning('cache-on-save: photo reorder skipped — ' . $e->getMessage());
|
||||
// Reconcile the entry's photos to the order the owner arranged in the form
|
||||
// (FilePond drag). On create this only renumbers the just-copied uploads;
|
||||
// on edit (M2) it also removes any photo the owner dropped and renumbers
|
||||
// the surviving set so the first file is the cover. Runs ONCE per submit
|
||||
// (see $photosReconciled) — a second pass would delete the just-renamed
|
||||
// photo-N files as "unlisted". Best-effort: any failure logs and is
|
||||
// skipped so a post is never lost over cosmetics.
|
||||
if (!$this->photosReconciled) {
|
||||
$this->photosReconciled = true;
|
||||
try {
|
||||
$this->reconcilePhotos($form);
|
||||
} catch (\Throwable $e) {
|
||||
$this->grav['log']->warning('cache-on-save: photo reconcile skipped — ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
$this->grav['cache']->deleteAll();
|
||||
// Two-part invalidation, latched to run ONCE per submit (see
|
||||
// $cacheInvalidated) — the 4 process actions would otherwise repeat it.
|
||||
// deleteAll() drops the Doctrine store (the tracker feed page cache etc.),
|
||||
// but the page-tree INDEX is keyed on
|
||||
// md5(dirs + folderHash + config->checksum() + lang) (Pages::buildRegularPages).
|
||||
// With cache.check.method:folder that index can survive a create — a fresh
|
||||
// entry then stays invisible to the API (GET /api/v1/pages{route} 404s), so
|
||||
// opening the just-posted entry for editing shows "this entry no longer
|
||||
// exists". invalidateCache() touches system.yaml, bumping config->checksum()
|
||||
// so the index key changes and the tree rebuilds on the next request.
|
||||
if (!$this->cacheInvalidated) {
|
||||
$this->cacheInvalidated = true;
|
||||
$this->grav['cache']->deleteAll();
|
||||
Cache::invalidateCache();
|
||||
$this->grav['log']->info('cache-on-save: cleared page cache + invalidated page-tree index after new-entry submit');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Rename the uploaded photos to photo-1..N in the submitted (drag) order.
|
||||
* Reconcile the entry's photo files to the submitted (drag) order.
|
||||
*
|
||||
* The published entry lists media in filename order and treats the first as
|
||||
* the hero (see partials/entry-journal + entry-story), so a deterministic
|
||||
* photo-N naming is what makes the arranged order stick. copyFiles() writes
|
||||
* each file under its unsanitised client filename, and post-form.js sends the
|
||||
* drag order via the top-level `photo_order` POST key (orderFromPost) — so we
|
||||
* can map each on-disk file to its final photo-N slot.
|
||||
* the hero/cover (see partials/entry-journal + entry-story), so a deterministic
|
||||
* photo-N naming is what makes the arranged order stick. post-form.js sends the
|
||||
* final ordered set via the top-level `photo_order` POST key (orderFromPost):
|
||||
* on create these are the just-uploaded client filenames; on edit (M2) the mix
|
||||
* of surviving existing photos (loaded into FilePond as local items) plus any
|
||||
* new uploads, in the arranged order.
|
||||
*
|
||||
* Create: fuzzily locate the fresh folder by its uploaded filenames, then
|
||||
* renumber. Edit: locate the folder authoritatively through the page tree via
|
||||
* the shared scope guard (findEntryFolder is unsafe once files are the generic
|
||||
* photo-N.jpg — many entries share those names), delete any image the owner
|
||||
* dropped (not in the manifest), then renumber the survivors.
|
||||
*
|
||||
* Fail-safe: an empty manifest reconciles nothing (photos are left untouched),
|
||||
* so a missing/failed `photo_order` on edit never wipes an entry's images.
|
||||
*/
|
||||
private function reorderPhotos(): void
|
||||
private function reconcilePhotos($form): void
|
||||
{
|
||||
$names = $this->orderFromPost();
|
||||
if (count($names) < 1) {
|
||||
return; // nothing uploaded
|
||||
return; // nothing submitted — leave the entry's photos untouched
|
||||
}
|
||||
|
||||
$activeTrip = $this->grav['config']->get('site.active_trip');
|
||||
$activeTrip = is_string($activeTrip) ? trim($activeTrip) : '';
|
||||
if ($activeTrip === '') {
|
||||
return;
|
||||
}
|
||||
$slug = preg_replace('#^/?trips/#', '', trim($activeTrip, '/'));
|
||||
$slug = preg_replace('#/.*$#', '', $slug);
|
||||
|
||||
$dir = $this->findEntryFolder($slug, $names);
|
||||
if ($dir === null) {
|
||||
return; // couldn't confidently locate the new entry folder
|
||||
}
|
||||
|
||||
// Two-phase rename via temp names so a target (photo-2.jpg) can't clobber
|
||||
// a not-yet-moved source of the same name.
|
||||
$planned = [];
|
||||
$i = 1;
|
||||
foreach ($names as $name) {
|
||||
$src = $dir . DIRECTORY_SEPARATOR . $name;
|
||||
if (!is_file($src)) {
|
||||
continue; // skip anything not actually on disk
|
||||
$editPath = $this->editPathFromForm($form);
|
||||
if ($editPath !== '') {
|
||||
// EDIT — resolve the target folder through the page tree (shared guard),
|
||||
// then prune dropped photos before renumbering the survivors.
|
||||
$segment = EntryScopeGuard::segmentFromEditPath($editPath);
|
||||
$page = EntryScopeGuard::resolveActiveDailyChild($this->grav, $segment);
|
||||
if ($page === null) {
|
||||
return; // out of scope / unresolvable — the save guard already ran
|
||||
}
|
||||
$ext = strtolower(pathinfo($name, PATHINFO_EXTENSION)) ?: 'jpg';
|
||||
$tmp = $dir . DIRECTORY_SEPARATOR . '.reorder-tmp-' . $i . '.' . $ext;
|
||||
$final = $dir . DIRECTORY_SEPARATOR . 'photo-' . $i . '.' . $ext;
|
||||
if ($src === $final) {
|
||||
$i++;
|
||||
continue; // already correctly named
|
||||
$dir = $page->path();
|
||||
$this->deleteUnlistedImages($dir, $names);
|
||||
} else {
|
||||
// CREATE — locate the fresh folder by the set of uploaded filenames.
|
||||
$activeTrip = $this->grav['config']->get('site.active_trip');
|
||||
$activeTrip = is_string($activeTrip) ? trim($activeTrip) : '';
|
||||
if ($activeTrip === '') {
|
||||
return;
|
||||
}
|
||||
$slug = preg_replace('#^/?trips/#', '', trim($activeTrip, '/'));
|
||||
$slug = preg_replace('#/.*$#', '', $slug);
|
||||
$dir = $this->findEntryFolder($slug, $names);
|
||||
if ($dir === null) {
|
||||
return; // couldn't confidently locate the new entry folder
|
||||
}
|
||||
@rename($src, $tmp);
|
||||
$planned[] = [$tmp, $final];
|
||||
$i++;
|
||||
}
|
||||
foreach ($planned as [$tmp, $final]) {
|
||||
if (is_file($tmp)) {
|
||||
@rename($tmp, $final);
|
||||
|
||||
PhotoRenumberer::renumber($dir, $names);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete every image file in $dir whose basename is not in $keep (the manifest
|
||||
* of photos the owner kept). Only touches known image extensions — never the
|
||||
* entry .md or any other file — and clears any Grav media sidecar so a stale
|
||||
* `.meta.yaml` can't resurrect a removed image.
|
||||
*/
|
||||
private function deleteUnlistedImages(string $dir, array $keep): void
|
||||
{
|
||||
$keepSet = array_flip($keep);
|
||||
$imageExts = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'heic', 'heif'];
|
||||
foreach (glob($dir . DIRECTORY_SEPARATOR . '*') ?: [] as $path) {
|
||||
if (!is_file($path)) {
|
||||
continue;
|
||||
}
|
||||
$base = basename($path);
|
||||
$ext = strtolower(pathinfo($base, PATHINFO_EXTENSION));
|
||||
if (!in_array($ext, $imageExts, true)) {
|
||||
continue; // never touch .md or non-image files
|
||||
}
|
||||
if (isset($keepSet[$base])) {
|
||||
continue; // still in the arranged set — keep it
|
||||
}
|
||||
@unlink($path);
|
||||
if (is_file($path . '.meta.yaml')) {
|
||||
@unlink($path . '.meta.yaml');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -97,19 +97,17 @@ class EntryScopeGuard
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a folder segment to the page that is a DIRECT child of the active
|
||||
* trip's dailies container, or null when the segment is unsafe, no active trip
|
||||
* is set, the page does not exist, or its parent is not the active dailies.
|
||||
* Resolve a safe segment to the page that is a DIRECT child of $parentRoute,
|
||||
* or null when the segment is unsafe, the page does not exist, or its parent
|
||||
* is not exactly $parentRoute. Resolving via $pages->find() + a parent-route
|
||||
* assertion (never raw path concatenation) is what closes the traversal hole;
|
||||
* both public resolvers below share this one body so they cannot drift.
|
||||
*/
|
||||
public static function resolveActiveDailyChild(Grav $grav, string $segment): ?PageInterface
|
||||
private static function resolveChildOf(Grav $grav, string $parentRoute, string $segment): ?PageInterface
|
||||
{
|
||||
if (!self::isSafeSegment($segment)) {
|
||||
return null;
|
||||
}
|
||||
$dailies = self::dailiesRoute($grav);
|
||||
if ($dailies === null) {
|
||||
return null;
|
||||
}
|
||||
$pages = $grav['pages'];
|
||||
// In the API request context the page tree is lazily disabled; enable it
|
||||
// so find() can resolve (mirrors the api plugin's own resolvePageByRoute).
|
||||
@@ -117,12 +115,47 @@ class EntryScopeGuard
|
||||
if (method_exists($pages, 'enablePages')) {
|
||||
$pages->enablePages();
|
||||
}
|
||||
$page = $pages->find($dailies . '/' . $segment);
|
||||
$page = $pages->find($parentRoute . '/' . $segment);
|
||||
if ($page === null) {
|
||||
return null;
|
||||
}
|
||||
$parent = $page->parent();
|
||||
if ($parent === null || $parent->route() !== $dailies) {
|
||||
if ($parent === null || $parent->route() !== $parentRoute) {
|
||||
return null;
|
||||
}
|
||||
return $page;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a folder segment to the page that is a DIRECT child of the active
|
||||
* trip's dailies container, or null when the segment is unsafe, no active trip
|
||||
* is set, the page does not exist, or its parent is not the active dailies.
|
||||
*/
|
||||
public static function resolveActiveDailyChild(Grav $grav, string $segment): ?PageInterface
|
||||
{
|
||||
$dailies = self::dailiesRoute($grav);
|
||||
if ($dailies === null) {
|
||||
return null;
|
||||
}
|
||||
return self::resolveChildOf($grav, $dailies, $segment);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a slug to the trip page that is a DIRECT child of /trips, or null
|
||||
* when the segment is unsafe, the page does not exist, or its parent is not
|
||||
* /trips. The trip-scoped analogue of resolveActiveDailyChild, used by the
|
||||
* publish/unpublish route (KTD4).
|
||||
*
|
||||
* Unlike the front-end listing collections, this does NOT filter on published
|
||||
* state: find() must return drafts so the owner can republish an unpublished
|
||||
* trip from the listing (R7).
|
||||
*/
|
||||
public static function resolveTripChild(Grav $grav, string $slug): ?PageInterface
|
||||
{
|
||||
$page = self::resolveChildOf($grav, '/trips', $slug);
|
||||
// Only actual trip pages are publishable — a non-trip page ever added as a
|
||||
// direct child of /trips must not be toggled through this endpoint.
|
||||
if ($page === null || $page->template() !== 'trip') {
|
||||
return null;
|
||||
}
|
||||
return $page;
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
<?php
|
||||
namespace Grav\Plugin\Shared;
|
||||
|
||||
/**
|
||||
* Single source of truth for the `photo-NN` naming invariant, shared by BOTH
|
||||
* paths that establish it so their numbering can never diverge:
|
||||
* - cache-on-save's create/edit reconcile (onFormProcessed), and
|
||||
* - entry-actions' live reorder route (POST /entry/{slug}/photos/order).
|
||||
*
|
||||
* Files named in the ordered manifest are renamed to `photo-01..NN` (ZERO-PADDED)
|
||||
* in that order. Zero-padding is load-bearing: the published feed lists media in
|
||||
* filename order and treats the first as the cover (entry.media.images|first), and
|
||||
* lexicographic order only equals numeric order past 9 photos when the index is
|
||||
* padded (otherwise photo-1, photo-10, photo-2…). The pad width grows with the
|
||||
* set so it stays correct for 100+ photos, while normal entries get `photo-01`.
|
||||
*
|
||||
* Safety: only files that are ON DISK and carry a known IMAGE extension are ever
|
||||
* renamed. A crafted manifest entry naming the entry `.md`, a `.gpx`, or a
|
||||
* `.meta.yaml` sidecar is silently skipped — it can never be renamed or clobbered.
|
||||
* This guard lives here (not only in the callers) so every caller inherits it.
|
||||
*
|
||||
* Completeness: renumber() ALWAYS renumbers every image already in $dir, not just
|
||||
* the manifest subset. $names only supplies the preferred ORDER; any on-disk image
|
||||
* the manifest omits is appended at the end. This makes an incomplete/stale
|
||||
* manifest (e.g. a second browser tab whose list predates a change) harmless —
|
||||
* without it, an unlisted image left sitting at a target slot would be silently
|
||||
* OVERWRITTEN (destroyed) by the second-phase rename. The reorder route trusts a
|
||||
* client-supplied list, so this guard is what keeps it from losing photos.
|
||||
*/
|
||||
class PhotoRenumberer
|
||||
{
|
||||
/** Image extensions eligible for renumbering. Broad on purpose: existing
|
||||
* entries may hold .heic even though new uploads are jpg/jpeg/png/webp. */
|
||||
private const IMAGE_EXTS = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'heic', 'heif'];
|
||||
|
||||
/**
|
||||
* Renumber every image in $dir to photo-01..NN, using $names as the preferred
|
||||
* order and appending any unlisted on-disk images at the end.
|
||||
*
|
||||
* Two-phase via temp names so a target (photo-02.jpg) can't clobber a
|
||||
* not-yet-moved source of the same name (e.g. a straight swap or the
|
||||
* un-padded photo-N → photo-0N normalisation pass). Non-image and missing
|
||||
* files in $names are skipped and do not consume an index; a repeated name is
|
||||
* counted once. Because every on-disk image becomes a target (see the
|
||||
* completeness note on the class), the surviving images are numbered
|
||||
* contiguously from 01 and no untouched file is ever overwritten.
|
||||
*
|
||||
* Idempotent: a file already at its correct padded name is left untouched,
|
||||
* so re-running with the same manifest (e.g. an auto-retried reorder) is a
|
||||
* no-op.
|
||||
*/
|
||||
public static function renumber(string $dir, array $names): void
|
||||
{
|
||||
// Keep only real image files, in the requested order, de-duplicated —
|
||||
// this is both the security filter and what determines the pad width.
|
||||
$targets = [];
|
||||
$seen = [];
|
||||
foreach ($names as $name) {
|
||||
if (!is_string($name) || $name === '') {
|
||||
continue;
|
||||
}
|
||||
$base = basename(str_replace('\\', '/', $name));
|
||||
if (isset($seen[$base])) {
|
||||
continue; // a repeated name must not consume a second index
|
||||
}
|
||||
$src = $dir . DIRECTORY_SEPARATOR . $base;
|
||||
if (!is_file($src)) {
|
||||
continue; // not on disk — skip (idempotent for auto-retry)
|
||||
}
|
||||
$ext = strtolower(pathinfo($base, PATHINFO_EXTENSION));
|
||||
if (!in_array($ext, self::IMAGE_EXTS, true)) {
|
||||
continue; // never rename the entry .md, a .gpx, or a sidecar
|
||||
}
|
||||
$seen[$base] = true;
|
||||
$targets[] = [$src, $ext ?: 'jpg'];
|
||||
}
|
||||
|
||||
// Completeness guard: append EVERY other image already in $dir that the
|
||||
// manifest didn't list (natural name order), so an incomplete/stale
|
||||
// manifest can't leave an unlisted image at a target slot for phase-2 to
|
||||
// overwrite. Hidden files ('.'-prefixed temp/sidecar) are never targets.
|
||||
$extra = [];
|
||||
foreach (@scandir($dir) ?: [] as $f) {
|
||||
if ($f === '' || $f[0] === '.' || isset($seen[$f])) {
|
||||
continue;
|
||||
}
|
||||
$p = $dir . DIRECTORY_SEPARATOR . $f;
|
||||
if (!is_file($p)) {
|
||||
continue;
|
||||
}
|
||||
$ext = strtolower(pathinfo($f, PATHINFO_EXTENSION));
|
||||
if (!in_array($ext, self::IMAGE_EXTS, true)) {
|
||||
continue;
|
||||
}
|
||||
$extra[$f] = [$p, $ext ?: 'jpg'];
|
||||
}
|
||||
if ($extra) {
|
||||
uksort($extra, 'strnatcasecmp');
|
||||
foreach ($extra as $t) {
|
||||
$targets[] = $t;
|
||||
}
|
||||
}
|
||||
|
||||
$width = max(2, strlen((string) count($targets)));
|
||||
|
||||
// Unique per-call token in the temp name so two concurrent renumbers on
|
||||
// the same folder can't collide on a shared '.reorder-tmp-N' path and
|
||||
// overwrite one photo's bytes.
|
||||
$token = bin2hex(random_bytes(4));
|
||||
|
||||
$planned = [];
|
||||
$i = 1;
|
||||
foreach ($targets as [$src, $ext]) {
|
||||
$index = str_pad((string) $i, $width, '0', STR_PAD_LEFT);
|
||||
$final = $dir . DIRECTORY_SEPARATOR . 'photo-' . $index . '.' . $ext;
|
||||
if ($src === $final) {
|
||||
$i++;
|
||||
continue; // already correctly named — leave it
|
||||
}
|
||||
$tmp = $dir . DIRECTORY_SEPARATOR . '.reorder-tmp-' . $token . '-' . $i . '.' . $ext;
|
||||
@rename($src, $tmp);
|
||||
$planned[] = [$tmp, $final];
|
||||
$i++;
|
||||
}
|
||||
foreach ($planned as [$tmp, $final]) {
|
||||
if (is_file($tmp)) {
|
||||
@rename($tmp, $final);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
<?php
|
||||
namespace Grav\Plugin\EntryActions;
|
||||
|
||||
use Grav\Common\Cache;
|
||||
use Grav\Common\Filesystem\Folder;
|
||||
use Grav\Plugin\Api\Controllers\AbstractApiController;
|
||||
use Grav\Plugin\Api\Exceptions\ApiException;
|
||||
@@ -8,12 +9,15 @@ use Grav\Plugin\Api\Exceptions\ForbiddenException;
|
||||
use Grav\Plugin\Api\Exceptions\NotFoundException;
|
||||
use Grav\Plugin\Api\Response\ApiResponse;
|
||||
use Grav\Plugin\Shared\EntryScopeGuard;
|
||||
use Grav\Plugin\Shared\PhotoRenumberer;
|
||||
use Psr\Http\Message\ResponseInterface;
|
||||
use Psr\Http\Message\ServerRequestInterface;
|
||||
|
||||
// Shared R6 guard lives in cache-on-save (the always-present custom plugin);
|
||||
// require it so save and delete enforce scope identically (KTD5).
|
||||
// Shared R6 guard + the photo-NN renumber helper both live in cache-on-save (the
|
||||
// always-present custom plugin); require them so save, delete and reorder enforce
|
||||
// scope identically (KTD5) and share one numbering invariant.
|
||||
require_once dirname(__DIR__, 2) . '/cache-on-save/classes/EntryScopeGuard.php';
|
||||
require_once dirname(__DIR__, 2) . '/cache-on-save/classes/PhotoRenumberer.php';
|
||||
|
||||
/**
|
||||
* DELETE /api/v1/entry/{slug}
|
||||
@@ -32,6 +36,11 @@ class EntryActionsApiController extends AbstractApiController
|
||||
{
|
||||
// Authenticated OWNER only (KTD8). getUser() throws 401 for anonymous.
|
||||
$user = $this->getUser($request);
|
||||
// Enforce the API-key scope cap (GHSA-x7hm) with the SAME permission the
|
||||
// stock media/page-write endpoints require. The owner already holds it
|
||||
// (their add/delete media uploads pass it), so this only caps a scoped
|
||||
// key — it never blocks the legitimate owner.
|
||||
$this->requirePermission($request, 'api.pages.write');
|
||||
if (!EntryScopeGuard::isOwnerUser($this->grav, $user)) {
|
||||
throw new ForbiddenException('Only the site owner can delete journal entries.');
|
||||
}
|
||||
@@ -54,7 +63,175 @@ class EntryActionsApiController extends AbstractApiController
|
||||
}
|
||||
|
||||
Folder::delete($path);
|
||||
// deleteAll() drops the cache stores but does NOT rebuild Grav's page-tree
|
||||
// index (keyed on folderHash, which doesn't change on child removal under
|
||||
// cache.check.method: folder). Without invalidateCache() the deleted entry
|
||||
// lingers in the index and the feed re-renders it — now image-less — on the
|
||||
// next load. Mirrors the create-path fix in the cache-on-save plugin. See
|
||||
// docs/solutions/integration-issues/grav-deleteall-doesnt-invalidate-page-tree-index.md
|
||||
$this->grav['cache']->deleteAll();
|
||||
Cache::invalidateCache();
|
||||
// Audit trail: entry deletion is destructive and owner-only — record who
|
||||
// did it and to what, so an unexpected disappearance is traceable.
|
||||
$this->grav['log']->info(sprintf('entry-actions: owner "%s" deleted entry "%s"', $user->username, $slug));
|
||||
|
||||
return ApiResponse::noContent();
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/v1/entry/{slug}/photos/order
|
||||
*
|
||||
* Body: { "order": ["photo-x.jpg", "photo-y.jpg", …] } — the entry's image
|
||||
* files in the display order the owner arranged. Renames them to photo-01..NN
|
||||
* so the feed cover (media.images|first) and numeric client sort follow the
|
||||
* drag. Same guard chain as deleteEntry: OWNER + direct-child-of-active-dailies.
|
||||
*
|
||||
* Filename safety is defence in depth: unsafe segments (containing '/' or '..')
|
||||
* are dropped here, and PhotoRenumberer only ever renames files that already
|
||||
* exist as image media in the folder — so a crafted order body can never touch
|
||||
* the entry .md, a .gpx or a .meta.yaml sidecar. An incomplete `order` (e.g. a
|
||||
* stale second tab) is safe too: PhotoRenumberer renumbers every on-disk image,
|
||||
* appending any the manifest omits, so no photo is lost — `order` only sorts.
|
||||
*/
|
||||
public function reorderPhotos(ServerRequestInterface $request): ResponseInterface
|
||||
{
|
||||
// Authenticated OWNER only (KTD8). getUser() throws 401 for anonymous.
|
||||
$user = $this->getUser($request);
|
||||
// Enforce the API-key scope cap (GHSA-x7hm) — see deleteEntry above.
|
||||
$this->requirePermission($request, 'api.pages.write');
|
||||
if (!EntryScopeGuard::isOwnerUser($this->grav, $user)) {
|
||||
throw new ForbiddenException('Only the site owner can reorder entry photos.');
|
||||
}
|
||||
|
||||
$slug = $this->getRouteParam($request, 'slug');
|
||||
if (!is_string($slug) || !EntryScopeGuard::isSafeSegment($slug)) {
|
||||
throw new ApiException(400, 'Bad Request', 'Invalid entry slug.');
|
||||
}
|
||||
|
||||
$page = EntryScopeGuard::resolveActiveDailyChild($this->grav, $slug);
|
||||
if ($page === null) {
|
||||
throw new NotFoundException('Entry not found in the active trip.');
|
||||
}
|
||||
|
||||
$path = $page->path();
|
||||
if (!is_string($path) || $path === '' || !is_dir($path)) {
|
||||
throw new NotFoundException('Entry folder not found.');
|
||||
}
|
||||
|
||||
// Reduce the body's `order` to a clean list of safe basenames. Anything
|
||||
// unsafe or non-string is dropped; PhotoRenumberer then keeps only the
|
||||
// entries that are real image files on disk.
|
||||
$body = $this->getRequestBody($request);
|
||||
$order = $body['order'] ?? null;
|
||||
if (!is_array($order)) {
|
||||
throw new ApiException(400, 'Bad Request', 'Body must include an "order" array of filenames.');
|
||||
}
|
||||
$names = [];
|
||||
foreach ($order as $name) {
|
||||
if (is_string($name) && EntryScopeGuard::isSafeSegment($name)) {
|
||||
$names[] = $name;
|
||||
}
|
||||
}
|
||||
|
||||
PhotoRenumberer::renumber($path, $names);
|
||||
$this->grav['cache']->deleteAll();
|
||||
// Audit trail: mirror deleteEntry — record the owner mutating an entry's
|
||||
// photo order (and how many files the manifest listed).
|
||||
$this->grav['log']->info(sprintf('entry-actions: owner "%s" reordered %d photo(s) for entry "%s"', $user->username, count($names), $slug));
|
||||
|
||||
return ApiResponse::noContent();
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/v1/trip/{slug}/publish
|
||||
*
|
||||
* Body: { "published": true|false } — sets the trip's published state and
|
||||
* persists it to trip.md frontmatter, then invalidates the page-tree cache so
|
||||
* the /trips listing, nav and home render reflect the change on the next load.
|
||||
* Owner-only, but (unlike deleteEntry) NOT active-trip scoped: the owner
|
||||
* publishes/unpublishes ANY trip from the listing. 401 (anon), 403 (non-owner),
|
||||
* 400 (bad slug / non-boolean body), 404 (slug is not a direct child of /trips).
|
||||
*
|
||||
* CSRF boundary: this is a session-cookie write with credentials. Its cross-
|
||||
* origin protection is the required `Content-Type: application/json`, which
|
||||
* (with the api plugin's CORS `origins: []`, i.e. same-origin only) forces a
|
||||
* CORS preflight that a cross-site page cannot satisfy — so a forged request
|
||||
* from another origin is rejected before it reaches this handler. The strict
|
||||
* is_bool guard below backs that up (a form-encoded forgery decodes to no key).
|
||||
*/
|
||||
public function setTripPublished(ServerRequestInterface $request): ResponseInterface
|
||||
{
|
||||
// Authenticated OWNER only (KTD8). getUser() throws 401 for anonymous.
|
||||
$user = $this->getUser($request);
|
||||
// Enforce the API-key scope cap (GHSA-x7hm) — see deleteEntry above.
|
||||
$this->requirePermission($request, 'api.pages.write');
|
||||
if (!EntryScopeGuard::isOwnerUser($this->grav, $user)) {
|
||||
throw new ForbiddenException('Only the site owner can publish trips.');
|
||||
}
|
||||
|
||||
$slug = $this->getRouteParam($request, 'slug');
|
||||
if (!is_string($slug) || !EntryScopeGuard::isSafeSegment($slug)) {
|
||||
throw new ApiException(400, 'Bad Request', 'Invalid trip slug.');
|
||||
}
|
||||
|
||||
// Resolve via find() + parent-route assertion; drafts resolve too so the
|
||||
// owner can republish an unpublished trip (R7, KTD4).
|
||||
$page = EntryScopeGuard::resolveTripChild($this->grav, $slug);
|
||||
if ($page === null) {
|
||||
throw new NotFoundException('Trip not found.');
|
||||
}
|
||||
|
||||
// Strict boolean only — never (bool)-cast (KTD2). A cast would coerce
|
||||
// "false"/0/""/a missing key into a valid boolean and silently mis-set
|
||||
// the flag, contradicting R5.
|
||||
$body = $this->getRequestBody($request);
|
||||
if (!is_array($body) || !array_key_exists('published', $body) || !is_bool($body['published'])) {
|
||||
throw new ApiException(400, 'Bad Request', 'Body must include a boolean "published".');
|
||||
}
|
||||
$published = $body['published'];
|
||||
|
||||
// Persist by mutating the page HEADER before save() (KTD1): in Grav 2.0
|
||||
// $page->published($v) sets only the in-memory property, while save()
|
||||
// serializes from the header object and the flag is read one-way from the
|
||||
// header at init. Mirror cache-on-save's header-mutation pattern.
|
||||
$header = $page->header();
|
||||
$header->published = $published;
|
||||
$page->save();
|
||||
|
||||
// A published-flag change rewrites trip.md IN PLACE — the trip folder's
|
||||
// structure is unchanged, so the pages-index cache id (md5 of the folder
|
||||
// checksum under cache.check.method: folder) does NOT change (KTD3). This
|
||||
// differs from deleteEntry, where the removed folder IS a structure change
|
||||
// that bumps the id, so a fresh id misses cache and rebuilds. With the id
|
||||
// unchanged, the stale index survives — and because the cache driver is
|
||||
// APCu (driver: auto), it lives in the web server's shared memory, which a
|
||||
// CLI `bin/grav clearcache` cannot reach at all. So: flush the runtime
|
||||
// store (deleteAll → APCu flushAll) AND apcu_clear_cache() directly to be
|
||||
// certain, clear the compiled files, and reset the in-memory tree so the
|
||||
// next request rebuilds from disk and re-reads the published flag.
|
||||
// save() above is already persisted to disk. If any invalidation call
|
||||
// throws, do NOT let it bubble to a plain 500 (which reads to the owner as
|
||||
// "nothing happened") and skip the audit line: the on-disk flag DID change.
|
||||
// Log a loud reconciliation warning instead so an operator knows to clear
|
||||
// cache manually, then still report success.
|
||||
try {
|
||||
$this->grav['cache']->deleteAll();
|
||||
if (function_exists('apcu_clear_cache')) {
|
||||
apcu_clear_cache();
|
||||
}
|
||||
$this->grav['pages']->reset();
|
||||
$this->grav['cache']->clearCache('standard');
|
||||
} catch (\Throwable $e) {
|
||||
$this->grav['log']->error(sprintf(
|
||||
'entry-actions: trip "%s" published=%s SAVED to disk but cache invalidation failed (%s) — clear cache manually',
|
||||
$slug,
|
||||
$published ? 'true' : 'false',
|
||||
$e->getMessage()
|
||||
));
|
||||
}
|
||||
// Audit trail: publish state is owner-only and changes site-wide
|
||||
// visibility — record who flipped which trip to what.
|
||||
$this->grav['log']->info(sprintf('entry-actions: owner "%s" set trip "%s" published=%s', $user->username, $slug, $published ? 'true' : 'false'));
|
||||
|
||||
return ApiResponse::noContent();
|
||||
}
|
||||
|
||||
@@ -8,12 +8,17 @@ use RocketTheme\Toolbox\Event\Event;
|
||||
* Entry Actions — a thin, purpose-built API surface for owner-only, active-trip
|
||||
* scoped journal-entry actions that the stock Grav API cannot express safely.
|
||||
*
|
||||
* M1 registers exactly one route: DELETE /api/v1/entry/{slug}. The stock
|
||||
* DELETE /api/v1/pages<route> only checks write-permission (no trip scope, and
|
||||
* any admin passes), which violates R6. This route requires the configured site
|
||||
* OWNER and asserts the target is a direct child of the active trip's dailies
|
||||
* container — sharing one guard (EntryScopeGuard) with the save path so the two
|
||||
* R6 enforcement points cannot diverge (KTD5).
|
||||
* Routes:
|
||||
* - DELETE /api/v1/entry/{slug} — delete a journal entry folder
|
||||
* - POST /api/v1/entry/{slug}/photos/order — reorder an entry's photos
|
||||
* - POST /api/v1/trip/{slug}/publish — publish/unpublish a trip
|
||||
*
|
||||
* The stock DELETE /api/v1/pages<route> only checks write-permission (no trip
|
||||
* scope, and any admin passes), which violates R6; and no stock endpoint can
|
||||
* rename media to the photo-NN cover order at all. Both custom routes require the
|
||||
* configured site OWNER and assert the target is a direct child of the active
|
||||
* trip's dailies container — sharing one guard (EntryScopeGuard) with the save
|
||||
* path so the R6 enforcement points cannot diverge (KTD5).
|
||||
*
|
||||
* Custom-in-repo (NOT GPM-managed): tracked via a `!` negation in user/.gitignore
|
||||
* and deployed with the content push, like cache-on-save. Never in plugins.txt.
|
||||
@@ -56,5 +61,15 @@ class EntryActionsPlugin extends Plugin
|
||||
{
|
||||
$routes = $event['routes'];
|
||||
$routes->delete('/entry/{slug}', [EntryActions\EntryActionsApiController::class, 'deleteEntry']);
|
||||
// Reorder an entry's photos to a client-supplied order → rename to
|
||||
// photo-01..NN so the feed cover (media.images|first) follows the drag.
|
||||
// Nested-static-after-param, same shape as the DELETE above — it only
|
||||
// registers once the API route-map cache is rebuilt (deploy must clear cache).
|
||||
$routes->post('/entry/{slug}/photos/order', [EntryActions\EntryActionsApiController::class, 'reorderPhotos']);
|
||||
// Publish/unpublish a trip from the /trips listing → mutate trip.md
|
||||
// `published` and invalidate the page-tree index. Owner-only, any trip
|
||||
// (not active-scoped). Same registration caveat as above: it only takes
|
||||
// effect once the API route-map cache is rebuilt (deploy must clear cache).
|
||||
$routes->post('/trip/{slug}/publish', [EntryActions\EntryActionsApiController::class, 'setTripPublished']);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
@@ -38,10 +55,10 @@ form:
|
||||
help: 'Leave blank if trip is ongoing'
|
||||
|
||||
header.cover_image:
|
||||
type: text
|
||||
label: 'Cover Image Filename'
|
||||
placeholder: 'cover.jpg'
|
||||
help: 'Used in the trips listing page'
|
||||
type: pagemediaselect
|
||||
accept: ['.jpg', '.jpeg', '.png', '.webp', '.gif']
|
||||
label: 'Cover Image'
|
||||
help: 'Pick from images uploaded to this trip page. Shown on the trips listing and the trip-page banner. Falls back to the first journal entry photo if left unset.'
|
||||
|
||||
header.album_url:
|
||||
type: text
|
||||
@@ -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.
|
||||
|
||||
@@ -289,8 +289,8 @@ body::after {
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.09em;
|
||||
text-transform: uppercase;
|
||||
color: #E0A458; /* warm amber — draft/unpublished */
|
||||
border: 1px solid #E0A458;
|
||||
color: var(--color-draft-accent); /* warm amber — draft/unpublished */
|
||||
border: 1px solid var(--color-draft-accent);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 0.1em 0.5em;
|
||||
line-height: 1.5;
|
||||
@@ -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 {
|
||||
@@ -1047,6 +1100,66 @@ body::after {
|
||||
color: var(--color-ink-muted);
|
||||
}
|
||||
|
||||
/* ── Trip page header extras: one-liner, description, banner ──────────────────── */
|
||||
|
||||
.home-trip-tagline {
|
||||
font-size: var(--text-md);
|
||||
line-height: var(--leading-snug);
|
||||
color: var(--color-ink-2);
|
||||
margin: 0 0 var(--space-2);
|
||||
}
|
||||
|
||||
.trip-header-desc {
|
||||
margin: var(--space-3) 0 var(--space-4);
|
||||
}
|
||||
|
||||
.trip-header-desc-body {
|
||||
font-size: var(--text-sm);
|
||||
line-height: var(--leading-normal);
|
||||
color: var(--color-ink-2);
|
||||
}
|
||||
|
||||
.trip-header-desc-body > :first-child { margin-top: 0; }
|
||||
.trip-header-desc-body > :last-child { margin-bottom: 0; }
|
||||
.trip-header-desc-body p { margin: 0 0 var(--space-2); }
|
||||
|
||||
/* Collapsed preview ≈3 lines. Uses max-height (not -webkit-line-clamp) so it
|
||||
holds across the multiple <p> that markdown content renders. */
|
||||
.trip-header-desc[data-collapsed="true"] .trip-header-desc-body {
|
||||
max-height: 4.8em;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.trip-header-desc-toggle {
|
||||
display: inline-block;
|
||||
margin-top: var(--space-1);
|
||||
padding: 0;
|
||||
background: none;
|
||||
border: none;
|
||||
font: inherit;
|
||||
font-size: var(--text-sm);
|
||||
color: var(--color-accent);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.trip-header-desc-toggle:hover { color: var(--color-accent-hover); }
|
||||
|
||||
.trip-header-banner {
|
||||
width: 100%;
|
||||
height: 200px;
|
||||
margin: var(--space-4) 0 0;
|
||||
border-radius: var(--radius-md);
|
||||
overflow: hidden;
|
||||
background: var(--color-border);
|
||||
}
|
||||
|
||||
.trip-header-banner img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* ── Trip page filter bar ────────────────────────────────────────────────────── */
|
||||
|
||||
.feed-sort-bar {
|
||||
@@ -1142,6 +1255,7 @@ body::after {
|
||||
.home-map-col { position: static; height: 40vh; align-self: stretch; }
|
||||
.home-map { height: 40vh; }
|
||||
.home-feed-col { padding: var(--space-6) var(--space-5); }
|
||||
.trip-header-banner { height: 130px; }
|
||||
}
|
||||
|
||||
/* ── Past trips archive ──────────────────────────────────────────────────────── */
|
||||
@@ -1211,6 +1325,13 @@ body::after {
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
|
||||
.trip-card-tagline {
|
||||
font-size: var(--text-sm);
|
||||
line-height: var(--leading-snug);
|
||||
color: var(--color-ink-muted);
|
||||
margin: 0 0 var(--space-3);
|
||||
}
|
||||
|
||||
.trip-card-meta {
|
||||
display: flex;
|
||||
gap: var(--space-4);
|
||||
@@ -1221,6 +1342,143 @@ body::after {
|
||||
.trip-card-dates { font-size: var(--text-sm); color: var(--color-ink-2); }
|
||||
.trip-card-counts { font-size: var(--text-sm); color: var(--color-ink-muted); }
|
||||
|
||||
/* ── Owner publish/unpublish toggle (U3) ─────────────────────────────────────── */
|
||||
/* The card is wrapped in a position:relative container so this overlay can sit
|
||||
top-right over the cover as a sibling of the navigating <a> (KTD6). The wrapper
|
||||
also guarantees an anchor even for a coverless draft (a min-height header strip
|
||||
on the card itself), so the toggle never collapses to nothing. */
|
||||
.trip-card-wrap {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.trip-publish-overlay {
|
||||
position: absolute;
|
||||
top: var(--space-3);
|
||||
right: var(--space-3);
|
||||
z-index: 2; /* above the card <a> */
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
/* Solid pill so both indicators stay legible over an arbitrary cover photo. */
|
||||
.trip-draft-badge {
|
||||
font-family: var(--font-ui);
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.09em;
|
||||
text-transform: uppercase;
|
||||
color: var(--color-draft-accent); /* warm amber — matches .journal-draft-badge */
|
||||
background: var(--color-canvas);
|
||||
border: 1px solid var(--color-draft-accent);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 0.15em 0.5em;
|
||||
line-height: 1.5;
|
||||
white-space: nowrap;
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
.trip-draft-badge[hidden] { display: none; }
|
||||
|
||||
/* The switch: a solid chip backing keeps the track/knob readable on any cover.
|
||||
≥44px touch target via padding; the visible track is smaller and centred. */
|
||||
.trip-publish-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 44px;
|
||||
min-height: 44px;
|
||||
padding: 0 var(--space-2);
|
||||
margin: 0;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-full);
|
||||
background: var(--color-canvas);
|
||||
box-shadow: var(--shadow-sm);
|
||||
cursor: pointer;
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
}
|
||||
|
||||
.trip-publish-track {
|
||||
position: relative;
|
||||
display: block;
|
||||
width: 40px;
|
||||
height: 22px;
|
||||
border-radius: var(--radius-full);
|
||||
background: var(--color-ink-muted); /* off = muted */
|
||||
transition: background 0.15s ease;
|
||||
}
|
||||
.trip-publish-knob {
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
left: 2px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 50%;
|
||||
background: var(--color-ink);
|
||||
transition: transform 0.15s ease;
|
||||
}
|
||||
|
||||
/* on = teal track, knob slid right */
|
||||
.trip-publish-toggle[aria-checked="true"] .trip-publish-track {
|
||||
background: var(--color-accent);
|
||||
}
|
||||
.trip-publish-toggle[aria-checked="true"] .trip-publish-knob {
|
||||
transform: translateX(18px);
|
||||
background: var(--color-accent-on);
|
||||
}
|
||||
|
||||
/* Pending (R13): dimmed + wait cursor while a toggle is in flight. */
|
||||
.trip-publish-toggle[aria-busy="true"] {
|
||||
opacity: 0.55;
|
||||
cursor: wait;
|
||||
}
|
||||
|
||||
/* Keyboard focus ring that reads over a busy cover photo (white ring + dark halo). */
|
||||
.trip-publish-toggle:focus-visible {
|
||||
outline: 2px solid var(--color-accent-on);
|
||||
outline-offset: 2px;
|
||||
box-shadow: 0 0 0 4px rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
|
||||
/* Visible page-level failure toast (R15). Distinct from feed-actions.js's
|
||||
sr-only #feed-actions-live region: the trip card has no inline message slot,
|
||||
so a sighted owner needs a real, visible notice. trip-publish.js creates and
|
||||
populates the element; this only styles it. */
|
||||
.trip-publish-toast {
|
||||
position: fixed;
|
||||
top: var(--space-4);
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
max-width: calc(100vw - var(--space-8));
|
||||
padding: var(--space-3) var(--space-4);
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--text-sm);
|
||||
color: var(--color-ink);
|
||||
background: var(--color-canvas);
|
||||
border: 1px solid var(--color-error);
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
.trip-publish-toast[hidden] { display: none; }
|
||||
.trip-publish-toast__close {
|
||||
flex-shrink: 0;
|
||||
min-width: 32px;
|
||||
min-height: 32px;
|
||||
padding: 0;
|
||||
font-size: var(--text-md);
|
||||
line-height: 1;
|
||||
color: var(--color-ink-muted);
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
}
|
||||
.trip-publish-toast__close:hover { color: var(--color-ink); }
|
||||
|
||||
/* ── Trip page sidebar ───────────────────────────────────────────────────────── */
|
||||
|
||||
.trip-counts {
|
||||
@@ -1828,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; }
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
--color-surface-raised: #2A2720; /* elevated surfaces: tooltips, hover */
|
||||
--color-ink-inverse: #17171A; /* text on accent-coloured buttons */
|
||||
--color-error: #c0392b; /* validation errors, form error status */
|
||||
--color-draft-accent: #E0A458; /* warm amber — draft/unpublished badges */
|
||||
|
||||
/* ── Glass overlays (paper colour at opacity, for story components) ── */
|
||||
--color-paper-glass-low: color-mix(in srgb, var(--color-paper) 8%, transparent);
|
||||
|
||||
@@ -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) {
|
||||
@@ -259,15 +260,26 @@
|
||||
} else if (!trackpointsPerFile || trackpointsPerFile.length === 0) {
|
||||
connect = true; /* no GPX present → connect all */
|
||||
} else {
|
||||
var prev = entries[i - 1];
|
||||
var covered = false;
|
||||
var prev = entries[i - 1];
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||