From 82feae08b306f1f287aa328a049e02448c864e97 Mon Sep 17 00:00:00 2001 From: Recoup Coding Agent Date: Sat, 28 Mar 2026 17:34:43 -0500 Subject: [PATCH] feat: add image preview on /files page (#1604) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: add image preview support on /files page When clicking an image file on the /files tab, show the image using a signed URL instead of "Preview not available for this file type". Co-Authored-By: Paperclip * feat: add sandbox image preview on /files page Add image preview support for sandbox (GitHub repo) files on the /files page. The previous commit only handled Supabase-stored files via FileInfoDialog. This adds support for the SandboxFilePreview path by: - Adding getImageContent() to fetch base64 image data via API format=base64 - Extending useSandboxFileContent hook to detect images and return data URLs - Adding imageUrl prop to FilePreview as alternative to storageKey - Passing imageUrl through SandboxFilePreview and SandboxFileTree Depends on API PR #364 (base64 format support for sandbox file endpoint). Co-Authored-By: Paperclip Co-Authored-By: Claude Opus 4.6 * refactor: remove format=base64 query param, API auto-detects binary files The API now auto-detects binary files by extension and returns base64 encoding automatically — no need to pass format=base64 in the request. Co-Authored-By: Claude Opus 4.6 (1M context) * refactor: consolidate getImageContent into getFileContents, fix loading UX Address PR review feedback: - Eliminate getImageContent.ts — the API auto-detects binary files, so getFileContents now handles base64 responses and builds data URLs - Validate data.content before constructing data URLs - Fix loading state showing false "Preview not available" for images by moving loading/error checks before image/isTextFile checks - Add a spinner to the loading state Co-Authored-By: Claude Opus 4.6 (1M context) * refactor: remove storageKey from FilePreview (YAGNI) storageKey/signed-URL image previews for Supabase-stored files are not part of this PR's scope. Remove the prop to keep the change focused on sandbox GitHub image previews only. Co-Authored-By: Claude Opus 4.6 (1M context) * revert: drop formatting-only changes to FileInfoDialogContent Not part of this PR's scope — keeps the diff focused. Co-Authored-By: Claude Opus 4.6 (1M context) * refactor: extract getMimeType into existing getMimeFromPath utility (SRP) Add image MIME types to the existing getMimeFromPath utility and reuse it in getFileContents instead of an inline function. Co-Authored-By: Claude Opus 4.6 (1M context) * refactor: use Skeleton for loading state, move MIME util to lib/files - Replace loading spinner with shadcn Skeleton component in FilePreview - Create lib/files/getImageMimeType.ts instead of modifying utils/ (SRP) - Revert utils/getMimeFromPath.ts to base branch state Co-Authored-By: Claude Opus 4.6 (1M context) * refactor: move getMimeFromPath from utils/ to lib/files/, add image MIME types Move getMimeFromPath to lib/files/ per project conventions (no utils/ path). Add image MIME types (png, jpg, jpeg, gif, webp, svg) to the existing map so it can be reused for both text and image MIME resolution (DRY). Co-Authored-By: Claude Opus 4.6 (1M context) * refactor: extract FilePreviewSkeleton into its own component (SRP) Co-Authored-By: Claude Opus 4.6 (1M context) --------- Co-authored-by: CTO Agent Co-authored-by: Paperclip Co-authored-by: Claude Opus 4.6 Co-authored-by: Sweets Sweetman --- components/Files/FilePreview.tsx | 66 ++++++++++++++------- components/Files/FilePreviewSkeleton.tsx | 15 +++++ components/Sandboxes/SandboxFilePreview.tsx | 3 + components/Sandboxes/SandboxFileTree.tsx | 1 + hooks/useSandboxFileContent.ts | 3 + hooks/useSaveKnowledgeEdit.ts | 2 +- {utils => lib/files}/getMimeFromPath.ts | 8 ++- lib/sandboxes/getFileContents.ts | 26 +++++++- 8 files changed, 98 insertions(+), 26 deletions(-) create mode 100644 components/Files/FilePreviewSkeleton.tsx rename {utils => lib/files}/getMimeFromPath.ts (75%) 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 2346e4e45..b6794387c 100644 --- a/components/Sandboxes/SandboxFileTree.tsx +++ b/components/Sandboxes/SandboxFileTree.tsx @@ -65,6 +65,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 }; }