feat(post-form): M2 photo edit — load, remove & reorder from the edit form

Editing an entry now loads its existing photos into FilePond so the owner can
remove and reorder them; the first photo is the cover. Adding NEW photos on edit
is intentionally suppressed (see below).

post-form.js (U7):
- On ?edit=, load the entry's current images into FilePond as LOCAL items (via
  the session media API, gpx-manager pattern). They display for remove/reorder
  and ride the existing photo_order manifest on submit, but are never re-uploaded.
- Exclude the FilePond field from the D1 prefill disable-sweep — FilePond reads
  its input's disabled state at init and never re-enables, which had removed its
  controls in edit mode.
- Suppress the add affordance in edit mode (allowBrowse/allowDrop off): a new
  upload on edit hits add-page-by-form's Grav-2.0 edit-merge fatal
  ((array)$page->header() yields mangled protected keys → array_merge(null,…)).
  That plugin is stock/GPM/git-ignored (no fork), so adding photos on edit is
  deferred to the form-to-page/image-upload rework.

cache-on-save.php (U8):
- reconcilePhotos(): on edit, resolve the entry folder via the shared scope guard
  (not the fuzzy create-path finder), delete any image dropped from the manifest,
  then renumber survivors photo-1..N in the submitted order (cover = first).
- Run reconciliation ONCE per submit: onFormProcessed fires per process action
  (4×); a 2nd pass deleted the just-renamed photo-N files as "unlisted".
- Empty manifest reconciles nothing (fail-safe: never wipes photos on a missing
  photo_order).

Verified on the container: existing photos load (V9); remove + reorder persist to
disk with cover=first (V10); reconcile helpers covered by a reflection unit test.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-05 11:25:01 +02:00
co-authored by Claude Opus 4.8
parent f8f03faa1d
commit 525b684493
3 changed files with 240 additions and 97 deletions
File diff suppressed because one or more lines are too long
+77 -12
View File
@@ -527,8 +527,9 @@ function initValidation() {
var firstInvalid = null;
// ≥1 photo (photos are the first field). Count any present FilePond item.
// Skipped in edit mode (KTD9): photos are untouched in M1, so an empty
// FilePond on an edit submit keeps the entry's existing images.
// Skipped in edit mode (U7): the edit form loads the entry's existing
// photos, and the owner may deliberately remove all of them to leave a
// text-only entry — so an empty FilePond on an edit submit is allowed.
if (!EDIT_MODE && document.querySelectorAll('.filepond--item').length < 1) {
firstInvalid = showPhotoError('Add at least one photo.');
}
@@ -687,14 +688,21 @@ function initSuccessState() {
notice.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
/* ── Edit mode (U5, KTD4/KTD9): prefill from the API, adapt the form ──────────
/* ── Edit mode (U5, KTD4; U7 photos): 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).
* overwrite_mode:edit server-side), and switch the chrome to "Edit entry" /
* "Save changes" (D6).
*
* Photos (M2/U7): the entry's existing images are loaded into FilePond as LOCAL
* items (already on the server — not re-uploaded on save) so the owner can drop,
* add and reorder them. On submit their filenames ride the same `photo_order`
* manifest as new uploads; cache-on-save reconciles the entry folder to match
* (delete dropped, renumber survivors photo-1..N, first = cover — U8). The
* >=1-photo rule stays relaxed in edit mode so an owner may leave a text-only
* entry after removing every photo.
*/
var EDIT_MODE = false;
@@ -726,9 +734,19 @@ function editSetContent(value) {
// Disable/enable the form's own fields + submit (get-location/weather live
// OUTSIDE the form, so they're untouched). Also gates the EasyMDE editor.
//
// The photos/FilePond control is deliberately EXCLUDED: FilePond reads the
// disabled state of its underlying input when the form plugin creates it and
// never re-enables (removing its browse button, so the owner can't add photos on
// edit — U7). Photos are also safe to leave live during the D1 prefill window:
// they load additively (editLoadPhotos), not by overwrite, so early interaction
// can't be clobbered the way an empty text field could.
function editFormDisabled(form, disabled) {
var els = form.querySelectorAll('input, textarea, select, button');
Array.prototype.forEach.call(els, function (el) { el.disabled = disabled; });
Array.prototype.forEach.call(els, function (el) {
if (el.type === 'file' || (el.name && el.name.indexOf('data[photos') === 0)) return;
el.disabled = disabled;
});
if (window.postFormEditor && window.postFormEditor.codemirror) {
window.postFormEditor.codemirror.setOption('readOnly', disabled ? 'nocursor' : false);
}
@@ -750,6 +768,53 @@ function editShowError(wrap, msg) {
if (h1) h1.insertAdjacentElement('afterend', banner); else wrap.insertBefore(banner, wrap.firstChild);
}
// Poll for the managed FilePond instance (created by the form plugin's handler on
// DOMContentLoaded) — the same instance initPhotoConversion hooks — then run cb.
function editWaitForPond(cb, tries) {
tries = tries || 0;
var ponds = (window.GravFilePond && window.GravFilePond.getInstances)
? window.GravFilePond.getInstances() : [];
if (ponds.length && ponds[0]) { cb(ponds[0]); return; }
if (tries < 120) setTimeout(function () { editWaitForPond(cb, tries + 1); }, 50);
}
// U7: load the entry's current photos into FilePond as LOCAL items. Local files
// already live on the server, so FilePond shows them for reorder/removal but does
// NOT re-upload them on save. Their filenames sort to folder order (first = cover)
// and ride the `photo_order` manifest on submit; cache-on-save reconciles the
// folder to match (U8). A broken media URL is skipped, never aborting the rest.
//
// Adding NEW photos on edit (R9) is intentionally suppressed here (allowBrowse /
// allowDrop off) — a new upload on the edit save goes through add-page-by-form's
// edit-mode file merge, which fatals on Grav 2.0 (`(array)$page->header()` yields
// mangled protected-property keys, so `$original_frontmatter['photos']` is never
// set → array_merge(null,…) TypeError). That's a stock, GPM-managed plugin we
// must not fork, so add-photo-on-edit is deferred to the form-to-page/image-upload
// rework. Remove + reorder (which never upload) work and are what M2 ships.
function editLoadPhotos(route) {
fetch('/api/v1/pages' + route + '/media', { credentials: 'include', headers: { Accept: 'application/json' } })
.then(function (r) { return r.ok ? r.json() : { data: [] }; })
.then(function (json) {
var media = (json && json.data) || [];
var images = media.filter(function (m) {
return m && typeof m.filename === 'string' && /\.(jpe?g|png|gif|webp|heic|heif)$/i.test(m.filename);
}).sort(function (a, b) {
return a.filename < b.filename ? -1 : (a.filename > b.filename ? 1 : 0);
});
editWaitForPond(function (pond) {
// Keep remove + reorder; drop the add affordance (see note above).
try { pond.setOptions({ allowBrowse: false, allowDrop: false, allowReorder: true }); } catch (e) { /* older API */ }
images.forEach(function (m) {
try {
var p = pond.addFile(route + '/' + m.filename, { type: 'local' });
if (p && typeof p.catch === 'function') p.catch(function () { /* skip a broken URL */ });
} catch (e) { /* older FilePond API — skip */ }
});
});
})
.catch(function () { /* no existing photos loaded — owner can still save */ });
}
function initEditMode() {
var form = document.querySelector('form[name="new-entry"]');
var wrap = document.querySelector('.post-form-wrap');
@@ -764,11 +829,9 @@ function initEditMode() {
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';
// U7: the photos section stays visible in edit mode — existing photos are
// loaded into FilePond below once the prefill resolves. The >=1-photo rule is
// skipped while EDIT_MODE (see initValidation) so removing every photo is OK.
// D1: no typing before prefill lands — disable + loading label.
editFormDisabled(form, true);
@@ -807,6 +870,8 @@ function initEditMode() {
var more = form.querySelector('.more-options');
if (more) more.open = true; // reveal Published/Featured/Connector
editLoadPhotos(route); // U7: pull the entry's existing photos into FilePond
})
.catch(function () {
// D7: inline error between heading and first field; keep the form