brewlog/static/js/components/image-upload.js
Jon Seager 5820c162f6
fix: address static assets code review findings
- Remove dead CSS color property in .tab-mobile
- Fix design token violations (text-red-500, fallback colors)
- Add disconnectedCallback to chip-scroll, image-upload, searchable-select
- Replace innerHTML with safe DOM APIs in world-map screen reader table
- Add credentials: same-origin to WebAuthn fetch calls
- Move page-specific scripts (donut-chart, location) out of base.html
- Add client-side image resizing (1920px max dimension)
- Resize app-icon-512.png from 2048x2048 to 512x512
- Document Datastar unsafe-eval CSP requirement
- Add static asset serving tests (16 routes)
- Add e2e tests for world-map, donut-chart, chip-scroll presence
- Add cache-busting query params to all static asset URLs
2026-02-13 17:13:02 +00:00

150 lines
4.7 KiB
JavaScript

customElements.define(
"image-upload",
class extends HTMLElement {
connectedCallback() {
this._ac = new AbortController();
const { signal } = this._ac;
const input = document.createElement("input");
input.type = "file";
input.accept = "image/*";
input.hidden = true;
this.appendChild(input);
this.addEventListener(
"click",
(e) => {
if (e.target !== input) input.click();
},
{ signal },
);
this.addEventListener(
"dragover",
(e) => {
e.preventDefault();
this.classList.add("border-accent");
},
{ signal },
);
this.addEventListener(
"dragleave",
() => {
this.classList.remove("border-accent");
},
{ signal },
);
this.addEventListener(
"drop",
(e) => {
e.preventDefault();
this.classList.remove("border-accent");
const file = e.dataTransfer?.files[0];
if (file && file.type.startsWith("image/")) {
this._handleFile(file);
}
},
{ signal },
);
input.addEventListener(
"change",
() => {
const file = input.files[0];
if (file) this._handleFile(file);
input.value = "";
},
{ signal },
);
}
disconnectedCallback() {
this._ac?.abort();
}
async _handleFile(file) {
const entityType = this.getAttribute("entity-type");
const entityId = this.getAttribute("entity-id");
const mode = this.getAttribute("mode");
const dataUrl = await imageToJpegDataUrl(file);
if (entityType && entityId && mode !== "deferred") {
this._upload(entityType, entityId, dataUrl);
} else {
// Deferred mode: store data URL in a target hidden input
const targetId = this.getAttribute("target-input");
if (targetId) {
const hiddenInput = document.getElementById(targetId);
hiddenInput.value = dataUrl;
// Remove existing server image preview (edit forms)
const existing = document.getElementById(`${targetId}-existing`);
if (existing) {
existing.remove();
// Create standalone preview after the hidden input since
// the Replace button (this element) was inside the removed container
let preview = hiddenInput.nextElementSibling;
if (
!preview ||
!preview.classList.contains("image-upload-preview")
) {
preview = document.createElement("div");
preview.className =
"image-upload-preview h-48 w-full bg-cover bg-center rounded-lg border";
hiddenInput.insertAdjacentElement("afterend", preview);
}
preview.style.backgroundImage = `url('${dataUrl}')`;
return;
}
}
this._showPreview(dataUrl);
}
}
_showPreview(dataUrl) {
const container = this.closest("#entity-image") || this.parentElement;
let preview = container.querySelector(".image-upload-preview");
if (!preview) {
preview = document.createElement("div");
preview.className =
"image-upload-preview h-48 w-full bg-cover bg-center rounded-lg border mt-2";
container.appendChild(preview);
}
preview.style.backgroundImage = `url('${dataUrl}')`;
}
async _upload(entityType, entityId, dataUrl) {
const originalContent = this.innerHTML;
const isReplace = this.getAttribute("mode") === "replace";
if (!isReplace) {
this.innerHTML =
'<svg class="h-5 w-5 animate-spin text-accent" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" aria-hidden="true"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path></svg>';
}
try {
const response = await fetch(
`/api/v1/${entityType}/${entityId}/image`,
{
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ image: dataUrl }),
},
);
if (!response.ok) throw new Error("Upload failed");
window.location.reload();
} catch {
this.innerHTML = originalContent;
const errEl = document.createElement("p");
errEl.className = "text-xs text-error mt-1";
errEl.textContent = "Upload failed. Try again.";
this.parentElement.appendChild(errEl);
setTimeout(() => errEl.remove(), 3000);
}
}
},
);