Files
intotheeast-com/docs/working/specs/2026-07-08-trip-publish-toggle-design.md
T
m038andClaude Opus 4.8 ceb0570c86 docs(spec): trip publish/unpublish toggle design
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
2026-07-08 09:16:38 +02:00

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:

  1. $user = $this->getUser($request) — 401 for anonymous.
  2. $this->requirePermission($request, 'api.pages.write') — same scope cap as the stock media/page-write endpoints (owner already holds it).
  3. EntryScopeGuard::isOwnerUser($this->grav, $user) — else ForbiddenException.
  4. Validate slug via EntryScopeGuard::isSafeSegment — else 400.
  5. 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 as resolveActiveDailyChild). Return nullNotFoundException.
  6. Read desired state: $published = (bool) ($body['published'] ?? …); reject a missing/non-bool value with 400.
  7. Set published + persist frontmatter. Use Grav's page API (verify exact call against add-page-by-form / cache-on-save savers before coding — likely $page->published($published); $page->save();). The write must land in trip.md frontmatter as published: true|false.
  8. Caching: $this->grav['cache']->deleteAll(); Cache::invalidateCache(); — publish state feeds .published() collections and routability, both keyed through the page-tree index; without invalidateCache() the listing/nav/home render stale (the exact bug fixed in deleteEntry).
  9. Audit log: owner "%s" set trip "%s" published=%s.
  10. 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:
    {% set trips = (is_owner ? page.children : page.children.published())
                     |sort((a, b) => a.date < b.date ? 1 : -1) %}
    
    Owner sees unpublished trips too; anon unchanged.
  • 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. Draft badge on unpublished cards.

Surface 2 — trip detail page (trip.html.twig)

  • Compute is_owner (separate from owner_can_edit).
  • Render the shared toggle in the header area, top-right corner near the trip header, with the Draft badge 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-toggle control.
  • On change:
    • If turning off (unpublish) AND data-active is true → window.confirm( 'This is your active trip — unpublish it anyway?'); if cancelled, revert the switch and stop.
    • POST /api/v1/trip/<slug>/publish with { published }, credentials: 'include'.
    • Success: optimistic UI — flip data-published, toggle the Draft badge, 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-live error (reuse the copy style from feed-actions.js: 401/403 → "sign in again"; other → "Couldn't update — try again.").

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 /trips listing; 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).

  1. TP1 — owner sees the toggle; anon does not. Owner load of /trips shows .trip-publish-toggle; an anon (cleared storageState) load does not, and an unpublished fixture trip is absent for anon.
  2. TP2 — unpublish hides it (caching). Owner toggles a published fixture trip off → reload /trips as anon → the trip is absent; owner reload → Draft badge present. This is the page-tree-index assertion (mirrors DEL4).
  3. TP3 — republish restores it. Toggle back on → anon reload sees it again.
  4. TP4 — active-trip confirm. Unpublishing the active trip prompts a confirm; dismissing leaves it published.
  5. TP5 — authz. POST /api/v1/trip/<slug>/publish as 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).