export function appendAttachments(container, attachments = []) { if (!attachments.length) return; const gallery = document.createElement("div"); gallery.className = "message-images"; for (const item of attachments) { if (!/^[a-f0-9-]{36}$/.test(item.id)) continue; const link = document.createElement("a"); link.href = `/attachments/${item.id}`; link.target = "_blank"; link.rel = "noopener"; const image = document.createElement("img"); image.src = link.href; image.alt = item.originalName || "Attached image"; image.loading = "lazy"; link.append(image); gallery.append(link); } container.append(gallery); } export function createAttachmentComposer({onChange, onError}) { const button = document.getElementById("attachImage"); const picker = document.getElementById("imagePicker"); const preview = document.getElementById("uploadPreviews"); const composer = document.querySelector(".input-container"); let items = []; let busy = false; let limits = {maxSizeMb: 10, maxFiles: 5}; fetch("/uploads/config").then(r => r.ok ? r.json() : limits).then(value => { limits = value; }).catch(() => {}); const render = () => { preview.replaceChildren(); preview.hidden = !items.length; for (const item of items) { const card = document.createElement("div"); card.className = "upload-preview"; const image = document.createElement("img"); image.src = item.preview; image.alt = item.file.name; const label = document.createElement("span"); label.textContent = `${item.file.name} — ${item.error || (item.metadata ? "Ready" : "Uploading…")}`; card.append(image, label); if (item.error) { const retry = document.createElement("button"); retry.type = "button"; retry.textContent = "Retry"; retry.disabled = busy; retry.onclick = () => { void upload(item); }; card.append(retry); } const remove = document.createElement("button"); remove.type = "button"; remove.textContent = "×"; remove.disabled = busy; remove.setAttribute("aria-label", `Remove ${item.file.name}`); remove.onclick = () => { items = items.filter(v => v !== item); item.controller?.abort(); URL.revokeObjectURL(item.preview); if (item.metadata) void fetch(`/attachments/${item.metadata.id}`, {method: "DELETE"}); render(); }; card.append(remove); preview.append(card); } onChange(); }; const upload = async item => { item.error = null; item.controller = new AbortController(); render(); try { const form = new FormData(); form.append("image", item.file); const response = await fetch("/uploads", {method: "POST", body: form, signal: item.controller.signal}); const result = await response.json(); if (!response.ok) throw new Error(result.error || "Upload failed"); item.metadata = result; if (!items.includes(item)) void fetch(`/attachments/${result.id}`, {method: "DELETE"}); } catch (error) { if (error.name !== "AbortError") item.error = error.message || "Upload failed"; } render(); }; const add = files => { if (busy) return; for (const file of files) { if (items.length >= limits.maxFiles) { onError(`Attach at most ${limits.maxFiles} images`); break; } if (!["image/png", "image/jpeg", "image/webp"].includes(file.type)) { onError("Use PNG, JPEG or WebP images"); continue; } if (!file.size || file.size > limits.maxSizeMb * 1024 * 1024) { onError(`Images must be non-empty and at most ${limits.maxSizeMb} MB`); continue; } const item = {file, preview: URL.createObjectURL(file)}; items.push(item); void upload(item); } }; button.onclick = () => picker.click(); picker.onchange = () => { add(picker.files); picker.value = ""; }; composer.addEventListener("dragover", event => { if (event.dataTransfer.types.includes("Files")) { event.preventDefault(); composer.classList.add("image-drag-target"); } }); composer.addEventListener("dragleave", () => composer.classList.remove("image-drag-target")); composer.addEventListener("drop", event => { event.preventDefault(); composer.classList.remove("image-drag-target"); add(event.dataTransfer.files); }); composer.addEventListener("paste", event => { const images = [...(event.clipboardData?.files || [])].filter(file => file.type.startsWith("image/")); if (images.length) { if (!event.clipboardData.getData("text/plain")) event.preventDefault(); add(images); } }); return { get attachments() { return items.filter(v => v.metadata).map(v => v.metadata); }, get pending() { return items.some(v => !v.metadata); }, setBusy(value) { busy = value; button.disabled = value; picker.disabled = value; render(); }, clear() { items.forEach(v => URL.revokeObjectURL(v.preview)); items = []; render(); } }; }