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.
This commit is contained in:
2026-09-27 07:12:51 +02:00
parent f48f16830e
commit 098b8dbe43
3 changed files with 104 additions and 21 deletions
@@ -213,3 +213,46 @@ describe("DownloadRow · narrow", () => {
expect(texts).not.toContain("0"); 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(<DownloadRow job={bare} />);
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(<DownloadRow job={named} />);
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(<DownloadRow job={withAsset("mp4")} />);
expect(container.querySelector("img")).toBeNull();
expect(container.querySelector("svg.lucide-film")).not.toBeNull();
unmount();
const { container: audio } = render(
<DownloadRow job={withAsset("m4a", { media_kind: "audio" })} />,
);
expect(audio.querySelector("svg.lucide-music")).not.toBeNull();
});
});
+38 -19
View File
@@ -1,6 +1,6 @@
import { useState, type ReactNode } from "react"; import { useState, type ReactNode } from "react";
import { useTranslation } from "react-i18next"; 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 clsx from "clsx";
import { qk } from "../lib/queryKeys"; import { qk } from "../lib/queryKeys";
import { useLiveQuery } from "../lib/useLiveQuery"; import { useLiveQuery } from "../lib/useLiveQuery";
@@ -15,7 +15,7 @@ import {
} from "../lib/format"; } from "../lib/format";
import { navItemAttrs } from "../lib/listNav"; import { navItemAttrs } from "../lib/listNav";
import { failCopyKeys } from "../lib/downloadFailure"; import { failCopyKeys } from "../lib/downloadFailure";
import { jobTitle } from "./downloadTitle"; import { displayUrl, hasOwnTitle, jobTitle } from "./downloadTitle";
import { IconButton } from "./ui/IconButton"; import { IconButton } from "./ui/IconButton";
import { Pressable } from "./ui/Pressable"; import { Pressable } from "./ui/Pressable";
import { SURFACE } from "./ui/surfaces"; import { SURFACE } from "./ui/surfaces";
@@ -113,19 +113,30 @@ function Thumb({ job }: { job: DownloadJob }) {
loading="lazy" loading="lazy"
className="w-full h-full object-cover" className="w-full h-full object-cover"
onError={() => { 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".
<div className="flex h-full items-center justify-center text-muted">
{job.asset?.media_kind === "audio" ? (
<Music className="w-7 h-7" aria-hidden="true" />
) : (
<Film className="w-7 h-7" aria-hidden="true" />
)}
</div>
)}
</div> </div>
); );
} }
// The channel to show under the title: a user override wins over the auto-extracted asset value. // 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. // `url` is null for sources without a channel link (e.g. reddit) — then it renders as plain text. No
function jobChannel(job: DownloadJob): { name: string; url: string | null } { // 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 { 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, 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 // 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). // address): the row's menu answers everywhere else on it (E28 C1).
const nativeMenu = { onContextMenu: (e: React.MouseEvent) => e.stopPropagation() }; 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. // Channel under the title — a clickable link when a channel URL is known, plain text otherwise.
function ChannelLine({ job }: { job: DownloadJob }) { function ChannelLine({ job }: { job: DownloadJob }) {
const { name, url } = jobChannel(job); const { name, url } = jobChannel(job);
if (!name) return null;
if (url) { if (url) {
return ( return (
<div className="text-sm mt-0.5 truncate"> <div className="text-sm mt-0.5 truncate">
@@ -234,6 +236,8 @@ export function DownloadRow({
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
const running = job.status === "running"; const running = job.status === "running";
const linkTitle = !hasOwnTitle(job) && !!job.source_url;
const sourceLink = linkTitle ? null : job.source_url;
return ( return (
<div <div
{...(navIndex !== undefined && navItemAttrs(navIndex, jobTitle(job)))} {...(navIndex !== undefined && navItemAttrs(navIndex, jobTitle(job)))}
@@ -255,7 +259,22 @@ export function DownloadRow({
<Scissors className="w-3 h-3" /> {t("downloads.clipBadge")} <Scissors className="w-3 h-3" /> {t("downloads.clipBadge")}
</span> </span>
)} )}
{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).
<a
href={job.source_url!}
target="_blank"
rel="noopener noreferrer"
title={`${t("downloads.source.open")} — ${job.source_url}`}
className="hover:text-accent hover:underline"
{...nativeMenu}
>
{jobTitle(job)} {jobTitle(job)}
</a>
) : (
jobTitle(job)
)}
</div> </div>
<ChannelLine job={job} /> <ChannelLine job={job} />
</div> </div>
@@ -303,9 +322,9 @@ export function DownloadRow({
(at 375). On touch each is a 44px box, so separate lines stacked 44px gaps (the owner, (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?"). */} 2026-09-27: "what are those huge empty parts?"). */}
<div className="contents @max-[40rem]/dlrow:[grid-area:foot] @max-[40rem]/dlrow:flex @max-[40rem]/dlrow:flex-wrap @max-[40rem]/dlrow:items-center @max-[40rem]/dlrow:gap-x-2"> <div className="contents @max-[40rem]/dlrow:[grid-area:foot] @max-[40rem]/dlrow:flex @max-[40rem]/dlrow:flex-wrap @max-[40rem]/dlrow:items-center @max-[40rem]/dlrow:gap-x-2">
{(!!job.source_url || !!job.extra_links?.length) && ( {(!!sourceLink || !!job.extra_links?.length) && (
<div className="[grid-area:src] min-w-0 @max-[40rem]/dlrow:min-w-32 @max-[40rem]/dlrow:flex-1"> <div className="[grid-area:src] min-w-0 @max-[40rem]/dlrow:min-w-32 @max-[40rem]/dlrow:flex-1">
{job.source_url && <SourceRef url={job.source_url} />} {sourceLink && <SourceRef url={sourceLink} />}
{job.extra_links?.map((url) => ( {job.extra_links?.map((url) => (
<SourceRef key={url} url={url} /> <SourceRef key={url} url={url} />
))} ))}
+22 -1
View File
@@ -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 // 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. // 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 { 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;
}
} }