Files
siftlode/frontend/src/components/LinkBox.tsx
T
peter 8b2260c9a8 fix(files): review round 1, the page — honest states, the keyboard, the tray's words
- 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.
2026-10-13 00:14:09 +02:00

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