@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.
- package/dist/index.cjs +1385 -45
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +360 -1
- package/dist/index.d.ts +360 -1
- package/dist/index.js +1364 -47
- package/dist/index.js.map +1 -1
- package/dist/styles.css +22 -0
- package/dist/tailwind-preset.cjs +17 -2
- package/dist/tailwind-preset.cjs.map +1 -1
- package/dist/tailwind-preset.js +17 -2
- package/dist/tailwind-preset.js.map +1 -1
- package/package.json +15 -1
- package/src/components/autocomplete.tsx +2 -1
- package/src/components/branding/CosmicFrogIcon.tsx +59 -0
- package/src/components/branding/DataStarIcon.tsx +35 -0
- package/src/components/branding/OptilogicLogo.tsx +88 -0
- package/src/components/branding/OptilogicLogoWithText.tsx +110 -0
- package/src/components/branding/index.ts +7 -0
- package/src/components/button.tsx +10 -8
- package/src/components/calendar.tsx +7 -7
- package/src/components/data-grid/DataGrid.tsx +6 -1
- package/src/components/data-grid/components/CellEditor.tsx +3 -3
- package/src/components/data-grid/hooks/useDataGridState.ts +18 -3
- package/src/components/data-grid/types.ts +4 -0
- package/src/components/data-grid/utils/dataProcessing.ts +40 -11
- package/src/components/date-picker.tsx +2 -1
- package/src/components/dropdown-menu.tsx +1 -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/components/input.tsx +1 -1
- package/src/components/popover.tsx +1 -1
- package/src/components/select.tsx +1 -1
- package/src/components/switch.tsx +5 -3
- package/src/components/textarea.tsx +1 -1
- package/src/index.ts +51 -0
- package/src/styles.css +22 -0
- package/src/tailwind-preset.ts +17 -1
- package/src/theme/index.ts +5 -0
- package/src/theme/presets.ts +112 -2
- package/src/theme/types.ts +35 -0
- 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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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";
|