Files
siftlode/frontend/src/modules/files/UploadDialog.tsx
T

235 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 { 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";
// 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<File | null>(initialFile);
const [name, setName] = useState("");
const [pct, setPct] = useState<number | null>(null); // null = idle; 0–100 while uploading
const abortRef = useRef<AbortController | null>(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: msg(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, msg("downloads.upload.failed"));
// Re-arm the form so the user can retry or pick again.
setPct(null);
abortRef.current = null;
}
};
return (
<Modal initialFocus="field" 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.
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. */}
<DropZone
className="px-4 py-8"
onFiles={([f]) => pick_(f)}
disabled={busy}
invalid={tooBig}
describedBy={tooBig ? refusalId : undefined}
>
{(dragOver) =>
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, lang)}</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>
</>
)
}
</DropZone>
{tooBig && (
<p key={pickSeq} id={refusalId} className="text-xs text-danger mt-2" role="alert">
{t("downloads.errors.upload.tooLarge", { size: formatBytes(cap ?? 0, lang) })}
</p>
)}
<label htmlFor="upload-display-name" className="block text-sm font-medium mt-4 mb-1">
{t("downloads.upload.nameLabel")}
</label>
<Input
className="w-full"
id="upload-display-name"
value={name}
onChange={(e) => 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 && (
<div className="mt-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>
)}
<ModalFooter>
<Button
variant="ghost"
size="sm"
onClick={() => (busy ? abortRef.current?.abort() : onClose())}
>
{t("downloads.actions.cancel")}
</Button>
{/* 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 <body> 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. */}
<Button
variant="primary"
size="sm"
onClick={submit}
disabled={!file || busy || tooBig}
aria-describedby={tooBig ? refusalId : undefined}
>
{t("downloads.upload.submit")}
</Button>
</ModalFooter>
</Modal>
);
}