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:
@@ -17,6 +17,7 @@ import {
|
|||||||
Share2,
|
Share2,
|
||||||
Pencil,
|
Pencil,
|
||||||
Trash2,
|
Trash2,
|
||||||
|
UploadCloud,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import Tabs from "./Tabs";
|
import Tabs from "./Tabs";
|
||||||
import { PageToolbar } from "./PageShell";
|
import { PageToolbar } from "./PageShell";
|
||||||
@@ -24,6 +25,7 @@ import { LoadingState, StateMessage } from "./QueryState";
|
|||||||
// Lazy: these dialogs/editors open on demand, so they stay out of the Downloads page chunk until
|
// Lazy: these dialogs/editors open on demand, so they stay out of the Downloads page chunk until
|
||||||
// used (the video editor in particular is heavy — filmstrip + scrubber).
|
// used (the video editor in particular is heavy — filmstrip + scrubber).
|
||||||
const DownloadDialog = lazy(() => import("./DownloadDialog"));
|
const DownloadDialog = lazy(() => import("./DownloadDialog"));
|
||||||
|
const UploadDialog = lazy(() => import("./UploadDialog"));
|
||||||
const ProfileEditor = lazy(() => import("./ProfileEditor"));
|
const ProfileEditor = lazy(() => import("./ProfileEditor"));
|
||||||
const VideoEditor = lazy(() => import("./VideoEditor"));
|
const VideoEditor = lazy(() => import("./VideoEditor"));
|
||||||
const ShareDialog = lazy(() => import("./ShareDialog"));
|
const ShareDialog = lazy(() => import("./ShareDialog"));
|
||||||
@@ -49,6 +51,7 @@ export default function DownloadCenter() {
|
|||||||
const [tab, setTab] = useState("queue");
|
const [tab, setTab] = useState("queue");
|
||||||
const [addUrl, setAddUrl] = useState("");
|
const [addUrl, setAddUrl] = useState("");
|
||||||
const [addSource, setAddSource] = useState<string | null>(null);
|
const [addSource, setAddSource] = useState<string | null>(null);
|
||||||
|
const [upload, setUpload] = useState(false);
|
||||||
const [manage, setManage] = useState(false);
|
const [manage, setManage] = useState(false);
|
||||||
const [metaJob, setMetaJob] = useState<DownloadJob | null>(null);
|
const [metaJob, setMetaJob] = useState<DownloadJob | null>(null);
|
||||||
const [shareJob, setShareJob] = useState<DownloadJob | null>(null);
|
const [shareJob, setShareJob] = useState<DownloadJob | null>(null);
|
||||||
@@ -207,6 +210,16 @@ export default function DownloadCenter() {
|
|||||||
>
|
>
|
||||||
{t("downloads.page.add")}
|
{t("downloads.page.add")}
|
||||||
</Button>
|
</Button>
|
||||||
|
{/* Local upload is a peer to "add a URL": a general file → the same library (E7 S5c). */}
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
icon={<UploadCloud className="w-4 h-4" />}
|
||||||
|
onClick={() => setUpload(true)}
|
||||||
|
className="shrink-0"
|
||||||
|
>
|
||||||
|
{t("downloads.page.upload")}
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{queue.map((job) => (
|
{queue.map((job) => (
|
||||||
@@ -406,6 +419,7 @@ export default function DownloadCenter() {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{upload && <UploadDialog onClose={() => setUpload(false)} />}
|
||||||
{manage && <ProfileEditor onClose={() => setManage(false)} />}
|
{manage && <ProfileEditor onClose={() => setManage(false)} />}
|
||||||
{metaJob && <MetaEditModal job={metaJob} onClose={() => setMetaJob(null)} />}
|
{metaJob && <MetaEditModal job={metaJob} onClose={() => setMetaJob(null)} />}
|
||||||
{shareJob && <ShareDialog job={shareJob} onClose={() => setShareJob(null)} />}
|
{shareJob && <ShareDialog job={shareJob} onClose={() => setShareJob(null)} />}
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
//
|
||||||
|
// E7·S5c — the local-upload dialog. It's a general file upload (any type, no format list): a picked
|
||||||
|
// file surfaces its name+size, and Upload streams it through api.uploadDownload (the XHR-progress
|
||||||
|
// path). This drives the real dialog against a mocked api so we can assert the file + optional name
|
||||||
|
// actually reach the ingest call, and that the success toast points at the library. i18n is
|
||||||
|
// key-passthrough.
|
||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||||
|
import { render, screen, fireEvent, waitFor, cleanup } from "@testing-library/react";
|
||||||
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
|
||||||
|
const h = vi.hoisted(() => ({
|
||||||
|
uploadDownload: vi.fn(async (_f: File, _o: unknown) => ({ id: 42 })),
|
||||||
|
notify: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("react-i18next", async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<typeof import("react-i18next")>();
|
||||||
|
return { ...actual, useTranslation: () => ({ t: (key: string) => key }) };
|
||||||
|
});
|
||||||
|
vi.mock("../lib/api", () => ({ api: { uploadDownload: h.uploadDownload } }));
|
||||||
|
vi.mock("../lib/downloads", () => ({ invalidateDownloads: vi.fn() }));
|
||||||
|
vi.mock("../lib/notifications", () => ({ notify: h.notify }));
|
||||||
|
vi.mock("../lib/nav", () => ({ navigateTo: vi.fn() }));
|
||||||
|
|
||||||
|
import UploadDialog from "./UploadDialog";
|
||||||
|
|
||||||
|
const wrap = (onClose = vi.fn()) => {
|
||||||
|
const qc = new QueryClient();
|
||||||
|
return render(
|
||||||
|
<QueryClientProvider client={qc}>
|
||||||
|
<UploadDialog onClose={onClose} />
|
||||||
|
</QueryClientProvider>,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// The dialog portals into document.body, so query the whole document, not the render container.
|
||||||
|
const fileInput = () => document.querySelector('input[type="file"]') as HTMLInputElement;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
h.uploadDownload.mockClear();
|
||||||
|
h.notify.mockClear();
|
||||||
|
});
|
||||||
|
afterEach(cleanup);
|
||||||
|
|
||||||
|
describe("UploadDialog", () => {
|
||||||
|
it("shows the picked file's name and uploads it with the optional name", async () => {
|
||||||
|
const onClose = vi.fn();
|
||||||
|
wrap(onClose);
|
||||||
|
const f = new File(["hello world"], "notes.pdf", { type: "application/pdf" });
|
||||||
|
fireEvent.change(fileInput(), { target: { files: [f] } });
|
||||||
|
|
||||||
|
// The drop zone now surfaces the file name.
|
||||||
|
expect(screen.getByText("notes.pdf")).toBeTruthy();
|
||||||
|
|
||||||
|
// Optional display name.
|
||||||
|
const nameBox = screen.getByPlaceholderText("downloads.upload.namePlaceholder");
|
||||||
|
fireEvent.change(nameBox, { target: { value: "My notes" } });
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByText("downloads.upload.submit"));
|
||||||
|
|
||||||
|
await waitFor(() => expect(h.uploadDownload).toHaveBeenCalledTimes(1));
|
||||||
|
const [passedFile, opts] = h.uploadDownload.mock.calls[0]!;
|
||||||
|
expect(passedFile).toBe(f);
|
||||||
|
expect((opts as { displayName?: string }).displayName).toBe("My notes");
|
||||||
|
await waitFor(() => expect(h.notify).toHaveBeenCalledTimes(1));
|
||||||
|
await waitFor(() => expect(onClose).toHaveBeenCalled());
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps Upload disabled until a file is chosen", () => {
|
||||||
|
wrap();
|
||||||
|
const btn = screen.getByText("downloads.upload.submit").closest("button")!;
|
||||||
|
expect(btn.disabled).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
import { useRef, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { UploadCloud, File as FileIcon } from "lucide-react";
|
||||||
|
import Modal from "./Modal";
|
||||||
|
import { Button } from "./ui/Button";
|
||||||
|
import { api } from "../lib/api";
|
||||||
|
import { invalidateDownloads } from "../lib/downloads";
|
||||||
|
import { notify } from "../lib/notifications";
|
||||||
|
import { navigateTo } from "../lib/nav";
|
||||||
|
import { formatBytes } from "../lib/format";
|
||||||
|
import { inputCls } from "./ui/form";
|
||||||
|
|
||||||
|
// Local-file ingest (E7 S5c · D5-A). A general upload — ANY file type, no format list: media
|
||||||
|
// (video/audio) joins the library with the full function set, a generic file is download-only. The
|
||||||
|
// file is streamed to the server via XHR so this dialog can show real upload progress. On success it
|
||||||
|
// lands in the Library as an already-`done` job, so we invalidate + point the user there.
|
||||||
|
export default function UploadDialog({ onClose }: { onClose: () => void }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const qc = useQueryClient();
|
||||||
|
const [file, setFile] = useState<File | null>(null);
|
||||||
|
const [name, setName] = useState("");
|
||||||
|
const [dragOver, setDragOver] = useState(false);
|
||||||
|
const [pct, setPct] = useState<number | null>(null); // null = idle; 0–100 while uploading
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const abortRef = useRef<AbortController | null>(null);
|
||||||
|
const busy = pct !== null;
|
||||||
|
|
||||||
|
const pick = () => inputRef.current?.click();
|
||||||
|
|
||||||
|
const submit = async () => {
|
||||||
|
if (!file || busy) return;
|
||||||
|
const ac = new AbortController();
|
||||||
|
abortRef.current = ac;
|
||||||
|
setPct(0);
|
||||||
|
try {
|
||||||
|
await api.uploadDownload(file, {
|
||||||
|
displayName: name.trim() || undefined,
|
||||||
|
onProgress: setPct,
|
||||||
|
signal: ac.signal,
|
||||||
|
});
|
||||||
|
invalidateDownloads(qc);
|
||||||
|
notify({
|
||||||
|
level: "success",
|
||||||
|
message: t("downloads.upload.added"),
|
||||||
|
action: { label: t("downloads.dialog.view"), onClick: () => navigateTo("downloads") },
|
||||||
|
});
|
||||||
|
onClose();
|
||||||
|
} catch {
|
||||||
|
// An aborted upload (Cancel) or a server rejection (cap/quota — already surfaced by the global
|
||||||
|
// error modal, like every other req call). Re-arm the form so the user can retry or pick again.
|
||||||
|
setPct(null);
|
||||||
|
abortRef.current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal title={t("downloads.upload.title")} onClose={onClose}>
|
||||||
|
<p className="text-sm text-muted mb-4">{t("downloads.upload.hint")}</p>
|
||||||
|
|
||||||
|
{/* Drop zone doubles as the picker trigger — a click anywhere opens the native file dialog. */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={pick}
|
||||||
|
onDragOver={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setDragOver(true);
|
||||||
|
}}
|
||||||
|
onDragLeave={() => setDragOver(false)}
|
||||||
|
onDrop={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setDragOver(false);
|
||||||
|
const f = e.dataTransfer.files?.[0];
|
||||||
|
if (f) {
|
||||||
|
setFile(f);
|
||||||
|
setName("");
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={busy}
|
||||||
|
className={`w-full flex flex-col items-center justify-center gap-2 rounded-xl border-2 border-dashed px-4 py-8 text-center transition disabled:opacity-60 ${
|
||||||
|
dragOver ? "border-accent bg-accent/5" : "border-border hover:border-fg/40"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{file ? (
|
||||||
|
<>
|
||||||
|
<FileIcon className="w-6 h-6 text-muted" />
|
||||||
|
<span className="text-sm font-medium break-all">{file.name}</span>
|
||||||
|
<span className="text-xs text-muted">{formatBytes(file.size)}</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<UploadCloud className="w-7 h-7 text-muted" />
|
||||||
|
<span className="text-sm text-muted">
|
||||||
|
{dragOver ? t("downloads.upload.dropActive") : t("downloads.upload.drop")}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type="file"
|
||||||
|
className="hidden"
|
||||||
|
onChange={(e) => {
|
||||||
|
const f = e.target.files?.[0];
|
||||||
|
if (f) {
|
||||||
|
setFile(f);
|
||||||
|
setName("");
|
||||||
|
}
|
||||||
|
e.target.value = ""; // let re-picking the same file fire onChange again
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<label className="block text-sm font-medium mt-4 mb-1">
|
||||||
|
{t("downloads.upload.nameLabel")}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
placeholder={t("downloads.upload.namePlaceholder")}
|
||||||
|
disabled={busy}
|
||||||
|
className={`${inputCls} mb-5`}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{busy && (
|
||||||
|
<div className="mb-5" aria-live="polite">
|
||||||
|
<div className="h-2 rounded-full bg-surface overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="h-full bg-accent transition-[width] duration-150"
|
||||||
|
style={{ width: `${pct ?? 0}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted mt-1.5 text-center">
|
||||||
|
{t("downloads.upload.uploading", { pct: pct ?? 0 })}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => (busy ? abortRef.current?.abort() : onClose())}
|
||||||
|
>
|
||||||
|
{t("downloads.actions.cancel")}
|
||||||
|
</Button>
|
||||||
|
<Button variant="primary" size="sm" onClick={submit} disabled={!file || busy}>
|
||||||
|
{t("downloads.upload.submit")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -21,8 +21,20 @@
|
|||||||
"addUrl": "Paste a YouTube link or video id…",
|
"addUrl": "Paste a YouTube link or video id…",
|
||||||
"add": "Add",
|
"add": "Add",
|
||||||
"paste": "Paste link from clipboard",
|
"paste": "Paste link from clipboard",
|
||||||
|
"upload": "Upload",
|
||||||
"manage": "Manage formats"
|
"manage": "Manage formats"
|
||||||
},
|
},
|
||||||
|
"upload": {
|
||||||
|
"title": "Upload a file",
|
||||||
|
"hint": "Any file type. Video and audio can be played and edited; other files are download-only.",
|
||||||
|
"drop": "Drag a file here, or click to choose",
|
||||||
|
"dropActive": "Drop to upload",
|
||||||
|
"nameLabel": "Name (optional)",
|
||||||
|
"namePlaceholder": "Leave blank to use the file name",
|
||||||
|
"submit": "Upload",
|
||||||
|
"uploading": "Uploading… {{pct}}%",
|
||||||
|
"added": "File uploaded"
|
||||||
|
},
|
||||||
"tabs": {
|
"tabs": {
|
||||||
"queue": "Queue",
|
"queue": "Queue",
|
||||||
"done": "Library",
|
"done": "Library",
|
||||||
@@ -212,6 +224,10 @@
|
|||||||
"source_not_ready": "The source download isn't ready to edit.",
|
"source_not_ready": "The source download isn't ready to edit.",
|
||||||
"generic": "That edit couldn't be created."
|
"generic": "That edit couldn't be created."
|
||||||
},
|
},
|
||||||
|
"upload": {
|
||||||
|
"tooLarge": "That file is too large (max {{size}}).",
|
||||||
|
"empty": "That file is empty."
|
||||||
|
},
|
||||||
"fail": {
|
"fail": {
|
||||||
"title": "Download failed",
|
"title": "Download failed",
|
||||||
"detailsLink": "details",
|
"detailsLink": "details",
|
||||||
|
|||||||
@@ -21,8 +21,20 @@
|
|||||||
"addUrl": "Illessz be egy YouTube linket vagy videó azonosítót…",
|
"addUrl": "Illessz be egy YouTube linket vagy videó azonosítót…",
|
||||||
"add": "Hozzáad",
|
"add": "Hozzáad",
|
||||||
"paste": "Link beillesztése a vágólapról",
|
"paste": "Link beillesztése a vágólapról",
|
||||||
|
"upload": "Feltöltés",
|
||||||
"manage": "Formátumok kezelése"
|
"manage": "Formátumok kezelése"
|
||||||
},
|
},
|
||||||
|
"upload": {
|
||||||
|
"title": "Fájl feltöltése",
|
||||||
|
"hint": "Bármilyen fájltípus. A videó és a hang lejátszható és szerkeszthető; a többi fájl csak letölthető.",
|
||||||
|
"drop": "Húzz ide egy fájlt, vagy kattints a kiválasztáshoz",
|
||||||
|
"dropActive": "Engedd el a feltöltéshez",
|
||||||
|
"nameLabel": "Név (opcionális)",
|
||||||
|
"namePlaceholder": "Hagyd üresen a fájlnév használatához",
|
||||||
|
"submit": "Feltöltés",
|
||||||
|
"uploading": "Feltöltés… {{pct}}%",
|
||||||
|
"added": "Fájl feltöltve"
|
||||||
|
},
|
||||||
"tabs": {
|
"tabs": {
|
||||||
"queue": "Sor",
|
"queue": "Sor",
|
||||||
"done": "Könyvtár",
|
"done": "Könyvtár",
|
||||||
@@ -212,6 +224,10 @@
|
|||||||
"source_not_ready": "A forrásletöltés még nem szerkeszthető.",
|
"source_not_ready": "A forrásletöltés még nem szerkeszthető.",
|
||||||
"generic": "Ezt a vágást nem sikerült létrehozni."
|
"generic": "Ezt a vágást nem sikerült létrehozni."
|
||||||
},
|
},
|
||||||
|
"upload": {
|
||||||
|
"tooLarge": "Ez a fájl túl nagy (legfeljebb {{size}}).",
|
||||||
|
"empty": "Ez a fájl üres."
|
||||||
|
},
|
||||||
"fail": {
|
"fail": {
|
||||||
"title": "A letöltés nem sikerült",
|
"title": "A letöltés nem sikerült",
|
||||||
"detailsLink": "részletek",
|
"detailsLink": "részletek",
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
// so the runtime graph is a clean barrel → domain → core with no cycles.
|
// so the runtime graph is a clean barrel → domain → core with no cycles.
|
||||||
import { notify, remove } from "../notifications";
|
import { notify, remove } from "../notifications";
|
||||||
import i18n from "../../i18n";
|
import i18n from "../../i18n";
|
||||||
|
import { formatBytes } from "../format";
|
||||||
import { reportError } from "../errorDialog";
|
import { reportError } from "../errorDialog";
|
||||||
import { activeAccountId, ACTIVE_ACCOUNT_KEY, notifyActiveAccountChange } from "../activeAccount";
|
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"));
|
return t(`downloads.errors.edit.${o.reason}`, t("downloads.errors.edit.generic"));
|
||||||
if (o?.code === "cookies")
|
if (o?.code === "cookies")
|
||||||
return t(`downloads.errors.cookies.${o.reason}`, t("downloads.errors.cookies.generic"));
|
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");
|
return t("errors.generic");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -235,3 +240,67 @@ export async function req<T = unknown>(
|
|||||||
return (r.status === 204 ? null : await r.json()) as T;
|
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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
// Download-center domain: the yt-dlp job lifecycle, per-user profiles, share links/recipients, the
|
// Download-center domain: the yt-dlp job lifecycle, per-user profiles, share links/recipients, the
|
||||||
// phase-2 editor, storyboards, and the admin storage/quota views. Self-contained — its types only
|
// phase-2 editor, storyboards, and the admin storage/quota views. Self-contained — its types only
|
||||||
// reference each other, and its methods only touch the shared `req`/`getActiveAccount` from core.
|
// reference each other, and its methods only touch the shared `req`/`getActiveAccount` from core.
|
||||||
import { req, getActiveAccount } from "./core";
|
import { req, uploadReq, getActiveAccount } from "./core";
|
||||||
|
|
||||||
export interface DownloadSpec {
|
export interface DownloadSpec {
|
||||||
mode: "av" | "v" | "a"; // audio+video | video-only | audio-only
|
mode: "av" | "v" | "a"; // audio+video | video-only | audio-only
|
||||||
@@ -183,6 +183,21 @@ export const downloadsApi = {
|
|||||||
spec?: DownloadSpec;
|
spec?: DownloadSpec;
|
||||||
display_name?: string;
|
display_name?: string;
|
||||||
}): Promise<DownloadJob> => req("/api/downloads", { method: "POST", body: JSON.stringify(body) }),
|
}): Promise<DownloadJob> => req("/api/downloads", { method: "POST", body: JSON.stringify(body) }),
|
||||||
|
// Ingest a local file into the library (E7 S5). Multipart via XHR so the dialog can show upload
|
||||||
|
// progress; any file type is accepted (media gets the full function set, a generic file is
|
||||||
|
// download-only). Returns the already-`done` job the ingest creates.
|
||||||
|
uploadDownload: (
|
||||||
|
file: File,
|
||||||
|
opts: { displayName?: string; onProgress?: (pct: number) => void; signal?: AbortSignal } = {},
|
||||||
|
): Promise<DownloadJob> => {
|
||||||
|
const form = new FormData();
|
||||||
|
form.append("file", file);
|
||||||
|
if (opts.displayName) form.append("display_name", opts.displayName);
|
||||||
|
return uploadReq<DownloadJob>("/api/downloads/upload", form, {
|
||||||
|
onProgress: opts.onProgress,
|
||||||
|
signal: opts.signal,
|
||||||
|
});
|
||||||
|
},
|
||||||
enqueueEdit: (body: {
|
enqueueEdit: (body: {
|
||||||
source_job_id: number;
|
source_job_id: number;
|
||||||
edit_spec: EditSpec;
|
edit_spec: EditSpec;
|
||||||
|
|||||||
Reference in New Issue
Block a user