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:
dwindown
2025-10-15 22:40:57 +07:00
parent f6c19e855d
commit df0fb5d22a
9 changed files with 929 additions and 600 deletions

15
package-lock.json generated
View File

@@ -14,6 +14,7 @@
"@codemirror/lang-html": "^6.4.9", "@codemirror/lang-html": "^6.4.9",
"@codemirror/lang-javascript": "^6.2.4", "@codemirror/lang-javascript": "^6.2.4",
"@codemirror/lang-json": "^6.0.2", "@codemirror/lang-json": "^6.0.2",
"@codemirror/lang-sql": "^6.10.0",
"@codemirror/search": "^6.5.11", "@codemirror/search": "^6.5.11",
"@codemirror/state": "^6.5.2", "@codemirror/state": "^6.5.2",
"@codemirror/theme-one-dark": "^6.1.3", "@codemirror/theme-one-dark": "^6.1.3",
@@ -2174,6 +2175,20 @@
"@lezer/json": "^1.0.0" "@lezer/json": "^1.0.0"
} }
}, },
"node_modules/@codemirror/lang-sql": {
"version": "6.10.0",
"resolved": "https://registry.npmjs.org/@codemirror/lang-sql/-/lang-sql-6.10.0.tgz",
"integrity": "sha512-6ayPkEd/yRw0XKBx5uAiToSgGECo/GY2NoJIHXIIQh1EVwLuKoU8BP/qK0qH5NLXAbtJRLuT73hx7P9X34iO4w==",
"license": "MIT",
"dependencies": {
"@codemirror/autocomplete": "^6.0.0",
"@codemirror/language": "^6.0.0",
"@codemirror/state": "^6.0.0",
"@lezer/common": "^1.2.0",
"@lezer/highlight": "^1.0.0",
"@lezer/lr": "^1.0.0"
}
},
"node_modules/@codemirror/language": { "node_modules/@codemirror/language": {
"version": "6.11.3", "version": "6.11.3",
"license": "MIT", "license": "MIT",

View File

@@ -10,6 +10,7 @@
"@codemirror/lang-html": "^6.4.9", "@codemirror/lang-html": "^6.4.9",
"@codemirror/lang-javascript": "^6.2.4", "@codemirror/lang-javascript": "^6.2.4",
"@codemirror/lang-json": "^6.0.2", "@codemirror/lang-json": "^6.0.2",
"@codemirror/lang-sql": "^6.10.0",
"@codemirror/search": "^6.5.11", "@codemirror/search": "^6.5.11",
"@codemirror/state": "^6.5.2", "@codemirror/state": "^6.5.2",
"@codemirror/theme-one-dark": "^6.1.3", "@codemirror/theme-one-dark": "^6.1.3",

View File

@@ -1,5 +1,16 @@
{ {
"changelog": [ "changelog": [
{
"date": "2025-10-15",
"changes": [
{
"datetime": "2025-10-15T22:32:00+07:00",
"type": "enhancement",
"title": "Object Editor Preview Mode & Mobile Optimizations",
"description": "Added Preview/Edit mode toggle to Object Editor's Tree View with read-only preview mode for better data visibility. Implemented clickable nested data values in preview mode - no need to switch to edit mode to explore JSON/serialized structures. Fixed mobile responsiveness for all data load notices across Object and Table editors. Improved Table Editor sticky header positioning and export section layout on mobile. Moved horizontal overflow handling from ObjectEditor to StructuredEditor component for consistent behavior in main view and nested modals. Updated TableEditor object modal footer layout and changed 'Apply Changes' to 'Save Changes' for UX consistency."
}
]
},
{ {
"date": "2025-10-14", "date": "2025-10-14",
"changes": [ "changes": [

View File

@@ -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 { EditorView } from '@codemirror/view';
import { EditorState } from '@codemirror/state'; import { EditorState } from '@codemirror/state';
import { basicSetup } from 'codemirror'; import { basicSetup } from 'codemirror';
import { json } from '@codemirror/lang-json'; import { json } from '@codemirror/lang-json';
import { sql } from '@codemirror/lang-sql';
import { oneDark } from '@codemirror/theme-one-dark'; import { oneDark } from '@codemirror/theme-one-dark';
import { Maximize2, Minimize2 } from 'lucide-react'; import { Maximize2, Minimize2 } from 'lucide-react';
@@ -13,7 +14,8 @@ const CodeMirrorEditor = ({
className = '', className = '',
language = 'json', language = 'json',
maxLines = 12, maxLines = 12,
showToggle = true showToggle = true,
cardRef = null // Reference to the card header for scroll target
}) => { }) => {
const editorRef = useRef(null); const editorRef = useRef(null);
const viewRef = useRef(null); const viewRef = useRef(null);
@@ -38,13 +40,26 @@ const CodeMirrorEditor = ({
return () => observer.disconnect(); return () => observer.disconnect();
}, []); }, []);
// Detect if content is single-line (minified)
const isSingleLine = (value || '').split('\n').length === 1;
// Initialize editor only once // Initialize editor only once
useEffect(() => { useEffect(() => {
if (!editorRef.current || viewRef.current) return; if (!editorRef.current || viewRef.current) return;
// Language extension
let langExtension = [];
if (language === 'json') {
langExtension = [json()];
} else if (language === 'sql') {
langExtension = [sql()];
}
const extensions = [ const extensions = [
basicSetup, basicSetup,
language === 'json' ? json() : [], ...langExtension,
// Enable line wrapping for single-line content
...(isSingleLine ? [EditorView.lineWrapping] : []),
EditorView.theme({ EditorView.theme({
'&': { '&': {
fontSize: '14px', fontSize: '14px',
@@ -58,6 +73,13 @@ const CodeMirrorEditor = ({
}, },
'.cm-editor': { '.cm-editor': {
borderRadius: '6px', borderRadius: '6px',
},
'.cm-scroller': {
overflowY: 'auto',
height: '100%',
},
'.cm-line': {
wordBreak: isSingleLine ? 'break-word' : 'normal',
} }
}), }),
EditorView.updateListener.of((update) => { EditorView.updateListener.of((update) => {
@@ -80,6 +102,25 @@ const CodeMirrorEditor = ({
viewRef.current = view; 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 () => { return () => {
if (viewRef.current) { if (viewRef.current) {
viewRef.current.destroy(); viewRef.current.destroy();
@@ -87,13 +128,15 @@ const CodeMirrorEditor = ({
} }
}; };
// eslint-disable-next-line react-hooks/exhaustive-deps // 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 // Apply overflow and height styles
useEffect(() => { const applyEditorStyles = useCallback(() => {
if (!viewRef.current) return; 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 (editorElement) {
if (isExpanded) { if (isExpanded) {
editorElement.style.height = 'auto'; editorElement.style.height = 'auto';
@@ -103,8 +146,19 @@ const CodeMirrorEditor = ({
editorElement.style.maxHeight = '350px'; editorElement.style.maxHeight = '350px';
} }
} }
// Handle scrolling
if (scrollerElement) {
scrollerElement.style.overflowY = isExpanded ? 'visible' : 'auto';
scrollerElement.style.height = '100%';
}
}, [isExpanded]); }, [isExpanded]);
// Apply styles on mount, expand/collapse, and content changes
useEffect(() => {
applyEditorStyles();
}, [applyEditorStyles]);
// Update content when value changes externally // Update content when value changes externally
useEffect(() => { useEffect(() => {
if (viewRef.current && value !== viewRef.current.state.doc.toString()) { if (viewRef.current && value !== viewRef.current.state.doc.toString()) {
@@ -116,8 +170,11 @@ const CodeMirrorEditor = ({
} }
}); });
viewRef.current.dispatch(transaction); viewRef.current.dispatch(transaction);
// Re-apply styles after content change (e.g., tab switch)
setTimeout(() => applyEditorStyles(), 10);
} }
}, [value]); }, [value, applyEditorStyles]);
return ( return (
<div className={`relative ${className}`}> <div className={`relative ${className}`}>
@@ -132,7 +189,12 @@ const CodeMirrorEditor = ({
onClick={() => { onClick={() => {
setIsExpanded(!isExpanded); setIsExpanded(!isExpanded);
setTimeout(() => { 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); }, 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" 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"

View File

@@ -1,13 +1,17 @@
import React, { useState, useEffect, useRef } from 'react'; 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 [data, setData] = useState(initialData);
const [expandedNodes, setExpandedNodes] = useState(new Set(['root'])); const [expandedNodes, setExpandedNodes] = useState(new Set(['root']));
const [fieldTypes, setFieldTypes] = useState({}); // Track intended types for fields const [fieldTypes, setFieldTypes] = useState({}); // Track intended types for fields
const isInternalUpdate = useRef(false); const isInternalUpdate = useRef(false);
const [nestedEditModal, setNestedEditModal] = useState(null); // { path, value, type: 'json' | 'serialized' } const [nestedEditModal, setNestedEditModal] = useState(null); // { path, value, type: 'json' | 'serialized' }
const [nestedData, setNestedData] = useState(null); 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) // Update internal data when initialData prop changes (but not from internal updates)
useEffect(() => { useEffect(() => {
@@ -533,7 +537,7 @@ const StructuredEditor = ({ onDataChange, initialData = {} }) => {
{canExpand && ( {canExpand && (
<button <button
onClick={() => toggleNode(path)} 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 ? ( {isExpanded ? (
<ChevronDown className="h-4 w-4" /> <ChevronDown className="h-4 w-4" />
@@ -558,74 +562,106 @@ const StructuredEditor = ({ onDataChange, initialData = {} }) => {
// Object properties: icon + editable key + colon (compact) // Object properties: icon + editable key + colon (compact)
<> <>
{getTypeIcon(value)} {getTypeIcon(value)}
<input {readOnly ? (
type="text" <span className="px-2 py-1 text-sm text-gray-900 dark:text-gray-100 font-mono">
defaultValue={key} {key}
onBlur={(e) => { </span>
const newKey = e.target.value.trim(); ) : (
if (newKey && newKey !== key) { <input
renameKey(key, newKey, path); type="text"
} defaultValue={key}
}} onBlur={(e) => {
onKeyDown={(e) => { const newKey = e.target.value.trim();
if (e.key === 'Enter') { if (newKey && newKey !== key) {
e.target.blur(); // Trigger blur to save changes renameKey(key, newKey, path);
} }
}} }}
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" onKeyDown={(e) => {
placeholder="Property name" if (e.key === 'Enter') {
style={{width: '120px'}} // Fixed width for consistency e.target.blur(); // Trigger blur to save changes
/> }
<span className="text-gray-500 hidden sm:inline">:</span> }}
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 ? ( {!canExpand ? (
typeof value === 'boolean' ? ( typeof value === 'boolean' ? (
<div className="flex-1 flex items-center space-x-2"> <div className="flex-1 flex items-center space-x-2">
<button {readOnly ? (
onClick={() => updateValue((!value).toString(), path)} <span className="text-sm text-gray-900 dark:text-gray-100 font-mono">
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.toString()}
value ? 'bg-blue-600' : 'bg-gray-200 dark:bg-gray-600' </span>
}`} ) : (
> <>
<span <button
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${ onClick={() => updateValue((!value).toString(), path)}
value ? 'translate-x-6' : 'translate-x-1' 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'
/> }`}
</button> >
<span className="text-sm text-gray-600 dark:text-gray-400 font-mono"> <span
{value.toString()} className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
</span> 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>
) : ( ) : (
<div className="flex-1 flex items-center gap-2"> <div className="flex-1 flex items-center gap-2">
{(fieldTypes[path] === 'longtext' || (typeof value === 'string' && value.includes('\n'))) ? ( {readOnly ? (
<textarea typeof value === 'string' && detectNestedData(value) ? (
value={getDisplayValue(value)} <span
onChange={(e) => updateValue(e.target.value, path)} onClick={() => openNestedEditor(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" 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"
placeholder="Long text value" title={`Click to view nested ${detectNestedData(value).type} data`}
rows={3} >
/> {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" {(fieldTypes[path] === 'longtext' || (typeof value === 'string' && value.includes('\n'))) ? (
value={getDisplayValue(value)} <textarea
onChange={(e) => updateValue(e.target.value, path)} value={getDisplayValue(value)}
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" onChange={(e) => updateValue(e.target.value, path)}
placeholder="Value" 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}
{typeof value === 'string' && detectNestedData(value) && ( />
<button ) : (
onClick={() => openNestedEditor(value, path)} <input
className="p-1 text-blue-600 hover:bg-blue-100 dark:hover:bg-blue-900 rounded flex-shrink-0" type="text"
title={`Edit nested ${detectNestedData(value).type} data`} value={getDisplayValue(value)}
> onChange={(e) => updateValue(e.target.value, path)}
<Edit3 className="h-4 w-4" /> 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"
</button> 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> </div>
) )
@@ -635,38 +671,40 @@ const StructuredEditor = ({ onDataChange, initialData = {} }) => {
</span> </span>
)} )}
<div className="flex items-center space-x-2 sm:space-x-2"> {!readOnly && (
<select <div className="flex items-center space-x-2 sm:space-x-2">
value={ <select
fieldTypes[path] || ( value={
value === null ? 'null' : fieldTypes[path] || (
value === undefined ? 'string' : value === null ? 'null' :
typeof value === 'string' ? (value.includes('\n') ? 'longtext' : 'string') : value === undefined ? 'string' :
typeof value === 'number' ? 'number' : typeof value === 'string' ? (value.includes('\n') ? 'longtext' : 'string') :
typeof value === 'boolean' ? 'boolean' : typeof value === 'number' ? 'number' :
Array.isArray(value) ? 'array' : 'object' 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" 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="string">String</option>
<option value="number">Number</option> <option value="longtext">Long Text</option>
<option value="boolean">Boolean</option> <option value="number">Number</option>
<option value="array">Array</option> <option value="boolean">Boolean</option>
<option value="object">Object</option> <option value="array">Array</option>
<option value="null">Null</option> <option value="object">Object</option>
</select> <option value="null">Null</option>
</select>
<button
onClick={() => removeProperty(key, parentPath)} <button
className="p-1 text-red-600 hover:bg-red-100 dark:hover:bg-red-900 rounded flex-shrink-0" onClick={() => removeProperty(key, parentPath)}
title="Remove property" 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> <Minus className="h-4 w-4" />
</div> </button>
</div>
)}
</div> </div>
</div> </div>
@@ -677,26 +715,30 @@ const StructuredEditor = ({ onDataChange, initialData = {} }) => {
{value.map((item, index) => {value.map((item, index) =>
renderValue(item, index.toString(), `${path}.${index}`, path) renderValue(item, index.toString(), `${path}.${index}`, path)
)} )}
<button {!readOnly && (
onClick={() => addArrayItem(value, path)} <button
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" 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> <Plus className="h-4 w-4" />
</button> <span>Add Item</span>
</button>
)}
</> </>
) : ( ) : (
<> <>
{Object.entries(value).map(([k, v]) => {Object.entries(value).map(([k, v]) =>
renderValue(v, k, `${path}.${k}`, path) renderValue(v, k, `${path}.${k}`, path)
)} )}
<button {!readOnly && (
onClick={() => addProperty(value, path)} <button
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" 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> <Plus className="h-4 w-4" />
</button> <span>Add Property</span>
</button>
)}
</> </>
)} )}
</div> </div>
@@ -708,29 +750,65 @@ const StructuredEditor = ({ onDataChange, initialData = {} }) => {
return ( return (
<div className="min-h-96 w-full"> <div className="min-h-96 w-full">
<div className="mb-4"> <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>
<div className="overflow-x-hidden"> <div className="w-full overflow-hidden">
{Object.keys(data).length === 0 ? ( <div className="w-full overflow-x-auto">
<div className="text-center text-gray-500 dark:text-gray-400 py-8"> <div className="min-w-max">
<Braces className="h-12 w-12 mx-auto mb-2 opacity-50" /> {Object.keys(data).length === 0 ? (
<p>No properties yet. Click "Add Property" to start building your data structure.</p> <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> </div>
) : ( </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 */} {/* Nested Data Editor Modal */}
@@ -749,7 +827,7 @@ const StructuredEditor = ({ onDataChange, initialData = {} }) => {
</div> </div>
<button <button
onClick={closeNestedEditor} 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" /> <X className="h-5 w-5" />
</button> </button>

View File

@@ -50,9 +50,9 @@ const Home = () => {
<span className="animate-pulse">_</span> <span className="animate-pulse">_</span>
</div> </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"> <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} {SITE_CONFIG.title}
</h1> </h1>
<p className="text-xl md:text-2xl text-slate-600 dark:text-slate-300 mb-4 max-w-3xl mx-auto leading-relaxed"> <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} {SITE_CONFIG.subtitle}
@@ -123,101 +123,102 @@ const Home = () => {
</div> </div>
</Link> </Link>
</div> </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>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-8"> {/* Tools Grid */}
<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={`transition-all duration-1000 delay-300 ${mounted ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-8'}`}>
<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"> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8 mb-20">
<Zap className="h-8 w-8 text-white" /> {filteredTools.map((tool, index) => (
</div> <div
<h3 className="text-xl font-bold text-slate-800 dark:text-white mb-3"> key={index}
Lightning Fast className={`transition-all duration-500 ${mounted ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-8'}`}
</h3> style={{ transitionDelay: `${400 + index * 100}ms` }}
<p className="text-slate-600 dark:text-slate-300 leading-relaxed"> >
Optimized algorithms and local processing ensure instant results <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> </p>
</div> </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"> {/* Features Section */}
<Shield className="h-8 w-8 text-white" /> <div className={`py-20 transition-all duration-1000 delay-700 ${mounted ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-8'}`}>
</div> <div className="text-center mb-16">
<h3 className="text-xl font-bold text-slate-800 dark:text-white mb-3"> <h2 className="text-3xl md:text-4xl font-bold text-slate-800 dark:text-white mb-4">
Privacy First Built for Developers
</h3> </h2>
<p className="text-slate-600 dark:text-slate-300 leading-relaxed"> <p className="text-xl text-slate-600 dark:text-slate-300 max-w-2xl mx-auto">
Your data never leaves your browser. Zero tracking, zero storage Every tool is crafted with developer experience and performance in mind
</p> </p>
</div> </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="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-8">
<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"> <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">
<Cpu className="h-8 w-8 text-white" /> <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> </div>
<h3 className="text-xl font-bold text-slate-800 dark:text-white mb-3">
No Limits <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">
</h3> <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">
<p className="text-slate-600 dark:text-slate-300 leading-relaxed"> <Shield className="h-8 w-8 text-white" />
Handle massive files and complex data without restrictions </div>
</p> <h3 className="text-xl font-bold text-slate-800 dark:text-white mb-3">
</div> Privacy First
</h3>
<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"> <p className="text-slate-600 dark:text-slate-300 leading-relaxed">
<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"> Your data never leaves your browser. Zero tracking, zero storage
<Code className="h-8 w-8 text-white" /> </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> </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> </div>
</div> </div>

View File

@@ -844,7 +844,7 @@ const InvoiceEditor = () => {
type="url" type="url"
value={url} value={url}
onChange={(e) => setUrl(e.target.value)} 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" className="tool-input w-full"
onKeyPress={(e) => e.key === 'Enter' && handleUrlFetch()} onKeyPress={(e) => e.key === 'Enter' && handleUrlFetch()}
/> />
@@ -858,7 +858,7 @@ const InvoiceEditor = () => {
</button> </button>
</div> </div>
<p className="text-xs text-gray-500 dark:text-gray-400"> <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> </p>
</div> </div>
)} )}

View File

@@ -4,33 +4,10 @@ import ToolLayout from '../components/ToolLayout';
import StructuredEditor from '../components/StructuredEditor'; import StructuredEditor from '../components/StructuredEditor';
import MindmapView from '../components/MindmapView'; import MindmapView from '../components/MindmapView';
import PostmanTable from '../components/PostmanTable'; import PostmanTable from '../components/PostmanTable';
import CodeEditor from '../components/CodeEditor';
import CodeMirrorEditor from '../components/CodeMirrorEditor'; 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 ObjectEditor = () => {
const isDark = useDarkMode(); const exportCardRef = useRef(null);
const [structuredData, setStructuredData] = useState({}); const [structuredData, setStructuredData] = useState({});
// Sync structured data to localStorage for navigation guard // Sync structured data to localStorage for navigation guard
@@ -58,9 +35,53 @@ const ObjectEditor = () => {
const fileInputRef = useRef(null); const fileInputRef = useRef(null);
const [pasteCollapsed, setPasteCollapsed] = useState(false); const [pasteCollapsed, setPasteCollapsed] = useState(false);
const [pasteDataSummary, setPasteDataSummary] = useState(null); const [pasteDataSummary, setPasteDataSummary] = useState(null);
const [urlDataSummary, setUrlDataSummary] = useState(null);
const [fileDataSummary, setFileDataSummary] = useState(null);
const [outputExpanded, setOutputExpanded] = useState(false); const [outputExpanded, setOutputExpanded] = useState(false);
const [usageTipsExpanded, setUsageTipsExpanded] = 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 // Helper function to check if user has data that would be lost
const hasUserData = () => { const hasUserData = () => {
return Object.keys(structuredData).length > 0; return Object.keys(structuredData).length > 0;
@@ -549,28 +570,38 @@ const ObjectEditor = () => {
// Handle file import (auto-load, same as Table/Invoice Editor) // Handle file import (auto-load, same as Table/Invoice Editor)
const handleFileImport = (event) => { const handleFileImport = (event) => {
const file = event.target.files[0]; const file = event.target.files[0];
if (file) { if (!file) return;
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);
}
};
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 // Fetch data from URL
const handleFetchData = async () => { const handleFetchData = async () => {
@@ -596,36 +627,38 @@ const ObjectEditor = () => {
} }
const contentType = response.headers.get('content-type'); const contentType = response.headers.get('content-type');
const text = await response.text();
let data;
if (!contentType || !contentType.includes('application/json')) { if (!contentType || !contentType.includes('application/json')) {
// Try to parse as JSON anyway, some APIs don't set correct content-type // Try to parse as JSON anyway, some APIs don't set correct content-type
const text = await response.text();
try { try {
const data = JSON.parse(text); data = JSON.parse(text);
setStructuredData(data);
generateOutputs(data);
setInputText(JSON.stringify(data, null, 2));
setInputFormat('JSON');
setInputValid(true);
setCreateNewCompleted(true);
} catch { } catch {
throw new Error('Response is not valid JSON. Content-Type: ' + (contentType || 'unknown')); throw new Error('Response is not valid JSON. Content-Type: ' + (contentType || 'unknown'));
} }
} else { } else {
const data = await response.json(); data = JSON.parse(text);
setStructuredData(data);
generateOutputs(data);
setInputText(JSON.stringify(data, null, 2));
setInputFormat('JSON');
setInputValid(true);
setCreateNewCompleted(true);
} }
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) { } catch (err) {
console.error('Fetch error:', err); console.error('Fetch error:', err);
if (err.name === 'TypeError' && err.message.includes('fetch')) { setError(`Failed to fetch data: ${err.message}`);
setError('Network error: Unable to fetch data. Check the URL and try again.'); setUrlDataSummary(null);
} else {
setError(`Fetch failed: ${err.message}`);
}
} finally { } finally {
setFetching(false); setFetching(false);
} }
@@ -779,43 +812,59 @@ const ObjectEditor = () => {
{/* URL Tab Content */} {/* URL Tab Content */}
{activeTab === 'url' && ( {activeTab === 'url' && (
<div className="space-y-3"> urlDataSummary ? (
<div className="flex gap-2"> <div className="p-4 bg-green-50 dark:bg-green-900/20 rounded-lg">
<div className="relative flex-1"> <div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
<input <span className="text-sm text-green-700 dark:text-green-300 break-words">
type="url" ✓ Data loaded: {urlDataSummary.format} ({urlDataSummary.size.toLocaleString()} chars, {urlDataSummary.properties} {urlDataSummary.properties === 1 ? 'property' : 'properties'})
value={fetchUrl} </span>
onChange={(e) => setFetchUrl(e.target.value)} <button
placeholder="https://api.telegram.org/bot<token>/getMe" onClick={() => setUrlDataSummary(null)}
className="tool-input w-full" className="text-sm text-blue-600 dark:text-blue-400 hover:underline whitespace-nowrap"
onKeyPress={(e) => e.key === 'Enter' && handleFetchData()} >
/> Fetch New URL ▼
</button>
</div> </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> </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. <div className="space-y-3">
</p> <div className="flex gap-2">
</div> <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 */} {/* Paste Tab Content */}
{activeTab === 'paste' && ( {activeTab === 'paste' && (
pasteCollapsed ? ( pasteCollapsed ? (
<div className="p-4 bg-green-50 dark:bg-green-900/20 rounded-lg"> <div className="p-4 bg-green-50 dark:bg-green-900/20 rounded-lg">
<div className="flex items-center justify-between"> <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"> <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'}) ✓ Object loaded: {pasteDataSummary.format} ({pasteDataSummary.size.toLocaleString()} chars, {pasteDataSummary.properties} {pasteDataSummary.properties === 1 ? 'property' : 'properties'})
</span> </span>
<button <button
onClick={() => setPasteCollapsed(false)} 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 ▼ Edit Input ▼
</button> </button>
@@ -868,20 +917,36 @@ const ObjectEditor = () => {
{/* Open Tab Content */} {/* Open Tab Content */}
{activeTab === 'open' && ( {activeTab === 'open' && (
<div className="space-y-3"> fileDataSummary ? (
<input <div className="p-4 bg-green-50 dark:bg-green-900/20 rounded-lg">
ref={fileInputRef} <div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
type="file" <span className="text-sm text-green-700 dark:text-green-300 break-words">
accept=".json,.txt" ✓ File loaded: {fileDataSummary.format} ({fileDataSummary.size.toLocaleString()} chars, {fileDataSummary.properties} {fileDataSummary.properties === 1 ? 'property' : 'properties'}) - {fileDataSummary.filename}
onChange={handleFileImport} </span>
className="tool-input" <button
/> onClick={() => setFileDataSummary(null)}
<div className="bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-md p-3"> className="text-sm text-blue-600 dark:text-blue-400 hover:underline whitespace-nowrap"
<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. Upload New File ▼
</p> </button>
</div>
</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> </div>
)} )}
@@ -900,7 +965,7 @@ const ObjectEditor = () => {
Object Editor Object Editor
</h3> </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"> <div className="flex border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
<button <button
onClick={() => setViewMode('visual')} onClick={() => setViewMode('visual')}
@@ -911,7 +976,7 @@ const ObjectEditor = () => {
}`} }`}
> >
<Edit3 className="h-4 w-4" /> <Edit3 className="h-4 w-4" />
<span className="hidden sm:inline">Visual Editor</span> <span className="hidden sm:inline">Tree</span>
</button> </button>
<button <button
onClick={() => setViewMode('mindmap')} onClick={() => setViewMode('mindmap')}
@@ -922,7 +987,7 @@ const ObjectEditor = () => {
}`} }`}
> >
<Workflow className="h-4 w-4" /> <Workflow className="h-4 w-4" />
<span className="hidden sm:inline">Mindmap View</span> <span className="hidden sm:inline">Mindmap</span>
</button> </button>
<button <button
onClick={() => setViewMode('table')} onClick={() => setViewMode('table')}
@@ -933,7 +998,7 @@ const ObjectEditor = () => {
}`} }`}
> >
<Table className="h-4 w-4" /> <Table className="h-4 w-4" />
<span className="hidden sm:inline">Table View</span> <span className="hidden sm:inline">Table</span>
</button> </button>
</div> </div>
</div> </div>
@@ -954,15 +1019,11 @@ const ObjectEditor = () => {
) : ( ) : (
<> <>
{viewMode === 'visual' && ( {viewMode === 'visual' && (
<div className="w-full overflow-hidden"> <div className="p-4">
<div className="w-full overflow-x-auto p-4"> <StructuredEditor
<div className="min-w-max"> initialData={structuredData}
<StructuredEditor onDataChange={handleStructuredDataChange}
initialData={structuredData} />
onDataChange={handleStructuredDataChange}
/>
</div>
</div>
</div> </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"> <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 */} {/* Export Header - Collapsible */}
<div <div
ref={exportCardRef}
onClick={() => setOutputExpanded(!outputExpanded)} 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" 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"> <div className="p-4">
{activeExportTab === 'json' && ( {activeExportTab === 'json' && (
<div className="space-y-3"> <div className="space-y-3">
<CodeEditor <CodeMirrorEditor
value={jsonFormat === 'pretty' ? (outputs.jsonPretty || '{}') : (outputs.jsonMinified || '{}')} value={jsonFormat === 'pretty' ? (outputs.jsonPretty || '{}') : (outputs.jsonMinified || '{}')}
language="json" language="json"
readOnly={true} readOnly={true}
height="300px" maxLines={12}
showToggle={true}
className="w-full" className="w-full"
theme={isDark ? 'dark' : 'light'} cardRef={exportCardRef}
/> />
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@@ -1070,26 +1133,27 @@ const ObjectEditor = () => {
<button <button
onClick={() => { onClick={() => {
const content = jsonFormat === 'pretty' ? (outputs.jsonPretty || '{}') : (outputs.jsonMinified || '{}'); 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" 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>
<button <button
onClick={() => { onClick={() => {
const content = jsonFormat === 'pretty' ? (outputs.jsonPretty || '{}') : (outputs.jsonMinified || '{}'); downloadFile(
const blob = new Blob([content], { type: 'application/json' }); getExportData("json"),
const url = URL.createObjectURL(blob); "object-data.json",
const a = document.createElement('a'); "application/json",
a.href = url; );
a.download = 'object-data.json'; setDownloadedButton('json');
a.click(); setTimeout(() => setDownloadedButton(null), 2000);
URL.revokeObjectURL(url);
}} }}
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" 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> </button>
</div> </div>
</div> </div>
@@ -1098,23 +1162,26 @@ const ObjectEditor = () => {
{activeExportTab === 'php' && ( {activeExportTab === 'php' && (
<div className="space-y-3"> <div className="space-y-3">
<CodeEditor <CodeMirrorEditor
value={outputs.serialized || 'a:0:{}'} value={outputs.serialized || 'a:0:{}'}
language="javascript" language="javascript"
readOnly={true} readOnly={true}
height="300px" maxLines={12}
showToggle={true}
className="w-full" className="w-full"
theme={isDark ? 'dark' : 'light'} cardRef={exportCardRef}
/> />
<div className="flex justify-end gap-2"> <div className="flex justify-end gap-2">
<button <button
onClick={() => { onClick={() => {
const content = outputs.serialized || 'a:0:{}'; 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" 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>
<button <button
onClick={() => { onClick={() => {
@@ -1124,12 +1191,16 @@ const ObjectEditor = () => {
const a = document.createElement('a'); const a = document.createElement('a');
a.href = url; a.href = url;
a.download = 'object-data.txt'; a.download = 'object-data.txt';
document.body.appendChild(a);
a.click(); a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url); 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" 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> </button>
</div> </div>
</div> </div>

View File

@@ -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 { 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 ToolLayout from '../components/ToolLayout';
import CodeEditor from '../components/CodeEditor';
import CodeMirrorEditor from '../components/CodeMirrorEditor'; import CodeMirrorEditor from '../components/CodeMirrorEditor';
import StructuredEditor from "../components/StructuredEditor"; import StructuredEditor from "../components/StructuredEditor";
import Papa from "papaparse"; 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 TableEditor = () => {
const isDark = useDarkMode(); const exportCardRef = useRef(null);
const [data, setData] = useState([]); const [data, setData] = useState([]);
const [columns, setColumns] = useState([]); const [columns, setColumns] = useState([]);
@@ -69,6 +46,8 @@ const TableEditor = () => {
const [createNewCompleted, setCreateNewCompleted] = useState(false); // Track if user completed Create New step const [createNewCompleted, setCreateNewCompleted] = useState(false); // Track if user completed Create New step
const [pasteCollapsed, setPasteCollapsed] = useState(false); // Track if paste input is collapsed const [pasteCollapsed, setPasteCollapsed] = useState(false); // Track if paste input is collapsed
const [pasteDataSummary, setPasteDataSummary] = useState(null); // Summary of pasted data 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 [exportExpanded, setExportExpanded] = useState(false); // Track if export section is expanded
const [usageTipsExpanded, setUsageTipsExpanded] = useState(false); // Track if usage tips 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 [sqlTableName, setSqlTableName] = useState(""); // Table name for SQL export
const [sqlPrimaryKey, setSqlPrimaryKey] = useState(""); // Primary key column 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 // Helper function to check if user has data that would be lost
const hasUserData = () => { const hasUserData = () => {
// Check if there are multiple tables (imported data) // Check if there are multiple tables (imported data)
@@ -650,55 +633,52 @@ const TableEditor = () => {
// Parse CSV/TSV data // Parse CSV/TSV data
const parseData = (text, hasHeaders = true) => { const parseData = (text, hasHeaders = true) => {
try { const result = Papa.parse(text.trim(), {
const result = Papa.parse(text.trim(), { header: false,
header: false, skipEmptyLines: true,
skipEmptyLines: true, delimiter: text.includes("\t") ? "\t" : ",",
delimiter: text.includes("\t") ? "\t" : ",", });
});
if (result.errors.length > 0) { if (result.errors.length > 0) {
throw new Error(result.errors[0].message); 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}`);
} }
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 // Parse SQL data
@@ -941,40 +921,37 @@ const TableEditor = () => {
// Parse JSON data // Parse JSON data
const parseJsonData = (text) => { const parseJsonData = (text) => {
try { const jsonData = JSON.parse(text);
const jsonData = JSON.parse(text);
if (!Array.isArray(jsonData)) { if (!Array.isArray(jsonData)) {
throw new Error("JSON must be an array of objects"); 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 (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 // Handle text input
@@ -987,15 +964,14 @@ const TableEditor = () => {
const trimmed = inputText.trim(); const trimmed = inputText.trim();
let format = ''; let format = '';
let success = false; let rowCount = 0;
try { try {
// Try to detect format // Try to detect format
if (trimmed.startsWith("[") && trimmed.endsWith("]")) { if (trimmed.startsWith("[") && trimmed.endsWith("]")) {
// JSON array // JSON array
parseJsonData(trimmed); rowCount = parseJsonData(trimmed);
format = 'JSON'; format = 'JSON';
success = true;
} else if ( } else if (
trimmed.toLowerCase().includes("insert into") && trimmed.toLowerCase().includes("insert into") &&
trimmed.toLowerCase().includes("values") trimmed.toLowerCase().includes("values")
@@ -1003,24 +979,25 @@ const TableEditor = () => {
// SQL INSERT statements // SQL INSERT statements
parseSqlData(trimmed); parseSqlData(trimmed);
format = 'SQL'; format = 'SQL';
success = true; // Get row count from state after parse
rowCount = data.length;
} else { } else {
// CSV/TSV // CSV/TSV
parseData(trimmed, useFirstRowAsHeader); parseData(trimmed, useFirstRowAsHeader);
format = trimmed.includes('\t') ? 'TSV' : 'CSV'; 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 // Collapse input and show summary
if (success && data.length > 0) { setPasteDataSummary({
setPasteDataSummary({ format: format,
format: format, size: inputText.length,
size: inputText.length, rows: rowCount || data.length // Use rowCount if available, fallback to data.length
rows: data.length });
}); setPasteCollapsed(true);
setPasteCollapsed(true); setCreateNewCompleted(true);
setError(''); setError('');
}
} catch (err) { } catch (err) {
// Keep input expanded on error // Keep input expanded on error
setPasteCollapsed(false); setPasteCollapsed(false);
@@ -1047,14 +1024,28 @@ const TableEditor = () => {
const contentType = response.headers.get("content-type") || ""; const contentType = response.headers.get("content-type") || "";
const text = await response.text(); const text = await response.text();
let format = '';
let rowCount = 0;
if (contentType.includes("application/json") || url.includes(".json")) { if (contentType.includes("application/json") || url.includes(".json")) {
parseJsonData(text); rowCount = parseJsonData(text);
format = 'JSON';
} else { } 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) { } catch (err) {
setError(`Failed to fetch data: ${err.message}`); setError(`Failed to fetch data: ${err.message}`);
setUrlDataSummary(null);
} finally { } finally {
setIsLoading(false); setIsLoading(false);
} }
@@ -1136,17 +1127,47 @@ const TableEditor = () => {
reader.onload = (e) => { reader.onload = (e) => {
try { try {
const content = e.target.result; const content = e.target.result;
let format = '';
let rowCount = 0;
// Check if it's SQL file for multi-table support // Check if it's SQL file for multi-table support
if (file.name.toLowerCase().endsWith(".sql")) { if (file.name.toLowerCase().endsWith(".sql")) {
initializeTablesFromSQL(content, file.name); 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 { } else {
// Fallback to single-table parsing rowCount = parseData(content, useFirstRowAsHeader);
parseData(content); format = file.name.toLowerCase().endsWith(".tsv") ? 'TSV' : 'CSV';
setFileDataSummary({
format: format,
size: content.length,
rows: rowCount,
filename: file.name
});
} }
setCreateNewCompleted(true);
} catch (err) { } catch (err) {
console.error("❌ File upload error:", err); console.error("❌ File upload error:", err);
setError("Failed to read file: " + err.message); setError("Failed to read file: " + err.message);
setFileDataSummary(null);
} finally { } finally {
setIsLoading(false); setIsLoading(false);
} }
@@ -1995,56 +2016,72 @@ const TableEditor = () => {
)} )}
{activeTab === "url" && ( {activeTab === "url" && (
<div className="space-y-3"> urlDataSummary ? (
<div className="flex gap-2"> <div className="p-4 bg-green-50 dark:bg-green-900/20 rounded-lg">
<div className="relative flex-1"> <div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
<input <span className="text-sm text-green-700 dark:text-green-300 break-words">
type="url" ✓ Data loaded: {urlDataSummary.format} ({urlDataSummary.size.toLocaleString()} chars, {urlDataSummary.rows} rows)
value={url} </span>
onChange={(e) => setUrl(e.target.value)} <button
placeholder="https://api.example.com/data.json or https://example.com/data.csv" onClick={() => setUrlDataSummary(null)}
className="tool-input w-full pr-10" className="text-sm text-blue-600 dark:text-blue-400 hover:underline whitespace-nowrap"
disabled={isLoading} >
/> Fetch New URL ▼
{url && !isLoading && ( </button>
<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> </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> </div>
<label className="flex items-center text-sm text-gray-600 dark:text-gray-400"> ) : (
<input <div className="space-y-3">
type="checkbox" <div className="flex gap-2">
checked={useFirstRowAsHeader} <div className="relative flex-1">
onChange={(e) => setUseFirstRowAsHeader(e.target.checked)} <input
className="mr-2" type="url"
/> value={url}
Use first row as column headers (for CSV/TSV) onChange={(e) => setUrl(e.target.value)}
</label> placeholder="https://api.example.com/data.json or https://example.com/data.csv"
</div> 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" && ( {activeTab === "paste" && (
pasteCollapsed ? ( pasteCollapsed ? (
<div className="p-4 bg-green-50 dark:bg-green-900/20 rounded-lg"> <div className="p-4 bg-green-50 dark:bg-green-900/20 rounded-lg">
<div className="flex items-center justify-between"> <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"> <span className="text-sm text-green-700 dark:text-green-300 break-words">
✓ Data loaded: {pasteDataSummary.format} ({pasteDataSummary.size.toLocaleString()} chars, {pasteDataSummary.rows} rows) ✓ Data loaded: {pasteDataSummary.format} ({pasteDataSummary.size.toLocaleString()} chars, {pasteDataSummary.rows} rows)
</span> </span>
<button <button
onClick={() => setPasteCollapsed(false)} 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 ▼ Edit Input ▼
</button> </button>
@@ -2092,18 +2129,33 @@ const TableEditor = () => {
)} )}
{activeTab === "upload" && ( {activeTab === "upload" && (
<div className="space-y-3"> fileDataSummary ? (
<input <div className="p-4 bg-green-50 dark:bg-green-900/20 rounded-lg">
type="file" <div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
accept=".csv,.tsv,.json,.sql" <span className="text-sm text-green-700 dark:text-green-300 break-words">
onChange={handleFileUpload} ✓ File loaded: {fileDataSummary.format} ({fileDataSummary.size.toLocaleString()} chars, {fileDataSummary.rows} rows) - {fileDataSummary.filename}
className="tool-input" </span>
/> <button
<label className="flex items-center text-sm text-gray-600 dark:text-gray-400"> 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 <input
type="checkbox" type="file"
checked={useFirstRowAsHeader} accept=".csv,.tsv,.json,.sql"
onChange={(e) => setUseFirstRowAsHeader(e.target.checked)} 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" className="mr-2"
/> />
Use first row as column headers (for CSV/TSV) Use first row as column headers (for CSV/TSV)
@@ -2115,7 +2167,8 @@ const TableEditor = () => {
open, edit, and export your files locally. open, edit, and export your files locally.
</p> </p>
</div> </div>
</div> </div>
)
)} )}
</div> </div>
)} )}
@@ -2288,7 +2341,7 @@ const TableEditor = () => {
style={{ maxWidth: '100%' }} style={{ maxWidth: '100%' }}
> >
<table className="w-full"> <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> <tr>
<th <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 ${ 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"> <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 */} {/* Export Header - Collapsible */}
<div <div
ref={exportCardRef}
onClick={() => setExportExpanded(!exportExpanded)} 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" 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"> <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" /> <Download className="h-5 w-5 text-green-600 dark:text-green-400" />
Export Results Export Results
@@ -2744,12 +2798,14 @@ const TableEditor = () => {
<div className="p-4"> <div className="p-4">
{exportTab === "json" && ( {exportTab === "json" && (
<div className="space-y-3"> <div className="space-y-3">
<CodeEditor <CodeMirrorEditor
value={getExportData("json")} value={getExportData("json")}
language="json" language="json"
readOnly={true} readOnly={true}
height="256px" maxLines={12}
theme={isDark ? 'dark' : 'light'} showToggle={true}
className="w-full"
cardRef={exportCardRef}
/> />
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@@ -2776,22 +2832,28 @@ const TableEditor = () => {
</div> </div>
<div className="flex gap-2"> <div className="flex gap-2">
<button <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" 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>
<button <button
onClick={() => onClick={() => {
downloadFile( downloadFile(
getExportData("json"), getExportData("json"),
"table-data.json", "table-data.json",
"application/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" 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> </button>
</div> </div>
</div> </div>
@@ -2800,31 +2862,39 @@ const TableEditor = () => {
{exportTab === "csv" && ( {exportTab === "csv" && (
<div className="space-y-3"> <div className="space-y-3">
<CodeEditor <CodeMirrorEditor
value={getExportData("csv")} value={getExportData("csv")}
language="javascript" language="javascript"
readOnly={true} readOnly={true}
height="256px" maxLines={12}
theme={isDark ? 'dark' : 'light'} showToggle={true}
className="w-full"
cardRef={exportCardRef}
/> />
<div className="flex justify-end gap-2"> <div className="flex justify-end gap-2">
<button <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" 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>
<button <button
onClick={() => onClick={() => {
downloadFile( downloadFile(
getExportData("csv"), getExportData("csv"),
"table-data.csv", "table-data.csv",
"text/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" 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> </button>
</div> </div>
</div> </div>
@@ -2832,31 +2902,39 @@ const TableEditor = () => {
{exportTab === "tsv" && ( {exportTab === "tsv" && (
<div className="space-y-3"> <div className="space-y-3">
<CodeEditor <CodeMirrorEditor
value={getExportData("tsv")} value={getExportData("tsv")}
language="javascript" language="javascript"
readOnly={true} readOnly={true}
height="256px" maxLines={12}
theme={isDark ? 'dark' : 'light'} showToggle={true}
className="w-full"
cardRef={exportCardRef}
/> />
<div className="flex justify-end gap-2"> <div className="flex justify-end gap-2">
<button <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" 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>
<button <button
onClick={() => onClick={() => {
downloadFile( downloadFile(
getExportData("tsv"), getExportData("tsv"),
"table-data.tsv", "table-data.tsv",
"text/tab-separated-values", "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" 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> </button>
</div> </div>
</div> </div>
@@ -2907,12 +2985,14 @@ const TableEditor = () => {
</div> </div>
</div> </div>
<CodeEditor <CodeMirrorEditor
value={getExportData("sql")} value={getExportData("sql")}
language="javascript" language="sql"
readOnly={true} readOnly={true}
height="256px" maxLines={12}
theme={isDark ? 'dark' : 'light'} showToggle={true}
className="w-full"
cardRef={exportCardRef}
/> />
{/* Intelligent Schema Analysis */} {/* Intelligent Schema Analysis */}
@@ -3046,22 +3126,28 @@ const TableEditor = () => {
<div className="flex justify-end gap-2"> <div className="flex justify-end gap-2">
<button <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" 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>
<button <button
onClick={() => onClick={() => {
downloadFile( downloadFile(
getExportData("sql"), getExportData("sql"),
`${sqlTableName || currentTable || originalFileName || "database"}.sql`, `${sqlTableName || "database"}.sql`,
"application/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" 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> </button>
</div> </div>
</div> </div>
@@ -3570,10 +3656,26 @@ const ObjectEditorModal = ({ modal, onClose, onApply }) => {
Row {modal.rowIndex} Column: {modal.columnName} Format:{" "} Row {modal.rowIndex} Column: {modal.columnName} Format:{" "}
{modal.format.type.replace("_", " ")} {modal.format.type.replace("_", " ")}
</p> </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> </div>
<button <button
onClick={onClose} 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" /> <X className="h-6 w-6" />
</button> </button>
@@ -3633,23 +3735,11 @@ const ObjectEditorModal = ({ modal, onClose, onApply }) => {
{/* Footer */} {/* 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="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 flex-col gap-3">
<div className="flex items-center space-x-4">
<span
className={`text-sm ${isValid ? "text-green-600" : "text-red-600"}`} {/* Buttons */}
> <div className="flex justify-end space-x-3">
{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">
<button <button
onClick={onClose} 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" 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} 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" 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> </button>
</div> </div>
</div> </div>