feat(trip): one-liner, description & retina cover on trip list + page
Give trips an optional one-liner (header.tagline) and description (markdown content), surface them where they help, and fix the soft cover image — all editable from admin. - U1: header.cover_image blueprint field → pagemediaselect media picker. - U2: new macros/cover.html.twig — single source for cover resolution (author-selected → first journal image → none; missing file falls back) and retina rendering (1x/2x cropResize + srcset). Merged resolve+render into one macro since Twig macros can't return a Medium object. - U3: trip-list card renders the one-liner (when set) and the retina cover. - U4: trip-page in-column header gains the one-liner, an expandable description, and a thin banner strip — gated behind a trip_header_extras partial flag (default off) so the shared home active-trip view is unchanged (R12/KTD4). - U5: styles for the card/header one-liner, collapsible description (max-height preview, not line-clamp, so it holds across paragraphs) and the banner, with a mobile banner-height reduction. Covers R1–R15. Verified with new Playwright specs + full trip/home/maps regression against an isolated worktree dev server. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RDS6t8wcpbwKvvrxykVQ5K
This commit is contained in:
@@ -38,10 +38,9 @@ form:
|
|||||||
help: 'Leave blank if trip is ongoing'
|
help: 'Leave blank if trip is ongoing'
|
||||||
|
|
||||||
header.cover_image:
|
header.cover_image:
|
||||||
type: text
|
type: pagemediaselect
|
||||||
label: 'Cover Image Filename'
|
label: 'Cover Image'
|
||||||
placeholder: 'cover.jpg'
|
help: 'Pick from images uploaded to this trip page. Shown on the trips listing and the trip-page banner. Falls back to the first journal entry photo if left unset.'
|
||||||
help: 'Used in the trips listing page'
|
|
||||||
|
|
||||||
header.album_url:
|
header.album_url:
|
||||||
type: text
|
type: text
|
||||||
|
|||||||
@@ -950,6 +950,66 @@ body::after {
|
|||||||
color: var(--color-ink-muted);
|
color: var(--color-ink-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Trip page header extras: one-liner, description, banner ──────────────────── */
|
||||||
|
|
||||||
|
.home-trip-tagline {
|
||||||
|
font-size: var(--text-md);
|
||||||
|
line-height: var(--leading-snug);
|
||||||
|
color: var(--color-ink-2);
|
||||||
|
margin: 0 0 var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.trip-header-desc {
|
||||||
|
margin: var(--space-3) 0 var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.trip-header-desc-body {
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
line-height: var(--leading-normal);
|
||||||
|
color: var(--color-ink-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.trip-header-desc-body > :first-child { margin-top: 0; }
|
||||||
|
.trip-header-desc-body > :last-child { margin-bottom: 0; }
|
||||||
|
.trip-header-desc-body p { margin: 0 0 var(--space-2); }
|
||||||
|
|
||||||
|
/* Collapsed preview ≈3 lines. Uses max-height (not -webkit-line-clamp) so it
|
||||||
|
holds across the multiple <p> that markdown content renders. */
|
||||||
|
.trip-header-desc[data-collapsed="true"] .trip-header-desc-body {
|
||||||
|
max-height: 4.8em;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trip-header-desc-toggle {
|
||||||
|
display: inline-block;
|
||||||
|
margin-top: var(--space-1);
|
||||||
|
padding: 0;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
font: inherit;
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--color-accent);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trip-header-desc-toggle:hover { color: var(--color-accent-hover); }
|
||||||
|
|
||||||
|
.trip-header-banner {
|
||||||
|
width: 100%;
|
||||||
|
height: 200px;
|
||||||
|
margin: var(--space-4) 0 0;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.trip-header-banner img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Trip page filter bar ────────────────────────────────────────────────────── */
|
/* ── Trip page filter bar ────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
.feed-sort-bar {
|
.feed-sort-bar {
|
||||||
@@ -1045,6 +1105,7 @@ body::after {
|
|||||||
.home-map-col { position: static; height: 40vh; align-self: stretch; }
|
.home-map-col { position: static; height: 40vh; align-self: stretch; }
|
||||||
.home-map { height: 40vh; }
|
.home-map { height: 40vh; }
|
||||||
.home-feed-col { padding: var(--space-6) var(--space-5); }
|
.home-feed-col { padding: var(--space-6) var(--space-5); }
|
||||||
|
.trip-header-banner { height: 130px; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Past trips archive ──────────────────────────────────────────────────────── */
|
/* ── Past trips archive ──────────────────────────────────────────────────────── */
|
||||||
@@ -1114,6 +1175,13 @@ body::after {
|
|||||||
margin-bottom: var(--space-2);
|
margin-bottom: var(--space-2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.trip-card-tagline {
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
line-height: var(--leading-snug);
|
||||||
|
color: var(--color-ink-muted);
|
||||||
|
margin: 0 0 var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
.trip-card-meta {
|
.trip-card-meta {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: var(--space-4);
|
gap: var(--space-4);
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
{#
|
||||||
|
Shared trip cover: one source of truth for cover resolution + retina
|
||||||
|
rendering, used by both the trip-list card (trips.html.twig) and the
|
||||||
|
trip-page banner strip (trip-feed-col.html.twig) so the two surfaces
|
||||||
|
cannot drift (KTD2).
|
||||||
|
|
||||||
|
Resolution (R7 / R11):
|
||||||
|
1. author-selected header.cover_image, when it resolves to page media;
|
||||||
|
2. else the first published journal entry's first image;
|
||||||
|
3. else nothing.
|
||||||
|
A set-but-missing cover_image (deleted/moved file) is `is not defined` in
|
||||||
|
page media, so it falls through to the auto-pick rather than rendering a
|
||||||
|
broken image (R11).
|
||||||
|
|
||||||
|
Twig macros can only emit strings — they cannot return a Medium object —
|
||||||
|
so resolution and rendering live together in one macro: it emits the
|
||||||
|
wrapper + retina <img> when a cover exists, and nothing at all when none
|
||||||
|
does (which gives R9/AE4 a clean text-only header for free).
|
||||||
|
|
||||||
|
Retina (R6 / R14): two explicit cropResize derivatives (1x at w×h, 2x at
|
||||||
|
2w×2h) as an explicit `srcset` with w-descriptors; `alt` is the trip title.
|
||||||
|
#}
|
||||||
|
{% macro render(trip_page, alt, w, h, wrapper_class, sizes) %}
|
||||||
|
{%- set cover = null -%}
|
||||||
|
{%- if trip_page.header.cover_image and trip_page.media[trip_page.header.cover_image] is defined -%}
|
||||||
|
{%- set cover = trip_page.media[trip_page.header.cover_image] -%}
|
||||||
|
{%- else -%}
|
||||||
|
{%- set dailies_page = grav.pages.find(trip_page.route ~ '/dailies') -%}
|
||||||
|
{%- if dailies_page -%}
|
||||||
|
{%- set first_entry = dailies_page.children.published()|first -%}
|
||||||
|
{%- if first_entry and first_entry.media.images|length > 0 -%}
|
||||||
|
{%- set cover = first_entry.media.images|first -%}
|
||||||
|
{%- endif -%}
|
||||||
|
{%- endif -%}
|
||||||
|
{%- endif -%}
|
||||||
|
{%- if cover -%}
|
||||||
|
<div class="{{ wrapper_class }}">
|
||||||
|
<img src="{{ cover.cropResize(w, h).url }}"
|
||||||
|
srcset="{{ cover.cropResize(w, h).url }} {{ w }}w, {{ cover.cropResize(w * 2, h * 2).url }} {{ (w * 2) }}w"
|
||||||
|
sizes="{{ sizes|default('100vw') }}"
|
||||||
|
alt="{{ alt }}"
|
||||||
|
loading="lazy">
|
||||||
|
</div>
|
||||||
|
{%- endif -%}
|
||||||
|
{% endmacro %}
|
||||||
@@ -1,8 +1,16 @@
|
|||||||
{% import 'macros/stats.html.twig' as stats_m %}
|
{% import 'macros/stats.html.twig' as stats_m %}
|
||||||
{% import 'macros/cycling.html.twig' as cycling_m %}
|
{% import 'macros/cycling.html.twig' as cycling_m %}
|
||||||
|
{% import 'macros/cover.html.twig' as cover %}
|
||||||
|
{# trip_header_extras: gated one-liner + description + banner for the trip-page
|
||||||
|
caller only. Home's active-trip include omits it (`only`), so it defaults off
|
||||||
|
and that header renders exactly as before (KTD4 / R12). #}
|
||||||
|
{% set trip_header_extras = trip_header_extras|default(false) %}
|
||||||
<div class="home-feed-col">
|
<div class="home-feed-col">
|
||||||
<div class="home-trip-header">
|
<div class="home-trip-header">
|
||||||
<h1 class="home-trip-name">{{ trip_page.title }}</h1>
|
<h1 class="home-trip-name">{{ trip_page.title }}</h1>
|
||||||
|
{% if trip_header_extras and trip_page.header.tagline %}
|
||||||
|
<p class="home-trip-tagline">{{ trip_page.header.tagline }}</p>
|
||||||
|
{% endif %}
|
||||||
{% if trip_page.header.date_start %}
|
{% if trip_page.header.date_start %}
|
||||||
<p class="trip-dates">
|
<p class="trip-dates">
|
||||||
{{ trip_page.header.date_start|date('d M Y') }}
|
{{ trip_page.header.date_start|date('d M Y') }}
|
||||||
@@ -13,6 +21,15 @@
|
|||||||
{{ journal_count }} journal {{ journal_count == 1 ? 'entry' : 'entries' }}
|
{{ journal_count }} journal {{ journal_count == 1 ? 'entry' : 'entries' }}
|
||||||
{% if story_count > 0 %} · {{ story_count }} {{ story_count == 1 ? 'story' : 'stories' }}{% endif %}
|
{% if story_count > 0 %} · {{ story_count }} {{ story_count == 1 ? 'story' : 'stories' }}{% endif %}
|
||||||
</span>
|
</span>
|
||||||
|
{% if trip_header_extras and trip_page.content|striptags|trim %}
|
||||||
|
<div class="trip-header-desc" data-collapsed="true">
|
||||||
|
<div class="trip-header-desc-body">{{ trip_page.content|raw }}</div>
|
||||||
|
<button type="button" class="trip-header-desc-toggle" aria-expanded="false">Read more</button>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if trip_header_extras %}
|
||||||
|
{{ cover.render(trip_page, trip_page.title, 720, 220, 'trip-header-banner', '100vw') }}
|
||||||
|
{% endif %}
|
||||||
<div class="trip-filter-bar">
|
<div class="trip-filter-bar">
|
||||||
<div class="trip-filter-group">
|
<div class="trip-filter-group">
|
||||||
<button class="trip-filter-btn is-active" data-filter="all" aria-pressed="true">All content</button>
|
<button class="trip-filter-btn is-active" data-filter="all" aria-pressed="true">All content</button>
|
||||||
@@ -62,4 +79,27 @@
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
{% if trip_header_extras %}
|
||||||
|
<script>
|
||||||
|
document.addEventListener('DOMContentLoaded', function () {
|
||||||
|
document.querySelectorAll('.trip-header-desc').forEach(function (desc) {
|
||||||
|
var body = desc.querySelector('.trip-header-desc-body');
|
||||||
|
var btn = desc.querySelector('.trip-header-desc-toggle');
|
||||||
|
if (!body || !btn) return;
|
||||||
|
// Drop the toggle when the description already fits the collapsed preview.
|
||||||
|
if (body.scrollHeight <= body.clientHeight + 4) {
|
||||||
|
btn.hidden = true;
|
||||||
|
desc.removeAttribute('data-collapsed');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
btn.addEventListener('click', function () {
|
||||||
|
var collapsed = desc.getAttribute('data-collapsed') === 'true';
|
||||||
|
desc.setAttribute('data-collapsed', collapsed ? 'false' : 'true');
|
||||||
|
btn.setAttribute('aria-expanded', collapsed ? 'true' : 'false');
|
||||||
|
btn.textContent = collapsed ? 'Show less' : 'Read more';
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -78,7 +78,8 @@
|
|||||||
has_gpx: has_gpx,
|
has_gpx: has_gpx,
|
||||||
gpx_urls: gpx_urls,
|
gpx_urls: gpx_urls,
|
||||||
gps_points: gps_points,
|
gps_points: gps_points,
|
||||||
show_sort: true
|
show_sort: true,
|
||||||
|
trip_header_extras: true
|
||||||
} only %}
|
} only %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
{% extends 'partials/base.html.twig' %}
|
{% extends 'partials/base.html.twig' %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
|
{% import 'macros/cover.html.twig' as cover %}
|
||||||
<h1 class="trips-heading">Past Trips</h1>
|
<h1 class="trips-heading">Past Trips</h1>
|
||||||
{% set trips = page.children.published()|sort((a, b) => a.date < b.date ? 1 : -1) %}
|
{% set trips = page.children.published()|sort((a, b) => a.date < b.date ? 1 : -1) %}
|
||||||
{% if trips|length == 0 %}
|
{% if trips|length == 0 %}
|
||||||
@@ -13,21 +14,11 @@
|
|||||||
{% set journal_count = dailies_page ? dailies_page.children.published()|length : 0 %}
|
{% set journal_count = dailies_page ? dailies_page.children.published()|length : 0 %}
|
||||||
{% set story_count = stories_page ? stories_page.children.published()|length : 0 %}
|
{% set story_count = stories_page ? stories_page.children.published()|length : 0 %}
|
||||||
<a class="trip-card" href="{{ trip.url }}">
|
<a class="trip-card" href="{{ trip.url }}">
|
||||||
{% set cover = null %}
|
{{ cover.render(trip, trip.title, 720, 240, 'trip-card-cover', '(max-width: 700px) 100vw, 360px') }}
|
||||||
{% if trip.header.cover_image and trip.media[trip.header.cover_image] is defined %}
|
|
||||||
{% set cover = trip.media[trip.header.cover_image] %}
|
|
||||||
{% elseif dailies_page %}
|
|
||||||
{% set first_entry = dailies_page.children.published()|first %}
|
|
||||||
{% if first_entry and first_entry.media.images|length > 0 %}
|
|
||||||
{% set cover = first_entry.media.images|first %}
|
|
||||||
{% endif %}
|
|
||||||
{% endif %}
|
|
||||||
{% if cover %}
|
|
||||||
<div class="trip-card-cover">
|
|
||||||
<img src="{{ cover.cropResize(720, 240).url }}" alt="{{ trip.title }}" loading="lazy">
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
<div class="trip-card-title">{{ trip.title }}</div>
|
<div class="trip-card-title">{{ trip.title }}</div>
|
||||||
|
{% if trip.header.tagline %}
|
||||||
|
<div class="trip-card-tagline">{{ trip.header.tagline }}</div>
|
||||||
|
{% endif %}
|
||||||
<div class="trip-card-meta">
|
<div class="trip-card-meta">
|
||||||
{% if trip.header.date_start %}
|
{% if trip.header.date_start %}
|
||||||
<span class="trip-card-dates">
|
<span class="trip-card-dates">
|
||||||
|
|||||||
Reference in New Issue
Block a user