import { useEffect, useId, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { useMutation, 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, type FileFolder } 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 { msg, 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"; import FilterSelect from "../../components/ui/FilterSelect"; import { RenameField } from "../../components/ui/RenameField"; import { StateMessage } from "../../components/ui/QueryState"; import { folderLabel, takesUploads } from "../../features/folders/fileFolders"; // 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); // What stands in the target list for Uploads before the user has that folder (the first upload makes // it), and for the row that makes a folder. const UPLOADS = "uploads"; const NEW_FOLDER = "new"; const isUploads = (f: FileFolder) => f.kind === "source" && f.source_key === "upload"; 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(); // Where it goes: the folder the dialog was opened on, until the user says otherwise. Null = Uploads. const [target, setTarget] = useState(folderId); const foldersQ = useQuery({ queryKey: qk.fileFolders(), queryFn: api.fileFolders }); const [naming, setNaming] = useState(false); const targetBox = useRef(null); const refocusTarget = useRef(false); // The name field saves on blur, and a press blurs it before the press does anything: a press on // Cancel, the ✕ or the backdrop that then closes the dialog is "never mind", not "make it". So a // name left by a press is made once the press is over, and only if the dialog is still here — its // close unmounts this and drops the wait. A name left without a press (Tab) is made at once. const pressing = useRef(false); const afterPress = useRef<(() => void) | null>(null); useEffect(() => { let wait = 0; const down = () => { pressing.current = true; }; const up = () => { pressing.current = false; const run = afterPress.current; afterPress.current = null; // After the click the press ends in, where a dismissal closes the dialog. if (run) wait = window.setTimeout(run); }; document.addEventListener("pointerdown", down, true); document.addEventListener("pointerup", up, true); document.addEventListener("pointercancel", up, true); return () => { document.removeEventListener("pointerdown", down, true); document.removeEventListener("pointerup", up, true); document.removeEventListener("pointercancel", up, true); window.clearTimeout(wait); afterPress.current = null; }; }, []); const makeFolder = useMutation({ mutationFn: (folder: string) => api.createFolder(folder), onSuccess: (made) => { // In the list at once, so the target reads its name rather than waiting for the refetch. qc.setQueryData(qk.fileFolders(), (old) => [...(old ?? []), made]); invalidateDownloads(qc); setTarget(made.id); }, meta: { failTitle: "errors.writeFailed.folder.create" }, }); // A new folder's name ended by a key leaves the keyboard's focus nowhere: back on the list once it // is back, as the folder is made (or fails). Until then on its place, which says it is being made. useEffect(() => { if (naming || !refocusTarget.current) return; const box = targetBox.current; const list = box?.querySelector("button"); if (!list) return box?.focus(); refocusTarget.current = false; list.focus(); }, [naming, makeFolder.isPending]); // Not while a folder is being named or made: the file would go to the last target, not that one. const targetSettling = naming || makeFolder.isPending; const targets = (foldersQ.data ?? []).filter(takesUploads); const uploadsRow = targets.find(isUploads); const targetOptions = [ ...(uploadsRow ? [] : [{ value: UPLOADS, label: t("files.source.upload") }]), ...[...targets] .sort((a, b) => Number(isUploads(b)) - Number(isUploads(a))) .map((f) => ({ value: String(f.id), label: folderLabel(f, t) })), { value: NEW_FOLDER, label: t("files.folder.new") }, ]; const targetValue = String(target ?? uploadsRow?.id ?? UPLOADS); const pickTarget = ([value]: string[]) => { // After the menu has closed: it hands the focus back as it goes, which took it off a field already // there, and a name field that loses the focus is done, with no name. if (value === NEW_FOLDER) { window.setTimeout(() => setNaming(true)); } else if (value === UPLOADS) setTarget(null); else setTarget(Number(value)); }; // 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 || targetSettling) 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: target, 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: msg(uploadedKey(job.status)), action: { label: msg("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, msg("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} />

{t("downloads.upload.targetLabel")}

{foldersQ.isError && !foldersQ.data ? ( // The file still goes where the dialog was opened on: only the list could not be read. A // failed REFETCH keeps the list it had (react-query sets isError beside the data): the // target the file goes to stays shown, as the rail does it. void foldersQ.refetch()}> {t("downloads.upload.targetFailed")} ) : naming ? ( { refocusTarget.current = byKey; setNaming(false); const name = folder?.trim(); if (!name) return; if (!byKey && pressing.current) afterPress.current = () => makeFolder.mutate(name); else makeFolder.mutate(name); }} /> ) : ( // The select's own height held while the list loads, so it does not push the form down. // It holds the keyboard's focus while a folder is made (tabIndex -1: never a Tab stop), and // says it in a live region, as the list it replaces is gone until then.
{/* Upload waits for it (`targetSettling`): this says why. */} {makeFolder.isPending && t("downloads.upload.makingFolder", { name: makeFolder.variables })} {foldersQ.data && !busy && !makeFolder.isPending && ( )} {busy && ( {targetOptions.find((o) => o.value === targetValue)?.label} )}
)} {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. */} ); }