@optilogic/core 1.0.0-beta.15 → 1.0.0-beta.16
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.cjs +863 -0
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +288 -1
- package/dist/index.d.ts +288 -1
- package/dist/index.js +845 -1
- package/dist/index.js.map +1 -1
- package/package.json +15 -1
- package/src/components/file-view/FileView.tsx +147 -0
- package/src/components/file-view/components/CodeRenderer.tsx +97 -0
- package/src/components/file-view/components/CsvRenderer.tsx +127 -0
- package/src/components/file-view/components/HtmlRenderer.tsx +24 -0
- package/src/components/file-view/components/ImageRenderer.tsx +67 -0
- package/src/components/file-view/components/MarkdownRenderer.tsx +304 -0
- package/src/components/file-view/components/PlainTextRenderer.tsx +27 -0
- package/src/components/file-view/components/index.ts +4 -0
- package/src/components/file-view/hooks/index.ts +5 -0
- package/src/components/file-view/hooks/useContentType.ts +34 -0
- package/src/components/file-view/hooks/useDarkMode.ts +62 -0
- package/src/components/file-view/hooks/useHighlightedTokens.ts +83 -0
- package/src/components/file-view/hooks/useShikiHighlighter.ts +69 -0
- package/src/components/file-view/index.ts +47 -0
- package/src/components/file-view/types.ts +180 -0
- package/src/components/file-view/utils/contentTypeDetection.ts +157 -0
- package/src/components/file-view/utils/index.ts +12 -0
- package/src/components/file-view/utils/languageMapping.ts +78 -0
- package/src/components/file-view/utils/rendererRegistry.ts +42 -0
- package/src/index.ts +39 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@optilogic/core",
|
|
3
|
-
"version": "1.0.0-beta.
|
|
3
|
+
"version": "1.0.0-beta.16",
|
|
4
4
|
"description": "Core UI components for Optilogic - A professional React component library",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.cjs",
|
|
@@ -63,6 +63,9 @@
|
|
|
63
63
|
"react-day-picker": "^9.0.0",
|
|
64
64
|
"react-dom": "^18.0.0 || ^19.0.0",
|
|
65
65
|
"sonner": "^2.0.0",
|
|
66
|
+
"react-markdown": "^9.0.0",
|
|
67
|
+
"remark-gfm": "^4.0.0",
|
|
68
|
+
"shiki": "^3.0.0",
|
|
66
69
|
"tailwindcss": "^3.4.0"
|
|
67
70
|
},
|
|
68
71
|
"peerDependenciesMeta": {
|
|
@@ -75,6 +78,15 @@
|
|
|
75
78
|
"react-day-picker": {
|
|
76
79
|
"optional": true
|
|
77
80
|
},
|
|
81
|
+
"react-markdown": {
|
|
82
|
+
"optional": true
|
|
83
|
+
},
|
|
84
|
+
"remark-gfm": {
|
|
85
|
+
"optional": true
|
|
86
|
+
},
|
|
87
|
+
"shiki": {
|
|
88
|
+
"optional": true
|
|
89
|
+
},
|
|
78
90
|
"sonner": {
|
|
79
91
|
"optional": true
|
|
80
92
|
}
|
|
@@ -88,6 +100,8 @@
|
|
|
88
100
|
"react": "^19.0.0",
|
|
89
101
|
"react-day-picker": "^9.13.0",
|
|
90
102
|
"react-dom": "^19.0.0",
|
|
103
|
+
"react-markdown": "^9.0.0",
|
|
104
|
+
"remark-gfm": "^4.0.0",
|
|
91
105
|
"tailwindcss": "^3.4.19",
|
|
92
106
|
"tsup": "^8.3.5",
|
|
93
107
|
"typescript": "^5.7.2"
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import { cn } from "../../utils/cn";
|
|
3
|
+
import { useContentType } from "./hooks/useContentType";
|
|
4
|
+
import { mergeRenderers, resolveRenderer } from "./utils/rendererRegistry";
|
|
5
|
+
import type { FileViewProps, FileViewError, RendererRegistry } from "./types";
|
|
6
|
+
|
|
7
|
+
function DefaultEmptyState({ message }: { message: string }) {
|
|
8
|
+
return (
|
|
9
|
+
<div className="flex h-full w-full items-center justify-center text-sm text-muted-foreground">
|
|
10
|
+
{message}
|
|
11
|
+
</div>
|
|
12
|
+
);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function DefaultLoadingState() {
|
|
16
|
+
return (
|
|
17
|
+
<div className="flex h-full w-full items-center justify-center">
|
|
18
|
+
<div className="flex flex-col items-center gap-3">
|
|
19
|
+
<div className="h-8 w-8 animate-spin rounded-full border-2 border-primary/20 border-t-primary" />
|
|
20
|
+
<p className="text-sm text-muted-foreground">Loading file...</p>
|
|
21
|
+
</div>
|
|
22
|
+
</div>
|
|
23
|
+
);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function DefaultErrorState({ error }: { error: FileViewError }) {
|
|
27
|
+
return (
|
|
28
|
+
<div className="flex h-full w-full items-center justify-center">
|
|
29
|
+
<div className="flex max-w-sm flex-col items-center gap-3 text-center">
|
|
30
|
+
<p className="text-sm font-medium text-destructive">{error.message}</p>
|
|
31
|
+
{error.onRetry && (
|
|
32
|
+
<button
|
|
33
|
+
onClick={error.onRetry}
|
|
34
|
+
className={cn(
|
|
35
|
+
"rounded-md border border-border bg-background px-3 py-1.5 text-sm text-foreground",
|
|
36
|
+
"transition-colors hover:bg-muted",
|
|
37
|
+
)}
|
|
38
|
+
>
|
|
39
|
+
Retry
|
|
40
|
+
</button>
|
|
41
|
+
)}
|
|
42
|
+
</div>
|
|
43
|
+
</div>
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* FileView
|
|
49
|
+
*
|
|
50
|
+
* A configurable file viewer that detects content type from the file name
|
|
51
|
+
* and delegates rendering to a pluggable renderer system.
|
|
52
|
+
*
|
|
53
|
+
* Built-in renderers: Code, Markdown, Image, PlainText.
|
|
54
|
+
* Users can override any renderer or add custom ones via the `renderers` prop.
|
|
55
|
+
*
|
|
56
|
+
* @example
|
|
57
|
+
* <FileView fileName="app.tsx" content={sourceCode} />
|
|
58
|
+
*
|
|
59
|
+
* @example
|
|
60
|
+
* <FileView fileName="photo.png" content={null} url={imageUrl} />
|
|
61
|
+
*
|
|
62
|
+
* @example
|
|
63
|
+
* <FileView
|
|
64
|
+
* fileName="data.txt"
|
|
65
|
+
* content={jsonContent}
|
|
66
|
+
* contentType="code"
|
|
67
|
+
* renderers={{ code: MyCustomCodeRenderer }}
|
|
68
|
+
* />
|
|
69
|
+
*/
|
|
70
|
+
export function FileView({
|
|
71
|
+
content,
|
|
72
|
+
url = null,
|
|
73
|
+
fileName,
|
|
74
|
+
contentType: contentTypeOverride,
|
|
75
|
+
renderers: userRenderers,
|
|
76
|
+
loading = false,
|
|
77
|
+
error = null,
|
|
78
|
+
loadingComponent,
|
|
79
|
+
emptyComponent,
|
|
80
|
+
emptyMessage = "No content to display",
|
|
81
|
+
errorComponent: ErrorComponent,
|
|
82
|
+
className,
|
|
83
|
+
rendererClassName,
|
|
84
|
+
resolveImageUrl,
|
|
85
|
+
}: FileViewProps) {
|
|
86
|
+
const { type: resolvedType } = useContentType({
|
|
87
|
+
fileName,
|
|
88
|
+
contentTypeOverride,
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
const registry = React.useMemo<RendererRegistry>(
|
|
92
|
+
() => mergeRenderers(userRenderers),
|
|
93
|
+
[userRenderers],
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
const Renderer = React.useMemo(
|
|
97
|
+
() => resolveRenderer(registry, resolvedType),
|
|
98
|
+
[registry, resolvedType],
|
|
99
|
+
);
|
|
100
|
+
|
|
101
|
+
// Loading state
|
|
102
|
+
if (loading) {
|
|
103
|
+
return (
|
|
104
|
+
<div className={cn("relative h-full w-full", className)}>
|
|
105
|
+
{loadingComponent ?? <DefaultLoadingState />}
|
|
106
|
+
</div>
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Error state
|
|
111
|
+
if (error) {
|
|
112
|
+
return (
|
|
113
|
+
<div className={cn("relative h-full w-full", className)}>
|
|
114
|
+
{ErrorComponent ? (
|
|
115
|
+
<ErrorComponent error={error} />
|
|
116
|
+
) : (
|
|
117
|
+
<DefaultErrorState error={error} />
|
|
118
|
+
)}
|
|
119
|
+
</div>
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Empty state
|
|
124
|
+
if ((content == null || content === "") && url == null) {
|
|
125
|
+
return (
|
|
126
|
+
<div className={cn("relative h-full w-full", className)}>
|
|
127
|
+
{emptyComponent ?? <DefaultEmptyState message={emptyMessage} />}
|
|
128
|
+
</div>
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Render
|
|
133
|
+
return (
|
|
134
|
+
<div className={cn("relative h-full w-full", className)}>
|
|
135
|
+
<Renderer
|
|
136
|
+
content={content}
|
|
137
|
+
url={url ?? null}
|
|
138
|
+
fileName={fileName}
|
|
139
|
+
contentType={resolvedType}
|
|
140
|
+
className={rendererClassName}
|
|
141
|
+
resolveImageUrl={resolveImageUrl}
|
|
142
|
+
/>
|
|
143
|
+
</div>
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
FileView.displayName = "FileView";
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import { cn } from "../../../utils/cn";
|
|
3
|
+
import { useHighlightedTokens } from "../hooks/useHighlightedTokens";
|
|
4
|
+
import type { FileRendererProps } from "../types";
|
|
5
|
+
import { getLanguageFromFileName } from "../utils/languageMapping";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* CodeRenderer
|
|
9
|
+
*
|
|
10
|
+
* Renders text content as code with line numbers in a left gutter.
|
|
11
|
+
* Uses shiki for syntax highlighting when available, falling back to
|
|
12
|
+
* plain monospace text otherwise.
|
|
13
|
+
*
|
|
14
|
+
* Layout:
|
|
15
|
+
* ┌─────────────────────────────────────────┐
|
|
16
|
+
* │ 1 │ import React from "react"; │
|
|
17
|
+
* │ 2 │ │
|
|
18
|
+
* │ 3 │ export function App() { │
|
|
19
|
+
* │ 4 │ return <div>Hello</div>; │
|
|
20
|
+
* │ 5 │ } │
|
|
21
|
+
* └─────────────────────────────────────────┘
|
|
22
|
+
*/
|
|
23
|
+
export function CodeRenderer({
|
|
24
|
+
content,
|
|
25
|
+
fileName,
|
|
26
|
+
className,
|
|
27
|
+
}: FileRendererProps) {
|
|
28
|
+
const language = React.useMemo(
|
|
29
|
+
() => getLanguageFromFileName(fileName),
|
|
30
|
+
[fileName],
|
|
31
|
+
);
|
|
32
|
+
|
|
33
|
+
const plainLines = React.useMemo(
|
|
34
|
+
() => (content ?? "").split("\n"),
|
|
35
|
+
[content],
|
|
36
|
+
);
|
|
37
|
+
|
|
38
|
+
const { lines: highlightedLines } = useHighlightedTokens(
|
|
39
|
+
content ?? "",
|
|
40
|
+
language,
|
|
41
|
+
);
|
|
42
|
+
|
|
43
|
+
const gutterWidth = React.useMemo(
|
|
44
|
+
() => `${Math.max(String(plainLines.length).length, 2) + 2}ch`,
|
|
45
|
+
[plainLines.length],
|
|
46
|
+
);
|
|
47
|
+
|
|
48
|
+
return (
|
|
49
|
+
<div
|
|
50
|
+
className={cn(
|
|
51
|
+
"relative h-full w-full overflow-auto rounded-md border border-border bg-background",
|
|
52
|
+
"scrollbar-thin",
|
|
53
|
+
className,
|
|
54
|
+
)}
|
|
55
|
+
>
|
|
56
|
+
<pre className="m-0 p-0">
|
|
57
|
+
<code className="block font-mono text-sm leading-relaxed">
|
|
58
|
+
{highlightedLines
|
|
59
|
+
? highlightedLines.map((tokens, index) => (
|
|
60
|
+
<div key={index} className="flex">
|
|
61
|
+
<span
|
|
62
|
+
className="sticky left-0 shrink-0 select-none border-r border-border bg-muted px-3 text-right text-muted-foreground"
|
|
63
|
+
style={{ minWidth: gutterWidth }}
|
|
64
|
+
>
|
|
65
|
+
{index + 1}
|
|
66
|
+
</span>
|
|
67
|
+
<span className="whitespace-pre px-4">
|
|
68
|
+
{tokens.length === 0 || (tokens.length === 1 && tokens[0].content === "")
|
|
69
|
+
? " "
|
|
70
|
+
: tokens.map((token, ti) => (
|
|
71
|
+
<span key={ti} style={{ color: token.color }}>
|
|
72
|
+
{token.content}
|
|
73
|
+
</span>
|
|
74
|
+
))}
|
|
75
|
+
</span>
|
|
76
|
+
</div>
|
|
77
|
+
))
|
|
78
|
+
: plainLines.map((line, index) => (
|
|
79
|
+
<div key={index} className="flex">
|
|
80
|
+
<span
|
|
81
|
+
className="sticky left-0 shrink-0 select-none border-r border-border bg-muted px-3 text-right text-muted-foreground"
|
|
82
|
+
style={{ minWidth: gutterWidth }}
|
|
83
|
+
>
|
|
84
|
+
{index + 1}
|
|
85
|
+
</span>
|
|
86
|
+
<span className="whitespace-pre px-4 text-foreground">
|
|
87
|
+
{line || " "}
|
|
88
|
+
</span>
|
|
89
|
+
</div>
|
|
90
|
+
))}
|
|
91
|
+
</code>
|
|
92
|
+
</pre>
|
|
93
|
+
</div>
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
CodeRenderer.displayName = "CodeRenderer";
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import { cn } from "../../../utils/cn";
|
|
3
|
+
import { DataGrid } from "../../data-grid";
|
|
4
|
+
import type { ColumnDef } from "../../data-grid";
|
|
5
|
+
import type { FileRendererProps } from "../types";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Parse a CSV/TSV string into headers and row objects.
|
|
9
|
+
* Handles quoted fields and double-quote escaping.
|
|
10
|
+
*/
|
|
11
|
+
function parseCSV(text: string): {
|
|
12
|
+
headers: string[];
|
|
13
|
+
rows: Record<string, string>[];
|
|
14
|
+
} {
|
|
15
|
+
const lines = text.split("\n");
|
|
16
|
+
if (lines.length === 0) return { headers: [], rows: [] };
|
|
17
|
+
|
|
18
|
+
// Auto-detect delimiter: if first line has tabs, treat as TSV
|
|
19
|
+
const firstLine = lines[0] ?? "";
|
|
20
|
+
const delimiter = firstLine.includes("\t") ? "\t" : ",";
|
|
21
|
+
|
|
22
|
+
function parseLine(line: string): string[] {
|
|
23
|
+
const fields: string[] = [];
|
|
24
|
+
let current = "";
|
|
25
|
+
let inQuotes = false;
|
|
26
|
+
let i = 0;
|
|
27
|
+
|
|
28
|
+
while (i < line.length) {
|
|
29
|
+
const char = line[i]!;
|
|
30
|
+
if (inQuotes) {
|
|
31
|
+
if (char === '"') {
|
|
32
|
+
if (i + 1 < line.length && line[i + 1] === '"') {
|
|
33
|
+
current += '"';
|
|
34
|
+
i += 2;
|
|
35
|
+
} else {
|
|
36
|
+
inQuotes = false;
|
|
37
|
+
i++;
|
|
38
|
+
}
|
|
39
|
+
} else {
|
|
40
|
+
current += char;
|
|
41
|
+
i++;
|
|
42
|
+
}
|
|
43
|
+
} else {
|
|
44
|
+
if (char === '"') {
|
|
45
|
+
inQuotes = true;
|
|
46
|
+
i++;
|
|
47
|
+
} else if (char === delimiter) {
|
|
48
|
+
fields.push(current.trim());
|
|
49
|
+
current = "";
|
|
50
|
+
i++;
|
|
51
|
+
} else {
|
|
52
|
+
current += char;
|
|
53
|
+
i++;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
fields.push(current.trim());
|
|
58
|
+
return fields;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const headers = parseLine(firstLine);
|
|
62
|
+
const rows: Record<string, string>[] = [];
|
|
63
|
+
|
|
64
|
+
for (let i = 1; i < lines.length; i++) {
|
|
65
|
+
const line = lines[i]!;
|
|
66
|
+
if (line.trim() === "") continue;
|
|
67
|
+
const values = parseLine(line);
|
|
68
|
+
const row: Record<string, string> = {};
|
|
69
|
+
for (let j = 0; j < headers.length; j++) {
|
|
70
|
+
row[headers[j]!] = values[j] ?? "";
|
|
71
|
+
}
|
|
72
|
+
rows.push(row);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return { headers, rows };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* CsvRenderer
|
|
80
|
+
*
|
|
81
|
+
* Parses CSV/TSV content and renders it in a DataGrid
|
|
82
|
+
* with sorting, filtering, resizable columns, and virtualization.
|
|
83
|
+
*/
|
|
84
|
+
export function CsvRenderer({ content, className }: FileRendererProps) {
|
|
85
|
+
const { headers, rows } = React.useMemo(
|
|
86
|
+
() => parseCSV(content ?? ""),
|
|
87
|
+
[content],
|
|
88
|
+
);
|
|
89
|
+
|
|
90
|
+
const columns = React.useMemo<ColumnDef<Record<string, string>>[]>(
|
|
91
|
+
() =>
|
|
92
|
+
headers.map((header) => ({
|
|
93
|
+
key: header,
|
|
94
|
+
header,
|
|
95
|
+
sortable: true,
|
|
96
|
+
filterable: true,
|
|
97
|
+
filterType: "text" as const,
|
|
98
|
+
})),
|
|
99
|
+
[headers],
|
|
100
|
+
);
|
|
101
|
+
|
|
102
|
+
if (headers.length === 0) {
|
|
103
|
+
return (
|
|
104
|
+
<div
|
|
105
|
+
className={cn(
|
|
106
|
+
"flex h-full w-full items-center justify-center text-sm text-muted-foreground",
|
|
107
|
+
className,
|
|
108
|
+
)}
|
|
109
|
+
>
|
|
110
|
+
No CSV data to display
|
|
111
|
+
</div>
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return (
|
|
116
|
+
<div className={cn("h-full w-full overflow-auto", className)}>
|
|
117
|
+
<DataGrid
|
|
118
|
+
data={rows}
|
|
119
|
+
columns={columns}
|
|
120
|
+
getRowKey={(_, index) => String(index)}
|
|
121
|
+
resizableColumns
|
|
122
|
+
/>
|
|
123
|
+
</div>
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
CsvRenderer.displayName = "CsvRenderer";
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { cn } from "../../../utils/cn";
|
|
2
|
+
import type { FileRendererProps } from "../types";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* HtmlRenderer
|
|
6
|
+
*
|
|
7
|
+
* Renders HTML content in a fully sandboxed iframe.
|
|
8
|
+
*/
|
|
9
|
+
export function HtmlRenderer({
|
|
10
|
+
content,
|
|
11
|
+
fileName,
|
|
12
|
+
className,
|
|
13
|
+
}: FileRendererProps) {
|
|
14
|
+
return (
|
|
15
|
+
<iframe
|
|
16
|
+
srcDoc={content ?? ""}
|
|
17
|
+
sandbox=""
|
|
18
|
+
title={fileName}
|
|
19
|
+
className={cn("h-full w-full border-0", className)}
|
|
20
|
+
/>
|
|
21
|
+
);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
HtmlRenderer.displayName = "HtmlRenderer";
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import { cn } from "../../../utils/cn";
|
|
3
|
+
import type { FileRendererProps } from "../types";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* ImageRenderer
|
|
7
|
+
*
|
|
8
|
+
* Renders an image from a URL, centered in its container.
|
|
9
|
+
* Handles load errors with a fallback message.
|
|
10
|
+
*/
|
|
11
|
+
export function ImageRenderer({
|
|
12
|
+
url,
|
|
13
|
+
fileName,
|
|
14
|
+
className,
|
|
15
|
+
}: FileRendererProps) {
|
|
16
|
+
const [hasError, setHasError] = React.useState(false);
|
|
17
|
+
|
|
18
|
+
React.useEffect(() => {
|
|
19
|
+
setHasError(false);
|
|
20
|
+
}, [url]);
|
|
21
|
+
|
|
22
|
+
if (!url) {
|
|
23
|
+
return (
|
|
24
|
+
<div
|
|
25
|
+
className={cn(
|
|
26
|
+
"flex h-full w-full items-center justify-center text-sm text-muted-foreground",
|
|
27
|
+
className,
|
|
28
|
+
)}
|
|
29
|
+
>
|
|
30
|
+
No image URL provided
|
|
31
|
+
</div>
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
if (hasError) {
|
|
36
|
+
return (
|
|
37
|
+
<div
|
|
38
|
+
className={cn(
|
|
39
|
+
"flex h-full w-full flex-col items-center justify-center gap-2 text-sm text-muted-foreground",
|
|
40
|
+
className,
|
|
41
|
+
)}
|
|
42
|
+
>
|
|
43
|
+
<span>Failed to load image</span>
|
|
44
|
+
<span className="text-xs">{fileName}</span>
|
|
45
|
+
</div>
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return (
|
|
50
|
+
<div
|
|
51
|
+
className={cn(
|
|
52
|
+
"flex h-full w-full items-center justify-center overflow-auto bg-background p-4",
|
|
53
|
+
"scrollbar-thin",
|
|
54
|
+
className,
|
|
55
|
+
)}
|
|
56
|
+
>
|
|
57
|
+
<img
|
|
58
|
+
src={url}
|
|
59
|
+
alt={fileName}
|
|
60
|
+
onError={() => setHasError(true)}
|
|
61
|
+
className="max-h-full max-w-full object-contain"
|
|
62
|
+
/>
|
|
63
|
+
</div>
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
ImageRenderer.displayName = "ImageRenderer";
|