mirror of https://github.com/kortix-ai/suna.git
file renderers v1
This commit is contained in:
parent
17dc1bad4d
commit
cd5018d1f4
File diff suppressed because it is too large
Load Diff
|
@ -27,8 +27,14 @@
|
|||
"@radix-ui/react-slot": "^1.2.0",
|
||||
"@radix-ui/react-tabs": "^1.1.4",
|
||||
"@radix-ui/react-tooltip": "^1.2.0",
|
||||
"@react-pdf/renderer": "^4.3.0",
|
||||
"@supabase/ssr": "latest",
|
||||
"@supabase/supabase-js": "latest",
|
||||
"@tailwindcss/typography": "^0.5.16",
|
||||
"@uiw/codemirror-extensions-langs": "^4.23.10",
|
||||
"@uiw/codemirror-theme-vscode": "^4.23.10",
|
||||
"@uiw/codemirror-theme-xcode": "^4.23.10",
|
||||
"@uiw/react-codemirror": "^4.23.10",
|
||||
"@usebasejump/shared": "^0.0.3",
|
||||
"autoprefixer": "10.4.17",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
|
@ -52,6 +58,8 @@
|
|||
"react-hook-form": "^7.55.0",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-scan": "^0.3.2",
|
||||
"rehype-raw": "^7.0.0",
|
||||
"rehype-sanitize": "^6.0.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"sonner": "^2.0.3",
|
||||
"swr": "^2.2.5",
|
||||
|
@ -72,7 +80,7 @@
|
|||
"encoding": "^0.1.13",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "15.2.2",
|
||||
"shiki": "^3.2.1",
|
||||
"shiki": "^3.2.2",
|
||||
"tailwindcss": "^4",
|
||||
"tw-animate-css": "^1.2.4",
|
||||
"typescript": "^5"
|
||||
|
|
|
@ -83,7 +83,7 @@ export function SidebarLeft({
|
|||
transform: state === "collapsed" ? "translateX(-10px)" : "translateX(0)",
|
||||
pointerEvents: state === "collapsed" ? "none" : "auto"
|
||||
}}>
|
||||
<span className="font-semibold"> SUNA</span>
|
||||
{/* <span className="font-semibold"> SUNA</span> */}
|
||||
</div>
|
||||
{state !== "collapsed" && (
|
||||
<div className="ml-auto">
|
||||
|
|
|
@ -0,0 +1,53 @@
|
|||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Download, File } from "lucide-react";
|
||||
|
||||
interface BinaryRendererProps {
|
||||
url: string;
|
||||
fileName: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function BinaryRenderer({ url, fileName, className }: BinaryRendererProps) {
|
||||
const fileExtension = fileName.split('.').pop()?.toLowerCase() || '';
|
||||
|
||||
// Handle download
|
||||
const handleDownload = () => {
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = fileName;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cn("flex flex-col items-center justify-center p-10", className)}>
|
||||
<div className="flex flex-col items-center text-center max-w-md">
|
||||
<div className="relative mb-6">
|
||||
<File className="h-24 w-24 text-muted-foreground/50" />
|
||||
<div className="absolute bottom-1 right-1 bg-background rounded-sm px-1.5 py-0.5 text-xs font-medium text-muted-foreground border">
|
||||
{fileExtension.toUpperCase()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3 className="text-lg font-semibold mb-2">{fileName}</h3>
|
||||
<p className="text-sm text-muted-foreground mb-6">
|
||||
This binary file cannot be previewed in the browser
|
||||
</p>
|
||||
|
||||
<Button
|
||||
variant="default"
|
||||
className="min-w-[150px]"
|
||||
onClick={handleDownload}
|
||||
>
|
||||
<Download className="h-4 w-4 mr-2" />
|
||||
Download
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,92 @@
|
|||
"use client";
|
||||
|
||||
import React, { useEffect, useState } from "react";
|
||||
import CodeMirror from "@uiw/react-codemirror";
|
||||
import { vscodeDark } from "@uiw/codemirror-theme-vscode";
|
||||
import { langs } from "@uiw/codemirror-extensions-langs";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { xcodeLight } from "@uiw/codemirror-theme-xcode";
|
||||
import { useTheme } from "next-themes";
|
||||
import { EditorView } from "@codemirror/view";
|
||||
|
||||
interface CodeRendererProps {
|
||||
content: string;
|
||||
language?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
// Map of language aliases to CodeMirror language support
|
||||
const languageMap: Record<string, any> = {
|
||||
js: langs.javascript,
|
||||
jsx: langs.jsx,
|
||||
ts: langs.typescript,
|
||||
tsx: langs.tsx,
|
||||
html: langs.html,
|
||||
css: langs.css,
|
||||
json: langs.json,
|
||||
md: langs.markdown,
|
||||
python: langs.python,
|
||||
py: langs.python,
|
||||
rust: langs.rust,
|
||||
go: langs.go,
|
||||
java: langs.java,
|
||||
c: langs.c,
|
||||
cpp: langs.cpp,
|
||||
cs: langs.csharp,
|
||||
php: langs.php,
|
||||
ruby: langs.ruby,
|
||||
sh: langs.shell,
|
||||
bash: langs.shell,
|
||||
sql: langs.sql,
|
||||
yaml: langs.yaml,
|
||||
yml: langs.yaml,
|
||||
// Add more languages as needed
|
||||
};
|
||||
|
||||
export function CodeRenderer({ content, language = "", className }: CodeRendererProps) {
|
||||
// Get current theme
|
||||
const { resolvedTheme } = useTheme();
|
||||
const [mounted, setMounted] = useState(false);
|
||||
|
||||
// Set mounted state to true after component mounts
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
// Determine the language extension to use
|
||||
const langExtension = language && languageMap[language]
|
||||
? [languageMap[language]()]
|
||||
: [];
|
||||
|
||||
// Add line wrapping extension
|
||||
const extensions = [
|
||||
...langExtension,
|
||||
EditorView.lineWrapping,
|
||||
];
|
||||
|
||||
// Select the theme based on the current theme
|
||||
const theme = mounted && resolvedTheme === 'dark' ? vscodeDark : xcodeLight;
|
||||
|
||||
return (
|
||||
<ScrollArea className={cn("w-full h-full", className)}>
|
||||
<div className="w-full">
|
||||
<CodeMirror
|
||||
value={content}
|
||||
theme={theme}
|
||||
extensions={extensions}
|
||||
basicSetup={{
|
||||
lineNumbers: false,
|
||||
highlightActiveLine: false,
|
||||
highlightActiveLineGutter: false,
|
||||
foldGutter: false,
|
||||
}}
|
||||
editable={false}
|
||||
className="text-sm w-full min-h-full"
|
||||
style={{ maxWidth: '100%' }}
|
||||
height="auto"
|
||||
/>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,297 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useRef, useEffect } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ZoomIn, ZoomOut, RotateCw, Download, Maximize2, Minimize2, Info } from "lucide-react";
|
||||
|
||||
interface ImageRendererProps {
|
||||
url: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function ImageRenderer({ url, className }: ImageRendererProps) {
|
||||
const [zoom, setZoom] = useState(1);
|
||||
const [rotation, setRotation] = useState(0);
|
||||
const [isPanning, setIsPanning] = useState(false);
|
||||
const [position, setPosition] = useState({ x: 0, y: 0 });
|
||||
const [startPanPosition, setStartPanPosition] = useState({ x: 0, y: 0 });
|
||||
const [isFitToScreen, setIsFitToScreen] = useState(true);
|
||||
const [imgLoaded, setImgLoaded] = useState(false);
|
||||
const [imgError, setImgError] = useState(false);
|
||||
const [imgInfo, setImgInfo] = useState<{
|
||||
width: number;
|
||||
height: number;
|
||||
type: string;
|
||||
} | null>(null);
|
||||
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const imageRef = useRef<HTMLImageElement>(null);
|
||||
|
||||
// Check if the url is an SVG
|
||||
const isSvg = url?.toLowerCase().endsWith('.svg') || url?.includes('image/svg');
|
||||
|
||||
// Reset position when zoom changes
|
||||
useEffect(() => {
|
||||
if (isFitToScreen) {
|
||||
setPosition({ x: 0, y: 0 });
|
||||
}
|
||||
}, [zoom, isFitToScreen]);
|
||||
|
||||
// Handle image load success
|
||||
const handleImageLoad = () => {
|
||||
setImgLoaded(true);
|
||||
setImgError(false);
|
||||
|
||||
if (imageRef.current) {
|
||||
setImgInfo({
|
||||
width: imageRef.current.naturalWidth,
|
||||
height: imageRef.current.naturalHeight,
|
||||
type: isSvg ? 'SVG' : url.split('.').pop()?.toUpperCase() || 'Image'
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Handle image load error
|
||||
const handleImageError = () => {
|
||||
setImgLoaded(false);
|
||||
setImgError(true);
|
||||
};
|
||||
|
||||
// Functions for zooming
|
||||
const handleZoomIn = () => {
|
||||
setZoom(prev => Math.min(prev + 0.25, 3));
|
||||
setIsFitToScreen(false);
|
||||
};
|
||||
|
||||
const handleZoomOut = () => {
|
||||
const newZoom = Math.max(zoom - 0.25, 0.5);
|
||||
setZoom(newZoom);
|
||||
if (newZoom === 0.5) {
|
||||
setIsFitToScreen(true);
|
||||
}
|
||||
};
|
||||
|
||||
// Function for rotation
|
||||
const handleRotate = () => {
|
||||
setRotation(prev => (prev + 90) % 360);
|
||||
};
|
||||
|
||||
// Function for download
|
||||
const handleDownload = () => {
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
const filename = url.split('/').pop() || 'image';
|
||||
link.download = filename;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
};
|
||||
|
||||
// Toggle fit to screen
|
||||
const toggleFitToScreen = () => {
|
||||
if (isFitToScreen) {
|
||||
setZoom(1);
|
||||
setIsFitToScreen(false);
|
||||
} else {
|
||||
setZoom(0.5);
|
||||
setPosition({ x: 0, y: 0 });
|
||||
setIsFitToScreen(true);
|
||||
}
|
||||
};
|
||||
|
||||
// Pan handlers
|
||||
const handleMouseDown = (e: React.MouseEvent) => {
|
||||
if (zoom > 0.5) {
|
||||
setIsPanning(true);
|
||||
setStartPanPosition({
|
||||
x: e.clientX - position.x,
|
||||
y: e.clientY - position.y
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseMove = (e: React.MouseEvent) => {
|
||||
if (isPanning && zoom > 0.5) {
|
||||
setPosition({
|
||||
x: e.clientX - startPanPosition.x,
|
||||
y: e.clientY - startPanPosition.y
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
setIsPanning(false);
|
||||
};
|
||||
|
||||
const handleMouseLeave = () => {
|
||||
setIsPanning(false);
|
||||
};
|
||||
|
||||
// Calculate transform styles
|
||||
const imageTransform = `scale(${zoom}) rotate(${rotation}deg)`;
|
||||
const translateTransform = `translate(${position.x}px, ${position.y}px)`;
|
||||
|
||||
// Show image info
|
||||
const [showInfo, setShowInfo] = useState(false);
|
||||
|
||||
return (
|
||||
<div className={cn("flex flex-col w-full h-full", className)}>
|
||||
{/* Controls */}
|
||||
<div className="flex items-center justify-between py-2 px-4 bg-muted/30 border-b mb-2 rounded-t-md">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 w-8 p-0"
|
||||
onClick={handleZoomOut}
|
||||
title="Zoom out"
|
||||
disabled={imgError}
|
||||
>
|
||||
<ZoomOut className="h-4 w-4" />
|
||||
</Button>
|
||||
<span className="text-xs font-medium">{Math.round(zoom * 100)}%</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 w-8 p-0"
|
||||
onClick={handleZoomIn}
|
||||
title="Zoom in"
|
||||
disabled={imgError}
|
||||
>
|
||||
<ZoomIn className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 w-8 p-0"
|
||||
onClick={handleRotate}
|
||||
title="Rotate"
|
||||
disabled={imgError}
|
||||
>
|
||||
<RotateCw className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 w-8 p-0"
|
||||
onClick={() => setShowInfo(!showInfo)}
|
||||
title="Image information"
|
||||
>
|
||||
<Info className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 w-8 p-0"
|
||||
onClick={toggleFitToScreen}
|
||||
title={isFitToScreen ? "Actual size" : "Fit to screen"}
|
||||
disabled={imgError}
|
||||
>
|
||||
{isFitToScreen ? (
|
||||
<Maximize2 className="h-4 w-4" />
|
||||
) : (
|
||||
<Minimize2 className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 w-8 p-0"
|
||||
onClick={handleDownload}
|
||||
title="Download"
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Image info overlay */}
|
||||
{showInfo && imgInfo && (
|
||||
<div className="absolute top-16 right-4 z-50 bg-background/80 backdrop-blur-sm p-3 rounded-md shadow-md border border-border text-xs">
|
||||
<p><strong>Type:</strong> {imgInfo.type}</p>
|
||||
<p><strong>Dimensions:</strong> {imgInfo.width} × {imgInfo.height}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Image container */}
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="flex-1 overflow-hidden relative bg-grid-pattern rounded-b-md"
|
||||
onMouseDown={handleMouseDown}
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseUp={handleMouseUp}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
style={{
|
||||
cursor: isPanning ? 'grabbing' : (zoom > 0.5 ? 'grab' : 'default'),
|
||||
backgroundColor: '#f5f5f5',
|
||||
backgroundImage: 'linear-gradient(45deg, #e0e0e0 25%, transparent 25%), linear-gradient(-45deg, #e0e0e0 25%, transparent 25%), linear-gradient(45deg, transparent 75%, #e0e0e0 75%), linear-gradient(-45deg, transparent 75%, #e0e0e0 75%)',
|
||||
backgroundSize: '20px 20px',
|
||||
backgroundPosition: '0 0, 0 10px, 10px -10px, -10px 0px',
|
||||
}}
|
||||
>
|
||||
{imgError ? (
|
||||
<div className="flex flex-col items-center justify-center h-full p-6 text-center">
|
||||
<p className="text-destructive font-medium mb-2">Failed to load image</p>
|
||||
<p className="text-sm text-muted-foreground">The image could not be displayed</p>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className="absolute inset-0 flex items-center justify-center"
|
||||
style={{
|
||||
transform: isFitToScreen ? 'none' : translateTransform,
|
||||
transition: isPanning ? 'none' : 'transform 0.1s ease',
|
||||
}}
|
||||
>
|
||||
{isSvg ? (
|
||||
// Special handling for SVG - embed it as an object for better rendering
|
||||
<object
|
||||
data={url}
|
||||
type="image/svg+xml"
|
||||
className="max-w-full max-h-full"
|
||||
style={{
|
||||
transform: imageTransform,
|
||||
transition: 'transform 0.2s ease',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
}}
|
||||
>
|
||||
{/* Fallback to img if object fails */}
|
||||
<img
|
||||
ref={imageRef}
|
||||
src={url}
|
||||
alt="SVG preview"
|
||||
className="max-w-full max-h-full object-contain"
|
||||
style={{
|
||||
transform: imageTransform,
|
||||
transition: 'transform 0.2s ease',
|
||||
}}
|
||||
draggable={false}
|
||||
onLoad={handleImageLoad}
|
||||
onError={handleImageError}
|
||||
/>
|
||||
</object>
|
||||
) : (
|
||||
<img
|
||||
ref={imageRef}
|
||||
src={url}
|
||||
alt="Image preview"
|
||||
className="max-w-full max-h-full object-contain"
|
||||
style={{
|
||||
transform: imageTransform,
|
||||
transition: 'transform 0.2s ease',
|
||||
}}
|
||||
draggable={false}
|
||||
onLoad={handleImageLoad}
|
||||
onError={handleImageError}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,123 @@
|
|||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { MarkdownRenderer } from "./markdown-renderer";
|
||||
import { CodeRenderer } from "./code-renderer";
|
||||
import { PdfRenderer } from "./pdf-renderer";
|
||||
import { ImageRenderer } from "./image-renderer";
|
||||
import { BinaryRenderer } from "./binary-renderer";
|
||||
|
||||
export type FileType =
|
||||
| 'markdown'
|
||||
| 'code'
|
||||
| 'pdf'
|
||||
| 'image'
|
||||
| 'text'
|
||||
| 'binary';
|
||||
|
||||
interface FileRendererProps {
|
||||
content: string | null;
|
||||
binaryUrl: string | null;
|
||||
fileName: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
// Helper function to determine file type from extension
|
||||
export function getFileTypeFromExtension(fileName: string): FileType {
|
||||
const extension = fileName.split('.').pop()?.toLowerCase() || '';
|
||||
|
||||
const markdownExtensions = ['md', 'markdown'];
|
||||
const codeExtensions = [
|
||||
'js', 'jsx', 'ts', 'tsx', 'html', 'css', 'json', 'py', 'python',
|
||||
'java', 'c', 'cpp', 'h', 'cs', 'go', 'rs', 'php', 'rb', 'sh', 'bash',
|
||||
'xml', 'yml', 'yaml', 'toml', 'sql', 'graphql', 'swift', 'kotlin',
|
||||
'dart', 'r', 'lua', 'scala', 'perl', 'haskell', 'rust'
|
||||
];
|
||||
const imageExtensions = ['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg', 'bmp', 'ico'];
|
||||
const pdfExtensions = ['pdf'];
|
||||
const textExtensions = ['txt', 'csv', 'log', 'env', 'ini'];
|
||||
|
||||
if (markdownExtensions.includes(extension)) {
|
||||
return 'markdown';
|
||||
} else if (codeExtensions.includes(extension)) {
|
||||
return 'code';
|
||||
} else if (imageExtensions.includes(extension)) {
|
||||
return 'image';
|
||||
} else if (pdfExtensions.includes(extension)) {
|
||||
return 'pdf';
|
||||
} else if (textExtensions.includes(extension)) {
|
||||
return 'text';
|
||||
} else {
|
||||
return 'binary';
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function to get language from file extension for code highlighting
|
||||
export function getLanguageFromExtension(fileName: string): string {
|
||||
const extension = fileName.split('.').pop()?.toLowerCase() || '';
|
||||
|
||||
const extensionToLanguage: Record<string, string> = {
|
||||
js: 'javascript',
|
||||
jsx: 'jsx',
|
||||
ts: 'typescript',
|
||||
tsx: 'tsx',
|
||||
html: 'html',
|
||||
css: 'css',
|
||||
json: 'json',
|
||||
py: 'python',
|
||||
python: 'python',
|
||||
java: 'java',
|
||||
c: 'c',
|
||||
cpp: 'cpp',
|
||||
h: 'c',
|
||||
cs: 'csharp',
|
||||
go: 'go',
|
||||
rs: 'rust',
|
||||
php: 'php',
|
||||
rb: 'ruby',
|
||||
sh: 'shell',
|
||||
bash: 'shell',
|
||||
xml: 'xml',
|
||||
yml: 'yaml',
|
||||
yaml: 'yaml',
|
||||
sql: 'sql',
|
||||
// Add more mappings as needed
|
||||
};
|
||||
|
||||
return extensionToLanguage[extension] || '';
|
||||
}
|
||||
|
||||
export function FileRenderer({ content, binaryUrl, fileName, className }: FileRendererProps) {
|
||||
const fileType = getFileTypeFromExtension(fileName);
|
||||
const language = getLanguageFromExtension(fileName);
|
||||
|
||||
return (
|
||||
<div className={cn("w-full h-full", className)}>
|
||||
{fileType === 'binary' ? (
|
||||
<BinaryRenderer
|
||||
url={binaryUrl || ''}
|
||||
fileName={fileName}
|
||||
/>
|
||||
) : fileType === 'image' && binaryUrl ? (
|
||||
<ImageRenderer url={binaryUrl} />
|
||||
) : fileType === 'pdf' && binaryUrl ? (
|
||||
<PdfRenderer url={binaryUrl} />
|
||||
) : fileType === 'markdown' ? (
|
||||
<MarkdownRenderer content={content || ''} />
|
||||
) : fileType === 'code' || fileType === 'text' ? (
|
||||
<CodeRenderer
|
||||
content={content || ''}
|
||||
language={language}
|
||||
className="w-full h-full"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full p-4">
|
||||
<pre className="text-sm font-mono whitespace-pre-wrap break-words leading-relaxed bg-muted/30 p-4 rounded-lg overflow-auto max-h-full">
|
||||
{content || ''}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,64 @@
|
|||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import rehypeRaw from "rehype-raw";
|
||||
import rehypeSanitize from "rehype-sanitize";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { CodeRenderer } from "./code-renderer";
|
||||
|
||||
interface MarkdownRendererProps {
|
||||
content: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function MarkdownRenderer({ content, className }: MarkdownRendererProps) {
|
||||
return (
|
||||
<ScrollArea className={cn("w-full h-full rounded-md relative", className)}>
|
||||
<div className="p-4 markdown prose prose-sm dark:prose-invert max-w-none">
|
||||
<ReactMarkdown
|
||||
rehypePlugins={[rehypeRaw, rehypeSanitize]}
|
||||
components={{
|
||||
code({ node, inline, className, children, ...props }) {
|
||||
const match = /language-(\w+)/.exec(className || "");
|
||||
|
||||
if (inline) {
|
||||
return (
|
||||
<code className={className} {...props}>
|
||||
{children}
|
||||
</code>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<CodeRenderer
|
||||
content={String(children).replace(/\n$/, "")}
|
||||
language={match ? match[1] : ""}
|
||||
/>
|
||||
);
|
||||
},
|
||||
// Style other elements as needed
|
||||
h1: ({ node, ...props }) => <h1 className="text-2xl font-bold my-4" {...props} />,
|
||||
h2: ({ node, ...props }) => <h2 className="text-xl font-bold my-3" {...props} />,
|
||||
h3: ({ node, ...props }) => <h3 className="text-lg font-bold my-2" {...props} />,
|
||||
a: ({ node, ...props }) => <a className="text-primary hover:underline" {...props} />,
|
||||
p: ({ node, ...props }) => <p className="my-2" {...props} />,
|
||||
ul: ({ node, ...props }) => <ul className="list-disc pl-5 my-2" {...props} />,
|
||||
ol: ({ node, ...props }) => <ol className="list-decimal pl-5 my-2" {...props} />,
|
||||
li: ({ node, ...props }) => <li className="my-1" {...props} />,
|
||||
blockquote: ({ node, ...props }) => (
|
||||
<blockquote className="border-l-4 border-muted pl-4 italic my-2" {...props} />
|
||||
),
|
||||
img: ({ node, ...props }) => (
|
||||
<img className="max-w-full h-auto rounded-md my-2" {...props} alt={props.alt || ""} />
|
||||
),
|
||||
pre: ({ node, ...props }) => <pre className="p-0 my-2 bg-transparent" {...props} />,
|
||||
}}
|
||||
>
|
||||
{content}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,125 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
ZoomIn,
|
||||
ZoomOut,
|
||||
RotateCw,
|
||||
Download,
|
||||
ArrowLeft,
|
||||
ArrowRight,
|
||||
Fullscreen
|
||||
} from "lucide-react";
|
||||
|
||||
interface PdfRendererProps {
|
||||
url: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function PdfRenderer({ url, className }: PdfRendererProps) {
|
||||
// State for zoom and rotation controls
|
||||
const [zoom, setZoom] = useState(100);
|
||||
const [rotation, setRotation] = useState(0);
|
||||
|
||||
// Handle zoom in/out
|
||||
const handleZoomIn = () => setZoom(prev => Math.min(prev + 25, 200));
|
||||
const handleZoomOut = () => setZoom(prev => Math.max(prev - 25, 50));
|
||||
|
||||
// Handle rotation
|
||||
const handleRotate = () => setRotation(prev => (prev + 90) % 360);
|
||||
|
||||
// Handle download
|
||||
const handleDownload = () => {
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = url.split('/').pop() || 'document.pdf';
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
};
|
||||
|
||||
// Handle fullscreen
|
||||
const handleFullscreen = () => {
|
||||
const iframe = document.querySelector('iframe');
|
||||
if (iframe) {
|
||||
if (iframe.requestFullscreen) {
|
||||
iframe.requestFullscreen();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cn("flex flex-col w-full h-full", className)}>
|
||||
{/* Controls */}
|
||||
<div className="flex items-center justify-between py-2 px-4 bg-muted/30 border-b mb-2 rounded-t-md">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 w-8 p-0"
|
||||
onClick={handleZoomOut}
|
||||
title="Zoom out"
|
||||
>
|
||||
<ZoomOut className="h-4 w-4" />
|
||||
</Button>
|
||||
<span className="text-xs font-medium">{zoom}%</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 w-8 p-0"
|
||||
onClick={handleZoomIn}
|
||||
title="Zoom in"
|
||||
>
|
||||
<ZoomIn className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 w-8 p-0"
|
||||
onClick={handleRotate}
|
||||
title="Rotate"
|
||||
>
|
||||
<RotateCw className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 w-8 p-0"
|
||||
onClick={handleFullscreen}
|
||||
title="Fullscreen"
|
||||
>
|
||||
<Fullscreen className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 w-8 p-0"
|
||||
onClick={handleDownload}
|
||||
title="Download"
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* PDF Viewer */}
|
||||
<div className="flex-1 overflow-hidden rounded-b-md bg-white">
|
||||
<iframe
|
||||
src={url}
|
||||
className="w-full h-full border-0"
|
||||
style={{
|
||||
transform: `scale(${zoom / 100}) rotate(${rotation}deg)`,
|
||||
transformOrigin: 'center center',
|
||||
transition: 'transform 0.2s ease'
|
||||
}}
|
||||
title="PDF Viewer"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -20,6 +20,7 @@ import { toast } from "sonner";
|
|||
import { createClient } from "@/lib/supabase/client";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { FileRenderer } from "@/components/file-renderers";
|
||||
|
||||
// Define API_URL
|
||||
const API_URL = process.env.NEXT_PUBLIC_BACKEND_URL || '';
|
||||
|
@ -367,103 +368,18 @@ export function FileViewerModal({
|
|||
}
|
||||
};
|
||||
|
||||
// Render file content based on type
|
||||
const renderFileContent = () => {
|
||||
if (isLoadingContent) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{[1, 2, 3, 4, 5].map((i) => (
|
||||
<Skeleton key={i} className="h-5 w-full" />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!selectedFile) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-[400px] text-muted-foreground">
|
||||
<File className="h-16 w-16 mb-4 opacity-30" />
|
||||
<p className="text-sm font-medium">Select a file to view its contents</p>
|
||||
<p className="text-sm text-muted-foreground mt-1">Choose a file from the sidebar to preview or edit</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (fileType === 'text' && fileContent) {
|
||||
return (
|
||||
<div className="w-full">
|
||||
<pre className="text-sm font-mono whitespace-pre-wrap break-words leading-relaxed bg-muted/30 p-4 rounded-lg">
|
||||
{fileContent}
|
||||
</pre>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (fileType === 'image' && binaryFileUrl) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-[400px] bg-muted/30 rounded-lg p-6">
|
||||
<img
|
||||
src={binaryFileUrl}
|
||||
alt={selectedFile}
|
||||
className="max-w-full object-contain rounded-md shadow-sm ring-1 ring-border/10"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (fileType === 'pdf' && binaryFileUrl) {
|
||||
return (
|
||||
<div className="w-full h-[600px]">
|
||||
<iframe
|
||||
src={binaryFileUrl}
|
||||
className="w-full h-full border-0 rounded-lg shadow-sm bg-white ring-1 ring-border/10"
|
||||
title={selectedFile}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (binaryFileUrl) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-[400px] gap-4 bg-muted/30 rounded-lg p-8">
|
||||
<File className="h-24 w-24 opacity-40" />
|
||||
<div className="text-center">
|
||||
<p className="text-sm font-medium">Binary File</p>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
This file cannot be previewed in the browser
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
className="mt-2"
|
||||
onClick={handleDownload}
|
||||
>
|
||||
<Download className="h-4 w-4 mr-2" />
|
||||
Download File
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-[400px] text-muted-foreground">
|
||||
<File className="h-16 w-16 mb-4 opacity-30" />
|
||||
<p className="text-sm font-medium">No preview available</p>
|
||||
<p className="text-sm text-muted-foreground mt-1">This file type cannot be previewed</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-[1000px] h-[85vh] max-h-[800px] flex flex-col p-0 gap-0">
|
||||
<DialogContent
|
||||
className="sm:max-w-[90vw] md:max-w-[1200px] w-[95vw] h-[90vh] max-h-[900px] flex flex-col p-0 gap-0 overflow-hidden"
|
||||
>
|
||||
<DialogHeader className="px-6 py-3 border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
|
||||
<DialogTitle className="text-lg font-semibold">Workspace Files</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex flex-col sm:flex-row h-full overflow-hidden divide-x divide-border">
|
||||
{/* File browser sidebar */}
|
||||
<div className="w-full sm:w-80 flex flex-col h-full bg-muted/5">
|
||||
<div className="w-full sm:w-[280px] lg:w-[320px] flex flex-col h-full bg-muted/5">
|
||||
{/* Breadcrumb navigation */}
|
||||
<div className="px-3 py-2 border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
|
||||
{renderBreadcrumbs()}
|
||||
|
@ -582,13 +498,27 @@ export function FileViewerModal({
|
|||
<div className="w-full flex-1 flex flex-col h-full bg-muted/5">
|
||||
{/* File content */}
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<ScrollArea className="h-full">
|
||||
<div className="p-4">
|
||||
{renderFileContent()}
|
||||
{isLoadingContent ? (
|
||||
<div className="p-4 space-y-3">
|
||||
{[1, 2, 3, 4, 5].map((i) => (
|
||||
<Skeleton key={i} className="h-5 w-full" />
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
) : !selectedFile ? (
|
||||
<div className="flex flex-col items-center justify-center min-h-[400px] text-muted-foreground">
|
||||
<File className="h-16 w-16 mb-4 opacity-30" />
|
||||
<p className="text-sm font-medium">Select a file to view its contents</p>
|
||||
<p className="text-sm text-muted-foreground mt-1">Choose a file from the sidebar to preview or edit</p>
|
||||
</div>
|
||||
) : (
|
||||
<FileRenderer
|
||||
content={fileContent}
|
||||
binaryUrl={binaryFileUrl}
|
||||
fileName={selectedFile}
|
||||
className="h-full"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
|
|
|
@ -0,0 +1,13 @@
|
|||
/** @type {import('tailwindcss').Config} */
|
||||
module.exports = {
|
||||
darkMode: ["class"],
|
||||
content: [
|
||||
"./src/pages/**/*.{js,ts,jsx,tsx,mdx}",
|
||||
"./src/components/**/*.{js,ts,jsx,tsx,mdx}",
|
||||
"./src/app/**/*.{js,ts,jsx,tsx,mdx}",
|
||||
],
|
||||
theme: {
|
||||
extend: {},
|
||||
},
|
||||
plugins: [require("@tailwindcss/typography")],
|
||||
}
|
Loading…
Reference in New Issue