feat(downloads): public generic-file watch surface (E7 S5d)
The /watch/{token} SPA gets a third branch: media_kind==='file' renders an
info card (name / TYPE · size) + a Download button, no player. The <video>/
<audio> branches now guard on media_kind!=='file' so legacy links
(media_kind null) still play. Download is gated on the link's allow_download,
mirroring the media branch; the backend serves a file force-download
(attachment + octet-stream) regardless, so the gate only reflects owner ACL.
FE-only (public_meta already emits media_kind/size_bytes/container since S5a).
i18n HU/EN/DE 'file' fallback label. Two DOM tests: file -> info card +
Download, no player; view-only file -> no Download.
This commit is contained in:
@@ -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(<WatchPage />);
|
||||
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(<WatchPage />);
|
||||
await findByText("secret.zip");
|
||||
expect(container.querySelector('a[href="/f.zip"]')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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/<token> 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() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{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. */
|
||||
<div className="flex-1 grid place-items-center">
|
||||
<div className="w-full max-w-md rounded-2xl glass 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)]
|
||||
.filter(Boolean)
|
||||
.join(" · ")}
|
||||
</div>
|
||||
{meta.allow_download && (
|
||||
<a
|
||||
href={meta.file_url}
|
||||
className="mt-5 inline-flex items-center gap-1.5 rounded-lg bg-surface hover:bg-card px-4 py-2 text-sm font-medium transition"
|
||||
>
|
||||
<Download className="w-4 h-4" /> {T.download}
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{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
|
||||
|
||||
Reference in New Issue
Block a user