Deep-review remediation on the frontend, all findings adversarially verified: - LibraryPlayer picks audio vs video from the canonical media_kind (single source), not a hardcoded container whitelist, so an uploaded audio file with an uncommon/absent container renders in <audio> instead of a broken video box (legacy null media_kind keeps the heuristic). - UploadDialog aborts the in-flight upload when the dialog is closed by X/ESC/backdrop/Back (not only the footer Cancel); drag highlight uses an enter/leave depth counter to stop the flicker; the progress bar gets a stable role=progressbar aria-label with the value on aria-valuenow. - DownloadCenter's Shared tab renders honest loading/error states instead of a blank panel, and the library/shared row-action gating moves to a single tested helper (lib/downloadActions). Tests: downloadActions gating matrix, the uploadReq XHR path (abort/401/422-localize/onerror), and the media_kind-driven audio/video branch. i18n: downloads.upload.uploadProgress (HU + EN).
176 lines
6.5 KiB
TypeScript
176 lines
6.5 KiB
TypeScript
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<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);
|
||
// 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 (
|
||
<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}
|
||
onDragEnter={(e) => {
|
||
e.preventDefault();
|
||
dragDepth.current += 1;
|
||
setDragOver(true);
|
||
}}
|
||
onDragOver={(e) => e.preventDefault()}
|
||
onDragLeave={() => {
|
||
dragDepth.current = Math.max(0, dragDepth.current - 1);
|
||
if (dragDepth.current === 0) setDragOver(false);
|
||
}}
|
||
onDrop={(e) => {
|
||
e.preventDefault();
|
||
dragDepth.current = 0;
|
||
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"
|
||
role="progressbar"
|
||
aria-valuenow={pct ?? 0}
|
||
aria-valuemin={0}
|
||
aria-valuemax={100}
|
||
// A STABLE descriptor as the name; the live percentage rides aria-valuenow (and the visible
|
||
// <p> below re-announces it via the aria-live wrapper) — don't bake the value into the name.
|
||
aria-label={t("downloads.upload.uploadProgress")}
|
||
>
|
||
<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>
|
||
);
|
||
}
|