feat(files): share a folder by link — its page lists what is in it, plays a video above the list
A /watch link may now open one of its owner's own folders (migration 0108: folder_id beside job_id, exactly one set; a folder's links go with it). The page shows what is in the folder when it is opened, newest first: a video or a song plays in the same player a file link has, above the list, each keeping its own resume point; a document only downloads, never opens as a page. A file moved out stops opening through the link at its next request. Password, expiry and stream-only work as on a file link; the unfurl names the folder and counts its files, never their names. Source folders and the Hub cannot be shared. The folder menu gets "Copy link" and "Share with a link…" (the Share dialog's link half); the rail marks a folder a live link opens. Two fixes in the Share dialog on the way: revoking the last link no longer mints a new one at once, and the new-link form goes back to downloadable after making a link.
This commit is contained in:
@@ -151,6 +151,13 @@ export interface ShareLink {
|
||||
created_at: string | null;
|
||||
}
|
||||
|
||||
// What a new link is made with; an omitted `allow_download` takes the server's default (true).
|
||||
export interface NewShareLink {
|
||||
allow_download?: boolean;
|
||||
expires_days?: number | null;
|
||||
password?: string;
|
||||
}
|
||||
|
||||
// The width of every user-supplied display name on a job, mirroring the backend's DISPLAY_TEXT_MAX
|
||||
// (backend/app/models.py) so an input can stop the user rather than letting the server truncate in
|
||||
// silence. The two are held together by `downloads.contract.test.ts` — a number living in two
|
||||
@@ -258,16 +265,21 @@ export const downloadsApi = {
|
||||
shareRecipients: (): Promise<ShareRecipient[]> => req("/api/downloads/recipients"),
|
||||
// Public watch links (share by link).
|
||||
downloadLinks: (jobId: number): Promise<ShareLink[]> => req(`/api/downloads/${jobId}/links`),
|
||||
createDownloadLink: (
|
||||
jobId: number,
|
||||
body: { allow_download?: boolean; expires_days?: number | null; password?: string },
|
||||
): Promise<ShareLink> =>
|
||||
createDownloadLink: (jobId: number, body: NewShareLink): Promise<ShareLink> =>
|
||||
req(`/api/downloads/${jobId}/links`, { method: "POST", body: JSON.stringify(body) }),
|
||||
updateDownloadLink: (
|
||||
linkId: number,
|
||||
patch: { allow_download?: boolean; expires_days?: number | null; password?: string },
|
||||
): Promise<ShareLink> =>
|
||||
req(`/api/downloads/links/${linkId}`, { method: "PATCH", body: JSON.stringify(patch) }),
|
||||
// A link to one of the user's own folders: it opens whatever is in the folder when it is opened.
|
||||
folderLinks: (folderId: number): Promise<ShareLink[]> =>
|
||||
req(`/api/downloads/folders/${folderId}/links`),
|
||||
createFolderLink: (folderId: number, body: NewShareLink): Promise<ShareLink> =>
|
||||
req(`/api/downloads/folders/${folderId}/links`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
revokeDownloadLink: (linkId: number): Promise<{ ok: true; deleted: number }> =>
|
||||
req(`/api/downloads/links/${linkId}`, { method: "DELETE" }),
|
||||
// The library player's resume checkpoint. A position too early or too close to the end clears it.
|
||||
|
||||
@@ -18,6 +18,8 @@ export interface FileFolder {
|
||||
count: number; // its done files
|
||||
size_bytes: number;
|
||||
created_at: string | null;
|
||||
// A live public link opens it: whatever is moved in is shown to whoever holds the link.
|
||||
has_link: boolean;
|
||||
}
|
||||
|
||||
// The most files one list answers with (backend `routes/files.py` MAX_LIST, held equal by
|
||||
|
||||
@@ -7,14 +7,19 @@ 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(),
|
||||
}));
|
||||
const { downloadLinks, createDownloadLink, folderLinks, createFolderLink, copyText, notify } =
|
||||
vi.hoisted(() => ({
|
||||
downloadLinks: vi.fn(),
|
||||
createDownloadLink: vi.fn(),
|
||||
folderLinks: vi.fn(),
|
||||
createFolderLink: vi.fn(),
|
||||
copyText: vi.fn(),
|
||||
notify: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./api", () => ({ api: { downloadLinks, createDownloadLink } }));
|
||||
vi.mock("./api", () => ({
|
||||
api: { downloadLinks, createDownloadLink, folderLinks, createFolderLink },
|
||||
}));
|
||||
vi.mock("./clipboard", () => ({ copyText }));
|
||||
vi.mock("./notifications", () => ({ notify }));
|
||||
|
||||
@@ -38,6 +43,8 @@ function qc() {
|
||||
beforeEach(() => {
|
||||
downloadLinks.mockReset();
|
||||
createDownloadLink.mockReset();
|
||||
folderLinks.mockReset();
|
||||
createFolderLink.mockReset();
|
||||
copyText.mockReset().mockResolvedValue(true);
|
||||
notify.mockReset();
|
||||
});
|
||||
@@ -48,7 +55,7 @@ describe("quickShareLink", () => {
|
||||
createDownloadLink.mockResolvedValue({ id: 7, url: "/watch/tok7" });
|
||||
const openSpy = vi.spyOn(window, "open").mockReturnValue(null);
|
||||
|
||||
const url = await quickShareLink(qc(), { id: 42 }, t);
|
||||
const url = await quickShareLink(qc(), { kind: "file", id: 42 }, t);
|
||||
|
||||
expect(url).toBe(`${origin}/watch/tok7`);
|
||||
// Minted with an EMPTY body so the backend default (allow_download=true) applies.
|
||||
@@ -65,7 +72,7 @@ describe("quickShareLink", () => {
|
||||
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);
|
||||
const url = await quickShareLink(qc(), { kind: "file", id: 1 }, t);
|
||||
|
||||
expect(url).toBe(`${origin}/watch/old`);
|
||||
expect(createDownloadLink).not.toHaveBeenCalled();
|
||||
@@ -78,7 +85,7 @@ describe("quickShareLink", () => {
|
||||
{ id: 3, url: "/watch/older" },
|
||||
]);
|
||||
|
||||
const url = await quickShareLink(qc(), { id: 1 }, t);
|
||||
const url = await quickShareLink(qc(), { kind: "file", id: 1 }, t);
|
||||
|
||||
expect(url).toBe(`${origin}/watch/older`);
|
||||
expect(createDownloadLink).not.toHaveBeenCalled();
|
||||
@@ -90,7 +97,7 @@ describe("quickShareLink", () => {
|
||||
createDownloadLink.mockResolvedValue({ id: 8, url: "/watch/new" });
|
||||
const client = qc();
|
||||
|
||||
const url = await quickShareLink(client, { id: 6 }, t);
|
||||
const url = await quickShareLink(client, { kind: "file", id: 6 }, t);
|
||||
|
||||
expect(url).toBe(`${origin}/watch/new`);
|
||||
expect(copyText).toHaveBeenCalledWith(`${origin}/watch/new`);
|
||||
@@ -100,11 +107,29 @@ describe("quickShareLink", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("a folder's link is the folder's own: listed, made and cached under the folder", async () => {
|
||||
folderLinks.mockResolvedValue([]);
|
||||
createFolderLink.mockResolvedValue({ id: 9, url: "/watch/fold" });
|
||||
const client = qc();
|
||||
client.setQueryData(qk.fileFolders(), []);
|
||||
|
||||
const url = await quickShareLink(client, { kind: "folder", id: 5 }, t);
|
||||
|
||||
expect(url).toBe(`${origin}/watch/fold`);
|
||||
expect(folderLinks).toHaveBeenCalledWith(5);
|
||||
expect(createFolderLink).toHaveBeenCalledWith(5, {});
|
||||
expect(downloadLinks).not.toHaveBeenCalled();
|
||||
expect(createDownloadLink).not.toHaveBeenCalled();
|
||||
expect(client.getQueryData(qk.folderLinks(5))).toEqual([{ id: 9, url: "/watch/fold" }]);
|
||||
// The rail's mark of a shared folder is asked for again.
|
||||
expect(client.getQueryState(qk.fileFolders())!.isInvalidated).toBe(true);
|
||||
});
|
||||
|
||||
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);
|
||||
const url = await quickShareLink(qc(), { kind: "file", id: 2 }, t);
|
||||
|
||||
expect(url).toBe(`${origin}/watch/x`); // the link is never a dead end
|
||||
const arg = firstToast();
|
||||
@@ -117,7 +142,7 @@ describe("quickShareLink", () => {
|
||||
downloadLinks.mockResolvedValue([]);
|
||||
createDownloadLink.mockRejectedValue(new Error("boom"));
|
||||
|
||||
const url = await quickShareLink(qc(), { id: 9 }, t);
|
||||
const url = await quickShareLink(qc(), { kind: "file", id: 9 }, t);
|
||||
|
||||
expect(url).toBeNull();
|
||||
expect(copyText).not.toHaveBeenCalled();
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { QueryClient } from "@tanstack/react-query";
|
||||
import { qk } from "./queryKeys";
|
||||
import { api } from "./api";
|
||||
import { api, type NewShareLink } from "./api";
|
||||
import { copyText } from "./clipboard";
|
||||
import { notify } from "./notifications";
|
||||
import { notifyActionError } from "./actionErrors";
|
||||
@@ -16,28 +16,51 @@ export function invalidateDownloads(qc: QueryClient) {
|
||||
qc.invalidateQueries({ queryKey: qk.storageUsage() });
|
||||
}
|
||||
|
||||
// What a public link opens: one file, or one of the user's own folders (whatever is in it when the
|
||||
// link is opened).
|
||||
export type LinkTarget = { kind: "file"; id: number } | { kind: "folder"; id: number };
|
||||
|
||||
export const linksKey = (target: LinkTarget) =>
|
||||
target.kind === "file" ? qk.downloadLinks(target.id) : qk.folderLinks(target.id);
|
||||
|
||||
export const fetchLinks = (target: LinkTarget) =>
|
||||
target.kind === "file" ? api.downloadLinks(target.id) : api.folderLinks(target.id);
|
||||
|
||||
export const makeLink = (target: LinkTarget, body: NewShareLink) =>
|
||||
target.kind === "file"
|
||||
? api.createDownloadLink(target.id, body)
|
||||
: api.createFolderLink(target.id, body);
|
||||
|
||||
/** After a link was made, changed or revoked: its list, and — for a folder — the rail's mark of the
|
||||
* folders a live link opens (an expiry change can end it too). */
|
||||
export function invalidateLinks(qc: QueryClient, target: LinkTarget) {
|
||||
void qc.invalidateQueries({ queryKey: linksKey(target) });
|
||||
if (target.kind === "folder") void qc.invalidateQueries({ queryKey: qk.fileFolders() });
|
||||
}
|
||||
|
||||
// One-click share: 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
|
||||
// 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.
|
||||
// Ensure the file or folder has a public link (minting one on demand — omitting `allow_download` so
|
||||
// the backend 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 },
|
||||
target: LinkTarget,
|
||||
t: (key: string) => string,
|
||||
): Promise<string | null> {
|
||||
let path;
|
||||
try {
|
||||
const existing = await qc.fetchQuery({
|
||||
queryKey: qk.downloadLinks(job.id),
|
||||
queryFn: () => api.downloadLinks(job.id),
|
||||
queryKey: linksKey(target),
|
||||
queryFn: () => fetchLinks(target),
|
||||
});
|
||||
// The newest link whose address can still be shown; one that cannot is no use to copy.
|
||||
path = existing.find((l) => l.url)?.url;
|
||||
if (!path) {
|
||||
const made = await api.createDownloadLink(job.id, {}); // omit allow_download → backend default (true)
|
||||
qc.setQueryData(qk.downloadLinks(job.id), [made, ...existing]);
|
||||
const made = await makeLink(target, {}); // omit allow_download → backend default (true)
|
||||
qc.setQueryData(linksKey(target), [made, ...existing]);
|
||||
if (target.kind === "folder") void qc.invalidateQueries({ queryKey: qk.fileFolders() });
|
||||
path = made.url;
|
||||
}
|
||||
if (!path) throw new Error("the new link came back without its address");
|
||||
|
||||
@@ -77,6 +77,7 @@ export const qk = {
|
||||
downloadProfiles: () => ["download-profiles"] as const,
|
||||
downloadCredentials: () => ["download-credentials"] as const,
|
||||
downloadLinks: (jobId: number) => ["download-links", jobId] as const,
|
||||
folderLinks: (folderId: number) => ["folder-links", folderId] as const,
|
||||
dlQuota: (userId: number) => ["dl-quota", userId] as const,
|
||||
storyboard: (jobId: number) => ["storyboard", jobId] as const,
|
||||
|
||||
|
||||
Reference in New Issue
Block a user