feat(files): download from anywhere — a link box, a pasted link, the palette; the running downloads in a tray
- Files toolbar link box (paste button, Enter); a lone http(s) link pasted onto any page opens the one app-wide download dialog, which now names the link; the palette's "Download from a link…" focuses the box on its history entry. - The download tray, bottom right on every page: what waits, runs, is paused or failed (reason + Retry, the queue's right-click / "…" menu), and what finished in this tab as "Done · Open" until closed; folds to a pill (per-account pref); closed, it stays shut — across a reload — until the work changes or something finishes. Back to top steps up over it, the page end keeps clear. - A finish is announced once: "Downloaded: …", whether a phone plays it, Copy link + Open (toasts and notices may carry a second action). - The inbox's ready notice opens the file in its folder: download_done carries job_id + folder_id. - GET /api/downloads is the queue only (in flight, failed, finished in the last 10 minutes), with each row's source key.
This commit is contained in:
@@ -0,0 +1,88 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ClipboardPaste, Download } from "lucide-react";
|
||||
import {
|
||||
asDownloadLink,
|
||||
requestDownload,
|
||||
takeLinkBox,
|
||||
useLinkBoxRequest,
|
||||
} from "../lib/downloadIntake";
|
||||
import { Button } from "./ui/Button";
|
||||
import { IconButton } from "./ui/IconButton";
|
||||
import { Input } from "./ui/Input";
|
||||
|
||||
/** The clipboard's text, or null where the browser refuses. Outside the component: the React
|
||||
* Compiler cannot lower an optional call inside a try yet, and skipped the whole page for it. */
|
||||
async function clipboardText(): Promise<string | null> {
|
||||
try {
|
||||
return (await navigator.clipboard?.readText()) ?? null;
|
||||
} catch {
|
||||
return null; // permission denied or unsupported — the paste button still works on a click
|
||||
}
|
||||
}
|
||||
|
||||
// "Paste a link and I download it": the field, the clipboard button and Download. The link goes to
|
||||
// the app's one download dialog (lib/downloadIntake), where the profile is picked.
|
||||
export function LinkBox({ className }: { className?: string }) {
|
||||
const { t } = useTranslation();
|
||||
const [url, setUrl] = useState("");
|
||||
const field = useRef<HTMLInputElement>(null);
|
||||
|
||||
const submit = () => {
|
||||
const link = url.trim();
|
||||
if (!link) return;
|
||||
requestDownload(link);
|
||||
setUrl("");
|
||||
};
|
||||
|
||||
// Pull a link off the clipboard. Reading it needs a secure context and a gesture or a permission
|
||||
// (iOS Safari and Brave are strict), so it is best-effort: the button is a gesture and reliable, the
|
||||
// field's focus tries silently. Only an http(s) link is taken, and never over what was typed.
|
||||
const paste = async (force: boolean) => {
|
||||
if (!force && url.trim()) return;
|
||||
const link = asDownloadLink(await clipboardText());
|
||||
if (link) setUrl(link);
|
||||
};
|
||||
|
||||
// The palette's "Download from link…" (lib/downloadIntake): the request wakes this effect, the
|
||||
// take decides — it clears the token, so a later pass or another entry's visit does nothing.
|
||||
const request = useLinkBoxRequest();
|
||||
useEffect(() => {
|
||||
if (request !== null && takeLinkBox()) field.current?.focus();
|
||||
}, [request]);
|
||||
|
||||
return (
|
||||
// Narrow, the field takes its own line and the two buttons go under it, right: beside them it
|
||||
// was left a few letters wide on a phone.
|
||||
<span className={`flex min-w-0 flex-wrap items-center justify-end gap-2 ${className ?? ""}`}>
|
||||
<Input
|
||||
ref={field}
|
||||
size="sm"
|
||||
className="min-w-0 flex-[1_1_14rem]"
|
||||
value={url}
|
||||
onChange={(e) => setUrl(e.target.value)}
|
||||
onFocus={() => void paste(false)}
|
||||
onKeyDown={(e) => e.key === "Enter" && submit()}
|
||||
placeholder={t("files.link.placeholder")}
|
||||
aria-label={t("files.link.label")}
|
||||
/>
|
||||
<IconButton
|
||||
label={t("downloads.page.paste")}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => void paste(true)}
|
||||
>
|
||||
<ClipboardPaste className="w-4 h-4" />
|
||||
</IconButton>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
icon={<Download className="w-4 h-4" />}
|
||||
onClick={submit}
|
||||
className="shrink-0"
|
||||
>
|
||||
{t("files.link.download")}
|
||||
</Button>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user