Files
dewedev/src/pages/TableEditor.js

3900 lines
143 KiB
JavaScript
Executable File
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 &lt; 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;