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:
2026-07-04 17:51:58 +02:00
parent 9a96d0f19e
commit c70e96879b
6 changed files with 162 additions and 266 deletions
+5 -5
View File
@@ -47,11 +47,11 @@ form:
-
name: photos
label: Photos (max 4)
# Custom controlled picker (theme: forms/fields/photos) — replaces
# filepond so post-form.js can convert HEIC before upload (U4). The
# destination/accept/limit below are still read server-side by the
# form's AJAX uploadFiles() handler.
type: photos
# Grav-managed filepond field. post-form.js hooks its beforeAddFile
# to convert HEIC->JPEG in the browser, then re-adds the JPEG via the
# public pond.addFile() API — FilePond keeps ownership of the upload
# and page-attach contract (U4).
type: filepond
multiple: true
destination: '@self'
limit: 4
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+10 -59
View File
@@ -20,71 +20,22 @@
min-height: 180px;
}
/* ── Photo picker (U4) — functional states; Field Notes polish in U5 ── */
.photo-picker__list {
list-style: none;
margin: 0.5rem 0 0;
padding: 0;
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
/* ── Photo HEIC-conversion feedback (U4) ────────────────────
* FilePond renders the thumbnails/progress; these are the pre-FilePond
* "converting…" status line and the draft photos-reselect hint (U6).
*/
.photo-convert-status:empty { display: none; }
.photo-convert-status {
font-size: var(--text-sm);
margin-top: var(--space-2);
}
.photo-card {
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 {
.photo-reauth-hint {
display: none;
font-size: var(--text-sm);
color: var(--color-ink-muted);
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) ── */
+92 -119
View File
@@ -85,131 +85,101 @@ function slugifyJpegName(name) {
return (base || 'photo') + '.jpg';
}
function initPhotoPicker() {
var root = document.querySelector('.photo-picker');
if (!root) return;
var input = root.querySelector('.photo-picker__input');
var list = root.querySelector('.photo-picker__list');
var hint = root.querySelector('.photo-picker__hint');
var form = root.closest('form');
if (!input || !form) return;
var fieldName = root.getAttribute('data-file-field-name') || 'photos';
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
var inFlight = 0; // conversions/uploads still running — gates Submit (R17)
function hiddenVal(name) {
var el = form.querySelector('[name="' + name + '"]');
return el ? el.value : '';
// Insert (once) a status line just after the FilePond field for conversion
// feedback, returning it. FilePond owns the per-thumbnail chrome; this line
// 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;
}
function setHint(msg, kind) {
if (!hint) return;
hint.textContent = msg || '';
hint.className = 'photo-picker__hint form-status' + (kind ? ' form-status--' + kind : '');
function initPhotoConversion() {
var form = document.querySelector('form[name="new-entry"]');
if (!form) return;
var converting = 0; // HEIC conversions in flight (before FilePond) — gates Submit
function setStatus(msg, kind) {
var el = photoStatusEl();
if (!el) return;
el.textContent = msg || '';
el.className = 'photo-convert-status form-status' + (kind ? ' form-status--' + kind : '');
}
function updateSubmitState() {
function updateGate() {
var btn = form.querySelector('button[type="submit"], input[type="submit"]');
if (btn) btn.disabled = inFlight > 0;
root.classList.toggle('is-busy', inFlight > 0);
if (btn) btn.disabled = converting > 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) {
var li = document.createElement('li');
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).
// Guard the submit while a HEIC is still converting (it hasn't entered
// FilePond yet, so FilePond's own processing gate wouldn't cover it). R17.
form.addEventListener('submit', function (e) {
if (inFlight > 0) {
if (converting > 0) {
e.preventDefault();
setHint('Hang on — photos are still uploading.', 'err');
setStatus('Hang on — a photo is still converting.', 'err');
}
}, 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 couldnt 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 ────────────────────────────────────────── */
@@ -421,12 +391,15 @@ function draftEls(form) {
}
function showReauthHint() {
var picker = document.querySelector('.photo-picker');
if (!picker || picker.querySelector('.photo-picker__reauth-hint')) return;
// The filepond-root div is server-rendered, so it exists even before
// 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');
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 cant be saved in a draft).';
picker.appendChild(p);
root.parentNode.insertBefore(p, root.nextSibling);
}
function initDraft() {
@@ -492,7 +465,7 @@ function boot() {
// Restore before disclosure/geo so their on-load checks (auto-open,
// weather-button enable) see the restored values.
initDraft();
initPhotoPicker();
initPhotoConversion();
initDisclosure();
initGeo();
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 %}