187 lines
8.6 KiB
JavaScript
187 lines
8.6 KiB
JavaScript
/**
|
||
* @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}`);
|
||
}
|
||
}
|
||
};
|
||
}
|