From 064f0f0c52c7956659e4a8f3cec4552539c1111f Mon Sep 17 00:00:00 2001 From: Mischa Date: Wed, 8 Jul 2026 14:50:05 +0200 Subject: [PATCH] feat(trips): owner publish/unpublish toggle on /trips listing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add an owner-only publish switch to each /trips card. It POSTs to a new entry-actions route that mutates trip.md `published` and invalidates the page-tree cache so the listing, nav and home reflect the change on the next load. Owner sees drafts (Draft badge); anon/non-owner unchanged. - U1 EntryScopeGuard::resolveTripChild — resolve a slug to a direct child of /trips (drafts included, for republish) - U2 POST /api/v1/trip/{slug}/publish (setTripPublished) — owner-gated write, strict is_bool body, header-mutation save, audit log - U3 trip-publish-toggle partial + CSS (role=switch, Draft badge, visible failure toast, ≥44px target) - U4 owner-aware /trips listing + card restructure (toggle overlays cover as a non-anchor sibling; works for coverless drafts) - U5 home active-trip branch falls back when the active trip is unpublished - U6 trip-publish.js (confirm/pending/optimistic/revert) + esbuild wiring Cache note: an in-place frontmatter edit keeps the folder-check cache id, and driver:auto uses APCu in web memory, so deleteAll()+invalidateCache() is insufficient — the endpoint also calls apcu_clear_cache(). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01Mpdu3Dt1iVoozHwAMyjrbn --- .../cache-on-save/classes/EntryScopeGuard.php | 32 ++++ .../classes/EntryActionsApiController.php | 80 ++++++++++ plugins/entry-actions/entry-actions.php | 6 + themes/intotheeast/css/style.css | 137 ++++++++++++++++ themes/intotheeast/js/src/trip-publish.js | 148 ++++++++++++++++++ themes/intotheeast/js/trip-publish.js | 1 + themes/intotheeast/package.json | 2 +- themes/intotheeast/templates/home.html.twig | 5 +- .../partials/trip-publish-toggle.html.twig | 27 ++++ themes/intotheeast/templates/trips.html.twig | 22 ++- 10 files changed, 457 insertions(+), 3 deletions(-) create mode 100644 themes/intotheeast/js/src/trip-publish.js create mode 100644 themes/intotheeast/js/trip-publish.js create mode 100644 themes/intotheeast/templates/partials/trip-publish-toggle.html.twig diff --git a/plugins/cache-on-save/classes/EntryScopeGuard.php b/plugins/cache-on-save/classes/EntryScopeGuard.php index 7ce5cb8..2fa21d5 100644 --- a/plugins/cache-on-save/classes/EntryScopeGuard.php +++ b/plugins/cache-on-save/classes/EntryScopeGuard.php @@ -127,4 +127,36 @@ class EntryScopeGuard } return $page; } + + /** + * Resolve a slug to the trip page that is a DIRECT child of /trips, or null + * when the segment is unsafe, the page does not exist, or its parent is not + * /trips. The trip-scoped analogue of resolveActiveDailyChild, used by the + * publish/unpublish route (KTD4). + * + * Unlike the front-end listing collections, this does NOT filter on published + * state: find() must return drafts so the owner can republish an unpublished + * trip from the listing (R7). + */ + public static function resolveTripChild(Grav $grav, string $slug): ?PageInterface + { + if (!self::isSafeSegment($slug)) { + return null; + } + $pages = $grav['pages']; + // In the API request context the page tree is lazily disabled; enable it + // so find() can resolve (mirrors resolveActiveDailyChild). Idempotent. + if (method_exists($pages, 'enablePages')) { + $pages->enablePages(); + } + $page = $pages->find('/trips/' . $slug); + if ($page === null) { + return null; + } + $parent = $page->parent(); + if ($parent === null || $parent->route() !== '/trips') { + return null; + } + return $page; + } } diff --git a/plugins/entry-actions/classes/EntryActionsApiController.php b/plugins/entry-actions/classes/EntryActionsApiController.php index e97884a..61be38a 100644 --- a/plugins/entry-actions/classes/EntryActionsApiController.php +++ b/plugins/entry-actions/classes/EntryActionsApiController.php @@ -141,4 +141,84 @@ class EntryActionsApiController extends AbstractApiController return ApiResponse::noContent(); } + + /** + * POST /api/v1/trip/{slug}/publish + * + * Body: { "published": true|false } — sets the trip's published state and + * persists it to trip.md frontmatter, then invalidates the page-tree cache so + * the /trips listing, nav and home render reflect the change on the next load. + * Owner-only, but (unlike deleteEntry) NOT active-trip scoped: the owner + * publishes/unpublishes ANY trip from the listing. 401 (anon), 403 (non-owner), + * 400 (bad slug / non-boolean body), 404 (slug is not a direct child of /trips). + * + * CSRF boundary: this is a session-cookie write with credentials. Its cross- + * origin protection is the required `Content-Type: application/json`, which + * (with the api plugin's CORS `origins: []`, i.e. same-origin only) forces a + * CORS preflight that a cross-site page cannot satisfy — so a forged request + * from another origin is rejected before it reaches this handler. The strict + * is_bool guard below backs that up (a form-encoded forgery decodes to no key). + */ + public function setTripPublished(ServerRequestInterface $request): ResponseInterface + { + // Authenticated OWNER only (KTD8). getUser() throws 401 for anonymous. + $user = $this->getUser($request); + // Enforce the API-key scope cap (GHSA-x7hm) — see deleteEntry above. + $this->requirePermission($request, 'api.pages.write'); + if (!EntryScopeGuard::isOwnerUser($this->grav, $user)) { + throw new ForbiddenException('Only the site owner can publish trips.'); + } + + $slug = $this->getRouteParam($request, 'slug'); + if (!is_string($slug) || !EntryScopeGuard::isSafeSegment($slug)) { + throw new ApiException(400, 'Bad Request', 'Invalid trip slug.'); + } + + // Resolve via find() + parent-route assertion; drafts resolve too so the + // owner can republish an unpublished trip (R7, KTD4). + $page = EntryScopeGuard::resolveTripChild($this->grav, $slug); + if ($page === null) { + throw new NotFoundException('Trip not found.'); + } + + // Strict boolean only — never (bool)-cast (KTD2). A cast would coerce + // "false"/0/""/a missing key into a valid boolean and silently mis-set + // the flag, contradicting R5. + $body = $this->getRequestBody($request); + if (!is_array($body) || !array_key_exists('published', $body) || !is_bool($body['published'])) { + throw new ApiException(400, 'Bad Request', 'Body must include a boolean "published".'); + } + $published = $body['published']; + + // Persist by mutating the page HEADER before save() (KTD1): in Grav 2.0 + // $page->published($v) sets only the in-memory property, while save() + // serializes from the header object and the flag is read one-way from the + // header at init. Mirror cache-on-save's header-mutation pattern. + $header = $page->header(); + $header->published = $published; + $page->save(); + + // A published-flag change rewrites trip.md IN PLACE — the trip folder's + // structure is unchanged, so the pages-index cache id (md5 of the folder + // checksum under cache.check.method: folder) does NOT change (KTD3). This + // differs from deleteEntry, where the removed folder IS a structure change + // that bumps the id, so a fresh id misses cache and rebuilds. With the id + // unchanged, the stale index survives — and because the cache driver is + // APCu (driver: auto), it lives in the web server's shared memory, which a + // CLI `bin/grav clearcache` cannot reach at all. So: flush the runtime + // store (deleteAll → APCu flushAll) AND apcu_clear_cache() directly to be + // certain, clear the compiled files, and reset the in-memory tree so the + // next request rebuilds from disk and re-reads the published flag. + $this->grav['cache']->deleteAll(); + if (function_exists('apcu_clear_cache')) { + apcu_clear_cache(); + } + $this->grav['pages']->reset(); + $this->grav['cache']->clearCache('standard'); + // Audit trail: publish state is owner-only and changes site-wide + // visibility — record who flipped which trip to what. + $this->grav['log']->info(sprintf('entry-actions: owner "%s" set trip "%s" published=%s', $user->username, $slug, $published ? 'true' : 'false')); + + return ApiResponse::noContent(); + } } diff --git a/plugins/entry-actions/entry-actions.php b/plugins/entry-actions/entry-actions.php index 0f3c9eb..20f20eb 100644 --- a/plugins/entry-actions/entry-actions.php +++ b/plugins/entry-actions/entry-actions.php @@ -11,6 +11,7 @@ use RocketTheme\Toolbox\Event\Event; * Routes: * - DELETE /api/v1/entry/{slug} — delete a journal entry folder * - POST /api/v1/entry/{slug}/photos/order — reorder an entry's photos + * - POST /api/v1/trip/{slug}/publish — publish/unpublish a trip * * The stock DELETE /api/v1/pages only checks write-permission (no trip * scope, and any admin passes), which violates R6; and no stock endpoint can @@ -65,5 +66,10 @@ class EntryActionsPlugin extends Plugin // Nested-static-after-param, same shape as the DELETE above — it only // registers once the API route-map cache is rebuilt (deploy must clear cache). $routes->post('/entry/{slug}/photos/order', [EntryActions\EntryActionsApiController::class, 'reorderPhotos']); + // Publish/unpublish a trip from the /trips listing → mutate trip.md + // `published` and invalidate the page-tree index. Owner-only, any trip + // (not active-scoped). Same registration caveat as above: it only takes + // effect once the API route-map cache is rebuilt (deploy must clear cache). + $routes->post('/trip/{slug}/publish', [EntryActions\EntryActionsApiController::class, 'setTripPublished']); } } diff --git a/themes/intotheeast/css/style.css b/themes/intotheeast/css/style.css index a329d8d..3e6fbf4 100644 --- a/themes/intotheeast/css/style.css +++ b/themes/intotheeast/css/style.css @@ -1289,6 +1289,143 @@ body::after { .trip-card-dates { font-size: var(--text-sm); color: var(--color-ink-2); } .trip-card-counts { font-size: var(--text-sm); color: var(--color-ink-muted); } +/* ── Owner publish/unpublish toggle (U3) ─────────────────────────────────────── */ +/* The card is wrapped in a position:relative container so this overlay can sit + top-right over the cover as a sibling of the navigating (KTD6). The wrapper + also guarantees an anchor even for a coverless draft (a min-height header strip + on the card itself), so the toggle never collapses to nothing. */ +.trip-card-wrap { + position: relative; +} + +.trip-publish-overlay { + position: absolute; + top: var(--space-3); + right: var(--space-3); + z-index: 2; /* above the card */ + display: flex; + align-items: center; + gap: var(--space-2); +} + +/* Solid pill so both indicators stay legible over an arbitrary cover photo. */ +.trip-draft-badge { + font-family: var(--font-ui); + font-size: 0.6875rem; + font-weight: 700; + letter-spacing: 0.09em; + text-transform: uppercase; + color: #E0A458; /* warm amber — matches .journal-draft-badge */ + background: var(--color-canvas); + border: 1px solid #E0A458; + border-radius: var(--radius-sm); + padding: 0.15em 0.5em; + line-height: 1.5; + white-space: nowrap; + box-shadow: var(--shadow-sm); +} +.trip-draft-badge[hidden] { display: none; } + +/* The switch: a solid chip backing keeps the track/knob readable on any cover. + ≥44px touch target via padding; the visible track is smaller and centred. */ +.trip-publish-toggle { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 44px; + min-height: 44px; + padding: 0 var(--space-2); + margin: 0; + border: 1px solid var(--color-border); + border-radius: var(--radius-full); + background: var(--color-canvas); + box-shadow: var(--shadow-sm); + cursor: pointer; + -webkit-appearance: none; + appearance: none; +} + +.trip-publish-track { + position: relative; + display: block; + width: 40px; + height: 22px; + border-radius: var(--radius-full); + background: var(--color-ink-muted); /* off = muted */ + transition: background 0.15s ease; +} +.trip-publish-knob { + position: absolute; + top: 2px; + left: 2px; + width: 18px; + height: 18px; + border-radius: 50%; + background: var(--color-ink); + transition: transform 0.15s ease; +} + +/* on = teal track, knob slid right */ +.trip-publish-toggle[aria-checked="true"] .trip-publish-track { + background: var(--color-accent); +} +.trip-publish-toggle[aria-checked="true"] .trip-publish-knob { + transform: translateX(18px); + background: var(--color-accent-on); +} + +/* Pending (R13): dimmed + wait cursor while a toggle is in flight. */ +.trip-publish-toggle[aria-busy="true"] { + opacity: 0.55; + cursor: wait; +} + +/* Keyboard focus ring that reads over a busy cover photo (white ring + dark halo). */ +.trip-publish-toggle:focus-visible { + outline: 2px solid var(--color-accent-on); + outline-offset: 2px; + box-shadow: 0 0 0 4px rgba(0, 0, 0, 0.45); +} + +/* Visible page-level failure toast (R15). Distinct from feed-actions.js's + sr-only #feed-actions-live region: the trip card has no inline message slot, + so a sighted owner needs a real, visible notice. trip-publish.js creates and + populates the element; this only styles it. */ +.trip-publish-toast { + position: fixed; + top: var(--space-4); + left: 50%; + transform: translateX(-50%); + z-index: 1000; + display: flex; + align-items: center; + gap: var(--space-3); + max-width: calc(100vw - var(--space-8)); + padding: var(--space-3) var(--space-4); + font-family: var(--font-ui); + font-size: var(--text-sm); + color: var(--color-ink); + background: var(--color-canvas); + border: 1px solid var(--color-error); + border-radius: var(--radius-md); + box-shadow: var(--shadow-lg); +} +.trip-publish-toast[hidden] { display: none; } +.trip-publish-toast__close { + flex-shrink: 0; + min-width: 32px; + min-height: 32px; + padding: 0; + font-size: var(--text-md); + line-height: 1; + color: var(--color-ink-muted); + background: transparent; + border: none; + border-radius: var(--radius-sm); + cursor: pointer; +} +.trip-publish-toast__close:hover { color: var(--color-ink); } + /* ── Trip page sidebar ───────────────────────────────────────────────────────── */ .trip-counts { diff --git a/themes/intotheeast/js/src/trip-publish.js b/themes/intotheeast/js/src/trip-publish.js new file mode 100644 index 0000000..82d0605 --- /dev/null +++ b/themes/intotheeast/js/src/trip-publish.js @@ -0,0 +1,148 @@ +/* + * trip-publish.js (U6) — owner publish/unpublish toggle for the /trips listing. + * + * Loaded only for the owner (trips.html.twig gates the asset). Each listing card + * carries a switch (partials/trip-publish-toggle.html.twig) overlaid on the cover + * as a sibling of the navigating , so toggling never follows the card link. + * + * Flow: click/Enter/Space on the switch → (if unpublishing the ACTIVE trip) + * window.confirm → lock the switch (aria-busy) → POST /api/v1/trip//publish + * {published} (session cookie) → on success flip the switch + Draft badge in place + * (no reload); on failure re-enable and surface a VISIBLE page-level toast. + * + * Markup contract (data-* on button.trip-publish-toggle): + * data-trip-slug, data-trip-route, data-published ("true"|"false"), + * data-active ("true" when this is site.active_trip); aria-checked mirrors + * data-published. The sibling .trip-draft-badge is shown iff not published. + */ + +var TOAST_TIMEOUT_MS = 5000; +var toastTimer = null; + +// One visible, page-level polite toast (R15). Modelled on the feed-actions.js +// live region but intentionally NOT sr-only: the trip card has no inline message +// slot, so a sighted owner must actually see the failure. Replaces (never queues) +// the message, auto-dismisses, and carries a manual close control. +function toastEl() { + var el = document.getElementById('trip-publish-live'); + if (!el) { + el = document.createElement('div'); + el.id = 'trip-publish-live'; + el.className = 'trip-publish-toast'; + el.setAttribute('role', 'status'); + el.setAttribute('aria-live', 'polite'); + el.hidden = true; + + var msg = document.createElement('span'); + msg.className = 'trip-publish-toast__msg'; + + var close = document.createElement('button'); + close.type = 'button'; + close.className = 'trip-publish-toast__close'; + close.setAttribute('aria-label', 'Dismiss'); + close.textContent = '×'; // × + close.addEventListener('click', hideToast); + + el.appendChild(msg); + el.appendChild(close); + document.body.appendChild(el); + } + return el; +} +function showToast(message) { + var el = toastEl(); + el.querySelector('.trip-publish-toast__msg').textContent = message; + el.hidden = false; + if (toastTimer) clearTimeout(toastTimer); + toastTimer = setTimeout(hideToast, TOAST_TIMEOUT_MS); +} +function hideToast() { + var el = document.getElementById('trip-publish-live'); + if (el) el.hidden = true; + if (toastTimer) { clearTimeout(toastTimer); toastTimer = null; } +} + +// A 401/403 almost always means the login session lapsed — say so; anything else +// is a generic retry (mirrors post-form.js editErrorMsg copy). +function errorMessage(status) { + return (status === 401 || status === 403) + ? 'Your login session expired — sign in again, then retry.' + : "Couldn't update — try again."; +} + +// Mutation fetch that resolves on success and rejects with a status-bearing Error +// otherwise (modelled on post-form.js apiSend), so the caller can tell an expired +// login apart from a generic failure. Cookies auto-included. +function apiSend(url, opts) { + return fetch(url, Object.assign({ credentials: 'include' }, opts)).then(function (r) { + if (r.ok) return r; // 204 is within r.ok + var e = new Error('HTTP ' + r.status); + e.status = r.status; + throw e; + }); +} + +function setPublishedUI(btn, published) { + btn.setAttribute('aria-checked', published ? 'true' : 'false'); + btn.setAttribute('data-published', published ? 'true' : 'false'); + var overlay = btn.closest('.trip-publish-overlay'); + var badge = overlay ? overlay.querySelector('.trip-draft-badge') : null; + if (badge) badge.hidden = published; +} + +function setPending(btn, pending) { + if (pending) { + btn.setAttribute('aria-busy', 'true'); + btn.disabled = true; + } else { + btn.removeAttribute('aria-busy'); + btn.disabled = false; + } +} + +function onToggle(btn) { + if (btn.getAttribute('aria-busy') === 'true') return; // already in flight (R13) + + var current = btn.getAttribute('data-published') === 'true'; + var next = !current; + + // R12: unpublishing the ACTIVE trip removes it from the home page — confirm + // first; cancelling leaves it published (no request, no UI change). + if (!next && btn.getAttribute('data-active') === 'true') { + if (!window.confirm('This is your active trip — unpublishing it also removes it from the home page. Unpublish anyway?')) { + return; + } + } + + var slug = btn.getAttribute('data-trip-slug'); + setPending(btn, true); + + apiSend('/api/v1/trip/' + encodeURIComponent(slug) + '/publish', { + method: 'POST', + headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, + body: JSON.stringify({ published: next }) + }).then(function () { + // Success: flip the switch + Draft badge in place, no reload (R14). + setPublishedUI(btn, next); + setPending(btn, false); + }).catch(function (err) { + // Failure: the UI never flipped, so revert is just re-enable (R15). + setPending(btn, false); + showToast(errorMessage(err && err.status)); + }); +} + +function initTripPublish() { + document.addEventListener('click', function (e) { + var btn = e.target.closest ? e.target.closest('.trip-publish-toggle') : null; + if (!btn) return; + e.preventDefault(); + onToggle(btn); + }); +} + +if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', initTripPublish); +} else { + initTripPublish(); +} diff --git a/themes/intotheeast/js/trip-publish.js b/themes/intotheeast/js/trip-publish.js new file mode 100644 index 0000000..9779810 --- /dev/null +++ b/themes/intotheeast/js/trip-publish.js @@ -0,0 +1 @@ +(()=>{var o=5e3,r=null;function d(){var t=document.getElementById("trip-publish-live");if(!t){t=document.createElement("div"),t.id="trip-publish-live",t.className="trip-publish-toast",t.setAttribute("role","status"),t.setAttribute("aria-live","polite"),t.hidden=!0;var e=document.createElement("span");e.className="trip-publish-toast__msg";var i=document.createElement("button");i.type="button",i.className="trip-publish-toast__close",i.setAttribute("aria-label","Dismiss"),i.textContent="\xD7",i.addEventListener("click",l),t.appendChild(e),t.appendChild(i),document.body.appendChild(t)}return t}function c(t){var e=d();e.querySelector(".trip-publish-toast__msg").textContent=t,e.hidden=!1,r&&clearTimeout(r),r=setTimeout(l,o)}function l(){var t=document.getElementById("trip-publish-live");t&&(t.hidden=!0),r&&(clearTimeout(r),r=null)}function p(t){return t===401||t===403?"Your login session expired \u2014 sign in again, then retry.":"Couldn't update \u2014 try again."}function f(t,e){return fetch(t,Object.assign({credentials:"include"},e)).then(function(i){if(i.ok)return i;var a=new Error("HTTP "+i.status);throw a.status=i.status,a})}function h(t,e){t.setAttribute("aria-checked",e?"true":"false"),t.setAttribute("data-published",e?"true":"false");var i=t.closest(".trip-publish-overlay"),a=i?i.querySelector(".trip-draft-badge"):null;a&&(a.hidden=e)}function n(t,e){e?(t.setAttribute("aria-busy","true"),t.disabled=!0):(t.removeAttribute("aria-busy"),t.disabled=!1)}function m(t){if(t.getAttribute("aria-busy")!=="true"){var e=t.getAttribute("data-published")==="true",i=!e;if(!(!i&&t.getAttribute("data-active")==="true"&&!window.confirm("This is your active trip \u2014 unpublishing it also removes it from the home page. Unpublish anyway?"))){var a=t.getAttribute("data-trip-slug");n(t,!0),f("/api/v1/trip/"+encodeURIComponent(a)+"/publish",{method:"POST",headers:{"Content-Type":"application/json",Accept:"application/json"},body:JSON.stringify({published:i})}).then(function(){h(t,i),n(t,!1)}).catch(function(u){n(t,!1),c(p(u&&u.status))})}}}function s(){document.addEventListener("click",function(t){var e=t.target.closest?t.target.closest(".trip-publish-toggle"):null;e&&(t.preventDefault(),m(e))})}document.readyState==="loading"?document.addEventListener("DOMContentLoaded",s):s();})(); diff --git a/themes/intotheeast/package.json b/themes/intotheeast/package.json index 6680083..a755877 100644 --- a/themes/intotheeast/package.json +++ b/themes/intotheeast/package.json @@ -1,7 +1,7 @@ { "private": true, "scripts": { - "build": "node scripts/gen-weather-icons.js && esbuild js/src/main.js --bundle --minify --format=iife --outfile=js/main.js --loader:.woff2=file --loader:.woff=file --asset-names=../fonts/[name] && esbuild js/src/map.js --bundle --minify --format=iife --outfile=js/map.js && esbuild js/src/feed-actions.js --bundle --minify --format=iife --outfile=js/feed-actions.js && rm -rf js/post && esbuild js/src/post-form.js --bundle --minify --format=esm --splitting --outdir=js/post && mkdir -p css-compiled fonts && { mv js/main.css css-compiled/main.css 2>/dev/null || true; } && { mv js/map.css css-compiled/map.css 2>/dev/null || true; } && { mv js/post/post-form.css css-compiled/post-form.css 2>/dev/null || true; }" + "build": "node scripts/gen-weather-icons.js && esbuild js/src/main.js --bundle --minify --format=iife --outfile=js/main.js --loader:.woff2=file --loader:.woff=file --asset-names=../fonts/[name] && esbuild js/src/map.js --bundle --minify --format=iife --outfile=js/map.js && esbuild js/src/feed-actions.js --bundle --minify --format=iife --outfile=js/feed-actions.js && esbuild js/src/trip-publish.js --bundle --minify --format=iife --outfile=js/trip-publish.js && rm -rf js/post && esbuild js/src/post-form.js --bundle --minify --format=esm --splitting --outdir=js/post && mkdir -p css-compiled fonts && { mv js/main.css css-compiled/main.css 2>/dev/null || true; } && { mv js/map.css css-compiled/map.css 2>/dev/null || true; } && { mv js/post/post-form.css css-compiled/post-form.css 2>/dev/null || true; }" }, "dependencies": { "@fontsource-variable/dm-sans": "latest", diff --git a/themes/intotheeast/templates/home.html.twig b/themes/intotheeast/templates/home.html.twig index 0fcb016..ccc60f1 100644 --- a/themes/intotheeast/templates/home.html.twig +++ b/themes/intotheeast/templates/home.html.twig @@ -9,7 +9,10 @@ {% set trip_route = config.site.active_trip %} {% set trip = grav.pages.find(trip_route) %} -{% if config.site.travelling %} +{# An unpublished active trip falls through to the between-trips state (R16, + KTD7): trip is resolved above and `.published` reads the trip.md flag. This is + the whole home fallback — site.active_trip is not touched. #} +{% if config.site.travelling and trip and trip.published %} {# ══════════════════════════════════════════════════════════ ACTIVE TRIP MODE #} {% set dailies_page = grav.pages.find(trip_route ~ '/dailies') %} diff --git a/themes/intotheeast/templates/partials/trip-publish-toggle.html.twig b/themes/intotheeast/templates/partials/trip-publish-toggle.html.twig new file mode 100644 index 0000000..5bd1169 --- /dev/null +++ b/themes/intotheeast/templates/partials/trip-publish-toggle.html.twig @@ -0,0 +1,27 @@ +{# + Owner-only publish/unpublish switch for a /trips listing card (U3, R10/R11). + + Rendered by trips.html.twig as a SIBLING of the navigating card (never a + child of it), overlaid top-right on the cover, so a click on it toggles publish + state rather than following the card link (KTD6). trip-publish.js binds the + button by `.trip-publish-toggle` and drives it from the data-* below. + + Params: + trip Page — the trip whose published state this controls + is_active bool — true when this trip is site.active_trip; drives the + "home page loses it" confirm in the JS (R12) +#} +{% set is_active = is_active ?? false %} +
+ Draft + +
diff --git a/themes/intotheeast/templates/trips.html.twig b/themes/intotheeast/templates/trips.html.twig index ca616ce..10c2019 100644 --- a/themes/intotheeast/templates/trips.html.twig +++ b/themes/intotheeast/templates/trips.html.twig @@ -3,7 +3,13 @@ {% block content %} {% import 'macros/cover.html.twig' as cover %}

Past Trips

-{% set trips = page.children.published()|sort((a, b) => a.date < b.date ? 1 : -1) %} +{# Owner gate (R1): broader than trip.html.twig's owner_can_edit — publishing + works on ANY trip, not just the active one, so it's just owner identity. #} +{% set is_owner = grav.user.authenticated + and grav.user.username == grav.config.site.owner_username %} +{# Owner sees drafts (R9); everyone else published only. #} +{% set trips = (is_owner ? page.children : page.children.published())|sort((a, b) => a.date < b.date ? 1 : -1) %} +{% if is_owner %}{% do assets.addJs('theme://js/trip-publish.js', {group: 'bottom'}) %}{% endif %} {% if trips|length == 0 %}

No trips yet.

{% else %} @@ -13,6 +19,18 @@ {% set stories_page = grav.pages.find(trip.route ~ '/stories') %} {% set journal_count = dailies_page ? dailies_page.children.published()|length : 0 %} {% set story_count = stories_page ? stories_page.children.published()|length : 0 %} + {# Robust active-trip match: site.active_trip may be a full route + (/trips/x) or a bare slug — normalise both sides before comparing so the + R12 confirm never silently drops (both forms exist in helpers.js / + cache-on-save). #} + {% set active = (config.site.active_trip ?? '')|trim('/') %} + {% set trip_route = trip.route|trim('/') %} + {% set is_active = active != '' and (active == trip_route or active == ('trips/' ~ trip.slug) or active == trip.slug) %} + {# The wrapper is a positioned container so the owner toggle can overlay the + cover as a SIBLING of the navigating
(KTD6) — and it exists even for a + coverless draft, giving the toggle an anchor whether or not the cover macro + emits an image. #} + + {% if is_owner %}{% include 'partials/trip-publish-toggle.html.twig' with { trip: trip, is_active: is_active } only %}{% endif %} + {% endfor %} {% endif %}