refactor(theme): share apiSend/apiErrorMsg between post-form and trip-publish

Address the P1 maintainability finding that trip-publish.js reimplemented
post-form.js's apiSend + login-expired error copy verbatim. Extract both into
js/src/api-utils.js and import from both entry points; esbuild inlines the
module into each bundle so there is no runtime coupling. Also drops the stale
data-trip-route reference from trip-publish.js's markup-contract comment.

Rebuilt js/trip-publish.js and js/post/post-form.js.

Verified: trip-publish suite 8/8; post suite unchanged (34 pass, same 6
owner-gate environmental fails as baseline — photo-editor specs that exercise
post-form's apiSend/apiErrorMsg all green).

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 17:14:00 +02:00
co-authored by Claude Opus 4.8
parent 5b4e31678e
commit 25cee53718
5 changed files with 95 additions and 102 deletions
File diff suppressed because one or more lines are too long
+32
View File
@@ -0,0 +1,32 @@
/*
* api-utils.js — shared owner-facing API helpers.
*
* Imported by post-form.js (edit/photo mutations) and trip-publish.js (the
* publish toggle) so the "login expired" copy and the ok-status handling live
* in ONE place; esbuild inlines this module into each bundle at build time, so
* there is no runtime coupling between the two entry points.
*/
// Mutation fetch that RESOLVES on success and REJECTS with a status-bearing
// Error otherwise, so callers can tell an expired login (401/403) apart from a
// generic failure. `okStatuses` lists extra codes to accept as success (e.g.
// 204 no-content, or 404 already-gone for an idempotent DELETE). Cookies
// auto-included.
export function apiSend(url, opts, okStatuses) {
return fetch(url, Object.assign({ credentials: 'include' }, opts)).then(function (r) {
if (r.ok || (okStatuses && okStatuses.indexOf(r.status) !== -1)) return r;
var e = new Error('HTTP ' + r.status);
e.status = r.status;
throw e;
});
}
// Turn a failed apiSend/fetch into owner-facing copy. A 401/403 almost always
// means the login session lapsed — say so, because a plain "try again" wouldn't
// help until they sign back in; otherwise return the caller's fallback.
export function apiErrorMsg(err, fallback) {
var s = err && err.status;
return (s === 401 || s === 403)
? 'Your login session expired — sign in again, then retry.'
: fallback;
}
+4 -25
View File
@@ -11,6 +11,7 @@ import EasyMDE from 'easymde';
import Sortable from 'sortablejs'; import Sortable from 'sortablejs';
import 'easymde/dist/easymde.min.css'; import 'easymde/dist/easymde.min.css';
import './post-form.css'; import './post-form.css';
import { apiSend, apiErrorMsg } from './api-utils.js';
/* ── Markdown editor (EasyMDE) ───────────────────────────── */ /* ── Markdown editor (EasyMDE) ───────────────────────────── */
function initEditor() { function initEditor() {
@@ -882,29 +883,7 @@ function initPhotoEditor(route) {
if (sortable) sortable.option('disabled', b); if (sortable) sortable.option('disabled', b);
} }
// Mutation fetch that RESOLVES on success and REJECTS with a status-bearing // apiSend / apiErrorMsg now live in ./api-utils.js (shared with trip-publish.js).
// Error otherwise, so callers can tell an expired login (401/403) apart from a
// generic failure. `okStatuses` lists extra codes to accept as success (e.g.
// 204 no-content, or 404 already-gone for an idempotent DELETE). Cookies
// auto-included. (Superseded the old boolean apiOk, which swallowed the code.)
function apiSend(url, opts, okStatuses) {
return fetch(url, Object.assign({ credentials: 'include' }, opts)).then(function (r) {
if (r.ok || (okStatuses && okStatuses.indexOf(r.status) !== -1)) return r;
var e = new Error('HTTP ' + r.status);
e.status = r.status;
throw e;
});
}
// Turn a failed apiSend/fetch into owner-facing copy. A 401/403 almost always
// means the login session lapsed mid-edit — say so, because a plain "try
// again" wouldn't help until they sign back in.
function editErrorMsg(err, fallback) {
var s = err && err.status;
return (s === 401 || s === 403)
? 'Your login session expired — sign in again, then retry.'
: fallback;
}
function mediaList() { function mediaList() {
return fetch('/api/v1/pages' + route + '/media', { credentials: 'include', headers: { Accept: 'application/json' } }) return fetch('/api/v1/pages' + route + '/media', { credentials: 'include', headers: { Accept: 'application/json' } })
@@ -990,7 +969,7 @@ function initPhotoEditor(route) {
function () { setStatus(''); render(next); } // saved; DOM already shows it function () { setStatus(''); render(next); } // saved; DOM already shows it
); );
}, function (err) { }, function (err) {
setStatus(editErrorMsg(err, 'Couldnt save the new order — reverted. Try again.'), true); setStatus(apiErrorMsg(err, 'Couldnt save the new order — reverted. Try again.'), true);
render(lastGood); // revert the SortableJS move to last-known-good render(lastGood); // revert the SortableJS move to last-known-good
}).then(function () { setBusy(false); }); }).then(function () { setBusy(false); });
} }
@@ -1038,7 +1017,7 @@ function initPhotoEditor(route) {
); );
}, function (err) { }, function (err) {
// The DELETE request itself failed — nothing changed on disk. // The DELETE request itself failed — nothing changed on disk.
setStatus(editErrorMsg(err, 'Couldnt delete that photo. Try again.'), true); setStatus(apiErrorMsg(err, 'Couldnt delete that photo. Try again.'), true);
render(lastGood); render(lastGood);
}) })
.then(function () { setBusy(false); }); .then(function () { setBusy(false); });
+4 -22
View File
@@ -11,11 +11,13 @@
* (no reload); on failure re-enable and surface a VISIBLE page-level toast. * (no reload); on failure re-enable and surface a VISIBLE page-level toast.
* *
* Markup contract (data-* on button.trip-publish-toggle): * Markup contract (data-* on button.trip-publish-toggle):
* data-trip-slug, data-trip-route, data-published ("true"|"false"), * data-trip-slug, data-published ("true"|"false"),
* data-active ("true" when this is site.active_trip); aria-checked mirrors * data-active ("true" when this is site.active_trip); aria-checked mirrors
* data-published. The sibling .trip-draft-badge is shown iff not published. * 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 TOAST_TIMEOUT_MS = 5000;
var toastTimer = null; var toastTimer = null;
@@ -62,26 +64,6 @@ function hideToast() {
if (toastTimer) { clearTimeout(toastTimer); toastTimer = null; } 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) { function setPublishedUI(btn, published) {
btn.setAttribute('aria-checked', published ? 'true' : 'false'); btn.setAttribute('aria-checked', published ? 'true' : 'false');
btn.setAttribute('data-published', published ? 'true' : 'false'); btn.setAttribute('data-published', published ? 'true' : 'false');
@@ -128,7 +110,7 @@ function onToggle(btn) {
}).catch(function (err) { }).catch(function (err) {
// Failure: the UI never flipped, so revert is just re-enable (R15). // Failure: the UI never flipped, so revert is just re-enable (R15).
setPending(btn, false); setPending(btn, false);
showToast(errorMessage(err && err.status)); showToast(apiErrorMsg(err, "Couldn't update — try again."));
}); });
} }
+1 -1
View File
@@ -1 +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();})(); (()=>{function s(t,e,i){return fetch(t,Object.assign({credentials:"include"},e)).then(function(r){if(r.ok||i&&i.indexOf(r.status)!==-1)return r;var n=new Error("HTTP "+r.status);throw n.status=r.status,n})}function o(t,e){var i=t&&t.status;return i===401||i===403?"Your login session expired \u2014 sign in again, then retry.":e}var p=5e3,a=null;function c(){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",d),t.appendChild(e),t.appendChild(i),document.body.appendChild(t)}return t}function f(t){var e=c();e.querySelector(".trip-publish-toast__msg").textContent=t,e.hidden=!1,a&&clearTimeout(a),a=setTimeout(d,p)}function d(){var t=document.getElementById("trip-publish-live");t&&(t.hidden=!0),a&&(clearTimeout(a),a=null)}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"),r=i?i.querySelector(".trip-draft-badge"):null;r&&(r.hidden=e)}function u(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 r=t.getAttribute("data-trip-slug");u(t,!0),s("/api/v1/trip/"+encodeURIComponent(r)+"/publish",{method:"POST",headers:{"Content-Type":"application/json",Accept:"application/json"},body:JSON.stringify({published:i})}).then(function(){h(t,i),u(t,!1)}).catch(function(n){u(t,!1),f(o(n,"Couldn't update \u2014 try again."))})}}}function l(){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",l):l();})();