Compare commits

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

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

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

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

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

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 23:07:03 +02:00
m038andClaude Opus 5 dd19995973 Merge feat/post-location-override into main
Post-form location override: search + map + drag pin (U1-U6), hardened by a
multi-agent code review — server-side cleanCoordinate() guard, strict
coordinate parsing, three closed gate-bypass paths, pin removal on blanked
fields, visible geocode failures.

Also carries maplibre's stylesheet moved to a lazy <link> at panel-open
(post-form.css 92,244 -> 26,784 raw; 14,528 -> 5,631 gzip), the test-entry
leak fix into real trip content, the GPX leg-connection fix, and the U+200E
coordinate strip.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Built entirely in JS (mirrors the existing 'More options' disclosure
pattern) so post-form.html.twig needs no template change.
2026-07-24 19:38:24 +02:00
m038 52e9fbadf2 refactor(map): extract MAP_STYLE into a shared map-style.js module
Single source of truth for the MapLibre style URL, shared between
maplibre-utils.js (multi-marker/GPX maps) and the new location-map.js
preview module — no behavior change.
2026-07-24 19:38:10 +02:00
m038 a00690fbde feat(post): unhide lat/lng fields, style the location-details panel
Removes the CSS rule hiding data[lat]/data[lng] and adds styling for the
new 'More location details' disclosure, search results list, map preview
container, and mismatch-flag state.
2026-07-24 19:38:05 +02:00
m038 4721af6452 (Grav GitSync) Automatic Commit from into-the-east-grav-sync 2026-07-19 09:58:44 +00:00
m038 1f1001df07 (Grav GitSync) Automatic Commit from into-the-east-grav-sync 2026-07-19 09:18:50 +00:00
m038 bf34d698aa (Grav GitSync) Automatic Commit from into-the-east-grav-sync 2026-07-17 13:28:37 +00:00
m038 3b89e207ef (Grav GitSync) Automatic Commit from into-the-east-grav-sync 2026-07-17 09:58:13 +00:00
m038 723b77ae0d (Grav GitSync) Automatic Commit from into-the-east-grav-sync 2026-07-15 19:39:04 +00:00
m038 c6402aa320 (Grav GitSync) Automatic Commit from into-the-east-grav-sync 2026-07-15 06:44:13 +00:00
m038 55bc890c32 (Grav GitSync) Automatic Commit from into-the-east-grav-sync 2026-07-15 06:41:49 +00:00
m038 1f638897e6 (Grav GitSync) Automatic Commit from into-the-east-grav-sync 2026-07-12 18:58:46 +00:00
m038 5727766ba8 (Grav GitSync) Automatic Commit from into-the-east-grav-sync 2026-07-11 18:32:40 +00:00
m038 b412a6e782 (Grav GitSync) Automatic Commit from into-the-east-grav-sync 2026-07-11 06:21:43 +00:00
m038 01487e3672 (Grav GitSync) Automatic Commit from into-the-east-grav-sync 2026-07-11 06:21:24 +00:00
m038 93f0a03b84 (Grav GitSync) Automatic Commit from into-the-east-grav-sync 2026-07-11 05:01:14 +00:00
m038 005dc4a157 (Grav GitSync) Automatic Commit from into-the-east-grav-sync 2026-07-10 16:47:08 +00:00
m038 c407ea010f (Grav GitSync) Automatic Commit from into-the-east-grav-sync 2026-07-09 18:44:20 +00:00
m038 894593d74f Merge branch 'main' of https://git.gorinskat.nl/m038/intotheeast-com-content 2026-07-09 18:20:27 +00:00
m038 259130cd3c (Grav GitSync) Automatic Commit from into-the-east-grav-sync 2026-07-09 18:20:26 +00:00
m038andClaude Fable 5 790c3deb26 fix(theme): hide the map fullscreen button when the map never initialises
The entry-map partial always rendered .feed-map-fullscreen-btn, but the
MapLibre init AND the button's click wiring only run when entries exist —
so on the home pre-departure state (active trip, no published posts) mobile
showed a dead button floating over an empty 40vh map area. Gate the button
on the same entries|length condition as the init script; it reappears with
the first published post, exactly when it becomes functional. Covers any
zero-entry trip page too (shared partial).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0195b3cDdMeize2Mm1FgC2aU
2026-07-09 20:20:20 +02:00
m038 36211a96ef Merge branch 'main' of ssh://m038-nas.tail63ee39.ts.net:222/m038/intotheeast-com-content 2026-07-09 17:56:42 +02:00
m038andClaude Fable 5 e17a5dc972 fix(theme): block submit on unfinished photo uploads; un-squeeze EXIF portraits in lightbox
Two prod bugs from the 2026-07-09 owner test:

- post-form.js: complete upload gate on create submit. The form plugin's
  guard only blocks PROCESSING/QUEUED, so a failed upload (processing-error)
  or a just-picked file (loading) submitted silently and the entry saved
  without its photo. Submit is now blocked unless every FilePond item is
  processing-complete, with a visible status message for the failed vs
  still-uploading cases. (Bundle rebuilt via make build-assets.)

- entry-journal partial: PhotoSwipe slides now link to a 2000px fit-within
  derivative and measure THAT file for data-pswp-width/height. The old
  img.width/height came from raw getimagesize() of the original, which
  ignores EXIF orientation, so stored-rotated portrait JPEGs got landscape
  slide boxes and rendered squeezed. Derivatives are re-encoded (EXIF
  stripped, orientation baked in server-side), so declared dims always match
  rendering. Also fixes the wrapper aspect-ratio pick for portrait-first
  entries. Note: Medium 'path' must be called as path() in Twig — the
  ArrayAccess 'path' item (page folder) shadows the method.

Covered by tests/ui/post/upload-gate.spec.js and lightbox-dims.spec.js in
the dev repo.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0195b3cDdMeize2Mm1FgC2aU
2026-07-09 17:56:01 +02:00
m038 f72ca2f024 (Grav GitSync) Automatic Commit from into-the-east-grav-sync 2026-07-09 14:56:43 +00:00
m038 2112df3b7f (Grav GitSync) Automatic Commit from into-the-east-grav-sync 2026-07-09 14:56:35 +00:00
m038 945732aade (Grav GitSync) Automatic Commit from into-the-east-grav-sync 2026-07-09 14:56:26 +00:00
m038 5ebf925ca7 (Grav GitSync) Automatic Commit from into-the-east-grav-sync 2026-07-09 14:56:20 +00:00
m038 a6749547bd (Grav GitSync) Automatic Commit from into-the-east-grav-sync 2026-07-09 14:56:04 +00:00
m038 35d82b0484 (Grav GitSync) Automatic Commit from into-the-east-grav-sync 2026-07-09 14:55:57 +00:00
m038 d4b5050616 (Grav GitSync) Automatic Commit from into-the-east-grav-sync 2026-07-09 14:55:49 +00:00
m038 e02c10e67a (Grav GitSync) Automatic Commit from into-the-east-grav-sync 2026-07-09 14:55:29 +00:00
m038andClaude Opus 4.8 874645db32 fix(theme): dark-theme login box, feedback messages, and nav logout link
- Override the Login plugin's white #grav-login box to use dark theme tokens
- Style flash/form notices (.notices, #messages .toast) globally on-theme;
  override markdown-notices' bright per-colour variant backgrounds so error/
  success/warning messages stay dark with a coloured left border
- Add a Logout link to the main nav (authenticated-only, plain nav style);
  no login link in the menu

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rw9Tceo6DSzVqWKkjVJpHZ
2026-07-08 23:56:04 +02:00
m038andClaude Opus 4.8 02fa4e94a7 content(theme): rename nav/heading/CTA 'Past Trips' to 'Trips'
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 23:29:05 +02:00
m038andClaude Opus 4.8 df0e00937e content(trip): add taglines for all trips; refine Denmark tagline
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 23:22:11 +02:00
m038 fae364585c Merge branch 'main' of https://git.gorinskat.nl/m038/intotheeast-com-content 2026-07-08 21:18:45 +00:00
m038 3cb50a298a (Grav GitSync) Automatic Commit from into-the-east-grav-sync 2026-07-08 21:18:44 +00:00
m038andClaude Opus 4.8 125f9e6131 content(trip): add Denmark 2026 description
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 23:18:32 +02:00
m038 4a40dc9c18 (Grav GitSync) Automatic Commit from into-the-east-grav-sync 2026-07-08 20:55:32 +00:00
m038andClaude Opus 4.8 f4ab73070b content: feature 3 entries as homepage highlights; publish Denmark 2026
- featured: true on Central Asia 2023, Italy 2025, and US/Canada/Mexico 2024 entries
  (surface in the between-trips homepage highlights grid)
- Denmark 2026 trip: published: true + Vestkystruten GPX track

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Mpdu3Dt1iVoozHwAMyjrbn
2026-07-08 22:28:18 +02:00
m038 543e8e3dce Merge feat/journal-post-form: trip publish/unpublish toggle + review fixes
Owner publish/unpublish toggle on the /trips listing (owner-gated API endpoint
mutating trip.md published state with APCu-aware cache invalidation, owner-
visible drafts, home fallback), plus the code-review follow-ups: shared scope-
guard helper, guarded cache-flush, shared api-utils (apiSend/apiErrorMsg with
opt-in timeout), template-asserted publish target, active-trip one-liner, and
the --color-draft-accent token.

Merges cleanly with the denmark-2026 cover content on main (disjoint files).
2026-07-08 17:47:27 +02:00
m038andClaude Opus 4.8 37b669424b fix(trips): remaining publish-toggle review findings
- resolveTripChild now asserts the resolved page uses the trip template, so a
  non-trip direct child of /trips could never be toggled through this endpoint
  (P3 adversarial).
- apiSend gains an optional timeoutMs (AbortController); trip-publish passes 10s
  so a hung toggle can't leave the switch stuck aria-busy. post-form omits it,
  keeping media uploads unbounded (P2 reliability).
- trips.html.twig reuses trip.html.twig's one-line active-trip slug match
  instead of a bespoke 3-branch OR (P2 maintainability).
- Draft-badge amber is now a --color-draft-accent token shared by the trip and
  journal badges instead of a twice-hardcoded #E0A458 literal (P3).

Rebuilt js/trip-publish.js and js/post/post-form.js (shared api-utils change).
PHP lint clean; trip-publish suite 10/10; post suite unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Mpdu3Dt1iVoozHwAMyjrbn
2026-07-08 17:31:40 +02:00
m038andClaude Opus 4.8 25cee53718 refactor(theme): share apiSend/apiErrorMsg between post-form and trip-publish
Address the P1 maintainability finding that trip-publish.js reimplemented
post-form.js's apiSend + login-expired error copy verbatim. Extract both into
js/src/api-utils.js and import from both entry points; esbuild inlines the
module into each bundle so there is no runtime coupling. Also drops the stale
data-trip-route reference from trip-publish.js's markup-contract comment.

Rebuilt js/trip-publish.js and js/post/post-form.js.

Verified: trip-publish suite 8/8; post suite unchanged (34 pass, same 6
owner-gate environmental fails as baseline — photo-editor specs that exercise
post-form's apiSend/apiErrorMsg all green).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Mpdu3Dt1iVoozHwAMyjrbn
2026-07-08 17:14:00 +02:00
m038andClaude Opus 4.8 5b4e31678e refactor(entry-actions): dedup scope guard + guard publish cache-flush
Address code-review findings on the trip publish/unpublish toggle:

- Extract EntryScopeGuard::resolveChildOf() so resolveActiveDailyChild and
  resolveTripChild share one find() + parent-route-assert body instead of two
  copies that could drift (P1 maintainability).
- Wrap setTripPublished's post-save cache invalidation in try/catch. save() has
  already persisted the published flag to disk, so a flush failure now logs a
  loud reconciliation warning (and still returns success + the audit line)
  rather than bubbling to a bare 500 that reads as "nothing happened"
  (P2 reliability / adversarial).

Behavior-preserving; PHP lint clean; trip-publish Playwright suite 8/8.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Mpdu3Dt1iVoozHwAMyjrbn
2026-07-08 17:06:17 +02:00
m038andClaude Opus 4.8 064f0f0c52 feat(trips): owner publish/unpublish toggle on /trips listing
Add an owner-only publish switch to each /trips card. It POSTs to a new
entry-actions route that mutates trip.md `published` and invalidates the
page-tree cache so the listing, nav and home reflect the change on the
next load. Owner sees drafts (Draft badge); anon/non-owner unchanged.

- U1 EntryScopeGuard::resolveTripChild — resolve a slug to a direct child
  of /trips (drafts included, for republish)
- U2 POST /api/v1/trip/{slug}/publish (setTripPublished) — owner-gated
  write, strict is_bool body, header-mutation save, audit log
- U3 trip-publish-toggle partial + CSS (role=switch, Draft badge, visible
  failure toast, ≥44px target)
- U4 owner-aware /trips listing + card restructure (toggle overlays cover
  as a non-anchor sibling; works for coverless drafts)
- U5 home active-trip branch falls back when the active trip is unpublished
- U6 trip-publish.js (confirm/pending/optimistic/revert) + esbuild wiring

Cache note: an in-place frontmatter edit keeps the folder-check cache id,
and driver:auto uses APCu in web memory, so deleteAll()+invalidateCache()
is insufficient — the endpoint also calls apcu_clear_cache().

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Mpdu3Dt1iVoozHwAMyjrbn
2026-07-08 14:50:05 +02:00
m038andClaude Opus 4.8 4cc0a18aaf content(denmark-2026): add trip cover image
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RDS6t8wcpbwKvvrxykVQ5K
2026-07-08 12:59:32 +02:00
m038 087e05affa (Grav GitSync) Automatic Commit from into-the-east-grav-sync 2026-07-08 10:51:37 +00:00
m038andClaude Opus 4.8 55da834396 feat(gpx-manager): list unpublished trips too, newest-first
Owner-only page, so drop the .published() filter and order by header.date_start
desc so the current/next trip is on top.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Mpdu3Dt1iVoozHwAMyjrbn
2026-07-08 10:29:05 +02:00
m038andClaude Opus 4.8 c46bbd952f fix(entry-actions): invalidate page-tree index on delete
deleteEntry did cache.deleteAll() but not Cache::invalidateCache(), so under
cache.check.method: folder the deleted entry lingered in the pages index and the
feed re-rendered it (image-less) on reload. Mirror the create-path fix. See
docs/solutions/integration-issues/grav-deleteall-doesnt-invalidate-page-tree-index.md

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Mpdu3Dt1iVoozHwAMyjrbn
2026-07-08 10:29:05 +02:00
m038 1dc9c9854c Merge commit 'b3b4f77'
# Conflicts:
#	themes/intotheeast/templates/partials/trip-feed-col.html.twig
#	themes/intotheeast/templates/trip.html.twig
2026-07-08 00:08:37 +02:00
m038andClaude Opus 4.8 d4fcbecaf1 content(italy-2025): rename to Tuscany Gravel 2025, fix dates + stories template
Trip title Cycling Tuscany 2025 -> Tuscany Gravel 2025, dates Oct->Sep,
and stories container uses the stories template (was default).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Mpdu3Dt1iVoozHwAMyjrbn
2026-07-08 00:03:58 +02:00
m038andClaude Opus 4.8 b3b4f774d0 feat(nav): add New Post + GPX Manager links for logged-in users
Both links render only when grav.user.authenticated; anonymous nav
(Home, Past Trips) is unchanged. Active-page highlight via aria-current.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Mpdu3Dt1iVoozHwAMyjrbn
2026-07-07 23:44:20 +02:00
m038andClaude Opus 4.8 034db29e66 fix(post-form): commit photo thumbnail grid + overlay styling
Renders FilePond items as a 3-up square-thumbnail grid on create+edit,
disables native image-drag so FilePond owns reorder, and hides the
file-info overlay on already-uploaded (idle) items. This is the styling
QA'd and approved on the worktree dev server; it was previously left
uncommitted (mis-tagged as unrelated WIP in earlier handovers).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Mpdu3Dt1iVoozHwAMyjrbn
2026-07-07 23:44:20 +02:00
m038andClaude Opus 4.8 536ca2085f fix(trip-cover): crop-to-fill cover strip + all-or-nothing retina
Banners/cards used cropResize (fit-inside), so a portrait fallback
source was handed back as a narrow sliver that object-fit:cover then
upscaled into a blur (reported on us-canada-mex-2024). Switch to
cropZoom (crop-to-fill) so the derivative is a real w×h cover strip.

Emit the 2x srcset descriptor only when the source is genuinely >=2w
wide (cover.width >= 2w), else 1x-only — no upscaling, no odd
intermediate widths. Imported pixelfed photos cap at ~1440px wide, so
auto-picked covers are usually 1x-only (see backlog: full-res reimport).

Also drop the no-photos-demo test fixture — it surfaced as stray demo
content in the trip list; AE4 (no cover + no images -> no banner) is a
trivial else-branch of the shared macro's cover guard, covered by
construction alongside the R7/AE3 fallback tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RDS6t8wcpbwKvvrxykVQ5K
2026-07-07 23:39:04 +02:00
m038andClaude Opus 4.8 21f0c8fdd5 test(fixture): add no-photos-demo trip for AE4 coverage
A minimal demo trip with no cover_image and a single published journal entry
that has no images — the fixture the trip-page banner AE4 assertion needs
(macro fallback loop finds an entry but no image -> null cover -> no banner
strip). Lives under docs/demo/trips/ like italy-2026-demo, so it is loaded into
the pages tree only for tests and never reaches live content.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RDS6t8wcpbwKvvrxykVQ5K
2026-07-07 09:13:48 +02:00
m038andClaude Opus 4.8 ac88e8c003 fix(trip-cover): restrict cover picker to images + guard non-image selection
The header.cover_image pagemediaselect field had no accept filter, so the
Admin media picker listed every file in the trip page folder — including the
GPX files placed there by the GPX manager. On a typical trip page (photos live
on the journal entries, not the trip page) the picker offered *only* GPX, and
selecting one routed a non-image Medium into cropResize, rendering a broken
<img> on both the trips list and the trip banner.

- Blueprint: add `accept: ['.jpg','.jpeg','.png','.webp','.gif']` so the picker
  only offers images (prevention at source).
- Macro: resolve cover_image against `media.images` instead of all media, so a
  non-image or unresolvable selection falls through to the entry-photo
  auto-pick (defence-in-depth; also hardens the R11 fallback).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RDS6t8wcpbwKvvrxykVQ5K
2026-07-07 08:35:32 +02:00
m038andClaude Opus 4.8 7f6bf9e3f3 refactor(post-form): derive disclosure default from rendered markup
initDisclosure hardcoded blueprint defaults into a field-name regex
(/\[published\]$/) to decide which toggle state counts as a deviation worth
auto-expanding "More options". Read each toggle's default from the HTML
`checked` attribute instead — Grav's toggle template stamps it on the default
option, and prefill/edit only ever set the live `.checked` property — so a
future default-ON advanced toggle Just Works. Rebuilt bundle.

Code review F4 (maintainability).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Mpdu3Dt1iVoozHwAMyjrbn
2026-07-07 08:31:59 +02:00
m038andClaude Opus 4.8 8db3ffeafc fix(cache-on-save): latch page-cache invalidation to once per submit
onFormProcessed fires once per process action (add_page/upload/message/
reset), so the deleteAll() + Cache::invalidateCache() pair ran 4x per post.
Gate it behind a $cacheInvalidated latch (same pattern as $photosReconciled)
so the store wipe + system.yaml touch happen exactly once, and log the step.

Code review F1 (perf) + F7 (observability).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Mpdu3Dt1iVoozHwAMyjrbn
2026-07-07 08:31:50 +02:00
m038andClaude Opus 4.8 13656e311a test(demo): give demo trip a tagline + description fixture
The italy-2026-demo trip is the UI-test fixture; add a tagline and a
multi-paragraph markdown description so the trip one-liner, expandable
description, and banner specs run against real content.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RDS6t8wcpbwKvvrxykVQ5K
2026-07-06 00:02:25 +02:00
m038andClaude Opus 4.8 ad72739b99 feat(trip): one-liner, description & retina cover on trip list + page
Give trips an optional one-liner (header.tagline) and description (markdown
content), surface them where they help, and fix the soft cover image — all
editable from admin.

- U1: header.cover_image blueprint field → pagemediaselect media picker.
- U2: new macros/cover.html.twig — single source for cover resolution
  (author-selected → first journal image → none; missing file falls back)
  and retina rendering (1x/2x cropResize + srcset). Merged resolve+render
  into one macro since Twig macros can't return a Medium object.
- U3: trip-list card renders the one-liner (when set) and the retina cover.
- U4: trip-page in-column header gains the one-liner, an expandable
  description, and a thin banner strip — gated behind a trip_header_extras
  partial flag (default off) so the shared home active-trip view is
  unchanged (R12/KTD4).
- U5: styles for the card/header one-liner, collapsible description
  (max-height preview, not line-clamp, so it holds across paragraphs) and
  the banner, with a mobile banner-height reduction.

Covers R1–R15. Verified with new Playwright specs + full trip/home/maps
regression against an isolated worktree dev server.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RDS6t8wcpbwKvvrxykVQ5K
2026-07-06 00:02:19 +02:00
m038andClaude Opus 4.8 a7bda6ed39 fix(post-form): only auto-expand More options when a field deviates from default
initDisclosure auto-opened the More-options panel whenever any advanced field
'had a value', but the published toggle defaults ON, so a plain create form
tripped it every load. A toggle now counts only when it deviates from its
blueprint default (published: OFF is notable; force_connect/featured: ON is),
so the panel stays collapsed on create. Edit mode still force-opens it
separately. Rebuilt bundle via make build-assets.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Mpdu3Dt1iVoozHwAMyjrbn
2026-07-05 23:42:31 +02:00
m038andClaude Opus 4.8 7775a4ed6d fix(cache-on-save): invalidate page index on save so new entries are editable
deleteAll() only clears the Doctrine store; the page-tree index is keyed on
md5(dirs + folderHash + config->checksum() + lang) (Pages::buildRegularPages).
With cache.check.method:folder a freshly-created entry could survive in that
index and stay invisible to GET /api/v1/pages{route} (404), so opening a
just-posted entry for editing showed "this entry no longer exists" ~2/3 of the
time. Add Cache::invalidateCache() (touches system.yaml -> bumps config
checksum) so the index key changes and the tree rebuilds next request. Chosen
over clearCache('standard'), which would nuke compiled Twig + assets on every
post. Fixes the create->edit round-trip (Playwright ES1) and the DEL1 flake.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Mpdu3Dt1iVoozHwAMyjrbn
2026-07-05 23:42:11 +02:00
m038andClaude Opus 4.8 7ffd75e8ec fix(review): surface auth-expiry, harden add-batch rollback, add audit log
Follow-up to the ce-code-review deferred items on the photo editor:

- Photo editor fetches now REJECT with a status-bearing error (apiSend)
  instead of the boolean apiOk that swallowed the HTTP code. Reorder,
  delete and add paths tell a lapsed login (401/403) apart from a generic
  failure and prompt the owner to sign in again rather than "try again".
- Add-batch rollback: the per-file cleanup DELETEs no longer swallow
  individual failures. If any rollback DELETE doesn't land (a stray
  stock-named file could steal the lexicographic cover slot), the owner is
  told cleanup was incomplete and to reload — instead of a false "rolled
  back cleanly".
- entry-actions delete + reorder routes now emit an owner-attributed audit
  log line, so a destructive mutation is traceable.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 20:43:41 +02:00
m038andClaude Opus 4.8 361a6b4d67 fix(review): harden photo reorder against data loss + failure-path drift
Addresses ce-code-review findings on the photo-editor media-API work:

- P0 (#1): PhotoRenumberer now renumbers EVERY on-disk image, using the
  client manifest only as preferred ORDER and appending any omitted image
  at the end. A stale/incomplete `order` (e.g. a second browser tab)
  previously left an unlisted photo at a target slot for phase-2's
  rename() to silently overwrite — verified data loss, now impossible.
  The reorder route inherits the guard; create/reconcile is unchanged.
- P2 (#3): unique per-call token in the .reorder-tmp-* name so two
  concurrent renumbers on one folder can't collide and clobber bytes.
- P3 (#7): de-duplicate the manifest so a repeated name can't shift/drop
  a photo.
- P2 (#2): applyReorder + doDelete split the two failure stages — a failed
  refresh AFTER a committed reorder/delete no longer reverts to a stale or
  ghost state, it reconciles to disk. A DELETE 404 is treated as success
  so a retried ghost cell converges.
- P2 (#4): both custom routes call requirePermission('api.pages.write')
  so the GHSA-x7hm API-key scope cap applies (owner already holds it, so
  the owner-only behaviour is unchanged).
- P3 (#8): refresh stale comments (photo-01..NN; drop editLoadPhotos ref).

PhotoRenumberer's 7-case unit suite still passes and the data-loss repro
now preserves all bytes. Assets rebuilt.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 20:14:41 +02:00
m038 b317640dd0 (Grav GitSync) Automatic Commit from into-the-east-grav-sync 2026-07-05 18:12:16 +00:00
m038 9bcb0fe4db Back to told name 2026-07-05 20:08:35 +02:00
m038 b5f169f25f (Grav GitSync) Automatic Commit from into-the-east-grav-sync 2026-07-05 18:04:56 +00:00
m038andClaude Opus 4.8 a4432d897e feat(post-form): live photo editor on entry edit (media API + SortableJS)
Replace the FilePond photo path in edit mode with our own thumbnail grid that
talks straight to the media API (the gpx-manager pattern). Add/delete/reorder
each persist immediately, decoupled from the form's text-field Save:

- Add: HEIC->JPEG client-side, stock POST .../media per file, then ONE reorder
  after the batch (renumber photo-01..NN). On a failed reorder: auto-retry
  (idempotent), else roll the just-uploaded files back so no orphan stock-named
  image breaks cover=first. Upload progress shown per file.
- Delete: inline 'Delete? [Confirm] [Cancel]' (Confirm disabled in flight),
  stock DELETE, then renumber the survivors.
- Reorder: SortableJS drag -> POST /entry/<slug>/photos/order. On failure the
  move reverts to last-known-good; the shown grid never disagrees with disk
  without an inline error.
- Loading + empty states; first cell badged Cover; photo-NN URLs cache-busted
  since reorder reuses them for different bytes.

FilePond is fully decommissioned in edit mode (initPhotoConversion early-returns
under EDIT_MODE): no stale photo_order manifest is posted on text Save, so
cache-on-save can't delete a live-added photo. Create-mode FilePond is untouched.

Adds sortablejs (bundled into js/post via the post-form entry). SVG excluded in
the file-input accept; the server-side SVG block is a documented fast-follow.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 19:19:57 +02:00
m038andClaude Opus 4.8 4fea522d5c feat(entry-actions): add owner-scoped photo reorder route
POST /api/v1/entry/{slug}/photos/order renames an entry's image files to
photo-01..NN in the client-supplied order so the feed cover (media.images|first)
follows the drag — no stock endpoint can express this. Same R6 guard chain as the
delete route (site OWNER + direct child of the active trip's dailies), then the
shared PhotoRenumberer does the two-phase rename and the cache is cleared.

Filename safety is layered: unsafe 'order' entries (/, ..) are dropped here and
PhotoRenumberer only renames real image files, so a crafted body can never touch
the entry .md, a .gpx or a .meta.yaml. Registers behind the API route-map cache,
so a deploy cache-clear is required (same as the existing DELETE /entry/{slug}).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 18:58:43 +02:00
m038andClaude Opus 4.8 fcf52a0e44 refactor(photos): extract shared zero-padded PhotoRenumberer helper
Factor cache-on-save's renumberPhotos into a shared PhotoRenumberer class
(Grav\Plugin\Shared), the single owner of the photo-NN naming invariant used
by both the create/edit reconcile and the upcoming live reorder route, so their
numbering can't diverge.

Changes vs the old private method:
- Zero-pads to photo-01..NN (pad width grows with the set) so lexicographic
  media order equals numeric order past 9 photos — cover = images|first stays
  correct for 10+ photos. Normalises pre-existing un-padded photo-N on first pass.
- Image-extension guard moved into the helper: only real image files on disk are
  renamed, so a crafted manifest naming the entry .md, a .gpx or a .meta.yaml is
  skipped by every caller, not just cache-on-save.

Create-mode entries now also emit photo-01..NN — an intentional, accepted side
effect of sharing one helper.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 18:56:59 +02:00
m038andClaude Opus 4.8 32c3d8c065 content: add tagline to Denmark 2026 trip
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 16:52:15 +02:00
m038andClaude Opus 4.8 05db592836 fix(cache): revalidate pages/media instead of 7-day browser cache
system.pages.expires was 604800 (Grav default), so browsers cached feed HTML and
entry media for a week with no revalidation — deleted entries lingered as ghost
cards (images 404), and edited/new photos didn't appear until a hard refresh. Set
expires: 0 → Cache-Control: no-cache, must-revalidate. With etag already on, an
unchanged page returns a cheap 304; changed content shows immediately. Also fixes
reorder staleness (photo-N.jpg is reused across reorders at the same URL). The
server-side page cache (cache.enabled) is untouched.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 14:55:05 +02:00
m038andClaude Opus 4.8 ff31683075 fix(post-form): render photo thumbnails on edit + clearer load errors
QA feedback fixes for the front-end journal edit form:

- Photos on edit showed a filename with no thumbnail. addFile(url,{type:'local'})
  routed through the form plugin's FilePond server.load, which returned HTML (not
  the image bytes), so image-preview had nothing to render. Fetch each image as a
  Blob and add it as a File (ordered) — the thumbnail renders, and type:'local'
  still means it is never re-uploaded and its filename rides the photo_order
  manifest. Verified: fileType image/jpeg, previews render, reorder/remove unchanged.

- Distinguish a deleted/missing entry (API 404 → "this entry no longer exists")
  from a transient load failure ("check your connection") in the prefill catch.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 14:55:05 +02:00
m038andClaude Opus 4.8 a9ec49e164 content: scaffold Denmark 2026 trip (Vestkustryten, prep)
New trip page tree at /trips/denmark-2026 — trip.md (10-20 Jul 2026)
plus inert dailies/stories containers. Not set active; prep only.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 14:50:14 +02:00
m038andClaude Opus 4.8 03323bcadf feat(post-form): enable adding photos when editing an entry (M2/R9)
Re-enable the FilePond browse/drop affordance in edit mode. On submit, existing
locals + new uploads ride the photo_order manifest and cache-on-save reconciles
the folder (delete dropped, renumber survivors photo-1..N, first = cover), so an
entry's photos can now be added, removed and reordered from the front-end edit
form. Verified end-to-end: add, remove, reorder, and all three in one save, plus
create-with-photos and edit remove/reorder regressions.

Depends on a local fix to add-page-by-form (its Grav-2.0 edit-mode header cast
fatals on a new upload); that plugin is git-ignored, so the fix ships as a
tracked patch in the superproject (deploy/patches/) rather than here.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 13:42:55 +02:00
m038 cb759746d0 Merge remote-tracking branch 'origin/main' into feat/journal-post-form 2026-07-05 11:32:23 +02:00
m038andClaude Opus 4.8 525b684493 feat(post-form): M2 photo edit — load, remove & reorder from the edit form
Editing an entry now loads its existing photos into FilePond so the owner can
remove and reorder them; the first photo is the cover. Adding NEW photos on edit
is intentionally suppressed (see below).

post-form.js (U7):
- On ?edit=, load the entry's current images into FilePond as LOCAL items (via
  the session media API, gpx-manager pattern). They display for remove/reorder
  and ride the existing photo_order manifest on submit, but are never re-uploaded.
- Exclude the FilePond field from the D1 prefill disable-sweep — FilePond reads
  its input's disabled state at init and never re-enables, which had removed its
  controls in edit mode.
- Suppress the add affordance in edit mode (allowBrowse/allowDrop off): a new
  upload on edit hits add-page-by-form's Grav-2.0 edit-merge fatal
  ((array)$page->header() yields mangled protected keys → array_merge(null,…)).
  That plugin is stock/GPM/git-ignored (no fork), so adding photos on edit is
  deferred to the form-to-page/image-upload rework.

cache-on-save.php (U8):
- reconcilePhotos(): on edit, resolve the entry folder via the shared scope guard
  (not the fuzzy create-path finder), delete any image dropped from the manifest,
  then renumber survivors photo-1..N in the submitted order (cover = first).
- Run reconciliation ONCE per submit: onFormProcessed fires per process action
  (4×); a 2nd pass deleted the just-renamed photo-N files as "unlisted".
- Empty manifest reconciles nothing (fail-safe: never wipes photos on a missing
  photo_order).

Verified on the container: existing photos load (V9); remove + reorder persist to
disk with cover=first (V10); reconcile helpers covered by a reflection unit test.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 11:25:01 +02:00
m038andClaude Opus 4.8 f8f03faa1d chore: gitignore stray tester.yaml test account
Prevents git add -A from sweeping a local browser-test account (with its
password hash) into the content repo, matching the existing testrunner.yaml
ignore.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H1FrCYNq6RXdGYbn5PFrhM
2026-07-05 01:11:53 +02:00
m038andClaude Opus 4.8 6e8eadbab5 security: stop tracking the per-host env/ secret tree
git-sync auto-commit stages everything not gitignored (git add -A over
user/), NOT just the pages/config/themes folders. Because /env/ was not
gitignored, prod's git-sync pushed the per-host env tree — including
api-private.php (JWT), security-private.php (CSRF salt), and the
git-sync token — to Gitea in commit 9337003. Gitignore /env/ and untrack
it so it can never round-trip again. (Working-tree files kept via
--cached; prod still needs them to run.) Leaked secrets rotated
separately; history purge TBD.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Np4cMQLF77i664CAQXySzU
2026-07-05 00:52:42 +02:00
m038andClaude Opus 4.8 ca15bd8bd7 merge: integrate prod git-sync auto-commit 9337003
Brings prod's merged post-form.md and account timestamp. The leaked
env/ secret tree it also carried is removed + gitignored in the
follow-up commit.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Np4cMQLF77i664CAQXySzU
2026-07-05 00:52:10 +02:00
m038andClaude Opus 4.8 1418dd275b config(api): exclude own IP from popularity page-view tracking
Adds 83.135.64.30 to popularity.exclude_ips so local/manual testing
against prod doesn't inflate real-visitor page-view stats. IP is
dynamic — a stopgap; smoke tests should target the test instance.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Np4cMQLF77i664CAQXySzU
2026-07-05 00:44:33 +02:00
121 changed files with 501073 additions and 378 deletions
+5
View File
@@ -5,6 +5,7 @@
!/plugins/entry-actions/ !/plugins/entry-actions/
/data/ /data/
/accounts/testrunner.yaml /accounts/testrunner.yaml
/accounts/tester.yaml
/pages/01.trips/italy-2026-demo/ /pages/01.trips/italy-2026-demo/
/pages/02.post/*ui-test*/ /pages/02.post/*ui-test*/
/config/plugins/git-sync.yaml /config/plugins/git-sync.yaml
@@ -12,4 +13,8 @@
/config/security.yaml /config/security.yaml
/config/security-private.php /config/security-private.php
/config/versions.yaml /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/ /themes/intotheeast/node_modules/
+3 -1
View File
@@ -49,7 +49,8 @@ invitations:
popularity: popularity:
enabled: true enabled: true
exclude_admin: true exclude_admin: true
exclude_ips: { } exclude_ips:
- 83.135.64.30
history: history:
daily: 30 daily: 30
monthly: 12 monthly: 12
@@ -57,3 +58,4 @@ popularity:
ignore: ignore:
- '/test*' - '/test*'
- /modular - /modular
salt: 095e9f1559db5a7e97e73fc3c394412b3627ea553ddbb60e80a88191acbec9c8
+1 -5
View File
@@ -5,10 +5,6 @@ author:
metadata: metadata:
description: 'Into the East — travel journal' description: 'Into the East — travel journal'
description: 'A travel blog by Mischa' description: 'A travel blog by Mischa'
active_trip: /trips/us-canada-mex-2024 active_trip: /trips/denmark-2026
travelling: false 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 owner_username: mischa
+7 -1
View File
@@ -76,7 +76,13 @@ pages:
- rss - rss
- atom - atom
append_url_extension: null 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 cache_control: null
last_modified: false last_modified: false
etag: true etag: true
+1
View File
@@ -5,4 +5,5 @@ date: '2025-09-01'
date_start: '2025-09-01' date_start: '2025-09-01'
date_end: '2025-09-08' date_end: '2025-09-08'
cover_image: '' cover_image: ''
tagline: '600 km of Tuscan gravel — hill towns, aperitivi, and relentless climbing'
--- ---
+10
View File
@@ -5,4 +5,14 @@ date: '2026-09-01'
date_start: '2026-09-01' date_start: '2026-09-01'
date_end: '2026-09-08' date_end: '2026-09-08'
cover_image: '' 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.
-7
View File
@@ -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';
-33
View File
@@ -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: ''
-7
View File
@@ -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';
-38
View File
@@ -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 template: entry
transport_mode: car transport_mode: car
published: true published: true
hero_image: 'photo-1.jpg' hero_image: photo-1.jpg
lat: '37.755579' lat: '37.755579'
lng: '73.271513' lng: '73.271513'
location_city: 'Alichur' location_city: Alichur
location_country: 'Tajikistan' location_country: Tajikistan
weather_temp_c: '15' 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. 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.
+1
View File
@@ -5,4 +5,5 @@ date: '2023-08-28'
date_start: '2023-08-28' date_start: '2023-08-28'
date_end: '2023-10-18' date_end: '2023-10-18'
cover_image: '' cover_image: ''
tagline: 'Seven weeks overland across Central Asia — the Pamir Highway, plov, and endless steppe'
--- ---
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 MiB

Binary file not shown.

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!
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 MiB

Binary file not shown.

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. Its awesome.
But the most important part: no daily destination. Just a rough idea and a shelter spot to sleep in when Im tired. No anxiety, no hotel needed—though probably no showers in the upcoming days either. :D
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

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, Im writing this while lying in my tent: 98km done and Im 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.
Id actually wanted to stop at 70km, since Id 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 nights shelter are here too.
Note to self: that starbucks triple espresso is great fuel, dont be ashamed.
@@ -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, its 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. Im glad my in-head currency conversion isnt 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. Ive reached 350km of the 620km in four days — pretty powerful, maybe also too much. Ive noticed that when youre on the bike, youre in bike mode: I take a picture here and there, but you cant 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. Thats 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 — weve 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.
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 696 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 MiB

Binary file not shown.

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? Ill 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 dont mind more of Denmark.
The legs felt rested and just kept plowing through. I glanced at the navigation and 50km was already done — Id 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. Tonights 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 mans version of a campfire roast. Three courses on one tiny burner. Now Im satisfied and heading to bed to charge up for tomorrow.
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 MiB

Binary file not shown.

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 youve got a routine and youre just riding. Yesterdays 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) — wasnt 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? Ill 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. Ill 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!
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 MiB

Binary file not shown.

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 Id seen on the whole trip. The east coast probably isnt 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 wouldnt 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 Ill 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
---
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 MiB

@@ -0,0 +1,49 @@
---
force_connect: false
published: true
featured: true
title: 'Vestkystruten - a reflection'
date: '29-07-2026 19:51'
end_date: '29-07-2026 19:51'
transport_mode: bicycle
hero_image: promenade-and-bike.jpg
hero_alt: 'Hidden sign of Vestkustryten 1 in the dune grass'
---
A week and a half after my last day in the saddle, I'm ready to face my own commitment: one journal entry per day, one story per trip. Instead of a highlight, though, a reflection feels more fitting. Why? Because this trip was more than a holiday. It was a confirmation that I actually enjoy cycling — and the sum of every trip that came before it, including the ones I didn't always enjoy.
[chapter-break image="gravel-road-through-dunes-bw.jpg" title="Rookie mistakes" number="I" alt="" /]
[scrolly-section image="bike-on-the-beach.jpg" alt=""]
BerlinPrague: too challenging for a rookie. BerlinCopenhagen: an accident brought trouble. A roundtrip in Tuscany: nine days of hills that nearly killed me. Vestkystruten 2026: a grand success, built entirely on those past failures and everything they taught me.
---
When you fall off your bike, you get up and ride again. And believe me — I know what I'm talking about when it comes to falling ;)
[/scrolly-section]
[chapter-break image="route-coast-straight.jpg" title="Riding Free" number="3" alt="" /]
My goal was to cycle free: no strict daily plan, just listening to my body and my surroundings. What I learned is that cycling is fun the moment you allow yourself to enjoy it — to take the break you need, eat the snack your body's asking for, and let the path be the point instead of the destination. Once you spend several days travelling under your own power, you realise it isn't like a flight or a train. Those get you to somewhere. The bike is a companion that carries you along the way to whatever it is you're looking for.
[chapter-break image="blue-clouds-on-beach.jpg" title="Everything You Miss" number="2" alt="" /]
[scrolly-section image="beautiful-dune-grass.jpg" alt=""]
I should have stopped more often in the cute little towns. I should have paused longer to soak up the nature. I could have spent more time swimming in the sea. These are all thoughts that surfaced while riding, or afterwards, reflecting at the end of a day in the saddle.
---
All valid — and all something to make peace with. You'll always miss a lovely little spot or the next cool thing. But everything you miss because you were somewhere else is also space you opened up for a different experience, another adventure.
[/scrolly-section]
[scrolly-section image="crazy-eyes.jpg" alt="Crazy eyes"]
About 650 kilometers in 8 days, and I look back with pride — not at the distance, but at how far I've come since those earlier trips, all of it adding up to an amazing holiday in Denmark. That's it. Denmark is awesome, my bike is awesome, and I can't wait for wherever the next one takes me.
[/scrolly-section]
[snap-gallery images="sea-grass-sign-route-1.jpg" captions="Vestkystruten - check" /]
File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 897 KiB

File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+13
View File
@@ -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!
File diff suppressed because it is too large Load Diff
@@ -3,13 +3,14 @@ title: '600km of Tuscany Begins with an Aperitif'
date: '2025-10-11 17:41' date: '2025-10-11 17:41'
template: entry template: entry
published: true published: true
hero_image: 'photo-1.jpg' hero_image: photo-1.jpg
lat: '43.0183' lat: '43.0183'
lng: '10.6059' lng: '10.6059'
location_city: 'Venturina Terme' location_city: 'Venturina Terme'
location_country: 'Italy' location_country: Italy
weather_temp_c: '18' weather_temp_c: '18'
weather_desc: 'partly cloudy' 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. 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 title: Stories
template: default template: stories
routable: false
visible: false
published: true published: true
--- ---
+5 -5
View File
@@ -1,9 +1,9 @@
--- ---
title: 'Cycling Tuscany 2025' title: 'Tuscany Gravel 2025'
template: trip template: trip
date: '2025-10-11' date: '2025-09-01'
date_start: '2025-10-11' date_start: '2025-09-01'
date_end: '2025-10-16' date_end: '2025-09-08'
cover_image: '' cover_image: ''
tagline: '600 km of Tuscan gravel — hill towns, aperitivi, and relentless climbing'
--- ---
+1
View File
@@ -5,4 +5,5 @@ date: '2024-05-28'
date_start: '2024-05-28' date_start: '2024-05-28'
date_end: '2024-05-28' date_end: '2024-05-28'
cover_image: '' 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' date: '2024-07-21 19:07'
template: entry template: entry
published: true published: true
hero_image: 'photo-1.jpg' hero_image: photo-1.jpg
lat: '37.765049' lat: '37.765049'
lng: '-122.508320' lng: '-122.508320'
location_city: 'San Francisco' location_city: 'San Francisco'
location_country: 'USA' location_country: USA
weather_temp_c: '18' weather_temp_c: '18'
weather_desc: 'partly cloudy' 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 🕺 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_start: '2024-07-21'
date_end: '2024-08-07' date_end: '2024-08-07'
cover_image: '' cover_image: ''
tagline: 'Three weeks across North America — Highway 1, craft beer, Niagara, and Mexico City'
--- ---
+191 -34
View File
@@ -1,16 +1,39 @@
<?php <?php
namespace Grav\Plugin; namespace Grav\Plugin;
use Grav\Common\Cache;
use Grav\Common\Data\ValidationException; use Grav\Common\Data\ValidationException;
use Grav\Common\Page\Interfaces\PageInterface;
use Grav\Common\Plugin; use Grav\Common\Plugin;
use RocketTheme\Toolbox\Event\Event; use RocketTheme\Toolbox\Event\Event;
require_once __DIR__ . '/classes/EntryScopeGuard.php'; require_once __DIR__ . '/classes/EntryScopeGuard.php';
require_once __DIR__ . '/classes/PhotoRenumberer.php';
use Grav\Plugin\Shared\EntryScopeGuard; use Grav\Plugin\Shared\EntryScopeGuard;
use Grav\Plugin\Shared\PhotoRenumberer;
class CacheOnSavePlugin extends Plugin 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 public static function getSubscribedEvents(): array
{ {
return [ return [
@@ -21,6 +44,16 @@ class CacheOnSavePlugin extends Plugin
// (priority 0) has created the page and copied the uploaded files — // (priority 0) has created the page and copied the uploaded files —
// we reorder those files, then clear the page-tree cache. // we reorder those files, then clear the page-tree cache.
'onFormProcessed' => ['onFormProcessed', -100], 'onFormProcessed' => ['onFormProcessed', -100],
// Fired by the api plugin (PagesController) on every Admin2-driven
// create/update/translate, right before $page->save().
// onFormValidationProcessed (above) does not fire for Admin2 saves,
// so BOTH hooks are needed to cover every path lat/lng can arrive
// through: this one for Admin2/API, that one for the public /post
// form. (Until the location-override panel shipped, the public
// form's lat/lng inputs were CSS-hidden and GPS-filled, making
// Admin2 the only realistic hand-entry route. They are now visible
// and directly editable, so the /post path is the primary one.)
'onAdminSave' => ['onAdminSave', 0],
]; ];
} }
@@ -52,6 +85,7 @@ class CacheOnSavePlugin extends Plugin
} }
$form->setData('parent', $this->resolveDailiesParent($activeTrip)); $form->setData('parent', $this->resolveDailiesParent($activeTrip));
$this->sanitizeCoordinates($form);
// One shared /post form drives both create and edit (KTD1). add-page-by-form // One shared /post form drives both create and edit (KTD1). add-page-by-form
// reads overwrite_mode from the /post page header's pageconfig (not form // reads overwrite_mode from the /post page header's pageconfig (not form
@@ -127,6 +161,79 @@ class CacheOnSavePlugin extends Plugin
return '/' . $trip . '/dailies'; return '/' . $trip . '/dailies';
} }
/**
* Strip invisible Unicode formatting characters (bidi marks, zero-width
* joiners, etc. — Unicode category "Cf") from a pasted lat/lng value, then
* validate the result is a real coordinate.
*
* Root cause this guards against: coordinates copied from a maps app can
* carry an invisible mark (e.g. U+200E LEFT-TO-RIGHT MARK) that neither
* JS `.trim()` nor PHP's numeric-string coercion strip. Twig's
* `number_format` filter then silently float-casts the corrupted string
* to 0.0 (PHP does not raise a warning), placing the entry at Null Island
* instead of failing loudly.
*
* Coordinates are optional (some entries intentionally have none), so a
* blank value cleans to '' with no error — only a non-blank value that
* still fails to parse as an in-range decimal after cleaning is rejected.
*
* @return string the cleaned value to write back
* @throws ValidationException if non-blank but still invalid after cleaning
*/
private function cleanCoordinate(string $field, string $raw, float $bound): string
{
$clean = trim(preg_replace('/\p{Cf}/u', '', $raw) ?? $raw);
if ($clean === '') {
return $clean;
}
if (!is_numeric($clean) || abs((float) $clean) > $bound) {
throw new ValidationException(sprintf(
'%s "%s" is not a valid coordinate — check for stray characters from pasting.',
$field === 'lat' ? 'Latitude' : 'Longitude',
$raw
));
}
return $clean;
}
/** Post-form entry point (see cleanCoordinate) — 'lat'/'lng' as top-level form fields. */
private function sanitizeCoordinates($form): void
{
foreach (['lat' => 90.0, 'lng' => 180.0] as $field => $bound) {
$raw = $form->value($field);
if (!is_string($raw)) {
continue;
}
$form->setData($field, $this->cleanCoordinate($field, $raw, $bound));
}
}
/**
* Admin2/API entry point (see cleanCoordinate) — 'header.lat'/'header.lng'
* on the Page object the api plugin is about to save. Fires on every
* create/update/translate (PagesController::create/update/translatePage),
* so this also re-validates already-clean values on every subsequent edit
* — harmless, since a clean value round-trips unchanged.
*/
public function onAdminSave(Event $event): void
{
$page = $event['page'] ?? $event['object'] ?? null;
if (!$page instanceof PageInterface) {
return;
}
$header = $page->header();
if (!$header) {
return;
}
foreach (['lat' => 90.0, 'lng' => 180.0] as $field => $bound) {
$raw = $header->{$field} ?? null;
if (!is_string($raw)) {
continue; // unset, null, or already a native number — nothing to clean
}
$header->{$field} = $this->cleanCoordinate($field, $raw, $bound);
}
}
/** /**
* The photo order the user arranged in the form, sent explicitly by * The photo order the user arranged in the form, sent explicitly by
* post-form.js as a JSON array of filenames in the dedicated * post-form.js as a JSON array of filenames in the dedicated
@@ -161,35 +268,80 @@ class CacheOnSavePlugin extends Plugin
return; return;
} }
// Reorder the just-copied photos to match the order the user arranged in // Reconcile the entry's photos to the order the owner arranged in the form
// the form (FilePond drag). Best-effort: any failure logs and is skipped // (FilePond drag). On create this only renumbers the just-copied uploads;
// so a post is never lost over cosmetics. // 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 { try {
$this->reorderPhotos(); $this->reconcilePhotos($form);
} catch (\Throwable $e) { } catch (\Throwable $e) {
$this->grav['log']->warning('cache-on-save: photo reorder skipped — ' . $e->getMessage()); $this->grav['log']->warning('cache-on-save: photo reconcile skipped — ' . $e->getMessage());
}
} }
// 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(); $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 published entry lists media in filename order and treats the first as
* the hero (see partials/entry-journal + entry-story), so a deterministic * the hero/cover (see partials/entry-journal + entry-story), so a deterministic
* photo-N naming is what makes the arranged order stick. copyFiles() writes * photo-N naming is what makes the arranged order stick. post-form.js sends the
* each file under its unsanitised client filename, and post-form.js sends the * final ordered set via the top-level `photo_order` POST key (orderFromPost):
* drag order via the top-level `photo_order` POST key (orderFromPost) — so we * on create these are the just-uploaded client filenames; on edit (M2) the mix
* can map each on-disk file to its final photo-N slot. * 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(); $names = $this->orderFromPost();
if (count($names) < 1) { if (count($names) < 1) {
return; // nothing uploaded return; // nothing submitted — leave the entry's photos untouched
} }
$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
}
$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 = $this->grav['config']->get('site.active_trip');
$activeTrip = is_string($activeTrip) ? trim($activeTrip) : ''; $activeTrip = is_string($activeTrip) ? trim($activeTrip) : '';
if ($activeTrip === '') { if ($activeTrip === '') {
@@ -197,35 +349,40 @@ class CacheOnSavePlugin extends Plugin
} }
$slug = preg_replace('#^/?trips/#', '', trim($activeTrip, '/')); $slug = preg_replace('#^/?trips/#', '', trim($activeTrip, '/'));
$slug = preg_replace('#/.*$#', '', $slug); $slug = preg_replace('#/.*$#', '', $slug);
$dir = $this->findEntryFolder($slug, $names); $dir = $this->findEntryFolder($slug, $names);
if ($dir === null) { if ($dir === null) {
return; // couldn't confidently locate the new entry folder return; // couldn't confidently locate the new entry folder
} }
}
// Two-phase rename via temp names so a target (photo-2.jpg) can't clobber PhotoRenumberer::renumber($dir, $names);
// 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
} }
$ext = strtolower(pathinfo($name, PATHINFO_EXTENSION)) ?: 'jpg';
$tmp = $dir . DIRECTORY_SEPARATOR . '.reorder-tmp-' . $i . '.' . $ext; /**
$final = $dir . DIRECTORY_SEPARATOR . 'photo-' . $i . '.' . $ext; * Delete every image file in $dir whose basename is not in $keep (the manifest
if ($src === $final) { * of photos the owner kept). Only touches known image extensions — never the
$i++; * entry .md or any other file — and clears any Grav media sidecar so a stale
continue; // already correctly named * `.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;
} }
@rename($src, $tmp); $base = basename($path);
$planned[] = [$tmp, $final]; $ext = strtolower(pathinfo($base, PATHINFO_EXTENSION));
$i++; if (!in_array($ext, $imageExts, true)) {
continue; // never touch .md or non-image files
} }
foreach ($planned as [$tmp, $final]) { if (isset($keepSet[$base])) {
if (is_file($tmp)) { continue; // still in the arranged set — keep it
@rename($tmp, $final); }
@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 * Resolve a safe segment to the page that is a DIRECT child of $parentRoute,
* trip's dailies container, or null when the segment is unsafe, no active trip * or null when the segment is unsafe, the page does not exist, or its parent
* is set, the page does not exist, or its parent is not the active dailies. * 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)) { if (!self::isSafeSegment($segment)) {
return null; return null;
} }
$dailies = self::dailiesRoute($grav);
if ($dailies === null) {
return null;
}
$pages = $grav['pages']; $pages = $grav['pages'];
// In the API request context the page tree is lazily disabled; enable it // In the API request context the page tree is lazily disabled; enable it
// so find() can resolve (mirrors the api plugin's own resolvePageByRoute). // so find() can resolve (mirrors the api plugin's own resolvePageByRoute).
@@ -117,12 +115,47 @@ class EntryScopeGuard
if (method_exists($pages, 'enablePages')) { if (method_exists($pages, 'enablePages')) {
$pages->enablePages(); $pages->enablePages();
} }
$page = $pages->find($dailies . '/' . $segment); $page = $pages->find($parentRoute . '/' . $segment);
if ($page === null) { if ($page === null) {
return null; return null;
} }
$parent = $page->parent(); $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 null;
} }
return $page; 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 <?php
namespace Grav\Plugin\EntryActions; namespace Grav\Plugin\EntryActions;
use Grav\Common\Cache;
use Grav\Common\Filesystem\Folder; use Grav\Common\Filesystem\Folder;
use Grav\Plugin\Api\Controllers\AbstractApiController; use Grav\Plugin\Api\Controllers\AbstractApiController;
use Grav\Plugin\Api\Exceptions\ApiException; 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\Exceptions\NotFoundException;
use Grav\Plugin\Api\Response\ApiResponse; use Grav\Plugin\Api\Response\ApiResponse;
use Grav\Plugin\Shared\EntryScopeGuard; use Grav\Plugin\Shared\EntryScopeGuard;
use Grav\Plugin\Shared\PhotoRenumberer;
use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Message\ServerRequestInterface;
// Shared R6 guard lives in cache-on-save (the always-present custom plugin); // Shared R6 guard + the photo-NN renumber helper both live in cache-on-save (the
// require it so save and delete enforce scope identically (KTD5). // 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/EntryScopeGuard.php';
require_once dirname(__DIR__, 2) . '/cache-on-save/classes/PhotoRenumberer.php';
/** /**
* DELETE /api/v1/entry/{slug} * DELETE /api/v1/entry/{slug}
@@ -32,6 +36,11 @@ class EntryActionsApiController extends AbstractApiController
{ {
// Authenticated OWNER only (KTD8). getUser() throws 401 for anonymous. // Authenticated OWNER only (KTD8). getUser() throws 401 for anonymous.
$user = $this->getUser($request); $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)) { if (!EntryScopeGuard::isOwnerUser($this->grav, $user)) {
throw new ForbiddenException('Only the site owner can delete journal entries.'); throw new ForbiddenException('Only the site owner can delete journal entries.');
} }
@@ -54,7 +63,175 @@ class EntryActionsApiController extends AbstractApiController
} }
Folder::delete($path); 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(); $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(); return ApiResponse::noContent();
} }
+21 -6
View File
@@ -8,12 +8,17 @@ use RocketTheme\Toolbox\Event\Event;
* Entry Actions — a thin, purpose-built API surface for owner-only, active-trip * 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. * scoped journal-entry actions that the stock Grav API cannot express safely.
* *
* M1 registers exactly one route: DELETE /api/v1/entry/{slug}. The stock * Routes:
* DELETE /api/v1/pages<route> only checks write-permission (no trip scope, and * - DELETE /api/v1/entry/{slug} — delete a journal entry folder
* any admin passes), which violates R6. This route requires the configured site * - POST /api/v1/entry/{slug}/photos/order — reorder an entry's photos
* OWNER and asserts the target is a direct child of the active trip's dailies * - POST /api/v1/trip/{slug}/publish — publish/unpublish a trip
* container — sharing one guard (EntryScopeGuard) with the save path so the two *
* R6 enforcement points cannot diverge (KTD5). * 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 * 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. * 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 = $event['routes'];
$routes->delete('/entry/{slug}', [EntryActions\EntryActionsApiController::class, 'deleteEntry']); $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']);
} }
} }
+64 -12
View File
@@ -1,5 +1,12 @@
title: 'Story' title: 'Story'
# Inherits Grav's default page form — this is what supplies the page-media
# uploader, plus the standard Options and Advanced tabs. Without it the form is
# standalone and has no way to upload images (see entry.yaml, same pattern).
'@extends':
type: default
context: blueprints://pages
form: form:
fields: fields:
tabs: tabs:
@@ -8,6 +15,7 @@ form:
fields: fields:
content: content:
ordering@: 0
type: tab type: tab
title: Content title: Content
fields: fields:
@@ -34,7 +42,7 @@ form:
type: text type: text
label: Hero Image label: Hero Image
placeholder: 'hero.jpg' placeholder: 'hero.jpg'
help: 'Filename of the hero image (upload via Media tab)' help: 'Filename of an image uploaded in the Images field below'
header.hero_alt: header.hero_alt:
type: text type: text
@@ -44,10 +52,53 @@ form:
content: content:
type: markdown type: markdown
label: Content label: Content
help: 'Prose renders as a normal reading column. For layout blocks (scrolly sections, galleries, pull quotes) use the shortcodes listed on the Blocks tab.'
validate: validate:
required: true required: true
header.media_order:
type: pagemedia
label: Images
help: 'Upload every image the story needs here first — shortcodes refer to them by bare filename (photo-1.jpg), never by path.'
blocks:
ordering@: 1
type: tab
title: Blocks
fields:
blocks_intro:
type: spacer
text: "<p>Paste these into the <strong>Content</strong> field. Image parameters take a <strong>bare filename</strong> of an image uploaded in the <strong>Images</strong> field on the Content tab. The hero image is not a block \u2014 it has its own field on the Content tab.</p>"
blocks_scrolly:
type: spacer
text: "<p style=\"margin:1.4em 0 0.4em\"><strong>Scrolly section \u2014 text panels scroll over a pinned image</strong></p><pre style=\"white-space:pre-wrap;word-break:break-word;padding:0.6em 0.8em;border-radius:6px;background:rgba(127,127,127,0.12)\">[scrolly-section image=\"hero.jpg\" alt=\"What the image shows\" caption=\"Optional caption\"]\nFirst panel.\n\n---\n\nSecond panel. A --- starts a new panel.\n[/scrolly-section]</pre>"
blocks_pullquote:
type: spacer
text: "<p style=\"margin:1.4em 0 0.4em\"><strong>Pull quote \u2014 large extracted quote, image optional</strong></p><pre style=\"white-space:pre-wrap;word-break:break-word;padding:0.6em 0.8em;border-radius:6px;background:rgba(127,127,127,0.12)\">[pull-quote image=\"photo-1.jpg\" alt=\"What the image shows\"]\nThe quote itself. Markdown works in here.\n[/pull-quote]</pre><p>Drop <em>image</em> and <em>alt</em> for the plain no-image variant.</p>"
blocks_chapter:
type: spacer
text: "<p style=\"margin:1.4em 0 0.4em\"><strong>Chapter break \u2014 section transition over a background image</strong></p><pre style=\"white-space:pre-wrap;word-break:break-word;padding:0.6em 0.8em;border-radius:6px;background:rgba(127,127,127,0.12)\">[chapter-break image=\"photo-1.jpg\" title=\"After Dark\" number=\"II\" alt=\"What the image shows\" /]</pre><p><em>number</em> is optional.</p>"
blocks_gallery:
type: spacer
text: "<p style=\"margin:1.4em 0 0.4em\"><strong>Snap gallery \u2014 swipeable carousel</strong></p><pre style=\"white-space:pre-wrap;word-break:break-word;padding:0.6em 0.8em;border-radius:6px;background:rgba(127,127,127,0.12)\">[snap-gallery images=\"hero.jpg,photo-1.jpg\" captions=\"First caption,Second caption\" alts=\"First alt,Second alt\" /]</pre><p><strong>No commas inside captions.</strong> The three lists are split on commas and zipped by position, so one comma in a caption shifts every caption after it. Use em dashes or semicolons.</p>"
blocks_fullbleed:
type: spacer
text: "<p style=\"margin:1.4em 0 0.4em\"><strong>Full bleed \u2014 single edge-to-edge image as a visual pause</strong></p><pre style=\"white-space:pre-wrap;word-break:break-word;padding:0.6em 0.8em;border-radius:6px;background:rgba(127,127,127,0.12)\">[full-bleed image=\"photo-2.jpg\" alt=\"What the image shows\" caption=\"Optional\" credit=\"Optional\" /]</pre>"
blocks_imagecaption:
type: spacer
text: "<p style=\"margin:1.4em 0 0.4em\"><strong>Image with caption \u2014 photo at a chosen width</strong></p><pre style=\"white-space:pre-wrap;word-break:break-word;padding:0.6em 0.8em;border-radius:6px;background:rgba(127,127,127,0.12)\">[image-caption image=\"photo-2.jpg\" alt=\"What the image shows\" caption=\"Optional\" credit=\"Optional\" width=\"column\" /]</pre><p><em>width</em>: column (default), full, or bleed.</p>"
blocks_gotchas:
type: spacer
text: "<p style=\"margin:1.4em 0 0.4em\"><strong>Two things that bite</strong></p><p>Self-closing tags need the space before <code>/]</code>. And a mistyped shortcode fails silently \u2014 it renders as literal text or vanishes, with no warning. Preview the page after adding each block.</p>"
location: location:
ordering@: 2
type: tab type: tab
title: Location title: Location
fields: fields:
@@ -97,21 +148,13 @@ form:
validate: validate:
type: bool type: bool
# Published now comes from the inherited Options tab — defining it here
# too would render two toggles bound to the same header key.
publishing: publishing:
ordering@: 3
type: tab type: tab
title: Publishing title: Publishing
fields: fields:
header.published:
type: toggle
label: Published
highlight: 1
default: 1
options:
1: 'Yes'
0: 'No'
validate:
type: bool
header.featured: header.featured:
type: toggle type: toggle
label: Featured highlight label: Featured highlight
@@ -123,3 +166,12 @@ form:
0: 'No' 0: 'No'
validate: validate:
type: bool type: bool
# Drop the inherited Date field — Story defines its own required
# "Start Date" on the Content tab, and both bind to header.date.
options:
fields:
publishing:
fields:
header.date:
unset@: true
+24 -18
View File
@@ -1,5 +1,13 @@
title: 'Trip' title: 'Trip'
# Inherits Grav's default page form — this is what supplies the page-media
# uploader (used for cover images and GPX files), plus the standard Options and
# Advanced tabs. Without it the form is standalone and can only *select*
# existing media, never upload any.
'@extends':
type: default
context: blueprints://pages
form: form:
fields: fields:
tabs: tabs:
@@ -8,6 +16,7 @@ form:
fields: fields:
content: content:
ordering@: 0
type: tab type: tab
title: Content title: Content
fields: fields:
@@ -21,7 +30,15 @@ form:
type: markdown type: markdown
label: Description label: Description
# Inherited from the default blueprint; relabelled because this is
# also where GPX files and cover images get uploaded.
header.media_order:
type: pagemedia
label: Images and GPX files
help: 'Upload cover images and .gpx route files here. GPX tracks are auto-detected on the map — no manual linking. The Cover Image field below picks from what is uploaded here.'
trip: trip:
ordering@: 1
type: tab type: tab
title: Trip title: Trip
fields: fields:
@@ -38,10 +55,10 @@ form:
help: 'Leave blank if trip is ongoing' help: 'Leave blank if trip is ongoing'
header.cover_image: header.cover_image:
type: text type: pagemediaselect
label: 'Cover Image Filename' accept: ['.jpg', '.jpeg', '.png', '.webp', '.gif']
placeholder: 'cover.jpg' label: 'Cover Image'
help: 'Used in the trips listing page' 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: header.album_url:
type: text type: text
@@ -78,17 +95,6 @@ form:
'manual': 'Manual — force connect only' 'manual': 'Manual — force connect only'
'intelligent_gpx': 'Intelligent GPX — suppress where route is covered' 'intelligent_gpx': 'Intelligent GPX — suppress where route is covered'
publishing: # No Publishing tab — it held only the Published toggle, which now comes
type: tab # from the inherited Options tab. Defining it here too would render two
title: Publishing # toggles bound to the same header key.
fields:
header.published:
type: toggle
label: Published
highlight: 1
default: 1
options:
1: 'Yes'
0: 'No'
validate:
type: bool
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+265 -7
View File
@@ -289,8 +289,8 @@ body::after {
font-weight: 700; font-weight: 700;
letter-spacing: 0.09em; letter-spacing: 0.09em;
text-transform: uppercase; text-transform: uppercase;
color: #E0A458; /* warm amber — draft/unpublished */ color: var(--color-draft-accent); /* warm amber — draft/unpublished */
border: 1px solid #E0A458; border: 1px solid var(--color-draft-accent);
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
padding: 0.1em 0.5em; padding: 0.1em 0.5em;
line-height: 1.5; line-height: 1.5;
@@ -791,8 +791,65 @@ body::after {
.feed-map-link:hover { color: var(--color-accent-hover); } .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 ─────────────────────────────────────────────────────────────── */ /* ── 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 { 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-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); } .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); color: var(--color-ink);
} }
/* Hide GPS coordinate fields — filled by JS, not user-facing */
.post-form-wrap .form-field:has(input[name="data[lat]"]),
.post-form-wrap .form-field:has(input[name="data[lng]"]) { display: none !important; }
/* Grav form field inputs */ /* Grav form field inputs */
.post-form-wrap .form-field { margin-bottom: var(--space-5); } .post-form-wrap .form-field { margin-bottom: var(--space-5); }
.post-form-wrap .form-label label { .post-form-wrap .form-label label {
@@ -1047,6 +1100,66 @@ body::after {
color: var(--color-ink-muted); 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 ────────────────────────────────────────────────────── */ /* ── Trip page filter bar ────────────────────────────────────────────────────── */
.feed-sort-bar { .feed-sort-bar {
@@ -1142,6 +1255,7 @@ body::after {
.home-map-col { position: static; height: 40vh; align-self: stretch; } .home-map-col { position: static; height: 40vh; align-self: stretch; }
.home-map { height: 40vh; } .home-map { height: 40vh; }
.home-feed-col { padding: var(--space-6) var(--space-5); } .home-feed-col { padding: var(--space-6) var(--space-5); }
.trip-header-banner { height: 130px; }
} }
/* ── Past trips archive ──────────────────────────────────────────────────────── */ /* ── Past trips archive ──────────────────────────────────────────────────────── */
@@ -1211,6 +1325,13 @@ body::after {
margin-bottom: var(--space-2); 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 { .trip-card-meta {
display: flex; display: flex;
gap: var(--space-4); 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-dates { font-size: var(--text-sm); color: var(--color-ink-2); }
.trip-card-counts { font-size: var(--text-sm); color: var(--color-ink-muted); } .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 page sidebar ───────────────────────────────────────────────────────── */
.trip-counts { .trip-counts {
@@ -1828,7 +2086,7 @@ body::after {
.scrolly-step:last-child { padding-bottom: 50vh; } .scrolly-step:last-child { padding-bottom: 50vh; }
@media (max-width: 768px), (pointer: coarse) { @media (max-width: 768px), (pointer: coarse) {
.scrolly { display: block; } .scrolly { display: block; }
.scrolly__steps { margin-top: calc(-(100vh - var(--site-header-height))); position: relative; z-index: 1; } .scrolly__steps { margin-top: calc(-1 * (100vh - var(--site-header-height))); position: relative; z-index: 1; }
.scrolly-step { min-height: 80vh; padding: var(--space-8) var(--space-6); align-items: center; justify-content: center; } .scrolly-step { min-height: 80vh; padding: var(--space-8) var(--space-6); align-items: center; justify-content: center; }
.scrolly-step:last-child { padding-bottom: 50vh; } .scrolly-step:last-child { padding-bottom: 50vh; }
} }
+1
View File
@@ -14,6 +14,7 @@
--color-surface-raised: #2A2720; /* elevated surfaces: tooltips, hover */ --color-surface-raised: #2A2720; /* elevated surfaces: tooltips, hover */
--color-ink-inverse: #17171A; /* text on accent-coloured buttons */ --color-ink-inverse: #17171A; /* text on accent-coloured buttons */
--color-error: #c0392b; /* validation errors, form error status */ --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) ── */ /* ── Glass overlays (paper colour at opacity, for story components) ── */
--color-paper-glass-low: color-mix(in srgb, var(--color-paper) 8%, transparent); --color-paper-glass-low: color-mix(in srgb, var(--color-paper) 8%, transparent);
File diff suppressed because one or more lines are too long
+19 -7
View File
@@ -1,8 +1,9 @@
/* Shared MapLibre GL utilities — loaded by map.html.twig, dailies.html.twig, home.html.twig */ /* Shared MapLibre GL utilities — loaded by map.html.twig, dailies.html.twig, home.html.twig */
import { MAP_STYLE } from './src/map-style.js';
(function (global) { (function (global) {
var ACCENT = '#2A8C73'; var ACCENT = '#2A8C73';
var ACCENT_DIM = '#155244'; var ACCENT_DIM = '#155244';
var MAP_STYLE = 'https://basemaps.cartocdn.com/gl/dark-matter-gl-style/style.json';
/* Build a GeoJSON LineString feature */ /* Build a GeoJSON LineString feature */
function lineFeature(coords) { function lineFeature(coords) {
@@ -260,14 +261,25 @@
connect = true; /* no GPX present → connect all */ connect = true; /* no GPX present → connect all */
} else { } else {
var prev = entries[i - 1]; var prev = entries[i - 1];
var covered = false; // Each endpoint just needs to be near SOME GPX file, not necessarily
// the same one — a leg can legitimately span two consecutive files
// (e.g. an unblogged intermediate stop splits one route in two).
// Requiring one shared file used to draw a straight connector straight
// over an already-GPX-covered path, since this trip's routes are one
// contiguous chain recorded a day per file (…-to-X.gpx, X-to-….gpx).
//
// The trade: two endpoints each near a DIFFERENT track now read as
// covered even when there is a real gap between them (a flight or a
// train transfer), so no connector is drawn across it. That case is
// not detectable from proximity alone — mark the entry after the gap
// `force_connect: true` and the branch above handles it.
var prevNear = false, currNear = false;
for (var f = 0; f < trackpointsPerFile.length; f++) { for (var f = 0; f < trackpointsPerFile.length; f++) {
if (isNearTrack(parseFloat(prev.lat), parseFloat(prev.lng), trackpointsPerFile[f], 10) && if (!prevNear && isNearTrack(parseFloat(prev.lat), parseFloat(prev.lng), trackpointsPerFile[f], 10)) prevNear = true;
isNearTrack(parseFloat(e.lat), parseFloat(e.lng), trackpointsPerFile[f], 10)) { if (!currNear && isNearTrack(parseFloat(e.lat), parseFloat(e.lng), trackpointsPerFile[f], 10)) currNear = true;
covered = true; break; if (prevNear && currNear) break;
} }
} connect = !(prevNear && currNear);
connect = !covered;
} }
} }
File diff suppressed because one or more lines are too long

Some files were not shown because too many files have changed in this diff Show More