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
@@ -324,3 +324,106 @@ describe("WatchPage resume", () => {
expect(loaded(other.container.querySelector("video")!).currentTime).toBe(0);
});
});
// A folder link opens the folder's list: a video or a song plays in the same player a file link has,
// above the list; a document only downloads; Download follows the link's own setting.
describe("WatchPage folder", () => {
const file = (id: number, extra: Record<string, unknown> = {}) => ({
id,
title: `Clip ${id}`,
audio: false,
media_kind: "video",
size_bytes: 1000,
duration_s: 71,
file_url: `/api/public/watch/tok-folder/files/${id}/file`,
poster_url: null,
...extra,
});
const folder = (extra: Record<string, unknown> = {}) => ({
needs_password: false,
kind: "folder",
name: "Friends",
allow_download: true,
files: [file(1), file(2), file(3, { title: "Plan.pdf", media_kind: "file", duration_s: null })],
...extra,
});
const scrolled = vi.fn();
Element.prototype.scrollIntoView = scrolled;
it("lists the folder's files and plays the one picked above the list", async () => {
window.history.replaceState({}, "", "/watch/tok-folder");
mockMeta(folder());
const page = render(<WatchPage />);
expect((await page.findByRole("heading", { level: 1 })).textContent).toBe("Friends");
expect(page.getByText(/^3 files/)).toBeTruthy();
expect(page.container.querySelector("video")).toBeNull();
fireEvent.click(page.getByRole("button", { name: "Play: Clip 2" }));
const video = page.container.querySelector("video")!;
expect(video.getAttribute("src")).toBe("/api/public/watch/tok-folder/files/2/file");
expect(video.autoplay).toBe(true);
expect(page.getByRole("button", { name: "Play: Clip 2" }).getAttribute("aria-current")).toBe(
"true",
);
// The file's title sits under the folder's, one level down.
expect(page.getByRole("heading", { level: 2 }).textContent).toBe("Clip 2");
expect(scrolled).toHaveBeenCalled();
fireEvent.click(page.getByRole("button", { name: "Play: Clip 1" }));
expect(page.container.querySelector("video")!.getAttribute("src")).toBe(
"/api/public/watch/tok-folder/files/1/file",
);
});
it("offers a document for download only, and Download only where the link allows it", async () => {
window.history.replaceState({}, "", "/watch/tok-folder");
mockMeta(folder());
const page = render(<WatchPage />);
await page.findByText("Plan.pdf");
expect(page.queryByRole("button", { name: "Play: Plan.pdf" })).toBeNull();
const pdf = page.getByRole("link", { name: "Download: Plan.pdf" });
expect(pdf.getAttribute("href")).toBe("/api/public/watch/tok-folder/files/3/file");
cleanup();
mockMeta(folder({ allow_download: false }));
const streamOnly = render(<WatchPage />);
await streamOnly.findByText("Plan.pdf");
expect(streamOnly.queryAllByRole("link", { name: /^Download/ })).toEqual([]);
});
it("each file keeps its own place in a folder", async () => {
window.history.replaceState({}, "", "/watch/tok-folder-resume");
mockMeta(folder());
const page = render(<WatchPage />);
fireEvent.click(await page.findByRole("button", { name: "Play: Clip 1" }));
const video = page.container.querySelector("video")!;
let t = 0;
Object.defineProperties(video, {
currentTime: { get: () => t, set: (v: number) => void (t = v), configurable: true },
duration: { get: () => 600, configurable: true },
readyState: { get: () => 1, configurable: true },
});
video.dispatchEvent(new Event("loadedmetadata"));
video.dispatchEvent(new Event("playing"));
video.currentTime = 250;
video.dispatchEvent(new Event("pause"));
const points = JSON.stringify(localStorage);
expect(points).toContain("tok-folder-resume/1");
expect(points).not.toContain('"tok-folder-resume"');
});
it("says when the folder is empty", async () => {
window.history.replaceState({}, "", "/watch/tok-folder");
mockMeta(folder({ files: [] }));
const page = render(<WatchPage />);
expect(await page.findByText("This folder is empty.")).toBeTruthy();
expect(page.getByText("0 files")).toBeTruthy();
});
it("a password folder link asks for the folder's password", async () => {
window.history.replaceState({}, "", "/watch/tok-folder-locked");
mockMeta({ needs_password: true, kind: "folder" });
const page = render(<WatchPage />);
expect(await page.findByText("This folder is password-protected")).toBeTruthy();
});
});