Files
2026-10-06 00:32:18 +02:00

187 lines
8.6 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* @file Verwaltet Bildauswahl, Uploads und Vorschauen sowie die Anzeige gespeicherter Anhänge.
*/
import {get, del, multipart, ApiError} from "./api.js";
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 draftStore = null;
let currentDraftId = "new";
const persist = (id = currentDraftId, values = items) => {
if (id != null) draftStore?.setAttachments(id, values.filter(item => item.metadata).map(item => item.metadata));
};
const drafts = new Map();
let busy = false;
let limits = {maxSizeMb: 10, maxFiles: 5};
void get("/uploads/config").then(value => { if (!value.error) limits = value; });
const render = () => {
persist();
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 || item.metadata?.originalName || "Recovered image";
image.onload = () => {
if (item.verifying) { item.verifying = false; onChange(); }
};
image.onerror = () => {
if (item.recovered && !item.error) {
item.error = "Image unavailable. Remove it and attach it again.";
item.verifying = false;
item.unavailable = true;
render();
}
};
const label = document.createElement("span");
label.textContent = `${item.file?.name || item.metadata?.originalName || "Recovered image"} — ${item.error || (item.metadata ? "Ready" : "Uploading…")}`;
card.append(image, label);
if (item.error && item.file) {
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 || item.metadata?.originalName || "image"}`);
remove.onclick = () => {
items = items.filter(v => v !== item);
item.controller?.abort(); URL.revokeObjectURL(item.preview);
if (item.metadata) void del(`/attachments/${item.metadata.id}`);
render();
};
card.append(remove); preview.append(card);
}
onChange();
};
const upload = async item => {
if (item.uploading || item.metadata || !items.includes(item)) return;
item.uploading = true;
item.error = null;
item.controller = new AbortController();
render();
try {
const form = new FormData(); form.append("image", item.file);
const result = await multipart("/uploads", form, {signal: item.controller.signal});
if (result.error) throw new ApiError(result.error, result);
item.metadata = result;
if (![items, ...drafts.values()].some(draft => draft.includes(item))) void del(`/attachments/${result.id}`);
} catch (error) {
if (error.code !== "ABORTED") item.error = error.message || "Upload failed";
}
item.uploading = false;
item.controller = null;
for (const [id, draft] of drafts) if (draft.includes(item)) persist(id, draft);
if (items.includes(item)) 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 {
setDraftStore(store) { draftStore = store; },
adoptDraft(chatId) {
if (currentDraftId !== chatId) {
if (currentDraftId === "new") draftStore?.setAttachments("new", []);
currentDraftId = chatId;
persist();
}
},
get attachments() { return items.filter(v => v.metadata && !v.unavailable).map(v => v.metadata); },
get pending() { return items.some(v => !v.metadata || v.verifying || v.unavailable); },
setBusy(value) { busy = value; button.disabled = value; picker.disabled = value; render(); },
clear() { items.forEach(v => URL.revokeObjectURL(v.preview)); items = []; render(); },
discard() {
for (const item of items) {
item.controller?.abort();
URL.revokeObjectURL(item.preview);
if (item.metadata) void del(`/attachments/${item.metadata.id}`);
}
items = [];
render();
},
saveDraft(chatId) {
chatId ??= "new";
drafts.set(chatId, items);
persist(chatId);
items = [];
currentDraftId = null;
render();
},
restoreDraft(chatId) {
currentDraftId = chatId ?? "new";
chatId ??= "new";
items = drafts.get(chatId) || (draftStore?.attachments(chatId) || []).map(metadata => ({metadata, recovered: true, verifying: true, preview: `/attachments/${metadata.id}`}));
drafts.delete(chatId);
render();
},
clearSentDraft(chatId, ids) {
const saved = drafts.get(chatId) || [];
const sent = new Set(ids);
drafts.set(chatId, saved.filter(item => {
if (!sent.has(item.metadata?.id)) return true;
URL.revokeObjectURL(item.preview);
return false;
}));
const remaining = (draftStore?.attachments(chatId) || []).filter(item => !sent.has(item.id));
draftStore?.setAttachments(chatId, remaining);
},
discardDraft(chatId) {
const saved = drafts.get(chatId) || [];
drafts.delete(chatId);
draftStore?.setAttachments(chatId, []);
for (const item of saved) {
item.controller?.abort();
URL.revokeObjectURL(item.preview);
if (item.metadata) void del(`/attachments/${item.metadata.id}`);
}
}
};
}