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 "../../components/ui/Modal"; import { Button } from "../../components/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 { showInTray, uploadEnded, uploadStarted } from "../../lib/downloadQueue"; import type { DownloadJob } from "../../lib/api"; import { useFilesActions } from "./filesContext"; import { formatBytes } from "../../lib/format"; import { DISPLAY_NAME_MAX } from "../../lib/api/downloads"; import { DropZone } from "../../components/ui/DropZone"; import { Input } from "../../components/ui/Input"; // Local-file ingest. 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 (Files) as an already-`done` job — or, for media no phone could play as it is (an // iPhone's HEVC, a webm, an mkv), as a `queued` job the worker converts first — so we invalidate, // say which, and point the user there. // Out here, not inline in the upload handler: a `?:` inside its `try` makes the React Compiler // skip the whole dialog. const uploadedKey = (status: string) => status === "done" ? "downloads.upload.added" : "downloads.upload.addedConverting"; // The job the upload's own notice announces (a done one), for the queue not to say it again. const announces = (job: { id: number; status: string }) => (job.status === "done" ? job.id : null); export default function UploadDialog({ folderId = null, initialFile = null, onClose, }: { /** The library folder the file goes into (an own folder, or the Uploads folder); null = Uploads. */ folderId?: number | null; /** A file already chosen — dropped on the Files page — so the dialog opens ready to send it. */ initialFile?: File | null; onClose: () => void; }) { const { t, i18n } = useTranslation(); const lang = i18n.language; const qc = useQueryClient(); const [file, setFile] = useState(initialFile); const [name, setName] = useState(""); const [pct, setPct] = useState(null); // null = idle; 0–100 while uploading const abortRef = useRef(null); 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.storageUsage(), queryFn: api.filesUsage }).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 { openFile } = useFilesActions(); // A done file is in Files, opened on it; one the worker still converts is in the tray until it is. const openUploaded = (job: DownloadJob) => { if (job.status === "done") openFile(job.id, job.folder_id ?? null); else showInTray(job.id); }; 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 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; uploadStarted(); try { const job = await api.uploadFile(file, { displayName, folderId, onProgress: setPct, signal: ac.signal, }); // Said here, so the queue does not say it again; one the worker still converts is the tray's // to announce when it is done. uploadEnded(announces(job)); invalidateDownloads(qc); notify({ level: "success", message: t(uploadedKey(job.status)), action: { label: t("downloads.dialog.view"), onClick: () => openUploaded(job), }, }); 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. uploadEnded(null); 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. With a file picked, its label shows the file's name rather than `dropActive`, so the zone's drag border is the only feedback that it will take a replacement. */} pick_(f)} disabled={busy} invalid={tooBig} describedBy={tooBig ? refusalId : undefined} > {(dragOver) => file ? ( <> {file.name} {formatBytes(file.size, lang)} ) : ( <> {dragOver ? t("downloads.upload.dropActive") : t("downloads.upload.drop")} ) } {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. */} ); }