Owner-gated publish switch on /trips cards + trip pages; extends entry-actions with a scope-guarded route + page-tree cache invalidation. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Mpdu3Dt1iVoozHwAMyjrbn
6.8 KiB
Trip publish/unpublish toggle — design
Date: 2026-07-08 Status: 📋 Design — awaiting plan
Goal
Let the logged-in owner publish/unpublish any trip directly from the UI, on
two surfaces: the Past Trips listing (/trips) and each trip detail page
(/trips/<slug>). Anonymous/non-owner visitors see no change. Toggling must
correctly invalidate Grav's page-tree cache so the change is reflected
everywhere on the next load.
Owner gate
A single rule, mirroring the post feed's owner logic:
{% set is_owner = grav.user.authenticated and grav.user.username == grav.config.site.owner_username %}
This is broader than owner_can_edit in trip.html.twig (which also
requires the page to be the active trip). Publishing must work on any trip, so
it gets its own is_owner flag. is_owner is computed in both trips.html.twig
and trip.html.twig. The backend enforces the same owner check independently
(defense in depth) — the UI gate is not the security boundary.
Backend — extend the entry-actions plugin
Reuses the plugin's existing owner-gate, API-key scope cap, and the
deleteAll() + Cache::invalidateCache() caching pattern.
Route
POST /api/v1/trip/{slug}/publish — registered in entry-actions.php
onApiRegisterRoutes. Body: { "published": true | false }.
Controller: setTripPublished(ServerRequestInterface): ResponseInterface
In EntryActionsApiController, mirroring deleteEntry:
$user = $this->getUser($request)— 401 for anonymous.$this->requirePermission($request, 'api.pages.write')— same scope cap as the stock media/page-write endpoints (owner already holds it).EntryScopeGuard::isOwnerUser($this->grav, $user)— elseForbiddenException.- Validate
slugviaEntryScopeGuard::isSafeSegment— else 400. - Resolve the page via a new guard
EntryScopeGuard::resolveTripChild($grav, $slug):$pages->find('/trips/' ~ slug), assert the resolved page's parent route is exactly/trips(no raw path concatenation — same style asresolveActiveDailyChild). Returnnull→NotFoundException. - Read desired state:
$published = (bool) ($body['published'] ?? …); reject a missing/non-bool value with 400. - Set published + persist frontmatter. Use Grav's page API (verify exact call
against
add-page-by-form/cache-on-savesavers before coding — likely$page->published($published); $page->save();). The write must land intrip.mdfrontmatter aspublished: true|false. - Caching:
$this->grav['cache']->deleteAll(); Cache::invalidateCache();— publish state feeds.published()collections and routability, both keyed through the page-tree index; withoutinvalidateCache()the listing/nav/home render stale (the exact bug fixed indeleteEntry). - Audit log:
owner "%s" set trip "%s" published=%s. - Return
ApiResponse::noContent()(204).
Frontend
Shared toggle partial
partials/trip-publish-toggle.html.twig — renders a sliding on/off switch
(a styled checkbox that moves left↔right) plus a Draft badge when unpublished.
Params: trip (the trip Page), is_active (bool, whether this trip is
site.active_trip). Emits data-trip-slug, data-trip-route,
data-published, and data-active for the JS to read. Rendered only when
is_owner.
Surface 1 — /trips listing (trips.html.twig)
- Make the collection owner-aware:
Owner sees unpublished trips too; anon unchanged.
{% set trips = (is_owner ? page.children : page.children.published()) |sort((a, b) => a.date < b.date ? 1 : -1) %} - The trip card is currently a single
<a>wrapping the cover + title. The toggle must not be inside the anchor (a click would navigate). Restructure the card so the cover image is in a positioned wrapper and the toggle sits as an overlay sibling. Toggle placement: absolutely positioned over the cover image, top-right corner.Draftbadge on unpublished cards.
Surface 2 — trip detail page (trip.html.twig)
- Compute
is_owner(separate fromowner_can_edit). - Render the shared toggle in the header area, top-right corner near the trip
header, with the
Draftbadge when unpublished.
JS — js/src/trip-publish.js → built to js/trip-publish.js
Loaded in the bottom group only when is_owner (like feed-actions.js).
- Binds each
.trip-publish-togglecontrol. - On change:
- If turning off (unpublish) AND
data-activeis true →window.confirm( 'This is your active trip — unpublish it anyway?'); if cancelled, revert the switch and stop. POST /api/v1/trip/<slug>/publishwith{ published },credentials: 'include'.- Success: optimistic UI — flip
data-published, toggle theDraftbadge, update the switch position/label. No full reload needed (server state is persisted + cache invalidated for other surfaces). - Failure: revert the switch to its prior state and show an inline,
aria-liveerror (reuse the copy style fromfeed-actions.js: 401/403 → "sign in again"; other → "Couldn't update — try again.").
- If turning off (unpublish) AND
Edge cases
- Active trip unpublish → JS
confirm()(above). Allowed on confirm. - Anon / non-owner → no toggle rendered; listing shows
.published()only; backend rejects with 401/403. - Unpublished trip visibility → drops from the public
/tripslisting; its detail page 404s for anon (Grav default for unpublished/unroutable). Owner still sees it in the listing (Draft badge) and can re-publish. - Child dailies/stories cascade → out of scope for v1; unpublishing a trip does not change its children's published state.
Testing (Playwright, tests/ui/trip/)
Tests run as the owner (testrunner via owner_username override), mirroring
the post specs. Use a throwaway fixture trip folder (create/cleanup on disk).
- TP1 — owner sees the toggle; anon does not. Owner load of
/tripsshows.trip-publish-toggle; an anon (cleared storageState) load does not, and an unpublished fixture trip is absent for anon. - TP2 — unpublish hides it (caching). Owner toggles a published fixture trip
off → reload
/tripsas anon → the trip is absent; owner reload → Draft badge present. This is the page-tree-index assertion (mirrors DEL4). - TP3 — republish restores it. Toggle back on → anon reload sees it again.
- TP4 — active-trip confirm. Unpublishing the active trip prompts a confirm; dismissing leaves it published.
- TP5 — authz.
POST /api/v1/trip/<slug>/publishas anon → 401; as a non-owner authenticated user → 403; frontmatter unchanged on disk.
Out of scope
- Bulk publish/unpublish.
- Scheduling / publish dates.
- Cascading child publish state.
- Reordering trips by publish state (order stays by date desc).