fix(post-form): convert HEIC via FilePond beforeAddFile, not a custom uploader (U4)
Runtime verification showed the custom picker uploaded to Grav's flash but never attached photos to the entry — attachment needs FilePond's exact (undocumented) submit contract. Reverting to type:filepond and hooking its beforeAddFile: a HEIC item is rejected, converted to JPEG via the lazy heic-to chunk, then re-added with pond.addFile() so FilePond owns upload + page-attach (the proven path). Web-format photos pass through; conversion failures fail closed (inline status, original never added). Removes the custom photos field template + AJAX uploader. Verified end-to-end in a browser: HEIC posts as JPEG, corrupt HEIC is skipped, Submit gated while converting, draft photos-reselect hint intact. Refs R8, R9, R16, R17, AE1, AE4, KTD4.
This commit is contained in:
@@ -47,11 +47,11 @@ form:
|
|||||||
-
|
-
|
||||||
name: photos
|
name: photos
|
||||||
label: Photos (max 4)
|
label: Photos (max 4)
|
||||||
# Custom controlled picker (theme: forms/fields/photos) — replaces
|
# Grav-managed filepond field. post-form.js hooks its beforeAddFile
|
||||||
# filepond so post-form.js can convert HEIC before upload (U4). The
|
# to convert HEIC->JPEG in the browser, then re-adds the JPEG via the
|
||||||
# destination/accept/limit below are still read server-side by the
|
# public pond.addFile() API — FilePond keeps ownership of the upload
|
||||||
# form's AJAX uploadFiles() handler.
|
# and page-attach contract (U4).
|
||||||
type: photos
|
type: filepond
|
||||||
multiple: true
|
multiple: true
|
||||||
destination: '@self'
|
destination: '@self'
|
||||||
limit: 4
|
limit: 4
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -20,71 +20,22 @@
|
|||||||
min-height: 180px;
|
min-height: 180px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Photo picker (U4) — functional states; Field Notes polish in U5 ── */
|
/* ── Photo HEIC-conversion feedback (U4) ────────────────────
|
||||||
.photo-picker__list {
|
* FilePond renders the thumbnails/progress; these are the pre-FilePond
|
||||||
list-style: none;
|
* "converting…" status line and the draft photos-reselect hint (U6).
|
||||||
margin: 0.5rem 0 0;
|
*/
|
||||||
padding: 0;
|
.photo-convert-status:empty { display: none; }
|
||||||
display: flex;
|
.photo-convert-status {
|
||||||
flex-wrap: wrap;
|
font-size: var(--text-sm);
|
||||||
gap: 0.5rem;
|
margin-top: var(--space-2);
|
||||||
}
|
}
|
||||||
.photo-card {
|
.photo-reauth-hint {
|
||||||
position: relative;
|
|
||||||
width: 84px;
|
|
||||||
height: 84px;
|
|
||||||
border-radius: 6px;
|
|
||||||
overflow: hidden;
|
|
||||||
background: rgba(0, 0, 0, 0.06);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
.photo-card__thumb {
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
object-fit: cover;
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
.photo-card__state {
|
|
||||||
position: absolute;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
bottom: 0;
|
|
||||||
padding: 2px 4px;
|
|
||||||
font-size: 0.7rem;
|
|
||||||
line-height: 1.2;
|
|
||||||
text-align: center;
|
|
||||||
color: #fff;
|
|
||||||
background: rgba(0, 0, 0, 0.55);
|
|
||||||
}
|
|
||||||
.photo-card.is-done .photo-card__state { background: rgba(31, 107, 90, 0.85); }
|
|
||||||
.photo-card.is-error {
|
|
||||||
background: rgba(176, 0, 32, 0.12);
|
|
||||||
outline: 2px solid rgba(176, 0, 32, 0.5);
|
|
||||||
}
|
|
||||||
.photo-card.is-error .photo-card__state { background: rgba(176, 0, 32, 0.85); }
|
|
||||||
.photo-card.is-converting .photo-card__thumb { opacity: 0.4; }
|
|
||||||
|
|
||||||
/* Photo picker add button + hint (U5 polish) */
|
|
||||||
.photo-picker__add {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--space-2);
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
.photo-picker.is-busy .photo-picker__add {
|
|
||||||
opacity: 0.6;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
.photo-picker__hint:empty { display: none; }
|
|
||||||
.photo-picker__reauth-hint {
|
|
||||||
display: none;
|
display: none;
|
||||||
font-size: var(--text-sm);
|
font-size: var(--text-sm);
|
||||||
color: var(--color-ink-muted);
|
color: var(--color-ink-muted);
|
||||||
margin-top: var(--space-2);
|
margin-top: var(--space-2);
|
||||||
}
|
}
|
||||||
.photo-picker__reauth-hint.is-shown { display: block; }
|
.photo-reauth-hint.is-shown { display: block; }
|
||||||
|
|
||||||
/* ── Field Notes styling for the new controls (U5, R11/R12) ── */
|
/* ── Field Notes styling for the new controls (U5, R11/R12) ── */
|
||||||
|
|
||||||
|
|||||||
@@ -85,131 +85,101 @@ function slugifyJpegName(name) {
|
|||||||
return (base || 'photo') + '.jpg';
|
return (base || 'photo') + '.jpg';
|
||||||
}
|
}
|
||||||
|
|
||||||
function initPhotoPicker() {
|
// Insert (once) a status line just after the FilePond field for conversion
|
||||||
var root = document.querySelector('.photo-picker');
|
// feedback, returning it. FilePond owns the per-thumbnail chrome; this line
|
||||||
if (!root) return;
|
// carries the pre-FilePond "converting…" / error state.
|
||||||
|
function photoStatusEl() {
|
||||||
|
var root = document.querySelector('.filepond-root, .form-input-file');
|
||||||
|
if (!root || !root.parentNode) return null;
|
||||||
|
var el = root.parentNode.querySelector('.photo-convert-status');
|
||||||
|
if (!el) {
|
||||||
|
el = document.createElement('p');
|
||||||
|
el.className = 'photo-convert-status form-status';
|
||||||
|
el.setAttribute('role', 'status');
|
||||||
|
root.parentNode.insertBefore(el, root.nextSibling);
|
||||||
|
}
|
||||||
|
return el;
|
||||||
|
}
|
||||||
|
|
||||||
var input = root.querySelector('.photo-picker__input');
|
function initPhotoConversion() {
|
||||||
var list = root.querySelector('.photo-picker__list');
|
var form = document.querySelector('form[name="new-entry"]');
|
||||||
var hint = root.querySelector('.photo-picker__hint');
|
if (!form) return;
|
||||||
var form = root.closest('form');
|
|
||||||
if (!input || !form) return;
|
|
||||||
|
|
||||||
var fieldName = root.getAttribute('data-file-field-name') || 'photos';
|
var converting = 0; // HEIC conversions in flight (before FilePond) — gates Submit
|
||||||
var uploadUrl = root.getAttribute('data-file-url-add');
|
|
||||||
var limit = parseInt(root.getAttribute('data-limit'), 10) || 4;
|
|
||||||
|
|
||||||
var attached = 0; // photos that converted + uploaded (or are reserved) OK
|
function setStatus(msg, kind) {
|
||||||
var inFlight = 0; // conversions/uploads still running — gates Submit (R17)
|
var el = photoStatusEl();
|
||||||
|
if (!el) return;
|
||||||
function hiddenVal(name) {
|
el.textContent = msg || '';
|
||||||
var el = form.querySelector('[name="' + name + '"]');
|
el.className = 'photo-convert-status form-status' + (kind ? ' form-status--' + kind : '');
|
||||||
return el ? el.value : '';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function setHint(msg, kind) {
|
function updateGate() {
|
||||||
if (!hint) return;
|
|
||||||
hint.textContent = msg || '';
|
|
||||||
hint.className = 'photo-picker__hint form-status' + (kind ? ' form-status--' + kind : '');
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateSubmitState() {
|
|
||||||
var btn = form.querySelector('button[type="submit"], input[type="submit"]');
|
var btn = form.querySelector('button[type="submit"], input[type="submit"]');
|
||||||
if (btn) btn.disabled = inFlight > 0;
|
if (btn) btn.disabled = converting > 0;
|
||||||
root.classList.toggle('is-busy', inFlight > 0);
|
if (converting > 0) {
|
||||||
|
setStatus('Converting ' + converting + ' photo' + (converting > 1 ? 's' : '') + '…');
|
||||||
|
} else {
|
||||||
|
var el = photoStatusEl();
|
||||||
|
if (el && /Converting/.test(el.textContent)) setStatus('');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function addCard(file) {
|
// Guard the submit while a HEIC is still converting (it hasn't entered
|
||||||
var li = document.createElement('li');
|
// FilePond yet, so FilePond's own processing gate wouldn't cover it). R17.
|
||||||
li.className = 'photo-card is-converting';
|
|
||||||
var img = document.createElement('img');
|
|
||||||
img.className = 'photo-card__thumb';
|
|
||||||
img.alt = file.name || 'photo';
|
|
||||||
var state = document.createElement('span');
|
|
||||||
state.className = 'photo-card__state';
|
|
||||||
state.textContent = 'converting…';
|
|
||||||
li.appendChild(img);
|
|
||||||
li.appendChild(state);
|
|
||||||
if (list) list.appendChild(li);
|
|
||||||
return { li: li, img: img, state: state };
|
|
||||||
}
|
|
||||||
|
|
||||||
function uploadToFlash(blob, filename) {
|
|
||||||
var fd = new FormData();
|
|
||||||
fd.append('__form-name__', hiddenVal('__form-name__'));
|
|
||||||
fd.append('__unique_form_id__', hiddenVal('__unique_form_id__'));
|
|
||||||
fd.append('__form-file-uploader__', '1');
|
|
||||||
fd.append('form-nonce', hiddenVal('form-nonce'));
|
|
||||||
fd.append('name', fieldName);
|
|
||||||
fd.append('data[' + fieldName + '][]', blob, filename);
|
|
||||||
return fetch(uploadUrl, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'X-Requested-With': 'XMLHttpRequest' },
|
|
||||||
body: fd,
|
|
||||||
credentials: 'same-origin'
|
|
||||||
}).then(function (r) { return r.json(); }).then(function (j) {
|
|
||||||
if (!j || j.status === 'error') {
|
|
||||||
throw new Error((j && j.message) || 'Upload failed');
|
|
||||||
}
|
|
||||||
return j;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function processFile(file) {
|
|
||||||
if (attached >= limit) {
|
|
||||||
setHint('You can attach up to ' + limit + ' photos.', 'err');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
attached++; // reserve a slot; released if this photo fails
|
|
||||||
inFlight++;
|
|
||||||
updateSubmitState();
|
|
||||||
var card = addCard(file);
|
|
||||||
|
|
||||||
looksLikeHeic(file).then(function (isHeic) {
|
|
||||||
if (!isHeic) return file; // already web-renderable (R9) — pass through
|
|
||||||
// Lazy-load the converter only when a HEIC is actually picked (KTD4);
|
|
||||||
// this is the separately-fetched code-split chunk.
|
|
||||||
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 (out) {
|
|
||||||
var passedThrough = out === file;
|
|
||||||
var filename = (passedThrough && /\.(jpe?g|png)$/i.test(file.name)) ? file.name : slugifyJpegName(file.name);
|
|
||||||
try { card.img.src = URL.createObjectURL(out); } catch (e) { /* no preview */ }
|
|
||||||
card.state.textContent = 'uploading…';
|
|
||||||
return uploadToFlash(out, filename);
|
|
||||||
}).then(function () {
|
|
||||||
card.li.classList.remove('is-converting');
|
|
||||||
card.li.classList.add('is-done');
|
|
||||||
card.state.textContent = '✓';
|
|
||||||
}).catch(function () {
|
|
||||||
// Fail closed (R16): drop this photo, keep the others + Submit usable,
|
|
||||||
// never upload the original HEIC.
|
|
||||||
attached--;
|
|
||||||
card.li.classList.remove('is-converting');
|
|
||||||
card.li.classList.add('is-error');
|
|
||||||
card.state.textContent = '✗ skipped';
|
|
||||||
setHint('A photo could not be processed and was skipped — the others are fine.', 'err');
|
|
||||||
}).then(function () {
|
|
||||||
inFlight--;
|
|
||||||
updateSubmitState();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
input.addEventListener('change', function () {
|
|
||||||
setHint('');
|
|
||||||
Array.prototype.slice.call(input.files || []).forEach(processFile);
|
|
||||||
input.value = ''; // let the same file be re-picked after a removal/error
|
|
||||||
});
|
|
||||||
|
|
||||||
// Backstop for the Submit gate (button is also disabled while in flight).
|
|
||||||
form.addEventListener('submit', function (e) {
|
form.addEventListener('submit', function (e) {
|
||||||
if (inFlight > 0) {
|
if (converting > 0) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setHint('Hang on — photos are still uploading.', 'err');
|
setStatus('Hang on — a photo is still converting.', 'err');
|
||||||
}
|
}
|
||||||
}, true);
|
}, true);
|
||||||
|
|
||||||
|
// FilePond hook: reject a HEIC item, convert it to JPEG via the lazy heic-to
|
||||||
|
// chunk (KTD4), then re-add the JPEG through pond.addFile() so FilePond
|
||||||
|
// uploads and page-attaches it via its own (correct) contract.
|
||||||
|
function makeBeforeAddFile(pond) {
|
||||||
|
return function (item) {
|
||||||
|
var file = item && item.file;
|
||||||
|
if (!file) return true;
|
||||||
|
return looksLikeHeic(file).then(function (isHeic) {
|
||||||
|
if (!isHeic) return true; // web format (R9) — FilePond handles it
|
||||||
|
converting++;
|
||||||
|
updateGate();
|
||||||
|
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) {
|
||||||
|
var jpegFile = new File([jpeg], slugifyJpegName(file.name), { type: 'image/jpeg' });
|
||||||
|
pond.addFile(jpegFile);
|
||||||
|
}).catch(function () {
|
||||||
|
// Fail closed (R16): the original HEIC is never added/uploaded.
|
||||||
|
setStatus('A photo couldn’t be converted and was skipped — the others are fine.', 'err');
|
||||||
|
}).then(function () {
|
||||||
|
converting--;
|
||||||
|
updateGate();
|
||||||
|
});
|
||||||
|
return false; // reject the original HEIC
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// The managed FilePond instance is created by the form plugin's
|
||||||
|
// filepond-handler (DOMContentLoaded). Poll briefly, then attach the hook.
|
||||||
|
var tries = 0;
|
||||||
|
(function attach() {
|
||||||
|
var ponds = (window.GravFilePond && window.GravFilePond.getInstances)
|
||||||
|
? window.GravFilePond.getInstances() : [];
|
||||||
|
if (!ponds.length) {
|
||||||
|
if (tries++ < 120) setTimeout(attach, 50);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ponds.forEach(function (pond) {
|
||||||
|
if (pond && !pond._heicHooked) {
|
||||||
|
pond._heicHooked = true;
|
||||||
|
pond.setOptions({ beforeAddFile: makeBeforeAddFile(pond) });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})();
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Field helper ────────────────────────────────────────── */
|
/* ── Field helper ────────────────────────────────────────── */
|
||||||
@@ -421,12 +391,15 @@ function draftEls(form) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function showReauthHint() {
|
function showReauthHint() {
|
||||||
var picker = document.querySelector('.photo-picker');
|
// The filepond-root div is server-rendered, so it exists even before
|
||||||
if (!picker || picker.querySelector('.photo-picker__reauth-hint')) return;
|
// FilePond enhances it. Place the hint right after the photo field.
|
||||||
|
var root = document.querySelector('.filepond-root, .form-input-file');
|
||||||
|
if (!root || !root.parentNode) return;
|
||||||
|
if (root.parentNode.querySelector('.photo-reauth-hint')) return;
|
||||||
var p = document.createElement('p');
|
var p = document.createElement('p');
|
||||||
p.className = 'photo-picker__reauth-hint is-shown';
|
p.className = 'photo-reauth-hint is-shown';
|
||||||
p.textContent = 'Your text was restored — photos need re-selecting (they can’t be saved in a draft).';
|
p.textContent = 'Your text was restored — photos need re-selecting (they can’t be saved in a draft).';
|
||||||
picker.appendChild(p);
|
root.parentNode.insertBefore(p, root.nextSibling);
|
||||||
}
|
}
|
||||||
|
|
||||||
function initDraft() {
|
function initDraft() {
|
||||||
@@ -492,7 +465,7 @@ function boot() {
|
|||||||
// Restore before disclosure/geo so their on-load checks (auto-open,
|
// 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.
|
||||||
initDraft();
|
initDraft();
|
||||||
initPhotoPicker();
|
initPhotoConversion();
|
||||||
initDisclosure();
|
initDisclosure();
|
||||||
initGeo();
|
initGeo();
|
||||||
initValidation();
|
initValidation();
|
||||||
|
|||||||
@@ -1,28 +0,0 @@
|
|||||||
{#
|
|
||||||
Custom `photos` form field — a plain, controlled picker that replaces Grav's
|
|
||||||
managed filepond/dropzone field on /post so post-form.js can convert HEIC
|
|
||||||
before upload (see U4). It carries the same destination/accept/limit settings
|
|
||||||
the server's uploadFiles() reads via the blueprint, but renders its own input
|
|
||||||
and uploads through post-form.js, not FilePond.
|
|
||||||
#}
|
|
||||||
{% extends "forms/field.html.twig" %}
|
|
||||||
|
|
||||||
{% block input %}
|
|
||||||
{% set files = config.plugins.form.files|merge(field|default([])) %}
|
|
||||||
{% set limit = not field.multiple ? 1 : (field.limit ?? files.limit ?? 4) %}
|
|
||||||
<div class="photo-picker {{ field.classes }}"
|
|
||||||
data-file-field-name="{{ field.name }}"
|
|
||||||
data-file-url-add="{{ form.getFileUploadAjaxRoute().getUri()|e('html_attr') }}"
|
|
||||||
data-limit="{{ limit }}">
|
|
||||||
<label class="photo-picker__add btn-action">
|
|
||||||
<span class="photo-picker__add-label">📷 Add photos</span>
|
|
||||||
<input type="file"
|
|
||||||
class="photo-picker__input"
|
|
||||||
accept="image/*,.heic,.heif,.HEIC,.HEIF"
|
|
||||||
{% if field.multiple %}multiple="multiple"{% endif %}
|
|
||||||
hidden />
|
|
||||||
</label>
|
|
||||||
<ul class="photo-picker__list" aria-live="polite"></ul>
|
|
||||||
<p class="photo-picker__hint form-status" role="status"></p>
|
|
||||||
</div>
|
|
||||||
{% endblock %}
|
|
||||||
Reference in New Issue
Block a user