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:
2026-08-29 18:53:53 +02:00
parent b4ad59274b
commit 584e9ced0c
7 changed files with 358 additions and 1 deletions
@@ -17,6 +17,7 @@ import {
Share2,
Pencil,
Trash2,
UploadCloud,
} from "lucide-react";
import Tabs from "./Tabs";
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
// used (the video editor in particular is heavy — filmstrip + scrubber).
const DownloadDialog = lazy(() => import("./DownloadDialog"));
const UploadDialog = lazy(() => import("./UploadDialog"));
const ProfileEditor = lazy(() => import("./ProfileEditor"));
const VideoEditor = lazy(() => import("./VideoEditor"));
const ShareDialog = lazy(() => import("./ShareDialog"));
@@ -49,6 +51,7 @@ export default function DownloadCenter() {
const [tab, setTab] = useState("queue");
const [addUrl, setAddUrl] = useState("");
const [addSource, setAddSource] = useState<string | null>(null);
const [upload, setUpload] = useState(false);
const [manage, setManage] = useState(false);
const [metaJob, setMetaJob] = useState<DownloadJob | null>(null);
const [shareJob, setShareJob] = useState<DownloadJob | null>(null);
@@ -207,6 +210,16 @@ export default function DownloadCenter() {
>
{t("downloads.page.add")}
</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 className="space-y-2">
{queue.map((job) => (
@@ -406,6 +419,7 @@ export default function DownloadCenter() {
}}
/>
)}
{upload && <UploadDialog onClose={() => setUpload(false)} />}
{manage && <ProfileEditor onClose={() => setManage(false)} />}
{metaJob && <MetaEditModal job={metaJob} onClose={() => setMetaJob(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);
});
});
+152
View File
@@ -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…",
"add": "Add",
"paste": "Paste link from clipboard",
"upload": "Upload",
"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": {
"queue": "Queue",
"done": "Library",
@@ -212,6 +224,10 @@
"source_not_ready": "The source download isn't ready to edit.",
"generic": "That edit couldn't be created."
},
"upload": {
"tooLarge": "That file is too large (max {{size}}).",
"empty": "That file is empty."
},
"fail": {
"title": "Download failed",
"detailsLink": "details",
@@ -21,8 +21,20 @@
"addUrl": "Illessz be egy YouTube linket vagy videó azonosítót…",
"add": "Hozzáad",
"paste": "Link beillesztése a vágólapról",
"upload": "Feltöltés",
"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": {
"queue": "Sor",
"done": "Könyvtár",
@@ -212,6 +224,10 @@
"source_not_ready": "A forrásletöltés még nem szerkeszthető.",
"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": {
"title": "A letöltés nem sikerült",
"detailsLink": "részletek",
+69
View File
@@ -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);
});
}
+16 -1
View File
@@ -1,7 +1,7 @@
// 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
// 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 {
mode: "av" | "v" | "a"; // audio+video | video-only | audio-only
@@ -183,6 +183,21 @@ export const downloadsApi = {
spec?: DownloadSpec;
display_name?: string;
}): 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: {
source_job_id: number;
edit_spec: EditSpec;