From 6e6e47652af5daa80ce60b96e68807026fd834c4 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Wed, 4 Nov 2026 19:16:20 +0100 Subject: [PATCH] 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. --- frontend/src/i18n/locales/en/downloads.json | 2 + frontend/src/i18n/locales/hu/downloads.json | 2 + frontend/src/modules/files/FileCard.tsx | 5 +- frontend/src/modules/files/Files.tsx | 4 +- .../modules/files/ShareDialog.dom.test.tsx | 68 ++++++++++ frontend/src/modules/files/ShareDialog.tsx | 54 +++++++- .../modules/files/UploadDialog.dom.test.tsx | 128 +++++++++++++++++- frontend/src/modules/files/UploadDialog.tsx | 100 +++++++++++++- frontend/src/modules/files/fileMeta.test.ts | 25 ++++ frontend/src/modules/files/fileMeta.ts | 8 ++ 10 files changed, 380 insertions(+), 16 deletions(-) create mode 100644 frontend/src/modules/files/fileMeta.test.ts create mode 100644 frontend/src/modules/files/fileMeta.ts diff --git a/frontend/src/i18n/locales/en/downloads.json b/frontend/src/i18n/locales/en/downloads.json index 3e924f73..9bef9fc1 100644 --- a/frontend/src/i18n/locales/en/downloads.json +++ b/frontend/src/i18n/locales/en/downloads.json @@ -32,6 +32,8 @@ "uploadProgress": "Upload progress", "added": "File uploaded", "addedConverting": "File uploaded — converting it so it plays on any phone", + "targetLabel": "Upload to", + "targetFailed": "The folders could not be loaded", "failed": "Couldn't upload the file" }, "status": { diff --git a/frontend/src/i18n/locales/hu/downloads.json b/frontend/src/i18n/locales/hu/downloads.json index ff26192c..10a10a22 100644 --- a/frontend/src/i18n/locales/hu/downloads.json +++ b/frontend/src/i18n/locales/hu/downloads.json @@ -32,6 +32,8 @@ "uploadProgress": "Feltöltés folyamata", "added": "Fájl feltöltve", "addedConverting": "Fájl feltöltve — átalakítjuk, hogy minden telefonon lejátszható legyen", + "targetLabel": "Feltöltés ide", + "targetFailed": "A mappákat nem sikerült betölteni", "failed": "Nem sikerült feltölteni a fájlt" }, "status": { diff --git a/frontend/src/modules/files/FileCard.tsx b/frontend/src/modules/files/FileCard.tsx index 73a20ecb..ed60f881 100644 --- a/frontend/src/modules/files/FileCard.tsx +++ b/frontend/src/modules/files/FileCard.tsx @@ -6,6 +6,7 @@ import { navItemAttrs } from "../../lib/listNav"; import { JobThumb } from "../../features/downloads/downloadRow"; import { jobTitle } from "../../features/downloads/downloadTitle"; import { PhoneBadge, RetentionBadge, SourceBadge } from "./fileBadges"; +import { fileMeta } from "./fileMeta"; import { MediaCard, MediaPlayGlyph } from "../../components/ui/MediaCard"; import { MEDIA_BADGE, @@ -46,7 +47,7 @@ export function FileCard({ }) { const { t } = useTranslation(); const title = jobTitle(file); - const channel = file.display_uploader || file.asset?.uploader || null; + const meta = fileMeta(file); const clip = file.job_kind === "edit" || file.job_kind === "clip"; const media = file.asset?.media_kind !== "file"; const thumb = ; @@ -102,7 +103,7 @@ export function FileCard({ - {channel && {channel}} + {meta && {meta}} diff --git a/frontend/src/modules/files/Files.tsx b/frontend/src/modules/files/Files.tsx index 5c09474d..ffa54259 100644 --- a/frontend/src/modules/files/Files.tsx +++ b/frontend/src/modules/files/Files.tsx @@ -41,6 +41,7 @@ import { jobTitle } from "../../features/downloads/downloadTitle"; import { FileCard } from "./FileCard"; import { PhoneBadge, RetentionBadge, SourceBadge } from "./fileBadges"; import { folderLabel, takesUploads } from "../../features/folders/fileFolders"; +import { fileMeta } from "./fileMeta"; import { ALL_FILES, useFiles, useFilesActions, viewParam, type FilesView } from "./filesContext"; import { useFileActions } from "./useFileActions"; import { useFolderActions } from "./useFolderActions"; @@ -157,7 +158,6 @@ function NameCell({ onRenamed: (name: string | null, byKey: boolean) => void; }) { const { t } = useTranslation(); - const channel = file.display_uploader || file.asset?.uploader || null; const clip = file.job_kind === "edit" || file.job_kind === "clip"; if (renaming) return ( @@ -180,7 +180,7 @@ function NameCell({ {title} )} - {channel ?? "—"} + {fileMeta(file) || "—"} {clip && ( diff --git a/frontend/src/modules/files/ShareDialog.dom.test.tsx b/frontend/src/modules/files/ShareDialog.dom.test.tsx index 37530a6c..42e6907d 100644 --- a/frontend/src/modules/files/ShareDialog.dom.test.tsx +++ b/frontend/src/modules/files/ShareDialog.dom.test.tsx @@ -24,7 +24,9 @@ vi.mock("../../components/ui/Modal", () => ({ ), })); vi.mock("../../components/ui/confirmContext", () => ({ useConfirm: () => async () => h.confirm })); +vi.mock("../../lib/clipboard", () => ({ copyText: h.copyText })); const h = vi.hoisted(() => ({ + copyText: vi.fn(async (_t: string) => true), confirm: false, revokeDownloadLink: vi.fn(), links: null as unknown[] | null, @@ -72,6 +74,9 @@ afterEach(cleanup); beforeEach(() => { h.links = null; h.confirm = false; + h.createDownloadLink.mockReset(); + h.copyText.mockReset(); + h.copyText.mockResolvedValue(true); }); const iconClass = (button: HTMLElement) => button.querySelector("svg")?.getAttribute("class") ?? ""; @@ -96,6 +101,69 @@ describe("ShareDialog · a link row's state colours", () => { }); }); +describe("ShareDialog · opened to share", () => { + const open = () => { + const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + render( + + {}} /> + , + ); + }; + const row = (id: number, url: string | null) => ({ + id, + token: "t", + url, + allow_download: true, + has_password: false, + expires_at: null, + view_count: 0, + created_at: null, + }); + const origin = window.location.origin; + + it("copies the newest link it can show, and says so", async () => { + h.links = [row(1, null), row(2, "/file/b"), row(3, "/file/c")]; + open(); + await waitFor(() => expect(h.copyText).toHaveBeenCalledWith(`${origin}/file/b`)); + expect(h.copyText).toHaveBeenCalledTimes(1); + expect((await screen.findByRole("status")).textContent).toBe("downloads.share.linkCopied"); + }); + + it("copies the link it made when there was none", async () => { + h.links = []; + h.createDownloadLink.mockResolvedValue(row(4, "/file/new")); + open(); + await waitFor(() => expect(h.copyText).toHaveBeenCalledWith(`${origin}/file/new`)); + }); + + it("claims nothing when the browser refused the copy", async () => { + h.links = [row(2, "/file/b")]; + h.copyText.mockResolvedValue(false); + open(); + await waitFor(() => expect(h.copyText).toHaveBeenCalled()); + await screen.findByRole("button", { name: "downloads.share.copy" }); + expect(screen.queryByRole("status")).toBeNull(); + }); + + it("offers the link in a new tab, beside Copy", async () => { + h.links = [row(2, "/file/b")]; + open(); + const openLink = await screen.findByRole("link", { name: "downloads.share.openNewTab" }); + expect(openLink.getAttribute("href")).toBe(`${origin}/file/b`); + expect(openLink.getAttribute("target")).toBe("_blank"); + expect(openLink.getAttribute("rel")).toContain("noopener"); + }); + + it("has no Open for a link whose address cannot be shown", async () => { + h.links = [row(1, null)]; + open(); + await screen.findByText("downloads.share.urlHidden"); + expect(screen.queryByRole("link", { name: "downloads.share.openNewTab" })).toBeNull(); + expect(h.copyText).not.toHaveBeenCalled(); + }); +}); + describe("ShareDialog · a link whose address cannot be shown again", () => { it("says so in place of the address, with nothing to copy", async () => { const link = (id: number, url: string | null) => ({ diff --git a/frontend/src/modules/files/ShareDialog.tsx b/frontend/src/modules/files/ShareDialog.tsx index 1d00c40e..f260b6f1 100644 --- a/frontend/src/modules/files/ShareDialog.tsx +++ b/frontend/src/modules/files/ShareDialog.tsx @@ -2,7 +2,7 @@ import { useEffect, useRef, useState } from "react"; import { qk } from "../../lib/queryKeys"; import { useTranslation } from "react-i18next"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { Check, Copy, Link2, Lock, Trash2, UserPlus, Eye } from "lucide-react"; +import { Check, Copy, ExternalLink, Link2, Lock, Trash2, UserPlus, Eye } from "lucide-react"; import Modal from "../../components/ui/Modal"; import { useConfirm } from "../../components/ui/confirmContext"; import { msg, notify, raw } from "../../lib/notifications"; @@ -18,6 +18,8 @@ import { } from "../../lib/downloads"; import { Button } from "../../components/ui/Button"; import { IconButton } from "../../components/ui/IconButton"; +import { IconLink } from "../../components/ui/LinkButton"; +import { copyText } from "../../lib/clipboard"; import { Input, InnerInput } from "../../components/ui/Input"; import { CheckRow } from "../../components/ui/NativeControls"; import { SURFACE } from "../../components/ui/surfaces"; @@ -75,7 +77,16 @@ function UserShare({ job }: { job: DownloadJob }) { // --- share by public link ------------------------------------------------------------------- -function LinkRow({ link, onChanged }: { link: ShareLink; onChanged: () => void }) { +function LinkRow({ + link, + copiedOnOpen, + onChanged, +}: { + link: ShareLink; + /** The dialog put this link on the clipboard when it opened. */ + copiedOnOpen: boolean; + onChanged: () => void; +}) { const { t, i18n } = useTranslation(); const confirm = useConfirm(); const { copied, copy: copyValue } = useCopyFeedback(); @@ -124,8 +135,20 @@ function LinkRow({ link, onChanged }: { link: ShareLink; onChanged: () => void } <> - {copied ? : } + {copied || copiedOnOpen ? ( + + ) : ( + + )} + + + ) : ( // The server keeps no copy of this link's address it can read: the link still opens for @@ -175,6 +198,11 @@ function LinkRow({ link, onChanged }: { link: ShareLink; onChanged: () => void } + {copiedOnOpen && ( +

+ {t("downloads.share.linkCopied")} +

+ )}
{t("downloads.share.views", { count: link.view_count })} @@ -243,9 +271,18 @@ function LinkRow({ link, onChanged }: { link: ShareLink; onChanged: () => void } ); } +/** Puts a link on the clipboard for the dialog that just opened, and answers whether it got there: + * a browser may refuse a copy no click asked for, and then nothing may claim it. */ +async function copiedLink(link: ShareLink | undefined): Promise { + if (!link?.url) return null; + return (await copyText(window.location.origin + link.url)) ? link.id : null; +} + function LinkShare({ target }: { target: LinkTarget }) { const { t } = useTranslation(); const qc = useQueryClient(); + // The link the dialog copied when it opened: sharing is almost always why it was opened. + const [copiedOnOpen, setCopiedOnOpen] = useState(null); const links = useQuery({ queryKey: linksKey(target), queryFn: () => fetchLinks(target), @@ -290,7 +327,9 @@ function LinkShare({ target }: { target: LinkTarget }) { useEffect(() => { if (!links.data || autoTried.current) return; autoTried.current = true; - if (links.data.length === 0) createLink(); + if (links.data.length === 0) + createLink(undefined, { onSuccess: (made) => void copiedLink(made).then(setCopiedOnOpen) }); + else void copiedLink(links.data.find((l) => l.url)).then(setCopiedOnOpen); }, [links.data, createLink]); return ( @@ -317,7 +356,12 @@ function LinkShare({ target }: { target: LinkTarget }) { ) : (
{links.data.map((l) => ( - + ))} {links.data.length === 0 && !creating && !create.isPending && (

{t("downloads.share.noLinks")}

diff --git a/frontend/src/modules/files/UploadDialog.dom.test.tsx b/frontend/src/modules/files/UploadDialog.dom.test.tsx index 671d0f2c..bb3f3b41 100644 --- a/frontend/src/modules/files/UploadDialog.dom.test.tsx +++ b/frontend/src/modules/files/UploadDialog.dom.test.tsx @@ -19,6 +19,8 @@ const h = vi.hoisted(() => ({ uploadStarted: vi.fn(), uploadEnded: vi.fn(), openFile: vi.fn(), + fileFolders: vi.fn(), + createFolder: vi.fn(), })); vi.mock("react-i18next", async (importOriginal) => { @@ -29,7 +31,12 @@ vi.mock("react-i18next", async (importOriginal) => { }; }); vi.mock("../../lib/api", () => ({ - api: { uploadFile: h.uploadFile, filesUsage: h.filesUsage }, + api: { + uploadFile: h.uploadFile, + filesUsage: h.filesUsage, + fileFolders: h.fileFolders, + createFolder: h.createFolder, + }, })); vi.mock("../../lib/downloads", () => ({ invalidateDownloads: vi.fn() })); // The rest of the module stays real: the dialog is a ui/Modal, which hosts its own toasts. @@ -47,12 +54,29 @@ import UploadDialog from "./UploadDialog"; import { FilesActionsContext } from "./filesContext"; import { msg, type NotifText } from "../../lib/notifications"; +const folder = (id: number, kind: string, source_key: string | null, name: string | null) => ({ + id, + kind, + source_key, + name, + retention_days: null, + count: 0, + size_bytes: 0, + created_at: null, + has_link: false, +}); +const FOLDERS = [ + folder(1, "source", "upload", null), + folder(5, "own", null, "peat"), + folder(9, "source", "site:reddit.com", null), +]; + const wrap = ( onClose = vi.fn(), folderId: number | null = null, initialFile: File | null = null, ) => { - const qc = new QueryClient(); + const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }); const files = { setSearch: vi.fn(), setView: vi.fn(), @@ -78,9 +102,109 @@ beforeEach(() => { h.notify.mockClear(); h.filesUsage.mockClear(); h.filesUsage.mockResolvedValue({ upload_max_bytes: 1_000_000 }); + h.createFolder.mockReset(); + h.fileFolders.mockReset(); + h.fileFolders.mockResolvedValue(FOLDERS); }); afterEach(cleanup); +describe("UploadDialog · where the file goes", () => { + const pick = () => + fireEvent.change(fileInput(), { target: { files: [new File(["x"], "a.pdf")] } }); + const target = () => screen.findByTestId("upload-target"); + const sentTo = async () => { + fireEvent.click(screen.getByText("downloads.upload.submit")); + await waitFor(() => expect(h.uploadFile).toHaveBeenCalledTimes(1)); + return (h.uploadFile.mock.calls[0]![1] as { folderId: number | null }).folderId; + }; + + it("says the folder it was opened on", async () => { + wrap(vi.fn(), 5); + expect((await target()).textContent).toContain("peat"); + }); + + it("says Uploads when it was opened on no folder, and sends there", async () => { + wrap(); + expect((await target()).textContent).toContain("files.source.upload"); + pick(); + expect(await sentTo()).toBeNull(); + }); + + it("says Uploads before the user has a folder of that name yet", async () => { + h.fileFolders.mockResolvedValue([FOLDERS[1]]); + wrap(); + expect((await target()).textContent).toContain("files.source.upload"); + }); + + it("offers only the folders that take an upload", async () => { + wrap(); + fireEvent.click(await target()); + expect(await screen.findByTestId("upload-target-5")).toBeTruthy(); + expect(screen.getByTestId("upload-target-1")).toBeTruthy(); + expect(screen.queryByTestId("upload-target-9")).toBeNull(); // a site's folder claims an origin + }); + + it("sends to the folder picked", async () => { + wrap(); + fireEvent.click(await target()); + fireEvent.click(await screen.findByTestId("upload-target-5")); + pick(); + expect(await sentTo()).toBe(5); + }); + + it("makes a new folder from the same list and sends there", async () => { + h.createFolder.mockResolvedValue({ ...FOLDERS[1], id: 7, name: "New one" }); + wrap(); + fireEvent.click(await target()); + fireEvent.click(await screen.findByTestId("upload-target-new")); + const field = await screen.findByLabelText("files.folder.newName"); + fireEvent.change(field, { target: { value: "New one" } }); + fireEvent.keyDown(field, { key: "Enter" }); + await waitFor(() => expect(h.createFolder).toHaveBeenCalledWith("New one")); + await waitFor(async () => expect((await target()).textContent).toContain("New one")); + pick(); + expect(await sentTo()).toBe(7); + }); + + it("will not upload while a new folder is on its way, to the wrong place", async () => { + let made: (f: unknown) => void = () => {}; + h.createFolder.mockReturnValue(new Promise((res) => (made = res))); + wrap(); + pick(); + fireEvent.click(await target()); + fireEvent.click(await screen.findByTestId("upload-target-new")); + const field = await screen.findByLabelText("files.folder.newName"); + const submit = screen.getByText("downloads.upload.submit").closest("button")!; + expect(submit.disabled || submit.getAttribute("aria-disabled") === "true").toBe(true); + fireEvent.change(field, { target: { value: "New one" } }); + fireEvent.keyDown(field, { key: "Enter" }); + await waitFor(() => expect(h.createFolder).toHaveBeenCalled()); + fireEvent.click(submit); + expect(h.uploadFile).not.toHaveBeenCalled(); + made({ ...FOLDERS[1], id: 7, name: "New one" }); + await waitFor(() => expect(submit.disabled).toBe(false)); + expect(await sentTo()).toBe(7); + }); + + it("an empty name makes no folder and leaves the target", async () => { + wrap(vi.fn(), 5); + fireEvent.click(await target()); + fireEvent.click(await screen.findByTestId("upload-target-new")); + fireEvent.keyDown(await screen.findByLabelText("files.folder.newName"), { key: "Enter" }); + expect((await target()).textContent).toContain("peat"); + expect(h.createFolder).not.toHaveBeenCalled(); + }); + + it("says when the folders could not be read, and offers Retry", async () => { + h.fileFolders.mockRejectedValue(new Error("down")); + wrap(); + expect(await screen.findByText("downloads.upload.targetFailed")).toBeTruthy(); + h.fileFolders.mockResolvedValue(FOLDERS); + fireEvent.click(screen.getByRole("button", { name: /retry/i })); + expect((await target()).textContent).toContain("files.source.upload"); + }); +}); + describe("UploadDialog", () => { it("shows the picked file's name and uploads it with the optional name", async () => { const onClose = vi.fn(); diff --git a/frontend/src/modules/files/UploadDialog.tsx b/frontend/src/modules/files/UploadDialog.tsx index 1f545af7..764e713e 100644 --- a/frontend/src/modules/files/UploadDialog.tsx +++ b/frontend/src/modules/files/UploadDialog.tsx @@ -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(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(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} /> +

+ {t("downloads.upload.targetLabel")} +

+ {foldersQ.isError ? ( + // The file still goes where the dialog was opened on: only the list could not be read. + void foldersQ.refetch()}> + {t("downloads.upload.targetFailed")} + + ) : naming ? ( + { + refocusTarget.current = byKey; + setNaming(false); + if (folder?.trim()) makeFolder.mutate(folder.trim()); + }} + /> + ) : ( +
+ {foldersQ.data && !busy && ( + + )} + {busy && ( + + {targetOptions.find((o) => o.value === targetValue)?.label} + + )} +
+ )} + {busy && (
{t("downloads.upload.submit")} diff --git a/frontend/src/modules/files/fileMeta.test.ts b/frontend/src/modules/files/fileMeta.test.ts new file mode 100644 index 00000000..42312c4b --- /dev/null +++ b/frontend/src/modules/files/fileMeta.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from "vitest"; +import { fileMeta } from "./fileMeta"; +import type { LibraryFile } from "../../lib/api"; + +const file = (uploader: string | null, container: string | null) => + ({ display_uploader: uploader, asset: { uploader: null, container } }) as unknown as LibraryFile; + +describe("fileMeta", () => { + it("names who made it and its format", () => { + expect(fileMeta(file("Someone", "mp4"))).toBe("Someone · MP4"); + }); + it("has the format alone when nobody is named", () => { + expect(fileMeta(file(null, "gz"))).toBe("GZ"); + }); + it("has the name alone when no format is known", () => { + expect(fileMeta(file("Someone", null))).toBe("Someone"); + }); + it("is empty when neither is known", () => { + expect(fileMeta(file(null, null))).toBe(""); + }); + it("reads the file's own uploader when none was typed", () => { + const f = { display_uploader: null, asset: { uploader: "Site", container: "webm" } }; + expect(fileMeta(f as unknown as LibraryFile)).toBe("Site · WEBM"); + }); +}); diff --git a/frontend/src/modules/files/fileMeta.ts b/frontend/src/modules/files/fileMeta.ts new file mode 100644 index 00000000..92b0cdee --- /dev/null +++ b/frontend/src/modules/files/fileMeta.ts @@ -0,0 +1,8 @@ +import type { LibraryFile } from "../../lib/api"; + +/** A file's line under its name, in the table and on its card: who made it and its format. */ +export function fileMeta(file: LibraryFile): string { + const who = file.display_uploader || file.asset?.uploader; + const format = file.asset?.container?.toUpperCase(); + return [who, format].filter(Boolean).join(" · "); +}