Merge branch 'main' into feat/post-location-override

Brings the branch up to user/main, which was ahead on both content and
theme fixes. Notably e17a5dc ("block submit on unfinished photo uploads;
un-squeeze EXIF portraits in lightbox") — the branch point predated it, and
its absence is why UG1, UG2 and LD1 failed here: the upload gate and the
oriented-derivative slide dims those specs assert simply were not on this
branch. Also picks up denmark-2026 `published: true`, so the local testing
flip is no longer needed.

js/src/post-form.js auto-merged cleanly, keeping both main's upload gate and
this branch's location-override panel + lazy maplibre CSS link. The only
conflict was js/post/post-form.js, a generated bundle — resolved by
rebuilding via make build-assets rather than hand-merging minified output.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-24 23:36:41 +02:00
co-authored by Claude Opus 5
62 changed files with 497908 additions and 41867 deletions
+57
View File
@@ -791,8 +791,65 @@ body::after {
.feed-map-link:hover { color: var(--color-accent-hover); }
/* ── Feedback / notification messages ────────────────────────────────────────── */
/* Grav renders feedback two ways and the theme styled neither globally:
- Form/flash messages as <div class="notices success|error|warning green|red|yellow">
(Form plugin form-messages.html.twig; also markdown-notices output).
- Plain flash messages as #messages .toast .toast-error|success|warning (quark
partials/messages.html.twig, resolved since this theme has none of its own).
Without these rules they fell back to light admonition boxes that clash with the
dark theme (first noticed on /login). Kept on-theme with a status-coloured
left border. */
.notices,
#messages .toast {
padding: 1rem 1.1rem;
border-radius: var(--radius-md);
font-size: var(--text-base);
margin: 0 0 var(--space-5);
background: var(--color-canvas);
color: var(--color-ink);
border: 1px solid var(--color-border);
border-left: 4px solid var(--color-accent);
}
.notices p,
#messages .toast p { margin: 0; }
/* markdown-notices sets bright per-colour backgrounds on .notices.red / .yellow /
.green / .blue at (0,2,0). Override each variant at equal specificity (this
sheet loads later) so the dark canvas + cream text always win — only the
left-border colour signals status. */
.notices.error, .notices.red,
.notices.success, .notices.green,
.notices.warning, .notices.yellow,
.notices.blue,
#messages .toast-error,
#messages .toast-success,
#messages .toast-warning {
background: var(--color-canvas);
color: var(--color-ink);
border-left-width: 4px;
border-left-style: solid;
}
.notices.error, .notices.red,
#messages .toast-error { border-left-color: var(--color-error); }
.notices.success, .notices.green,
#messages .toast-success { border-left-color: var(--color-accent); }
.notices.warning, .notices.yellow,
#messages .toast-warning { border-left-color: #c98a2e; }
.notices.blue { border-left-color: var(--color-accent); }
/* ── Login form ─────────────────────────────────────────────────────────────── */
/* Neutralise the Login plugin's built-in white box (#grav-login in
plugin://login/css/login.css) so the wrapper matches the dark theme. */
#grav-login {
background: var(--color-canvas);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
color: var(--color-ink);
}
.login-form { max-width: 400px; margin: var(--space-8) auto; padding: 0 var(--space-4); }
.login-form .form-field { margin-bottom: var(--space-5); }
.login-form .form-label label { display: block; font-size: var(--text-sm); font-weight: 600; margin-bottom: var(--space-2); }
File diff suppressed because one or more lines are too long
+32
View File
@@ -243,6 +243,38 @@ function initPhotoConversion() {
}
}, true);
// Complete upload gate (BUG 2026-07-09: fast create submit lost the photo).
// The form plugin's own submit guard (filepond-handler.js) only blocks the
// PROCESSING / PROCESSING_QUEUED states. A file still being read (LOADING —
// the "too quick" click) or one whose upload FAILED (PROCESSING_ERROR)
// slips through it and the entry is saved without the photo — silently,
// because an errored thumbnail still satisfies the ≥1-photo count. Block
// submit unless every FilePond item reached PROCESSING_COMPLETE, and say
// which case blocked it.
form.addEventListener('submit', function (e) {
if (!orderPond) return;
var files = orderPond.getFiles();
if (!files.length) return; // the ≥1-photo rule is initValidation's job
var statuses = (window.FilePond && window.FilePond.FileStatus) || {};
var pending = 0;
var failed = 0;
files.forEach(function (f) {
if (f.status === statuses.PROCESSING_COMPLETE) return;
if (f.status === statuses.PROCESSING_ERROR || f.status === statuses.LOAD_ERROR) failed++;
else pending++; // LOADING, INIT, IDLE, QUEUED, PROCESSING, …
});
if (!pending && !failed) return;
e.preventDefault();
if (collapse) collapse.details.open = true; // reveal the item states
setStatus(failed
? 'A photo failed to upload — remove it (tap its ✕) and re-add it before posting.'
: 'Photos are still uploading — hang on a moment.', 'err');
var el = photoStatusEl();
if (el && typeof el.scrollIntoView === 'function') {
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
}, true);
// FilePond hook: reject a HEIC item, convert it to JPEG via the lazy heic-to
// chunk (KTD4), then re-add the JPEG through pond.addFile() so FilePond
// uploads and page-attaches it via its own (correct) contract.
@@ -30,6 +30,7 @@
{% if grav.user.authenticated %}
<a href="{{ base_url_absolute }}/post"{% if page.template == 'post-form' %} aria-current="page"{% endif %}>New Post</a>
<a href="{{ base_url_absolute }}/gpx-manager"{% if page.template == 'gpx-manager' %} aria-current="page"{% endif %}>GPX Manager</a>
<a href="{{ url(uri.addNonce((uri.path)|trim('/') ~ '/task' ~ config.system.param_sep ~ 'login.logout', 'logout-form', 'logout-nonce'))|e }}">Logout</a>
{% endif %}
</nav>
{% endblock %}
@@ -38,15 +38,32 @@
{% set images = entry.media.images %}
{% if images|length > 0 %}
{% set firstImg = images|first %}
{% set wrapRatio = firstImg.height > firstImg.width ? '4 / 5' : '4 / 3' %}
{#
Lightbox slides link to a 2000px fit-within DERIVATIVE, and the
data-pswp-* dims are measured from that derivative's saved file — never
from `img.width`/`img.height`, which are the ORIGINAL's raw stored pixels
with EXIF orientation ignored. Originals from iPhones are often stored
rotated (landscape pixels + EXIF "rotate 90°"), so raw dims disagree with
what the browser renders and PhotoSwipe squeezes portraits into landscape
boxes (BUG 2026-07-09, covered by tests/ui/post/lightbox-dims.spec.js).
The derivative is re-encoded (EXIF stripped, orientation baked in when
php-exif is available), so its measured dims always match its rendering.
Note: .path()/.url each finalize+reset the medium's operation queue,
hence the repeated cropResize() calls — the derivative file itself is
cached. path() needs explicit parens: Medium is ArrayAccess and carries a
'path' ITEM (the page folder), which shadows the method in Twig's
attribute resolution and hands getimagesize a directory.
#}
{% set firstDims = (images|first).cropResize(2000, 2000).path()|getimagesize %}
{% set wrapRatio = firstDims[1] > firstDims[0] ? '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 %}
{% set slideDims = img.cropResize(2000, 2000).path()|getimagesize %}
<a class="journal-photo-slide"
href="{{ img.url }}"
data-pswp-width="{{ img.width }}"
data-pswp-height="{{ img.height }}"
href="{{ img.cropResize(2000, 2000).url }}"
data-pswp-width="{{ slideDims[0] }}"
data-pswp-height="{{ slideDims[1] }}"
style="--thumb: url('{{ img.cropResize(900, 675).url }}')"
target="_blank">
<img src="{{ img.cropResize(900, 675).url }}" alt="{{ entry.title }}" loading="lazy">
@@ -23,12 +23,18 @@
#}
<div class="home-map-col">
<div class="home-map" id="{{ map_id }}">
{# Same gate as the init script below: without entries the map never
initialises and nothing wires the button's click handler, so on
mobile (the only viewport where it shows) it would float as a dead
control over an empty map area (home pre-departure state). #}
{% if entries|length > 0 %}
<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>
{% endif %}
</div>
</div>