feat(post-form): U5 — edit prefill + edit-mode form behaviour

The card Edit link opens /post?edit=<route>. post-form.js now (KTD4/KTD9, D1/D6/D7):
- On ?edit=, disables the form and shows 'Loading entry…' before the fetch (D1),
  so slow-connection typing can't be overwritten by the incoming prefill.
- GETs /api/v1/pages<route> (credentials:include, the gpx-manager session pattern)
  and populates every field from data.header.* / data.content: title, date
  (space→T for datetime-local), content (EasyMDE), lat, lng, city, country,
  weather select, temp, transport, featured, force_connect, published toggle.
- Sets the hidden edit_path to <route>/entry.md so cache-on-save toggles
  overwrite_mode:edit and the save writes back in place (stable URL).
- Hides the photos section and skips the ≥1-photo rule (photos untouched in M1).
- Switches chrome to 'Edit entry' / 'Save changes' (D6); reveals More options.
- On fetch failure, shows an inline banner and keeps the form disabled (D7).
- Skips draft restore in edit mode; carries edit+return on the form action for a
  future re-render (D3/D5). Rebuilt via make build-assets.

Verified in a headless browser: all fields prefill correctly incl. edit_path and
the published toggle, photos hidden, chrome correct (V6); fetch-failure banner
shown with fields left disabled (D7).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H1FrCYNq6RXdGYbn5PFrhM
This commit is contained in:
2026-07-05 00:03:33 +02:00
co-authored by Claude Opus 4.8
parent c4228e9027
commit 265a06a972
4 changed files with 198 additions and 50 deletions
File diff suppressed because one or more lines are too long
+13
View File
@@ -4,6 +4,19 @@
* mobile layout, and the "More options" disclosure land in U5.
*/
/* Edit-mode prefill-failure banner (U5, D7): shown between the heading and the
first field when the entry can't be loaded for editing. */
.post-edit-error {
margin: 1rem 0;
padding: 0.75rem 1rem;
border: 1px solid #E5786A;
border-radius: 8px;
background: rgba(229, 120, 106, 0.12);
color: #E5786A;
font-size: 0.9rem;
line-height: 1.4;
}
/* EasyMDE toolbar glyphs — replace the FontAwesome icons EasyMDE expects. */
.editor-toolbar .mde-btn::before {
font-style: normal;
+137 -2
View File
@@ -527,7 +527,9 @@ function initValidation() {
var firstInvalid = null;
// ≥1 photo (photos are the first field). Count any present FilePond item.
if (document.querySelectorAll('.filepond--item').length < 1) {
// Skipped in edit mode (KTD9): photos are untouched in M1, so an empty
// FilePond on an edit submit keeps the entry's existing images.
if (!EDIT_MODE && document.querySelectorAll('.filepond--item').length < 1) {
firstInvalid = showPhotoError('Add at least one photo.');
}
@@ -580,6 +582,7 @@ function showReauthHint() {
function initDraft() {
var form = document.querySelector('form[name="new-entry"]');
if (!form) return; // e.g. session expired → login form shown; draft is left intact
if (EDIT_MODE) return; // editing an existing entry: don't restore/overwrite with a create draft
// A confirmed successful post: clear the draft and do NOT restore onto the
// freshly reset form. This runs before any save, so process.reset can't
@@ -684,14 +687,146 @@ function initSuccessState() {
notice.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
/* ── Edit mode (U5, KTD4/KTD9): prefill from the API, adapt the form ──────────
* The Edit control on a journal card links to /post?edit=<entry-route>. We detect
* that param, disable the form (D1), fetch the entry via the session-auth Grav
* API (credentials:include — the gpx-manager pattern), populate every field, set
* the hidden edit_path so the save writes back in place (cache-on-save toggles
* overwrite_mode:edit server-side), hide the photos section and relax the
* >=1-photo rule (photos are untouched in M1 — an empty FilePond leaves existing
* images intact), and switch the chrome to "Edit entry" / "Save changes" (D6).
*/
var EDIT_MODE = false;
function qparam(name) {
return new URLSearchParams(window.location.search).get(name);
}
function editSetText(name, value) {
var el = field(name);
if (el) el.value = (value === undefined || value === null) ? '' : String(value);
}
// Toggles (published/featured/force_connect) render as a 1/0 radio pair.
function editSetToggle(name, truthy) {
var want = truthy ? '1' : '0';
var radios = document.querySelectorAll('[name="data[' + name + ']"]');
Array.prototype.forEach.call(radios, function (r) { r.checked = (String(r.value) === want); });
}
function editSetContent(value) {
var text = (value === undefined || value === null) ? '' : String(value);
if (window.postFormEditor && typeof window.postFormEditor.value === 'function') {
window.postFormEditor.value(text);
} else {
var ta = field('content');
if (ta) ta.value = text;
}
}
// Disable/enable the form's own fields + submit (get-location/weather live
// OUTSIDE the form, so they're untouched). Also gates the EasyMDE editor.
function editFormDisabled(form, disabled) {
var els = form.querySelectorAll('input, textarea, select, button');
Array.prototype.forEach.call(els, function (el) { el.disabled = disabled; });
if (window.postFormEditor && window.postFormEditor.codemirror) {
window.postFormEditor.codemirror.setOption('readOnly', disabled ? 'nocursor' : false);
}
}
function editSubmitLabel(btn, text) {
if (!btn) return;
if (btn.tagName === 'INPUT') btn.value = text; else btn.textContent = text;
}
function editShowError(wrap, msg) {
var existing = wrap.querySelector('.post-edit-error');
if (existing) { existing.textContent = msg; return; }
var banner = document.createElement('div');
banner.className = 'post-edit-error';
banner.setAttribute('role', 'alert');
banner.textContent = msg;
var h1 = wrap.querySelector('h1');
if (h1) h1.insertAdjacentElement('afterend', banner); else wrap.insertBefore(banner, wrap.firstChild);
}
function initEditMode() {
var form = document.querySelector('form[name="new-entry"]');
var wrap = document.querySelector('.post-form-wrap');
if (!form || !wrap) return;
var route = qparam('edit');
if (!route) return; // create mode — nothing to do
EDIT_MODE = true;
var h1 = wrap.querySelector('h1');
if (h1) h1.textContent = 'Edit entry'; // D6
var submitBtn = form.querySelector('button[type="submit"], input[type="submit"]');
var origLabel = submitBtn ? (submitBtn.tagName === 'INPUT' ? submitBtn.value : submitBtn.textContent) : 'Save changes';
// KTD9: hide the photos section (photos untouched in M1). The >=1-photo rule
// is skipped while EDIT_MODE (see initValidation).
var photoField = form.querySelector('.photos-collapse') || form.querySelector('.filepond-root, .form-input-file');
var photoWrapper = photoField ? (photoField.closest('.form-field') || photoField) : null;
if (photoWrapper) photoWrapper.style.display = 'none';
// D1: no typing before prefill lands — disable + loading label.
editFormDisabled(form, true);
editSubmitLabel(submitBtn, 'Loading entry…');
// D3/D5: carry the edit context on the form action so a server re-render
// (once the Form 9.1.10 filepond regression is fixed) re-enters edit mode and
// the return target survives the round-trip.
var ret = qparam('return') || wrap.getAttribute('data-trip-url') || '';
form.setAttribute('action', '/post?edit=' + encodeURIComponent(route) + (ret ? '&return=' + encodeURIComponent(ret) : ''));
fetch('/api/v1/pages' + route, { credentials: 'include', headers: { Accept: 'application/json' } })
.then(function (r) { if (!r.ok) throw new Error('HTTP ' + r.status); return r.json(); })
.then(function (json) {
var d = (json && json.data) || {};
var h = d.header || {};
editSetText('title', h.title != null ? h.title : d.title);
editSetText('date', h.date ? String(h.date).replace(' ', 'T') : ''); // datetime-local wants a T separator
editSetContent(d.content);
editSetText('lat', h.lat);
editSetText('lng', h.lng);
editSetText('location_city', h.location_city);
editSetText('location_country', h.location_country);
editSetText('weather_desc', h.weather_desc);
editSetText('weather_temp_c', h.weather_temp_c);
editSetText('transport_mode', h.transport_mode);
editSetToggle('featured', h.featured);
editSetToggle('force_connect', h.force_connect);
editSetToggle('published', h.published !== undefined ? h.published : d.published);
var editPathEl = field('edit_path');
if (editPathEl) editPathEl.value = route + '/entry.md';
editFormDisabled(form, false);
editSubmitLabel(submitBtn, 'Save changes'); // D6
var more = form.querySelector('.more-options');
if (more) more.open = true; // reveal Published/Featured/Connector
})
.catch(function () {
// D7: inline error between heading and first field; keep the form
// disabled and empty rather than leaving a half-filled state.
editShowError(wrap, 'Sorry — this entry could not be loaded for editing. Go back to the journal and try again.');
editSubmitLabel(submitBtn, origLabel);
});
}
/* ── Boot ────────────────────────────────────────────────── */
function boot() {
// Exposed for U6 draft restore and the Playwright specs; null when this
// bundle loads on a page without the content field.
window.postFormEditor = initEditor();
initSuccessState();
// Edit mode first: sets EDIT_MODE (so initDraft skips and initValidation
// relaxes the photo rule), disables the form and starts the async prefill.
initEditMode();
// Restore before disclosure/geo so their on-load checks (auto-open,
// weather-button enable) see the restored values.
// weather-button enable) see the restored values. No-op in edit mode.
initDraft();
initPhotoConversion();
initDisclosure();