import { useEffect, useId, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { UploadCloud, File as FileIcon } from "lucide-react"; import Modal, { ModalFooter } from "./ui/Modal"; import { Button } from "./ui/Button"; import { api } from "../lib/api"; import { qk } from "../lib/queryKeys"; import { isAborted } from "../lib/api/core"; import { notifyActionError } from "../lib/actionErrors"; import { invalidateDownloads } from "../lib/downloads"; import { notify } from "../lib/notifications"; import { navigateTo } from "../lib/nav"; import { formatBytes } from "../lib/format"; import { DISPLAY_NAME_MAX } from "../lib/api/downloads"; import { Pressable } from "./ui/Pressable"; import { Input } from "./ui/Input"; // 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, i18n } = useTranslation(); const lang = i18n.language; 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; // The per-file cap, so an over-cap file is refused HERE instead of after the user has pushed it up // the wire. Both server-side caps are still the boundary — this is only the pleasant half of it. // On a default install the one that answers is the edge ceiling (`bodylimit`), whose refusal // carries no number, so without this the user would be told "too large" and not what the limit is. // A cap that has not loaded yet blocks nothing — the server still refuses, honestly. const cap = useQuery({ queryKey: qk.downloadUsage(), queryFn: api.downloadUsage }).data ?.upload_max_bytes; const tooBig = !!file && !!cap && file.size > cap; const refusalId = useId(); // Every pick gets a new number, so the refusal below REMOUNTS rather than being reconciled. Two // over-cap picks in a row leave the node and its text identical — the message names the cap, not // the file — and a live region that is not touched is not announced, so the second refusal was // silent to a screen reader. `SetupWizard` and `Welcome` both key their alerts for the // same reason, and `formAlerts.dom.test.tsx` is the test that exists because of it. const [pickSeq, setPickSeq] = useState(0); const pick_ = (f: File) => { setFile(f); setName(""); setPickSeq((n) => n + 1); }; // 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 () => { // `tooBig` here is belt and braces, NOT the mechanism: `ui/Button` is focus-safe, so while the // button holds focus it swallows the activation itself, and while it does not it is natively // disabled. Measured — deleting this term breaks nothing, which is exactly why it must not be // described as the guard. It is here for the next caller of `submit`, not for the only one. if (!file || busy || tooBig) return; const ac = new AbortController(); abortRef.current = ac; setPct(0); const displayName = name.trim() || undefined; try { await api.uploadDownload(file, { displayName, 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 (e) { // Everything except the user's own Cancel is said out loud. `uploadReq` shares `reportFailure` // with `req`, which deliberately leaves 403/404/429 to the caller and answers a 502/503/504 // with only the transient "connection lost" notice — so this empty catch meant a proxy 502 on // a long upload, or an nginx 413, snapped the dialog back to its pre-upload state with nothing // named and no job in the list. The comment here claimed the global error modal had it; it // had not since the shared `reportFailure` landed (deep-review round 5). if (!isAborted(e)) notifyActionError(e, t("downloads.upload.failed")); // 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. */} { 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) pick_(f); }} disabled={busy} // The refusal is tied to the control it invalidates, not left to a one-shot announcement: // the drop zone reads as invalid and points at the sentence, so a reader who tabs back to // it hears WHY. (Measured, because `aria-invalid` is not in `button`'s ARIA-supported // list: Chromium's accessibility node for a native button does carry `invalid` from this // attribute.) The signal that is NOT colour is the sentence below, visible to everyone — // the border only reinforces it. aria-invalid={tooBig} aria-describedby={tooBig ? refusalId : undefined} // `dragOver` outranks `tooBig`: a drag is transient and the refusal repaints the instant // the drop lands, whereas a zone that stays red under the pointer tells the user their // drop target is dead — in the one state where they are trying to replace the file. With // a file picked, the label below shows its name rather than `dropActive`, so this border // is the ONLY feedback that the zone will accept the drop. 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" : tooBig ? "border-danger" : "border-border hover:border-fg/40" }`} > {file ? ( <> {file.name} {formatBytes(file.size, lang)} ) : ( <> {dragOver ? t("downloads.upload.dropActive") : t("downloads.upload.drop")} )} { const f = e.target.files?.[0]; if (f) pick_(f); e.target.value = ""; // let re-picking the same file fire onChange again }} /> {tooBig && ( )} setName(e.target.value)} placeholder={t("downloads.upload.namePlaceholder")} disabled={busy} // The column behind it is varchar(255) and the name rides in a REQUEST HEADER, where // nothing else bounds it; the server truncates to the same number. The number is not a // third copy: `downloads.contract.test.ts` holds DISPLAY_NAME_MAX against the backend's // own DISPLAY_TEXT_MAX. maxLength={DISPLAY_NAME_MAX} /> {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 })}

)} {/* Plain `disabled` — `ui/Button` is a FocusSafeButton, which already expresses it as `aria-disabled` + a click guard for as long as the button HOLDS focus, so this cannot strand a keyboard user on when the cap arrives late or a second file is dropped. `submit` refuses independently, which is what that soft state relies on. The REASON is reachable from the drop zone (aria-invalid + aria-describedby above) rather than from here, because a natively disabled button is not in the tab order to be asked. */} ); }