Add support for message history pagination, preserve scroll in rendering, and improve draft/message workflows.
CI / check (22) (push) Successful in 9m34s
CI / check (24) (push) Successful in 9m34s

This commit is contained in:
2026-10-06 00:32:18 +02:00
parent 1c0f06716e
commit 387c0ac2e7
40 changed files with 2374 additions and 1314 deletions
+52 -17
View File
@@ -1,6 +1,8 @@
/**
* @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");
@@ -27,11 +29,17 @@ export function createAttachmentComposer({onChange, onError}) {
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};
fetch("/uploads/config").then(r => r.ok ? r.json() : limits).then(value => { limits = value; }).catch(() => {});
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) {
@@ -39,11 +47,22 @@ export function createAttachmentComposer({onChange, onError}) {
card.className = "upload-preview";
const image = document.createElement("img");
image.src = item.preview;
image.alt = item.file.name;
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.error || (item.metadata ? "Ready" : "Uploading…")}`;
label.textContent = `${item.file?.name || item.metadata?.originalName || "Recovered image"} — ${item.error || (item.metadata ? "Ready" : "Uploading…")}`;
card.append(image, label);
if (item.error) {
if (item.error && item.file) {
const retry = document.createElement("button");
retry.type = "button"; retry.textContent = "Retry"; retry.disabled = busy;
retry.onclick = () => { void upload(item); };
@@ -51,11 +70,11 @@ export function createAttachmentComposer({onChange, onError}) {
}
const remove = document.createElement("button");
remove.type = "button"; remove.textContent = "×"; remove.disabled = busy;
remove.setAttribute("aria-label", `Remove ${item.file.name}`);
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 fetch(`/attachments/${item.metadata.id}`, {method: "DELETE"});
if (item.metadata) void del(`/attachments/${item.metadata.id}`);
render();
};
card.append(remove); preview.append(card);
@@ -70,16 +89,16 @@ export function createAttachmentComposer({onChange, onError}) {
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");
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 fetch(`/attachments/${result.id}`, {method: "DELETE"});
if (![items, ...drafts.values()].some(draft => draft.includes(item))) void del(`/attachments/${result.id}`);
} catch (error) {
if (error.name !== "AbortError") item.error = error.message || "Upload failed";
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 => {
@@ -106,26 +125,39 @@ export function createAttachmentComposer({onChange, onError}) {
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); },
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 fetch(`/attachments/${item.metadata.id}`, {method: "DELETE"});
if (item.metadata) void del(`/attachments/${item.metadata.id}`);
}
items = [];
render();
},
saveDraft(chatId) {
if (chatId != null) drafts.set(chatId, items);
chatId ??= "new";
drafts.set(chatId, items);
persist(chatId);
items = [];
currentDraftId = null;
render();
},
restoreDraft(chatId) {
items = drafts.get(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();
},
@@ -137,14 +169,17 @@ export function createAttachmentComposer({onChange, onError}) {
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 fetch(`/attachments/${item.metadata.id}`, {method: "DELETE"});
if (item.metadata) void del(`/attachments/${item.metadata.id}`);
}
}
};