Compare commits

...
15 Commits
Author SHA1 Message Date
m038andClaude Opus 4.8 536ca2085f fix(trip-cover): crop-to-fill cover strip + all-or-nothing retina
Banners/cards used cropResize (fit-inside), so a portrait fallback
source was handed back as a narrow sliver that object-fit:cover then
upscaled into a blur (reported on us-canada-mex-2024). Switch to
cropZoom (crop-to-fill) so the derivative is a real w×h cover strip.

Emit the 2x srcset descriptor only when the source is genuinely >=2w
wide (cover.width >= 2w), else 1x-only — no upscaling, no odd
intermediate widths. Imported pixelfed photos cap at ~1440px wide, so
auto-picked covers are usually 1x-only (see backlog: full-res reimport).

Also drop the no-photos-demo test fixture — it surfaced as stray demo
content in the trip list; AE4 (no cover + no images -> no banner) is a
trivial else-branch of the shared macro's cover guard, covered by
construction alongside the R7/AE3 fallback tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RDS6t8wcpbwKvvrxykVQ5K
2026-07-07 23:39:04 +02:00
m038andClaude Opus 4.8 21f0c8fdd5 test(fixture): add no-photos-demo trip for AE4 coverage
A minimal demo trip with no cover_image and a single published journal entry
that has no images — the fixture the trip-page banner AE4 assertion needs
(macro fallback loop finds an entry but no image -> null cover -> no banner
strip). Lives under docs/demo/trips/ like italy-2026-demo, so it is loaded into
the pages tree only for tests and never reaches live content.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RDS6t8wcpbwKvvrxykVQ5K
2026-07-07 09:13:48 +02:00
m038andClaude Opus 4.8 ac88e8c003 fix(trip-cover): restrict cover picker to images + guard non-image selection
The header.cover_image pagemediaselect field had no accept filter, so the
Admin media picker listed every file in the trip page folder — including the
GPX files placed there by the GPX manager. On a typical trip page (photos live
on the journal entries, not the trip page) the picker offered *only* GPX, and
selecting one routed a non-image Medium into cropResize, rendering a broken
<img> on both the trips list and the trip banner.

- Blueprint: add `accept: ['.jpg','.jpeg','.png','.webp','.gif']` so the picker
  only offers images (prevention at source).
- Macro: resolve cover_image against `media.images` instead of all media, so a
  non-image or unresolvable selection falls through to the entry-photo
  auto-pick (defence-in-depth; also hardens the R11 fallback).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RDS6t8wcpbwKvvrxykVQ5K
2026-07-07 08:35:32 +02:00
m038andClaude Opus 4.8 13656e311a test(demo): give demo trip a tagline + description fixture
The italy-2026-demo trip is the UI-test fixture; add a tagline and a
multi-paragraph markdown description so the trip one-liner, expandable
description, and banner specs run against real content.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RDS6t8wcpbwKvvrxykVQ5K
2026-07-06 00:02:25 +02:00
m038andClaude Opus 4.8 ad72739b99 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
2026-07-06 00:02:19 +02:00
m038 b317640dd0 (Grav GitSync) Automatic Commit from into-the-east-grav-sync 2026-07-05 18:12:16 +00:00
m038 9bcb0fe4db Back to told name 2026-07-05 20:08:35 +02:00
m038 b5f169f25f (Grav GitSync) Automatic Commit from into-the-east-grav-sync 2026-07-05 18:04:56 +00:00
m038andClaude Opus 4.8 32c3d8c065 content: add tagline to Denmark 2026 trip
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 16:52:15 +02:00
m038andClaude Opus 4.8 a9ec49e164 content: scaffold Denmark 2026 trip (Vestkustryten, prep)
New trip page tree at /trips/denmark-2026 — trip.md (10-20 Jul 2026)
plus inert dailies/stories containers. Not set active; prep only.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 14:50:14 +02:00
m038andClaude Opus 4.8 6e8eadbab5 security: stop tracking the per-host env/ secret tree
git-sync auto-commit stages everything not gitignored (git add -A over
user/), NOT just the pages/config/themes folders. Because /env/ was not
gitignored, prod's git-sync pushed the per-host env tree — including
api-private.php (JWT), security-private.php (CSRF salt), and the
git-sync token — to Gitea in commit 9337003. Gitignore /env/ and untrack
it so it can never round-trip again. (Working-tree files kept via
--cached; prod still needs them to run.) Leaked secrets rotated
separately; history purge TBD.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Np4cMQLF77i664CAQXySzU
2026-07-05 00:52:42 +02:00
m038andClaude Opus 4.8 ca15bd8bd7 merge: integrate prod git-sync auto-commit 9337003
Brings prod's merged post-form.md and account timestamp. The leaked
env/ secret tree it also carried is removed + gitignored in the
follow-up commit.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Np4cMQLF77i664CAQXySzU
2026-07-05 00:52:10 +02:00
m038andClaude Opus 4.8 1418dd275b config(api): exclude own IP from popularity page-view tracking
Adds 83.135.64.30 to popularity.exclude_ips so local/manual testing
against prod doesn't inflate real-visitor page-view stats. IP is
dynamic — a stopgap; smoke tests should target the test instance.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Np4cMQLF77i664CAQXySzU
2026-07-05 00:44:33 +02:00
m038 93370033ea (Grav GitSync) Automatic Commit from into-the-east-grav-sync 2026-07-04 22:31:12 +00:00
m038andClaude Opus 4.8 2840018372 chore: stop tracking per-install secrets in config
- gitignore config/security-private.php (CSRF/nonce + rate-limit signing
  salt); it is a per-install secret Grav auto-generates and its own header
  says not to commit. Each environment now keeps its own uncommitted salt.
- drop the runtime-generated popularity.salt from api.yaml; Grav re-derives
  it per install.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Np4cMQLF77i664CAQXySzU
2026-07-05 00:26:41 +02:00
15 changed files with 236 additions and 53 deletions
+5
View File
@@ -9,5 +9,10 @@
/config/plugins/git-sync.yaml
/config/plugins/api-private.php
/config/security.yaml
/config/security-private.php
/config/versions.yaml
# Per-host env override tree — holds LIVE secrets (JWT, CSRF salt, git-sync
# token) + per-host overrides. Never commit/sync: git-sync stages anything
# not gitignored, so an untracked /env/ would boomerang to Gitea.
/env/
/themes/intotheeast/node_modules/
+5 -3
View File
@@ -10,10 +10,12 @@ admin_next:
preferences:
pluginsViewMode: cards
colorMode: dark
accentHue: 271
accentSaturation: 91
fontFamily: inter
accentHue: 38
accentSaturation: 92
fontFamily: public-sans
pagesViewMode: tree
fontSize: large
editorMode: normal
content_editor: ''
groups: { }
access:
+2 -2
View File
@@ -49,7 +49,8 @@ invitations:
popularity:
enabled: true
exclude_admin: true
exclude_ips: { }
exclude_ips:
- 83.135.64.30
history:
daily: 30
monthly: 12
@@ -57,4 +58,3 @@ popularity:
ignore:
- '/test*'
- /modular
salt: 37517ad926e4f900863af7f1b58718cdccc34f7265dedd09d33b6f820046c510
-7
View File
@@ -1,7 +0,0 @@
<?php
// Auto-generated private secret. Do NOT commit to version control.
// Used for CSRF nonce signing and admin rate-limit hashing. Regenerate by
// deleting this file; the next request will write a new value.
return 'lsUHWFkCwvGZrL';
+10
View File
@@ -5,4 +5,14 @@ date: '2026-09-01'
date_start: '2026-09-01'
date_end: '2026-09-08'
cover_image: ''
tagline: 'A week through the hill towns of southern Tuscany by bike'
---
We set out from Campiglia with panniers full and no fixed plan beyond a rough
line south through the Val d'Orcia. What followed was a week of gravel farm
roads, long climbs into walled towns, and slow evenings that smelled of
woodsmoke and ripe figs.
This is the running account of that ride — the villages that surprised us, the
food that kept us pedalling, and the quiet stretches of road where the whole
trip finally made sense.
@@ -0,0 +1,13 @@
---
title: Journal
template: default
routable: false
visible: false
content:
items: '@self.children'
order:
by: date
dir: desc
filter:
published: true
---
@@ -0,0 +1,7 @@
---
title: Stories
template: default
routable: false
visible: false
published: true
---
+11
View File
@@ -0,0 +1,11 @@
---
title: 'Vestkustryten - Denmark 2026'
template: trip
date: '2026-07-10'
date_start: '2026-07-10'
date_end: '2026-07-20'
cover_image: ''
tagline: '10 days along the West Coast to the top of Denmark'
published: false
---
+7 -22
View File
@@ -1,19 +1,15 @@
---
title: 'New Entry'
title: 'New journal entry'
template: post-form
access:
site.login: true
# Keep in sync with active_trip in user/config/site.yaml
pageconfig:
parent: '/trips/italy-2026-demo/dailies'
parent: /trips/italy-2026-demo/dailies
slug_field: 'date,title'
overwrite_mode: false
pagefrontmatter:
template: entry
published: true
form:
name: new-entry
action: /post
@@ -26,77 +22,66 @@ form:
autofocus: true
validate:
required: true
-
name: date
label: Date & Time
label: 'Date & Time'
type: datetime
default: now
format: 'Y-m-d H:i'
validate:
required: true
-
name: content
label: "What happened today?"
label: 'What happened today?'
type: textarea
rows: 10
validate:
required: true
-
name: photos
label: Photos (max 4)
label: 'Photos (max 4)'
type: filepond
multiple: true
destination: '@self'
limit: 4
accept:
- 'image/*'
-
name: lat
label: Latitude
type: text
placeholder: 'tap "Get Location" below'
-
name: lng
label: Longitude
type: text
placeholder: ''
-
name: location_city
label: City
type: text
placeholder: 'e.g. Kyoto'
-
name: location_country
label: Country
type: text
placeholder: 'e.g. Japan'
-
name: weather_temp_c
type: hidden
-
name: weather_desc
type: hidden
novalidate: true
buttons:
-
type: submit
value: Post Daily
value: 'Post Daily'
classes: btn-post
process:
add_page: true
upload: true
message: 'Entry posted successfully!'
reset: true
---
+4 -4
View File
@@ -38,10 +38,10 @@ form:
help: 'Leave blank if trip is ongoing'
header.cover_image:
type: text
label: 'Cover Image Filename'
placeholder: 'cover.jpg'
help: 'Used in the trips listing page'
type: pagemediaselect
accept: ['.jpg', '.jpeg', '.png', '.webp', '.gif']
label: 'Cover Image'
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.'
header.album_url:
type: text
+68
View File
@@ -950,6 +950,66 @@ body::after {
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 ────────────────────────────────────────────────────── */
.feed-sort-bar {
@@ -1045,6 +1105,7 @@ body::after {
.home-map-col { position: static; height: 40vh; align-self: stretch; }
.home-map { height: 40vh; }
.home-feed-col { padding: var(--space-6) var(--space-5); }
.trip-header-banner { height: 130px; }
}
/* ── Past trips archive ──────────────────────────────────────────────────────── */
@@ -1114,6 +1175,13 @@ body::after {
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 {
display: flex;
gap: var(--space-4);
@@ -0,0 +1,57 @@
{#
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). Resolution matches against `media.images` (not all
media), so a non-image selection — e.g. a `.gpx` from the trip page's own
media, which the picker used to offer — also falls through instead of
routing a non-image Medium into cropResize.
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).
Rendering (R6 / R14): cropZoom crops-to-fill (cover), so the 1x derivative is
an actual w×h strip matching the banner/card aspect — NOT cropResize, which
fits-inside and would hand a portrait source back as a narrow sliver that the
wrapper's object-fit:cover then upscales into a blur. `alt` is the trip title.
Retina, all-or-nothing: a 2x derivative (2w×2h) is added to the `srcset` only
when the source is genuinely wide enough to supply it without upscaling
(`cover.width >= 2w`); otherwise the srcset is 1x only — no blurry, re-encoded
upscale, and no odd intermediate widths. Note most imported phone photos are
≤1440px wide, so an auto-picked fallback cover is usually 1x-only; the 2x
kicks in mainly for an explicitly-set wide landscape `cover_image`.
#}
{% macro render(trip_page, alt, w, h, wrapper_class, sizes) %}
{%- set cover = null -%}
{%- if trip_page.header.cover_image and trip_page.media.images[trip_page.header.cover_image] is defined -%}
{%- set cover = trip_page.media.images[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.cropZoom(w, h).url }}"
srcset="{{ cover.cropZoom(w, h).url }} {{ w }}w{% if cover.width >= (w * 2) %}, {{ cover.cropZoom(w * 2, h * 2).url }} {{ (w * 2) }}w{% endif %}"
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/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-trip-header">
<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 %}
<p class="trip-dates">
{{ trip_page.header.date_start|date('d M Y') }}
@@ -13,6 +21,15 @@
{{ journal_count }} journal {{ journal_count == 1 ? 'entry' : 'entries' }}
{% if story_count > 0 %} · {{ story_count }} {{ story_count == 1 ? 'story' : 'stories' }}{% endif %}
</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-group">
<button class="trip-filter-btn is-active" data-filter="all" aria-pressed="true">All content</button>
@@ -62,4 +79,27 @@
});
});
</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>
+2 -1
View File
@@ -78,7 +78,8 @@
has_gpx: has_gpx,
gpx_urls: gpx_urls,
gps_points: gps_points,
show_sort: true
show_sort: true,
trip_header_extras: true
} only %}
</div>
+5 -14
View File
@@ -1,6 +1,7 @@
{% extends 'partials/base.html.twig' %}
{% block content %}
{% import 'macros/cover.html.twig' as cover %}
<h1 class="trips-heading">Past Trips</h1>
{% set trips = page.children.published()|sort((a, b) => a.date < b.date ? 1 : -1) %}
{% if trips|length == 0 %}
@@ -13,21 +14,11 @@
{% 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 }}">
{% set cover = null %}
{% 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 %}
{{ cover.render(trip, trip.title, 720, 240, 'trip-card-cover', '(max-width: 700px) 100vw, 360px') }}
<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">
{% if trip.header.date_start %}
<span class="trip-card-dates">