Files
devplacepy/devplacepy/static/js/components/AppUpload.js
T
blindxfishandClaude Opus 5 076f55f380 Attach images pasted into the composer, comments and chat
Pressing Ctrl+V with a screenshot on the clipboard now attaches it
immediately instead of requiring a trip through the file picker.

The clipboard reader lives in dp-upload behind a new opt-in `paste`
boolean attribute: with it set, the component binds one paste listener
on its closest form and routes the clipboard image files through the
same handleFiles path as the picker and the drop target, so validation,
limits, the terms gate and the hidden attachment_uids field are shared.
A paste carrying plain text is never swallowed.

It is opt-in rather than a form-wide default because a form may hold
several upload buttons - projects.html has cover and logo beside the
attachment one - and a default would attach one pasted screenshot to
all of them.

Set on _attachment_form.html, so every form including it inherits the
behaviour (post composer, post edit, gists, projects, issues,
screenshots), plus _comment_form.html, messages.html and the embed-mode
skeleton AppChat builds.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-17 22:23:25 +02:00

323 lines
11 KiB
JavaScript

// retoor <retoor@molodetz.nl>
import { Component } from "./Component.js";
export class AppUpload extends Component {
connectedCallback() {
if (this._built) {
return;
}
this._built = true;
this.mode = this.attr("mode", "attachment");
this.endpoint = this.attr("endpoint", "/uploads/upload");
this.fieldName = this.attr("field-name", "file");
this.uidsName = this.attr("name", "attachment_uids");
this.maxBytes = this.intAttr("max-size", 10) * 1024 * 1024;
this.maxFiles = this.intAttr("max-files", 10);
const declaredTypes = this.attr("allowed-types", "")
.split(",").map((t) => t.trim().toLowerCase()).filter(Boolean);
const wildcard = declaredTypes.some((t) => t === "*" || t === ".*" || t === "*.*");
this.allowedTypes = wildcard ? [] : declaredTypes;
this.showChips = this.boolAttr("show-chips");
this.extraFields = {};
this.items = [];
this.busyCount = 0;
this.build();
}
build() {
this.button = document.createElement("button");
this.button.type = "button";
this.button.className = "dp-upload-btn";
this.button.innerHTML =
'<span class="dp-upload-icon" aria-hidden="true">&#x1F4CE;</span>' +
'<span class="dp-upload-label"></span>' +
'<span class="dp-upload-count" hidden></span>';
const label = this.attr("label", "");
const labelEl = this.button.querySelector(".dp-upload-label");
labelEl.textContent = label;
labelEl.hidden = !label;
if (!label) this.button.setAttribute("aria-label", "Upload file");
this.countEl = this.button.querySelector(".dp-upload-count");
this.input = document.createElement("input");
this.input.type = "file";
this.input.className = "dp-upload-input";
this.input.hidden = true;
if (this.boolAttr("multiple")) {
this.input.multiple = true;
}
if (this.boolAttr("directory")) {
this.input.webkitdirectory = true;
}
const accept = this.attr("accept", "");
if (accept) {
this.input.accept = accept;
}
if (this.mode === "field") {
this.input.name = this.fieldName;
}
this.chips = document.createElement("div");
this.chips.className = "dp-upload-chips";
this.errorEl = document.createElement("div");
this.errorEl.className = "dp-upload-error";
this.button.addEventListener("click", (e) => {
e.preventDefault();
this.open();
});
this.button.addEventListener("dragover", (e) => {
e.preventDefault();
this.button.classList.add("dragover");
});
this.button.addEventListener("dragleave", () => this.button.classList.remove("dragover"));
this.button.addEventListener("drop", (e) => {
e.preventDefault();
this.button.classList.remove("dragover");
this.handleFiles(e.dataTransfer.files);
});
this.input.addEventListener("change", () => this.handleFiles(this.input.files));
this.append(this.button, this.input, this.chips, this.errorEl);
if (this.mode === "attachment") {
this.uidsInput = document.createElement("input");
this.uidsInput.type = "hidden";
this.uidsInput.name = this.uidsName;
this.append(this.uidsInput);
}
this.pendingSubmitForm = null;
const form = this.closest("form");
if (form) {
if (this.boolAttr("paste")) {
form.addEventListener("paste", (event) => this.handlePaste(event));
}
form.addEventListener("submit", (event) => {
if (this.busyCount > 0) {
event.preventDefault();
event.stopImmediatePropagation();
this.pendingSubmitForm = form;
if (window.app && window.app.toast) {
window.app.toast.show("Waiting for upload to finish...", { type: "info" });
}
}
}, true);
}
}
open() {
this.input.click();
}
handlePaste(event) {
const data = event.clipboardData;
const files = Array.from(data ? data.files : [])
.filter((file) => file.type.startsWith("image/"));
if (!files.length) {
return;
}
if (!data.getData("text/plain")) {
event.preventDefault();
}
this.handleFiles(files);
}
clear() {
this.items = [];
if (this.mode === "field") {
this.input.value = "";
}
if (this.uidsInput) {
this.uidsInput.value = "";
}
this.renderChips();
this.updateCount();
}
async handleFiles(fileList) {
this.clearError();
const files = Array.from(fileList || []);
if (!files.length) {
return;
}
if (this.mode === "field") {
this.setFieldFiles(files);
return;
}
if (this.mode === "attachment") {
const remaining = this.maxFiles - this.items.length;
if (files.length > remaining) {
this.showError(`You can only add ${Math.max(0, remaining)} more file(s).`);
return;
}
}
for (const file of files) {
if (this.validate(file)) {
await this.upload(file);
}
}
this.input.value = "";
if (this.mode === "direct") {
this.emit("done", {});
}
}
validate(file) {
if (file.size > this.maxBytes) {
this.showError(`"${file.name}" exceeds the ${this.intAttr("max-size", 10)}MB limit.`);
return false;
}
if (this.allowedTypes.length) {
const ext = "." + file.name.split(".").pop().toLowerCase();
if (!this.allowedTypes.includes(ext)) {
this.showError(`"${file.name}" type is not allowed.`);
return false;
}
}
return true;
}
async upload(file, termsRetry = false) {
this.button.classList.add("uploading");
this.setBusy(true);
const body = new FormData();
body.append(this.fieldName, file);
for (const [key, value] of Object.entries(this.extraFields)) {
body.append(key, value);
}
try {
const response = await fetch(this.endpoint, {
method: "POST",
headers: { "Accept": "application/json", "X-Requested-With": "fetch" },
body,
});
const data = await response.json().catch(() => ({}));
if (!response.ok || data.ok === false || data.error) {
const gate = window.app && window.app.termsGate;
if (!termsRetry && gate && gate.matches(data)) {
return gate.intercept(data.error, () => this.upload(file, true));
}
const message = (data.error && (data.error.message || data.error)) || "Upload failed";
throw new Error(message);
}
if (this.mode === "attachment" && data.uid) {
this.items.push({ uid: data.uid, name: file.name });
this.syncUids();
this.renderChips();
}
this.emit("uploaded", { file, result: data });
} catch (error) {
this.showError(error.message);
this.emit("error", { file, message: error.message });
} finally {
this.button.classList.remove("uploading");
this.setBusy(false);
this.updateCount();
}
}
setBusy(busy) {
this.busyCount = Math.max(0, this.busyCount + (busy ? 1 : -1));
const active = this.busyCount > 0;
if (active === this._lastBusy) {
return;
}
this._lastBusy = active;
this.emit("busy", { busy: active });
if (!active && this.pendingSubmitForm) {
const form = this.pendingSubmitForm;
this.pendingSubmitForm = null;
if (form.requestSubmit) {
form.requestSubmit();
} else {
form.submit();
}
}
}
setFieldFiles(files) {
if (this.input.multiple) {
this.items.push(...files.map((file) => ({ file, name: file.name })));
} else {
this.items = files.slice(0, 1).map((file) => ({ file, name: file.name }));
}
this.applyFieldInput();
this.renderChips();
this.updateCount();
}
applyFieldInput() {
const transfer = new DataTransfer();
for (const item of this.items) {
transfer.items.add(item.file);
}
this.input.files = transfer.files;
}
removeAt(index) {
this.items.splice(index, 1);
if (this.mode === "attachment") {
this.syncUids();
}
if (this.mode === "field") {
this.applyFieldInput();
}
this.renderChips();
this.updateCount();
}
renderChips() {
this.chips.textContent = "";
if (!this.showChips) {
return;
}
this.items.forEach((item, index) => {
const chip = document.createElement("span");
chip.className = "dp-upload-chip";
const name = document.createElement("span");
name.className = "dp-upload-chip-name";
name.textContent = item.name;
const remove = document.createElement("button");
remove.type = "button";
remove.className = "dp-upload-chip-remove";
remove.innerHTML = "&times;";
remove.setAttribute("aria-label", `Remove ${item.name}`);
remove.addEventListener("click", () => this.removeAt(index));
chip.append(name, remove);
this.chips.appendChild(chip);
});
}
updateCount() {
const count = this.items.length;
this.countEl.hidden = count === 0;
this.countEl.textContent = count ? `(${count})` : "";
this.emit("change", { count });
}
syncUids() {
if (this.uidsInput) {
this.uidsInput.value = this.items.map((item) => item.uid).join(",");
}
}
emit(name, detail) {
this.dispatchEvent(new CustomEvent(`dp-upload:${name}`, { detail, bubbles: true }));
}
showError(message) {
this.errorEl.textContent = message;
if (window.app && window.app.toast) {
window.app.toast.show(message, { type: "error" });
}
}
clearError() {
this.errorEl.textContent = "";
}
}
customElements.define("dp-upload", AppUpload);