Compare commits

...

84 Commits

Author SHA1 Message Date
m038 7dcaa703e0 ux: merge journey fields into entry/location tabs; unstack lat/lng and weather fields 2026-06-20 10:15:02 +02:00
m038 a3565677a5 demo: add hero images to all daily entries and Japan story; simplify Japan story image refs 2026-06-20 09:53:14 +02:00
m038 37c38e925a fix: add transport_mode to entry JSON serialisation in all three map templates; note bbox approach in isNearTrack 2026-06-20 00:54:04 +02:00
m038 3301f049cc feat: apply GPX connector algorithm to dailies feed mini-map 2026-06-20 00:47:39 +02:00
m038 b1665dad80 feat: use buildJourneySegments in trip.html.twig mini-map 2026-06-20 00:45:01 +02:00
m038 d9fd5eb74c feat: use buildJourneySegments in map.html.twig — suppress connectors covered by GPX 2026-06-20 00:42:34 +02:00
m038 dfca8ef6e2 feat: add GPX proximity algorithm to MapUtils (buildJourneySegments, extractTrackpoints)
Adds haversineKm, extractTrackpoints, isNearTrack, buildJourneySegments, and
addJourneySegments to the shared MapLibre GL IIFE. Updates MapUtils export to
expose the new functions. ES5-only; no arrow functions, const/let, or modules.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WPJztrVGbwic2xTG7G9fjM
2026-06-20 00:39:39 +02:00
m038 6ce77d7be7 fix: restore entry.yaml original structure, keep only Journey tab addition 2026-06-20 00:36:48 +02:00
m038 2adf06831c feat: add force_connect and transport_mode fields to entry and story blueprints 2026-06-20 00:33:07 +02:00
m038 3772a64a0e fix: story back button uses history.back(); add demo images; fix story dates for chronological interleaving 2026-06-20 00:05:53 +02:00
m038 3bd1e61817 docs: add three Tuscany demo stories (gallery-led, scrollytelling-led, mood-fragment) 2026-06-19 23:41:48 +02:00
m038 14e386a122 fix: remove 1m per-step elevation threshold — Komoot data is pre-smoothed, threshold filtered nearly all gain/loss 2026-06-19 23:34:39 +02:00
m038 8152fe79b6 fix: compute GPX stats per-file to avoid spurious inter-track segments
Both stats.html.twig and trip.html.twig previously flattened all GPX
trackpoints into a single masterPts array before computing haversine
distance, elevation, and moving time. This caused the junction between
file N's last point and file N+1's first point to be treated as a real
segment — e.g. Florence→coast (~79 km, ~42 h) for Italy's 3-file demo
data, overstating distance and moving time significantly.

Fix: compute all metrics within each file independently and sum the
results. fileResults collection and callback consumption are unchanged.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WPJztrVGbwic2xTG7G9fjM
2026-06-19 23:13:08 +02:00
m038 1a247e1889 fix: story template-story class, datetime attr, imageName escaping, raw content comments
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vgmzx8VTTTmCskSpQtsLTr
2026-06-19 23:12:47 +02:00
m038 103ceb62b9 fix: deterministic GPX ordering in parseGpxFiles (trip.html.twig) 2026-06-19 23:06:28 +02:00
m038 3845d1b5e4 docs: add demo story content (The Thousand Gates, all four shortcode blocks) 2026-06-19 23:04:11 +02:00
m038 c123a035ce feat: expand trip inline stats to 6 stats + add cycling panel with GPX parsing
- Expanded stats block from 4 to 6 stats (days, entries, countries, cities, distance, temp range)
- Added date_end-aware days calculation (uses header.date_end when available)
- Added cities dedup logic (seen_city_lower) matching Task 1 pattern
- Added temperature range computation (temp_min / temp_max)
- Added has_gpx boolean flag
- Distance label is conditional: km cycled (GPX) vs km roamed (no GPX)
- Stats note text is conditional to match distance mode
- Cycling button added to filter bar (only rendered when has_gpx)
- Cycling panel (7 stat blocks) added after stats block (hidden by default, toggled independently)
- Replaced old haversine IIFE with unified haversineKm + parseGpxFiles + IIFE
- GPX Mode A: fetches GPX files, sums trackpoint distances, populates cycling panel
- GPX Mode B: haversine between entry GPS points (no GPX)
- Updated .trip-stats-grid from repeat(4) to repeat(3) columns
- Added .trip-cycling-block, .trip-cycling-header, .trip-cycling-icon, .trip-cycling-title, .trip-cycling-grid CSS

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WPJztrVGbwic2xTG7G9fjM
2026-06-19 23:01:42 +02:00
m038 dfd1c38396 feat: add stories listing page and all story/shortcode CSS 2026-06-19 22:59:17 +02:00
m038 48b877c439 fix: deterministic multi-GPX trackpoint ordering and catch-path completion
Pre-allocate fileResults[idx] slots so GPX files always concatenate in URL
order regardless of fetch arrival order (Bug 1). Both .then and .catch now
call computeDistance() after decrementing pending so a failed last fetch no
longer leaves the distance element permanently blank (Bug 2).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WPJztrVGbwic2xTG7G9fjM
2026-06-19 22:56:38 +02:00
m038 0dc9095b4b feat: add story.html.twig with hero scroll effect and shortcode JS 2026-06-19 22:56:00 +02:00
m038 fcdb3de387 feat: add pull-quote and snap-gallery shortcodes 2026-06-19 22:50:27 +02:00
m038 3b5dc18ec6 feat: expand stats page to 6 stats — cities, temp range, distance mode detection 2026-06-19 22:50:12 +02:00
m038 a06f744ec1 feat: add scrolly-section shortcode (Scrollama-driven sticky image steps) 2026-06-19 22:47:23 +02:00
m038 c514bfd4a9 feat: add story-blocks plugin with chapter-break shortcode 2026-06-19 22:43:54 +02:00
m038 916969c96f feat: journey line — Catmull-Rom spline curve, dotted subordinate style under GPX tracks 2026-06-19 22:33:48 +02:00
m038 3ef8d48ee2 feat: add entry url to map_entries data and as data-url attribute on all markers 2026-06-19 22:18:04 +02:00
m038 997baf4cc3 fix: marker click scrolls to card on home/trip pages instead of navigating (no url field) 2026-06-19 22:14:58 +02:00
m038 456fc94c8e fix: bump MapLibre CSS specificity to 020 — CDN loads after style.css so same-specificity rules lost 2026-06-19 22:11:21 +02:00
m038 044e74f5d3 feat: hover-only title tooltip on map markers; click navigates to entry 2026-06-19 22:05:52 +02:00
m038 f7df6ef37e fix: remove cooperativeGestures, increase fitBounds padding, add popups to embedded maps 2026-06-19 22:01:54 +02:00
m038 a363052f5f feat: migrate trip overview map to MapLibre GL (removes last Leaflet reference) 2026-06-19 21:57:12 +02:00
m038 b431cfc0ac feat: migrate mini-map and home map to MapLibre GL 2026-06-19 21:49:52 +02:00
m038 87a782ae12 feat: migrate full map page to MapLibre GL with animated journey line 2026-06-19 21:46:23 +02:00
m038 12c5b2c4a1 feat: add shared MapLibre GL utilities (journey line, markers) 2026-06-19 21:43:45 +02:00
m038 0d1688c6c4 Revert "revert: remove out-of-scope stats block (belongs in separate task)"
This reverts commit a9043f711e.
2026-06-19 21:40:09 +02:00
m038 a9043f711e revert: remove out-of-scope stats block (belongs in separate task) 2026-06-19 21:39:42 +02:00
m038 93005bd7cd fix: replace raw rgba with color-mix token in MapLibre attribution style 2026-06-19 21:39:21 +02:00
m038 fe0aa669bc style: swap Leaflet CSS override for MapLibre design-token styles 2026-06-19 21:36:35 +02:00
m038 897da36a21 feat: add inline stats block with toggle to trip page
Adds Twig computation for days on road, countries visited, and GPS
points; an expandable stats panel (hidden by default) with haversine
distance calculation; and toggle JS that activates the Stats button.
2026-06-19 21:35:54 +02:00
m038 eb739d80ab feat: wire up feed filter — All content / Journal / Stories
Added JavaScript to the trip.html.twig template that:
- Adds event listeners to filter buttons (.trip-filter-btn)
- Shows/hides article cards based on data-type attribute (journal/story)
- Manages active state of filter buttons
- Displays empty state message when no results match the filter
- Uses ES5 syntax (no arrow functions, const/let, or template literals)

Also added hidden feed-filter-empty element to display appropriate
empty messages for each filter type.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RB86BaJBG3eGiMdfhmHRrQ
2026-06-19 21:32:13 +02:00
m038 0478a18fa8 feat: add filter bar markup and pill button styles to trip page
Replace the old trip-nav links with a new filter bar component featuring:
- Three pill buttons for filtering (All content, Journal, Stories)
- "All content" button active by default with teal accent styling
- Separate Stats button with matching pill styling
- CSS for buttons with hover and active states
- Responsive flexbox layout that wraps on narrow screens

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RB86BaJBG3eGiMdfhmHRrQ
2026-06-19 21:29:33 +02:00
m038 2508936928 feat: add data-type attributes to feed cards; restyle story card with full border 2026-06-19 21:26:50 +02:00
m038 650e97883b demo: add placeholder hero images to Tuscany Gravel 2025 entries (QA)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RB86BaJBG3eGiMdfhmHRrQ
2026-06-19 20:57:36 +02:00
m038 2eef8fbf9a fix: Leaflet void background corrected to actual CartoDB ocean color (#282828)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RB86BaJBG3eGiMdfhmHRrQ
2026-06-19 20:54:22 +02:00
m038 11224289de fix: Leaflet void background matches CartoDB ocean color (#0d0d17)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RB86BaJBG3eGiMdfhmHRrQ
2026-06-19 20:50:58 +02:00
m038 69c9f4f939 feat: trip page matches home layout — sticky map + feed, GPX route, no sidebar
- Same home-layout (45% sticky map / 55% scrollable feed) on every trip page
- GPX route overlay loaded from trip page media
- Marker click scrolls to entry card (same as home page)
- Map sub-nav link removed (map is now embedded)
- Separate /map page remains accessible by URL but has no nav link

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RB86BaJBG3eGiMdfhmHRrQ
2026-06-19 20:42:41 +02:00
m038 010478b3fa fix: sort past trips descending by date (newest first)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RB86BaJBG3eGiMdfhmHRrQ
2026-06-19 17:32:47 +02:00
m038 49d10f4816 fix: home map visible on mobile, invalidateSize on both maps
- Explicit height: 40vh on .home-map (not just 100% of parent) so Leaflet
  can measure the container reliably before CSS inheritance is resolved
- align-self: stretch on .home-map-col so it spans full width in flex column
- setTimeout invalidateSize(100ms) on home and dailies maps as safety net

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RB86BaJBG3eGiMdfhmHRrQ
2026-06-19 17:29:52 +02:00
m038 a9eda558c0 fix: nav slash, back button context, home page max-width
- Past Trips nav link: add missing / (base_url_absolute has no trailing slash)
- Entry back link: history.back() with journal fallback, label → "← Back"
- Home page: max-width 1400px instead of none — narrows layout on wide screens

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RB86BaJBG3eGiMdfhmHRrQ
2026-06-19 17:01:45 +02:00
m038 16b44513f2 fix: stories escape link goes to trip page not archive 2026-06-19 15:51:51 +02:00
m038 ab8a5138dd feat: dailies merges stories, id attrs for map sync; stories escape link 2026-06-19 15:47:42 +02:00
m038 b66f1cdb2d feat: trip page — entry counts, merged feed, sticky sidebar index 2026-06-19 15:45:06 +02:00
m038 a78236bf3b feat: home page template — sticky map + merged feed
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RB86BaJBG3eGiMdfhmHRrQ
2026-06-19 15:42:43 +02:00
m038 a9843a0a2d feat: past trips archive with trip cards and entry counts 2026-06-19 15:40:15 +02:00
m038 5c98bf239a fix: add missing .trip-feed and .trip-sidebar-section CSS classes 2026-06-19 15:37:58 +02:00
m038 86b2778a47 feat: CSS for home layout, story cards, trip sidebar, escape link 2026-06-19 15:36:38 +02:00
m038 035c92f293 feat: home page routing — real / route, new nav (Home + Past Trips) 2026-06-19 15:34:12 +02:00
m038 fbc4fc195b fix: slice File to Blob before append so 3rd-arg filename is always used 2026-06-19 15:33:13 +02:00
m038 597add6c1d fix: use fd.append 3rd arg to set slugified filename in multipart upload 2026-06-19 15:13:00 +02:00
m038 1c9a6711b3 fix: slugify uploaded GPX filename before sending to API 2026-06-19 15:11:29 +02:00
m038 537f443cf1 feat: gpx-manager list, upload, delete via Grav API session auth 2026-06-19 14:58:25 +02:00
m038 e4451857c2 feat: gpx-manager template layout with trip sections 2026-06-19 14:57:59 +02:00
m038 feeef865aa feat: add gpx-manager page definition (access-protected) 2026-06-19 14:57:24 +02:00
m038 5c02432ce0 fix: use !important to override Leaflet default grey background 2026-06-19 13:22:53 +02:00
m038 d3ef42f04f fix: set leaflet-container background to match dark tile color, prevent grey flash 2026-06-19 13:21:50 +02:00
m038 bae9d68943 fix: switch map tiles to CartoDB dark (no API key required) 2026-06-19 13:18:36 +02:00
m038 dc162ff58c feat: switch to Stadia Alidade Smooth Dark map tiles 2026-06-19 13:11:42 +02:00
m038 3d5e29e26c feat: add paper grain texture, fix hardcoded colors, improve typography 2026-06-19 13:11:36 +02:00
m038 ba3a2ea9e7 feat: switch to warm-dark color tokens 2026-06-19 13:11:32 +02:00
m038 64b7fcc166 feat: Grav 2.0 compat — flex accounts/pages, api.super permission
- accounts.type: flex (required by admin2 API)
- pages.type: flex (required for admin2 pages API)
- Add access.api.super + api.access to mischa account (admin2 uses api.* permissions, not admin.*)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-19 13:02:25 +02:00
m038 0bb3b3bcce chore: consolidate all docs/plans/specs into main repo docs/
Moved from user/ repo: milestone specs, design spec, QA docs, research,
posting pipeline, bugs log, UI redesign plan.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-19 10:36:47 +02:00
m038 a1acabbf17 chore: move docs/plans/specs to main repo 2026-06-19 10:36:39 +02:00
m038 70b4e1ca7a fix: use trip-relative URL for entry back-link, add active_trip sync comment
- entry.html.twig: replace hardcoded /tracker href with page.parent().url
- post-form.md: add comment to keep pageconfig.parent in sync with active_trip in site.yaml
2026-06-19 02:01:06 +02:00
m038 24f3c14d77 feat: add Admin blueprint for trip page type with date range, cover image, and album URL fields 2026-06-19 01:54:59 +02:00
m038 d1066d7eb3 chore: remove stale docs/demo/tracker (moved to docs/demo/trips/) 2026-06-19 01:52:36 +02:00
m038 ffda4568ab fix: update post form parent and add Italy 2025 demo trip with GPX routes
- Change pageconfig.parent from '/tracker' to '/trips/japan-korea-2026/dailies'
- Move japan-korea-2026 demo entries to docs/demo/trips/japan-korea-2026/dailies/
- Add Italy 2025 (Tuscany Gravel) demo trip: 5 entries with real Tuscany
  coordinates, plus trip.md, map/stats/stories stubs, and 3 GPX routes

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-19 01:50:02 +02:00
m038 86997cb878 feat: add GPX route rendering to trip map via leaflet-gpx
Adds leaflet-gpx@2.1.2 CDN script to map template, collects *.gpx
media files from the trip page, and renders them as teal polylines
beneath entry pins. Also fixes user/config/media.yaml to use the
required types: key so Grav's Media class correctly discovers .gpx
files. Map remains functional when no GPX files are present.
2026-06-19 01:38:36 +02:00
m038 50a5f2d178 feat: add trip/trips/stories templates, update nav and map/stats to use trip-relative paths
- Rename tracker.html.twig to dailies.html.twig; update dailies.md template key
- Fix map.html.twig and stats.html.twig: find dailies via page.parent().route
- Update base.html.twig nav to use config.site.active_trip for all hrefs
- Fix dailies.html.twig mini-map link to use page.parent().url/map
- Create trip.html.twig, trips.html.twig, stories.html.twig

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-19 01:27:39 +02:00
m038 2a32917568 refactor: rename tracker to dailies (URL slug), keep nav label as Journal 2026-06-19 01:24:43 +02:00
m038 24acae2a85 feat: restructure pages under trips/japan-korea-2026 entity
- Create trips/japan-korea-2026/{tracker,map,stats,stories} hierarchy
- Move 8 entry folders from 01.tracker into trips/.../01.tracker/
- Add active_trip: japan-korea-2026 to site.yaml
- Whitelist GPX file type in media.yaml
2026-06-19 01:19:41 +02:00
m038 534b9a96f1 feat: add demo entries with images + fix form upload action
- New demo entry: Arashiyama with single hero image (bamboo.jpg)
- New demo entry: Gyeongbokgung with four gallery images (palace-gate,
  throne-hall, hanok-rooftops, bugaksan)
- post-form.md: add upload: true to process block so filepond photo
  uploads are handled after page creation; simplify list-of-maps to
  flat map (Symfony YAML preserves insertion order)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-19 00:12:55 +02:00
m038 3a79fe2cc7 fix: use ISO date format for page date storage
Changes dateformat.default from 'd M Y' to 'Y-m-d H:i' so dates
submitted via the post form are stored as '2026-06-19 10:30' rather
than '18 Jun 2026'. This ensures add-page-by-form generates slugs in
YYYY-MM-DD-HHmm-title order, preserving chronological sort.

All templates use explicit |date() filters so display is unaffected.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-19 00:10:49 +02:00
m038 9a9220e066 docs: add posting pipeline reference and Admin entry blueprint
- posting-pipeline.md: full frontmatter reference, frontend form flow,
  Admin backend flow, page folder structure
- blueprints/entry.yaml: Admin form fields for city, country, lat/lng,
  weather condition dropdown, temperature, hero image

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-18 23:24:37 +02:00
m038 c05b9e3400 feat: add Grav 2.0 compat flag for cache-on-save plugin and switch GPM to testing channel
- Add blueprints.yaml for cache-on-save plugin with Grav 2.0 support
- Update system.yaml GPM setting from stable to testing channel
- Update .gitignore to allow cache-on-save plugin tracking

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-18 23:19:27 +02:00
109 changed files with 181220 additions and 235 deletions
+1
View File
@@ -1,3 +1,4 @@
/plugins/ /plugins/
!/plugins/
!/plugins/cache-on-save/ !/plugins/cache-on-save/
/data/ /data/
+9 -6
View File
@@ -1,13 +1,16 @@
login: mischa login: mischa
state: enabled
title: Mischa
email: mischa@gorinskat.nl
fullname: Mischa
hashed_password: $2y$10$koiWKjhhipph4uTbm7fWjOj79uwxfE/mYSXGKANrAvUrSqezY3xL2
language: en
access: access:
admin: admin:
login: true login: true
super: true super: true
site: site:
login: true login: true
state: enabled api:
title: Mischa super: true
email: mischa@gorinskat.nl access: true
fullname: Mischa
hashed_password: '$2y$10$dUEYTopGEDouFoAa/Wxw6.vsOA71yr3gSStfDvr10aKm4ih9ObQ7m'
language: en
+4
View File
@@ -0,0 +1,4 @@
types:
gpx:
type: file
mime: application/gpx+xml
+1 -1
View File
@@ -1 +1 @@
salt: lsUHWFkCwvGZrL { }
+1
View File
@@ -6,3 +6,4 @@ author:
taxonomies: [category, tag] taxonomies: [category, tag]
metadata: metadata:
description: 'Into the East — travel journal' description: 'Into the East — travel journal'
active_trip: japan-korea-2026
+5 -5
View File
@@ -28,10 +28,10 @@ languages:
pages_fallback_only: false pages_fallback_only: false
debug: false debug: false
home: home:
alias: /tracker alias: /home
hide_in_urls: false hide_in_urls: false
pages: pages:
type: regular type: flex
dirs: dirs:
- 'page://' - 'page://'
theme: intotheeast theme: intotheeast
@@ -41,7 +41,7 @@ pages:
list: list:
count: 20 count: 20
dateformat: dateformat:
default: 'd M Y' default: 'Y-m-d H:i'
short: 'D, d M Y G:i:s' short: 'D, d M Y G:i:s'
long: 'D, d M Y G:i:s' long: 'D, d M Y G:i:s'
publish_dates: true publish_dates: true
@@ -210,7 +210,7 @@ session:
domain: null domain: null
path: null path: null
gpm: gpm:
releases: stable releases: testing
official_gpm_only: true official_gpm_only: true
http: http:
method: curl method: curl
@@ -221,7 +221,7 @@ http:
verify_peer: true verify_peer: true
verify_host: true verify_host: true
accounts: accounts:
type: regular type: flex
storage: file storage: file
avatar: gravatar avatar: gravatar
flex: flex:
Binary file not shown.

After

Width:  |  Height:  |  Size: 221 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 152 KiB

@@ -0,0 +1,28 @@
---
title: The Val d'Orcia at Dawn
date: '2025-09-05 10:00'
location_name: Val d'Orcia
location_country: Italy
lat: 43.078
lng: 11.676
hero_image: hero.jpg
hero_alt: Cypress-lined dirt road at first light, Tuscany
published: true
---
We left camp before the heat arrived. At six in the morning the Val d'Orcia belongs entirely to the light — long shadows, pale gold, not a car on the white roads. The kind of silence that has texture.
[snap-gallery images="hero.jpg,photo.jpg" captions="First light on the valley floor,The hills fold endlessly east" alts="Wide valley at dawn with golden light,Rolling green hills under morning sky" /]
We stopped twice before nine. Once for a puncture, once because the view demanded it.
[chapter-break image="hero.jpg" title="The Hour Before Heat" alt="Hazy hillside shimmering in early morning warmth" /]
By ten the temperature had shifted. The colours changed too — softer, more diffuse, the sky turning white at the edges. We dropped into the lower valley and the road surface changed from gravel to packed earth, then back again.
[snap-gallery images="photo.jpg,hero.jpg" captions="The texture of Tuscan gravel — coarser than it looks,The road ahead disappears into the heat" alts="Close-up of pale gravel surface,Road vanishing into bright haze" /]
[pull-quote]
The best hours of a cycling day are the ones nobody sees. Four in the morning to ten. Then it belongs to the sun.
[/pull-quote]
We made Pienza by noon. It was already thirty degrees and the ice cream queue was six deep.
Binary file not shown.

After

Width:  |  Height:  |  Size: 204 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 119 KiB

@@ -0,0 +1,55 @@
---
title: The Long Climb to Montalcino
date: '2025-09-06 20:00'
end_date: 2025-09-06
location_name: Montalcino
location_country: Italy
lat: 43.058
lng: 11.489
hero_image: hero.jpg
hero_alt: Hairpin road climbing through olive groves towards a hilltop town
published: true
---
The profile showed fourteen kilometres at an average of six percent. In practice it was steeper at the bottom and gentler at the top, which is the worst possible arrangement. We started climbing at two in the afternoon, which was also the worst possible decision.
[scrolly-section image="hero.jpg" alt="Empty road rising steeply through olive groves" caption="SP55 — 14km, 840m elevation gain"]
The first kilometre is the most honest. You find out immediately whether your legs have anything to say.
---
By the halfway point the olive groves had given way to scrub oak and the road had narrowed to a single lane. No cars had passed in forty minutes. The silence was absolute except for breathing.
---
Then, at the last bend before the top, the town appeared. Just the outline of it — a tower, a wall, rooftops. It was enough.
[/scrolly-section]
[chapter-break image="photo.jpg" title="Montalcino" number="II" alt="Medieval town gate with stone archway" /]
[pull-quote image="photo.jpg" alt="Rows of Brunello vines descending from hilltop town"]
From the top you could see the whole valley we had spent two days riding through. It looked completely flat from up here.
[/pull-quote]
We found a bar in the main piazza. The owner brought two glasses of water without being asked. Then two more. Then a small plate of bread and oil that nobody ordered. We sat there for an hour.
[scrolly-section image="photo.jpg" alt="Shaded medieval piazza with stone buildings" caption="Piazza del Popolo, Montalcino"]
The piazza at five in the afternoon is a different place from the piazza at noon. People have returned from wherever they go during the heat.
---
A wine shop with barrels in the window and a handwritten list on a chalkboard. We looked at it for a long time and bought nothing. The prices were very reasonable and this felt suspicious.
---
A cat on a warm stone wall, watching traffic that did not exist. It had clearly been watching this traffic for years.
---
The fortress walls turn amber just before sunset. You could photograph this from a hundred different angles and it would look the same in all of them: very good.
---
The descent back to the valley takes twenty minutes. The climb took two and a half hours. This ratio never stops feeling wrong.
[/scrolly-section]
We found the agriturismo by following a handwritten sign nailed to a cypress tree. It was exactly what it promised to be.
Binary file not shown.

After

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 136 KiB

@@ -0,0 +1,32 @@
---
title: One Evening in Siena
date: '2025-09-05 22:00'
location_name: Siena
location_country: Italy
lat: 43.318
lng: 11.330
hero_image: hero.jpg
hero_alt: The Piazza del Campo at dusk, terracotta rooftops fading to blue
published: true
---
[pull-quote]
Siena is not a city that tries to impress you. It has been here for a thousand years and intends to be here for a thousand more. You fit around it.
[/pull-quote]
We rolled in at half past six, legs finished, panniers heavier than they started. The Campo appeared without warning at the end of a narrow street and we both stopped pedalling at exactly the same moment.
[scrolly-section image="hero.jpg" alt="Piazza del Campo seen from the upper rim, sloping shell-shaped square"]
The square fills from the edges inward as evening comes. First the locals — people who have been here before and know which bench faces west. Then the tourists, then the pigeons, then the shadows.
---
A busker with a cello at the top of the slope. A couple arguing quietly in a language I couldn't place. Three children running in a circle for reasons nobody questioned. The ordinary business of a city at the end of a summer day.
[/scrolly-section]
[snap-gallery images="hero.jpg,photo.jpg" captions="The Campo at the moment the light goes warm,A doorway on Via di Città — every doorway in Siena looks like this" alts="Wide shot of Campo at golden hour,Ornate stone doorway with iron lantern" /]
We found a place to eat down three flights of stairs in a basement that appeared to have no ventilation and no menu. It was perfect. The relief of sitting down after eight hours on a bike is a specific physical sensation that is difficult to describe to anyone who has not experienced it.
[pull-quote image="photo.jpg" alt="Sunset view over Siena rooftops from high vantage point"]
Cycling makes you earn every place you arrive at. Siena earned.
[/pull-quote]
@@ -0,0 +1,13 @@
---
title: "Rolling through Val d'Orcia"
template: entry
date: '2025-09-05 08:00'
lat: 43.078
lng: 11.676
location_city: Pienza
location_country: Italy
weather_temp_c: 24
weather_desc: Sunny
published: true
---
Cypress trees lining dirt roads, heat already rising. The Val d'Orcia is everything they say it is.
Binary file not shown.

After

Width:  |  Height:  |  Size: 278 KiB

@@ -0,0 +1,13 @@
---
title: "Siena at dusk"
template: entry
date: '2025-09-05 19:00'
lat: 43.318
lng: 11.335
location_city: Siena
location_country: Italy
weather_temp_c: 21
weather_desc: Clear
published: true
---
Rolled in just before sunset. The Piazza del Campo was still warm from the day's heat.
Binary file not shown.

After

Width:  |  Height:  |  Size: 115 KiB

@@ -0,0 +1,13 @@
---
title: "Towers of San Gimignano"
template: entry
date: '2025-09-06 12:00'
lat: 43.546
lng: 11.321
location_city: 'San Gimignano'
location_country: Italy
weather_temp_c: 26
weather_desc: Hot and sunny
published: true
---
Ate lunch in the shadow of the medieval towers. Legs tired, gelato mandatory.
Binary file not shown.

After

Width:  |  Height:  |  Size: 172 KiB

@@ -0,0 +1,13 @@
---
title: "Into Florence"
template: entry
date: '2025-09-06 18:00'
lat: 43.767
lng: 11.253
location_city: Florence
location_country: Italy
weather_temp_c: 28
weather_desc: Warm
published: true
---
City traffic after days of gravel roads. Dodged trams and found the hotel.
Binary file not shown.

After

Width:  |  Height:  |  Size: 121 KiB

@@ -0,0 +1,13 @@
---
title: "Tyrrhenian coast"
template: entry
date: '2025-09-08 09:00'
lat: 43.553
lng: 10.313
location_city: Livorno
location_country: Italy
weather_temp_c: 23
weather_desc: Sea breeze
published: true
---
The sea appeared suddenly between two hills. Eight days of riding ends here.
Binary file not shown.

After

Width:  |  Height:  |  Size: 155 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
+5
View File
@@ -0,0 +1,5 @@
---
title: Stories
template: stories
published: true
---
+8
View File
@@ -0,0 +1,8 @@
---
title: 'Tuscany Gravel 2025'
template: trip
date: '2025-09-01'
date_start: '2025-09-01'
date_end: '2025-09-08'
cover_image: ''
---
Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 129 KiB

@@ -0,0 +1,43 @@
---
title: The Thousand Gates
date: 2026-03-28
end_date: 2026-03-29
location_name: Kyoto
location_country: Japan
lat: 34.967
lng: 135.773
hero_image: hero.jpg
hero_alt: A vermillion torii gate at dawn, half-lit by morning sun
published: true
---
We left the ryokan before sunrise. Kyoto in March has a particular quality of light — not yet warm, but already golden at the edges. The streets were empty except for a few cyclists and one very confused vending machine that kept flashing its lights at nothing.
[chapter-break image="photo.jpg" title="Fushimi Inari" number="I" alt="Rows of vermillion torii gates stretching into darkness" /]
The path up through Fushimi Inari begins the moment you pass the main shrine. There is no dramatic threshold — just a gate, then another gate, then several thousand more. Each was donated by a business or family. You can read their names on the back of each post, small kanji pressed into the lacquered red.
[scrolly-section image="photo.jpg" alt="Tunnel of torii gates seen from below" caption="Senbon Torii — the Thousand Gates"]
The first gate smells of fresh lacquer. Someone has recently repainted it, and the colour is almost aggressive in its redness.
---
By the tenth gate the smell is gone and the city has disappeared. Pine trees close in on both sides. The only sounds are other footsteps and the occasional crow.
---
By the hundredth gate you stop counting. The path becomes the thing itself — not a means to a destination but a place to be.
---
Near the summit there is a small shrine with fox statues wearing tiny red bibs. An old woman is arranging fresh flowers in front of them, moving with the unhurried certainty of someone who has done this ten thousand times.
[/scrolly-section]
[pull-quote image="photo.jpg" alt="View over Kyoto from the hilltop"]
The gates never seemed to end — and somewhere around gate five hundred, I stopped wanting them to.
[/pull-quote]
By the time we descended, the city had woken up. Taxis, schoolchildren, a delivery truck arguing with a narrow alley. We found a coffee shop down a side street that did not appear to expect visitors and sat there for an hour watching nothing in particular happen.
[snap-gallery images="hero.jpg,photo.jpg" captions="Morning light on the main shrine,Fox statues at the upper shrine" alts="Sunlit shrine building,Stone fox statues with red bibs" /]
That evening we had ramen in a place with eight seats and a chef who appeared to be operating entirely by memory. There was no menu. You sat down and food appeared. It was the best meal of the trip.
Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 164 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 124 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

@@ -0,0 +1,23 @@
---
title: 'Last Morning in Arashiyama'
date: '2026-03-31 07:30'
template: entry
published: true
hero_image: 'bamboo.jpg'
lat: '35.0094'
lng: '135.6728'
location_city: 'Kyoto'
location_country: 'Japan'
weather_temp_c: 13
weather_desc: 'Partly cloudy'
---
The alarm went off at 6am and I almost ignored it. Then I remembered why I had set it: Arashiyama before the crowds arrive.
By 7am the bamboo grove was quiet. Not silent — bamboo is never silent, the stalks creak and the leaves hiss against each other in any breeze at all — but quiet in the sense of no one else being there. An hour later there would be tour groups and selfie sticks and the particular difficulty of appreciating something beautiful while surrounded by people also trying to appreciate it. At 7am there was just the grove and the green light filtering down through the canopy and a single cat sitting very still on a stone wall watching me with professional indifference.
I walked the main path twice. The stalks are taller than I expected, 15 or 20 metres, and they grow so densely that the sky mostly disappears. The colour is extraordinary: not one green but twenty, each stalk a slightly different shade depending on age and light, the whole thing shifting as the breeze moves through it.
The Oi River was flat and grey in the morning light, a single cormorant fishing from a low rock. Across the water the hills were still wrapped in low cloud. I sat on a bench and ate a convenience store onigiri and watched the mist burn off slowly.
Flight to Seoul at 2pm. Packing takes twenty minutes when you never properly unpack.
Binary file not shown.

After

Width:  |  Height:  |  Size: 146 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

@@ -0,0 +1,23 @@
---
title: 'Gyeongbokgung and Beyond'
date: '2026-04-02 11:00'
template: entry
published: true
hero_image: 'palace-gate.jpg'
lat: '37.5796'
lng: '126.9770'
location_city: 'Seoul'
location_country: 'South Korea'
weather_temp_c: 12
weather_desc: 'Sunny'
---
Sunday in Seoul and the whole city seemed to have the same idea: Gyeongbokgung Palace, the largest of the five grand palaces built during the Joseon dynasty, restored after the Japanese colonial period and now open and enormous and full of people doing what people do when confronted with a large photogenic space — walking through it slowly with their phones held in front of them.
I did the same thing. The main gate, Gwanghwamun, is so large that the guards performing the changing ceremony looked like toys underneath it. The throne hall beyond has curved roofs that sweep upward at the corners in a way that seems to defy the weight of the tiles. Behind everything, Bugaksan mountain rises up, still snow-capped, framing the whole compound like a backdrop.
I stayed for two hours then walked north to Bukchon Hanok Village: a hillside neighbourhood of traditional Korean houses, narrow lanes, and — given it was a Sunday — approximately four hundred other tourists also walking those narrow lanes. Worth it regardless. The geometry of the rooftops against the Seoul skyline is exactly as good as every photograph suggests.
Afternoon: the National Folk Museum inside the palace grounds, a covered market near Insadong for dinner supplies, then back to Mapo on the subway reading a novel and failing to remember which stop was mine.
Three days left in Korea. I am already sad about the food.
Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

+123
View File
@@ -0,0 +1,123 @@
# Daily Entry Posting Pipeline
Two ways to create a daily entry: the mobile frontend form at `/post`, or directly from the Grav Admin panel. Both produce the same page structure under `user/pages/01.tracker/`.
---
## Frontmatter Reference
Every entry page (`template: entry`) supports these frontmatter fields:
| Field | Type | Required | Notes |
|---|---|---|---|
| `title` | string | ✅ | Entry headline |
| `date` | datetime | ✅ | Format: `Y-m-d H:i` (e.g. `2026-06-17 10:00`) |
| `template` | string | ✅ | Always `entry` |
| `published` | bool | ✅ | `true` to show in tracker feed |
| `lat` | string | — | Latitude decimal degrees (e.g. `52.3676`) |
| `lng` | string | — | Longitude decimal degrees (e.g. `4.9041`) |
| `location_city` | string | — | City name shown under the title (e.g. `Kyoto`) |
| `location_country` | string | — | Country name shown under the title (e.g. `Japan`) |
| `weather_desc` | string | — | Condition label — must be one of the values below |
| `weather_temp_c` | number | — | Temperature in Celsius (displayed rounded, e.g. `19`) |
| `hero_image` | string | — | Filename of the hero image (e.g. `photo.jpg`). Leave blank to auto-select the first uploaded image. |
**`weather_desc` allowed values** (matched to emoji icons in `entry.html.twig`):
`Sunny` · `Partly cloudy` · `Cloudy` · `Foggy` · `Drizzle` · `Rain` · `Snow` · `Thunderstorm`
**Page media (photos):** images are stored as files in the page folder (`user/pages/01.tracker/<slug>/`). All images in the folder are shown in the gallery. `hero_image` pins one as the full-width header.
**Example complete frontmatter:**
```yaml
---
title: 'First Day in Kyoto'
date: '2026-07-20 09:30'
template: entry
published: true
lat: '35.0116'
lng: '135.7681'
location_city: 'Kyoto'
location_country: 'Japan'
weather_desc: 'Sunny'
weather_temp_c: 28
hero_image: 'temple.jpg'
---
```
---
## Flow 1 — Mobile Frontend Form (`/post`)
This is the primary posting flow, designed for one-handed phone use.
```
Browser → /post (post-form.md)
└─ Grav Form plugin validates fields
└─ add-page-by-form plugin (onFormProcessed)
├─ reads pageconfig.parent (/tracker) and pageconfig.slug_field (date + title)
├─ reads pagefrontmatter (template: entry, published: true)
├─ merges form field values into new page frontmatter
├─ writes user/pages/01.tracker/<slug>/entry.md
└─ moves uploaded photos into the page folder
└─ cache-on-save plugin (onFormProcessed)
└─ calls $grav['cache']->deleteAll() so tracker feed shows the entry immediately
└─ form shows success message, resets fields
```
**The form fields and their mapping to frontmatter:**
| Form field | Frontmatter key | Notes |
|---|---|---|
| `title` | `title` | Required |
| `date` | `date` | Defaults to current datetime |
| `content` | page body (markdown) | Required |
| `photos` | page media files | Uploaded to page folder |
| `lat` | `lat` | Filled via "Get Location" button |
| `lng` | `lng` | Filled via "Get Location" button |
| `location_city` | `location_city` | Manual text entry |
| `location_country` | `location_country` | Manual text entry |
| `weather_temp_c` | `weather_temp_c` | Hidden — set by weather JS widget |
| `weather_desc` | `weather_desc` | Hidden — set by weather JS widget |
**Slug format:** `<YYYY-MM-DD>.<slugified-title>` (controlled by `slug_field: 'date,title'` in `post-form.md`).
**Security:** the `/post` page requires `access: site.login: true` — anonymous visitors get redirected to login.
---
## Flow 2 — Admin Panel (sit-down workflow)
Use this for drafts, scheduled posts, or editing existing entries.
1. Log in at `/admin`
2. Go to **Pages****Add Page**
3. Set:
- **Page Title:** your entry title
- **Parent Page:** `/tracker`
- **Page Template:** `entry`
4. Fill in the **Entry** tab fields (city, country, lat/lng, weather)
5. Write content in the **Content** tab
6. Upload photos via the **Media** tab
7. Set `published: true` (or leave `false` for a draft)
8. For scheduling: set `publish_date` in **Options****Scheduling**
9. Save
The Admin form fields are defined by `user/themes/intotheeast/blueprints/entry.yaml`.
**Drafts:** set `published: false` — the entry won't appear in the tracker feed until you flip it to `true`. Useful for writing ahead of time on the road.
**Scheduling:** Grav supports `publish_date` and `unpublish_date` in page frontmatter. Set them in the Admin Options tab. Requires `pages.publish_dates: true` in `system.yaml` (already enabled).
---
## Page folder structure
```
user/pages/01.tracker/
└─ 2026-07-20.first-day-in-kyoto/
├─ entry.md ← frontmatter + markdown body
├─ temple.jpg ← hero image (referenced by hero_image)
└─ market.jpg ← additional gallery image
```
The folder name follows the pattern `<date>.<slug>`. Grav uses the folder name for ordering and routing.
+5
View File
@@ -0,0 +1,5 @@
---
title: Home
visible: false
routable: true
---
@@ -0,0 +1,13 @@
---
title: "Rolling through Val d'Orcia"
template: entry
date: '2025-09-05 08:00'
lat: 43.078
lng: 11.676
location_city: Pienza
location_country: Italy
weather_temp_c: 24
weather_desc: Sunny
published: true
---
Cypress trees lining dirt roads, heat already rising. The Val d'Orcia is everything they say it is.
Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

@@ -0,0 +1,13 @@
---
title: "Siena at dusk"
template: entry
date: '2025-09-05 19:00'
lat: 43.318
lng: 11.335
location_city: Siena
location_country: Italy
weather_temp_c: 21
weather_desc: Clear
published: true
---
Rolled in just before sunset. The Piazza del Campo was still warm from the day's heat.
Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

@@ -0,0 +1,13 @@
---
title: "Towers of San Gimignano"
template: entry
date: '2025-09-06 12:00'
lat: 43.546
lng: 11.321
location_city: 'San Gimignano'
location_country: Italy
weather_temp_c: 26
weather_desc: Hot and sunny
published: true
---
Ate lunch in the shadow of the medieval towers. Legs tired, gelato mandatory.
Binary file not shown.

After

Width:  |  Height:  |  Size: 146 KiB

@@ -0,0 +1,13 @@
---
title: "Into Florence"
template: entry
date: '2025-09-06 18:00'
lat: 43.767
lng: 11.253
location_city: Florence
location_country: Italy
weather_temp_c: 28
weather_desc: Warm
published: true
---
City traffic after days of gravel roads. Dodged trams and found the hotel.
Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

@@ -0,0 +1,13 @@
---
title: "Tyrrhenian coast"
template: entry
date: '2025-09-08 09:00'
lat: 43.553
lng: 10.313
location_city: Livorno
location_country: Italy
weather_temp_c: 23
weather_desc: Sea breeze
published: true
---
The sea appeared suddenly between two hills. Eight days of riding ends here.
Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

@@ -1,6 +1,6 @@
--- ---
title: 'The Journey' title: 'The Journey'
template: tracker template: dailies
content: content:
items: '@self.children' items: '@self.children'
order: order:
@@ -0,0 +1,4 @@
---
title: 'Trip Map'
template: map
---
@@ -0,0 +1,4 @@
---
title: 'Trip Stats'
template: stats
---
@@ -0,0 +1,5 @@
---
title: Stories
template: stories
published: true
---
+10
View File
@@ -0,0 +1,10 @@
---
title: 'Japan & Korea 2026'
template: trip
date: '2026-06-17'
date_start: '2026-06-17'
date_end: ''
cover_image: ''
content:
items: '@self.children'
---
+9
View File
@@ -0,0 +1,9 @@
---
title: Trips
template: trips
content:
items: '@self.children'
order:
by: date
dir: desc
---
+6 -7
View File
@@ -4,8 +4,9 @@ template: post-form
access: access:
site.login: true site.login: true
# Keep in sync with active_trip in user/config/site.yaml
pageconfig: pageconfig:
parent: '/tracker' parent: '/trips/japan-korea-2026/dailies'
slug_field: 'date,title' slug_field: 'date,title'
overwrite_mode: false overwrite_mode: false
@@ -94,10 +95,8 @@ form:
classes: btn-post classes: btn-post
process: process:
- add_page: true
add_page: true upload: true
- message: 'Entry posted successfully!'
message: 'Entry posted successfully!' reset: true
-
reset: true
--- ---
+8
View File
@@ -0,0 +1,8 @@
---
title: 'GPX Manager'
template: gpx-manager
visible: false
routable: true
access:
admin.login: true
---
+13
View File
@@ -0,0 +1,13 @@
name: Cache On Save
version: 1.0.0
description: Clears Grav cache on new-entry form submission
author:
name: Mischa
email: mischa@gorinskat.nl
license: MIT
dependencies:
- { name: grav, version: '>=1.6.0' }
grav:
version: ['1.7', '2.0']
@@ -0,0 +1,39 @@
<?php
namespace Grav\Plugin\Shortcodes;
use Thunder\Shortcode\Shortcode\ShortcodeInterface;
class ChapterBreakShortcode extends Shortcode
{
public function init(): void
{
$this->shortcode->getHandlers()->add('chapter-break', function (ShortcodeInterface $sc) {
$plugin = $this->grav['plugins']->getPlugin('story-blocks');
$page = $plugin ? $plugin->getCurrentPage() : null;
$imageName = htmlspecialchars($sc->getParameter('image', ''), ENT_QUOTES);
$title = htmlspecialchars($sc->getParameter('title', ''), ENT_QUOTES);
$number = htmlspecialchars($sc->getParameter('number', ''), ENT_QUOTES);
$alt = htmlspecialchars($sc->getParameter('alt', $title), ENT_QUOTES);
$imageUrl = ($page && $imageName) ? $page->url() . '/' . $imageName : $imageName;
$numberHtml = $number
? '<span class="chapter-break__number" aria-hidden="true">' . $number . '</span>'
: '';
return <<<HTML
<div class="chapter-break" aria-label="Chapter: {$title}">
<div class="chapter-break__bg">
<img src="{$imageUrl}" alt="{$alt}" class="chapter-break__img" loading="lazy">
<div class="chapter-break__tint" aria-hidden="true"></div>
</div>
<div class="chapter-break__panel">
{$numberHtml}
<h2 class="chapter-break__title">{$title}</h2>
<div class="chapter-break__rule" aria-hidden="true"></div>
</div>
</div>
HTML;
});
}
}
@@ -0,0 +1,43 @@
<?php
namespace Grav\Plugin\Shortcodes;
use Thunder\Shortcode\Shortcode\ShortcodeInterface;
class PullQuoteShortcode extends Shortcode
{
public function init(): void
{
$this->shortcode->getHandlers()->add('pull-quote', function (ShortcodeInterface $sc) {
$plugin = $this->grav['plugins']->getPlugin('story-blocks');
$page = $plugin ? $plugin->getCurrentPage() : null;
$imageName = htmlspecialchars($sc->getParameter('image', ''), ENT_QUOTES);
$alt = htmlspecialchars($sc->getParameter('alt', ''), ENT_QUOTES);
$content = trim($sc->getContent()); // ShortcodeCore renders inner Markdown to HTML; trusted author content
$imageUrl = ($page && $imageName) ? $page->url() . '/' . $imageName : '';
$bgHtml = '';
if ($imageUrl) {
$bgHtml = <<<HTML
<div class="pull-quote__bg" aria-hidden="true">
<img src="{$imageUrl}" alt="{$alt}" class="pull-quote__bg-img" loading="lazy">
<div class="pull-quote__bg-tint"></div>
</div>
HTML;
}
$innerClass = $imageUrl ? 'pull-quote__inner' : 'pull-quote__inner pull-quote__inner--no-image';
return <<<HTML
<blockquote class="pull-quote" aria-label="Pull quote">
{$bgHtml}
<div class="{$innerClass}">
<span class="pull-quote__mark" aria-hidden="true">"</span>
<p class="pull-quote__text">{$content}</p>
<span class="pull-quote__mark pull-quote__mark--close" aria-hidden="true">"</span>
</div>
</blockquote>
HTML;
});
}
}
@@ -0,0 +1,42 @@
<?php
namespace Grav\Plugin\Shortcodes;
use Thunder\Shortcode\Shortcode\ShortcodeInterface;
class ScrollySectionShortcode extends Shortcode
{
public function init(): void
{
$this->shortcode->getHandlers()->add('scrolly-section', function (ShortcodeInterface $sc) {
$plugin = $this->grav['plugins']->getPlugin('story-blocks');
$page = $plugin ? $plugin->getCurrentPage() : null;
$imageName = htmlspecialchars($sc->getParameter('image', ''), ENT_QUOTES);
$alt = htmlspecialchars($sc->getParameter('alt', ''), ENT_QUOTES);
$caption = htmlspecialchars($sc->getParameter('caption', ''), ENT_QUOTES);
$content = $sc->getContent(); // ShortcodeCore renders inner Markdown to HTML; trusted author content
$imageUrl = ($page && $imageName) ? $page->url() . '/' . $imageName : $imageName;
$captionHtml = $caption
? '<p class="scrolly__caption">' . $caption . '</p>'
: '';
return <<<HTML
<div class="scrolly">
<div class="scrolly__media" aria-hidden="true">
<div class="scrolly__media-inner">
<img src="{$imageUrl}" alt="{$alt}" class="scrolly__img" loading="lazy">
<div class="scrolly__img-overlay"></div>
</div>
{$captionHtml}
</div>
<div class="scrolly__steps">
<div class="scrolly__steps-content">
{$content}
</div>
</div>
</div>
HTML;
});
}
}
@@ -0,0 +1,54 @@
<?php
namespace Grav\Plugin\Shortcodes;
use Thunder\Shortcode\Shortcode\ShortcodeInterface;
class SnapGalleryShortcode extends Shortcode
{
public function init(): void
{
$this->shortcode->getHandlers()->add('snap-gallery', function (ShortcodeInterface $sc) {
$plugin = $this->grav['plugins']->getPlugin('story-blocks');
$page = $plugin ? $plugin->getCurrentPage() : null;
$baseUrl = $page ? $page->url() . '/' : '';
$images = array_map('trim', explode(',', $sc->getParameter('images', '')));
$captions = array_map('trim', explode(',', $sc->getParameter('captions', '')));
$alts = array_map('trim', explode(',', $sc->getParameter('alts', '')));
$slidesHtml = '';
$dotsHtml = '';
foreach ($images as $i => $filename) {
if (!$filename) continue;
$url = $baseUrl . htmlspecialchars($filename, ENT_QUOTES);
$caption = htmlspecialchars($captions[$i] ?? '', ENT_QUOTES);
$alt = htmlspecialchars($alts[$i] ?? '', ENT_QUOTES);
$eager = $i === 0 ? 'eager' : 'lazy';
$active = $i === 0 ? ' is-active' : '';
$captionTag = $caption
? '<figcaption class="pgallery__caption">' . $caption . '</figcaption>'
: '';
$slidesHtml .= <<<HTML
<figure class="pgallery__slide" data-index="{$i}">
<img src="{$url}" alt="" class="pgallery__bg" aria-hidden="true" loading="{$eager}">
<img src="{$url}" alt="{$alt}" class="pgallery__fg" loading="{$eager}">
{$captionTag}
</figure>
HTML;
$dotsHtml .= '<span class="pgallery__dot' . $active . '" data-dot="' . $i . '" aria-hidden="true"></span>';
}
return <<<HTML
<div class="pgallery">
<div class="pgallery__frame" role="region" aria-label="Photo gallery">
{$slidesHtml}
<div class="pgallery__dots" aria-hidden="true">{$dotsHtml}</div>
</div>
</div>
HTML;
});
}
}
+33
View File
@@ -0,0 +1,33 @@
<?php
namespace Grav\Plugin;
use Grav\Common\Plugin;
use RocketTheme\Toolbox\Event\Event;
class StoryBlocksPlugin extends Plugin
{
private $currentPage = null;
public static function getSubscribedEvents(): array
{
return [
'onShortcodeHandlers' => ['onShortcodeHandlers', 0],
'onPageContentRaw' => ['onPageContentRaw', 1000],
];
}
public function onPageContentRaw(Event $event): void
{
$this->currentPage = $event['page'];
}
public function getCurrentPage()
{
return $this->currentPage;
}
public function onShortcodeHandlers(): void
{
$this->grav['shortcode']->registerAllShortcodes(__DIR__ . '/shortcodes');
}
}
+13
View File
@@ -0,0 +1,13 @@
name: Story Blocks
version: 1.0.0
description: Storytelling shortcode blocks for long-form travel stories
author:
name: Mischa
homepage: https://github.com/m-cluitmans
keywords: shortcode, story, storytelling
bugs: ''
license: MIT
dependencies:
- { name: grav, version: '>=2.0.0-rc.1' }
- { name: shortcode-core, version: '>=5.0.0' }
enabled: true
+88
View File
@@ -0,0 +1,88 @@
title: 'Daily Entry'
'@extends':
type: default
context: blueprints://pages
form:
fields:
tabs:
type: tabs
active: 1
fields:
entry:
type: tab
title: Entry
fields:
header.location_city:
type: text
label: City
placeholder: 'e.g. Kyoto'
help: 'Shown under the entry title on the tracker feed'
header.location_country:
type: text
label: Country
placeholder: 'e.g. Japan'
header.lat:
type: text
label: Latitude
placeholder: '35.0116'
header.lng:
type: text
label: Longitude
placeholder: '135.7681'
header.weather_desc:
type: select
label: Weather Condition
default: ''
options:
'': '— none —'
'Sunny': '☀️ Sunny'
'Partly cloudy': '⛅ Partly cloudy'
'Cloudy': '☁️ Cloudy'
'Foggy': '🌫️ Foggy'
'Drizzle': '🌦️ Drizzle'
'Rain': '🌧️ Rain'
'Snow': '❄️ Snow'
'Thunderstorm': '⛈️ Thunderstorm'
header.weather_temp_c:
type: number
label: 'Temperature (°C)'
placeholder: '19'
validate:
min: -60
max: 60
header.hero_image:
type: text
label: Hero Image Filename
placeholder: 'photo.jpg'
help: 'Filename of the hero/header image. Leave blank to use the first uploaded image.'
header.transport_mode:
type: select
label: How I arrived here
default: ''
options:
'': '— not specified —'
'walking': 'Walking'
'bicycle': 'Bicycle'
'bus': 'Bus'
'train': 'Train'
'car': 'Car'
header.force_connect:
type: toggle
label: Force connector line
help: 'When GPX tracks are present, always draw a connector from the previous marker to this one'
highlight: 1
default: 0
options:
1: 'Yes'
0: 'No'
validate:
type: bool
+107
View File
@@ -0,0 +1,107 @@
title: 'Story'
form:
fields:
tabs:
type: tabs
active: 1
fields:
content:
type: tab
title: Content
fields:
header.title:
type: text
label: Title
validate:
required: true
header.date:
type: datetime
label: Date
format: 'Y-m-d H:i'
validate:
required: true
header.hero_image:
type: text
label: Hero Image
placeholder: 'hero.jpg'
help: 'Filename of the hero image (upload via Media tab)'
header.hero_alt:
type: text
label: Hero Image Alt Text
placeholder: 'Description of the hero image'
content:
type: markdown
label: Content
validate:
required: true
location:
type: tab
title: Location
fields:
header.location_name:
type: text
label: Location Name
placeholder: 'e.g. Val d''Orcia'
header.location_country:
type: text
label: Country
placeholder: 'e.g. Italy'
header.lat:
type: text
label: Latitude
placeholder: '43.0780'
help: 'GPS latitude (decimal degrees)'
header.lng:
type: text
label: Longitude
placeholder: '11.6760'
help: 'GPS longitude (decimal degrees)'
header.transport_mode:
type: select
label: How I arrived here
default: ''
options:
'': '— not specified —'
'walking': 'Walking'
'bicycle': 'Bicycle'
'bus': 'Bus'
'train': 'Train'
'car': 'Car'
header.force_connect:
type: toggle
label: Force connector line
help: 'When GPX tracks are present, always draw a connector from the previous marker to this one'
highlight: 1
default: 0
options:
1: 'Yes'
0: 'No'
validate:
type: bool
publishing:
type: tab
title: Publishing
fields:
header.published:
type: toggle
label: Published
highlight: 1
default: 1
options:
1: 'Yes'
0: 'No'
validate:
type: bool
+38
View File
@@ -0,0 +1,38 @@
title: 'Trip'
'@extends':
type: default
context: blueprints://pages
form:
fields:
tabs:
type: tabs
active: 1
fields:
trip:
type: tab
title: Trip
fields:
header.date_start:
type: date
label: 'Start Date'
placeholder: '2026-06-17'
help: 'First day of the trip'
header.date_end:
type: date
label: 'End Date'
placeholder: ''
help: 'Leave blank if trip is ongoing'
header.cover_image:
type: text
label: 'Cover Image Filename'
placeholder: 'cover.jpg'
help: 'Used in the trips listing page'
header.album_url:
type: text
label: 'Photo Album URL'
placeholder: 'https://photos.google.com/...'
help: 'Link to external photo album for this trip'
+919 -3
View File
@@ -9,6 +9,18 @@ body {
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
} }
body::after {
content: '';
position: fixed;
inset: 0;
pointer-events: none;
z-index: 9998;
opacity: 0.035;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='200' height='200'%3E%3Cfilter id='noise'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.65' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='200' height='200' filter='url(%23noise)' opacity='1'/%3E%3C/svg%3E");
background-repeat: repeat;
background-size: 200px 200px;
}
.site-main { .site-main {
max-width: var(--content-width); max-width: var(--content-width);
margin: 0 auto; margin: 0 auto;
@@ -255,7 +267,7 @@ body {
} }
.entry-body { margin-bottom: var(--space-10); } .entry-body { margin-bottom: var(--space-10); }
.entry-body p { margin-bottom: 1.1em; font-size: var(--text-md); line-height: var(--leading-normal); color: var(--color-ink-2); } .entry-body p { margin-bottom: 1.4em; font-size: var(--text-md); line-height: var(--leading-normal); color: var(--color-ink-2); }
.entry-body img { max-width: 100%; height: auto; border-radius: var(--radius-sm); } .entry-body img { max-width: 100%; height: auto; border-radius: var(--radius-sm); }
.entry-footer { border-top: 1px solid var(--color-border); padding-top: var(--space-5); } .entry-footer { border-top: 1px solid var(--color-border); padding-top: var(--space-5); }
@@ -365,6 +377,84 @@ body {
font-style: italic; font-style: italic;
} }
/* ── MapLibre GL overrides ───────────────────────────────────────────────── */
/* Selectors use a parent class to reach specificity 020, beating MapLibre's
own 010 rules which load after style.css (inline CDN link in templates). */
/* Navigation controls (zoom +/) */
.maplibregl-ctrl .maplibregl-ctrl-group {
background: var(--color-canvas);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
box-shadow: var(--shadow-sm);
}
.maplibregl-ctrl-group button {
background: var(--color-canvas);
color: var(--color-ink-2);
}
.maplibregl-ctrl-group button:hover {
background: var(--color-surface-raised);
color: var(--color-ink);
}
.maplibregl-ctrl-group button + button {
border-top: 1px solid var(--color-border);
}
/* Attribution bar */
.maplibregl-ctrl-attrib {
background: color-mix(in srgb, var(--color-paper) 75%, transparent) !important;
color: var(--color-ink-muted) !important;
font-family: var(--font-ui);
font-size: 0.7rem;
backdrop-filter: blur(4px);
-webkit-backdrop-filter: blur(4px);
}
.maplibregl-ctrl-attrib a {
color: var(--color-accent) !important;
}
/* Popup */
.maplibregl-popup .maplibregl-popup-content {
background: var(--color-canvas);
color: var(--color-ink);
font-family: var(--font-ui);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
box-shadow: var(--shadow-md);
padding: var(--space-4);
}
.maplibregl-popup .maplibregl-popup-tip {
border-top-color: var(--color-canvas) !important;
}
.maplibregl-popup .maplibregl-popup-close-button {
color: var(--color-ink-muted);
font-size: 1.1rem;
padding: var(--space-1) var(--space-2);
}
.maplibregl-popup .maplibregl-popup-close-button:hover {
color: var(--color-ink);
background: transparent;
}
/* Cursor */
.maplibregl-canvas-container.maplibregl-interactive { cursor: grab; }
.maplibregl-canvas-container.maplibregl-interactive:active { cursor: grabbing; }
/* Hover tooltip (title only, non-interactive) */
.map-tip-popup { pointer-events: none; }
.map-tip-popup .maplibregl-popup-content {
padding: var(--space-2) var(--space-3);
}
.map-tip {
font-family: var(--font-ui);
font-size: var(--text-sm);
font-weight: 500;
color: var(--color-ink);
white-space: nowrap;
user-select: none;
display: block;
}
/* ── Stats page ──────────────────────────────────────────────────────────────── */ /* ── Stats page ──────────────────────────────────────────────────────────────── */
.stats-heading { .stats-heading {
@@ -377,11 +467,15 @@ body {
.stats-grid { .stats-grid {
display: grid; display: grid;
grid-template-columns: repeat(2, 1fr); grid-template-columns: repeat(3, 1fr);
gap: var(--space-4); gap: var(--space-4);
margin-bottom: var(--space-8); margin-bottom: var(--space-8);
} }
@media (max-width: 600px) {
.stats-grid { grid-template-columns: repeat(2, 1fr); }
}
.stat-block { .stat-block {
background: var(--color-canvas); background: var(--color-canvas);
border: 1px solid var(--color-border); border: 1px solid var(--color-border);
@@ -399,6 +493,7 @@ body {
color: var(--color-accent); color: var(--color-accent);
line-height: 1.1; line-height: 1.1;
margin-bottom: var(--space-2); margin-bottom: var(--space-2);
font-variant-numeric: tabular-nums;
} }
.stat-label { .stat-label {
@@ -494,7 +589,7 @@ body {
.login-form .button { display: block; width: 100%; text-align: center; padding: 0.85rem 1rem; min-height: 44px; border-radius: var(--radius-md); font-size: var(--text-base); font-family: var(--font-ui); font-weight: 600; cursor: pointer; border: none; } .login-form .button { display: block; width: 100%; text-align: center; padding: 0.85rem 1rem; min-height: 44px; border-radius: var(--radius-md); font-size: var(--text-base); font-family: var(--font-ui); font-weight: 600; cursor: pointer; border: none; }
.login-form .button.primary { background: var(--color-accent); color: var(--color-accent-on); } .login-form .button.primary { background: var(--color-accent); color: var(--color-accent-on); }
.login-form .button.primary:hover { background: var(--color-accent-hover); } .login-form .button.primary:hover { background: var(--color-accent-hover); }
.login-form .button.secondary { background: #f0f0f0; color: #333; text-decoration: none; line-height: 44px; padding: 0 1rem; } .login-form .button.secondary { background: var(--color-canvas); color: var(--color-ink); text-decoration: none; line-height: 44px; padding: 0 1rem; }
.login-form .rememberme { display: flex; align-items: center; gap: var(--space-2); font-size: var(--text-sm); } .login-form .rememberme { display: flex; align-items: center; gap: var(--space-2); font-size: var(--text-sm); }
/* ── Post form ───────────────────────────────────────────────────────────────── */ /* ── Post form ───────────────────────────────────────────────────────────────── */
@@ -632,3 +727,824 @@ body {
outline: 2px solid var(--color-accent); outline: 2px solid var(--color-accent);
outline-offset: 2px; outline-offset: 2px;
} }
/* ── Home page layout ────────────────────────────────────────────────────────── */
.home-page .site-main { max-width: 1400px; margin: 0 auto; padding: 0; }
.home-layout {
display: grid;
grid-template-columns: 45% 55%;
}
.home-map-col {
position: sticky;
top: var(--site-header-height);
height: calc(100vh - var(--site-header-height));
align-self: start;
}
.home-map {
width: 100%;
height: 100%;
}
.home-feed-col {
padding: var(--space-8) var(--space-8);
}
.home-trip-header {
margin-bottom: var(--space-8);
padding-bottom: var(--space-6);
border-bottom: 1px solid var(--color-border);
}
.home-trip-name {
font-family: var(--font-display);
font-size: var(--text-2xl);
font-weight: 400;
color: var(--color-ink);
margin-bottom: var(--space-2);
}
.home-trip-counts {
font-size: var(--text-sm);
color: var(--color-ink-muted);
}
/* ── Trip page filter bar ────────────────────────────────────────────────────── */
.trip-filter-bar {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-3);
margin-top: var(--space-4);
flex-wrap: wrap;
}
.trip-filter-group {
display: flex;
gap: var(--space-2);
flex-wrap: wrap;
}
.trip-filter-btn,
.trip-stats-btn {
font-family: var(--font-ui);
font-size: var(--text-sm);
font-weight: 500;
color: var(--color-ink-muted);
background: transparent;
border: 1px solid var(--color-border);
border-radius: var(--radius-full);
padding: var(--space-1) var(--space-4);
cursor: pointer;
transition: color 0.15s, border-color 0.15s, background 0.15s;
}
.trip-filter-btn:hover,
.trip-stats-btn:hover {
color: var(--color-ink);
border-color: var(--color-ink-muted);
}
.trip-filter-btn.is-active,
.trip-stats-btn.is-active {
color: var(--color-accent);
border-color: var(--color-accent);
background: var(--color-accent-light);
}
@media (max-width: 768px) {
.home-layout { display: flex; flex-direction: column; }
.home-map-col { position: static; height: 40vh; align-self: stretch; }
.home-map { height: 40vh; }
.home-feed-col { padding: var(--space-6) var(--space-5); }
}
/* ── Past trips archive ──────────────────────────────────────────────────────── */
.trips-heading {
font-family: var(--font-display);
font-size: var(--text-2xl);
font-weight: 400;
color: var(--color-ink);
margin-bottom: var(--space-8);
}
.trips-list {
display: flex;
flex-direction: column;
gap: var(--space-4);
}
.trip-card {
display: block;
background: var(--color-canvas);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
padding: var(--space-6);
text-decoration: none;
color: inherit;
transition: border-color 0.15s, background 0.15s;
}
.trip-card:hover {
border-color: var(--color-accent);
background: var(--color-surface-raised);
}
.trip-card-title {
font-family: var(--font-display);
font-size: var(--text-xl);
font-weight: 400;
color: var(--color-ink);
margin-bottom: var(--space-2);
}
.trip-card-meta {
display: flex;
gap: var(--space-4);
flex-wrap: wrap;
align-items: center;
}
.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 page sidebar ───────────────────────────────────────────────────────── */
.trip-counts {
font-size: var(--text-sm);
color: var(--color-ink-muted);
margin-top: var(--space-2);
}
.trip-layout {
display: grid;
grid-template-columns: 1fr 220px;
gap: var(--space-10);
align-items: start;
}
.trip-sidebar {
position: sticky;
top: calc(var(--site-header-height) + var(--space-6));
display: flex;
flex-direction: column;
gap: var(--space-6);
}
.trip-sidebar-heading {
font-size: var(--text-xs);
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--color-ink-muted);
margin-bottom: var(--space-3);
}
.trip-sidebar-list {
list-style: none;
display: flex;
flex-direction: column;
gap: var(--space-1);
}
.trip-sidebar-link {
display: block;
font-size: var(--text-sm);
color: var(--color-ink-2);
text-decoration: none;
padding: var(--space-1) 0;
transition: color 0.15s;
}
.trip-sidebar-link:hover { color: var(--color-accent); }
.trip-sidebar-date {
font-size: var(--text-xs);
color: var(--color-ink-muted);
margin-right: var(--space-2);
}
@media (max-width: 900px) {
.trip-layout { grid-template-columns: 1fr; }
.trip-sidebar { position: static; display: none; }
}
/* ── Story cards in feed ─────────────────────────────────────────────────────── */
.entry-card--story {
border: 2px solid var(--color-accent);
border-radius: var(--radius-md);
padding: var(--space-6);
background: var(--color-canvas);
}
.entry-card-photo--story { aspect-ratio: 16 / 7; }
.story-badge {
display: inline-block;
font-size: var(--text-xs);
font-weight: 600;
font-variant: small-caps;
letter-spacing: 0.08em;
color: var(--color-accent);
margin-bottom: var(--space-2);
}
/* ── Story page escape link ──────────────────────────────────────────────────── */
.story-escape {
position: fixed;
top: var(--space-5);
left: var(--space-5);
z-index: 200;
font-size: var(--text-sm);
font-weight: 500;
color: var(--color-ink);
text-decoration: none;
background: rgba(0,0,0,0.6);
padding: var(--space-2) var(--space-4);
border-radius: var(--radius-full);
backdrop-filter: blur(4px);
}
.story-escape:hover { color: var(--color-accent); }
.trip-feed { min-width: 0; }
.trip-sidebar-section {}
/* ── Trip page inline stats block ───────────────────────────────────────────── */
.trip-stats-block {
background: var(--color-canvas);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
padding: var(--space-6);
margin-bottom: var(--space-6);
}
.trip-stats-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: var(--space-4);
margin-bottom: var(--space-4);
}
@media (max-width: 600px) {
.trip-stats-grid { grid-template-columns: repeat(2, 1fr); }
}
.trip-stats-countries {
font-size: var(--text-sm);
color: var(--color-ink-2);
margin-bottom: var(--space-2);
}
.trip-stats-note {
font-size: var(--text-xs);
color: var(--color-ink-muted);
}
/* ── Trip page cycling panel ─────────────────────────────────────────────────── */
.trip-cycling-block {
background: var(--color-canvas);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
padding: var(--space-6);
margin-bottom: var(--space-6);
}
.trip-cycling-header {
display: flex;
align-items: center;
gap: var(--space-2);
margin-bottom: var(--space-4);
}
.trip-cycling-icon {
font-size: var(--text-xl);
}
.trip-cycling-title {
font-family: var(--font-display);
font-size: var(--text-lg);
color: var(--color-ink);
}
.trip-cycling-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: var(--space-4);
}
@media (max-width: 600px) {
.trip-cycling-grid { grid-template-columns: repeat(2, 1fr); }
}
/* ── Story pages ─────────────────────────────────────────────────────────── */
/* Override site-main constraints for story pages */
.template-story .site-main { max-width: none; padding: 0; }
/* Floating escape link */
.story-escape {
position: fixed;
top: calc(var(--site-header-height) + var(--space-4));
left: var(--space-4);
z-index: 100;
font-family: var(--font-ui);
font-size: var(--text-sm);
font-weight: 500;
color: rgba(255,255,255,0.85);
background: rgba(26,24,20,0.55);
backdrop-filter: blur(6px);
-webkit-backdrop-filter: blur(6px);
border: 1px solid rgba(255,255,255,0.12);
border-radius: var(--radius-full);
padding: var(--space-2) var(--space-4);
text-decoration: none;
transition: background 0.2s;
}
.story-escape:hover { background: rgba(26,24,20,0.8); }
/* Hero */
.story-hero { position: relative; }
.story-hero__img-wrap {
position: sticky;
top: 0;
height: 100vh;
width: 100%;
overflow: hidden;
}
.story-hero__img {
width: 100%;
height: 100%;
object-fit: cover;
animation: storyKenBurns 12s ease-out forwards;
transform-origin: center center;
}
@keyframes storyKenBurns {
from { transform: scale(1.06); }
to { transform: scale(1); }
}
@media (prefers-reduced-motion: reduce) {
.story-hero__img { animation: none; }
}
.story-hero__img-placeholder {
width: 100%;
height: 100%;
background: linear-gradient(135deg, #1A1814 0%, #2A2720 100%);
}
.story-hero__overlay {
position: fixed;
inset: 0;
pointer-events: none;
z-index: 1;
}
.story-hero__content {
position: absolute;
bottom: 18%;
left: 0;
width: 100%;
padding: 0 var(--space-8);
text-align: center;
color: #fff;
z-index: 2;
}
.story-hero__title {
font-family: var(--font-display);
font-size: clamp(2.2rem, 6vw, 4.5rem);
font-weight: 900;
line-height: 1.08;
letter-spacing: -0.02em;
margin-bottom: var(--space-3);
text-shadow: 0 2px 20px rgba(0,0,0,0.5);
animation: storyReveal 0.9s cubic-bezier(.16,1,.3,1) 0.2s both;
}
.story-hero__meta {
font-family: var(--font-ui);
font-size: var(--text-base);
opacity: 0.85;
letter-spacing: 0.04em;
text-shadow: 0 1px 6px rgba(0,0,0,0.4);
animation: storyReveal 0.9s cubic-bezier(.16,1,.3,1) 0.55s both;
margin: 0;
}
@keyframes storyReveal {
from { filter: blur(10px); opacity: 0; transform: translateY(22px); }
to { filter: blur(0); opacity: 1; transform: translateY(0); }
}
@media (prefers-reduced-motion: reduce) {
.story-hero__title, .story-hero__meta { animation: none; opacity: 1; filter: none; transform: none; }
}
.story-hero__scroll-cue {
position: absolute;
bottom: var(--space-8);
left: 50%;
transform: translateX(-50%);
color: rgba(255,255,255,0.7);
z-index: 2;
animation: storyCueBounce 2s ease-in-out infinite;
transition: opacity 0.4s;
}
.story-hero__scroll-cue.is-hidden { opacity: 0; pointer-events: none; }
@keyframes storyCueBounce {
0%, 100% { transform: translateX(-50%) translateY(0); }
50% { transform: translateX(-50%) translateY(6px); }
}
@media (prefers-reduced-motion: reduce) {
.story-hero__scroll-cue { animation: none; }
}
.story-hero__spacer { height: 40vh; position: relative; z-index: 3; }
/* Story body */
.story-body {
max-width: 680px;
margin: 0 auto;
padding: var(--space-16) var(--space-6) var(--space-16);
}
.story-body p {
font-family: var(--font-ui);
font-size: 1.0625rem;
line-height: 1.85;
color: var(--color-ink-2);
margin-bottom: var(--space-6);
}
.story-body h2, .story-body h3 {
font-family: var(--font-display);
color: var(--color-ink);
margin-top: var(--space-10);
margin-bottom: var(--space-4);
}
.story-footer {
margin-top: var(--space-16);
padding-top: var(--space-8);
border-top: 1px solid var(--color-border);
}
.story-footer a {
font-family: var(--font-ui);
font-size: var(--text-sm);
color: var(--color-accent);
text-decoration: none;
}
/* ── ChapterBreak ─────────────────────────────────────────── */
.chapter-break {
position: relative;
width: 100vw;
left: 50%;
margin-left: -50vw;
height: 60vh;
min-height: 320px;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
margin-top: var(--space-16);
margin-bottom: var(--space-16);
}
.chapter-break__bg { position: absolute; inset: 0; }
.chapter-break__img { width: 100%; height: 100%; object-fit: cover; display: block; }
.chapter-break__tint {
position: absolute;
inset: 0;
background: linear-gradient(to bottom, rgba(0,0,0,0.4) 0%, rgba(0,0,0,0.6) 100%);
}
.chapter-break__panel {
position: relative;
z-index: 1;
display: flex;
flex-direction: column;
align-items: center;
gap: var(--space-2);
padding: var(--space-10) var(--space-12);
background: rgba(26,24,20,0.25);
backdrop-filter: blur(18px) saturate(1.4);
-webkit-backdrop-filter: blur(18px) saturate(1.4);
border: 1px solid rgba(255,255,255,0.12);
border-radius: var(--radius-sm);
max-width: 520px;
width: 90%;
text-align: center;
opacity: 0;
filter: blur(12px);
transform: translateY(28px);
transition: opacity 0.9s cubic-bezier(.16,1,.3,1), filter 0.9s cubic-bezier(.16,1,.3,1), transform 0.9s cubic-bezier(.16,1,.3,1);
}
.chapter-break__panel.is-revealed { opacity: 1; filter: blur(0); transform: translateY(0); }
@media (prefers-reduced-motion: reduce) {
.chapter-break__panel { opacity: 1 !important; filter: none !important; transform: none !important; transition: none !important; }
}
.chapter-break__number {
font-family: var(--font-ui);
font-size: var(--text-xs);
font-weight: 600;
letter-spacing: 0.22em;
text-transform: uppercase;
color: rgba(255,255,255,0.6);
}
.chapter-break__title {
font-family: var(--font-display);
font-size: clamp(1.6rem, 4vw, 2.6rem);
font-weight: 700;
line-height: 1.15;
color: #fff;
text-shadow: 0 2px 16px rgba(0,0,0,0.4);
margin: 0;
}
.chapter-break__rule {
width: 40px;
height: 2px;
background: var(--color-accent);
border-radius: 1px;
margin-top: var(--space-2);
}
/* ── ScrollySection ───────────────────────────────────────── */
.scrolly {
position: relative;
display: grid;
grid-template-columns: 55% 45%;
width: 100vw;
left: 50%;
margin-left: -50vw;
margin-top: var(--space-16);
margin-bottom: var(--space-16);
align-items: start;
}
.scrolly__media {
position: sticky;
top: var(--site-header-height);
height: calc(100vh - var(--site-header-height));
overflow: hidden;
}
.scrolly__media-inner { position: relative; width: 100%; height: 100%; }
.scrolly__img {
width: 100%;
height: 100%;
object-fit: cover;
object-position: 50% 50%;
display: block;
will-change: filter, object-position;
transition: filter 0.6s cubic-bezier(.16,1,.3,1), object-position 1.2s cubic-bezier(.16,1,.3,1);
}
.scrolly .scrolly__img { margin: 0; border-radius: 0; max-width: none; }
.scrolly__img-overlay {
position: absolute;
inset: 0;
background: rgba(0,0,0,0);
transition: background 0.6s ease;
pointer-events: none;
}
.scrolly__caption {
position: absolute;
bottom: var(--space-4);
left: var(--space-4);
right: var(--space-4);
font-family: var(--font-ui);
font-size: var(--text-xs);
color: rgba(255,255,255,0.65);
text-align: center;
pointer-events: none;
margin: 0;
}
.scrolly__steps-content { display: none; }
.scrolly-step { min-height: 60vh; display: flex; align-items: center; padding: var(--space-16) var(--space-8); }
.scrolly-step__inner {
background: rgba(26,24,20,0.92);
backdrop-filter: blur(4px);
-webkit-backdrop-filter: blur(4px);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
padding: var(--space-8) var(--space-8);
opacity: 0;
transform: translateY(20px);
transition: opacity 0.55s cubic-bezier(.16,1,.3,1), transform 0.55s cubic-bezier(.16,1,.3,1);
}
.scrolly-step.is-active .scrolly-step__inner { opacity: 1; transform: translateY(0); }
@media (prefers-reduced-motion: reduce) {
.scrolly-step__inner { opacity: 1 !important; transform: none !important; transition: none !important; }
}
.scrolly-step__inner p {
font-family: var(--font-ui);
font-size: 1.05rem;
line-height: 1.8;
color: var(--color-ink-2);
margin-bottom: var(--space-3);
}
.scrolly-step__inner p:last-child { margin-bottom: 0; }
.scrolly-step:last-child { padding-bottom: 50vh; }
@media (max-width: 768px), (pointer: coarse) {
.scrolly { display: block; }
.scrolly__steps { margin-top: calc(-(100vh - var(--site-header-height))); position: relative; z-index: 1; }
.scrolly-step { min-height: 80vh; padding: var(--space-8) var(--space-6); align-items: center; justify-content: center; }
.scrolly-step:last-child { padding-bottom: 50vh; }
}
/* ── PullQuote ────────────────────────────────────────────── */
.pull-quote {
position: relative;
width: calc(100% + 3rem);
margin-left: -1.5rem;
margin-right: -1.5rem;
margin-top: var(--space-12);
margin-bottom: var(--space-12);
overflow: hidden;
border-radius: var(--radius-sm);
border: none;
background: transparent;
padding: 0;
opacity: 0;
filter: blur(6px);
transform: translateY(24px);
transition: opacity 0.85s cubic-bezier(.16,1,.3,1), filter 0.85s cubic-bezier(.16,1,.3,1), transform 0.85s cubic-bezier(.16,1,.3,1);
}
.pull-quote.is-revealed { opacity: 1; filter: blur(0); transform: translateY(0); }
@media (prefers-reduced-motion: reduce) {
.pull-quote { opacity: 1 !important; filter: none !important; transform: none !important; transition: none !important; }
}
.pull-quote__bg { position: absolute; inset: 0; z-index: 0; }
.pull-quote__bg-img { width: 100%; height: 100%; object-fit: cover; display: block; }
.pull-quote__bg-tint { position: absolute; inset: 0; background: rgba(0,0,0,0.55); }
.pull-quote__inner {
position: relative;
z-index: 1;
display: flex;
flex-direction: column;
align-items: center;
padding: var(--space-10) var(--space-8);
backdrop-filter: blur(14px) saturate(1.3);
-webkit-backdrop-filter: blur(14px) saturate(1.3);
background: rgba(26,24,20,0.08);
text-align: center;
}
.pull-quote__inner--no-image {
background: var(--color-canvas);
backdrop-filter: none;
-webkit-backdrop-filter: none;
}
.pull-quote__mark {
font-family: var(--font-display);
font-size: 5rem;
line-height: 0.8;
color: var(--color-accent);
opacity: 0.4;
display: block;
}
.pull-quote__mark--close { margin-top: var(--space-2); }
.pull-quote__text {
font-family: var(--font-display);
font-size: clamp(1.2rem, 3vw, 1.6rem);
font-style: italic;
color: #fff;
line-height: 1.5;
margin: var(--space-4) 0;
}
.pull-quote__inner--no-image .pull-quote__text { color: var(--color-ink); }
/* ── SnapGallery ──────────────────────────────────────────── */
.pgallery {
width: 100vw;
left: 50%;
margin-left: -50vw;
position: relative;
margin-top: var(--space-16);
margin-bottom: var(--space-16);
}
.pgallery__frame {
position: relative;
height: 100vh;
overflow-y: scroll;
scroll-snap-type: y mandatory;
-webkit-overflow-scrolling: touch;
}
.pgallery__slide {
position: relative;
height: 100vh;
display: flex;
align-items: center;
justify-content: center;
scroll-snap-align: start;
scroll-snap-stop: always;
}
.pgallery__bg {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
filter: blur(20px) brightness(0.4);
transform: scale(1.05);
}
.pgallery__fg {
position: relative;
z-index: 1;
max-height: 90vh;
max-width: 90vw;
object-fit: contain;
}
.pgallery__caption {
position: absolute;
bottom: var(--space-8);
left: 0;
right: 0;
z-index: 2;
text-align: center;
font-family: var(--font-ui);
font-size: var(--text-sm);
color: rgba(255,255,255,0.8);
margin: 0;
padding: 0 var(--space-8);
}
.pgallery__dots {
position: absolute;
right: var(--space-4);
top: 50%;
transform: translateY(-50%);
z-index: 3;
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.pgallery__dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: rgba(255,255,255,0.35);
transition: background 0.25s;
display: block;
}
.pgallery__dot.is-active { background: var(--color-accent); }
/* ── Stories listing ──────────────────────────────────────── */
.stories-listing { padding: var(--space-10) 0; }
.stories-listing__heading {
font-family: var(--font-display);
font-size: var(--text-2xl);
font-weight: 400;
color: var(--color-ink);
margin-bottom: var(--space-10);
}
.stories-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: var(--space-8);
}
@media (max-width: 640px) { .stories-grid { grid-template-columns: 1fr; } }
.story-card {
text-decoration: none;
color: inherit;
display: flex;
flex-direction: column;
border-radius: var(--radius-md);
overflow: hidden;
background: var(--color-canvas);
border: 1px solid var(--color-border);
transition: box-shadow 0.2s;
}
.story-card:hover { box-shadow: var(--shadow-md); }
.story-card__photo { aspect-ratio: 16/9; overflow: hidden; }
.story-card__photo img { width: 100%; height: 100%; object-fit: cover; display: block; transition: transform 0.4s ease; }
.story-card:hover .story-card__photo img { transform: scale(1.03); }
.story-card__photo--empty { background: var(--color-surface-raised); }
.story-card__body { padding: var(--space-5); }
.story-card__date {
display: block;
font-family: var(--font-ui);
font-size: var(--text-xs);
color: var(--color-ink-muted);
text-transform: uppercase;
letter-spacing: 0.06em;
margin-bottom: var(--space-1);
}
.story-card__location {
display: block;
font-family: var(--font-ui);
font-size: var(--text-xs);
color: var(--color-ink-muted);
margin-bottom: var(--space-3);
}
.story-card__title {
font-family: var(--font-display);
font-size: var(--text-lg);
font-weight: 400;
color: var(--color-ink);
margin-bottom: var(--space-3);
line-height: var(--leading-snug);
}
.story-card__cta {
font-family: var(--font-ui);
font-size: var(--text-sm);
color: var(--color-accent);
}
.stories-empty {
font-family: var(--font-ui);
color: var(--color-ink-muted);
font-style: italic;
}
+14 -12
View File
@@ -1,16 +1,18 @@
:root { :root {
/* ── Colors ─────────────────────────────────────────────── */ /* ── Dark palette (warm notebook) ──────────────────────────────────────── */
--color-ink: #17171A; --color-paper: #1A1814; /* page background — warm near-black */
--color-ink-2: #4A4850; --color-canvas: #22201B; /* card surfaces, form backgrounds */
--color-ink-muted: #9896A0; --color-ink: #EDE8DF; /* primary text — warm cream */
--color-paper: #F7F5F2; --color-ink-2: #B8B0A4; /* body text — muted warm */
--color-canvas: #FFFFFF; --color-ink-muted: #7A7268; /* labels, timestamps, captions */
--color-border: #E8E6E3; --color-border: #2E2B25; /* standard dividers */
--color-border-soft: #F0EDEA; --color-border-soft: #252219; /* subtle dividers */
--color-accent: #1F6B5A; --color-accent: #2A8C73; /* teal — lightened for dark contrast */
--color-accent-hover: #185647; --color-accent-hover: #236655; /* hover/pressed teal */
--color-accent-light: #EBF5F2; --color-accent-light: #1A2E29; /* pale teal tint backgrounds */
--color-accent-on: #FFFFFF; --color-accent-on: #FFFFFF; /* text on accent surfaces */
--color-surface-raised: #2A2720; /* elevated surfaces: tooltips, hover */
--color-ink-inverse: #17171A; /* text on accent-coloured buttons */
/* ── Fonts ───────────────────────────────────────────────── */ /* ── Fonts ───────────────────────────────────────────────── */
--font-display: 'DM Serif Display', Georgia, serif; --font-display: 'DM Serif Display', Georgia, serif;
+278
View File
@@ -0,0 +1,278 @@
/* Shared MapLibre GL utilities — loaded by map.html.twig, dailies.html.twig, home.html.twig */
(function (global) {
var ACCENT = '#2A8C73';
var ACCENT_DIM = '#155244';
var MAP_STYLE = 'https://basemaps.cartocdn.com/gl/dark-matter-gl-style/style.json';
/* Build a GeoJSON LineString feature */
function lineFeature(coords) {
return { type: 'Feature', properties: {}, geometry: { type: 'LineString', coordinates: coords } };
}
/*
* Catmull-Rom spline through waypoints dense interpolated coords.
* Produces a smooth curve that passes through every entry dot.
* steps: interpolated points per segment (16 is plenty for daily entries).
*/
function catmullRomSpline(coords, steps) {
if (coords.length < 2) return coords;
steps = steps || 16;
var out = [];
for (var i = 0; i < coords.length - 1; i++) {
var p0 = coords[Math.max(i - 1, 0)];
var p1 = coords[i];
var p2 = coords[i + 1];
var p3 = coords[Math.min(i + 2, coords.length - 1)];
for (var s = 0; s < steps; s++) {
var t = s / steps;
var t2 = t * t;
var t3 = t2 * t;
out.push([
0.5 * ((2*p1[0]) + (-p0[0]+p2[0])*t + (2*p0[0]-5*p1[0]+4*p2[0]-p3[0])*t2 + (-p0[0]+3*p1[0]-3*p2[0]+p3[0])*t3),
0.5 * ((2*p1[1]) + (-p0[1]+p2[1])*t + (2*p0[1]-5*p1[1]+4*p2[1]-p3[1])*t2 + (-p0[1]+3*p1[1]-3*p2[1]+p3[1])*t3)
]);
}
}
out.push(coords[coords.length - 1]);
return out;
}
/*
* Progressively draw the journey line using a requestAnimationFrame loop.
* splineCoords: dense interpolated coords from catmullRomSpline().
*/
function animateJourneyLine(map, splineCoords, sourceId) {
if (splineCoords.length < 2) return;
var segDist = [0];
for (var i = 1; i < splineCoords.length; i++) {
var dx = splineCoords[i][0] - splineCoords[i - 1][0];
var dy = splineCoords[i][1] - splineCoords[i - 1][1];
segDist.push(segDist[i - 1] + Math.sqrt(dx * dx + dy * dy));
}
var totalDist = segDist[segDist.length - 1];
var DURATION = 5000;
var startTime = performance.now();
function frame(now) {
if (!map.getSource(sourceId)) return;
var t = Math.min((now - startTime) / DURATION, 1);
var eased = 1 - Math.pow(1 - t, 3);
var target = eased * totalDist;
var animCoords = [splineCoords[0]];
for (var j = 1; j < splineCoords.length; j++) {
if (segDist[j] <= target) {
animCoords.push(splineCoords[j]);
} else {
var frac = (target - segDist[j - 1]) / (segDist[j] - segDist[j - 1]);
animCoords.push([
splineCoords[j - 1][0] + (splineCoords[j][0] - splineCoords[j - 1][0]) * frac,
splineCoords[j - 1][1] + (splineCoords[j][1] - splineCoords[j - 1][1]) * frac
]);
break;
}
}
map.getSource(sourceId).setData(lineFeature(animCoords));
if (t < 1) requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
}
/*
* Add a journey line to a loaded map dotted, subordinate style so GPX
* tracks read as the primary route where they exist.
* coords: [[lng, lat], ...] raw waypoints (daily entry positions).
*/
function addJourneyLine(map, coords, sourceId) {
if (coords.length < 2) return;
var splineCoords = catmullRomSpline(coords, 16);
map.addSource(sourceId, { type: 'geojson', data: lineFeature([splineCoords[0]]) });
map.addLayer({
id: sourceId + '-line', type: 'line', source: sourceId,
layout: { 'line-join': 'round', 'line-cap': 'round' },
paint: {
'line-color': ACCENT,
'line-width': 2,
'line-opacity': 0.45,
'line-dasharray': [0, 2.5]
}
});
var reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (reducedMotion) {
map.getSource(sourceId).setData(lineFeature(splineCoords));
} else {
animateJourneyLine(map, splineCoords, sourceId);
}
}
/*
* Return a styled <div> element for a map marker dot.
* isLatest: make it larger with a teal ring.
*/
function createDotMarker(isLatest) {
var el = document.createElement('div');
var size = isLatest ? 18 : 12;
var bg = isLatest ? ACCENT_DIM : ACCENT;
var ring = isLatest ? ',0 0 0 4px rgba(42,140,115,0.25)' : '';
el.style.cssText = [
'width:' + size + 'px',
'height:' + size + 'px',
'background:' + bg,
'border:2px solid #fff',
'border-radius:50%',
'box-shadow:0 1px 4px rgba(0,0,0,0.4)' + ring,
'cursor:pointer'
].join(';');
return el;
}
/* ── GPX connector algorithm ────────────────────────────────────────── */
/* Haversine distance in km between two [lat, lng] points */
function haversineKm(lat1, lng1, lat2, lng2) {
var R = 6371;
var dLat = (lat2 - lat1) * Math.PI / 180;
var dLng = (lng2 - lng1) * Math.PI / 180;
var a = Math.sin(dLat / 2) * Math.sin(dLat / 2) +
Math.cos(lat1 * Math.PI / 180) * Math.cos(lat2 * Math.PI / 180) *
Math.sin(dLng / 2) * Math.sin(dLng / 2);
return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
}
/*
* Extract trackpoints from a toGeoJSON output.
* Returns [[lat, lng], ...] latitude first (internal convention).
* GeoJSON coordinates are [lng, lat]; we flip them here.
*/
function extractTrackpoints(geojson) {
var points = [];
(geojson.features || []).forEach(function (feat) {
var coords = [];
if (feat.geometry.type === 'LineString') {
coords = feat.geometry.coordinates;
} else if (feat.geometry.type === 'MultiLineString') {
feat.geometry.coordinates.forEach(function (line) {
coords = coords.concat(line);
});
}
coords.forEach(function (c) { points.push([c[1], c[0]]); }); // [lng,lat] → [lat,lng]
});
return points;
}
/*
* Check whether a marker is within thresholdKm of any trackpoint in the array.
* trackpoints: [[lat, lng], ...] (internal convention, latitude first).
* Samples every 10th point for performance; always checks the last point.
*/
function isNearTrack(markerLat, markerLng, trackpoints, thresholdKm) {
if (!trackpoints || trackpoints.length === 0) return false;
var degLat = thresholdKm / 111;
var degLng = thresholdKm / (111 * Math.cos(markerLat * Math.PI / 180));
for (var i = 0; i < trackpoints.length; i += 10) {
var pt = trackpoints[i];
if (Math.abs(pt[0] - markerLat) > degLat || Math.abs(pt[1] - markerLng) > degLng) continue;
if (haversineKm(markerLat, markerLng, pt[0], pt[1]) <= thresholdKm) return true;
}
// Always check the last point (may be skipped by stride=10).
// Note: per-point degree pre-filter in the loop is functionally equivalent
// to a per-file bounding-box skip at this data scale.
var last = trackpoints[trackpoints.length - 1];
return haversineKm(markerLat, markerLng, last[0], last[1]) <= thresholdKm;
}
/*
* Build journey line segments from entries and GPX trackpoints.
*
* entries: [{lat, lng, force_connect}, ...] in chronological order
* allTrackpoints: [ [[lat,lng],...], ... ] one sub-array per GPX file
* thresholdKm: proximity radius (default 10)
*
* Returns array of segments, each segment being [[lng, lat], ...] in MapLibre
* coordinate order. A segment with < 2 points is omitted.
*
* Rules:
* - No GPX files all adjacent pairs connected (one segment)
* - GPX present, pair covered by same file connector suppressed
* - GPX present, pair NOT covered by any single file connector drawn
* - force_connect on arriving entry always draw connector
*/
function buildJourneySegments(entries, allTrackpoints, thresholdKm) {
thresholdKm = thresholdKm || 10;
var hasGpx = allTrackpoints && allTrackpoints.length > 0;
var segments = [];
var current = [];
for (var i = 0; i < entries.length; i++) {
var e = entries[i];
var lngLat = [parseFloat(e.lng), parseFloat(e.lat)]; // MapLibre: [lng, lat]
if (i === 0) {
current.push(lngLat);
continue;
}
var prev = entries[i - 1];
var connect;
if (!hasGpx || e.force_connect) {
connect = true;
} else {
var pLat = parseFloat(prev.lat);
var pLng = parseFloat(prev.lng);
var cLat = parseFloat(e.lat);
var cLng = parseFloat(e.lng);
var covered = false;
for (var f = 0; f < allTrackpoints.length; f++) {
if (isNearTrack(pLat, pLng, allTrackpoints[f], thresholdKm) &&
isNearTrack(cLat, cLng, allTrackpoints[f], thresholdKm)) {
covered = true;
break;
}
}
connect = !covered;
}
if (connect) {
current.push(lngLat);
} else {
if (current.length >= 2) segments.push(current);
current = [lngLat]; // start new segment from this point
}
}
if (current.length >= 2) segments.push(current);
return segments;
}
/*
* Draw journey segments calls addJourneyLine once per segment.
* baseSourceId: e.g. 'journey' sources become 'journey-0', 'journey-1', ...
* (single segment gets plain 'journey' for backwards compatibility).
*/
function addJourneySegments(map, segments, baseSourceId) {
segments.forEach(function (coords, i) {
var sid = segments.length === 1 ? baseSourceId : baseSourceId + '-' + i;
addJourneyLine(map, coords, sid);
});
}
global.MapUtils = {
MAP_STYLE: MAP_STYLE,
ACCENT: ACCENT,
addJourneyLine: addJourneyLine,
addJourneySegments: addJourneySegments,
buildJourneySegments: buildJourneySegments,
extractTrackpoints: extractTrackpoints,
createDotMarker: createDotMarker
};
})(window);
@@ -0,0 +1,183 @@
{% extends 'default.html.twig' %}
{% block content %}
{% set journal_entries = page.collection() %}
{% set stories_page = grav.pages.find(page.parent().route ~ '/stories') %}
{% set story_entries = stories_page ? stories_page.children.published() : [] %}
{% set all_items = [] %}
{% for e in journal_entries %}
{% set all_items = all_items|merge([{'type': 'journal', 'page': e, 'date': e.date}]) %}
{% endfor %}
{% for s in story_entries %}
{% set all_items = all_items|merge([{'type': 'story', 'page': s, 'date': s.date}]) %}
{% endfor %}
{% set all_items = all_items|sort((a, b) => a.date < b.date ? 1 : -1) %}
{# Collect GPS entries for mini-map #}
{% set map_entries = [] %}
{% for item in all_items %}
{% if item.type == 'journal' and item.page.header.lat is not empty and item.page.header.lng is not empty %}
{% set map_entries = map_entries|merge([{
'lat': item.page.header.lat,
'lng': item.page.header.lng,
'title': item.page.title,
'slug': item.page.slug,
'url': item.page.url,
'force_connect': item.page.header.force_connect ? true : false,
'transport_mode': item.page.header.transport_mode ? item.page.header.transport_mode : null
}]) %}
{% endif %}
{% endfor %}
{# Collect GPX URLs from parent trip page for connector algorithm #}
{% set trip_page = page.parent() %}
{% set gpx_urls = [] %}
{% for name, media in trip_page.media.all %}
{% if name|split('.')|last == 'gpx' %}
{% set gpx_urls = gpx_urls|merge([trip_page.url ~ '/' ~ name]) %}
{% endif %}
{% endfor %}
{% if map_entries|length > 0 %}
<div class="feed-map-wrap">
<div class="feed-map" id="feed-map"></div>
<a class="feed-map-link" href="{{ page.parent().url }}/map">View full map →</a>
</div>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/maplibre-gl@4/dist/maplibre-gl.css">
<script src="https://cdn.jsdelivr.net/npm/maplibre-gl@4/dist/maplibre-gl.js"></script>
<script src="https://cdn.jsdelivr.net/npm/@mapbox/togeojson@0.16.2/togeojson.min.js"></script>
<script src="{{ url('theme://js/maplibre-utils.js') }}"></script>
<script>
var FEED_ENTRIES = {{ map_entries|json_encode|raw }};
var GPX_URLS = {{ gpx_urls|json_encode|raw }};
var feedMap = new maplibregl.Map({
container: 'feed-map',
style: MapUtils.MAP_STYLE,
center: [20, 20],
zoom: 2
});
feedMap.on('load', function () {
var bounds = new maplibregl.LngLatBounds();
FEED_ENTRIES.forEach(function (entry, i) {
var isLatest = (i === FEED_ENTRIES.length - 1);
var lngLat = [parseFloat(entry.lng), parseFloat(entry.lat)];
bounds.extend(lngLat);
var el = MapUtils.createDotMarker(isLatest);
el.dataset.url = entry.url;
var popup = new maplibregl.Popup({ offset: 12, closeButton: false, closeOnClick: false, className: 'map-tip-popup' })
.setLngLat(lngLat)
.setHTML('<span class="map-tip">' + entry.title + '</span>');
el.addEventListener('mouseenter', function () { popup.addTo(feedMap); });
el.addEventListener('mouseleave', function () { popup.remove(); });
el.addEventListener('click', function () { window.location.href = entry.url; });
new maplibregl.Marker({ element: el }).setLngLat(lngLat).addTo(feedMap);
});
if (FEED_ENTRIES.length === 1) {
feedMap.jumpTo({ center: [parseFloat(FEED_ENTRIES[0].lng), parseFloat(FEED_ENTRIES[0].lat)], zoom: 10 });
} else {
feedMap.fitBounds(bounds, { padding: 60, maxZoom: 11 });
}
Promise.all(GPX_URLS.map(function (url, idx) {
return fetch(url)
.then(function (r) { return r.text(); })
.then(function (text) {
var xml = new DOMParser().parseFromString(text, 'text/xml');
var geojson = toGeoJSON.gpx(xml);
return MapUtils.extractTrackpoints(geojson);
})
.catch(function (err) {
console.warn('GPX load failed (feed-map):', url, err);
return [];
});
})).then(function (allTrackpoints) {
var validTrackpoints = allTrackpoints.filter(function (tp) { return tp.length > 0; });
var segments = MapUtils.buildJourneySegments(FEED_ENTRIES, validTrackpoints, 10);
MapUtils.addJourneySegments(feedMap, segments, 'feed-journey');
});
});
</script>
{% endif %}
<div class="feed">
{% if all_items|length > 0 %}
{% for item in all_items %}
{% set entry = item.page %}
{% set hero = null %}
{% if entry.header.hero_image and entry.media[entry.header.hero_image] is defined %}
{% set hero = entry.media[entry.header.hero_image] %}
{% elseif entry.media.images|length > 0 %}
{% set hero = entry.media.images|first %}
{% endif %}
{% if item.type == 'journal' %}
<article class="entry-card" id="entry-{{ entry.slug }}" data-lat="{{ entry.header.lat }}" data-lng="{{ entry.header.lng }}">
<a class="entry-card-inner" href="{{ entry.url }}">
{% if hero %}
<div class="entry-card-photo">
<img src="{{ hero.cropResize(720, 405).url }}" alt="{{ entry.title }}" loading="lazy">
<div class="entry-card-photo-overlay">
<time class="entry-date-overlay" datetime="{{ entry.date|date('Y-m-d') }}">
{{ entry.date|date('d M Y')|upper }}
</time>
{% if entry.header.location_city or entry.header.location_country %}
<span class="entry-location-overlay">
📍
{% if entry.header.location_city %}{{ entry.header.location_city|slice(0,20) }}{% endif %}
{% if entry.header.location_city and entry.header.location_country %}, {% endif %}
{% if entry.header.location_country %}{{ entry.header.location_country }}{% endif %}
</span>
{% endif %}
</div>
</div>
{% else %}
<div class="entry-card-textmeta">
<time class="entry-date-plain" datetime="{{ entry.date|date('Y-m-d') }}">
{{ entry.date|date('d M Y')|upper }}
</time>
{% if entry.header.location_city or entry.header.location_country %}
<span class="entry-location-plain">
{%- set _loc = [] -%}
{%- if entry.header.location_city -%}{%- set _loc = _loc|merge([entry.header.location_city]) -%}{%- endif -%}
{%- if entry.header.location_country -%}{%- set _loc = _loc|merge([entry.header.location_country]) -%}{%- endif -%}
📍 {{ _loc|join(', ') }}
</span>
{% endif %}
</div>
{% endif %}
<div class="entry-card-body">
<h2 class="entry-title">{{ entry.title }}</h2>
<p class="entry-excerpt">{{ entry.summary|striptags|slice(0, 250)|trim }}</p>
<span class="entry-read-more">Read entry →</span>
</div>
</a>
</article>
{% else %}
<article class="entry-card entry-card--story" id="entry-{{ entry.slug }}">
<a class="entry-card-inner" href="{{ entry.url }}">
{% if hero %}
<div class="entry-card-photo entry-card-photo--story">
<img src="{{ hero.cropResize(720, 405).url }}" alt="{{ entry.title }}" loading="lazy">
</div>
{% endif %}
<div class="entry-card-body">
<span class="story-badge">✦ Story</span>
<h2 class="entry-title">{{ entry.title }}</h2>
</div>
</a>
</article>
{% endif %}
{% endfor %}
{% else %}
<p class="feed-empty">No entries yet. The journey is about to begin.</p>
{% endif %}
</div>
{% endblock %}
+1 -1
View File
@@ -121,7 +121,7 @@
{% endif %} {% endif %}
<footer class="entry-footer"> <footer class="entry-footer">
<a href="{{ base_url_absolute }}/tracker">← Back to journal</a> <a href="{{ page.parent().url }}" onclick="if(history.length>1){event.preventDefault();history.back()}">← Back</a>
</footer> </footer>
</article> </article>
{% endblock %} {% endblock %}
@@ -0,0 +1,165 @@
{% extends 'partials/base.html.twig' %}
{% block content %}
{% set trips_page = grav.pages.find('/trips') %}
{% set trips = trips_page ? trips_page.children.published() : [] %}
<div class="gpx-manager">
<h1 class="gpx-manager__title">GPX Files</h1>
{% if trips is empty %}
<p>No trips found.</p>
{% else %}
{% for trip in trips %}
<section class="gpx-trip" data-route="{{ trip.route }}">
<h2 class="gpx-trip__name">{{ trip.title }}</h2>
<div class="gpx-file-list" id="files-{{ trip.slug }}">
<p class="gpx-loading">Loading…</p>
</div>
<form class="gpx-upload-form" data-trip-route="{{ trip.route }}">
<label class="gpx-upload-label">
<input type="file" accept=".gpx,application/gpx+xml" name="file" class="gpx-file-input">
</label>
<button type="submit" class="gpx-upload-btn">Upload</button>
<span class="gpx-status"></span>
</form>
</section>
{% endfor %}
{% endif %}
</div>
<style>
.gpx-manager { max-width: 720px; margin: 2rem auto; padding: 0 1rem; font-family: 'DM Sans', sans-serif; }
.gpx-manager__title { font-family: 'DM Serif Display', serif; font-size: 1.75rem; margin-bottom: 2rem; }
.gpx-trip { border: 1px solid #e0ddd6; border-radius: 8px; padding: 1.25rem; margin-bottom: 1.5rem; }
.gpx-trip__name { font-size: 1.1rem; font-weight: 600; margin: 0 0 1rem; }
.gpx-table { width: 100%; border-collapse: collapse; font-size: 0.875rem; margin-bottom: 1rem; }
.gpx-table th { text-align: left; color: #666; font-weight: 500; padding: 0.25rem 0.5rem; border-bottom: 1px solid #e0ddd6; }
.gpx-table td { padding: 0.5rem; border-bottom: 1px solid #f0ede8; }
.gpx-empty, .gpx-loading { color: #888; font-size: 0.875rem; margin-bottom: 0.75rem; }
.gpx-upload-form { display: flex; align-items: center; gap: 0.75rem; flex-wrap: wrap; margin-top: 0.75rem; }
.gpx-upload-btn { background: #1F6B5A; color: #fff; border: none; border-radius: 5px; padding: 0.4rem 1rem; font-size: 0.875rem; cursor: pointer; }
.gpx-upload-btn:disabled { opacity: 0.5; cursor: default; }
.gpx-delete { background: none; border: 1px solid #ccc; border-radius: 4px; padding: 0.2rem 0.5rem; font-size: 0.8rem; cursor: pointer; color: #c0392b; }
.gpx-delete:disabled { opacity: 0.5; }
.gpx-status { font-size: 0.8rem; color: #555; }
.gpx-status.error { color: #c0392b; }
</style>
<script>
const API = '/api/v1';
function formatSize(bytes) {
if (bytes >= 1048576) return (bytes / 1048576).toFixed(1) + ' MB';
return (bytes / 1024).toFixed(0) + ' KB';
}
function slugifyFilename(filename) {
const lastDot = filename.lastIndexOf('.');
const name = lastDot > 0 ? filename.slice(0, lastDot) : filename;
const ext = lastDot > 0 ? filename.slice(lastDot).toLowerCase() : '';
const slug = name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, '');
return slug + ext;
}
function formatDate(iso) {
return new Date(iso).toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' });
}
async function apiFetch(url, options) {
const res = await fetch(url, { credentials: 'include', ...options });
if (res.status === 401) { window.location.href = '/admin'; return null; }
return res;
}
async function loadFiles(tripRoute) {
const res = await apiFetch(`${API}/pages${tripRoute}/media`);
if (!res || !res.ok) return [];
const data = await res.json();
return (data.data || []).filter(f => f.filename.toLowerCase().endsWith('.gpx'));
}
async function renderTrip(tripEl) {
const route = tripEl.dataset.route;
const list = tripEl.querySelector('.gpx-file-list');
list.innerHTML = '<p class="gpx-loading">Loading…</p>';
const files = await loadFiles(route);
if (files.length === 0) {
list.innerHTML = '<p class="gpx-empty">No GPX files.</p>';
return;
}
const rows = files.map(f =>
`<tr>
<td>${f.filename}</td>
<td>${formatSize(f.size)}</td>
<td>${formatDate(f.modified)}</td>
<td><button class="gpx-delete" data-filename="${f.filename}">Delete</button></td>
</tr>`
).join('');
list.innerHTML = `<table class="gpx-table">
<thead><tr><th>File</th><th>Size</th><th>Modified</th><th></th></tr></thead>
<tbody>${rows}</tbody>
</table>`;
list.querySelectorAll('.gpx-delete').forEach(btn => {
btn.addEventListener('click', async () => {
if (!confirm(`Delete ${btn.dataset.filename}?`)) return;
btn.disabled = true;
const res = await apiFetch(
`${API}/pages${route}/media/${encodeURIComponent(btn.dataset.filename)}`,
{ method: 'DELETE' }
);
if (res && (res.ok || res.status === 204)) {
await renderTrip(tripEl);
} else {
btn.disabled = false;
alert('Delete failed — check console.');
}
});
});
}
function initUpload(formEl) {
formEl.addEventListener('submit', async e => {
e.preventDefault();
const route = formEl.dataset.tripRoute;
const fileInput = formEl.querySelector('input[type=file]');
const file = fileInput.files[0];
const status = formEl.querySelector('.gpx-status');
const btn = formEl.querySelector('.gpx-upload-btn');
if (!file) { status.textContent = 'Choose a file first.'; return; }
status.textContent = 'Uploading…';
status.className = 'gpx-status';
btn.disabled = true;
const slugged = slugifyFilename(file.name);
const fd = new FormData();
fd.append('file', file.slice(0, file.size, file.type), slugged);
const res = await apiFetch(`${API}/pages${route}/media`, { method: 'POST', body: fd });
btn.disabled = false;
if (res && res.ok) {
status.textContent = 'Uploaded!';
fileInput.value = '';
await renderTrip(formEl.closest('.gpx-trip'));
setTimeout(() => { status.textContent = ''; }, 3000);
} else {
const err = res ? await res.json().catch(() => ({})) : {};
status.textContent = 'Error: ' + (err.detail || (res ? res.statusText : 'network error'));
status.className = 'gpx-status error';
}
});
}
document.querySelectorAll('.gpx-trip').forEach(renderTrip);
document.querySelectorAll('.gpx-upload-form').forEach(initUpload);
</script>
{% endblock %}

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