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 { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { import {
Ban, Ban,
CalendarClock,
ClipboardPaste,
Copy, Copy,
Download, Download,
Link2, Link2,
@@ -18,7 +20,7 @@ import {
Trash2, Trash2,
} from "lucide-react"; } from "lucide-react";
import clsx from "clsx"; import clsx from "clsx";
import Tabs, { usePersistedTab } from "./Tabs"; import Tabs from "./Tabs";
import { PageToolbar } from "./PageShell"; import { PageToolbar } from "./PageShell";
import { LoadingState, StateMessage } from "./QueryState"; import { LoadingState, StateMessage } from "./QueryState";
import Modal from "./Modal"; import Modal from "./Modal";
@@ -35,7 +37,7 @@ import { api, type DownloadJob } from "../lib/api";
import { invalidateDownloads } from "../lib/downloads"; import { invalidateDownloads } from "../lib/downloads";
import { notify } from "../lib/notifications"; import { notify } from "../lib/notifications";
import { copyText } from "../lib/clipboard"; 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"; import { inputCls } from "./ui/form";
const GiB = 1024 ** 3; const GiB = 1024 ** 3;
@@ -83,10 +85,24 @@ function StatusPill({ job }: { job: DownloadJob }) {
function Thumb({ job }: { job: DownloadJob }) { function Thumb({ job }: { job: DownloadJob }) {
// Prefer the source thumbnail; fall back to our generated poster for thumbnail-less sources. // 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 ( return (
<div className="w-28 aspect-video shrink-0 rounded-lg overflow-hidden bg-surface border border-border"> <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> </div>
); );
} }
@@ -218,6 +234,14 @@ function DownloadRow({ job, children }: { job: DownloadJob; children?: React.Rea
{job.asset?.duration_s ? ( {job.asset?.duration_s ? (
<span className="text-muted">· {formatDuration(job.asset.duration_s)}</span> <span className="text-muted">· {formatDuration(job.asset.duration_s)}</span>
) : null} ) : 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} {job.error ? <span className="text-red-400 truncate">· {job.error}</span> : null}
</div> </div>
{job.source_url && <SourceRef url={job.source_url} />} {job.source_url && <SourceRef url={job.source_url} />}
@@ -655,7 +679,9 @@ export default function DownloadCenter() {
const { t } = useTranslation(); const { t } = useTranslation();
const qc = useQueryClient(); const qc = useQueryClient();
const confirm = useConfirm(); 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 [addUrl, setAddUrl] = useState("");
const [addSource, setAddSource] = useState<string | null>(null); const [addSource, setAddSource] = useState<string | null>(null);
const [manage, setManage] = useState(false); const [manage, setManage] = useState(false);
@@ -706,14 +732,28 @@ export default function DownloadCenter() {
if (await confirm({ title, message: body, confirmLabel: title, danger: true })) fn(); 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 = [ const tabs = [
{ id: "queue", label: t("downloads.tabs.queue"), badge: queue.length }, { id: "queue", label: t("downloads.tabs.queue"), badge: queue.length },
{ id: "done", label: t("downloads.tabs.done"), badge: library.length }, { id: "done", label: t("downloads.tabs.done"), badge: library.length },
{ id: "shared", label: t("downloads.tabs.shared") }, { id: "shared", label: t("downloads.tabs.shared") },
...(me.role === "admin" ? [{ id: "system", label: t("downloads.tabs.system") }] : []), ...(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 // Guard against the active tab going away under us (e.g. admin on "system" gets demoted mid-session)
// account) — that would render a blank page with no active tab. Fall back to the default. // — that would render a blank page with no active tab. Fall back to the default.
useEffect(() => { useEffect(() => {
if (!tabs.some((tb) => tb.id === tab)) setTab("queue"); if (!tabs.some((tb) => tb.id === tab)) setTab("queue");
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
@@ -756,10 +796,19 @@ export default function DownloadCenter() {
<input <input
value={addUrl} value={addUrl}
onChange={(e) => setAddUrl(e.target.value)} onChange={(e) => setAddUrl(e.target.value)}
onFocus={() => pasteFromClipboard()}
onKeyDown={(e) => e.key === "Enter" && addUrl.trim() && setAddSource(addUrl.trim())} onKeyDown={(e) => e.key === "Enter" && addUrl.trim() && setAddSource(addUrl.trim())}
placeholder={t("downloads.page.addUrl")} placeholder={t("downloads.page.addUrl")}
className={inputCls} 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 <button
onClick={() => addUrl.trim() && setAddSource(addUrl.trim())} 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" 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 { qk } from "../lib/queryKeys";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; 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 ( return (
<div> <div>
<div className="flex items-center gap-2 text-sm font-medium mb-1"> <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) => ( {(links.data ?? []).map((l) => (
<LinkRow key={l.id} link={l} onChanged={invalidate} /> <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> <p className="text-xs text-muted">{t("downloads.share.noLinks")}</p>
)} )}
</div> </div>
@@ -20,6 +20,7 @@
"subtitle": "Download videos to the server, then save them to your device.", "subtitle": "Download videos to the server, then save them to your device.",
"addUrl": "Paste a YouTube link or video id…", "addUrl": "Paste a YouTube link or video id…",
"add": "Add", "add": "Add",
"paste": "Paste link from clipboard",
"manage": "Manage formats" "manage": "Manage formats"
}, },
"tabs": { "tabs": {
@@ -20,6 +20,7 @@
"subtitle": "Töltsd le a videókat a szerverre, majd mentsd a saját gépedre.", "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…", "addUrl": "Illessz be egy YouTube linket vagy videó azonosítót…",
"add": "Hozzáad", "add": "Hozzáad",
"paste": "Link beillesztése a vágólapról",
"manage": "Formátumok kezelése" "manage": "Formátumok kezelése"
}, },
"tabs": { "tabs": {