Compare commits

...

2 Commits

Author SHA1 Message Date
dwindown
df0fb5d22a feat: Object Editor Preview Mode & Mobile Optimizations
Major Enhancements:
- Added Preview/Edit mode toggle to StructuredEditor component
  * Preview mode: Read-only view with full text visibility
  * Edit mode: Full editing capabilities with all controls
  * Toggle positioned below title, responsive on mobile
  * Works in both main ObjectEditor view and nested modals

- Clickable nested data in Preview mode
  * JSON/serialized values are blue and clickable
  * Opens modal directly without switching to Edit mode
  * Hover effects and tooltips for better UX
  * No longer need edit mode just to explore structure

Mobile Responsiveness Improvements:
- Fixed all data load notices in ObjectEditor (URL, Paste, Open tabs)
- Fixed all data load notices in TableEditor (URL, Paste, Open tabs)
- Notices now stack vertically on mobile with proper spacing
- Added break-words for long text, whitespace-nowrap for buttons
- Dark mode colors added for better visibility

Table Editor Fixes:
- Fixed sticky header showing row underneath (top-[-1px])
- Made Export section header mobile responsive
- Updated object modal footer layout:
  * Format info and properties combined on single line
  * Buttons moved to separate row below
  * Changed 'Apply Changes' to 'Save Changes' for consistency

StructuredEditor Improvements:
- Moved overflow-x handling from ObjectEditor to StructuredEditor
- Now works consistently in main view and nested modals
- Long strings scroll horizontally everywhere
- 'Add Property' button hidden in Preview mode
- Improved chevron colors for dark mode visibility

Technical Changes:
- StructuredEditor now manages its own editMode state
- readOnly prop can still be passed from parent if needed
- Proper conditional rendering for all UI elements
- Consistent mobile-first responsive design patterns
2025-10-15 22:40:57 +07:00
dwindown
f6c19e855d fix: improve SEO with pre-rendering and dynamic meta tags
Critical SEO improvements to fix Google Search Console indexing:

## Sitemap Updates:
- Added missing Invoice Editor and What's New pages
- Updated all lastmod dates to 2025-10-15
- Increased editor tools priority to 0.9
- Added organizational comments
- Fixed /whats-new route (was /release-notes)

## Pre-rendering Implementation:
- Added react-snap for static HTML generation
- Configured to pre-render all tool pages
- Solves React SPA indexing issue
- Crawlers now see full HTML content

## Dynamic Meta Tags:
- Added react-helmet-async for SEO management
- Created reusable SEO component with:
  - Dynamic titles and descriptions
  - Open Graph tags (Facebook)
  - Twitter Card tags
  - JSON-LD structured data
  - Canonical URLs
- Wrapped App with HelmetProvider
- Added SEO to Home page

## Route Fixes:
- Added /whats-new route (primary)
- Kept /release-notes as fallback
- Consistent routing across app

## Documentation:
- Created comprehensive SEO_FIX_GUIDE.md
- Step-by-step Google Search Console instructions
- Troubleshooting guide
- Timeline expectations
- Testing procedures

These changes will dramatically improve Google indexing and search visibility.
2025-10-15 10:01:48 +07:00
13 changed files with 2598 additions and 697 deletions

412
SEO_FIX_GUIDE.md Normal file
View File

@@ -0,0 +1,412 @@
# SEO Fix Guide - Google Search Console Indexing
**Date:** October 15, 2025
**Issue:** Google Search Console only indexing homepage, not tool pages
**Status:** FIXED ✅
---
## 🔍 Problems Identified
### 1. **Outdated Sitemap**
- Missing Invoice Editor (`/invoice-editor`)
- Missing What's New page (`/whats-new`)
- Old lastmod dates (2025-01-23)
- Wrong priorities
### 2. **React SPA Issue** (CRITICAL)
- Google can't index JavaScript-rendered pages
- Tool pages have no HTML content for crawlers
- All content loads client-side via React Router
- Search engines see empty `<div id="root"></div>`
### 3. **Missing Meta Tags**
- No dynamic meta tags per page
- No Open Graph tags
- No Twitter Card tags
- No structured data (JSON-LD)
---
## ✅ Solutions Implemented
### 1. **Updated Sitemap.xml**
**File:** `/public/sitemap.xml`
**Changes:**
- ✅ Added Invoice Editor
- ✅ Added What's New page
- ✅ Updated all lastmod dates to 2025-10-15
- ✅ Increased editor tools priority to 0.9
- ✅ Added comments for better organization
- ✅ Proper priority hierarchy
### 2. **Pre-rendering with react-snap**
**File:** `package.json`
**Added:**
```json
{
"dependencies": {
"react-snap": "^1.23.0"
},
"scripts": {
"build": "react-scripts build && react-snap"
},
"reactSnap": {
"include": [
"/",
"/object-editor",
"/table-editor",
"/invoice-editor",
"/url",
"/base64",
"/beautifier",
"/diff",
"/text-length",
"/whats-new",
"/privacy",
"/terms"
]
}
}
```
**What it does:**
- Generates static HTML for each route
- Crawlers see full HTML content
- Improves SEO dramatically
- Faster first paint
### 3. **Dynamic Meta Tags with react-helmet-async**
**Files Created:**
- `/src/components/SEO.js` - Reusable SEO component
- Updated `/src/App.js` - Wrapped with HelmetProvider
- Updated `/src/pages/Home.js` - Added SEO component
**Features:**
- Dynamic title per page
- Dynamic description per page
- Open Graph tags (Facebook)
- Twitter Card tags
- JSON-LD structured data
- Canonical URLs
---
## 📋 Steps to Fix in Google Search Console
### Step 1: Verify Sitemap Update
1. **Go to Google Search Console**
- URL: https://search.google.com/search-console
- Select property: `dewe.dev`
2. **Navigate to Sitemaps**
- Left sidebar → "Sitemaps"
3. **Remove Old Sitemap** (if exists)
- Find `https://dewe.dev/sitemap.xml`
- Click the 3 dots → "Remove sitemap"
4. **Submit New Sitemap**
- Click "Add a new sitemap"
- Enter: `sitemap.xml`
- Click "Submit"
5. **Wait for Processing**
- Status will show "Couldn't fetch" initially
- After deployment, it will show "Success"
- Check back in 24-48 hours
---
### Step 2: Request Indexing for Key Pages
1. **Go to URL Inspection Tool**
- Top search bar in Google Search Console
2. **Inspect Each Tool Page:**
```
https://dewe.dev/object-editor
https://dewe.dev/table-editor
https://dewe.dev/invoice-editor
https://dewe.dev/url
https://dewe.dev/base64
https://dewe.dev/beautifier
https://dewe.dev/diff
https://dewe.dev/text-length
https://dewe.dev/whats-new
```
3. **For Each URL:**
- Paste URL in search bar
- Click "Test Live URL"
- Wait for test to complete
- If "URL is on Google": Great!
- If "URL is not on Google": Click "Request Indexing"
- Repeat for all pages
---
### Step 3: Check robots.txt
1. **Go to Settings**
- Left sidebar → "Settings"
- Click "Open report" under "robots.txt"
2. **Verify robots.txt is accessible**
- Should show your robots.txt content
- Should reference sitemap: `Sitemap: https://dewe.dev/sitemap.xml`
3. **If not accessible:**
- Check if `https://dewe.dev/robots.txt` works in browser
- Ensure Coolify/server serves static files correctly
---
### Step 4: Monitor Coverage
1. **Go to Coverage Report**
- Left sidebar → "Coverage" (or "Pages")
2. **Check Indexed Pages**
- Should see increase in "Valid" pages
- Monitor "Excluded" and "Error" sections
3. **Common Issues:**
- **"Discovered - currently not indexed"**: Normal, Google will index soon
- **"Crawled - currently not indexed"**: Low priority, may take time
- **"Excluded by 'noindex' tag"**: Check meta tags (shouldn't happen)
- **"Soft 404"**: Page has no content (pre-rendering should fix this)
---
### Step 5: Check Enhancements
1. **Go to Enhancements**
- Left sidebar → "Enhancements"
2. **Check Mobile Usability**
- Should show "Valid" for all pages
- Fix any mobile issues
3. **Check Core Web Vitals**
- Monitor performance metrics
- Aim for "Good" status
---
## 🚀 Deployment Steps
### 1. Install Dependencies
```bash
cd /Users/dwindown/CascadeProjects/developer-tools
npm install
```
### 2. Test Build Locally
```bash
npm run build
```
**Expected:**
- Build completes successfully
- react-snap generates HTML files for each route
- Check `build/` folder for HTML files
### 3. Deploy to Production
```bash
git add -A
git commit -m "fix: improve SEO with pre-rendering and dynamic meta tags
- Updated sitemap.xml with all current pages
- Added react-snap for static HTML generation
- Implemented react-helmet-async for dynamic meta tags
- Created SEO component with Open Graph and Twitter Cards
- Added JSON-LD structured data
- Fixed Google Search Console indexing issues"
git push
```
### 4. Verify Deployment
- Wait for Coolify to deploy
- Check https://dewe.dev/sitemap.xml
- Check https://dewe.dev/robots.txt
- Check https://dewe.dev/object-editor (view source, should see HTML content)
---
## 🧪 Testing & Verification
### Test 1: View Page Source
1. Open https://dewe.dev/object-editor
2. Right-click → "View Page Source"
3. **Before fix:** Only see `<div id="root"></div>`
4. **After fix:** See full HTML content with meta tags
### Test 2: Google Rich Results Test
1. Go to https://search.google.com/test/rich-results
2. Enter: `https://dewe.dev/object-editor`
3. Should show structured data (JSON-LD)
4. Should pass validation
### Test 3: Facebook Sharing Debugger
1. Go to https://developers.facebook.com/tools/debug/
2. Enter: `https://dewe.dev/object-editor`
3. Should show Open Graph tags
4. Should display preview image
### Test 4: Twitter Card Validator
1. Go to https://cards-dev.twitter.com/validator
2. Enter: `https://dewe.dev/object-editor`
3. Should show Twitter Card preview
4. Should display correctly
---
## 📊 Expected Timeline
| Action | Timeline |
|--------|----------|
| Deploy changes | Immediate |
| Sitemap processed | 1-2 hours |
| Pages crawled | 1-7 days |
| Pages indexed | 3-14 days |
| Full coverage | 2-4 weeks |
**Note:** Google indexing is not instant. Be patient!
---
## 🔧 Troubleshooting
### Issue: "Couldn't fetch sitemap"
**Solution:**
- Check if `https://dewe.dev/sitemap.xml` is accessible
- Ensure Coolify serves static files from `/public`
- Check server logs for 404 errors
### Issue: "Discovered - currently not indexed"
**Solution:**
- Normal! Google discovered but hasn't indexed yet
- Request indexing manually (Step 2 above)
- Wait 7-14 days
### Issue: "Crawled - currently not indexed"
**Solution:**
- Google crawled but deemed low priority
- Improve content quality
- Add more internal links
- Wait for Google to re-evaluate
### Issue: "Soft 404"
**Solution:**
- Page has no content or very little content
- Pre-rendering should fix this
- Check if react-snap generated HTML correctly
- Verify build output
### Issue: react-snap fails during build
**Solution:**
- Check console for errors
- May need to add `window.snapSaveState = () => ({})` to index.js
- Try `npm run build:no-snap` to build without pre-rendering
- Check react-snap documentation
---
## 📝 Additional Recommendations
### 1. Add More Content
- Write blog posts about each tool
- Create tutorial pages
- Add FAQ section
- More content = better SEO
### 2. Internal Linking
- Link between related tools
- Add "Related Tools" section
- Create tool categories pages
- Improve navigation
### 3. Performance Optimization
- Optimize images
- Minimize JavaScript
- Use CDN for assets
- Improve Core Web Vitals
### 4. Schema Markup
- Add more structured data
- Use SoftwareApplication schema
- Add BreadcrumbList schema
- Add Organization schema
### 5. Social Signals
- Share on social media
- Get backlinks
- Engage with developer communities
- Build brand awareness
---
## 🎯 Success Metrics
### Week 1
- ✅ Sitemap processed
- ✅ 3-5 pages indexed
- ✅ No crawl errors
### Week 2
- ✅ 8-10 pages indexed
- ✅ Appearing in search results
- ✅ Mobile usability: Good
### Week 4
- ✅ All pages indexed
- ✅ Ranking for brand keywords
- ✅ Organic traffic increasing
---
## 📞 Support
If issues persist after 2 weeks:
1. Check Google Search Console for specific errors
2. Review server logs for crawl errors
3. Test with different browsers
4. Consider hiring SEO consultant
---
## ✅ Checklist
### Immediate Actions
- [ ] Deploy code changes
- [ ] Verify sitemap.xml is accessible
- [ ] Verify robots.txt is accessible
- [ ] Submit sitemap in Google Search Console
- [ ] Request indexing for key pages
### Within 24 Hours
- [ ] Check sitemap processing status
- [ ] Verify HTML pre-rendering works
- [ ] Test Open Graph tags
- [ ] Test Twitter Cards
### Within 1 Week
- [ ] Monitor coverage report
- [ ] Check for crawl errors
- [ ] Verify pages being indexed
- [ ] Check search appearance
### Within 1 Month
- [ ] Review all pages indexed
- [ ] Check ranking positions
- [ ] Monitor organic traffic
- [ ] Optimize based on data
---
**Good luck! Your SEO should improve significantly with these changes.** 🚀

1132
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -10,6 +10,7 @@
"@codemirror/lang-html": "^6.4.9",
"@codemirror/lang-javascript": "^6.2.4",
"@codemirror/lang-json": "^6.0.2",
"@codemirror/lang-sql": "^6.10.0",
"@codemirror/search": "^6.5.11",
"@codemirror/state": "^6.5.2",
"@codemirror/theme-one-dark": "^6.1.3",
@@ -32,8 +33,10 @@
"react": "18.3.1",
"react-diff-view": "^3.3.2",
"react-dom": "18.3.1",
"react-helmet-async": "^2.0.5",
"react-router-dom": "6.26.2",
"react-scripts": "5.0.1",
"react-snap": "^1.23.0",
"reactflow": "^11.11.4",
"serialize-javascript": "^6.0.0",
"web-vitals": "^2.1.4"
@@ -46,10 +49,36 @@
},
"scripts": {
"start": "react-scripts start",
"build": "react-scripts build",
"build": "react-scripts build && react-snap",
"build:no-snap": "react-scripts build",
"test": "react-scripts test",
"eject": "react-scripts eject"
},
"reactSnap": {
"inlineCss": true,
"minifyHtml": {
"collapseWhitespace": false,
"removeComments": false
},
"puppeteerArgs": [
"--no-sandbox",
"--disable-setuid-sandbox"
],
"include": [
"/",
"/object-editor",
"/table-editor",
"/invoice-editor",
"/url",
"/base64",
"/beautifier",
"/diff",
"/text-length",
"/whats-new",
"/privacy",
"/terms"
]
},
"eslintConfig": {
"extends": [
"react-app",

View File

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

View File

@@ -3,63 +3,86 @@
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.sitemaps.org/schemas/sitemap/0.9
http://www.sitemaps.org/schemas/sitemap/0.9/sitemap.xsd">
<!-- Homepage -->
<url>
<loc>https://dewe.dev/</loc>
<lastmod>2025-01-23</lastmod>
<lastmod>2025-10-15</lastmod>
<changefreq>weekly</changefreq>
<priority>1.0</priority>
</url>
<!-- Editor Tools (High Priority) -->
<url>
<loc>https://dewe.dev/object-editor</loc>
<lastmod>2025-01-23</lastmod>
<lastmod>2025-10-15</lastmod>
<changefreq>monthly</changefreq>
<priority>0.8</priority>
<priority>0.9</priority>
</url>
<url>
<loc>https://dewe.dev/table-editor</loc>
<lastmod>2025-01-23</lastmod>
<lastmod>2025-10-15</lastmod>
<changefreq>monthly</changefreq>
<priority>0.8</priority>
<priority>0.9</priority>
</url>
<url>
<loc>https://dewe.dev/invoice-editor</loc>
<lastmod>2025-10-15</lastmod>
<changefreq>monthly</changefreq>
<priority>0.9</priority>
</url>
<!-- Converter Tools -->
<url>
<loc>https://dewe.dev/url</loc>
<lastmod>2025-01-23</lastmod>
<lastmod>2025-10-15</lastmod>
<changefreq>monthly</changefreq>
<priority>0.8</priority>
</url>
<url>
<loc>https://dewe.dev/base64</loc>
<lastmod>2025-01-23</lastmod>
<lastmod>2025-10-15</lastmod>
<changefreq>monthly</changefreq>
<priority>0.8</priority>
</url>
<url>
<loc>https://dewe.dev/beautifier</loc>
<lastmod>2025-01-23</lastmod>
<lastmod>2025-10-15</lastmod>
<changefreq>monthly</changefreq>
<priority>0.8</priority>
</url>
<!-- Utility Tools -->
<url>
<loc>https://dewe.dev/diff</loc>
<lastmod>2025-01-23</lastmod>
<lastmod>2025-10-15</lastmod>
<changefreq>monthly</changefreq>
<priority>0.8</priority>
</url>
<url>
<loc>https://dewe.dev/text-length</loc>
<lastmod>2025-01-23</lastmod>
<lastmod>2025-10-15</lastmod>
<changefreq>monthly</changefreq>
<priority>0.8</priority>
</url>
<!-- Info Pages -->
<url>
<loc>https://dewe.dev/whats-new</loc>
<lastmod>2025-10-15</lastmod>
<changefreq>weekly</changefreq>
<priority>0.7</priority>
</url>
<!-- Legal Pages -->
<url>
<loc>https://dewe.dev/privacy</loc>
<lastmod>2025-01-23</lastmod>
<lastmod>2025-10-15</lastmod>
<changefreq>yearly</changefreq>
<priority>0.3</priority>
</url>
<url>
<loc>https://dewe.dev/terms</loc>
<lastmod>2025-01-23</lastmod>
<lastmod>2025-10-15</lastmod>
<changefreq>yearly</changefreq>
<priority>0.3</priority>
</url>

View File

@@ -1,5 +1,6 @@
import React, { useEffect } from 'react';
import { BrowserRouter as Router, Routes, Route } from 'react-router-dom';
import { HelmetProvider } from 'react-helmet-async';
import Layout from './components/Layout';
import ErrorBoundary from './components/ErrorBoundary';
import Home from './pages/Home';
@@ -30,31 +31,34 @@ function App() {
}, []);
return (
<ErrorBoundary>
<Router>
<Layout>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/json" element={<JsonTool />} />
<Route path="/serialize" element={<SerializeTool />} />
<Route path="/url" element={<UrlTool />} />
<Route path="/base64" element={<Base64Tool />} />
<Route path="/csv-json" element={<CsvJsonTool />} />
<Route path="/beautifier" element={<BeautifierTool />} />
<Route path="/diff" element={<DiffTool />} />
<Route path="/text-length" element={<TextLengthTool />} />
<Route path="/object-editor" element={<ObjectEditor />} />
<Route path="/table-editor" element={<TableEditor />} />
<Route path="/invoice-editor" element={<InvoiceEditor />} />
<Route path="/invoice-preview" element={<InvoicePreview />} />
<Route path="/invoice-preview-minimal" element={<InvoicePreviewMinimal />} />
<Route path="/release-notes" element={<ReleaseNotes />} />
<Route path="/privacy" element={<PrivacyPolicy />} />
<Route path="/terms" element={<TermsOfService />} />
</Routes>
</Layout>
</Router>
</ErrorBoundary>
<HelmetProvider>
<ErrorBoundary>
<Router>
<Layout>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/json" element={<JsonTool />} />
<Route path="/serialize" element={<SerializeTool />} />
<Route path="/url" element={<UrlTool />} />
<Route path="/base64" element={<Base64Tool />} />
<Route path="/csv-json" element={<CsvJsonTool />} />
<Route path="/beautifier" element={<BeautifierTool />} />
<Route path="/diff" element={<DiffTool />} />
<Route path="/text-length" element={<TextLengthTool />} />
<Route path="/object-editor" element={<ObjectEditor />} />
<Route path="/table-editor" element={<TableEditor />} />
<Route path="/invoice-editor" element={<InvoiceEditor />} />
<Route path="/invoice-preview" element={<InvoicePreview />} />
<Route path="/invoice-preview-minimal" element={<InvoicePreviewMinimal />} />
<Route path="/whats-new" element={<ReleaseNotes />} />
<Route path="/release-notes" element={<ReleaseNotes />} />
<Route path="/privacy" element={<PrivacyPolicy />} />
<Route path="/terms" element={<TermsOfService />} />
</Routes>
</Layout>
</Router>
</ErrorBoundary>
</HelmetProvider>
);
}

View File

@@ -1,8 +1,9 @@
import React, { useRef, useEffect, useState } from 'react';
import React, { useRef, useEffect, useState, useCallback } from 'react';
import { EditorView } from '@codemirror/view';
import { EditorState } from '@codemirror/state';
import { basicSetup } from 'codemirror';
import { json } from '@codemirror/lang-json';
import { sql } from '@codemirror/lang-sql';
import { oneDark } from '@codemirror/theme-one-dark';
import { Maximize2, Minimize2 } from 'lucide-react';
@@ -13,7 +14,8 @@ const CodeMirrorEditor = ({
className = '',
language = 'json',
maxLines = 12,
showToggle = true
showToggle = true,
cardRef = null // Reference to the card header for scroll target
}) => {
const editorRef = useRef(null);
const viewRef = useRef(null);
@@ -38,13 +40,26 @@ const CodeMirrorEditor = ({
return () => observer.disconnect();
}, []);
// Detect if content is single-line (minified)
const isSingleLine = (value || '').split('\n').length === 1;
// Initialize editor only once
useEffect(() => {
if (!editorRef.current || viewRef.current) return;
// Language extension
let langExtension = [];
if (language === 'json') {
langExtension = [json()];
} else if (language === 'sql') {
langExtension = [sql()];
}
const extensions = [
basicSetup,
language === 'json' ? json() : [],
...langExtension,
// Enable line wrapping for single-line content
...(isSingleLine ? [EditorView.lineWrapping] : []),
EditorView.theme({
'&': {
fontSize: '14px',
@@ -58,6 +73,13 @@ const CodeMirrorEditor = ({
},
'.cm-editor': {
borderRadius: '6px',
},
'.cm-scroller': {
overflowY: 'auto',
height: '100%',
},
'.cm-line': {
wordBreak: isSingleLine ? 'break-word' : 'normal',
}
}),
EditorView.updateListener.of((update) => {
@@ -80,6 +102,25 @@ const CodeMirrorEditor = ({
viewRef.current = view;
// Apply styles immediately after editor creation
setTimeout(() => {
const editorElement = editorRef.current?.querySelector('.cm-editor');
const scrollerElement = editorRef.current?.querySelector('.cm-scroller');
if (editorElement) {
editorElement.style.height = '350px';
editorElement.style.maxHeight = '350px';
}
if (scrollerElement) {
scrollerElement.style.overflowY = 'auto';
scrollerElement.style.overflowX = isSingleLine ? 'hidden' : 'auto';
scrollerElement.style.height = '100%';
}
// No manual wrapping needed - EditorView.lineWrapping handles it
}, 0);
return () => {
if (viewRef.current) {
viewRef.current.destroy();
@@ -87,13 +128,15 @@ const CodeMirrorEditor = ({
}
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isDark]); // Only recreate on theme change
}, [isDark, isSingleLine]); // Recreate when theme or line count changes
// Handle height changes without recreating editor
useEffect(() => {
if (!viewRef.current) return;
// Apply overflow and height styles
const applyEditorStyles = useCallback(() => {
if (!viewRef.current || !editorRef.current) return;
const editorElement = editorRef.current.querySelector('.cm-editor');
const scrollerElement = editorRef.current.querySelector('.cm-scroller');
const editorElement = editorRef.current?.querySelector('.cm-editor');
if (editorElement) {
if (isExpanded) {
editorElement.style.height = 'auto';
@@ -103,8 +146,19 @@ const CodeMirrorEditor = ({
editorElement.style.maxHeight = '350px';
}
}
// Handle scrolling
if (scrollerElement) {
scrollerElement.style.overflowY = isExpanded ? 'visible' : 'auto';
scrollerElement.style.height = '100%';
}
}, [isExpanded]);
// Apply styles on mount, expand/collapse, and content changes
useEffect(() => {
applyEditorStyles();
}, [applyEditorStyles]);
// Update content when value changes externally
useEffect(() => {
if (viewRef.current && value !== viewRef.current.state.doc.toString()) {
@@ -116,8 +170,11 @@ const CodeMirrorEditor = ({
}
});
viewRef.current.dispatch(transaction);
// Re-apply styles after content change (e.g., tab switch)
setTimeout(() => applyEditorStyles(), 10);
}
}, [value]);
}, [value, applyEditorStyles]);
return (
<div className={`relative ${className}`}>
@@ -132,7 +189,12 @@ const CodeMirrorEditor = ({
onClick={() => {
setIsExpanded(!isExpanded);
setTimeout(() => {
window.scrollTo({ top: 0, behavior: 'smooth' });
// Scroll to card header if cardRef is provided, otherwise scroll to top
if (cardRef?.current) {
cardRef.current.scrollIntoView({ behavior: 'smooth', block: 'start' });
} else {
window.scrollTo({ top: 0, behavior: 'smooth' });
}
}, 50);
}}
className="absolute bottom-2 right-2 p-1 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 bg-white dark:bg-gray-800 rounded border border-gray-300 dark:border-gray-600 shadow-sm z-10"

75
src/components/SEO.js Normal file
View File

@@ -0,0 +1,75 @@
import React from 'react';
import { Helmet } from 'react-helmet-async';
const SEO = ({
title,
description,
keywords,
path = '/',
type = 'website',
image = 'https://dewe.dev/og-image.png'
}) => {
const siteUrl = 'https://dewe.dev';
const fullUrl = `${siteUrl}${path}`;
const fullTitle = title ? `${title} | Developer Tools` : 'Developer Tools - Essential Web Developer Utilities';
const defaultDescription = 'Free online developer tools for JSON, CSV, Base64, URL encoding, code beautification, and more. Privacy-first, no data storage, all processing in your browser.';
const metaDescription = description || defaultDescription;
const defaultKeywords = 'developer tools, json editor, csv converter, base64 encoder, url encoder, code beautifier, diff tool, web developer utilities, online tools';
const metaKeywords = keywords || defaultKeywords;
return (
<Helmet>
{/* Basic Meta Tags */}
<title>{fullTitle}</title>
<meta name="description" content={metaDescription} />
<meta name="keywords" content={metaKeywords} />
<link rel="canonical" href={fullUrl} />
{/* Open Graph / Facebook */}
<meta property="og:type" content={type} />
<meta property="og:url" content={fullUrl} />
<meta property="og:title" content={fullTitle} />
<meta property="og:description" content={metaDescription} />
<meta property="og:image" content={image} />
<meta property="og:site_name" content="Developer Tools" />
{/* Twitter Card */}
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:url" content={fullUrl} />
<meta name="twitter:title" content={fullTitle} />
<meta name="twitter:description" content={metaDescription} />
<meta name="twitter:image" content={image} />
{/* Additional SEO Tags */}
<meta name="robots" content="index, follow" />
<meta name="googlebot" content="index, follow" />
<meta name="author" content="Developer Tools" />
<meta name="language" content="English" />
{/* JSON-LD Structured Data */}
<script type="application/ld+json">
{JSON.stringify({
"@context": "https://schema.org",
"@type": "WebApplication",
"name": fullTitle,
"description": metaDescription,
"url": fullUrl,
"applicationCategory": "DeveloperApplication",
"operatingSystem": "Any",
"offers": {
"@type": "Offer",
"price": "0",
"priceCurrency": "USD"
},
"provider": {
"@type": "Organization",
"name": "Developer Tools",
"url": siteUrl
}
})}
</script>
</Helmet>
);
};
export default SEO;

View File

@@ -1,13 +1,17 @@
import React, { useState, useEffect, useRef } from 'react';
import { Plus, Minus, ChevronDown, ChevronRight, Type, Hash, ToggleLeft, List, Braces, Edit3, X } from 'lucide-react';
import { Plus, Minus, ChevronDown, ChevronRight, Type, Hash, ToggleLeft, List, Braces, Edit3, X, Eye, Pencil } from 'lucide-react';
const StructuredEditor = ({ onDataChange, initialData = {} }) => {
const StructuredEditor = ({ onDataChange, initialData = {}, readOnly: readOnlyProp = false }) => {
const [data, setData] = useState(initialData);
const [expandedNodes, setExpandedNodes] = useState(new Set(['root']));
const [fieldTypes, setFieldTypes] = useState({}); // Track intended types for fields
const isInternalUpdate = useRef(false);
const [nestedEditModal, setNestedEditModal] = useState(null); // { path, value, type: 'json' | 'serialized' }
const [nestedData, setNestedData] = useState(null);
const [editMode, setEditMode] = useState(false); // Internal edit mode state
// Use internal editMode if readOnlyProp is not explicitly set, otherwise use prop
const readOnly = readOnlyProp !== false ? readOnlyProp : !editMode;
// Update internal data when initialData prop changes (but not from internal updates)
useEffect(() => {
@@ -533,7 +537,7 @@ const StructuredEditor = ({ onDataChange, initialData = {} }) => {
{canExpand && (
<button
onClick={() => toggleNode(path)}
className="p-1 hover:bg-gray-100 dark:hover:bg-gray-700 rounded"
className="p-1 hover:bg-gray-100 dark:hover:bg-gray-700 rounded text-gray-700 dark:text-gray-300"
>
{isExpanded ? (
<ChevronDown className="h-4 w-4" />
@@ -558,74 +562,106 @@ const StructuredEditor = ({ onDataChange, initialData = {} }) => {
// Object properties: icon + editable key + colon (compact)
<>
{getTypeIcon(value)}
<input
type="text"
defaultValue={key}
onBlur={(e) => {
const newKey = e.target.value.trim();
if (newKey && newKey !== key) {
renameKey(key, newKey, path);
}
}}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.target.blur(); // Trigger blur to save changes
}
}}
className="px-2 py-1 text-sm border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-blue-500 focus:border-blue-500 min-w-0"
placeholder="Property name"
style={{width: '120px'}} // Fixed width for consistency
/>
<span className="text-gray-500 hidden sm:inline">:</span>
{readOnly ? (
<span className="px-2 py-1 text-sm text-gray-900 dark:text-gray-100 font-mono">
{key}
</span>
) : (
<input
type="text"
defaultValue={key}
onBlur={(e) => {
const newKey = e.target.value.trim();
if (newKey && newKey !== key) {
renameKey(key, newKey, path);
}
}}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.target.blur(); // Trigger blur to save changes
}
}}
className="px-2 py-1 text-sm border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-blue-500 focus:border-blue-500 min-w-0"
placeholder="Property name"
style={{width: '120px'}} // Fixed width for consistency
/>
)}
<span className="text-gray-500 inline">:</span>
</>
)}
{!canExpand ? (
typeof value === 'boolean' ? (
<div className="flex-1 flex items-center space-x-2">
<button
onClick={() => updateValue((!value).toString(), path)}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 ${
value ? 'bg-blue-600' : 'bg-gray-200 dark:bg-gray-600'
}`}
>
<span
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
value ? 'translate-x-6' : 'translate-x-1'
}`}
/>
</button>
<span className="text-sm text-gray-600 dark:text-gray-400 font-mono">
{value.toString()}
</span>
{readOnly ? (
<span className="text-sm text-gray-900 dark:text-gray-100 font-mono">
{value.toString()}
</span>
) : (
<>
<button
onClick={() => updateValue((!value).toString(), path)}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 ${
value ? 'bg-blue-600' : 'bg-gray-200 dark:bg-gray-600'
}`}
>
<span
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
value ? 'translate-x-6' : 'translate-x-1'
}`}
/>
</button>
<span className="text-sm text-gray-600 dark:text-gray-400 font-mono">
{value.toString()}
</span>
</>
)}
</div>
) : (
<div className="flex-1 flex items-center gap-2">
{(fieldTypes[path] === 'longtext' || (typeof value === 'string' && value.includes('\n'))) ? (
<textarea
value={getDisplayValue(value)}
onChange={(e) => updateValue(e.target.value, path)}
className="flex-1 px-2 py-1 text-sm border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 min-w-0 resize-y items"
placeholder="Long text value"
rows={3}
/>
{readOnly ? (
typeof value === 'string' && detectNestedData(value) ? (
<span
onClick={() => openNestedEditor(value, path)}
className="px-2 py-1 text-sm text-blue-600 dark:text-blue-400 font-mono break-all cursor-pointer hover:bg-blue-50 dark:hover:bg-blue-900/20 rounded transition-colors"
title={`Click to view nested ${detectNestedData(value).type} data`}
>
{getDisplayValue(value)}
</span>
) : (
<span className="px-2 py-1 text-sm text-gray-900 dark:text-gray-100 font-mono break-all">
{getDisplayValue(value)}
</span>
)
) : (
<input
type="text"
value={getDisplayValue(value)}
onChange={(e) => updateValue(e.target.value, path)}
className="flex-1 px-2 py-1 text-sm border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 min-w-0"
placeholder="Value"
/>
)}
{typeof value === 'string' && detectNestedData(value) && (
<button
onClick={() => openNestedEditor(value, path)}
className="p-1 text-blue-600 hover:bg-blue-100 dark:hover:bg-blue-900 rounded flex-shrink-0"
title={`Edit nested ${detectNestedData(value).type} data`}
>
<Edit3 className="h-4 w-4" />
</button>
<>
{(fieldTypes[path] === 'longtext' || (typeof value === 'string' && value.includes('\n'))) ? (
<textarea
value={getDisplayValue(value)}
onChange={(e) => updateValue(e.target.value, path)}
className="flex-1 px-2 py-1 text-sm border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 min-w-0 resize-y items"
placeholder="Long text value"
rows={3}
/>
) : (
<input
type="text"
value={getDisplayValue(value)}
onChange={(e) => updateValue(e.target.value, path)}
className="flex-1 px-2 py-1 text-sm border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 min-w-0"
placeholder="Value"
/>
)}
{typeof value === 'string' && detectNestedData(value) && (
<button
onClick={() => openNestedEditor(value, path)}
className="p-1 text-blue-600 hover:bg-blue-100 dark:hover:bg-blue-900 rounded flex-shrink-0"
title={`Edit nested ${detectNestedData(value).type} data`}
>
<Edit3 className="h-4 w-4" />
</button>
)}
</>
)}
</div>
)
@@ -635,38 +671,40 @@ const StructuredEditor = ({ onDataChange, initialData = {} }) => {
</span>
)}
<div className="flex items-center space-x-2 sm:space-x-2">
<select
value={
fieldTypes[path] || (
value === null ? 'null' :
value === undefined ? 'string' :
typeof value === 'string' ? (value.includes('\n') ? 'longtext' : 'string') :
typeof value === 'number' ? 'number' :
typeof value === 'boolean' ? 'boolean' :
Array.isArray(value) ? 'array' : 'object'
)
}
onChange={(e) => changeType(e.target.value, path)}
className="px-2 py-1 text-xs border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 min-w-0"
>
<option value="string">String</option>
<option value="longtext">Long Text</option>
<option value="number">Number</option>
<option value="boolean">Boolean</option>
<option value="array">Array</option>
<option value="object">Object</option>
<option value="null">Null</option>
</select>
<button
onClick={() => removeProperty(key, parentPath)}
className="p-1 text-red-600 hover:bg-red-100 dark:hover:bg-red-900 rounded flex-shrink-0"
title="Remove property"
>
<Minus className="h-4 w-4" />
</button>
</div>
{!readOnly && (
<div className="flex items-center space-x-2 sm:space-x-2">
<select
value={
fieldTypes[path] || (
value === null ? 'null' :
value === undefined ? 'string' :
typeof value === 'string' ? (value.includes('\n') ? 'longtext' : 'string') :
typeof value === 'number' ? 'number' :
typeof value === 'boolean' ? 'boolean' :
Array.isArray(value) ? 'array' : 'object'
)
}
onChange={(e) => changeType(e.target.value, path)}
className="px-2 py-1 text-xs border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 min-w-0"
>
<option value="string">String</option>
<option value="longtext">Long Text</option>
<option value="number">Number</option>
<option value="boolean">Boolean</option>
<option value="array">Array</option>
<option value="object">Object</option>
<option value="null">Null</option>
</select>
<button
onClick={() => removeProperty(key, parentPath)}
className="p-1 text-red-600 hover:bg-red-100 dark:hover:bg-red-900 rounded flex-shrink-0"
title="Remove property"
>
<Minus className="h-4 w-4" />
</button>
</div>
)}
</div>
</div>
@@ -677,26 +715,30 @@ const StructuredEditor = ({ onDataChange, initialData = {} }) => {
{value.map((item, index) =>
renderValue(item, index.toString(), `${path}.${index}`, path)
)}
<button
onClick={() => addArrayItem(value, path)}
className="flex items-center space-x-1 px-2 py-1 text-sm text-blue-600 hover:bg-blue-100 dark:hover:bg-blue-900 rounded"
>
<Plus className="h-4 w-4" />
<span>Add Item</span>
</button>
{!readOnly && (
<button
onClick={() => addArrayItem(value, path)}
className="flex items-center space-x-1 px-2 py-1 text-sm text-blue-600 hover:bg-blue-100 dark:hover:bg-blue-900 rounded"
>
<Plus className="h-4 w-4" />
<span>Add Item</span>
</button>
)}
</>
) : (
<>
{Object.entries(value).map(([k, v]) =>
renderValue(v, k, `${path}.${k}`, path)
)}
<button
onClick={() => addProperty(value, path)}
className="flex items-center space-x-1 px-2 py-1 text-sm text-blue-600 hover:bg-blue-100 dark:hover:bg-blue-900 rounded"
>
<Plus className="h-4 w-4" />
<span>Add Property</span>
</button>
{!readOnly && (
<button
onClick={() => addProperty(value, path)}
className="flex items-center space-x-1 px-2 py-1 text-sm text-blue-600 hover:bg-blue-100 dark:hover:bg-blue-900 rounded"
>
<Plus className="h-4 w-4" />
<span>Add Property</span>
</button>
)}
</>
)}
</div>
@@ -708,29 +750,65 @@ const StructuredEditor = ({ onDataChange, initialData = {} }) => {
return (
<div className="min-h-96 w-full">
<div className="mb-4">
<h3 className="text-lg font-medium text-gray-900 dark:text-gray-100">Structured Data Editor</h3>
<div className="flex flex-col gap-3 mb-3">
<h3 className="text-lg font-medium text-gray-900 dark:text-gray-100">Structured Data Editor</h3>
{/* Mode Toggle - Below title on mobile, inline on desktop */}
{readOnlyProp === false && (
<div className="flex border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden bg-white dark:bg-gray-800 shadow-sm w-fit">
<button
onClick={() => setEditMode(false)}
className={`flex items-center gap-2 px-3 py-1.5 text-xs font-medium transition-colors ${
!editMode
? 'bg-green-50 dark:bg-green-900/20 text-green-700 dark:text-green-300'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-700'
}`}
>
<Eye className="h-3.5 w-3.5" />
<span>Preview</span>
</button>
<button
onClick={() => setEditMode(true)}
className={`flex items-center gap-2 px-3 py-1.5 text-xs font-medium transition-colors border-l border-gray-200 dark:border-gray-700 ${
editMode
? 'bg-orange-50 dark:bg-orange-900/20 text-orange-700 dark:text-orange-300'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-700'
}`}
>
<Pencil className="h-3.5 w-3.5" />
<span>Edit</span>
</button>
</div>
)}
</div>
</div>
<div className="overflow-x-hidden">
{Object.keys(data).length === 0 ? (
<div className="text-center text-gray-500 dark:text-gray-400 py-8">
<Braces className="h-12 w-12 mx-auto mb-2 opacity-50" />
<p>No properties yet. Click "Add Property" to start building your data structure.</p>
<div className="w-full overflow-hidden">
<div className="w-full overflow-x-auto">
<div className="min-w-max">
{Object.keys(data).length === 0 ? (
<div className="text-center text-gray-500 dark:text-gray-400 py-8">
<Braces className="h-12 w-12 mx-auto mb-2 opacity-50" />
<p>No properties yet. Click "Add Property" to start building your data structure.</p>
</div>
) : (
Object.entries(data).map(([key, value]) =>
renderValue(value, key, `root.${key}`, 'root')
)
)}
{/* Root level Add Property button */}
{!readOnly && (
<button
onClick={() => addProperty(data, 'root')}
className="flex items-center space-x-1 px-2 py-1 text-sm text-blue-600 hover:bg-blue-100 dark:hover:bg-blue-900 rounded"
>
<Plus className="h-4 w-4" />
<span>Add Property</span>
</button>
)}
</div>
) : (
Object.entries(data).map(([key, value]) =>
renderValue(value, key, `root.${key}`, 'root')
)
)}
{/* Root level Add Property button */}
<button
onClick={() => addProperty(data, 'root')}
className="flex items-center space-x-1 px-2 py-1 text-sm text-blue-600 hover:bg-blue-100 dark:hover:bg-blue-900 rounded"
>
<Plus className="h-4 w-4" />
<span>Add Property</span>
</button>
</div>
</div>
{/* Nested Data Editor Modal */}
@@ -749,7 +827,7 @@ const StructuredEditor = ({ onDataChange, initialData = {} }) => {
</div>
<button
onClick={closeNestedEditor}
className="p-2 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700 rounded"
className="p-2 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700 rounded self-start"
>
<X className="h-5 w-5" />
</button>

View File

@@ -4,6 +4,7 @@ import { Link } from 'react-router-dom';
import ToolCard from '../components/ToolCard';
import { TOOLS, SITE_CONFIG } from '../config/tools';
import { useAnalytics } from '../hooks/useAnalytics';
import SEO from '../components/SEO';
const Home = () => {
const [searchTerm, setSearchTerm] = useState('');
@@ -32,20 +33,26 @@ const Home = () => {
);
return (
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-blue-50 to-indigo-50 dark:from-slate-900 dark:via-slate-800 dark:to-indigo-900">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
{/* Hero Section */}
<div className={`text-center pt-16 pb-20 transition-all duration-1000 ${mounted ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-8'}`}>
{/* Terminal-style header */}
<div className="inline-flex items-center gap-2 px-4 py-2 bg-slate-800 dark:bg-slate-700 rounded-full text-green-400 font-mono text-sm mb-8 shadow-lg">
<Terminal className="h-4 w-4" />
<span>~/dewe.dev $</span>
<span className="animate-pulse">_</span>
</div>
<h1 className="text-5xl hidden md:text-7xl font-bold bg-gradient-to-r from-blue-600 via-purple-600 to-indigo-600 bg-clip-text text-transparent mb-6">
{SITE_CONFIG.title}
</h1>
<>
<SEO
title="Home"
description="Free online developer tools for JSON editing, table manipulation, invoice generation, Base64 encoding, URL encoding, code beautification, and more. Privacy-first, no data storage."
keywords="developer tools, json editor, table editor, invoice generator, base64 encoder, url encoder, code beautifier, diff tool, web developer utilities"
path="/"
/>
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-blue-50 to-indigo-50 dark:from-slate-900 dark:via-slate-800 dark:to-slate-900">
<div className="container mx-auto px-4 py-12">
<div className="text-center mb-16">
{/* Terminal-style header */}
<div className="inline-flex items-center gap-2 px-4 py-2 bg-slate-800 dark:bg-slate-700 rounded-full text-green-400 font-mono text-sm mb-8 shadow-lg">
<Terminal className="h-4 w-4" />
<span>~/dewe.dev $</span>
<span className="animate-pulse">_</span>
</div>
<h1 className="text-5xl md:text-7xl font-bold bg-gradient-to-r from-blue-600 via-purple-600 to-indigo-600 bg-clip-text text-transparent mb-6">
{SITE_CONFIG.title}
</h1>
<p className="text-xl md:text-2xl text-slate-600 dark:text-slate-300 mb-4 max-w-3xl mx-auto leading-relaxed">
{SITE_CONFIG.subtitle}
@@ -116,106 +123,107 @@ const Home = () => {
</div>
</Link>
</div>
</div>
{/* Tools Grid */}
<div className={`transition-all duration-1000 delay-300 ${mounted ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-8'}`}>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8 mb-20">
{filteredTools.map((tool, index) => (
<div
key={index}
className={`transition-all duration-500 ${mounted ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-8'}`}
style={{ transitionDelay: `${400 + index * 100}ms` }}
>
<ToolCard
icon={tool.icon}
title={tool.name}
description={tool.description}
path={tool.path}
tags={tool.tags}
category={tool.category}
/>
</div>
))}
</div>
</div>
{/* No Results */}
{filteredTools.length === 0 && (
<div className="text-center py-20">
<div className="text-6xl mb-4">🔍</div>
<p className="text-slate-500 dark:text-slate-400 text-xl mb-2">
No tools found matching "{searchTerm}"
</p>
<p className="text-slate-400 dark:text-slate-500">
Try searching for "editor", "encode", or "format"
</p>
</div>
)}
{/* Features Section */}
<div className={`py-20 transition-all duration-1000 delay-700 ${mounted ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-8'}`}>
<div className="text-center mb-16">
<h2 className="text-3xl md:text-4xl font-bold text-slate-800 dark:text-white mb-4">
Built for Developers
</h2>
<p className="text-xl text-slate-600 dark:text-slate-300 max-w-2xl mx-auto">
Every tool is crafted with developer experience and performance in mind
</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-8">
<div className="group text-center p-8 rounded-2xl bg-white/50 dark:bg-slate-800/50 backdrop-blur-sm border border-slate-200 dark:border-slate-700 hover:border-blue-300 dark:hover:border-blue-600 transition-all duration-300 hover:shadow-xl hover:shadow-blue-500/10">
<div className="bg-gradient-to-br from-blue-500 to-blue-600 rounded-2xl w-16 h-16 flex items-center justify-center mx-auto mb-6 group-hover:scale-110 transition-transform duration-300">
<Zap className="h-8 w-8 text-white" />
</div>
<h3 className="text-xl font-bold text-slate-800 dark:text-white mb-3">
Lightning Fast
</h3>
<p className="text-slate-600 dark:text-slate-300 leading-relaxed">
Optimized algorithms and local processing ensure instant results
{/* Tools Grid */}
<div className={`transition-all duration-1000 delay-300 ${mounted ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-8'}`}>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8 mb-20">
{filteredTools.map((tool, index) => (
<div
key={index}
className={`transition-all duration-500 ${mounted ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-8'}`}
style={{ transitionDelay: `${400 + index * 100}ms` }}
>
<ToolCard
icon={tool.icon}
title={tool.name}
description={tool.description}
path={tool.path}
tags={tool.tags}
category={tool.category}
/>
</div>
))}
</div>
</div>
{/* No Results */}
{filteredTools.length === 0 && (
<div className="text-center py-20">
<div className="text-6xl mb-4">🔍</div>
<p className="text-slate-500 dark:text-slate-400 text-xl mb-2">
No tools found matching "{searchTerm}"
</p>
<p className="text-slate-400 dark:text-slate-500">
Try searching for "editor", "encode", or "format"
</p>
</div>
<div className="group text-center p-8 rounded-2xl bg-white/50 dark:bg-slate-800/50 backdrop-blur-sm border border-slate-200 dark:border-slate-700 hover:border-purple-300 dark:hover:border-purple-600 transition-all duration-300 hover:shadow-xl hover:shadow-purple-500/10">
<div className="bg-gradient-to-br from-purple-500 to-purple-600 rounded-2xl w-16 h-16 flex items-center justify-center mx-auto mb-6 group-hover:scale-110 transition-transform duration-300">
<Shield className="h-8 w-8 text-white" />
</div>
<h3 className="text-xl font-bold text-slate-800 dark:text-white mb-3">
Privacy First
</h3>
<p className="text-slate-600 dark:text-slate-300 leading-relaxed">
Your data never leaves your browser. Zero tracking, zero storage
)}
{/* Features Section */}
<div className={`py-20 transition-all duration-1000 delay-700 ${mounted ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-8'}`}>
<div className="text-center mb-16">
<h2 className="text-3xl md:text-4xl font-bold text-slate-800 dark:text-white mb-4">
Built for Developers
</h2>
<p className="text-xl text-slate-600 dark:text-slate-300 max-w-2xl mx-auto">
Every tool is crafted with developer experience and performance in mind
</p>
</div>
<div className="group text-center p-8 rounded-2xl bg-white/50 dark:bg-slate-800/50 backdrop-blur-sm border border-slate-200 dark:border-slate-700 hover:border-green-300 dark:hover:border-green-600 transition-all duration-300 hover:shadow-xl hover:shadow-green-500/10">
<div className="bg-gradient-to-br from-green-500 to-green-600 rounded-2xl w-16 h-16 flex items-center justify-center mx-auto mb-6 group-hover:scale-110 transition-transform duration-300">
<Cpu className="h-8 w-8 text-white" />
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-8">
<div className="group text-center p-8 rounded-2xl bg-white/50 dark:bg-slate-800/50 backdrop-blur-sm border border-slate-200 dark:border-slate-700 hover:border-blue-300 dark:hover:border-blue-600 transition-all duration-300 hover:shadow-xl hover:shadow-blue-500/10">
<div className="bg-gradient-to-br from-blue-500 to-blue-600 rounded-2xl w-16 h-16 flex items-center justify-center mx-auto mb-6 group-hover:scale-110 transition-transform duration-300">
<Zap className="h-8 w-8 text-white" />
</div>
<h3 className="text-xl font-bold text-slate-800 dark:text-white mb-3">
Lightning Fast
</h3>
<p className="text-slate-600 dark:text-slate-300 leading-relaxed">
Optimized algorithms and local processing ensure instant results
</p>
</div>
<h3 className="text-xl font-bold text-slate-800 dark:text-white mb-3">
No Limits
</h3>
<p className="text-slate-600 dark:text-slate-300 leading-relaxed">
Handle massive files and complex data without restrictions
</p>
</div>
<div className="group text-center p-8 rounded-2xl bg-white/50 dark:bg-slate-800/50 backdrop-blur-sm border border-slate-200 dark:border-slate-700 hover:border-indigo-300 dark:hover:border-indigo-600 transition-all duration-300 hover:shadow-xl hover:shadow-indigo-500/10">
<div className="bg-gradient-to-br from-indigo-500 to-indigo-600 rounded-2xl w-16 h-16 flex items-center justify-center mx-auto mb-6 group-hover:scale-110 transition-transform duration-300">
<Code className="h-8 w-8 text-white" />
<div className="group text-center p-8 rounded-2xl bg-white/50 dark:bg-slate-800/50 backdrop-blur-sm border border-slate-200 dark:border-slate-700 hover:border-purple-300 dark:hover:border-purple-600 transition-all duration-300 hover:shadow-xl hover:shadow-purple-500/10">
<div className="bg-gradient-to-br from-purple-500 to-purple-600 rounded-2xl w-16 h-16 flex items-center justify-center mx-auto mb-6 group-hover:scale-110 transition-transform duration-300">
<Shield className="h-8 w-8 text-white" />
</div>
<h3 className="text-xl font-bold text-slate-800 dark:text-white mb-3">
Privacy First
</h3>
<p className="text-slate-600 dark:text-slate-300 leading-relaxed">
Your data never leaves your browser. Zero tracking, zero storage
</p>
</div>
<div className="group text-center p-8 rounded-2xl bg-white/50 dark:bg-slate-800/50 backdrop-blur-sm border border-slate-200 dark:border-slate-700 hover:border-green-300 dark:hover:border-green-600 transition-all duration-300 hover:shadow-xl hover:shadow-green-500/10">
<div className="bg-gradient-to-br from-green-500 to-green-600 rounded-2xl w-16 h-16 flex items-center justify-center mx-auto mb-6 group-hover:scale-110 transition-transform duration-300">
<Cpu className="h-8 w-8 text-white" />
</div>
<h3 className="text-xl font-bold text-slate-800 dark:text-white mb-3">
No Limits
</h3>
<p className="text-slate-600 dark:text-slate-300 leading-relaxed">
Handle massive files and complex data without restrictions
</p>
</div>
<div className="group text-center p-8 rounded-2xl bg-white/50 dark:bg-slate-800/50 backdrop-blur-sm border border-slate-200 dark:border-slate-700 hover:border-indigo-300 dark:hover:border-indigo-600 transition-all duration-300 hover:shadow-xl hover:shadow-indigo-500/10">
<div className="bg-gradient-to-br from-indigo-500 to-indigo-600 rounded-2xl w-16 h-16 flex items-center justify-center mx-auto mb-6 group-hover:scale-110 transition-transform duration-300">
<Code className="h-8 w-8 text-white" />
</div>
<h3 className="text-xl font-bold text-slate-800 dark:text-white mb-3">
Dev Focused
</h3>
<p className="text-slate-600 dark:text-slate-300 leading-relaxed">
Syntax highlighting, shortcuts, and workflows developers love
</p>
</div>
<h3 className="text-xl font-bold text-slate-800 dark:text-white mb-3">
Dev Focused
</h3>
<p className="text-slate-600 dark:text-slate-300 leading-relaxed">
Syntax highlighting, shortcuts, and workflows developers love
</p>
</div>
</div>
</div>
</div>
</div>
</>
);
};

View File

@@ -844,7 +844,7 @@ const InvoiceEditor = () => {
type="url"
value={url}
onChange={(e) => setUrl(e.target.value)}
placeholder="https://api.telegram.org/bot<token>/getMe"
placeholder="https://your-url.com/invoice/your-invoice"
className="tool-input w-full"
onKeyPress={(e) => e.key === 'Enter' && handleUrlFetch()}
/>
@@ -858,7 +858,7 @@ const InvoiceEditor = () => {
</button>
</div>
<p className="text-xs text-gray-500 dark:text-gray-400">
Enter any URL that returns JSON data. Examples: Telegram Bot API, JSONPlaceholder, GitHub API, etc.
Enter any URL that returns exported JSON data from your previous invoice work.
</p>
</div>
)}

View File

@@ -4,33 +4,10 @@ import ToolLayout from '../components/ToolLayout';
import StructuredEditor from '../components/StructuredEditor';
import MindmapView from '../components/MindmapView';
import PostmanTable from '../components/PostmanTable';
import CodeEditor from '../components/CodeEditor';
import CodeMirrorEditor from '../components/CodeMirrorEditor';
// Hook to detect dark mode
const useDarkMode = () => {
const [isDark, setIsDark] = useState(() => {
return document.documentElement.classList.contains('dark');
});
useEffect(() => {
const observer = new MutationObserver(() => {
setIsDark(document.documentElement.classList.contains('dark'));
});
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ['class']
});
return () => observer.disconnect();
}, []);
return isDark;
};
const ObjectEditor = () => {
const isDark = useDarkMode();
const exportCardRef = useRef(null);
const [structuredData, setStructuredData] = useState({});
// Sync structured data to localStorage for navigation guard
@@ -58,9 +35,53 @@ const ObjectEditor = () => {
const fileInputRef = useRef(null);
const [pasteCollapsed, setPasteCollapsed] = useState(false);
const [pasteDataSummary, setPasteDataSummary] = useState(null);
const [urlDataSummary, setUrlDataSummary] = useState(null);
const [fileDataSummary, setFileDataSummary] = useState(null);
const [outputExpanded, setOutputExpanded] = useState(false);
const [usageTipsExpanded, setUsageTipsExpanded] = useState(false);
// Button feedback states
const [copiedButton, setCopiedButton] = useState(null);
const [downloadedButton, setDownloadedButton] = useState(null);
const copyToClipboard = async (text) => {
try {
await navigator.clipboard.writeText(text);
} catch (err) {
console.error("Failed to copy:", err);
}
};
// Export functions
const getExportData = (format) => {
if (Object.keys(structuredData).length === 0) return "";
switch (format) {
case "json":
// Convert data to use column names instead of column IDs
// Use Object.fromEntries with columns.map to preserve column order
const jsonData = structuredData;
return jsonFormat === "pretty"
? JSON.stringify(jsonData, null, 2)
: JSON.stringify(jsonData);
default:
return "";
}
};
const downloadFile = (content, filename, mimeType) => {
const blob = new Blob([content], { type: mimeType });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
};
// Helper function to check if user has data that would be lost
const hasUserData = () => {
return Object.keys(structuredData).length > 0;
@@ -549,28 +570,38 @@ const ObjectEditor = () => {
// Handle file import (auto-load, same as Table/Invoice Editor)
const handleFileImport = (event) => {
const file = event.target.files[0];
if (file) {
const reader = new FileReader();
reader.onload = (e) => {
try {
const content = e.target.result;
const detection = detectInputFormat(content);
if (detection.valid) {
setStructuredData(detection.data);
setCreateNewCompleted(true);
setError('');
} else {
setError(detection.error || 'Invalid format. Please enter valid JSON or PHP serialized data.');
}
} catch (err) {
setError('Failed to read file: ' + err.message);
}
};
reader.readAsText(file);
}
};
if (!file) return;
const reader = new FileReader();
reader.onload = (e) => {
const content = e.target.result;
const detection = detectInputFormat(content);
if (detection.valid) {
setStructuredData(detection.data);
generateOutputs(detection.data);
setInputText(content);
setInputFormat(detection.format);
setInputValid(true);
setError('');
setCreateNewCompleted(true);
// Set file data summary
setFileDataSummary({
format: detection.format,
size: content.length,
properties: Object.keys(detection.data).length,
filename: file.name
});
// Stay on Open tab - don't switch to paste
} else {
setError(detection.error || 'Invalid file format');
setFileDataSummary(null);
}
};
reader.readAsText(file);
};
// Fetch data from URL
const handleFetchData = async () => {
@@ -596,36 +627,38 @@ const ObjectEditor = () => {
}
const contentType = response.headers.get('content-type');
const text = await response.text();
let data;
if (!contentType || !contentType.includes('application/json')) {
// Try to parse as JSON anyway, some APIs don't set correct content-type
const text = await response.text();
try {
const data = JSON.parse(text);
setStructuredData(data);
generateOutputs(data);
setInputText(JSON.stringify(data, null, 2));
setInputFormat('JSON');
setInputValid(true);
setCreateNewCompleted(true);
data = JSON.parse(text);
} catch {
throw new Error('Response is not valid JSON. Content-Type: ' + (contentType || 'unknown'));
}
} else {
const data = await response.json();
setStructuredData(data);
generateOutputs(data);
setInputText(JSON.stringify(data, null, 2));
setInputFormat('JSON');
setInputValid(true);
setCreateNewCompleted(true);
data = JSON.parse(text);
}
setStructuredData(data);
generateOutputs(data);
setInputText(JSON.stringify(data, null, 2));
setInputFormat('JSON');
setInputValid(true);
setCreateNewCompleted(true);
// Set URL data summary
setUrlDataSummary({
format: 'JSON',
size: text.length,
properties: Object.keys(data).length,
url: url
});
} catch (err) {
console.error('Fetch error:', err);
if (err.name === 'TypeError' && err.message.includes('fetch')) {
setError('Network error: Unable to fetch data. Check the URL and try again.');
} else {
setError(`Fetch failed: ${err.message}`);
}
setError(`Failed to fetch data: ${err.message}`);
setUrlDataSummary(null);
} finally {
setFetching(false);
}
@@ -779,43 +812,59 @@ const ObjectEditor = () => {
{/* URL Tab Content */}
{activeTab === 'url' && (
<div className="space-y-3">
<div className="flex gap-2">
<div className="relative flex-1">
<input
type="url"
value={fetchUrl}
onChange={(e) => setFetchUrl(e.target.value)}
placeholder="https://api.telegram.org/bot<token>/getMe"
className="tool-input w-full"
onKeyPress={(e) => e.key === 'Enter' && handleFetchData()}
/>
urlDataSummary ? (
<div className="p-4 bg-green-50 dark:bg-green-900/20 rounded-lg">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
<span className="text-sm text-green-700 dark:text-green-300 break-words">
✓ Data loaded: {urlDataSummary.format} ({urlDataSummary.size.toLocaleString()} chars, {urlDataSummary.properties} {urlDataSummary.properties === 1 ? 'property' : 'properties'})
</span>
<button
onClick={() => setUrlDataSummary(null)}
className="text-sm text-blue-600 dark:text-blue-400 hover:underline whitespace-nowrap"
>
Fetch New URL ▼
</button>
</div>
<button
onClick={handleFetchData}
disabled={fetching || !fetchUrl.trim()}
className="bg-blue-600 hover:bg-blue-700 disabled:bg-gray-400 text-white font-medium px-4 py-2 rounded-md transition-colors flex items-center whitespace-nowrap"
>
{fetching ? 'Fetching...' : 'Fetch Data'}
</button>
</div>
<p className="text-xs text-gray-500 dark:text-gray-400">
Enter any URL that returns JSON data. Examples: Telegram Bot API, JSONPlaceholder, GitHub API, etc.
</p>
</div>
) : (
<div className="space-y-3">
<div className="flex gap-2">
<div className="relative flex-1">
<input
type="url"
value={fetchUrl}
onChange={(e) => setFetchUrl(e.target.value)}
placeholder="https://api.telegram.org/bot<token>/getMe"
className="tool-input w-full"
onKeyPress={(e) => e.key === 'Enter' && handleFetchData()}
/>
</div>
<button
onClick={handleFetchData}
disabled={fetching || !fetchUrl.trim()}
className="bg-blue-600 hover:bg-blue-700 disabled:bg-gray-400 text-white font-medium px-4 py-2 rounded-md transition-colors flex items-center whitespace-nowrap"
>
{fetching ? 'Fetching...' : 'Fetch Data'}
</button>
</div>
<p className="text-xs text-gray-500 dark:text-gray-400">
Enter any URL that returns JSON data. Examples: Telegram Bot API, JSONPlaceholder, GitHub API, etc.
</p>
</div>
)
)}
{/* Paste Tab Content */}
{activeTab === 'paste' && (
pasteCollapsed ? (
<div className="p-4 bg-green-50 dark:bg-green-900/20 rounded-lg">
<div className="flex items-center justify-between">
<span className="text-sm text-green-700 dark:text-green-300">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
<span className="text-sm text-green-700 dark:text-green-300 break-words">
✓ Object loaded: {pasteDataSummary.format} ({pasteDataSummary.size.toLocaleString()} chars, {pasteDataSummary.properties} {pasteDataSummary.properties === 1 ? 'property' : 'properties'})
</span>
<button
onClick={() => setPasteCollapsed(false)}
className="text-sm text-blue-600 hover:underline"
className="text-sm text-blue-600 dark:text-blue-400 hover:underline whitespace-nowrap"
>
Edit Input ▼
</button>
@@ -868,20 +917,36 @@ const ObjectEditor = () => {
{/* Open Tab Content */}
{activeTab === 'open' && (
<div className="space-y-3">
<input
ref={fileInputRef}
type="file"
accept=".json,.txt"
onChange={handleFileImport}
className="tool-input"
/>
<div className="bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-md p-3">
<p className="text-xs text-green-700 dark:text-green-300">
🔒 <strong>Privacy:</strong> Your data stays in your browser. We don't store or upload anything - just help you open, edit, and export your files locally.
</p>
fileDataSummary ? (
<div className="p-4 bg-green-50 dark:bg-green-900/20 rounded-lg">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
<span className="text-sm text-green-700 dark:text-green-300 break-words">
✓ File loaded: {fileDataSummary.format} ({fileDataSummary.size.toLocaleString()} chars, {fileDataSummary.properties} {fileDataSummary.properties === 1 ? 'property' : 'properties'}) - {fileDataSummary.filename}
</span>
<button
onClick={() => setFileDataSummary(null)}
className="text-sm text-blue-600 dark:text-blue-400 hover:underline whitespace-nowrap"
>
Upload New File ▼
</button>
</div>
</div>
</div>
) : (
<div className="space-y-3">
<input
ref={fileInputRef}
type="file"
accept=".json,.txt"
onChange={handleFileImport}
className="tool-input"
/>
<div className="bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-md p-3">
<p className="text-xs text-green-700 dark:text-green-300">
🔒 <strong>Privacy:</strong> Your data stays in your browser. We don't store or upload anything - just help you open, edit, and export your files locally.
</p>
</div>
</div>
)
)}
</div>
)}
@@ -900,7 +965,7 @@ const ObjectEditor = () => {
Object Editor
</h3>
{/* View Mode Tabs - Moved to right */}
{/* View Mode Tabs */}
<div className="flex border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
<button
onClick={() => setViewMode('visual')}
@@ -911,7 +976,7 @@ const ObjectEditor = () => {
}`}
>
<Edit3 className="h-4 w-4" />
<span className="hidden sm:inline">Visual Editor</span>
<span className="hidden sm:inline">Tree</span>
</button>
<button
onClick={() => setViewMode('mindmap')}
@@ -922,7 +987,7 @@ const ObjectEditor = () => {
}`}
>
<Workflow className="h-4 w-4" />
<span className="hidden sm:inline">Mindmap View</span>
<span className="hidden sm:inline">Mindmap</span>
</button>
<button
onClick={() => setViewMode('table')}
@@ -933,7 +998,7 @@ const ObjectEditor = () => {
}`}
>
<Table className="h-4 w-4" />
<span className="hidden sm:inline">Table View</span>
<span className="hidden sm:inline">Table</span>
</button>
</div>
</div>
@@ -954,15 +1019,11 @@ const ObjectEditor = () => {
) : (
<>
{viewMode === 'visual' && (
<div className="w-full overflow-hidden">
<div className="w-full overflow-x-auto p-4">
<div className="min-w-max">
<StructuredEditor
initialData={structuredData}
onDataChange={handleStructuredDataChange}
/>
</div>
</div>
<div className="p-4">
<StructuredEditor
initialData={structuredData}
onDataChange={handleStructuredDataChange}
/>
</div>
)}
@@ -987,6 +1048,7 @@ const ObjectEditor = () => {
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 overflow-hidden mt-6">
{/* Export Header - Collapsible */}
<div
ref={exportCardRef}
onClick={() => setOutputExpanded(!outputExpanded)}
className="px-4 py-3 border-b border-gray-200 dark:border-gray-700 cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
>
@@ -1035,13 +1097,14 @@ const ObjectEditor = () => {
<div className="p-4">
{activeExportTab === 'json' && (
<div className="space-y-3">
<CodeEditor
value={jsonFormat === 'pretty' ? (outputs.jsonPretty || '{}') : (outputs.jsonMinified || '{}')}
<CodeMirrorEditor
value={jsonFormat === 'pretty' ? (outputs.jsonPretty || '{}') : (outputs.jsonMinified || '{}')}
language="json"
readOnly={true}
height="300px"
maxLines={12}
showToggle={true}
className="w-full"
theme={isDark ? 'dark' : 'light'}
cardRef={exportCardRef}
/>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
@@ -1070,26 +1133,27 @@ const ObjectEditor = () => {
<button
onClick={() => {
const content = jsonFormat === 'pretty' ? (outputs.jsonPretty || '{}') : (outputs.jsonMinified || '{}');
navigator.clipboard.writeText(content);
copyToClipboard(content);
setCopiedButton('json');
setTimeout(() => setCopiedButton(null), 2000);
}}
className="px-3 py-1 text-sm bg-gray-100 hover:bg-gray-200 dark:bg-gray-600 dark:hover:bg-gray-500 text-gray-700 dark:text-gray-200 rounded transition-colors"
>
Copy
{copiedButton === 'json' ? '✓ Copied!' : 'Copy'}
</button>
<button
onClick={() => {
const content = jsonFormat === 'pretty' ? (outputs.jsonPretty || '{}') : (outputs.jsonMinified || '{}');
const blob = new Blob([content], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'object-data.json';
a.click();
URL.revokeObjectURL(url);
downloadFile(
getExportData("json"),
"object-data.json",
"application/json",
);
setDownloadedButton('json');
setTimeout(() => setDownloadedButton(null), 2000);
}}
className="px-3 py-1 text-sm bg-blue-600 hover:bg-blue-700 dark:bg-blue-600 dark:hover:bg-blue-700 text-white rounded transition-colors"
>
Download
{downloadedButton === 'json' ? '✓ Downloaded!' : 'Download'}
</button>
</div>
</div>
@@ -1098,23 +1162,26 @@ const ObjectEditor = () => {
{activeExportTab === 'php' && (
<div className="space-y-3">
<CodeEditor
<CodeMirrorEditor
value={outputs.serialized || 'a:0:{}'}
language="javascript"
readOnly={true}
height="300px"
maxLines={12}
showToggle={true}
className="w-full"
theme={isDark ? 'dark' : 'light'}
cardRef={exportCardRef}
/>
<div className="flex justify-end gap-2">
<button
onClick={() => {
const content = outputs.serialized || 'a:0:{}';
navigator.clipboard.writeText(content);
copyToClipboard(content);
setCopiedButton('php');
setTimeout(() => setCopiedButton(null), 2000);
}}
className="px-3 py-1 text-sm bg-gray-100 hover:bg-gray-200 dark:bg-gray-600 dark:hover:bg-gray-500 text-gray-700 dark:text-gray-200 rounded transition-colors"
>
Copy
{copiedButton === 'php' ? '✓ Copied!' : 'Copy'}
</button>
<button
onClick={() => {
@@ -1124,12 +1191,16 @@ const ObjectEditor = () => {
const a = document.createElement('a');
a.href = url;
a.download = 'object-data.txt';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
setDownloadedButton('php');
setTimeout(() => setDownloadedButton(null), 2000);
}}
className="px-3 py-1 text-sm bg-blue-600 hover:bg-blue-700 dark:bg-blue-600 dark:hover:bg-blue-700 text-white rounded transition-colors"
>
Download
{downloadedButton === 'php' ? '✓ Downloaded!' : 'Download'}
</button>
</div>
</div>

View File

@@ -1,35 +1,12 @@
import React, { useState, useEffect } from 'react';
import React, { useState, useEffect, useRef } from 'react';
import { Plus, Upload, FileText, Globe, Download, X, Table, Trash2, Database, Braces, Code, Eye, Minimize2, Maximize2, Search, ArrowUpDown, AlertTriangle, Edit3, ChevronUp, ChevronDown } from 'lucide-react';
import ToolLayout from '../components/ToolLayout';
import CodeEditor from '../components/CodeEditor';
import CodeMirrorEditor from '../components/CodeMirrorEditor';
import StructuredEditor from "../components/StructuredEditor";
import Papa from "papaparse";
// Hook to detect dark mode
const useDarkMode = () => {
const [isDark, setIsDark] = useState(() => {
return document.documentElement.classList.contains('dark');
});
useEffect(() => {
const observer = new MutationObserver(() => {
setIsDark(document.documentElement.classList.contains('dark'));
});
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ['class']
});
return () => observer.disconnect();
}, []);
return isDark;
};
const TableEditor = () => {
const isDark = useDarkMode();
const exportCardRef = useRef(null);
const [data, setData] = useState([]);
const [columns, setColumns] = useState([]);
@@ -69,6 +46,8 @@ const TableEditor = () => {
const [createNewCompleted, setCreateNewCompleted] = useState(false); // Track if user completed Create New step
const [pasteCollapsed, setPasteCollapsed] = useState(false); // Track if paste input is collapsed
const [pasteDataSummary, setPasteDataSummary] = useState(null); // Summary of pasted data
const [urlDataSummary, setUrlDataSummary] = useState(null); // Summary of URL fetched data
const [fileDataSummary, setFileDataSummary] = useState(null); // Summary of file uploaded data
const [exportExpanded, setExportExpanded] = useState(false); // Track if export section is expanded
const [usageTipsExpanded, setUsageTipsExpanded] = useState(false); // Track if usage tips is expanded
@@ -76,6 +55,10 @@ const TableEditor = () => {
const [sqlTableName, setSqlTableName] = useState(""); // Table name for SQL export
const [sqlPrimaryKey, setSqlPrimaryKey] = useState(""); // Primary key column for SQL export
// Button feedback states
const [copiedButton, setCopiedButton] = useState(null);
const [downloadedButton, setDownloadedButton] = useState(null);
// Helper function to check if user has data that would be lost
const hasUserData = () => {
// Check if there are multiple tables (imported data)
@@ -650,55 +633,52 @@ const TableEditor = () => {
// Parse CSV/TSV data
const parseData = (text, hasHeaders = true) => {
try {
const result = Papa.parse(text.trim(), {
header: false,
skipEmptyLines: true,
delimiter: text.includes("\t") ? "\t" : ",",
});
const result = Papa.parse(text.trim(), {
header: false,
skipEmptyLines: true,
delimiter: text.includes("\t") ? "\t" : ",",
});
if (result.errors.length > 0) {
throw new Error(result.errors[0].message);
}
const rows = result.data;
if (rows.length === 0) {
throw new Error("No data found");
}
let headers;
let dataRows;
if (hasHeaders && rows.length > 0) {
headers = rows[0].map((header, index) => ({
id: `col_${index}`,
name: header || `Column ${index + 1}`,
type: "text",
}));
dataRows = rows.slice(1);
} else {
headers = rows[0].map((_, index) => ({
id: `col_${index}`,
name: `Column ${index + 1}`,
type: "text",
}));
dataRows = rows;
}
const tableData = dataRows.map((row, index) => {
const rowData = { id: `row_${index}` };
headers.forEach((header, colIndex) => {
rowData[header.id] = row[colIndex] || "";
});
return rowData;
});
setColumns(headers);
setData(tableData);
setError("");
} catch (err) {
setError(`Failed to parse data: ${err.message}`);
if (result.errors.length > 0) {
throw new Error(result.errors[0].message);
}
const rows = result.data;
if (rows.length === 0) {
throw new Error("No data found");
}
let headers;
let dataRows;
if (hasHeaders && rows.length > 0) {
headers = rows[0].map((header, index) => ({
id: `col_${index}`,
name: header || `Column ${index + 1}`,
type: "text",
}));
dataRows = rows.slice(1);
} else {
headers = rows[0].map((_, index) => ({
id: `col_${index}`,
name: `Column ${index + 1}`,
type: "text",
}));
dataRows = rows;
}
const tableData = dataRows.map((row, index) => {
const rowData = { id: `row_${index}` };
headers.forEach((header, colIndex) => {
rowData[header.id] = row[colIndex] || "";
});
return rowData;
});
setColumns(headers);
setData(tableData);
setError("");
return tableData.length; // Return actual row count
};
// Parse SQL data
@@ -941,40 +921,37 @@ const TableEditor = () => {
// Parse JSON data
const parseJsonData = (text) => {
try {
const jsonData = JSON.parse(text);
const jsonData = JSON.parse(text);
if (!Array.isArray(jsonData)) {
throw new Error("JSON must be an array of objects");
}
if (jsonData.length === 0) {
throw new Error("Array is empty");
}
// Extract columns from first object
const firstItem = jsonData[0];
const headers = Object.keys(firstItem).map((key, index) => ({
id: `col_${index}`,
name: key,
type: typeof firstItem[key] === "number" ? "number" : "text",
}));
// Convert to table format
const tableData = jsonData.map((item, index) => {
const rowData = { id: `row_${index}` };
headers.forEach((header) => {
rowData[header.id] = item[header.name] || "";
});
return rowData;
});
setColumns(headers);
setData(tableData);
setError("");
} catch (err) {
setError(`Failed to parse JSON: ${err.message}`);
if (!Array.isArray(jsonData)) {
throw new Error("JSON must be an array of objects");
}
if (jsonData.length === 0) {
throw new Error("Array is empty");
}
// Extract columns from first object
const firstItem = jsonData[0];
const headers = Object.keys(firstItem).map((key, index) => ({
id: `col_${index}`,
name: key,
type: typeof firstItem[key] === "number" ? "number" : "text",
}));
// Convert to table format
const tableData = jsonData.map((item, index) => {
const rowData = { id: `row_${index}` };
headers.forEach((header) => {
rowData[header.id] = item[header.name] || "";
});
return rowData;
});
setColumns(headers);
setData(tableData);
setError("");
return tableData.length; // Return row count for summary
};
// Handle text input
@@ -987,15 +964,14 @@ const TableEditor = () => {
const trimmed = inputText.trim();
let format = '';
let success = false;
let rowCount = 0;
try {
// Try to detect format
if (trimmed.startsWith("[") && trimmed.endsWith("]")) {
// JSON array
parseJsonData(trimmed);
rowCount = parseJsonData(trimmed);
format = 'JSON';
success = true;
} else if (
trimmed.toLowerCase().includes("insert into") &&
trimmed.toLowerCase().includes("values")
@@ -1003,24 +979,25 @@ const TableEditor = () => {
// SQL INSERT statements
parseSqlData(trimmed);
format = 'SQL';
success = true;
// Get row count from state after parse
rowCount = data.length;
} else {
// CSV/TSV
parseData(trimmed, useFirstRowAsHeader);
format = trimmed.includes('\t') ? 'TSV' : 'CSV';
success = true;
// Get row count from state after parse
rowCount = data.length;
}
// If successful, collapse input and show summary
if (success && data.length > 0) {
setPasteDataSummary({
format: format,
size: inputText.length,
rows: data.length
});
setPasteCollapsed(true);
setError('');
}
// Collapse input and show summary
setPasteDataSummary({
format: format,
size: inputText.length,
rows: rowCount || data.length // Use rowCount if available, fallback to data.length
});
setPasteCollapsed(true);
setCreateNewCompleted(true);
setError('');
} catch (err) {
// Keep input expanded on error
setPasteCollapsed(false);
@@ -1047,14 +1024,28 @@ const TableEditor = () => {
const contentType = response.headers.get("content-type") || "";
const text = await response.text();
let format = '';
let rowCount = 0;
if (contentType.includes("application/json") || url.includes(".json")) {
parseJsonData(text);
rowCount = parseJsonData(text);
format = 'JSON';
} else {
parseData(text, useFirstRowAsHeader);
rowCount = parseData(text, useFirstRowAsHeader);
format = text.includes('\t') ? 'TSV' : 'CSV';
}
// Set summary for URL fetch
setUrlDataSummary({
format: format,
size: text.length,
rows: rowCount,
url: url.trim()
});
setCreateNewCompleted(true);
} catch (err) {
setError(`Failed to fetch data: ${err.message}`);
setUrlDataSummary(null);
} finally {
setIsLoading(false);
}
@@ -1136,17 +1127,47 @@ const TableEditor = () => {
reader.onload = (e) => {
try {
const content = e.target.result;
let format = '';
let rowCount = 0;
// Check if it's SQL file for multi-table support
if (file.name.toLowerCase().endsWith(".sql")) {
initializeTablesFromSQL(content, file.name);
format = 'SQL';
// For SQL, we need to wait for state update, use a timeout
setTimeout(() => {
setFileDataSummary({
format: format,
size: content.length,
rows: data.length,
filename: file.name
});
}, 100);
} else if (file.name.toLowerCase().endsWith(".json")) {
rowCount = parseJsonData(content);
format = 'JSON';
setFileDataSummary({
format: format,
size: content.length,
rows: rowCount,
filename: file.name
});
} else {
// Fallback to single-table parsing
parseData(content);
rowCount = parseData(content, useFirstRowAsHeader);
format = file.name.toLowerCase().endsWith(".tsv") ? 'TSV' : 'CSV';
setFileDataSummary({
format: format,
size: content.length,
rows: rowCount,
filename: file.name
});
}
setCreateNewCompleted(true);
} catch (err) {
console.error("❌ File upload error:", err);
setError("Failed to read file: " + err.message);
setFileDataSummary(null);
} finally {
setIsLoading(false);
}
@@ -1995,56 +2016,72 @@ const TableEditor = () => {
)}
{activeTab === "url" && (
<div className="space-y-3">
<div className="flex gap-2">
<div className="relative flex-1">
<input
type="url"
value={url}
onChange={(e) => setUrl(e.target.value)}
placeholder="https://api.example.com/data.json or https://example.com/data.csv"
className="tool-input w-full pr-10"
disabled={isLoading}
/>
{url && !isLoading && (
<button
onClick={() => setUrl("")}
className="absolute right-3 top-1/2 transform -translate-y-1/2 text-gray-400 hover:text-gray-600 dark:text-gray-500 dark:hover:text-gray-300 transition-colors"
>
<X className="h-4 w-4" />
</button>
)}
urlDataSummary ? (
<div className="p-4 bg-green-50 dark:bg-green-900/20 rounded-lg">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
<span className="text-sm text-green-700 dark:text-green-300 break-words">
✓ Data loaded: {urlDataSummary.format} ({urlDataSummary.size.toLocaleString()} chars, {urlDataSummary.rows} rows)
</span>
<button
onClick={() => setUrlDataSummary(null)}
className="text-sm text-blue-600 dark:text-blue-400 hover:underline whitespace-nowrap"
>
Fetch New URL ▼
</button>
</div>
<button
onClick={fetchUrlData}
disabled={isLoading || !url.trim()}
className="bg-blue-600 hover:bg-blue-700 disabled:bg-gray-400 text-white font-medium px-4 py-2 rounded-md transition-colors flex items-center whitespace-nowrap"
>
{isLoading ? "Fetching..." : "Fetch Data"}
</button>
</div>
<label className="flex items-center text-sm text-gray-600 dark:text-gray-400">
<input
type="checkbox"
checked={useFirstRowAsHeader}
onChange={(e) => setUseFirstRowAsHeader(e.target.checked)}
className="mr-2"
/>
Use first row as column headers (for CSV/TSV)
</label>
</div>
) : (
<div className="space-y-3">
<div className="flex gap-2">
<div className="relative flex-1">
<input
type="url"
value={url}
onChange={(e) => setUrl(e.target.value)}
placeholder="https://api.example.com/data.json or https://example.com/data.csv"
className="tool-input w-full pr-10"
disabled={isLoading}
/>
{url && !isLoading && (
<button
onClick={() => setUrl("")}
className="absolute right-3 top-1/2 transform -translate-y-1/2 text-gray-400 hover:text-gray-600 dark:text-gray-500 dark:hover:text-gray-300 transition-colors"
>
<X className="h-4 w-4" />
</button>
)}
</div>
<button
onClick={fetchUrlData}
disabled={isLoading || !url.trim()}
className="bg-blue-600 hover:bg-blue-700 disabled:bg-gray-400 text-white font-medium px-4 py-2 rounded-md transition-colors flex items-center whitespace-nowrap"
>
{isLoading ? "Fetching..." : "Fetch Data"}
</button>
</div>
<label className="flex items-center text-sm text-gray-600 dark:text-gray-400">
<input
type="checkbox"
checked={useFirstRowAsHeader}
onChange={(e) => setUseFirstRowAsHeader(e.target.checked)}
className="mr-2"
/>
Use first row as column headers (for CSV/TSV)
</label>
</div>
)
)}
{activeTab === "paste" && (
pasteCollapsed ? (
<div className="p-4 bg-green-50 dark:bg-green-900/20 rounded-lg">
<div className="flex items-center justify-between">
<span className="text-sm text-green-700 dark:text-green-300">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
<span className="text-sm text-green-700 dark:text-green-300 break-words">
✓ Data loaded: {pasteDataSummary.format} ({pasteDataSummary.size.toLocaleString()} chars, {pasteDataSummary.rows} rows)
</span>
<button
onClick={() => setPasteCollapsed(false)}
className="text-sm text-blue-600 hover:underline"
className="text-sm text-blue-600 dark:text-blue-400 hover:underline whitespace-nowrap"
>
Edit Input ▼
</button>
@@ -2092,18 +2129,33 @@ const TableEditor = () => {
)}
{activeTab === "upload" && (
<div className="space-y-3">
<input
type="file"
accept=".csv,.tsv,.json,.sql"
onChange={handleFileUpload}
className="tool-input"
/>
<label className="flex items-center text-sm text-gray-600 dark:text-gray-400">
fileDataSummary ? (
<div className="p-4 bg-green-50 dark:bg-green-900/20 rounded-lg">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
<span className="text-sm text-green-700 dark:text-green-300 break-words">
✓ File loaded: {fileDataSummary.format} ({fileDataSummary.size.toLocaleString()} chars, {fileDataSummary.rows} rows) - {fileDataSummary.filename}
</span>
<button
onClick={() => setFileDataSummary(null)}
className="text-sm text-blue-600 dark:text-blue-400 hover:underline whitespace-nowrap"
>
Upload New File ▼
</button>
</div>
</div>
) : (
<div className="space-y-3">
<input
type="checkbox"
checked={useFirstRowAsHeader}
onChange={(e) => setUseFirstRowAsHeader(e.target.checked)}
type="file"
accept=".csv,.tsv,.json,.sql"
onChange={handleFileUpload}
className="tool-input"
/>
<label className="flex items-center text-sm text-gray-600 dark:text-gray-400">
<input
type="checkbox"
checked={useFirstRowAsHeader}
onChange={(e) => setUseFirstRowAsHeader(e.target.checked)}
className="mr-2"
/>
Use first row as column headers (for CSV/TSV)
@@ -2115,7 +2167,8 @@ const TableEditor = () => {
open, edit, and export your files locally.
</p>
</div>
</div>
</div>
)
)}
</div>
)}
@@ -2288,7 +2341,7 @@ const TableEditor = () => {
style={{ maxWidth: '100%' }}
>
<table className="w-full">
<thead className="bg-gray-50 dark:bg-gray-700 sticky top-0 z-10">
<thead className="bg-gray-50 dark:bg-gray-700 sticky top-[-1px] z-10">
<tr>
<th
className={`px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider border-r border-gray-200 dark:border-gray-600 ${
@@ -2663,10 +2716,11 @@ const TableEditor = () => {
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 overflow-hidden mt-6">
{/* Export Header - Collapsible */}
<div
ref={exportCardRef}
onClick={() => setExportExpanded(!exportExpanded)}
className="px-4 py-3 border-b border-gray-200 dark:border-gray-700 cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
>
<div className="flex items-center justify-between">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white flex items-center gap-2">
<Download className="h-5 w-5 text-green-600 dark:text-green-400" />
Export Results
@@ -2744,12 +2798,14 @@ const TableEditor = () => {
<div className="p-4">
{exportTab === "json" && (
<div className="space-y-3">
<CodeEditor
<CodeMirrorEditor
value={getExportData("json")}
language="json"
readOnly={true}
height="256px"
theme={isDark ? 'dark' : 'light'}
maxLines={12}
showToggle={true}
className="w-full"
cardRef={exportCardRef}
/>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
@@ -2776,22 +2832,28 @@ const TableEditor = () => {
</div>
<div className="flex gap-2">
<button
onClick={() => copyToClipboard(getExportData("json"))}
onClick={() => {
copyToClipboard(getExportData("json"));
setCopiedButton('json');
setTimeout(() => setCopiedButton(null), 2000);
}}
className="px-3 py-1 text-sm bg-gray-100 hover:bg-gray-200 dark:bg-gray-600 dark:hover:bg-gray-500 text-gray-700 dark:text-gray-200 rounded transition-colors"
>
Copy
{copiedButton === 'json' ? '✓ Copied!' : 'Copy'}
</button>
<button
onClick={() =>
onClick={() => {
downloadFile(
getExportData("json"),
"table-data.json",
"application/json",
)
}
);
setDownloadedButton('json');
setTimeout(() => setDownloadedButton(null), 2000);
}}
className="px-3 py-1 text-sm bg-blue-600 hover:bg-blue-700 dark:bg-blue-600 dark:hover:bg-blue-700 text-white rounded transition-colors"
>
Download
{downloadedButton === 'json' ? '✓ Downloaded!' : 'Download'}
</button>
</div>
</div>
@@ -2800,31 +2862,39 @@ const TableEditor = () => {
{exportTab === "csv" && (
<div className="space-y-3">
<CodeEditor
<CodeMirrorEditor
value={getExportData("csv")}
language="javascript"
readOnly={true}
height="256px"
theme={isDark ? 'dark' : 'light'}
maxLines={12}
showToggle={true}
className="w-full"
cardRef={exportCardRef}
/>
<div className="flex justify-end gap-2">
<button
onClick={() => copyToClipboard(getExportData("csv"))}
onClick={() => {
copyToClipboard(getExportData("csv"));
setCopiedButton('csv');
setTimeout(() => setCopiedButton(null), 2000);
}}
className="px-3 py-1 text-sm bg-gray-100 hover:bg-gray-200 dark:bg-gray-600 dark:hover:bg-gray-500 text-gray-700 dark:text-gray-200 rounded transition-colors"
>
Copy
{copiedButton === 'csv' ? '✓ Copied!' : 'Copy'}
</button>
<button
onClick={() =>
onClick={() => {
downloadFile(
getExportData("csv"),
"table-data.csv",
"text/csv",
)
}
);
setDownloadedButton('csv');
setTimeout(() => setDownloadedButton(null), 2000);
}}
className="px-3 py-1 text-sm bg-blue-600 hover:bg-blue-700 dark:bg-blue-600 dark:hover:bg-blue-700 text-white rounded transition-colors"
>
Download
{downloadedButton === 'csv' ? '✓ Downloaded!' : 'Download'}
</button>
</div>
</div>
@@ -2832,31 +2902,39 @@ const TableEditor = () => {
{exportTab === "tsv" && (
<div className="space-y-3">
<CodeEditor
<CodeMirrorEditor
value={getExportData("tsv")}
language="javascript"
readOnly={true}
height="256px"
theme={isDark ? 'dark' : 'light'}
maxLines={12}
showToggle={true}
className="w-full"
cardRef={exportCardRef}
/>
<div className="flex justify-end gap-2">
<button
onClick={() => copyToClipboard(getExportData("tsv"))}
onClick={() => {
copyToClipboard(getExportData("tsv"));
setCopiedButton('tsv');
setTimeout(() => setCopiedButton(null), 2000);
}}
className="px-3 py-1 text-sm bg-gray-100 hover:bg-gray-200 dark:bg-gray-600 dark:hover:bg-gray-500 text-gray-700 dark:text-gray-200 rounded transition-colors"
>
Copy
{copiedButton === 'tsv' ? '✓ Copied!' : 'Copy'}
</button>
<button
onClick={() =>
onClick={() => {
downloadFile(
getExportData("tsv"),
"table-data.tsv",
"text/tab-separated-values",
)
}
);
setDownloadedButton('tsv');
setTimeout(() => setDownloadedButton(null), 2000);
}}
className="px-3 py-1 text-sm bg-blue-600 hover:bg-blue-700 dark:bg-blue-600 dark:hover:bg-blue-700 text-white rounded transition-colors"
>
Download
{downloadedButton === 'tsv' ? '✓ Downloaded!' : 'Download'}
</button>
</div>
</div>
@@ -2907,12 +2985,14 @@ const TableEditor = () => {
</div>
</div>
<CodeEditor
<CodeMirrorEditor
value={getExportData("sql")}
language="javascript"
language="sql"
readOnly={true}
height="256px"
theme={isDark ? 'dark' : 'light'}
maxLines={12}
showToggle={true}
className="w-full"
cardRef={exportCardRef}
/>
{/* Intelligent Schema Analysis */}
@@ -3046,22 +3126,28 @@ const TableEditor = () => {
<div className="flex justify-end gap-2">
<button
onClick={() => copyToClipboard(getExportData("sql"))}
onClick={() => {
copyToClipboard(getExportData("sql"));
setCopiedButton('sql');
setTimeout(() => setCopiedButton(null), 2000);
}}
className="px-3 py-1 text-sm bg-gray-100 hover:bg-gray-200 dark:bg-gray-600 dark:hover:bg-gray-500 text-gray-700 dark:text-gray-200 rounded transition-colors"
>
Copy
{copiedButton === 'sql' ? '✓ Copied!' : 'Copy'}
</button>
<button
onClick={() =>
onClick={() => {
downloadFile(
getExportData("sql"),
`${sqlTableName || currentTable || originalFileName || "database"}.sql`,
`${sqlTableName || "database"}.sql`,
"application/sql",
)
}
);
setDownloadedButton('sql');
setTimeout(() => setDownloadedButton(null), 2000);
}}
className="px-3 py-1 text-sm bg-blue-600 hover:bg-blue-700 dark:bg-blue-600 dark:hover:bg-blue-700 text-white rounded transition-colors"
>
Download
{downloadedButton === 'sql' ? '✓ Downloaded!' : 'Download'}
</button>
</div>
</div>
@@ -3570,10 +3656,26 @@ const ObjectEditorModal = ({ modal, onClose, onApply }) => {
Row {modal.rowIndex} Column: {modal.columnName} Format:{" "}
{modal.format.type.replace("_", " ")}
</p>
{/* Format info */}
<div className="text-sm">
<span
className={`${isValid ? "text-green-600 dark:text-green-400" : "text-red-600 dark:text-red-400"}`}
>
{isValid ? "✓ Valid" : "✗ Invalid"}{" "}
{modal.format.type.replace("_", " ")}
</span>
{isValid &&
structuredData &&
typeof structuredData === "object" && (
<span className="text-gray-600 dark:text-gray-400">
{" • "}{Object.keys(structuredData).length} properties
</span>
)}
</div>
</div>
<button
onClick={onClose}
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 self-start"
>
<X className="h-6 w-6" />
</button>
@@ -3633,23 +3735,11 @@ const ObjectEditorModal = ({ modal, onClose, onApply }) => {
{/* Footer */}
<div className="px-6 py-4 border-t border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-700 flex-shrink-0">
<div className="flex items-center justify-between">
<div className="flex items-center space-x-4">
<span
className={`text-sm ${isValid ? "text-green-600" : "text-red-600"}`}
>
{isValid ? "✓ Valid" : "✗ Invalid"}{" "}
{modal.format.type.replace("_", " ")}
</span>
{isValid &&
structuredData &&
typeof structuredData === "object" && (
<span className="text-sm text-gray-600 dark:text-gray-400">
{Object.keys(structuredData).length} properties
</span>
)}
</div>
<div className="flex space-x-3">
<div className="flex flex-col gap-3">
{/* Buttons */}
<div className="flex justify-end space-x-3">
<button
onClick={onClose}
className="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-600 border border-gray-300 dark:border-gray-500 rounded-md hover:bg-gray-50 dark:hover:bg-gray-500 transition-colors"
@@ -3661,7 +3751,7 @@ const ObjectEditorModal = ({ modal, onClose, onApply }) => {
disabled={!isValid}
className="px-4 py-2 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 disabled:bg-gray-400 rounded-md transition-colors"
>
Apply Changes
Save Changes
</button>
</div>
</div>