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