3900 lines
143 KiB
JavaScript
Executable File
3900 lines
143 KiB
JavaScript
Executable File
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 CodeMirrorEditor from '../components/CodeMirrorEditor';
|
||
import StructuredEditor from "../components/StructuredEditor";
|
||
import SEO from '../components/SEO';
|
||
import RelatedTools from '../components/RelatedTools';
|
||
import Papa from "papaparse";
|
||
|
||
const TableEditor = () => {
|
||
const exportCardRef = useRef(null);
|
||
const [data, setData] = useState([]);
|
||
const [columns, setColumns] = useState([]);
|
||
|
||
// Sync table data to localStorage for navigation guard
|
||
useEffect(() => {
|
||
localStorage.setItem('tableEditorData', JSON.stringify(data));
|
||
}, [data]);
|
||
const [inputText, setInputText] = useState("");
|
||
const [url, setUrl] = useState("");
|
||
const [isLoading, setIsLoading] = useState(false);
|
||
// eslint-disable-next-line no-unused-vars
|
||
const [error, setError] = useState("");
|
||
const [useFirstRowAsHeader, setUseFirstRowAsHeader] = useState(true);
|
||
const [searchTerm, setSearchTerm] = useState("");
|
||
const [sortConfig, setSortConfig] = useState({ key: null, direction: "asc" });
|
||
const [editingCell, setEditingCell] = useState(null);
|
||
const [selectedRows, setSelectedRows] = useState(new Set());
|
||
const [activeTab, setActiveTab] = useState("create");
|
||
const [selectedColumns, setSelectedColumns] = useState(new Set());
|
||
const [editingHeader, setEditingHeader] = useState(null);
|
||
const [objectEditorModal, setObjectEditorModal] = useState(null);
|
||
const [exportTab, setExportTab] = useState("json");
|
||
const [jsonFormat, setJsonFormat] = useState("pretty"); // 'pretty' or 'minify'
|
||
|
||
// Multi-table management state
|
||
const [tableRegistry, setTableRegistry] = useState({}); // { tableName: { data, columns, modified, originalSchema, originalData } }
|
||
const [availableTables, setAvailableTables] = useState([]); // List of discovered table names
|
||
const [currentTable, setCurrentTable] = useState(""); // Currently active table name
|
||
const [originalFileName, setOriginalFileName] = useState(""); // For export naming
|
||
const [isTableFullscreen, setIsTableFullscreen] = useState(false); // For fullscreen table view
|
||
const [frozenColumns, setFrozenColumns] = useState(0); // Number of columns to freeze on horizontal scroll
|
||
const [columnWidths, setColumnWidths] = useState({}); // Store custom column widths
|
||
const [resizing, setResizing] = useState(null); // Track which column is being resized
|
||
const [showClearConfirmModal, setShowClearConfirmModal] = useState(false); // For clear confirmation modal
|
||
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 [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
|
||
|
||
// SQL Export specific state
|
||
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)
|
||
if (availableTables.length > 0) {
|
||
return true;
|
||
}
|
||
|
||
// Check if there's actual user data (not just initial empty structure)
|
||
if (data.length === 0) {
|
||
return false;
|
||
}
|
||
|
||
// Check if it's just the initial empty table structure
|
||
const isInitialEmptyTable =
|
||
data.length === 1 &&
|
||
columns.length === 3 &&
|
||
columns.some((col) => col.name === "id") &&
|
||
columns.some((col) => col.name === "name") &&
|
||
columns.some((col) => col.name === "value") &&
|
||
data[0] &&
|
||
Object.values(data[0]).every((val) => val === "" || val === data[0].id);
|
||
|
||
if (isInitialEmptyTable) {
|
||
return false;
|
||
}
|
||
|
||
// Check if all rows are empty
|
||
const hasNonEmptyData = data.some((row) =>
|
||
Object.entries(row).some(
|
||
([key, value]) =>
|
||
key !== "id" && value !== null && value !== undefined && value !== "",
|
||
),
|
||
);
|
||
|
||
return hasNonEmptyData;
|
||
};
|
||
|
||
// Check if current data has been modified from initial state
|
||
const hasModifiedData = () => {
|
||
// Check if there are multiple tables (imported data)
|
||
if (availableTables.length > 0) {
|
||
return true;
|
||
}
|
||
|
||
// Check if there's actual user data (not just initial empty structure)
|
||
if (data.length === 0) {
|
||
return false;
|
||
}
|
||
|
||
// Check if it's just the initial empty table structure
|
||
const isInitialEmptyTable =
|
||
data.length === 1 &&
|
||
columns.length === 3 &&
|
||
columns.some((col) => col.name === "id") &&
|
||
columns.some((col) => col.name === "name") &&
|
||
columns.some((col) => col.name === "value") &&
|
||
data[0] &&
|
||
Object.values(data[0]).every((val) => val === "" || val === data[0].id);
|
||
|
||
if (isInitialEmptyTable) {
|
||
return false;
|
||
}
|
||
|
||
// Check if it's the sample data (unchanged)
|
||
const isSampleData =
|
||
data.length === 4 &&
|
||
columns.length === 5 &&
|
||
columns.some((col) => col.name === "id") &&
|
||
columns.some((col) => col.name === "name") &&
|
||
columns.some((col) => col.name === "email") &&
|
||
columns.some((col) => col.name === "age") &&
|
||
columns.some((col) => col.name === "city") &&
|
||
currentTable === "sample_data";
|
||
|
||
if (isSampleData) {
|
||
// Check if sample data is unchanged
|
||
const expectedSampleData = [
|
||
{
|
||
id: 1,
|
||
name: "John Doe",
|
||
email: "john@example.com",
|
||
age: 30,
|
||
city: "New York",
|
||
},
|
||
{
|
||
id: 2,
|
||
name: "Jane Smith",
|
||
email: "jane@example.com",
|
||
age: 25,
|
||
city: "Los Angeles",
|
||
},
|
||
{
|
||
id: 3,
|
||
name: "Bob Johnson",
|
||
email: "bob@example.com",
|
||
age: 35,
|
||
city: "Chicago",
|
||
},
|
||
{
|
||
id: 4,
|
||
name: "Alice Brown",
|
||
email: "alice@example.com",
|
||
age: 28,
|
||
city: "Houston",
|
||
},
|
||
];
|
||
|
||
const isUnchangedSample = data.every((row, index) => {
|
||
const expected = expectedSampleData[index];
|
||
return (
|
||
expected &&
|
||
row.col_0 === expected.id &&
|
||
row.col_1 === expected.name &&
|
||
row.col_2 === expected.email &&
|
||
row.col_3 === expected.age &&
|
||
row.col_4 === expected.city
|
||
);
|
||
});
|
||
|
||
return !isUnchangedSample;
|
||
}
|
||
|
||
// Any other data is considered modified
|
||
return true;
|
||
};
|
||
|
||
// Handle tab change with confirmation if data exists
|
||
const handleTabChange = (newTab) => {
|
||
// For Create New tab, use more specific logic
|
||
if (newTab === "create" && activeTab !== "create") {
|
||
if (hasModifiedData()) {
|
||
setPendingTabChange(newTab);
|
||
setShowInputChangeModal(true);
|
||
} else {
|
||
setActiveTab(newTab);
|
||
setCreateNewCompleted(false);
|
||
}
|
||
} else if (hasUserData() && activeTab !== newTab) {
|
||
setPendingTabChange(newTab);
|
||
setShowInputChangeModal(true);
|
||
} else {
|
||
// No data or same tab, proceed directly
|
||
setActiveTab(newTab);
|
||
// If clicking Create New again after completion, show the options again
|
||
if (newTab === "create" && createNewCompleted) {
|
||
setCreateNewCompleted(false);
|
||
}
|
||
// Don't auto-create table for 'create' tab - let user choose Start Empty or Load Sample
|
||
}
|
||
};
|
||
|
||
// Clear all data function
|
||
const clearAllData = () => {
|
||
setData([]);
|
||
setColumns([]);
|
||
setTableRegistry({});
|
||
setAvailableTables([]);
|
||
setCurrentTable("");
|
||
setOriginalFileName("");
|
||
setCreateNewCompleted(false);
|
||
};
|
||
|
||
// Confirm input method change and clear data
|
||
const confirmInputChange = () => {
|
||
// Handle special Create New button actions
|
||
if (pendingTabChange === "create_empty") {
|
||
clearAllData();
|
||
createEmptyTable();
|
||
setCreateNewCompleted(true);
|
||
} else if (pendingTabChange === "create_sample") {
|
||
clearAllData();
|
||
// Load sample data
|
||
const sampleData = [
|
||
{
|
||
id: 1,
|
||
name: "John Doe",
|
||
email: "john@example.com",
|
||
age: 30,
|
||
city: "New York",
|
||
},
|
||
{
|
||
id: 2,
|
||
name: "Jane Smith",
|
||
email: "jane@example.com",
|
||
age: 25,
|
||
city: "Los Angeles",
|
||
},
|
||
{
|
||
id: 3,
|
||
name: "Bob Johnson",
|
||
email: "bob@example.com",
|
||
age: 35,
|
||
city: "Chicago",
|
||
},
|
||
{
|
||
id: 4,
|
||
name: "Alice Brown",
|
||
email: "alice@example.com",
|
||
age: 28,
|
||
city: "Houston",
|
||
},
|
||
];
|
||
|
||
const sampleColumns = [
|
||
{ id: "col_0", name: "id" },
|
||
{ id: "col_1", name: "name" },
|
||
{ id: "col_2", name: "email" },
|
||
{ id: "col_3", name: "age" },
|
||
{ id: "col_4", name: "city" },
|
||
];
|
||
|
||
const formattedData = sampleData.map((row, index) => ({
|
||
id: `row_${index}`,
|
||
col_0: row.id,
|
||
col_1: row.name,
|
||
col_2: row.email,
|
||
col_3: row.age,
|
||
col_4: row.city,
|
||
}));
|
||
|
||
setData(formattedData);
|
||
setColumns(sampleColumns);
|
||
setCurrentTable("sample_data");
|
||
setOriginalFileName("sample_data");
|
||
setCreateNewCompleted(true);
|
||
} else {
|
||
// Handle regular tab switches
|
||
clearAllData();
|
||
setActiveTab(pendingTabChange);
|
||
// If switching to create tab, reset completion state to show options
|
||
if (pendingTabChange === "create") {
|
||
setCreateNewCompleted(false);
|
||
}
|
||
}
|
||
|
||
// Close modal
|
||
setShowInputChangeModal(false);
|
||
setPendingTabChange(null);
|
||
};
|
||
|
||
// SQL parsing functions for multi-table support
|
||
const parseMultiTableSQL = (sqlContent) => {
|
||
const tables = {};
|
||
const lines = sqlContent.split("\n");
|
||
let currentTable = null;
|
||
let currentSchema = "";
|
||
let insideCreateTable = false;
|
||
let insideInsert = false;
|
||
|
||
|
||
for (let i = 0; i < lines.length; i++) {
|
||
const line = lines[i].trim();
|
||
|
||
// Detect CREATE TABLE statements
|
||
const createTableMatch = line.match(/CREATE TABLE\s+`?(\w+)`?\s*\(/i);
|
||
if (createTableMatch) {
|
||
currentTable = createTableMatch[1];
|
||
currentSchema = line + "\n";
|
||
insideCreateTable = true;
|
||
|
||
if (!tables[currentTable]) {
|
||
tables[currentTable] = {
|
||
name: currentTable,
|
||
schema: "",
|
||
data: [],
|
||
columns: [],
|
||
rowCount: 0,
|
||
};
|
||
}
|
||
continue;
|
||
}
|
||
|
||
// Continue collecting CREATE TABLE schema
|
||
if (insideCreateTable) {
|
||
currentSchema += line + "\n";
|
||
if (line.includes(");") || line.includes(") ENGINE")) {
|
||
insideCreateTable = false;
|
||
if (currentTable && tables[currentTable]) {
|
||
tables[currentTable].schema = currentSchema;
|
||
}
|
||
}
|
||
continue;
|
||
}
|
||
|
||
// Detect INSERT statements
|
||
const insertMatch = line.match(
|
||
/INSERT INTO\s+`?(\w+)`?\s*(?:\([^)]+\))?\s*VALUES/i,
|
||
);
|
||
if (insertMatch) {
|
||
currentTable = insertMatch[1];
|
||
insideInsert = true;
|
||
|
||
if (!tables[currentTable]) {
|
||
tables[currentTable] = {
|
||
name: currentTable,
|
||
schema: "",
|
||
data: [],
|
||
columns: [],
|
||
rowCount: 0,
|
||
};
|
||
}
|
||
|
||
// Extract column names from INSERT statement
|
||
const columnsMatch = line.match(/INSERT INTO\s+`?\w+`?\s*\(([^)]+)\)/i);
|
||
if (columnsMatch && tables[currentTable].columns.length === 0) {
|
||
const columnNames = columnsMatch[1]
|
||
.split(",")
|
||
.map((col) => col.trim().replace(/`/g, ""));
|
||
|
||
tables[currentTable].columns = columnNames.map((name, index) => ({
|
||
id: `col_${index}`,
|
||
name: name,
|
||
}));
|
||
}
|
||
|
||
// Extract VALUES data
|
||
const valuesMatch = line.match(/VALUES\s*(.+)/i);
|
||
if (valuesMatch) {
|
||
const valuesStr = valuesMatch[1];
|
||
const rows = parseInsertValues(valuesStr);
|
||
tables[currentTable].data.push(...rows);
|
||
tables[currentTable].rowCount += rows.length;
|
||
}
|
||
continue;
|
||
}
|
||
|
||
// Continue collecting INSERT data from multi-line statements
|
||
if (
|
||
insideInsert &&
|
||
currentTable &&
|
||
line.includes("(") &&
|
||
line.includes(")")
|
||
) {
|
||
const rows = parseInsertValues(line);
|
||
tables[currentTable].data.push(...rows);
|
||
tables[currentTable].rowCount += rows.length;
|
||
|
||
if (line.endsWith(";")) {
|
||
insideInsert = false;
|
||
}
|
||
}
|
||
}
|
||
|
||
return tables;
|
||
};
|
||
|
||
const parseInsertValues = (valuesStr) => {
|
||
const rows = [];
|
||
|
||
// Better parsing: find complete tuples first, then parse values respecting quotes
|
||
const tuples = [];
|
||
let currentTuple = "";
|
||
let parenCount = 0;
|
||
let inString = false;
|
||
let stringChar = "";
|
||
|
||
for (let i = 0; i < valuesStr.length; i++) {
|
||
const char = valuesStr[i];
|
||
const prevChar = i > 0 ? valuesStr[i - 1] : "";
|
||
|
||
if (!inString && (char === '"' || char === "'")) {
|
||
inString = true;
|
||
stringChar = char;
|
||
} else if (inString && char === stringChar && prevChar !== "\\") {
|
||
inString = false;
|
||
stringChar = "";
|
||
}
|
||
|
||
if (!inString) {
|
||
if (char === "(") parenCount++;
|
||
if (char === ")") parenCount--;
|
||
}
|
||
|
||
currentTuple += char;
|
||
|
||
if (!inString && parenCount === 0 && char === ")") {
|
||
tuples.push(currentTuple.trim());
|
||
currentTuple = "";
|
||
// Skip comma and whitespace
|
||
while (
|
||
i + 1 < valuesStr.length &&
|
||
(valuesStr[i + 1] === "," || valuesStr[i + 1] === " ")
|
||
) {
|
||
i++;
|
||
}
|
||
}
|
||
}
|
||
|
||
tuples.forEach((tuple) => {
|
||
|
||
// Remove outer parentheses
|
||
const innerContent = tuple.replace(/^\(|\)$/g, "").trim();
|
||
|
||
// Parse values respecting quotes and nested structures
|
||
const values = [];
|
||
let currentValue = "";
|
||
let inString = false;
|
||
let stringChar = "";
|
||
let parenCount = 0;
|
||
|
||
for (let i = 0; i < innerContent.length; i++) {
|
||
const char = innerContent[i];
|
||
const prevChar = i > 0 ? innerContent[i - 1] : "";
|
||
|
||
if (!inString && (char === '"' || char === "'")) {
|
||
inString = true;
|
||
stringChar = char;
|
||
currentValue += char;
|
||
continue;
|
||
} else if (inString && char === stringChar && prevChar !== "\\") {
|
||
inString = false;
|
||
stringChar = "";
|
||
currentValue += char;
|
||
continue;
|
||
}
|
||
|
||
if (!inString) {
|
||
if (char === "(" || char === "{" || char === "[") parenCount++;
|
||
if (char === ")" || char === "}" || char === "]") parenCount--;
|
||
|
||
if (char === "," && parenCount === 0) {
|
||
values.push(currentValue.trim());
|
||
currentValue = "";
|
||
continue;
|
||
}
|
||
}
|
||
|
||
currentValue += char;
|
||
}
|
||
|
||
if (currentValue.trim()) {
|
||
values.push(currentValue.trim());
|
||
}
|
||
|
||
|
||
const processedValues = values.map((val) => {
|
||
val = String(val).trim();
|
||
// Remove quotes and handle NULL
|
||
if (val === "NULL") return "";
|
||
if (val.startsWith("'") && val.endsWith("'")) {
|
||
let unquoted = val.slice(1, -1);
|
||
|
||
// Handle escaped JSON properly (same logic as main parser)
|
||
if (unquoted.includes('\\"') || unquoted.includes("\\'")) {
|
||
|
||
// Try to detect if this is escaped JSON
|
||
const isEscapedJson = (str) => {
|
||
const unescaped = str.replace(/\\"/g, '"').replace(/\\'/g, "'");
|
||
const trimmed = unescaped.trim();
|
||
|
||
if (
|
||
(trimmed.startsWith("{") && trimmed.endsWith("}")) ||
|
||
(trimmed.startsWith("[") && trimmed.endsWith("]"))
|
||
) {
|
||
try {
|
||
JSON.parse(trimmed);
|
||
return true;
|
||
} catch (e) {
|
||
return false;
|
||
}
|
||
}
|
||
return false;
|
||
};
|
||
|
||
// If it's escaped JSON, unescape it
|
||
if (isEscapedJson(unquoted)) {
|
||
unquoted = unquoted.replace(/\\"/g, '"').replace(/\\'/g, "'");
|
||
} else {
|
||
// Only unescape single quotes for non-JSON strings
|
||
unquoted = unquoted.replace(/''/g, "'");
|
||
}
|
||
} else {
|
||
// Only unescape single quotes, preserve JSON structure
|
||
unquoted = unquoted.replace(/''/g, "'");
|
||
}
|
||
|
||
return unquoted;
|
||
}
|
||
return val;
|
||
});
|
||
|
||
const rowObj = {};
|
||
processedValues.forEach((value, index) => {
|
||
rowObj[`col_${index}`] = value;
|
||
});
|
||
rowObj.id = Date.now() + Math.random(); // Generate unique ID
|
||
rows.push(rowObj);
|
||
});
|
||
|
||
return rows;
|
||
};
|
||
|
||
// Detect structured data formats in cell values
|
||
const detectCellFormat = (value) => {
|
||
if (!value || typeof value !== "string" || value.length < 2) return null;
|
||
|
||
const trimmed = value.trim();
|
||
|
||
// JSON Object detection
|
||
if (trimmed.startsWith("{") && trimmed.endsWith("}")) {
|
||
try {
|
||
JSON.parse(trimmed);
|
||
return { type: "json", subtype: "object", icon: Braces };
|
||
} catch {
|
||
return null; // If it's not valid JSON, don't treat it as JSON
|
||
}
|
||
}
|
||
|
||
// JSON Array detection
|
||
if (trimmed.startsWith("[") && trimmed.endsWith("]")) {
|
||
try {
|
||
JSON.parse(trimmed);
|
||
return { type: "json", subtype: "array", icon: Braces };
|
||
} catch {
|
||
return null; // If it's not valid JSON, don't treat it as JSON
|
||
}
|
||
}
|
||
|
||
// PHP Serialized detection
|
||
if (trimmed.match(/^[aOs]:\d+:/)) {
|
||
return { type: "php_serialized", subtype: "serialized", icon: Code };
|
||
}
|
||
|
||
// Base64 JSON detection (common in APIs)
|
||
if (trimmed.match(/^[A-Za-z0-9+/]+=*$/) && trimmed.length > 20) {
|
||
try {
|
||
const decoded = atob(trimmed);
|
||
JSON.parse(decoded);
|
||
return { type: "base64_json", subtype: "encoded", icon: Eye };
|
||
} catch {}
|
||
}
|
||
|
||
return null;
|
||
};
|
||
|
||
// Open Object Editor modal for structured data
|
||
const openObjectEditor = (rowId, columnId, value, format) => {
|
||
const column = columns.find((c) => c.id === columnId);
|
||
|
||
// Value should already be properly unescaped by the SQL parser
|
||
setObjectEditorModal({
|
||
rowId,
|
||
columnId,
|
||
rowIndex: data.findIndex((r) => r.id === rowId) + 1,
|
||
columnName: column?.name || "Unknown",
|
||
originalValue: value,
|
||
currentValue: value,
|
||
format,
|
||
isValid: true,
|
||
});
|
||
};
|
||
|
||
// Close Object Editor modal
|
||
const closeObjectEditor = () => {
|
||
setObjectEditorModal(null);
|
||
};
|
||
|
||
// Apply changes from Object Editor back to table
|
||
const applyObjectEditorChanges = (newValue) => {
|
||
if (!objectEditorModal) return;
|
||
|
||
const { rowId, columnId } = objectEditorModal;
|
||
|
||
setData(
|
||
data.map((row) =>
|
||
row.id === rowId ? { ...row, [columnId]: newValue } : row,
|
||
),
|
||
);
|
||
|
||
closeObjectEditor();
|
||
};
|
||
|
||
// Parse CSV/TSV data
|
||
const parseData = (text, hasHeaders = true) => {
|
||
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("");
|
||
return tableData.length; // Return actual row count
|
||
};
|
||
|
||
// Parse SQL data
|
||
const parseSqlData = (text) => {
|
||
try {
|
||
// Clean the SQL text - remove comments and unnecessary lines
|
||
const cleanLines = text
|
||
.split("\n")
|
||
.map((line) => line.trim())
|
||
.filter(
|
||
(line) =>
|
||
line &&
|
||
!line.startsWith("--") &&
|
||
!line.startsWith("/*") &&
|
||
!line.startsWith("*/") &&
|
||
!line.startsWith("SET ") &&
|
||
!line.startsWith("START ") &&
|
||
!line.startsWith("COMMIT") &&
|
||
!line.startsWith("/*!") &&
|
||
!line.startsWith("CREATE ") &&
|
||
!line.startsWith("ALTER ") &&
|
||
!line.startsWith("DROP ") &&
|
||
line !== ";",
|
||
);
|
||
|
||
// Join lines and split by semicolons to handle multi-line INSERT statements
|
||
const cleanText = cleanLines.join(" ");
|
||
const statements = cleanText
|
||
.split(";")
|
||
.map((stmt) => stmt.trim())
|
||
.filter((stmt) => stmt);
|
||
|
||
// Look for INSERT statements
|
||
const insertStatements = statements.filter(
|
||
(stmt) =>
|
||
stmt.toLowerCase().includes("insert into") &&
|
||
stmt.toLowerCase().includes("values"),
|
||
);
|
||
|
||
if (insertStatements.length === 0) {
|
||
throw new Error(
|
||
"No INSERT statements found. Please provide SQL with INSERT INTO ... VALUES statements.",
|
||
);
|
||
}
|
||
|
||
// Parse the first INSERT to get table structure
|
||
const firstInsert = insertStatements[0];
|
||
|
||
// Extract table name and columns
|
||
const tableMatch = firstInsert.match(
|
||
/insert\s+into\s+`?(\w+)`?\s*\(([^)]+)\)/i,
|
||
);
|
||
if (!tableMatch) {
|
||
throw new Error(
|
||
"Could not parse table structure. Expected format: INSERT INTO table (col1, col2) VALUES ...",
|
||
);
|
||
}
|
||
|
||
const columnsPart = tableMatch[2];
|
||
|
||
// Parse column names
|
||
const columnNames = columnsPart
|
||
.split(",")
|
||
.map((col) => col.trim().replace(/[`'"]/g, ""))
|
||
.filter((col) => col);
|
||
|
||
// Create headers
|
||
const headers = columnNames.map((name, index) => ({
|
||
id: `col_${index}`,
|
||
name: name,
|
||
type: "text",
|
||
}));
|
||
|
||
// Parse all INSERT statements for data
|
||
const allRows = [];
|
||
|
||
insertStatements.forEach((statement, statementIndex) => {
|
||
// Extract VALUES part
|
||
const valuesMatch = statement.match(/values\s*(.+)$/i);
|
||
if (!valuesMatch) return;
|
||
|
||
let valuesStr = valuesMatch[1].trim();
|
||
|
||
// Handle multiple value sets in one INSERT
|
||
// Split by ), ( but be careful with nested parentheses
|
||
const valueSets = [];
|
||
let currentSet = "";
|
||
let parenCount = 0;
|
||
let inString = false;
|
||
let stringChar = "";
|
||
|
||
for (let i = 0; i < valuesStr.length; i++) {
|
||
const char = valuesStr[i];
|
||
const prevChar = i > 0 ? valuesStr[i - 1] : "";
|
||
|
||
if (!inString && (char === '"' || char === "'")) {
|
||
inString = true;
|
||
stringChar = char;
|
||
} else if (inString && char === stringChar && prevChar !== "\\") {
|
||
inString = false;
|
||
stringChar = "";
|
||
}
|
||
|
||
if (!inString) {
|
||
if (char === "(") parenCount++;
|
||
if (char === ")") parenCount--;
|
||
}
|
||
|
||
currentSet += char;
|
||
|
||
if (!inString && parenCount === 0 && char === ")") {
|
||
valueSets.push(currentSet.trim());
|
||
currentSet = "";
|
||
// Skip comma and whitespace
|
||
while (
|
||
i + 1 < valuesStr.length &&
|
||
(valuesStr[i + 1] === "," || valuesStr[i + 1] === " ")
|
||
) {
|
||
i++;
|
||
}
|
||
}
|
||
}
|
||
|
||
// Parse each value set
|
||
valueSets.forEach((valueSet, setIndex) => {
|
||
// Remove outer parentheses
|
||
valueSet = valueSet.replace(/^\(|\)$/g, "").trim();
|
||
if (valueSet.endsWith(";")) {
|
||
valueSet = valueSet.slice(0, -1).trim();
|
||
}
|
||
|
||
// Parse individual values
|
||
const values = [];
|
||
let currentValue = "";
|
||
let inString = false;
|
||
let stringChar = "";
|
||
let parenCount = 0;
|
||
|
||
for (let i = 0; i < valueSet.length; i++) {
|
||
const char = valueSet[i];
|
||
const prevChar = i > 0 ? valueSet[i - 1] : "";
|
||
|
||
if (!inString && (char === '"' || char === "'")) {
|
||
inString = true;
|
||
stringChar = char;
|
||
continue; // Don't include quote in value
|
||
} else if (inString && char === stringChar && prevChar !== "\\") {
|
||
inString = false;
|
||
stringChar = "";
|
||
continue; // Don't include quote in value
|
||
}
|
||
|
||
if (!inString) {
|
||
if (char === "(") parenCount++;
|
||
if (char === ")") parenCount--;
|
||
|
||
if (char === "," && parenCount === 0) {
|
||
values.push(currentValue.trim());
|
||
currentValue = "";
|
||
continue;
|
||
}
|
||
}
|
||
|
||
currentValue += char;
|
||
}
|
||
|
||
if (currentValue.trim()) {
|
||
values.push(currentValue.trim());
|
||
}
|
||
|
||
// Create row data
|
||
if (values.length === headers.length) {
|
||
const rowData = { id: `row_${allRows.length}` };
|
||
headers.forEach((header, index) => {
|
||
let value = values[index] || "";
|
||
|
||
// Handle NULL values
|
||
if (value.toLowerCase() === "null") {
|
||
value = "";
|
||
}
|
||
|
||
// Clean up the value - handle escaped JSON properly
|
||
|
||
if (value.includes('\\"') || value.includes("\\'")) {
|
||
|
||
// Try to detect if this is escaped JSON
|
||
const isEscapedJson = (str) => {
|
||
// Check if it looks like escaped JSON (starts with '{' or '[' after unescaping)
|
||
const unescaped = str
|
||
.replace(/\\"/g, '"')
|
||
.replace(/\\'/g, "'");
|
||
const trimmed = unescaped.trim();
|
||
|
||
if (
|
||
(trimmed.startsWith("{") && trimmed.endsWith("}")) ||
|
||
(trimmed.startsWith("[") && trimmed.endsWith("]"))
|
||
) {
|
||
try {
|
||
JSON.parse(trimmed);
|
||
return true;
|
||
} catch (e) {
|
||
return false;
|
||
}
|
||
}
|
||
return false;
|
||
};
|
||
|
||
// If it's escaped JSON, unescape it
|
||
if (isEscapedJson(value)) {
|
||
value = value.replace(/\\"/g, '"').replace(/\\'/g, "'");
|
||
} else {
|
||
// Only unescape single quotes for non-JSON strings
|
||
if (value.includes("\\'")) {
|
||
value = value.replace(/\\'/g, "'");
|
||
}
|
||
}
|
||
}
|
||
|
||
|
||
rowData[header.id] = value;
|
||
});
|
||
allRows.push(rowData);
|
||
}
|
||
});
|
||
});
|
||
|
||
if (allRows.length === 0) {
|
||
throw new Error(
|
||
"No data rows could be parsed from the SQL statements.",
|
||
);
|
||
}
|
||
|
||
setColumns(headers);
|
||
setData(allRows);
|
||
setError("");
|
||
} catch (err) {
|
||
setError(`Failed to parse SQL: ${err.message}`);
|
||
}
|
||
};
|
||
|
||
// Parse JSON data
|
||
const parseJsonData = (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("");
|
||
return tableData.length; // Return row count for summary
|
||
};
|
||
|
||
// Handle text input
|
||
const handleTextInput = () => {
|
||
if (!inputText.trim()) {
|
||
setError("Please enter some data");
|
||
setPasteCollapsed(false);
|
||
return;
|
||
}
|
||
|
||
const trimmed = inputText.trim();
|
||
let format = '';
|
||
let rowCount = 0;
|
||
|
||
try {
|
||
// Try to detect format
|
||
if (trimmed.startsWith("[") && trimmed.endsWith("]")) {
|
||
// JSON array
|
||
rowCount = parseJsonData(trimmed);
|
||
format = 'JSON';
|
||
} else if (
|
||
trimmed.toLowerCase().includes("insert into") &&
|
||
trimmed.toLowerCase().includes("values")
|
||
) {
|
||
// SQL INSERT statements
|
||
parseSqlData(trimmed);
|
||
format = 'SQL';
|
||
// Get row count from state after parse
|
||
rowCount = data.length;
|
||
} else {
|
||
// CSV/TSV
|
||
parseData(trimmed, useFirstRowAsHeader);
|
||
format = trimmed.includes('\t') ? 'TSV' : 'CSV';
|
||
// Get row count from state after parse
|
||
rowCount = data.length;
|
||
}
|
||
|
||
// 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);
|
||
setError(err.message || 'Failed to parse data');
|
||
}
|
||
};
|
||
|
||
// Fetch data from URL
|
||
const fetchUrlData = async () => {
|
||
if (!url.trim()) {
|
||
setError("Please enter a valid URL");
|
||
return;
|
||
}
|
||
|
||
setIsLoading(true);
|
||
setError("");
|
||
|
||
try {
|
||
const response = await fetch(url.trim());
|
||
|
||
if (!response.ok) {
|
||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||
}
|
||
|
||
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")) {
|
||
rowCount = parseJsonData(text);
|
||
format = 'JSON';
|
||
} else {
|
||
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);
|
||
}
|
||
};
|
||
|
||
// Multi-table management functions
|
||
const switchToTable = (tableName) => {
|
||
if (!tableName || !tableRegistry[tableName]) return;
|
||
|
||
// Save current table state if we have one
|
||
if (currentTable && tableRegistry[currentTable]) {
|
||
setTableRegistry((prev) => ({
|
||
...prev,
|
||
[currentTable]: {
|
||
...prev[currentTable],
|
||
data: data,
|
||
columns: columns,
|
||
modified: true,
|
||
},
|
||
}));
|
||
}
|
||
|
||
// Load new table
|
||
const tableData = tableRegistry[tableName];
|
||
setCurrentTable(tableName);
|
||
setData(tableData.data);
|
||
setColumns(tableData.columns);
|
||
setSearchTerm("");
|
||
setSelectedRows(new Set());
|
||
setSelectedColumns(new Set());
|
||
};
|
||
|
||
const initializeTablesFromSQL = (sqlContent, fileName = "") => {
|
||
|
||
const discoveredTables = parseMultiTableSQL(sqlContent);
|
||
const tableNames = Object.keys(discoveredTables);
|
||
|
||
|
||
if (tableNames.length === 0) {
|
||
console.error("❌ No tables found in SQL file");
|
||
setError("No tables found in SQL file");
|
||
return;
|
||
}
|
||
|
||
// Initialize table registry
|
||
const registry = {};
|
||
tableNames.forEach((tableName) => {
|
||
const tableInfo = discoveredTables[tableName];
|
||
registry[tableName] = {
|
||
data: tableInfo.data,
|
||
columns: tableInfo.columns,
|
||
modified: false,
|
||
originalSchema: tableInfo.schema,
|
||
originalData: [...tableInfo.data],
|
||
rowCount: tableInfo.rowCount,
|
||
};
|
||
});
|
||
|
||
setTableRegistry(registry);
|
||
setAvailableTables(tableNames);
|
||
setOriginalFileName(fileName.replace(/\.[^/.]+$/, "")); // Remove extension
|
||
|
||
// Load first table by default
|
||
const firstTable = tableNames[0];
|
||
setCurrentTable(firstTable);
|
||
setData(registry[firstTable].data);
|
||
setColumns(registry[firstTable].columns);
|
||
};
|
||
|
||
// Handle file upload
|
||
const handleFileUpload = (event) => {
|
||
const file = event.target.files[0];
|
||
if (!file) return;
|
||
|
||
setIsLoading(true);
|
||
setError("");
|
||
|
||
const reader = new FileReader();
|
||
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 {
|
||
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);
|
||
}
|
||
};
|
||
reader.readAsText(file);
|
||
};
|
||
|
||
// Filter and sort data
|
||
const filteredAndSortedData = React.useMemo(() => {
|
||
let filtered = data;
|
||
|
||
// Apply search filter
|
||
if (searchTerm) {
|
||
filtered = data.filter((row) =>
|
||
columns.some((col) =>
|
||
String(row[col.id] || "")
|
||
.toLowerCase()
|
||
.includes(searchTerm.toLowerCase()),
|
||
),
|
||
);
|
||
}
|
||
|
||
// Apply sorting
|
||
if (sortConfig.key) {
|
||
filtered = [...filtered].sort((a, b) => {
|
||
const aVal = a[sortConfig.key] || "";
|
||
const bVal = b[sortConfig.key] || "";
|
||
|
||
if (sortConfig.direction === "asc") {
|
||
return aVal.toString().localeCompare(bVal.toString());
|
||
} else {
|
||
return bVal.toString().localeCompare(aVal.toString());
|
||
}
|
||
});
|
||
}
|
||
|
||
return filtered;
|
||
}, [data, searchTerm, sortConfig, columns]);
|
||
|
||
// Handle sorting
|
||
const handleSort = (columnId) => {
|
||
setSortConfig((prev) => ({
|
||
key: columnId,
|
||
direction:
|
||
prev.key === columnId && prev.direction === "asc" ? "desc" : "asc",
|
||
}));
|
||
};
|
||
|
||
// Add new row
|
||
const addRow = () => {
|
||
const newRow = { id: `row_${Date.now()}` };
|
||
columns.forEach((col) => {
|
||
newRow[col.id] = "";
|
||
});
|
||
setData([...data, newRow]);
|
||
};
|
||
|
||
// Column resize functions
|
||
const getColumnWidth = (columnId) => {
|
||
return columnWidths[columnId] || 150; // Default width
|
||
};
|
||
|
||
const handleResizeStart = (e, columnId) => {
|
||
e.preventDefault();
|
||
const startX = e.clientX;
|
||
const startWidth = getColumnWidth(columnId);
|
||
|
||
setResizing({ columnId, startX, startWidth });
|
||
|
||
const handleMouseMove = (e) => {
|
||
if (!resizing && resizing?.columnId === columnId) return;
|
||
|
||
const deltaX = e.clientX - startX;
|
||
const newWidth = Math.max(50, startWidth + deltaX); // Minimum width of 50px
|
||
|
||
setColumnWidths(prev => ({
|
||
...prev,
|
||
[columnId]: newWidth
|
||
}));
|
||
};
|
||
|
||
const handleMouseUp = () => {
|
||
setResizing(null);
|
||
document.removeEventListener('mousemove', handleMouseMove);
|
||
document.removeEventListener('mouseup', handleMouseUp);
|
||
};
|
||
|
||
document.addEventListener('mousemove', handleMouseMove);
|
||
document.addEventListener('mouseup', handleMouseUp);
|
||
};
|
||
|
||
// Add new column
|
||
const addColumn = () => {
|
||
const newColumnId = `col_${Date.now()}`;
|
||
const newColumn = {
|
||
id: newColumnId,
|
||
name: `Column ${columns.length + 1}`,
|
||
type: "text",
|
||
};
|
||
|
||
setColumns([...columns, newColumn]);
|
||
|
||
// Add empty values for the new column in all existing rows
|
||
setData(
|
||
data.map((row) => ({
|
||
...row,
|
||
[newColumnId]: "",
|
||
})),
|
||
);
|
||
|
||
// Auto-trigger header editing for the new column
|
||
setEditingHeader(newColumnId);
|
||
|
||
// Auto-scroll to the right to show the new column and focus on header input
|
||
setTimeout(() => {
|
||
// Try multiple selectors to find the table container
|
||
const selectors = [
|
||
'[class*="overflow-auto"][class*="max-h-"]',
|
||
'.overflow-auto',
|
||
'div[class*="overflow-auto"]'
|
||
];
|
||
|
||
let tableContainer = null;
|
||
for (const selector of selectors) {
|
||
tableContainer = document.querySelector(selector);
|
||
if (tableContainer) break;
|
||
}
|
||
|
||
if (tableContainer) {
|
||
// Check if horizontal scrolling is needed
|
||
const needsScroll = tableContainer.scrollWidth > tableContainer.clientWidth;
|
||
if (needsScroll) {
|
||
// Smooth scroll to the far right to show the new column
|
||
tableContainer.scrollTo({
|
||
left: tableContainer.scrollWidth - tableContainer.clientWidth,
|
||
behavior: 'smooth'
|
||
});
|
||
}
|
||
}
|
||
|
||
// Focus on the header input field after scroll
|
||
setTimeout(() => {
|
||
const headerInput = document.querySelector(`input[value="${newColumn.name}"]`);
|
||
if (headerInput) {
|
||
headerInput.focus();
|
||
headerInput.select(); // Select all text for easy replacement
|
||
}
|
||
}, 100);
|
||
}, 200);
|
||
};
|
||
|
||
// Delete selected rows
|
||
const deleteSelectedRows = () => {
|
||
setData(data.filter((row) => !selectedRows.has(row.id)));
|
||
setSelectedRows(new Set());
|
||
};
|
||
|
||
// Delete selected columns
|
||
const deleteSelectedColumns = () => {
|
||
const remainingColumns = columns.filter(
|
||
(col) => !selectedColumns.has(col.id),
|
||
);
|
||
setColumns(remainingColumns);
|
||
|
||
// Remove deleted column data from all rows
|
||
setData(
|
||
data.map((row) => {
|
||
const newRow = { ...row };
|
||
selectedColumns.forEach((colId) => {
|
||
delete newRow[colId];
|
||
});
|
||
return newRow;
|
||
}),
|
||
);
|
||
|
||
setSelectedColumns(new Set());
|
||
};
|
||
|
||
// Handle cell edit with proper event handling
|
||
const handleCellEdit = (rowId, columnId, value) => {
|
||
setData(
|
||
data.map((row) =>
|
||
row.id === rowId ? { ...row, [columnId]: value } : row,
|
||
),
|
||
);
|
||
};
|
||
|
||
// Handle cell key events
|
||
const handleCellKeyDown = (e, rowId, columnId) => {
|
||
if (e.key === "Enter" || e.key === "Escape") {
|
||
setEditingCell(null);
|
||
}
|
||
};
|
||
|
||
// Handle header edit
|
||
const handleHeaderEdit = (columnId, newName) => {
|
||
setColumns(
|
||
columns.map((col) =>
|
||
col.id === columnId ? { ...col, name: newName } : col,
|
||
),
|
||
);
|
||
};
|
||
|
||
// Handle header key events
|
||
const handleHeaderKeyDown = (e, columnId) => {
|
||
if (e.key === "Enter" || e.key === "Escape") {
|
||
setEditingHeader(null);
|
||
}
|
||
};
|
||
|
||
// Export functions
|
||
const getExportData = (format) => {
|
||
if (data.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 = data.map((row) => {
|
||
return Object.fromEntries(
|
||
columns.map((col) => [col.name, row[col.id] || ""]),
|
||
);
|
||
});
|
||
return jsonFormat === "pretty"
|
||
? JSON.stringify(jsonData, null, 2)
|
||
: JSON.stringify(jsonData);
|
||
|
||
case "csv":
|
||
return Papa.unparse({
|
||
fields: columns.map((col) => col.name),
|
||
data: data.map((row) => columns.map((col) => row[col.id] || "")),
|
||
});
|
||
|
||
case "tsv":
|
||
return Papa.unparse(
|
||
{
|
||
fields: columns.map((col) => col.name),
|
||
data: data.map((row) => columns.map((col) => row[col.id] || "")),
|
||
},
|
||
{ delimiter: "\t" },
|
||
);
|
||
|
||
case "sql":
|
||
return generateMultiTableSQL();
|
||
|
||
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);
|
||
};
|
||
|
||
const generateMultiTableSQL = () => {
|
||
// Get current table state without triggering re-render
|
||
const currentTableData =
|
||
currentTable && tableRegistry[currentTable]
|
||
? {
|
||
...tableRegistry[currentTable],
|
||
data: data,
|
||
columns: columns,
|
||
modified: true,
|
||
}
|
||
: null;
|
||
|
||
const sqlParts = [];
|
||
const dbName = originalFileName || "database";
|
||
|
||
// Add header comment
|
||
sqlParts.push(`-- Database: ${dbName}`);
|
||
sqlParts.push(`-- Generated by Table Editor`);
|
||
sqlParts.push(`-- Export Date: ${new Date().toISOString()}`);
|
||
sqlParts.push("");
|
||
|
||
// If we have multiple tables, export all of them
|
||
if (availableTables.length > 1) {
|
||
sqlParts.push(`-- Tables: ${availableTables.join(", ")}`);
|
||
sqlParts.push("");
|
||
|
||
availableTables.forEach((tableName) => {
|
||
const tableData =
|
||
tableName === currentTable && currentTableData
|
||
? currentTableData
|
||
: tableRegistry[tableName];
|
||
if (!tableData) return;
|
||
|
||
const actualData = tableData.data;
|
||
const actualColumns = tableData.columns;
|
||
const isModified = tableData.modified;
|
||
|
||
sqlParts.push(`-- ================================================`);
|
||
sqlParts.push(
|
||
`-- Table: ${tableName} (${actualData.length} rows, ${actualColumns.length} columns)`,
|
||
);
|
||
sqlParts.push(`-- Status: ${isModified ? "Modified" : "Original"}`);
|
||
sqlParts.push(`-- ================================================`);
|
||
sqlParts.push("");
|
||
|
||
// Add DROP TABLE
|
||
sqlParts.push(`DROP TABLE IF EXISTS \`${tableName}\`;`);
|
||
sqlParts.push("");
|
||
|
||
// Add CREATE TABLE (use original schema if available)
|
||
if (tableData.originalSchema) {
|
||
sqlParts.push(tableData.originalSchema);
|
||
} else {
|
||
// Generate intelligent CREATE TABLE based on data analysis
|
||
sqlParts.push(
|
||
generateCreateTableStatement(
|
||
tableName,
|
||
actualColumns,
|
||
sqlPrimaryKey,
|
||
actualData,
|
||
),
|
||
);
|
||
}
|
||
sqlParts.push("");
|
||
|
||
// Add INSERT statements if there's data
|
||
if (actualData.length > 0) {
|
||
const columnNames = actualColumns.map((col) => col.name);
|
||
const insertStatements = actualData.map((row) => {
|
||
const values = actualColumns.map((col) => {
|
||
const value = row[col.id] || "";
|
||
if (value === "" || value === null || value === undefined)
|
||
return "NULL";
|
||
return typeof value === "string"
|
||
? `'${value.replace(/'/g, "''")}'`
|
||
: value;
|
||
});
|
||
return `INSERT INTO \`${tableName}\` (\`${columnNames.join("`, `")}\`) VALUES (${values.join(", ")});`;
|
||
});
|
||
|
||
sqlParts.push(
|
||
`INSERT INTO \`${tableName}\` (\`${columnNames.join("`, `")}\`) VALUES`,
|
||
);
|
||
insertStatements.forEach((stmt, index) => {
|
||
const values = stmt.match(/VALUES \((.+)\);/)[1];
|
||
sqlParts.push(
|
||
`(${values})${index === insertStatements.length - 1 ? ";" : ","}`,
|
||
);
|
||
});
|
||
} else {
|
||
sqlParts.push(`-- No data for table ${tableName}`);
|
||
}
|
||
|
||
sqlParts.push("");
|
||
});
|
||
} else {
|
||
// Single table export
|
||
const tableName =
|
||
sqlTableName || currentTable || originalFileName || "table_data";
|
||
const columnNames = columns.map((col) => col.name);
|
||
|
||
sqlParts.push(`-- Table: ${tableName}`);
|
||
sqlParts.push("");
|
||
sqlParts.push(`DROP TABLE IF EXISTS \`${tableName}\`;`);
|
||
sqlParts.push("");
|
||
|
||
// Use original schema if available
|
||
const singleTableData = currentTableData || tableRegistry[tableName];
|
||
if (singleTableData?.originalSchema) {
|
||
sqlParts.push(singleTableData.originalSchema);
|
||
} else {
|
||
sqlParts.push(
|
||
generateCreateTableStatement(tableName, columns, sqlPrimaryKey),
|
||
);
|
||
}
|
||
sqlParts.push("");
|
||
|
||
if (data.length > 0) {
|
||
const insertStatements = data.map((row) => {
|
||
const values = columns.map((col) => {
|
||
const value = row[col.id] || "";
|
||
if (value === "" || value === null || value === undefined)
|
||
return "NULL";
|
||
return typeof value === "string"
|
||
? `'${value.replace(/'/g, "''")}'`
|
||
: value;
|
||
});
|
||
return `(${values.join(", ")})`;
|
||
});
|
||
|
||
sqlParts.push(
|
||
`INSERT INTO \`${tableName}\` (\`${columnNames.join("`, `")}\`) VALUES`,
|
||
);
|
||
insertStatements.forEach((values, index) => {
|
||
sqlParts.push(
|
||
`${values}${index === insertStatements.length - 1 ? ";" : ","}`,
|
||
);
|
||
});
|
||
}
|
||
}
|
||
|
||
return sqlParts.join("\n");
|
||
};
|
||
|
||
// Intelligent column type detection for SQL export
|
||
const analyzeColumnData = (columnId, tableData) => {
|
||
const values = tableData
|
||
.map((row) => row[columnId])
|
||
.filter((val) => val !== null && val !== undefined && val !== "");
|
||
|
||
if (values.length === 0) {
|
||
return { type: "VARCHAR(100)", nullable: true, analysis: "Empty column" };
|
||
}
|
||
|
||
let maxLength = 0;
|
||
let hasJson = false;
|
||
let hasDate = false;
|
||
let hasNumber = false;
|
||
let hasEmail = false;
|
||
let hasUrl = false;
|
||
let hasPhone = false;
|
||
let allIntegers = true;
|
||
let dateFormats = new Set();
|
||
|
||
values.forEach((val) => {
|
||
const strVal = String(val);
|
||
maxLength = Math.max(maxLength, strVal.length);
|
||
|
||
// JSON detection
|
||
if (
|
||
(strVal.startsWith("{") && strVal.endsWith("}")) ||
|
||
(strVal.startsWith("[") && strVal.endsWith("]"))
|
||
) {
|
||
try {
|
||
JSON.parse(strVal);
|
||
hasJson = true;
|
||
} catch {}
|
||
}
|
||
|
||
// Date detection (various formats)
|
||
const datePatterns = [
|
||
/^\d{1,2}\s+(Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)\s+\d{4}$/i, // "17 Mar 2022"
|
||
/^\d{4}-\d{2}-\d{2}$/, // "2022-03-17"
|
||
/^\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2}$/, // "2022-03-17 10:30:00"
|
||
/^\d{1,2}\/\d{1,2}\/\d{4}$/, // "3/17/2022"
|
||
/^\d{2}-\d{2}-\d{4}$/, // "17-03-2022"
|
||
];
|
||
|
||
datePatterns.forEach((pattern, index) => {
|
||
if (pattern.test(strVal)) {
|
||
hasDate = true;
|
||
dateFormats.add(index);
|
||
}
|
||
});
|
||
|
||
// Number detection
|
||
if (!isNaN(strVal) && strVal.trim() !== "") {
|
||
hasNumber = true;
|
||
if (!Number.isInteger(Number(strVal))) {
|
||
allIntegers = false;
|
||
}
|
||
} else {
|
||
allIntegers = false;
|
||
}
|
||
|
||
// Email detection
|
||
if (strVal.includes("@") && strVal.includes(".")) {
|
||
hasEmail = true;
|
||
}
|
||
|
||
// URL detection
|
||
if (strVal.startsWith("http://") || strVal.startsWith("https://")) {
|
||
hasUrl = true;
|
||
}
|
||
|
||
// Phone detection (basic)
|
||
if (/^[+\d\s\-()]{10,}$/.test(strVal)) {
|
||
hasPhone = true;
|
||
}
|
||
});
|
||
|
||
const nullableCount = tableData.length - values.length;
|
||
const nullable = nullableCount > 0;
|
||
|
||
// Determine best column type
|
||
if (hasJson) {
|
||
return {
|
||
type: "JSON",
|
||
nullable,
|
||
analysis: `JSON data detected (max length: ${maxLength})`,
|
||
fallbackType:
|
||
maxLength > 65535
|
||
? "LONGTEXT"
|
||
: maxLength > 16777215
|
||
? "MEDIUMTEXT"
|
||
: "TEXT",
|
||
};
|
||
}
|
||
|
||
if (hasNumber && values.every((val) => !isNaN(val) && String(val).trim() !== "")) {
|
||
if (allIntegers) {
|
||
const maxVal = Math.max(...values.map((v) => Math.abs(Number(v))));
|
||
if (maxVal < 128)
|
||
return {
|
||
type: "TINYINT",
|
||
nullable,
|
||
analysis: "Small integers (-128 to 127)",
|
||
};
|
||
if (maxVal < 32768)
|
||
return {
|
||
type: "SMALLINT",
|
||
nullable,
|
||
analysis: "Small integers (-32,768 to 32,767)",
|
||
};
|
||
if (maxVal < 2147483648)
|
||
return { type: "INT", nullable, analysis: "Standard integers" };
|
||
return { type: "BIGINT", nullable, analysis: "Large integers" };
|
||
} else {
|
||
return { type: "DECIMAL(10,2)", nullable, analysis: "Decimal numbers" };
|
||
}
|
||
}
|
||
|
||
if (hasDate && dateFormats.size === 1) {
|
||
const formatNames = ["TEXT", "DATE", "DATETIME", "DATE", "DATE"];
|
||
const detectedFormat = Array.from(dateFormats)[0];
|
||
return {
|
||
type: formatNames[detectedFormat] || "TEXT",
|
||
nullable,
|
||
analysis: `Date format detected (pattern ${detectedFormat})`,
|
||
};
|
||
}
|
||
|
||
// Text-based analysis
|
||
if (maxLength <= 50) {
|
||
const size = Math.max(50, Math.ceil(maxLength * 1.2));
|
||
let analysis = `Short text (max: ${maxLength})`;
|
||
if (hasEmail) analysis += ", emails detected";
|
||
if (hasPhone) analysis += ", phone numbers detected";
|
||
return { type: `VARCHAR(${size})`, nullable, analysis };
|
||
}
|
||
|
||
if (maxLength <= 255) {
|
||
const size = Math.max(100, Math.ceil(maxLength * 1.2));
|
||
let analysis = `Medium text (max: ${maxLength})`;
|
||
if (hasUrl) analysis += ", URLs detected";
|
||
return { type: `VARCHAR(${size})`, nullable, analysis };
|
||
}
|
||
|
||
if (maxLength <= 1000) {
|
||
const size = Math.ceil(maxLength * 1.2);
|
||
return {
|
||
type: `VARCHAR(${size})`,
|
||
nullable,
|
||
analysis: `Long text (max: ${maxLength})`,
|
||
};
|
||
}
|
||
|
||
if (maxLength <= 65535) {
|
||
return {
|
||
type: "TEXT",
|
||
nullable,
|
||
analysis: `Very long text (max: ${maxLength})`,
|
||
};
|
||
}
|
||
|
||
if (maxLength <= 16777215) {
|
||
return {
|
||
type: "MEDIUMTEXT",
|
||
nullable,
|
||
analysis: `Extra long text (max: ${maxLength})`,
|
||
};
|
||
}
|
||
|
||
return {
|
||
type: "LONGTEXT",
|
||
nullable,
|
||
analysis: `Extremely long text (max: ${maxLength})`,
|
||
};
|
||
};
|
||
|
||
const generateCreateTableStatement = (
|
||
tableName,
|
||
tableColumns,
|
||
primaryKeyColumn = null,
|
||
tableData = data,
|
||
) => {
|
||
const analysisResults = [];
|
||
|
||
const columnDefs = tableColumns.map((col) => {
|
||
const analysis = analyzeColumnData(col.id, tableData);
|
||
analysisResults.push({ column: col.name, ...analysis });
|
||
|
||
let def = ` \`${col.name}\` ${analysis.type}`;
|
||
|
||
// Add PRIMARY KEY if this column is selected as primary key
|
||
if (primaryKeyColumn && col.name === primaryKeyColumn) {
|
||
def += " NOT NULL PRIMARY KEY";
|
||
} else {
|
||
def += analysis.nullable ? " DEFAULT NULL" : " NOT NULL";
|
||
}
|
||
|
||
return def;
|
||
});
|
||
|
||
// Store analysis for user display
|
||
window.lastSqlAnalysis = analysisResults;
|
||
|
||
return `CREATE TABLE \`${tableName}\` (\n${columnDefs.join(",\n")}\n) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;`;
|
||
};
|
||
|
||
const copyToClipboard = async (text) => {
|
||
try {
|
||
await navigator.clipboard.writeText(text);
|
||
} catch (err) {
|
||
console.error("Failed to copy:", err);
|
||
}
|
||
};
|
||
|
||
|
||
const createEmptyTable = () => {
|
||
const newTableName = "new_table";
|
||
const initialColumns = [
|
||
{ id: "col_0", name: "id" },
|
||
{ id: "col_1", name: "name" },
|
||
{ id: "col_2", name: "value" },
|
||
];
|
||
|
||
// Create one empty row to make the table visible
|
||
const initialData = [
|
||
{
|
||
id: Date.now(),
|
||
col_0: "",
|
||
col_1: "",
|
||
col_2: "",
|
||
},
|
||
];
|
||
|
||
setData(initialData);
|
||
setColumns(initialColumns);
|
||
setCurrentTable(newTableName);
|
||
setAvailableTables([newTableName]);
|
||
setTableRegistry({
|
||
[newTableName]: {
|
||
data: [],
|
||
columns: initialColumns,
|
||
modified: true,
|
||
originalSchema: "",
|
||
originalData: [],
|
||
rowCount: 0,
|
||
},
|
||
});
|
||
setInputText("");
|
||
setUrl("");
|
||
setError("");
|
||
setSearchTerm("");
|
||
setSortConfig({ key: null, direction: "asc" });
|
||
setSelectedRows(new Set());
|
||
setSelectedColumns(new Set());
|
||
setOriginalFileName("");
|
||
};
|
||
|
||
const clearData = () => {
|
||
// Show confirmation modal
|
||
setShowClearConfirmModal(true);
|
||
};
|
||
|
||
|
||
return (
|
||
<>
|
||
<SEO
|
||
title="Excel-Like Table Editor - Import CSV, JSON, Excel Online"
|
||
description="✓ Import CSV, JSON, Excel ✓ Visual editing ✓ Export to multiple formats ✓ Sort & filter ✓ No installation. Edit tables online now!"
|
||
keywords="table editor, csv editor, excel online, json to csv, csv to json, data editor, spreadsheet editor, online table, sql editor, database editor"
|
||
path="/table-editor"
|
||
toolId="table-editor"
|
||
/>
|
||
<ToolLayout
|
||
title="Table Editor"
|
||
description="Import, edit, and export tabular data from various sources"
|
||
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">
|
||
{/* Tabs */}
|
||
<div className="flex border-b border-gray-200 dark:border-gray-700 overflow-x-auto scrollbar-hide">
|
||
<div className="flex min-w-max">
|
||
<button
|
||
onClick={() => handleTabChange("create")}
|
||
className={`flex items-center gap-1 sm:gap-2 px-3 sm:px-4 py-3 text-sm font-medium transition-colors whitespace-nowrap ${
|
||
activeTab === "create"
|
||
? "bg-blue-50 dark:bg-blue-900/20 text-blue-700 dark:text-blue-300 border-b-2 border-blue-500"
|
||
: "text-gray-600 dark:text-gray-600 hover:text-gray-900 dark:hover:text-gray-200"
|
||
}`}
|
||
>
|
||
<Plus className="h-4 w-4" />
|
||
Create New
|
||
</button>
|
||
<button
|
||
onClick={() => handleTabChange("url")}
|
||
className={`flex items-center gap-1 sm:gap-2 px-3 sm:px-4 py-3 text-sm font-medium transition-colors whitespace-nowrap ${
|
||
activeTab === "url"
|
||
? "bg-blue-50 dark:bg-blue-900/20 text-blue-700 dark:text-blue-300 border-b-2 border-blue-500"
|
||
: "text-gray-600 dark:text-gray-600 hover:text-gray-900 dark:hover:text-gray-200"
|
||
}`}
|
||
>
|
||
<Globe className="h-4 w-4" />
|
||
URL
|
||
</button>
|
||
<button
|
||
onClick={() => handleTabChange("paste")}
|
||
className={`flex items-center gap-1 sm:gap-2 px-3 sm:px-4 py-3 text-sm font-medium transition-colors whitespace-nowrap ${
|
||
activeTab === "paste"
|
||
? "bg-blue-50 dark:bg-blue-900/20 text-blue-700 dark:text-blue-300 border-b-2 border-blue-500"
|
||
: "text-gray-600 dark:text-gray-600 hover:text-gray-900 dark:hover:text-gray-200"
|
||
}`}
|
||
>
|
||
<FileText className="h-4 w-4" />
|
||
Paste
|
||
</button>
|
||
<button
|
||
onClick={() => handleTabChange("upload")}
|
||
className={`flex items-center gap-1 sm:gap-2 px-3 sm:px-4 py-3 text-sm font-medium transition-colors whitespace-nowrap ${
|
||
activeTab === "upload"
|
||
? "bg-blue-50 dark:bg-blue-900/20 text-blue-700 dark:text-blue-300 border-b-2 border-blue-500"
|
||
: "text-gray-600 dark:text-gray-600 hover:text-gray-900 dark:hover:text-gray-200"
|
||
}`}
|
||
>
|
||
<Upload className="h-4 w-4" />
|
||
Open
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Tab Content */}
|
||
{(activeTab !== "create" || !createNewCompleted) && (
|
||
<div className="p-4">
|
||
{activeTab === "create" && !createNewCompleted && (
|
||
<div className="space-y-4">
|
||
<div className="text-center">
|
||
<h3 className="text-lg font-medium text-gray-900 dark:text-gray-100 mb-2">
|
||
Start Building Your Table
|
||
</h3>
|
||
<p className="text-sm text-gray-600 dark:text-gray-600 mb-4">
|
||
Choose how you'd like to begin working with your data
|
||
</p>
|
||
</div>
|
||
|
||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||
<button
|
||
onClick={() => {
|
||
if (hasModifiedData()) {
|
||
setPendingTabChange("create_empty");
|
||
setShowInputChangeModal(true);
|
||
} else {
|
||
createEmptyTable();
|
||
setCreateNewCompleted(true);
|
||
}
|
||
}}
|
||
className="flex flex-col items-center p-6 border-2 border-dashed border-gray-300 dark:border-gray-600 rounded-lg hover:border-blue-500 dark:hover:border-blue-400 hover:bg-blue-50 dark:hover:bg-blue-900/20 transition-colors group"
|
||
>
|
||
<Plus className="h-8 w-8 text-gray-600 group-hover:text-blue-500 dark:group-hover:text-blue-400 mb-2" />
|
||
<span className="font-medium text-gray-900 dark:text-gray-100 group-hover:text-blue-600 dark:group-hover:text-blue-400">
|
||
Start Empty
|
||
</span>
|
||
<span className="text-xs text-gray-600 dark:text-gray-600 text-center mt-1">
|
||
Create a blank table with basic columns
|
||
</span>
|
||
</button>
|
||
|
||
<button
|
||
onClick={() => {
|
||
if (hasModifiedData()) {
|
||
setPendingTabChange("create_sample");
|
||
setShowInputChangeModal(true);
|
||
} else {
|
||
// Load sample data
|
||
const sampleData = [
|
||
{
|
||
id: 1,
|
||
name: "John Doe",
|
||
email: "john@example.com",
|
||
age: 30,
|
||
city: "New York",
|
||
},
|
||
{
|
||
id: 2,
|
||
name: "Jane Smith",
|
||
email: "jane@example.com",
|
||
age: 25,
|
||
city: "Los Angeles",
|
||
},
|
||
{
|
||
id: 3,
|
||
name: "Bob Johnson",
|
||
email: "bob@example.com",
|
||
age: 35,
|
||
city: "Chicago",
|
||
},
|
||
{
|
||
id: 4,
|
||
name: "Alice Brown",
|
||
email: "alice@example.com",
|
||
age: 28,
|
||
city: "Houston",
|
||
},
|
||
];
|
||
|
||
const sampleColumns = [
|
||
{ id: "col_0", name: "id" },
|
||
{ id: "col_1", name: "name" },
|
||
{ id: "col_2", name: "email" },
|
||
{ id: "col_3", name: "age" },
|
||
{ id: "col_4", name: "city" },
|
||
];
|
||
|
||
const formattedData = sampleData.map((row, index) => ({
|
||
id: `row_${index}`,
|
||
col_0: row.id,
|
||
col_1: row.name,
|
||
col_2: row.email,
|
||
col_3: row.age,
|
||
col_4: row.city,
|
||
}));
|
||
|
||
setData(formattedData);
|
||
setColumns(sampleColumns);
|
||
setCurrentTable("sample_data");
|
||
setOriginalFileName("sample_data");
|
||
setCreateNewCompleted(true);
|
||
}
|
||
}}
|
||
className="flex flex-col items-center p-6 border-2 border-dashed border-gray-300 dark:border-gray-600 rounded-lg hover:border-green-500 dark:hover:border-green-400 hover:bg-green-50 dark:hover:bg-green-900/20 transition-colors group"
|
||
>
|
||
<FileText className="h-8 w-8 text-gray-600 group-hover:text-green-500 dark:group-hover:text-green-400 mb-2" />
|
||
<span className="font-medium text-gray-900 dark:text-gray-100 group-hover:text-green-600 dark:group-hover:text-green-400">
|
||
Load Sample
|
||
</span>
|
||
<span className="text-xs text-gray-600 dark:text-gray-600 text-center mt-1">
|
||
Start with example data to explore features
|
||
</span>
|
||
</button>
|
||
</div>
|
||
|
||
<div className="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-3">
|
||
<p className="text-xs text-blue-700 dark:text-blue-300">
|
||
💡 <strong>Tip:</strong> You can always import data later
|
||
using the URL, Paste, or Open tabs, or start editing
|
||
directly in the table below.
|
||
</p>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{activeTab === "url" && (
|
||
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>
|
||
</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-600 hover:text-gray-600 dark:text-gray-600 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-600">
|
||
<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 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 dark:text-blue-400 hover:underline whitespace-nowrap"
|
||
>
|
||
Edit Input ▼
|
||
</button>
|
||
</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-600">
|
||
<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" && (
|
||
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="file"
|
||
accept=".csv,.tsv,.json,.sql"
|
||
onChange={handleFileUpload}
|
||
className="tool-input"
|
||
/>
|
||
<label className="flex items-center text-sm text-gray-600 dark:text-gray-600">
|
||
<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 className="bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-md p-3">
|
||
<p className="text-xs text-green-700 dark:text-green-300">
|
||
🔒 <strong>Privacy:</strong> Your data stays in your
|
||
browser. We don't store or upload anything - just help you
|
||
open, edit, and export your files locally.
|
||
</p>
|
||
</div>
|
||
</div>
|
||
)
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{data.length > 0 && (
|
||
<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-[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">
|
||
<div className="flex items-center justify-between">
|
||
<div className="flex items-center gap-4 flex-1 min-w-0">
|
||
<div className="flex gap-2 items-center">
|
||
<Database className="h-5 w-5 text-blue-600 dark:text-blue-400" />
|
||
<div className="flex-col gap-2">
|
||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white flex items-center gap-2">
|
||
{availableTables.length > 1 ? "Multi-Table Database" : "Table Editor"}
|
||
</h3>
|
||
{availableTables.length === 1 && (
|
||
<p className="text-sm text-gray-600 dark:text-gray-600">
|
||
{data.length} rows, {columns.length} columns
|
||
</p>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Action Tabs - Right Side */}
|
||
<div className="flex border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
|
||
<button
|
||
onClick={() => setIsTableFullscreen(!isTableFullscreen)}
|
||
className={`flex items-center gap-2 px-3 py-2 text-sm font-medium transition-colors ${
|
||
isTableFullscreen
|
||
? "bg-blue-50 dark:bg-blue-900/20 text-blue-700 dark:text-blue-300"
|
||
: "text-gray-600 dark:text-gray-600 hover:text-gray-900 dark:hover:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-700"
|
||
}`}
|
||
>
|
||
{isTableFullscreen ? (
|
||
<Minimize2 className="h-4 w-4" />
|
||
) : (
|
||
<Maximize2 className="h-4 w-4" />
|
||
)}
|
||
<span className="hidden sm:inline">
|
||
{isTableFullscreen ? "Exit Fullscreen" : "Fullscreen"}
|
||
</span>
|
||
</button>
|
||
<button
|
||
onClick={clearData}
|
||
className="flex items-center gap-2 px-3 py-2 text-sm font-medium transition-colors border-l border-gray-200 dark:border-gray-700 text-gray-600 dark:text-gray-600 hover:text-gray-900 dark:hover:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-700"
|
||
>
|
||
<AlertTriangle className="h-4 w-4" />
|
||
<span className="hidden sm:inline">Clear All</span>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Table Selector */}
|
||
{availableTables.length > 1 && (
|
||
<div className="px-4 py-3 flex flex-col sm:flex-row items-start sm:items-center gap-3 sm:gap-4 border-b border-gray-200 dark:border-gray-700 justify-between">
|
||
<div className="flex items-center gap-2 w-full sm:max-w-1/2">
|
||
<span className="text-sm text-gray-600 dark:text-gray-600 whitespace-nowrap hidden sm:inline">
|
||
Current Table:
|
||
</span>
|
||
<select
|
||
value={currentTable}
|
||
onChange={(e) => switchToTable(e.target.value)}
|
||
className="tool-input text-sm py-1 px-2 w-full sm:min-w-48"
|
||
>
|
||
{availableTables.map((tableName) => {
|
||
const tableData = tableRegistry[tableName];
|
||
const isModified =
|
||
tableName === currentTable || tableData?.modified;
|
||
return (
|
||
<option key={tableName} value={tableName}>
|
||
{tableName} ({tableData?.rowCount || 0} rows){" "}
|
||
{isModified ? "✓" : ""}
|
||
</option>
|
||
);
|
||
})}
|
||
</select>
|
||
</div>
|
||
<p className="text-sm text-gray-600 dark:text-gray-600">
|
||
{data.length} rows, {columns.length} columns
|
||
</p>
|
||
</div>
|
||
)}
|
||
|
||
{/* Table Body - edge to edge */}
|
||
<div className="flex flex-col h-full min-w-0">
|
||
{/* Controls */}
|
||
<div className="px-4 py-3 flex flex-col sm:flex-row items-start sm:items-center gap-3 sm:gap-4 border-b border-gray-200 dark:border-gray-700">
|
||
{/* Search Bar */}
|
||
<div className="relative w-full">
|
||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-600" />
|
||
<input
|
||
type="text"
|
||
value={searchTerm}
|
||
onChange={(e) => setSearchTerm(e.target.value)}
|
||
placeholder="Search data..."
|
||
className="tool-input pl-10 w-full"
|
||
/>
|
||
</div>
|
||
|
||
{/* Action Buttons Row */}
|
||
<div className="flex items-center gap-2 w-full justify-between">
|
||
<div className="flex items-center gap-2">
|
||
{selectedRows.size > 0 && (
|
||
<button
|
||
onClick={deleteSelectedRows}
|
||
className="tool-button-secondary flex items-center gap-1 text-red-600 text-sm px-2 py-1 sm:px-3 sm:py-2"
|
||
>
|
||
<Trash2 className="h-4 w-4" />
|
||
<span className="hidden xs:inline">Delete Rows</span>
|
||
<span className="xs:hidden">Rows</span>
|
||
<span className="ml-1">({selectedRows.size})</span>
|
||
</button>
|
||
)}
|
||
|
||
{selectedColumns.size > 0 && (
|
||
<button
|
||
onClick={deleteSelectedColumns}
|
||
className="tool-button-secondary flex items-center gap-1 text-red-600 text-sm px-2 py-1 sm:px-3 sm:py-2"
|
||
>
|
||
<Trash2 className="h-4 w-4" />
|
||
<span className="hidden xs:inline">Delete Columns</span>
|
||
<span className="xs:hidden">Cols</span>
|
||
<span className="ml-1">({selectedColumns.size})</span>
|
||
</button>
|
||
)}
|
||
</div>
|
||
|
||
{/* Freeze Columns Control */}
|
||
<div className="flex items-center gap-2">
|
||
<span className="text-xs sm:text-sm text-gray-600 dark:text-gray-600 whitespace-nowrap">
|
||
Freeze:
|
||
</span>
|
||
<select
|
||
value={frozenColumns}
|
||
onChange={(e) => setFrozenColumns(parseInt(e.target.value))}
|
||
className="tool-input text-xs sm:text-sm py-1 px-1 sm:px-2 w-16 sm:w-20"
|
||
title="Number of columns to freeze on horizontal scroll"
|
||
>
|
||
<option value={0}>None</option>
|
||
{columns.map((_, index) => (
|
||
<option key={index} value={index + 1}>
|
||
{index + 1} col{index === 0 ? "" : "s"}
|
||
</option>
|
||
))}
|
||
</select>
|
||
{frozenColumns > 0 && (
|
||
<div className="flex items-center gap-1 text-xs text-blue-600 dark:text-blue-400">
|
||
<div className="w-3 h-3 border-r-2 border-blue-500"></div>
|
||
<span>Frozen</span>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Table */}
|
||
<div
|
||
className={`overflow-auto w-full ${isTableFullscreen ? "max-h-[calc(100vh-200px)]" : "max-h-[500px]"}`}
|
||
style={{ maxWidth: '100%' }}
|
||
>
|
||
<table className="w-full">
|
||
<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-600 dark:text-gray-300 tracking-wider border-r border-gray-200 dark:border-gray-600 ${
|
||
frozenColumns > 0
|
||
? "sticky left-0 z-20 bg-blue-50 dark:!bg-blue-900"
|
||
: ""
|
||
}`}
|
||
style={{ width: '40px', maxWidth: '40px', minWidth: '40px' }}
|
||
>
|
||
<input
|
||
type="checkbox"
|
||
checked={
|
||
selectedRows.size === filteredAndSortedData.length &&
|
||
filteredAndSortedData.length > 0
|
||
}
|
||
onChange={(e) => {
|
||
if (e.target.checked) {
|
||
setSelectedRows(
|
||
new Set(
|
||
filteredAndSortedData.map((row) => row.id),
|
||
),
|
||
);
|
||
} else {
|
||
setSelectedRows(new Set());
|
||
}
|
||
}}
|
||
className="rounded border-gray-300 dark:border-gray-600"
|
||
/>
|
||
</th>
|
||
{columns.map((column, index) => {
|
||
const isFrozen = index < frozenColumns;
|
||
const leftOffset = isFrozen
|
||
? 40 + columns.slice(0, index).reduce((acc, col) => acc + getColumnWidth(col.id), 0)
|
||
: 0;
|
||
|
||
return (
|
||
<th
|
||
key={column.id}
|
||
className={`relative px-4 py-3 text-left text-sm font-medium text-gray-600 dark:text-gray-300 tracking-wider hover:bg-gray-100 dark:hover:bg-gray-600 border-r border-gray-200 dark:border-gray-600 ${
|
||
isFrozen
|
||
? "sticky z-20 bg-blue-50 dark:!bg-blue-900"
|
||
: ""
|
||
}`}
|
||
style={{
|
||
width: `${getColumnWidth(column.id)}px`,
|
||
minWidth: `${getColumnWidth(column.id)}px`,
|
||
maxWidth: `${getColumnWidth(column.id)}px`,
|
||
...(isFrozen ? { left: `${leftOffset}px` } : {})
|
||
}}
|
||
>
|
||
<div className="flex items-center gap-2">
|
||
<input
|
||
type="checkbox"
|
||
checked={selectedColumns.has(column.id)}
|
||
onChange={(e) => {
|
||
const newSelected = new Set(selectedColumns);
|
||
if (e.target.checked) {
|
||
newSelected.add(column.id);
|
||
} else {
|
||
newSelected.delete(column.id);
|
||
}
|
||
setSelectedColumns(newSelected);
|
||
}}
|
||
onClick={(e) => e.stopPropagation()}
|
||
className="mr-2"
|
||
/>
|
||
<div className="flex items-center gap-2 flex-1 min-w-0">
|
||
{editingHeader === column.id ? (
|
||
<input
|
||
type="text"
|
||
value={column.name}
|
||
onChange={(e) =>
|
||
handleHeaderEdit(column.id, e.target.value)
|
||
}
|
||
onBlur={() => setEditingHeader(null)}
|
||
onKeyDown={(e) =>
|
||
handleHeaderKeyDown(e, column.id)
|
||
}
|
||
className="flex-1 bg-transparent border-b border-blue-500 focus:outline-none text-gray-900 dark:text-gray-100 font-medium"
|
||
autoFocus
|
||
/>
|
||
) : (
|
||
<span
|
||
className="truncate whitespace-nowrap cursor-pointer flex-1 text-gray-900 dark:text-gray-100 font-medium hover:text-blue-600 dark:hover:text-blue-400"
|
||
onClick={() => setEditingHeader(column.id)}
|
||
>
|
||
{column.name}
|
||
</span>
|
||
)}
|
||
<button
|
||
onClick={() => handleSort(column.id)}
|
||
className="p-1 rounded hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors"
|
||
title="Sort by this column"
|
||
>
|
||
<ArrowUpDown
|
||
className={`h-4 w-4 flex-shrink-0 ${
|
||
sortConfig.key === column.id
|
||
? "text-blue-600 dark:text-blue-400"
|
||
: "text-gray-600 hover:text-gray-600 dark:hover:text-gray-300"
|
||
}`}
|
||
/>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Resize handle */}
|
||
<div
|
||
className="absolute right-0 top-0 bottom-0 w-1 cursor-col-resize hover:bg-blue-500 hover:w-1.5 transition-all z-30"
|
||
onMouseDown={(e) => handleResizeStart(e, column.id)}
|
||
title="Drag to resize column"
|
||
/>
|
||
</th>
|
||
);
|
||
})}
|
||
|
||
{/* System Column - Add Column */}
|
||
<th className="px-4 py-3 text-center border-l-2 border-dashed border-gray-300 dark:border-gray-600 hover:bg-blue-50 dark:hover:bg-blue-900/20 w-[60px]">
|
||
<button
|
||
onClick={addColumn}
|
||
className="flex items-center justify-center text-gray-600 hover:text-blue-600 p-2 rounded-lg transition-colors group"
|
||
title="Add new column"
|
||
>
|
||
<Plus className="h-4 w-4 group-hover:scale-110 transition-transform" />
|
||
</button>
|
||
</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody className="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700">
|
||
{filteredAndSortedData.map((row) => (
|
||
<tr
|
||
key={row.id}
|
||
className="hover:bg-gray-50 dark:hover:bg-gray-700"
|
||
>
|
||
<td
|
||
className={`px-4 py-3 border-r border-gray-200 dark:border-gray-600 ${
|
||
frozenColumns > 0
|
||
? "sticky left-0 z-10 bg-blue-50 dark:!bg-blue-900"
|
||
: ""
|
||
}`}
|
||
style={{ width: '40px', maxWidth: '40px', minWidth: '40px' }}
|
||
>
|
||
<input
|
||
type="checkbox"
|
||
checked={selectedRows.has(row.id)}
|
||
onChange={(e) => {
|
||
const newSelected = new Set(selectedRows);
|
||
if (e.target.checked) {
|
||
newSelected.add(row.id);
|
||
} else {
|
||
newSelected.delete(row.id);
|
||
}
|
||
setSelectedRows(newSelected);
|
||
}}
|
||
className="rounded border-gray-300 dark:border-gray-600"
|
||
/>
|
||
</td>
|
||
{columns.map((column, index) => {
|
||
const isFrozen = index < frozenColumns;
|
||
const leftOffset = isFrozen
|
||
? 40 + columns.slice(0, index).reduce((acc, col) => acc + getColumnWidth(col.id), 0)
|
||
: 0;
|
||
|
||
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 overflow-hidden ${
|
||
isFrozen
|
||
? "sticky z-10 bg-blue-50 dark:!bg-blue-900"
|
||
: ""
|
||
}`}
|
||
style={{
|
||
width: `${getColumnWidth(column.id)}px`,
|
||
minWidth: `${getColumnWidth(column.id)}px`,
|
||
maxWidth: `${getColumnWidth(column.id)}px`,
|
||
...(isFrozen ? { left: `${leftOffset}px` } : {})
|
||
}}
|
||
>
|
||
{editingCell?.rowId === row.id &&
|
||
editingCell?.columnId === column.id ? (
|
||
(() => {
|
||
const cellValue = String(row[column.id] || "");
|
||
const isLongValue =
|
||
cellValue.length > 100 ||
|
||
cellValue.includes("\n");
|
||
|
||
if (isLongValue) {
|
||
return (
|
||
<textarea
|
||
value={cellValue}
|
||
onChange={(e) =>
|
||
handleCellEdit(
|
||
row.id,
|
||
column.id,
|
||
e.target.value,
|
||
)
|
||
}
|
||
onBlur={() => setEditingCell(null)}
|
||
onKeyDown={(e) => {
|
||
if (e.key === "Escape") {
|
||
setEditingCell(null);
|
||
}
|
||
// Don't close on Enter for multiline
|
||
}}
|
||
className="w-full min-h-20 max-h-32 bg-white dark:bg-gray-700 border border-blue-500 rounded p-2 focus:outline-none focus:ring-2 focus:ring-blue-500 resize-y"
|
||
autoFocus
|
||
/>
|
||
);
|
||
} else {
|
||
return (
|
||
<input
|
||
type="text"
|
||
value={cellValue}
|
||
onChange={(e) =>
|
||
handleCellEdit(
|
||
row.id,
|
||
column.id,
|
||
e.target.value,
|
||
)
|
||
}
|
||
onBlur={() => setEditingCell(null)}
|
||
onKeyDown={(e) =>
|
||
handleCellKeyDown(e, row.id, column.id)
|
||
}
|
||
className="w-full bg-transparent border-b border-blue-500 focus:outline-none"
|
||
autoFocus
|
||
/>
|
||
);
|
||
}
|
||
})()
|
||
) : (
|
||
<div className="relative group">
|
||
{(() => {
|
||
const format = detectCellFormat(
|
||
row[column.id],
|
||
);
|
||
const cellValue = String(row[column.id] || "");
|
||
const isLongValue = cellValue.length > 50;
|
||
|
||
if (format) {
|
||
// For JSON/structured data, click to open Object Editor
|
||
return (
|
||
<div className="flex items-center gap-2 p-1">
|
||
<span className="text-xs px-1.5 py-0.5 bg-blue-50 dark:bg-blue-900/30 text-blue-600 dark:text-blue-400 rounded font-medium">
|
||
OBJ
|
||
</span>
|
||
<div
|
||
className="flex-1 p-1 rounded truncate cursor-pointer hover:bg-blue-50 dark:hover:bg-blue-900/20 text-sm transition-colors"
|
||
onClick={() =>
|
||
openObjectEditor(
|
||
row.id,
|
||
column.id,
|
||
cellValue,
|
||
format,
|
||
)
|
||
}
|
||
title={`${format.type.toUpperCase()} data - Click to edit with Object Editor`}
|
||
>
|
||
{cellValue.length > 40
|
||
? cellValue.substring(0, 40) + "..."
|
||
: cellValue}
|
||
</div>
|
||
</div>
|
||
);
|
||
} else {
|
||
// 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 overflow-hidden"
|
||
onClick={() =>
|
||
setEditingCell({
|
||
rowId: row.id,
|
||
columnId: column.id,
|
||
})
|
||
}
|
||
title={
|
||
isLongValue ? cellValue : undefined
|
||
}
|
||
>
|
||
<span className="truncate block w-full">
|
||
{cellValue || (
|
||
<span className="text-gray-600 dark:text-gray-600 italic text-sm">
|
||
Click to edit
|
||
</span>
|
||
)}
|
||
</span>
|
||
</div>
|
||
);
|
||
}
|
||
})()}
|
||
</div>
|
||
)}
|
||
</td>
|
||
);
|
||
})}
|
||
|
||
{/* Empty cell for system column alignment */}
|
||
<td className="px-4 py-3 border-l-2 border-dashed border-gray-300 dark:border-gray-600 w-[60px]">
|
||
{/* Empty cell to align with system column header */}
|
||
</td>
|
||
</tr>
|
||
))}
|
||
|
||
{/* System Row - Add Row */}
|
||
<tr className="border-t-2 border-dashed border-gray-300 dark:border-gray-600">
|
||
<td
|
||
colSpan={columns.length + 1}
|
||
className="py-4 px-4 relative"
|
||
>
|
||
<button
|
||
onClick={addRow}
|
||
className="flex items-center justify-center gap-2 text-gray-600 hover:text-blue-600 px-3 py-2 rounded-lg transition-colors group whitespace-nowrap sticky left-4"
|
||
title="Add new row"
|
||
>
|
||
<Plus className="h-4 w-4 group-hover:scale-110 transition-transform" />
|
||
<span className="text-sm font-medium">Add Row</span>
|
||
</button>
|
||
</td>
|
||
</tr>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Object Editor Modal */}
|
||
{objectEditorModal && (
|
||
<ObjectEditorModal
|
||
modal={objectEditorModal}
|
||
onClose={closeObjectEditor}
|
||
onApply={applyObjectEditorChanges}
|
||
/>
|
||
)}
|
||
|
||
{/* Clear Confirmation Modal */}
|
||
{showClearConfirmModal && (
|
||
<ClearConfirmationModal
|
||
tableCount={availableTables.length}
|
||
rowCount={data.length}
|
||
columnCount={columns.length}
|
||
tableName={currentTable || originalFileName || "table"}
|
||
onConfirm={() => {
|
||
clearAllData();
|
||
setShowClearConfirmModal(false);
|
||
}}
|
||
onCancel={() => setShowClearConfirmModal(false)}
|
||
/>
|
||
)}
|
||
|
||
{/* Input Method Change Confirmation Modal */}
|
||
{showInputChangeModal && (
|
||
<InputChangeConfirmationModal
|
||
tableCount={availableTables.length}
|
||
rowCount={data.length}
|
||
columnCount={columns.length}
|
||
currentMethod={activeTab}
|
||
newMethod={pendingTabChange}
|
||
onConfirm={confirmInputChange}
|
||
onCancel={() => {
|
||
// If user cancels while on Create New tab with modified data, hide the tab content
|
||
if (activeTab === "create" && hasModifiedData()) {
|
||
setCreateNewCompleted(true);
|
||
}
|
||
setShowInputChangeModal(false);
|
||
setPendingTabChange(null);
|
||
}}
|
||
/>
|
||
)}
|
||
|
||
{/* 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 - 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 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
|
||
{exportExpanded ? <ChevronUp className="h-4 w-4" /> : <ChevronDown className="h-4 w-4" />}
|
||
</h3>
|
||
<div className="text-sm text-gray-600 dark:text-gray-600">
|
||
{availableTables.length > 1 ? (
|
||
<span>
|
||
Database: {originalFileName || "Multi-table"} (
|
||
{availableTables.length} tables)
|
||
</span>
|
||
) : (
|
||
<span>
|
||
Table: {currentTable || "Data"} ({data.length} rows,{" "}
|
||
{columns.length} columns)
|
||
</span>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Export Content - Collapsible */}
|
||
{exportExpanded && (
|
||
<div>
|
||
|
||
{/* Export Tabs */}
|
||
<div className="flex border-b border-gray-200 dark:border-gray-700">
|
||
<button
|
||
onClick={() => setExportTab("json")}
|
||
className={`flex items-center gap-1 sm:gap-2 px-3 sm:px-4 py-3 text-sm font-medium transition-colors whitespace-nowrap ${
|
||
exportTab === "json"
|
||
? "bg-blue-50 dark:bg-blue-900/20 text-blue-700 dark:text-blue-300 border-b-2 border-blue-500"
|
||
: "text-gray-600 dark:text-gray-600 hover:text-gray-900 dark:hover:text-gray-200"
|
||
}`}
|
||
>
|
||
<Braces className="h-4 w-4" />
|
||
JSON
|
||
</button>
|
||
<button
|
||
onClick={() => setExportTab("csv")}
|
||
className={`flex items-center gap-1 sm:gap-2 px-3 sm:px-4 py-3 text-sm font-medium transition-colors whitespace-nowrap ${
|
||
exportTab === "csv"
|
||
? "bg-blue-50 dark:bg-blue-900/20 text-blue-700 dark:text-blue-300 border-b-2 border-blue-500"
|
||
: "text-gray-600 dark:text-gray-600 hover:text-gray-900 dark:hover:text-gray-200"
|
||
}`}
|
||
>
|
||
<FileText className="h-4 w-4" />
|
||
CSV
|
||
</button>
|
||
<button
|
||
onClick={() => setExportTab("tsv")}
|
||
className={`flex items-center gap-1 sm:gap-2 px-3 sm:px-4 py-3 text-sm font-medium transition-colors whitespace-nowrap ${
|
||
exportTab === "tsv"
|
||
? "bg-blue-50 dark:bg-blue-900/20 text-blue-700 dark:text-blue-300 border-b-2 border-blue-500"
|
||
: "text-gray-600 dark:text-gray-600 hover:text-gray-900 dark:hover:text-gray-200"
|
||
}`}
|
||
>
|
||
<FileText className="h-4 w-4" />
|
||
TSV
|
||
</button>
|
||
<button
|
||
onClick={() => setExportTab("sql")}
|
||
className={`flex items-center gap-1 sm:gap-2 px-3 sm:px-4 py-3 text-sm font-medium transition-colors whitespace-nowrap ${
|
||
exportTab === "sql"
|
||
? "bg-blue-50 dark:bg-blue-900/20 text-blue-700 dark:text-blue-300 border-b-2 border-blue-500"
|
||
: "text-gray-600 dark:text-gray-600 hover:text-gray-900 dark:hover:text-gray-200"
|
||
}`}
|
||
>
|
||
<Database className="h-4 w-4" />
|
||
SQL
|
||
</button>
|
||
</div>
|
||
|
||
{/* Export Content */}
|
||
<div className="p-4">
|
||
{exportTab === "json" && (
|
||
<div className="space-y-3">
|
||
<CodeMirrorEditor
|
||
value={getExportData("json")}
|
||
language="json"
|
||
readOnly={true}
|
||
maxLines={12}
|
||
showToggle={true}
|
||
className="w-full"
|
||
cardRef={exportCardRef}
|
||
/>
|
||
<div className="flex items-center justify-between">
|
||
<div className="flex items-center gap-2">
|
||
<button
|
||
onClick={() => setJsonFormat("pretty")}
|
||
className={`px-3 py-1 text-sm rounded ${
|
||
jsonFormat === "pretty"
|
||
? "bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300"
|
||
: "bg-gray-100 text-gray-600 dark:bg-gray-600 dark:text-gray-300"
|
||
}`}
|
||
>
|
||
Pretty
|
||
</button>
|
||
<button
|
||
onClick={() => setJsonFormat("minify")}
|
||
className={`px-3 py-1 text-sm rounded ${
|
||
jsonFormat === "minify"
|
||
? "bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300"
|
||
: "bg-gray-100 text-gray-600 dark:bg-gray-600 dark:text-gray-300"
|
||
}`}
|
||
>
|
||
Minify
|
||
</button>
|
||
</div>
|
||
<div className="flex gap-2">
|
||
<button
|
||
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"
|
||
>
|
||
{copiedButton === 'json' ? '✓ Copied!' : 'Copy'}
|
||
</button>
|
||
<button
|
||
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"
|
||
>
|
||
{downloadedButton === 'json' ? '✓ Downloaded!' : 'Download'}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{exportTab === "csv" && (
|
||
<div className="space-y-3">
|
||
<CodeMirrorEditor
|
||
value={getExportData("csv")}
|
||
language="javascript"
|
||
readOnly={true}
|
||
maxLines={12}
|
||
showToggle={true}
|
||
className="w-full"
|
||
cardRef={exportCardRef}
|
||
/>
|
||
<div className="flex justify-end gap-2">
|
||
<button
|
||
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"
|
||
>
|
||
{copiedButton === 'csv' ? '✓ Copied!' : 'Copy'}
|
||
</button>
|
||
<button
|
||
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"
|
||
>
|
||
{downloadedButton === 'csv' ? '✓ Downloaded!' : 'Download'}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{exportTab === "tsv" && (
|
||
<div className="space-y-3">
|
||
<CodeMirrorEditor
|
||
value={getExportData("tsv")}
|
||
language="javascript"
|
||
readOnly={true}
|
||
maxLines={12}
|
||
showToggle={true}
|
||
className="w-full"
|
||
cardRef={exportCardRef}
|
||
/>
|
||
<div className="flex justify-end gap-2">
|
||
<button
|
||
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"
|
||
>
|
||
{copiedButton === 'tsv' ? '✓ Copied!' : 'Copy'}
|
||
</button>
|
||
<button
|
||
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"
|
||
>
|
||
{downloadedButton === 'tsv' ? '✓ Downloaded!' : 'Download'}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{exportTab === "sql" && (
|
||
<div className="space-y-3">
|
||
{/* SQL Export Controls */}
|
||
<div className="bg-gray-50 dark:bg-gray-700 rounded-lg p-4 space-y-3">
|
||
<h4 className="text-sm font-medium text-gray-900 dark:text-gray-100">
|
||
SQL Export Settings
|
||
</h4>
|
||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||
Table Name
|
||
</label>
|
||
<input
|
||
type="text"
|
||
value={
|
||
sqlTableName ||
|
||
currentTable ||
|
||
originalFileName ||
|
||
"table_data"
|
||
}
|
||
onChange={(e) => setSqlTableName(e.target.value)}
|
||
placeholder="Enter table name"
|
||
className="tool-input w-full text-sm"
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||
Primary Key Column
|
||
</label>
|
||
<select
|
||
value={sqlPrimaryKey}
|
||
onChange={(e) => setSqlPrimaryKey(e.target.value)}
|
||
className="tool-input w-full text-sm"
|
||
>
|
||
<option value="">No Primary Key</option>
|
||
{columns.map((column) => (
|
||
<option key={column.id} value={column.name}>
|
||
{column.name}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<CodeMirrorEditor
|
||
value={getExportData("sql")}
|
||
language="sql"
|
||
readOnly={true}
|
||
maxLines={12}
|
||
showToggle={true}
|
||
className="w-full"
|
||
cardRef={exportCardRef}
|
||
/>
|
||
|
||
{/* Intelligent Schema Analysis */}
|
||
{window.lastSqlAnalysis && (
|
||
<div className="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-md p-4">
|
||
<div className="flex items-start gap-3">
|
||
<div className="flex-shrink-0">
|
||
<div className="w-5 h-5 rounded-full bg-blue-100 dark:bg-blue-900/40 flex items-center justify-center">
|
||
<span className="text-blue-600 dark:text-blue-400 text-xs font-bold">
|
||
🧠
|
||
</span>
|
||
</div>
|
||
</div>
|
||
<div className="flex-1">
|
||
<h4 className="text-sm font-medium text-blue-800 dark:text-blue-200 mb-2">
|
||
🔍 Intelligent Schema Analysis
|
||
</h4>
|
||
<div className="text-xs text-blue-700 dark:text-blue-300 space-y-2">
|
||
<p>
|
||
<strong>
|
||
Auto-detected column types based on your data:
|
||
</strong>
|
||
</p>
|
||
<div className="grid grid-cols-1 gap-1 ml-2 max-h-32 overflow-y-auto">
|
||
{window.lastSqlAnalysis.map((col, index) => (
|
||
<div
|
||
key={index}
|
||
className="flex items-center justify-between bg-blue-100 dark:bg-blue-900/30 rounded px-2 py-1"
|
||
>
|
||
<span className="font-mono font-medium">
|
||
{col.column}
|
||
</span>
|
||
<span className="text-blue-600 dark:text-blue-400 font-mono text-xs">
|
||
{col.type}
|
||
</span>
|
||
<span
|
||
className="text-blue-500 dark:text-blue-400 text-xs opacity-75"
|
||
title={col.analysis}
|
||
>
|
||
{col.analysis.split("(")[0]}
|
||
</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
<p className="pt-1 text-xs opacity-75">
|
||
<strong>Smart Detection:</strong> JSON →
|
||
JSON/LONGTEXT, Numbers → INT/DECIMAL, Dates →
|
||
DATE/DATETIME, Text → VARCHAR(optimized size),
|
||
URLs/Emails → Appropriate sizing
|
||
</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* SQL Schema Notice */}
|
||
<div className="bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-md p-4">
|
||
<div className="flex items-start gap-3">
|
||
<div className="flex-shrink-0">
|
||
<div className="w-5 h-5 rounded-full bg-amber-100 dark:bg-amber-900/40 flex items-center justify-center">
|
||
<span className="text-amber-600 dark:text-amber-400 text-xs font-bold">
|
||
!
|
||
</span>
|
||
</div>
|
||
</div>
|
||
<div className="flex-1">
|
||
<h4 className="text-sm font-medium text-amber-800 dark:text-amber-200 mb-2">
|
||
📋 SQL Schema Information
|
||
</h4>
|
||
<div className="text-xs text-amber-700 dark:text-amber-300 space-y-2">
|
||
<p>
|
||
<strong>What this schema does:</strong>
|
||
</p>
|
||
<ul className="list-disc list-inside space-y-1 ml-2">
|
||
<li>
|
||
<strong>DROP TABLE IF EXISTS</strong> - Safely
|
||
removes existing table if it exists
|
||
</li>
|
||
<li>
|
||
<strong>CREATE TABLE</strong> - Builds new table
|
||
with original or generated structure
|
||
</li>
|
||
<li>
|
||
<strong>INSERT VALUES</strong> - Populates table
|
||
with your edited data
|
||
</li>
|
||
</ul>
|
||
|
||
<p className="pt-2">
|
||
<strong>🔒 Data Safety Policy:</strong>
|
||
</p>
|
||
<ul className="list-disc list-inside space-y-1 ml-2">
|
||
<li>
|
||
<strong>⚠️ DESTRUCTIVE:</strong> Will completely
|
||
replace existing table data
|
||
</li>
|
||
<li>
|
||
<strong>✅ BACKUP FIRST:</strong> Always backup your
|
||
database before importing
|
||
</li>
|
||
<li>
|
||
<strong>🎯 TARGETED:</strong> Only affects the
|
||
specific table(s) in this export
|
||
</li>
|
||
</ul>
|
||
|
||
<p className="pt-2">
|
||
<strong>📥 How to use:</strong>
|
||
</p>
|
||
<ul className="list-disc list-inside space-y-1 ml-2">
|
||
<li>
|
||
<strong>phpMyAdmin:</strong> Go to SQL tab → Paste →
|
||
Execute
|
||
</li>
|
||
<li>
|
||
<strong>MySQL CLI:</strong>{" "}
|
||
<code className="bg-amber-100 dark:bg-amber-900/40 px-1 rounded">
|
||
mysql -u user -p database < file.sql
|
||
</code>
|
||
</li>
|
||
<li>
|
||
<strong>Import Tool:</strong> Use database import
|
||
feature with downloaded .sql file
|
||
</li>
|
||
</ul>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="flex justify-end gap-2">
|
||
<button
|
||
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"
|
||
>
|
||
{copiedButton === 'sql' ? '✓ Copied!' : 'Copy'}
|
||
</button>
|
||
<button
|
||
onClick={() => {
|
||
downloadFile(
|
||
getExportData("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"
|
||
>
|
||
{downloadedButton === 'sql' ? '✓ Downloaded!' : 'Download'}
|
||
</button>
|
||
</div>
|
||
</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 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">
|
||
<li>
|
||
<strong>Create New:</strong> Start empty or load sample data to
|
||
explore features
|
||
</li>
|
||
<li>
|
||
<strong>URL Import:</strong> Fetch data directly from CSV/JSON
|
||
endpoints
|
||
</li>
|
||
<li>
|
||
<strong>Paste Data:</strong> Auto-detects CSV, TSV, JSON arrays,
|
||
or SQL INSERT statements
|
||
</li>
|
||
<li>
|
||
<strong>Open Files:</strong> Import .csv, .json, .sql files
|
||
(multi-table SQL files supported)
|
||
</li>
|
||
</ul>
|
||
</div>
|
||
|
||
<div>
|
||
<p className="font-medium mb-1">🎯 Table Management:</p>
|
||
<ul className="list-disc list-inside space-y-1 ml-2">
|
||
<li>
|
||
<strong>Multi-Table Support:</strong> SQL files with multiple
|
||
tables show dropdown selector
|
||
</li>
|
||
<li>
|
||
<strong>Edit Headers:</strong> Click column headers to rename
|
||
them
|
||
</li>
|
||
<li>
|
||
<strong>Edit Cells:</strong> Click any cell to edit inline
|
||
</li>
|
||
<li>
|
||
<strong>Object Data:</strong> Cells with [OBJ] badge open Object
|
||
Editor for visual JSON/serialized editing
|
||
</li>
|
||
</ul>
|
||
</div>
|
||
|
||
<div>
|
||
<p className="font-medium mb-1">🚀 Data Operations:</p>
|
||
<ul className="list-disc list-inside space-y-1 ml-2">
|
||
<li>
|
||
<strong>Add/Delete:</strong> Use buttons to add rows/columns or
|
||
select multiple to delete
|
||
</li>
|
||
<li>
|
||
<strong>Search & Sort:</strong> Filter data and sort by any
|
||
column
|
||
</li>
|
||
<li>
|
||
<strong>Bulk Operations:</strong> Select multiple rows/columns
|
||
for batch operations
|
||
</li>
|
||
<li>
|
||
<strong>Data Validation:</strong> Real-time validation for data
|
||
types and formats
|
||
</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>JSON:</strong> Export includes pretty-formatted and
|
||
minified versions
|
||
</li>
|
||
<li>
|
||
<strong>CSV/TSV:</strong> Standard delimited formats for
|
||
spreadsheet compatibility
|
||
</li>
|
||
<li>
|
||
<strong>SQL:</strong> Intelligent schema generation with
|
||
complete database preservation
|
||
</li>
|
||
<li>
|
||
<strong>Copy & Download:</strong> All formats support both
|
||
clipboard copy and file download
|
||
</li>
|
||
</ul>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Related Tools */}
|
||
<RelatedTools toolId="table-editor" />
|
||
</ToolLayout>
|
||
</>
|
||
);
|
||
};
|
||
|
||
// Clear Confirmation Modal Component
|
||
const ClearConfirmationModal = ({
|
||
tableCount,
|
||
rowCount,
|
||
columnCount,
|
||
tableName,
|
||
onConfirm,
|
||
onCancel,
|
||
}) => {
|
||
return (
|
||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-md w-full overflow-hidden">
|
||
{/* Header */}
|
||
<div className="px-6 py-4 border-b border-gray-200 dark:border-gray-700 bg-red-50 dark:bg-red-900/20">
|
||
<div className="flex items-center gap-3">
|
||
<div className="flex-shrink-0">
|
||
<AlertTriangle className="h-6 w-6 text-red-600 dark:text-red-400" />
|
||
</div>
|
||
<div>
|
||
<h3 className="text-lg font-semibold text-red-900 dark:text-red-100">
|
||
Clear All Data
|
||
</h3>
|
||
<p className="text-sm text-red-700 dark:text-red-300">
|
||
This action cannot be undone
|
||
</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Content */}
|
||
<div className="px-6 py-4">
|
||
<p className="text-gray-700 dark:text-gray-300 mb-4">
|
||
Are you sure you want to clear all table data?
|
||
</p>
|
||
|
||
<div className="bg-gray-50 dark:bg-gray-700 rounded-lg p-4 mb-4">
|
||
<h4 className="text-sm font-medium text-gray-900 dark:text-gray-100 mb-2">
|
||
This will permanently delete:
|
||
</h4>
|
||
<ul className="text-sm text-gray-600 dark:text-gray-600 space-y-1">
|
||
{tableCount > 1 ? (
|
||
<>
|
||
<li>• {tableCount} tables</li>
|
||
<li>
|
||
• All {rowCount} rows in current table "{tableName}"
|
||
</li>
|
||
<li>• All imported data and modifications</li>
|
||
</>
|
||
) : (
|
||
<>
|
||
<li>• All {rowCount} rows</li>
|
||
<li>• All {columnCount} columns</li>
|
||
<li>• Table "{tableName}"</li>
|
||
</>
|
||
)}
|
||
</ul>
|
||
</div>
|
||
|
||
<div className="bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-lg p-3">
|
||
<div className="flex items-start gap-2">
|
||
<AlertTriangle className="h-4 w-4 text-amber-600 dark:text-amber-400 flex-shrink-0 mt-0.5" />
|
||
<p className="text-xs text-amber-700 dark:text-amber-300">
|
||
<strong>Warning:</strong> This action cannot be undone. All your
|
||
work will be lost.
|
||
</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Footer */}
|
||
<div className="px-6 py-4 border-t border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-700">
|
||
<div className="flex justify-end gap-3">
|
||
<button
|
||
onClick={onCancel}
|
||
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"
|
||
>
|
||
Cancel
|
||
</button>
|
||
<button
|
||
onClick={onConfirm}
|
||
className="px-4 py-2 text-sm font-medium text-white bg-red-600 hover:bg-red-700 rounded-md transition-colors flex items-center gap-2"
|
||
>
|
||
<AlertTriangle className="h-4 w-4" />
|
||
Clear All Data
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
// 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;
|
||
|
||
// Handle different formats
|
||
if (modal.format.type === "base64_json") {
|
||
data = atob(modal.originalValue);
|
||
}
|
||
|
||
if (modal.format.type === "php_serialized") {
|
||
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);
|
||
return {
|
||
structuredData: parsed,
|
||
currentValue: data,
|
||
isValid: true,
|
||
error: "",
|
||
};
|
||
} catch (err) {
|
||
// Try one more time with basic unescaping if JSON parsing failed
|
||
try {
|
||
const unescaped = modal.originalValue
|
||
.replace(/\\"/g, '"')
|
||
.replace(/\\'/g, "'");
|
||
const parsed = JSON.parse(unescaped);
|
||
return {
|
||
structuredData: parsed,
|
||
currentValue: unescaped,
|
||
isValid: true,
|
||
error: "",
|
||
};
|
||
} catch (secondErr) {
|
||
return {
|
||
structuredData: {},
|
||
currentValue: modal.originalValue,
|
||
isValid: false,
|
||
error: err.message,
|
||
};
|
||
}
|
||
}
|
||
};
|
||
|
||
const initialState = initializeData();
|
||
const [structuredData, setStructuredData] = useState(
|
||
initialState.structuredData,
|
||
);
|
||
const [viewMode, setViewMode] = useState("visual");
|
||
const [currentValue, setCurrentValue] = useState(initialState.currentValue);
|
||
const [isValid, setIsValid] = useState(initialState.isValid);
|
||
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 {
|
||
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) {
|
||
setIsValid(false);
|
||
setError(err.message);
|
||
}
|
||
};
|
||
|
||
// Handle raw text changes
|
||
const handleRawValueChange = (newValue) => {
|
||
setCurrentValue(newValue);
|
||
try {
|
||
let data = newValue;
|
||
|
||
// Handle different formats
|
||
if (modal.format.type === "base64_json") {
|
||
data = atob(newValue);
|
||
}
|
||
|
||
if (modal.format.type === "php_serialized") {
|
||
try {
|
||
const parsed = phpUnserialize(newValue);
|
||
setStructuredData(parsed);
|
||
setIsValid(true);
|
||
setError("");
|
||
} catch (err) {
|
||
setIsValid(false);
|
||
setError(err.message);
|
||
}
|
||
return;
|
||
}
|
||
|
||
const parsed = JSON.parse(data);
|
||
setStructuredData(parsed);
|
||
setIsValid(true);
|
||
setError("");
|
||
} catch (err) {
|
||
setIsValid(false);
|
||
setError(err.message);
|
||
}
|
||
};
|
||
|
||
const handleApply = () => {
|
||
if (!isValid) return;
|
||
|
||
let finalValue = currentValue;
|
||
|
||
// Handle encoding back if needed
|
||
if (modal.format.type === "base64_json") {
|
||
try {
|
||
// Validate JSON first
|
||
JSON.parse(currentValue);
|
||
finalValue = btoa(currentValue);
|
||
} catch (err) {
|
||
setError("Invalid JSON for Base64 encoding");
|
||
return;
|
||
}
|
||
}
|
||
|
||
onApply(finalValue);
|
||
};
|
||
|
||
const renderVisualEditor = () => {
|
||
if (!isValid) {
|
||
return (
|
||
<div className="h-full flex items-center justify-center text-gray-600 dark:text-gray-600 p-6">
|
||
<div className="text-center">
|
||
<Code className="h-12 w-12 mx-auto mb-4 opacity-50" />
|
||
<p>Invalid or unparseable data</p>
|
||
{error && <p className="text-sm text-red-500 mt-2">{error}</p>}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
|
||
return (
|
||
<div className="h-full bg-white dark:bg-gray-800 p-6">
|
||
<StructuredEditor
|
||
initialData={structuredData}
|
||
onDataChange={handleStructuredDataChange}
|
||
/>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
return (
|
||
<div
|
||
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 */}
|
||
<div className="px-6 py-4 border-b border-gray-200 dark:border-gray-700">
|
||
<div className="flex items-center justify-between">
|
||
<div>
|
||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
|
||
Object Editor
|
||
</h3>
|
||
<p className="text-sm text-gray-600 dark:text-gray-600">
|
||
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-600">
|
||
{" • "}{Object.keys(structuredData).length} properties
|
||
</span>
|
||
)}
|
||
</div>
|
||
</div>
|
||
<button
|
||
onClick={onClose}
|
||
className="text-gray-600 hover:text-gray-600 dark:hover:text-gray-300 self-start"
|
||
>
|
||
<X className="h-6 w-6" />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* View Mode Tabs */}
|
||
<div className="px-6 py-3 border-b border-gray-200 dark:border-gray-700 flex-shrink-0">
|
||
<div className="flex space-x-1">
|
||
<button
|
||
onClick={() => setViewMode("visual")}
|
||
className={`px-3 py-2 text-sm font-medium rounded-md transition-colors ${
|
||
viewMode === "visual"
|
||
? "bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300"
|
||
: "text-gray-600 hover:text-gray-900 dark:text-gray-600 dark:hover:text-gray-200"
|
||
}`}
|
||
>
|
||
<Edit3 className="h-4 w-4 inline mr-2" />
|
||
Visual Editor
|
||
</button>
|
||
<button
|
||
onClick={() => setViewMode("raw")}
|
||
className={`px-3 py-2 text-sm font-medium rounded-md transition-colors ${
|
||
viewMode === "raw"
|
||
? "bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300"
|
||
: "text-gray-600 hover:text-gray-900 dark:text-gray-600 dark:hover:text-gray-200"
|
||
}`}
|
||
>
|
||
<Code className="h-4 w-4 inline mr-2" />
|
||
Raw Editor
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Content */}
|
||
<div className="flex-1 overflow-y-auto min-h-0">
|
||
{viewMode === "visual" ? (
|
||
<div className="h-full flex flex-col">{renderVisualEditor()}</div>
|
||
) : (
|
||
<div className="p-6 space-y-4 h-full flex flex-col">
|
||
<textarea
|
||
value={currentValue}
|
||
onChange={(e) => handleRawValueChange(e.target.value)}
|
||
className="flex-1 w-full min-h-80 font-mono text-sm border border-gray-300 dark:border-gray-600 rounded-md p-3 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-blue-500 focus:border-transparent resize-none"
|
||
placeholder="Enter JSON, serialized data, or other structured content..."
|
||
/>
|
||
{!isValid && (
|
||
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-md p-3 flex-shrink-0">
|
||
<p className="text-sm text-red-700 dark:text-red-300">
|
||
<strong>Validation Error:</strong> {error}
|
||
</p>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* 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 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"
|
||
>
|
||
Cancel
|
||
</button>
|
||
<button
|
||
onClick={handleApply}
|
||
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"
|
||
>
|
||
Save Changes
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
// Input Method Change Confirmation Modal Component
|
||
const InputChangeConfirmationModal = ({
|
||
tableCount,
|
||
rowCount,
|
||
columnCount,
|
||
currentMethod,
|
||
newMethod,
|
||
onConfirm,
|
||
onCancel,
|
||
}) => {
|
||
const getMethodName = (method) => {
|
||
switch (method) {
|
||
case "create":
|
||
return "Create New";
|
||
case "create_empty":
|
||
return "Start Empty";
|
||
case "create_sample":
|
||
return "Load Sample";
|
||
case "url":
|
||
return "URL Import";
|
||
case "paste":
|
||
return "Paste Data";
|
||
case "upload":
|
||
return "File Upload";
|
||
default:
|
||
return method;
|
||
}
|
||
};
|
||
|
||
return (
|
||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-md w-full overflow-hidden">
|
||
{/* Header */}
|
||
<div className="px-6 py-4 border-b border-gray-200 dark:border-gray-700 bg-amber-50 dark:bg-amber-900/20">
|
||
<div className="flex items-center gap-3">
|
||
<div className="flex-shrink-0">
|
||
<AlertTriangle className="h-6 w-6 text-amber-600 dark:text-amber-400" />
|
||
</div>
|
||
<div>
|
||
<h3 className="text-lg font-semibold text-amber-900 dark:text-amber-100">
|
||
Change Input Method
|
||
</h3>
|
||
<p className="text-sm text-amber-700 dark:text-amber-300">
|
||
This will clear all current data
|
||
</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Content */}
|
||
<div className="px-6 py-4">
|
||
<p className="text-gray-700 dark:text-gray-300 mb-4">
|
||
{newMethod === "create_empty" || newMethod === "create_sample" ? (
|
||
<>
|
||
Using <strong>{getMethodName(newMethod)}</strong> will clear all
|
||
current data.
|
||
</>
|
||
) : (
|
||
<>
|
||
Switching from <strong>{getMethodName(currentMethod)}</strong>{" "}
|
||
to <strong>{getMethodName(newMethod)}</strong> will clear all
|
||
current data.
|
||
</>
|
||
)}
|
||
</p>
|
||
|
||
<div className="bg-gray-50 dark:bg-gray-700 rounded-lg p-4 mb-4">
|
||
<h4 className="text-sm font-medium text-gray-900 dark:text-gray-100 mb-2">
|
||
This will permanently delete:
|
||
</h4>
|
||
<ul className="text-sm text-gray-600 dark:text-gray-600 space-y-1">
|
||
{tableCount > 1 ? (
|
||
<>
|
||
<li>• {tableCount} imported tables</li>
|
||
<li>• All {rowCount} rows in current table</li>
|
||
<li>• All modifications and edits</li>
|
||
</>
|
||
) : (
|
||
<>
|
||
<li>• All {rowCount} rows</li>
|
||
<li>• All {columnCount} columns</li>
|
||
<li>• All modifications and edits</li>
|
||
</>
|
||
)}
|
||
</ul>
|
||
</div>
|
||
|
||
<div className="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-3">
|
||
<div className="flex items-start gap-2">
|
||
<AlertTriangle className="h-4 w-4 text-blue-600 dark:text-blue-400 flex-shrink-0 mt-0.5" />
|
||
<p className="text-xs text-blue-700 dark:text-blue-300">
|
||
<strong>Tip:</strong> Consider exporting your current data
|
||
before switching methods to avoid losing your work.
|
||
</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Footer */}
|
||
<div className="px-6 py-4 border-t border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-700">
|
||
<div className="flex justify-end gap-3">
|
||
<button
|
||
onClick={onCancel}
|
||
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"
|
||
>
|
||
Cancel
|
||
</button>
|
||
<button
|
||
onClick={onConfirm}
|
||
className="px-4 py-2 text-sm font-medium text-white bg-amber-600 hover:bg-amber-700 rounded-md transition-colors flex items-center gap-2"
|
||
>
|
||
<AlertTriangle className="h-4 w-4" />
|
||
Switch & Clear Data
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
export default TableEditor;
|