Files
siftlode/frontend/src/components/LinkBox.tsx
T
peter 1c4f0042d2 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.
2026-10-11 16:56:13 +02:00

89 lines
3.1 KiB
TypeScript

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>
);
}