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:
@@ -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"
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
Reference in New Issue
Block a user