feat: Object Editor Preview Mode & Mobile Optimizations
Major Enhancements: - Added Preview/Edit mode toggle to StructuredEditor component * Preview mode: Read-only view with full text visibility * Edit mode: Full editing capabilities with all controls * Toggle positioned below title, responsive on mobile * Works in both main ObjectEditor view and nested modals - Clickable nested data in Preview mode * JSON/serialized values are blue and clickable * Opens modal directly without switching to Edit mode * Hover effects and tooltips for better UX * No longer need edit mode just to explore structure Mobile Responsiveness Improvements: - Fixed all data load notices in ObjectEditor (URL, Paste, Open tabs) - Fixed all data load notices in TableEditor (URL, Paste, Open tabs) - Notices now stack vertically on mobile with proper spacing - Added break-words for long text, whitespace-nowrap for buttons - Dark mode colors added for better visibility Table Editor Fixes: - Fixed sticky header showing row underneath (top-[-1px]) - Made Export section header mobile responsive - Updated object modal footer layout: * Format info and properties combined on single line * Buttons moved to separate row below * Changed 'Apply Changes' to 'Save Changes' for consistency StructuredEditor Improvements: - Moved overflow-x handling from ObjectEditor to StructuredEditor - Now works consistently in main view and nested modals - Long strings scroll horizontally everywhere - 'Add Property' button hidden in Preview mode - Improved chevron colors for dark mode visibility Technical Changes: - StructuredEditor now manages its own editMode state - readOnly prop can still be passed from parent if needed - Proper conditional rendering for all UI elements - Consistent mobile-first responsive design patterns
This commit is contained in:
@@ -1,8 +1,9 @@
|
||||
import React, { useRef, useEffect, useState } from 'react';
|
||||
import React, { useRef, useEffect, useState, useCallback } from 'react';
|
||||
import { EditorView } from '@codemirror/view';
|
||||
import { EditorState } from '@codemirror/state';
|
||||
import { basicSetup } from 'codemirror';
|
||||
import { json } from '@codemirror/lang-json';
|
||||
import { sql } from '@codemirror/lang-sql';
|
||||
import { oneDark } from '@codemirror/theme-one-dark';
|
||||
import { Maximize2, Minimize2 } from 'lucide-react';
|
||||
|
||||
@@ -13,7 +14,8 @@ const CodeMirrorEditor = ({
|
||||
className = '',
|
||||
language = 'json',
|
||||
maxLines = 12,
|
||||
showToggle = true
|
||||
showToggle = true,
|
||||
cardRef = null // Reference to the card header for scroll target
|
||||
}) => {
|
||||
const editorRef = useRef(null);
|
||||
const viewRef = useRef(null);
|
||||
@@ -38,13 +40,26 @@ const CodeMirrorEditor = ({
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
// Detect if content is single-line (minified)
|
||||
const isSingleLine = (value || '').split('\n').length === 1;
|
||||
|
||||
// Initialize editor only once
|
||||
useEffect(() => {
|
||||
if (!editorRef.current || viewRef.current) return;
|
||||
|
||||
// Language extension
|
||||
let langExtension = [];
|
||||
if (language === 'json') {
|
||||
langExtension = [json()];
|
||||
} else if (language === 'sql') {
|
||||
langExtension = [sql()];
|
||||
}
|
||||
|
||||
const extensions = [
|
||||
basicSetup,
|
||||
language === 'json' ? json() : [],
|
||||
...langExtension,
|
||||
// Enable line wrapping for single-line content
|
||||
...(isSingleLine ? [EditorView.lineWrapping] : []),
|
||||
EditorView.theme({
|
||||
'&': {
|
||||
fontSize: '14px',
|
||||
@@ -58,6 +73,13 @@ const CodeMirrorEditor = ({
|
||||
},
|
||||
'.cm-editor': {
|
||||
borderRadius: '6px',
|
||||
},
|
||||
'.cm-scroller': {
|
||||
overflowY: 'auto',
|
||||
height: '100%',
|
||||
},
|
||||
'.cm-line': {
|
||||
wordBreak: isSingleLine ? 'break-word' : 'normal',
|
||||
}
|
||||
}),
|
||||
EditorView.updateListener.of((update) => {
|
||||
@@ -80,6 +102,25 @@ const CodeMirrorEditor = ({
|
||||
|
||||
viewRef.current = view;
|
||||
|
||||
// Apply styles immediately after editor creation
|
||||
setTimeout(() => {
|
||||
const editorElement = editorRef.current?.querySelector('.cm-editor');
|
||||
const scrollerElement = editorRef.current?.querySelector('.cm-scroller');
|
||||
|
||||
if (editorElement) {
|
||||
editorElement.style.height = '350px';
|
||||
editorElement.style.maxHeight = '350px';
|
||||
}
|
||||
|
||||
if (scrollerElement) {
|
||||
scrollerElement.style.overflowY = 'auto';
|
||||
scrollerElement.style.overflowX = isSingleLine ? 'hidden' : 'auto';
|
||||
scrollerElement.style.height = '100%';
|
||||
}
|
||||
|
||||
// No manual wrapping needed - EditorView.lineWrapping handles it
|
||||
}, 0);
|
||||
|
||||
return () => {
|
||||
if (viewRef.current) {
|
||||
viewRef.current.destroy();
|
||||
@@ -87,13 +128,15 @@ const CodeMirrorEditor = ({
|
||||
}
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isDark]); // Only recreate on theme change
|
||||
}, [isDark, isSingleLine]); // Recreate when theme or line count changes
|
||||
|
||||
// Handle height changes without recreating editor
|
||||
useEffect(() => {
|
||||
if (!viewRef.current) return;
|
||||
// Apply overflow and height styles
|
||||
const applyEditorStyles = useCallback(() => {
|
||||
if (!viewRef.current || !editorRef.current) return;
|
||||
|
||||
const editorElement = editorRef.current.querySelector('.cm-editor');
|
||||
const scrollerElement = editorRef.current.querySelector('.cm-scroller');
|
||||
|
||||
const editorElement = editorRef.current?.querySelector('.cm-editor');
|
||||
if (editorElement) {
|
||||
if (isExpanded) {
|
||||
editorElement.style.height = 'auto';
|
||||
@@ -103,8 +146,19 @@ const CodeMirrorEditor = ({
|
||||
editorElement.style.maxHeight = '350px';
|
||||
}
|
||||
}
|
||||
|
||||
// Handle scrolling
|
||||
if (scrollerElement) {
|
||||
scrollerElement.style.overflowY = isExpanded ? 'visible' : 'auto';
|
||||
scrollerElement.style.height = '100%';
|
||||
}
|
||||
}, [isExpanded]);
|
||||
|
||||
// Apply styles on mount, expand/collapse, and content changes
|
||||
useEffect(() => {
|
||||
applyEditorStyles();
|
||||
}, [applyEditorStyles]);
|
||||
|
||||
// Update content when value changes externally
|
||||
useEffect(() => {
|
||||
if (viewRef.current && value !== viewRef.current.state.doc.toString()) {
|
||||
@@ -116,8 +170,11 @@ const CodeMirrorEditor = ({
|
||||
}
|
||||
});
|
||||
viewRef.current.dispatch(transaction);
|
||||
|
||||
// Re-apply styles after content change (e.g., tab switch)
|
||||
setTimeout(() => applyEditorStyles(), 10);
|
||||
}
|
||||
}, [value]);
|
||||
}, [value, applyEditorStyles]);
|
||||
|
||||
return (
|
||||
<div className={`relative ${className}`}>
|
||||
@@ -132,7 +189,12 @@ const CodeMirrorEditor = ({
|
||||
onClick={() => {
|
||||
setIsExpanded(!isExpanded);
|
||||
setTimeout(() => {
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
// Scroll to card header if cardRef is provided, otherwise scroll to top
|
||||
if (cardRef?.current) {
|
||||
cardRef.current.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
} else {
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
}
|
||||
}, 50);
|
||||
}}
|
||||
className="absolute bottom-2 right-2 p-1 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 bg-white dark:bg-gray-800 rounded border border-gray-300 dark:border-gray-600 shadow-sm z-10"
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { Plus, Minus, ChevronDown, ChevronRight, Type, Hash, ToggleLeft, List, Braces, Edit3, X } from 'lucide-react';
|
||||
import { Plus, Minus, ChevronDown, ChevronRight, Type, Hash, ToggleLeft, List, Braces, Edit3, X, Eye, Pencil } from 'lucide-react';
|
||||
|
||||
const StructuredEditor = ({ onDataChange, initialData = {} }) => {
|
||||
const StructuredEditor = ({ onDataChange, initialData = {}, readOnly: readOnlyProp = false }) => {
|
||||
const [data, setData] = useState(initialData);
|
||||
const [expandedNodes, setExpandedNodes] = useState(new Set(['root']));
|
||||
const [fieldTypes, setFieldTypes] = useState({}); // Track intended types for fields
|
||||
const isInternalUpdate = useRef(false);
|
||||
const [nestedEditModal, setNestedEditModal] = useState(null); // { path, value, type: 'json' | 'serialized' }
|
||||
const [nestedData, setNestedData] = useState(null);
|
||||
const [editMode, setEditMode] = useState(false); // Internal edit mode state
|
||||
|
||||
// Use internal editMode if readOnlyProp is not explicitly set, otherwise use prop
|
||||
const readOnly = readOnlyProp !== false ? readOnlyProp : !editMode;
|
||||
|
||||
// Update internal data when initialData prop changes (but not from internal updates)
|
||||
useEffect(() => {
|
||||
@@ -533,7 +537,7 @@ const StructuredEditor = ({ onDataChange, initialData = {} }) => {
|
||||
{canExpand && (
|
||||
<button
|
||||
onClick={() => toggleNode(path)}
|
||||
className="p-1 hover:bg-gray-100 dark:hover:bg-gray-700 rounded"
|
||||
className="p-1 hover:bg-gray-100 dark:hover:bg-gray-700 rounded text-gray-700 dark:text-gray-300"
|
||||
>
|
||||
{isExpanded ? (
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
@@ -558,74 +562,106 @@ const StructuredEditor = ({ onDataChange, initialData = {} }) => {
|
||||
// Object properties: icon + editable key + colon (compact)
|
||||
<>
|
||||
{getTypeIcon(value)}
|
||||
<input
|
||||
type="text"
|
||||
defaultValue={key}
|
||||
onBlur={(e) => {
|
||||
const newKey = e.target.value.trim();
|
||||
if (newKey && newKey !== key) {
|
||||
renameKey(key, newKey, path);
|
||||
}
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.target.blur(); // Trigger blur to save changes
|
||||
}
|
||||
}}
|
||||
className="px-2 py-1 text-sm border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-blue-500 focus:border-blue-500 min-w-0"
|
||||
placeholder="Property name"
|
||||
style={{width: '120px'}} // Fixed width for consistency
|
||||
/>
|
||||
<span className="text-gray-500 hidden sm:inline">:</span>
|
||||
{readOnly ? (
|
||||
<span className="px-2 py-1 text-sm text-gray-900 dark:text-gray-100 font-mono">
|
||||
{key}
|
||||
</span>
|
||||
) : (
|
||||
<input
|
||||
type="text"
|
||||
defaultValue={key}
|
||||
onBlur={(e) => {
|
||||
const newKey = e.target.value.trim();
|
||||
if (newKey && newKey !== key) {
|
||||
renameKey(key, newKey, path);
|
||||
}
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.target.blur(); // Trigger blur to save changes
|
||||
}
|
||||
}}
|
||||
className="px-2 py-1 text-sm border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-blue-500 focus:border-blue-500 min-w-0"
|
||||
placeholder="Property name"
|
||||
style={{width: '120px'}} // Fixed width for consistency
|
||||
/>
|
||||
)}
|
||||
<span className="text-gray-500 inline">:</span>
|
||||
</>
|
||||
)}
|
||||
|
||||
{!canExpand ? (
|
||||
typeof value === 'boolean' ? (
|
||||
<div className="flex-1 flex items-center space-x-2">
|
||||
<button
|
||||
onClick={() => updateValue((!value).toString(), path)}
|
||||
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 ${
|
||||
value ? 'bg-blue-600' : 'bg-gray-200 dark:bg-gray-600'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
|
||||
value ? 'translate-x-6' : 'translate-x-1'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
<span className="text-sm text-gray-600 dark:text-gray-400 font-mono">
|
||||
{value.toString()}
|
||||
</span>
|
||||
{readOnly ? (
|
||||
<span className="text-sm text-gray-900 dark:text-gray-100 font-mono">
|
||||
{value.toString()}
|
||||
</span>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
onClick={() => updateValue((!value).toString(), path)}
|
||||
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 ${
|
||||
value ? 'bg-blue-600' : 'bg-gray-200 dark:bg-gray-600'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
|
||||
value ? 'translate-x-6' : 'translate-x-1'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
<span className="text-sm text-gray-600 dark:text-gray-400 font-mono">
|
||||
{value.toString()}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex-1 flex items-center gap-2">
|
||||
{(fieldTypes[path] === 'longtext' || (typeof value === 'string' && value.includes('\n'))) ? (
|
||||
<textarea
|
||||
value={getDisplayValue(value)}
|
||||
onChange={(e) => updateValue(e.target.value, path)}
|
||||
className="flex-1 px-2 py-1 text-sm border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 min-w-0 resize-y items"
|
||||
placeholder="Long text value"
|
||||
rows={3}
|
||||
/>
|
||||
{readOnly ? (
|
||||
typeof value === 'string' && detectNestedData(value) ? (
|
||||
<span
|
||||
onClick={() => openNestedEditor(value, path)}
|
||||
className="px-2 py-1 text-sm text-blue-600 dark:text-blue-400 font-mono break-all cursor-pointer hover:bg-blue-50 dark:hover:bg-blue-900/20 rounded transition-colors"
|
||||
title={`Click to view nested ${detectNestedData(value).type} data`}
|
||||
>
|
||||
{getDisplayValue(value)}
|
||||
</span>
|
||||
) : (
|
||||
<span className="px-2 py-1 text-sm text-gray-900 dark:text-gray-100 font-mono break-all">
|
||||
{getDisplayValue(value)}
|
||||
</span>
|
||||
)
|
||||
) : (
|
||||
<input
|
||||
type="text"
|
||||
value={getDisplayValue(value)}
|
||||
onChange={(e) => updateValue(e.target.value, path)}
|
||||
className="flex-1 px-2 py-1 text-sm border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 min-w-0"
|
||||
placeholder="Value"
|
||||
/>
|
||||
)}
|
||||
{typeof value === 'string' && detectNestedData(value) && (
|
||||
<button
|
||||
onClick={() => openNestedEditor(value, path)}
|
||||
className="p-1 text-blue-600 hover:bg-blue-100 dark:hover:bg-blue-900 rounded flex-shrink-0"
|
||||
title={`Edit nested ${detectNestedData(value).type} data`}
|
||||
>
|
||||
<Edit3 className="h-4 w-4" />
|
||||
</button>
|
||||
<>
|
||||
{(fieldTypes[path] === 'longtext' || (typeof value === 'string' && value.includes('\n'))) ? (
|
||||
<textarea
|
||||
value={getDisplayValue(value)}
|
||||
onChange={(e) => updateValue(e.target.value, path)}
|
||||
className="flex-1 px-2 py-1 text-sm border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 min-w-0 resize-y items"
|
||||
placeholder="Long text value"
|
||||
rows={3}
|
||||
/>
|
||||
) : (
|
||||
<input
|
||||
type="text"
|
||||
value={getDisplayValue(value)}
|
||||
onChange={(e) => updateValue(e.target.value, path)}
|
||||
className="flex-1 px-2 py-1 text-sm border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 min-w-0"
|
||||
placeholder="Value"
|
||||
/>
|
||||
)}
|
||||
{typeof value === 'string' && detectNestedData(value) && (
|
||||
<button
|
||||
onClick={() => openNestedEditor(value, path)}
|
||||
className="p-1 text-blue-600 hover:bg-blue-100 dark:hover:bg-blue-900 rounded flex-shrink-0"
|
||||
title={`Edit nested ${detectNestedData(value).type} data`}
|
||||
>
|
||||
<Edit3 className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
@@ -635,38 +671,40 @@ const StructuredEditor = ({ onDataChange, initialData = {} }) => {
|
||||
</span>
|
||||
)}
|
||||
|
||||
<div className="flex items-center space-x-2 sm:space-x-2">
|
||||
<select
|
||||
value={
|
||||
fieldTypes[path] || (
|
||||
value === null ? 'null' :
|
||||
value === undefined ? 'string' :
|
||||
typeof value === 'string' ? (value.includes('\n') ? 'longtext' : 'string') :
|
||||
typeof value === 'number' ? 'number' :
|
||||
typeof value === 'boolean' ? 'boolean' :
|
||||
Array.isArray(value) ? 'array' : 'object'
|
||||
)
|
||||
}
|
||||
onChange={(e) => changeType(e.target.value, path)}
|
||||
className="px-2 py-1 text-xs border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 min-w-0"
|
||||
>
|
||||
<option value="string">String</option>
|
||||
<option value="longtext">Long Text</option>
|
||||
<option value="number">Number</option>
|
||||
<option value="boolean">Boolean</option>
|
||||
<option value="array">Array</option>
|
||||
<option value="object">Object</option>
|
||||
<option value="null">Null</option>
|
||||
</select>
|
||||
|
||||
<button
|
||||
onClick={() => removeProperty(key, parentPath)}
|
||||
className="p-1 text-red-600 hover:bg-red-100 dark:hover:bg-red-900 rounded flex-shrink-0"
|
||||
title="Remove property"
|
||||
>
|
||||
<Minus className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
{!readOnly && (
|
||||
<div className="flex items-center space-x-2 sm:space-x-2">
|
||||
<select
|
||||
value={
|
||||
fieldTypes[path] || (
|
||||
value === null ? 'null' :
|
||||
value === undefined ? 'string' :
|
||||
typeof value === 'string' ? (value.includes('\n') ? 'longtext' : 'string') :
|
||||
typeof value === 'number' ? 'number' :
|
||||
typeof value === 'boolean' ? 'boolean' :
|
||||
Array.isArray(value) ? 'array' : 'object'
|
||||
)
|
||||
}
|
||||
onChange={(e) => changeType(e.target.value, path)}
|
||||
className="px-2 py-1 text-xs border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 min-w-0"
|
||||
>
|
||||
<option value="string">String</option>
|
||||
<option value="longtext">Long Text</option>
|
||||
<option value="number">Number</option>
|
||||
<option value="boolean">Boolean</option>
|
||||
<option value="array">Array</option>
|
||||
<option value="object">Object</option>
|
||||
<option value="null">Null</option>
|
||||
</select>
|
||||
|
||||
<button
|
||||
onClick={() => removeProperty(key, parentPath)}
|
||||
className="p-1 text-red-600 hover:bg-red-100 dark:hover:bg-red-900 rounded flex-shrink-0"
|
||||
title="Remove property"
|
||||
>
|
||||
<Minus className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -677,26 +715,30 @@ const StructuredEditor = ({ onDataChange, initialData = {} }) => {
|
||||
{value.map((item, index) =>
|
||||
renderValue(item, index.toString(), `${path}.${index}`, path)
|
||||
)}
|
||||
<button
|
||||
onClick={() => addArrayItem(value, path)}
|
||||
className="flex items-center space-x-1 px-2 py-1 text-sm text-blue-600 hover:bg-blue-100 dark:hover:bg-blue-900 rounded"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
<span>Add Item</span>
|
||||
</button>
|
||||
{!readOnly && (
|
||||
<button
|
||||
onClick={() => addArrayItem(value, path)}
|
||||
className="flex items-center space-x-1 px-2 py-1 text-sm text-blue-600 hover:bg-blue-100 dark:hover:bg-blue-900 rounded"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
<span>Add Item</span>
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{Object.entries(value).map(([k, v]) =>
|
||||
renderValue(v, k, `${path}.${k}`, path)
|
||||
)}
|
||||
<button
|
||||
onClick={() => addProperty(value, path)}
|
||||
className="flex items-center space-x-1 px-2 py-1 text-sm text-blue-600 hover:bg-blue-100 dark:hover:bg-blue-900 rounded"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
<span>Add Property</span>
|
||||
</button>
|
||||
{!readOnly && (
|
||||
<button
|
||||
onClick={() => addProperty(value, path)}
|
||||
className="flex items-center space-x-1 px-2 py-1 text-sm text-blue-600 hover:bg-blue-100 dark:hover:bg-blue-900 rounded"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
<span>Add Property</span>
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
@@ -708,29 +750,65 @@ const StructuredEditor = ({ onDataChange, initialData = {} }) => {
|
||||
return (
|
||||
<div className="min-h-96 w-full">
|
||||
<div className="mb-4">
|
||||
<h3 className="text-lg font-medium text-gray-900 dark:text-gray-100">Structured Data Editor</h3>
|
||||
<div className="flex flex-col gap-3 mb-3">
|
||||
<h3 className="text-lg font-medium text-gray-900 dark:text-gray-100">Structured Data Editor</h3>
|
||||
|
||||
{/* Mode Toggle - Below title on mobile, inline on desktop */}
|
||||
{readOnlyProp === false && (
|
||||
<div className="flex border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden bg-white dark:bg-gray-800 shadow-sm w-fit">
|
||||
<button
|
||||
onClick={() => setEditMode(false)}
|
||||
className={`flex items-center gap-2 px-3 py-1.5 text-xs font-medium transition-colors ${
|
||||
!editMode
|
||||
? 'bg-green-50 dark:bg-green-900/20 text-green-700 dark:text-green-300'
|
||||
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-700'
|
||||
}`}
|
||||
>
|
||||
<Eye className="h-3.5 w-3.5" />
|
||||
<span>Preview</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setEditMode(true)}
|
||||
className={`flex items-center gap-2 px-3 py-1.5 text-xs font-medium transition-colors border-l border-gray-200 dark:border-gray-700 ${
|
||||
editMode
|
||||
? 'bg-orange-50 dark:bg-orange-900/20 text-orange-700 dark:text-orange-300'
|
||||
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-700'
|
||||
}`}
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
<span>Edit</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-hidden">
|
||||
{Object.keys(data).length === 0 ? (
|
||||
<div className="text-center text-gray-500 dark:text-gray-400 py-8">
|
||||
<Braces className="h-12 w-12 mx-auto mb-2 opacity-50" />
|
||||
<p>No properties yet. Click "Add Property" to start building your data structure.</p>
|
||||
<div className="w-full overflow-hidden">
|
||||
<div className="w-full overflow-x-auto">
|
||||
<div className="min-w-max">
|
||||
{Object.keys(data).length === 0 ? (
|
||||
<div className="text-center text-gray-500 dark:text-gray-400 py-8">
|
||||
<Braces className="h-12 w-12 mx-auto mb-2 opacity-50" />
|
||||
<p>No properties yet. Click "Add Property" to start building your data structure.</p>
|
||||
</div>
|
||||
) : (
|
||||
Object.entries(data).map(([key, value]) =>
|
||||
renderValue(value, key, `root.${key}`, 'root')
|
||||
)
|
||||
)}
|
||||
|
||||
{/* Root level Add Property button */}
|
||||
{!readOnly && (
|
||||
<button
|
||||
onClick={() => addProperty(data, 'root')}
|
||||
className="flex items-center space-x-1 px-2 py-1 text-sm text-blue-600 hover:bg-blue-100 dark:hover:bg-blue-900 rounded"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
<span>Add Property</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
Object.entries(data).map(([key, value]) =>
|
||||
renderValue(value, key, `root.${key}`, 'root')
|
||||
)
|
||||
)}
|
||||
|
||||
{/* Root level Add Property button */}
|
||||
<button
|
||||
onClick={() => addProperty(data, 'root')}
|
||||
className="flex items-center space-x-1 px-2 py-1 text-sm text-blue-600 hover:bg-blue-100 dark:hover:bg-blue-900 rounded"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
<span>Add Property</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Nested Data Editor Modal */}
|
||||
@@ -749,7 +827,7 @@ const StructuredEditor = ({ onDataChange, initialData = {} }) => {
|
||||
</div>
|
||||
<button
|
||||
onClick={closeNestedEditor}
|
||||
className="p-2 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700 rounded"
|
||||
className="p-2 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700 rounded self-start"
|
||||
>
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
|
||||
@@ -50,9 +50,9 @@ const Home = () => {
|
||||
<span className="animate-pulse">_</span>
|
||||
</div>
|
||||
|
||||
<h1 className="text-5xl hidden md:text-7xl font-bold bg-gradient-to-r from-blue-600 via-purple-600 to-indigo-600 bg-clip-text text-transparent mb-6">
|
||||
{SITE_CONFIG.title}
|
||||
</h1>
|
||||
<h1 className="text-5xl md:text-7xl font-bold bg-gradient-to-r from-blue-600 via-purple-600 to-indigo-600 bg-clip-text text-transparent mb-6">
|
||||
{SITE_CONFIG.title}
|
||||
</h1>
|
||||
|
||||
<p className="text-xl md:text-2xl text-slate-600 dark:text-slate-300 mb-4 max-w-3xl mx-auto leading-relaxed">
|
||||
{SITE_CONFIG.subtitle}
|
||||
@@ -123,101 +123,102 @@ const Home = () => {
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tools Grid */}
|
||||
<div className={`transition-all duration-1000 delay-300 ${mounted ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-8'}`}>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8 mb-20">
|
||||
{filteredTools.map((tool, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={`transition-all duration-500 ${mounted ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-8'}`}
|
||||
style={{ transitionDelay: `${400 + index * 100}ms` }}
|
||||
>
|
||||
<ToolCard
|
||||
icon={tool.icon}
|
||||
title={tool.name}
|
||||
description={tool.description}
|
||||
path={tool.path}
|
||||
tags={tool.tags}
|
||||
category={tool.category}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* No Results */}
|
||||
{filteredTools.length === 0 && (
|
||||
<div className="text-center py-20">
|
||||
<div className="text-6xl mb-4">🔍</div>
|
||||
<p className="text-slate-500 dark:text-slate-400 text-xl mb-2">
|
||||
No tools found matching "{searchTerm}"
|
||||
</p>
|
||||
<p className="text-slate-400 dark:text-slate-500">
|
||||
Try searching for "editor", "encode", or "format"
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Features Section */}
|
||||
<div className={`py-20 transition-all duration-1000 delay-700 ${mounted ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-8'}`}>
|
||||
<div className="text-center mb-16">
|
||||
<h2 className="text-3xl md:text-4xl font-bold text-slate-800 dark:text-white mb-4">
|
||||
Built for Developers
|
||||
</h2>
|
||||
<p className="text-xl text-slate-600 dark:text-slate-300 max-w-2xl mx-auto">
|
||||
Every tool is crafted with developer experience and performance in mind
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-8">
|
||||
<div className="group text-center p-8 rounded-2xl bg-white/50 dark:bg-slate-800/50 backdrop-blur-sm border border-slate-200 dark:border-slate-700 hover:border-blue-300 dark:hover:border-blue-600 transition-all duration-300 hover:shadow-xl hover:shadow-blue-500/10">
|
||||
<div className="bg-gradient-to-br from-blue-500 to-blue-600 rounded-2xl w-16 h-16 flex items-center justify-center mx-auto mb-6 group-hover:scale-110 transition-transform duration-300">
|
||||
<Zap className="h-8 w-8 text-white" />
|
||||
</div>
|
||||
<h3 className="text-xl font-bold text-slate-800 dark:text-white mb-3">
|
||||
Lightning Fast
|
||||
</h3>
|
||||
<p className="text-slate-600 dark:text-slate-300 leading-relaxed">
|
||||
Optimized algorithms and local processing ensure instant results
|
||||
{/* Tools Grid */}
|
||||
<div className={`transition-all duration-1000 delay-300 ${mounted ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-8'}`}>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8 mb-20">
|
||||
{filteredTools.map((tool, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={`transition-all duration-500 ${mounted ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-8'}`}
|
||||
style={{ transitionDelay: `${400 + index * 100}ms` }}
|
||||
>
|
||||
<ToolCard
|
||||
icon={tool.icon}
|
||||
title={tool.name}
|
||||
description={tool.description}
|
||||
path={tool.path}
|
||||
tags={tool.tags}
|
||||
category={tool.category}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* No Results */}
|
||||
{filteredTools.length === 0 && (
|
||||
<div className="text-center py-20">
|
||||
<div className="text-6xl mb-4">🔍</div>
|
||||
<p className="text-slate-500 dark:text-slate-400 text-xl mb-2">
|
||||
No tools found matching "{searchTerm}"
|
||||
</p>
|
||||
<p className="text-slate-400 dark:text-slate-500">
|
||||
Try searching for "editor", "encode", or "format"
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="group text-center p-8 rounded-2xl bg-white/50 dark:bg-slate-800/50 backdrop-blur-sm border border-slate-200 dark:border-slate-700 hover:border-purple-300 dark:hover:border-purple-600 transition-all duration-300 hover:shadow-xl hover:shadow-purple-500/10">
|
||||
<div className="bg-gradient-to-br from-purple-500 to-purple-600 rounded-2xl w-16 h-16 flex items-center justify-center mx-auto mb-6 group-hover:scale-110 transition-transform duration-300">
|
||||
<Shield className="h-8 w-8 text-white" />
|
||||
</div>
|
||||
<h3 className="text-xl font-bold text-slate-800 dark:text-white mb-3">
|
||||
Privacy First
|
||||
</h3>
|
||||
<p className="text-slate-600 dark:text-slate-300 leading-relaxed">
|
||||
Your data never leaves your browser. Zero tracking, zero storage
|
||||
)}
|
||||
|
||||
{/* Features Section */}
|
||||
<div className={`py-20 transition-all duration-1000 delay-700 ${mounted ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-8'}`}>
|
||||
<div className="text-center mb-16">
|
||||
<h2 className="text-3xl md:text-4xl font-bold text-slate-800 dark:text-white mb-4">
|
||||
Built for Developers
|
||||
</h2>
|
||||
<p className="text-xl text-slate-600 dark:text-slate-300 max-w-2xl mx-auto">
|
||||
Every tool is crafted with developer experience and performance in mind
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="group text-center p-8 rounded-2xl bg-white/50 dark:bg-slate-800/50 backdrop-blur-sm border border-slate-200 dark:border-slate-700 hover:border-green-300 dark:hover:border-green-600 transition-all duration-300 hover:shadow-xl hover:shadow-green-500/10">
|
||||
<div className="bg-gradient-to-br from-green-500 to-green-600 rounded-2xl w-16 h-16 flex items-center justify-center mx-auto mb-6 group-hover:scale-110 transition-transform duration-300">
|
||||
<Cpu className="h-8 w-8 text-white" />
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-8">
|
||||
<div className="group text-center p-8 rounded-2xl bg-white/50 dark:bg-slate-800/50 backdrop-blur-sm border border-slate-200 dark:border-slate-700 hover:border-blue-300 dark:hover:border-blue-600 transition-all duration-300 hover:shadow-xl hover:shadow-blue-500/10">
|
||||
<div className="bg-gradient-to-br from-blue-500 to-blue-600 rounded-2xl w-16 h-16 flex items-center justify-center mx-auto mb-6 group-hover:scale-110 transition-transform duration-300">
|
||||
<Zap className="h-8 w-8 text-white" />
|
||||
</div>
|
||||
<h3 className="text-xl font-bold text-slate-800 dark:text-white mb-3">
|
||||
Lightning Fast
|
||||
</h3>
|
||||
<p className="text-slate-600 dark:text-slate-300 leading-relaxed">
|
||||
Optimized algorithms and local processing ensure instant results
|
||||
</p>
|
||||
</div>
|
||||
<h3 className="text-xl font-bold text-slate-800 dark:text-white mb-3">
|
||||
No Limits
|
||||
</h3>
|
||||
<p className="text-slate-600 dark:text-slate-300 leading-relaxed">
|
||||
Handle massive files and complex data without restrictions
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="group text-center p-8 rounded-2xl bg-white/50 dark:bg-slate-800/50 backdrop-blur-sm border border-slate-200 dark:border-slate-700 hover:border-indigo-300 dark:hover:border-indigo-600 transition-all duration-300 hover:shadow-xl hover:shadow-indigo-500/10">
|
||||
<div className="bg-gradient-to-br from-indigo-500 to-indigo-600 rounded-2xl w-16 h-16 flex items-center justify-center mx-auto mb-6 group-hover:scale-110 transition-transform duration-300">
|
||||
<Code className="h-8 w-8 text-white" />
|
||||
|
||||
<div className="group text-center p-8 rounded-2xl bg-white/50 dark:bg-slate-800/50 backdrop-blur-sm border border-slate-200 dark:border-slate-700 hover:border-purple-300 dark:hover:border-purple-600 transition-all duration-300 hover:shadow-xl hover:shadow-purple-500/10">
|
||||
<div className="bg-gradient-to-br from-purple-500 to-purple-600 rounded-2xl w-16 h-16 flex items-center justify-center mx-auto mb-6 group-hover:scale-110 transition-transform duration-300">
|
||||
<Shield className="h-8 w-8 text-white" />
|
||||
</div>
|
||||
<h3 className="text-xl font-bold text-slate-800 dark:text-white mb-3">
|
||||
Privacy First
|
||||
</h3>
|
||||
<p className="text-slate-600 dark:text-slate-300 leading-relaxed">
|
||||
Your data never leaves your browser. Zero tracking, zero storage
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="group text-center p-8 rounded-2xl bg-white/50 dark:bg-slate-800/50 backdrop-blur-sm border border-slate-200 dark:border-slate-700 hover:border-green-300 dark:hover:border-green-600 transition-all duration-300 hover:shadow-xl hover:shadow-green-500/10">
|
||||
<div className="bg-gradient-to-br from-green-500 to-green-600 rounded-2xl w-16 h-16 flex items-center justify-center mx-auto mb-6 group-hover:scale-110 transition-transform duration-300">
|
||||
<Cpu className="h-8 w-8 text-white" />
|
||||
</div>
|
||||
<h3 className="text-xl font-bold text-slate-800 dark:text-white mb-3">
|
||||
No Limits
|
||||
</h3>
|
||||
<p className="text-slate-600 dark:text-slate-300 leading-relaxed">
|
||||
Handle massive files and complex data without restrictions
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="group text-center p-8 rounded-2xl bg-white/50 dark:bg-slate-800/50 backdrop-blur-sm border border-slate-200 dark:border-slate-700 hover:border-indigo-300 dark:hover:border-indigo-600 transition-all duration-300 hover:shadow-xl hover:shadow-indigo-500/10">
|
||||
<div className="bg-gradient-to-br from-indigo-500 to-indigo-600 rounded-2xl w-16 h-16 flex items-center justify-center mx-auto mb-6 group-hover:scale-110 transition-transform duration-300">
|
||||
<Code className="h-8 w-8 text-white" />
|
||||
</div>
|
||||
<h3 className="text-xl font-bold text-slate-800 dark:text-white mb-3">
|
||||
Dev Focused
|
||||
</h3>
|
||||
<p className="text-slate-600 dark:text-slate-300 leading-relaxed">
|
||||
Syntax highlighting, shortcuts, and workflows developers love
|
||||
</p>
|
||||
</div>
|
||||
<h3 className="text-xl font-bold text-slate-800 dark:text-white mb-3">
|
||||
Dev Focused
|
||||
</h3>
|
||||
<p className="text-slate-600 dark:text-slate-300 leading-relaxed">
|
||||
Syntax highlighting, shortcuts, and workflows developers love
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -844,7 +844,7 @@ const InvoiceEditor = () => {
|
||||
type="url"
|
||||
value={url}
|
||||
onChange={(e) => setUrl(e.target.value)}
|
||||
placeholder="https://api.telegram.org/bot<token>/getMe"
|
||||
placeholder="https://your-url.com/invoice/your-invoice"
|
||||
className="tool-input w-full"
|
||||
onKeyPress={(e) => e.key === 'Enter' && handleUrlFetch()}
|
||||
/>
|
||||
@@ -858,7 +858,7 @@ const InvoiceEditor = () => {
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||
Enter any URL that returns JSON data. Examples: Telegram Bot API, JSONPlaceholder, GitHub API, etc.
|
||||
Enter any URL that returns exported JSON data from your previous invoice work.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -4,33 +4,10 @@ import ToolLayout from '../components/ToolLayout';
|
||||
import StructuredEditor from '../components/StructuredEditor';
|
||||
import MindmapView from '../components/MindmapView';
|
||||
import PostmanTable from '../components/PostmanTable';
|
||||
import CodeEditor from '../components/CodeEditor';
|
||||
import CodeMirrorEditor from '../components/CodeMirrorEditor';
|
||||
|
||||
// Hook to detect dark mode
|
||||
const useDarkMode = () => {
|
||||
const [isDark, setIsDark] = useState(() => {
|
||||
return document.documentElement.classList.contains('dark');
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const observer = new MutationObserver(() => {
|
||||
setIsDark(document.documentElement.classList.contains('dark'));
|
||||
});
|
||||
|
||||
observer.observe(document.documentElement, {
|
||||
attributes: true,
|
||||
attributeFilter: ['class']
|
||||
});
|
||||
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
return isDark;
|
||||
};
|
||||
|
||||
const ObjectEditor = () => {
|
||||
const isDark = useDarkMode();
|
||||
const exportCardRef = useRef(null);
|
||||
const [structuredData, setStructuredData] = useState({});
|
||||
|
||||
// Sync structured data to localStorage for navigation guard
|
||||
@@ -58,9 +35,53 @@ const ObjectEditor = () => {
|
||||
const fileInputRef = useRef(null);
|
||||
const [pasteCollapsed, setPasteCollapsed] = useState(false);
|
||||
const [pasteDataSummary, setPasteDataSummary] = useState(null);
|
||||
const [urlDataSummary, setUrlDataSummary] = useState(null);
|
||||
const [fileDataSummary, setFileDataSummary] = useState(null);
|
||||
const [outputExpanded, setOutputExpanded] = useState(false);
|
||||
const [usageTipsExpanded, setUsageTipsExpanded] = useState(false);
|
||||
|
||||
// Button feedback states
|
||||
const [copiedButton, setCopiedButton] = useState(null);
|
||||
const [downloadedButton, setDownloadedButton] = useState(null);
|
||||
|
||||
const copyToClipboard = async (text) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
} catch (err) {
|
||||
console.error("Failed to copy:", err);
|
||||
}
|
||||
};
|
||||
|
||||
// Export functions
|
||||
const getExportData = (format) => {
|
||||
if (Object.keys(structuredData).length === 0) return "";
|
||||
|
||||
switch (format) {
|
||||
case "json":
|
||||
// Convert data to use column names instead of column IDs
|
||||
// Use Object.fromEntries with columns.map to preserve column order
|
||||
const jsonData = structuredData;
|
||||
return jsonFormat === "pretty"
|
||||
? JSON.stringify(jsonData, null, 2)
|
||||
: JSON.stringify(jsonData);
|
||||
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
};
|
||||
|
||||
const downloadFile = (content, filename, mimeType) => {
|
||||
const blob = new Blob([content], { type: mimeType });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
// Helper function to check if user has data that would be lost
|
||||
const hasUserData = () => {
|
||||
return Object.keys(structuredData).length > 0;
|
||||
@@ -549,28 +570,38 @@ const ObjectEditor = () => {
|
||||
// Handle file import (auto-load, same as Table/Invoice Editor)
|
||||
const handleFileImport = (event) => {
|
||||
const file = event.target.files[0];
|
||||
if (file) {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
try {
|
||||
const content = e.target.result;
|
||||
const detection = detectInputFormat(content);
|
||||
|
||||
if (detection.valid) {
|
||||
setStructuredData(detection.data);
|
||||
setCreateNewCompleted(true);
|
||||
setError('');
|
||||
} else {
|
||||
setError(detection.error || 'Invalid format. Please enter valid JSON or PHP serialized data.');
|
||||
}
|
||||
} catch (err) {
|
||||
setError('Failed to read file: ' + err.message);
|
||||
}
|
||||
};
|
||||
reader.readAsText(file);
|
||||
}
|
||||
};
|
||||
if (!file) return;
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
const content = e.target.result;
|
||||
const detection = detectInputFormat(content);
|
||||
|
||||
if (detection.valid) {
|
||||
setStructuredData(detection.data);
|
||||
generateOutputs(detection.data);
|
||||
setInputText(content);
|
||||
setInputFormat(detection.format);
|
||||
setInputValid(true);
|
||||
setError('');
|
||||
setCreateNewCompleted(true);
|
||||
|
||||
// Set file data summary
|
||||
setFileDataSummary({
|
||||
format: detection.format,
|
||||
size: content.length,
|
||||
properties: Object.keys(detection.data).length,
|
||||
filename: file.name
|
||||
});
|
||||
|
||||
// Stay on Open tab - don't switch to paste
|
||||
} else {
|
||||
setError(detection.error || 'Invalid file format');
|
||||
setFileDataSummary(null);
|
||||
}
|
||||
};
|
||||
reader.readAsText(file);
|
||||
};
|
||||
|
||||
// Fetch data from URL
|
||||
const handleFetchData = async () => {
|
||||
@@ -596,36 +627,38 @@ const ObjectEditor = () => {
|
||||
}
|
||||
|
||||
const contentType = response.headers.get('content-type');
|
||||
const text = await response.text();
|
||||
let data;
|
||||
|
||||
if (!contentType || !contentType.includes('application/json')) {
|
||||
// Try to parse as JSON anyway, some APIs don't set correct content-type
|
||||
const text = await response.text();
|
||||
try {
|
||||
const data = JSON.parse(text);
|
||||
setStructuredData(data);
|
||||
generateOutputs(data);
|
||||
setInputText(JSON.stringify(data, null, 2));
|
||||
setInputFormat('JSON');
|
||||
setInputValid(true);
|
||||
setCreateNewCompleted(true);
|
||||
data = JSON.parse(text);
|
||||
} catch {
|
||||
throw new Error('Response is not valid JSON. Content-Type: ' + (contentType || 'unknown'));
|
||||
}
|
||||
} else {
|
||||
const data = await response.json();
|
||||
setStructuredData(data);
|
||||
generateOutputs(data);
|
||||
setInputText(JSON.stringify(data, null, 2));
|
||||
setInputFormat('JSON');
|
||||
setInputValid(true);
|
||||
setCreateNewCompleted(true);
|
||||
data = JSON.parse(text);
|
||||
}
|
||||
|
||||
setStructuredData(data);
|
||||
generateOutputs(data);
|
||||
setInputText(JSON.stringify(data, null, 2));
|
||||
setInputFormat('JSON');
|
||||
setInputValid(true);
|
||||
setCreateNewCompleted(true);
|
||||
|
||||
// Set URL data summary
|
||||
setUrlDataSummary({
|
||||
format: 'JSON',
|
||||
size: text.length,
|
||||
properties: Object.keys(data).length,
|
||||
url: url
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Fetch error:', err);
|
||||
if (err.name === 'TypeError' && err.message.includes('fetch')) {
|
||||
setError('Network error: Unable to fetch data. Check the URL and try again.');
|
||||
} else {
|
||||
setError(`Fetch failed: ${err.message}`);
|
||||
}
|
||||
setError(`Failed to fetch data: ${err.message}`);
|
||||
setUrlDataSummary(null);
|
||||
} finally {
|
||||
setFetching(false);
|
||||
}
|
||||
@@ -779,43 +812,59 @@ const ObjectEditor = () => {
|
||||
|
||||
{/* URL Tab Content */}
|
||||
{activeTab === 'url' && (
|
||||
<div className="space-y-3">
|
||||
<div className="flex gap-2">
|
||||
<div className="relative flex-1">
|
||||
<input
|
||||
type="url"
|
||||
value={fetchUrl}
|
||||
onChange={(e) => setFetchUrl(e.target.value)}
|
||||
placeholder="https://api.telegram.org/bot<token>/getMe"
|
||||
className="tool-input w-full"
|
||||
onKeyPress={(e) => e.key === 'Enter' && handleFetchData()}
|
||||
/>
|
||||
urlDataSummary ? (
|
||||
<div className="p-4 bg-green-50 dark:bg-green-900/20 rounded-lg">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
|
||||
<span className="text-sm text-green-700 dark:text-green-300 break-words">
|
||||
✓ Data loaded: {urlDataSummary.format} ({urlDataSummary.size.toLocaleString()} chars, {urlDataSummary.properties} {urlDataSummary.properties === 1 ? 'property' : 'properties'})
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setUrlDataSummary(null)}
|
||||
className="text-sm text-blue-600 dark:text-blue-400 hover:underline whitespace-nowrap"
|
||||
>
|
||||
Fetch New URL ▼
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleFetchData}
|
||||
disabled={fetching || !fetchUrl.trim()}
|
||||
className="bg-blue-600 hover:bg-blue-700 disabled:bg-gray-400 text-white font-medium px-4 py-2 rounded-md transition-colors flex items-center whitespace-nowrap"
|
||||
>
|
||||
{fetching ? 'Fetching...' : 'Fetch Data'}
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||
Enter any URL that returns JSON data. Examples: Telegram Bot API, JSONPlaceholder, GitHub API, etc.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
<div className="flex gap-2">
|
||||
<div className="relative flex-1">
|
||||
<input
|
||||
type="url"
|
||||
value={fetchUrl}
|
||||
onChange={(e) => setFetchUrl(e.target.value)}
|
||||
placeholder="https://api.telegram.org/bot<token>/getMe"
|
||||
className="tool-input w-full"
|
||||
onKeyPress={(e) => e.key === 'Enter' && handleFetchData()}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleFetchData}
|
||||
disabled={fetching || !fetchUrl.trim()}
|
||||
className="bg-blue-600 hover:bg-blue-700 disabled:bg-gray-400 text-white font-medium px-4 py-2 rounded-md transition-colors flex items-center whitespace-nowrap"
|
||||
>
|
||||
{fetching ? 'Fetching...' : 'Fetch Data'}
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||
Enter any URL that returns JSON data. Examples: Telegram Bot API, JSONPlaceholder, GitHub API, etc.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
|
||||
{/* Paste Tab Content */}
|
||||
{activeTab === 'paste' && (
|
||||
pasteCollapsed ? (
|
||||
<div className="p-4 bg-green-50 dark:bg-green-900/20 rounded-lg">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-green-700 dark:text-green-300">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
|
||||
<span className="text-sm text-green-700 dark:text-green-300 break-words">
|
||||
✓ Object loaded: {pasteDataSummary.format} ({pasteDataSummary.size.toLocaleString()} chars, {pasteDataSummary.properties} {pasteDataSummary.properties === 1 ? 'property' : 'properties'})
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setPasteCollapsed(false)}
|
||||
className="text-sm text-blue-600 hover:underline"
|
||||
className="text-sm text-blue-600 dark:text-blue-400 hover:underline whitespace-nowrap"
|
||||
>
|
||||
Edit Input ▼
|
||||
</button>
|
||||
@@ -868,20 +917,36 @@ const ObjectEditor = () => {
|
||||
|
||||
{/* Open Tab Content */}
|
||||
{activeTab === 'open' && (
|
||||
<div className="space-y-3">
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".json,.txt"
|
||||
onChange={handleFileImport}
|
||||
className="tool-input"
|
||||
/>
|
||||
<div className="bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-md p-3">
|
||||
<p className="text-xs text-green-700 dark:text-green-300">
|
||||
🔒 <strong>Privacy:</strong> Your data stays in your browser. We don't store or upload anything - just help you open, edit, and export your files locally.
|
||||
</p>
|
||||
fileDataSummary ? (
|
||||
<div className="p-4 bg-green-50 dark:bg-green-900/20 rounded-lg">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
|
||||
<span className="text-sm text-green-700 dark:text-green-300 break-words">
|
||||
✓ File loaded: {fileDataSummary.format} ({fileDataSummary.size.toLocaleString()} chars, {fileDataSummary.properties} {fileDataSummary.properties === 1 ? 'property' : 'properties'}) - {fileDataSummary.filename}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setFileDataSummary(null)}
|
||||
className="text-sm text-blue-600 dark:text-blue-400 hover:underline whitespace-nowrap"
|
||||
>
|
||||
Upload New File ▼
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".json,.txt"
|
||||
onChange={handleFileImport}
|
||||
className="tool-input"
|
||||
/>
|
||||
<div className="bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-md p-3">
|
||||
<p className="text-xs text-green-700 dark:text-green-300">
|
||||
🔒 <strong>Privacy:</strong> Your data stays in your browser. We don't store or upload anything - just help you open, edit, and export your files locally.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
@@ -900,7 +965,7 @@ const ObjectEditor = () => {
|
||||
Object Editor
|
||||
</h3>
|
||||
|
||||
{/* View Mode Tabs - Moved to right */}
|
||||
{/* View Mode Tabs */}
|
||||
<div className="flex border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
|
||||
<button
|
||||
onClick={() => setViewMode('visual')}
|
||||
@@ -911,7 +976,7 @@ const ObjectEditor = () => {
|
||||
}`}
|
||||
>
|
||||
<Edit3 className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">Visual Editor</span>
|
||||
<span className="hidden sm:inline">Tree</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setViewMode('mindmap')}
|
||||
@@ -922,7 +987,7 @@ const ObjectEditor = () => {
|
||||
}`}
|
||||
>
|
||||
<Workflow className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">Mindmap View</span>
|
||||
<span className="hidden sm:inline">Mindmap</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setViewMode('table')}
|
||||
@@ -933,7 +998,7 @@ const ObjectEditor = () => {
|
||||
}`}
|
||||
>
|
||||
<Table className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">Table View</span>
|
||||
<span className="hidden sm:inline">Table</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -954,15 +1019,11 @@ const ObjectEditor = () => {
|
||||
) : (
|
||||
<>
|
||||
{viewMode === 'visual' && (
|
||||
<div className="w-full overflow-hidden">
|
||||
<div className="w-full overflow-x-auto p-4">
|
||||
<div className="min-w-max">
|
||||
<StructuredEditor
|
||||
initialData={structuredData}
|
||||
onDataChange={handleStructuredDataChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-4">
|
||||
<StructuredEditor
|
||||
initialData={structuredData}
|
||||
onDataChange={handleStructuredDataChange}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -987,6 +1048,7 @@ const ObjectEditor = () => {
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 overflow-hidden mt-6">
|
||||
{/* Export Header - Collapsible */}
|
||||
<div
|
||||
ref={exportCardRef}
|
||||
onClick={() => setOutputExpanded(!outputExpanded)}
|
||||
className="px-4 py-3 border-b border-gray-200 dark:border-gray-700 cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
|
||||
>
|
||||
@@ -1035,13 +1097,14 @@ const ObjectEditor = () => {
|
||||
<div className="p-4">
|
||||
{activeExportTab === 'json' && (
|
||||
<div className="space-y-3">
|
||||
<CodeEditor
|
||||
value={jsonFormat === 'pretty' ? (outputs.jsonPretty || '{}') : (outputs.jsonMinified || '{}')}
|
||||
<CodeMirrorEditor
|
||||
value={jsonFormat === 'pretty' ? (outputs.jsonPretty || '{}') : (outputs.jsonMinified || '{}')}
|
||||
language="json"
|
||||
readOnly={true}
|
||||
height="300px"
|
||||
maxLines={12}
|
||||
showToggle={true}
|
||||
className="w-full"
|
||||
theme={isDark ? 'dark' : 'light'}
|
||||
cardRef={exportCardRef}
|
||||
/>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -1070,26 +1133,27 @@ const ObjectEditor = () => {
|
||||
<button
|
||||
onClick={() => {
|
||||
const content = jsonFormat === 'pretty' ? (outputs.jsonPretty || '{}') : (outputs.jsonMinified || '{}');
|
||||
navigator.clipboard.writeText(content);
|
||||
copyToClipboard(content);
|
||||
setCopiedButton('json');
|
||||
setTimeout(() => setCopiedButton(null), 2000);
|
||||
}}
|
||||
className="px-3 py-1 text-sm bg-gray-100 hover:bg-gray-200 dark:bg-gray-600 dark:hover:bg-gray-500 text-gray-700 dark:text-gray-200 rounded transition-colors"
|
||||
>
|
||||
Copy
|
||||
{copiedButton === 'json' ? '✓ Copied!' : 'Copy'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
const content = jsonFormat === 'pretty' ? (outputs.jsonPretty || '{}') : (outputs.jsonMinified || '{}');
|
||||
const blob = new Blob([content], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = 'object-data.json';
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
downloadFile(
|
||||
getExportData("json"),
|
||||
"object-data.json",
|
||||
"application/json",
|
||||
);
|
||||
setDownloadedButton('json');
|
||||
setTimeout(() => setDownloadedButton(null), 2000);
|
||||
}}
|
||||
className="px-3 py-1 text-sm bg-blue-600 hover:bg-blue-700 dark:bg-blue-600 dark:hover:bg-blue-700 text-white rounded transition-colors"
|
||||
>
|
||||
Download
|
||||
{downloadedButton === 'json' ? '✓ Downloaded!' : 'Download'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1098,23 +1162,26 @@ const ObjectEditor = () => {
|
||||
|
||||
{activeExportTab === 'php' && (
|
||||
<div className="space-y-3">
|
||||
<CodeEditor
|
||||
<CodeMirrorEditor
|
||||
value={outputs.serialized || 'a:0:{}'}
|
||||
language="javascript"
|
||||
readOnly={true}
|
||||
height="300px"
|
||||
maxLines={12}
|
||||
showToggle={true}
|
||||
className="w-full"
|
||||
theme={isDark ? 'dark' : 'light'}
|
||||
cardRef={exportCardRef}
|
||||
/>
|
||||
<div className="flex justify-end gap-2">
|
||||
<button
|
||||
onClick={() => {
|
||||
const content = outputs.serialized || 'a:0:{}';
|
||||
navigator.clipboard.writeText(content);
|
||||
copyToClipboard(content);
|
||||
setCopiedButton('php');
|
||||
setTimeout(() => setCopiedButton(null), 2000);
|
||||
}}
|
||||
className="px-3 py-1 text-sm bg-gray-100 hover:bg-gray-200 dark:bg-gray-600 dark:hover:bg-gray-500 text-gray-700 dark:text-gray-200 rounded transition-colors"
|
||||
>
|
||||
Copy
|
||||
{copiedButton === 'php' ? '✓ Copied!' : 'Copy'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
@@ -1124,12 +1191,16 @@ const ObjectEditor = () => {
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = 'object-data.txt';
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
setDownloadedButton('php');
|
||||
setTimeout(() => setDownloadedButton(null), 2000);
|
||||
}}
|
||||
className="px-3 py-1 text-sm bg-blue-600 hover:bg-blue-700 dark:bg-blue-600 dark:hover:bg-blue-700 text-white rounded transition-colors"
|
||||
>
|
||||
Download
|
||||
{downloadedButton === 'php' ? '✓ Downloaded!' : 'Download'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,35 +1,12 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { Plus, Upload, FileText, Globe, Download, X, Table, Trash2, Database, Braces, Code, Eye, Minimize2, Maximize2, Search, ArrowUpDown, AlertTriangle, Edit3, ChevronUp, ChevronDown } from 'lucide-react';
|
||||
import ToolLayout from '../components/ToolLayout';
|
||||
import CodeEditor from '../components/CodeEditor';
|
||||
import CodeMirrorEditor from '../components/CodeMirrorEditor';
|
||||
import StructuredEditor from "../components/StructuredEditor";
|
||||
import Papa from "papaparse";
|
||||
|
||||
// Hook to detect dark mode
|
||||
const useDarkMode = () => {
|
||||
const [isDark, setIsDark] = useState(() => {
|
||||
return document.documentElement.classList.contains('dark');
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const observer = new MutationObserver(() => {
|
||||
setIsDark(document.documentElement.classList.contains('dark'));
|
||||
});
|
||||
|
||||
observer.observe(document.documentElement, {
|
||||
attributes: true,
|
||||
attributeFilter: ['class']
|
||||
});
|
||||
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
return isDark;
|
||||
};
|
||||
|
||||
const TableEditor = () => {
|
||||
const isDark = useDarkMode();
|
||||
const exportCardRef = useRef(null);
|
||||
const [data, setData] = useState([]);
|
||||
const [columns, setColumns] = useState([]);
|
||||
|
||||
@@ -69,6 +46,8 @@ const TableEditor = () => {
|
||||
const [createNewCompleted, setCreateNewCompleted] = useState(false); // Track if user completed Create New step
|
||||
const [pasteCollapsed, setPasteCollapsed] = useState(false); // Track if paste input is collapsed
|
||||
const [pasteDataSummary, setPasteDataSummary] = useState(null); // Summary of pasted data
|
||||
const [urlDataSummary, setUrlDataSummary] = useState(null); // Summary of URL fetched data
|
||||
const [fileDataSummary, setFileDataSummary] = useState(null); // Summary of file uploaded data
|
||||
const [exportExpanded, setExportExpanded] = useState(false); // Track if export section is expanded
|
||||
const [usageTipsExpanded, setUsageTipsExpanded] = useState(false); // Track if usage tips is expanded
|
||||
|
||||
@@ -76,6 +55,10 @@ const TableEditor = () => {
|
||||
const [sqlTableName, setSqlTableName] = useState(""); // Table name for SQL export
|
||||
const [sqlPrimaryKey, setSqlPrimaryKey] = useState(""); // Primary key column for SQL export
|
||||
|
||||
// Button feedback states
|
||||
const [copiedButton, setCopiedButton] = useState(null);
|
||||
const [downloadedButton, setDownloadedButton] = useState(null);
|
||||
|
||||
// Helper function to check if user has data that would be lost
|
||||
const hasUserData = () => {
|
||||
// Check if there are multiple tables (imported data)
|
||||
@@ -650,55 +633,52 @@ const TableEditor = () => {
|
||||
|
||||
// Parse CSV/TSV data
|
||||
const parseData = (text, hasHeaders = true) => {
|
||||
try {
|
||||
const result = Papa.parse(text.trim(), {
|
||||
header: false,
|
||||
skipEmptyLines: true,
|
||||
delimiter: text.includes("\t") ? "\t" : ",",
|
||||
});
|
||||
const result = Papa.parse(text.trim(), {
|
||||
header: false,
|
||||
skipEmptyLines: true,
|
||||
delimiter: text.includes("\t") ? "\t" : ",",
|
||||
});
|
||||
|
||||
if (result.errors.length > 0) {
|
||||
throw new Error(result.errors[0].message);
|
||||
}
|
||||
|
||||
const rows = result.data;
|
||||
if (rows.length === 0) {
|
||||
throw new Error("No data found");
|
||||
}
|
||||
|
||||
let headers;
|
||||
let dataRows;
|
||||
|
||||
if (hasHeaders && rows.length > 0) {
|
||||
headers = rows[0].map((header, index) => ({
|
||||
id: `col_${index}`,
|
||||
name: header || `Column ${index + 1}`,
|
||||
type: "text",
|
||||
}));
|
||||
dataRows = rows.slice(1);
|
||||
} else {
|
||||
headers = rows[0].map((_, index) => ({
|
||||
id: `col_${index}`,
|
||||
name: `Column ${index + 1}`,
|
||||
type: "text",
|
||||
}));
|
||||
dataRows = rows;
|
||||
}
|
||||
|
||||
const tableData = dataRows.map((row, index) => {
|
||||
const rowData = { id: `row_${index}` };
|
||||
headers.forEach((header, colIndex) => {
|
||||
rowData[header.id] = row[colIndex] || "";
|
||||
});
|
||||
return rowData;
|
||||
});
|
||||
|
||||
setColumns(headers);
|
||||
setData(tableData);
|
||||
setError("");
|
||||
} catch (err) {
|
||||
setError(`Failed to parse data: ${err.message}`);
|
||||
if (result.errors.length > 0) {
|
||||
throw new Error(result.errors[0].message);
|
||||
}
|
||||
|
||||
const rows = result.data;
|
||||
if (rows.length === 0) {
|
||||
throw new Error("No data found");
|
||||
}
|
||||
|
||||
let headers;
|
||||
let dataRows;
|
||||
|
||||
if (hasHeaders && rows.length > 0) {
|
||||
headers = rows[0].map((header, index) => ({
|
||||
id: `col_${index}`,
|
||||
name: header || `Column ${index + 1}`,
|
||||
type: "text",
|
||||
}));
|
||||
dataRows = rows.slice(1);
|
||||
} else {
|
||||
headers = rows[0].map((_, index) => ({
|
||||
id: `col_${index}`,
|
||||
name: `Column ${index + 1}`,
|
||||
type: "text",
|
||||
}));
|
||||
dataRows = rows;
|
||||
}
|
||||
|
||||
const tableData = dataRows.map((row, index) => {
|
||||
const rowData = { id: `row_${index}` };
|
||||
headers.forEach((header, colIndex) => {
|
||||
rowData[header.id] = row[colIndex] || "";
|
||||
});
|
||||
return rowData;
|
||||
});
|
||||
|
||||
setColumns(headers);
|
||||
setData(tableData);
|
||||
setError("");
|
||||
return tableData.length; // Return actual row count
|
||||
};
|
||||
|
||||
// Parse SQL data
|
||||
@@ -941,40 +921,37 @@ const TableEditor = () => {
|
||||
|
||||
// Parse JSON data
|
||||
const parseJsonData = (text) => {
|
||||
try {
|
||||
const jsonData = JSON.parse(text);
|
||||
const jsonData = JSON.parse(text);
|
||||
|
||||
if (!Array.isArray(jsonData)) {
|
||||
throw new Error("JSON must be an array of objects");
|
||||
}
|
||||
|
||||
if (jsonData.length === 0) {
|
||||
throw new Error("Array is empty");
|
||||
}
|
||||
|
||||
// Extract columns from first object
|
||||
const firstItem = jsonData[0];
|
||||
const headers = Object.keys(firstItem).map((key, index) => ({
|
||||
id: `col_${index}`,
|
||||
name: key,
|
||||
type: typeof firstItem[key] === "number" ? "number" : "text",
|
||||
}));
|
||||
|
||||
// Convert to table format
|
||||
const tableData = jsonData.map((item, index) => {
|
||||
const rowData = { id: `row_${index}` };
|
||||
headers.forEach((header) => {
|
||||
rowData[header.id] = item[header.name] || "";
|
||||
});
|
||||
return rowData;
|
||||
});
|
||||
|
||||
setColumns(headers);
|
||||
setData(tableData);
|
||||
setError("");
|
||||
} catch (err) {
|
||||
setError(`Failed to parse JSON: ${err.message}`);
|
||||
if (!Array.isArray(jsonData)) {
|
||||
throw new Error("JSON must be an array of objects");
|
||||
}
|
||||
|
||||
if (jsonData.length === 0) {
|
||||
throw new Error("Array is empty");
|
||||
}
|
||||
|
||||
// Extract columns from first object
|
||||
const firstItem = jsonData[0];
|
||||
const headers = Object.keys(firstItem).map((key, index) => ({
|
||||
id: `col_${index}`,
|
||||
name: key,
|
||||
type: typeof firstItem[key] === "number" ? "number" : "text",
|
||||
}));
|
||||
|
||||
// Convert to table format
|
||||
const tableData = jsonData.map((item, index) => {
|
||||
const rowData = { id: `row_${index}` };
|
||||
headers.forEach((header) => {
|
||||
rowData[header.id] = item[header.name] || "";
|
||||
});
|
||||
return rowData;
|
||||
});
|
||||
|
||||
setColumns(headers);
|
||||
setData(tableData);
|
||||
setError("");
|
||||
return tableData.length; // Return row count for summary
|
||||
};
|
||||
|
||||
// Handle text input
|
||||
@@ -987,15 +964,14 @@ const TableEditor = () => {
|
||||
|
||||
const trimmed = inputText.trim();
|
||||
let format = '';
|
||||
let success = false;
|
||||
let rowCount = 0;
|
||||
|
||||
try {
|
||||
// Try to detect format
|
||||
if (trimmed.startsWith("[") && trimmed.endsWith("]")) {
|
||||
// JSON array
|
||||
parseJsonData(trimmed);
|
||||
rowCount = parseJsonData(trimmed);
|
||||
format = 'JSON';
|
||||
success = true;
|
||||
} else if (
|
||||
trimmed.toLowerCase().includes("insert into") &&
|
||||
trimmed.toLowerCase().includes("values")
|
||||
@@ -1003,24 +979,25 @@ const TableEditor = () => {
|
||||
// SQL INSERT statements
|
||||
parseSqlData(trimmed);
|
||||
format = 'SQL';
|
||||
success = true;
|
||||
// Get row count from state after parse
|
||||
rowCount = data.length;
|
||||
} else {
|
||||
// CSV/TSV
|
||||
parseData(trimmed, useFirstRowAsHeader);
|
||||
format = trimmed.includes('\t') ? 'TSV' : 'CSV';
|
||||
success = true;
|
||||
// Get row count from state after parse
|
||||
rowCount = data.length;
|
||||
}
|
||||
|
||||
// If successful, collapse input and show summary
|
||||
if (success && data.length > 0) {
|
||||
setPasteDataSummary({
|
||||
format: format,
|
||||
size: inputText.length,
|
||||
rows: data.length
|
||||
});
|
||||
setPasteCollapsed(true);
|
||||
setError('');
|
||||
}
|
||||
// Collapse input and show summary
|
||||
setPasteDataSummary({
|
||||
format: format,
|
||||
size: inputText.length,
|
||||
rows: rowCount || data.length // Use rowCount if available, fallback to data.length
|
||||
});
|
||||
setPasteCollapsed(true);
|
||||
setCreateNewCompleted(true);
|
||||
setError('');
|
||||
} catch (err) {
|
||||
// Keep input expanded on error
|
||||
setPasteCollapsed(false);
|
||||
@@ -1047,14 +1024,28 @@ const TableEditor = () => {
|
||||
|
||||
const contentType = response.headers.get("content-type") || "";
|
||||
const text = await response.text();
|
||||
let format = '';
|
||||
let rowCount = 0;
|
||||
|
||||
if (contentType.includes("application/json") || url.includes(".json")) {
|
||||
parseJsonData(text);
|
||||
rowCount = parseJsonData(text);
|
||||
format = 'JSON';
|
||||
} else {
|
||||
parseData(text, useFirstRowAsHeader);
|
||||
rowCount = parseData(text, useFirstRowAsHeader);
|
||||
format = text.includes('\t') ? 'TSV' : 'CSV';
|
||||
}
|
||||
|
||||
// Set summary for URL fetch
|
||||
setUrlDataSummary({
|
||||
format: format,
|
||||
size: text.length,
|
||||
rows: rowCount,
|
||||
url: url.trim()
|
||||
});
|
||||
setCreateNewCompleted(true);
|
||||
} catch (err) {
|
||||
setError(`Failed to fetch data: ${err.message}`);
|
||||
setUrlDataSummary(null);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
@@ -1136,17 +1127,47 @@ const TableEditor = () => {
|
||||
reader.onload = (e) => {
|
||||
try {
|
||||
const content = e.target.result;
|
||||
let format = '';
|
||||
let rowCount = 0;
|
||||
|
||||
// Check if it's SQL file for multi-table support
|
||||
if (file.name.toLowerCase().endsWith(".sql")) {
|
||||
initializeTablesFromSQL(content, file.name);
|
||||
format = 'SQL';
|
||||
// For SQL, we need to wait for state update, use a timeout
|
||||
setTimeout(() => {
|
||||
setFileDataSummary({
|
||||
format: format,
|
||||
size: content.length,
|
||||
rows: data.length,
|
||||
filename: file.name
|
||||
});
|
||||
}, 100);
|
||||
} else if (file.name.toLowerCase().endsWith(".json")) {
|
||||
rowCount = parseJsonData(content);
|
||||
format = 'JSON';
|
||||
setFileDataSummary({
|
||||
format: format,
|
||||
size: content.length,
|
||||
rows: rowCount,
|
||||
filename: file.name
|
||||
});
|
||||
} else {
|
||||
// Fallback to single-table parsing
|
||||
parseData(content);
|
||||
rowCount = parseData(content, useFirstRowAsHeader);
|
||||
format = file.name.toLowerCase().endsWith(".tsv") ? 'TSV' : 'CSV';
|
||||
setFileDataSummary({
|
||||
format: format,
|
||||
size: content.length,
|
||||
rows: rowCount,
|
||||
filename: file.name
|
||||
});
|
||||
}
|
||||
|
||||
setCreateNewCompleted(true);
|
||||
} catch (err) {
|
||||
console.error("❌ File upload error:", err);
|
||||
setError("Failed to read file: " + err.message);
|
||||
setFileDataSummary(null);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
@@ -1995,56 +2016,72 @@ const TableEditor = () => {
|
||||
)}
|
||||
|
||||
{activeTab === "url" && (
|
||||
<div className="space-y-3">
|
||||
<div className="flex gap-2">
|
||||
<div className="relative flex-1">
|
||||
<input
|
||||
type="url"
|
||||
value={url}
|
||||
onChange={(e) => setUrl(e.target.value)}
|
||||
placeholder="https://api.example.com/data.json or https://example.com/data.csv"
|
||||
className="tool-input w-full pr-10"
|
||||
disabled={isLoading}
|
||||
/>
|
||||
{url && !isLoading && (
|
||||
<button
|
||||
onClick={() => setUrl("")}
|
||||
className="absolute right-3 top-1/2 transform -translate-y-1/2 text-gray-400 hover:text-gray-600 dark:text-gray-500 dark:hover:text-gray-300 transition-colors"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
urlDataSummary ? (
|
||||
<div className="p-4 bg-green-50 dark:bg-green-900/20 rounded-lg">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
|
||||
<span className="text-sm text-green-700 dark:text-green-300 break-words">
|
||||
✓ Data loaded: {urlDataSummary.format} ({urlDataSummary.size.toLocaleString()} chars, {urlDataSummary.rows} rows)
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setUrlDataSummary(null)}
|
||||
className="text-sm text-blue-600 dark:text-blue-400 hover:underline whitespace-nowrap"
|
||||
>
|
||||
Fetch New URL ▼
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
onClick={fetchUrlData}
|
||||
disabled={isLoading || !url.trim()}
|
||||
className="bg-blue-600 hover:bg-blue-700 disabled:bg-gray-400 text-white font-medium px-4 py-2 rounded-md transition-colors flex items-center whitespace-nowrap"
|
||||
>
|
||||
{isLoading ? "Fetching..." : "Fetch Data"}
|
||||
</button>
|
||||
</div>
|
||||
<label className="flex items-center text-sm text-gray-600 dark:text-gray-400">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={useFirstRowAsHeader}
|
||||
onChange={(e) => setUseFirstRowAsHeader(e.target.checked)}
|
||||
className="mr-2"
|
||||
/>
|
||||
Use first row as column headers (for CSV/TSV)
|
||||
</label>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
<div className="flex gap-2">
|
||||
<div className="relative flex-1">
|
||||
<input
|
||||
type="url"
|
||||
value={url}
|
||||
onChange={(e) => setUrl(e.target.value)}
|
||||
placeholder="https://api.example.com/data.json or https://example.com/data.csv"
|
||||
className="tool-input w-full pr-10"
|
||||
disabled={isLoading}
|
||||
/>
|
||||
{url && !isLoading && (
|
||||
<button
|
||||
onClick={() => setUrl("")}
|
||||
className="absolute right-3 top-1/2 transform -translate-y-1/2 text-gray-400 hover:text-gray-600 dark:text-gray-500 dark:hover:text-gray-300 transition-colors"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={fetchUrlData}
|
||||
disabled={isLoading || !url.trim()}
|
||||
className="bg-blue-600 hover:bg-blue-700 disabled:bg-gray-400 text-white font-medium px-4 py-2 rounded-md transition-colors flex items-center whitespace-nowrap"
|
||||
>
|
||||
{isLoading ? "Fetching..." : "Fetch Data"}
|
||||
</button>
|
||||
</div>
|
||||
<label className="flex items-center text-sm text-gray-600 dark:text-gray-400">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={useFirstRowAsHeader}
|
||||
onChange={(e) => setUseFirstRowAsHeader(e.target.checked)}
|
||||
className="mr-2"
|
||||
/>
|
||||
Use first row as column headers (for CSV/TSV)
|
||||
</label>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
|
||||
{activeTab === "paste" && (
|
||||
pasteCollapsed ? (
|
||||
<div className="p-4 bg-green-50 dark:bg-green-900/20 rounded-lg">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-green-700 dark:text-green-300">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
|
||||
<span className="text-sm text-green-700 dark:text-green-300 break-words">
|
||||
✓ Data loaded: {pasteDataSummary.format} ({pasteDataSummary.size.toLocaleString()} chars, {pasteDataSummary.rows} rows)
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setPasteCollapsed(false)}
|
||||
className="text-sm text-blue-600 hover:underline"
|
||||
className="text-sm text-blue-600 dark:text-blue-400 hover:underline whitespace-nowrap"
|
||||
>
|
||||
Edit Input ▼
|
||||
</button>
|
||||
@@ -2092,18 +2129,33 @@ const TableEditor = () => {
|
||||
)}
|
||||
|
||||
{activeTab === "upload" && (
|
||||
<div className="space-y-3">
|
||||
<input
|
||||
type="file"
|
||||
accept=".csv,.tsv,.json,.sql"
|
||||
onChange={handleFileUpload}
|
||||
className="tool-input"
|
||||
/>
|
||||
<label className="flex items-center text-sm text-gray-600 dark:text-gray-400">
|
||||
fileDataSummary ? (
|
||||
<div className="p-4 bg-green-50 dark:bg-green-900/20 rounded-lg">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
|
||||
<span className="text-sm text-green-700 dark:text-green-300 break-words">
|
||||
✓ File loaded: {fileDataSummary.format} ({fileDataSummary.size.toLocaleString()} chars, {fileDataSummary.rows} rows) - {fileDataSummary.filename}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setFileDataSummary(null)}
|
||||
className="text-sm text-blue-600 dark:text-blue-400 hover:underline whitespace-nowrap"
|
||||
>
|
||||
Upload New File ▼
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={useFirstRowAsHeader}
|
||||
onChange={(e) => setUseFirstRowAsHeader(e.target.checked)}
|
||||
type="file"
|
||||
accept=".csv,.tsv,.json,.sql"
|
||||
onChange={handleFileUpload}
|
||||
className="tool-input"
|
||||
/>
|
||||
<label className="flex items-center text-sm text-gray-600 dark:text-gray-400">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={useFirstRowAsHeader}
|
||||
onChange={(e) => setUseFirstRowAsHeader(e.target.checked)}
|
||||
className="mr-2"
|
||||
/>
|
||||
Use first row as column headers (for CSV/TSV)
|
||||
@@ -2115,7 +2167,8 @@ const TableEditor = () => {
|
||||
open, edit, and export your files locally.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
@@ -2288,7 +2341,7 @@ const TableEditor = () => {
|
||||
style={{ maxWidth: '100%' }}
|
||||
>
|
||||
<table className="w-full">
|
||||
<thead className="bg-gray-50 dark:bg-gray-700 sticky top-0 z-10">
|
||||
<thead className="bg-gray-50 dark:bg-gray-700 sticky top-[-1px] z-10">
|
||||
<tr>
|
||||
<th
|
||||
className={`px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider border-r border-gray-200 dark:border-gray-600 ${
|
||||
@@ -2663,10 +2716,11 @@ const TableEditor = () => {
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 overflow-hidden mt-6">
|
||||
{/* Export Header - Collapsible */}
|
||||
<div
|
||||
ref={exportCardRef}
|
||||
onClick={() => setExportExpanded(!exportExpanded)}
|
||||
className="px-4 py-3 border-b border-gray-200 dark:border-gray-700 cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white flex items-center gap-2">
|
||||
<Download className="h-5 w-5 text-green-600 dark:text-green-400" />
|
||||
Export Results
|
||||
@@ -2744,12 +2798,14 @@ const TableEditor = () => {
|
||||
<div className="p-4">
|
||||
{exportTab === "json" && (
|
||||
<div className="space-y-3">
|
||||
<CodeEditor
|
||||
<CodeMirrorEditor
|
||||
value={getExportData("json")}
|
||||
language="json"
|
||||
readOnly={true}
|
||||
height="256px"
|
||||
theme={isDark ? 'dark' : 'light'}
|
||||
maxLines={12}
|
||||
showToggle={true}
|
||||
className="w-full"
|
||||
cardRef={exportCardRef}
|
||||
/>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -2776,22 +2832,28 @@ const TableEditor = () => {
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => copyToClipboard(getExportData("json"))}
|
||||
onClick={() => {
|
||||
copyToClipboard(getExportData("json"));
|
||||
setCopiedButton('json');
|
||||
setTimeout(() => setCopiedButton(null), 2000);
|
||||
}}
|
||||
className="px-3 py-1 text-sm bg-gray-100 hover:bg-gray-200 dark:bg-gray-600 dark:hover:bg-gray-500 text-gray-700 dark:text-gray-200 rounded transition-colors"
|
||||
>
|
||||
Copy
|
||||
{copiedButton === 'json' ? '✓ Copied!' : 'Copy'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() =>
|
||||
onClick={() => {
|
||||
downloadFile(
|
||||
getExportData("json"),
|
||||
"table-data.json",
|
||||
"application/json",
|
||||
)
|
||||
}
|
||||
);
|
||||
setDownloadedButton('json');
|
||||
setTimeout(() => setDownloadedButton(null), 2000);
|
||||
}}
|
||||
className="px-3 py-1 text-sm bg-blue-600 hover:bg-blue-700 dark:bg-blue-600 dark:hover:bg-blue-700 text-white rounded transition-colors"
|
||||
>
|
||||
Download
|
||||
{downloadedButton === 'json' ? '✓ Downloaded!' : 'Download'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -2800,31 +2862,39 @@ const TableEditor = () => {
|
||||
|
||||
{exportTab === "csv" && (
|
||||
<div className="space-y-3">
|
||||
<CodeEditor
|
||||
<CodeMirrorEditor
|
||||
value={getExportData("csv")}
|
||||
language="javascript"
|
||||
readOnly={true}
|
||||
height="256px"
|
||||
theme={isDark ? 'dark' : 'light'}
|
||||
maxLines={12}
|
||||
showToggle={true}
|
||||
className="w-full"
|
||||
cardRef={exportCardRef}
|
||||
/>
|
||||
<div className="flex justify-end gap-2">
|
||||
<button
|
||||
onClick={() => copyToClipboard(getExportData("csv"))}
|
||||
onClick={() => {
|
||||
copyToClipboard(getExportData("csv"));
|
||||
setCopiedButton('csv');
|
||||
setTimeout(() => setCopiedButton(null), 2000);
|
||||
}}
|
||||
className="px-3 py-1 text-sm bg-gray-100 hover:bg-gray-200 dark:bg-gray-600 dark:hover:bg-gray-500 text-gray-700 dark:text-gray-200 rounded transition-colors"
|
||||
>
|
||||
Copy
|
||||
{copiedButton === 'csv' ? '✓ Copied!' : 'Copy'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() =>
|
||||
onClick={() => {
|
||||
downloadFile(
|
||||
getExportData("csv"),
|
||||
"table-data.csv",
|
||||
"text/csv",
|
||||
)
|
||||
}
|
||||
);
|
||||
setDownloadedButton('csv');
|
||||
setTimeout(() => setDownloadedButton(null), 2000);
|
||||
}}
|
||||
className="px-3 py-1 text-sm bg-blue-600 hover:bg-blue-700 dark:bg-blue-600 dark:hover:bg-blue-700 text-white rounded transition-colors"
|
||||
>
|
||||
Download
|
||||
{downloadedButton === 'csv' ? '✓ Downloaded!' : 'Download'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -2832,31 +2902,39 @@ const TableEditor = () => {
|
||||
|
||||
{exportTab === "tsv" && (
|
||||
<div className="space-y-3">
|
||||
<CodeEditor
|
||||
<CodeMirrorEditor
|
||||
value={getExportData("tsv")}
|
||||
language="javascript"
|
||||
readOnly={true}
|
||||
height="256px"
|
||||
theme={isDark ? 'dark' : 'light'}
|
||||
maxLines={12}
|
||||
showToggle={true}
|
||||
className="w-full"
|
||||
cardRef={exportCardRef}
|
||||
/>
|
||||
<div className="flex justify-end gap-2">
|
||||
<button
|
||||
onClick={() => copyToClipboard(getExportData("tsv"))}
|
||||
onClick={() => {
|
||||
copyToClipboard(getExportData("tsv"));
|
||||
setCopiedButton('tsv');
|
||||
setTimeout(() => setCopiedButton(null), 2000);
|
||||
}}
|
||||
className="px-3 py-1 text-sm bg-gray-100 hover:bg-gray-200 dark:bg-gray-600 dark:hover:bg-gray-500 text-gray-700 dark:text-gray-200 rounded transition-colors"
|
||||
>
|
||||
Copy
|
||||
{copiedButton === 'tsv' ? '✓ Copied!' : 'Copy'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() =>
|
||||
onClick={() => {
|
||||
downloadFile(
|
||||
getExportData("tsv"),
|
||||
"table-data.tsv",
|
||||
"text/tab-separated-values",
|
||||
)
|
||||
}
|
||||
);
|
||||
setDownloadedButton('tsv');
|
||||
setTimeout(() => setDownloadedButton(null), 2000);
|
||||
}}
|
||||
className="px-3 py-1 text-sm bg-blue-600 hover:bg-blue-700 dark:bg-blue-600 dark:hover:bg-blue-700 text-white rounded transition-colors"
|
||||
>
|
||||
Download
|
||||
{downloadedButton === 'tsv' ? '✓ Downloaded!' : 'Download'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -2907,12 +2985,14 @@ const TableEditor = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<CodeEditor
|
||||
<CodeMirrorEditor
|
||||
value={getExportData("sql")}
|
||||
language="javascript"
|
||||
language="sql"
|
||||
readOnly={true}
|
||||
height="256px"
|
||||
theme={isDark ? 'dark' : 'light'}
|
||||
maxLines={12}
|
||||
showToggle={true}
|
||||
className="w-full"
|
||||
cardRef={exportCardRef}
|
||||
/>
|
||||
|
||||
{/* Intelligent Schema Analysis */}
|
||||
@@ -3046,22 +3126,28 @@ const TableEditor = () => {
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<button
|
||||
onClick={() => copyToClipboard(getExportData("sql"))}
|
||||
onClick={() => {
|
||||
copyToClipboard(getExportData("sql"));
|
||||
setCopiedButton('sql');
|
||||
setTimeout(() => setCopiedButton(null), 2000);
|
||||
}}
|
||||
className="px-3 py-1 text-sm bg-gray-100 hover:bg-gray-200 dark:bg-gray-600 dark:hover:bg-gray-500 text-gray-700 dark:text-gray-200 rounded transition-colors"
|
||||
>
|
||||
Copy
|
||||
{copiedButton === 'sql' ? '✓ Copied!' : 'Copy'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() =>
|
||||
onClick={() => {
|
||||
downloadFile(
|
||||
getExportData("sql"),
|
||||
`${sqlTableName || currentTable || originalFileName || "database"}.sql`,
|
||||
`${sqlTableName || "database"}.sql`,
|
||||
"application/sql",
|
||||
)
|
||||
}
|
||||
);
|
||||
setDownloadedButton('sql');
|
||||
setTimeout(() => setDownloadedButton(null), 2000);
|
||||
}}
|
||||
className="px-3 py-1 text-sm bg-blue-600 hover:bg-blue-700 dark:bg-blue-600 dark:hover:bg-blue-700 text-white rounded transition-colors"
|
||||
>
|
||||
Download
|
||||
{downloadedButton === 'sql' ? '✓ Downloaded!' : 'Download'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -3570,10 +3656,26 @@ const ObjectEditorModal = ({ modal, onClose, onApply }) => {
|
||||
Row {modal.rowIndex} • Column: {modal.columnName} • Format:{" "}
|
||||
{modal.format.type.replace("_", " ")}
|
||||
</p>
|
||||
{/* Format info */}
|
||||
<div className="text-sm">
|
||||
<span
|
||||
className={`${isValid ? "text-green-600 dark:text-green-400" : "text-red-600 dark:text-red-400"}`}
|
||||
>
|
||||
{isValid ? "✓ Valid" : "✗ Invalid"}{" "}
|
||||
{modal.format.type.replace("_", " ")}
|
||||
</span>
|
||||
{isValid &&
|
||||
structuredData &&
|
||||
typeof structuredData === "object" && (
|
||||
<span className="text-gray-600 dark:text-gray-400">
|
||||
{" • "}{Object.keys(structuredData).length} properties
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
|
||||
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 self-start"
|
||||
>
|
||||
<X className="h-6 w-6" />
|
||||
</button>
|
||||
@@ -3633,23 +3735,11 @@ const ObjectEditorModal = ({ modal, onClose, onApply }) => {
|
||||
|
||||
{/* Footer */}
|
||||
<div className="px-6 py-4 border-t border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-700 flex-shrink-0">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-4">
|
||||
<span
|
||||
className={`text-sm ${isValid ? "text-green-600" : "text-red-600"}`}
|
||||
>
|
||||
{isValid ? "✓ Valid" : "✗ Invalid"}{" "}
|
||||
{modal.format.type.replace("_", " ")}
|
||||
</span>
|
||||
{isValid &&
|
||||
structuredData &&
|
||||
typeof structuredData === "object" && (
|
||||
<span className="text-sm text-gray-600 dark:text-gray-400">
|
||||
{Object.keys(structuredData).length} properties
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex space-x-3">
|
||||
<div className="flex flex-col gap-3">
|
||||
|
||||
|
||||
{/* Buttons */}
|
||||
<div className="flex justify-end space-x-3">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-600 border border-gray-300 dark:border-gray-500 rounded-md hover:bg-gray-50 dark:hover:bg-gray-500 transition-colors"
|
||||
@@ -3661,7 +3751,7 @@ const ObjectEditorModal = ({ modal, onClose, onApply }) => {
|
||||
disabled={!isValid}
|
||||
className="px-4 py-2 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 disabled:bg-gray-400 rounded-md transition-colors"
|
||||
>
|
||||
Apply Changes
|
||||
Save Changes
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user