- A tag counts the followed channels the Subscriptions tab shows: hidden ones are left out, and a subscribe, unsubscribe, hide or tag toggle refreshes the count. The Tag manager's row and its hover list count the same channels as the chip; /api/tags sends both counts under their names. - A chip's count keeps the chip's colour after a dot (a dimmed count fell under 4.5:1). A pick in the open "+N" fold closes it; a chip that leaves the list hands the focus to the one in its place. The fold is a dialog of toggle buttons, not an empty menu. - Upload: a folder name left by a press is made once the press is over, if the dialog is still open, instead of guessing a dismissal from Modal's markup. While the folder is made, its place holds the focus and a live region says so. - Share: an expired link has no Copy and a warn badge; the link made on open goes into the cached list, so a reopen makes no second one; the New link form survives the link made on open. - Watch: a repeated Enter sends one unlock while one is on its way. See all buttons are named with their rail. - An unsized clip of a rotated file gets the upright size its crop cut.
370 lines
17 KiB
TypeScript
370 lines
17 KiB
TypeScript
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<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();
|
||
|
||
// 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<HTMLDivElement>(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<FileFolder[]>(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 (
|
||
<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}
|
||
/>
|
||
|
||
<p className="block text-sm font-medium mt-4 mb-1">{t("downloads.upload.targetLabel")}</p>
|
||
{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.
|
||
<StateMessage tone="error" compact onRetry={() => void foldersQ.refetch()}>
|
||
{t("downloads.upload.targetFailed")}
|
||
</StateMessage>
|
||
) : naming ? (
|
||
<RenameField
|
||
initial=""
|
||
label={t("files.folder.newName")}
|
||
onDone={(folder, byKey) => {
|
||
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.
|
||
<div ref={targetBox} tabIndex={-1} className="min-h-8 flex items-center outline-none">
|
||
<span role="status" className="text-sm text-muted">
|
||
{/* Upload waits for it (`targetSettling`): this says why. */}
|
||
{makeFolder.isPending &&
|
||
t("downloads.upload.makingFolder", { name: makeFolder.variables })}
|
||
</span>
|
||
{foldersQ.data && !busy && !makeFolder.isPending && (
|
||
<FilterSelect
|
||
hideLabel
|
||
label={t("downloads.upload.targetLabel")}
|
||
options={targetOptions}
|
||
selected={[targetValue]}
|
||
onChange={pickTarget}
|
||
testId="upload-target"
|
||
/>
|
||
)}
|
||
{busy && (
|
||
<span className="text-sm text-muted">
|
||
{targetOptions.find((o) => o.value === targetValue)?.label}
|
||
</span>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{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 || targetSettling}
|
||
aria-describedby={tooBig ? refusalId : undefined}
|
||
>
|
||
{t("downloads.upload.submit")}
|
||
</Button>
|
||
</ModalFooter>
|
||
</Modal>
|
||
);
|
||
}
|