feat(downloads): local-upload UI — drag-drop dialog + progress (E7 S5c)
UploadDialog (drag-drop + native picker + optional display name) opened from a new Upload button in the queue add-row. Uploads stream through a new XHR helper (uploadReq in api/core) so the dialog shows real 0-100% progress with a cancel — fetch/req can't report upload progress. The upload lands as an already-done job; the S5b file-kind row gates it to download+share. i18n HU/EN; upload cap/empty errors localize via the shared structured-detail path.
This commit is contained in:
@@ -4,6 +4,7 @@
|
||||
// so the runtime graph is a clean barrel → domain → core with no cycles.
|
||||
import { notify, remove } from "../notifications";
|
||||
import i18n from "../../i18n";
|
||||
import { formatBytes } from "../format";
|
||||
import { reportError } from "../errorDialog";
|
||||
import { activeAccountId, ACTIVE_ACCOUNT_KEY, notifyActiveAccountChange } from "../activeAccount";
|
||||
|
||||
@@ -45,6 +46,10 @@ function localizeDetail(d: unknown): string {
|
||||
return t(`downloads.errors.edit.${o.reason}`, t("downloads.errors.edit.generic"));
|
||||
if (o?.code === "cookies")
|
||||
return t(`downloads.errors.cookies.${o.reason}`, t("downloads.errors.cookies.generic"));
|
||||
// Local-upload ingest (E7 S5): the per-file cap carries the limit in bytes; empty is a bare code.
|
||||
if (o?.code === "upload_too_large")
|
||||
return t("downloads.errors.upload.tooLarge", { size: formatBytes(o.limit) });
|
||||
if (o?.code === "upload_empty") return t("downloads.errors.upload.empty");
|
||||
return t("errors.generic");
|
||||
}
|
||||
|
||||
@@ -235,3 +240,67 @@ export async function req<T = unknown>(
|
||||
return (r.status === 204 ? null : await r.json()) as T;
|
||||
}
|
||||
}
|
||||
|
||||
// Multipart upload with progress. `req` rides `fetch`, which can't report upload progress, so a
|
||||
// file upload uses XHR instead: the caller gets a 0–100% callback as the body streams and an
|
||||
// optional AbortSignal to cancel a large upload. Everything else mirrors `req` — the per-tab
|
||||
// account header, credentials, the structured-detail localization, the global error modal for
|
||||
// 4xx/5xx, and an `HttpError` throw — so callers handle failures exactly as they do a `req` call.
|
||||
// The browser sets the multipart Content-Type (with its boundary), so we deliberately don't.
|
||||
export function uploadReq<T = unknown>(
|
||||
url: string,
|
||||
form: FormData,
|
||||
cfg: { onProgress?: (pct: number) => void; signal?: AbortSignal } = {},
|
||||
): Promise<T> {
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.open("POST", url);
|
||||
xhr.withCredentials = true;
|
||||
const active = getActiveAccount();
|
||||
if (active != null) xhr.setRequestHeader(ACTIVE_ACCOUNT_HEADER, String(active));
|
||||
if (cfg.onProgress) {
|
||||
xhr.upload.onprogress = (e) => {
|
||||
if (e.lengthComputable) cfg.onProgress?.(Math.round((e.loaded / e.total) * 100));
|
||||
};
|
||||
}
|
||||
if (cfg.signal) {
|
||||
if (cfg.signal.aborted) return reject(new HttpError(0, "aborted"));
|
||||
cfg.signal.addEventListener("abort", () => xhr.abort());
|
||||
}
|
||||
xhr.onload = () => {
|
||||
markConnectivityRestored();
|
||||
if (xhr.status >= 200 && xhr.status < 300) {
|
||||
try {
|
||||
resolve((xhr.responseText ? JSON.parse(xhr.responseText) : null) as T);
|
||||
} catch {
|
||||
resolve(null as T);
|
||||
}
|
||||
return;
|
||||
}
|
||||
let detail: string | undefined;
|
||||
let detailData: unknown;
|
||||
try {
|
||||
const body = JSON.parse(xhr.responseText);
|
||||
if (body && typeof body.detail === "string") detail = body.detail;
|
||||
else if (body && body.detail && typeof body.detail === "object") {
|
||||
detailData = body.detail;
|
||||
detail = localizeDetail(body.detail);
|
||||
}
|
||||
} catch {
|
||||
/* no JSON body */
|
||||
}
|
||||
if (xhr.status === 401) onUnauthorized?.();
|
||||
else if (xhr.status >= 500)
|
||||
reportError(detail || `${i18n.t("errors.server")} (${xhr.status})`);
|
||||
else if (xhr.status === 400 || xhr.status === 409 || xhr.status === 422) reportError(detail);
|
||||
reject(new HttpError(xhr.status, detail, detailData));
|
||||
};
|
||||
// A network-level failure (server unreachable) — same soft "connection lost" status as `req`.
|
||||
xhr.onerror = () => {
|
||||
markConnectivityLost();
|
||||
reject(new HttpError(0, i18n.t("errors.offline.body")));
|
||||
};
|
||||
xhr.onabort = () => reject(new HttpError(0, "aborted"));
|
||||
xhr.send(form);
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user