From 098b8dbe43840067e1a75eb136dd03b5cee2eae3 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Sun, 27 Sep 2026 07:12:51 +0200 Subject: [PATCH] feat(downloads): a bare link is titled once, a missing picture shows a glyph A job with no name yet is titled by its short link, as the link itself; the channel line no longer falls back to the ref and the link line does not repeat it. No picture at all draws a film (or note) glyph instead of an empty box. --- .../src/components/downloadRow.dom.test.tsx | 43 ++++++++++++++ frontend/src/components/downloadRow.tsx | 59 ++++++++++++------- frontend/src/components/downloadTitle.ts | 23 +++++++- 3 files changed, 104 insertions(+), 21 deletions(-) diff --git a/frontend/src/components/downloadRow.dom.test.tsx b/frontend/src/components/downloadRow.dom.test.tsx index 3c8f58de..7e6274a2 100644 --- a/frontend/src/components/downloadRow.dom.test.tsx +++ b/frontend/src/components/downloadRow.dom.test.tsx @@ -213,3 +213,46 @@ describe("DownloadRow · narrow", () => { expect(texts).not.toContain("0"); }); }); + +// A link queued (or failed) before its details: the title, the channel line and the link line all +// fell back to the same URL (the owner, 2026-09-27). Now the title IS the short link, once. +describe("DownloadRow · a link with no details yet", () => { + const bare: DownloadJob = { + ...baseJob, + status: "error", + source_kind: "url", + source_ref: "https://www.facebook.com/watch?v=12345", + source_url: "https://www.facebook.com/watch?v=12345", + display_name: null, + asset: null, + }; + + it("shows the short link once, as the title's link, with no channel line", () => { + const { container } = render(); + const links = [...container.querySelectorAll("a")].filter((a) => a.href === bare.source_url); + expect(links.map((a) => a.textContent)).toEqual(["www.facebook.com/watch?v=12345"]); + expect(container.textContent).not.toContain("https://"); + }); + + it("keeps the named row as it was: title text, and the link on its own line", () => { + const named = { ...bare, display_name: "A clip" }; + const { container } = render(); + expect(screen.getByText("A clip").closest("a")).toBeNull(); + const links = [...container.querySelectorAll("a")].filter((a) => a.href === bare.source_url); + expect(links.map((a) => a.textContent)).toEqual(["www.facebook.com/watch?v=12345"]); + }); +}); + +// No picture at all: a quiet glyph of what it is instead of an empty box (the owner, 2026-09-27). +describe("DownloadRow · no picture", () => { + it("draws a film glyph for a video, a note for audio", () => { + const { container, unmount } = render(); + expect(container.querySelector("img")).toBeNull(); + expect(container.querySelector("svg.lucide-film")).not.toBeNull(); + unmount(); + const { container: audio } = render( + , + ); + expect(audio.querySelector("svg.lucide-music")).not.toBeNull(); + }); +}); diff --git a/frontend/src/components/downloadRow.tsx b/frontend/src/components/downloadRow.tsx index cf80c90a..c149b10f 100644 --- a/frontend/src/components/downloadRow.tsx +++ b/frontend/src/components/downloadRow.tsx @@ -1,6 +1,6 @@ import { useState, type ReactNode } from "react"; import { useTranslation } from "react-i18next"; -import { CalendarClock, File, Link2, Scissors } from "lucide-react"; +import { CalendarClock, File, Film, Link2, Music, Scissors } from "lucide-react"; import clsx from "clsx"; import { qk } from "../lib/queryKeys"; import { useLiveQuery } from "../lib/useLiveQuery"; @@ -15,7 +15,7 @@ import { } from "../lib/format"; import { navItemAttrs } from "../lib/listNav"; import { failCopyKeys } from "../lib/downloadFailure"; -import { jobTitle } from "./downloadTitle"; +import { displayUrl, hasOwnTitle, jobTitle } from "./downloadTitle"; import { IconButton } from "./ui/IconButton"; import { Pressable } from "./ui/Pressable"; import { SURFACE } from "./ui/surfaces"; @@ -113,19 +113,30 @@ function Thumb({ job }: { job: DownloadJob }) { loading="lazy" className="w-full h-full object-cover" onError={() => { - if (remote && src !== remote) setFailedPrimary(primary); + if (src !== remote) setFailedPrimary(primary); }} /> - ) : null} + ) : ( + // No picture at all (a link that failed before its details, a poster that never came): a + // quiet glyph of what it is, like the file glyph above — an empty box read as "still loading". +
+ {job.asset?.media_kind === "audio" ? ( +
+ )} ); } // The channel to show under the title: a user override wins over the auto-extracted asset value. -// `url` is null for sources without a channel link (e.g. reddit) — then it renders as plain text. -function jobChannel(job: DownloadJob): { name: string; url: string | null } { +// `url` is null for sources without a channel link (e.g. reddit) — then it renders as plain text. No +// channel known yet → no line: the ref it once fell back to repeated the title (the owner, 2026-09-27). +function jobChannel(job: DownloadJob): { name: string | null; url: string | null } { return { - name: job.display_uploader || job.asset?.uploader || job.source_ref, + name: job.display_uploader || job.asset?.uploader || null, url: job.display_uploader_url || job.asset?.uploader_url || null, }; } @@ -157,16 +168,6 @@ export function IconBtn({ ); } -// Compact display of a URL: drop the protocol + trailing slash so "youtube.com/watch?v=…" fits. -function displayUrl(url: string): string { - try { - const u = new URL(url); - return (u.host + u.pathname + u.search).replace(/\/$/, ""); - } catch { - return url; - } -} - // A link inside the row keeps the BROWSER's own right-click menu (open in a new tab, copy the // address): the row's menu answers everywhere else on it (E28 C1). const nativeMenu = { onContextMenu: (e: React.MouseEvent) => e.stopPropagation() }; @@ -195,6 +196,7 @@ function SourceRef({ url }: { url: string }) { // Channel under the title — a clickable link when a channel URL is known, plain text otherwise. function ChannelLine({ job }: { job: DownloadJob }) { const { name, url } = jobChannel(job); + if (!name) return null; if (url) { return (
@@ -234,6 +236,8 @@ export function DownloadRow({ }) { const { t } = useTranslation(); const running = job.status === "running"; + const linkTitle = !hasOwnTitle(job) && !!job.source_url; + const sourceLink = linkTitle ? null : job.source_url; return (
{t("downloads.clipBadge")} )} - {jobTitle(job)} + {linkTitle ? ( + // No name yet: the title IS the link, so the link line below leaves it out (it said the + // same URL a third time). + + {jobTitle(job)} + + ) : ( + jobTitle(job) + )}
@@ -303,9 +322,9 @@ export function DownloadRow({ (at 375). On touch each is a 44px box, so separate lines stacked 44px gaps (the owner, 2026-09-27: "what are those huge empty parts?"). */}
- {(!!job.source_url || !!job.extra_links?.length) && ( + {(!!sourceLink || !!job.extra_links?.length) && (
- {job.source_url && } + {sourceLink && } {job.extra_links?.map((url) => ( ))} diff --git a/frontend/src/components/downloadTitle.ts b/frontend/src/components/downloadTitle.ts index e38d4963..105e321c 100644 --- a/frontend/src/components/downloadTitle.ts +++ b/frontend/src/components/downloadTitle.ts @@ -2,6 +2,27 @@ import type { DownloadJob } from "../lib/api"; // A download job's display title (a user rename wins over the extracted asset title), shared by the // row, the admin list and the library player. Its own module so downloadRow exports only components. +// With no name yet — a link queued before its details arrive, or one that failed first — it is the +// link itself, shortened the way the row shows a link, never the raw ref (a URL, or a bare video id). export function jobTitle(job: DownloadJob): string { - return job.display_name || job.asset?.title || job.source_ref; + return ( + job.display_name || + job.asset?.title || + (job.source_url ? displayUrl(job.source_url) : job.source_ref) + ); +} + +/** The job has a name of its own (a rename or an extracted title), not the link standing in for one. */ +export function hasOwnTitle(job: DownloadJob): boolean { + return !!(job.display_name || job.asset?.title); +} + +// Compact display of a URL: drop the protocol + trailing slash so "youtube.com/watch?v=…" fits. +export function displayUrl(url: string): string { + try { + const u = new URL(url); + return (u.host + u.pathname + u.search).replace(/\/$/, ""); + } catch { + return url; + } }