feat(post-form): live photo editor on entry edit (media API + SortableJS)

Replace the FilePond photo path in edit mode with our own thumbnail grid that
talks straight to the media API (the gpx-manager pattern). Add/delete/reorder
each persist immediately, decoupled from the form's text-field Save:

- Add: HEIC->JPEG client-side, stock POST .../media per file, then ONE reorder
  after the batch (renumber photo-01..NN). On a failed reorder: auto-retry
  (idempotent), else roll the just-uploaded files back so no orphan stock-named
  image breaks cover=first. Upload progress shown per file.
- Delete: inline 'Delete? [Confirm] [Cancel]' (Confirm disabled in flight),
  stock DELETE, then renumber the survivors.
- Reorder: SortableJS drag -> POST /entry/<slug>/photos/order. On failure the
  move reverts to last-known-good; the shown grid never disagrees with disk
  without an inline error.
- Loading + empty states; first cell badged Cover; photo-NN URLs cache-busted
  since reorder reuses them for different bytes.

FilePond is fully decommissioned in edit mode (initPhotoConversion early-returns
under EDIT_MODE): no stale photo_order manifest is posted on text Save, so
cache-on-save can't delete a live-added photo. Create-mode FilePond is untouched.

Adds sortablejs (bundled into js/post via the post-form entry). SVG excluded in
the file-input accept; the server-side SVG block is a documented fast-follow.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-05 19:19:57 +02:00
co-authored by Claude Opus 4.8
parent 4fea522d5c
commit a4432d897e
6 changed files with 554 additions and 149 deletions
+309 -65
View File
@@ -8,6 +8,7 @@
* initTripStats pattern, so this is a no-op if an element is absent.
*/
import EasyMDE from 'easymde';
import Sortable from 'sortablejs';
import 'easymde/dist/easymde.min.css';
import './post-form.css';
@@ -135,6 +136,15 @@ function initPhotoConversion() {
var form = document.querySelector('form[name="new-entry"]');
if (!form) return;
// Edit mode runs its OWN live photo editor (initPhotoEditor) against the media
// API and decommissions FilePond entirely — no collapse, no HEIC-into-FilePond
// hooks, and crucially no `photo_order` submit handler. A stale photo_order
// posted on text Save would drive cache-on-save.reconcilePhotos() →
// deleteUnlistedImages() and silently delete any photo added live after the
// page opened. Skipping the wiring here (FilePond left unpopulated) means no
// manifest is posted, so the live-managed folder is left untouched on Save.
if (EDIT_MODE) return;
var converting = 0; // HEIC conversions in flight (before FilePond) — gates Submit
var collapse = buildPhotoCollapse();
var orderPond = null; // set when the managed FilePond instance is found
@@ -696,13 +706,11 @@ function initSuccessState() {
* 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.
* Photos: FilePond is decommissioned in edit mode (see initPhotoConversion's
* EDIT_MODE guard). Instead initPhotoEditor renders our own thumbnail grid from
* the media API and persists add/delete/reorder LIVE — decoupled from this form's
* text-field Save. 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;
@@ -768,67 +776,303 @@ 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);
/* ── Photo editor (edit mode): live add / delete / reorder via the media API ───
* Replaces the FilePond photo path in edit mode with our own thumbnail grid (the
* gpx-manager pattern): add + delete hit the STOCK media API and reorder hits the
* custom /entry/{slug}/photos/order route; each op persists IMMEDIATELY, decoupled
* from the form's text-field Save. SortableJS provides the drag FilePond couldn't
* do reliably here. After every mutation the entry is renumbered photo-01..NN
* server-side so the feed cover (media.images|first) follows the arranged order.
*
* Every op's failure path shows an inline error and never lets the shown grid
* disagree with disk without saying so: a failed reorder reverts the SortableJS
* move to the last-known-good order; a failed add rolls its uploads back.
*/
var PHOTO_EXT_RE = /\.(jpe?g|png|webp|heic|heif|gif)$/i;
// Numeric index behind a photo-NN filename (zero-padding tolerant); non-photo
// names (freshly stock-uploaded, not yet renumbered) sort last, then by name.
function photoIndexOf(name) {
var m = /(?:^|\/)photo-0*(\d+)\./i.exec(name);
return m ? parseInt(m[1], 10) : Number.MAX_SAFE_INTEGER;
}
function byPhotoOrder(a, b) {
var ia = photoIndexOf(a), ib = photoIndexOf(b);
if (ia !== ib) return ia - ib;
return a < b ? -1 : (a > b ? 1 : 0);
}
// U7/R9: load the entry's current photos into FilePond as LOCAL items (already on
// the server — shown for reorder/removal, never re-uploaded). Adding new photos on
// edit is enabled (allowBrowse/allowDrop): on submit, existing filenames + any new
// uploads ride the `photo_order` manifest and cache-on-save reconciles the folder
// (delete dropped, renumber survivors photo-1..N, first = cover). A broken media
// URL is skipped, never aborting the rest.
//
// Add-on-edit depends on a local patch to add-page-by-form (stock GPM plugin's
// edit-mode file merge fatals on Grav 2.0's Header object — see the patch note in
// add-page-by-form.php). That patch is git-ignored, so it must be re-applied on a
// fresh plugin install until upstream is forked.
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);
// A throwaway, collision-proof upload name. The file is renumbered to photo-NN
// immediately after the batch, so this name only has to be unique enough not to
// clobber an existing photo-NN or a same-named sibling in the same add batch.
function uploadName(origName, forceJpeg) {
var s = String(origName || 'photo');
var dot = s.lastIndexOf('.');
var base = (dot > 0 ? s.slice(0, dot) : s).toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '') || 'photo';
var ext = forceJpeg ? '.jpg' : (dot > 0 ? s.slice(dot).toLowerCase() : '.jpg');
var uniq = Date.now().toString(36) + Math.random().toString(36).slice(2, 6);
return base + '-' + uniq + ext;
}
function initPhotoEditor(route) {
var form = document.querySelector('form[name="new-entry"]');
if (!form || !route) return;
// The reorder route keys on the entry's folder segment (last path segment).
var slug = route.split('/').filter(Boolean).pop();
if (!slug) return;
// Decommission the (hidden, unpopulated) FilePond section entirely.
var fpRoot = document.querySelector('.filepond-root, .form-input-file');
var fpField = fpRoot ? fpRoot.closest('.form-field') : null;
if (fpField) fpField.style.display = 'none';
var legacyCollapse = form.querySelector('.photos-collapse');
if (legacyCollapse) legacyCollapse.style.display = 'none';
// Build the editor and place it at the top of the form (photos come first).
var host = document.createElement('div');
host.className = 'photo-editor';
host.innerHTML =
'<div class="photo-editor__head">' +
'<span class="photo-editor__label">Photos</span>' +
'<button type="button" class="photo-editor__add">Add photos</button>' +
'</div>' +
'<p class="photo-editor__status gpx-status" role="status"></p>' +
'<div class="photo-editor__grid" aria-live="polite"></div>' +
// SVG intentionally excluded (server-side block is a documented fast-follow).
'<input type="file" class="photo-editor__input" multiple hidden ' +
'accept="image/jpeg,image/png,image/webp,.jpg,.jpeg,.png,.webp,.heic,.heif">';
if (fpField && fpField.parentNode) fpField.parentNode.insertBefore(host, fpField);
else form.insertBefore(host, form.firstElementChild);
var grid = host.querySelector('.photo-editor__grid');
var statusEl = host.querySelector('.photo-editor__status');
var addBtn = host.querySelector('.photo-editor__add');
var fileInput = host.querySelector('.photo-editor__input');
var photos = []; // current filenames, in display order
var sortable = null;
var busy = false;
var renderNonce = 0; // cache-buster: photo-NN URLs are reused across reorders
function setStatus(msg, isError) {
statusEl.textContent = msg || '';
statusEl.className = 'photo-editor__status gpx-status' + (isError ? ' error' : '');
}
function setBusy(b) {
busy = b;
addBtn.disabled = b;
Array.prototype.forEach.call(
grid.querySelectorAll('.photo-editor__del, .photo-editor__confirm-yes, .photo-editor__confirm-no'),
function (el) { el.disabled = b; }
);
if (sortable) sortable.option('disabled', b);
}
// Boolean-ok fetch (204 counts as ok for DELETE). Cookies auto-included.
function apiOk(url, opts) {
return fetch(url, Object.assign({ credentials: 'include' }, opts))
.then(function (r) { return r.ok || r.status === 204; });
}
function mediaList() {
return fetch('/api/v1/pages' + route + '/media', { credentials: 'include', headers: { Accept: 'application/json' } })
.then(function (r) { if (!r.ok) throw new Error('HTTP ' + r.status); return r.json(); })
.then(function (j) {
return ((j && j.data) || [])
.filter(function (m) { return m && typeof m.filename === 'string' && PHOTO_EXT_RE.test(m.filename); })
.map(function (m) { return m.filename; })
.sort(byPhotoOrder);
});
editWaitForPond(function (pond) {
try { pond.setOptions({ allowBrowse: true, allowDrop: true, allowReorder: true }); } catch (e) { /* older API */ }
// Fetch each image as a real Blob and add it as a File. Passing a
// plain URL to addFile({type:'local'}) routes through the form
// plugin's FilePond server.load, which returns HTML here (not the
// image bytes) — so the image-preview plugin had nothing to render
// and items showed as a bare filename. A real File gives FilePond
// the image data → thumbnail renders. type:'local' still means it
// is never re-uploaded; its filename rides the photo_order manifest.
Promise.all(images.map(function (m) {
return fetch(route + '/' + m.filename, { credentials: 'include' })
.then(function (r) { return r.ok ? r.blob() : null; })
.then(function (blob) {
return blob ? new File([blob], m.filename, { type: blob.type || 'image/jpeg' }) : null;
})
.catch(function () { return null; });
})).then(function (files) {
// Add in the sorted (cover) order; index keeps FilePond's list
// ordered even though the fetches resolve concurrently.
files.forEach(function (file, i) {
if (!file) return;
try {
var p = pond.addFile(file, { type: 'local', index: i });
if (p && typeof p.catch === 'function') p.catch(function () { /* skip */ });
} catch (e) { /* older FilePond API — skip */ }
});
});
}
function reorder(order) {
return apiOk('/api/v1/entry/' + encodeURIComponent(slug) + '/photos/order', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify({ order: order })
}).then(function (ok) { if (!ok) throw new Error('reorder failed'); });
}
function render(list) {
photos = (list || []).slice();
if (sortable) { sortable.destroy(); sortable = null; }
grid.innerHTML = '';
if (!photos.length) {
grid.innerHTML = '<p class="photo-editor__empty">No photos yet — add some.</p>';
return;
}
var v = ++renderNonce;
photos.forEach(function (name) {
var cell = document.createElement('div');
cell.className = 'photo-editor__cell';
cell.setAttribute('data-filename', name);
var img = document.createElement('img');
img.className = 'photo-editor__img';
img.loading = 'lazy';
img.alt = name;
// Reorder reuses photo-NN URLs for different bytes; bust the cache.
img.src = route + '/' + encodeURIComponent(name) + '?v=' + v;
var del = document.createElement('button');
del.type = 'button';
del.className = 'photo-editor__del';
del.setAttribute('aria-label', 'Delete ' + name);
del.textContent = '✕';
del.addEventListener('click', function () { confirmDelete(cell, name); });
cell.appendChild(img);
cell.appendChild(del);
grid.appendChild(cell);
});
if (photos.length > 1) {
sortable = new Sortable(grid, {
animation: 150,
draggable: '.photo-editor__cell',
filter: '.photo-editor__confirm',
onEnd: applyReorder
});
})
.catch(function () { /* no existing photos loaded — owner can still save */ });
}
}
function currentDomOrder() {
return Array.prototype.map.call(grid.querySelectorAll('.photo-editor__cell'),
function (c) { return c.getAttribute('data-filename'); });
}
function sameOrder(a, b) {
if (a.length !== b.length) return false;
for (var i = 0; i < a.length; i++) { if (a[i] !== b[i]) return false; }
return true;
}
function applyReorder() {
var next = currentDomOrder();
if (sameOrder(next, photos)) return; // dropped in place
var lastGood = photos.slice();
setBusy(true);
setStatus('Saving order…');
reorder(next)
.then(function () { return mediaList(); })
.then(function (list) { setStatus(''); render(list); })
.catch(function () {
setStatus('Couldnt save the new order — reverted. Try again.', true);
render(lastGood); // revert the SortableJS move to last-known-good
})
.then(function () { setBusy(false); });
}
// ✕ swaps the cell to an inline "Delete? [Confirm] [Cancel]" (option a).
function confirmDelete(cell, name) {
if (busy || cell.querySelector('.photo-editor__confirm')) return;
var overlay = document.createElement('div');
overlay.className = 'photo-editor__confirm';
overlay.innerHTML =
'<span class="photo-editor__confirm-q">Delete?</span>' +
'<button type="button" class="photo-editor__confirm-yes">Confirm</button>' +
'<button type="button" class="photo-editor__confirm-no">Cancel</button>';
cell.appendChild(overlay);
cell.classList.add('is-confirming');
overlay.querySelector('.photo-editor__confirm-no').addEventListener('click', function () {
cell.classList.remove('is-confirming');
overlay.remove();
});
overlay.querySelector('.photo-editor__confirm-yes').addEventListener('click', function () {
overlay.querySelector('.photo-editor__confirm-yes').disabled = true;
overlay.querySelector('.photo-editor__confirm-no').disabled = true;
doDelete(name);
});
}
function doDelete(name) {
var lastGood = photos.slice();
setBusy(true);
setStatus('Deleting…');
apiOk('/api/v1/pages' + route + '/media/' + encodeURIComponent(name), { method: 'DELETE' })
.then(function (ok) {
if (!ok) throw new Error('delete failed');
// Renumber the survivors so cover=first stays correct (idempotent).
var remaining = photos.filter(function (p) { return p !== name; });
return (remaining.length ? reorder(remaining) : Promise.resolve()).then(mediaList);
})
.then(function (list) { setStatus(''); render(list); })
.catch(function () {
setStatus('Couldnt delete that photo. Try again.', true);
render(lastGood);
})
.then(function () { setBusy(false); });
}
// HEIC→JPEG (reusing the sniff + lazy heic-to converter) else pass through.
function toWebSafe(file) {
return looksLikeHeic(file).then(function (isHeic) {
if (!isHeic) return { blob: file, name: uploadName(file.name, false) };
return import('heic-to').then(function (mod) {
var heicTo = mod.heicTo || (mod.default && mod.default.heicTo);
return heicTo({ blob: file, type: 'image/jpeg', quality: 0.85 });
}).then(function (jpeg) { return { blob: jpeg, name: uploadName(file.name, true) }; });
});
}
addBtn.addEventListener('click', function () { if (!busy) fileInput.click(); });
fileInput.addEventListener('change', function () {
var files = Array.prototype.slice.call(fileInput.files || []);
fileInput.value = '';
if (files.length) addFiles(files);
});
function addFiles(files) {
setBusy(true);
var total = files.length, done = 0, failed = 0;
var before = photos.slice();
var seq = Promise.resolve();
files.forEach(function (file) {
seq = seq.then(function () {
done++;
setStatus('Uploading ' + done + ' of ' + total + '…');
return toWebSafe(file).then(function (prep) {
var fd = new FormData();
fd.append('file', prep.blob, prep.name);
return apiOk('/api/v1/pages' + route + '/media', { method: 'POST', body: fd });
}).then(function (ok) { if (!ok) failed++; }, function () { failed++; });
});
});
seq.then(function () {
setStatus('Finishing…');
return mediaList(); // authoritative set (includes new stock-named files)
}).then(function (current) {
var kept = before.filter(function (n) { return current.indexOf(n) !== -1; });
var added = current.filter(function (n) { return before.indexOf(n) === -1; });
var order = kept.concat(added);
if (!order.length) { render([]); return null; }
// One renumber pass after the whole batch. If it fails, auto-retry
// (idempotent — renumber skips files not on disk); if it still fails,
// roll the just-added files back so no orphan stock-named image breaks
// cover=first, then surface a single error.
return reorder(order)
.catch(function () { return reorder(order); })
.catch(function () {
return Promise.all(added.map(function (n) {
return apiOk('/api/v1/pages' + route + '/media/' + encodeURIComponent(n), { method: 'DELETE' }).catch(function () {});
})).then(function () { var e = new Error('reorder failed'); e.rolledBack = true; throw e; });
})
.then(mediaList);
}).then(function (list) {
if (list) render(list);
setStatus(failed ? (failed + ' photo' + (failed > 1 ? 's' : '') + ' couldnt be added.') : '', !!failed);
}).catch(function (err) {
setStatus(err && err.rolledBack
? 'Couldnt finish adding photos — changes were rolled back. Try again.'
: 'Couldnt add photos. Please try again.', true);
return mediaList().then(render, function () { render(before); });
}).then(function () { setBusy(false); });
}
// Initial load.
grid.innerHTML = '<p class="photo-editor__loading">Loading photos…</p>';
mediaList().then(render).catch(function () {
setStatus('Couldnt load photos.', true);
render([]);
});
}
function initEditMode() {
@@ -887,7 +1131,7 @@ 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
initPhotoEditor(route); // live add/delete/reorder via the media API
})
.catch(function (err) {
// D7: inline error between heading and first field; keep the form