feat(downloads): one-click Share + downloadable-by-default links (E7 S3)

D2-A: the library row's Share button becomes a single action — quickShareLink()
ensures a public link (minting on demand), copies it to the clipboard, and toasts
with an "Open in new tab" action. A copy failure still toasts (warning) with that
action so the link is never a dead end. Advanced controls (password/expiry/revoke/
user-ACL) stay in the existing ShareDialog, reached via a new "Manage sharing" row
button.

D1 (forward-only): CreateLinkIn.allow_download defaults to true, so a newly minted
link is downloadable by default. The default lives in the API, not a migration —
the download_links column default stays false, so no existing link flips. The
ShareDialog create-default is flipped to match.

Tests: TestCreateLinkDefault (default true; explicit false honored); 4 vitest cases
for quickShareLink (mint-vs-reuse, copy ok/fail, create failure). HU/EN parity.
This commit is contained in:
2026-08-29 06:36:07 +02:00
parent 94fcc40bde
commit fdca12968b
8 changed files with 202 additions and 8 deletions
+24 -2
View File
@@ -6,6 +6,7 @@ import {
Ban,
ClipboardPaste,
Download,
Link2,
Pause,
Play,
Plus,
@@ -29,7 +30,7 @@ import { useConfirm } from "./ConfirmProvider";
import { useLiveQuery } from "../lib/useLiveQuery";
import { useMe } from "../lib/useMe";
import { api, type DownloadJob } from "../lib/api";
import { invalidateDownloads } from "../lib/downloads";
import { invalidateDownloads, quickShareLink } from "../lib/downloads";
import { inputCls } from "./ui/form";
import { Button } from "./ui/Button";
import { DownloadRow, IconBtn, UsageBar } from "./downloadRow";
@@ -101,6 +102,21 @@ export default function DownloadCenter() {
if (await confirm({ title, message: body, confirmLabel: title, danger: true })) fn();
};
// One-click share (E7 S3 · D2-A): ensure a downloadable link, copy it, toast with "open in new tab"
// (see quickShareLink). Re-clicking reuses the existing link; a per-job in-flight guard also stops a
// double-click from racing two POSTs into two links before the cache is primed. Advanced controls
// (password, expiry, revoke, share-with-a-user) live in the Share dialog, via the manage-share button.
const [sharingId, setSharingId] = useState<number | null>(null);
const quickShare = async (job: DownloadJob) => {
if (sharingId !== null) return;
setSharingId(job.id);
try {
await quickShareLink(qc, job, t);
} finally {
setSharingId(null);
}
};
// 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
@@ -261,9 +277,15 @@ export default function DownloadCenter() {
>
<Pencil className="w-4 h-4" />
</IconBtn>
<IconBtn onClick={() => setShareJob(job)} title={t("downloads.actions.share")}>
<IconBtn onClick={() => quickShare(job)} title={t("downloads.actions.share")}>
<Share2 className="w-4 h-4" />
</IconBtn>
<IconBtn
onClick={() => setShareJob(job)}
title={t("downloads.actions.shareManage")}
>
<Link2 className="w-4 h-4" />
</IconBtn>
<IconBtn
onClick={() =>
confirmThen(
+5 -3
View File
@@ -254,7 +254,9 @@ function LinkShare({ job }: { job: DownloadJob }) {
queryFn: () => api.downloadLinks(job.id),
});
const [creating, setCreating] = useState(false);
const [allowDownload, setAllowDownload] = useState(false);
// D1 (E7 S3): downloadable by default — matches the one-click Share (which relies on the backend
// default) and the common "let them have the file" intent. The owner can uncheck it here.
const [allowDownload, setAllowDownload] = useState(true);
const [expiryDays, setExpiryDays] = useState<number | null>(null);
const [password, setPassword] = useState("");
const invalidate = () => qc.invalidateQueries({ queryKey: qk.downloadLinks(job.id) });
@@ -276,8 +278,8 @@ 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,
// (no password, no expiry, downloadable per D1) 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(() => {