Compare commits

...

46 Commits

Author SHA1 Message Date
m038 21c1d22859 fix: PhotoSwipe bg doesn't cover bottom — add !important and pin bg to viewport 2026-06-22 09:11:52 +02:00
m038 68b328dabc feat: enrich Slovenia 2024 Piran entry with coords and weather 2026-06-22 09:07:17 +02:00
m038 817bd17959 feat: split Slovenia 2024 into its own trip
Move Piran entry out of us-canada-mex-2024 into a new slovenia-2024 trip.
Rename entry folder to match the post title convention.
Fix us-canada-mex-2024 date_start to 2024-07-21 (first actual US entry).
2026-06-22 09:06:00 +02:00
m038 77dd99ee2b feat(stories): add mini-map via shared partial, add story card IDs 2026-06-22 01:39:29 +02:00
m038 857f33be54 refactor(dailies): use shared feed-map partial 2026-06-22 01:37:33 +02:00
m038 320a98893a feat: add shared feed-map partial (dailies + stories) 2026-06-22 01:33:26 +02:00
m038 e07fb3a72a feat(map): exit fullscreen on marker click, then scroll to entry
When fullscreen is active, clicking a marker now triggers fsBtn.click()
to exit cleanly (handles class, body overflow, tripMap.resize + icon),
then waits 450ms for the exit animation before scrolling to the entry
and firing the highlight. Also fixes missing icon-swap CSS for
.home-map-col.is-fullscreen (was only targeting .feed-map-wrap).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vgmzx8VTTTmCskSpQtsLTr
2026-06-22 01:12:52 +02:00
m038 1bb588d1d2 fix(trip): switch panel animation to max-height (grid-template-rows broken)
grid-template-rows: 0fr fails to fully collapse when the direct grid
child has overflow:hidden (creates a BFC that prevents 0-height).
max-height: 0 → 600px with overflow:hidden is simpler and reliable.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vgmzx8VTTTmCskSpQtsLTr
2026-06-22 01:09:38 +02:00
m038 aa1cb7411c fix(map): collapse attribution on load; darken fullscreen button
Attribution: MapLibre v4 uses <details> and may open it after load
regardless of compact:true — remove the open attribute in the load
handler to guarantee collapsed state.

Button: switch from teal to --color-canvas (#22201B) so it sits quietly
against the dark map; icon reads in --color-ink (warm cream).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vgmzx8VTTTmCskSpQtsLTr
2026-06-22 01:05:12 +02:00
m038 5fe8c015f1 fix(map): theme fullscreen button with accent colour
Replace plain white with --color-accent/--color-accent-on so the button
reads as a site control rather than a stray MapLibre element.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vgmzx8VTTTmCskSpQtsLTr
2026-06-22 01:00:50 +02:00
m038 6f9538053c feat(map): mobile fullscreen button on trip page map
Button in bottom-right of #trip-map (z-index:1000), hidden ≥769px.
Attribution moved to bottom-left to free the corner. Clicking toggles
.is-fullscreen on .home-map-col (position:fixed, 100dvh), locks body
scroll, and calls tripMap.resize() for MapLibre to re-render.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vgmzx8VTTTmCskSpQtsLTr
2026-06-22 00:56:57 +02:00
m038 5e503cf3a5 fix(map): fullscreen btn inside map div, attribution moved to bottom-left
Button is back inside #feed-map with z-index:1000 to clear all MapLibre
layers. Attribution control disabled in constructor and re-added to
bottom-left so bottom-right is free for the fullscreen button.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vgmzx8VTTTmCskSpQtsLTr
2026-06-22 00:20:23 +02:00
m038 ce860cfef9 fix(map): move fullscreen button outside feed-map div, top-right corner
MapLibre's attribution button occupies bottom-right of the container.
Moving our button out of the map div avoids MapLibre's DOM entirely,
and top-right is clear of all default MapLibre controls.
Position anchor moves to feed-map-wrap (position:relative).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vgmzx8VTTTmCskSpQtsLTr
2026-06-21 23:54:13 +02:00
m038 989755d33c feat(map): mobile fullscreen button for feed mini-map
Button in the bottom-right corner of the map, hidden ≥769px. Clicking
it toggles .is-fullscreen on .feed-map-wrap (position:fixed, full
viewport), locks body scroll, and calls feedMap.resize() so MapLibre
re-renders at the new size. Icon swaps between expand SVG and ✕.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vgmzx8VTTTmCskSpQtsLTr
2026-06-21 23:44:09 +02:00
m038 9ddf52c635 fix(trip): raise stat-value clamp floor to 2rem (32px)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vgmzx8VTTTmCskSpQtsLTr
2026-06-21 23:35:32 +02:00
m038 9b62f79301 fix(trip): raise stat-value clamp floor to --text-xl (1.75rem)
22px floor was too close to the preferred at 375px (6vw=22.5px), so
values were pinned near the minimum. 28px floor makes values pop more
on small screens while long values like ~12,366 still wrap gracefully.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vgmzx8VTTTmCskSpQtsLTr
2026-06-21 23:33:06 +02:00
m038 64aa9ec023 revert(trip): restore stat-label to --text-xs
Small label is intentional — the contrast with the larger value is the
visual hierarchy. Revert the sm bump from the previous commit.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vgmzx8VTTTmCskSpQtsLTr
2026-06-21 23:30:24 +02:00
m038 9bfd96af2c fix(trip): bump stat-label to --text-sm, widen stat-value fluid range
Label: xs (12px) → sm (14px) for clearer hierarchy below the value.
Value preferred: 5.5vw → 6vw so short values stay bold on mid phones.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vgmzx8VTTTmCskSpQtsLTr
2026-06-21 23:28:42 +02:00
m038 000af6934f fix(trip): raise stat-value clamp floor to --text-lg for visual hierarchy
14px floor was too close to the 12px label size. 1.375rem keeps the
value visually dominant over the label even at minimum size.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vgmzx8VTTTmCskSpQtsLTr
2026-06-21 23:23:39 +02:00
m038 c94e36a861 fix(trip): fluid stat-value font size with clamp()
Replaces fixed 3rem with clamp(--text-sm, 5.5vw, --text-3xl) so long
values like "4:32:15" scale down on mobile instead of overflowing.
Desktop (≥870px viewport) is unchanged at 3rem.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vgmzx8VTTTmCskSpQtsLTr
2026-06-21 23:21:58 +02:00
m038 2c831628b2 fix(trip): fix cycling stats mobile grid — span lone last card full width
7 cycling stat blocks in a 2-col mobile grid leaves a lone card in the
last row's left column with empty space on the right. Using
:last-child:nth-child(odd) + grid-column: 1/-1 spans that card across
both columns. Also minmax(0,1fr) on both grids for strictly equal widths.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vgmzx8VTTTmCskSpQtsLTr
2026-06-21 23:18:09 +02:00
m038 02fc666661 feat(trip): pill radius on panel toggles, slide animation, mobile close button
- Radius: trip-panel-toggle now uses --radius-full, consistent with filter pills
- Animation: stats/cycling blocks use CSS grid-template-rows 0fr→1fr transition
  (inner trip-panel-inner div carries decoration so border/padding don't peek
  out when collapsed; display:none removed)
- Close button: ↑ Close stats / ↑ Close cycling at bottom of each panel,
  hidden ≥769px, triggers the header toggle via data-toggle attribute

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vgmzx8VTTTmCskSpQtsLTr
2026-06-21 23:09:52 +02:00
m038 c3cb224402 style(trip): give Stats/Cycling panel toggles a square bordered style
Border + 4px radius instead of borderless text, matching the visual
weight of the filter pills without the full pill roundness.
Active state gets teal border + accent-light background like other active controls.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vgmzx8VTTTmCskSpQtsLTr
2026-06-21 23:03:56 +02:00
m038 2b8ea1963b refactor(trip): declutter filter bar — move Stats/Cycling to panel toggles
Filter bar now has one job: content type (All/Journal/Stories) + sort icon (↑/↓).
Stats and Cycling move to a lean text-button row below the bar with a
rotating ▾ caret — CSS handles expand/collapse state via .is-active,
no JS changes needed for the caret animation.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vgmzx8VTTTmCskSpQtsLTr
2026-06-21 22:58:28 +02:00
m038 f94880e758 feat: add sort toggle to dailies and stories pages
dailies: reverse Twig output to ascending (matching trip default),
add feed-sort-bar above feed, add sort JS using [data-type] + appendChild.

stories: wrap heading in flex header row with sort button inline,
add sort JS targeting .story-card children of .stories-grid.

CSS: feed-sort-bar (right-aligned button above feed),
stories-listing__header (flex row, baseline-aligned), heading margin moved to header.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vgmzx8VTTTmCskSpQtsLTr
2026-06-21 22:27:11 +02:00
m038 b6142cee44 feat(trip): add ascending/descending sort toggle button
Button sits in the right filter group alongside Stats/Cycling.
Default state: ascending (↑ Oldest first, no highlight).
Toggled state: descending (↓ Newest first, is-active pill style).
DOM reversal uses insertBefore against the anchored #feed-filter-empty
so the empty-state message stays last regardless of sort direction.
Interacts safely with the type filter (show/hide by data-type).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vgmzx8VTTTmCskSpQtsLTr
2026-06-21 22:21:55 +02:00
m038 53bfe5955d fix(photoswipe): target currSlide.container not currSlide.el
pswp.currSlide is a Slide instance whose DOM element is stored as
.container (.pswp__zoom-wrap). The .el property belongs to the
itemHolder wrapper, not the Slide — so currSlide.el was always
undefined, the null-guard exited early, and no animation played.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vgmzx8VTTTmCskSpQtsLTr
2026-06-21 21:45:22 +02:00
m038 9f503c011d fix(photoswipe): keyboard arrow animation via CSS keyframes
Previous approach (CSS transition + reflow trick) is unreliable in
Firefox. New approach: PhotoSwipe emits 'change' synchronously before
painting; we add a direction-aware CSS keyframe animation to the
incoming slide element, with animation-fill-mode:both so there is no
flash before the animation starts.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vgmzx8VTTTmCskSpQtsLTr
2026-06-21 21:36:48 +02:00
m038 415d95ed47 feat(photoswipe): animate keyboard arrow navigation in lightbox
PhotoSwipe's goTo() moves slides instantly (no spring animation unlike
swipe). Intercepts keydown in capture phase, sets a CSS transition and
forces a reflow before PhotoSwipe moves the container, so the browser
animates from the old position to the new one. Cleans up on close.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vgmzx8VTTTmCskSpQtsLTr
2026-06-21 21:29:06 +02:00
m038 e787544a2b feat(strip): smooth scroll animation on arrow button clicks
scroll-behavior: smooth on the strip element ensures programmatic
scrollBy calls animate consistently, cooperating with scroll-snap.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vgmzx8VTTTmCskSpQtsLTr
2026-06-21 21:22:42 +02:00
m038 9f94164c61 fix(arrows): insert strip-controls after wrap, not inside it
Dots moved inside journal-photo-wrap (overflow:hidden) earlier, so
controls were being clipped. Now inserts after the wrap element.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vgmzx8VTTTmCskSpQtsLTr
2026-06-21 21:13:43 +02:00
m038 608ccfdecd fix(partial): restore data-slides on photo strip
Missing data-slides caused base.html.twig arrow script to read
slideCount as 1 and bail before creating prev/next controls.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vgmzx8VTTTmCskSpQtsLTr
2026-06-21 21:10:48 +02:00
m038 933652fd57 refactor(templates): extract entry markup into shared partials
Creates partials/entry-journal.html.twig and partials/entry-story.html.twig
so trip, dailies, and home all use the same up-to-date markup. Home page
gains PhotoSwipe, blurred fill, adaptive aspect ratio, and hash-based
marker scroll. Future changes only need to happen in one place.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vgmzx8VTTTmCskSpQtsLTr
2026-06-21 21:07:12 +02:00
m038 fdaed1033a fix(ios): use 100dvh for PhotoSwipe to fix dynamic viewport
iOS Safari freezes 100vh at the initial viewport height (address bar
visible). 100dvh tracks the live viewport as browser chrome shows/hides.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vgmzx8VTTTmCskSpQtsLTr
2026-06-21 20:48:13 +02:00
m038 bc77baca2e fix(scroll): clear URL hash when back-to-top is clicked
Uses history.pushState to strip the stale #entry-slug without
triggering a page jump, then smooth-scrolls to the top.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vgmzx8VTTTmCskSpQtsLTr
2026-06-21 20:41:41 +02:00
m038 7c9a55224a fix(scroll): use hash navigation for marker clicks
Browser handles scroll natively via window.location.hash, respecting
scroll-margin-top. Updates URL for shareability and screen reader
compatibility. Added html { scroll-behavior: smooth } for smooth
hash navigation globally.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vgmzx8VTTTmCskSpQtsLTr
2026-06-21 20:40:15 +02:00
m038 85ba3747b1 fix(scroll): use block:start so scroll-margin-top is respected
block:center ignores scroll-margin-top. block:start positions the
entry's top edge at the margin offset, clearing the sticky header.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vgmzx8VTTTmCskSpQtsLTr
2026-06-21 20:36:46 +02:00
m038 71f8629d18 fix(scroll): add scroll-margin-top to all feed entry types
Offsets scroll targets by header height + 1rem so marker clicks land
below the sticky nav, not behind it. Applies to both .journal-post
and .entry-card (story cards).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vgmzx8VTTTmCskSpQtsLTr
2026-06-21 20:33:57 +02:00
m038 b1492918d5 feat(trip): add back-to-top button
Reuses .story-totop styles. Appears after scrolling past 80% of
viewport height, smooth-scrolls to top on click.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vgmzx8VTTTmCskSpQtsLTr
2026-06-21 20:29:07 +02:00
m038 95ea38d250 fix(feed): reduce excess spacing between entries
Removed redundant margin-bottom on .journal-post (feed gap already
separates items). Reduced padding-bottom and gap from 3rem to 2rem,
cutting the between-entry whitespace roughly in half.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vgmzx8VTTTmCskSpQtsLTr
2026-06-21 20:25:02 +02:00
m038 81be69f08d fix(photos): make PhotoSwipe background fully opaque
Prevents page dots from leaking through the semi-transparent overlay.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vgmzx8VTTTmCskSpQtsLTr
2026-06-21 20:21:33 +02:00
m038 71eaa3e788 feat(photos): adaptive aspect ratio per entry (portrait 4:5, landscape 4:3)
Portrait entries (first image taller than wide) get a 4:5 container —
Instagram's proven cap that prevents single photos dominating the screen.
Landscape entries keep 4:3. Aspect-ratio moved from slide to wrap so the
strip inherits it via height:100%.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vgmzx8VTTTmCskSpQtsLTr
2026-06-21 20:18:05 +02:00
m038 5c75f1416f feat(photos): blurred ambient fill, dots overlay, PhotoSwipe on trip + dailies
- object-fit: contain + ::before blurred background fill on all slides
- dots moved inside photo-wrap, overlaid at bottom with shadow for contrast
- arrows hidden on touch devices via @media (hover: none)
- margin-bottom increased for breathing room below photo block
- trip.html.twig brought up to parity with dailies: same structure,
  same PhotoSwipe init, same expand button, same dot overlay

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vgmzx8VTTTmCskSpQtsLTr
2026-06-21 20:09:24 +02:00
m038 3379e50503 fix(dailies): fix PhotoSwipe CSS loading + restore expand button
Move PhotoSwipe CSS from per-entry assets.addCss() (runs after head is
committed) to a single <link> tag at block start. Restore the expand
button as the reliable mobile tap target — it dispatches a synthetic
click on the visible <a> slide, which bubbles to PhotoSwipe's gallery
handler. Merge dot sync into the single module script.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vgmzx8VTTTmCskSpQtsLTr
2026-06-21 19:39:33 +02:00
m038 30c8937566 feat(dailies): replace custom lightbox with PhotoSwipe v5
Drops ~80 lines of fragile custom lightbox JS/HTML/CSS in favour of
PhotoSwipe v5 loaded from CDN. Slides are now <a> tags (the pswp-gallery
children) which always fire click events on iOS even inside scroll
containers — the root cause of the mobile tap issue. Pinch-to-zoom and
swipe-to-dismiss come for free. Dot sync kept as a separate vanilla JS
block.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vgmzx8VTTTmCskSpQtsLTr
2026-06-21 19:31:27 +02:00
m038 770a96b099 feat(dailies): 4:3 photo strip, lightbox, and dot sync
- Aspect ratio 3:2 → 4:3 (less aggressive crop; closer to phone native)
- Slides become <button> elements with data-full pointing to original image
- Tap/click any photo in the feed opens a full-screen lightbox showing the
  uncropped original; prev/next browses all feed photos; Esc/arrows/backdrop
  click close
- Dot indicator now syncs with scroll via IntersectionObserver

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vgmzx8VTTTmCskSpQtsLTr
2026-06-21 19:06:54 +02:00
20 changed files with 939 additions and 426 deletions
@@ -4,12 +4,12 @@ date: '2024-05-28 07:03'
template: entry template: entry
published: true published: true
hero_image: 'photo-1.jpg' hero_image: 'photo-1.jpg'
lat: '' lat: '45.5285'
lng: '' lng: '13.5680'
location_city: '' location_city: Piran
location_country: '' location_country: Slovenia
weather_temp_c: '' weather_temp_c: '21'
weather_desc: '' weather_desc: sunny
--- ---
A sunny day in Piran. We drove from Ljubljana through the beautiful Slovenian countryside. The more west we went, the more Mediterranean the landscape felt. Piran is a cute, Mediterranean harbor town, with little streets, squares and no cars. The view from the old fortification walls was great and the climb in the warm weather gave us a sense of accomplishment which we rewarded with a well deserved ice cream. A sunny day in Piran. We drove from Ljubljana through the beautiful Slovenian countryside. The more west we went, the more Mediterranean the landscape felt. Piran is a cute, Mediterranean harbor town, with little streets, squares and no cars. The view from the old fortification walls was great and the climb in the warm weather gave us a sense of accomplishment which we rewarded with a well deserved ice cream.
@@ -0,0 +1,11 @@
---
title: 'The Journey'
template: dailies
content:
items: '@self.children'
order:
by: date
dir: desc
filter:
published: true
---
@@ -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
---
+8
View File
@@ -0,0 +1,8 @@
---
title: 'Slovenia 2024'
template: trip
date: '2024-05-28'
date_start: '2024-05-28'
date_end: '2024-05-28'
cover_image: ''
---
+2 -2
View File
@@ -1,8 +1,8 @@
--- ---
title: 'Northern America 2024' title: 'Northern America 2024'
template: trip template: trip
date: '2024-05-28' date: '2024-07-21'
date_start: '2024-05-28' date_start: '2024-07-21'
date_end: '2024-08-07' date_end: '2024-08-07'
cover_image: '' cover_image: ''
--- ---
+248 -69
View File
@@ -1,5 +1,7 @@
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
html { scroll-behavior: smooth; }
body { body {
font-family: var(--font-ui); font-family: var(--font-ui);
font-size: var(--text-base); font-size: var(--text-base);
@@ -87,7 +89,7 @@ body::after {
/* ── Feed ────────────────────────────────────────────────────────────────────── */ /* ── Feed ────────────────────────────────────────────────────────────────────── */
.feed { display: flex; flex-direction: column; gap: var(--space-12); } .feed { display: flex; flex-direction: column; gap: var(--space-8); }
.feed-empty { color: var(--color-ink-muted); font-style: italic; } .feed-empty { color: var(--color-ink-muted); font-style: italic; }
.entry-card { .entry-card {
@@ -95,8 +97,9 @@ body::after {
text-decoration: none; text-decoration: none;
color: inherit; color: inherit;
border-bottom: 1px solid var(--color-border); border-bottom: 1px solid var(--color-border);
padding-bottom: var(--space-12); padding-bottom: var(--space-8);
transition: background 0.15s; transition: background 0.15s;
scroll-margin-top: calc(var(--site-header-height) + var(--space-4));
} }
/* Card: photo variant */ /* Card: photo variant */
@@ -162,8 +165,8 @@ body::after {
.journal-post { .journal-post {
border-bottom: 1px solid var(--color-border); border-bottom: 1px solid var(--color-border);
padding-bottom: var(--space-12); padding-bottom: var(--space-8);
margin-bottom: var(--space-12); scroll-margin-top: calc(var(--site-header-height) + var(--space-4));
} }
.journal-post-header { .journal-post-header {
@@ -202,13 +205,21 @@ body::after {
color: var(--color-ink-muted); color: var(--color-ink-muted);
} }
.journal-photo-wrap {
position: relative;
margin-bottom: var(--space-5);
border-radius: var(--radius-md);
overflow: hidden;
aspect-ratio: 4 / 3;
}
.journal-photo-strip { .journal-photo-strip {
display: flex; display: flex;
overflow-x: scroll; overflow-x: scroll;
scroll-snap-type: x mandatory; scroll-snap-type: x mandatory;
scroll-behavior: smooth;
scrollbar-width: none; scrollbar-width: none;
border-radius: var(--radius-md); height: 100%;
margin-bottom: var(--space-3);
} }
.journal-photo-strip::-webkit-scrollbar { display: none; } .journal-photo-strip::-webkit-scrollbar { display: none; }
@@ -216,34 +227,76 @@ body::after {
.journal-photo-slide { .journal-photo-slide {
flex: 0 0 100%; flex: 0 0 100%;
scroll-snap-align: start; scroll-snap-align: start;
aspect-ratio: 3 / 2;
overflow: hidden; overflow: hidden;
display: block;
text-decoration: none;
position: relative;
}
.journal-photo-slide::before {
content: '';
position: absolute;
inset: 0;
background-image: var(--thumb);
background-size: cover;
background-position: center;
filter: blur(24px) brightness(0.75);
transform: scale(1.15);
z-index: 0;
} }
.journal-photo-slide img { .journal-photo-slide img {
position: relative;
z-index: 1;
width: 100%; width: 100%;
height: 100%; height: 100%;
object-fit: cover; object-fit: contain;
display: block; display: block;
} }
.journal-photo-dots { .journal-photo-expand {
position: absolute;
bottom: var(--space-3);
right: var(--space-3);
width: 32px;
height: 32px;
background: rgba(0,0,0,0.45);
border: none;
border-radius: 50%;
color: #fff;
cursor: pointer;
display: flex; display: flex;
align-items: center;
justify-content: center; justify-content: center;
padding: 0;
z-index: 2;
-webkit-tap-highlight-color: transparent;
transform: translateZ(0);
}
.journal-photo-expand:active { background: rgba(0,0,0,0.7); }
.journal-photo-dots {
position: absolute;
bottom: var(--space-3);
left: 50%;
transform: translateX(-50%);
display: flex;
gap: var(--space-2); gap: var(--space-2);
margin-bottom: var(--space-4); z-index: 2;
} }
.journal-photo-dot { .journal-photo-dot {
width: 6px; width: 6px;
height: 6px; height: 6px;
border-radius: 9999px; border-radius: 9999px;
background: var(--color-border); background: rgba(255,255,255,0.5);
box-shadow: 0 0 0 1px rgba(0,0,0,0.2), 0 1px 3px rgba(0,0,0,0.4);
transition: background 0.2s; transition: background 0.2s;
} }
.journal-photo-dot.is-active { .journal-photo-dot.is-active {
background: var(--color-ink-muted); background: #fff;
} }
.strip-controls { .strip-controls {
@@ -254,6 +307,10 @@ body::after {
margin-bottom: var(--space-4); margin-bottom: var(--space-4);
} }
@media (hover: none) {
.strip-controls { display: none; }
}
.strip-prev, .strip-prev,
.strip-next { .strip-next {
background: transparent; background: transparent;
@@ -282,7 +339,8 @@ body::after {
.journal-post-body p:last-child { margin-bottom: 0; } .journal-post-body p:last-child { margin-bottom: 0; }
.journal-post.is-highlighted, .journal-post.is-highlighted,
.entry-card.is-highlighted { .entry-card.is-highlighted,
.story-card.is-highlighted {
animation: card-highlight 0.7s ease-out forwards; animation: card-highlight 0.7s ease-out forwards;
} }
@@ -395,53 +453,28 @@ body::after {
.gallery-thumb:focus { outline: 2px solid var(--color-accent); outline-offset: 2px; } .gallery-thumb:focus { outline: 2px solid var(--color-accent); outline-offset: 2px; }
/* ── Lightbox ────────────────────────────────────────────────────────────────── */ /* ── PhotoSwipe overrides ─────────────────────────────────────────────────────── */
.lightbox { .pswp__bg { background: #000; }
position: fixed;
inset: 0; /* pswp.css loads in <body> after this stylesheet, so !important is needed to win */
background: rgba(0,0,0,0.94); .pswp { height: 100dvh !important; }
z-index: 1000; /* Pin bg directly to viewport so it can't be cut short by parent height rounding */
display: flex; .pswp__bg { position: fixed !important; inset: 0 !important; }
align-items: center;
justify-content: center; /* Keyboard arrow navigation slide-in animations */
.pswp-key-from-right { animation: pswpKeyFromRight 0.35s cubic-bezier(0.4, 0, 0.22, 1) both; }
.pswp-key-from-left { animation: pswpKeyFromLeft 0.35s cubic-bezier(0.4, 0, 0.22, 1) both; }
@keyframes pswpKeyFromRight {
from { transform: translateX(48px); opacity: 0; }
to { transform: translateX(0); opacity: 1; }
} }
@keyframes pswpKeyFromLeft {
.lightbox[hidden] { display: none; } from { transform: translateX(-48px); opacity: 0; }
to { transform: translateX(0); opacity: 1; }
.lightbox-img {
max-width: 92vw;
max-height: 90vh;
object-fit: contain;
border-radius: var(--radius-sm);
display: block;
} }
.lightbox-close,
.lightbox-prev,
.lightbox-next {
position: absolute;
background: rgba(255,255,255,0.12);
border: none;
color: #fff;
cursor: pointer;
border-radius: 50%;
width: 44px;
height: 44px;
font-size: 1.4rem;
display: flex;
align-items: center;
justify-content: center;
transition: background 0.15s;
}
.lightbox-close { top: 1rem; right: 1rem; }
.lightbox-prev { left: 0.75rem; top: 50%; transform: translateY(-50%); }
.lightbox-next { right: 0.75rem; top: 50%; transform: translateY(-50%); }
.lightbox-close:hover,
.lightbox-prev:hover,
.lightbox-next:hover { background: rgba(255,255,255,0.26); }
/* ── Map page ────────────────────────────────────────────────────────────────── */ /* ── Map page ────────────────────────────────────────────────────────────────── */
.map-page .site-main { max-width: none; padding: 0; } .map-page .site-main { max-width: none; padding: 0; }
@@ -571,7 +604,7 @@ body::after {
.stat-value { .stat-value {
display: block; display: block;
font-family: var(--font-display); font-family: var(--font-display);
font-size: var(--text-3xl); font-size: clamp(2rem, 6vw, var(--text-3xl));
font-weight: 400; font-weight: 400;
color: var(--color-accent); color: var(--color-accent);
line-height: 1.1; line-height: 1.1;
@@ -615,6 +648,7 @@ body::after {
/* ── Mini-map on tracker feed ────────────────────────────────────────────────── */ /* ── Mini-map on tracker feed ────────────────────────────────────────────────── */
.feed-map-wrap { .feed-map-wrap {
position: relative;
margin-bottom: var(--space-10); margin-bottom: var(--space-10);
border-radius: var(--radius-md); border-radius: var(--radius-md);
overflow: hidden; overflow: hidden;
@@ -623,6 +657,7 @@ body::after {
} }
.feed-map { .feed-map {
position: relative;
height: 240px; height: 240px;
width: 100%; width: 100%;
} }
@@ -631,6 +666,53 @@ body::after {
.feed-map { height: 300px; } .feed-map { height: 300px; }
} }
.feed-map-fullscreen-btn {
position: absolute;
bottom: var(--space-2);
right: var(--space-2);
width: 2rem;
height: 2rem;
background: var(--color-canvas);
border: none;
border-radius: var(--radius-sm);
color: var(--color-ink);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
box-shadow: var(--shadow-sm);
transition: background 0.15s;
}
.feed-map-fullscreen-btn:hover { background: var(--color-paper); }
.feed-map-fs-close { display: none; font-size: 1rem; line-height: 1; }
@media (min-width: 769px) {
.feed-map-fullscreen-btn { display: none; }
}
.feed-map-wrap.is-fullscreen {
position: fixed;
inset: 0;
z-index: 9999;
margin: 0;
border-radius: 0;
border: none;
}
.feed-map-wrap.is-fullscreen .feed-map {
height: 100dvh;
}
.feed-map-wrap.is-fullscreen .feed-map-link { display: none; }
.feed-map-wrap.is-fullscreen .feed-map-fs-open,
.home-map-col.is-fullscreen .feed-map-fs-open { display: none; }
.feed-map-wrap.is-fullscreen .feed-map-fs-close,
.home-map-col.is-fullscreen .feed-map-fs-close { display: block; }
.feed-map-link { .feed-map-link {
display: block; display: block;
text-align: right; text-align: right;
@@ -852,10 +934,22 @@ body::after {
} }
.home-map { .home-map {
position: relative;
width: 100%; width: 100%;
height: 100%; height: 100%;
} }
.home-map-col.is-fullscreen {
position: fixed !important;
inset: 0;
z-index: 9999;
height: 100dvh !important;
}
.home-map-col.is-fullscreen .home-map {
height: 100dvh;
}
.home-feed-col { .home-feed-col {
padding: var(--space-8) var(--space-8); padding: var(--space-8) var(--space-8);
} }
@@ -881,6 +975,12 @@ body::after {
/* ── Trip page filter bar ────────────────────────────────────────────────────── */ /* ── Trip page filter bar ────────────────────────────────────────────────────── */
.feed-sort-bar {
display: flex;
justify-content: flex-end;
margin-bottom: var(--space-4);
}
.trip-filter-bar { .trip-filter-bar {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -923,6 +1023,46 @@ body::after {
background: var(--color-accent-light); background: var(--color-accent-light);
} }
.trip-panel-toggles {
display: flex;
gap: var(--space-5);
margin-top: var(--space-3);
}
.trip-panel-toggle {
background: transparent;
border: 1px solid var(--color-border);
border-radius: var(--radius-full);
padding: var(--space-1) var(--space-3);
font-family: var(--font-ui);
font-size: var(--text-sm);
font-weight: 500;
color: var(--color-ink-muted);
cursor: pointer;
display: flex;
align-items: center;
gap: var(--space-2);
transition: color 0.15s, border-color 0.15s, background 0.15s;
}
.trip-panel-toggle:hover {
color: var(--color-ink);
border-color: var(--color-ink-muted);
}
.trip-panel-toggle.is-active {
color: var(--color-accent);
border-color: var(--color-accent);
background: var(--color-accent-light);
}
.trip-panel-caret {
display: inline-block;
font-size: 0.75em;
transition: transform 0.2s ease;
}
.trip-panel-toggle.is-active .trip-panel-caret { transform: rotate(180deg); }
@media (max-width: 768px) { @media (max-width: 768px) {
.home-layout { display: flex; flex-direction: column; } .home-layout { display: flex; flex-direction: column; }
.home-map-col { position: static; height: 40vh; align-self: stretch; } .home-map-col { position: static; height: 40vh; align-self: stretch; }
@@ -1073,12 +1213,51 @@ body::after {
/* ── Trip page inline stats block ───────────────────────────────────────────── */ /* ── Trip page inline stats block ───────────────────────────────────────────── */
.trip-stats-block { .trip-stats-block,
.trip-cycling-block {
max-height: 0;
overflow: hidden;
margin-bottom: 0;
transition: max-height 0.4s ease, margin-bottom 0.35s ease;
}
.trip-stats-block.is-open,
.trip-cycling-block.is-open {
max-height: 600px;
margin-bottom: var(--space-6);
}
.trip-panel-inner {
background: var(--color-canvas); background: var(--color-canvas);
border: 1px solid var(--color-border); border: 1px solid var(--color-border);
border-radius: var(--radius-md); border-radius: var(--radius-md);
padding: var(--space-6); padding: var(--space-6);
margin-bottom: var(--space-6); }
.trip-panel-close {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
margin-top: var(--space-6);
padding: var(--space-2) var(--space-4);
background: transparent;
border: 1px solid var(--color-border);
border-radius: var(--radius-full);
font-family: var(--font-ui);
font-size: var(--text-sm);
color: var(--color-ink-muted);
cursor: pointer;
transition: color 0.15s, border-color 0.15s;
}
.trip-panel-close:hover {
color: var(--color-ink);
border-color: var(--color-ink-muted);
}
@media (min-width: 769px) {
.trip-panel-close { display: none; }
} }
.trip-stats-grid { .trip-stats-grid {
@@ -1089,7 +1268,7 @@ body::after {
} }
@media (max-width: 600px) { @media (max-width: 600px) {
.trip-stats-grid { grid-template-columns: repeat(2, 1fr); } .trip-stats-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
} }
.trip-stats-countries { .trip-stats-countries {
@@ -1105,13 +1284,6 @@ body::after {
/* ── Trip page cycling panel ─────────────────────────────────────────────────── */ /* ── 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 { .trip-cycling-header {
display: flex; display: flex;
@@ -1137,7 +1309,8 @@ body::after {
} }
@media (max-width: 600px) { @media (max-width: 600px) {
.trip-cycling-grid { grid-template-columns: repeat(2, 1fr); } .trip-cycling-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.trip-cycling-grid .stat-block:last-child:nth-child(odd) { grid-column: 1 / -1; }
} }
/* ── Story pages ─────────────────────────────────────────────────────────── */ /* ── Story pages ─────────────────────────────────────────────────────────── */
@@ -1708,12 +1881,18 @@ body::after {
/* ── Stories listing ──────────────────────────────────────── */ /* ── Stories listing ──────────────────────────────────────── */
.stories-listing { padding: var(--space-10) 0; } .stories-listing { padding: var(--space-10) 0; }
.stories-listing__header {
display: flex;
align-items: baseline;
justify-content: space-between;
margin-bottom: var(--space-10);
}
.stories-listing__heading { .stories-listing__heading {
font-family: var(--font-display); font-family: var(--font-display);
font-size: var(--text-2xl); font-size: var(--text-2xl);
font-weight: 400; font-weight: 400;
color: var(--color-ink); color: var(--color-ink);
margin-bottom: var(--space-10); margin-bottom: 0;
} }
.stories-grid { .stories-grid {
display: grid; display: grid;
+98 -118
View File
@@ -1,6 +1,7 @@
{% extends 'default.html.twig' %} {% extends 'default.html.twig' %}
{% block content %} {% block content %}
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/photoswipe@5/dist/photoswipe.css">
{% set journal_entries = page.collection() %} {% set journal_entries = page.collection() %}
{% set stories_page = grav.pages.find(page.parent().route ~ '/stories') %} {% set stories_page = grav.pages.find(page.parent().route ~ '/stories') %}
{% set story_entries = stories_page ? stories_page.children.published() : [] %} {% set story_entries = stories_page ? stories_page.children.published() : [] %}
@@ -13,7 +14,8 @@
{% set all_items = all_items|merge([{'type': 'story', 'page': s, 'date': s.date}]) %} {% set all_items = all_items|merge([{'type': 'story', 'page': s, 'date': s.date}]) %}
{% endfor %} {% endfor %}
{# No sort needed: page.collection() returns journal entries date-descending per dailies.md config. Dailies has no stories, so no re-merge sort is needed. #} {# page.collection() returns date-descending; reverse to match ascending default on trip page. #}
{% set all_items = all_items|reverse %}
{# Collect GPS entries for mini-map #} {# Collect GPS entries for mini-map #}
{% set map_entries = [] %} {% set map_entries = [] %}
@@ -33,134 +35,112 @@
{% set trip_page = page.parent() %} {% set trip_page = page.parent() %}
{% if map_entries|length > 0 %} {% include 'partials/feed-map.html.twig' with {
<div class="feed-map-wrap"> 'map_entries': map_entries,
<div class="feed-map" id="feed-map"></div> 'map_id': 'feed-map',
<a class="feed-map-link" href="{{ page.parent().url }}/map">View full map →</a> 'map_var': 'feedMap',
'link_href': page.parent().url ~ '/map',
'card_prefix': 'entry-',
'trip_page': trip_page,
'show_journey': true
} only %}
<div class="feed-sort-bar">
<button class="trip-stats-btn" id="feed-sort-toggle" aria-label="Sort: oldest first">↑ Oldest first</button>
</div> </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="{{ url('theme://js/maplibre-utils.js') }}"></script>
<script>
var FEED_ENTRIES = {{ map_entries|json_encode|raw }};
{% set _ac = trip_page.header.autoconnect ?? 'on' %}
var AUTOCONNECT = "{{ _ac == 'intelligent_gpx' ? 'on' : _ac }}";
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 });
}
var segments = MapUtils.buildJourneySegments(FEED_ENTRIES, { connectMode: AUTOCONNECT });
MapUtils.addJourneySegments(feedMap, segments, 'feed-journey');
});
</script>
{% endif %}
<div class="feed"> <div class="feed">
{% if all_items|length > 0 %} {% if all_items|length > 0 %}
{% for item in all_items %} {% for item in all_items %}
{% set entry = item.page %} {% set entry = item.page %}
{% if item.type == 'journal' %} {% if item.type == 'journal' %}
{% set weather_icons = { {% include 'partials/entry-journal.html.twig' %}
'Sunny': '☀️', 'Partly cloudy': '⛅', 'Cloudy': '☁️',
'Foggy': '🌫️', 'Drizzle': '🌦️', 'Rain': '🌧️',
'Snow': '❄️', 'Thunderstorm': '⛈️'
} %}
<article class="journal-post" id="entry-{{ entry.slug }}" data-type="journal" data-lat="{{ entry.header.lat }}" data-lng="{{ entry.header.lng }}">
<header class="journal-post-header">
<h2 class="journal-post-title">{{ entry.title }}</h2>
<p class="journal-post-meta">
<a class="journal-post-permalink" href="{{ entry.url }}">
<time datetime="{{ entry.date|date('Y-m-d') }}">{{ entry.date|date('d M Y')|upper }}</time>
</a>
{% if entry.header.location_city or entry.header.location_country %}
<span class="journal-post-location">
· 📍
{%- 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 %}
{% if entry.header.weather_desc %}
<span class="journal-post-weather">· {{ weather_icons[entry.header.weather_desc] ?? '' }} {{ entry.header.weather_desc }}</span>
{% endif %}
</p>
</header>
{% set images = entry.media.images %}
{% if images|length > 0 %}
<div class="journal-photo-strip" data-slides="{{ images|length }}">
{% for img in images %}
<div class="journal-photo-slide">
<img src="{{ img.cropResize(900, 600).url }}" alt="{{ entry.title }}" loading="lazy">
</div>
{% endfor %}
</div>
{% if images|length > 1 %}
<div class="journal-photo-dots" aria-hidden="true">
{% for img in images %}
<span class="journal-photo-dot{% if loop.first %} is-active{% endif %}"></span>
{% endfor %}
</div>
{% endif %}
{% endif %}
<div class="journal-post-body">{{ entry.content|raw }}</div>
</article>
{% else %} {% else %}
{% set hero = null %} {% include 'partials/entry-story.html.twig' %}
{% 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 %}
<a class="entry-card entry-card--story" id="entry-{{ entry.slug }}" data-type="story" 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>
{% endif %} {% endif %}
{% endfor %} {% endfor %}
{% else %} {% else %}
<p class="feed-empty">No entries yet. The journey is about to begin.</p> <p class="feed-empty">No entries yet. The journey is about to begin.</p>
{% endif %} {% endif %}
</div> </div>
<script type="module">
import PhotoSwipeLightbox from 'https://cdn.jsdelivr.net/npm/photoswipe@5/dist/photoswipe-lightbox.esm.min.js';
const lightbox = new PhotoSwipeLightbox({
gallery: '.pswp-gallery',
children: 'a.journal-photo-slide',
pswpModule: () => import('https://cdn.jsdelivr.net/npm/photoswipe@5/dist/photoswipe.esm.min.js')
});
lightbox.on('afterOpen', function () {
var pswp = lightbox.pswp;
var keyDir = 0;
var clearTimer = null;
function onKey(e) {
if (e.key === 'ArrowRight') keyDir = 1;
else if (e.key === 'ArrowLeft') keyDir = -1;
else keyDir = 0;
}
document.addEventListener('keydown', onKey, true);
pswp.on('change', function () {
if (!keyDir) return;
var dir = keyDir;
keyDir = 0;
var el = pswp.currSlide && pswp.currSlide.container;
if (!el) return;
el.classList.remove('pswp-key-from-left', 'pswp-key-from-right');
el.offsetWidth;
el.classList.add(dir > 0 ? 'pswp-key-from-right' : 'pswp-key-from-left');
clearTimeout(clearTimer);
clearTimer = setTimeout(function () { el.classList.remove('pswp-key-from-left', 'pswp-key-from-right'); }, 400);
});
pswp.on('close', function () {
document.removeEventListener('keydown', onKey, true);
clearTimeout(clearTimer);
});
});
lightbox.init();
/* Per-strip: dot sync + expand button → tap the visible slide to trigger pswp */
document.querySelectorAll('.journal-photo-wrap').forEach(function (wrap) {
var strip = wrap.querySelector('.journal-photo-strip');
var slides = Array.from(strip.querySelectorAll('a.journal-photo-slide'));
var expandBtn = wrap.querySelector('.journal-photo-expand');
var article = wrap.closest('article');
var dots = article ? Array.from(article.querySelectorAll('.journal-photo-dot')) : [];
var visibleIdx = 0;
var io = new IntersectionObserver(function (entries) {
entries.forEach(function (e) {
if (!e.isIntersecting) return;
visibleIdx = slides.indexOf(e.target);
dots.forEach(function (d) { d.classList.remove('is-active'); });
if (dots[visibleIdx]) dots[visibleIdx].classList.add('is-active');
});
}, { root: strip, threshold: 0.5 });
slides.forEach(function (s) { io.observe(s); });
if (expandBtn && slides.length) {
expandBtn.addEventListener('click', function () {
slides[visibleIdx].dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
});
}
});
</script>
<script>
(function() {
var sortBtn = document.getElementById('feed-sort-toggle');
if (!sortBtn) return;
var feed = document.querySelector('.feed');
var ascending = true;
sortBtn.addEventListener('click', function() {
ascending = !ascending;
var entries = Array.from(feed.querySelectorAll('[data-type]'));
entries.reverse().forEach(function(el) { feed.appendChild(el); });
sortBtn.textContent = ascending ? '↑ Oldest first' : '↓ Newest first';
sortBtn.setAttribute('aria-label', ascending ? 'Sort: oldest first' : 'Sort: newest first');
sortBtn.classList.toggle('is-active', !ascending);
});
})();
</script>
{% endblock %} {% endblock %}
+67 -66
View File
@@ -1,6 +1,7 @@
{% extends 'partials/base.html.twig' %} {% extends 'partials/base.html.twig' %}
{% block content %} {% block content %}
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/photoswipe@5/dist/photoswipe.css">
{% set trip_route = config.site.active_trip %} {% set trip_route = config.site.active_trip %}
{% set trip = grav.pages.find(trip_route) %} {% set trip = grav.pages.find(trip_route) %}
@@ -65,73 +66,10 @@
{% if all_items|length > 0 %} {% if all_items|length > 0 %}
{% for item in all_items %} {% for item in all_items %}
{% set entry = item.page %} {% set entry = item.page %}
{% if item.type == 'journal' %} {% if item.type == 'journal' %}
{% set weather_icons = { {% include 'partials/entry-journal.html.twig' %}
'Sunny': '☀️', 'Partly cloudy': '⛅', 'Cloudy': '☁️',
'Foggy': '🌫️', 'Drizzle': '🌦️', 'Rain': '🌧️',
'Snow': '❄️', 'Thunderstorm': '⛈️'
} %}
<article class="journal-post" id="entry-{{ entry.slug }}" data-lat="{{ entry.header.lat }}" data-lng="{{ entry.header.lng }}">
<header class="journal-post-header">
<h2 class="journal-post-title">{{ entry.title }}</h2>
<p class="journal-post-meta">
<a class="journal-post-permalink" href="{{ entry.url }}">
<time datetime="{{ entry.date|date('Y-m-d') }}">{{ entry.date|date('d M Y')|upper }}</time>
</a>
{% if entry.header.location_city or entry.header.location_country %}
<span class="journal-post-location">
· 📍
{%- 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 %}
{% if entry.header.weather_desc %}
<span class="journal-post-weather">· {{ weather_icons[entry.header.weather_desc] ?? '' }} {{ entry.header.weather_desc }}</span>
{% endif %}
</p>
</header>
{% set images = entry.media.images %}
{% if images|length > 0 %}
<div class="journal-photo-strip" data-slides="{{ images|length }}">
{% for img in images %}
<div class="journal-photo-slide">
<img src="{{ img.cropResize(900, 600).url }}" alt="{{ entry.title }}" loading="lazy">
</div>
{% endfor %}
</div>
{% if images|length > 1 %}
<div class="journal-photo-dots" aria-hidden="true">
{% for img in images %}
<span class="journal-photo-dot{% if loop.first %} is-active{% endif %}"></span>
{% endfor %}
</div>
{% endif %}
{% endif %}
<div class="journal-post-body">{{ entry.content|raw }}</div>
</article>
{% else %} {% else %}
{% set hero = null %} {% include 'partials/entry-story.html.twig' %}
{% 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 %}
<a class="entry-card entry-card--story" id="entry-{{ entry.slug }}" 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>
{% endif %} {% endif %}
{% endfor %} {% endfor %}
{% else %} {% else %}
@@ -180,7 +118,8 @@ homeMap.on('load', function () {
el.addEventListener('mouseleave', function () { popup.remove(); }); el.addEventListener('mouseleave', function () { popup.remove(); });
el.addEventListener('click', function () { el.addEventListener('click', function () {
var card = document.getElementById('entry-' + entry.slug); var card = document.getElementById('entry-' + entry.slug);
if (card) card.scrollIntoView({ behavior: 'smooth', block: 'center' }); if (!card) return;
window.location.hash = 'entry-' + entry.slug;
}); });
new maplibregl.Marker({ element: el }).setLngLat(lngLat).addTo(homeMap); new maplibregl.Marker({ element: el }).setLngLat(lngLat).addTo(homeMap);
@@ -199,6 +138,68 @@ homeMap.on('load', function () {
</script> </script>
{% endif %} {% endif %}
<script type="module">
import PhotoSwipeLightbox from 'https://cdn.jsdelivr.net/npm/photoswipe@5/dist/photoswipe-lightbox.esm.min.js';
const lightbox = new PhotoSwipeLightbox({
gallery: '.pswp-gallery',
children: 'a.journal-photo-slide',
pswpModule: () => import('https://cdn.jsdelivr.net/npm/photoswipe@5/dist/photoswipe.esm.min.js')
});
lightbox.on('afterOpen', function () {
var pswp = lightbox.pswp;
var keyDir = 0;
var clearTimer = null;
function onKey(e) {
if (e.key === 'ArrowRight') keyDir = 1;
else if (e.key === 'ArrowLeft') keyDir = -1;
else keyDir = 0;
}
document.addEventListener('keydown', onKey, true);
pswp.on('change', function () {
if (!keyDir) return;
var dir = keyDir;
keyDir = 0;
var el = pswp.currSlide && pswp.currSlide.container;
if (!el) return;
el.classList.remove('pswp-key-from-left', 'pswp-key-from-right');
el.offsetWidth;
el.classList.add(dir > 0 ? 'pswp-key-from-right' : 'pswp-key-from-left');
clearTimeout(clearTimer);
clearTimer = setTimeout(function () { el.classList.remove('pswp-key-from-left', 'pswp-key-from-right'); }, 400);
});
pswp.on('close', function () {
document.removeEventListener('keydown', onKey, true);
clearTimeout(clearTimer);
});
});
lightbox.init();
document.querySelectorAll('.journal-photo-wrap').forEach(function (wrap) {
var strip = wrap.querySelector('.journal-photo-strip');
var slides = Array.from(strip.querySelectorAll('a.journal-photo-slide'));
var expandBtn = wrap.querySelector('.journal-photo-expand');
var article = wrap.closest('article');
var dots = article ? Array.from(article.querySelectorAll('.journal-photo-dot')) : [];
var visibleIdx = 0;
var io = new IntersectionObserver(function (entries) {
entries.forEach(function (e) {
if (!e.isIntersecting) return;
visibleIdx = slides.indexOf(e.target);
dots.forEach(function (d) { d.classList.remove('is-active'); });
if (dots[visibleIdx]) dots[visibleIdx].classList.add('is-active');
});
}, { root: strip, threshold: 0.5 });
slides.forEach(function (s) { io.observe(s); });
if (expandBtn && slides.length) {
expandBtn.addEventListener('click', function () {
slides[visibleIdx].dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
});
}
});
</script>
{% else %} {% else %}
{# ══════════════════════════════════════════════════════ BETWEEN-TRIPS MODE #} {# ══════════════════════════════════════════════════════ BETWEEN-TRIPS MODE #}
@@ -66,7 +66,8 @@
controls.className = 'strip-controls'; controls.className = 'strip-controls';
controls.appendChild(prev); controls.appendChild(prev);
controls.appendChild(next); controls.appendChild(next);
dots.insertAdjacentElement('afterend', controls); var wrap = strip.closest('.journal-photo-wrap');
(wrap || dots).insertAdjacentElement('afterend', controls);
}); });
})(); })();
</script> </script>
@@ -0,0 +1,59 @@
{% set weather_icons = {
'Sunny': '☀️', 'Partly cloudy': '⛅', 'Cloudy': '☁️',
'Foggy': '🌫️', 'Drizzle': '🌦️', 'Rain': '🌧️',
'Snow': '❄️', 'Thunderstorm': '⛈️'
} %}
<article class="journal-post" id="entry-{{ entry.slug }}" data-type="journal" data-lat="{{ entry.header.lat }}" data-lng="{{ entry.header.lng }}">
<header class="journal-post-header">
<h2 class="journal-post-title">{{ entry.title }}</h2>
<p class="journal-post-meta">
<a class="journal-post-permalink" href="{{ entry.url }}">
<time datetime="{{ entry.date|date('Y-m-d') }}">{{ entry.date|date('d M Y')|upper }}</time>
</a>
{% if entry.header.location_city or entry.header.location_country %}
<span class="journal-post-location">
· 📍
{%- 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 %}
{% if entry.header.weather_desc %}
<span class="journal-post-weather">· {{ weather_icons[entry.header.weather_desc] ?? '' }} {{ entry.header.weather_desc }}</span>
{% endif %}
</p>
</header>
{% set images = entry.media.images %}
{% if images|length > 0 %}
{% set firstImg = images|first %}
{% set wrapRatio = firstImg.height > firstImg.width ? '4 / 5' : '4 / 3' %}
<div class="journal-photo-wrap" style="aspect-ratio: {{ wrapRatio }}">
<div class="journal-photo-strip pswp-gallery" id="gallery-{{ entry.slug }}" data-slides="{{ images|length }}">
{% for img in images %}
<a class="journal-photo-slide"
href="{{ img.url }}"
data-pswp-width="{{ img.width }}"
data-pswp-height="{{ img.height }}"
style="--thumb: url('{{ img.cropResize(900, 675).url }}')"
target="_blank">
<img src="{{ img.cropResize(900, 675).url }}" alt="{{ entry.title }}" loading="lazy">
</a>
{% endfor %}
</div>
{% if images|length > 1 %}
<div class="journal-photo-dots" aria-hidden="true">
{% for img in images %}
<span class="journal-photo-dot{% if loop.first %} is-active{% endif %}"></span>
{% endfor %}
</div>
{% endif %}
<button class="journal-photo-expand" aria-label="View full size">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M15 3h6v6M9 21H3v-6M21 3l-7 7M3 21l7-7"/></svg>
</button>
</div>
{% endif %}
<div class="journal-post-body">{{ entry.content|raw }}</div>
</article>
@@ -0,0 +1,17 @@
{% 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 %}
<a class="entry-card entry-card--story" id="entry-{{ entry.slug }}" data-type="story" 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>
@@ -0,0 +1,117 @@
{#
Feed mini-map partial — shared by dailies.html.twig and stories.html.twig.
Required variables (via {% include ... with {...} only %}):
map_entries — array: [{lat, lng, title, slug, url, type, force_connect, transport_mode}]
map_id — string: HTML id for the map div (e.g. 'feed-map', 'stories-map')
map_var — string: JS variable name for the MapLibre Map (e.g. 'feedMap', 'storiesMap')
link_href — string|null: URL for "View full map" link; null/empty hides the link
card_prefix — string: prefix for scroll-to card IDs ('entry-' or 'story-')
trip_page — Grav page: trip page for autoconnect setting (used when show_journey is true)
show_journey — bool: whether to draw the route connector line between markers
#}
{% if map_entries|length > 0 %}
<div class="feed-map-wrap">
<div class="feed-map" id="{{ map_id }}">
<button class="feed-map-fullscreen-btn" id="{{ map_id }}-fullscreen" aria-label="Expand map">
<svg class="feed-map-fs-open" aria-hidden="true" width="14" height="14" viewBox="0 0 14 14" fill="currentColor">
<path d="M0 0v4h1.5V1.5H4V0z M14 0H10v1.5h2.5V4H14z M0 14v-4h1.5v2.5H4V14z M14 14H10v-1.5h2.5V10H14z"/>
</svg>
<span class="feed-map-fs-close" aria-hidden="true">✕</span>
</button>
</div>
{% if link_href %}
<a class="feed-map-link" href="{{ link_href }}">View full map →</a>
{% endif %}
</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="{{ url('theme://js/maplibre-utils.js') }}"></script>
<script>
{% set js_suffix = map_id|replace({'-': '_'})|upper %}
var MAP_ENTRIES_{{ js_suffix }} = {{ map_entries|json_encode|raw }};
{% if show_journey %}
{% set _ac = trip_page ? (trip_page.header.autoconnect ?? 'on') : 'on' %}
var AUTOCONNECT_{{ js_suffix }} = "{{ _ac == 'intelligent_gpx' ? 'on' : _ac }}";
{% endif %}
var {{ map_var }} = new maplibregl.Map({
container: '{{ map_id }}',
style: MapUtils.MAP_STYLE,
center: [20, 20],
zoom: 2,
attributionControl: false
});
{{ map_var }}.addControl(new maplibregl.AttributionControl({ compact: true }), 'bottom-left');
{{ map_var }}.on('load', function () {
var attrib = {{ map_var }}.getContainer().querySelector('.maplibregl-ctrl-attrib');
if (attrib) attrib.removeAttribute('open');
var bounds = new maplibregl.LngLatBounds();
var entries = MAP_ENTRIES_{{ js_suffix }};
entries.forEach(function (entry, i) {
var isLatest = (entry.type !== 'story') && (i === entries.length - 1);
var lngLat = [parseFloat(entry.lng), parseFloat(entry.lat)];
bounds.extend(lngLat);
var el = entry.type === 'story' ? MapUtils.createStoryMarker() : 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({{ map_var }}); });
el.addEventListener('mouseleave', function () { popup.remove(); });
el.addEventListener('click', function () {
var card = document.getElementById('{{ card_prefix }}' + entry.slug);
var mapWrap = document.querySelector('.feed-map-wrap');
var isFs = mapWrap && mapWrap.classList.contains('is-fullscreen');
function scrollAndHighlight() {
if (!card) { window.location.href = entry.url; return; }
window.location.hash = '{{ card_prefix }}' + entry.slug;
setTimeout(function () {
card.classList.add('is-highlighted');
setTimeout(function () { card.classList.remove('is-highlighted'); }, 700);
}, 350);
}
if (isFs) {
var fsBtn = document.getElementById('{{ map_id }}-fullscreen');
if (fsBtn) fsBtn.click();
setTimeout(scrollAndHighlight, 450);
} else {
scrollAndHighlight();
}
});
new maplibregl.Marker({ element: el }).setLngLat(lngLat).addTo({{ map_var }});
});
if (entries.length === 1) {
{{ map_var }}.jumpTo({ center: [parseFloat(entries[0].lng), parseFloat(entries[0].lat)], zoom: 10 });
} else {
{{ map_var }}.fitBounds(bounds, { padding: 60, maxZoom: 11 });
}
{% if show_journey %}
var segments = MapUtils.buildJourneySegments(entries, { connectMode: AUTOCONNECT_{{ js_suffix }} });
MapUtils.addJourneySegments({{ map_var }}, segments, '{{ map_id }}-journey');
{% endif %}
});
</script>
<script>
(function() {
var fsBtn = document.getElementById('{{ map_id }}-fullscreen');
var mapWrap = document.querySelector('.feed-map-wrap');
if (!fsBtn || !mapWrap) return;
fsBtn.addEventListener('click', function() {
var isFs = mapWrap.classList.toggle('is-fullscreen');
fsBtn.setAttribute('aria-label', isFs ? 'Close map' : 'Expand map');
document.body.style.overflow = isFs ? 'hidden' : '';
setTimeout(function() { typeof {{ map_var }} !== 'undefined' && {{ map_var }}.resize(); }, 50);
});
})();
</script>
{% endif %}
+51 -1
View File
@@ -3,8 +3,40 @@
{% block content %} {% block content %}
{% set stories = page.children.published().order('date', 'asc') %} {% set stories = page.children.published().order('date', 'asc') %}
{# Collect stories that have coordinates for the mini-map #}
{% set map_entries = [] %}
{% for story in stories %}
{% if story.header.lat is not empty and story.header.lng is not empty %}
{% set map_entries = map_entries|merge([{
'lat': story.header.lat,
'lng': story.header.lng,
'title': story.title,
'slug': story.slug,
'url': story.url,
'type': 'story',
'force_connect': false,
'transport_mode': null
}]) %}
{% endif %}
{% endfor %}
{% set trip_page = page.parent() %}
{% include 'partials/feed-map.html.twig' with {
'map_entries': map_entries,
'map_id': 'stories-map',
'map_var': 'storiesMap',
'link_href': null,
'card_prefix': 'story-',
'trip_page': trip_page,
'show_journey': false
} only %}
<div class="stories-listing"> <div class="stories-listing">
<div class="stories-listing__header">
<h1 class="stories-listing__heading">Stories</h1> <h1 class="stories-listing__heading">Stories</h1>
<button class="trip-stats-btn" id="feed-sort-toggle" aria-label="Sort: oldest first">↑ Oldest first</button>
</div>
{% if stories|length > 0 %} {% if stories|length > 0 %}
<div class="stories-grid"> <div class="stories-grid">
@@ -19,7 +51,7 @@
{% set date_str = story.date|date('d M') ~ '' ~ story.header.end_date|date('d M Y') %} {% set date_str = story.date|date('d M') ~ '' ~ story.header.end_date|date('d M Y') %}
{% endif %} {% endif %}
<a class="story-card" href="{{ story.url }}"> <a class="story-card" id="story-{{ story.slug }}" href="{{ story.url }}">
{% if hero %} {% if hero %}
<div class="story-card__photo"> <div class="story-card__photo">
<img src="{{ hero.cropResize(720, 405).url }}" alt="{{ story.title }}" loading="lazy"> <img src="{{ hero.cropResize(720, 405).url }}" alt="{{ story.title }}" loading="lazy">
@@ -42,4 +74,22 @@
<p class="stories-empty">No stories yet — check back soon.</p> <p class="stories-empty">No stories yet — check back soon.</p>
{% endif %} {% endif %}
</div> </div>
<script>
(function() {
var sortBtn = document.getElementById('feed-sort-toggle');
if (!sortBtn) return;
var grid = document.querySelector('.stories-grid');
if (!grid) return;
var ascending = true;
sortBtn.addEventListener('click', function() {
ascending = !ascending;
var cards = Array.from(grid.querySelectorAll('.story-card'));
cards.reverse().forEach(function(el) { grid.appendChild(el); });
sortBtn.textContent = ascending ? '↑ Oldest first' : '↓ Newest first';
sortBtn.setAttribute('aria-label', ascending ? 'Sort: oldest first' : 'Sort: newest first');
sortBtn.classList.toggle('is-active', !ascending);
});
})();
</script>
{% endblock %} {% endblock %}
+170 -93
View File
@@ -1,6 +1,7 @@
{% extends 'partials/base.html.twig' %} {% extends 'partials/base.html.twig' %}
{% block content %} {% block content %}
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/photoswipe@5/dist/photoswipe.css">
{% set dailies_page = grav.pages.find(page.route ~ '/dailies') %} {% set dailies_page = grav.pages.find(page.route ~ '/dailies') %}
{% set stories_page = grav.pages.find(page.route ~ '/stories') %} {% set stories_page = grav.pages.find(page.route ~ '/stories') %}
{% set journal_entries = dailies_page ? dailies_page.children.published() : [] %} {% set journal_entries = dailies_page ? dailies_page.children.published() : [] %}
@@ -104,7 +105,14 @@
<div class="home-layout"> <div class="home-layout">
<div class="home-map-col"> <div class="home-map-col">
<div class="home-map" id="trip-map"></div> <div class="home-map" id="trip-map">
<button class="feed-map-fullscreen-btn" id="trip-map-fullscreen" aria-label="Expand map">
<svg class="feed-map-fs-open" aria-hidden="true" width="14" height="14" viewBox="0 0 14 14" fill="currentColor">
<path d="M0 0v4h1.5V1.5H4V0z M14 0H10v1.5h2.5V4H14z M0 14v-4h1.5v2.5H4V14z M14 14H10v-1.5h2.5V10H14z"/>
</svg>
<span class="feed-map-fs-close" aria-hidden="true">✕</span>
</button>
</div>
</div> </div>
<div class="home-feed-col"> <div class="home-feed-col">
@@ -126,16 +134,18 @@
<button class="trip-filter-btn" data-filter="journal" aria-pressed="false">Journal</button> <button class="trip-filter-btn" data-filter="journal" aria-pressed="false">Journal</button>
<button class="trip-filter-btn" data-filter="story" aria-pressed="false">Stories</button> <button class="trip-filter-btn" data-filter="story" aria-pressed="false">Stories</button>
</div> </div>
<div class="trip-filter-group"> <button class="trip-stats-btn" id="trip-sort-toggle" aria-label="Sort: oldest first">↑</button>
<button class="trip-stats-btn" id="trip-stats-toggle" aria-expanded="false" aria-controls="trip-stats-block">Stats</button> </div>
<div class="trip-panel-toggles">
<button class="trip-panel-toggle" id="trip-stats-toggle" aria-expanded="false" aria-controls="trip-stats-block">Stats <span class="trip-panel-caret" aria-hidden="true">▾</span></button>
{% if has_gpx %} {% if has_gpx %}
<button class="trip-stats-btn" id="trip-cycling-toggle" aria-expanded="false" aria-controls="trip-cycling-block">Cycling</button> <button class="trip-panel-toggle" id="trip-cycling-toggle" aria-expanded="false" aria-controls="trip-cycling-block">Cycling <span class="trip-panel-caret" aria-hidden="true">▾</span></button>
{% endif %} {% endif %}
</div> </div>
</div> </div>
</div>
<div id="trip-stats-block" class="trip-stats-block" style="display:none"> <div id="trip-stats-block" class="trip-stats-block">
<div class="trip-panel-inner">
<div class="trip-stats-grid"> <div class="trip-stats-grid">
<div class="stat-block"> <div class="stat-block">
<span class="stat-value">{{ days_on_road }}</span> <span class="stat-value">{{ days_on_road }}</span>
@@ -170,10 +180,13 @@
<p class="trip-stats-countries">{{ country_display|join(' · ') }}</p> <p class="trip-stats-countries">{{ country_display|join(' · ') }}</p>
{% endif %} {% endif %}
<p class="trip-stats-note">{{ has_gpx ? 'Distance based on GPS track data.' : 'Distance is approximate — straight lines between entry locations.' }}</p> <p class="trip-stats-note">{{ has_gpx ? 'Distance based on GPS track data.' : 'Distance is approximate — straight lines between entry locations.' }}</p>
<button class="trip-panel-close" data-toggle="trip-stats-toggle">↑ Close stats</button>
</div>
</div> </div>
{% if has_gpx %} {% if has_gpx %}
<div id="trip-cycling-block" class="trip-cycling-block" style="display:none"> <div id="trip-cycling-block" class="trip-cycling-block">
<div class="trip-panel-inner">
<div class="trip-cycling-header"> <div class="trip-cycling-header">
<span class="trip-cycling-icon">🚴</span> <span class="trip-cycling-icon">🚴</span>
<span class="trip-cycling-title">Cycling Stats</span> <span class="trip-cycling-title">Cycling Stats</span>
@@ -208,6 +221,8 @@
<span class="stat-label">km/h avg speed</span> <span class="stat-label">km/h avg speed</span>
</div> </div>
</div> </div>
<button class="trip-panel-close" data-toggle="trip-cycling-toggle">↑ Close cycling</button>
</div>
</div> </div>
{% endif %} {% endif %}
@@ -215,73 +230,10 @@
{% if all_items|length > 0 %} {% if all_items|length > 0 %}
{% for item in all_items %} {% for item in all_items %}
{% set entry = item.page %} {% set entry = item.page %}
{% if item.type == 'journal' %} {% if item.type == 'journal' %}
{% set weather_icons = { {% include 'partials/entry-journal.html.twig' %}
'Sunny': '☀️', 'Partly cloudy': '⛅', 'Cloudy': '☁️',
'Foggy': '🌫️', 'Drizzle': '🌦️', 'Rain': '🌧️',
'Snow': '❄️', 'Thunderstorm': '⛈️'
} %}
<article class="journal-post" id="entry-{{ entry.slug }}" data-type="journal" data-lat="{{ entry.header.lat }}" data-lng="{{ entry.header.lng }}">
<header class="journal-post-header">
<h2 class="journal-post-title">{{ entry.title }}</h2>
<p class="journal-post-meta">
<a class="journal-post-permalink" href="{{ entry.url }}">
<time datetime="{{ entry.date|date('Y-m-d') }}">{{ entry.date|date('d M Y')|upper }}</time>
</a>
{% if entry.header.location_city or entry.header.location_country %}
<span class="journal-post-location">
· 📍
{%- 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 %}
{% if entry.header.weather_desc %}
<span class="journal-post-weather">· {{ weather_icons[entry.header.weather_desc] ?? '' }} {{ entry.header.weather_desc }}</span>
{% endif %}
</p>
</header>
{% set images = entry.media.images %}
{% if images|length > 0 %}
<div class="journal-photo-strip" data-slides="{{ images|length }}">
{% for img in images %}
<div class="journal-photo-slide">
<img src="{{ img.cropResize(900, 600).url }}" alt="{{ entry.title }}" loading="lazy">
</div>
{% endfor %}
</div>
{% if images|length > 1 %}
<div class="journal-photo-dots" aria-hidden="true">
{% for img in images %}
<span class="journal-photo-dot{% if loop.first %} is-active{% endif %}"></span>
{% endfor %}
</div>
{% endif %}
{% endif %}
<div class="journal-post-body">{{ entry.content|raw }}</div>
</article>
{% else %} {% else %}
{% set hero = null %} {% include 'partials/entry-story.html.twig' %}
{% 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 %}
<a class="entry-card entry-card--story" id="entry-{{ entry.slug }}" data-type="story" 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>
{% endif %} {% endif %}
{% endfor %} {% endfor %}
{% else %} {% else %}
@@ -306,8 +258,10 @@ var tripMap = new maplibregl.Map({
container: 'trip-map', container: 'trip-map',
style: MapUtils.MAP_STYLE, style: MapUtils.MAP_STYLE,
center: [20, 20], center: [20, 20],
zoom: 2 zoom: 2,
attributionControl: false
}); });
tripMap.addControl(new maplibregl.AttributionControl({ compact: true }), 'bottom-left');
tripMap.on('load', function () { tripMap.on('load', function () {
if (TRIP_ENTRIES.length === 0) { if (TRIP_ENTRIES.length === 0) {
@@ -333,11 +287,22 @@ tripMap.on('load', function () {
el.addEventListener('click', function () { el.addEventListener('click', function () {
var card = document.getElementById('entry-' + entry.slug); var card = document.getElementById('entry-' + entry.slug);
if (!card) return; if (!card) return;
card.scrollIntoView({ behavior: 'smooth', block: 'center' }); var mapCol = document.querySelector('.home-map-col');
var isFs = mapCol && mapCol.classList.contains('is-fullscreen');
function scrollAndHighlight() {
window.location.hash = 'entry-' + entry.slug;
setTimeout(function () { setTimeout(function () {
card.classList.add('is-highlighted'); card.classList.add('is-highlighted');
setTimeout(function () { card.classList.remove('is-highlighted'); }, 700); setTimeout(function () { card.classList.remove('is-highlighted'); }, 700);
}, 350); }, 350);
}
if (isFs) {
var fsBtn = document.getElementById('trip-map-fullscreen');
if (fsBtn) fsBtn.click();
setTimeout(scrollAndHighlight, 450);
} else {
scrollAndHighlight();
}
}); });
new maplibregl.Marker({ element: el }).setLngLat(lngLat).addTo(tripMap); new maplibregl.Marker({ element: el }).setLngLat(lngLat).addTo(tripMap);
@@ -352,9 +317,25 @@ tripMap.on('load', function () {
/* ── GPX tracks + journey segments ─────────────────────────── */ /* ── GPX tracks + journey segments ─────────────────────────── */
MapUtils.renderGpxJourney(tripMap, USE_GPX ? GPX_URLS : [], TRIP_ENTRIES, 'gpx', 'trip-journey', { connectMode: AUTOCONNECT }); MapUtils.renderGpxJourney(tripMap, USE_GPX ? GPX_URLS : [], TRIP_ENTRIES, 'gpx', 'trip-journey', { connectMode: AUTOCONNECT });
// Collapse attribution <details> which MapLibre may open on load
var attrib = tripMap.getContainer().querySelector('.maplibregl-ctrl-attrib');
if (attrib) attrib.removeAttribute('open');
}); });
setTimeout(function () { tripMap.resize(); }, 100); setTimeout(function () { tripMap.resize(); }, 100);
(function() {
var fsBtn = document.getElementById('trip-map-fullscreen');
var mapCol = document.querySelector('.home-map-col');
if (!fsBtn || !mapCol) return;
fsBtn.addEventListener('click', function() {
var isFs = mapCol.classList.toggle('is-fullscreen');
fsBtn.setAttribute('aria-label', isFs ? 'Close map' : 'Expand map');
document.body.style.overflow = isFs ? 'hidden' : '';
setTimeout(function() { tripMap.resize(); }, 50);
});
})();
(function() { (function() {
var filterBtns = document.querySelectorAll('.trip-filter-btn'); var filterBtns = document.querySelectorAll('.trip-filter-btn');
var cards = document.querySelectorAll('[data-type]'); var cards = document.querySelectorAll('[data-type]');
@@ -389,6 +370,23 @@ setTimeout(function () { tripMap.resize(); }, 100);
}); });
})(); })();
(function() {
var sortBtn = document.getElementById('trip-sort-toggle');
if (!sortBtn) return;
var feed = document.querySelector('.feed');
var emptyMsg = document.getElementById('feed-filter-empty');
var ascending = true;
sortBtn.addEventListener('click', function() {
ascending = !ascending;
var entries = Array.from(feed.querySelectorAll('[data-type]'));
entries.reverse().forEach(function(el) { feed.insertBefore(el, emptyMsg); });
sortBtn.textContent = ascending ? '↑' : '↓';
sortBtn.setAttribute('aria-label', ascending ? 'Sort: oldest first' : 'Sort: newest first');
sortBtn.classList.toggle('is-active', !ascending);
});
})();
var STATS_GPS = {{ gps_points|json_encode|raw }}; var STATS_GPS = {{ gps_points|json_encode|raw }};
var HAS_GPX = {{ has_gpx ? 'true' : 'false' }}; var HAS_GPX = {{ has_gpx ? 'true' : 'false' }};
@@ -522,29 +520,108 @@ function parseGpxFiles(urls, callback) {
} }
} }
// Stats toggle function makePanelToggle(toggleId, blockId) {
var statsToggle = document.getElementById('trip-stats-toggle'); var toggle = document.getElementById(toggleId);
var statsBlock = document.getElementById('trip-stats-block'); var block = document.getElementById(blockId);
if (statsToggle && statsBlock) { if (!toggle || !block) return;
statsToggle.addEventListener('click', function() { toggle.addEventListener('click', function() {
var isOpen = statsBlock.style.display !== 'none'; var isOpen = block.classList.contains('is-open');
statsBlock.style.display = isOpen ? 'none' : ''; block.classList.toggle('is-open', !isOpen);
statsToggle.classList.toggle('is-active', !isOpen); toggle.classList.toggle('is-active', !isOpen);
statsToggle.setAttribute('aria-expanded', isOpen ? 'false' : 'true'); toggle.setAttribute('aria-expanded', isOpen ? 'false' : 'true');
}); });
} }
makePanelToggle('trip-stats-toggle', 'trip-stats-block');
makePanelToggle('trip-cycling-toggle', 'trip-cycling-block');
// Cycling toggle (only present when has_gpx) // Close buttons inside panels (mobile only via CSS)
var cycToggle = document.getElementById('trip-cycling-toggle'); document.querySelectorAll('.trip-panel-close').forEach(function(btn) {
var cycBlock = document.getElementById('trip-cycling-block'); var toggleBtn = document.getElementById(btn.getAttribute('data-toggle'));
if (cycToggle && cycBlock) { if (toggleBtn) btn.addEventListener('click', function() { toggleBtn.click(); });
cycToggle.addEventListener('click', function() { });
var isOpen = cycBlock.style.display !== 'none'; })();
cycBlock.style.display = isOpen ? 'none' : '';
cycToggle.classList.toggle('is-active', !isOpen); /* ── Back to top ─────────────────────────────────────────── */
cycToggle.setAttribute('aria-expanded', isOpen ? 'false' : 'true'); document.addEventListener('DOMContentLoaded', function () {
var btn = document.getElementById('trip-totop');
if (!btn) return;
var threshold = window.innerHeight * 0.8;
var shown = false;
btn.addEventListener('click', function () {
history.pushState(null, '', window.location.pathname + window.location.search);
window.scrollTo({ top: 0, behavior: 'smooth' });
});
window.addEventListener('scroll', function () {
var shouldShow = window.scrollY > threshold;
if (shouldShow !== shown) {
shown = shouldShow;
btn.classList.toggle('is-visible', shown);
}
}, { passive: true });
});
</script>
<button class="story-totop" id="trip-totop" aria-label="Back to top">↑ Top</button>
<script type="module">
import PhotoSwipeLightbox from 'https://cdn.jsdelivr.net/npm/photoswipe@5/dist/photoswipe-lightbox.esm.min.js';
const lightbox = new PhotoSwipeLightbox({
gallery: '.pswp-gallery',
children: 'a.journal-photo-slide',
pswpModule: () => import('https://cdn.jsdelivr.net/npm/photoswipe@5/dist/photoswipe.esm.min.js')
});
lightbox.on('afterOpen', function () {
var pswp = lightbox.pswp;
var keyDir = 0;
var clearTimer = null;
function onKey(e) {
if (e.key === 'ArrowRight') keyDir = 1;
else if (e.key === 'ArrowLeft') keyDir = -1;
else keyDir = 0;
}
document.addEventListener('keydown', onKey, true);
pswp.on('change', function () {
if (!keyDir) return;
var dir = keyDir;
keyDir = 0;
var el = pswp.currSlide && pswp.currSlide.container;
if (!el) return;
el.classList.remove('pswp-key-from-left', 'pswp-key-from-right');
el.offsetWidth;
el.classList.add(dir > 0 ? 'pswp-key-from-right' : 'pswp-key-from-left');
clearTimeout(clearTimer);
clearTimer = setTimeout(function () { el.classList.remove('pswp-key-from-left', 'pswp-key-from-right'); }, 400);
});
pswp.on('close', function () {
document.removeEventListener('keydown', onKey, true);
clearTimeout(clearTimer);
});
});
lightbox.init();
document.querySelectorAll('.journal-photo-wrap').forEach(function (wrap) {
var strip = wrap.querySelector('.journal-photo-strip');
var slides = Array.from(strip.querySelectorAll('a.journal-photo-slide'));
var expandBtn = wrap.querySelector('.journal-photo-expand');
var article = wrap.closest('article');
var dots = article ? Array.from(article.querySelectorAll('.journal-photo-dot')) : [];
var visibleIdx = 0;
var io = new IntersectionObserver(function (entries) {
entries.forEach(function (e) {
if (!e.isIntersecting) return;
visibleIdx = slides.indexOf(e.target);
dots.forEach(function (d) { d.classList.remove('is-active'); });
if (dots[visibleIdx]) dots[visibleIdx].classList.add('is-active');
});
}, { root: strip, threshold: 0.5 });
slides.forEach(function (s) { io.observe(s); });
if (expandBtn && slides.length) {
expandBtn.addEventListener('click', function () {
slides[visibleIdx].dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
}); });
} }
})(); });
</script> </script>
{% endblock %} {% endblock %}