feat(downloads): S1 UI quick wins — queue default, dates, auto-link, thumb fallback

- Always land on the Queue tab (drop the persisted last-tab) so new work/failures are the default view
- Show the download date (relative) on Library rows
- Share dialog opens with a default link already generated when none exists; all link controls stay available
- Thumbnail falls back to the self-hosted poster when a remote (i.ytimg/fbcdn) thumbnail 404s
- Add a clipboard-paste affordance to the add-URL box (explicit button + best-effort on focus)
This commit is contained in:
2026-07-28 01:21:50 +02:00
parent 0d655e6f88
commit 3f15f35256
4 changed files with 72 additions and 9 deletions
+56 -7
View File
@@ -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 (
<div className="w-28 aspect-video shrink-0 rounded-lg overflow-hidden bg-surface border border-border">
{url ? <img src={url} alt="" loading="lazy" className="w-full h-full object-cover" /> : null}
{src ? (
<img
src={src}
alt=""
loading="lazy"
className="w-full h-full object-cover"
onError={() => {
if (job.poster_url && src !== job.poster_url) setSrc(job.poster_url);
}}
/>
) : null}
</div>
);
}
@@ -218,6 +234,14 @@ function DownloadRow({ job, children }: { job: DownloadJob; children?: React.Rea
{job.asset?.duration_s ? (
<span className="text-muted">· {formatDuration(job.asset.duration_s)}</span>
) : null}
{job.status === "done" && job.created_at ? (
<span
className="text-muted inline-flex items-center gap-1"
title={new Date(job.created_at).toLocaleString()}
>
· <CalendarClock className="w-3 h-3" /> {relativeTime(job.created_at)}
</span>
) : null}
{job.error ? <span className="text-red-400 truncate">· {job.error}</span> : null}
</div>
{job.source_url && <SourceRef url={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<string | null>(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() {
<input
value={addUrl}
onChange={(e) => setAddUrl(e.target.value)}
onFocus={() => pasteFromClipboard()}
onKeyDown={(e) => e.key === "Enter" && addUrl.trim() && setAddSource(addUrl.trim())}
placeholder={t("downloads.page.addUrl")}
className={inputCls}
/>
<button
onClick={() => pasteFromClipboard(true)}
title={t("downloads.page.paste")}
aria-label={t("downloads.page.paste")}
className="inline-flex items-center justify-center px-2.5 py-1.5 rounded-lg text-muted hover:text-fg hover:bg-surface transition shrink-0"
>
<ClipboardPaste className="w-4 h-4" />
</button>
<button
onClick={() => addUrl.trim() && setAddSource(addUrl.trim())}
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm font-medium bg-accent text-accent-fg hover:opacity-90 transition shrink-0"
+14 -2
View File
@@ -1,4 +1,4 @@
import { useMemo, useState } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import { qk } from "../lib/queryKeys";
import { useTranslation } from "react-i18next";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
@@ -271,6 +271,18 @@ function LinkShare({ job }: { job: DownloadJob }) {
},
});
// Open with a link already generated: sharing is almost always the goal, so mint a default
// (no password, no expiry, view-only) link the moment the dialog finds none — as if the user
// had pressed "New link" → "Create". All further controls (password, download toggle, expiry,
// extra links, revoke) stay available on the created link and via the "New link" button.
const autoTried = useRef(false);
useEffect(() => {
if (links.data && links.data.length === 0 && !autoTried.current && !create.isPending) {
autoTried.current = true;
create.mutate();
}
}, [links.data, create]);
return (
<div>
<div className="flex items-center gap-2 text-sm font-medium mb-1">
@@ -282,7 +294,7 @@ function LinkShare({ job }: { job: DownloadJob }) {
{(links.data ?? []).map((l) => (
<LinkRow key={l.id} link={l} onChanged={invalidate} />
))}
{links.data && links.data.length === 0 && !creating && (
{links.data && links.data.length === 0 && !creating && !create.isPending && (
<p className="text-xs text-muted">{t("downloads.share.noLinks")}</p>
)}
</div>
@@ -20,6 +20,7 @@
"subtitle": "Download videos to the server, then save them to your device.",
"addUrl": "Paste a YouTube link or video id…",
"add": "Add",
"paste": "Paste link from clipboard",
"manage": "Manage formats"
},
"tabs": {
@@ -20,6 +20,7 @@
"subtitle": "Töltsd le a videókat a szerverre, majd mentsd a saját gépedre.",
"addUrl": "Illessz be egy YouTube linket vagy videó azonosítót…",
"add": "Hozzáad",
"paste": "Link beillesztése a vágólapról",
"manage": "Formátumok kezelése"
},
"tabs": {