diff --git a/frontend/src/components/WatchPage.dom.test.tsx b/frontend/src/components/WatchPage.dom.test.tsx index 74e0b1c1..0838192a 100644 --- a/frontend/src/components/WatchPage.dom.test.tsx +++ b/frontend/src/components/WatchPage.dom.test.tsx @@ -36,4 +36,41 @@ describe("WatchPage media element (S3 · 5a)", () => { await waitFor(() => expect(container.querySelector("video")).not.toBeNull()); expect(container.querySelector("audio")).toBeNull(); }); + + it("renders an info card + Download for a generic file, no player (S5d)", async () => { + window.history.replaceState({}, "", "/watch/tok-file"); + mockMeta({ + needs_password: false, + media_kind: "file", + title: "report.pdf", + container: "pdf", + size_bytes: 2_400_000, + allow_download: true, + file_url: "/f.pdf", + }); + const { container, findByText } = render(); + await findByText("report.pdf"); + expect(container.querySelector("video")).toBeNull(); + expect(container.querySelector("audio")).toBeNull(); + // Type · size chip and a Download link to the file. + await findByText(/PDF · 2\.3 MB/); + const dl = container.querySelector('a[href="/f.pdf"]'); + expect(dl).not.toBeNull(); + }); + + it("omits the Download for a view-only file link (S5d)", async () => { + window.history.replaceState({}, "", "/watch/tok-file-noview"); + mockMeta({ + needs_password: false, + media_kind: "file", + title: "secret.zip", + container: "zip", + size_bytes: 1000, + allow_download: false, + file_url: "/f.zip", + }); + const { container, findByText } = render(); + await findByText("secret.zip"); + expect(container.querySelector('a[href="/f.zip"]')).toBeNull(); + }); }); diff --git a/frontend/src/components/WatchPage.tsx b/frontend/src/components/WatchPage.tsx index 6ff7b9c3..0aae21f3 100644 --- a/frontend/src/components/WatchPage.tsx +++ b/frontend/src/components/WatchPage.tsx @@ -1,6 +1,7 @@ import { useEffect, useRef, useState } from "react"; -import { Download, Link2, Lock, PlayCircle } from "lucide-react"; +import { Download, File as FileIcon, Link2, Lock, PlayCircle } from "lucide-react"; import { Button } from "./ui/Button"; +import { formatBytes } from "../lib/format"; // Standalone, login-free video page for a shared /watch/ link. Rendered OUTSIDE the app // tree (no auth, no providers) — like the /privacy and /terms pages. Uses plain fetch and a tiny @@ -48,6 +49,9 @@ type Meta = { extra_links?: string[]; duration_s?: number | null; audio?: boolean; + media_kind?: "video" | "audio" | "file" | null; + size_bytes?: number | null; + container?: string | null; allow_download?: boolean; file_url?: string; poster_url?: string | null; @@ -73,6 +77,7 @@ const STR = { watch: "Watch", wrong: "Wrong password.", download: "Download", + file: "File", links: "Links", brand: "Shared via Siftlode", }, @@ -85,6 +90,7 @@ const STR = { watch: "Megnézem", wrong: "Hibás jelszó.", download: "Letöltés", + file: "Fájl", links: "Linkek", brand: "Megosztva a Siftlode-dal", }, @@ -97,6 +103,7 @@ const STR = { watch: "Ansehen", wrong: "Falsches Passwort.", download: "Herunterladen", + file: "Datei", links: "Links", brand: "Geteilt über Siftlode", }, @@ -215,7 +222,37 @@ export default function WatchPage() { )} - {status === "ready" && meta && ( + {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. */ + + + + + + + {meta.title || T.file} + + + {[meta.container?.toUpperCase(), formatBytes(meta.size_bytes)] + .filter(Boolean) + .join(" · ")} + + {meta.allow_download && ( + + {T.download} + + )} + + + )} + + {status === "ready" && meta && meta.media_kind !== "file" && ( {meta.audio ? ( /* Audio-only asset (mp3): WebKit won't play it in a , so render a native