diff --git a/components/Files/FilePreview.tsx b/components/Files/FilePreview.tsx index bb0df4696..9586220d0 100644 --- a/components/Files/FilePreview.tsx +++ b/components/Files/FilePreview.tsx @@ -1,6 +1,7 @@ import ReactMarkdown from "react-markdown"; import remarkBreaks from "remark-breaks"; import remarkGfm from "remark-gfm"; +import FilePreviewSkeleton from "./FilePreviewSkeleton"; type FilePreviewProps = { content: string | null; @@ -8,48 +9,71 @@ type FilePreviewProps = { 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 ; + } + + if (error) { return ( -
-

Preview not available for this file type

+
+

{error}

); } - if (loading) { + if (imageUrl) { return ( -
-

Loading...

+
+ {/* eslint-disable-next-line @next/next/no-img-element */} + {fileName
); } - if (error) { + if (!isTextFile) { return ( -
-

{error}

+
+

+ Preview not available for this file type +

); } // 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 (
); } - diff --git a/components/Files/FilePreviewSkeleton.tsx b/components/Files/FilePreviewSkeleton.tsx new file mode 100644 index 000000000..1fd80d776 --- /dev/null +++ b/components/Files/FilePreviewSkeleton.tsx @@ -0,0 +1,15 @@ +import { Skeleton } from "@/components/ui/skeleton"; + +export default function FilePreviewSkeleton() { + return ( +
+
+ + + + + +
+
+ ); +} diff --git a/components/Sandboxes/SandboxFilePreview.tsx b/components/Sandboxes/SandboxFilePreview.tsx index add33c973..55507da9f 100644 --- a/components/Sandboxes/SandboxFilePreview.tsx +++ b/components/Sandboxes/SandboxFilePreview.tsx @@ -6,6 +6,7 @@ import { isTextFile } from "@/utils/isTextFile"; interface SandboxFilePreviewProps { selectedPath: string; content: string | null; + imageUrl: string | null; loading: boolean; error: string | null; } @@ -13,6 +14,7 @@ interface SandboxFilePreviewProps { export default function SandboxFilePreview({ selectedPath, content, + imageUrl, loading, error, }: SandboxFilePreviewProps) { @@ -27,6 +29,7 @@ export default function SandboxFilePreview({ error={error} isTextFile={isTextFile(fileName)} fileName={fileName} + imageUrl={imageUrl ?? undefined} />
); diff --git a/components/Sandboxes/SandboxFileTree.tsx b/components/Sandboxes/SandboxFileTree.tsx index cd9d246d3..4a9a10cb4 100644 --- a/components/Sandboxes/SandboxFileTree.tsx +++ b/components/Sandboxes/SandboxFileTree.tsx @@ -46,7 +46,10 @@ export default function SandboxFileTree() {

Repository Files

- + {filetree.map((node) => ( ))} @@ -56,6 +59,7 @@ export default function SandboxFileTree() { diff --git a/hooks/useSandboxFileContent.ts b/hooks/useSandboxFileContent.ts index 1f820cf93..7835ea88b 100644 --- a/hooks/useSandboxFileContent.ts +++ b/hooks/useSandboxFileContent.ts @@ -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; @@ -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); }, }); @@ -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, diff --git a/hooks/useSaveKnowledgeEdit.ts b/hooks/useSaveKnowledgeEdit.ts index a12bcfcff..9c035ef16 100644 --- a/hooks/useSaveKnowledgeEdit.ts +++ b/hooks/useSaveKnowledgeEdit.ts @@ -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"; diff --git a/utils/getMimeFromPath.ts b/lib/files/getMimeFromPath.ts similarity index 75% rename from utils/getMimeFromPath.ts rename to lib/files/getMimeFromPath.ts index 841394c3d..8557d4dff 100644 --- a/utils/getMimeFromPath.ts +++ b/lib/files/getMimeFromPath.ts @@ -7,6 +7,12 @@ const mimeByExt: Record = { 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 => { @@ -15,4 +21,4 @@ export const getMimeFromPath = (path: string): string => { return (ext && mimeByExt[ext]) || "text/plain"; }; -export default getMimeFromPath; \ No newline at end of file +export default getMimeFromPath; diff --git a/lib/sandboxes/getFileContents.ts b/lib/sandboxes/getFileContents.ts index 97bd6654c..04bb69cf8 100644 --- a/lib/sandboxes/getFileContents.ts +++ b/lib/sandboxes/getFileContents.ts @@ -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 { const response = await fetch( `${NEW_API_BASE_URL}/api/sandboxes/file?path=${encodeURIComponent(path)}`, { @@ -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 }; }