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

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 { 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"

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>
)}

View File

@@ -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>

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 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>