@optilogic/core 1.0.0-beta.9 → 1.1.0

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.
Files changed (58) hide show
  1. package/dist/index.cjs +1385 -45
  2. package/dist/index.cjs.map +1 -1
  3. package/dist/index.d.cts +360 -1
  4. package/dist/index.d.ts +360 -1
  5. package/dist/index.js +1364 -47
  6. package/dist/index.js.map +1 -1
  7. package/dist/styles.css +22 -0
  8. package/dist/tailwind-preset.cjs +17 -2
  9. package/dist/tailwind-preset.cjs.map +1 -1
  10. package/dist/tailwind-preset.js +17 -2
  11. package/dist/tailwind-preset.js.map +1 -1
  12. package/package.json +15 -1
  13. package/src/components/autocomplete.tsx +2 -1
  14. package/src/components/branding/CosmicFrogIcon.tsx +59 -0
  15. package/src/components/branding/DataStarIcon.tsx +35 -0
  16. package/src/components/branding/OptilogicLogo.tsx +88 -0
  17. package/src/components/branding/OptilogicLogoWithText.tsx +110 -0
  18. package/src/components/branding/index.ts +7 -0
  19. package/src/components/button.tsx +10 -8
  20. package/src/components/calendar.tsx +7 -7
  21. package/src/components/data-grid/DataGrid.tsx +6 -1
  22. package/src/components/data-grid/components/CellEditor.tsx +3 -3
  23. package/src/components/data-grid/hooks/useDataGridState.ts +18 -3
  24. package/src/components/data-grid/types.ts +4 -0
  25. package/src/components/data-grid/utils/dataProcessing.ts +40 -11
  26. package/src/components/date-picker.tsx +2 -1
  27. package/src/components/dropdown-menu.tsx +1 -1
  28. package/src/components/file-view/FileView.tsx +147 -0
  29. package/src/components/file-view/components/CodeRenderer.tsx +97 -0
  30. package/src/components/file-view/components/CsvRenderer.tsx +127 -0
  31. package/src/components/file-view/components/HtmlRenderer.tsx +24 -0
  32. package/src/components/file-view/components/ImageRenderer.tsx +67 -0
  33. package/src/components/file-view/components/MarkdownRenderer.tsx +304 -0
  34. package/src/components/file-view/components/PlainTextRenderer.tsx +27 -0
  35. package/src/components/file-view/components/index.ts +4 -0
  36. package/src/components/file-view/hooks/index.ts +5 -0
  37. package/src/components/file-view/hooks/useContentType.ts +34 -0
  38. package/src/components/file-view/hooks/useDarkMode.ts +62 -0
  39. package/src/components/file-view/hooks/useHighlightedTokens.ts +83 -0
  40. package/src/components/file-view/hooks/useShikiHighlighter.ts +69 -0
  41. package/src/components/file-view/index.ts +47 -0
  42. package/src/components/file-view/types.ts +180 -0
  43. package/src/components/file-view/utils/contentTypeDetection.ts +157 -0
  44. package/src/components/file-view/utils/index.ts +12 -0
  45. package/src/components/file-view/utils/languageMapping.ts +78 -0
  46. package/src/components/file-view/utils/rendererRegistry.ts +42 -0
  47. package/src/components/input.tsx +1 -1
  48. package/src/components/popover.tsx +1 -1
  49. package/src/components/select.tsx +1 -1
  50. package/src/components/switch.tsx +5 -3
  51. package/src/components/textarea.tsx +1 -1
  52. package/src/index.ts +51 -0
  53. package/src/styles.css +22 -0
  54. package/src/tailwind-preset.ts +17 -1
  55. package/src/theme/index.ts +5 -0
  56. package/src/theme/presets.ts +112 -2
  57. package/src/theme/types.ts +35 -0
  58. package/src/theme/utils.ts +231 -0
@@ -182,7 +182,7 @@ export function DataGrid<T = Record<string, CellValue>>({
182
182
  );
183
183
 
184
184
  // Use data grid state hook
185
- const { state, actions, isControlled } = useDataGridState({
185
+ const { state, actions, processedDataRef, isControlled } = useDataGridState({
186
186
  sorting: controlledSorting,
187
187
  filters: controlledFilters,
188
188
  columnWidths: controlledColumnWidths,
@@ -229,6 +229,9 @@ export function DataGrid<T = Record<string, CellValue>>({
229
229
  visibleColumns,
230
230
  ]);
231
231
 
232
+ // Keep the state hook's ref in sync so editing resolves the correct row
233
+ processedDataRef.current = processedData;
234
+
232
235
  // Use column resize manager
233
236
  const { resizingColumn, getResizeProps } = useColumnResizeManager({
234
237
  columns: visibleColumns,
@@ -671,6 +674,7 @@ export function DataGrid<T = Record<string, CellValue>>({
671
674
  "flex-shrink-0 px-3 py-2 text-sm overflow-hidden",
672
675
  showColumnBorders && "border-r border-border last:border-r-0",
673
676
  isFocused && !isEditingThisCell && "ring-2 ring-inset ring-primary",
677
+ isEditingThisCell && "ring-2 ring-inset ring-primary bg-background",
674
678
  column.align === "center" && "text-center",
675
679
  column.align === "right" && "text-right"
676
680
  )}
@@ -864,6 +868,7 @@ export function DataGrid<T = Record<string, CellValue>>({
864
868
  "flex-shrink-0 px-3 py-2 text-sm overflow-hidden",
865
869
  showColumnBorders && "border-r border-border last:border-r-0",
866
870
  isFocused && !isEditingThisCell && "ring-2 ring-inset ring-primary",
871
+ isEditingThisCell && "ring-2 ring-inset ring-primary bg-background",
867
872
  column.align === "center" && "text-center",
868
873
  column.align === "right" && "text-right"
869
874
  )}
@@ -147,7 +147,7 @@ export function CellEditor<T = Record<string, CellValue>>({
147
147
  onKeyDown={handleKeyDown}
148
148
  onBlur={handleBlur}
149
149
  className={cn(
150
- "h-full w-full border-0 rounded-none focus:ring-2 focus:ring-primary text-sm px-2",
150
+ "h-full w-full border-0 rounded-none bg-background focus:ring-0 text-sm px-2",
151
151
  validationError && "ring-2 ring-destructive focus:ring-destructive",
152
152
  className
153
153
  )}
@@ -165,7 +165,7 @@ export function CellEditor<T = Record<string, CellValue>>({
165
165
  onKeyDown={handleKeyDown}
166
166
  onBlur={handleBlur}
167
167
  className={cn(
168
- "h-full w-full border-0 rounded-none focus:ring-2 focus:ring-primary text-sm px-2",
168
+ "h-full w-full border-0 rounded-none bg-background focus:ring-0 text-sm px-2",
169
169
  validationError && "ring-2 ring-destructive focus:ring-destructive",
170
170
  className
171
171
  )}
@@ -234,7 +234,7 @@ export function CellEditor<T = Record<string, CellValue>>({
234
234
  <SelectTrigger
235
235
  ref={selectRef}
236
236
  className={cn(
237
- "h-full w-full border-0 rounded-none focus:ring-2 focus:ring-primary text-sm",
237
+ "h-full w-full border-0 rounded-none bg-background focus:ring-0 text-sm",
238
238
  validationError && "ring-2 ring-destructive focus:ring-destructive",
239
239
  className
240
240
  )}
@@ -6,7 +6,7 @@
6
6
  * cell focus, and cell editing.
7
7
  */
8
8
 
9
- import { useState, useCallback, useMemo, useRef, useEffect } from "react";
9
+ import { useState, useCallback, useMemo, useRef, useEffect, type MutableRefObject } from "react";
10
10
  import type {
11
11
  SortConfig,
12
12
  FilterConfig,
@@ -64,6 +64,8 @@ export interface UseDataGridStateReturn {
64
64
  commitEdit: (value?: CellValue) => void;
65
65
  cancelEdit: () => void;
66
66
  };
67
+ /** Ref that DataGrid should populate with the current processedData array */
68
+ processedDataRef: MutableRefObject<unknown[]>;
67
69
  isControlled: {
68
70
  sorting: boolean;
69
71
  filters: boolean;
@@ -126,6 +128,10 @@ export function useDataGridState<T = Record<string, CellValue>>(
126
128
  useState<CellPosition | null>(null);
127
129
  const [editingCell, setEditingCell] = useState<EditingCell | null>(null);
128
130
 
131
+ // Ref populated by DataGrid with the current sorted/filtered data.
132
+ // Editing callbacks read from this so they resolve the correct row after sort/filter.
133
+ const processedDataRef = useRef<unknown[]>([]);
134
+
129
135
  const sorting = isControlled.sorting ? controlledSorting! : internalSorting;
130
136
  const filters = isControlled.filters ? controlledFilters! : internalFilters;
131
137
  const focusedCell = isControlled.focusedCell
@@ -276,7 +282,12 @@ export function useDataGridState<T = Record<string, CellValue>>(
276
282
  const column = columns.find((c) => c.key === columnKey);
277
283
  if (!column || !column.editable) return;
278
284
 
279
- const row = data[rowIndex];
285
+ // Use processedData (sorted/filtered) so the rowIndex maps to the
286
+ // correct visual row, falling back to the raw data array.
287
+ const resolvedData = processedDataRef.current.length > 0
288
+ ? processedDataRef.current
289
+ : data;
290
+ const row = resolvedData[rowIndex] as T | undefined;
280
291
  if (!row) return;
281
292
 
282
293
  const value = getCellValue(row, column);
@@ -305,7 +316,10 @@ export function useDataGridState<T = Record<string, CellValue>>(
305
316
 
306
317
  const column = columns.find((c) => c.key === columnKey);
307
318
  if (column?.validator) {
308
- const row = data[rowIndex];
319
+ const resolvedData = processedDataRef.current.length > 0
320
+ ? processedDataRef.current
321
+ : data;
322
+ const row = resolvedData[rowIndex] as T;
309
323
  const validationResult = column.validator(value, row);
310
324
  if (validationResult !== true && typeof validationResult === "string") {
311
325
  return;
@@ -341,6 +355,7 @@ export function useDataGridState<T = Record<string, CellValue>>(
341
355
  commitEdit,
342
356
  cancelEdit,
343
357
  },
358
+ processedDataRef,
344
359
  isControlled,
345
360
  };
346
361
  }
@@ -137,6 +137,10 @@ export interface ColumnDef<T = Record<string, CellValue>> {
137
137
  /** Accessor function to get cell value (defaults to row[key]) */
138
138
  accessor?: (row: T) => CellValue;
139
139
 
140
+ // Data type
141
+ /** Data type hint for sorting/comparison (auto-detected from values if omitted) */
142
+ dataType?: "string" | "number" | "date" | "boolean";
143
+
140
144
  // Sorting
141
145
  /** Whether this column is sortable */
142
146
  sortable?: boolean;
@@ -59,20 +59,49 @@ export function applySorting<T>(
59
59
  if (aVal == null) return sort.direction === "asc" ? 1 : -1;
60
60
  if (bVal == null) return sort.direction === "asc" ? -1 : 1;
61
61
 
62
- // Compare based on type
63
62
  let comparison = 0;
64
63
 
65
- if (typeof aVal === "string" && typeof bVal === "string") {
66
- comparison = aVal.localeCompare(bVal);
67
- } else if (typeof aVal === "number" && typeof bVal === "number") {
68
- comparison = aVal - bVal;
69
- } else if (aVal instanceof Date && bVal instanceof Date) {
70
- comparison = aVal.getTime() - bVal.getTime();
71
- } else if (typeof aVal === "boolean" && typeof bVal === "boolean") {
72
- comparison = aVal === bVal ? 0 : aVal ? 1 : -1;
73
- } else {
74
- // Fallback: convert to string
64
+ // If column specifies a dataType, coerce values accordingly
65
+ if (column.dataType === "number") {
66
+ const aNum = Number(aVal);
67
+ const bNum = Number(bVal);
68
+ if (!isNaN(aNum) && !isNaN(bNum)) {
69
+ comparison = aNum - bNum;
70
+ } else {
71
+ comparison = String(aVal).localeCompare(String(bVal));
72
+ }
73
+ } else if (column.dataType === "date") {
74
+ const aDate = aVal instanceof Date ? aVal : new Date(aVal as string | number);
75
+ const bDate = bVal instanceof Date ? bVal : new Date(bVal as string | number);
76
+ const aTime = isNaN(aDate.getTime()) ? 0 : aDate.getTime();
77
+ const bTime = isNaN(bDate.getTime()) ? 0 : bDate.getTime();
78
+ comparison = aTime - bTime;
79
+ } else if (column.dataType === "boolean") {
80
+ const aBool = aVal === true || aVal === "true" || aVal === 1;
81
+ const bBool = bVal === true || bVal === "true" || bVal === 1;
82
+ comparison = aBool === bBool ? 0 : aBool ? 1 : -1;
83
+ } else if (column.dataType === "string") {
75
84
  comparison = String(aVal).localeCompare(String(bVal));
85
+ } else {
86
+ // No dataType specified -- auto-detect from runtime types
87
+ if (typeof aVal === "number" && typeof bVal === "number") {
88
+ comparison = aVal - bVal;
89
+ } else if (aVal instanceof Date && bVal instanceof Date) {
90
+ comparison = aVal.getTime() - bVal.getTime();
91
+ } else if (typeof aVal === "boolean" && typeof bVal === "boolean") {
92
+ comparison = aVal === bVal ? 0 : aVal ? 1 : -1;
93
+ } else {
94
+ // Try numeric parse (handles string-encoded numbers from SQL results etc.)
95
+ const aStr = String(aVal);
96
+ const bStr = String(bVal);
97
+ const aNum = Number(aStr);
98
+ const bNum = Number(bStr);
99
+ if (!isNaN(aNum) && !isNaN(bNum) && aStr !== "" && bStr !== "") {
100
+ comparison = aNum - bNum;
101
+ } else {
102
+ comparison = aStr.localeCompare(bStr);
103
+ }
104
+ }
76
105
  }
77
106
 
78
107
  return sort.direction === "asc" ? comparison : -comparison;
@@ -304,8 +304,9 @@ function DatePickerInput({
304
304
  <div
305
305
  className={cn(
306
306
  "flex items-center rounded-md border border-input bg-background ring-offset-background",
307
+ "hover:border-input-hover",
307
308
  "focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-2",
308
- disabled && "opacity-50 cursor-not-allowed",
309
+ disabled && "opacity-50 cursor-not-allowed hover:border-input",
309
310
  sizeClasses[size]
310
311
  )}
311
312
  >
@@ -73,7 +73,7 @@ const DropdownMenuContent = React.forwardRef<
73
73
  ref={ref}
74
74
  sideOffset={sideOffset}
75
75
  className={cn(
76
- "z-50 min-w-[8rem] overflow-hidden rounded-md border border-border bg-popover p-1 text-popover-foreground shadow-md",
76
+ "z-50 min-w-[8rem] max-w-[90vw] overflow-hidden rounded-md border border-border bg-popover p-1 text-popover-foreground shadow-md",
77
77
  "data-[state=open]:animate-in data-[state=closed]:animate-out",
78
78
  "data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
79
79
  "data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95",
@@ -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="allow-scripts"
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";