Files
siftlode/frontend/src/components/WatchPage.tsx
T
peter 86712b6b0e 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.
2026-10-15 02:23:24 +02:00

562 lines
21 KiB
TypeScript

import { useEffect, useId, useState } from "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 { 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";
import { linkPoint, saveLinkPoint, useResume } from "../lib/resume";
import { LS, readJSON, writeJSON } from "../lib/storage";
import { send } from "../lib/http";
import { Input } from "./ui/Input";
import { SURFACE } from "./ui/surfaces";
import { IconLink, LinkButton } from "./ui/LinkButton";
import { T, lang } from "./watchStrings";
// 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).
// 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;
source_url?: string | null;
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;
};
// 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 {
const u = new URL(url);
return (u.host + u.pathname + u.search).replace(/\/$/, "");
} catch {
return url;
}
}
/** What a 404 from the link means: `video_preparing` is a link whose video is being made again (a
* re-download, a conversion) and will work shortly; any other is a link that is gone. */
async function notFoundState(r: Response): Promise<"preparing" | "gone"> {
const body = (await r.json().catch(() => null)) as { detail?: { reason?: string } } | null;
return body?.detail?.reason === "video_preparing" ? "preparing" : "gone";
}
export default function WatchPage() {
const token = window.location.pathname.split("/watch/")[1]?.split(/[/?#]/)[0] ?? "";
// Seeded from the URL rather than corrected by the effect below: with no token there is nothing
// to load, and "loading" was committed and painted before the effect said so.
const [status, setStatus] = useState<
"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);
// Bumped by Retry, which asks again.
const [attempt, setAttempt] = useState(0);
// Set once the viewer acted (Retry, the password): the state that answers replaces the button they
// were on, so its own Retry takes the focus rather than leaving it on <body>. Not on the first load,
// where nothing had the focus to lose.
const [acted, setActed] = useState(false);
useEffect(() => {
if (!token) return; // the seed above already says "gone"
send(`/api/public/watch/${encodeURIComponent(token)}`)
.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<Answer>;
})
.then((m) => {
if (typeof m === "string") setStatus(m);
else if (m.needs_password) {
setLocked(m.kind ?? "file");
setStatus("password");
} else {
setMeta(m);
setStatus("ready");
}
})
.catch(() => setStatus("error"));
}, [token, attempt]);
const retry = () => {
setActed(true);
setStatus("loading");
setAttempt((n) => n + 1);
};
// unlock()'s steps, apart so `unlocking` clears on every way out without a try/finally (the React
// Compiler cannot lower one yet, and skips the whole page).
const unlockSteps = async () => {
const r = await send(`/api/public/watch/${encodeURIComponent(token)}/unlock`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ password }),
});
if (r.status === 403) {
setError(T.wrong);
return;
}
// Unlocking is rate-limited per link: too many tries is not a dead link either.
if (r.status === 429) {
setError(T.tooMany);
return;
}
if (r.status === 404) {
setStatus(await notFoundState(r));
return;
}
if (!r.ok) throw new Error(`HTTP ${r.status}`);
setMeta(await r.json());
setStatus("ready");
};
const unlock = async () => {
setActed(true);
setUnlocking(true);
setError(null);
try {
await unlockSteps();
} catch {
setError(T.unlockFailed);
}
setUnlocking(false);
};
return (
// No page fill of its own (B6): the body's ambient scheme backdrop shows through, as in the app.
<div className="min-h-dvh text-fg flex flex-col items-center">
<div className="w-full max-w-4xl px-4 py-6 flex-1 flex flex-col">
<div className="flex items-center gap-2 mb-5 text-sm text-muted">
<PlayCircle className="w-5 h-5 text-accent" />
<span className="font-semibold text-fg">Siftlode</span>
</div>
{status === "loading" && (
<div role="status" className="flex-1 grid place-items-center text-muted">
{T.loading}
</div>
)}
{status === "gone" && (
<div className="flex-1 grid place-items-center text-center">
<div>
<div className="text-lg font-semibold">{T.gone}</div>
<div className="text-sm text-muted mt-1">{T.goneHint}</div>
</div>
</div>
)}
{status === "preparing" && (
<div className="flex-1 grid place-items-center text-center">
{/* An alert, like the failure below: it is inserted already filled, which a polite status
region is not reliably announced for — and a Retry that answers "still preparing"
would otherwise say nothing at all. */}
<div role="alert">
<div className="text-lg font-semibold">{T.preparing}</div>
<div className="text-sm text-muted mt-1">{T.preparingHint}</div>
<Button
variant="primary"
size="md"
onClick={retry}
autoFocus={acted}
className="mt-4"
>
{T.retry}
</Button>
</div>
</div>
)}
{status === "error" && (
<div className="flex-1 grid place-items-center text-center">
{/* Announced: it replaces the loading line, and this page has no live region of its own. */}
<div role="alert">
<div className="text-lg font-semibold">{T.failed}</div>
<div className="text-sm text-muted mt-1">{T.failedHint}</div>
<Button
variant="primary"
size="md"
onClick={retry}
autoFocus={acted}
className="mt-4"
>
{T.retry}
</Button>
</div>
</div>
)}
{status === "password" && (
<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" />{" "}
{locked === "folder" ? T.passwordTitleFolder : T.passwordTitle}
</div>
<Input
className="w-full"
type="password"
value={password}
autoFocus
onChange={(e) => setPassword(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && password && unlock()}
placeholder={T.passwordPlaceholder}
aria-describedby={error ? errorId : undefined}
/>
{/* Announced: focus stays in the input, so a silent message reads as a submit that did nothing. */}
{error && (
<div id={errorId} role="alert" className="text-sm text-danger mt-2">
{error}
</div>
)}
<Button
variant="primary"
size="md"
fullWidth
onClick={unlock}
disabled={!password || unlocking}
className="mt-4"
>
{T.watch}
</Button>
</div>
</div>
)}
{status === "ready" && meta?.kind === "folder" && (
<FolderView folder={meta} token={token} />
)}
{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>
);
}