File upload
A native file input, an image preview via an object URL, and a real progress bar. Progress needs upload events, so this uses XMLHttpRequest (the one place fetch can't report progress) wrapped in a small promise.
import { html, mount, createState, onCleanup } from "@zoijs/core";
function upload(file, onProgress) {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open("POST", "/api/upload");
xhr.upload.addEventListener("progress", (e) => {
if (e.lengthComputable) onProgress(Math.round((e.loaded / e.total) * 100));
});
xhr.addEventListener("load", () =>
xhr.status < 400 ? resolve(JSON.parse(xhr.responseText)) : reject(new Error(`HTTP ${xhr.status}`))
);
xhr.addEventListener("error", () => reject(new Error("Network error")));
const body = new FormData();
body.append("file", file);
xhr.send(body);
});
}
function Uploader() {
const file = createState(null);
const previewUrl = createState("");
const progress = createState(0);
const status = createState("idle"); // idle | uploading | done | error
// Object URLs must be revoked or they leak memory.
const setPreview = (url) => {
const old = previewUrl.peek();
if (old) URL.revokeObjectURL(old);
previewUrl.set(url);
};
onCleanup(() => setPreview(""));
const onPick = (e) => {
const f = e.target.files[0] || null;
file.set(f);
status.set("idle");
progress.set(0);
setPreview(f && f.type.startsWith("image/") ? URL.createObjectURL(f) : "");
};
const start = async () => {
const f = file.get();
if (!f) return;
status.set("uploading");
try {
await upload(f, (pct) => progress.set(pct));
status.set("done");
} catch {
status.set("error");
}
};
return html`
<section>
<input type="file" accept="image/*" onchange=${onPick} />
${() => previewUrl.get() && html`<img src=${() => previewUrl.get()} alt="Preview" style="max-width:200px" />`}
<button onclick=${start} disabled=${() => !file.get() || status.get() === "uploading"}>
Upload
</button>
${() =>
status.get() === "uploading" &&
html`<progress value=${() => progress.get()} max="100">${() => progress.get()}%</progress>`}
${() => status.get() === "done" && html`<p>✓ Uploaded.</p>`}
${() => status.get() === "error" && html`<p class="error">Upload failed.</p>`}
</section>
`;
}
mount(Uploader, "#app");Watch-outs#
- Revoke object URLs.
URL.createObjectURL(file)allocates memory that lives until yourevokeObjectURLit.setPreviewrevokes the previous URL on every change, andonCleanuprevokes the last one on unmount — so previews never leak. fetchcan't do upload progress. It has no upload-progress events, so a real progress bar meansXMLHttpRequest. Everything else (the body, the response) is the sameFormDatayou'd send withfetch.srcis guarded. Ablob:object URL is a safe image source and Zoijs allows it; it blocks dangerous schemes onsrc/href. Setacceptand validate type/size before sending — never trust the client, but don't render untrusted bytes as anything but an image either. See Security.- One file shown; many is the same shape. For multi-file, iterate
e.target.files, keep an array of{ file, url, progress, status }in state, and render it witheach— one row per file, each with its own progress.
Next: Data table.