Files
siftlode/frontend/src/lib/downloads.ts
T
peter 86712b6b0e 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.
2026-10-15 02:23:24 +02:00

83 lines
3.7 KiB
TypeScript

import type { QueryClient } from "@tanstack/react-query";
import { qk } from "./queryKeys";
import { api, type NewShareLink } from "./api";
import { copyText } from "./clipboard";
import { notify } from "./notifications";
import { notifyActionError } from "./actionErrors";
// The download queue, the per-card download index, the library (its listings and folder counts) and
// the storage meter all move together whenever a job is enqueued, deleted, finishes, or a file is
// renamed or moved — invalidate them as one unit.
export function invalidateDownloads(qc: QueryClient) {
qc.invalidateQueries({ queryKey: qk.downloads() });
qc.invalidateQueries({ queryKey: qk.downloadIndex() });
qc.invalidateQueries({ queryKey: qk.files() });
qc.invalidateQueries({ queryKey: qk.fileFolders() });
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 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,
target: LinkTarget,
t: (key: string) => string,
): Promise<string | null> {
let path;
try {
const existing = await qc.fetchQuery({
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 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");
} catch (e) {
notifyActionError(e, t("downloads.share.linkFailed"));
return null;
}
const url = window.location.origin + path;
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;
}