- resolveTripChild now asserts the resolved page uses the trip template, so a non-trip direct child of /trips could never be toggled through this endpoint (P3 adversarial). - apiSend gains an optional timeoutMs (AbortController); trip-publish passes 10s so a hung toggle can't leave the switch stuck aria-busy. post-form omits it, keeping media uploads unbounded (P2 reliability). - trips.html.twig reuses trip.html.twig's one-line active-trip slug match instead of a bespoke 3-branch OR (P2 maintainability). - Draft-badge amber is now a --color-draft-accent token shared by the trip and journal badges instead of a twice-hardcoded #E0A458 literal (P3). Rebuilt js/trip-publish.js and js/post/post-form.js (shared api-utils change). PHP lint clean; trip-publish suite 10/10; post suite unchanged. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Mpdu3Dt1iVoozHwAMyjrbn
132 lines
4.9 KiB
JavaScript
132 lines
4.9 KiB
JavaScript
/*
|
||
* 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-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.
|
||
*/
|
||
|
||
import { apiSend, apiErrorMsg } from './api-utils.js';
|
||
|
||
var TOAST_TIMEOUT_MS = 5000;
|
||
var PUBLISH_TIMEOUT_MS = 10000; // abort a hung toggle so the switch never sticks (R13)
|
||
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; }
|
||
}
|
||
|
||
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 })
|
||
}, null, PUBLISH_TIMEOUT_MS).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(apiErrorMsg(err, "Couldn't update — try again."));
|
||
});
|
||
}
|
||
|
||
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();
|
||
}
|