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:
@@ -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());
|
||||
});
|
||||
});
|
||||
@@ -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)];
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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" };
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
|
||||
@@ -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[];
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user