Compare commits

...
49 Commits
Author SHA1 Message Date
m038andClaude Opus 4.8 55da834396 feat(gpx-manager): list unpublished trips too, newest-first
Owner-only page, so drop the .published() filter and order by header.date_start
desc so the current/next trip is on top.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Mpdu3Dt1iVoozHwAMyjrbn
2026-07-08 10:29:05 +02:00
m038andClaude Opus 4.8 c46bbd952f fix(entry-actions): invalidate page-tree index on delete
deleteEntry did cache.deleteAll() but not Cache::invalidateCache(), so under
cache.check.method: folder the deleted entry lingered in the pages index and the
feed re-rendered it (image-less) on reload. Mirror the create-path fix. See
docs/solutions/integration-issues/grav-deleteall-doesnt-invalidate-page-tree-index.md

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Mpdu3Dt1iVoozHwAMyjrbn
2026-07-08 10:29:05 +02:00
m038 1dc9c9854c Merge commit 'b3b4f77'
# Conflicts:
#	themes/intotheeast/templates/partials/trip-feed-col.html.twig
#	themes/intotheeast/templates/trip.html.twig
2026-07-08 00:08:37 +02:00
m038andClaude Opus 4.8 d4fcbecaf1 content(italy-2025): rename to Tuscany Gravel 2025, fix dates + stories template
Trip title Cycling Tuscany 2025 -> Tuscany Gravel 2025, dates Oct->Sep,
and stories container uses the stories template (was default).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Mpdu3Dt1iVoozHwAMyjrbn
2026-07-08 00:03:58 +02:00
m038andClaude Opus 4.8 b3b4f774d0 feat(nav): add New Post + GPX Manager links for logged-in users
Both links render only when grav.user.authenticated; anonymous nav
(Home, Past Trips) is unchanged. Active-page highlight via aria-current.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Mpdu3Dt1iVoozHwAMyjrbn
2026-07-07 23:44:20 +02:00
m038andClaude Opus 4.8 034db29e66 fix(post-form): commit photo thumbnail grid + overlay styling
Renders FilePond items as a 3-up square-thumbnail grid on create+edit,
disables native image-drag so FilePond owns reorder, and hides the
file-info overlay on already-uploaded (idle) items. This is the styling
QA'd and approved on the worktree dev server; it was previously left
uncommitted (mis-tagged as unrelated WIP in earlier handovers).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Mpdu3Dt1iVoozHwAMyjrbn
2026-07-07 23:44:20 +02:00
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 7f6bf9e3f3 refactor(post-form): derive disclosure default from rendered markup
initDisclosure hardcoded blueprint defaults into a field-name regex
(/\[published\]$/) to decide which toggle state counts as a deviation worth
auto-expanding "More options". Read each toggle's default from the HTML
`checked` attribute instead — Grav's toggle template stamps it on the default
option, and prefill/edit only ever set the live `.checked` property — so a
future default-ON advanced toggle Just Works. Rebuilt bundle.

Code review F4 (maintainability).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Mpdu3Dt1iVoozHwAMyjrbn
2026-07-07 08:31:59 +02:00
m038andClaude Opus 4.8 8db3ffeafc fix(cache-on-save): latch page-cache invalidation to once per submit
onFormProcessed fires once per process action (add_page/upload/message/
reset), so the deleteAll() + Cache::invalidateCache() pair ran 4x per post.
Gate it behind a $cacheInvalidated latch (same pattern as $photosReconciled)
so the store wipe + system.yaml touch happen exactly once, and log the step.

Code review F1 (perf) + F7 (observability).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Mpdu3Dt1iVoozHwAMyjrbn
2026-07-07 08:31:50 +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
m038andClaude Opus 4.8 a7bda6ed39 fix(post-form): only auto-expand More options when a field deviates from default
initDisclosure auto-opened the More-options panel whenever any advanced field
'had a value', but the published toggle defaults ON, so a plain create form
tripped it every load. A toggle now counts only when it deviates from its
blueprint default (published: OFF is notable; force_connect/featured: ON is),
so the panel stays collapsed on create. Edit mode still force-opens it
separately. Rebuilt bundle via make build-assets.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Mpdu3Dt1iVoozHwAMyjrbn
2026-07-05 23:42:31 +02:00
m038andClaude Opus 4.8 7775a4ed6d fix(cache-on-save): invalidate page index on save so new entries are editable
deleteAll() only clears the Doctrine store; the page-tree index is keyed on
md5(dirs + folderHash + config->checksum() + lang) (Pages::buildRegularPages).
With cache.check.method:folder a freshly-created entry could survive in that
index and stay invisible to GET /api/v1/pages{route} (404), so opening a
just-posted entry for editing showed "this entry no longer exists" ~2/3 of the
time. Add Cache::invalidateCache() (touches system.yaml -> bumps config
checksum) so the index key changes and the tree rebuilds next request. Chosen
over clearCache('standard'), which would nuke compiled Twig + assets on every
post. Fixes the create->edit round-trip (Playwright ES1) and the DEL1 flake.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Mpdu3Dt1iVoozHwAMyjrbn
2026-07-05 23:42:11 +02:00
m038andClaude Opus 4.8 7ffd75e8ec fix(review): surface auth-expiry, harden add-batch rollback, add audit log
Follow-up to the ce-code-review deferred items on the photo editor:

- Photo editor fetches now REJECT with a status-bearing error (apiSend)
  instead of the boolean apiOk that swallowed the HTTP code. Reorder,
  delete and add paths tell a lapsed login (401/403) apart from a generic
  failure and prompt the owner to sign in again rather than "try again".
- Add-batch rollback: the per-file cleanup DELETEs no longer swallow
  individual failures. If any rollback DELETE doesn't land (a stray
  stock-named file could steal the lexicographic cover slot), the owner is
  told cleanup was incomplete and to reload — instead of a false "rolled
  back cleanly".
- entry-actions delete + reorder routes now emit an owner-attributed audit
  log line, so a destructive mutation is traceable.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 20:43:41 +02:00
m038andClaude Opus 4.8 361a6b4d67 fix(review): harden photo reorder against data loss + failure-path drift
Addresses ce-code-review findings on the photo-editor media-API work:

- P0 (#1): PhotoRenumberer now renumbers EVERY on-disk image, using the
  client manifest only as preferred ORDER and appending any omitted image
  at the end. A stale/incomplete `order` (e.g. a second browser tab)
  previously left an unlisted photo at a target slot for phase-2's
  rename() to silently overwrite — verified data loss, now impossible.
  The reorder route inherits the guard; create/reconcile is unchanged.
- P2 (#3): unique per-call token in the .reorder-tmp-* name so two
  concurrent renumbers on one folder can't collide and clobber bytes.
- P3 (#7): de-duplicate the manifest so a repeated name can't shift/drop
  a photo.
- P2 (#2): applyReorder + doDelete split the two failure stages — a failed
  refresh AFTER a committed reorder/delete no longer reverts to a stale or
  ghost state, it reconciles to disk. A DELETE 404 is treated as success
  so a retried ghost cell converges.
- P2 (#4): both custom routes call requirePermission('api.pages.write')
  so the GHSA-x7hm API-key scope cap applies (owner already holds it, so
  the owner-only behaviour is unchanged).
- P3 (#8): refresh stale comments (photo-01..NN; drop editLoadPhotos ref).

PhotoRenumberer's 7-case unit suite still passes and the data-loss repro
now preserves all bytes. Assets rebuilt.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 20:14:41 +02:00
m038andClaude Opus 4.8 a4432d897e feat(post-form): live photo editor on entry edit (media API + SortableJS)
Replace the FilePond photo path in edit mode with our own thumbnail grid that
talks straight to the media API (the gpx-manager pattern). Add/delete/reorder
each persist immediately, decoupled from the form's text-field Save:

- Add: HEIC->JPEG client-side, stock POST .../media per file, then ONE reorder
  after the batch (renumber photo-01..NN). On a failed reorder: auto-retry
  (idempotent), else roll the just-uploaded files back so no orphan stock-named
  image breaks cover=first. Upload progress shown per file.
- Delete: inline 'Delete? [Confirm] [Cancel]' (Confirm disabled in flight),
  stock DELETE, then renumber the survivors.
- Reorder: SortableJS drag -> POST /entry/<slug>/photos/order. On failure the
  move reverts to last-known-good; the shown grid never disagrees with disk
  without an inline error.
- Loading + empty states; first cell badged Cover; photo-NN URLs cache-busted
  since reorder reuses them for different bytes.

FilePond is fully decommissioned in edit mode (initPhotoConversion early-returns
under EDIT_MODE): no stale photo_order manifest is posted on text Save, so
cache-on-save can't delete a live-added photo. Create-mode FilePond is untouched.

Adds sortablejs (bundled into js/post via the post-form entry). SVG excluded in
the file-input accept; the server-side SVG block is a documented fast-follow.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 19:19:57 +02:00
m038andClaude Opus 4.8 4fea522d5c feat(entry-actions): add owner-scoped photo reorder route
POST /api/v1/entry/{slug}/photos/order renames an entry's image files to
photo-01..NN in the client-supplied order so the feed cover (media.images|first)
follows the drag — no stock endpoint can express this. Same R6 guard chain as the
delete route (site OWNER + direct child of the active trip's dailies), then the
shared PhotoRenumberer does the two-phase rename and the cache is cleared.

Filename safety is layered: unsafe 'order' entries (/, ..) are dropped here and
PhotoRenumberer only renames real image files, so a crafted body can never touch
the entry .md, a .gpx or a .meta.yaml. Registers behind the API route-map cache,
so a deploy cache-clear is required (same as the existing DELETE /entry/{slug}).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 18:58:43 +02:00
m038andClaude Opus 4.8 fcf52a0e44 refactor(photos): extract shared zero-padded PhotoRenumberer helper
Factor cache-on-save's renumberPhotos into a shared PhotoRenumberer class
(Grav\Plugin\Shared), the single owner of the photo-NN naming invariant used
by both the create/edit reconcile and the upcoming live reorder route, so their
numbering can't diverge.

Changes vs the old private method:
- Zero-pads to photo-01..NN (pad width grows with the set) so lexicographic
  media order equals numeric order past 9 photos — cover = images|first stays
  correct for 10+ photos. Normalises pre-existing un-padded photo-N on first pass.
- Image-extension guard moved into the helper: only real image files on disk are
  renamed, so a crafted manifest naming the entry .md, a .gpx or a .meta.yaml is
  skipped by every caller, not just cache-on-save.

Create-mode entries now also emit photo-01..NN — an intentional, accepted side
effect of sharing one helper.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 18:56:59 +02:00
m038andClaude Opus 4.8 05db592836 fix(cache): revalidate pages/media instead of 7-day browser cache
system.pages.expires was 604800 (Grav default), so browsers cached feed HTML and
entry media for a week with no revalidation — deleted entries lingered as ghost
cards (images 404), and edited/new photos didn't appear until a hard refresh. Set
expires: 0 → Cache-Control: no-cache, must-revalidate. With etag already on, an
unchanged page returns a cheap 304; changed content shows immediately. Also fixes
reorder staleness (photo-N.jpg is reused across reorders at the same URL). The
server-side page cache (cache.enabled) is untouched.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 14:55:05 +02:00
m038andClaude Opus 4.8 ff31683075 fix(post-form): render photo thumbnails on edit + clearer load errors
QA feedback fixes for the front-end journal edit form:

- Photos on edit showed a filename with no thumbnail. addFile(url,{type:'local'})
  routed through the form plugin's FilePond server.load, which returned HTML (not
  the image bytes), so image-preview had nothing to render. Fetch each image as a
  Blob and add it as a File (ordered) — the thumbnail renders, and type:'local'
  still means it is never re-uploaded and its filename rides the photo_order
  manifest. Verified: fileType image/jpeg, previews render, reorder/remove unchanged.

- Distinguish a deleted/missing entry (API 404 → "this entry no longer exists")
  from a transient load failure ("check your connection") in the prefill catch.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 14:55:05 +02:00
m038andClaude Opus 4.8 03323bcadf feat(post-form): enable adding photos when editing an entry (M2/R9)
Re-enable the FilePond browse/drop affordance in edit mode. On submit, existing
locals + new uploads ride the photo_order manifest and cache-on-save reconciles
the folder (delete dropped, renumber survivors photo-1..N, first = cover), so an
entry's photos can now be added, removed and reordered from the front-end edit
form. Verified end-to-end: add, remove, reorder, and all three in one save, plus
create-with-photos and edit remove/reorder regressions.

Depends on a local fix to add-page-by-form (its Grav-2.0 edit-mode header cast
fatals on a new upload); that plugin is git-ignored, so the fix ships as a
tracked patch in the superproject (deploy/patches/) rather than here.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 13:42:55 +02:00
m038 cb759746d0 Merge remote-tracking branch 'origin/main' into feat/journal-post-form 2026-07-05 11:32:23 +02:00
m038andClaude Opus 4.8 525b684493 feat(post-form): M2 photo edit — load, remove & reorder from the edit form
Editing an entry now loads its existing photos into FilePond so the owner can
remove and reorder them; the first photo is the cover. Adding NEW photos on edit
is intentionally suppressed (see below).

post-form.js (U7):
- On ?edit=, load the entry's current images into FilePond as LOCAL items (via
  the session media API, gpx-manager pattern). They display for remove/reorder
  and ride the existing photo_order manifest on submit, but are never re-uploaded.
- Exclude the FilePond field from the D1 prefill disable-sweep — FilePond reads
  its input's disabled state at init and never re-enables, which had removed its
  controls in edit mode.
- Suppress the add affordance in edit mode (allowBrowse/allowDrop off): a new
  upload on edit hits add-page-by-form's Grav-2.0 edit-merge fatal
  ((array)$page->header() yields mangled protected keys → array_merge(null,…)).
  That plugin is stock/GPM/git-ignored (no fork), so adding photos on edit is
  deferred to the form-to-page/image-upload rework.

cache-on-save.php (U8):
- reconcilePhotos(): on edit, resolve the entry folder via the shared scope guard
  (not the fuzzy create-path finder), delete any image dropped from the manifest,
  then renumber survivors photo-1..N in the submitted order (cover = first).
- Run reconciliation ONCE per submit: onFormProcessed fires per process action
  (4×); a 2nd pass deleted the just-renamed photo-N files as "unlisted".
- Empty manifest reconciles nothing (fail-safe: never wipes photos on a missing
  photo_order).

Verified on the container: existing photos load (V9); remove + reorder persist to
disk with cover=first (V10); reconcile helpers covered by a reflection unit test.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 11:25:01 +02:00
m038andClaude Opus 4.8 f8f03faa1d chore: gitignore stray tester.yaml test account
Prevents git add -A from sweeping a local browser-test account (with its
password hash) into the content repo, matching the existing testrunner.yaml
ignore.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H1FrCYNq6RXdGYbn5PFrhM
2026-07-05 01:11:53 +02:00
m038 16a570ca3b Merge remote-tracking branch 'origin/main' into feat/journal-post-form
# Conflicts:
#	pages/02.post/post-form.md
2026-07-05 00:42:17 +02:00
m038andClaude Opus 4.8 5752877e66 refactor(content): migrate demo source journal entries entry.md -> entry.en.md
Same rename as the real trips, applied to the italy-2026-demo seed source so
demo-load produces entry.en.md entries. Keeps the demo consistent with how the
edit form writes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H1FrCYNq6RXdGYbn5PFrhM
2026-07-05 00:29:17 +02:00
m038andClaude Opus 4.8 6abd08af40 refactor(content): migrate journal entries entry.md -> entry.en.md
The site runs languages.supported:[en] with include_default_lang_file_extension:
true, so add-page-by-form (and thus the new edit-in-place save) writes entry.en.md.
The 36 seeded journal entries used the plain entry.md, so an edit would leave a
stale orphan entry.md shadowed by the new entry.en.md. Rename them to the
config-canonical entry.en.md so edits overwrite in place with no orphan. Folder
names (which carry the entry URL) are unchanged; only the page file is renamed.
Stories are unaffected.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H1FrCYNq6RXdGYbn5PFrhM
2026-07-05 00:29:00 +02:00
m038andClaude Opus 4.8 b8daea217d feat(post-form): U6 — owner-scoped delete API route + card wiring
New custom-in-repo plugin entry-actions (un-ignored in .gitignore, NOT in
plugins.txt) registers DELETE /api/v1/entry/{slug} via onApiRegisterRoutes
(KTD5). The handler requires the authenticated site OWNER (not any login/admin),
rejects unsafe slugs (400), resolves the target through the page tree, asserts it
is a direct child of the active trip's dailies container, deletes the folder and
clears the cache — sharing EntryScopeGuard with the save path so R6 can't diverge.
A lazy per-namespace autoloader loads the controller on cached-route requests
(the router dispatches from route.cache without re-firing onApiRegisterRoutes).
EntryScopeGuard gains isOwnerUser() (API user comes from the request, not
$grav['user']) and enablePages() before find() (pages are lazily disabled in the
API context).

feed-actions.js (new, built via make build-assets; loaded on the trip/home feed
only when owner_can_edit) wires the inline Delete → Cancel/Confirm swap: on
Confirm it locks both buttons (D2, no double-DELETE), fetches the route
(credentials:include), removes the card, moves focus to the next card, and
announces via a page-level aria-live region (D4); on failure it restores the
control with an inline message (D7). Adds .sr-only + .entry-action[hidden] CSS.

Verified on the 2.0.4 container — API matrix 8/8 (anon 401, non-owner 403, bad
slug 400, out-of-scope 404, owner 204 + folder removed; V3/V5) and the delete UI
in a headless browser (confirm swap, card removal, disk deletion, live announce).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H1FrCYNq6RXdGYbn5PFrhM
2026-07-05 00:19:15 +02:00
m038andClaude Opus 4.8 265a06a972 feat(post-form): U5 — edit prefill + edit-mode form behaviour
The card Edit link opens /post?edit=<route>. post-form.js now (KTD4/KTD9, D1/D6/D7):
- On ?edit=, disables the form and shows 'Loading entry…' before the fetch (D1),
  so slow-connection typing can't be overwritten by the incoming prefill.
- GETs /api/v1/pages<route> (credentials:include, the gpx-manager session pattern)
  and populates every field from data.header.* / data.content: title, date
  (space→T for datetime-local), content (EasyMDE), lat, lng, city, country,
  weather select, temp, transport, featured, force_connect, published toggle.
- Sets the hidden edit_path to <route>/entry.md so cache-on-save toggles
  overwrite_mode:edit and the save writes back in place (stable URL).
- Hides the photos section and skips the ≥1-photo rule (photos untouched in M1).
- Switches chrome to 'Edit entry' / 'Save changes' (D6); reveals More options.
- On fetch failure, shows an inline banner and keeps the form disabled (D7).
- Skips draft restore in edit mode; carries edit+return on the form action for a
  future re-render (D3/D5). Rebuilt via make build-assets.

Verified in a headless browser: all fields prefill correctly incl. edit_path and
the published toggle, photos hidden, chrome correct (V6); fetch-failure banner
shown with fields left disabled (D7).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H1FrCYNq6RXdGYbn5PFrhM
2026-07-05 00:03:33 +02:00
m038andClaude Opus 4.8 c4228e9027 feat(post-form): U4 — Draft badge + owner Edit/Delete controls on cards
Render on each journal card, gated by owner_can_edit (R1/R3/R5/R6, KTD8):
- Draft badge (amber) when the entry is unpublished.
- Edit link -> /post?edit=<route>&return=<feed-url> (D5: a save from the home
  feed returns to home, not always the trip page); feed_return_url threaded from
  trip.html.twig/home.html.twig (page.url).
- Delete control with inline Cancel/Confirm button-swap (no browser dialog),
  data-entry-route carried for U6's delete JS, plus an aria-live status slot.
- 44x44px min tap targets (D8); titlerow wraps on narrow phones.

Verified on the container: anon sees no controls/badge; owner sees Edit+Delete on
all 13 cards with a Draft badge on the unpublished one; a non-owner authenticated
session (testrunner vs owner mischa) sees none and no drafts (V5). Browser
screenshot verification unavailable (no local Playwright); did a code-level
layout review instead.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H1FrCYNq6RXdGYbn5PFrhM
2026-07-04 23:48:57 +02:00
m038andClaude Opus 4.8 a2d4f81bfe feat(post-form): U3 — auth-aware feed collection; drafts owner-only
Owner-aware draft visibility (R5, KTD7/KTD8) on trip.html.twig and home.html.twig
(active-trip branch):
- Compute owner_can_edit = authenticated AND username == site.owner_username AND
  (this is the active trip). The super-admin tester authenticates too, so the gate
  is owner identity, not mere login.
- The feed list (all_items) uses an owner-aware journal collection: owner sees
  drafts, everyone else (and every non-active-trip view) sees published only.
- journal_entries stays published-only — it feeds stats/counts. map_entries now
  filters on item.page.published, so drafts get a feed card but no marker and no
  stat contribution.
- Thread owner_can_edit into trip-feed-col (defaults false) for the U4 controls.
Between-trips home grid stays published-only. Stories untouched (journal drafts only).

Verified on the container: draft entry with coords shows in the owner's feed but
not the map or count; absent entirely for anonymous (V4).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H1FrCYNq6RXdGYbn5PFrhM
2026-07-04 23:44:00 +02:00
m038andClaude Opus 4.8 4e91492f65 feat(post-form): U2 — server-side active-trip + owner scope guard on edit save
Enforce R6 on the save path (KTD6): in cache-on-save's onFormValidationProcessed,
when a hidden edit_path is present, require the site owner (not merely any login —
the super-admin tester also authenticates) AND that the target resolves through
the page tree to a direct child of the active trip's dailies container. Fail
closed with a ValidationException so add_page never runs. Create (empty edit_path)
is left untouched.

New shared EntryScopeGuard (classes/EntryScopeGuard.php) is the single source of
truth for both R6 enforcement points — this save guard and U6's delete route call
the same isOwner()/resolveActiveDailyChild()/segment helpers, so they cannot
diverge (KTD5). Resolution is via $pages->find() + a parent-route assertion, never
raw path concatenation, closing the traversal hole (basename(dirname()) yields the
same target add-page-by-form writes to).

Verified on the 2.0.4 container: non-owner edit, out-of-scope edit_path, unsafe
'..' segment, and non-dailies-child targets are all rejected; owner in-place edit
succeeds (V3).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H1FrCYNq6RXdGYbn5PFrhM
2026-07-04 23:40:04 +02:00
m038andClaude Opus 4.8 aa5d34717e feat(post-form): U1 — shared create/edit form + published field + owner_username
One /post form now drives both create and edit (KTD1, Alt B): cache-on-save
toggles add-page-by-form's overwrite_mode per submit — 'edit' when the new hidden
edit_path field is filled (write back in place, stable URL), 'false' when empty
(fresh dated folder via slug_field). add-page-by-form stays stock (no fork);
the toggle lives in cache-on-save, which reads edit_path and mutates the /post
page header's pageconfig before add-page-by-form's onFormProcessed runs.

- post-form.md: overwrite_mode:false (create-safe fallback), hidden edit_path,
  authoritative published toggle (default on), removed the static
  pagefrontmatter.published so the field wins on every submit (KTD3).
- cache-on-save.php: editPathFromForm + setOverwriteMode helpers.
- site.yaml: owner_username (single source of truth for the KTD8 owner gate).

Verified against the 2.0.4 container: create writes a fresh dated folder with
published:true/false as a real boolean (V1, V2); edit writes in place with the
slug/URL unchanged; save-as-draft-on-create works.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H1FrCYNq6RXdGYbn5PFrhM
2026-07-04 23:31:25 +02:00
m038 fff5358ce2 Merge remote-tracking branch 'origin/main' into feat/journal-post-form 2026-07-04 22:53:45 +02:00
m038andClaude Opus 4.8 8cf1145e6f feat(post-form): reverse-geocode City/Country + drop hero-image field
Get Location now reverse-geocodes the captured coordinates into City +
Country via BigDataCloud's free client endpoint, filling only blank fields
(never clobbering a manual entry) and appending the resolved place to the
location status. Best-effort — a failure leaves the coordinates intact.

Removes the redundant hero_image field: journal entries render their hero
from the first uploaded photo (entry-journal uses entry.media.images|first),
so an explicit hero filename served no purpose. Stories still use hero_image
but are not posted through this form.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 21:06:01 +02:00
m038andClaude Opus 4.8 8b037511fd feat(post-form): require 1–6 photos per entry
Raise the FilePond limit from 4 to 6 and enforce a minimum of one photo.
The photo field is first in the form, so initValidation checks it first: an
empty picker blocks submit, reveals the (possibly collapsed) photo section,
and shows "Add at least one photo." under its header. Labels updated to
"Photos (1–6)".

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 20:59:01 +02:00
m038andClaude Opus 4.8 1cde137daa feat(post-form): native datetime-local picker + require date client-side
The stock Grav `datetime` field template is deprecated and falls back to a
plain text box, so `type: datetime` + `default: now` rendered a raw input
showing the literal word "now" — unusable. Add a theme override at
templates/forms/fields/datetime/datetime.html.twig that renders a native
<input type="datetime-local"> (real calendar+clock, great on mobile), drop
the `default: now`, and prefill the current local time from post-form.js.

Also add `date` to the existing client-side validator. Together with the
picker (which can't hold an invalid value) this stops a bad/empty date from
round-tripping to the server — which was the trigger that made Grav re-render
the managed FilePond field from the session flash as filename-only inputs and
resurrect a photo the user had removed. Grav still reformats the submitted
value to the blueprint `format: 'Y-m-d H:i'` on save, so stored dates and
folder slugs are unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 20:41:49 +02:00
m038andClaude Opus 4.8 d17d1256ba feat(post-form): dark-theme the photo widget + drag-to-reorder that sticks
Styling: repaint FilePond's default light drop zone/thumbnails/actions with
the Field Notes dark tokens so the picker matches the site palette.

Reordering: enable FilePond drag-reorder (allowReorder, itemInsertLocation
'after'). FilePond does NOT re-sequence its submitted data[photos][] inputs on
reorder, so post-form.js sends the visual order as a top-level `photo_order`
POST key on submit. cache-on-save reads it from $_POST (after add-page-by-form
copies the files, priority -100) and renames them photo-1..N in that order —
which the entry honours since it lists media by filename and treats the first
as hero. The order key is top-level (not data[...]), so it never lands in the
entry frontmatter. Best-effort + self-idempotent: locates the new entry folder
by the uploaded filenames and no-ops if they're already renamed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 20:25:56 +02:00
m038andClaude Opus 4.8 87da3e4b5b feat(post-form): photos-first order, auto-collapsing photo summary, success-only view
- Reorder blueprint to Photos → Title → Content; drop title autofocus so the
  picker leads (it anchors what you write).
- After upload the photo section auto-collapses to a live summary bar
  ("✓ N photos ready — tap to review") and re-expands on tap; the field's
  own .form-label is hidden so the <summary> is the sole header. Trailing
  refreshes settle the summary past FilePond's event/DOM timing gap.
- Replace FilePond's murky completed-thumbnail overlay with a clean green ✓
  badge (remove action left intact).
- On a successful post, hide the reset form + location/weather controls so
  only the "✓ Saved" confirmation + View-journal CTA remain.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 19:46:49 +02:00
m038 d3a520426a fix(post-form): load FilePond CSS in head + real success confirmation
Three field-reported bugs, all root-caused on the isolated test server:

1+2. FilePond's stylesheet never loaded — the filepond field registers it via
   assets.addCss() during body render, too late for the theme's head-only
   {{ assets.css() }}. Photo tiles rendered as giant unstyled boxes that
   stacked and overlapped the rest of the form (Get Location/Weather, Submit),
   making it unusable and looking like upload errors. Load filepond.min.css +
   image-preview CSS in the head_assets block; hide the PQINA credit.

3. The success notice rendered at the top of a long, reset form (off-screen
   after submitting from the bottom) and .notices was unstyled on the dark
   theme. Style .notices; on load, scroll the confirmation into view and inject
   a 'View your journal' link (to site.active_trip) + 'Post another' CTA.

Verified in a browser: 3 photos render compact without overlap; post-submit
shows the confirmation + working view link.
2026-07-04 19:23:02 +02:00
m038 c70e96879b fix(post-form): convert HEIC via FilePond beforeAddFile, not a custom uploader (U4)
Runtime verification showed the custom picker uploaded to Grav's flash but
never attached photos to the entry — attachment needs FilePond's exact
(undocumented) submit contract. Reverting to type:filepond and hooking its
beforeAddFile: a HEIC item is rejected, converted to JPEG via the lazy heic-to
chunk, then re-added with pond.addFile() so FilePond owns upload + page-attach
(the proven path). Web-format photos pass through; conversion failures fail
closed (inline status, original never added). Removes the custom photos field
template + AJAX uploader.

Verified end-to-end in a browser: HEIC posts as JPEG, corrupt HEIC is skipped,
Submit gated while converting, draft photos-reselect hint intact.

Refs R8, R9, R16, R17, AE1, AE4, KTD4.
2026-07-04 17:51:58 +02:00
m038 9a96d0f19e feat(post-form): text-only draft persistence (U6)
Mirror text field values (incl. EasyMDE content) to localStorage on every
edit and restore them on load. Clear the draft only when the server confirms
a successful post (.notices.success) — the invariant that guarantees text
survives validation failures, save errors, and session expiry (login form
shown → form absent → draft left intact for post-reauth restore). Photos are
not persisted (File/Blob can't serialize); on restore an inline hint says
they need re-selecting.

Refs R19 (preserve-on-failure), R20, KTD6.
2026-07-04 16:45:28 +02:00
m038 7f021ab114 feat(post-form): Field Notes styling, disclosure, feedback states (U5)
Migrate Get Location/Weather + required-field validation out of the
template's inline scripts into the bundle: generalize the field lookup so
weather_desc (now a <select>) is found, gate Get Weather until coords exist,
and add idle/loading/success/error states. Build the 'More options' <details>
(native, KTD5) around the advanced trio, auto-open when any has a value.
Add Field Notes CSS: select/number styling, EasyMDE dark theme, disclosure,
button spinner, and server form-message states.

Refs R5, R11, R12, R18, R19, AE3, KTD5.
2026-07-04 16:42:58 +02:00
m038 bf5b5c2f3c feat(post-form): client-side HEIC->JPEG photo picker (U4)
Replace /post's managed filepond field with a controlled 'photos' field
(theme forms/fields/photos) + picker logic in post-form.js: magic-byte
sniff (ISO-BMFF ftyp brands, not filename/MIME), lazy import('heic-to')
only for real HEIC (deferred 3MB chunk via ESM splitting), per-thumbnail
converting/uploading/done/error states, in-flight counter gating Submit,
and fail-closed skip on conversion failure. Converted JPEGs POST to Grav's
AJAX file-upload route into the form flash (the only path copyFiles reads),
so add-page-by-form attaches them on submit. Web-format photos pass through.

Refs R8, R9, R16, R17, AE1, AE4, KTD4.
2026-07-04 16:38:13 +02:00
m038 6282528dd8 feat(post-form): EasyMDE editor + /post-scoped ESM bundle (U3)
Add a page-scoped js/src/post-form.js bundle (built ESM + code-splitting
so U4's heic-to becomes a deferred chunk) loaded only by post-form.html.twig
via a new base head_assets block. EasyMDE replaces the bare textarea with a
minimal toolbar (bold/italic/list/link/preview), FA-free via CSS glyphs, and
syncs codemirror.save() on change + capture-phase submit so the inline
required-field validator and payload see the live value.

Refs R7, R15, KTD2, KTD3.
2026-07-04 15:40:28 +02:00
m038 87e216594e feat(post-form): expose all entry fields + weather picker (U2)
weather_desc hidden->select (emoji conditions mirroring entry.yaml),
weather_temp_c hidden->editable number, add transport_mode select and
the advanced trio (hero_image, force_connect, featured) tagged
advanced-field for U5's More options disclosure.

Refs R3, R4, R6, AE3.
2026-07-04 15:32:46 +02:00
m038 817c20c24a feat(post-form): inject active-trip parent server-side (U1)
Derive the entry write target from site.active_trip at submit time in
cache-on-save's onFormValidationProcessed handler, and fail closed
(ValidationException) when no active trip is set. Removes the hardcoded
pageconfig.parent that had to be hand-synced with active_trip.

Refs R1, R2, AE2, KTD1.
2026-07-04 15:29:07 +02:00
84 changed files with 3448 additions and 178 deletions
+2
View File
@@ -2,8 +2,10 @@
!/plugins/.gitkeep !/plugins/.gitkeep
!/plugins/cache-on-save/ !/plugins/cache-on-save/
!/plugins/story-blocks/ !/plugins/story-blocks/
!/plugins/entry-actions/
/data/ /data/
/accounts/testrunner.yaml /accounts/testrunner.yaml
/accounts/tester.yaml
/pages/01.trips/italy-2026-demo/ /pages/01.trips/italy-2026-demo/
/pages/02.post/*ui-test*/ /pages/02.post/*ui-test*/
/config/plugins/git-sync.yaml /config/plugins/git-sync.yaml
+5
View File
@@ -7,3 +7,8 @@ metadata:
description: 'A travel blog by Mischa' description: 'A travel blog by Mischa'
active_trip: /trips/us-canada-mex-2024 active_trip: /trips/us-canada-mex-2024
travelling: false travelling: false
# Single source of truth for the site owner's account username. Backs both the
# front-end edit/delete UI gate and the server-side scope guards (KTD8): only
# this user (not merely any authenticated/super-admin account) may edit, delete,
# or see drafts on the active trip's feed.
owner_username: mischa
+7 -1
View File
@@ -76,7 +76,13 @@ pages:
- rss - rss
- atom - atom
append_url_extension: null append_url_extension: null
expires: 604800 # expires: 0 → Cache-Control: max-age=0, so the browser (and any CDN) revalidates
# each load instead of serving up to 7 days stale. With etag on, an unchanged
# page returns a cheap 304; an edited/deleted/new entry shows immediately. This
# matters because the owner edits the live site and entry media reuses filenames
# (photo-N.jpg) across reorders. Server-side page cache (cache.enabled) is
# unaffected and still does the heavy lifting.
expires: 0
cache_control: null cache_control: null
last_modified: false last_modified: false
etag: true etag: true
+10
View File
@@ -5,4 +5,14 @@ date: '2026-09-01'
date_start: '2026-09-01' date_start: '2026-09-01'
date_end: '2026-09-08' date_end: '2026-09-08'
cover_image: '' 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.
@@ -1,7 +1,5 @@
--- ---
title: Stories title: Stories
template: default template: stories
routable: false
visible: false
published: true published: true
--- ---
+4 -5
View File
@@ -1,9 +1,8 @@
--- ---
title: 'Cycling Tuscany 2025' title: 'Tuscany Gravel 2025'
template: trip template: trip
date: '2025-10-11' date: '2025-09-01'
date_start: '2025-10-11' date_start: '2025-09-01'
date_end: '2025-10-16' date_end: '2025-09-08'
cover_image: '' cover_image: ''
--- ---
+143 -21
View File
@@ -1,87 +1,209 @@
--- ---
title: 'New journal entry' title: 'New Entry'
template: post-form template: post-form
access: access:
site.login: true site.login: true
# Parent (write target) is injected server-side from site.active_trip by the
# cache-on-save plugin (onFormValidationProcessed) — no manual sync needed.
#
# overwrite_mode is toggled per submit by cache-on-save (KTD1, Alt B): edit when
# the hidden edit_path field is filled (write back in place), false when empty
# (create a fresh dated folder via slug_field). The static value here is the
# create-safe fallback for if cache-on-save doesn't run — add-page-by-form stays
# stock (no fork), so a static `edit` would break create (empty edit_path -> '.').
pageconfig: pageconfig:
parent: /trips/italy-2026-demo/dailies
slug_field: 'date,title' slug_field: 'date,title'
overwrite_mode: false overwrite_mode: false
# published is NOT a static pagefrontmatter default anymore — it is an
# authoritative form field (below) so every submit (create AND edit) writes the
# owner's chosen publish state. Only `template` stays static here.
pagefrontmatter: pagefrontmatter:
template: entry template: entry
published: true
form: form:
name: new-entry name: new-entry
action: /post action: /post
enctype: multipart/form-data enctype: multipart/form-data
fields: fields:
# Photos come first — they anchor what you'll write (Photos → Title →
# Content). After upload post-form.js auto-collapses this into a summary
# bar so the thumbnails don't crowd the writing area.
-
name: photos
label: Photos (16)
# Grav-managed filepond field. post-form.js hooks its beforeAddFile
# to convert HEIC->JPEG in the browser, then re-adds the JPEG via the
# public pond.addFile() API — FilePond keeps ownership of the upload
# and page-attach contract (U4).
type: filepond
multiple: true
destination: '@self'
# Max 6 photos per entry; at least 1 is required (enforced
# client-side in post-form.js initValidation).
limit: 6
accept:
- 'image/*'
- -
name: title name: title
label: Title label: Title
type: text type: text
autofocus: true
validate: validate:
required: true required: true
- -
name: date name: date
label: 'Date & Time' label: Date & Time
# Rendered as a native <input type="datetime-local"> by the theme
# override at templates/forms/fields/datetime/datetime.html.twig.
# No `default: now` — the stock text fallback would print the literal
# string "now" into the picker; post-form.js prefills the current
# local time instead (and the client validator requires this field).
type: datetime type: datetime
default: now
format: 'Y-m-d H:i' format: 'Y-m-d H:i'
validate: validate:
required: true required: true
- -
name: content name: content
label: 'What happened today?' label: "What happened today?"
type: textarea type: textarea
rows: 10 rows: 10
validate: validate:
required: true required: true
-
name: photos
label: 'Photos (max 4)'
type: filepond
multiple: true
destination: '@self'
limit: 4
accept:
- 'image/*'
- -
name: lat name: lat
label: Latitude label: Latitude
type: text type: text
placeholder: 'tap "Get Location" below' placeholder: 'tap "Get Location" below'
- -
name: lng name: lng
label: Longitude label: Longitude
type: text type: text
placeholder: '' placeholder: ''
- -
name: location_city name: location_city
label: City label: City
type: text type: text
placeholder: 'e.g. Kyoto' placeholder: 'e.g. Kyoto'
- -
name: location_country name: location_country
label: Country label: Country
type: text type: text
placeholder: 'e.g. Japan' placeholder: 'e.g. Japan'
-
name: weather_temp_c
type: hidden
- -
name: weather_desc name: weather_desc
label: Weather Condition
type: select
default: ''
options:
'': '— none —'
'Sunny': '☀️ Sunny'
'Partly cloudy': '⛅ Partly cloudy'
'Cloudy': '☁️ Cloudy'
'Foggy': '🌫️ Foggy'
'Drizzle': '🌦️ Drizzle'
'Rain': '🌧️ Rain'
'Snow': '❄️ Snow'
'Thunderstorm': '⛈️ Thunderstorm'
-
name: weather_temp_c
label: 'Temperature (°C)'
type: number
placeholder: 'tap "Get Weather" or type it'
validate:
min: -60
max: 60
-
name: transport_mode
label: 'How I got here'
type: select
default: ''
options:
'': '— not specified —'
'walking': '🚶 Walking'
'bicycle': '🚲 Bicycle'
'bus': '🚌 Bus'
'train': '🚆 Train'
'car': '🚗 Car'
'plane': '✈️ Plane'
# Hidden edit target. Empty on create (add-page-by-form falls through to
# slug_field and writes a fresh dated folder); on edit, post-form.js sets
# it to the entry's path so overwrite_mode:edit writes back in place.
-
name: edit_path
type: hidden type: hidden
default: ''
# Advanced fields — collapsed behind "More options" (see U5).
# No hero_image field: journal entries render their hero from the first
# uploaded photo (entry-journal.html.twig uses entry.media.images|first),
# so an explicit hero filename was redundant. Stories still use hero_image
# but they aren't posted through this form.
-
name: published
label: Published
# Authoritative publish state (replaces the removed static
# pagefrontmatter.published). Default ON so new entries publish; the
# owner flips it OFF to save/keep a draft, or to unpublish on edit.
# validate.type:bool keeps it a real boolean in frontmatter (not '0').
type: toggle
classes: advanced-field
highlight: 1
default: 1
options:
1: 'Yes'
0: 'No'
validate:
type: bool
-
name: force_connect
label: Force connector line
type: toggle
classes: advanced-field
highlight: 0
default: 0
options:
1: 'Yes'
0: 'No'
validate:
type: bool
-
name: featured
label: Featured highlight
type: toggle
classes: advanced-field
highlight: 0
default: 0
options:
1: 'Yes'
0: 'No'
validate:
type: bool
novalidate: true novalidate: true
buttons: buttons:
- -
type: submit type: submit
value: 'Post Daily' value: Post Daily
classes: btn-post classes: btn-post
process: process:
add_page: true add_page: true
upload: true upload: true
message: 'Entry posted successfully!' message: 'Entry posted successfully!'
reset: true reset: true
--- ---
+321 -3
View File
@@ -1,26 +1,344 @@
<?php <?php
namespace Grav\Plugin; namespace Grav\Plugin;
use Grav\Common\Cache;
use Grav\Common\Data\ValidationException;
use Grav\Common\Plugin; use Grav\Common\Plugin;
use RocketTheme\Toolbox\Event\Event; use RocketTheme\Toolbox\Event\Event;
require_once __DIR__ . '/classes/EntryScopeGuard.php';
require_once __DIR__ . '/classes/PhotoRenumberer.php';
use Grav\Plugin\Shared\EntryScopeGuard;
use Grav\Plugin\Shared\PhotoRenumberer;
class CacheOnSavePlugin extends Plugin class CacheOnSavePlugin extends Plugin
{ {
/**
* onFormProcessed fires once per `process:` action (add_page, upload, message,
* reset — 4x for the post form). Photo reconciliation must run exactly once:
* the first pass renames the kept photos to photo-01..NN, so a second pass with
* the same manifest would see those renamed files as "unlisted" and delete
* them. This latches after the first run (the plugin instance persists for the
* request); the first fire is the `add_page` action, after add-page-by-form
* (priority 0) has created the page and copied files, so files are present.
*/
private bool $photosReconciled = false;
/**
* Same 4x-per-submit firing as $photosReconciled: onFormProcessed runs once
* per process action. Clearing the page-tree cache is idempotent, but doing it
* four times per post is wasted work (a full deleteAll() + system.yaml touch
* each time). Latch it so the invalidation runs exactly once per submission.
*/
private bool $cacheInvalidated = false;
public static function getSubscribedEvents(): array public static function getSubscribedEvents(): array
{ {
return [ return [
'onFormProcessed' => ['onFormProcessed', 0], // Runs before add-page-by-form's onFormProcessed (page write), so it
// can inject the write target and abort the submit by failing validation.
'onFormValidationProcessed' => ['onFormValidationProcessed', 0],
// Priority -100 so this runs AFTER add-page-by-form's onFormProcessed
// (priority 0) has created the page and copied the uploaded files —
// we reorder those files, then clear the page-tree cache.
'onFormProcessed' => ['onFormProcessed', -100],
]; ];
} }
/**
* Server-authoritative active-trip parent injection.
*
* The post form no longer hardcodes `pageconfig.parent`; instead the write
* target is derived from `site.active_trip` at submit time and injected into
* the form data. add-page-by-form reads `$form->value()->toArray()['parent']`
* (add-page-by-form.php:521) and honours it over any pageconfig/header parent.
*
* Fail closed: if `active_trip` is unset/empty we throw a ValidationException
* so the `add_page` action never runs. Merely leaving `parent` unset is unsafe —
* with `pageconfig.parent` removed, add-page-by-form's `getParentPage('')`
* resolves to the /post page itself and the entry would silently land there.
*/
public function onFormValidationProcessed(Event $event): void
{
$form = $event['form'];
if (!$form || $form->getName() !== 'new-entry') {
return;
}
$activeTrip = $this->grav['config']->get('site.active_trip');
$activeTrip = is_string($activeTrip) ? trim($activeTrip) : '';
if ($activeTrip === '') {
throw new ValidationException('No active trip is set — cannot post an entry. Set site.active_trip first.');
}
$form->setData('parent', $this->resolveDailiesParent($activeTrip));
// One shared /post form drives both create and edit (KTD1). add-page-by-form
// reads overwrite_mode from the /post page header's pageconfig (not form
// data), so we toggle it here per submit.
$editPath = $this->editPathFromForm($form);
if ($editPath === '') {
// CREATE — left untouched (any site.login user): overwrite_mode:false
// so stock add-page-by-form falls through to slug_field (date,title)
// and writes a fresh dated folder.
$this->setOverwriteMode('false');
return;
}
// EDIT — enforce R6 server-side (KTD6) BEFORE allowing an in-place write.
// Fail closed (ValidationException) so the add_page action never runs.
// The UI only renders Edit for the owner on the active trip, but that gate
// is cosmetic; this is the authoritative check.
if (!EntryScopeGuard::isOwner($this->grav)) {
throw new ValidationException('You are not allowed to edit journal entries.');
}
$segment = EntryScopeGuard::segmentFromEditPath($editPath);
if (EntryScopeGuard::resolveActiveDailyChild($this->grav, $segment) === null) {
// Unsafe/traversal segment, no active trip, missing page, or a target
// outside the active trip's dailies — all rejected identically.
throw new ValidationException('That entry is not editable here — it is not in the active trip.');
}
$this->setOverwriteMode('edit');
}
/**
* The hidden edit_path form field, trimmed. Empty string when creating a new
* entry; the entry's `<route>/entry.md` path when editing (set by post-form.js).
*/
private function editPathFromForm($form): string
{
$value = $form->value('edit_path');
return is_string($value) ? trim($value) : '';
}
/**
* Override add-page-by-form's overwrite_mode by mutating the current (/post)
* page header's pageconfig. add-page-by-form reads
* `$grav['page']->header()->pageconfig['overwrite_mode']` (add-page-by-form.php
* :385) from the same page singleton, and Page::header() returns a cached
* instance, so this write is visible when its onFormProcessed runs afterwards.
*/
private function setOverwriteMode(string $mode): void
{
$page = $this->grav['page'] ?? null;
if (!$page) {
return;
}
$header = $page->header();
$pageconfig = (isset($header->pageconfig) && is_array($header->pageconfig)) ? $header->pageconfig : [];
$pageconfig['overwrite_mode'] = $mode;
$header->pageconfig = $pageconfig;
}
/**
* Normalise `active_trip` to its dailies container route.
*
* Accepts either a full route ("/trips/italy-2026-demo") or a bare slug
* ("italy-2026-demo") and returns "/trips/<slug>/dailies".
*/
private function resolveDailiesParent(string $activeTrip): string
{
$trip = trim($activeTrip, '/');
if (strpos($trip, 'trips/') !== 0) {
$trip = 'trips/' . $trip;
}
return '/' . $trip . '/dailies';
}
/**
* The photo order the user arranged in the form, sent explicitly by
* post-form.js as a JSON array of filenames in the dedicated
* data[photo_order] input. FilePond does not re-sequence its own submitted
* inputs on reorder, so this is the only reliable source of the drag order.
* Read straight from $_POST as a top-level key (not under data[]), so Grav's
* form never captures it and it never lands in the entry frontmatter.
*/
private function orderFromPost(): array
{
$raw = $_POST['photo_order'] ?? null;
if (!is_string($raw) || $raw === '') {
return [];
}
$decoded = json_decode($raw, true);
if (!is_array($decoded)) {
return [];
}
$names = [];
foreach ($decoded as $name) {
if (is_string($name) && $name !== '') {
$names[] = basename(str_replace('\\', '/', $name));
}
}
return $names;
}
public function onFormProcessed(Event $event): void public function onFormProcessed(Event $event): void
{ {
$form = $event['form']; $form = $event['form'];
if (!$form) { if (!$form || $form->getName() !== 'new-entry') {
return; return;
} }
if ($form->getName() === 'new-entry') {
// Reconcile the entry's photos to the order the owner arranged in the form
// (FilePond drag). On create this only renumbers the just-copied uploads;
// on edit (M2) it also removes any photo the owner dropped and renumbers
// the surviving set so the first file is the cover. Runs ONCE per submit
// (see $photosReconciled) — a second pass would delete the just-renamed
// photo-N files as "unlisted". Best-effort: any failure logs and is
// skipped so a post is never lost over cosmetics.
if (!$this->photosReconciled) {
$this->photosReconciled = true;
try {
$this->reconcilePhotos($form);
} catch (\Throwable $e) {
$this->grav['log']->warning('cache-on-save: photo reconcile skipped — ' . $e->getMessage());
}
}
// Two-part invalidation, latched to run ONCE per submit (see
// $cacheInvalidated) — the 4 process actions would otherwise repeat it.
// deleteAll() drops the Doctrine store (the tracker feed page cache etc.),
// but the page-tree INDEX is keyed on
// md5(dirs + folderHash + config->checksum() + lang) (Pages::buildRegularPages).
// With cache.check.method:folder that index can survive a create — a fresh
// entry then stays invisible to the API (GET /api/v1/pages{route} 404s), so
// opening the just-posted entry for editing shows "this entry no longer
// exists". invalidateCache() touches system.yaml, bumping config->checksum()
// so the index key changes and the tree rebuilds on the next request.
if (!$this->cacheInvalidated) {
$this->cacheInvalidated = true;
$this->grav['cache']->deleteAll(); $this->grav['cache']->deleteAll();
Cache::invalidateCache();
$this->grav['log']->info('cache-on-save: cleared page cache + invalidated page-tree index after new-entry submit');
} }
} }
/**
* Reconcile the entry's photo files to the submitted (drag) order.
*
* The published entry lists media in filename order and treats the first as
* the hero/cover (see partials/entry-journal + entry-story), so a deterministic
* photo-N naming is what makes the arranged order stick. post-form.js sends the
* final ordered set via the top-level `photo_order` POST key (orderFromPost):
* on create these are the just-uploaded client filenames; on edit (M2) the mix
* of surviving existing photos (loaded into FilePond as local items) plus any
* new uploads, in the arranged order.
*
* Create: fuzzily locate the fresh folder by its uploaded filenames, then
* renumber. Edit: locate the folder authoritatively through the page tree via
* the shared scope guard (findEntryFolder is unsafe once files are the generic
* photo-N.jpg — many entries share those names), delete any image the owner
* dropped (not in the manifest), then renumber the survivors.
*
* Fail-safe: an empty manifest reconciles nothing (photos are left untouched),
* so a missing/failed `photo_order` on edit never wipes an entry's images.
*/
private function reconcilePhotos($form): void
{
$names = $this->orderFromPost();
if (count($names) < 1) {
return; // nothing submitted — leave the entry's photos untouched
}
$editPath = $this->editPathFromForm($form);
if ($editPath !== '') {
// EDIT — resolve the target folder through the page tree (shared guard),
// then prune dropped photos before renumbering the survivors.
$segment = EntryScopeGuard::segmentFromEditPath($editPath);
$page = EntryScopeGuard::resolveActiveDailyChild($this->grav, $segment);
if ($page === null) {
return; // out of scope / unresolvable — the save guard already ran
}
$dir = $page->path();
$this->deleteUnlistedImages($dir, $names);
} else {
// CREATE — locate the fresh folder by the set of uploaded filenames.
$activeTrip = $this->grav['config']->get('site.active_trip');
$activeTrip = is_string($activeTrip) ? trim($activeTrip) : '';
if ($activeTrip === '') {
return;
}
$slug = preg_replace('#^/?trips/#', '', trim($activeTrip, '/'));
$slug = preg_replace('#/.*$#', '', $slug);
$dir = $this->findEntryFolder($slug, $names);
if ($dir === null) {
return; // couldn't confidently locate the new entry folder
}
}
PhotoRenumberer::renumber($dir, $names);
}
/**
* Delete every image file in $dir whose basename is not in $keep (the manifest
* of photos the owner kept). Only touches known image extensions — never the
* entry .md or any other file — and clears any Grav media sidecar so a stale
* `.meta.yaml` can't resurrect a removed image.
*/
private function deleteUnlistedImages(string $dir, array $keep): void
{
$keepSet = array_flip($keep);
$imageExts = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'heic', 'heif'];
foreach (glob($dir . DIRECTORY_SEPARATOR . '*') ?: [] as $path) {
if (!is_file($path)) {
continue;
}
$base = basename($path);
$ext = strtolower(pathinfo($base, PATHINFO_EXTENSION));
if (!in_array($ext, $imageExts, true)) {
continue; // never touch .md or non-image files
}
if (isset($keepSet[$base])) {
continue; // still in the arranged set — keep it
}
@unlink($path);
if (is_file($path . '.meta.yaml')) {
@unlink($path . '.meta.yaml');
}
}
}
/**
* Locate the freshly-created entry folder: the child of the active trip's
* dailies directory that contains all of the uploaded files. Matching by the
* exact uploaded filenames avoids re-deriving add-page-by-form's slug logic.
*/
private function findEntryFolder(string $slug, array $names): ?string
{
$pagesRoot = rtrim(USER_DIR, '/\\') . '/pages';
$dailies = null;
foreach (glob($pagesRoot . '/*trips*', GLOB_ONLYDIR) ?: [] as $tripsDir) {
foreach (glob($tripsDir . '/*', GLOB_ONLYDIR) ?: [] as $tripDir) {
$base = basename($tripDir);
if ($base === $slug || preg_match('/(^|\.)' . preg_quote($slug, '/') . '$/', $base)) {
$found = glob($tripDir . '/*dailies*', GLOB_ONLYDIR) ?: [];
if ($found) {
$dailies = $found[0];
break 2;
}
}
}
}
if ($dailies === null) {
return null;
}
foreach (glob($dailies . '/*', GLOB_ONLYDIR) ?: [] as $child) {
$allPresent = true;
foreach ($names as $name) {
if (!is_file($child . DIRECTORY_SEPARATOR . $name)) {
$allPresent = false;
break;
}
}
if ($allPresent) {
return $child;
}
}
return null;
}
} }
@@ -0,0 +1,130 @@
<?php
namespace Grav\Plugin\Shared;
use Grav\Common\Grav;
use Grav\Common\Page\Interfaces\PageInterface;
/**
* Single source of truth for the R6 server-side scope guard, shared by BOTH
* enforcement points so they cannot diverge (KTD5):
* - the save/publish path (cache-on-save onFormValidationProcessed), and
* - the delete path (entry-actions API route).
*
* Two independent checks, both required:
* 1. isOwner() — the acting user is the configured site owner, NOT merely any
* authenticated account (the super-admin `tester` also authenticates).
* 2. resolveActiveDailyChild() — the target resolves, through the page tree,
* to a real DIRECT child of the active trip's `dailies` container. Resolving
* via $pages->find() + a parent-route assertion (never raw path concatenation)
* closes the traversal hole where a string like `/…/dailies/../other/entry.md`
* prefix-matches the active dailies but points elsewhere.
*/
class EntryScopeGuard
{
/**
* Active trip's dailies container route ("/trips/<slug>/dailies"), or null
* when site.active_trip is unset. Accepts a full route or a bare slug.
*/
public static function dailiesRoute(Grav $grav): ?string
{
$active = $grav['config']->get('site.active_trip');
$active = is_string($active) ? trim($active) : '';
if ($active === '') {
return null;
}
$trip = trim($active, '/');
if (strpos($trip, 'trips/') !== 0) {
$trip = 'trips/' . $trip;
}
return '/' . $trip . '/dailies';
}
/**
* True only when the current user is authenticated AND their username equals
* site.owner_username. Gating on authentication alone would grant rights to
* every account, including the super-admin `tester` (KTD8).
*/
public static function isOwner(Grav $grav): bool
{
$user = $grav['user'] ?? null;
if (!$user || empty($user->authenticated)) {
return false;
}
return self::isOwnerUser($grav, $user);
}
/**
* Owner check for an explicit user object — used by the API delete route,
* whose authenticated user comes from the request (api_user attribute), not
* $grav['user']. Same rule: username must equal site.owner_username.
*/
public static function isOwnerUser(Grav $grav, $user): bool
{
if (!$user || !isset($user->username)) {
return false;
}
$owner = $grav['config']->get('site.owner_username');
return is_string($owner) && $owner !== '' && $user->username === $owner;
}
/**
* A safe single path segment: non-empty, no separators, no dot-traversal.
*/
public static function isSafeSegment(string $segment): bool
{
if ($segment === '' || $segment === '.' || $segment === '..') {
return false;
}
if (strpbrk($segment, '/\\') !== false) {
return false;
}
return strpos($segment, '..') === false;
}
/**
* The folder segment carried by a hidden edit_path value. post-form.js sets
* edit_path to "<entry-route>/entry.md", so basename(dirname()) is the entry's
* own folder name (its route's last segment) — the same value stock
* add-page-by-form derives for the in-place write.
*/
public static function segmentFromEditPath(string $editPath): string
{
$editPath = trim($editPath);
if ($editPath === '') {
return '';
}
return basename(dirname($editPath));
}
/**
* Resolve a folder segment to the page that is a DIRECT child of the active
* trip's dailies container, or null when the segment is unsafe, no active trip
* is set, the page does not exist, or its parent is not the active dailies.
*/
public static function resolveActiveDailyChild(Grav $grav, string $segment): ?PageInterface
{
if (!self::isSafeSegment($segment)) {
return null;
}
$dailies = self::dailiesRoute($grav);
if ($dailies === null) {
return null;
}
$pages = $grav['pages'];
// In the API request context the page tree is lazily disabled; enable it
// so find() can resolve (mirrors the api plugin's own resolvePageByRoute).
// Idempotent — a no-op in the frontend save-path context.
if (method_exists($pages, 'enablePages')) {
$pages->enablePages();
}
$page = $pages->find($dailies . '/' . $segment);
if ($page === null) {
return null;
}
$parent = $page->parent();
if ($parent === null || $parent->route() !== $dailies) {
return null;
}
return $page;
}
}
@@ -0,0 +1,131 @@
<?php
namespace Grav\Plugin\Shared;
/**
* Single source of truth for the `photo-NN` naming invariant, shared by BOTH
* paths that establish it so their numbering can never diverge:
* - cache-on-save's create/edit reconcile (onFormProcessed), and
* - entry-actions' live reorder route (POST /entry/{slug}/photos/order).
*
* Files named in the ordered manifest are renamed to `photo-01..NN` (ZERO-PADDED)
* in that order. Zero-padding is load-bearing: the published feed lists media in
* filename order and treats the first as the cover (entry.media.images|first), and
* lexicographic order only equals numeric order past 9 photos when the index is
* padded (otherwise photo-1, photo-10, photo-2…). The pad width grows with the
* set so it stays correct for 100+ photos, while normal entries get `photo-01`.
*
* Safety: only files that are ON DISK and carry a known IMAGE extension are ever
* renamed. A crafted manifest entry naming the entry `.md`, a `.gpx`, or a
* `.meta.yaml` sidecar is silently skipped — it can never be renamed or clobbered.
* This guard lives here (not only in the callers) so every caller inherits it.
*
* Completeness: renumber() ALWAYS renumbers every image already in $dir, not just
* the manifest subset. $names only supplies the preferred ORDER; any on-disk image
* the manifest omits is appended at the end. This makes an incomplete/stale
* manifest (e.g. a second browser tab whose list predates a change) harmless —
* without it, an unlisted image left sitting at a target slot would be silently
* OVERWRITTEN (destroyed) by the second-phase rename. The reorder route trusts a
* client-supplied list, so this guard is what keeps it from losing photos.
*/
class PhotoRenumberer
{
/** Image extensions eligible for renumbering. Broad on purpose: existing
* entries may hold .heic even though new uploads are jpg/jpeg/png/webp. */
private const IMAGE_EXTS = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'heic', 'heif'];
/**
* Renumber every image in $dir to photo-01..NN, using $names as the preferred
* order and appending any unlisted on-disk images at the end.
*
* Two-phase via temp names so a target (photo-02.jpg) can't clobber a
* not-yet-moved source of the same name (e.g. a straight swap or the
* un-padded photo-N → photo-0N normalisation pass). Non-image and missing
* files in $names are skipped and do not consume an index; a repeated name is
* counted once. Because every on-disk image becomes a target (see the
* completeness note on the class), the surviving images are numbered
* contiguously from 01 and no untouched file is ever overwritten.
*
* Idempotent: a file already at its correct padded name is left untouched,
* so re-running with the same manifest (e.g. an auto-retried reorder) is a
* no-op.
*/
public static function renumber(string $dir, array $names): void
{
// Keep only real image files, in the requested order, de-duplicated —
// this is both the security filter and what determines the pad width.
$targets = [];
$seen = [];
foreach ($names as $name) {
if (!is_string($name) || $name === '') {
continue;
}
$base = basename(str_replace('\\', '/', $name));
if (isset($seen[$base])) {
continue; // a repeated name must not consume a second index
}
$src = $dir . DIRECTORY_SEPARATOR . $base;
if (!is_file($src)) {
continue; // not on disk — skip (idempotent for auto-retry)
}
$ext = strtolower(pathinfo($base, PATHINFO_EXTENSION));
if (!in_array($ext, self::IMAGE_EXTS, true)) {
continue; // never rename the entry .md, a .gpx, or a sidecar
}
$seen[$base] = true;
$targets[] = [$src, $ext ?: 'jpg'];
}
// Completeness guard: append EVERY other image already in $dir that the
// manifest didn't list (natural name order), so an incomplete/stale
// manifest can't leave an unlisted image at a target slot for phase-2 to
// overwrite. Hidden files ('.'-prefixed temp/sidecar) are never targets.
$extra = [];
foreach (@scandir($dir) ?: [] as $f) {
if ($f === '' || $f[0] === '.' || isset($seen[$f])) {
continue;
}
$p = $dir . DIRECTORY_SEPARATOR . $f;
if (!is_file($p)) {
continue;
}
$ext = strtolower(pathinfo($f, PATHINFO_EXTENSION));
if (!in_array($ext, self::IMAGE_EXTS, true)) {
continue;
}
$extra[$f] = [$p, $ext ?: 'jpg'];
}
if ($extra) {
uksort($extra, 'strnatcasecmp');
foreach ($extra as $t) {
$targets[] = $t;
}
}
$width = max(2, strlen((string) count($targets)));
// Unique per-call token in the temp name so two concurrent renumbers on
// the same folder can't collide on a shared '.reorder-tmp-N' path and
// overwrite one photo's bytes.
$token = bin2hex(random_bytes(4));
$planned = [];
$i = 1;
foreach ($targets as [$src, $ext]) {
$index = str_pad((string) $i, $width, '0', STR_PAD_LEFT);
$final = $dir . DIRECTORY_SEPARATOR . 'photo-' . $index . '.' . $ext;
if ($src === $final) {
$i++;
continue; // already correctly named — leave it
}
$tmp = $dir . DIRECTORY_SEPARATOR . '.reorder-tmp-' . $token . '-' . $i . '.' . $ext;
@rename($src, $tmp);
$planned[] = [$tmp, $final];
$i++;
}
foreach ($planned as [$tmp, $final]) {
if (is_file($tmp)) {
@rename($tmp, $final);
}
}
}
}
+14
View File
@@ -0,0 +1,14 @@
name: Entry Actions
version: 0.1.0
description: Owner-only, active-trip-scoped journal entry actions (M1: delete) via the Grav API.
icon: trash
author:
name: Mischa
homepage: https://intotheeast.com
keywords: api, journal, delete
bugs: https://intotheeast.com
license: MIT
dependencies:
- { name: grav, version: '>=2.0.0' }
- { name: api }
@@ -0,0 +1,144 @@
<?php
namespace Grav\Plugin\EntryActions;
use Grav\Common\Cache;
use Grav\Common\Filesystem\Folder;
use Grav\Plugin\Api\Controllers\AbstractApiController;
use Grav\Plugin\Api\Exceptions\ApiException;
use Grav\Plugin\Api\Exceptions\ForbiddenException;
use Grav\Plugin\Api\Exceptions\NotFoundException;
use Grav\Plugin\Api\Response\ApiResponse;
use Grav\Plugin\Shared\EntryScopeGuard;
use Grav\Plugin\Shared\PhotoRenumberer;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
// Shared R6 guard + the photo-NN renumber helper both live in cache-on-save (the
// always-present custom plugin); require them so save, delete and reorder enforce
// scope identically (KTD5) and share one numbering invariant.
require_once dirname(__DIR__, 2) . '/cache-on-save/classes/EntryScopeGuard.php';
require_once dirname(__DIR__, 2) . '/cache-on-save/classes/PhotoRenumberer.php';
/**
* DELETE /api/v1/entry/{slug}
*
* Deletes a journal entry folder, but only when ALL hold:
* - the request is the authenticated site OWNER (not merely any login/admin);
* - {slug} is a safe single segment (no '/', no '..');
* - it resolves through the page tree to a DIRECT child of the ACTIVE trip's
* dailies container.
* Otherwise: 401 (anon), 403 (non-owner), 400 (bad slug), 404 (out of scope /
* not found). On success the folder is removed and the page-tree cache cleared.
*/
class EntryActionsApiController extends AbstractApiController
{
public function deleteEntry(ServerRequestInterface $request): ResponseInterface
{
// Authenticated OWNER only (KTD8). getUser() throws 401 for anonymous.
$user = $this->getUser($request);
// Enforce the API-key scope cap (GHSA-x7hm) with the SAME permission the
// stock media/page-write endpoints require. The owner already holds it
// (their add/delete media uploads pass it), so this only caps a scoped
// key — it never blocks the legitimate owner.
$this->requirePermission($request, 'api.pages.write');
if (!EntryScopeGuard::isOwnerUser($this->grav, $user)) {
throw new ForbiddenException('Only the site owner can delete journal entries.');
}
$slug = $this->getRouteParam($request, 'slug');
if (!is_string($slug) || !EntryScopeGuard::isSafeSegment($slug)) {
throw new ApiException(400, 'Bad Request', 'Invalid entry slug.');
}
// Resolve via $pages->find() + parent-route assertion (never raw path
// concatenation) — same shared check the save path uses.
$page = EntryScopeGuard::resolveActiveDailyChild($this->grav, $slug);
if ($page === null) {
throw new NotFoundException('Entry not found in the active trip.');
}
$path = $page->path();
if (!is_string($path) || $path === '' || !is_dir($path)) {
throw new NotFoundException('Entry folder not found.');
}
Folder::delete($path);
// deleteAll() drops the cache stores but does NOT rebuild Grav's page-tree
// index (keyed on folderHash, which doesn't change on child removal under
// cache.check.method: folder). Without invalidateCache() the deleted entry
// lingers in the index and the feed re-renders it — now image-less — on the
// next load. Mirrors the create-path fix in the cache-on-save plugin. See
// docs/solutions/integration-issues/grav-deleteall-doesnt-invalidate-page-tree-index.md
$this->grav['cache']->deleteAll();
Cache::invalidateCache();
// Audit trail: entry deletion is destructive and owner-only — record who
// did it and to what, so an unexpected disappearance is traceable.
$this->grav['log']->info(sprintf('entry-actions: owner "%s" deleted entry "%s"', $user->username, $slug));
return ApiResponse::noContent();
}
/**
* POST /api/v1/entry/{slug}/photos/order
*
* Body: { "order": ["photo-x.jpg", "photo-y.jpg", …] } — the entry's image
* files in the display order the owner arranged. Renames them to photo-01..NN
* so the feed cover (media.images|first) and numeric client sort follow the
* drag. Same guard chain as deleteEntry: OWNER + direct-child-of-active-dailies.
*
* Filename safety is defence in depth: unsafe segments (containing '/' or '..')
* are dropped here, and PhotoRenumberer only ever renames files that already
* exist as image media in the folder — so a crafted order body can never touch
* the entry .md, a .gpx or a .meta.yaml sidecar. An incomplete `order` (e.g. a
* stale second tab) is safe too: PhotoRenumberer renumbers every on-disk image,
* appending any the manifest omits, so no photo is lost — `order` only sorts.
*/
public function reorderPhotos(ServerRequestInterface $request): ResponseInterface
{
// Authenticated OWNER only (KTD8). getUser() throws 401 for anonymous.
$user = $this->getUser($request);
// Enforce the API-key scope cap (GHSA-x7hm) — see deleteEntry above.
$this->requirePermission($request, 'api.pages.write');
if (!EntryScopeGuard::isOwnerUser($this->grav, $user)) {
throw new ForbiddenException('Only the site owner can reorder entry photos.');
}
$slug = $this->getRouteParam($request, 'slug');
if (!is_string($slug) || !EntryScopeGuard::isSafeSegment($slug)) {
throw new ApiException(400, 'Bad Request', 'Invalid entry slug.');
}
$page = EntryScopeGuard::resolveActiveDailyChild($this->grav, $slug);
if ($page === null) {
throw new NotFoundException('Entry not found in the active trip.');
}
$path = $page->path();
if (!is_string($path) || $path === '' || !is_dir($path)) {
throw new NotFoundException('Entry folder not found.');
}
// Reduce the body's `order` to a clean list of safe basenames. Anything
// unsafe or non-string is dropped; PhotoRenumberer then keeps only the
// entries that are real image files on disk.
$body = $this->getRequestBody($request);
$order = $body['order'] ?? null;
if (!is_array($order)) {
throw new ApiException(400, 'Bad Request', 'Body must include an "order" array of filenames.');
}
$names = [];
foreach ($order as $name) {
if (is_string($name) && EntryScopeGuard::isSafeSegment($name)) {
$names[] = $name;
}
}
PhotoRenumberer::renumber($path, $names);
$this->grav['cache']->deleteAll();
// Audit trail: mirror deleteEntry — record the owner mutating an entry's
// photo order (and how many files the manifest listed).
$this->grav['log']->info(sprintf('entry-actions: owner "%s" reordered %d photo(s) for entry "%s"', $user->username, count($names), $slug));
return ApiResponse::noContent();
}
}
+69
View File
@@ -0,0 +1,69 @@
<?php
namespace Grav\Plugin;
use Grav\Common\Plugin;
use RocketTheme\Toolbox\Event\Event;
/**
* Entry Actions — a thin, purpose-built API surface for owner-only, active-trip
* scoped journal-entry actions that the stock Grav API cannot express safely.
*
* Routes:
* - DELETE /api/v1/entry/{slug} — delete a journal entry folder
* - POST /api/v1/entry/{slug}/photos/order — reorder an entry's photos
*
* The stock DELETE /api/v1/pages<route> only checks write-permission (no trip
* scope, and any admin passes), which violates R6; and no stock endpoint can
* rename media to the photo-NN cover order at all. Both custom routes require the
* configured site OWNER and assert the target is a direct child of the active
* trip's dailies container — sharing one guard (EntryScopeGuard) with the save
* path so the R6 enforcement points cannot diverge (KTD5).
*
* Custom-in-repo (NOT GPM-managed): tracked via a `!` negation in user/.gitignore
* and deployed with the content push, like cache-on-save. Never in plugins.txt.
*/
class EntryActionsPlugin extends Plugin
{
public static function getSubscribedEvents(): array
{
return [
'onPluginsInitialized' => ['onPluginsInitialized', 0],
'onApiRegisterRoutes' => ['onApiRegisterRoutes', 0],
];
}
/**
* Register a lazy PSR-4 autoloader for this plugin's classes. The api router
* dispatches from a CACHED route map and instantiates the controller directly
* (ApiRouter::handleRoute → `new $controllerClass`) WITHOUT re-firing
* onApiRegisterRoutes, so requiring the class only there would leave it
* unloaded on cached-route requests. Lazy autoloading fires exactly when the
* router constructs the controller — by which point the api plugin's own
* autoloader (for AbstractApiController) is already registered.
*/
public function onPluginsInitialized(): void
{
spl_autoload_register(static function (string $class): void {
$prefix = 'Grav\\Plugin\\EntryActions\\';
if (strncmp($class, $prefix, strlen($prefix)) !== 0) {
return;
}
$rel = substr($class, strlen($prefix));
$file = __DIR__ . '/classes/' . str_replace('\\', '/', $rel) . '.php';
if (is_file($file)) {
require_once $file;
}
});
}
public function onApiRegisterRoutes(Event $event): void
{
$routes = $event['routes'];
$routes->delete('/entry/{slug}', [EntryActions\EntryActionsApiController::class, 'deleteEntry']);
// Reorder an entry's photos to a client-supplied order → rename to
// photo-01..NN so the feed cover (media.images|first) follows the drag.
// Nested-static-after-param, same shape as the DELETE above — it only
// registers once the API route-map cache is rebuilt (deploy must clear cache).
$routes->post('/entry/{slug}/photos/order', [EntryActions\EntryActionsApiController::class, 'reorderPhotos']);
}
}
+1
View File
@@ -0,0 +1 @@
enabled: true
+4 -4
View File
@@ -38,10 +38,10 @@ 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' accept: ['.jpg', '.jpeg', '.png', '.webp', '.gif']
placeholder: 'cover.jpg' label: 'Cover Image'
help: 'Used in the trips listing page' 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: header.album_url:
type: text type: text
File diff suppressed because one or more lines are too long
+165
View File
@@ -269,6 +269,103 @@ body::after {
gap: var(--space-1); gap: var(--space-1);
} }
/* ── Owner card controls (U4): Draft badge + Edit/Delete ─────────────────── */
.journal-post-titlerow {
display: flex;
align-items: flex-start;
justify-content: space-between;
flex-wrap: wrap; /* phone-first: actions wrap below a long title */
gap: var(--space-2) var(--space-3);
margin-bottom: var(--space-2);
}
.journal-post-titlerow .journal-post-title { margin-bottom: 0; }
.journal-draft-badge {
display: inline-block;
vertical-align: middle;
margin-left: var(--space-2);
font-family: var(--font-sans);
font-size: 0.6875rem;
font-weight: 700;
letter-spacing: 0.09em;
text-transform: uppercase;
color: #E0A458; /* warm amber — draft/unpublished */
border: 1px solid #E0A458;
border-radius: var(--radius-sm);
padding: 0.1em 0.5em;
line-height: 1.5;
white-space: nowrap;
}
.journal-post-actions {
display: flex;
align-items: center;
gap: var(--space-2);
flex-wrap: wrap;
flex-shrink: 0;
}
.entry-action {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 44px; /* D8 — phone-first tap target */
min-width: 44px;
padding: 0 var(--space-3);
font-family: var(--font-sans);
font-size: var(--text-xs);
font-weight: 600;
letter-spacing: 0.04em;
border-radius: var(--radius-sm);
border: 1px solid var(--color-border);
background: transparent;
color: var(--color-ink-2);
text-decoration: none;
cursor: pointer;
transition: background .15s, color .15s, border-color .15s;
}
.entry-action:hover,
.entry-action:focus-visible {
border-color: var(--color-accent);
color: var(--color-accent);
outline: none;
}
/* [hidden] must win over the inline-flex display above (used to swap
Delete <-> Cancel/Confirm from feed-actions.js). */
.entry-action[hidden] { display: none; }
.entry-action--confirm {
color: #E5786A; /* soft red — destructive confirm */
border-color: #E5786A;
}
.entry-action--confirm:hover,
.entry-action--confirm:focus-visible {
background: #E5786A;
color: var(--color-ink-inverse);
}
.entry-delete-confirm {
display: inline-flex;
align-items: center;
gap: var(--space-2);
}
.entry-delete-confirm[hidden] { display: none; }
.entry-delete-msg {
font-size: var(--text-xs);
color: #E5786A;
}
.entry-delete-msg:empty { display: none; }
/* Visually-hidden but screen-reader-available (feed-actions live region). */
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.journal-photo-wrap { .journal-photo-wrap {
position: relative; position: relative;
margin-bottom: var(--space-5); margin-bottom: var(--space-5);
@@ -950,6 +1047,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 +1202,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 +1272,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);
+1
View File
@@ -0,0 +1 @@
(()=>{function m(){var e=document.getElementById("feed-actions-live");return e||(e=document.createElement("div"),e.id="feed-actions-live",e.setAttribute("aria-live","polite"),e.setAttribute("role","status"),e.className="sr-only",document.body.appendChild(e)),e}function s(e){m().textContent=e}function v(e){var n=String(e||"").replace(/\/+$/,"").split("/");return n[n.length-1]||""}function u(e,n){var i=e.querySelector("[data-delete-start]"),r=e.querySelector(".entry-delete-confirm");if(i&&(i.hidden=n),r&&(r.hidden=!n),!n){var t=e.querySelector(".entry-delete-msg");t&&(t.textContent="")}}function g(e){var n=e.getAttribute("data-entry-route"),i=v(n),r=e.querySelector("[data-delete-cancel]"),t=e.querySelector("[data-delete-confirm]"),a=e.querySelector(".entry-delete-msg"),l=e.closest(".journal-post");t&&(t.disabled=!0,t.textContent="Deleting\u2026"),r&&(r.disabled=!0),a&&(a.textContent=""),s("Deleting entry\u2026"),fetch("/api/v1/entry/"+encodeURIComponent(i),{method:"DELETE",credentials:"include",headers:{Accept:"application/json"}}).then(function(d){if(!(d.status===204||d.ok))throw new Error("HTTP "+d.status);for(var o=l?l.nextElementSibling:null;o&&!o.classList.contains("journal-post");)o=o.nextElementSibling;var c=o||(l&&l.previousElementSibling&&l.previousElementSibling.classList&&l.previousElementSibling.classList.contains("journal-post")?l.previousElementSibling:null)||document.querySelector(".home-trip-name, .home-feed-col h1, .feed");if(l&&l.remove(),c){c.hasAttribute("tabindex")||c.setAttribute("tabindex","-1");try{c.focus({preventScroll:!1})}catch{}}s("Entry deleted.")}).catch(function(){t&&(t.disabled=!1,t.textContent="Confirm delete"),r&&(r.disabled=!1),a&&(a.textContent="Could not delete \u2014 please try again."),s("Delete failed.")})}function f(){document.addEventListener("click",function(e){var n=e.target.closest?e.target.closest("[data-delete-start]"):null,i=e.target.closest?e.target.closest("[data-delete-cancel]"):null,r=e.target.closest?e.target.closest("[data-delete-confirm]"):null;if(!(!n&&!i&&!r)){var t=(n||i||r).closest(".journal-post-actions");if(t){if(e.preventDefault(),n){u(t,!0);return}if(i){u(t,!1);return}if(r){var a=t.querySelector("[data-delete-confirm]");if(a&&a.disabled)return;g(t)}}}})}document.readyState==="loading"?document.addEventListener("DOMContentLoaded",f):f();})();
@@ -0,0 +1 @@
var g=Object.create;var f=Object.defineProperty;var h=Object.getOwnPropertyDescriptor;var i=Object.getOwnPropertyNames;var j=Object.getPrototypeOf,k=Object.prototype.hasOwnProperty;var m=(a=>typeof require<"u"?require:typeof Proxy<"u"?new Proxy(a,{get:(b,c)=>(typeof require<"u"?require:b)[c]}):a)(function(a){if(typeof require<"u")return require.apply(this,arguments);throw Error('Dynamic require of "'+a+'" is not supported')});var n=(a,b)=>()=>(b||a((b={exports:{}}).exports,b),b.exports);var l=(a,b,c,e)=>{if(b&&typeof b=="object"||typeof b=="function")for(let d of i(b))!k.call(a,d)&&d!==c&&f(a,d,{get:()=>b[d],enumerable:!(e=h(b,d))||e.enumerable});return a};var o=(a,b,c)=>(c=a!=null?g(j(a)):{},l(b||!a||!a.__esModule?f(c,"default",{value:a,enumerable:!0}):c,a));export{m as a,n as b,o as c};
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+120
View File
@@ -0,0 +1,120 @@
/*
* feed-actions.js (U6) — owner delete wiring for journal feed cards.
*
* Loaded only on the trip / home active-trip feed for the owner. The Edit link is
* a plain navigation (no JS). Delete is a two-step inline confirm (no browser
* dialog): Delete → Cancel / Confirm delete → DELETE /api/v1/entry/<slug>
* (session cookie), then the card is removed and focus moves to the next card.
*
* Markup (rendered by partials/entry-journal.html.twig):
* article.journal-post[data-entry-route]
* .journal-post-actions[data-entry-route]
* button[data-delete-start] (Delete)
* .entry-delete-confirm[hidden]
* button[data-delete-cancel] (Cancel)
* button[data-delete-confirm] (Confirm delete)
* .entry-delete-msg[aria-live] (inline error slot)
*/
// One page-level polite live region for cross-card announcements (D4) — the
// per-card .entry-delete-msg vanishes with the card it belongs to.
function liveRegion() {
var el = document.getElementById('feed-actions-live');
if (!el) {
el = document.createElement('div');
el.id = 'feed-actions-live';
el.setAttribute('aria-live', 'polite');
el.setAttribute('role', 'status');
el.className = 'sr-only';
document.body.appendChild(el);
}
return el;
}
function announce(msg) { liveRegion().textContent = msg; }
function slugFromRoute(route) {
var parts = String(route || '').replace(/\/+$/, '').split('/');
return parts[parts.length - 1] || '';
}
function showConfirm(actions, show) {
var del = actions.querySelector('[data-delete-start]');
var confirm = actions.querySelector('.entry-delete-confirm');
if (del) del.hidden = show;
if (confirm) confirm.hidden = !show;
if (!show) {
var msg = actions.querySelector('.entry-delete-msg');
if (msg) msg.textContent = '';
}
}
function performDelete(actions) {
var route = actions.getAttribute('data-entry-route');
var slug = slugFromRoute(route);
var cancelBtn = actions.querySelector('[data-delete-cancel]');
var confirmBtn = actions.querySelector('[data-delete-confirm]');
var msg = actions.querySelector('.entry-delete-msg');
var card = actions.closest('.journal-post');
// D2: lock both buttons immediately so a mobile double-tap can't fire two
// DELETEs (the second would 404 on an already-removed folder).
if (confirmBtn) { confirmBtn.disabled = true; confirmBtn.textContent = 'Deleting…'; }
if (cancelBtn) cancelBtn.disabled = true;
if (msg) msg.textContent = '';
announce('Deleting entry…');
fetch('/api/v1/entry/' + encodeURIComponent(slug), {
method: 'DELETE',
credentials: 'include',
headers: { Accept: 'application/json' }
}).then(function (r) {
if (!(r.status === 204 || r.ok)) throw new Error('HTTP ' + r.status);
// D4: move focus to the next journal card (or the feed heading) BEFORE
// removing this one, then announce.
var next = card ? card.nextElementSibling : null;
while (next && !next.classList.contains('journal-post')) next = next.nextElementSibling;
var focusTarget = next
|| (card && card.previousElementSibling && card.previousElementSibling.classList && card.previousElementSibling.classList.contains('journal-post') ? card.previousElementSibling : null)
|| document.querySelector('.home-trip-name, .home-feed-col h1, .feed');
if (card) card.remove();
if (focusTarget) {
if (!focusTarget.hasAttribute('tabindex')) focusTarget.setAttribute('tabindex', '-1');
try { focusTarget.focus({ preventScroll: false }); } catch (e) { /* older browsers */ }
}
announce('Entry deleted.');
}).catch(function () {
// D7: restore the control + inline error, constrained to the card.
if (confirmBtn) { confirmBtn.disabled = false; confirmBtn.textContent = 'Confirm delete'; }
if (cancelBtn) cancelBtn.disabled = false;
if (msg) msg.textContent = 'Could not delete — please try again.';
announce('Delete failed.');
});
}
function initFeedActions() {
document.addEventListener('click', function (e) {
var start = e.target.closest ? e.target.closest('[data-delete-start]') : null;
var cancel = e.target.closest ? e.target.closest('[data-delete-cancel]') : null;
var confirm = e.target.closest ? e.target.closest('[data-delete-confirm]') : null;
if (!start && !cancel && !confirm) return;
var actions = (start || cancel || confirm).closest('.journal-post-actions');
if (!actions) return;
e.preventDefault();
if (start) { showConfirm(actions, true); return; }
if (cancel) { showConfirm(actions, false); return; }
if (confirm) {
var btn = actions.querySelector('[data-delete-confirm]');
if (btn && btn.disabled) return; // already in flight (D2)
performDelete(actions);
}
});
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initFeedActions);
} else {
initFeedActions();
}
+521
View File
@@ -0,0 +1,521 @@
/*
* /post form bundle CSS. U3 scope: make the EasyMDE editor usable without the
* FontAwesome dependency (toolbar glyphs supplied here). Field Notes theming,
* mobile layout, and the "More options" disclosure land in U5.
*/
/* Edit-mode prefill-failure banner (U5, D7): shown between the heading and the
first field when the entry can't be loaded for editing. */
.post-edit-error {
margin: 1rem 0;
padding: 0.75rem 1rem;
border: 1px solid #E5786A;
border-radius: 8px;
background: rgba(229, 120, 106, 0.12);
color: #E5786A;
font-size: 0.9rem;
line-height: 1.4;
}
/* EasyMDE toolbar glyphs — replace the FontAwesome icons EasyMDE expects. */
.editor-toolbar .mde-btn::before {
font-style: normal;
font-weight: 400;
font-family: inherit;
}
.editor-toolbar .mde-bold::before { content: 'B'; font-weight: 700; }
.editor-toolbar .mde-italic::before { content: 'I'; font-style: italic; }
.editor-toolbar .mde-ul::before { content: '\2022\2002\2014'; } /* • — */
.editor-toolbar .mde-link::before { content: '\1F517'; } /* 🔗 */
.editor-toolbar .mde-preview::before { content: '\1F441'; } /* 👁 */
.EasyMDEContainer .CodeMirror {
min-height: 180px;
}
/* ── Photo HEIC-conversion feedback (U4) ────────────────────
* FilePond renders the thumbnails/progress; these are the pre-FilePond
* "converting…" status line and the draft photos-reselect hint (U6).
*/
.photo-convert-status:empty { display: none; }
.photo-convert-status {
font-size: var(--text-sm);
margin-top: var(--space-2);
}
.photo-reauth-hint {
display: none;
font-size: var(--text-sm);
color: var(--color-ink-muted);
margin-top: var(--space-2);
}
.photo-reauth-hint.is-shown { display: block; }
/* ── Field Notes styling for the new controls (U5, R11/R12) ── */
/* Select fields (weather condition, transport mode) match the text inputs. */
.post-form-wrap select {
width: 100%;
font-family: var(--font-ui);
font-size: var(--text-base);
padding: 0.875rem 1rem;
min-height: 44px;
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background: var(--color-canvas);
color: var(--color-ink);
-webkit-appearance: none;
appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='8' viewBox='0 0 12 8'%3E%3Cpath fill='%2390887E' d='M1 1l5 5 5-5'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 1rem center;
padding-right: 2.5rem;
}
.post-form-wrap select:focus {
outline: 2px solid var(--color-accent);
outline-offset: 1px;
border-color: var(--color-accent);
}
.post-form-wrap input[type="number"] {
width: 100%;
font-family: var(--font-ui);
font-size: var(--text-base);
padding: 0.875rem 1rem;
min-height: 44px;
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background: var(--color-canvas);
color: var(--color-ink);
-webkit-appearance: none;
appearance: none;
}
.post-form-wrap input[type="number"]:focus {
outline: 2px solid var(--color-accent);
outline-offset: 1px;
border-color: var(--color-accent);
}
/* "More options" disclosure */
.more-options {
margin-bottom: var(--space-5);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background: var(--color-canvas);
}
.more-options__summary {
cursor: pointer;
padding: 0.875rem 1rem;
min-height: 44px;
display: flex;
align-items: center;
font-family: var(--font-ui);
font-size: var(--text-sm);
font-weight: 600;
color: var(--color-ink);
list-style: none;
user-select: none;
}
.more-options__summary::-webkit-details-marker { display: none; }
.more-options__summary::before {
content: '▸';
margin-right: var(--space-2);
color: var(--color-ink-muted);
transition: transform 0.15s;
}
.more-options[open] .more-options__summary::before { transform: rotate(90deg); }
.more-options[open] .more-options__summary { border-bottom: 1px solid var(--color-border); }
.more-options > .form-field { padding: 0 1rem; }
.more-options > .form-field:first-of-type { padding-top: var(--space-4); }
.more-options > .form-field:last-child { padding-bottom: var(--space-4); margin-bottom: 0; }
/* Button loading spinner (Get Location / Get Weather) */
.btn-action.is-loading { position: relative; color: transparent; pointer-events: none; }
.btn-action.is-loading::after {
content: '';
position: absolute;
top: 50%;
left: 50%;
width: 16px;
height: 16px;
margin: -8px 0 0 -8px;
border: 2px solid var(--color-ink-muted);
border-top-color: var(--color-accent);
border-radius: 50%;
animation: post-spin 0.7s linear infinite;
}
.btn-action[disabled] { opacity: 0.5; cursor: not-allowed; }
@keyframes post-spin { to { transform: rotate(360deg); } }
/* Grav renders form status as <div class="notices success|error green|red">.
The theme doesn't style .notices, so on the dark background it was nearly
invisible — style it prominently here. */
.post-form-wrap .notices {
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-left: 4px solid var(--color-accent);
}
.post-form-wrap .notices p { margin: 0; }
.post-form-wrap .notices.error,
.post-form-wrap .notices.red { border-left-color: var(--color-error); }
.post-form-wrap .notices.success,
.post-form-wrap .notices.green { border-left-color: var(--color-accent); }
/* Post-success confirmation CTA (injected by post-form.js). */
.post-success {
margin: 0 0 var(--space-5);
padding: 1.1rem;
border-radius: var(--radius-md);
background: var(--color-accent-light);
border: 1px solid var(--color-accent);
}
.post-success__title {
font-family: var(--font-ui);
font-size: var(--text-md);
font-weight: 600;
color: var(--color-ink);
margin: 0 0 var(--space-3);
}
.post-success__actions { display: flex; flex-wrap: wrap; gap: var(--space-3); }
.post-success__view {
flex: 1;
min-width: 140px;
text-align: center;
padding: 0.8rem 1rem;
min-height: 44px;
border-radius: var(--radius-md);
background: var(--color-accent);
color: var(--color-accent-on);
font-weight: 600;
text-decoration: none;
}
.post-success__again {
flex: 1;
min-width: 140px;
text-align: center;
padding: 0.8rem 1rem;
min-height: 44px;
border-radius: var(--radius-md);
background: transparent;
color: var(--color-ink);
border: 1px solid var(--color-border);
font-weight: 600;
text-decoration: none;
}
/* Hide FilePond's "Powered by PQINA" credit. */
.filepond--credits { display: none !important; }
/* Field Notes dark theme for the FilePond widget — the default is a light/cream
panel that clashes with the site's warm near-black palette. Repaint the drop
zone, thumbnails and actions with the design tokens. */
.filepond--root { font-family: var(--font-ui); font-size: var(--text-base); }
.filepond--panel-root {
background-color: var(--color-canvas);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
}
.filepond--drop-label,
.filepond--drop-label label { color: var(--color-ink-muted); }
.filepond--label-action {
color: var(--color-accent);
text-decoration-color: var(--color-accent);
}
.filepond--item-panel {
background-color: var(--color-surface-raised);
border-radius: var(--radius-md);
}
.filepond--drip-blob { background-color: var(--color-accent); }
.filepond--file { color: var(--color-ink); }
.filepond--file-action-button {
color: var(--color-ink);
background-color: rgba(0, 0, 0, 0.45);
}
.filepond--file-action-button:hover { background-color: rgba(0, 0, 0, 0.65); }
/* Issue #2: FilePond's completed thumbnail carries a murky gradient tint + the
filename overlay, which reads as "something's wrong". Strip those overlays
and show a single clean green ✓ badge so a finished upload is unambiguous.
The remove (×) action button is left untouched so photos stay removable. */
.filepond--image-preview-overlay { display: none !important; }
.filepond--item[data-filepond-item-state="processing-complete"] .filepond--file-info,
.filepond--item[data-filepond-item-state="processing-complete"] .filepond--file-status {
opacity: 0;
}
.filepond--item[data-filepond-item-state="processing-complete"]::after {
content: '✓';
position: absolute;
top: 8px;
right: 8px;
z-index: 5;
width: 24px;
height: 24px;
line-height: 24px;
text-align: center;
border-radius: 50%;
background: var(--color-accent);
color: #fff;
font-size: 15px;
font-weight: 700;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.35);
pointer-events: none;
}
/* ── Photo thumbnail grid (edit + create) ─────────────────────────────────
Render FilePond items as a compact square-thumbnail grid so existing photos
(loaded on edit) and new uploads are visible and reorderable without the
full-width, overflowing vertical list the default produced. Setting a fixed
item width is FilePond's supported way to switch its layout engine to a grid. */
.filepond--list.filepond--list {
/* FilePond positions items absolutely; the wrapper needs a little inset so
the 3-up grid doesn't butt against the drop-zone edge. */
margin: 0.25rem;
}
.filepond--item {
width: calc(33.333% - 0.5rem);
}
/* Square thumbnails regardless of source aspect ratio. */
.filepond--item .filepond--panel-root,
.filepond--item .filepond--image-preview-wrapper { border-radius: var(--radius-sm); }
/* Native image dragging hijacked FilePond's reorder: dragging a thumbnail
started a browser ghost-image / page scroll instead of moving the item.
Disable user-drag on the preview so FilePond owns the gesture. */
.filepond--image-preview,
.filepond--image-preview-wrapper,
.filepond--image-clip,
.filepond--image-preview canvas,
.filepond--image-bitmap,
.filepond--file img {
-webkit-user-drag: none;
-khtml-user-drag: none;
user-drag: none;
}
/* Existing photos loaded for editing sit in FilePond's 'idle' state (they are
already on the server, never uploaded). Give them the same clean look as a
completed upload — hide the filename/size overlay so the grid reads as photos,
not a list of filenames. */
.filepond--item[data-filepond-item-state="idle"] .filepond--file-info,
.filepond--item[data-filepond-item-state="idle"] .filepond--file-status {
opacity: 0;
}
/* Issue #3: photo section auto-collapses to a summary bar after upload — same
<details> affordance as "More options" above. */
.photos-collapse {
margin-bottom: var(--space-5);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background: var(--color-canvas);
}
.photos-collapse__summary {
cursor: pointer;
padding: 0.875rem 1rem;
min-height: 44px;
display: flex;
align-items: center;
font-family: var(--font-ui);
font-size: var(--text-sm);
font-weight: 600;
color: var(--color-ink);
list-style: none;
user-select: none;
}
.photos-collapse__summary::-webkit-details-marker { display: none; }
.photos-collapse__summary::before {
content: '▸';
margin-right: var(--space-2);
color: var(--color-ink-muted);
transition: transform 0.15s;
}
.photos-collapse[open] .photos-collapse__summary::before { transform: rotate(90deg); }
.photos-collapse[open] .photos-collapse__summary { border-bottom: 1px solid var(--color-border); }
.photos-collapse > :not(summary) { padding-left: 1rem; padding-right: 1rem; }
.photos-collapse[open] .filepond--root,
.photos-collapse[open] .filepond-root { margin: var(--space-4) 0; }
.photos-collapse[open] > .photo-convert-status:last-child,
.photos-collapse[open] > .photo-reauth-hint:last-child { padding-bottom: var(--space-4); }
/* ── EasyMDE — Field Notes dark theme (U5) ─────────────────── */
.EasyMDEContainer .CodeMirror {
background: var(--color-canvas);
color: var(--color-ink);
border: 1px solid var(--color-border);
border-radius: 0 0 var(--radius-md) var(--radius-md);
font-family: var(--font-ui);
font-size: var(--text-base);
line-height: var(--leading-normal);
padding: var(--space-1);
}
.EasyMDEContainer .CodeMirror-cursor { border-color: var(--color-ink); }
.EasyMDEContainer .CodeMirror-selected { background: var(--color-accent-light) !important; }
.EasyMDEContainer .editor-toolbar {
background: var(--color-surface-raised);
border: 1px solid var(--color-border);
border-bottom: none;
border-radius: var(--radius-md) var(--radius-md) 0 0;
opacity: 1;
}
.EasyMDEContainer .editor-toolbar button {
color: var(--color-ink) !important;
min-width: 34px;
height: 34px;
}
.EasyMDEContainer .editor-toolbar button:hover,
.EasyMDEContainer .editor-toolbar button.active {
background: var(--color-accent-light);
border-color: var(--color-border);
}
.EasyMDEContainer .editor-toolbar i.separator { border-color: var(--color-border); }
.EasyMDEContainer .editor-preview,
.EasyMDEContainer .editor-preview-side {
background: var(--color-canvas);
color: var(--color-ink);
}
.EasyMDEContainer .editor-preview a { color: var(--color-accent); }
/* ── Photo editor (edit mode only) ────────────────────────────────────────
Own thumbnail grid that talks straight to the media API (add/delete/reorder
live), replacing the FilePond photo path when editing. */
.photo-editor {
margin-bottom: var(--space-5);
}
.photo-editor__head {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-3);
margin-bottom: var(--space-2);
}
.photo-editor__label {
font-family: var(--font-ui);
font-size: var(--text-base);
font-weight: 600;
color: var(--color-ink);
}
.photo-editor__add {
font-family: var(--font-ui);
font-size: var(--text-sm);
background: var(--color-accent);
color: #fff;
border: none;
border-radius: var(--radius-sm);
padding: var(--space-2) var(--space-4);
cursor: pointer;
}
.photo-editor__add:disabled { opacity: 0.5; cursor: default; }
.photo-editor__status {
font-size: var(--text-sm);
color: var(--color-ink-muted);
min-height: 1.2em;
margin: 0 0 var(--space-2);
}
.photo-editor__status.error { color: var(--color-error); }
.photo-editor__grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: var(--space-2);
}
@media (min-width: 600px) {
.photo-editor__grid { grid-template-columns: repeat(4, 1fr); }
}
.photo-editor__loading,
.photo-editor__empty {
grid-column: 1 / -1;
color: var(--color-ink-muted);
font-size: var(--text-sm);
padding: var(--space-4) 0;
}
.photo-editor__cell {
position: relative;
aspect-ratio: 1 / 1;
border-radius: var(--radius-sm);
overflow: hidden;
background: var(--color-surface-raised);
cursor: grab;
touch-action: none; /* let SortableJS own the touch-drag gesture */
}
.photo-editor__cell:active { cursor: grabbing; }
.photo-editor__img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
-webkit-user-drag: none;
user-select: none;
pointer-events: none; /* the cell, not the image, is the drag handle */
}
/* First thumbnail is the feed cover. */
.photo-editor__cell:first-child::after {
content: 'Cover';
position: absolute;
left: var(--space-1);
bottom: var(--space-1);
font-family: var(--font-ui);
font-size: var(--text-xs, 0.7rem);
font-weight: 600;
color: #fff;
background: var(--color-accent);
padding: 1px 6px;
border-radius: var(--radius-sm);
pointer-events: none;
}
.photo-editor__del {
position: absolute;
top: var(--space-1);
right: var(--space-1);
width: 24px;
height: 24px;
line-height: 1;
display: flex;
align-items: center;
justify-content: center;
border: none;
border-radius: 50%;
background: rgba(0, 0, 0, 0.55);
color: #fff;
font-size: 0.85rem;
cursor: pointer;
}
.photo-editor__del:hover { background: rgba(0, 0, 0, 0.75); }
.photo-editor__del:disabled { opacity: 0.4; cursor: default; }
/* Inline delete confirm — covers the cell (option a: live delete, deliberate step). */
.photo-editor__confirm {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: var(--space-1);
padding: var(--space-2);
background: rgba(0, 0, 0, 0.72);
text-align: center;
}
.photo-editor__confirm-q {
color: #fff;
font-family: var(--font-ui);
font-size: var(--text-sm);
}
.photo-editor__confirm button {
font-family: var(--font-ui);
font-size: var(--text-xs, 0.72rem);
border: none;
border-radius: var(--radius-sm);
padding: 3px 10px;
cursor: pointer;
}
.photo-editor__confirm-yes { background: var(--color-error); color: #fff; }
.photo-editor__confirm-no { background: #fff; color: var(--color-ink); }
.photo-editor__confirm button:disabled { opacity: 0.5; cursor: default; }
/* SortableJS drag feedback. */
.photo-editor__cell.sortable-ghost { opacity: 0.4; }
.photo-editor__cell.sortable-chosen { outline: 2px solid var(--color-accent); }
File diff suppressed because it is too large Load Diff
+92 -1
View File
@@ -8,9 +8,12 @@
"@fontsource-variable/dm-sans": "latest", "@fontsource-variable/dm-sans": "latest",
"@fontsource/dm-serif-display": "latest", "@fontsource/dm-serif-display": "latest",
"@mapbox/togeojson": "^0.16.2", "@mapbox/togeojson": "^0.16.2",
"easymde": "^2",
"heic-to": "^1",
"maplibre-gl": "^4", "maplibre-gl": "^4",
"photoswipe": "^5", "photoswipe": "^5",
"scrollama": "^3" "scrollama": "^3",
"sortablejs": "^1.15.7"
}, },
"devDependencies": { "devDependencies": {
"esbuild": "^0.21", "esbuild": "^0.21",
@@ -523,6 +526,21 @@
"integrity": "sha512-RKJ22hX8mHe3Y6wH/N3wCM6BWtjaxIyyUIkpHOvfFnxdI4yD4tBXEBKSbriGujF6jnSVkJrffuo6vxACiSSxIw==", "integrity": "sha512-RKJ22hX8mHe3Y6wH/N3wCM6BWtjaxIyyUIkpHOvfFnxdI4yD4tBXEBKSbriGujF6jnSVkJrffuo6vxACiSSxIw==",
"license": "ISC" "license": "ISC"
}, },
"node_modules/@types/codemirror": {
"version": "5.60.17",
"resolved": "https://registry.npmjs.org/@types/codemirror/-/codemirror-5.60.17.tgz",
"integrity": "sha512-AZq2FIsUHVMlp7VSe2hTfl5w4pcUkoFkM3zVsRKsn1ca8CXRDYvnin04+HP2REkwsxemuHqvDofdlhUWNpbwfw==",
"license": "MIT",
"dependencies": {
"@types/tern": "*"
}
},
"node_modules/@types/estree": {
"version": "1.0.9",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
"integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==",
"license": "MIT"
},
"node_modules/@types/geojson": { "node_modules/@types/geojson": {
"version": "7946.0.16", "version": "7946.0.16",
"resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz", "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz",
@@ -555,6 +573,12 @@
"@types/pbf": "*" "@types/pbf": "*"
} }
}, },
"node_modules/@types/marked": {
"version": "4.3.2",
"resolved": "https://registry.npmjs.org/@types/marked/-/marked-4.3.2.tgz",
"integrity": "sha512-a79Yc3TOk6dGdituy8hmTTJXjOkZ7zsFYV10L337ttq/rec8lRMDBpV7fL3uLx6TgbFCa5DU/h8FmIBQPSbU0w==",
"license": "MIT"
},
"node_modules/@types/pbf": { "node_modules/@types/pbf": {
"version": "3.0.5", "version": "3.0.5",
"resolved": "https://registry.npmjs.org/@types/pbf/-/pbf-3.0.5.tgz", "resolved": "https://registry.npmjs.org/@types/pbf/-/pbf-3.0.5.tgz",
@@ -570,6 +594,15 @@
"@types/geojson": "*" "@types/geojson": "*"
} }
}, },
"node_modules/@types/tern": {
"version": "0.23.9",
"resolved": "https://registry.npmjs.org/@types/tern/-/tern-0.23.9.tgz",
"integrity": "sha512-ypzHFE/wBzh+BlH6rrBgS5I/Z7RD21pGhZ2rltb/+ZrVM1awdZwjx7hE5XfuYgHWk9uvV5HLZN3SloevCAp3Bw==",
"license": "MIT",
"dependencies": {
"@types/estree": "*"
}
},
"node_modules/@xmldom/xmldom": { "node_modules/@xmldom/xmldom": {
"version": "0.8.13", "version": "0.8.13",
"resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.13.tgz", "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.13.tgz",
@@ -585,6 +618,21 @@
"integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/codemirror": {
"version": "5.65.21",
"resolved": "https://registry.npmjs.org/codemirror/-/codemirror-5.65.21.tgz",
"integrity": "sha512-6teYk0bA0nR3QP0ihGMoxuKzpl5W80FpnHpBJpgy66NK3cZv5b/d/HY8PnRvfSsCG1MTfr92u2WUl+wT0E40mQ==",
"license": "MIT"
},
"node_modules/codemirror-spell-checker": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/codemirror-spell-checker/-/codemirror-spell-checker-1.1.2.tgz",
"integrity": "sha512-2Tl6n0v+GJRsC9K3MLCdLaMOmvWL0uukajNJseorZJsslaxZyZMgENocPU8R0DyoTAiKsyqiemSOZo7kjGV0LQ==",
"license": "MIT",
"dependencies": {
"typo-js": "*"
}
},
"node_modules/concat-stream": { "node_modules/concat-stream": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz", "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz",
@@ -606,6 +654,19 @@
"integrity": "sha512-X7hshQbLyMJ/3RPhyObLARM2sNxxmRALLKx1+NVFFnQ9gKzmCrxm9+uLIAdBcvc8FNLpctqlQ2V6AE92Ol9UDQ==", "integrity": "sha512-X7hshQbLyMJ/3RPhyObLARM2sNxxmRALLKx1+NVFFnQ9gKzmCrxm9+uLIAdBcvc8FNLpctqlQ2V6AE92Ol9UDQ==",
"license": "ISC" "license": "ISC"
}, },
"node_modules/easymde": {
"version": "2.21.0",
"resolved": "https://registry.npmjs.org/easymde/-/easymde-2.21.0.tgz",
"integrity": "sha512-5uE7I/DEN8gvGRwxaqAv7h1PMEK2ykNXVX5zL0dK3nCYROGja3AMbdQz8eCEELnfvCfy7tRkTmLuvyJG8uSWjQ==",
"license": "MIT",
"dependencies": {
"@types/codemirror": "^5.60.10",
"@types/marked": "^4.0.7",
"codemirror": "^5.65.15",
"codemirror-spell-checker": "1.1.2",
"marked": "^4.1.0"
}
},
"node_modules/esbuild": { "node_modules/esbuild": {
"version": "0.21.5", "version": "0.21.5",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz",
@@ -683,6 +744,12 @@
"node": ">=16" "node": ">=16"
} }
}, },
"node_modules/heic-to": {
"version": "1.5.2",
"resolved": "https://registry.npmjs.org/heic-to/-/heic-to-1.5.2.tgz",
"integrity": "sha512-8Fns+lZHAWmz5U5IUxDeXKwIf3foBoKNPLxxFY4B0MkLjNuomEIHCoDbDE+x/llFK3NCEO1cu4+n3iUKY+Svmw==",
"license": "LGPL-3.0"
},
"node_modules/ieee754": { "node_modules/ieee754": {
"version": "1.2.1", "version": "1.2.1",
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
@@ -796,6 +863,18 @@
"url": "https://github.com/maplibre/maplibre-gl-js?sponsor=1" "url": "https://github.com/maplibre/maplibre-gl-js?sponsor=1"
} }
}, },
"node_modules/marked": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/marked/-/marked-4.3.0.tgz",
"integrity": "sha512-PRsaiG84bK+AMvxziE/lCFss8juXjNaWzVbN5tXAm4XjeaS9NAHhop+PjQxz2A9h8Q4M/xGmzP8vqNwy6JeK0A==",
"license": "MIT",
"bin": {
"marked": "bin/marked.js"
},
"engines": {
"node": ">= 12"
}
},
"node_modules/minimist": { "node_modules/minimist": {
"version": "1.2.8", "version": "1.2.8",
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
@@ -906,6 +985,12 @@
"integrity": "sha512-PIPwB1kYBnbw/ezvPBJa5dCN5qEwokfpAkI3BmpZWAwcVID4nDf1qH6WV16A2fQaJmsKx0un5S/zhxN+PQeKDQ==", "integrity": "sha512-PIPwB1kYBnbw/ezvPBJa5dCN5qEwokfpAkI3BmpZWAwcVID4nDf1qH6WV16A2fQaJmsKx0un5S/zhxN+PQeKDQ==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/sortablejs": {
"version": "1.15.7",
"resolved": "https://registry.npmjs.org/sortablejs/-/sortablejs-1.15.7.tgz",
"integrity": "sha512-Kk8wLQPlS+yi1ZEf48a4+fzHa4yxjC30M/Sr2AnQu+f/MPwvvX9XjZ6OWejiz8crBsLwSq8GHqaxaET7u6ux0A==",
"license": "MIT"
},
"node_modules/string_decoder": { "node_modules/string_decoder": {
"version": "1.3.0", "version": "1.3.0",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
@@ -936,6 +1021,12 @@
"integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==", "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/typo-js": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/typo-js/-/typo-js-1.3.2.tgz",
"integrity": "sha512-Z1YkJ7IIYNrFeOxAlHUercY4Q2I+PhYD/3VkWpJGy/Oqudy3bFpNcQxnv6Oa9fTSXCHPGz1eDoX1bZYm2Z891A==",
"license": "BSD-3-Clause"
},
"node_modules/util-deprecate": { "node_modules/util-deprecate": {
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
+5 -2
View File
@@ -1,15 +1,18 @@
{ {
"private": true, "private": true,
"scripts": { "scripts": {
"build": "node scripts/gen-weather-icons.js && esbuild js/src/main.js --bundle --minify --format=iife --outfile=js/main.js --loader:.woff2=file --loader:.woff=file --asset-names=../fonts/[name] && esbuild js/src/map.js --bundle --minify --format=iife --outfile=js/map.js && mkdir -p css-compiled fonts && { mv js/main.css css-compiled/main.css 2>/dev/null || true; } && { mv js/map.css css-compiled/map.css 2>/dev/null || true; }" "build": "node scripts/gen-weather-icons.js && esbuild js/src/main.js --bundle --minify --format=iife --outfile=js/main.js --loader:.woff2=file --loader:.woff=file --asset-names=../fonts/[name] && esbuild js/src/map.js --bundle --minify --format=iife --outfile=js/map.js && esbuild js/src/feed-actions.js --bundle --minify --format=iife --outfile=js/feed-actions.js && rm -rf js/post && esbuild js/src/post-form.js --bundle --minify --format=esm --splitting --outdir=js/post && mkdir -p css-compiled fonts && { mv js/main.css css-compiled/main.css 2>/dev/null || true; } && { mv js/map.css css-compiled/map.css 2>/dev/null || true; } && { mv js/post/post-form.css css-compiled/post-form.css 2>/dev/null || true; }"
}, },
"dependencies": { "dependencies": {
"@fontsource-variable/dm-sans": "latest", "@fontsource-variable/dm-sans": "latest",
"@fontsource/dm-serif-display": "latest", "@fontsource/dm-serif-display": "latest",
"@mapbox/togeojson": "^0.16.2", "@mapbox/togeojson": "^0.16.2",
"easymde": "^2",
"heic-to": "^1",
"maplibre-gl": "^4", "maplibre-gl": "^4",
"photoswipe": "^5", "photoswipe": "^5",
"scrollama": "^3" "scrollama": "^3",
"sortablejs": "^1.15.7"
}, },
"devDependencies": { "devDependencies": {
"esbuild": "^0.21", "esbuild": "^0.21",
@@ -0,0 +1,18 @@
{# intotheeast override of Grav's datetime field.
Grav's stock forms/fields/datetime/datetime.html.twig is DEPRECATED and just
extends the text field, so `type: datetime` renders a plain <input type="text">
— the owner sees a raw box (the literal word "now" from `default: now`) with no
way to pick a date/time. Here we render a native <input type="datetime-local">
instead: a real calendar + clock picker, and an excellent one on mobile.
The value is prefilled to the current local time by post-form.js (JS knows the
traveller's timezone; the server doesn't), and the existing client-side
validator now requires this field — so an empty or invalid date can no longer
reach the server and destroy the FilePond photo list on a re-render. #}
{% extends "forms/field.html.twig" %}
{% block input_attributes %}
type="datetime-local"
{{ parent() }}
{% endblock %}
@@ -2,7 +2,10 @@
{% block content %} {% block content %}
{% set trips_page = grav.pages.find('/trips') %} {% set trips_page = grav.pages.find('/trips') %}
{% set trips = trips_page ? trips_page.children.published() : [] %} {# Owner-only page (admin login), so show unpublished trips too — no .published()
filter — and order newest first by trip start date so the current/next trip is
at the top. #}
{% set trips = trips_page ? trips_page.children.order('header.date_start', 'desc') : [] %}
<div class="gpx-manager"> <div class="gpx-manager">
<h1 class="gpx-manager__title">GPX Files</h1> <h1 class="gpx-manager__title">GPX Files</h1>
+15 -3
View File
@@ -14,11 +14,20 @@
{% set dailies_page = grav.pages.find(trip_route ~ '/dailies') %} {% set dailies_page = grav.pages.find(trip_route ~ '/dailies') %}
{% set stories_page = grav.pages.find(trip_route ~ '/stories') %} {% set stories_page = grav.pages.find(trip_route ~ '/stories') %}
{# published-only — feeds the map, stats and counts (drafts excluded, R5) #}
{% set journal_entries = dailies_page ? dailies_page.children.published() : [] %} {% set journal_entries = dailies_page ? dailies_page.children.published() : [] %}
{% set story_entries = stories_page ? stories_page.children.published() : [] %} {% set story_entries = stories_page ? stories_page.children.published() : [] %}
{# This branch IS the active trip, so the owner gate is just owner identity
(KTD8). The super-admin tester authenticates too, so gate on owner_username. #}
{% set owner_can_edit = grav.user.authenticated
and grav.user.username == grav.config.site.owner_username %}
{# Owner-aware feed list: owner sees drafts; everyone else published only #}
{% set journal_feed = (owner_can_edit and dailies_page) ? dailies_page.children : journal_entries %}
{% if owner_can_edit %}{% do assets.addJs('theme://js/feed-actions.js', {group: 'bottom'}) %}{% endif %}
{% set all_items = [] %} {% set all_items = [] %}
{% for e in journal_entries %} {% for e in journal_feed %}
{% set all_items = all_items|merge([{'type': 'journal', 'page': e, 'date': e.header.date}]) %} {% set all_items = all_items|merge([{'type': 'journal', 'page': e, 'date': e.header.date}]) %}
{% endfor %} {% endfor %}
{% for s in story_entries %} {% for s in story_entries %}
@@ -38,7 +47,8 @@
{% set map_entries = [] %} {% set map_entries = [] %}
{% for item in all_items %} {% 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 %} {# drafts render as a feed card only — never a map marker (R5) #}
{% if item.type == 'journal' and item.page.published and item.page.header.lat is not empty and item.page.header.lng is not empty %}
{% set map_entries = map_entries|merge([{ {% set map_entries = map_entries|merge([{
'lat': item.page.header.lat|number_format(6, '.', ''), 'lat': item.page.header.lat|number_format(6, '.', ''),
'lng': item.page.header.lng|number_format(6, '.', ''), 'lng': item.page.header.lng|number_format(6, '.', ''),
@@ -87,7 +97,9 @@
has_gpx: home_gpx_urls|length > 0, has_gpx: home_gpx_urls|length > 0,
gpx_urls: home_gpx_urls, gpx_urls: home_gpx_urls,
gps_points: gps_points, gps_points: gps_points,
show_sort: false show_sort: false,
owner_can_edit: owner_can_edit,
feed_return_url: page.url
} only %} } only %}
{% endif %} {% endif %}
</div> </div>
@@ -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 %}
@@ -10,6 +10,7 @@
{% do assets.addJs('theme://js/main.js', {group: 'bottom'}) %} {% do assets.addJs('theme://js/main.js', {group: 'bottom'}) %}
{% do assets.addJs('theme://js/nav.js', {group: 'bottom'}) %} {% do assets.addJs('theme://js/nav.js', {group: 'bottom'}) %}
{% block map_assets %}{% endblock %} {% block map_assets %}{% endblock %}
{% block head_assets %}{% endblock %}
{{ assets.css()|raw }} {{ assets.css()|raw }}
{{ assets.js()|raw }} {{ assets.js()|raw }}
</head> </head>
@@ -26,6 +27,10 @@
<nav class="site-nav" id="site-nav" aria-label="Main navigation"> <nav class="site-nav" id="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 }}"{% 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> <a href="{{ base_url_absolute }}/trips"{% if page.template == 'trips' %} aria-current="page"{% endif %}>Past Trips</a>
{% 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>
{% endif %}
</nav> </nav>
{% endblock %} {% endblock %}
</header> </header>
@@ -1,7 +1,22 @@
{% include 'partials/weather-icons.html.twig' %} {% include 'partials/weather-icons.html.twig' %}
<article class="journal-post" id="entry-{{ entry.slug }}" data-type="journal" data-lat="{{ entry.header.lat }}" data-lng="{{ entry.header.lng }}"> {% set owner_can_edit = owner_can_edit ?? false %}
{% set feed_return_url = feed_return_url ?? trip_page.url ?? '/' %}
<article class="journal-post{% if not entry.published %} is-draft{% endif %}" id="entry-{{ entry.slug }}" data-type="journal" data-lat="{{ entry.header.lat }}" data-lng="{{ entry.header.lng }}"{% if owner_can_edit %} data-entry-route="{{ entry.route }}"{% endif %}>
<header class="journal-post-header"> <header class="journal-post-header">
<h2 class="journal-post-title">{{ entry.title }}</h2> <div class="journal-post-titlerow">
<h2 class="journal-post-title">{{ entry.title }}{% if not entry.published %} <span class="journal-draft-badge">Draft</span>{% endif %}</h2>
{% if owner_can_edit %}
<div class="journal-post-actions" data-entry-route="{{ entry.route }}">
<a class="entry-action entry-action--edit" href="/post?edit={{ entry.route|url_encode }}&return={{ feed_return_url|url_encode }}">Edit</a>
<button class="entry-action entry-action--delete" type="button" data-delete-start>Delete</button>
<span class="entry-delete-confirm" hidden>
<button class="entry-action entry-action--cancel" type="button" data-delete-cancel>Cancel</button>
<button class="entry-action entry-action--confirm" type="button" data-delete-confirm>Confirm delete</button>
</span>
<span class="entry-delete-msg" role="status" aria-live="polite"></span>
</div>
{% endif %}
</div>
<p class="journal-post-meta"> <p class="journal-post-meta">
<a class="journal-post-permalink" href="{{ entry.url }}"> <a class="journal-post-permalink" href="{{ entry.url }}">
<time datetime="{{ entry.date|date('Y-m-d') }}">{{ entry.date|date('d M Y')|upper }}</time> <time datetime="{{ entry.date|date('Y-m-d') }}">{{ entry.date|date('d M Y')|upper }}</time>
@@ -1,8 +1,20 @@
{% 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) %}
{# owner_can_edit gates the Draft badge + Edit/Delete controls on each card
(threaded into entry-journal below). Default false so any caller that doesn't
pass it renders a read-only feed. #}
{% set owner_can_edit = owner_can_edit ?? 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 +25,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 +83,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>
+17 -115
View File
@@ -1,126 +1,28 @@
{% extends 'default.html.twig' %} {% extends 'default.html.twig' %}
{% block head_assets %}
{# FilePond's own CSS must load from the <head>. The filepond field registers
it via assets.addCss() during body rendering, which is too late for the
theme's head-only {{ assets.css() }} — so the widget renders unstyled
(giant overlapping tiles) unless we add it here. #}
{% do assets.addCss('plugin://form/assets/filepond/filepond.min.css') %}
{% do assets.addCss('plugin://form/assets/filepond/filepond-plugin-image-preview.min.css') %}
{% do assets.addCss('theme://css-compiled/post-form.css') %}
<script type="module" src="{{ url('theme://js/post/post-form.js') }}"></script>
{% endblock %}
{% block content %} {% block content %}
<div class="post-form-wrap"> <div class="post-form-wrap" data-trip-url="{{ config.site.active_trip }}">
<h1>New Entry</h1> <h1>New Entry</h1>
{% include 'forms/form.html.twig' ignore missing %} {% include 'forms/form.html.twig' ignore missing %}
<div class="form-action-row"> <div class="form-action-row">
<button type="button" id="get-location" class="btn-action">📍 Get Location</button> <button type="button" id="get-location" class="btn-action">📍 Get Location</button>
<button type="button" id="get-weather" class="btn-action">🌤 Get Weather</button> <button type="button" id="get-weather" class="btn-action" disabled>🌤 Get Weather</button>
</div> </div>
<p id="location-status" class="form-status"></p> <p id="location-status" class="form-status" role="status"></p>
<p id="weather-status" class="form-status"></p> <p id="weather-status" class="form-status" role="status"></p>
</div> </div>
{# Get Location / Get Weather, disclosure, and validation all live in the
<script> page-scoped bundle (js/src/post-form.js), loaded via head_assets above. #}
// Custom validation — form uses novalidate so we handle it here
(function() {
var REQUIRED = ['title', 'content'];
var form = document.querySelector('form[name="new-entry"]');
if (!form) return;
function clearErrors() {
form.querySelectorAll('.field-error').forEach(function(el) { el.remove(); });
form.querySelectorAll('.field-invalid').forEach(function(el) { el.classList.remove('field-invalid'); });
}
function showError(field, msg) {
field.classList.add('field-invalid');
var err = document.createElement('span');
err.className = 'field-error';
err.textContent = msg;
field.parentNode.insertBefore(err, field.nextSibling);
}
form.addEventListener('submit', function(e) {
clearErrors();
var firstInvalid = null;
REQUIRED.forEach(function(name) {
var field = form.querySelector('[name="data[' + name + ']"]');
if (field && !field.value.trim()) {
showError(field, name.charAt(0).toUpperCase() + name.slice(1) + ' is required.');
if (!firstInvalid) firstInvalid = field;
}
});
if (firstInvalid) {
e.preventDefault();
firstInvalid.focus();
firstInvalid.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
});
}());
</script>
<script>
var WMO_MAP = {
0:'Sunny',1:'Partly cloudy',2:'Partly cloudy',3:'Cloudy',
45:'Foggy',48:'Foggy',
51:'Drizzle',53:'Drizzle',55:'Drizzle',56:'Drizzle',57:'Drizzle',
61:'Rain',63:'Rain',65:'Rain',66:'Rain',67:'Rain',80:'Rain',81:'Rain',82:'Rain',
71:'Snow',73:'Snow',75:'Snow',77:'Snow',85:'Snow',86:'Snow',
95:'Thunderstorm',96:'Thunderstorm',99:'Thunderstorm'
};
function getField(name) {
return document.querySelector('input[name="data[' + name + ']"]');
}
document.getElementById('get-location').addEventListener('click', function() {
var status = document.getElementById('location-status');
status.className = 'form-status';
status.textContent = 'Getting location…';
if (!navigator.geolocation) {
status.textContent = 'Geolocation not supported.';
return;
}
navigator.geolocation.getCurrentPosition(function(pos) {
var lat = pos.coords.latitude.toFixed(6);
var lng = pos.coords.longitude.toFixed(6);
var latField = getField('lat');
var lngField = getField('lng');
if (latField) latField.value = lat;
if (lngField) lngField.value = lng;
status.textContent = '✓ Location captured · ' + lat + ', ' + lng;
status.classList.add('form-status--ok');
}, function(err) {
status.textContent = '✗ Could not get location: ' + err.message;
status.classList.add('form-status--err');
});
});
document.getElementById('get-weather').addEventListener('click', function() {
var status = document.getElementById('weather-status');
status.className = 'form-status';
var latField = getField('lat');
var lngField = getField('lng');
var lat = latField ? latField.value.trim() : '';
var lng = lngField ? lngField.value.trim() : '';
if (!lat || !lng) {
status.textContent = 'Get location first, then fetch weather.';
return;
}
status.textContent = 'Fetching weather…';
var url = 'https://api.open-meteo.com/v1/forecast?latitude=' + lat +
'&longitude=' + lng +
'&current=temperature_2m,weather_code&temperature_unit=celsius';
fetch(url)
.then(function(r) { return r.json(); })
.then(function(data) {
var temp = Math.round(data.current.temperature_2m);
var code = data.current.weather_code;
var desc = WMO_MAP[code] || 'Cloudy';
var tempField = getField('weather_temp_c');
var descField = getField('weather_desc');
if (tempField) tempField.value = temp;
if (descField) descField.value = desc;
status.textContent = '✓ Weather set · ' + desc + ' · ' + temp + '°C';
status.classList.add('form-status--ok');
})
.catch(function() {
status.textContent = '✗ Could not fetch weather — enter manually if needed.';
status.classList.add('form-status--err');
});
});
</script>
{% endblock %} {% endblock %}
+21 -3
View File
@@ -9,11 +9,25 @@
{% endblock %} {% endblock %}
{% 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') %}
{# journal_entries stays published-only — it feeds the map, stats and counts,
which must never include drafts (R5). #}
{% set journal_entries = dailies_page ? dailies_page.children.published() : [] %} {% set journal_entries = dailies_page ? dailies_page.children.published() : [] %}
{% set story_entries = stories_page ? stories_page.children.published() : [] %} {% set story_entries = stories_page ? stories_page.children.published() : [] %}
{# Owner gate (KTD8): the site owner (not merely any login — the super-admin
tester also authenticates) viewing the ACTIVE trip. Drives draft visibility
in the feed and the Edit/Delete controls (threaded to the card partial). #}
{% set active_trip_slug = (grav.config.site.active_trip|default(''))|split('/')|last %}
{% set owner_can_edit = grav.user.authenticated
and grav.user.username == grav.config.site.owner_username
and page.slug == active_trip_slug %}
{# Feed list is owner-aware: the owner sees drafts (unpublished) too; everyone
else (and every non-active-trip view) sees published only (R5, KTD7). #}
{% set journal_feed = (owner_can_edit and dailies_page) ? dailies_page.children : journal_entries %}
{% if owner_can_edit %}{% do assets.addJs('theme://js/feed-actions.js', {group: 'bottom'}) %}{% endif %}
{% set all_items = [] %} {% set all_items = [] %}
{% for e in journal_entries %} {% for e in journal_feed %}
{% set all_items = all_items|merge([{'type': 'journal', 'page': e, 'date': e.header.date}]) %} {% set all_items = all_items|merge([{'type': 'journal', 'page': e, 'date': e.header.date}]) %}
{% endfor %} {% endfor %}
{% for s in story_entries %} {% for s in story_entries %}
@@ -41,7 +55,8 @@
{% set map_entries = [] %} {% set map_entries = [] %}
{% for item in all_items %} {% for item in all_items %}
{% if item.page.header.lat is not empty and item.page.header.lng is not empty %} {# drafts render as a feed card only — never a map marker (R5) #}
{% if item.page.published and item.page.header.lat is not empty and item.page.header.lng is not empty %}
{% set map_entries = map_entries|merge([{ {% set map_entries = map_entries|merge([{
'type': item.type, 'type': item.type,
'lat': item.page.header.lat|number_format(6, '.', ''), 'lat': item.page.header.lat|number_format(6, '.', ''),
@@ -78,7 +93,10 @@
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,
owner_can_edit: owner_can_edit,
feed_return_url: page.url
} only %} } only %}
</div> </div>
+5 -14
View File
@@ -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">