# 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. The **write control lives on one surface β€” the Past Trips listing** (`/trips`), which already shows drafts and toggles both directions reversibly. The **trip detail page** (`/trips/`) carries **no publish UI**: an unpublished trip's detail page 404s (for everyone, owner included), so a control there could only strand the owner and a `Draft` indicator there would be unreachable β€” see Surface 2. 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: ```twig {% 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, computed in `trips.html.twig` (the listing β€” the only surface with the write control). 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)`: call `$pages->enablePages()` first (guarded by `method_exists` β€” the API request context lazily disables the page tree, exactly as `resolveActiveDailyChild` does), then `$pages->find('/trips/' ~ slug)`, assert the resolved page's parent route is exactly `/trips` (no raw path concatenation β€” same style as `resolveActiveDailyChild`). Return `null` β†’ `NotFoundException`. (`find()` returns unpublished trips too β€” verified against `Pages.php:966`/`1986` β€” so the owner can republish a draft from the listing.) 6. Read desired state: reject a missing or non-boolean value with 400 β€” `if (!array_key_exists('published', $body) || !is_bool($body['published'])) β†’ 400` β€” then assign the raw boolean (`$published = $body['published']`). Do **not** `(bool)`-cast the value: a cast silently coerces anything (`"false"`, `0`, `""`, a missing key) into a valid boolean and never rejects, contradicting the 400. 7. Set published + persist frontmatter by mutating the page **header** before saving: `$header = $page->header(); $header->published = $published; $page->save();` β€” mirroring `cache-on-save`'s `setOverwriteMode()` header-mutation pattern. Do **not** rely on `$page->published($published)` alone: in Grav 2.0 that only sets the in-memory property (`Page.php:1714`), while `save()` serializes from the header object (`Page.php:1256`) and the flag is read one-way *from* the header at init (`Page.php:541`) β€” so the on-disk `trip.md` would be unchanged and the toggle would silently no-op. 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`. The switch carries `role="switch"` + `aria-checked` and a per-instance accessible name β€” `aria-label="Published β€” {{ trip.title }}"` β€” so a screen-reader user on the listing (where every card's switch is otherwise identical) can tell which trip a toggle controls before triggering a destructive unpublish. ### Surface 1 β€” `/trips` listing (`trips.html.twig`) - Make the collection owner-aware: ```twig {% 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 `` 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. - **Legibility over arbitrary covers:** give the overlay toggle a solid pill/chip background reusing the `Draft`-badge styling (Field Notes paper/teal) so it stays legible on any cover photo, and a β‰₯44px touch target kept clear of the card `` hit area. ### Surface 2 β€” trip detail page (`trip.html.twig`) **No publish UI in v1 β€” management is listing-only.** The detail page gets neither a write toggle nor a `Draft` indicator, for a concrete reason: an unpublished trip is not routable, and Grav's frontend serves a 404 for unpublished pages to *everyone including the owner* (`Page::routable()` = `routable && published`, with no published routable child to redirect to since `dailies`/`stories` are `routable:false` β€” verified in `Pages::dispatch` / `Page.php:1772`). So a trip's detail page only ever renders while it is **published** β€” which means a `Draft` indicator there would be unreachable, and a write toggle could only *unpublish*, immediately stranding the owner on a page that 404s on the next load with no in-UI path back. All publish/unpublish therefore happens on the `/trips` listing (Surface 1), which shows drafts and is fully reversible. `trip.html.twig` needs no `is_owner` computation for this feature. ### JS β€” `js/src/trip-publish.js` β†’ built to `js/trip-publish.js` Loaded on `/trips` in the `bottom` group **only when `is_owner`** (gated like `feed-actions.js` β€” but note the request shape below differs from it). - Binds each `.trip-publish-toggle` control (listing cards only). - On change: - If turning **off** (unpublish) AND `data-active` is true β†’ `window.confirm( 'This is your active trip β€” unpublishing it also removes it from the home page. Unpublish anyway?')`; if cancelled, revert the switch and stop. (Home falls back to its pre-departure state when the active trip is unpublished β€” see Edge cases.) - **Pending:** disable the switch and set `aria-busy` for the duration of the request, ignoring further toggles β€” guards against a double-tap, or a toggle during the active-trip `confirm()`, firing a second contradictory POST and racing the revert paths. Show it dimmed with a wait cursor while pending; re-enable on success or after the failure revert. - `POST /api/v1/trip//publish` sending **`headers: { 'Content-Type': 'application/json', Accept: 'application/json' }` and `body: JSON.stringify({ published })`**, `credentials: 'include'`. Model this on `post-form.js`'s `apiSend`, **not** `feed-actions.js` (which is a body-less DELETE with no `Content-Type`). The `Content-Type: application/json` is load-bearing: the API's `JsonBodyParserMiddleware` only parses the body when that header is present (`JsonBodyParserMiddleware.php:16`); without it the body decodes to `[]`, the strict `is_bool` guard (backend step 6) sees no `published` key, and **every toggle 400s**. - **Success:** optimistic UI β€” flip `data-published`, toggle the `Draft` badge, update the switch position/label in place on the card. No full reload needed (server state is persisted + cache invalidated for other surfaces). The card stays visible to the owner either way (the owner-aware collection includes drafts). - **Failure:** revert the switch to its prior state and surface an error via one shared page-level `aria-live` toast region (the listing's corner overlay has no room for an inline message). 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. **Home then treats it as no active trip:** gate `home.html.twig`'s active-trip branch on the resolved active trip being **published** as well as `config.site.travelling` (`{% if config.site.travelling and trip.published %}` β€” `trip` is already resolved at `home.html.twig:10`). When the active trip is unpublished, home falls through to its between-trips / pre-departure state instead of rendering a draft trip. No need to touch `site.active_trip`. - **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 **everyone including the owner** (Grav default for unpublished/unroutable β€” there is no owner-preview bypass). The owner still sees the trip in the `/trips` listing (Draft badge) and re-publishes from there. (Scope note: this toggle governs only whether the trip appears in the `/trips` listing β€” it is not a content-privacy control. Child dailies are aggregated inline by the trip page and are not individually linked; a story reachable by a direct link stays reachable, which is acceptable.) - **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 of the `/trips` listing β†’ Draft badge present (asserted on the listing, since the detail page 404s for the owner too). This is the page-tree-index assertion (mirrors DEL4). 3. **TP3 β€” republish restores it (from the listing).** As owner on `/trips`, toggle a Draft fixture trip back on β†’ anon reload sees it again. Republish is asserted on the listing surface, not the detail page (which 404s while unpublished). 4. **TP4 β€” active-trip confirm.** Unpublishing the active trip prompts a confirm; dismissing leaves it published. 5. **TP5 β€” authz.** `POST /api/v1/trip//publish` as anon β†’ 401; as a non-owner authenticated user β†’ 403; frontmatter unchanged on disk. 6. **TP6 β€” active trip unpublished β†’ home falls back.** With the fixture trip set as `site.active_trip` and `travelling: true`, unpublish it β†’ reload `/` β†’ home renders its between-trips / pre-departure state, not the draft trip's active-trip view. (Needs the `active_trip` override on the fixture; mirrors the home-suite setup.) ## Out of scope - Bulk publish/unpublish. - Scheduling / publish dates. - Cascading child publish state. - Reordering trips by publish state (order stays by date desc). - A publish/unpublish write control on the trip detail page. Management is listing-only by design (an unpublished trip's detail page 404s, so a detail-page toggle could only strand the owner β€” see Surface 2).