feat(files): a file that is not media opens in a viewer — an image, a PDF, text as text

- GET /api/files/{id}/preview: an allow-list on the stored extension (downloads/preview.py) picks
  image / PDF / text; served inline under `CSP: sandbox; frame-ancestors 'self'`, the type from the
  table; HTML and SVG arrive as plain text; a text is its first 1 MiB (X-Preview-Truncated), sent
  as UTF-8 whatever it was written in (BOM, UTF-8, else cp1250); a NUL means binary: 415
- the list says which preview each file gets (asset.preview); FE/BE contract on kinds and header
- the viewer (shared dialog shell): ← / → through the list's such files in its order, Save, open in
  a tab, the file's menu; one stage height for every kind; a no-preview card offers the file
- the name, the card's picture and the menu's Preview open it; an image up to 8 MB is its own
  thumbnail
This commit is contained in:
2026-10-12 15:28:31 +02:00
parent 6707550513
commit fb8fb980e5
26 changed files with 1090 additions and 48 deletions
+15 -11
View File
@@ -18,15 +18,16 @@ import {
import { MoreButton } from "./ui/MoreButton";
import { Pressable } from "./ui/Pressable";
// A library file in the Files grid: the one media card (ui/MediaCard) — its picture plays it, the
// title under it, then where it came from and what needs saying about it (kept, leaving soon). The
// A library file in the Files grid: the one media card (ui/MediaCard) — its picture plays it (a file
// that is not media: opens it in the viewer), the title under it, then where it came from and what
// needs saying about it (kept, leaving soon). The
// right click, the long press and the "…" open the same menu as its table row (DESIGN.md "Files page":
// the grid is the list's other paint, a toggle remembered per folder).
export function FileCard({
file,
index,
onPlay,
onOpen,
menuProps,
moreProps,
menuOpen,
@@ -35,8 +36,8 @@ export function FileCard({
file: LibraryFile;
/** Its place on the page, for the keyboard walk. */
index: number;
/** Null: nothing to play (a plain file, a shared one its owner keeps from me). */
onPlay: (() => void) | null;
/** Plays it, or opens it in the viewer. Null: a shared file its owner keeps from me. */
onOpen: (() => void) | null;
menuProps: Record<string, unknown>;
moreProps: Record<string, unknown>;
menuOpen: boolean;
@@ -47,6 +48,7 @@ export function FileCard({
const title = jobTitle(file);
const channel = file.display_uploader || file.asset?.uploader || null;
const clip = file.job_kind === "edit" || file.job_kind === "clip";
const media = file.asset?.media_kind !== "file";
const thumb = <JobThumb job={file} box="h-full w-full" />;
return (
<MediaCard
@@ -55,17 +57,19 @@ export function FileCard({
className={clsx(arrived && "ring-2 ring-accent/60")}
picture={
<div className={mediaPictureBox("wide")}>
{onPlay ? (
{onOpen ? (
<Pressable
onClick={onPlay}
aria-label={t("downloads.actions.play")}
onClick={onOpen}
aria-label={t(media ? "downloads.actions.play" : "files.actions.view")}
data-nav-open=""
className={MEDIA_PICTURE}
>
{thumb}
<div className={MEDIA_HOVER_LAYER}>
<MediaPlayGlyph small />
</div>
{media && (
<div className={MEDIA_HOVER_LAYER}>
<MediaPlayGlyph small />
</div>
)}
</Pressable>
) : (
<div className={clsx(MEDIA_PICTURE, "cursor-default")}>{thumb}</div>
@@ -0,0 +1,159 @@
// @vitest-environment jsdom
//
// The Files viewer: what each kind of file shows (an image, a PDF in a frame, text as text — HTML too,
// never a page — or the "no preview" card), ← / → through the files it was given in their order,
// and closing when the file it shows leaves the list. i18n is key-passthrough.
import { afterEach, describe, expect, it, vi } from "vitest";
import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { api, HttpError, type LibraryFile } from "../lib/api";
vi.mock("react-i18next", async (importOriginal) => {
const actual = await importOriginal<typeof import("react-i18next")>();
return {
...actual,
useTranslation: () => ({
i18n: { language: "en" },
t: (key: string, o?: Record<string, unknown>) =>
o && "n" in o ? `${key}:${String(o.n)}/${String(o.total)}` : key,
}),
};
});
import FilePreview from "./FilePreview";
const file = (id: number, preview: string | null, extra: Record<string, unknown> = {}) =>
({
id,
status: "done",
display_name: `File ${id}`,
can_download: true,
job_kind: "upload",
shared: false,
source_key: "upload",
asset: { media_kind: "file", preview, container: "x", size_bytes: 4096, ...extra },
}) as unknown as LibraryFile;
afterEach(() => {
cleanup();
vi.restoreAllMocks();
});
function mount(files: LibraryFile[], fileId: number, onClose = vi.fn()) {
const onFileChange = vi.fn();
const view = (list: LibraryFile[], id: number) => (
<QueryClientProvider client={new QueryClient()}>
<FilePreview
files={list}
fileId={id}
onFileChange={onFileChange}
onClose={onClose}
menuItems={() => []}
/>
</QueryClientProvider>
);
const r = render(view(files, fileId));
return {
onFileChange,
onClose,
rerender: (l: LibraryFile[], id: number) => r.rerender(view(l, id)),
};
}
describe("FilePreview · what it shows", () => {
it("an image as an image, from the sandboxed preview", () => {
mount([file(1, "image")], 1);
const img = screen.getByRole("img", { name: "File 1" });
expect(img.getAttribute("src")).toBe(api.previewUrl(1));
});
it("a PDF in a frame of the preview, the browser's own viewer", () => {
mount([file(2, "pdf")], 2);
const frame = document.querySelector("iframe")!;
expect(frame.getAttribute("src")).toBe(api.previewUrl(2));
expect(frame.title).toBe("File 2");
});
it("a page's source as text, line by line — never as a page", async () => {
vi.spyOn(api, "previewText").mockResolvedValue({
text: "<h1 id=x>hi</h1>\n<script>boom()</script>",
truncated: false,
});
mount([file(3, "text")], 3);
await screen.findByText("<h1 id=x>hi</h1>");
expect(screen.getByText("<script>boom()</script>")).toBeTruthy();
expect(document.getElementById("x")).toBeNull();
expect(document.querySelector("script")).toBeNull();
expect(screen.getByText("files.preview.asText")).toBeTruthy();
});
it("numbers a file's lines, the newline that ends it making no line of its own", async () => {
vi.spyOn(api, "previewText").mockResolvedValue({ text: "one\r\ntwo\n", truncated: false });
mount([file(8, "text")], 8);
await screen.findByText("one");
expect([...document.querySelectorAll("li")].map((li) => li.textContent)).toEqual([
"1one",
"2two",
]);
});
it("says when it shows only the start of a long text", async () => {
vi.spyOn(api, "previewText").mockResolvedValue({ text: "a", truncated: true });
mount([file(4, "text")], 4);
expect(await screen.findByText("files.preview.truncated")).toBeTruthy();
});
it("a text the server refuses (binary after all) gets the no-preview card, not an error", async () => {
vi.spyOn(api, "previewText").mockRejectedValue(new HttpError(415, "x", {}));
mount([file(5, "text")], 5);
expect(await screen.findByText("files.preview.none")).toBeTruthy();
expect(screen.queryByRole("alert")).toBeNull();
// A new tab would only show the refusal.
expect(screen.queryByRole("link", { name: "files.preview.openInTab" })).toBeNull();
});
it("a text that fails to load says so, with a retry", async () => {
vi.spyOn(api, "previewText").mockRejectedValue(new HttpError(500, "x", {}));
mount([file(6, "text")], 6);
expect(await screen.findByRole("alert", {}, { timeout: 3000 })).toBeTruthy();
expect(screen.getByRole("button", { name: "common.retry" })).toBeTruthy();
});
it("anything else: the card that offers the file itself", () => {
mount([file(7, null)], 7);
expect(screen.getByText("files.preview.none")).toBeTruthy();
const save = screen.getAllByRole("link", { name: "downloads.actions.download" });
expect(save.every((a) => a.getAttribute("href") === api.fileUrl(7))).toBe(true);
// Nothing to open in a tab: the preview would refuse it.
expect(screen.queryByRole("link", { name: "files.preview.openInTab" })).toBeNull();
});
});
describe("FilePreview · stepping", () => {
const list = [file(1, "image"), file(2, "pdf"), file(3, null)];
it("← / → go to the files beside it, in the order given", () => {
const { onFileChange } = mount(list, 2);
expect(screen.getByText("files.preview.position:2/3")).toBeTruthy();
fireEvent.keyDown(window, { key: "ArrowRight" });
expect(onFileChange).toHaveBeenLastCalledWith(3);
fireEvent.keyDown(window, { key: "ArrowLeft" });
expect(onFileChange).toHaveBeenLastCalledWith(1);
});
it("stops at either end", () => {
const { onFileChange } = mount(list, 1);
fireEvent.keyDown(window, { key: "ArrowLeft" });
expect(onFileChange).not.toHaveBeenCalled();
expect(
screen.getByRole("button", { name: "files.preview.prev" }).hasAttribute("disabled"),
).toBe(true);
});
it("closes when the file it shows leaves the list", async () => {
const { onClose, rerender } = mount(list, 2);
expect(onClose).not.toHaveBeenCalled();
act(() => rerender([list[0]!, list[2]!], 2));
await waitFor(() => expect(onClose).toHaveBeenCalled());
});
});
+287
View File
@@ -0,0 +1,287 @@
import { useEffect, useEffectEvent, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import {
ChevronLeft,
ChevronRight,
Download,
ExternalLink,
FileQuestion,
WrapText,
} from "lucide-react";
import { api, HttpError, type FilePreviewKind, type LibraryFile } from "../lib/api";
import { formatBytes } from "../lib/format";
import { dispatch, useKeyScope } from "../lib/keymap";
import { qk } from "../lib/queryKeys";
import { jobTitle } from "./downloadTitle";
import { type ActionMenuItem } from "./ui/ActionMenu";
import { ContextMenu } from "./ui/ContextMenu";
import { IconButton } from "./ui/IconButton";
import { IconLink, LinkButton } from "./ui/LinkButton";
import Modal from "./ui/Modal";
import { MoreButton } from "./ui/MoreButton";
import { LoadingState, StateMessage } from "./ui/QueryState";
import { useContextMenu } from "./ui/useContextMenu";
// The Files viewer (DESIGN.md "Files preview"): the library's files that are not media, one at a time,
// in the shared dialog shell. What a file shows is the server's call (`asset.preview`,
// backend downloads/preview.py): an image as an image, a PDF in the browser's own viewer, a text
// format as plain text — HTML and SVG too, never rendered: on this origin a page would run with the
// reader's session. The rest gets a card that offers the file itself. ← / → step through `files`, the
// list's own order, so the viewer pages the way the list reads.
//
// Every kind fills the same stage, so a step never resizes the dialog: its ‹ › stay under the pointer.
const STAGE = "h-[min(75dvh,56rem)] w-full";
/** A text file's head (one read, shared by the viewer's header and its stage). */
function useTextPreview(id: number, enabled: boolean) {
return useQuery({
queryKey: qk.filePreview(id),
queryFn: () => api.previewText(id),
enabled,
// A refusal (the "text" turned out binary) is an answer, not a blip to retry.
retry: (n, e) => !(e instanceof HttpError && e.status < 500) && n < 1,
});
}
/** The server's "no preview" for a file it listed as text: it was binary after all. */
const refused = (e: unknown) => e instanceof HttpError && e.status === 415;
export default function FilePreview({
files,
fileId,
onFileChange,
onClose,
menuItems,
}: {
/** The files the viewer steps through, in the list's order. */
files: readonly LibraryFile[];
fileId: number;
onFileChange: (id: number) => void;
onClose: () => void;
/** The file's row menu, behind the viewer's "…". */
menuItems: (file: LibraryFile) => ActionMenuItem[];
}) {
const { t } = useTranslation();
const at = files.findIndex((f) => f.id === fileId);
const file = at >= 0 ? files[at] : undefined;
// Gone from the list under the viewer (deleted from its menu, or by another tab): nothing to show.
useEffect(() => {
if (!file) onClose();
}, [file, onClose]);
const step = (by: number) => {
const next = files[at + by];
if (next) onFileChange(next.id);
};
useKeyScope("fileViewer");
const onKey = useEffectEvent((e: KeyboardEvent) => {
dispatch(e, "fileViewer", {
prevFile: (ev) => {
ev.preventDefault();
step(-1);
},
nextFile: (ev) => {
ev.preventDefault();
step(1);
},
});
});
useEffect(() => {
const handler = (e: KeyboardEvent) => onKey(e);
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, []);
const menu = useContextMenu<LibraryFile>({ keyOf: (f) => String(f.id) });
const text = useTextPreview(fileId, file?.asset?.preview === "text");
if (!file) return null;
const kind = file.asset?.preview ?? null;
// A new tab would only show the refusal.
const openable = kind !== null && !refused(text.error);
const title = jobTitle(file);
return (
<Modal
initialFocus="close"
title={<span className="block truncate">{title}</span>}
onClose={onClose}
maxWidth="max-w-6xl"
headerActions={
<>
<IconButton
variant="ghost"
label={t("files.preview.prev")}
disabled={at <= 0}
onClick={() => step(-1)}
>
<ChevronLeft />
</IconButton>
<span className="text-xs tabular-nums text-muted">
{t("files.preview.position", { n: at + 1, total: files.length })}
</span>
<IconButton
variant="ghost"
label={t("files.preview.next")}
disabled={at >= files.length - 1}
onClick={() => step(1)}
>
<ChevronRight />
</IconButton>
{openable ? (
<IconLink
href={api.previewUrl(file.id)}
target="_blank"
rel="noopener"
label={t("files.preview.openInTab")}
>
<ExternalLink />
</IconLink>
) : (
// Its place kept, so the icons after it do not shift from one file to the next.
<span className="size-7 shrink-0" aria-hidden />
)}
<IconLink href={api.fileUrl(file.id)} label={t("downloads.actions.download")}>
<Download />
</IconLink>
<MoreButton
label={t("files.rowMenu")}
expanded={menu.open && menu.target?.id === file.id}
menuProps={menu.buttonProps(file)}
/>
<ContextMenu
menu={menu}
ariaLabel={t("files.rowMenu")}
items={menu.target ? menuItems(menu.target) : []}
/>
</>
}
>
{/* Keyed by file: a step starts the next one's own load, never a frame of the last one. */}
<Stage key={file.id} file={file} kind={kind} title={title} />
</Modal>
);
}
function Stage({
file,
kind,
title,
}: {
file: LibraryFile;
kind: FilePreviewKind | null;
title: string;
}) {
const { t } = useTranslation();
const [failed, setFailed] = useState(false);
if (kind === "image")
return failed ? (
<StateMessage tone="error" className={`${STAGE} justify-center`}>
{t("common.loadError")}
</StateMessage>
) : (
<div className={`${STAGE} flex items-center justify-center`}>
<img
src={api.previewUrl(file.id)}
alt={title}
onError={() => setFailed(true)}
className="max-h-full max-w-full rounded-md object-contain"
/>
</div>
);
if (kind === "pdf")
return (
<iframe
src={api.previewUrl(file.id)}
title={title}
className={`${STAGE} rounded-md border border-border bg-white`}
/>
);
if (kind === "text") return <TextStage file={file} />;
return <NoPreview file={file} />;
}
function TextStage({ file }: { file: LibraryFile }) {
const { t, i18n } = useTranslation();
const [wrap, setWrap] = useState(true);
const q = useTextPreview(file.id, true);
if (q.isError)
return refused(q.error) ? (
<NoPreview file={file} />
) : (
<StateMessage
tone="error"
onRetry={() => void q.refetch()}
className={`${STAGE} justify-center`}
>
{t("common.loadError")}
</StateMessage>
);
if (!q.data) return <LoadingState className={`${STAGE} justify-center`} />;
// A file's last line ends in a newline; that is no line of its own.
const lines = q.data.text.replace(/\r?\n$/, "").split(/\r?\n/);
return (
<div className={`${STAGE} flex flex-col gap-2`}>
<div className="flex items-center gap-2 text-xs text-muted">
<span className="min-w-0 flex-1 truncate">
{q.data.truncated
? t("files.preview.truncated", {
size: formatBytes(file.asset?.size_bytes, i18n.language),
})
: t("files.preview.asText")}
</span>
<IconButton
variant="ghost"
label={t("files.preview.wrap")}
pressed={wrap}
onClick={() => setWrap(!wrap)}
>
<WrapText />
</IconButton>
</div>
<div
// Scrolls by keyboard too: a focusable region with a name.
tabIndex={0}
role="region"
aria-label={jobTitle(file)}
className="min-h-0 flex-1 overflow-auto rounded-md border border-border bg-bg font-mono text-xs leading-relaxed"
>
<ol className="m-0 list-none p-0">
{lines.map((line, i) => (
<li key={i} className="flex">
<span
aria-hidden
className="sticky left-0 w-12 shrink-0 select-none bg-bg pr-3 text-right text-muted"
>
{i + 1}
</span>
<span
className={wrap ? "min-w-0 whitespace-pre-wrap wrap-anywhere" : "whitespace-pre"}
>
{line || " "}
</span>
</li>
))}
</ol>
</div>
</div>
);
}
function NoPreview({ file }: { file: LibraryFile }) {
const { t, i18n } = useTranslation();
const size = file.asset?.size_bytes;
return (
<div className={`${STAGE} flex flex-col items-center justify-center gap-3 text-sm text-muted`}>
<FileQuestion className="size-10" aria-hidden />
<span className="text-center">
{jobTitle(file)}
{file.asset?.container ? ` · ${file.asset.container.toUpperCase()}` : ""}
{size ? ` · ${formatBytes(size, i18n.language)}` : ""}
</span>
<span>{t("files.preview.none")}</span>
<LinkButton href={api.fileUrl(file.id)} icon={<Download className="size-4" />}>
{t("downloads.actions.download")}
</LinkButton>
</div>
);
}
@@ -402,6 +402,62 @@ describe("Files · a shared file's row", () => {
});
});
describe("Files · the viewer", () => {
const doc = (id: number, preview: string | null = "pdf") =>
file(id, {
job_kind: "upload",
display_name: `Doc ${id}`,
asset: { media_kind: "file", preview, container: "pdf", size_bytes: 10 },
} as never);
const viewer = () => screen.findByRole("dialog");
it("opens a file that is not media from its name, in the sandboxed preview", async () => {
mocks.files = [file(1), doc(2)];
mocks.folders = [YT];
mount();
fireEvent.click(screen.getByText("Doc 2"));
const dialog = await viewer();
expect(dialog.querySelector("iframe")!.getAttribute("src")).toBe(api.previewUrl(2));
});
it("puts Preview first in such a file's menu, and it opens the viewer", async () => {
mocks.files = [doc(2, null)];
mocks.folders = [YT];
mount();
await openMenu(() => fireEvent.contextMenu(rowOf("Doc 2")));
expect(menuRows()[0]).toBe("files.actions.view");
fireEvent.click(menuItem("files.actions.view"));
expect(await viewer()).toBeTruthy();
expect(screen.getByText("files.preview.none")).toBeTruthy();
});
it("steps through the list's files that are not media, in the list's order", async () => {
mocks.files = [doc(2), file(1), doc(3)];
mocks.folders = [YT];
mount();
fireEvent.click(screen.getByText("Doc 2"));
const dialog = await viewer();
fireEvent.keyDown(window, { key: "ArrowRight" });
await waitFor(() =>
expect(dialog.querySelector("iframe")!.getAttribute("src")).toBe(api.previewUrl(3)),
);
});
it("leaves Preview and Rename out of the viewer's own menu", async () => {
mocks.files = [doc(2)];
mocks.folders = [YT, OWN];
mount();
fireEvent.click(screen.getByText("Doc 2"));
const dialog = await viewer();
await openMenu(() =>
fireEvent.click(dialog.querySelector<HTMLElement>('button[aria-label="files.rowMenu"]')!),
);
expect(menuRows()).not.toContain("files.actions.view");
expect(menuRows()).not.toContain("files.actions.rename");
expect(menuRows()).toContain("files.actions.move");
});
});
describe("Files · the view", () => {
it("starts the list at the scroller's top, so its sticky header does not jump on the first scroll", () => {
mocks.files = [file(2)];
+27 -8
View File
@@ -44,6 +44,7 @@ import { useContextMenu } from "./ui/useContextMenu";
const UploadDialog = lazy(() => import("./UploadDialog"));
const ShareDialog = lazy(() => import("./ShareDialog"));
const LibraryPlayer = lazy(() => import("./LibraryPlayer"));
const FilePreview = lazy(() => import("./FilePreview"));
const VideoEditor = lazy(() => import("./VideoEditor"));
// The Files library: every done file of mine, the ones shared with me, or one folder's (the rail
@@ -110,12 +111,12 @@ function uploadFolder(view: FilesView, folders: readonly FileFolder[]): number |
function NameCell({
file,
renaming,
onPlay,
onOpen,
onRenamed,
}: {
file: LibraryFile;
renaming: boolean;
onPlay: (() => void) | null;
onOpen: (() => void) | null;
onRenamed: (name: string | null, byKey: boolean) => void;
}) {
const { t } = useTranslation();
@@ -128,11 +129,11 @@ function NameCell({
const title = <span className="block truncate text-sm font-medium">{jobTitle(file)}</span>;
return (
<div className="flex min-w-0 flex-col">
{onPlay ? (
{onOpen ? (
<Pressable
selectable
data-nav-open=""
onClick={onPlay}
onClick={onOpen}
title={jobTitle(file)}
className="min-w-0 rounded-sm hover:text-accent"
>
@@ -194,10 +195,13 @@ export default function Files() {
const [editFile, setEditFile] = useState<LibraryFile | null>(null);
const [playing, setPlaying] = useState<{ file: LibraryFile; fromStart: boolean } | null>(null);
const play = (file: LibraryFile, fromStart = false) => setPlaying({ file, fromStart });
// The file open in the viewer (one that is not media), by id: the list under it keeps re-reading.
const [viewing, setViewing] = useState<number | null>(null);
const actions = useFileActions({
folders,
onPlay: play,
onView: (f) => setViewing(f.id),
onEdit: setEditFile,
onRename: (f) => setRenamingId(f.id),
onEditDetails: setMetaFile,
@@ -309,8 +313,11 @@ export default function Files() {
}, []);
const iconSlot = <span className="size-9 shrink-0" aria-hidden />;
const playOf = (f: LibraryFile) =>
(f.shared ? sharedRowActions(f) : libraryRowActions(f)).play ? () => play(f) : null;
// What a row's name or a card's picture does: plays it, or opens it in the viewer.
const openOf = (f: LibraryFile) => {
const a = f.shared ? sharedRowActions(f) : libraryRowActions(f);
return a.play ? () => play(f) : a.view ? () => setViewing(f.id) : null;
};
const columns: Column<LibraryFile>[] = [
{
key: "thumb",
@@ -338,7 +345,7 @@ export default function Files() {
key={renamingId === f.id ? "renaming" : "shown"}
file={f}
renaming={renamingId === f.id}
onPlay={playOf(f)}
onOpen={openOf(f)}
onRenamed={(name, byKey) => {
if (byKey) refocusFile.current = f.id;
setRenamingId(null);
@@ -504,7 +511,7 @@ export default function Files() {
key={f.id}
file={f}
index={i}
onPlay={playOf(f)}
onOpen={openOf(f)}
menuProps={rowMenu.triggerProps(f)}
moreProps={rowMenu.buttonProps(f)}
menuOpen={rowMenu.open && rowMenu.target?.id === f.id}
@@ -655,6 +662,18 @@ export default function Files() {
/>
)}
{editFile && <VideoEditor job={editFile} onClose={() => setEditFile(null)} />}
{viewing !== null && (
<FilePreview
// The viewer pages the list as it reads (its sort), through the files it can open.
files={sortRows(rows, columns, sort, lang).filter(
(f) => (f.shared ? sharedRowActions(f) : libraryRowActions(f)).view,
)}
fileId={viewing}
onFileChange={setViewing}
onClose={() => setViewing(null)}
menuItems={(f) => actions.items(f, true)}
/>
)}
</Suspense>
</div>
{dragging && (
@@ -105,3 +105,33 @@ describe("JobThumb · no picture", () => {
expect(container.querySelector("svg.lucide-film")).not.toBeNull();
});
});
// A file that is not media: an image is its own picture (its sandboxed preview), unless it is too big
// for a thumbnail or fails; anything else draws the file glyph.
describe("JobThumb · a file", () => {
const image = (over: Partial<NonNullable<DownloadJob["asset"]>> = {}) =>
withAsset("png", { media_kind: "file", preview: "image", size_bytes: 200_000, ...over });
it("shows an image through its preview", () => {
const { container } = render(<JobThumb job={image()} />);
expect(new URL(container.querySelector("img")!.src).pathname).toBe("/api/files/1/preview");
});
it("keeps the glyph for an image too big to be a thumbnail, and for other files", () => {
const { container, unmount } = render(<JobThumb job={image({ size_bytes: 50_000_000 })} />);
expect(container.querySelector("img")).toBeNull();
expect(container.querySelector("svg.lucide-file")).not.toBeNull();
unmount();
const { container: pdf } = render(
<JobThumb job={withAsset("pdf", { media_kind: "file", preview: "pdf" })} />,
);
expect(pdf.querySelector("img")).toBeNull();
});
it("falls back to the glyph when the image fails", () => {
const { container } = render(<JobThumb job={image()} />);
fireEvent.error(container.querySelector("img")!);
expect(container.querySelector("img")).toBeNull();
expect(container.querySelector("svg.lucide-file")).not.toBeNull();
});
});
+29 -2
View File
@@ -58,6 +58,18 @@ const THUMB_BOX =
/** A job's picture: its poster, else the source's thumbnail, else a glyph of what it is. `box` sizes
* it (the Files list draws a smaller one). */
// An image larger than this keeps the file glyph: a row's thumbnail is the whole file, and a page of
// camera originals would pull hundreds of megabytes for pictures a few rem wide.
const IMAGE_THUMB_MAX_BYTES = 8 * 1024 * 1024;
/** An image file's own picture for its thumbnail, or null (not an image, not readable, too big). */
function imageThumbOf(job: DownloadJob): string | null {
const a = job.asset;
if (a?.preview !== "image" || !job.can_download) return null;
if ((a.size_bytes ?? Infinity) > IMAGE_THUMB_MAX_BYTES) return null;
return api.previewUrl(job.id);
}
export function JobThumb({ job, box = THUMB_BOX }: { job: DownloadJob; box?: string }) {
// Prefer OUR self-hosted poster: it's always present on disk, never expires, and is same-origin, so
// a source-CDN signature expiry (Facebook's fbcdn URLs expire within days) or an ad-blocker/Shields
@@ -83,14 +95,29 @@ export function JobThumb({ job, box = THUMB_BOX }: { job: DownloadJob; box?: str
// way, so the row falls to the glyph below instead of the browser's broken-image mark.
const [failedRemote, setFailedRemote] = useState<string | null>(null);
if (failedRemote !== null && failedRemote !== remote) setFailedRemote(null);
// An image file's own picture failed (gone since, or not an image after all): the glyph instead.
const [failedPicture, setFailedPicture] = useState<string | null>(null);
const candidate = failedPrimary && failedPrimary === primary ? remote : primary;
const src = candidate && candidate === failedRemote ? null : candidate;
// A generic uploaded file (media_kind='file') has no video frame to poster — it's a document/
// archive, not playable media. Show a neutral file glyph instead of an (always-empty) thumbnail.
// archive, not playable media. An image is its own picture (its sandboxed preview); the rest show
// a neutral file glyph instead of an (always-empty) thumbnail.
if (job.asset?.media_kind === "file") {
const picture = imageThumbOf(job);
return (
<div className={`${box} flex items-center justify-center text-muted`}>
<File className="w-8 h-8" aria-hidden="true" />
{picture && picture !== failedPicture ? (
<img
src={picture}
alt=""
loading="lazy"
decoding="async"
className="h-full w-full object-cover"
onError={() => setFailedPicture(picture)}
/>
) : (
<File className="w-8 h-8" aria-hidden="true" />
)}
</div>
);
}
+33 -12
View File
@@ -3,6 +3,7 @@ import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import {
Download,
Eye,
FolderInput,
Info,
Link,
@@ -54,6 +55,7 @@ const patchFail = (p: Patch) =>
export function useFileActions({
folders,
onPlay,
onView,
onEdit,
onRename,
onEditDetails,
@@ -62,6 +64,8 @@ export function useFileActions({
/** The caller's folders — the Move submenu's places. */
folders: readonly FileFolder[];
onPlay: (file: LibraryFile, fromStart?: boolean) => void;
/** Open a file that is not media in the Files viewer. */
onView: (file: LibraryFile) => void;
onEdit: (file: LibraryFile) => void;
onRename: (file: LibraryFile) => void;
onEditDetails: (file: LibraryFile) => void;
@@ -126,6 +130,12 @@ export function useFileActions({
: []),
];
};
const view = (file: LibraryFile): ActionMenuItem => ({
id: "view",
label: t("files.actions.view"),
icon: icon(Eye),
onSelect: () => onView(file),
});
const save = (file: LibraryFile): ActionMenuItem => ({
id: "save",
label: t("downloads.actions.download"),
@@ -184,12 +194,13 @@ export function useFileActions({
return { id: "move", label: t("files.actions.move"), icon: icon(FolderInput), items };
};
function mineItems(file: LibraryFile): ActionMenuItem[] {
function mineItems(file: LibraryFile, inViewer: boolean): ActionMenuItem[] {
const a = libraryRowActions(file);
return menuGroups(
// The row's icons first, in the row's order.
[
...(a.play ? play(file) : []),
...(a.view && !inViewer ? [view(file)] : []),
...(a.play ? [copyLink(file)] : []),
{
id: "share",
@@ -201,12 +212,17 @@ export function useFileActions({
],
[
...(a.edit ? [edit(file)] : []),
{
id: "rename",
label: t("files.actions.rename"),
icon: icon(Pencil),
onSelect: () => onRename(file),
},
// Renamed in place, in its row: not from the viewer that covers the row.
...(inViewer
? []
: [
{
id: "rename",
label: t("files.actions.rename"),
icon: icon(Pencil),
onSelect: () => onRename(file),
},
]),
moveTo(file),
{
id: "keep",
@@ -261,10 +277,14 @@ export function useFileActions({
);
}
function sharedItems(file: LibraryFile): ActionMenuItem[] {
function sharedItems(file: LibraryFile, inViewer: boolean): ActionMenuItem[] {
const a = sharedRowActions(file);
return menuGroups(
[...(a.play ? play(file) : []), ...(a.save ? [save(file)] : [])],
[
...(a.play ? play(file) : []),
...(a.view && !inViewer ? [view(file)] : []),
...(a.save ? [save(file)] : []),
],
a.edit ? [edit(file)] : [],
copies(file),
[
@@ -285,9 +305,10 @@ export function useFileActions({
);
}
/** The file's menu rows. */
const items = (file: LibraryFile): ActionMenuItem[] =>
file.shared ? sharedItems(file) : mineItems(file);
/** The file's menu rows; `inViewer`: the viewer's "…", which leaves out what the viewer is or
* what needs the row it covers. */
const items = (file: LibraryFile, inViewer = false): ActionMenuItem[] =>
file.shared ? sharedItems(file, inViewer) : mineItems(file, inViewer);
/** The row's Copy-link icon: the menu's row, one click. */
const share = (file: LibraryFile) => void quickShare(file);
+2 -1
View File
@@ -237,6 +237,7 @@
"file_folder_name_taken": "You already have a folder with that name",
"file_folder_not_own": "Only a folder you made can be renamed or deleted",
"file_folder_gone": "That folder no longer exists",
"file_folder_not_its_source": "A file can only go into one of your own folders or back to the folder it came from"
"file_folder_not_its_source": "A file can only go into one of your own folders or back to the folder it came from",
"file_no_preview": "There is no preview for this file"
}
}
+12 -1
View File
@@ -55,7 +55,8 @@
"keep": "Keep",
"unkeep": "Stop keeping",
"details": "Details…",
"upload": "Upload"
"upload": "Upload",
"view": "Preview"
},
"folder": {
"menu": "Folder actions",
@@ -91,5 +92,15 @@
"label": "A link to download",
"placeholder": "Paste a link and I'll download it (YouTube, TikTok, Facebook…)",
"download": "Download"
},
"preview": {
"prev": "Previous file",
"next": "Next file",
"position": "{{n}} / {{total}}",
"openInTab": "Open in a new tab",
"none": "There is no preview for this file",
"asText": "Shown as plain text",
"truncated": "Showing the start of this {{size}} file",
"wrap": "Wrap long lines"
}
}
+1
View File
@@ -10,6 +10,7 @@
"fineSeek": "Fine seek",
"episode": "Previous / next episode",
"video": "Previous / next video",
"file": "Previous / next file",
"volume": "Volume up / down",
"mute": "Mute",
"audio": "Cycle audio track",
+2 -1
View File
@@ -237,6 +237,7 @@
"file_folder_name_taken": "Már van ilyen nevű mappád",
"file_folder_not_own": "Csak a saját mappádat nevezheted át vagy törölheted",
"file_folder_gone": "Ez a mappa már nem létezik",
"file_folder_not_its_source": "Egy fájl csak a saját mappáidba vagy vissza az eredeti mappájába kerülhet"
"file_folder_not_its_source": "Egy fájl csak a saját mappáidba vagy vissza az eredeti mappájába kerülhet",
"file_no_preview": "Ehhez a fájlhoz nincs előnézet"
}
}
+12 -1
View File
@@ -55,7 +55,8 @@
"keep": "Megtartom",
"unkeep": "Megtartás visszavonása",
"details": "Részletek…",
"upload": "Feltöltés"
"upload": "Feltöltés",
"view": "Előnézet"
},
"folder": {
"menu": "Mappa műveletei",
@@ -91,5 +92,15 @@
"label": "Letöltendő link",
"placeholder": "Illessz be egy linket, és letöltöm (YouTube, TikTok, Facebook…)",
"download": "Letöltés"
},
"preview": {
"prev": "Előző fájl",
"next": "Következő fájl",
"position": "{{n}} / {{total}}",
"openInTab": "Megnyitás új lapon",
"none": "Ehhez a fájlhoz nincs előnézet",
"asText": "Egyszerű szövegként",
"truncated": "A {{size}} méretű fájl eleje látszik",
"wrap": "Hosszú sorok tördelése"
}
}
+1
View File
@@ -9,6 +9,7 @@
"seek5": "Tekerés 5 másodperc",
"fineSeek": "Finom léptetés",
"episode": "Előző / következő rész",
"file": "Előző / következő fájl",
"video": "Előző / következő videó",
"volume": "Hangerő fel / le",
"mute": "Némítás",
@@ -36,3 +36,33 @@ describe("display-name bound", () => {
for (const m of declared) expect(m[1]).toBe("DISPLAY_TEXT_MAX");
});
});
// The viewer reads what the server decides (backend downloads/preview.py): the preview kinds it may
// be told and the header that says a text was cut. Each side names them in its own language, so the
// gate reads both: a kind the server adds that the viewer does not know would fall to "no preview".
describe("file preview", () => {
it("knows the kinds the server hands out", () => {
const backend = src("../../../../backend/app/downloads/preview.py").match(
/^PreviewKind = Literal\[(.*)\]$/m,
);
const frontend = src("./downloads.ts").match(/^export type FilePreviewKind = (.*);$/m);
expect(backend, "downloads/preview.py no longer defines PreviewKind").toBeTruthy();
expect(frontend, "lib/api/downloads.ts no longer exports FilePreviewKind").toBeTruthy();
const kinds = (s: string, sep: string) =>
s
.split(sep)
.map((k) => k.trim().replace(/"/g, ""))
.sort();
expect(kinds(frontend![1]!, "|")).toEqual(kinds(backend![1]!, ","));
});
it("reads the header the server cuts a text with", () => {
const backend = src("../../../../backend/app/routes/files.py").match(
/^TRUNCATED_HEADER = "(.*)"$/m,
);
const frontend = src("./files.ts").match(/^const PREVIEW_TRUNCATED_HEADER = "(.*)";$/m);
expect(backend, "routes/files.py no longer defines TRUNCATED_HEADER").toBeTruthy();
expect(frontend, "lib/api/files.ts no longer defines PREVIEW_TRUNCATED_HEADER").toBeTruthy();
expect(frontend![1]).toBe(backend![1]);
});
});
+5
View File
@@ -39,9 +39,14 @@ interface DownloadAsset {
// (video/audio) gets play/edit; a generic uploaded file is download+share only, no player. Null
// while still in flight (set at finalize). See backend downloads/mediakind.py.
media_kind: "video" | "audio" | "file" | null;
// What the library's viewer shows of a generic file (backend downloads/preview.py): an image, a PDF
// in the browser's own viewer, plain text; null = no preview (media plays instead).
preview?: FilePreviewKind | null;
thumbnail_url: string | null;
}
export type FilePreviewKind = "image" | "pdf" | "text";
export type DownloadStatus = "queued" | "running" | "paused" | "done" | "error" | "canceled";
// "download" (default), "edit" (a per-user trim/crop derivative cut from another job), "upload"
// (a locally-uploaded file) or "clip" (a marked range of a YouTube video or a Plex item, cut straight
+19
View File
@@ -37,6 +37,14 @@ export interface LibraryFile extends DownloadJob {
source_key: string;
}
// Set by the server on a text preview cut at its size cap (`routes/files.py` TRUNCATED_HEADER).
const PREVIEW_TRUNCATED_HEADER = "X-Preview-Truncated";
export interface FilePreviewText {
text: string;
truncated: boolean;
}
// What a PATCH may change. Presence decides: a null / "" text falls back to the system value.
export interface FilePatch {
name?: string | null;
@@ -133,4 +141,15 @@ export const filesApi = {
const a = getActiveAccount();
return a != null ? `/api/files/${id}/file?account=${a}` : `/api/files/${id}/file`;
},
// The viewer's copy of an image or a PDF (`/preview`, sandboxed): an <img> / <iframe> source, so it
// carries the account the same way.
previewUrl: (id: number): string => {
const a = getActiveAccount();
return a != null ? `/api/files/${id}/preview?account=${a}` : `/api/files/${id}/preview`;
},
// A text file's head as text, and whether the server cut it there. Quiet: the viewer says why.
previewText: async (id: number): Promise<FilePreviewText> => {
const r = await req<Response>(`/api/files/${id}/preview`, {}, { raw: true, quiet: true });
return { text: await r.text(), truncated: r.headers.get(PREVIEW_TRUNCATED_HEADER) === "1" };
},
};
+19 -6
View File
@@ -1,5 +1,5 @@
// The Library/Shared row-action gate. A generic uploaded file (media_kind='file') is
// download+share only (no Play/Edit/Details); an edit clip and an upload can't be re-downloaded.
// The Library/Shared row-action gate. A generic uploaded file (media_kind='file') opens in the viewer
// instead of a player (no Play/Edit/Details); an edit clip and an upload can't be re-downloaded.
// These pin the gate so a regression that shows Play/Edit on a file, or re-download on an upload,
// is caught (the affordance itself, not just the backend guard behind it).
import { describe, it, expect } from "vitest";
@@ -31,14 +31,16 @@ describe("libraryRowActions", () => {
it("a media (video) item gets play, edit, details, redownload", () => {
expect(libraryRowActions(job({}, {}))).toEqual({
play: true,
view: false,
edit: true,
editDetails: true,
redownload: true,
});
});
it("a generic file gets NO play/edit/details (download+share only)", () => {
it("a generic file opens in the viewer and gets NO play/edit/details", () => {
const a = libraryRowActions(job({}, { media_kind: "file", duration_s: null }));
expect(a.view).toBe(true);
expect(a.play).toBe(false);
expect(a.edit).toBe(false);
expect(a.editDetails).toBe(false);
@@ -62,19 +64,30 @@ describe("libraryRowActions", () => {
describe("sharedRowActions", () => {
it("a downloadable media item gets play, save, edit", () => {
expect(sharedRowActions(job({}, {}))).toEqual({ play: true, save: true, edit: true });
expect(sharedRowActions(job({}, {}))).toEqual({
play: true,
view: false,
save: true,
edit: true,
});
});
it("nothing is offered when the owner disabled download", () => {
expect(sharedRowActions(job({ can_download: false }, {}))).toEqual({
play: false,
view: false,
save: false,
edit: false,
});
});
it("a shared generic file gets only save (no player/trim)", () => {
it("a shared generic file opens in the viewer and saves (no player/trim)", () => {
const a = sharedRowActions(job({}, { media_kind: "file", duration_s: null }));
expect(a).toEqual({ play: false, save: true, edit: false });
expect(a).toEqual({ play: false, view: true, save: true, edit: false });
});
it("a shared generic file its owner keeps from me does not open either", () => {
const a = sharedRowActions(job({ can_download: false }, { media_kind: "file" }));
expect(a.view).toBe(false);
});
});
+7 -2
View File
@@ -1,8 +1,8 @@
import type { DownloadJob } from "./api";
// Which row actions apply to a completed library / shared item — gated on the stored media_kind and
// job_kind. A generic uploaded file (media_kind='file') is download + share only: no player,
// no trim, no details edit. Re-download needs a re-fetchable source, which an edit clip and an upload
// job_kind. A generic uploaded file (media_kind='file') opens in the Files viewer instead of a player,
// and has no trim and no details edit. Re-download needs a re-fetchable source, which an edit clip and an upload
// both lack (the backend 409s both). A single source for the gate the Library + Shared rows render,
// so it's unit-testable instead of buried in JSX conditionals — and the two tabs can't drift apart.
@@ -11,6 +11,8 @@ const hasDuration = (job: DownloadJob): boolean => (job.asset?.duration_s ?? 0)
export interface LibraryRowActions {
play: boolean;
/** Opens in the Files viewer: a file that is not media (its preview, or the "no preview" card). */
view: boolean;
edit: boolean;
editDetails: boolean;
redownload: boolean;
@@ -20,6 +22,7 @@ export function libraryRowActions(job: DownloadJob): LibraryRowActions {
const media = isMediaKind(job);
return {
play: media,
view: !media && job.can_download,
edit: media && job.can_download && hasDuration(job),
editDetails: media,
redownload: !job.job_kind || job.job_kind === "download",
@@ -28,6 +31,7 @@ export function libraryRowActions(job: DownloadJob): LibraryRowActions {
export interface SharedRowActions {
play: boolean;
view: boolean;
save: boolean;
edit: boolean;
}
@@ -37,6 +41,7 @@ export function sharedRowActions(job: DownloadJob): SharedRowActions {
// The owner's `can_download` gates save/play/edit on a shared item (it reflects their link ACL).
return {
play: media && job.can_download,
view: !media && job.can_download,
save: job.can_download,
edit: media && job.can_download && hasDuration(job),
};
+5
View File
@@ -37,6 +37,7 @@ export type KeyScope =
| "ytPlayer"
| "plexPlayer"
| "mediaPlayer"
| "fileViewer"
| "plexBrowse";
const PAGE_LEVEL: ReadonlySet<KeyScope> = new Set<KeyScope>([
@@ -234,6 +235,10 @@ export const SHORTCUTS = [
},
{ id: "mute", scope: "mediaPlayer", keys: ["m"], labelKey: "keys.mute" },
// --- the Files viewer: through the list's files that are not media ---------------------------
{ id: "prevFile", scope: "fileViewer", keys: ["ArrowLeft"], labelKey: "keys.file" },
{ id: "nextFile", scope: "fileViewer", keys: ["ArrowRight"], labelKey: "keys.file" },
// --- the Plex browser (drill-down) ----------------------------------------------------------
{ id: "back", scope: "plexBrowse", keys: ["Backspace"], labelKey: "keys.browseBack" },
] as const satisfies readonly Shortcut[];
+1
View File
@@ -68,6 +68,7 @@ export const qk = {
// Every listing's key starts with "files", so one invalidation reaches each folder, search and sort.
files: (p?: FileListParams) => (p ? (["files", p] as const) : (["files"] as const)),
fileFolders: () => ["file-folders"] as const,
filePreview: (id: number) => ["file-preview", id] as const,
storageUsage: () => ["storage-usage"] as const,
// --- downloads ---
downloads: () => ["downloads"] as const,