Open bot files in a preview modal instead of a new tab
Markdown rendered, CSV/TSV as a table, images, PDFs, audio, video and sandboxed HTML in place, other text as code, with Download and Open in a new tab. Used by file chips, message attachments, the files modal, task files and the files browser. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
@@ -0,0 +1,61 @@
|
||||
import Img from "@moduletrace/twui/layout/Img";
|
||||
import Stack from "@moduletrace/twui/layout/Stack";
|
||||
import type { FilePreviewKind } from "@/src/utils/file-preview-kind";
|
||||
import FilePreviewNotice from "./file-preview-notice";
|
||||
import FilePreviewText from "./file-preview-text";
|
||||
|
||||
type Props = {
|
||||
kind: FilePreviewKind;
|
||||
name: string;
|
||||
url: string;
|
||||
size?: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* The preview itself, by kind. Media the browser draws itself (images,
|
||||
* PDFs, audio, video) load from the file's URL; text kinds are read into
|
||||
* the page (`FilePreviewText`). Nothing here runs the file: an SVG shows
|
||||
* through `<img>`, which runs no script, and an HTML page in a frame
|
||||
* sandboxed twice (the attribute here and the `sandbox` CSP the media
|
||||
* server sends with it, `servedFileHeaders`): no script, no forms, an
|
||||
* origin of its own.
|
||||
*/
|
||||
export default function FilePreviewBody({ kind, name, url, size }: Props) {
|
||||
if (kind == "image") {
|
||||
return (
|
||||
<Stack className="grow min-h-0 w-full items-center justify-center p-4 overflow-auto bg-sunken">
|
||||
<Img src={url} alt={name} className="max-w-full max-h-full object-contain" />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
if (kind == "pdf") {
|
||||
return <iframe src={url} title={name} className="block grow w-full border-0 bg-paper" />;
|
||||
}
|
||||
|
||||
if (kind == "html") {
|
||||
return <iframe src={url} title={name} sandbox="" referrerPolicy="no-referrer" className="block grow w-full border-0 bg-paper" />;
|
||||
}
|
||||
|
||||
if (kind == "audio") {
|
||||
return (
|
||||
<Stack className="grow w-full items-center justify-center p-6">
|
||||
<audio src={url} controls preload="metadata" className="w-full max-w-xl" />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
if (kind == "video") {
|
||||
return (
|
||||
<Stack className="grow min-h-0 w-full items-center justify-center p-4 bg-sunken">
|
||||
<video src={url} controls preload="metadata" className="max-w-full max-h-full" />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
if (kind == "none") {
|
||||
return <FilePreviewNotice message="This kind of file can't be previewed here. Download it to open it." />;
|
||||
}
|
||||
|
||||
return <FilePreviewText kind={kind} url={url} size={size} />;
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import CodeBlock from "@moduletrace/twui/elements/CodeBlock";
|
||||
import Stack from "@moduletrace/twui/layout/Stack";
|
||||
|
||||
type Props = {
|
||||
text: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* A plain text file in the preview, as it is, on the sunken surface with
|
||||
* twui's copy button.
|
||||
*/
|
||||
export default function FilePreviewCode({ text }: Props) {
|
||||
return (
|
||||
<Stack className="grow min-h-0 w-full overflow-auto p-4 items-stretch">
|
||||
<CodeBlock
|
||||
singleBlock
|
||||
backgroundColor="var(--color-sunken)"
|
||||
wrapperProps={{
|
||||
className:
|
||||
"max-w-full! outline-hairline dark:outline-hairline [&_.twui-code-block-header_svg]:stroke-ink-secondary",
|
||||
}}
|
||||
className="font-mono text-[13px] leading-5 text-ink py-2 pl-2 pr-10 whitespace-pre-wrap wrap-anywhere"
|
||||
>
|
||||
{text}
|
||||
</CodeBlock>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { useState, type ReactNode } from "react";
|
||||
import Link from "@moduletrace/twui/layout/Link";
|
||||
import isPlainClick from "@/src/utils/is-plain-click";
|
||||
import FilePreviewModal from "./file-preview-modal";
|
||||
|
||||
type Props = {
|
||||
name: string;
|
||||
url: string;
|
||||
media_type?: string | null;
|
||||
size?: number;
|
||||
title?: string;
|
||||
className?: string;
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
/**
|
||||
* A link to a file that opens it in the preview (`FilePreviewModal`) on a
|
||||
* plain click. It stays a real link to the bytes, so a middle click or
|
||||
* Ctrl/Cmd click still opens the file in a new tab.
|
||||
*/
|
||||
export default function FilePreviewLink({ name, url, media_type, size, title, className, children }: Props) {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Link
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
title={title || `Preview ${name}`}
|
||||
onClick={(event) => {
|
||||
if (!isPlainClick({ event })) return;
|
||||
|
||||
event.preventDefault();
|
||||
setOpen(true);
|
||||
}}
|
||||
className={className}
|
||||
>
|
||||
{children}
|
||||
</Link>
|
||||
{open ? (
|
||||
<FilePreviewModal
|
||||
name={name}
|
||||
url={url}
|
||||
media_type={media_type}
|
||||
size={size}
|
||||
onClose={() => setOpen(false)}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import Markdown, { type Components } from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import H2 from "@moduletrace/twui/layout/H2";
|
||||
import H3 from "@moduletrace/twui/layout/H3";
|
||||
import H4 from "@moduletrace/twui/layout/H4";
|
||||
import H5 from "@moduletrace/twui/layout/H5";
|
||||
import Stack from "@moduletrace/twui/layout/Stack";
|
||||
import { MessageMarkdownComponents } from "@/src/pages/admin/bots/[bot_id]/(data)/message-markdown-components";
|
||||
|
||||
type Props = {
|
||||
text: string;
|
||||
};
|
||||
|
||||
const RemarkPlugins = [remarkGfm];
|
||||
|
||||
const HeadingClassName = "my-0! tracking-normal! font-bold text-ink";
|
||||
|
||||
/**
|
||||
* A message's markdown elements, with headings sized for a document
|
||||
* rather than a chat line.
|
||||
*/
|
||||
const DocumentComponents: Components = {
|
||||
...MessageMarkdownComponents,
|
||||
h1: ({ children }) => <H2 className={twMerge(HeadingClassName, "text-2xl! leading-8! mt-2!")}>{children}</H2>,
|
||||
h2: ({ children }) => <H3 className={twMerge(HeadingClassName, "text-xl! leading-7! mt-2!")}>{children}</H3>,
|
||||
h3: ({ children }) => <H4 className={twMerge(HeadingClassName, "text-[17px]! leading-6! mt-1!")}>{children}</H4>,
|
||||
h4: ({ children }) => <H5 className={twMerge(HeadingClassName, "text-[15px]! leading-6!")}>{children}</H5>,
|
||||
h5: ({ children }) => <H5 className={twMerge(HeadingClassName, "text-sm! leading-5!")}>{children}</H5>,
|
||||
h6: ({ children }) => <H5 className={twMerge(HeadingClassName, "text-sm! leading-5! text-ink-secondary")}>{children}</H5>,
|
||||
};
|
||||
|
||||
/**
|
||||
* A markdown file rendered in the preview, through the same elements as a
|
||||
* bot's messages (raw HTML never rendered), in a readable column.
|
||||
*/
|
||||
export default function FilePreviewMarkdown({ text }: Props) {
|
||||
return (
|
||||
<Stack className="grow min-h-0 w-full overflow-auto items-center">
|
||||
<Stack className="w-full max-w-[760px] gap-3! items-stretch px-6 py-5">
|
||||
<Markdown remarkPlugins={RemarkPlugins} components={DocumentComponents}>
|
||||
{text}
|
||||
</Markdown>
|
||||
</Stack>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import Modal from "@moduletrace/twui/elements/Modal";
|
||||
import H2 from "@moduletrace/twui/layout/H2";
|
||||
import Link from "@moduletrace/twui/layout/Link";
|
||||
import Row from "@moduletrace/twui/layout/Row";
|
||||
import Span from "@moduletrace/twui/layout/Span";
|
||||
import Stack from "@moduletrace/twui/layout/Stack";
|
||||
import { Download, SquareArrowOutUpRight } from "lucide-react";
|
||||
import filePreviewKind from "@/src/utils/file-preview-kind";
|
||||
import formatBytes from "@/src/utils/format-bytes";
|
||||
import FilePreviewBody from "./file-preview-body";
|
||||
|
||||
type Props = {
|
||||
name: string;
|
||||
url: string;
|
||||
media_type?: string | null;
|
||||
size?: number;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
const ActionClassName =
|
||||
"shrink-0 inline-flex items-center gap-1.5 px-2 py-1.5 text-[13px] leading-[18px] text-ink-secondary hover:text-ink rounded-default hover:bg-sunken no-underline";
|
||||
|
||||
/**
|
||||
* A file over the page: markdown rendered, a CSV as a table, an image,
|
||||
* PDF, audio or video in place, other text as code, with ways to download
|
||||
* it or open it in a new tab. A file it can't show offers those only.
|
||||
*/
|
||||
export default function FilePreviewModal({
|
||||
name,
|
||||
url,
|
||||
media_type,
|
||||
size,
|
||||
onClose,
|
||||
}: Props) {
|
||||
const kind = filePreviewKind({ name, media_type });
|
||||
const base_name = name.split("/").pop() || name;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open
|
||||
setOpen={(next_open) => {
|
||||
if (!next_open) onClose();
|
||||
}}
|
||||
className="max-w-[95vw]! w-[95vw] h-[90vh] p-0 bg-paper flex flex-col overflow-hidden"
|
||||
>
|
||||
<Row className="w-full gap-3! flex-nowrap items-center pl-5 pr-14 py-3 border-0 border-b border-solid border-hairline">
|
||||
<Stack className="min-w-0 grow gap-0! items-start">
|
||||
<H2
|
||||
title={name}
|
||||
className="text-base! leading-6! tracking-normal! font-bold text-ink truncate w-full"
|
||||
>
|
||||
{base_name}
|
||||
</H2>
|
||||
{size ? (
|
||||
<Span className="text-[13px] leading-[18px] text-ink-secondary">
|
||||
{formatBytes({ bytes: size })}
|
||||
</Span>
|
||||
) : null}
|
||||
</Stack>
|
||||
<Link
|
||||
href={url}
|
||||
download={base_name}
|
||||
title={`Download ${base_name}`}
|
||||
className={ActionClassName}
|
||||
>
|
||||
<Download size={16} aria-hidden />
|
||||
<Span className="hidden sm:inline">Download</Span>
|
||||
</Link>
|
||||
<Link
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
title="Open in a new tab"
|
||||
aria-label="Open in a new tab"
|
||||
className={ActionClassName}
|
||||
>
|
||||
<SquareArrowOutUpRight size={16} aria-hidden />
|
||||
</Link>
|
||||
</Row>
|
||||
<FilePreviewBody
|
||||
kind={kind}
|
||||
name={base_name}
|
||||
url={url}
|
||||
size={size}
|
||||
/>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import P from "@moduletrace/twui/layout/P";
|
||||
import Stack from "@moduletrace/twui/layout/Stack";
|
||||
|
||||
type Props = {
|
||||
message: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* What the preview says instead of a file it doesn't show.
|
||||
*/
|
||||
export default function FilePreviewNotice({ message }: Props) {
|
||||
return (
|
||||
<Stack className="grow w-full items-center justify-center p-6">
|
||||
<P noMargin className="text-sm leading-5 text-ink-secondary text-center max-w-md">
|
||||
{message}
|
||||
</P>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
type Props = {
|
||||
value: string;
|
||||
header?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* One cell of a previewed CSV, its text kept to one readable width.
|
||||
*/
|
||||
export default function FilePreviewTableCell({ value, header }: Props) {
|
||||
const className = twMerge(
|
||||
"px-3 py-1.5 max-w-80 text-left align-top whitespace-pre-wrap wrap-break-word border-0 border-b border-r border-solid border-hairline",
|
||||
header && "font-bold border-hairline-strong",
|
||||
);
|
||||
|
||||
if (header) return <th className={className}>{value}</th>;
|
||||
|
||||
return <td className={className}>{value}</td>;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import FilePreviewTableCell from "./file-preview-table-cell";
|
||||
|
||||
type Props = {
|
||||
cells: string[];
|
||||
header?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* One row of a previewed CSV.
|
||||
*/
|
||||
export default function FilePreviewTableRow({ cells, header }: Props) {
|
||||
return (
|
||||
<tr>
|
||||
{cells.map((value, index) => (
|
||||
<FilePreviewTableCell key={index} value={value} header={header} />
|
||||
))}
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { useMemo } from "react";
|
||||
import P from "@moduletrace/twui/layout/P";
|
||||
import Stack from "@moduletrace/twui/layout/Stack";
|
||||
import { AppData } from "@/src/data/app-data";
|
||||
import parseCsv from "@/src/utils/parse-csv";
|
||||
import FilePreviewNotice from "./file-preview-notice";
|
||||
import FilePreviewTableRow from "./file-preview-table-row";
|
||||
|
||||
type Props = {
|
||||
text: string;
|
||||
delimiter: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* A CSV or TSV file as a table: its first row the header (kept in view
|
||||
* while scrolling), up to `AppData.FilePreviewMaxTableRows` rows. Cells
|
||||
* are text only. Tables have no children-based twui equivalent, so they're
|
||||
* styled elements, as in a message.
|
||||
*/
|
||||
export default function FilePreviewTable({ text, delimiter }: Props) {
|
||||
const { rows, truncated } = useMemo(
|
||||
() => parseCsv({ text, delimiter, max_rows: AppData.FilePreviewMaxTableRows + 1 }),
|
||||
[text, delimiter],
|
||||
);
|
||||
|
||||
if (!rows.length) return <FilePreviewNotice message="This file is empty." />;
|
||||
|
||||
const [header, ...body] = rows;
|
||||
|
||||
return (
|
||||
<Stack className="grow min-h-0 w-full items-stretch gap-0!">
|
||||
<Stack className="grow min-h-0 w-full overflow-auto items-start">
|
||||
<table className="text-[13px] leading-5 text-ink border-separate border-spacing-0">
|
||||
<thead className="sticky top-0 bg-surface">
|
||||
<FilePreviewTableRow cells={header!} header />
|
||||
</thead>
|
||||
<tbody>
|
||||
{body.map((cells, index) => (
|
||||
<FilePreviewTableRow key={index} cells={cells} />
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</Stack>
|
||||
<P
|
||||
noMargin
|
||||
className="shrink-0 px-5 py-2 text-[12px] leading-4 text-ink-secondary border-0 border-t border-solid border-hairline"
|
||||
>
|
||||
{truncated
|
||||
? `Showing the first ${body.length} rows. Download the file for the rest.`
|
||||
: `${body.length} ${body.length == 1 ? "row" : "rows"}`}
|
||||
</P>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import Loading from "@moduletrace/twui/elements/Loading";
|
||||
import Stack from "@moduletrace/twui/layout/Stack";
|
||||
import useFileText from "@/src/hooks/use-file-text";
|
||||
import formatBytes from "@/src/utils/format-bytes";
|
||||
import { AppData } from "@/src/data/app-data";
|
||||
import type { FilePreviewKind } from "@/src/utils/file-preview-kind";
|
||||
import FilePreviewCode from "./file-preview-code";
|
||||
import FilePreviewMarkdown from "./file-preview-markdown";
|
||||
import FilePreviewNotice from "./file-preview-notice";
|
||||
import FilePreviewTable from "./file-preview-table";
|
||||
|
||||
type Props = {
|
||||
kind: FilePreviewKind;
|
||||
url: string;
|
||||
size?: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* A text file read into the preview: markdown rendered, CSV or TSV as a
|
||||
* table, anything else as code.
|
||||
*/
|
||||
export default function FilePreviewText({ kind, url, size }: Props) {
|
||||
const { text, loading, error, too_large } = useFileText({ url, size });
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Stack className="grow w-full items-center justify-center">
|
||||
<Loading />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
if (too_large) {
|
||||
return (
|
||||
<FilePreviewNotice
|
||||
message={`This file is bigger than ${formatBytes({ bytes: AppData.FilePreviewMaxTextBytes })}, too big to preview. Download it to open it.`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || text == null) {
|
||||
return <FilePreviewNotice message={error || "The file couldn't be read."} />;
|
||||
}
|
||||
|
||||
if (kind == "markdown") return <FilePreviewMarkdown text={text} />;
|
||||
if (kind == "csv") return <FilePreviewTable text={text} delimiter="," />;
|
||||
if (kind == "tsv") return <FilePreviewTable text={text} delimiter={"\t"} />;
|
||||
|
||||
return <FilePreviewCode text={text} />;
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import { renderToString } from "react-dom/server";
|
||||
import { AppData } from "@/src/data/app-data";
|
||||
import FilePreviewBody from "./file-preview-body";
|
||||
import FilePreviewLink from "./file-preview-link";
|
||||
import FilePreviewMarkdown from "./file-preview-markdown";
|
||||
import FilePreviewTable from "./file-preview-table";
|
||||
|
||||
test("a markdown file renders as a document, raw HTML left out", () => {
|
||||
const html = renderToString(
|
||||
<FilePreviewMarkdown text={"# Report\n\nSome **bold** text.\n\n| a | b |\n| - | - |\n| 1 | 2 |\n\n<script>alert(1)</script>"} />,
|
||||
);
|
||||
|
||||
expect(html).toContain("Report");
|
||||
expect(html).toContain("<h2");
|
||||
expect(html).toContain("font-bold");
|
||||
expect(html).toContain("<table");
|
||||
expect(html).not.toContain("<script");
|
||||
});
|
||||
|
||||
test("a CSV renders as a table with its header row", () => {
|
||||
const html = renderToString(<FilePreviewTable text={'name,city\n"Smith, J",Lagos\n'} delimiter="," />);
|
||||
|
||||
expect(html).toContain("<thead");
|
||||
expect(html).toContain("<th");
|
||||
expect(html).toContain("Smith, J");
|
||||
expect(html).toContain("1 row");
|
||||
});
|
||||
|
||||
test("a long CSV says it was cut", () => {
|
||||
const rows = Array.from({ length: AppData.FilePreviewMaxTableRows + 5 }, (_, i) => `${i}`).join("\n");
|
||||
const html = renderToString(<FilePreviewTable text={`n\n${rows}`} delimiter="," />);
|
||||
|
||||
expect(html).toContain(`Showing the first ${AppData.FilePreviewMaxTableRows} rows`);
|
||||
});
|
||||
|
||||
test("media previews load from the file's URL, unknown files offer a download", () => {
|
||||
expect(renderToString(<FilePreviewBody kind="image" name="a.png" url="/media/1" />)).toContain('src="/media/1"');
|
||||
expect(renderToString(<FilePreviewBody kind="pdf" name="a.pdf" url="/media/2" />)).toContain("<iframe");
|
||||
expect(renderToString(<FilePreviewBody kind="html" name="a.html" url="/media/4" />)).toContain('sandbox=""');
|
||||
expect(renderToString(<FilePreviewBody kind="none" name="a.zip" url="/media/3" />)).toContain("can't be previewed");
|
||||
});
|
||||
|
||||
test("a file link stays a real link to the bytes until clicked", () => {
|
||||
const html = renderToString(
|
||||
<FilePreviewLink name="report.md" url="/media/9">
|
||||
report.md
|
||||
</FilePreviewLink>,
|
||||
);
|
||||
|
||||
expect(html).toContain('href="/media/9"');
|
||||
expect(html).toContain("Preview report.md");
|
||||
});
|
||||
@@ -1,9 +1,9 @@
|
||||
import Row from "@moduletrace/twui/layout/Row";
|
||||
import Link from "@moduletrace/twui/layout/Link";
|
||||
import { FileText, Image as ImageIcon } from "lucide-react";
|
||||
import Span from "@moduletrace/twui/layout/Span";
|
||||
import type { TaskFile } from "@/src/types";
|
||||
import fileRef from "@/src/utils/file-ref";
|
||||
import FilePreviewLink from "./file-preview-link";
|
||||
|
||||
type Props = {
|
||||
file: TaskFile;
|
||||
@@ -15,8 +15,8 @@ type Props = {
|
||||
};
|
||||
|
||||
/**
|
||||
* One file on a task: its name, opening the bytes at `/media/<id>` (private,
|
||||
* so only someone who reaches the task can load it), and its file ref (built by `fileRef`) when it
|
||||
* One file on a task: its name, opening it in the file preview (its bytes at
|
||||
* `/media/<id>`, private, so only someone who reaches the task can load them), and its file ref (built by `fileRef`) when it
|
||||
* lives in a bot's or workspace's folder (so the file's real home is
|
||||
* reachable too).
|
||||
*/
|
||||
@@ -39,15 +39,14 @@ export default function TaskFileRow({ file, onDetach, busy }: Props) {
|
||||
) : (
|
||||
<FileText size={16} className="shrink-0 text-ink-secondary" aria-hidden />
|
||||
)}
|
||||
<Link
|
||||
href={`/media/${file.media_id}`}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
title={file.name || "Open the file"}
|
||||
<FilePreviewLink
|
||||
name={file.name || `File ${file.media_id}`}
|
||||
url={`/media/${file.media_id}`}
|
||||
media_type={file.media_type}
|
||||
className="grow min-w-0 text-[13px] leading-[18px] text-ink truncate hover:underline"
|
||||
>
|
||||
{file.name || `File ${file.media_id}`}
|
||||
</Link>
|
||||
</FilePreviewLink>
|
||||
{file.kind == "reference" ? (
|
||||
<Span className="shrink-0 text-[11px] leading-4 text-ink-secondary">reference</Span>
|
||||
) : null}
|
||||
|
||||
Reference in New Issue
Block a user