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(" · "); +}