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:
@@ -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")}
|
||||
|
||||
Reference in New Issue
Block a user