feat(files): the upload dialog says where the file goes, and sharing copies on open

The upload dialog shows its target folder and can change it, with New folder in the same list; a
file never goes while a folder is being named or made. The share dialog puts the link on the clipboard
when it opens, says so only if the browser let it, and has Open in new tab beside Copy. A file's
format joins the line under its name.
This commit is contained in:
2026-11-04 19:16:20 +01:00
parent 4c09a22add
commit 6e6e47652a
10 changed files with 380 additions and 16 deletions
+95 -5
View File
@@ -1,10 +1,10 @@
import { useEffect, useId, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { useQuery, useQueryClient } from "@tanstack/react-query";
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 } from "../../lib/api";
import { api, type FileFolder } from "../../lib/api";
import { qk } from "../../lib/queryKeys";
import { isAborted } from "../../lib/api/core";
import { notifyActionError } from "../../lib/actionErrors";
@@ -17,6 +17,10 @@ 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
@@ -30,6 +34,12 @@ 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,
@@ -65,6 +75,48 @@ export default function UploadDialog({
// 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);
// A new folder's name ended by a key leaves the keyboard's focus nowhere: back on the list.
const refocusTarget = useRef(false);
useEffect(() => {
if (naming || !refocusTarget.current) return;
refocusTarget.current = false;
targetBox.current?.querySelector("button")?.focus();
}, [naming]);
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" },
});
// 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);
@@ -86,7 +138,7 @@ export default function UploadDialog({
// 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;
if (!file || busy || tooBig || targetSettling) return;
const ac = new AbortController();
abortRef.current = ac;
setPct(0);
@@ -95,7 +147,7 @@ export default function UploadDialog({
try {
const job = await api.uploadFile(file, {
displayName,
folderId,
folderId: target,
onProgress: setPct,
signal: ac.signal,
});
@@ -182,6 +234,44 @@ export default function UploadDialog({
maxLength={DISPLAY_NAME_MAX}
/>
<p id="upload-target-label" className="block text-sm font-medium mt-4 mb-1">
{t("downloads.upload.targetLabel")}
</p>
{foldersQ.isError ? (
// The file still goes where the dialog was opened on: only the list could not be read.
<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);
if (folder?.trim()) makeFolder.mutate(folder.trim());
}}
/>
) : (
<div ref={targetBox} aria-labelledby="upload-target-label">
{foldersQ.data && !busy && (
<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
@@ -223,7 +313,7 @@ export default function UploadDialog({
variant="primary"
size="sm"
onClick={submit}
disabled={!file || busy || tooBig}
disabled={!file || busy || tooBig || targetSettling}
aria-describedby={tooBig ? refusalId : undefined}
>
{t("downloads.upload.submit")}