Files
siftlode/frontend/src/modules/files/UploadDialog.tsx
T
peter 4d1cf66d96 fix: tag counts, chip focus, upload naming, share links and unsized clips hold after round 2
- 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.
2026-11-06 02:20:52 +01:00

370 lines
17 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 { 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>
);
}