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
+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>
);
}