feat: support HEIF/HEIC image uploads via client-side Canvas conversion

Convert all image files to JPEG on the client before uploading, allowing
iPhone HEIF/HEIC photos to work without server-side libheif dependency.
This commit is contained in:
Jon Seager 2026-02-10 17:56:48 +00:00
parent bb4aa103fb
commit fbf5a288c1
No known key found for this signature in database
5 changed files with 48 additions and 26 deletions

View file

@ -56,6 +56,7 @@ pub(super) fn router() -> axum::Router<AppState> {
) )
.route("/static/js/components/chip-scroll.js", get(chip_scroll_js)) .route("/static/js/components/chip-scroll.js", get(chip_scroll_js))
.route("/static/js/location.js", get(location_js)) .route("/static/js/location.js", get(location_js))
.route("/static/js/image-utils.js", get(image_utils_js))
.route("/static/js/components/world-map.js", get(world_map_js)) .route("/static/js/components/world-map.js", get(world_map_js))
.route("/static/js/components/donut-chart.js", get(donut_chart_js)) .route("/static/js/components/donut-chart.js", get(donut_chart_js))
.route( .route(
@ -102,6 +103,16 @@ async fn location_js() -> impl IntoResponse {
) )
} }
async fn image_utils_js() -> impl IntoResponse {
(
[
("content-type", "application/javascript; charset=utf-8"),
("cache-control", "public, max-age=604800"),
],
include_str!("../../../../static/js/image-utils.js"),
)
}
async fn photo_capture_js() -> impl IntoResponse { async fn photo_capture_js() -> impl IntoResponse {
( (
[ [

View file

@ -37,14 +37,12 @@ customElements.define(
}); });
} }
_handleFile(file) { async _handleFile(file) {
const entityType = this.getAttribute("entity-type"); const entityType = this.getAttribute("entity-type");
const entityId = this.getAttribute("entity-id"); const entityId = this.getAttribute("entity-id");
const mode = this.getAttribute("mode"); const mode = this.getAttribute("mode");
const reader = new FileReader();
reader.onload = () => { const dataUrl = await imageToJpegDataUrl(file);
const dataUrl = reader.result;
if (entityType && entityId && mode !== "deferred") { if (entityType && entityId && mode !== "deferred") {
this._upload(entityType, entityId, dataUrl); this._upload(entityType, entityId, dataUrl);
@ -56,9 +54,6 @@ customElements.define(
} }
this._showPreview(dataUrl); this._showPreview(dataUrl);
} }
};
reader.readAsDataURL(file);
} }
_showPreview(dataUrl) { _showPreview(dataUrl) {

View file

@ -12,18 +12,15 @@ customElements.define(
if (e.target !== input) input.click(); if (e.target !== input) input.click();
}); });
input.addEventListener("change", () => { input.addEventListener("change", async () => {
const file = input.files[0]; const file = input.files[0];
if (!file) return; if (!file) return;
const reader = new FileReader(); const dataUrl = await imageToJpegDataUrl(file);
reader.onload = () => {
document.getElementById(this.getAttribute("target-input")).value = document.getElementById(this.getAttribute("target-input")).value =
reader.result; dataUrl;
document document
.getElementById(this.getAttribute("target-form")) .getElementById(this.getAttribute("target-form"))
.requestSubmit(); .requestSubmit();
};
reader.readAsDataURL(file);
input.value = ""; input.value = "";
}); });
} }

18
static/js/image-utils.js Normal file
View file

@ -0,0 +1,18 @@
/** Convert any image file (HEIC, AVIF, WebP, PNG, etc.) to a JPEG data URL via Canvas. */
const imageToJpegDataUrl = (file) =>
new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => {
const canvas = document.createElement("canvas");
canvas.width = img.naturalWidth;
canvas.height = img.naturalHeight;
canvas.getContext("2d").drawImage(img, 0, 0);
URL.revokeObjectURL(img.src);
resolve(canvas.toDataURL("image/jpeg", 0.92));
};
img.onerror = () => {
URL.revokeObjectURL(img.src);
reject(new Error("Failed to load image"));
};
img.src = URL.createObjectURL(file);
});

View file

@ -49,6 +49,7 @@
src="https://cdn.jsdelivr.net/gh/starfederation/datastar@1.0.0-RC.6/bundles/datastar.js" src="https://cdn.jsdelivr.net/gh/starfederation/datastar@1.0.0-RC.6/bundles/datastar.js"
></script> ></script>
<script defer src="/static/js/location.js"></script> <script defer src="/static/js/location.js"></script>
<script defer src="/static/js/image-utils.js"></script>
<script defer src="/static/js/components/photo-capture.js"></script> <script defer src="/static/js/components/photo-capture.js"></script>
<script defer src="/static/js/components/searchable-select.js"></script> <script defer src="/static/js/components/searchable-select.js"></script>
<script defer src="/static/js/components/chip-scroll.js"></script> <script defer src="/static/js/components/chip-scroll.js"></script>