Files
intotheeast-com/docs/working/plans/2026-06-19-home-and-trip-pages.md
T

42 KiB

Home Page & Content Flow Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Replace the redirect-based home page with a real side-by-side map + feed home page, add a past-trips archive, add story cards to all feeds, and enrich the trip page with a sticky sidebar index.

Architecture: Pure Twig + CSS. New home.html.twig template for the home page; updated trips.html.twig, trip.html.twig, dailies.html.twig, stories.html.twig. All feeds merge journal entries and story entries into one chronological collection sorted descending by date. Leaflet map on the home page reuses the same CDN setup as dailies.html.twig.

Tech Stack: Grav CMS 2.0 (Twig 3), Vanilla CSS (custom properties from tokens.css), Leaflet.js 1.9.4 via CDN

Global Constraints

  • All template/theme changes committed via git -C user (the user/ dir is a separate git repo)
  • No new Grav plugins, no JS framework, no build pipeline
  • Existing CSS token names in tokens.css must not change — only new rules added to style.css
  • Theme dir: user/themes/intotheeast/
  • Active trip slug: config.site.active_trip (set in user/config/site.yaml)
  • Dev server: http://100.96.115.96:8081 — twig.cache is false, changes take effect immediately
  • All commits use git -C /home/mischa/Nextcloud/Projects/travel-blog-intotheeast/user

Task 1: Home page routing + nav

Files:

  • Create: user/pages/00.home/home.md
  • Modify: user/config/system.yaml (line 31: home.alias)
  • Modify: user/themes/intotheeast/templates/partials/base.html.twig

Interfaces:

  • Produces: / serves a real page (not redirect); nav shows "Home" + "Past Trips"; {% block nav %} is overridable by child templates

  • Step 1: Create the home page directory and markdown file

mkdir -p /home/mischa/Nextcloud/Projects/travel-blog-intotheeast/user/pages/00.home

Write user/pages/00.home/home.md:

---
title: Home
visible: false
routable: true
---
  • Step 2: Change home.alias in system.yaml

In user/config/system.yaml, find line 31:

home:
  alias: /trips/japan-korea-2026/dailies

Replace with:

home:
  alias: /home
  • Step 3: Update base.html.twig — nav + body class + block nav

Replace the entire content of user/themes/intotheeast/templates/partials/base.html.twig with:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>{% if page.title %}{{ page.title }} | {% endif %}{{ site.title }}</title>
    <link rel="preconnect" href="https://fonts.googleapis.com">
    <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
    <link href="https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,400;0,9..40,500;0,9..40,600;1,9..40,400&family=DM+Serif+Display:ital@0;1&display=swap" rel="stylesheet">
    <link rel="stylesheet" href="{{ url('theme://css/tokens.css') }}">
    <link rel="stylesheet" href="{{ url('theme://css/style.css') }}">
    {{ assets.css()|raw }}
    {{ assets.js()|raw }}
</head>
<body class="{% if page.template == 'map' %}map-page{% endif %}{% if page.template == 'home' %} home-page{% endif %}">
    <header class="site-header">
        <a class="site-title" href="{{ base_url_absolute }}">into the east</a>
        {% block nav %}
        <nav class="site-nav" aria-label="Main navigation">
            <a href="{{ base_url_absolute }}"{% if page.template == 'home' %} aria-current="page"{% endif %}>Home</a>
            <a href="{{ base_url_absolute }}trips"{% if page.template == 'trips' %} aria-current="page"{% endif %}>Past Trips</a>
        </nav>
        {% endblock %}
    </header>
    <main class="site-main">
        {% block content %}{% endblock %}
    </main>
    {{ assets.js('bottom')|raw }}
</body>
</html>
  • Step 4: Verify routing
curl -s -o /dev/null -w "%{http_code}" http://100.96.115.96:8081/

Expected: 200 (was previously a redirect chain ending at /trips/japan-korea-2026/dailies)

curl -s http://100.96.115.96:8081/ | grep -o 'Home\|Past Trips'

Expected: Home and Past Trips (nav links present)

  • Step 5: Commit
git -C /home/mischa/Nextcloud/Projects/travel-blog-intotheeast/user add pages/00.home/home.md config/system.yaml themes/intotheeast/templates/partials/base.html.twig
git -C /home/mischa/Nextcloud/Projects/travel-blog-intotheeast/user commit -m "feat: home page routing — real / route, new nav (Home + Past Trips)"

Task 2: CSS additions

Files:

  • Modify: user/themes/intotheeast/css/style.css (append new rules at end)

Interfaces:

  • Produces: .home-page, .home-layout, .home-map-col, .home-map, .home-feed-col, .home-trip-header, .home-trip-name, .home-trip-counts; .trips-heading, .trips-list, .trip-card, .trip-card-title, .trip-card-meta, .trip-card-dates, .trip-card-counts; .trip-counts, .trip-layout, .trip-feed, .trip-sidebar, .trip-sidebar-section, .trip-sidebar-heading, .trip-sidebar-list, .trip-sidebar-link, .trip-sidebar-date; .entry-card--story, .entry-card-photo--story, .story-badge; .story-escape

  • Step 1: Append CSS rules to style.css

Append to the end of user/themes/intotheeast/css/style.css:

/* ── Home page layout ────────────────────────────────────────────────────────── */

.home-page .site-main { max-width: none; 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);
}

@media (max-width: 768px) {
    .home-layout { display: flex; flex-direction: column; }
    .home-map-col { position: static; 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-left: 3px solid var(--color-accent);
    padding-left: var(--space-5);
}

.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); }
  • Step 2: Verify CSS loads without errors
curl -s http://100.96.115.96:8081/trips | grep -c "200\|html"

Load any page and confirm no CSS parse errors in browser dev tools console. Expected: page renders normally, no console errors.

  • Step 3: Commit
git -C /home/mischa/Nextcloud/Projects/travel-blog-intotheeast/user add themes/intotheeast/css/style.css
git -C /home/mischa/Nextcloud/Projects/travel-blog-intotheeast/user commit -m "feat: CSS for home layout, story cards, trip sidebar, escape link"

Task 3: Past trips archive

Files:

  • Modify: user/themes/intotheeast/templates/trips.html.twig

Interfaces:

  • Consumes: .trip-card, .trip-card-title, .trip-card-meta, .trip-card-dates, .trip-card-counts, .trips-heading, .trips-list from Task 2

  • Produces: /trips renders trip cards with title, date range, and entry counts

  • Step 1: Rewrite trips.html.twig

Replace the entire content of user/themes/intotheeast/templates/trips.html.twig with:

{% extends 'partials/base.html.twig' %}

{% block content %}
<h1 class="trips-heading">Past Trips</h1>
{% set trips = page.children.published() %}
{% if trips|length == 0 %}
<p class="feed-empty">No trips yet.</p>
{% else %}
<div class="trips-list">
{% for trip in trips %}
    {% set dailies_page = grav.pages.find(trip.route ~ '/dailies') %}
    {% set stories_page = grav.pages.find(trip.route ~ '/stories') %}
    {% set journal_count = dailies_page ? dailies_page.children.published()|length : 0 %}
    {% set story_count = stories_page ? stories_page.children.published()|length : 0 %}
    <a class="trip-card" href="{{ trip.url }}">
        <div class="trip-card-title">{{ trip.title }}</div>
        <div class="trip-card-meta">
            {% if trip.header.date_start %}
            <span class="trip-card-dates">
                {{ trip.header.date_start|date('M Y') }}
                {% if trip.header.date_end %}{{ trip.header.date_end|date('M Y') }}{% else %} — Ongoing{% endif %}
            </span>
            {% endif %}
            <span class="trip-card-counts">
                {{ journal_count }} journal {{ journal_count == 1 ? 'entry' : 'entries' }}
                {% if story_count > 0 %} · {{ story_count }} {{ story_count == 1 ? 'story' : 'stories' }}{% endif %}
            </span>
        </div>
    </a>
{% endfor %}
</div>
{% endif %}
{% endblock %}
  • Step 2: Verify /trips renders trip cards
curl -s http://100.96.115.96:8081/trips | grep -o 'trip-card\|trip-card-title'

Expected output: trip-card and trip-card-title appear (at least one trip card rendered).

curl -s http://100.96.115.96:8081/trips | grep -o 'journal entr\|Ongoing\|stories'

Expected: at least one of these strings present (entry counts or ongoing label).

  • Step 3: Commit
git -C /home/mischa/Nextcloud/Projects/travel-blog-intotheeast/user add themes/intotheeast/templates/trips.html.twig
git -C /home/mischa/Nextcloud/Projects/travel-blog-intotheeast/user commit -m "feat: past trips archive with trip cards and entry counts"

Task 4: Home page template

Files:

  • Create: user/themes/intotheeast/templates/home.html.twig

Interfaces:

  • Consumes: config.site.active_trip; CSS classes from Task 2; home.md page from Task 1

  • Produces: / renders two-column map + feed layout; map markers click to scroll to entry cards

  • Step 1: Create home.html.twig

Create user/themes/intotheeast/templates/home.html.twig with this content:

{% extends 'partials/base.html.twig' %}

{% block content %}
{% set slug = config.site.active_trip %}
{% set trip = grav.pages.find('/trips/' ~ slug) %}
{% set dailies_page = grav.pages.find('/trips/' ~ slug ~ '/dailies') %}
{% set stories_page = grav.pages.find('/trips/' ~ slug ~ '/stories') %}
{% set journal_entries = dailies_page ? dailies_page.children.published() : [] %}
{% 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) %}

{% set journal_count = journal_entries|length %}
{% set story_count = story_entries|length %}

{% 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|number_format(6, '.', ''),
            'lng': item.page.header.lng|number_format(6, '.', ''),
            'slug': item.page.slug,
            'title': item.page.title
        }]) %}
    {% endif %}
{% endfor %}

<div class="home-layout">
    <div class="home-map-col">
        <div class="home-map" id="home-map"></div>
    </div>

    <div class="home-feed-col">
        <div class="home-trip-header">
            <h1 class="home-trip-name">{{ trip ? trip.title : slug }}</h1>
            <span class="home-trip-counts">
                {{ journal_count }} journal {{ journal_count == 1 ? 'entry' : 'entries' }}
                {% if story_count > 0 %} · {{ story_count }} {{ story_count == 1 ? 'story' : 'stories' }}{% endif %}
            </span>
        </div>

        <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>
    </div>
</div>

{% if map_entries|length > 0 %}
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/leaflet@1.9.4/dist/leaflet.min.css">
<script src="https://cdn.jsdelivr.net/npm/leaflet@1.9.4/dist/leaflet.min.js"></script>
<script>
var HOME_ENTRIES = {{ map_entries|json_encode|raw }};

var map = L.map('home-map', { minZoom: 2, maxZoom: 18, zoomControl: true });
L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', {
    maxZoom: 20,
    attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors © <a href="https://carto.com/attributions">CARTO</a>'
}).addTo(map);

var latLngs = HOME_ENTRIES.map(function(e) { return [parseFloat(e.lat), parseFloat(e.lng)]; });

if (latLngs.length > 1) {
    L.polyline(latLngs, { color: '#2A8C73', weight: 3, opacity: 0.7 }).addTo(map);
}

HOME_ENTRIES.forEach(function(entry, i) {
    var isLatest = (i === HOME_ENTRIES.length - 1);
    var size = isLatest ? 16 : 10;
    var color = isLatest ? '#155244' : '#2A8C73';
    var icon = L.divIcon({
        className: '',
        html: '<div style="width:' + size + 'px;height:' + size + 'px;background:' + color + ';border:2px solid #fff;border-radius:50%;box-shadow:0 1px 3px rgba(0,0,0,0.35);cursor:pointer;"></div>',
        iconSize: [size, size],
        iconAnchor: [size/2, size/2]
    });
    L.marker([parseFloat(entry.lat), parseFloat(entry.lng)], { icon: icon })
        .addTo(map)
        .on('click', function() {
            var card = document.getElementById('entry-' + entry.slug);
            if (card) { card.scrollIntoView({ behavior: 'smooth', block: 'center' }); }
        });
});

if (latLngs.length === 1) {
    map.setView(latLngs[0], 10);
} else {
    map.fitBounds(L.latLngBounds(latLngs), { padding: [20, 20] });
}
</script>
{% endif %}
{% endblock %}
  • Step 2: Verify home page renders
curl -s http://100.96.115.96:8081/ | grep -o 'home-layout\|home-trip-name\|entry-card'

Expected: home-layout, home-trip-name, entry-card all present.

curl -s http://100.96.115.96:8081/ | grep -o 'leaflet\|home-map'

Expected: leaflet and home-map present (map loaded, assuming entries with lat/lng exist).

  • Step 3: Visual check in browser

Open http://100.96.115.96:8081/ in a browser. Confirm:

  • Two-column layout: map left, feed right

  • Map is sticky as you scroll the feed

  • Map markers are visible (if demo entries with GPS loaded)

  • Trip name and entry count show in the feed header

  • On mobile viewport (< 768px): map stacks above feed

  • Step 4: Commit

git -C /home/mischa/Nextcloud/Projects/travel-blog-intotheeast/user add themes/intotheeast/templates/home.html.twig
git -C /home/mischa/Nextcloud/Projects/travel-blog-intotheeast/user commit -m "feat: home page template — sticky map + merged feed"

Task 5: Trip page sidebar

Files:

  • Modify: user/themes/intotheeast/templates/trip.html.twig

Interfaces:

  • Consumes: CSS classes .trip-counts, .trip-layout, .trip-feed, .trip-sidebar, .trip-sidebar-section, .trip-sidebar-heading, .trip-sidebar-list, .trip-sidebar-link, .trip-sidebar-date, .entry-card--story, .story-badge from Task 2

  • Produces: /trips/<slug>/ renders entry count in header, merged feed as main content, sticky right sidebar with journal + story jump-links

  • Step 1: Rewrite trip.html.twig

Replace the entire content of user/themes/intotheeast/templates/trip.html.twig with:

{% extends 'partials/base.html.twig' %}

{% block content %}
{% set dailies_page = grav.pages.find(page.route ~ '/dailies') %}
{% set stories_page = grav.pages.find(page.route ~ '/stories') %}
{% set journal_entries = dailies_page ? dailies_page.children.published() : [] %}
{% 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) %}

{% set journal_count = journal_entries|length %}
{% set story_count = story_entries|length %}

<div class="trip-hero">
    <h1>{{ page.title }}</h1>
    {% if page.header.date_start %}
    <p class="trip-dates">
        {{ page.header.date_start|date('d M Y') }}
        {% if page.header.date_end %}{{ page.header.date_end|date('d M Y') }}{% endif %}
    </p>
    {% endif %}
    <p class="trip-counts">
        {{ journal_count }} journal {{ journal_count == 1 ? 'entry' : 'entries' }}
        {% if story_count > 0 %} · {{ story_count }} {{ story_count == 1 ? 'story' : 'stories' }}{% endif %}
    </p>
</div>

<nav class="trip-nav">
    <a href="{{ page.route }}/dailies">Journal</a>
    <a href="{{ page.route }}/map">Map</a>
    <a href="{{ page.route }}/stats">Stats</a>
    <a href="{{ page.route }}/stories">Stories</a>
</nav>

<div class="trip-layout">
    <div class="trip-feed">
        <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 }}">
                        <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>
    </div>

    <aside class="trip-sidebar">
        {% if journal_entries|length > 0 %}
        <div class="trip-sidebar-section">
            <h3 class="trip-sidebar-heading">Journal</h3>
            <ul class="trip-sidebar-list">
                {% for e in journal_entries %}
                <li>
                    <a href="#entry-{{ e.slug }}" class="trip-sidebar-link">
                        <span class="trip-sidebar-date">{{ e.date|date('d M') }}</span>{{ e.title }}
                    </a>
                </li>
                {% endfor %}
            </ul>
        </div>
        {% endif %}
        {% if story_entries|length > 0 %}
        <div class="trip-sidebar-section">
            <h3 class="trip-sidebar-heading">Stories</h3>
            <ul class="trip-sidebar-list">
                {% for s in story_entries %}
                <li><a href="#entry-{{ s.slug }}" class="trip-sidebar-link">{{ s.title }}</a></li>
                {% endfor %}
            </ul>
        </div>
        {% endif %}
    </aside>
</div>
{% endblock %}
  • Step 2: Verify trip page
curl -s http://100.96.115.96:8081/trips/japan-korea-2026 | grep -o 'trip-counts\|trip-layout\|trip-sidebar\|entry-card'

Expected: trip-counts, trip-layout, trip-sidebar, entry-card all present.

curl -s http://100.96.115.96:8081/trips/japan-korea-2026 | grep -o 'journal entr\|trip-sidebar-heading'

Expected: journal entr (count line) and trip-sidebar-heading present.

  • Step 3: Visual check

Open http://100.96.115.96:8081/trips/japan-korea-2026 in browser. Confirm:

  • Trip title + entry count visible in hero

  • Two-column layout: feed left, sidebar right

  • Sidebar shows "Journal" section with date + entry title links

  • Clicking a sidebar link scrolls to that entry card

  • Step 4: Commit

git -C /home/mischa/Nextcloud/Projects/travel-blog-intotheeast/user add themes/intotheeast/templates/trip.html.twig
git -C /home/mischa/Nextcloud/Projects/travel-blog-intotheeast/user commit -m "feat: trip page — entry counts, merged feed, sticky sidebar index"

Files:

  • Modify: user/themes/intotheeast/templates/dailies.html.twig
  • Modify: user/themes/intotheeast/templates/stories.html.twig

Interfaces:

  • Consumes: CSS classes from Task 2; {% block nav %} from Task 1

  • Produces: /trips/<slug>/dailies shows merged feed with story cards and id/data- attrs on cards; map markers scroll to cards; /trips/<slug>/stories/<slug> shows escape link instead of global nav

  • Step 1: Update dailies.html.twig — add id/data attrs, merge stories, scroll-on-marker-click

Replace the entire content of user/themes/intotheeast/templates/dailies.html.twig with:

{% 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
        }]) %}
    {% 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/leaflet@1.9.4/dist/leaflet.min.css">
<script src="https://cdn.jsdelivr.net/npm/leaflet@1.9.4/dist/leaflet.min.js"></script>
<script>
var FEED_ENTRIES = {{ map_entries|json_encode|raw }};

var map = L.map('feed-map', { minZoom: 2, maxZoom: 18, zoomControl: true });
L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', {
    maxZoom: 20,
    attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors © <a href="https://carto.com/attributions">CARTO</a>'
}).addTo(map);

var latLngs = FEED_ENTRIES.map(function(e) { return [parseFloat(e.lat), parseFloat(e.lng)]; });

if (latLngs.length > 1) {
    L.polyline(latLngs, { color: '#1F6B5A', weight: 3, opacity: 0.7 }).addTo(map);
}

FEED_ENTRIES.forEach(function(entry, i) {
    var isLatest = (i === FEED_ENTRIES.length - 1);
    var size = isLatest ? 16 : 10;
    var color = isLatest ? '#155244' : '#1F6B5A';
    var icon = L.divIcon({
        className: '',
        html: '<div style="width:' + size + 'px;height:' + size + 'px;background:' + color + ';border:2px solid #fff;border-radius:50%;box-shadow:0 1px 3px rgba(0,0,0,0.35);cursor:pointer;"></div>',
        iconSize: [size, size],
        iconAnchor: [size/2, size/2]
    });
    L.marker([parseFloat(entry.lat), parseFloat(entry.lng)], { icon: icon })
        .addTo(map)
        .on('click', function() {
            var card = document.getElementById('entry-' + entry.slug);
            if (card) { card.scrollIntoView({ behavior: 'smooth', block: 'center' }); }
        });
});

if (latLngs.length === 1) {
    map.setView(latLngs[0], 10);
} else {
    map.fitBounds(L.latLngBounds(latLngs), { padding: [20, 20] });
}
</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 %}
  • Step 2: Update stories.html.twig — escape link, no global nav

Replace the entire content of user/themes/intotheeast/templates/stories.html.twig with:

{% extends 'partials/base.html.twig' %}

{% block nav %}
<a class="story-escape" href="{{ page.parent.parent.url }}">← Back</a>
{% endblock %}

{% block content %}
<h1>{{ page.title }}</h1>
<p>Stories coming soon.</p>
{% endblock %}
  • Step 3: Verify dailies feed
curl -s http://100.96.115.96:8081/trips/japan-korea-2026/dailies | grep -o 'id="entry-\|entry-card--story\|story-badge'

Expected: id="entry- present (cards have anchor IDs). entry-card--story and story-badge present only if story pages exist.

  • Step 4: Verify stories escape link

First check if a story page exists (otherwise create a test one). If stories exist:

curl -s http://100.96.115.96:8081/trips/japan-korea-2026/stories | grep -o 'story-escape\|site-nav'

Expected: story-escape present, site-nav absent.

  • Step 5: Commit
git -C /home/mischa/Nextcloud/Projects/travel-blog-intotheeast/user add themes/intotheeast/templates/dailies.html.twig themes/intotheeast/templates/stories.html.twig
git -C /home/mischa/Nextcloud/Projects/travel-blog-intotheeast/user commit -m "feat: dailies merges stories, id attrs for map sync; stories escape link"

Self-Review

Spec coverage check:

  • §1 URL structure — Task 1 (home.md, system.yaml) ✓
  • §2 Home page layout + data + map — Task 4 ✓
  • §2 Mobile stack — Task 2 CSS media query ✓
  • §3 Past trips archive with counts — Task 3 ✓
  • §4 Trip page counts + sidebar + merged feed — Task 5 ✓
  • §5 Story cards in feeds (home + trip + dailies) — Tasks 4, 5, 6 ✓
  • §5 Story card visual treatment (teal border, badge) — Task 2 CSS ✓
  • §6 Nav updated to Home + Past Trips — Task 1 ✓
  • Stories escape link / nav override — Task 6 ✓

Placeholder scan: None found.

Type consistency: entry.slug used consistently for id="entry-{{ entry.slug }}" in Tasks 4, 5, 6. Map entries use slug field for scroll target in Tasks 4, 6. All match.