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:
2026-08-29 19:51:58 +02:00
parent 584e9ced0c
commit 104bd1e3a6
2 changed files with 76 additions and 2 deletions
@@ -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();
});
});
+39 -2
View File
@@ -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