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:
@@ -50,6 +50,7 @@ const folder = (id: number, extra: Partial<FileFolder>): FileFolder => ({
|
||||
count: 1,
|
||||
size_bytes: 0,
|
||||
created_at: null,
|
||||
has_link: false,
|
||||
...extra,
|
||||
});
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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" />;
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user