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:
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
{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).
|
||||||
|
<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)}
|
||||||
|
</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} />
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user