- A library past the list's cap is searched and sorted on the server; the count says "the newest". - Each folder and each search opens on its first page (DataTable pageScope, useCardPager scope). - A file dropped where the page takes none never reaches the browser (which would unload the app). - The viewer's arrows wait while a layer opened from it is on top, and scroll a wide text line; a text shows its first 5000 lines and says where the rest is. - Confirms name the file (no more "your downloads"); the asking menu rows end in "…". - The tray names a running step beside its bar, shows a pause mark for paused work, polls slowly while only paused work waits, and forgets a finished file that was deleted. - An unchanged rename sends nothing; a paste opens the download dialog only with nothing on top; the link box never replaces what was typed. - Honest states: folders that failed to load, a file no longer there, the storage figure; the rail says "nothing downloaded" only when there is nothing. - Focus after deleting the open folder and when the format form opens; the format name's label. - ui/Meter (one danger line for both quota bars) and ui/RenameField in the platform layer. - Copy fixes, stale comments and the speculative hub key; a contract test for the list's parameters; e2e: the Files page and the paste intake.
91 lines
3.3 KiB
TypeScript
91 lines
3.3 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());
|
|
// Asked again once the read is back: it can wait on a permission prompt, and what was typed
|
|
// meanwhile is the reader's.
|
|
if (link) setUrl((typed) => (force || !typed.trim() ? link : typed));
|
|
};
|
|
|
|
// 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>
|
|
);
|
|
}
|