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
@@ -50,6 +50,7 @@ const folder = (id: number, extra: Partial<FileFolder>): FileFolder => ({
count: 1,
size_bytes: 0,
created_at: null,
has_link: false,
...extra,
});
+5 -2
View File
@@ -195,7 +195,10 @@ export default function DownloadTray() {
title,
}),
message: t(job.asset?.media_kind === "file" ? "tray.done.file" : "tray.done.phone"),
action: { label: t("tray.done.copyLink"), onClick: () => void quickShareLink(qc, job, t) },
action: {
label: t("tray.done.copyLink"),
onClick: () => void quickShareLink(qc, { kind: "file", id: job.id }, t),
},
secondaryAction: {
label: t("tray.done.open"),
onClick: () => openFile(job.id, job.folder_id),
@@ -295,7 +298,7 @@ export default function DownloadTray() {
id: "copyLink",
label: t("tray.done.copyLink"),
icon: <Link className="w-4 h-4" />,
onSelect: () => void quickShareLink(qc, job, t),
onSelect: () => void quickShareLink(qc, { kind: "file", id: job.id }, t),
},
]);
const menuItems = menu.target
@@ -151,6 +151,7 @@ const folder = (id: number, extra: Partial<FileFolder>): FileFolder => ({
count: 1,
size_bytes: 0,
created_at: null,
has_link: false,
...extra,
});
const YT = folder(1, { source_key: "youtube" });
@@ -853,12 +854,14 @@ describe("Files · the grid", () => {
describe("Files · the open folder's menu", () => {
const folderMenu = () => screen.getByRole("button", { name: "files.folder.menu" });
it("an own folder is renamed, kept for a while, or deleted", async () => {
it("an own folder is shared by link, renamed, kept for a while, or deleted", async () => {
mocks.files = [];
mocks.folders = [YT, OWN];
mount({ kind: "folder", id: 2 });
await openMenu(() => fireEvent.click(folderMenu()));
expect(menuRows()).toEqual([
"files.actions.copyLink",
"files.folder.shareLink",
"files.folder.rename",
"files.folder.retention",
"files.folder.delete",
+1
View File
@@ -751,6 +751,7 @@ export default function Files() {
ariaLabel={t("files.folder.menu")}
items={folderMenu.target ? folderActions.items(folderMenu.target) : []}
/>
{folderActions.dialog}
<Suspense fallback={null}>
{upload && (
<UploadDialog
@@ -62,6 +62,7 @@ const folder = (id: number, extra: Partial<FileFolder>): FileFolder => ({
count: 1,
size_bytes: 0,
created_at: null,
has_link: false,
...extra,
});
const YT = folder(1, { source_key: "youtube" });
@@ -124,6 +125,8 @@ describe("FilesRail · folders", () => {
});
await waitFor(() =>
expect(menuRows()).toEqual([
"files.actions.copyLink",
"files.folder.shareLink",
"files.folder.rename",
"files.folder.retention",
"files.folder.delete",
@@ -196,3 +199,62 @@ describe("FilesRail · what it says when a part is empty or missing", () => {
expect(await screen.findByText("files.usage.failed")).toBeTruthy();
});
});
describe("FilesRail · a folder shared by link", () => {
it("marks the folder a live link opens, and only that one", async () => {
const work = folder(5, { kind: "own", name: "Munka", retention_days: null });
await mount([YT, { ...OWN, has_link: true }, work]);
expect(entry("Receptek").textContent).toContain("files.folder.shared");
expect(entry("Munka").textContent).not.toContain("files.folder.shared");
expect(entry("files.source.youtube").textContent).not.toContain("files.folder.shared");
});
it("copies the folder's link in one click, making it the first time", async () => {
await mount();
const list = vi.spyOn(api, "folderLinks").mockResolvedValue([]);
const make = vi
.spyOn(api, "createFolderLink")
.mockResolvedValue({ id: 1, url: "/watch/f" } as never);
act(() => {
fireEvent.contextMenu(entry("Receptek"));
});
await waitFor(() => expect(menuRows()).toContain("files.actions.copyLink"));
fireEvent.click(
screen
.getAllByRole("menuitem", { hidden: true })
.find((b) => b.textContent === "files.actions.copyLink")!,
);
await waitFor(() => expect(make).toHaveBeenCalledWith(2, {}));
expect(list).toHaveBeenCalledWith(2);
});
it("opens the folder's Share dialog", async () => {
await mount();
vi.spyOn(api, "folderLinks").mockResolvedValue([
{
id: 1,
token: "f",
url: "/watch/f",
allow_download: true,
has_password: false,
expires_at: null,
view_count: 0,
created_at: null,
},
]);
act(() => {
fireEvent.contextMenu(entry("Receptek"));
});
await waitFor(() => expect(menuRows()).toContain("files.folder.shareLink"));
fireEvent.click(
screen
.getAllByRole("menuitem", { hidden: true })
.find((b) => b.textContent === "files.folder.shareLink")!,
);
// The dialog is its own chunk, fetched on first use: slower than a re-render under a full run.
expect(
await screen.findByText("downloads.share.folderLinkHint", {}, { timeout: 5000 }),
).toBeTruthy();
expect(await screen.findByDisplayValue(`${window.location.origin}/watch/f`)).toBeTruthy();
});
});
+13 -1
View File
@@ -3,7 +3,7 @@ import { Meter } from "./ui/Meter";
import { useQuery } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import clsx from "clsx";
import { FolderOpen, FolderPlus, HardDrive, Users } from "lucide-react";
import { FolderOpen, FolderPlus, HardDrive, Link2, Users } from "lucide-react";
import { api, type FileFolder } from "../lib/api";
import { compareText } from "../lib/collate";
import { formatBytes } from "../lib/format";
@@ -36,16 +36,20 @@ function Entry({
view,
trigger,
folderId,
shared = false,
}: {
icon: React.ComponentType<{ className?: string }>;
label: string;
count?: number;
/** A live public link opens the folder: what is moved in is shown to whoever holds the link. */
shared?: boolean;
view: FilesView;
/** The folder's menu triggers (right click, long press, Shift+F10). */
trigger?: Record<string, unknown>;
/** A folder's entry is found by it, to hand the keyboard back after a rename. */
folderId?: number;
}) {
const { t } = useTranslation();
const { view: current } = useFiles();
const { setView } = useFilesActions();
const active = sameView(view, current);
@@ -64,6 +68,12 @@ function Entry({
>
<Icon className={clsx("size-4 shrink-0", !active && "text-muted")} />
<span className="min-w-0 flex-1 truncate text-left">{label}</span>
{shared && (
<span title={t("files.folder.shared")} className="shrink-0">
<Link2 aria-hidden className="size-3.5 text-accent" />
<span className="sr-only">{t("files.folder.shared")}</span>
</span>
)}
{count !== undefined && <span className="text-xs tabular-nums text-muted">{count}</span>}
</Pressable>
);
@@ -176,6 +186,7 @@ export default function FilesRail({
icon={folderIcon(f)}
label={folderLabel(f, t)}
count={f.count}
shared={f.has_link}
view={{ kind: "folder", id: f.id }}
trigger={menu.triggerProps(f)}
folderId={f.id}
@@ -267,6 +278,7 @@ export default function FilesRail({
ariaLabel={t("files.folder.menu")}
items={menu.target ? folderActions.items(menu.target) : []}
/>
{folderActions.dialog}
</FilterRail>
);
}
@@ -5,7 +5,7 @@
// colour class outranks them (clsx does not merge Tailwind), so a password-protected link looked
// unprotected and Revoke hovered neutral. They live on the ICON now; this pins them there.
import { describe, it, expect, afterEach, beforeEach, vi } from "vitest";
import { render, screen, cleanup } from "@testing-library/react";
import { render, screen, cleanup, fireEvent, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { ReactNode } from "react";
@@ -15,12 +15,30 @@ vi.mock("react-i18next", async (importOriginal) => ({
...(await importOriginal<typeof import("react-i18next")>()),
useTranslation: () => ({ t: (k: string) => k, i18n: { language: "en" } }),
}));
vi.mock("./ui/Modal", () => ({ default: ({ children }: { children: ReactNode }) => children }));
vi.mock("./confirmContext", () => ({ useConfirm: () => async () => false }));
const h = vi.hoisted(() => ({ links: null as unknown[] | null }));
vi.mock("./ui/Modal", () => ({
default: ({ title, children }: { title: string; children: ReactNode }) => (
<div>
<h2>{title}</h2>
{children}
</div>
),
}));
vi.mock("./confirmContext", () => ({ useConfirm: () => async () => h.confirm }));
const h = vi.hoisted(() => ({
confirm: false,
revokeDownloadLink: vi.fn(),
links: null as unknown[] | null,
folderLinks: vi.fn(),
createFolderLink: vi.fn(),
createDownloadLink: vi.fn(),
}));
vi.mock("../lib/api", () => ({
api: {
shareRecipients: async () => [],
folderLinks: h.folderLinks,
createFolderLink: h.createFolderLink,
createDownloadLink: h.createDownloadLink,
revokeDownloadLink: h.revokeDownloadLink,
downloadLinks: async () =>
h.links ?? [
{
@@ -53,6 +71,7 @@ import type { DownloadJob } from "../lib/api";
afterEach(cleanup);
beforeEach(() => {
h.links = null;
h.confirm = false;
});
const iconClass = (button: HTMLElement) => button.querySelector("svg")?.getAttribute("class") ?? "";
@@ -106,3 +125,86 @@ describe("ShareDialog · a link whose address cannot be shown again", () => {
expect(screen.getAllByRole("button", { name: "downloads.share.revoke" })).toHaveLength(2);
});
});
describe("ShareDialog · a folder", () => {
const link = (id: number) => ({
id,
token: `f${id}`,
url: `/watch/f${id}`,
allow_download: true,
has_password: false,
expires_at: null,
view_count: 0,
created_at: null,
});
it("shares by link only, says what a folder link shows, and makes it for the folder", async () => {
h.folderLinks.mockReset().mockResolvedValue([link(4)]);
h.createFolderLink.mockReset().mockResolvedValue(link(5));
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
render(
<QueryClientProvider client={qc}>
<ShareDialog folder={{ id: 7, name: "Friends" }} onClose={() => {}} />
</QueryClientProvider>,
);
expect(await screen.findByDisplayValue(`${window.location.origin}/watch/f4`)).toBeTruthy();
expect(h.folderLinks).toHaveBeenCalledWith(7);
expect(screen.getByRole("heading").textContent).toBe("downloads.share.folderTitle");
expect(screen.getByText("downloads.share.folderLinkHint")).toBeTruthy();
// No sharing with a user: "Shared with me" lists files, not folders.
expect(screen.queryByText("downloads.share.userSection")).toBeNull();
// A stream-only link, then the form is back at its default for the next one: downloadable.
fireEvent.click(screen.getByRole("button", { name: "downloads.share.newLink" }));
// The form's own checkbox, under the link rows' (each row has its own).
const allow = () => {
const boxes = screen.getAllByRole("checkbox", { name: "downloads.share.allowDownload" });
return boxes[boxes.length - 1]!;
};
fireEvent.click(allow());
expect((allow() as HTMLInputElement).checked).toBe(false);
fireEvent.click(screen.getByRole("button", { name: "downloads.share.createLink" }));
await waitFor(() =>
expect(h.createFolderLink).toHaveBeenCalledWith(
7,
expect.objectContaining({
allow_download: false,
}),
),
);
expect(h.createDownloadLink).not.toHaveBeenCalled();
fireEvent.click(await screen.findByRole("button", { name: "downloads.share.newLink" }));
expect((allow() as HTMLInputElement).checked).toBe(true);
});
it("makes a link on opening with none, but never right after the last one was revoked", async () => {
h.folderLinks.mockReset().mockResolvedValue([]);
h.createFolderLink.mockReset().mockResolvedValue(link(6));
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
const view = render(
<QueryClientProvider client={qc}>
<ShareDialog folder={{ id: 7, name: "Friends" }} onClose={() => {}} />
</QueryClientProvider>,
);
await waitFor(() => expect(h.createFolderLink).toHaveBeenCalledTimes(1));
view.unmount();
h.confirm = true;
h.folderLinks
.mockReset()
.mockResolvedValueOnce([link(4)])
.mockResolvedValue([]);
h.createFolderLink.mockReset();
h.revokeDownloadLink.mockReset().mockResolvedValue({ ok: true, deleted: 4 });
const qc2 = new QueryClient({ defaultOptions: { queries: { retry: false } } });
render(
<QueryClientProvider client={qc2}>
<ShareDialog folder={{ id: 7, name: "Friends" }} onClose={() => {}} />
</QueryClientProvider>,
);
fireEvent.click(await screen.findByRole("button", { name: "downloads.share.revoke" }));
await waitFor(() => expect(h.revokeDownloadLink).toHaveBeenCalledWith(4));
await screen.findByText("downloads.share.noLinks");
expect(h.createFolderLink).not.toHaveBeenCalled();
});
});
+40 -16
View File
@@ -10,6 +10,7 @@ import { notifyActionError } from "../lib/actionErrors";
import { copyText } from "../lib/clipboard";
import { formatDate } from "../lib/format";
import { api, type DownloadJob, type ShareLink, type ShareRecipient } from "../lib/api";
import { fetchLinks, invalidateLinks, linksKey, makeLink, type LinkTarget } from "../lib/downloads";
import { Button } from "./ui/Button";
import { IconButton } from "./ui/IconButton";
import { Input, InnerInput } from "./ui/Input";
@@ -237,12 +238,12 @@ function LinkRow({ link, onChanged }: { link: ShareLink; onChanged: () => void }
);
}
function LinkShare({ job }: { job: DownloadJob }) {
function LinkShare({ target }: { target: LinkTarget }) {
const { t } = useTranslation();
const qc = useQueryClient();
const links = useQuery({
queryKey: qk.downloadLinks(job.id),
queryFn: () => api.downloadLinks(job.id),
queryKey: linksKey(target),
queryFn: () => fetchLinks(target),
});
const [creating, setCreating] = useState(false);
// Downloadable by default — matches the one-click Share (which relies on the backend
@@ -250,11 +251,11 @@ function LinkShare({ job }: { job: DownloadJob }) {
const [allowDownload, setAllowDownload] = useState(true);
const [expiryDays, setExpiryDays] = useState<number | null>(null);
const [password, setPassword] = useState("");
const invalidate = () => void qc.invalidateQueries({ queryKey: qk.downloadLinks(job.id) });
const invalidate = () => invalidateLinks(qc, target);
const create = useMutation({
mutationFn: () =>
api.createDownloadLink(job.id, {
makeLink(target, {
allow_download: allowDownload,
expires_days: expiryDays,
password: password.trim() || undefined,
@@ -262,7 +263,8 @@ function LinkShare({ job }: { job: DownloadJob }) {
onSuccess: () => {
invalidate();
setCreating(false);
setAllowDownload(false);
// Back to the form's own default for the next link: downloadable.
setAllowDownload(true);
setExpiryDays(null);
setPassword("");
},
@@ -276,21 +278,26 @@ function LinkShare({ job }: { job: DownloadJob }) {
const autoTried = useRef(false);
// The mutation OBJECT is rebuilt every render; `mutate` is not (react-query memoises it), so the
// two halves the effect actually uses can be named while `links.data` stays the only real trigger.
// The autoTried ref is what makes it fire exactly once.
const { mutate: createLink, isPending: createPending } = create;
// The autoTried ref is what makes it fire exactly once — and only on the list the dialog OPENED
// with: revoking the last link empties the list too, and minting a fresh public link right after
// the owner revoked one is the opposite of what they asked for.
const { mutate: createLink } = create;
useEffect(() => {
if (links.data && links.data.length === 0 && !autoTried.current && !createPending) {
autoTried.current = true;
createLink();
}
}, [links.data, createLink, createPending]);
if (!links.data || autoTried.current) return;
autoTried.current = true;
if (links.data.length === 0) createLink();
}, [links.data, createLink]);
return (
<div>
<div className="flex items-center gap-2 text-sm font-medium mb-1">
<Link2 className="w-4 h-4 text-muted" /> {t("downloads.share.linkSection")}
</div>
<p className="text-xs text-muted mb-2">{t("downloads.share.linkHint")}</p>
<p className="text-xs text-muted mb-2">
{t(
target.kind === "folder" ? "downloads.share.folderLinkHint" : "downloads.share.linkHint",
)}
</p>
<div className="space-y-2">
{(links.data ?? []).map((l) => (
@@ -359,14 +366,31 @@ function LinkShare({ job }: { job: DownloadJob }) {
);
}
export default function ShareDialog({ job, onClose }: { job: DownloadJob; onClose: () => void }) {
// A file is shared with a registered user and by link; a folder by link only — "Shared with me" lists
// files, and a folder link already shows its files to whoever has it.
type ShareDialogProps = { onClose: () => void } & (
| { job: DownloadJob; folder?: never }
| { folder: { id: number; name: string }; job?: never }
);
export default function ShareDialog({ job, folder, onClose }: ShareDialogProps) {
const { t } = useTranslation();
if (folder)
return (
<Modal
initialFocus="close"
title={t("downloads.share.folderTitle", { name: folder.name })}
onClose={onClose}
>
<LinkShare target={{ kind: "folder", id: folder.id }} />
</Modal>
);
return (
<Modal initialFocus="close" title={t("downloads.share.title")} onClose={onClose}>
<div className="space-y-5">
<UserShare job={job} />
<div className="border-t border-border" />
<LinkShare job={job} />
<LinkShare target={{ kind: "file", id: job.id }} />
</div>
</Modal>
);
@@ -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();
});
});
+329 -167
View File
@@ -1,8 +1,22 @@
import { useEffect, useId, useState } from "react";
import { Download, File as FileIcon, Link2, Lock, PlayCircle, Repeat } from "lucide-react";
import clsx from "clsx";
import {
Download,
File as FileIcon,
Film,
Link2,
Lock,
Music,
Play,
PlayCircle,
Repeat,
} from "lucide-react";
import { Button } from "./ui/Button";
import { Chip } from "./ui/Chip";
import { formatBytes } from "../lib/format";
import { Pressable } from "./ui/Pressable";
import RemoteImage from "./ui/RemoteImage";
import { MEDIA_BADGE } from "./ui/mediaCardStyles";
import { formatBytes, formatDuration } from "../lib/format";
import { exposeVideoToBackgroundPlay } from "../lib/browserBackgroundPlay";
import { useMediaSession } from "../lib/mediaSession";
import { useMediaKeys } from "../lib/mediaKeys";
@@ -11,15 +25,17 @@ import { LS, readJSON, writeJSON } from "../lib/storage";
import { send } from "../lib/http";
import { Input } from "./ui/Input";
import { SURFACE } from "./ui/surfaces";
import { LinkButton } from "./ui/LinkButton";
import { IconLink, LinkButton } from "./ui/LinkButton";
import { T, lang } from "./watchStrings";
// Standalone, login-free video page for a shared /watch/<token> link. Its own entry (watchMain.tsx,
// watch.html): no auth, no providers, none of the app's bundle. Uses plain fetch and a tiny
// self-contained i18n dict (`watchStrings`: the public viewer has no app language preference).
// Standalone, login-free page for a shared /watch/<token> link: one file, or a folder's list of them.
// Its own entry (watchMain.tsx, watch.html): no auth, no providers, none of the app's bundle. Uses
// plain fetch and a tiny self-contained i18n dict (`watchStrings`: the public viewer has no app
// language preference).
type Meta = {
needs_password: boolean;
// One shared file: a file link's page, or one row of a folder link's.
type FileMeta = {
kind?: "file";
title?: string | null;
uploader?: string | null;
uploader_url?: string | null;
@@ -35,6 +51,21 @@ type Meta = {
poster_url?: string | null;
};
// A folder link's page: what is in the folder now, newest first.
type FolderMeta = {
kind: "folder";
name: string;
allow_download: boolean;
files: (FileMeta & { id: number })[];
};
type Meta = FileMeta | FolderMeta;
// What the link answers before it is opened: a password link says only which kind it is.
type Answer =
| (Meta & { needs_password?: false })
| { needs_password: true; kind?: "file" | "folder" };
// Compact display of a URL: drop the protocol + trailing slash so it fits on one line.
function displayUrl(url: string): string {
try {
@@ -60,37 +91,12 @@ export default function WatchPage() {
"loading" | "ready" | "password" | "gone" | "preparing" | "error"
>(token ? "loading" : "gone");
const [meta, setMeta] = useState<Meta | null>(null);
// A password link's kind, for its title, until it is unlocked.
const [locked, setLocked] = useState<"file" | "folder">("file");
const [password, setPassword] = useState("");
const [error, setError] = useState<string | null>(null);
const errorId = useId();
const [unlocking, setUnlocking] = useState(false);
// Whichever element renders (video or audio-only), as state: the OS's media controls, the keys and
// the resume bind to it once it exists.
const [media, setMedia] = useState<HTMLMediaElement | null>(null);
useMediaSession(
status === "ready" ? media : null,
meta && {
title: meta.title || "Video",
artist: meta.uploader || "Siftlode",
artwork: meta.poster_url,
},
);
useMediaKeys(media);
// It opens where this browser stopped in this link (the viewer has no account to keep it in).
// Read once per link, at render: the point a later checkpoint writes must not move the start.
const [start] = useState(() => (token ? linkPoint(token) : 0));
useResume(media, token || null, start, (position, duration) =>
saveLinkPoint(token, position, duration),
);
// Repeat the shared media when it ends — on unless the viewer turned it off. Remembered per browser
// (this page has no account); an unreadable storage just means the default.
const [repeat, setRepeat] = useState(() => readJSON<boolean>(LS.watchRepeat, true) !== false);
const toggleRepeat = () => {
const next = !repeat;
setRepeat(next);
writeJSON(LS.watchRepeat, next);
};
// Bumped by Retry, which asks again.
const [attempt, setAttempt] = useState(0);
@@ -101,18 +107,20 @@ export default function WatchPage() {
useEffect(() => {
if (!token) return; // the seed above already says "gone"
send(`/api/public/watch/${encodeURIComponent(token)}`)
.then((r): Promise<Meta | "preparing" | "gone"> => {
.then((r): Promise<Answer | "preparing" | "gone"> => {
// Only a 404 says the link is gone (unknown, expired, revoked, or its file removed) — or that
// its video is being made again (`notFoundState`). Anything else — a 5xx while the server
// restarts, a dropped connection — is a failure worth retrying, not a dead link.
if (r.status === 404) return notFoundState(r);
if (!r.ok) throw new Error(`HTTP ${r.status}`);
return r.json() as Promise<Meta>;
return r.json() as Promise<Answer>;
})
.then((m) => {
if (typeof m === "string") setStatus(m);
else if (m.needs_password) setStatus("password");
else {
else if (m.needs_password) {
setLocked(m.kind ?? "file");
setStatus("password");
} else {
setMeta(m);
setStatus("ready");
}
@@ -231,7 +239,8 @@ export default function WatchPage() {
<div className="flex-1 grid place-items-center">
<div className={`${SURFACE.panel} w-full max-w-sm p-6`}>
<div className="flex items-center gap-2 font-medium mb-4">
<Lock className="w-4 h-4 text-accent" /> {T.passwordTitle}
<Lock className="w-4 h-4 text-accent" />{" "}
{locked === "folder" ? T.passwordTitleFolder : T.passwordTitle}
</div>
<Input
className="w-full"
@@ -263,137 +272,290 @@ export default function WatchPage() {
</div>
)}
{status === "ready" && meta && meta.media_kind === "file" && (
/* A generic uploaded file has no player — just an info card (name/type/size) and, if the
link allows it, a Download. The backend serves the file force-download regardless, so
gating the button on allow_download only mirrors the owner's ACL, matching the media
branch's download affordance below. It is the page's one action, so it is the filled
primary, as the unlock card's Watch is. */
<div className="flex-1 grid place-items-center">
<div className={`${SURFACE.panel} w-full max-w-md p-6 text-center`}>
<div className="mx-auto w-16 h-16 rounded-xl bg-surface grid place-items-center mb-4">
<FileIcon className="w-8 h-8 text-accent" />
</div>
<h1 className="text-lg font-semibold leading-snug break-words">
{meta.title || T.file}
</h1>
<div className="text-sm text-muted mt-1">
{[meta.container?.toUpperCase(), formatBytes(meta.size_bytes, lang)]
.filter(Boolean)
.join(" · ")}
</div>
{meta.allow_download && (
<LinkButton
href={meta.file_url}
variant="primary"
icon={<Download />}
className="mt-5"
>
{T.download}
</LinkButton>
)}
</div>
</div>
{status === "ready" && meta?.kind === "folder" && (
<FolderView folder={meta} token={token} />
)}
{status === "ready" && meta && meta.media_kind !== "file" && (
<div>
{meta.audio ? (
/* Audio-only asset (mp3): WebKit won't play it in a <video>, so render a native
<audio>. No poster exists for these, so present it as a deliberate padded bar on
the surface (not the black video box) so it looks intentional without artwork. */
<div className={`${SURFACE.card} p-4`}>
<audio
ref={setMedia}
controls
loop={repeat}
src={meta.file_url}
className="w-full block"
/>
</div>
) : (
/* Shrink-wrap the player to the video so a vertical/short clip renders as a narrow
player instead of a wide box with black bars; a landscape clip fills the width. */
<div className="rounded-xl overflow-hidden bg-black border border-border w-fit max-w-full mx-auto">
<video
ref={(el) => {
setMedia(el);
// So Brave iOS's background play keeps it going when the app is left.
exposeVideoToBackgroundPlay(el);
}}
controls
playsInline
loop={repeat}
src={meta.file_url}
poster={meta.poster_url || undefined}
className="max-h-[80dvh] max-w-full mx-auto block"
/>
</div>
)}
{/* The actions wrap under the title when the row is too narrow for both (a phone), rather
than squeezing the title into a column a word wide. */}
<div className={`${SURFACE.panel} mt-3 flex flex-wrap items-start gap-x-3 gap-y-2 p-3`}>
<div className="min-w-0 flex-1 basis-60">
<h1 className="text-lg font-semibold leading-snug break-words">
{meta.title || "Video"}
</h1>
{meta.uploader &&
(meta.uploader_url ? (
<a
href={meta.uploader_url}
target="_blank"
rel="noopener noreferrer"
className="text-sm text-muted hover:text-accent hover:underline"
>
{meta.uploader}
</a>
) : (
<div className="text-sm text-muted">{meta.uploader}</div>
))}
</div>
<div className="flex shrink-0 items-center gap-2">
{/* An on/off toggle, so the shared pressed-pill paint (a solid fill when on) — not a
Button, whose variants differ only in colour. */}
<Chip
size="md"
pressed={repeat}
icon={<Repeat className="size-3.5" aria-hidden />}
onClick={toggleRepeat}
className="font-medium"
>
{T.repeat}
</Chip>
{/* The toolbar rung, level with the Repeat chip beside it (one row, one height). */}
{meta.allow_download && (
<LinkButton href={meta.file_url} icon={<Download />} size="sm" variant="glass">
{T.download}
</LinkButton>
)}
</div>
</div>
{(meta.source_url || (meta.extra_links && meta.extra_links.length > 0)) && (
<div className="mt-3 flex flex-col gap-1.5">
{[meta.source_url, ...(meta.extra_links ?? [])]
.filter((u): u is string => !!u)
.map((url) => (
<a
key={url}
href={url}
target="_blank"
rel="noopener noreferrer"
title={url}
className="inline-flex min-h-6 items-center gap-1.5 text-sm text-muted hover:text-accent hover:underline min-w-0 touch-target"
>
<Link2 className="w-3.5 h-3.5 shrink-0" />
<span className="truncate">{displayUrl(url)}</span>
</a>
))}
</div>
)}
</div>
{status === "ready" && meta && meta.kind !== "folder" && (
<FileView meta={meta} pointKey={token} />
)}
</div>
<div className="text-xs text-muted py-4">{T.brand}</div>
</div>
);
}
/** One shared file: its player (or, for a document, its card), title and actions. `pointKey` is where
* this browser keeps the point it stopped at: the link's token, or the token and the file's id in a
* folder. `nested` is a file shown inside a folder's page, whose heading is the folder's. */
function FileView({
meta,
pointKey,
nested = false,
}: {
meta: FileMeta;
pointKey: string;
nested?: boolean;
}) {
const Heading = nested ? "h2" : "h1";
// Whichever element renders (video or audio-only), as state: the OS's media controls, the keys and
// the resume bind to it once it exists.
const [media, setMedia] = useState<HTMLMediaElement | null>(null);
useMediaSession(media, {
title: meta.title || "Video",
artist: meta.uploader || "Siftlode",
artwork: meta.poster_url,
});
useMediaKeys(media);
// It opens where this browser stopped in this file (the viewer has no account to keep it in).
// Read once per file, at render: the point a later checkpoint writes must not move the start.
const [start] = useState(() => linkPoint(pointKey));
useResume(media, pointKey, start, (position, duration) =>
saveLinkPoint(pointKey, position, duration),
);
// Repeat the shared media when it ends — on unless the viewer turned it off. Remembered per browser
// (this page has no account); an unreadable storage just means the default.
const [repeat, setRepeat] = useState(() => readJSON<boolean>(LS.watchRepeat, true) !== false);
const toggleRepeat = () => {
const next = !repeat;
setRepeat(next);
writeJSON(LS.watchRepeat, next);
};
if (meta.media_kind === "file")
/* A generic uploaded file has no player — just an info card (name/type/size) and, if the
link allows it, a Download. The backend serves the file force-download regardless, so
gating the button on allow_download only mirrors the owner's ACL, matching the media
branch's download affordance below. It is the page's one action, so it is the filled
primary, as the unlock card's Watch is. */
return (
<div className="flex-1 grid place-items-center">
<div className={`${SURFACE.panel} w-full max-w-md p-6 text-center`}>
<div className="mx-auto w-16 h-16 rounded-xl bg-surface grid place-items-center mb-4">
<FileIcon className="w-8 h-8 text-accent" />
</div>
<Heading className="text-lg font-semibold leading-snug break-words">
{meta.title || T.file}
</Heading>
<div className="text-sm text-muted mt-1">
{[meta.container?.toUpperCase(), formatBytes(meta.size_bytes, lang)]
.filter(Boolean)
.join(" · ")}
</div>
{meta.allow_download && (
<LinkButton href={meta.file_url} variant="primary" icon={<Download />} className="mt-5">
{T.download}
</LinkButton>
)}
</div>
</div>
);
return (
<div>
{meta.audio ? (
/* Audio-only asset (mp3): WebKit won't play it in a <video>, so render a native
<audio>. No poster exists for these, so present it as a deliberate padded bar on
the surface (not the black video box) so it looks intentional without artwork. */
<div className={`${SURFACE.card} p-4`}>
<audio
ref={setMedia}
controls
autoPlay={nested}
loop={repeat}
src={meta.file_url}
className="w-full block"
/>
</div>
) : (
/* Shrink-wrap the player to the video so a vertical/short clip renders as a narrow
player instead of a wide box with black bars; a landscape clip fills the width. */
<div className="rounded-xl overflow-hidden bg-black border border-border w-fit max-w-full mx-auto">
<video
ref={(el) => {
setMedia(el);
// So Brave iOS's background play keeps it going when the app is left.
exposeVideoToBackgroundPlay(el);
}}
controls
playsInline
// In a folder the viewer picked it from the list just now: it starts.
autoPlay={nested}
loop={repeat}
src={meta.file_url}
poster={meta.poster_url || undefined}
className={clsx("max-w-full mx-auto block", nested ? "max-h-[60dvh]" : "max-h-[80dvh]")}
/>
</div>
)}
{/* The actions wrap under the title when the row is too narrow for both (a phone), rather
than squeezing the title into a column a word wide. */}
<div className={`${SURFACE.panel} mt-3 flex flex-wrap items-start gap-x-3 gap-y-2 p-3`}>
<div className="min-w-0 flex-1 basis-60">
<Heading className="text-lg font-semibold leading-snug break-words">
{meta.title || "Video"}
</Heading>
{meta.uploader &&
(meta.uploader_url ? (
<a
href={meta.uploader_url}
target="_blank"
rel="noopener noreferrer"
className="text-sm text-muted hover:text-accent hover:underline"
>
{meta.uploader}
</a>
) : (
<div className="text-sm text-muted">{meta.uploader}</div>
))}
</div>
<div className="flex shrink-0 items-center gap-2">
{/* An on/off toggle, so the shared pressed-pill paint (a solid fill when on) — not a
Button, whose variants differ only in colour. */}
<Chip
size="md"
pressed={repeat}
icon={<Repeat className="size-3.5" aria-hidden />}
onClick={toggleRepeat}
className="font-medium"
>
{T.repeat}
</Chip>
{/* The toolbar rung, level with the Repeat chip beside it (one row, one height). */}
{meta.allow_download && (
<LinkButton href={meta.file_url} icon={<Download />} size="sm" variant="glass">
{T.download}
</LinkButton>
)}
</div>
</div>
{(meta.source_url || (meta.extra_links && meta.extra_links.length > 0)) && (
<div className="mt-3 flex flex-col gap-1.5">
{[meta.source_url, ...(meta.extra_links ?? [])]
.filter((u): u is string => !!u)
.map((url) => (
<a
key={url}
href={url}
target="_blank"
rel="noopener noreferrer"
title={url}
className="inline-flex min-h-6 items-center gap-1.5 text-sm text-muted hover:text-accent hover:underline min-w-0 touch-target"
>
<Link2 className="w-3.5 h-3.5 shrink-0" />
<span className="truncate">{displayUrl(url)}</span>
</a>
))}
</div>
)}
</div>
);
}
/** A file's picture in the folder list: its poster frame, else a glyph of its kind; a video or a
* song carries its length. */
function RowThumb({ file }: { file: FileMeta }) {
const Glyph = file.media_kind === "file" ? FileIcon : file.audio ? Music : Film;
const length = file.media_kind !== "file" && file.duration_s ? file.duration_s : null;
// Above the length badge when there is one, so the badge never covers it.
const glyph = (
<Glyph
aria-hidden
className={clsx(
"absolute inset-x-0 mx-auto text-muted",
length ? "top-1 size-4" : "inset-y-0 my-auto size-5",
)}
/>
);
return (
<div className="relative h-10 w-16 shrink-0 overflow-hidden rounded-lg bg-surface">
<RemoteImage
src={file.poster_url}
alt=""
loading="lazy"
className="h-full w-full object-cover"
fallback={glyph}
/>
{length ? (
<span className={clsx(MEDIA_BADGE, "bottom-0.5 right-0.5")}>{formatDuration(length)}</span>
) : null}
</div>
);
}
/** A folder link's page: the folder's name, its files, and the one playing above them. Play opens the
* same player a file link has; a document only downloads. */
function FolderView({ folder, token }: { folder: FolderMeta; token: string }) {
const [playing, setPlaying] = useState<number | null>(null);
const current = folder.files.find((f) => f.id === playing);
const total = folder.files.reduce((n, f) => n + (f.size_bytes ?? 0), 0);
return (
<div className="flex flex-col gap-4">
<div>
<h1 className="text-xl font-bold leading-snug break-words">{folder.name}</h1>
<div className="text-sm text-muted">
{[T.files(folder.files.length), folder.files.length > 0 && formatBytes(total, lang)]
.filter(Boolean)
.join(" · ")}
</div>
</div>
{current && (
// Keyed by the file, so a new pick is a new player (its own resume point, its own media
// session) and brings the player into view — on a phone it is above the row just tapped.
<div key={current.id} ref={(el) => el?.scrollIntoView({ block: "start" })}>
<FileView meta={current} pointKey={`${token}/${current.id}`} nested />
</div>
)}
{folder.files.length === 0 ? (
<p className="text-sm text-muted">{T.folderEmpty}</p>
) : (
<ul className="flex flex-col">
{folder.files.map((f) => {
const title = f.title || T.file;
const body = (
<>
<RowThumb file={f} />
<span className="min-w-0 flex-1">
<span className="block truncate text-sm">{title}</span>
<span className="block text-xs text-muted">
{formatBytes(f.size_bytes, lang)}
</span>
</span>
</>
);
return (
<li
key={f.id}
className={clsx(
"flex items-center gap-1 rounded-lg",
f.id === playing && "bg-accent/10",
)}
>
{f.media_kind === "file" ? (
<div className="flex min-w-0 flex-1 items-center gap-3 px-1.5 py-1.5">{body}</div>
) : (
<Pressable
layer
selectable
aria-current={f.id === playing ? "true" : undefined}
aria-label={`${T.play}: ${title}`}
onClick={() => setPlaying(f.id)}
className="flex min-w-0 flex-1 items-center gap-3 rounded-lg px-1.5 py-1.5"
>
{body}
<Play aria-hidden className="size-4 shrink-0 text-muted" />
</Pressable>
)}
{folder.allow_download && (
<IconLink href={f.file_url} label={`${T.download}: ${title}`}>
<Download />
</IconLink>
)}
</li>
);
})}
</ul>
)}
</div>
);
}
+1 -1
View File
@@ -108,7 +108,7 @@ export function useFileActions({
const quickShare = async (file: LibraryFile) => {
if (sharingId !== null) return;
setSharingId(file.id);
await quickShareLink(qc, file, t).finally(() => setSharingId(null));
await quickShareLink(qc, { kind: "file", id: file.id }, t).finally(() => setSharingId(null));
};
const icon = (I: typeof PlayCircle) => <I className="w-4 h-4" />;
+46 -6
View File
@@ -1,18 +1,22 @@
import { lazy, Suspense, useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import { Clock, Pencil, Trash2 } from "lucide-react";
import { Clock, Link, Pencil, Share2, Trash2 } from "lucide-react";
import { api, type FileFolder, type FolderPatch } from "../lib/api";
import { notifyActionError } from "../lib/actionErrors";
import { invalidateDownloads } from "../lib/downloads";
import { invalidateDownloads, quickShareLink } from "../lib/downloads";
import { useConfirm } from "./confirmContext";
import { folderLabel } from "./fileFolders";
import { type ActionMenuItem } from "./ui/ActionMenu";
import { menuGroups } from "./ui/menuGroups";
// A library folder's ONE menu — the rail entry's right click and the Files page's "…" beside an open
// folder's name. An own folder is renamed (inline, where it is named), kept for a while and deleted; a
// source folder is named by its origin and cannot go, so only how long it keeps its files is its
// user's to set (the server holds the same rules: `routes/files.py`).
// folder's name. An own folder is shared by link (its link opens whatever is in it), renamed (inline,
// where it is named), kept for a while and deleted; a source folder is named by its origin and cannot
// go, so only how long it keeps its files is its user's to set (the server holds the same rules:
// `routes/files.py`, `routes/downloads.py`).
const ShareDialog = lazy(() => import("./ShareDialog"));
/** The choices the Retention submenu offers (days; null = until the user deletes the file). */
const RETENTION_CHOICES: readonly (number | null)[] = [7, 30, 90, null];
@@ -25,6 +29,9 @@ export function useFolderActions({ onRename }: { onRename: (folder: FileFolder)
const qc = useQueryClient();
const { t } = useTranslation();
const confirm = useConfirm();
// The own folder whose Share dialog is open (the hook's caller renders `dialog`).
const [sharing, setSharing] = useState<FileFolder | null>(null);
const [copying, setCopying] = useState(false);
// Every write lands in the listings too: a retention change moves its files' expiry, a delete moves
// the files themselves.
@@ -83,10 +90,34 @@ export function useFolderActions({ onRename }: { onRename: (folder: FileFolder)
if (ok) remove.mutate(folder.id);
};
// The same two a file row has, in its order: one click copies the folder's link (made the first
// time), the dialog is for a password, an expiry or revoking it.
const share = (folder: FileFolder): ActionMenuItem[] => [
{
id: "copy-link",
label: t("files.actions.copyLink"),
icon: icon(Link),
disabled: copying,
onSelect: () => {
setCopying(true);
void quickShareLink(qc, { kind: "folder", id: folder.id }, t).finally(() =>
setCopying(false),
);
},
},
{
id: "share-link",
label: t("files.folder.shareLink"),
icon: icon(Share2),
onSelect: () => setSharing(folder),
},
];
/** The folder's menu rows. */
const items = (folder: FileFolder): ActionMenuItem[] =>
folder.kind === "own"
? menuGroups(
share(folder),
[
{
id: "rename",
@@ -126,5 +157,14 @@ export function useFolderActions({ onRename }: { onRename: (folder: FileFolder)
}
};
return { items, rename, add };
const dialog = sharing && (
<Suspense fallback={null}>
<ShareDialog
folder={{ id: sharing.id, name: folderLabel(sharing, t) }}
onClose={() => setSharing(null)}
/>
</Suspense>
);
return { items, rename, add, dialog };
}
+12
View File
@@ -26,6 +26,10 @@ const STR = {
crashTitle: "Something went wrong.",
crashHint: "Reload the page to try again.",
reload: "Reload",
passwordTitleFolder: "This folder is password-protected",
folderEmpty: "This folder is empty.",
play: "Play",
files: (n: number) => (n === 1 ? "1 file" : `${n} files`),
},
hu: {
loading: "Betöltés…",
@@ -50,6 +54,10 @@ const STR = {
crashTitle: "Valami hiba történt.",
crashHint: "Töltsd újra az oldalt, és próbáld újra.",
reload: "Újratöltés",
passwordTitleFolder: "Ez a mappa jelszóval védett",
folderEmpty: "Ez a mappa üres.",
play: "Lejátszás",
files: (n: number) => `${n} fájl`,
},
de: {
loading: "Wird geladen…",
@@ -74,6 +82,10 @@ const STR = {
crashTitle: "Etwas ist schiefgelaufen.",
crashHint: "Lade die Seite neu und versuche es erneut.",
reload: "Neu laden",
passwordTitleFolder: "Dieser Ordner ist passwortgeschützt",
folderEmpty: "Dieser Ordner ist leer.",
play: "Abspielen",
files: (n: number) => (n === 1 ? "1 Datei" : `${n} Dateien`),
},
} as const;