Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 45 additions & 21 deletions components/Files/FilePreview.tsx
Original file line number Diff line number Diff line change
@@ -1,55 +1,79 @@
import ReactMarkdown from "react-markdown";
import remarkBreaks from "remark-breaks";
import remarkGfm from "remark-gfm";
import FilePreviewSkeleton from "./FilePreviewSkeleton";

type FilePreviewProps = {
content: string | null;
loading: boolean;
error: string | null;
isTextFile: boolean;
fileName?: string;
imageUrl?: string;
};

export default function FilePreview({ content, loading, error, isTextFile, fileName }: FilePreviewProps) {
if (!isTextFile) {
export default function FilePreview({
content,
loading,
error,
isTextFile,
fileName,
imageUrl,
}: FilePreviewProps) {
if (loading) {
return <FilePreviewSkeleton />;
}

if (error) {
return (
<div className="flex items-center justify-center h-full min-h-[300px] border-2 border-dashed border-border rounded-lg">
<p className="text-sm text-muted-foreground">Preview not available for this file type</p>
<div className="flex items-center justify-center h-full min-h-[300px] border border-border rounded-lg bg-background">
<p className="text-sm text-destructive">{error}</p>
</div>
);
}

if (loading) {
if (imageUrl) {
return (
<div className="flex items-center justify-center h-full min-h-[300px] border border-border rounded-lg bg-background">
<p className="text-sm text-muted-foreground">Loading...</p>
<div className="flex-1 border border-border rounded-lg bg-background overflow-hidden flex items-center justify-center min-h-[300px]">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={imageUrl}
alt={fileName || "Image preview"}
className="max-w-full max-h-[70vh] object-contain"
/>
</div>
);
}

if (error) {
if (!isTextFile) {
return (
<div className="flex items-center justify-center h-full min-h-[300px] border border-border rounded-lg bg-background">
<p className="text-sm text-destructive">{error}</p>
<div className="flex items-center justify-center h-full min-h-[300px] border-2 border-dashed border-border rounded-lg">
<p className="text-sm text-muted-foreground">
Preview not available for this file type
</p>
</div>
);
}

// Check if file is markdown
const lowerFileName = fileName?.toLowerCase() ?? '';
const isMarkdown = lowerFileName.endsWith('.md') || lowerFileName.endsWith('.markdown');

const lowerFileName = fileName?.toLowerCase() ?? "";
const isMarkdown =
lowerFileName.endsWith(".md") || lowerFileName.endsWith(".markdown");

// Limit preview size for performance (10MB - matches upload limit)
const MAX_PREVIEW_SIZE = 10 * 1024 * 1024;
const contentToShow = content && content.length > MAX_PREVIEW_SIZE
? content.substring(0, MAX_PREVIEW_SIZE) + '\n\n...(content truncated for preview)'
: content;
const contentToShow =
content && content.length > MAX_PREVIEW_SIZE
? content.substring(0, MAX_PREVIEW_SIZE) +
"\n\n...(content truncated for preview)"
: content;

return (
<div className="flex-1 border border-border rounded-lg bg-background overflow-hidden flex flex-col">
<div className="overflow-auto flex-1 p-6 sm:p-8">
{isMarkdown ? (
<article className="prose prose-sm sm:prose lg:prose-base max-w-none dark:prose-invert
<article
className="prose prose-sm sm:prose lg:prose-base max-w-none dark:prose-invert
prose-headings:font-semibold prose-headings:tracking-tight
prose-h1:text-3xl prose-h1:mb-6 prose-h1:mt-0 prose-h1:pb-2 prose-h1:border-b prose-h1:border-border
prose-h2:text-2xl prose-h2:mb-4 prose-h2:mt-8 prose-h2:pb-2 prose-h2:border-b prose-h2:border-border/50
Expand All @@ -62,13 +86,14 @@ export default function FilePreview({ content, loading, error, isTextFile, fileN
prose-em:italic
prose-code:bg-muted prose-code:px-1.5 prose-code:py-0.5 prose-code:rounded prose-code:text-sm
prose-pre:bg-muted prose-pre:p-4 prose-pre:rounded-lg
prose-blockquote:border-l-4 prose-blockquote:border-border prose-blockquote:pl-4 prose-blockquote:italic">
<ReactMarkdown
prose-blockquote:border-l-4 prose-blockquote:border-border prose-blockquote:pl-4 prose-blockquote:italic"
>
<ReactMarkdown
remarkPlugins={[remarkGfm, remarkBreaks]}
components={{
a: ({ ...props }) => (
<a {...props} target="_blank" rel="noopener noreferrer" />
)
),
}}
>
{contentToShow || ""}
Expand All @@ -83,4 +108,3 @@ export default function FilePreview({ content, loading, error, isTextFile, fileN
</div>
);
}

15 changes: 15 additions & 0 deletions components/Files/FilePreviewSkeleton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { Skeleton } from "@/components/ui/skeleton";

export default function FilePreviewSkeleton() {
return (
<div className="flex-1 border border-border rounded-lg bg-background overflow-hidden p-6 sm:p-8">
<div className="space-y-3">
<Skeleton className="h-4 w-3/4" />
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-5/6" />
<Skeleton className="h-4 w-2/3" />
<Skeleton className="h-32 w-full" />
</div>
</div>
);
}
3 changes: 3 additions & 0 deletions components/Sandboxes/SandboxFilePreview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,15 @@ import { isTextFile } from "@/utils/isTextFile";
interface SandboxFilePreviewProps {
selectedPath: string;
content: string | null;
imageUrl: string | null;
loading: boolean;
error: string | null;
}

export default function SandboxFilePreview({
selectedPath,
content,
imageUrl,
loading,
error,
}: SandboxFilePreviewProps) {
Expand All @@ -27,6 +29,7 @@ export default function SandboxFilePreview({
error={error}
isTextFile={isTextFile(fileName)}
fileName={fileName}
imageUrl={imageUrl ?? undefined}
/>
</div>
);
Expand Down
6 changes: 5 additions & 1 deletion components/Sandboxes/SandboxFileTree.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,10 @@ export default function SandboxFileTree() {
<div className="flex w-full flex-col gap-4 lg:flex-row">
<div className="w-full lg:max-w-md lg:shrink-0">
<h2 className="mb-2 text-lg font-medium">Repository Files</h2>
<FileTree selectedPath={fileContent.selectedPath} onSelect={fileContent.select}>
<FileTree
selectedPath={fileContent.selectedPath}
onSelect={fileContent.select}
>
{filetree.map((node) => (
<FileNodeComponent key={node.path} node={node} />
))}
Expand All @@ -56,6 +59,7 @@ export default function SandboxFileTree() {
<SandboxFilePreview
selectedPath={fileContent.selectedPath}
content={fileContent.content}
imageUrl={fileContent.imageUrl}
loading={fileContent.loading}
error={fileContent.error}
/>
Expand Down
3 changes: 3 additions & 0 deletions hooks/useSandboxFileContent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { getFileContents } from "@/lib/sandboxes/getFileContents";
interface UseSandboxFileContentReturn {
selectedPath: string | undefined;
content: string | null;
imageUrl: string | null;
loading: boolean;
error: string | null;
select: (path: string) => void;
Expand All @@ -21,6 +22,7 @@ export default function useSandboxFileContent(): UseSandboxFileContentReturn {
if (!accessToken) {
throw new Error("Please sign in to view file contents");
}

return getFileContents(accessToken, path);
},
});
Expand All @@ -36,6 +38,7 @@ export default function useSandboxFileContent(): UseSandboxFileContentReturn {
return {
selectedPath,
content: mutation.data?.content ?? null,
imageUrl: mutation.data?.imageUrl ?? null,
loading: mutation.isPending,
error: mutation.error?.message ?? null,
select,
Expand Down
2 changes: 1 addition & 1 deletion hooks/useSaveKnowledgeEdit.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { useArtistProvider } from "@/providers/ArtistProvider";
import getMimeFromPath from "@/utils/getMimeFromPath";
import getMimeFromPath from "@/lib/files/getMimeFromPath";
import { uploadFile } from "@/lib/arweave/uploadFile";
import { useQueryClient } from "@tanstack/react-query";
import { toast } from "react-toastify";
Expand Down
8 changes: 7 additions & 1 deletion utils/getMimeFromPath.ts → lib/files/getMimeFromPath.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,12 @@ const mimeByExt: Record<string, string> = {
xml: "application/xml",
yml: "application/x-yaml",
yaml: "application/x-yaml",
png: "image/png",
jpg: "image/jpeg",
jpeg: "image/jpeg",
gif: "image/gif",
webp: "image/webp",
svg: "image/svg+xml",
};

export const getMimeFromPath = (path: string): string => {
Expand All @@ -15,4 +21,4 @@ export const getMimeFromPath = (path: string): string => {
return (ext && mimeByExt[ext]) || "text/plain";
};

export default getMimeFromPath;
export default getMimeFromPath;
26 changes: 23 additions & 3 deletions lib/sandboxes/getFileContents.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,27 @@
import { NEW_API_BASE_URL } from "@/lib/consts";
import { getMimeFromPath } from "@/lib/files/getMimeFromPath";

interface GetFileContentsResponse {
status: "success" | "error";
content?: string;
encoding?: "base64";
error?: string;
}

interface FileContentsResult {
content: string | null;
imageUrl: string | null;
}

/**
* Fetches file content from the sandbox file API.
* The API auto-detects binary files and returns base64-encoded content.
* When base64 content is returned, this builds a data URL for image preview.
*/
export async function getFileContents(
accessToken: string,
path: string,
): Promise<{ content: string }> {
): Promise<FileContentsResult> {
const response = await fetch(
`${NEW_API_BASE_URL}/api/sandboxes/file?path=${encodeURIComponent(path)}`,
{
Expand All @@ -22,9 +34,17 @@ export async function getFileContents(

const data: GetFileContentsResponse = await response.json();

if (!response.ok || data.status === "error") {
if (!response.ok || data.status === "error" || !data.content) {
throw new Error(data.error || "Failed to fetch file contents");
}

return { content: data.content || "" };
if (data.encoding === "base64") {
const mimeType = getMimeFromPath(path);
return {
content: null,
imageUrl: `data:${mimeType};base64,${data.content}`,
};
}

return { content: data.content, imageUrl: null };
}
Loading