212 lines
9.6 KiB
TypeScript
212 lines
9.6 KiB
TypeScript
/**
|
|
* Client runtime for `<div data-uploader>` elements — drag-and-drop + file
|
|
* input, per-file progress bars, and success/failed states. Injected by
|
|
* `collectScripts` only on pages that contain `data-uploader` (same mechanism as
|
|
* `validate.js`). Self-contained: it injects its own themed stylesheet (using
|
|
* `--wire-*` tokens) and posts each file via XHR so upload progress is live.
|
|
*
|
|
* Markup it enhances (also a valid no-JS `<form>` fallback if you wrap it):
|
|
* <div data-uploader="public" data-endpoint="/api/upload"
|
|
* data-accept="image/*" data-max="10000000" data-multiple></div>
|
|
*
|
|
* Events dispatched on the element (bubble):
|
|
* wrnexus:upload detail: { file, result: { key, url, name, size, type } }
|
|
* wrnexus:upload-error detail: { file, error }
|
|
*
|
|
* NOTE: written with single/double quotes + string concatenation only — no
|
|
* backticks and no ${...}, so it embeds safely in the exported template string.
|
|
*/
|
|
export const UPLOAD_JS_HREF = "/__wrnexus/uploader.js";
|
|
|
|
export const UPLOAD_RUNTIME = `
|
|
(function () {
|
|
if (typeof document === "undefined") return;
|
|
var CSRF_COOKIE = "wire-csrf";
|
|
|
|
var CSS =
|
|
".wire-uploader{display:block}" +
|
|
".wire-uploader-zone{display:flex;align-items:center;justify-content:center;text-align:center;" +
|
|
"min-height:8rem;padding:1.25rem;border:2px dashed var(--wire-border,#cbd5e1);border-radius:12px;" +
|
|
"background:var(--wire-surface,transparent);color:var(--wire-muted,#64748b);cursor:pointer;" +
|
|
"transition:border-color .15s ease,background-color .15s ease;position:relative}" +
|
|
".wire-uploader-zone:hover,.wire-uploader-zone:focus-visible{border-color:var(--wire-brand,#3f7dff);outline:none}" +
|
|
".wire-uploader-zone.is-drag{border-color:var(--wire-brand,#3f7dff);background:color-mix(in oklab,var(--wire-brand,#3f7dff) 8%,transparent)}" +
|
|
".wire-uploader-prompt{font-size:.9rem;pointer-events:none}" +
|
|
".wire-uploader-input{position:absolute;inset:0;width:100%;height:100%;opacity:0;cursor:pointer}" +
|
|
".wire-uploader-list{list-style:none;margin:.75rem 0 0;padding:0;display:flex;flex-direction:column;gap:.5rem}" +
|
|
".wire-uploader-item{display:grid;grid-template-columns:1fr auto;gap:.15rem .75rem;align-items:center;" +
|
|
"font-size:.82rem;padding:.5rem .7rem;border:1px solid var(--wire-border,#e2e8f0);border-radius:8px}" +
|
|
".wire-uploader-name{font-weight:500;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--wire-text,#0f172a)}" +
|
|
".wire-uploader-meta{color:var(--wire-muted,#94a3b8);font-variant-numeric:tabular-nums}" +
|
|
".wire-uploader-bar{grid-column:1/-1;height:5px;border-radius:999px;background:var(--wire-border,#e2e8f0);overflow:hidden}" +
|
|
".wire-uploader-fill{height:100%;width:0;border-radius:999px;background:var(--wire-brand,#3f7dff);transition:width .15s ease}" +
|
|
".wire-uploader-status{grid-column:1/-1;font-size:.75rem;color:var(--wire-muted,#94a3b8);font-variant-numeric:tabular-nums}" +
|
|
".wire-uploader-item.is-done .wire-uploader-fill{background:var(--wire-success,#16a34a)}" +
|
|
".wire-uploader-item.is-done .wire-uploader-status{color:var(--wire-success,#16a34a)}" +
|
|
".wire-uploader-item.is-error .wire-uploader-fill{background:var(--wire-danger,#dc2626)}" +
|
|
".wire-uploader-item.is-error .wire-uploader-status{color:var(--wire-danger,#dc2626)}";
|
|
|
|
function injectCss() {
|
|
if (document.getElementById("wire-uploader-css")) return;
|
|
var s = document.createElement("style");
|
|
s.id = "wire-uploader-css";
|
|
s.textContent = CSS;
|
|
document.head.appendChild(s);
|
|
}
|
|
|
|
function cookie(name) {
|
|
var m = document.cookie.match(new RegExp("(?:^|; )" + name + "=([^;]*)"));
|
|
return m ? decodeURIComponent(m[1]) : "";
|
|
}
|
|
function el(tag, cls, text) {
|
|
var e = document.createElement(tag);
|
|
if (cls) e.className = cls;
|
|
if (text != null) e.textContent = text;
|
|
return e;
|
|
}
|
|
function fmt(n) {
|
|
if (n < 1024) return n + " B";
|
|
if (n < 1048576) return (n / 1024).toFixed(1) + " KB";
|
|
return (n / 1048576).toFixed(1) + " MB";
|
|
}
|
|
function accepts(accept, file) {
|
|
var list = (accept || "").split(",").map(function (s) { return s.trim().toLowerCase(); }).filter(Boolean);
|
|
if (!list.length) return true;
|
|
var type = (file.type || "").toLowerCase();
|
|
var name = (file.name || "").toLowerCase();
|
|
var ext = name.indexOf(".") >= 0 ? name.slice(name.lastIndexOf(".")) : "";
|
|
return list.some(function (rule) {
|
|
if (rule.charAt(0) === ".") return rule === ext;
|
|
if (rule.slice(-2) === "/*") return type.indexOf(rule.slice(0, -1)) === 0;
|
|
return rule === type;
|
|
});
|
|
}
|
|
|
|
function setup(root) {
|
|
if (root.__wrnexusUploader) return;
|
|
root.__wrnexusUploader = true;
|
|
|
|
var endpoint = root.getAttribute("data-endpoint") || "/api/upload";
|
|
var multipleAttr = root.getAttribute("data-multiple");
|
|
var multiple = root.hasAttribute("data-multiple") && multipleAttr !== "false";
|
|
var accept = root.getAttribute("data-accept") || "";
|
|
var maxBytes = parseInt(root.getAttribute("data-max") || "0", 10) || 0;
|
|
var field = root.getAttribute("data-field") || (multiple ? "files" : "file");
|
|
var invalidate = String(root.getAttribute("data-invalidate") || "")
|
|
.split(",")
|
|
.map(function (tag) { return tag.trim(); })
|
|
.filter(Boolean);
|
|
var promptText = root.getAttribute("data-label") || "Drag files here or click to browse";
|
|
|
|
root.classList.add("wire-uploader");
|
|
var zone = el("div", "wire-uploader-zone");
|
|
zone.setAttribute("role", "button");
|
|
zone.setAttribute("tabindex", "0");
|
|
zone.appendChild(el("div", "wire-uploader-prompt", promptText));
|
|
var input = document.createElement("input");
|
|
input.type = "file";
|
|
input.className = "wire-uploader-input";
|
|
if (multiple) input.multiple = true;
|
|
if (accept) input.accept = accept;
|
|
zone.appendChild(input);
|
|
var listEl = el("ul", "wire-uploader-list");
|
|
root.appendChild(zone);
|
|
root.appendChild(listEl);
|
|
|
|
zone.addEventListener("keydown", function (e) {
|
|
if (e.key === "Enter" || e.key === " ") { e.preventDefault(); input.click(); }
|
|
});
|
|
["dragenter", "dragover"].forEach(function (ev) {
|
|
zone.addEventListener(ev, function (e) { e.preventDefault(); zone.classList.add("is-drag"); });
|
|
});
|
|
["dragleave", "drop"].forEach(function (ev) {
|
|
zone.addEventListener(ev, function (e) { e.preventDefault(); zone.classList.remove("is-drag"); });
|
|
});
|
|
zone.addEventListener("drop", function (e) {
|
|
if (e.dataTransfer && e.dataTransfer.files) handle(e.dataTransfer.files);
|
|
});
|
|
input.addEventListener("change", function () {
|
|
if (input.files) handle(input.files);
|
|
input.value = "";
|
|
});
|
|
|
|
function handle(files) {
|
|
var arr = Array.prototype.slice.call(files);
|
|
if (!multiple) arr = arr.slice(0, 1);
|
|
arr.forEach(uploadOne);
|
|
}
|
|
|
|
function row(file) {
|
|
var li = el("li", "wire-uploader-item");
|
|
li.appendChild(el("span", "wire-uploader-name", file.name));
|
|
li.appendChild(el("span", "wire-uploader-meta", fmt(file.size)));
|
|
var bar = el("div", "wire-uploader-bar");
|
|
var fill = el("div", "wire-uploader-fill");
|
|
bar.appendChild(fill);
|
|
li.appendChild(bar);
|
|
var status = el("span", "wire-uploader-status", "");
|
|
li.appendChild(status);
|
|
listEl.appendChild(li);
|
|
return { li: li, fill: fill, status: status };
|
|
}
|
|
|
|
function uploadOne(file) {
|
|
var ui = row(file);
|
|
if (maxBytes && file.size > maxBytes) return fail(ui, "Too large (max " + fmt(maxBytes) + ")", file);
|
|
if (!accepts(accept, file)) return fail(ui, "Type not allowed", file);
|
|
|
|
var fd = new FormData();
|
|
fd.append(field, file, file.name);
|
|
var xhr = new XMLHttpRequest();
|
|
xhr.open("POST", endpoint, true);
|
|
var token = cookie(CSRF_COOKIE);
|
|
if (token) xhr.setRequestHeader("x-csrf-token", token);
|
|
xhr.upload.addEventListener("progress", function (e) {
|
|
if (e.lengthComputable) {
|
|
var pct = Math.round((e.loaded / e.total) * 100);
|
|
ui.fill.style.width = pct + "%";
|
|
ui.status.textContent = pct + "%";
|
|
}
|
|
});
|
|
xhr.addEventListener("load", function () {
|
|
var data = null;
|
|
try { data = JSON.parse(xhr.responseText); } catch (e2) {}
|
|
if (xhr.status >= 200 && xhr.status < 300 && data && data.ok) {
|
|
done(ui, (data.files && data.files[0]) || null, file);
|
|
} else {
|
|
fail(ui, (data && data.error) || ("Upload failed (" + xhr.status + ")"), file);
|
|
}
|
|
});
|
|
xhr.addEventListener("error", function () { fail(ui, "Network error", file); });
|
|
xhr.send(fd);
|
|
}
|
|
|
|
function done(ui, info, file) {
|
|
ui.li.classList.remove("is-error");
|
|
ui.li.classList.add("is-done");
|
|
ui.fill.style.width = "100%";
|
|
ui.status.textContent = "\\u2713 Uploaded";
|
|
root.dispatchEvent(new CustomEvent("wrnexus:upload", { bubbles: true, detail: { file: file, result: info } }));
|
|
if (invalidate.length) {
|
|
window.dispatchEvent(new CustomEvent("wrnexus:cache:invalidate", {
|
|
detail: { tags: invalidate, source: "uploader", file: file, result: info }
|
|
}));
|
|
}
|
|
}
|
|
function fail(ui, msg, file) {
|
|
ui.li.classList.add("is-error");
|
|
ui.status.textContent = "\\u2717 " + msg;
|
|
root.dispatchEvent(new CustomEvent("wrnexus:upload-error", { bubbles: true, detail: { file: file, error: msg } }));
|
|
}
|
|
}
|
|
|
|
function init() {
|
|
injectCss();
|
|
var nodes = document.querySelectorAll("[data-uploader]");
|
|
for (var i = 0; i < nodes.length; i++) setup(nodes[i]);
|
|
}
|
|
if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", init);
|
|
else init();
|
|
})();
|
|
`;
|