From fdca12968b21852b0dd11ac5866201a290d3991e Mon Sep 17 00:00:00 2001 From: npeter83 Date: Sat, 29 Aug 2026 06:36:07 +0200 Subject: [PATCH] feat(downloads): one-click Share + downloadable-by-default links (E7 S3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- backend/app/routes/downloads.py | 7 +- backend/tests/test_links.py | 18 ++++ frontend/src/components/DownloadCenter.tsx | 26 +++++- frontend/src/components/ShareDialog.tsx | 8 +- frontend/src/i18n/locales/en/downloads.json | 6 +- frontend/src/i18n/locales/hu/downloads.json | 6 +- frontend/src/lib/downloads.test.ts | 97 +++++++++++++++++++++ frontend/src/lib/downloads.ts | 42 +++++++++ 8 files changed, 202 insertions(+), 8 deletions(-) create mode 100644 frontend/src/lib/downloads.test.ts diff --git a/backend/app/routes/downloads.py b/backend/app/routes/downloads.py index 950639c0..c4f5e231 100644 --- a/backend/app/routes/downloads.py +++ b/backend/app/routes/downloads.py @@ -823,7 +823,12 @@ def list_links( class CreateLinkIn(BaseModel): password: str = "" - allow_download: bool = False + # D1 (E7 S3): a newly shared download is downloadable by default — the common intent is "let the + # recipient have the file", not stream-only. Forward-only: this only sets the default for links + # minted from now on; existing links keep whatever `allow_download` they were created with (the + # column default stays False, so no back-catalogue link silently flips). The owner can still + # uncheck it in the Share dialog, and toggle any link stream-only afterwards. + allow_download: bool = True expires_days: int | None = None diff --git a/backend/tests/test_links.py b/backend/tests/test_links.py index 8a7aa55b..6d5907d1 100644 --- a/backend/tests/test_links.py +++ b/backend/tests/test_links.py @@ -93,6 +93,24 @@ class TestPublicMetaAudioFlag: assert meta["audio"] is False +class TestCreateLinkDefault: + """D1 (E7 S3): a newly minted share link is downloadable by default. The one-click Share relies on + this — it POSTs no `allow_download`, so the API default decides. Forward-only lives here (the API + default), NOT in a migration: the DownloadLink column default stays False, so no existing back- + catalogue link silently flips to downloadable.""" + + def test_omitted_allow_download_defaults_downloadable(self): + from app.routes.downloads import CreateLinkIn + + assert CreateLinkIn().allow_download is True + + def test_explicit_false_is_respected(self): + # The owner can still uncheck it to mint a stream-only link. + from app.routes.downloads import CreateLinkIn + + assert CreateLinkIn(allow_download=False).allow_download is False + + class TestIsExpired: def test_none_expiry_never_expires(self): assert is_expired(SimpleNamespace(expires_at=None)) is False diff --git a/frontend/src/components/DownloadCenter.tsx b/frontend/src/components/DownloadCenter.tsx index 46e5c9bb..c02dbdd1 100644 --- a/frontend/src/components/DownloadCenter.tsx +++ b/frontend/src/components/DownloadCenter.tsx @@ -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(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() { > - setShareJob(job)} title={t("downloads.actions.share")}> + quickShare(job)} title={t("downloads.actions.share")}> + setShareJob(job)} + title={t("downloads.actions.shareManage")} + > + + confirmThen( diff --git a/frontend/src/components/ShareDialog.tsx b/frontend/src/components/ShareDialog.tsx index 41e58e19..f8c72a27 100644 --- a/frontend/src/components/ShareDialog.tsx +++ b/frontend/src/components/ShareDialog.tsx @@ -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(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(() => { diff --git a/frontend/src/i18n/locales/en/downloads.json b/frontend/src/i18n/locales/en/downloads.json index 8338f218..198b7c6b 100644 --- a/frontend/src/i18n/locales/en/downloads.json +++ b/frontend/src/i18n/locales/en/downloads.json @@ -58,7 +58,8 @@ "download": "Save to my device", "rename": "Rename", "editDetails": "Edit details", - "share": "Share", + "share": "Share (copy link)", + "shareManage": "Manage sharing", "edit": "Edit / trim", "redownload": "Re-download (rebuild file)", "removeShared": "Remove from my list" @@ -104,6 +105,9 @@ "noUsers": "No other users yet.", "linkSection": "Share a link", "linkHint": "Anyone with the link can watch — no account needed.", + "linkCopied": "Share link copied to clipboard", + "openNewTab": "Open in new tab", + "linkFailed": "Couldn’t create the share link", "noLinks": "No links yet.", "newLink": "Create a link", "createLink": "Create link", diff --git a/frontend/src/i18n/locales/hu/downloads.json b/frontend/src/i18n/locales/hu/downloads.json index b08efe38..583f3f01 100644 --- a/frontend/src/i18n/locales/hu/downloads.json +++ b/frontend/src/i18n/locales/hu/downloads.json @@ -58,7 +58,8 @@ "download": "Mentés a gépemre", "rename": "Átnevezés", "editDetails": "Adatok szerkesztése", - "share": "Megosztás", + "share": "Megosztás (link másolása)", + "shareManage": "Megosztás kezelése", "edit": "Szerkesztés / vágás", "redownload": "Újraletöltés (fájl újraépítése)", "removeShared": "Eltávolítás a listámból" @@ -104,6 +105,9 @@ "noUsers": "Nincs más felhasználó.", "linkSection": "Megosztás linkkel", "linkHint": "A link birtokában bárki megnézheti — fiók nélkül is.", + "linkCopied": "Megosztási link a vágólapra másolva", + "openNewTab": "Megnyitás új lapon", + "linkFailed": "Nem sikerült a megosztási link létrehozása", "noLinks": "Még nincs link.", "newLink": "Link létrehozása", "createLink": "Létrehozás", diff --git a/frontend/src/lib/downloads.test.ts b/frontend/src/lib/downloads.test.ts new file mode 100644 index 00000000..b540cc4f --- /dev/null +++ b/frontend/src/lib/downloads.test.ts @@ -0,0 +1,97 @@ +// @vitest-environment jsdom +// +// One-click share (E7 S3 · D2-A): quickShareLink turns the row's Share button into a single action — +// ensure a public link (mint on demand, downloadable per D1), copy it, and toast with an "open in new +// tab" action. These pin the branch matrix: reuse-vs-mint, copy-ok-vs-fail, and the create-failure path. +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { QueryClient } from "@tanstack/react-query"; + +// vi.mock is hoisted above these consts, so the mock fns must live in a hoisted block too. +const { downloadLinks, createDownloadLink, copyText, notify } = vi.hoisted(() => ({ + downloadLinks: vi.fn(), + createDownloadLink: vi.fn(), + copyText: vi.fn(), + notify: vi.fn(), +})); + +vi.mock("./api", () => ({ api: { downloadLinks, createDownloadLink } })); +vi.mock("./clipboard", () => ({ copyText })); +vi.mock("./notifications", () => ({ notify })); + +import { quickShareLink } from "./downloads"; + +const t = (k: string) => k; +const origin = window.location.origin; + +type ToastArg = { + level: string; + message: string; + action?: { label: string; onClick: () => void }; +}; +const firstToast = () => notify.mock.calls[0]![0] as ToastArg; + +function qc() { + return new QueryClient({ defaultOptions: { queries: { retry: false } } }); +} + +beforeEach(() => { + downloadLinks.mockReset(); + createDownloadLink.mockReset(); + copyText.mockReset().mockResolvedValue(true); + notify.mockReset(); +}); + +describe("quickShareLink", () => { + it("mints a downloadable link on demand, copies it, and toasts with an open-in-new-tab action", async () => { + downloadLinks.mockResolvedValue([]); // none yet + createDownloadLink.mockResolvedValue({ id: 7, url: "/watch/tok7" }); + const openSpy = vi.spyOn(window, "open").mockReturnValue(null); + + const url = await quickShareLink(qc(), { id: 42 }, t); + + expect(url).toBe(`${origin}/watch/tok7`); + // Minted with an EMPTY body so the backend D1 default (allow_download=true) applies. + expect(createDownloadLink).toHaveBeenCalledWith(42, {}); + expect(copyText).toHaveBeenCalledWith(`${origin}/watch/tok7`); + const arg = firstToast(); + expect(arg.level).toBe("success"); + expect(arg.message).toBe("downloads.share.linkCopied"); + expect(arg.action!.label).toBe("downloads.share.openNewTab"); + arg.action!.onClick(); + expect(openSpy).toHaveBeenCalledWith(`${origin}/watch/tok7`, "_blank", "noopener,noreferrer"); + }); + + it("reuses an existing link instead of minting a duplicate", async () => { + downloadLinks.mockResolvedValue([{ id: 3, url: "/watch/old" }]); + + const url = await quickShareLink(qc(), { id: 1 }, t); + + expect(url).toBe(`${origin}/watch/old`); + expect(createDownloadLink).not.toHaveBeenCalled(); + expect(firstToast().level).toBe("success"); + }); + + it("still toasts (warning) with the open action when the clipboard copy fails", async () => { + downloadLinks.mockResolvedValue([{ id: 5, url: "/watch/x" }]); + copyText.mockResolvedValue(false); + + const url = await quickShareLink(qc(), { id: 2 }, t); + + expect(url).toBe(`${origin}/watch/x`); // the link is never a dead end + const arg = firstToast(); + expect(arg.level).toBe("warning"); + expect(arg.message).toBe("downloads.share.copyFailed"); + expect(arg.action!.label).toBe("downloads.share.openNewTab"); + }); + + it("reports an error and returns null when link creation fails", async () => { + downloadLinks.mockResolvedValue([]); + createDownloadLink.mockRejectedValue(new Error("boom")); + + const url = await quickShareLink(qc(), { id: 9 }, t); + + expect(url).toBeNull(); + expect(copyText).not.toHaveBeenCalled(); + expect(notify).toHaveBeenCalledWith({ level: "error", message: "downloads.share.linkFailed" }); + }); +}); diff --git a/frontend/src/lib/downloads.ts b/frontend/src/lib/downloads.ts index 36dc6cb6..f5a39fb5 100644 --- a/frontend/src/lib/downloads.ts +++ b/frontend/src/lib/downloads.ts @@ -1,5 +1,8 @@ import type { QueryClient } from "@tanstack/react-query"; import { qk } from "./queryKeys"; +import { api } from "./api"; +import { copyText } from "./clipboard"; +import { notify } from "./notifications"; // The download queue, the per-card download index, and the storage-usage meter all move together // whenever a job is enqueued, deleted, or its state changes — invalidate them as one unit. @@ -8,3 +11,42 @@ export function invalidateDownloads(qc: QueryClient) { qc.invalidateQueries({ queryKey: qk.downloadIndex() }); qc.invalidateQueries({ queryKey: qk.downloadUsage() }); } + +// One-click share (E7 S3 · D2-A): the row's Share button turns "share this" into a single action. +// Ensure the job has a public link (minting one on demand — omitting `allow_download` so the backend +// D1 default makes it downloadable), copy it to the clipboard, and toast 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, share-with-a-user) live in the Share dialog. Returns +// the shared URL, or null if link creation failed — so the caller/tests can react to the outcome. +export async function quickShareLink( + qc: QueryClient, + job: { id: number }, + t: (key: string) => string, +): Promise { + let link; + try { + const existing = await qc.fetchQuery({ + queryKey: qk.downloadLinks(job.id), + queryFn: () => api.downloadLinks(job.id), + }); + link = existing[0]; + if (!link) { + link = await api.createDownloadLink(job.id, {}); // omit allow_download → backend D1 default (true) + qc.setQueryData(qk.downloadLinks(job.id), [link]); + } + } catch { + notify({ level: "error", message: t("downloads.share.linkFailed") }); + return null; + } + const url = window.location.origin + link.url; + const action = { + label: t("downloads.share.openNewTab"), + onClick: () => window.open(url, "_blank", "noopener,noreferrer"), + }; + notify( + (await copyText(url)) + ? { level: "success", message: t("downloads.share.linkCopied"), action } + : { level: "warning", message: t("downloads.share.copyFailed"), action }, + ); + return url; +}