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);
|
color: var(--color-ink);
|
||||||
}
|
}
|
||||||
.EasyMDEContainer .editor-preview a { color: var(--color-accent); }
|
.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.
|
* initTripStats pattern, so this is a no-op if an element is absent.
|
||||||
*/
|
*/
|
||||||
import EasyMDE from 'easymde';
|
import EasyMDE from 'easymde';
|
||||||
|
import Sortable from 'sortablejs';
|
||||||
import 'easymde/dist/easymde.min.css';
|
import 'easymde/dist/easymde.min.css';
|
||||||
import './post-form.css';
|
import './post-form.css';
|
||||||
|
|
||||||
@@ -135,6 +136,15 @@ function initPhotoConversion() {
|
|||||||
var form = document.querySelector('form[name="new-entry"]');
|
var form = document.querySelector('form[name="new-entry"]');
|
||||||
if (!form) return;
|
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 converting = 0; // HEIC conversions in flight (before FilePond) — gates Submit
|
||||||
var collapse = buildPhotoCollapse();
|
var collapse = buildPhotoCollapse();
|
||||||
var orderPond = null; // set when the managed FilePond instance is found
|
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" /
|
* overwrite_mode:edit server-side), and switch the chrome to "Edit entry" /
|
||||||
* "Save changes" (D6).
|
* "Save changes" (D6).
|
||||||
*
|
*
|
||||||
* Photos (M2/U7): the entry's existing images are loaded into FilePond as LOCAL
|
* Photos: FilePond is decommissioned in edit mode (see initPhotoConversion's
|
||||||
* items (already on the server — not re-uploaded on save) so the owner can drop,
|
* EDIT_MODE guard). Instead initPhotoEditor renders our own thumbnail grid from
|
||||||
* add and reorder them. On submit their filenames ride the same `photo_order`
|
* the media API and persists add/delete/reorder LIVE — decoupled from this form's
|
||||||
* manifest as new uploads; cache-on-save reconciles the entry folder to match
|
* text-field Save. The >=1-photo rule stays relaxed in edit mode so an owner may
|
||||||
* (delete dropped, renumber survivors photo-1..N, first = cover — U8). The
|
* leave a text-only entry after removing every photo.
|
||||||
* >=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;
|
var EDIT_MODE = false;
|
||||||
|
|
||||||
@@ -768,67 +776,303 @@ function editShowError(wrap, msg) {
|
|||||||
if (h1) h1.insertAdjacentElement('afterend', banner); else wrap.insertBefore(banner, wrap.firstChild);
|
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
|
/* ── Photo editor (edit mode): live add / delete / reorder via the media API ───
|
||||||
// DOMContentLoaded) — the same instance initPhotoConversion hooks — then run cb.
|
* Replaces the FilePond photo path in edit mode with our own thumbnail grid (the
|
||||||
function editWaitForPond(cb, tries) {
|
* gpx-manager pattern): add + delete hit the STOCK media API and reorder hits the
|
||||||
tries = tries || 0;
|
* custom /entry/{slug}/photos/order route; each op persists IMMEDIATELY, decoupled
|
||||||
var ponds = (window.GravFilePond && window.GravFilePond.getInstances)
|
* from the form's text-field Save. SortableJS provides the drag FilePond couldn't
|
||||||
? window.GravFilePond.getInstances() : [];
|
* do reliably here. After every mutation the entry is renumbered photo-01..NN
|
||||||
if (ponds.length && ponds[0]) { cb(ponds[0]); return; }
|
* server-side so the feed cover (media.images|first) follows the arranged order.
|
||||||
if (tries < 120) setTimeout(function () { editWaitForPond(cb, tries + 1); }, 50);
|
*
|
||||||
|
* 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
|
// A throwaway, collision-proof upload name. The file is renumbered to photo-NN
|
||||||
// the server — shown for reorder/removal, never re-uploaded). Adding new photos on
|
// immediately after the batch, so this name only has to be unique enough not to
|
||||||
// edit is enabled (allowBrowse/allowDrop): on submit, existing filenames + any new
|
// clobber an existing photo-NN or a same-named sibling in the same add batch.
|
||||||
// uploads ride the `photo_order` manifest and cache-on-save reconciles the folder
|
function uploadName(origName, forceJpeg) {
|
||||||
// (delete dropped, renumber survivors photo-1..N, first = cover). A broken media
|
var s = String(origName || 'photo');
|
||||||
// URL is skipped, never aborting the rest.
|
var dot = s.lastIndexOf('.');
|
||||||
//
|
var base = (dot > 0 ? s.slice(0, dot) : s).toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '') || 'photo';
|
||||||
// Add-on-edit depends on a local patch to add-page-by-form (stock GPM plugin's
|
var ext = forceJpeg ? '.jpg' : (dot > 0 ? s.slice(dot).toLowerCase() : '.jpg');
|
||||||
// edit-mode file merge fatals on Grav 2.0's Header object — see the patch note in
|
var uniq = Date.now().toString(36) + Math.random().toString(36).slice(2, 6);
|
||||||
// add-page-by-form.php). That patch is git-ignored, so it must be re-applied on a
|
return base + '-' + uniq + ext;
|
||||||
// fresh plugin install until upstream is forked.
|
}
|
||||||
function editLoadPhotos(route) {
|
|
||||||
fetch('/api/v1/pages' + route + '/media', { credentials: 'include', headers: { Accept: 'application/json' } })
|
function initPhotoEditor(route) {
|
||||||
.then(function (r) { return r.ok ? r.json() : { data: [] }; })
|
var form = document.querySelector('form[name="new-entry"]');
|
||||||
.then(function (json) {
|
if (!form || !route) return;
|
||||||
var media = (json && json.data) || [];
|
// The reorder route keys on the entry's folder segment (last path segment).
|
||||||
var images = media.filter(function (m) {
|
var slug = route.split('/').filter(Boolean).pop();
|
||||||
return m && typeof m.filename === 'string' && /\.(jpe?g|png|gif|webp|heic|heif)$/i.test(m.filename);
|
if (!slug) return;
|
||||||
}).sort(function (a, b) {
|
|
||||||
return a.filename < b.filename ? -1 : (a.filename > b.filename ? 1 : 0);
|
// 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
|
function reorder(order) {
|
||||||
// plain URL to addFile({type:'local'}) routes through the form
|
return apiOk('/api/v1/entry/' + encodeURIComponent(slug) + '/photos/order', {
|
||||||
// plugin's FilePond server.load, which returns HTML here (not the
|
method: 'POST',
|
||||||
// image bytes) — so the image-preview plugin had nothing to render
|
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
||||||
// and items showed as a bare filename. A real File gives FilePond
|
body: JSON.stringify({ order: order })
|
||||||
// the image data → thumbnail renders. type:'local' still means it
|
}).then(function (ok) { if (!ok) throw new Error('reorder failed'); });
|
||||||
// is never re-uploaded; its filename rides the photo_order manifest.
|
}
|
||||||
Promise.all(images.map(function (m) {
|
|
||||||
return fetch(route + '/' + m.filename, { credentials: 'include' })
|
function render(list) {
|
||||||
.then(function (r) { return r.ok ? r.blob() : null; })
|
photos = (list || []).slice();
|
||||||
.then(function (blob) {
|
if (sortable) { sortable.destroy(); sortable = null; }
|
||||||
return blob ? new File([blob], m.filename, { type: blob.type || 'image/jpeg' }) : 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
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
})
|
})
|
||||||
.catch(function () { return null; });
|
.then(function () { setBusy(false); });
|
||||||
})).then(function (files) {
|
}
|
||||||
// Add in the sorted (cover) order; index keeps FilePond's list
|
|
||||||
// ordered even though the fetches resolve concurrently.
|
// ✕ swaps the cell to an inline "Delete? [Confirm] [Cancel]" (option a).
|
||||||
files.forEach(function (file, i) {
|
function confirmDelete(cell, name) {
|
||||||
if (!file) return;
|
if (busy || cell.querySelector('.photo-editor__confirm')) return;
|
||||||
try {
|
var overlay = document.createElement('div');
|
||||||
var p = pond.addFile(file, { type: 'local', index: i });
|
overlay.className = 'photo-editor__confirm';
|
||||||
if (p && typeof p.catch === 'function') p.catch(function () { /* skip */ });
|
overlay.innerHTML =
|
||||||
} catch (e) { /* older FilePond API — skip */ }
|
'<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);
|
||||||
})
|
})
|
||||||
.catch(function () { /* no existing photos loaded — owner can still save */ });
|
.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() {
|
function initEditMode() {
|
||||||
@@ -887,7 +1131,7 @@ function initEditMode() {
|
|||||||
var more = form.querySelector('.more-options');
|
var more = form.querySelector('.more-options');
|
||||||
if (more) more.open = true; // reveal Published/Featured/Connector
|
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) {
|
.catch(function (err) {
|
||||||
// D7: inline error between heading and first field; keep the form
|
// D7: inline error between heading and first field; keep the form
|
||||||
|
|||||||
Generated
+8
-1
@@ -12,7 +12,8 @@
|
|||||||
"heic-to": "^1",
|
"heic-to": "^1",
|
||||||
"maplibre-gl": "^4",
|
"maplibre-gl": "^4",
|
||||||
"photoswipe": "^5",
|
"photoswipe": "^5",
|
||||||
"scrollama": "^3"
|
"scrollama": "^3",
|
||||||
|
"sortablejs": "^1.15.7"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"esbuild": "^0.21",
|
"esbuild": "^0.21",
|
||||||
@@ -984,6 +985,12 @@
|
|||||||
"integrity": "sha512-PIPwB1kYBnbw/ezvPBJa5dCN5qEwokfpAkI3BmpZWAwcVID4nDf1qH6WV16A2fQaJmsKx0un5S/zhxN+PQeKDQ==",
|
"integrity": "sha512-PIPwB1kYBnbw/ezvPBJa5dCN5qEwokfpAkI3BmpZWAwcVID4nDf1qH6WV16A2fQaJmsKx0un5S/zhxN+PQeKDQ==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/string_decoder": {
|
||||||
"version": "1.3.0",
|
"version": "1.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
|
||||||
|
|||||||
@@ -11,7 +11,8 @@
|
|||||||
"heic-to": "^1",
|
"heic-to": "^1",
|
||||||
"maplibre-gl": "^4",
|
"maplibre-gl": "^4",
|
||||||
"photoswipe": "^5",
|
"photoswipe": "^5",
|
||||||
"scrollama": "^3"
|
"scrollama": "^3",
|
||||||
|
"sortablejs": "^1.15.7"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"esbuild": "^0.21",
|
"esbuild": "^0.21",
|
||||||
|
|||||||
Reference in New Issue
Block a user