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
+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>
);
}