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:
2026-10-15 02:23:24 +02:00
parent 90567718b2
commit 86712b6b0e
33 changed files with 1880 additions and 321 deletions
+16 -4
View File
@@ -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.
+2
View File
@@ -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