From ceb0570c863013fa666ccc4aac608c14174720d1 Mon Sep 17 00:00:00 2001 From: Mischa Date: Wed, 8 Jul 2026 09:16:38 +0200 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_01Mpdu3Dt1iVoozHwAMyjrbn --- .../2026-07-08-trip-publish-toggle-design.md | 146 ++++++++++++++++++ 1 file changed, 146 insertions(+) create mode 100644 docs/working/specs/2026-07-08-trip-publish-toggle-design.md diff --git a/docs/working/specs/2026-07-08-trip-publish-toggle-design.md b/docs/working/specs/2026-07-08-trip-publish-toggle-design.md new file mode 100644 index 0000000..a576344 --- /dev/null +++ b/docs/working/specs/2026-07-08-trip-publish-toggle-design.md @@ -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/`). 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 `` 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//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//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).