diff --git a/frontend/src/components/DownloadCenter.tsx b/frontend/src/components/DownloadCenter.tsx index 84d68db..c4e2ed6 100644 --- a/frontend/src/components/DownloadCenter.tsx +++ b/frontend/src/components/DownloadCenter.tsx @@ -4,6 +4,8 @@ import { useTranslation } from "react-i18next"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { Ban, + CalendarClock, + ClipboardPaste, Copy, Download, Link2, @@ -18,7 +20,7 @@ import { Trash2, } from "lucide-react"; import clsx from "clsx"; -import Tabs, { usePersistedTab } from "./Tabs"; +import Tabs from "./Tabs"; import { PageToolbar } from "./PageShell"; import { LoadingState, StateMessage } from "./QueryState"; import Modal from "./Modal"; @@ -35,7 +37,7 @@ import { api, type DownloadJob } from "../lib/api"; import { invalidateDownloads } from "../lib/downloads"; import { notify } from "../lib/notifications"; import { copyText } from "../lib/clipboard"; -import { formatBytes, formatDuration, formatEta, formatSpeed } from "../lib/format"; +import { formatBytes, formatDuration, formatEta, formatSpeed, relativeTime } from "../lib/format"; import { inputCls } from "./ui/form"; const GiB = 1024 ** 3; @@ -83,10 +85,24 @@ function StatusPill({ job }: { job: DownloadJob }) { function Thumb({ job }: { job: DownloadJob }) { // Prefer the source thumbnail; fall back to our generated poster for thumbnail-less sources. - const url = job.asset?.thumbnail_url || job.poster_url; + const primary = job.asset?.thumbnail_url || job.poster_url; + // A remote i.ytimg.com thumbnail 404s once the source video is deleted/privated, leaving a + // broken image. On load failure fall back to our always-present self-hosted poster. + const [src, setSrc] = useState(primary); + useEffect(() => setSrc(primary), [primary]); return (
- {url ? : null} + {src ? ( + { + if (job.poster_url && src !== job.poster_url) setSrc(job.poster_url); + }} + /> + ) : null}
); } @@ -218,6 +234,14 @@ function DownloadRow({ job, children }: { job: DownloadJob; children?: React.Rea {job.asset?.duration_s ? ( · {formatDuration(job.asset.duration_s)} ) : null} + {job.status === "done" && job.created_at ? ( + + · {relativeTime(job.created_at)} + + ) : null} {job.error ? · {job.error} : null} {job.source_url && } @@ -655,7 +679,9 @@ export default function DownloadCenter() { const { t } = useTranslation(); const qc = useQueryClient(); const confirm = useConfirm(); - const [tab, setTab] = usePersistedTab("siftlode.downloadTab", "queue"); + // Always land on the queue tab (not a persisted last-tab) — the queue is where new work and + // any failures are, so it's the useful default every visit. + const [tab, setTab] = useState("queue"); const [addUrl, setAddUrl] = useState(""); const [addSource, setAddSource] = useState(null); const [manage, setManage] = useState(false); @@ -706,14 +732,28 @@ export default function DownloadCenter() { if (await confirm({ title, message: body, confirmLabel: title, danger: true })) fn(); }; + // Pull a link off the clipboard into the add-URL box. Clipboard reads need a secure context + + // a user gesture/permission (iOS Safari/Brave are strict) — so this is best-effort: the paste + // button is an explicit gesture (reliable), and the input's onFocus tries silently (ignored if + // the browser refuses without a click). Only accept an http(s) URL, don't clobber existing text. + const pasteFromClipboard = async (force = false) => { + if (!force && addUrl.trim()) return; + try { + const text = (await navigator.clipboard?.readText())?.trim(); + if (text && /^https?:\/\/\S+$/i.test(text)) setAddUrl(text); + } catch { + /* permission denied or unsupported — the manual paste button still works on a click */ + } + }; + const tabs = [ { id: "queue", label: t("downloads.tabs.queue"), badge: queue.length }, { id: "done", label: t("downloads.tabs.done"), badge: library.length }, { id: "shared", label: t("downloads.tabs.shared") }, ...(me.role === "admin" ? [{ id: "system", label: t("downloads.tabs.system") }] : []), ]; - // A persisted tab can point at one no longer available (e.g. "system" restored for a now-non-admin - // account) — that would render a blank page with no active tab. Fall back to the default. + // Guard against the active tab going away under us (e.g. admin on "system" gets demoted mid-session) + // — that would render a blank page with no active tab. Fall back to the default. useEffect(() => { if (!tabs.some((tb) => tb.id === tab)) setTab("queue"); // eslint-disable-next-line react-hooks/exhaustive-deps @@ -756,10 +796,19 @@ export default function DownloadCenter() { setAddUrl(e.target.value)} + onFocus={() => pasteFromClipboard()} onKeyDown={(e) => e.key === "Enter" && addUrl.trim() && setAddSource(addUrl.trim())} placeholder={t("downloads.page.addUrl")} className={inputCls} /> +