feat: show per-file upload progress in the media bin

Uploads now go through XHR instead of fetch so byte-level progress is
visible; the drop zone shows a bar, percent, and filename instead of
looking stale while the backend receives the file.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
digital-garbage
2026-09-22 18:14:58 +03:00
co-authored by Claude Sonnet 5
parent 27a82eead1
commit ee94e5851c
4 changed files with 72 additions and 10 deletions
+22 -5
View File
@@ -112,11 +112,28 @@
deleteMedia: (id) => j("DELETE", API(`/media/${encodeURIComponent(id)}`)),
renameMedia: (id, name) => j("PATCH", API(`/media/${encodeURIComponent(id)}`), { name }),
importClipToMediaBin: (clip, name) => j("POST", API("/media/import-clip"), { clip, name: name || null }),
async uploadMedia(file) {
const fd = new FormData(); fd.append("file", file, file.name);
const res = await fetch(API("/media"), { method: "POST", body: fd });
if (!res.ok) throw new Error((await res.json().catch(() => ({}))).detail || res.statusText);
return res.json();
// XMLHttpRequest, not fetch: fetch has no cross-browser upload-progress event, and a
// multi-MB video/photo upload over a slow or messy connection is exactly the case where
// the caller most wants to show something moving instead of a stalled-looking UI.
// onProgress(loadedBytes, totalBytes) fires repeatedly during the upload; totalBytes may
// be 0 if the browser can't compute it (rare, e.g. a chunked body — never for a plain File).
uploadMedia(file, onProgress) {
return new Promise((resolve, reject) => {
const fd = new FormData(); fd.append("file", file, file.name);
const xhr = new XMLHttpRequest();
xhr.open("POST", API("/media"));
if (onProgress) {
xhr.upload.onprogress = (e) => onProgress(e.loaded, e.lengthComputable ? e.total : 0);
}
xhr.onload = () => {
let body = {};
try { body = JSON.parse(xhr.responseText || "{}"); } catch (_) {}
if (xhr.status >= 200 && xhr.status < 300) resolve(body);
else reject(new Error(body.detail || xhr.statusText || `HTTP ${xhr.status}`));
};
xhr.onerror = () => reject(new Error("Network error during upload"));
xhr.send(fd);
});
},
// models / node slots
+22 -4
View File
@@ -457,10 +457,28 @@
const exportId = _exportableMediaId(st, items);
const wrap = el("div", "bin" + (mediaSelectMode ? " media-select-mode" : ""));
const drop = el("div", "mediabin");
drop.append(el("div", "big", "🎞"));
drop.append(el("div", null, "Drop images, video & audio here"));
drop.append(el("div", "pj-meta", "or click to browse · drag onto a clip to set its anchor"));
const up = st.mediaUpload;
const drop = el("div", "mediabin" + (up ? " uploading" : ""));
if (up) {
// Was silent before: uploadMedia's own loop ran with no visible state at all, so a
// batch of photos or one big video over a slow connection just looked like the drop
// zone had eaten the files and done nothing.
const pct = up.size > 0 ? Math.min(100, Math.round((up.loaded / up.size) * 100)) : null;
drop.append(el("div", "big", "⬆"));
drop.append(el("div", null, up.total > 1
? `Uploading ${up.current}/${up.total}: ${up.name}`
: `Uploading ${up.name}`));
const barWrap = el("div", "media-upload-bar");
const bar = el("div", "media-upload-bar-fill");
bar.style.width = (pct == null ? 0 : pct) + "%";
barWrap.append(bar);
drop.append(barWrap);
drop.append(el("div", "pj-meta", pct == null ? "uploading…" : `${pct}%`));
} else {
drop.append(el("div", "big", "🎞"));
drop.append(el("div", null, "Drop images, video & audio here"));
drop.append(el("div", "pj-meta", "or click to browse · drag onto a clip to set its anchor"));
}
const file = el("input"); file.type = "file"; file.accept = "image/*,video/*,audio/*"; file.multiple = true; file.style.display = "none";
file.onchange = () => { if (file.files.length) S.uploadMedia([...file.files]); file.value = ""; };
drop.onclick = () => file.click();
+22 -1
View File
@@ -28,6 +28,7 @@
models: { slots: [] }, // pluggable node config (shared with Models modal)
mediaBin: [], // uploaded assets [{id,name,kind,...}]
mediaPreviewId: null, // transient image preview in the player (not scene assignment)
mediaUpload: null, // in-progress upload: {current,total,name,loaded,size} or null
shortcuts: [], // prompt shortcut library
shortcutCategories: [], // managed grouping list: [{name, sub_categories:[]}]
imageTargets: [], // where an image asset can be wired [{value,label}]
@@ -4421,8 +4422,28 @@
// ── media bin + libraries ─────────────────────────────────────────────────────
async function loadMedia() { try { state.mediaBin = (await API.listMedia()).media || []; } catch (_) { state.mediaBin = []; } notify(); }
// state.mediaUpload: null when idle, else { current, total, name, loaded, size } -- the
// drop zone had no way to show this at all before, so a batch of photos/a big video
// uploading over a slow connection just looked like nothing was happening.
async function uploadMedia(files) {
for (const f of files) { try { await API.uploadMedia(f); } catch (e) { console.error("upload failed", e); } }
const list = [...files];
let lastNotify = 0;
for (let i = 0; i < list.length; i++) {
const f = list[i];
set({ mediaUpload: { current: i + 1, total: list.length, name: f.name, loaded: 0, size: f.size } });
lastNotify = Date.now();
try {
await API.uploadMedia(f, (loaded, total) => {
state.mediaUpload = { ...state.mediaUpload, loaded, size: total || f.size };
// XHR fires this many times a second -- notify() rebuilds the whole media grid,
// so this throttles to a still-smooth ~8fps instead of hammering a full re-render
// on every tick. The final `loaded === total` (upload done) always gets through.
const now = Date.now();
if (now - lastNotify >= 120 || loaded >= total) { lastNotify = now; notify(); }
});
} catch (e) { console.error("upload failed", e); }
}
state.mediaUpload = null;
await loadMedia();
}
async function deleteMedia(id) {
+6
View File
@@ -646,8 +646,14 @@ input[type=number] { font-family: var(--font-ui); }
color: var(--faint); font-size: 12px; transition: border-color .15s, background .15s;
}
.mediabin.drag { border-color: var(--teal); background: var(--teal-soft); color: var(--teal); }
.mediabin.uploading { border-style: solid; border-color: var(--accent); color: var(--text); cursor: default; }
.mediabin .big { font-size: 26px; opacity: .5; margin-bottom: 8px; }
.mediabin { cursor: pointer; padding: 18px 14px; }
.media-upload-bar {
width: 100%; max-width: 220px; height: 5px; margin: 8px auto 4px;
background: var(--ink-2); border-radius: 3px; overflow: hidden;
}
.media-upload-bar-fill { height: 100%; background: var(--accent); transition: width .12s linear; }
.soon { font-family: var(--font-ui); font-size: 10px; letter-spacing: 1px; color: var(--faint); border: 1px solid var(--line); border-radius: 4px; padding: 1px 5px; }
/* bin tabs (Media / Shortcuts / Transitions) */