import { useEffect, 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); // dragenter/dragleave fire as the cursor crosses the drop zone's OWN children (icon/text), so a // plain boolean flickers. Count enter vs leave and only clear the highlight at depth 0. const dragDepth = useRef(0); const busy = pct !== null; // Closing the dialog mid-upload (X / ESC / backdrop / browser Back all unmount us) must abort the // in-flight XHR — otherwise it keeps streaming in the background and still toasts success. Only the // footer Cancel wired the abort; this covers every other dismissal. useEffect(() => () => abortRef.current?.abort(), []); 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 && (
below re-announces it via the aria-live wrapper) — don't bake the value into the name. aria-label={t("downloads.upload.uploadProgress")} >

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

)}
); }