feat(trips): owner publish/unpublish toggle on /trips listing
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Mpdu3Dt1iVoozHwAMyjrbn
This commit is contained in:
@@ -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 <a>, 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/<slug>/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();
|
||||
}
|
||||
@@ -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();})();
|
||||
Reference in New Issue
Block a user