Compare commits
84 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7dcaa703e0 | |||
| a3565677a5 | |||
| 37c38e925a | |||
| 3301f049cc | |||
| b1665dad80 | |||
| d9fd5eb74c | |||
| dfca8ef6e2 | |||
| 6ce77d7be7 | |||
| 2adf06831c | |||
| 3772a64a0e | |||
| 3bd1e61817 | |||
| 14e386a122 | |||
| 8152fe79b6 | |||
| 1a247e1889 | |||
| 103ceb62b9 | |||
| 3845d1b5e4 | |||
| c123a035ce | |||
| dfd1c38396 | |||
| 48b877c439 | |||
| 0dc9095b4b | |||
| fcdb3de387 | |||
| 3b5dc18ec6 | |||
| a06f744ec1 | |||
| c514bfd4a9 | |||
| 916969c96f | |||
| 3ef8d48ee2 | |||
| 997baf4cc3 | |||
| 456fc94c8e | |||
| 044e74f5d3 | |||
| f7df6ef37e | |||
| a363052f5f | |||
| b431cfc0ac | |||
| 87a782ae12 | |||
| 12c5b2c4a1 | |||
| 0d1688c6c4 | |||
| a9043f711e | |||
| 93005bd7cd | |||
| fe0aa669bc | |||
| 897da36a21 | |||
| eb739d80ab | |||
| 0478a18fa8 | |||
| 2508936928 | |||
| 650e97883b | |||
| 2eef8fbf9a | |||
| 11224289de | |||
| 69c9f4f939 | |||
| 010478b3fa | |||
| 49d10f4816 | |||
| a9eda558c0 | |||
| 16b44513f2 | |||
| ab8a5138dd | |||
| b66f1cdb2d | |||
| a78236bf3b | |||
| a9843a0a2d | |||
| 5c98bf239a | |||
| 86b2778a47 | |||
| 035c92f293 | |||
| fbc4fc195b | |||
| 597add6c1d | |||
| 1c9a6711b3 | |||
| 537f443cf1 | |||
| e4451857c2 | |||
| feeef865aa | |||
| 5c02432ce0 | |||
| d3ef42f04f | |||
| bae9d68943 | |||
| dc162ff58c | |||
| 3d5e29e26c | |||
| ba3a2ea9e7 | |||
| 64b7fcc166 | |||
| 0bb3b3bcce | |||
| a1acabbf17 | |||
| 70b4e1ca7a | |||
| 24f3c14d77 | |||
| d1066d7eb3 | |||
| ffda4568ab | |||
| 86997cb878 | |||
| 50a5f2d178 | |||
| 2a32917568 | |||
| 24acae2a85 | |||
| 534b9a96f1 | |||
| 3a79fe2cc7 | |||
| 9a9220e066 | |||
| c05b9e3400 |
@@ -1,3 +1,4 @@
|
||||
/plugins/
|
||||
!/plugins/
|
||||
!/plugins/cache-on-save/
|
||||
/data/
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
login: mischa
|
||||
state: enabled
|
||||
title: Mischa
|
||||
email: mischa@gorinskat.nl
|
||||
fullname: Mischa
|
||||
hashed_password: $2y$10$koiWKjhhipph4uTbm7fWjOj79uwxfE/mYSXGKANrAvUrSqezY3xL2
|
||||
language: en
|
||||
access:
|
||||
admin:
|
||||
login: true
|
||||
super: true
|
||||
site:
|
||||
login: true
|
||||
state: enabled
|
||||
title: Mischa
|
||||
email: mischa@gorinskat.nl
|
||||
fullname: Mischa
|
||||
hashed_password: '$2y$10$dUEYTopGEDouFoAa/Wxw6.vsOA71yr3gSStfDvr10aKm4ih9ObQ7m'
|
||||
language: en
|
||||
api:
|
||||
super: true
|
||||
access: true
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
types:
|
||||
gpx:
|
||||
type: file
|
||||
mime: application/gpx+xml
|
||||
|
||||
@@ -1 +1 @@
|
||||
salt: lsUHWFkCwvGZrL
|
||||
{ }
|
||||
@@ -6,3 +6,4 @@ author:
|
||||
taxonomies: [category, tag]
|
||||
metadata:
|
||||
description: 'Into the East — travel journal'
|
||||
active_trip: japan-korea-2026
|
||||
|
||||
@@ -28,10 +28,10 @@ languages:
|
||||
pages_fallback_only: false
|
||||
debug: false
|
||||
home:
|
||||
alias: /tracker
|
||||
alias: /home
|
||||
hide_in_urls: false
|
||||
pages:
|
||||
type: regular
|
||||
type: flex
|
||||
dirs:
|
||||
- 'page://'
|
||||
theme: intotheeast
|
||||
@@ -41,7 +41,7 @@ pages:
|
||||
list:
|
||||
count: 20
|
||||
dateformat:
|
||||
default: 'd M Y'
|
||||
default: 'Y-m-d H:i'
|
||||
short: 'D, d M Y G:i:s'
|
||||
long: 'D, d M Y G:i:s'
|
||||
publish_dates: true
|
||||
@@ -210,7 +210,7 @@ session:
|
||||
domain: null
|
||||
path: null
|
||||
gpm:
|
||||
releases: stable
|
||||
releases: testing
|
||||
official_gpm_only: true
|
||||
http:
|
||||
method: curl
|
||||
@@ -221,7 +221,7 @@ http:
|
||||
verify_peer: true
|
||||
verify_host: true
|
||||
accounts:
|
||||
type: regular
|
||||
type: flex
|
||||
storage: file
|
||||
avatar: gravatar
|
||||
flex:
|
||||
|
||||
|
After Width: | Height: | Size: 221 KiB |
|
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.
|
||||
|
After Width: | Height: | Size: 204 KiB |
|
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.
|
||||
|
After Width: | Height: | Size: 126 KiB |
|
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.
|
||||
|
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.
|
||||
|
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.
|
||||
|
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.
|
||||
|
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.
|
||||
|
After Width: | Height: | Size: 155 KiB |
@@ -0,0 +1,5 @@
|
||||
---
|
||||
title: Stories
|
||||
template: stories
|
||||
published: true
|
||||
---
|
||||
@@ -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: ''
|
||||
---
|
||||
|
After Width: | Height: | Size: 92 KiB |
|
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.
|
||||
|
After Width: | Height: | Size: 57 KiB |
|
After Width: | Height: | Size: 71 KiB |
|
After Width: | Height: | Size: 57 KiB |
|
After Width: | Height: | Size: 83 KiB |
|
After Width: | Height: | Size: 164 KiB |
|
After Width: | Height: | Size: 124 KiB |
|
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.
|
||||
|
After Width: | Height: | Size: 146 KiB |
|
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.
|
||||
|
After Width: | Height: | Size: 39 KiB |
|
After Width: | Height: | Size: 49 KiB |
|
After Width: | Height: | Size: 45 KiB |
@@ -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.
|
||||
@@ -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.
|
||||
|
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.
|
||||
|
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.
|
||||
|
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.
|
||||
|
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.
|
||||
|
After Width: | Height: | Size: 45 KiB |
@@ -1,6 +1,6 @@
|
||||
---
|
||||
title: 'The Journey'
|
||||
template: tracker
|
||||
template: dailies
|
||||
content:
|
||||
items: '@self.children'
|
||||
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
|
||||
---
|
||||
@@ -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'
|
||||
---
|
||||
@@ -0,0 +1,9 @@
|
||||
---
|
||||
title: Trips
|
||||
template: trips
|
||||
content:
|
||||
items: '@self.children'
|
||||
order:
|
||||
by: date
|
||||
dir: desc
|
||||
---
|
||||
@@ -4,8 +4,9 @@ template: post-form
|
||||
access:
|
||||
site.login: true
|
||||
|
||||
# Keep in sync with active_trip in user/config/site.yaml
|
||||
pageconfig:
|
||||
parent: '/tracker'
|
||||
parent: '/trips/japan-korea-2026/dailies'
|
||||
slug_field: 'date,title'
|
||||
overwrite_mode: false
|
||||
|
||||
@@ -94,10 +95,8 @@ form:
|
||||
classes: btn-post
|
||||
|
||||
process:
|
||||
-
|
||||
add_page: true
|
||||
-
|
||||
message: 'Entry posted successfully!'
|
||||
-
|
||||
reset: true
|
||||
add_page: true
|
||||
upload: true
|
||||
message: 'Entry posted successfully!'
|
||||
reset: true
|
||||
---
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
---
|
||||
title: 'GPX Manager'
|
||||
template: gpx-manager
|
||||
visible: false
|
||||
routable: true
|
||||
access:
|
||||
admin.login: true
|
||||
---
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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'
|
||||
@@ -9,6 +9,18 @@ body {
|
||||
-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 {
|
||||
max-width: var(--content-width);
|
||||
margin: 0 auto;
|
||||
@@ -255,7 +267,7 @@ body {
|
||||
}
|
||||
|
||||
.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-footer { border-top: 1px solid var(--color-border); padding-top: var(--space-5); }
|
||||
@@ -365,6 +377,84 @@ body {
|
||||
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-heading {
|
||||
@@ -377,11 +467,15 @@ body {
|
||||
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: var(--space-4);
|
||||
margin-bottom: var(--space-8);
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.stats-grid { grid-template-columns: repeat(2, 1fr); }
|
||||
}
|
||||
|
||||
.stat-block {
|
||||
background: var(--color-canvas);
|
||||
border: 1px solid var(--color-border);
|
||||
@@ -399,6 +493,7 @@ body {
|
||||
color: var(--color-accent);
|
||||
line-height: 1.1;
|
||||
margin-bottom: var(--space-2);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.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.primary { background: var(--color-accent); color: var(--color-accent-on); }
|
||||
.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); }
|
||||
|
||||
/* ── Post form ───────────────────────────────────────────────────────────────── */
|
||||
@@ -632,3 +727,824 @@ body {
|
||||
outline: 2px solid var(--color-accent);
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -1,16 +1,18 @@
|
||||
:root {
|
||||
/* ── Colors ─────────────────────────────────────────────── */
|
||||
--color-ink: #17171A;
|
||||
--color-ink-2: #4A4850;
|
||||
--color-ink-muted: #9896A0;
|
||||
--color-paper: #F7F5F2;
|
||||
--color-canvas: #FFFFFF;
|
||||
--color-border: #E8E6E3;
|
||||
--color-border-soft: #F0EDEA;
|
||||
--color-accent: #1F6B5A;
|
||||
--color-accent-hover: #185647;
|
||||
--color-accent-light: #EBF5F2;
|
||||
--color-accent-on: #FFFFFF;
|
||||
/* ── Dark palette (warm notebook) ──────────────────────────────────────── */
|
||||
--color-paper: #1A1814; /* page background — warm near-black */
|
||||
--color-canvas: #22201B; /* card surfaces, form backgrounds */
|
||||
--color-ink: #EDE8DF; /* primary text — warm cream */
|
||||
--color-ink-2: #B8B0A4; /* body text — muted warm */
|
||||
--color-ink-muted: #7A7268; /* labels, timestamps, captions */
|
||||
--color-border: #2E2B25; /* standard dividers */
|
||||
--color-border-soft: #252219; /* subtle dividers */
|
||||
--color-accent: #2A8C73; /* teal — lightened for dark contrast */
|
||||
--color-accent-hover: #236655; /* hover/pressed teal */
|
||||
--color-accent-light: #1A2E29; /* pale teal tint backgrounds */
|
||||
--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 ───────────────────────────────────────────────── */
|
||||
--font-display: 'DM Serif Display', Georgia, serif;
|
||||
|
||||
@@ -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 %}
|
||||
@@ -121,7 +121,7 @@
|
||||
{% endif %}
|
||||
|
||||
<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>
|
||||
</article>
|
||||
{% 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 %}
|
||||