(() => {
const isTypingTarget = (el) => {
if (!el) return false;
const tag = el.tagName;
if (tag === "TEXTAREA") return true;
if (tag === "INPUT") {
const t = el.type;
return t !== "file" && t !== "checkbox" && t !== "radio" && t !== "button" && t !== "submit";
}
return !!el.isContentEditable;
};
const extFor = (type) => {
if (!type) return "bin";
const slash = type.indexOf("/");
return slash >= 0 ? type.slice(slash + 1).split("+")[0] : "bin";
};
const pastedName = (blob) => {
if (blob.name && blob.name !== "image.png") return blob;
return new File([blob], `pasted-${Date.now()}.${extFor(blob.type)}`, { type: blob.type });
};
// Toast — created on demand, auto-removes after a few seconds.
const showToast = (msg, opts = {}) => {
let host = document.getElementById("dms-toast-host");
if (!host) {
host = document.createElement("div");
host.id = "dms-toast-host";
host.className = "toast toast-end z-50";
document.body.appendChild(host);
}
const alert = document.createElement("div");
const kind = opts.kind || "info";
alert.className = `alert alert-${kind} shadow-lg`;
if (opts.href) {
const a = document.createElement("a");
a.href = opts.href;
a.className = "underline";
a.textContent = msg;
alert.appendChild(a);
} else {
const span = document.createElement("span");
span.textContent = msg;
alert.appendChild(span);
}
host.appendChild(alert);
const ttl = opts.ttl ?? 4000;
if (ttl > 0) setTimeout(() => alert.remove(), ttl);
return alert;
};
// Path 1: a quick-upload form is on this page — attach the pasted file to it.
const attachToQuickForm = (file) => {
const fileInput = document.getElementById("quick-upload-file");
const preview = document.getElementById("quick-upload-preview");
if (!fileInput) return false;
const dt = new DataTransfer();
dt.items.add(file);
fileInput.files = dt.files;
if (preview) {
const img = preview.querySelector("img");
const name = preview.querySelector("[data-name]");
if (img) {
if (img.src) URL.revokeObjectURL(img.src);
img.src = URL.createObjectURL(file);
}
if (name) name.textContent = `${file.name} · ${(file.size / 1024).toFixed(1)} KB`;
preview.classList.remove("hidden");
preview.classList.add("flex");
}
fileInput.scrollIntoView({ behavior: "smooth", block: "center" });
return true;
};
// Path 2: anywhere else on the site — POST it directly and surface the result.
const uploadDirect = async (file) => {
const limitInput = document.querySelector("input[type=file][data-max-bytes]");
if (limitInput && overLimit(file, limitInput)) return;
const fd = new FormData();
fd.append("file", file, file.name);
const status = showToast(`Uploading ${file.name}…`, { kind: "info", ttl: 0 });
try {
const res = await fetch("/uploads", { method: "POST", body: fd, credentials: "same-origin" });
status.remove();
if (res.redirected && res.url.includes("/login")) {
showToast("Sign in to upload.", { kind: "warning" });
return;
}
if (!res.ok) {
showToast(`Upload failed (${res.status}).`, { kind: "error" });
return;
}
const href = res.redirected ? res.url : "/uploads";
showToast(`Uploaded ${file.name} — view`, { kind: "success", href });
} catch (e) {
status.remove();
showToast(`Upload error: ${e.message || e}`, { kind: "error" });
}
};
// Fetch a pasted link server-side. Going through the server keeps the
// original bytes (animated GIFs, videos) that a clipboard image-paste would
// otherwise flatten into a single-frame PNG.
const uploadUrl = async (url) => {
const status = showToast(`Fetching ${url}…`, { kind: "info", ttl: 0 });
try {
const res = await fetch("/uploads/url", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({ url }),
credentials: "same-origin",
});
status.remove();
if (res.redirected && res.url.includes("/login")) {
showToast("Sign in to upload.", { kind: "warning" });
return;
}
if (!res.ok) {
showToast(`Fetch failed (${res.status}).`, { kind: "error" });
return;
}
const href = res.redirected ? res.url : "/uploads";
showToast("Fetched link — view", { kind: "success", href });
} catch (e) {
status.remove();
showToast(`Fetch error: ${e.message || e}`, { kind: "error" });
}
};
const looksLikeUrl = (text) => /^https?:\/\/\S+$/i.test((text || "").trim());
document.addEventListener("paste", (e) => {
if (!document.body.dataset.authed) return;
if (isTypingTarget(e.target)) return;
const cd = e.clipboardData;
const items = cd && cd.items;
if (!items) return;
for (const it of items) {
if (it.kind !== "file") continue;
const blob = it.getAsFile();
if (!blob || !blob.type.startsWith("image/")) continue;
const file = pastedName(blob);
e.preventDefault();
if (!attachToQuickForm(file)) uploadDirect(file);
return;
}
// No file on the clipboard — fall back to a pasted link.
const text = cd.getData && cd.getData("text/plain");
if (looksLikeUrl(text)) {
e.preventDefault();
uploadUrl(text.trim());
}
});
// Video and GIF tiles point at files a worker produces — the animated
// `preview`, then the `poster` frame. Either job may not have run yet (or
// may have failed on a corrupt file), so step down to `data-fallback-src`
// first, and only then swap a broken image for a label rather than leaving
// a broken-image icon.
document.querySelectorAll("img[data-fallback]").forEach((img) => {
const onError = () => {
const next = img.dataset.fallbackSrc;
if (next) {
delete img.dataset.fallbackSrc;
img.src = next;
return;
}
img.removeEventListener("error", onError);
const box = document.createElement("div");
box.className = img.className + " flex items-center justify-center bg-base-300 text-xs opacity-70";
box.textContent = img.dataset.fallback || "no preview";
img.replaceWith(box);
};
img.addEventListener("error", onError);
});
const humanBytes = (n) => {
const mib = 1024 * 1024;
if (n >= mib) return `${(n / mib).toFixed(1)} MiB`;
if (n >= 1024) return `${(n / 1024).toFixed(1)} KiB`;
return `${n} B`;
};
// Block oversize files in the browser rather than discovering the limit
// mid-upload. The server answers as soon as it has read the headers, which
// leaves the rest of the body in flight and reads to a proxy as a 502 — so
// the friendly "too large" page never reaches the user. Checking here means
// the bytes are never sent at all.
const overLimit = (file, input) => {
const max = parseInt(input.dataset.maxBytes || "0", 10);
if (!max || !file || file.size <= max) return false;
showToast(
`${file.name} is ${humanBytes(file.size)} — the limit is ${humanBytes(max)}.`,
{ kind: "error", ttl: 8000 }
);
return true;
};
document.querySelectorAll("input[type=file][data-max-bytes]").forEach((input) => {
const form = input.closest("form");
if (!form) return;
form.addEventListener("submit", (e) => {
const file = input.files && input.files[0];
if (overLimit(file, input)) e.preventDefault();
});
});
// When the quick-upload form is on the page, also preview manual file picks.
const quickFile = document.getElementById("quick-upload-file");
const quickPreview = document.getElementById("quick-upload-preview");
if (quickFile && quickPreview) {
const img = quickPreview.querySelector("img");
const name = quickPreview.querySelector("[data-name]");
quickFile.addEventListener("change", () => {
const f = quickFile.files && quickFile.files[0];
if (!f || !f.type.startsWith("image/")) {
quickPreview.classList.add("hidden");
quickPreview.classList.remove("flex");
return;
}
if (img) {
if (img.src) URL.revokeObjectURL(img.src);
img.src = URL.createObjectURL(f);
}
if (name) name.textContent = `${f.name} · ${(f.size / 1024).toFixed(1)} KB`;
quickPreview.classList.remove("hidden");
quickPreview.classList.add("flex");
});
}
})();
⭐
Log in to mark this repository.
8.4 KiB · text
History
c20d4f7