Improve image uploads and previews

This commit is contained in:
rajchales-monito
2026-07-23 22:28:50 +02:00
parent 9f4f4aa90a
commit f2b388779d
7 changed files with 276 additions and 18 deletions
+101 -13
View File
@@ -5,6 +5,8 @@
const lang = (boot.lang || document.documentElement.lang || navigator.language || "cs").slice(0, 2).toLowerCase();
const storageKey = `maalflows:${siteKey}`;
const session = loadSession();
const allowedImageTypes = new Set(["image/jpeg", "image/png", "image/webp", "image/gif", "image/avif"]);
const maxAttachmentBytes = 3_000_000;
fetch(`${apiBase}/api/widget/config?siteKey=${encodeURIComponent(siteKey)}`)
.then((res) => res.json())
@@ -34,11 +36,22 @@
<div class="mf-messages"></div>
<div class="mf-typing" hidden>Operator pise...</div>
<form class="mf-form">
<textarea name="message" required rows="3" placeholder="${escapeHtml(copy.placeholder)}"></textarea>
<div class="mf-actions">
<input name="attachments" type="file" accept="image/*" multiple>
<button type="submit">${escapeHtml(copy.sendLabel)}</button>
<div class="mf-composer">
<textarea name="message" required rows="3" placeholder="${escapeHtml(copy.placeholder)}"></textarea>
<button class="mf-send" type="submit" aria-label="${escapeHtml(copy.sendLabel)}" title="${escapeHtml(copy.sendLabel)}">
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M3.7 20.3 21 12 3.7 3.7l2.5 7.1L14 12l-7.8 1.2-2.5 7.1Z"></path>
</svg>
</button>
</div>
<label class="mf-dropzone" tabindex="0">
<input name="attachments" type="file" accept=".jpg,.jpeg,.png,.webp,.gif,.avif,image/jpeg,image/png,image/webp,image/gif,image/avif" multiple>
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M4 17.5V6.5A2.5 2.5 0 0 1 6.5 4h11A2.5 2.5 0 0 1 20 6.5v11a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 4 17.5Zm3.3-1.2 2.9-3.4 2 2.4 2.7-3.2 3.1 4.2H7.3Zm1-6.8a1.7 1.7 0 1 0 0-3.4 1.7 1.7 0 0 0 0 3.4Z"></path>
</svg>
<span>Pridat fotku nebo pretahnout</span>
</label>
<div class="mf-attachment-list"></div>
<p class="mf-note">Odeslanim zpravy nam predavate udaje potrebne pro odpoved.</p>
</form>
</section>
@@ -50,6 +63,9 @@
const form = root.querySelector(".mf-form");
const messages = root.querySelector(".mf-messages");
const typing = root.querySelector(".mf-typing");
const dropzone = root.querySelector(".mf-dropzone");
const attachmentList = root.querySelector(".mf-attachment-list");
let selectedFiles = [];
launcher.addEventListener("click", () => panel.classList.toggle("open"));
close.addEventListener("click", () => panel.classList.remove("open"));
@@ -59,13 +75,53 @@
fetch(`${apiBase}/api/widget/conversations/${session.conversationId}/typing`, { method: "POST" }).catch(() => {});
}, 700));
form.attachments.addEventListener("change", () => {
selectedFiles = mergeImageFiles(selectedFiles, [...form.attachments.files]);
renderSelectedFiles(attachmentList, selectedFiles);
form.attachments.value = "";
});
dropzone.addEventListener("keydown", (event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
form.attachments.click();
}
});
for (const eventName of ["dragenter", "dragover"]) {
dropzone.addEventListener(eventName, (event) => {
event.preventDefault();
dropzone.classList.add("dragging");
});
}
for (const eventName of ["dragleave", "drop"]) {
dropzone.addEventListener(eventName, (event) => {
event.preventDefault();
dropzone.classList.remove("dragging");
});
}
dropzone.addEventListener("drop", (event) => {
selectedFiles = mergeImageFiles(selectedFiles, [...event.dataTransfer.files]);
renderSelectedFiles(attachmentList, selectedFiles);
});
attachmentList.addEventListener("click", (event) => {
const removeButton = event.target.closest("[data-remove]");
if (!removeButton) return;
selectedFiles.splice(Number(removeButton.dataset.remove), 1);
renderSelectedFiles(attachmentList, selectedFiles);
});
form.addEventListener("submit", async (event) => {
event.preventDefault();
const body = await payloadFromForm(form);
const body = await payloadFromForm(form, selectedFiles);
if (!body.message.trim()) return;
addMessage(messages, "visitor", body.message, []);
form.message.value = "";
form.attachments.value = "";
selectedFiles = [];
renderSelectedFiles(attachmentList, selectedFiles);
const url = session.conversationId
? `${apiBase}/api/widget/conversations/${session.conversationId}/messages`
@@ -97,12 +153,11 @@
if (session.conversationId) connectEvents(messages, typing);
}
async function payloadFromForm(form) {
async function payloadFromForm(form, selectedFiles) {
const fd = new FormData(form);
const files = [...form.attachments.files].slice(0, 3);
return {
message: fd.get("message") || "",
attachments: await Promise.all(files.map(fileToPayload))
attachments: await Promise.all(selectedFiles.slice(0, 3).map(fileToPayload))
};
}
@@ -131,11 +186,35 @@
function addMessage(messages, sender, body, attachments) {
const item = document.createElement("article");
item.className = `mf-message ${sender}`;
item.innerHTML = `<p>${escapeHtml(body)}</p>${attachments.map((a) => `<a href="${apiBase}${a.url}" target="_blank" rel="noreferrer">${escapeHtml(a.name)}</a>`).join("")}`;
item.innerHTML = `<p>${escapeHtml(body)}</p>${attachments.map((a) => `<a class="mf-photo" href="${apiBase}${a.url}" target="_blank" rel="noreferrer"><img src="${apiBase}${a.url}" alt="${escapeHtml(a.name)}"></a>`).join("")}`;
messages.append(item);
messages.scrollTop = messages.scrollHeight;
}
function mergeImageFiles(currentFiles, incomingFiles) {
const nextFiles = [...currentFiles];
for (const file of incomingFiles) {
if (!isAllowedImageFile(file)) continue;
const exists = nextFiles.some((item) => item.name === file.name && item.size === file.size && item.lastModified === file.lastModified);
if (!exists) nextFiles.push(file);
if (nextFiles.length === 3) break;
}
return nextFiles;
}
function renderSelectedFiles(container, files) {
container.innerHTML = files.map((file, index) => `
<span class="mf-attachment-chip">
<span>${escapeHtml(file.name)}</span>
<button type="button" data-remove="${index}" aria-label="Odebrat fotku">×</button>
</span>
`).join("");
}
function isAllowedImageFile(file) {
return allowedImageTypes.has(file.type) && file.size > 0 && file.size <= maxAttachmentBytes;
}
function localized(settings, language) {
const pick = (obj) => obj?.[language] || obj?.en || obj?.cs || "";
return {
@@ -180,13 +259,22 @@
.mf-message.visitor { align-self: flex-end; background: ${c.brand}; color: ${c.brandText}; }
.mf-message p { margin: 0; white-space: pre-wrap; }
.mf-message a { color: inherit; display: inline-block; margin-top: 6px; }
.mf-photo img { display: block; width: min(180px, 100%); max-height: 130px; object-fit: cover; border-radius: 6px; }
.mf-typing { padding: 0 14px 8px; color: ${c.muted}; font-size: 13px; }
.mf-form { border-top: 1px solid #dfe6e2; padding: 12px; display: grid; gap: 9px; }
.mf-composer { display: grid; grid-template-columns: minmax(0, 1fr) 42px; gap: 9px; align-items: end; }
input, textarea { width: 100%; border: 1px solid #dfe6e2; border-radius: 6px; padding: 9px 10px; color: ${c.text}; background: white; }
textarea { resize: vertical; min-height: 72px; }
.mf-actions { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 9px; align-items: center; }
.mf-actions button { border: 0; border-radius: 6px; background: ${c.brand}; color: ${c.brandText}; padding: 10px 13px; cursor: pointer; }
input[type=file] { font-size: 12px; padding: 7px; }
.mf-send { width: 42px; height: 42px; display: grid; place-items: center; border: 0; border-radius: 50%; background: ${c.brand}; color: ${c.brandText}; cursor: pointer; }
.mf-send svg { width: 22px; height: 22px; fill: currentColor; transform: translateX(1px); }
.mf-dropzone { min-height: 42px; border: 1px dashed #b9c9c1; border-radius: 8px; display: flex; align-items: center; gap: 8px; padding: 9px 10px; color: ${c.muted}; background: #f8fbf9; font-size: 12px; cursor: pointer; }
.mf-dropzone.dragging { border-color: ${c.brand}; background: #eef8f4; color: ${c.text}; }
.mf-dropzone input { display: none; }
.mf-dropzone svg { width: 18px; height: 18px; fill: currentColor; flex: 0 0 auto; }
.mf-attachment-list { display: flex; flex-wrap: wrap; gap: 6px; }
.mf-attachment-chip { max-width: 100%; display: inline-flex; align-items: center; gap: 6px; border: 1px solid #dfe6e2; border-radius: 999px; background: #f8fbf9; color: ${c.text}; padding: 5px 5px 5px 9px; font-size: 12px; }
.mf-attachment-chip span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 210px; }
.mf-attachment-chip button { width: 22px; height: 22px; border: 0; border-radius: 50%; background: #e8eee9; color: ${c.text}; cursor: pointer; padding: 0; }
.mf-note { margin: 0; color: ${c.muted}; font-size: 11px; line-height: 1.3; }
@media (max-width: 640px) {
.mf-launcher { ${mobile.side}: ${mobile.sideOffsetPx}px; bottom: ${mobile.bottomPx}px; }