Add support for message history pagination, preserve scroll in rendering, and improve draft/message workflows.
This commit is contained in:
+52
-17
@@ -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}`);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user