feat: comprehensive editor UX refinement with collapsible sections and data loss prevention
Major improvements to Object Editor, Table Editor, and Invoice Editor: ## UX Enhancements: - Made export sections collapsible across all editors to reduce page height - Added comprehensive, collapsible usage tips with eye-catching design - Implemented consistent input method patterns (file auto-load, inline URL buttons) - Paste sections now collapse after successful parsing with data summaries ## Data Loss Prevention: - Added confirmation modals when switching input methods with existing data - Amber-themed warning design with specific data summaries - Suggests saving before proceeding with destructive actions - Prevents accidental data loss across all editor tools ## Consistency Improvements: - Standardized file input styling with 'tool-input' class - URL fetch buttons now inline (not below input) across all editors - Parse buttons positioned consistently on bottom-right - Auto-load behavior for file inputs matching across editors ## Bug Fixes: - Fixed Table Editor cell text overflow with proper truncation - Fixed Object Editor file input to auto-load content - Removed unnecessary parse buttons and checkboxes - Fixed Invoice Editor URL form layout ## Documentation: - Created EDITOR_TOOL_GUIDE.md with comprehensive patterns - Created EDITOR_CHECKLIST.md for quick reference - Created PROJECT_ROADMAP.md with future plans - Created TODO.md with detailed task lists - Documented data loss prevention patterns - Added code examples and best practices All editors now follow consistent UX patterns with improved user experience and data protection.
This commit is contained in:
@@ -123,7 +123,7 @@ const CodeMirrorEditor = ({
|
||||
<div className={`relative ${className}`}>
|
||||
<div
|
||||
ref={editorRef}
|
||||
className={`dewedev-code-mirror border border-gray-300 dark:border-gray-600 rounded-md ${
|
||||
className={`dewedev-code-mirror border border-gray-300 dark:border-gray-600 rounded-md overflow-hidden ${
|
||||
isDark ? 'bg-gray-900' : 'bg-white'
|
||||
} ${isExpanded ? 'h-auto' : 'h-[350px]'}`}
|
||||
/>
|
||||
|
||||
@@ -454,7 +454,7 @@ const MindmapView = React.memo(({ data }) => {
|
||||
return (
|
||||
<div className={`w-full border border-gray-200 dark:border-gray-700 rounded-lg bg-white dark:bg-gray-800 ${
|
||||
isFullscreen
|
||||
? 'fixed inset-0 z-50 rounded-none'
|
||||
? 'fixed inset-0 z-[99999] rounded-none'
|
||||
: 'h-[600px]'
|
||||
}`}>
|
||||
<ReactFlow
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { Plus, Minus, ChevronDown, ChevronRight, Type, Hash, ToggleLeft, List, Braces } from 'lucide-react';
|
||||
import { Plus, Minus, ChevronDown, ChevronRight, Type, Hash, ToggleLeft, List, Braces, Edit3, X } from 'lucide-react';
|
||||
|
||||
const StructuredEditor = ({ onDataChange, initialData = {} }) => {
|
||||
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);
|
||||
|
||||
// Update internal data when initialData prop changes (but not from internal updates)
|
||||
useEffect(() => {
|
||||
@@ -28,6 +30,173 @@ const StructuredEditor = ({ onDataChange, initialData = {} }) => {
|
||||
onDataChange(newData);
|
||||
};
|
||||
|
||||
// PHP serialize/unserialize functions
|
||||
const phpSerialize = (data) => {
|
||||
if (data === null) return 'N;';
|
||||
if (typeof data === 'boolean') return data ? 'b:1;' : 'b:0;';
|
||||
if (typeof data === 'number') {
|
||||
return Number.isInteger(data) ? `i:${data};` : `d:${data};`;
|
||||
}
|
||||
if (typeof data === 'string') {
|
||||
const escapedData = data.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
||||
const byteLength = new TextEncoder().encode(escapedData).length;
|
||||
return `s:${byteLength}:"${escapedData}";`;
|
||||
}
|
||||
if (Array.isArray(data)) {
|
||||
let result = `a:${data.length}:{`;
|
||||
data.forEach((item, index) => {
|
||||
result += phpSerialize(index) + phpSerialize(item);
|
||||
});
|
||||
result += '}';
|
||||
return result;
|
||||
}
|
||||
if (typeof data === 'object') {
|
||||
const keys = Object.keys(data);
|
||||
let result = `a:${keys.length}:{`;
|
||||
keys.forEach(key => {
|
||||
result += phpSerialize(key) + phpSerialize(data[key]);
|
||||
});
|
||||
result += '}';
|
||||
return result;
|
||||
}
|
||||
return 'N;';
|
||||
};
|
||||
|
||||
const phpUnserialize = (str) => {
|
||||
let index = 0;
|
||||
const parseValue = () => {
|
||||
if (index >= str.length) throw new Error('Unexpected end of string');
|
||||
const type = str[index];
|
||||
if (type === 'N') {
|
||||
index += 2;
|
||||
return null;
|
||||
}
|
||||
if (str[index + 1] !== ':') throw new Error(`Expected ':' after type '${type}'`);
|
||||
index += 2;
|
||||
switch (type) {
|
||||
case 'b':
|
||||
const boolVal = str[index] === '1';
|
||||
index += 2;
|
||||
return boolVal;
|
||||
case 'i':
|
||||
let intStr = '';
|
||||
while (index < str.length && str[index] !== ';') intStr += str[index++];
|
||||
index++;
|
||||
return parseInt(intStr);
|
||||
case 'd':
|
||||
let floatStr = '';
|
||||
while (index < str.length && str[index] !== ';') floatStr += str[index++];
|
||||
index++;
|
||||
return parseFloat(floatStr);
|
||||
case 's':
|
||||
let lenStr = '';
|
||||
while (index < str.length && str[index] !== ':') lenStr += str[index++];
|
||||
index++;
|
||||
if (str[index] !== '"') throw new Error('Expected opening quote');
|
||||
index++;
|
||||
const byteLength = parseInt(lenStr);
|
||||
if (byteLength === 0) {
|
||||
index += 2;
|
||||
return '';
|
||||
}
|
||||
let endQuotePos = -1;
|
||||
for (let i = index; i < str.length - 1; i++) {
|
||||
if (str[i] === '"' && str[i + 1] === ';') {
|
||||
endQuotePos = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (endQuotePos === -1) throw new Error('Could not find closing quote');
|
||||
const strValue = str.substring(index, endQuotePos);
|
||||
index = endQuotePos + 2;
|
||||
return strValue.replace(/\\"/g, '"').replace(/\\\\/g, '\\');
|
||||
case 'a':
|
||||
let countStr = '';
|
||||
while (index < str.length && str[index] !== ':') countStr += str[index++];
|
||||
const count = parseInt(countStr);
|
||||
index += 2;
|
||||
const result = {};
|
||||
let isArray = true;
|
||||
for (let i = 0; i < count; i++) {
|
||||
const key = parseValue();
|
||||
const value = parseValue();
|
||||
result[key] = value;
|
||||
if (key !== i) isArray = false;
|
||||
}
|
||||
index++;
|
||||
return isArray ? Object.values(result) : result;
|
||||
default:
|
||||
throw new Error(`Unknown type: ${type}`);
|
||||
}
|
||||
};
|
||||
return parseValue();
|
||||
};
|
||||
|
||||
// Detect if a string contains JSON or serialized data
|
||||
const detectNestedData = (value) => {
|
||||
if (typeof value !== 'string' || value.length < 5) return null;
|
||||
|
||||
// Try JSON first
|
||||
try {
|
||||
const parsed = JSON.parse(value);
|
||||
if (typeof parsed === 'object' && parsed !== null) {
|
||||
return { type: 'json', data: parsed };
|
||||
}
|
||||
} catch (e) {
|
||||
// Not JSON, continue
|
||||
}
|
||||
|
||||
// Try PHP serialized
|
||||
try {
|
||||
// Check if it looks like PHP serialized format
|
||||
if (/^[abidsNO]:[^;]*;/.test(value) || /^a:\d+:\{/.test(value)) {
|
||||
const parsed = phpUnserialize(value);
|
||||
if (typeof parsed === 'object' && parsed !== null) {
|
||||
return { type: 'serialized', data: parsed };
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// Not serialized
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
// Open nested editor modal
|
||||
const openNestedEditor = (value, path) => {
|
||||
const detected = detectNestedData(value);
|
||||
if (detected) {
|
||||
setNestedEditModal({ path, value, type: detected.type });
|
||||
setNestedData(detected.data);
|
||||
}
|
||||
};
|
||||
|
||||
// Save nested editor changes
|
||||
const saveNestedEdit = () => {
|
||||
if (!nestedEditModal || !nestedData) return;
|
||||
|
||||
// Convert back to string based on type
|
||||
let stringValue;
|
||||
if (nestedEditModal.type === 'json') {
|
||||
stringValue = JSON.stringify(nestedData);
|
||||
} else if (nestedEditModal.type === 'serialized') {
|
||||
stringValue = phpSerialize(nestedData);
|
||||
}
|
||||
|
||||
// Update the value in the main data
|
||||
updateValue(stringValue, nestedEditModal.path);
|
||||
|
||||
// Close modal
|
||||
setNestedEditModal(null);
|
||||
setNestedData(null);
|
||||
};
|
||||
|
||||
// Close nested editor modal
|
||||
const closeNestedEditor = () => {
|
||||
setNestedEditModal(null);
|
||||
setNestedData(null);
|
||||
};
|
||||
|
||||
const toggleNode = (path) => {
|
||||
const newExpanded = new Set(expandedNodes);
|
||||
if (newExpanded.has(path)) {
|
||||
@@ -431,23 +600,34 @@ const StructuredEditor = ({ onDataChange, initialData = {} }) => {
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
(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"
|
||||
/>
|
||||
)
|
||||
<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}
|
||||
/>
|
||||
) : (
|
||||
<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>
|
||||
)
|
||||
) : (
|
||||
<span className="flex-1 text-sm text-gray-600 dark:text-gray-400">
|
||||
@@ -552,6 +732,55 @@ const StructuredEditor = ({ onDataChange, initialData = {} }) => {
|
||||
<span>Add Property</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Nested Data Editor Modal */}
|
||||
{nestedEditModal && nestedData && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-[99999] p-4">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-4xl w-full max-h-[90vh] overflow-hidden flex flex-col">
|
||||
{/* Modal Header */}
|
||||
<div className="px-6 py-4 border-b border-gray-200 dark:border-gray-700 bg-blue-50 dark:bg-blue-900/20 flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-lg font-medium text-gray-900 dark:text-gray-100">
|
||||
Edit Nested {nestedEditModal.type === 'json' ? 'JSON' : 'Serialized'} Data
|
||||
</h3>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">
|
||||
Changes will be saved back as a {nestedEditModal.type === 'json' ? 'JSON' : 'serialized'} string
|
||||
</p>
|
||||
</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"
|
||||
>
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Modal Body - Nested Editor */}
|
||||
<div className="flex-1 overflow-auto p-6">
|
||||
<StructuredEditor
|
||||
initialData={nestedData}
|
||||
onDataChange={setNestedData}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Modal Footer */}
|
||||
<div className="px-6 py-4 border-t border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900/20 flex justify-end gap-3">
|
||||
<button
|
||||
onClick={closeNestedEditor}
|
||||
className="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md hover:bg-gray-50 dark:hover:bg-gray-600 transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={saveNestedEdit}
|
||||
className="px-4 py-2 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 dark:bg-blue-600 dark:hover:bg-blue-700 rounded-md transition-colors"
|
||||
>
|
||||
Save Changes
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -22,6 +22,10 @@ const InvoiceEditor = () => {
|
||||
const [error, setError] = useState('');
|
||||
const fileInputRef = useRef(null);
|
||||
const logoInputRef = useRef(null);
|
||||
const [pasteCollapsed, setPasteCollapsed] = useState(false);
|
||||
const [pasteDataSummary, setPasteDataSummary] = useState(null);
|
||||
const [exportExpanded, setExportExpanded] = useState(false);
|
||||
const [usageTipsExpanded, setUsageTipsExpanded] = useState(false);
|
||||
|
||||
|
||||
|
||||
@@ -632,7 +636,8 @@ const InvoiceEditor = () => {
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
const handleFileImport = (event) => {
|
||||
// Handle file import (same as Table Editor)
|
||||
const handleFileSelect = (event) => {
|
||||
const file = event.target.files[0];
|
||||
if (file) {
|
||||
const reader = new FileReader();
|
||||
@@ -642,7 +647,6 @@ const InvoiceEditor = () => {
|
||||
const importedData = JSON.parse(content);
|
||||
setInvoiceData(importedData);
|
||||
setCreateNewCompleted(true);
|
||||
setActiveTab('create');
|
||||
setError('');
|
||||
} catch (err) {
|
||||
setError('Invalid JSON file format');
|
||||
@@ -834,105 +838,109 @@ const InvoiceEditor = () => {
|
||||
|
||||
{activeTab === 'url' && (
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Invoice JSON URL
|
||||
</label>
|
||||
<input
|
||||
type="url"
|
||||
value={url}
|
||||
onChange={(e) => setUrl(e.target.value)}
|
||||
placeholder="https://drive.google.com/file/d/... or any JSON URL"
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:text-white"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleUrlFetch}
|
||||
disabled={!url.trim() || isLoading}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>
|
||||
Loading...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Globe className="h-4 w-4" />
|
||||
Fetch Invoice Data
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
<div className="flex items-center gap-2 p-3 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg">
|
||||
<div className="flex-shrink-0">
|
||||
<svg className="h-4 w-4 text-blue-600 dark:text-blue-400" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clipRule="evenodd" />
|
||||
</svg>
|
||||
<div className="flex gap-2">
|
||||
<div className="relative flex-1">
|
||||
<input
|
||||
type="url"
|
||||
value={url}
|
||||
onChange={(e) => setUrl(e.target.value)}
|
||||
placeholder="https://api.telegram.org/bot<token>/getMe"
|
||||
className="tool-input w-full"
|
||||
onKeyPress={(e) => e.key === 'Enter' && handleUrlFetch()}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-blue-700 dark:text-blue-300">
|
||||
<strong>Google Drive:</strong> Use share links like "drive.google.com/file/d/..." - we'll convert them automatically.
|
||||
</p>
|
||||
<button
|
||||
onClick={handleUrlFetch}
|
||||
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>
|
||||
<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>
|
||||
)}
|
||||
|
||||
{activeTab === 'paste' && (
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Paste Invoice JSON Data
|
||||
</label>
|
||||
<CodeMirrorEditor
|
||||
value={inputText}
|
||||
onChange={setInputText}
|
||||
placeholder="Paste your invoice JSON data here..."
|
||||
language="json"
|
||||
maxLines={12}
|
||||
showToggle={true}
|
||||
className="w-full"
|
||||
/>
|
||||
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">
|
||||
✓ Invoice loaded: {pasteDataSummary.invoiceNumber || 'New Invoice'}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setPasteCollapsed(false)}
|
||||
className="text-sm text-blue-600 hover:underline"
|
||||
>
|
||||
Edit Input ▼
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
try {
|
||||
const parsed = JSON.parse(inputText);
|
||||
setInvoiceData(parsed);
|
||||
setCreateNewCompleted(true);
|
||||
setError('');
|
||||
} catch (err) {
|
||||
setError('Invalid JSON format');
|
||||
}
|
||||
}}
|
||||
disabled={!inputText.trim()}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Load Invoice Data
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<CodeMirrorEditor
|
||||
value={inputText}
|
||||
onChange={setInputText}
|
||||
placeholder="Paste your invoice JSON data here..."
|
||||
language="json"
|
||||
maxLines={12}
|
||||
showToggle={true}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
{error && (
|
||||
<div className="p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg">
|
||||
<p className="text-sm text-red-600 dark:text-red-400">
|
||||
<strong>Invalid Data:</strong> {error}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center justify-between flex-shrink-0">
|
||||
<div className="text-sm text-gray-600 dark:text-gray-400">
|
||||
Supports JSON invoice templates
|
||||
</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
try {
|
||||
const parsed = JSON.parse(inputText);
|
||||
setInvoiceData(parsed);
|
||||
setCreateNewCompleted(true);
|
||||
setPasteDataSummary({
|
||||
invoiceNumber: parsed.invoiceNumber || 'New Invoice',
|
||||
size: inputText.length
|
||||
});
|
||||
setPasteCollapsed(true);
|
||||
setError('');
|
||||
} catch (err) {
|
||||
setError('Invalid JSON format: ' + err.message);
|
||||
setPasteCollapsed(false);
|
||||
}
|
||||
}}
|
||||
disabled={!inputText.trim()}
|
||||
className="bg-blue-600 hover:bg-blue-700 disabled:bg-gray-400 disabled:cursor-not-allowed text-white font-medium px-4 py-2 rounded-md transition-colors flex-shrink-0"
|
||||
>
|
||||
Load Invoice
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
|
||||
{activeTab === 'open' && (
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Choose File
|
||||
</label>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".json"
|
||||
onChange={handleFileImport}
|
||||
className="block w-full text-sm text-gray-500 dark:text-gray-400 file:mr-4 file:py-2 file:px-4 file:rounded-md file:border-0 file:text-sm file:font-medium file:bg-blue-50 file:text-blue-700 hover:file:bg-blue-100 dark:file:bg-blue-900/20 dark:file:text-blue-300 dark:hover:file:bg-blue-900/30"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 p-3 bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-lg">
|
||||
<div className="flex-shrink-0">
|
||||
<svg className="h-4 w-4 text-green-600 dark:text-green-400" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clipRule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".json"
|
||||
onChange={handleFileSelect}
|
||||
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.
|
||||
🔒 <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>
|
||||
@@ -1955,14 +1963,21 @@ const InvoiceEditor = () => {
|
||||
{/* Export Section */}
|
||||
{(activeTab !== 'create' || createNewCompleted) && createNewCompleted && (
|
||||
<div className="w-full bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 overflow-hidden">
|
||||
<div className="px-4 sm:px-6 py-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<div className="flex items-center gap-3">
|
||||
<Download className="h-5 w-5 text-green-600 dark:text-green-400" />
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">Export Invoice</h2>
|
||||
<div
|
||||
onClick={() => setExportExpanded(!exportExpanded)}
|
||||
className="px-4 sm:px-6 py-4 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 items-center gap-3">
|
||||
<Download className="h-5 w-5 text-green-600 dark:text-green-400" />
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">Export Invoice</h2>
|
||||
{exportExpanded ? <ChevronUp className="h-4 w-4" /> : <ChevronDown className="h-4 w-4" />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4 sm:p-6">
|
||||
{exportExpanded && (
|
||||
<div className="p-4 sm:p-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<button
|
||||
onClick={handleGeneratePreview}
|
||||
@@ -1993,9 +2008,75 @@ const InvoiceEditor = () => {
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Usage Tips */}
|
||||
<div className="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-md overflow-hidden">
|
||||
<div
|
||||
onClick={() => setUsageTipsExpanded(!usageTipsExpanded)}
|
||||
className="px-4 py-3 cursor-pointer hover:bg-blue-100 dark:hover:bg-blue-900/30 transition-colors flex items-center justify-between"
|
||||
>
|
||||
<h4 className="text-blue-800 dark:text-blue-200 font-medium flex items-center gap-2">
|
||||
💡 Usage Tips
|
||||
</h4>
|
||||
{usageTipsExpanded ? <ChevronUp className="h-4 w-4 text-blue-600 dark:text-blue-400" /> : <ChevronDown className="h-4 w-4 text-blue-600 dark:text-blue-400" />}
|
||||
</div>
|
||||
|
||||
{usageTipsExpanded && (
|
||||
<div className="px-4 pb-4 text-blue-700 dark:text-blue-300 text-sm space-y-3">
|
||||
<div>
|
||||
<p className="font-medium mb-1">📝 Input Methods:</p>
|
||||
<ul className="list-disc list-inside space-y-1 ml-2">
|
||||
<li><strong>Create New:</strong> Start empty or load sample invoice to explore features</li>
|
||||
<li><strong>URL Import:</strong> Fetch invoice data directly from JSON endpoints</li>
|
||||
<li><strong>Paste Data:</strong> Auto-detects JSON invoice templates</li>
|
||||
<li><strong>Open Files:</strong> Import .json invoice files</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="font-medium mb-1">✏️ Invoice Editing:</p>
|
||||
<ul className="list-disc list-inside space-y-1 ml-2">
|
||||
<li><strong>Company & Client:</strong> Fill in business details, addresses, and contact info</li>
|
||||
<li><strong>Items:</strong> Add products/services with descriptions, quantities, and prices</li>
|
||||
<li><strong>Fees & Discounts:</strong> Add additional fees or discounts (fixed or percentage)</li>
|
||||
<li><strong>Payment Terms:</strong> Set full payment, installments, or down payment options</li>
|
||||
<li><strong>Digital Signature:</strong> Draw or upload signature for professional invoices</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="font-medium mb-1">🎨 Customization:</p>
|
||||
<ul className="list-disc list-inside space-y-1 ml-2">
|
||||
<li><strong>Settings:</strong> Change color scheme, currency, and display options</li>
|
||||
<li><strong>Logo Upload:</strong> Add your company logo for branding</li>
|
||||
<li><strong>Payment Methods:</strong> Add bank details, payment links, or QR codes</li>
|
||||
<li><strong>Notes & Messages:</strong> Include payment terms, thank you messages, and authorized signatures</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="font-medium mb-1">📤 Export Options:</p>
|
||||
<ul className="list-disc list-inside space-y-1 ml-2">
|
||||
<li><strong>PDF:</strong> Generate professional PDF invoices for clients</li>
|
||||
<li><strong>JSON:</strong> Save as reusable invoice templates</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="font-medium mb-1">💾 Data Privacy:</p>
|
||||
<ul className="list-disc list-inside space-y-1 ml-2">
|
||||
<li><strong>Local Processing:</strong> All data stays in your browser</li>
|
||||
<li><strong>No Upload:</strong> We don't store or transmit your invoice data</li>
|
||||
<li><strong>Secure:</strong> Your business information remains private</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Error Display */}
|
||||
{error && (
|
||||
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4">
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useState, useRef, useEffect, useCallback } from 'react';
|
||||
import { Plus, Upload, FileText, Globe, Edit3, Download, Workflow, Table, Braces, Code, AlertTriangle } from 'lucide-react';
|
||||
import { Plus, Upload, FileText, Globe, Edit3, Download, Workflow, Table, Braces, Code, AlertTriangle, ChevronUp, ChevronDown } from 'lucide-react';
|
||||
import ToolLayout from '../components/ToolLayout';
|
||||
import StructuredEditor from '../components/StructuredEditor';
|
||||
import MindmapView from '../components/MindmapView';
|
||||
@@ -56,6 +56,10 @@ const ObjectEditor = () => {
|
||||
const [showInputChangeModal, setShowInputChangeModal] = useState(false);
|
||||
const [pendingTabChange, setPendingTabChange] = useState(null);
|
||||
const fileInputRef = useRef(null);
|
||||
const [pasteCollapsed, setPasteCollapsed] = useState(false);
|
||||
const [pasteDataSummary, setPasteDataSummary] = useState(null);
|
||||
const [outputExpanded, setOutputExpanded] = useState(false);
|
||||
const [usageTipsExpanded, setUsageTipsExpanded] = useState(false);
|
||||
|
||||
// Helper function to check if user has data that would be lost
|
||||
const hasUserData = () => {
|
||||
@@ -427,7 +431,7 @@ const ObjectEditor = () => {
|
||||
}
|
||||
};
|
||||
|
||||
// Handle input text change
|
||||
// Handle input text change (validation only, no auto-load)
|
||||
const handleInputChange = (value) => {
|
||||
setInputText(value);
|
||||
const detection = detectInputFormat(value);
|
||||
@@ -435,11 +439,8 @@ const ObjectEditor = () => {
|
||||
setInputValid(detection.valid);
|
||||
|
||||
if (detection.valid) {
|
||||
setStructuredData(detection.data);
|
||||
setError('');
|
||||
setCreateNewCompleted(true);
|
||||
} else if (value.trim()) {
|
||||
// Use specific error message if available, otherwise generic message
|
||||
const errorMessage = detection.error || 'Invalid format. Please enter valid JSON or PHP serialized data.';
|
||||
setError(errorMessage);
|
||||
} else {
|
||||
@@ -447,6 +448,28 @@ const ObjectEditor = () => {
|
||||
}
|
||||
};
|
||||
|
||||
// Handle Parse Object button click
|
||||
const handleParseObject = () => {
|
||||
const detection = detectInputFormat(inputText);
|
||||
|
||||
if (detection.valid) {
|
||||
setStructuredData(detection.data);
|
||||
setError('');
|
||||
setCreateNewCompleted(true);
|
||||
setPasteDataSummary({
|
||||
format: detection.format,
|
||||
size: inputText.length,
|
||||
properties: Object.keys(detection.data).length
|
||||
});
|
||||
setPasteCollapsed(true);
|
||||
} else {
|
||||
// Show error, keep input expanded
|
||||
const errorMessage = detection.error || 'Invalid format. Please enter valid JSON or PHP serialized data.';
|
||||
setError(errorMessage);
|
||||
setPasteCollapsed(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle structured data change from visual editor
|
||||
const handleStructuredDataChange = (newData) => {
|
||||
setStructuredData(newData);
|
||||
@@ -523,16 +546,26 @@ const ObjectEditor = () => {
|
||||
}
|
||||
}, [serializeToPhp]);
|
||||
|
||||
// Handle file import
|
||||
// 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) => {
|
||||
const content = e.target.result;
|
||||
setInputText(content);
|
||||
handleInputChange(content);
|
||||
setCreateNewCompleted(true);
|
||||
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);
|
||||
}
|
||||
@@ -774,33 +807,63 @@ const ObjectEditor = () => {
|
||||
|
||||
{/* Paste Tab Content */}
|
||||
{activeTab === 'paste' && (
|
||||
<div className="space-y-3">
|
||||
<div className="flex justify-end items-center">
|
||||
{inputFormat && (
|
||||
<span className={`text-xs px-2 py-1 rounded ${
|
||||
inputValid
|
||||
? 'bg-green-100 text-green-800 dark:bg-green-900/20 dark:text-green-300'
|
||||
: 'bg-red-100 text-red-800 dark:bg-red-900/20 dark:text-red-300'
|
||||
}`}>
|
||||
{inputFormat} {inputValid ? '✓' : '✗'}
|
||||
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">
|
||||
✓ 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"
|
||||
>
|
||||
Edit Input ▼
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<CodeMirrorEditor
|
||||
value={inputText}
|
||||
onChange={(value) => handleInputChange(value)}
|
||||
language={inputFormat === 'JSON' ? 'json' : 'javascript'}
|
||||
placeholder="Paste JSON or PHP serialized data here..."
|
||||
maxLines={12}
|
||||
showToggle={true}
|
||||
className="w-full"
|
||||
/>
|
||||
{error && (
|
||||
<p className="text-sm text-red-600 dark:text-red-400">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<CodeMirrorEditor
|
||||
value={inputText}
|
||||
onChange={(value) => handleInputChange(value)}
|
||||
language={inputFormat === 'JSON' ? 'json' : 'javascript'}
|
||||
placeholder="Paste JSON or PHP serialized data here..."
|
||||
maxLines={12}
|
||||
showToggle={true}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
{error && (
|
||||
<div className="p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg">
|
||||
<p className="text-sm text-red-600 dark:text-red-400">
|
||||
<strong>Invalid Data:</strong> {error}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center justify-between flex-shrink-0">
|
||||
<div className="text-sm text-gray-600 dark:text-gray-400">
|
||||
{inputFormat && (
|
||||
<span className={`inline-flex items-center px-2 py-1 rounded text-xs font-medium ${
|
||||
inputValid
|
||||
? 'bg-green-100 text-green-800 dark:bg-green-900/20 dark:text-green-300'
|
||||
: 'bg-amber-100 text-amber-800 dark:bg-amber-900/20 dark:text-amber-300'
|
||||
}`}>
|
||||
{inputFormat} {inputValid ? '✓ Valid' : '⚠ Invalid'}
|
||||
</span>
|
||||
)}
|
||||
{!inputFormat && 'Auto-detects JSON and PHP serialized formats'}
|
||||
</div>
|
||||
<button
|
||||
onClick={handleParseObject}
|
||||
disabled={!inputValid || !inputText.trim()}
|
||||
className="bg-blue-600 hover:bg-blue-700 disabled:bg-gray-400 disabled:cursor-not-allowed text-white font-medium px-4 py-2 rounded-md transition-colors flex-shrink-0"
|
||||
>
|
||||
Parse Object
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
|
||||
{/* Open Tab Content */}
|
||||
@@ -811,28 +874,11 @@ const ObjectEditor = () => {
|
||||
type="file"
|
||||
accept=".json,.txt"
|
||||
onChange={handleFileImport}
|
||||
className="tool-input w-full"
|
||||
className="tool-input"
|
||||
/>
|
||||
<div className="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="useFirstRowAsHeader"
|
||||
checked={true}
|
||||
readOnly
|
||||
className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded mr-2"
|
||||
/>
|
||||
<label htmlFor="useFirstRowAsHeader" className="text-sm text-gray-700 dark:text-gray-300">
|
||||
Use first row as column headers (for CSV/TSV)
|
||||
</label>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 p-3 bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-lg">
|
||||
<div className="flex-shrink-0">
|
||||
<svg className="h-4 w-4 text-green-600 dark:text-green-400" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clipRule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
<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.
|
||||
🔒 <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>
|
||||
@@ -939,12 +985,16 @@ const ObjectEditor = () => {
|
||||
{/* Export Section - Only show if createNewCompleted or not on create tab */}
|
||||
{(activeTab !== 'create' || createNewCompleted) && Object.keys(structuredData).length > 0 && (
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 overflow-hidden mt-6">
|
||||
{/* Export Header */}
|
||||
<div className="px-4 py-3 border-b border-gray-200 dark:border-gray-700">
|
||||
{/* Export Header - Collapsible */}
|
||||
<div
|
||||
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"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<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
|
||||
{outputExpanded ? <ChevronUp className="h-4 w-4" /> : <ChevronDown className="h-4 w-4" />}
|
||||
</h3>
|
||||
<div className="text-sm text-gray-600 dark:text-gray-400">
|
||||
<span>Object: {Object.keys(structuredData).length} properties</span>
|
||||
@@ -952,6 +1002,9 @@ const ObjectEditor = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Export Content - Collapsible */}
|
||||
{outputExpanded && (
|
||||
<div>
|
||||
{/* Export Tabs */}
|
||||
<div className="flex border-b border-gray-200 dark:border-gray-700">
|
||||
<button
|
||||
@@ -1082,6 +1135,8 @@ const ObjectEditor = () => {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1104,9 +1159,19 @@ const ObjectEditor = () => {
|
||||
)}
|
||||
|
||||
{/* Usage Tips */}
|
||||
<div className="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-md p-4 mt-6">
|
||||
<h4 className="text-blue-800 dark:text-blue-200 font-medium mb-3">Usage Tips</h4>
|
||||
<div className="text-blue-700 dark:text-blue-300 text-sm space-y-2">
|
||||
<div className="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-md overflow-hidden mt-6">
|
||||
<div
|
||||
onClick={() => setUsageTipsExpanded(!usageTipsExpanded)}
|
||||
className="px-4 py-3 cursor-pointer hover:bg-blue-100 dark:hover:bg-blue-900/30 transition-colors flex items-center justify-between"
|
||||
>
|
||||
<h4 className="text-blue-800 dark:text-blue-200 font-medium flex items-center gap-2">
|
||||
💡 Usage Tips
|
||||
</h4>
|
||||
{usageTipsExpanded ? <ChevronUp className="h-4 w-4 text-blue-600 dark:text-blue-400" /> : <ChevronDown className="h-4 w-4 text-blue-600 dark:text-blue-400" />}
|
||||
</div>
|
||||
|
||||
{usageTipsExpanded && (
|
||||
<div className="px-4 pb-4 text-blue-700 dark:text-blue-300 text-sm space-y-2">
|
||||
<div>
|
||||
<p className="font-medium mb-1">📝 Input Methods:</p>
|
||||
<ul className="list-disc list-inside space-y-1 ml-2">
|
||||
@@ -1144,6 +1209,7 @@ const ObjectEditor = () => {
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ToolLayout>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Plus, Upload, FileText, Globe, Download, X, Table, Trash2, Database, Braces, Code, Eye, Minimize2, Maximize2, Search, ArrowUpDown, AlertTriangle, Edit3 } 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 CodeEditor from '../components/CodeEditor';
|
||||
import CodeMirrorEditor from '../components/CodeMirrorEditor';
|
||||
@@ -67,6 +67,10 @@ const TableEditor = () => {
|
||||
const [showInputChangeModal, setShowInputChangeModal] = useState(false); // For input method change confirmation
|
||||
const [pendingTabChange, setPendingTabChange] = useState(null); // Store pending tab change
|
||||
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 [exportExpanded, setExportExpanded] = useState(false); // Track if export section is expanded
|
||||
const [usageTipsExpanded, setUsageTipsExpanded] = useState(false); // Track if usage tips is expanded
|
||||
|
||||
// SQL Export specific state
|
||||
const [sqlTableName, setSqlTableName] = useState(""); // Table name for SQL export
|
||||
@@ -977,24 +981,50 @@ const TableEditor = () => {
|
||||
const handleTextInput = () => {
|
||||
if (!inputText.trim()) {
|
||||
setError("Please enter some data");
|
||||
setPasteCollapsed(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const trimmed = inputText.trim();
|
||||
let format = '';
|
||||
let success = false;
|
||||
|
||||
// Try to detect format
|
||||
if (trimmed.startsWith("[") && trimmed.endsWith("]")) {
|
||||
// JSON array
|
||||
parseJsonData(trimmed);
|
||||
} else if (
|
||||
trimmed.toLowerCase().includes("insert into") &&
|
||||
trimmed.toLowerCase().includes("values")
|
||||
) {
|
||||
// SQL INSERT statements
|
||||
parseSqlData(trimmed);
|
||||
} else {
|
||||
// CSV/TSV
|
||||
parseData(trimmed, useFirstRowAsHeader);
|
||||
try {
|
||||
// Try to detect format
|
||||
if (trimmed.startsWith("[") && trimmed.endsWith("]")) {
|
||||
// JSON array
|
||||
parseJsonData(trimmed);
|
||||
format = 'JSON';
|
||||
success = true;
|
||||
} else if (
|
||||
trimmed.toLowerCase().includes("insert into") &&
|
||||
trimmed.toLowerCase().includes("values")
|
||||
) {
|
||||
// SQL INSERT statements
|
||||
parseSqlData(trimmed);
|
||||
format = 'SQL';
|
||||
success = true;
|
||||
} else {
|
||||
// CSV/TSV
|
||||
parseData(trimmed, useFirstRowAsHeader);
|
||||
format = trimmed.includes('\t') ? 'TSV' : 'CSV';
|
||||
success = true;
|
||||
}
|
||||
|
||||
// If successful, collapse input and show summary
|
||||
if (success && data.length > 0) {
|
||||
setPasteDataSummary({
|
||||
format: format,
|
||||
size: inputText.length,
|
||||
rows: data.length
|
||||
});
|
||||
setPasteCollapsed(true);
|
||||
setError('');
|
||||
}
|
||||
} catch (err) {
|
||||
// Keep input expanded on error
|
||||
setPasteCollapsed(false);
|
||||
setError(err.message || 'Failed to parse data');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1793,7 +1823,7 @@ const TableEditor = () => {
|
||||
icon={Table}
|
||||
>
|
||||
{/* Input Section with Tabs */}
|
||||
<div className="w-full bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 overflow-hidden mb-4 sm:mb-6">
|
||||
<div className="w-full bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 overflow-hidden">
|
||||
{/* Tabs */}
|
||||
<div className="flex border-b border-gray-200 dark:border-gray-700 overflow-x-auto scrollbar-hide">
|
||||
<div className="flex min-w-max">
|
||||
@@ -2006,34 +2036,59 @@ const TableEditor = () => {
|
||||
)}
|
||||
|
||||
{activeTab === "paste" && (
|
||||
<div className="space-y-3">
|
||||
<CodeMirrorEditor
|
||||
value={inputText}
|
||||
onChange={setInputText}
|
||||
language="json"
|
||||
placeholder="Paste CSV, TSV, JSON, or SQL INSERT statements here..."
|
||||
maxLines={12}
|
||||
showToggle={true}
|
||||
className="w-full"
|
||||
/>
|
||||
<div className="flex items-center justify-between">
|
||||
<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>
|
||||
<button
|
||||
onClick={handleTextInput}
|
||||
className="bg-blue-600 hover:bg-blue-700 text-white font-medium px-4 py-2 rounded-md transition-colors"
|
||||
>
|
||||
Parse Data
|
||||
</button>
|
||||
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">
|
||||
✓ Data loaded: {pasteDataSummary.format} ({pasteDataSummary.size.toLocaleString()} chars, {pasteDataSummary.rows} rows)
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setPasteCollapsed(false)}
|
||||
className="text-sm text-blue-600 hover:underline"
|
||||
>
|
||||
Edit Input ▼
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<CodeMirrorEditor
|
||||
value={inputText}
|
||||
onChange={setInputText}
|
||||
language="json"
|
||||
placeholder="Paste CSV, TSV, JSON, or SQL INSERT statements here..."
|
||||
maxLines={12}
|
||||
showToggle={true}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
{error && (
|
||||
<div className="p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg">
|
||||
<p className="text-sm text-red-600 dark:text-red-400">
|
||||
<strong>Invalid Data:</strong> {error}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center justify-between flex-shrink-0">
|
||||
<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>
|
||||
<button
|
||||
onClick={handleTextInput}
|
||||
className="bg-blue-600 hover:bg-blue-700 text-white font-medium px-4 py-2 rounded-md transition-colors flex-shrink-0"
|
||||
>
|
||||
Parse Data
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
|
||||
{activeTab === "upload" && (
|
||||
@@ -2070,9 +2125,10 @@ const TableEditor = () => {
|
||||
<div
|
||||
className={`bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 min-w-0 ${
|
||||
isTableFullscreen
|
||||
? "fixed inset-0 z-50 rounded-none border-0 shadow-none overflow-hidden"
|
||||
: "overflow-x-auto"
|
||||
? "fixed inset-0 z-[99999] rounded-none border-0 shadow-none overflow-hidden !m-0"
|
||||
: "overflow-x-auto mt-4 sm:mt-6"
|
||||
}`}
|
||||
style={isTableFullscreen ? { marginTop: "0 !important" } : {}}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="px-4 py-3 border-b border-gray-200 dark:border-gray-700">
|
||||
@@ -2398,7 +2454,7 @@ const TableEditor = () => {
|
||||
return (
|
||||
<td
|
||||
key={column.id}
|
||||
className={`px-4 py-3 text-sm text-gray-900 dark:text-gray-100 border-r border-gray-200 dark:border-gray-600 break-words ${
|
||||
className={`px-4 py-3 text-sm text-gray-900 dark:text-gray-100 border-r border-gray-200 dark:border-gray-600 break-words overflow-hidden ${
|
||||
isFrozen
|
||||
? "sticky z-10 bg-blue-50 dark:!bg-blue-900"
|
||||
: ""
|
||||
@@ -2500,7 +2556,7 @@ const TableEditor = () => {
|
||||
// For regular text, show normal cell
|
||||
return (
|
||||
<div
|
||||
className="cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-600 p-2 rounded min-h-[32px] flex items-center"
|
||||
className="cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-600 p-2 rounded min-h-[32px] flex items-center overflow-hidden"
|
||||
onClick={() =>
|
||||
setEditingCell({
|
||||
rowId: row.id,
|
||||
@@ -2511,11 +2567,13 @@ const TableEditor = () => {
|
||||
isLongValue ? cellValue : undefined
|
||||
}
|
||||
>
|
||||
{cellValue || (
|
||||
<span className="text-gray-400 dark:text-gray-500 italic text-sm">
|
||||
Click to edit
|
||||
</span>
|
||||
)}
|
||||
<span className="truncate block w-full">
|
||||
{cellValue || (
|
||||
<span className="text-gray-400 dark:text-gray-500 italic text-sm">
|
||||
Click to edit
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2603,12 +2661,16 @@ const TableEditor = () => {
|
||||
{/* Export Section */}
|
||||
{data.length > 0 && (
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 overflow-hidden mt-6">
|
||||
{/* Export Header */}
|
||||
<div className="px-4 py-3 border-b border-gray-200 dark:border-gray-700">
|
||||
{/* Export Header - Collapsible */}
|
||||
<div
|
||||
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">
|
||||
<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
|
||||
{exportExpanded ? <ChevronUp className="h-4 w-4" /> : <ChevronDown className="h-4 w-4" />}
|
||||
</h3>
|
||||
<div className="text-sm text-gray-600 dark:text-gray-400">
|
||||
{availableTables.length > 1 ? (
|
||||
@@ -2625,6 +2687,10 @@ const TableEditor = () => {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Export Content - Collapsible */}
|
||||
{exportExpanded && (
|
||||
<div>
|
||||
|
||||
{/* Export Tabs */}
|
||||
<div className="flex border-b border-gray-200 dark:border-gray-700">
|
||||
@@ -3001,15 +3067,25 @@ const TableEditor = () => {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Usage Tips */}
|
||||
<div className="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-md p-4 mt-6">
|
||||
<h4 className="text-blue-800 dark:text-blue-200 font-medium mb-3">
|
||||
Usage Tips
|
||||
</h4>
|
||||
<div className="text-blue-700 dark:text-blue-300 text-sm space-y-2">
|
||||
<div className="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-md overflow-hidden mt-6">
|
||||
<div
|
||||
onClick={() => setUsageTipsExpanded(!usageTipsExpanded)}
|
||||
className="px-4 py-3 cursor-pointer hover:bg-blue-100 dark:hover:bg-blue-900/30 transition-colors flex items-center justify-between"
|
||||
>
|
||||
<h4 className="text-blue-800 dark:text-blue-200 font-medium flex items-center gap-2">
|
||||
💡 Usage Tips
|
||||
</h4>
|
||||
{usageTipsExpanded ? <ChevronUp className="h-4 w-4 text-blue-600 dark:text-blue-400" /> : <ChevronDown className="h-4 w-4 text-blue-600 dark:text-blue-400" />}
|
||||
</div>
|
||||
|
||||
{usageTipsExpanded && (
|
||||
<div className="px-4 pb-4 text-blue-700 dark:text-blue-300 text-sm space-y-2">
|
||||
<div>
|
||||
<p className="font-medium mb-1">📝 Input Methods:</p>
|
||||
<ul className="list-disc list-inside space-y-1 ml-2">
|
||||
@@ -3097,6 +3173,7 @@ const TableEditor = () => {
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ToolLayout>
|
||||
);
|
||||
@@ -3197,6 +3274,77 @@ const ClearConfirmationModal = ({
|
||||
// Object Editor Modal Component
|
||||
const ObjectEditorModal = ({ modal, onClose, onApply }) => {
|
||||
// Initialize with parsed data immediately
|
||||
// PHP unserialize function (same as StructuredEditor)
|
||||
const phpUnserialize = (str) => {
|
||||
let index = 0;
|
||||
const parseValue = () => {
|
||||
if (index >= str.length) throw new Error('Unexpected end of string');
|
||||
const type = str[index];
|
||||
if (type === 'N') {
|
||||
index += 2;
|
||||
return null;
|
||||
}
|
||||
if (str[index + 1] !== ':') throw new Error(`Expected ':' after type '${type}'`);
|
||||
index += 2;
|
||||
switch (type) {
|
||||
case 'b':
|
||||
const boolVal = str[index] === '1';
|
||||
index += 2;
|
||||
return boolVal;
|
||||
case 'i':
|
||||
let intStr = '';
|
||||
while (index < str.length && str[index] !== ';') intStr += str[index++];
|
||||
index++;
|
||||
return parseInt(intStr);
|
||||
case 'd':
|
||||
let floatStr = '';
|
||||
while (index < str.length && str[index] !== ';') floatStr += str[index++];
|
||||
index++;
|
||||
return parseFloat(floatStr);
|
||||
case 's':
|
||||
let lenStr = '';
|
||||
while (index < str.length && str[index] !== ':') lenStr += str[index++];
|
||||
index++;
|
||||
if (str[index] !== '"') throw new Error('Expected opening quote');
|
||||
index++;
|
||||
const byteLength = parseInt(lenStr);
|
||||
if (byteLength === 0) {
|
||||
index += 2;
|
||||
return '';
|
||||
}
|
||||
let endQuotePos = -1;
|
||||
for (let i = index; i < str.length - 1; i++) {
|
||||
if (str[i] === '"' && str[i + 1] === ';') {
|
||||
endQuotePos = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (endQuotePos === -1) throw new Error('Could not find closing quote');
|
||||
const strValue = str.substring(index, endQuotePos);
|
||||
index = endQuotePos + 2;
|
||||
return strValue.replace(/\\"/g, '"').replace(/\\\\/g, '\\');
|
||||
case 'a':
|
||||
let countStr = '';
|
||||
while (index < str.length && str[index] !== ':') countStr += str[index++];
|
||||
const count = parseInt(countStr);
|
||||
index += 2;
|
||||
const result = {};
|
||||
let isArray = true;
|
||||
for (let i = 0; i < count; i++) {
|
||||
const key = parseValue();
|
||||
const value = parseValue();
|
||||
result[key] = value;
|
||||
if (key !== i) isArray = false;
|
||||
}
|
||||
index++;
|
||||
return isArray ? Object.values(result) : result;
|
||||
default:
|
||||
throw new Error(`Unknown type: ${type}`);
|
||||
}
|
||||
};
|
||||
return parseValue();
|
||||
};
|
||||
|
||||
const initializeData = () => {
|
||||
try {
|
||||
let data = modal.originalValue;
|
||||
@@ -3207,12 +3355,25 @@ const ObjectEditorModal = ({ modal, onClose, onApply }) => {
|
||||
}
|
||||
|
||||
if (modal.format.type === "php_serialized") {
|
||||
return {
|
||||
structuredData: {},
|
||||
currentValue: modal.originalValue,
|
||||
isValid: true,
|
||||
error: "",
|
||||
};
|
||||
try {
|
||||
console.log('Attempting to parse PHP serialized:', modal.originalValue);
|
||||
const parsed = phpUnserialize(modal.originalValue);
|
||||
console.log('Parsed result:', parsed);
|
||||
return {
|
||||
structuredData: parsed,
|
||||
currentValue: modal.originalValue,
|
||||
isValid: true,
|
||||
error: "",
|
||||
};
|
||||
} catch (err) {
|
||||
console.error('PHP unserialize error:', err);
|
||||
return {
|
||||
structuredData: {},
|
||||
currentValue: modal.originalValue,
|
||||
isValid: false,
|
||||
error: err.message,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const parsed = JSON.parse(data);
|
||||
@@ -3256,13 +3417,56 @@ const ObjectEditorModal = ({ modal, onClose, onApply }) => {
|
||||
const [error, setError] = useState(initialState.error);
|
||||
|
||||
// Debug log to see what we initialized with
|
||||
console.log('Modal initialized with:', {
|
||||
structuredData,
|
||||
isValid,
|
||||
error,
|
||||
format: modal.format.type
|
||||
});
|
||||
|
||||
// PHP serialize function
|
||||
const phpSerialize = (data) => {
|
||||
if (data === null) return 'N;';
|
||||
if (typeof data === 'boolean') return data ? 'b:1;' : 'b:0;';
|
||||
if (typeof data === 'number') {
|
||||
return Number.isInteger(data) ? `i:${data};` : `d:${data};`;
|
||||
}
|
||||
if (typeof data === 'string') {
|
||||
const escapedData = data.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
||||
const byteLength = new TextEncoder().encode(escapedData).length;
|
||||
return `s:${byteLength}:"${escapedData}";`;
|
||||
}
|
||||
if (Array.isArray(data)) {
|
||||
let result = `a:${data.length}:{`;
|
||||
data.forEach((item, index) => {
|
||||
result += phpSerialize(index) + phpSerialize(item);
|
||||
});
|
||||
result += '}';
|
||||
return result;
|
||||
}
|
||||
if (typeof data === 'object') {
|
||||
const keys = Object.keys(data);
|
||||
let result = `a:${keys.length}:{`;
|
||||
keys.forEach(key => {
|
||||
result += phpSerialize(key) + phpSerialize(data[key]);
|
||||
});
|
||||
result += '}';
|
||||
return result;
|
||||
}
|
||||
return 'N;';
|
||||
};
|
||||
|
||||
// Update current value when structured data changes
|
||||
const handleStructuredDataChange = (newData) => {
|
||||
setStructuredData(newData);
|
||||
try {
|
||||
const jsonString = JSON.stringify(newData, null, 2);
|
||||
setCurrentValue(jsonString);
|
||||
if (modal.format.type === "php_serialized") {
|
||||
const serialized = phpSerialize(newData);
|
||||
setCurrentValue(serialized);
|
||||
} else {
|
||||
const jsonString = JSON.stringify(newData, null, 2);
|
||||
setCurrentValue(jsonString);
|
||||
}
|
||||
setIsValid(true);
|
||||
setError("");
|
||||
} catch (err) {
|
||||
@@ -3283,8 +3487,15 @@ const ObjectEditorModal = ({ modal, onClose, onApply }) => {
|
||||
}
|
||||
|
||||
if (modal.format.type === "php_serialized") {
|
||||
setIsValid(true);
|
||||
setError("");
|
||||
try {
|
||||
const parsed = phpUnserialize(newValue);
|
||||
setStructuredData(parsed);
|
||||
setIsValid(true);
|
||||
setError("");
|
||||
} catch (err) {
|
||||
setIsValid(false);
|
||||
setError(err.message);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -3331,16 +3542,6 @@ const ObjectEditorModal = ({ modal, onClose, onApply }) => {
|
||||
);
|
||||
}
|
||||
|
||||
if (modal.format.type === "php_serialized") {
|
||||
return (
|
||||
<div className="h-full flex items-center justify-center text-gray-500 dark:text-gray-400 p-6">
|
||||
<div className="text-center">
|
||||
<Code className="h-12 w-12 mx-auto mb-4 opacity-50" />
|
||||
<p>PHP Serialized data - use Raw Editor to modify</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full bg-white dark:bg-gray-800 p-6">
|
||||
@@ -3354,8 +3555,8 @@ const ObjectEditorModal = ({ modal, onClose, onApply }) => {
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4"
|
||||
style={{ minHeight: "100vh" }}
|
||||
className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-[99999] p-4 !m-0"
|
||||
style={{ minHeight: "100vh", marginTop: "0 !important" }}
|
||||
>
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-5xl w-full max-h-[90vh] overflow-hidden flex flex-col">
|
||||
{/* Header */}
|
||||
|
||||
Reference in New Issue
Block a user