Skip to content

Commit df9c1f3

Browse files
pa-lemclaude
andcommitted
Add reusable FileViewer component for artifacts
Introduces a new FileViewer component that can display various file types: - Text files (JSON, YAML, CSV, etc.) with syntax highlighting - Images (PNG, JPEG, SVG) - PDFs via embedded iframe - Fallback for unsupported types This refactors the artifact details view to use the new FileViewer instead of the old ArtifactFile component, which is now removed. Components added: - shared/components/file/ui/file-viewer.tsx - Main viewer component - shared/components/file/ui/text-file-viewer.tsx - Text content viewer - shared/components/file/ui/embed-viewer.tsx - Container for embedded content - shared/components/file/ui/file-viewer-fallback.tsx - Fallback for unsupported types - shared/components/file/ui/file-info-card.tsx - File metadata display - shared/components/file/api/ - API layer for fetching file content - shared/components/file/domain/ - React Query hooks - shared/components/data-viewer/data-viewer-*-button.tsx - Reusable action buttons - shared/utils/file.ts - File icon utilities Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 649fa6e commit df9c1f3

File tree

14 files changed

+373
-52
lines changed

14 files changed

+373
-52
lines changed

frontend/app/src/entities/artifacts/ui/artifact-details.tsx

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,14 @@
11
import { Separator } from "@/shared/components/aria/separator";
22
import { Col, Row } from "@/shared/components/container";
3+
import { CONTENT_TYPE_CONFIG } from "@/shared/components/data-viewer/data-viewer";
34
import ErrorScreen from "@/shared/components/errors/error-screen";
5+
import { FileViewer } from "@/shared/components/file/ui/file-viewer";
46
import Content from "@/shared/components/layout/content";
57
import { LoadingIndicator } from "@/shared/components/loading/loading-indicator";
68
import { Card } from "@/shared/components/ui/card";
9+
import { CONFIG } from "@/shared/config/config";
710

811
import { assertArtifactObject } from "@/entities/artifacts/types";
9-
import { ArtifactFile } from "@/entities/artifacts/ui/artifact-file";
1012
import { ArtifactHeader } from "@/entities/artifacts/ui/artifact-header";
1113
import { NodeEvents } from "@/entities/events/ui/node-details-events";
1214
import { useGetObject } from "@/entities/nodes/object/domain/get-object.query";
@@ -53,12 +55,13 @@ export function ArtifactsDetails({ artifactId, artifactSchema }: ArtifactsDetail
5355
</Row>
5456
</Col>
5557

56-
<ArtifactFile
57-
artifactId={artifactId}
58-
storageId={artifact.storage_id.value}
59-
contentType={artifact.content_type.value}
60-
className="m-1 grow overflow-hidden"
61-
/>
58+
<div className="flex grow overflow-hidden p-1">
59+
<FileViewer
60+
url={CONFIG.ARTIFACTS_CONTENT_URL(artifact.storage_id.value)}
61+
fileName={`${artifactId}.${CONTENT_TYPE_CONFIG[artifact.content_type.value]?.extension ?? "txt"}`}
62+
contentType={artifact.content_type.value}
63+
/>
64+
</div>
6265
</Content.Card>
6366
<Card className="min-w-90 overflow-auto p-0">
6467
<div className="border-gray-200 border-b p-2 font-semibold">Activities</div>

frontend/app/src/entities/artifacts/ui/artifact-file.tsx

Lines changed: 0 additions & 45 deletions
This file was deleted.
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import { DownloadIcon } from "lucide-react";
2+
3+
import { Tooltip } from "@/shared/components/aria/tooltip";
4+
5+
import { DataViewerLinkButton } from "./data-viewer-action-button";
6+
7+
interface DataViewerDownloadLinkButtonProps {
8+
href: string;
9+
fileName?: string;
10+
}
11+
12+
export function DataViewerDownloadLinkButton({
13+
href,
14+
fileName,
15+
}: DataViewerDownloadLinkButtonProps) {
16+
return (
17+
<Tooltip message="Download">
18+
<DataViewerLinkButton href={href} download={fileName}>
19+
<DownloadIcon className="size-4" />
20+
</DataViewerLinkButton>
21+
</Tooltip>
22+
);
23+
}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import { Tooltip } from "@/shared/components/aria/tooltip";
2+
3+
import { DataViewerLinkButton } from "./data-viewer-action-button";
4+
5+
interface DataViewerRawButtonProps {
6+
href: string;
7+
}
8+
9+
export function DataViewerRawButton({ href }: DataViewerRawButtonProps) {
10+
return (
11+
<Tooltip message="Raw">
12+
<DataViewerLinkButton href={href} target="_blank" rel="noopener noreferrer">
13+
Raw
14+
</DataViewerLinkButton>
15+
</Tooltip>
16+
);
17+
}
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
const read = async (reader: ReadableStreamDefaultReader<Uint8Array>): Promise<string> => {
2+
const result = await reader.read();
3+
const currentValue = new TextDecoder().decode(result.value);
4+
5+
if (result.done) {
6+
return currentValue;
7+
}
8+
9+
const nextResult = await read(reader);
10+
return `${currentValue}${nextResult}`;
11+
};
12+
13+
export interface GetFileContentFromApiParams {
14+
url: string;
15+
}
16+
17+
export async function getFileContentFromApi({
18+
url,
19+
}: GetFileContentFromApiParams): Promise<string | null> {
20+
let reader: ReadableStreamDefaultReader<Uint8Array> | null = null;
21+
22+
try {
23+
const response = await fetch(url);
24+
25+
if (!response.ok) {
26+
return null;
27+
}
28+
29+
const stream = response.body;
30+
if (!stream) {
31+
return null;
32+
}
33+
34+
reader = stream.getReader();
35+
return await read(reader);
36+
} catch {
37+
return null;
38+
} finally {
39+
if (reader) {
40+
reader.releaseLock();
41+
}
42+
}
43+
}
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
export const fileContentQueryKeys = {
2+
all: ["file-content"] as const,
3+
byUrl: (url: string) => [...fileContentQueryKeys.all, url] as const,
4+
} as const;
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import { queryOptions, useQuery } from "@tanstack/react-query";
2+
3+
import type { QueryConfig } from "@/shared/api/types";
4+
5+
import { getFileContentFromApi } from "../api/get-file-content-from-api";
6+
import { fileContentQueryKeys } from "./file-content.query-keys";
7+
8+
export interface GetFileContentParams {
9+
url: string;
10+
}
11+
12+
export function getFileContentQueryOptions({ url }: GetFileContentParams) {
13+
return queryOptions({
14+
queryKey: fileContentQueryKeys.byUrl(url),
15+
queryFn: () => getFileContentFromApi({ url }),
16+
enabled: !!url,
17+
});
18+
}
19+
20+
export function useGetFileContent(
21+
params: GetFileContentParams,
22+
config?: QueryConfig<typeof getFileContentQueryOptions>
23+
) {
24+
return useQuery({
25+
...getFileContentQueryOptions(params),
26+
...config,
27+
});
28+
}
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import type { ReactNode } from "react";
2+
3+
import { Col, Row } from "@/shared/components/container";
4+
import { DataViewerDownloadLinkButton } from "@/shared/components/data-viewer/data-viewer-download-link-button";
5+
import { DataViewerRawButton } from "@/shared/components/data-viewer/data-viewer-raw-button";
6+
7+
import type { FileViewerBaseProps } from "./types";
8+
9+
interface EmbedViewerProps extends Partial<FileViewerBaseProps> {
10+
title: string;
11+
children: ReactNode;
12+
}
13+
14+
export function EmbedViewer({ title, url, downloadUrl, fileName, children }: EmbedViewerProps) {
15+
const hasActions = url || downloadUrl;
16+
17+
return (
18+
<Col className="grow rounded-lg bg-neutral-800 p-2 text-neutral-200">
19+
<Row>
20+
<span className="grow px-1 font-medium">{title}</span>
21+
{hasActions && (
22+
<>
23+
{url && <DataViewerRawButton href={url} />}
24+
{downloadUrl && <DataViewerDownloadLinkButton href={downloadUrl} fileName={fileName} />}
25+
</>
26+
)}
27+
</Row>
28+
{children}
29+
</Col>
30+
);
31+
}
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import { getFileIcon } from "@/shared/utils/file";
2+
3+
import { EmbedViewer } from "./embed-viewer";
4+
5+
interface FileViewerFallbackProps {
6+
url?: string;
7+
downloadUrl?: string;
8+
fileName: string;
9+
contentType?: string;
10+
}
11+
12+
export function FileViewerFallback({
13+
url,
14+
downloadUrl,
15+
fileName,
16+
contentType,
17+
}: FileViewerFallbackProps) {
18+
const FileIconComponent = getFileIcon(contentType);
19+
20+
return (
21+
<EmbedViewer title="Preview" url={url} downloadUrl={downloadUrl} fileName={fileName}>
22+
<div className="flex flex-col items-center justify-center rounded-lg border border-neutral-700 py-12 text-center">
23+
<FileIconComponent className="mb-3 size-12 text-neutral-500" />
24+
<p className="text-neutral-400 text-sm">Preview not available for this file type</p>
25+
</div>
26+
</EmbedViewer>
27+
);
28+
}
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import { EmbedViewer } from "./embed-viewer";
2+
import { FileViewerFallback } from "./file-viewer-fallback";
3+
import { TextFileViewer } from "./text-file-viewer";
4+
import { getViewerType } from "./utils";
5+
6+
export interface FileViewerProps {
7+
url: string;
8+
downloadUrl?: string;
9+
fileName: string;
10+
contentType?: string;
11+
}
12+
13+
export function FileViewer({ url, downloadUrl, fileName, contentType }: FileViewerProps) {
14+
const viewerType = getViewerType(contentType);
15+
16+
switch (viewerType.type) {
17+
case "text":
18+
return (
19+
<TextFileViewer
20+
url={url}
21+
fileName={fileName}
22+
contentType={viewerType.dataViewerContentType}
23+
/>
24+
);
25+
26+
case "image":
27+
return (
28+
<EmbedViewer title="Image" url={url} downloadUrl={downloadUrl} fileName={fileName}>
29+
<div className="flex justify-center rounded-lg border border-neutral-700 bg-white p-4">
30+
<img src={url} alt={fileName} className="max-h-150 max-w-full rounded" />
31+
</div>
32+
</EmbedViewer>
33+
);
34+
35+
case "pdf":
36+
return (
37+
<EmbedViewer title="PDF" url={url} downloadUrl={downloadUrl} fileName={fileName}>
38+
<iframe
39+
src={url}
40+
title={fileName}
41+
className="h-150 w-full rounded-lg border border-neutral-700"
42+
/>
43+
</EmbedViewer>
44+
);
45+
46+
case "unsupported":
47+
return (
48+
<FileViewerFallback
49+
url={url}
50+
downloadUrl={downloadUrl}
51+
fileName={fileName}
52+
contentType={contentType}
53+
/>
54+
);
55+
}
56+
}

0 commit comments

Comments
 (0)