feat(post-form): client-side HEIC->JPEG photo picker (U4)

Replace /post's managed filepond field with a controlled 'photos' field
(theme forms/fields/photos) + picker logic in post-form.js: magic-byte
sniff (ISO-BMFF ftyp brands, not filename/MIME), lazy import('heic-to')
only for real HEIC (deferred 3MB chunk via ESM splitting), per-thumbnail
converting/uploading/done/error states, in-flight counter gating Submit,
and fail-closed skip on conversion failure. Converted JPEGs POST to Grav's
AJAX file-upload route into the form flash (the only path copyFiles reads),
so add-page-by-form attaches them on submit. Web-format photos pass through.

Refs R8, R9, R16, R17, AE1, AE4, KTD4.
This commit is contained in:
2026-07-04 16:38:13 +02:00
parent 6282528dd8
commit bf5b5c2f3c
10 changed files with 318 additions and 66 deletions
@@ -0,0 +1,28 @@
{#
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 %}