@optilogic/core 1.0.0-beta.14 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@optilogic/core",
3
- "version": "1.0.0-beta.14",
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";