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
This commit is contained in:
2026-07-08 09:16:38 +02:00
co-authored by Claude Opus 4.8
parent 877d29b2b4
commit ceb0570c86
@@ -0,0 +1,146 @@
# 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:
```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. `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 `null``NotFoundException`.
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:
```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 `<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).