diff --git a/frontend/src/components/DownloadCenter.tsx b/frontend/src/components/DownloadCenter.tsx index 2ac0ad6d..0f2cb7b5 100644 --- a/frontend/src/components/DownloadCenter.tsx +++ b/frontend/src/components/DownloadCenter.tsx @@ -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(null); + const [upload, setUpload] = useState(false); const [manage, setManage] = useState(false); const [metaJob, setMetaJob] = useState(null); const [shareJob, setShareJob] = useState(null); @@ -207,6 +210,16 @@ export default function DownloadCenter() { > {t("downloads.page.add")} + {/* Local upload is a peer to "add a URL": a general file → the same library (E7 S5c). */} +
{queue.map((job) => ( @@ -406,6 +419,7 @@ export default function DownloadCenter() { }} /> )} + {upload && setUpload(false)} />} {manage && setManage(false)} />} {metaJob && setMetaJob(null)} />} {shareJob && setShareJob(null)} />} diff --git a/frontend/src/components/UploadDialog.dom.test.tsx b/frontend/src/components/UploadDialog.dom.test.tsx new file mode 100644 index 00000000..4259b031 --- /dev/null +++ b/frontend/src/components/UploadDialog.dom.test.tsx @@ -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(); + 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( + + + , + ); +}; + +// 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); + }); +}); diff --git a/frontend/src/components/UploadDialog.tsx b/frontend/src/components/UploadDialog.tsx new file mode 100644 index 00000000..4edbdd85 --- /dev/null +++ b/frontend/src/components/UploadDialog.tsx @@ -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(null); + const [name, setName] = useState(""); + const [dragOver, setDragOver] = useState(false); + const [pct, setPct] = useState(null); // null = idle; 0–100 while uploading + const inputRef = useRef(null); + const abortRef = useRef(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 ( + +

{t("downloads.upload.hint")}

+ + {/* Drop zone doubles as the picker trigger — a click anywhere opens the native file dialog. */} + + { + const f = e.target.files?.[0]; + if (f) { + setFile(f); + setName(""); + } + e.target.value = ""; // let re-picking the same file fire onChange again + }} + /> + + + setName(e.target.value)} + placeholder={t("downloads.upload.namePlaceholder")} + disabled={busy} + className={`${inputCls} mb-5`} + /> + + {busy && ( +
+
+
+
+

+ {t("downloads.upload.uploading", { pct: pct ?? 0 })} +

+
+ )} + +
+ + +
+ + ); +} diff --git a/frontend/src/i18n/locales/en/downloads.json b/frontend/src/i18n/locales/en/downloads.json index b96178a8..06c7cd21 100644 --- a/frontend/src/i18n/locales/en/downloads.json +++ b/frontend/src/i18n/locales/en/downloads.json @@ -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", diff --git a/frontend/src/i18n/locales/hu/downloads.json b/frontend/src/i18n/locales/hu/downloads.json index 98149116..c4e03813 100644 --- a/frontend/src/i18n/locales/hu/downloads.json +++ b/frontend/src/i18n/locales/hu/downloads.json @@ -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", diff --git a/frontend/src/lib/api/core.ts b/frontend/src/lib/api/core.ts index 82e125f5..c3bf4e0a 100644 --- a/frontend/src/lib/api/core.ts +++ b/frontend/src/lib/api/core.ts @@ -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( 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( + url: string, + form: FormData, + cfg: { onProgress?: (pct: number) => void; signal?: AbortSignal } = {}, +): Promise { + return new Promise((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); + }); +} diff --git a/frontend/src/lib/api/downloads.ts b/frontend/src/lib/api/downloads.ts index cec665cd..ed8ca53c 100644 --- a/frontend/src/lib/api/downloads.ts +++ b/frontend/src/lib/api/downloads.ts @@ -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 => 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 => { + const form = new FormData(); + form.append("file", file); + if (opts.displayName) form.append("display_name", opts.displayName); + return uploadReq("/api/downloads/upload", form, { + onProgress: opts.onProgress, + signal: opts.signal, + }); + }, enqueueEdit: (body: { source_job_id: number; edit_spec: EditSpec;