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:
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -336,3 +336,146 @@
|
||||
color: var(--color-ink);
|
||||
}
|
||||
.EasyMDEContainer .editor-preview a { color: var(--color-accent); }
|
||||
|
||||
/* ── Photo editor (edit mode only) ────────────────────────────────────────
|
||||
Own thumbnail grid that talks straight to the media API (add/delete/reorder
|
||||
live), replacing the FilePond photo path when editing. */
|
||||
.photo-editor {
|
||||
margin-bottom: var(--space-5);
|
||||
}
|
||||
.photo-editor__head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-3);
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
.photo-editor__label {
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--text-base);
|
||||
font-weight: 600;
|
||||
color: var(--color-ink);
|
||||
}
|
||||
.photo-editor__add {
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--text-sm);
|
||||
background: var(--color-accent);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
padding: var(--space-2) var(--space-4);
|
||||
cursor: pointer;
|
||||
}
|
||||
.photo-editor__add:disabled { opacity: 0.5; cursor: default; }
|
||||
|
||||
.photo-editor__status {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--color-ink-muted);
|
||||
min-height: 1.2em;
|
||||
margin: 0 0 var(--space-2);
|
||||
}
|
||||
.photo-editor__status.error { color: var(--color-error); }
|
||||
|
||||
.photo-editor__grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: var(--space-2);
|
||||
}
|
||||
@media (min-width: 600px) {
|
||||
.photo-editor__grid { grid-template-columns: repeat(4, 1fr); }
|
||||
}
|
||||
.photo-editor__loading,
|
||||
.photo-editor__empty {
|
||||
grid-column: 1 / -1;
|
||||
color: var(--color-ink-muted);
|
||||
font-size: var(--text-sm);
|
||||
padding: var(--space-4) 0;
|
||||
}
|
||||
|
||||
.photo-editor__cell {
|
||||
position: relative;
|
||||
aspect-ratio: 1 / 1;
|
||||
border-radius: var(--radius-sm);
|
||||
overflow: hidden;
|
||||
background: var(--color-surface-raised);
|
||||
cursor: grab;
|
||||
touch-action: none; /* let SortableJS own the touch-drag gesture */
|
||||
}
|
||||
.photo-editor__cell:active { cursor: grabbing; }
|
||||
.photo-editor__img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
-webkit-user-drag: none;
|
||||
user-select: none;
|
||||
pointer-events: none; /* the cell, not the image, is the drag handle */
|
||||
}
|
||||
/* First thumbnail is the feed cover. */
|
||||
.photo-editor__cell:first-child::after {
|
||||
content: 'Cover';
|
||||
position: absolute;
|
||||
left: var(--space-1);
|
||||
bottom: var(--space-1);
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--text-xs, 0.7rem);
|
||||
font-weight: 600;
|
||||
color: #fff;
|
||||
background: var(--color-accent);
|
||||
padding: 1px 6px;
|
||||
border-radius: var(--radius-sm);
|
||||
pointer-events: none;
|
||||
}
|
||||
.photo-editor__del {
|
||||
position: absolute;
|
||||
top: var(--space-1);
|
||||
right: var(--space-1);
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
line-height: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
color: #fff;
|
||||
font-size: 0.85rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
.photo-editor__del:hover { background: rgba(0, 0, 0, 0.75); }
|
||||
.photo-editor__del:disabled { opacity: 0.4; cursor: default; }
|
||||
|
||||
/* Inline delete confirm — covers the cell (option a: live delete, deliberate step). */
|
||||
.photo-editor__confirm {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--space-1);
|
||||
padding: var(--space-2);
|
||||
background: rgba(0, 0, 0, 0.72);
|
||||
text-align: center;
|
||||
}
|
||||
.photo-editor__confirm-q {
|
||||
color: #fff;
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
.photo-editor__confirm button {
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--text-xs, 0.72rem);
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 3px 10px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.photo-editor__confirm-yes { background: var(--color-error); color: #fff; }
|
||||
.photo-editor__confirm-no { background: #fff; color: var(--color-ink); }
|
||||
.photo-editor__confirm button:disabled { opacity: 0.5; cursor: default; }
|
||||
|
||||
/* SortableJS drag feedback. */
|
||||
.photo-editor__cell.sortable-ghost { opacity: 0.4; }
|
||||
.photo-editor__cell.sortable-chosen { outline: 2px solid var(--color-accent); }
|
||||
|
||||
@@ -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('Couldn’t 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('Couldn’t 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' : '') + ' couldn’t be added.') : '', !!failed);
|
||||
}).catch(function (err) {
|
||||
setStatus(err && err.rolledBack
|
||||
? 'Couldn’t finish adding photos — changes were rolled back. Try again.'
|
||||
: 'Couldn’t 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('Couldn’t 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
|
||||
|
||||
Generated
+8
-1
@@ -12,7 +12,8 @@
|
||||
"heic-to": "^1",
|
||||
"maplibre-gl": "^4",
|
||||
"photoswipe": "^5",
|
||||
"scrollama": "^3"
|
||||
"scrollama": "^3",
|
||||
"sortablejs": "^1.15.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"esbuild": "^0.21",
|
||||
@@ -984,6 +985,12 @@
|
||||
"integrity": "sha512-PIPwB1kYBnbw/ezvPBJa5dCN5qEwokfpAkI3BmpZWAwcVID4nDf1qH6WV16A2fQaJmsKx0un5S/zhxN+PQeKDQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/sortablejs": {
|
||||
"version": "1.15.7",
|
||||
"resolved": "https://registry.npmjs.org/sortablejs/-/sortablejs-1.15.7.tgz",
|
||||
"integrity": "sha512-Kk8wLQPlS+yi1ZEf48a4+fzHa4yxjC30M/Sr2AnQu+f/MPwvvX9XjZ6OWejiz8crBsLwSq8GHqaxaET7u6ux0A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/string_decoder": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
|
||||
|
||||
@@ -11,7 +11,8 @@
|
||||
"heic-to": "^1",
|
||||
"maplibre-gl": "^4",
|
||||
"photoswipe": "^5",
|
||||
"scrollama": "^3"
|
||||
"scrollama": "^3",
|
||||
"sortablejs": "^1.15.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"esbuild": "^0.21",
|
||||
|
||||
Reference in New Issue
Block a user