Compare commits
2 Commits
f60c1d16c8
...
df0fb5d22a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
df0fb5d22a | ||
|
|
f6c19e855d |
412
SEO_FIX_GUIDE.md
Normal file
412
SEO_FIX_GUIDE.md
Normal 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
1132
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
31
package.json
31
package.json
@@ -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",
|
||||
|
||||
@@ -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": [
|
||||
|
||||
@@ -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>
|
||||
|
||||
54
src/App.js
54
src/App.js
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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
75
src/components/SEO.js
Normal 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;
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user