Compare commits

..

15 Commits

Author SHA1 Message Date
Dwindi Ramadhana
1047642909 fix(diagram): correct flowchart template edge connections to match diagram logic 2026-06-14 20:06:01 +07:00
Dwindi Ramadhana
8caf6fbba5 fix(diagram): add missing Copy icon import for DiagramEditor export section 2026-06-14 19:57:25 +07:00
Dwindi Ramadhana
3727ace366 fix(build): remove unused imports in DiagramEditor and ReactFlowEditor 2026-06-14 18:45:14 +07:00
Dwindi Ramadhana
81c399ab42 fix(build): add missing DiagramEditor and FullscreenAdBanner files to git tracking 2026-06-14 16:05:40 +07:00
Dwindi Ramadhana
518b0127d2 fix(diagram): resolve DOMURL undefined error causing build failure 2026-06-14 15:55:08 +07:00
Dwindi Ramadhana
deb2bf0b8a fix(ads): adjust internal iframe styles to minimize bottom gap 2026-06-14 15:43:42 +07:00
Dwindi Ramadhana
e4ccff4bbf chore: update TODO.md to mark Markdown Editor MVP tasks as completed 2026-06-14 13:30:37 +07:00
Dwindi Ramadhana
0b1cfbdabd fix(layout): remove global overflow-x-hidden to restore sticky sidebar behavior 2026-06-14 13:15:51 +07:00
Dwindi Ramadhana
c580a5f7b0 fix(markdown-editor): align max width and formatting across read and edit views 2026-06-14 12:35:18 +07:00
Dwindi Ramadhana
9232052508 chore(ads): update adsterra anti-adblock custom domain to downconvenientmagnetic 2026-06-14 12:32:16 +07:00
Dwindi Ramadhana
fcbfeb44f8 fix(build): remove unused import EyeOff to pass strict CI build 2026-06-14 11:52:19 +07:00
Dwindi Ramadhana
dd0b98e077 chore: remove metadata files created by SSD 2026-06-14 01:01:03 +07:00
Dwindi Ramadhana
dcba58c2b9 chore: ignore OS meta files and update changelog for WYSIWYG and Object Editor updates 2026-06-14 00:56:38 +07:00
Dwindi Ramadhana
7b3dce06ea refactor(markdown-editor): migrate to tiptap for WYSIWYG editing, standardize UI spacing, and update export engine 2026-06-14 00:54:01 +07:00
Dwindi Ramadhana
13e694aa82 feat: add multidimensional search, preview mode prioritization, and collapse/expand all to Object Editor 2026-06-13 20:11:07 +07:00
29 changed files with 5470 additions and 2485 deletions

Binary file not shown.

BIN
._package-lock.json generated

Binary file not shown.

Binary file not shown.

3
.gitignore vendored
View File

@@ -11,7 +11,6 @@ node_modules/
/backup /backup
# Misc # Misc
.DS_Store
.env.local .env.local
.env.development.local .env.development.local
.env.test.local .env.test.local
@@ -46,4 +45,6 @@ pids
*.swo *.swo
# OS generated files # OS generated files
.DS_Store
Thumbs.db Thumbs.db
._*

View File

@@ -131,37 +131,36 @@ Build a comprehensive suite of developer tools with a focus on:
--- ---
#### Priority 3: AdSense Integration 💵 #### Priority 3: Adsterra Integration 💵
**Status:** ⏳ In Progress (Awaiting approval) **Status:** ✅ Completed
**Timeline:** 1 day **Timeline:** 1 day
**Impact:** HIGH - Start earning revenue **Impact:** HIGH - Start earning revenue
**Steps:** **Steps:**
1. Apply for Google AdSense account 1. Apply for Adsterra account
2. Add AdSense script to `index.html` 2. Add Adsterra anti-adblock script to `index.html` and components
3. Create ad units in AdSense dashboard 3. Create ad units in Adsterra dashboard
4. Implement ad components with AdSense code 4. Implement ad components with Adsterra code
5. Test ad display and responsiveness 5. Test ad display and responsiveness
6. Monitor ad performance
**Ad Units Needed:** **Ad Units Needed:**
- Desktop Sidebar 1 (300x250) - Desktop Sidebar 1 (300x250)
- Desktop Sidebar 2 (300x250) - Desktop Sidebar 2 (300x250)
- Desktop Sidebar 3 (300x250) - Desktop Sidebar 3 (300x250)
- Mobile Bottom Banner (320x50) - Mobile Bottom Banner (320x50)
**Compliance:** **Compliance:**
- Add Privacy Policy page - Add Privacy Policy page
- Add Terms of Service page - Add Terms of Service page
- Cookie consent banner (if required) - Cookie consent banner
- GDPR compliance (if applicable) - GDPR compliance
--- ---
### Phase 2: Content Expansion (Week 3-6) ### Phase 2: Content Expansion (Week 3-6)
#### Markdown Editor 📝 #### Markdown Editor 📝
**Status:** ✅ Completed (October 22, 2025) **Status:** ✅ Completed (June 14, 2026)
**Timeline:** 1-2 weeks **Timeline:** 1-2 weeks
**Impact:** HIGH - Major new feature, attracts new users **Impact:** HIGH - Major new feature, attracts new users
@@ -170,29 +169,25 @@ Build a comprehensive suite of developer tools with a focus on:
- Create New (empty/sample) - Create New (empty/sample)
- URL Import (fetch markdown from GitHub, Gist, etc.) - URL Import (fetch markdown from GitHub, Gist, etc.)
- Paste (markdown, HTML auto-convert, plain text) - Paste (markdown, HTML auto-convert, plain text)
- Open Files (.md, .txt, .html, .docx) - Open Files (.md, .txt)
- **Editor:** - **Editor:**
- CodeMirror with markdown syntax highlighting - Tiptap-powered WYSIWYG Rich Text Editor
- Split view (editor + live preview) - Fallback Raw Markdown CodeMirror Editor
- View modes: Split, Editor Only, Preview Only, Fullscreen - View modes: Read, Edit, Markdown
- Markdown toolbar (Bold, Italic, Headers, Links, Images, Code, Lists, Tables) - Toolbar (Bold, Italic, Headers, Links, Images, Code, Lists, Tables)
- Line numbers
- Word count & statistics - Word count & statistics
- **Preview:** - **Preview:**
- Live rendering (marked + DOMPurify) - Auto-generated HTML parsing
- Syntax highlighting for code blocks (highlight.js) - Syntax highlighting for code blocks (highlight.js)
- GitHub Flavored Markdown support
- Table of Contents auto-generation
- Mermaid diagram rendering (in preview)
- **Export:** - **Export:**
- Markdown (.md) - Standard, GFM, CommonMark - Markdown (.md)
- HTML (.html) - Standalone with CSS - HTML (.html)
- HTML Content Body
- Plain Text (.txt) - Plain Text (.txt)
- PDF (.pdf) - via html2pdf - PDF (.pdf) - via html2pdf
- DOCX (.docx) - via docx library
**Advanced Features (Post-MVP):** **Advanced Features (Post-MVP):**
- Tables support (GitHub-style) - Tables support (GitHub-style)

368
TODO.md
View File

@@ -179,310 +179,168 @@
--- ---
### 💵 Priority 3: AdSense Integration (1 day) - ⏳ IN PROGRESS ### 💵 Priority 3: Adsterra Integration (1 day) - ✅ COMPLETED
#### AdSense Setup #### Adsterra Setup
- [ ] Apply for Google AdSense account - [x] Apply for Adsterra publisher account
- [ ] Provide website URL - [x] Add website URL
- [ ] Provide contact information - [x] Receive approval
- [ ] Wait for approval (can take 1-3 days)
- [ ] Verify site ownership (add verification code)
#### Ad Units Creation #### Ad Units Creation
- [ ] Log in to AdSense dashboard - [x] Create ad unit: Desktop Sidebar 1 (300x250)
- [ ] Create ad unit: Desktop Sidebar 1 (300x250) - [x] Create ad unit: Desktop Sidebar 2 (300x250)
- [ ] Create ad unit: Desktop Sidebar 2 (300x250) - [x] Create ad unit: Mobile Bottom Banner (320x50)
- [ ] Create ad unit: Desktop Sidebar 3 (300x250) - [x] Copy ad unit codes
- [ ] Create ad unit: Mobile Bottom Banner (320x50) - [x] Request Anti-Adblock custom domain
- [ ] Copy ad unit codes
#### Implementation #### Implementation
- [ ] Add AdSense script to `public/index.html` - [x] Update `AdBlock.jsx` with Adsterra iframe code
```html - [x] Update `MobileAdBanner.jsx` with Adsterra iframe code
<script async src="https://pagead2.googlesyndication.com/pagead/js/adsbygoogle.js?client=ca-pub-XXXXXXXX" - [x] Update custom Anti-Adblock domain (`downconvenientmagnetic.com`)
crossorigin="anonymous"></script>
```
- [ ] Update `AdBlock.jsx` with AdSense code
```jsx
<ins className="adsbygoogle"
style={{ display: 'block' }}
data-ad-client="ca-pub-XXXXXXXX"
data-ad-slot="XXXXXXXXXX"
data-ad-format="auto"
data-full-width-responsive="true"></ins>
```
- [ ] Update `MobileAdBanner.jsx` with AdSense code
- [ ] Initialize ads: `(adsbygoogle = window.adsbygoogle || []).push({});`
#### Testing #### Testing
- [ ] Test ad display on desktop - [x] Test ad display on desktop
- [ ] Test ad display on mobile - [x] Test ad display on mobile
- [ ] Verify ads load correctly - [x] Verify ads load correctly
- [ ] Check for console errors - [x] Check for console errors
- [ ] Test with ad blocker (should show message) - [x] Test on different devices
- [ ] Test on different browsers (Chrome, Firefox, Safari)
- [ ] Test on different devices
#### Monitoring #### Monitoring
- [ ] Set up AdSense reporting - [x] Monitor ad impressions
- [ ] Monitor ad impressions - [x] Monitor ad clicks
- [ ] Monitor ad clicks - [x] Track CTR (Click-Through Rate)
- [ ] Monitor ad revenue
- [ ] Track CTR (Click-Through Rate)
- [ ] Identify best-performing ad units
#### Compliance #### Compliance
- [ ] Create Privacy Policy page - [x] Create Privacy Policy page
- [ ] Data collection disclosure - [x] Data collection disclosure
- [ ] Cookie usage disclosure - [x] Cookie usage disclosure
- [ ] Third-party services (AdSense) - [x] Third-party services (Adsterra)
- [ ] User rights (GDPR) - [x] User rights (GDPR)
- [ ] Create Terms of Service page - [x] Create Terms of Service page
- [ ] Acceptable use policy - [x] Acceptable use policy
- [ ] Disclaimer - [x] Disclaimer
- [ ] Limitation of liability - [x] Limitation of liability
- [ ] Add cookie consent banner (if required) - [x] Add cookie consent banner
- [ ] Show on first visit - [x] Add "Privacy Policy" link in footer
- [ ] Allow accept/decline - [x] Add "Terms of Service" link in footer
- [ ] Store preference
- [ ] Add "About Ads" link in footer
- [ ] Add "Privacy Policy" link in footer
- [ ] Add "Terms of Service" link in footer
#### Optimization
- [ ] Test different ad placements
- [ ] Test different ad sizes
- [ ] Monitor ad viewability
- [ ] Optimize for higher CTR
- [ ] A/B test ad positions (optional)
--- ---
## 📋 Phase 2: Content Expansion ## 📋 Phase 2: Content Expansion
### 📝 Markdown Editor - MVP (1-2 weeks) - ✅ COMPLETED (Oct 22, 2025) ### 📝 Markdown Editor - MVP (1-2 weeks) - ✅ COMPLETED
#### Planning #### Planning
- [ ] Finalize feature list for MVP - [x] Finalize feature list for MVP
- [ ] Design UI mockup (split view) - [x] Design UI mockup (WYSIWYG layout)
- [ ] Plan component structure - [x] Plan component structure
- [ ] Choose markdown parser (marked vs markdown-it) - [x] Implement Tiptap integration
- [ ] Plan export formats - [x] Plan export formats
#### Project Setup #### Project Setup
- [ ] Create `MarkdownEditor.jsx` page - [x] Create `MarkdownEditor.jsx` page
- [ ] Set up routing (`/markdown-editor`) - [x] Create `RichMarkdownEditor.js` component
- [ ] Add to navigation menu - [x] Set up routing (`/markdown-editor`)
- [ ] Add to homepage tools list - [x] Add to navigation menu
- [x] Add to homepage tools list
#### Input Section #### Input Section
- [ ] Implement Create New tab - [x] Implement Create New tab
- [ ] Start Empty button - [x] Start Empty button
- [ ] Load Sample button (with example markdown) - [x] Load Sample button (with example markdown)
- [ ] Tip box - [x] Tip box
- [ ] Implement URL tab - [x] Implement URL tab
- [ ] Use AdvancedURLFetch component - [x] Use AdvancedURLFetch component
- [ ] Support GitHub raw URLs - [x] Support GitHub raw URLs
- [ ] Support Gist URLs - [x] Support Gist URLs
- [ ] Test with various markdown sources - [x] Test with various markdown sources
- [ ] Implement Paste tab - [x] Implement Paste tab
- [ ] CodeMirror editor - [x] CodeMirror editor
- [ ] Markdown syntax highlighting - [x] Markdown syntax highlighting
- [ ] Auto-detect markdown - [x] Parse button
- [ ] Parse button - [x] Implement Open tab
- [ ] Collapse after parse - [x] Support .md files
- [ ] Implement Open tab - [x] Support .txt files
- [ ] Support .md files - [x] Auto-load on file selection
- [ ] Support .txt files
- [ ] Support .html files (convert to markdown)
- [ ] Support .docx files (convert to markdown)
- [ ] Auto-load on file selection
#### Editor Section #### Editor Section
- [ ] Set up CodeMirror for markdown - [x] Implement WYSIWYG Editor (Tiptap)
- [ ] Install @codemirror/lang-markdown - [x] Install `@tiptap/react` and `tiptap-markdown`
- [ ] Configure markdown mode - [x] Add standard text formatting (bold, italic, strike)
- [ ] Add syntax highlighting - [x] Add block formatting (headers, quotes, lists)
- [ ] Add line numbers - [x] Add inline code and code block extensions
- [ ] Add line wrapping - [x] Set up Lowlight syntax highlighting
- [ ] Implement split view layout - [x] Implement view mode toggle
- [ ] Editor pane (left) - [x] Read mode (Clean preview default)
- [ ] Preview pane (right) - [x] Edit mode (WYSIWYG Tiptap)
- [ ] Resizable divider (optional) - [x] Markdown mode (Raw CodeMirror)
- [ ] Implement view mode toggle - [x] Fullscreen mode
- [ ] Split view (default) - [x] Add editor features
- [ ] Editor only - [x] Word count
- [ ] Preview only - [x] Character count
- [ ] Fullscreen mode - [x] Line count
- [ ] Add markdown toolbar - [x] Reading time estimate
- [ ] Bold button (Ctrl+B)
- [ ] Italic button (Ctrl+I)
- [ ] H1 button
- [ ] H2 button
- [ ] H3 button
- [ ] Link button (Ctrl+K)
- [ ] Image button
- [ ] Code button (Ctrl+`)
- [ ] Quote button
- [ ] Unordered list button
- [ ] Ordered list button
- [ ] Table button
- [ ] Add editor features
- [ ] Word count
- [ ] Character count
- [ ] Line count
- [ ] Reading time estimate
#### Preview Section #### Preview Section
- [ ] Set up markdown parser (marked) - [x] Build robust HTML to Markdown / Markdown to HTML sync
- [ ] Install marked - [x] Set up markdown fallback parser (marked)
- [ ] Install DOMPurify - [x] GitHub Flavored Markdown support (Tables, task lists)
- [ ] Configure marked options - [x] Custom code block rendering with Copy button in Read mode
- [ ] Implement live preview
- [ ] Real-time rendering
- [ ] Debounce for performance
- [ ] Scroll sync (optional)
- [ ] Add syntax highlighting for code blocks
- [ ] Install highlight.js
- [ ] Configure languages
- [ ] Apply highlighting
- [ ] Add GitHub Flavored Markdown support
- [ ] Tables
- [ ] Strikethrough
- [ ] Task lists
- [ ] Autolinks
- [ ] Implement Table of Contents
- [ ] Auto-generate from headers
- [ ] Clickable links
- [ ] Collapsible (optional)
- [ ] Add mermaid diagram rendering
- [ ] Install mermaid
- [ ] Detect mermaid code blocks
- [ ] Render diagrams
- [ ] Error handling
#### Export Section #### Export Section
- [ ] Create collapsible export section - [x] Create collapsible export section
- [ ] Implement Markdown export - [x] Implement Markdown export
- [ ] Standard Markdown - [x] Copy to clipboard
- [ ] GitHub Flavored Markdown - [x] Download as .md file
- [ ] CommonMark - [x] Implement HTML export
- [ ] Copy to clipboard - [x] Standalone HTML with CSS
- [ ] Download as .md file - [x] Download as .html file
- [ ] Implement HTML export - [x] Implement HTML Content export
- [ ] Standalone HTML with CSS - [x] Strip React/Tailwind wrapper classes
- [ ] Inline styles - [x] Download body HTML only
- [ ] Include syntax highlighting CSS - [x] Implement Plain Text export
- [ ] Copy to clipboard - [x] Strip markdown syntax via regex
- [ ] Download as .html file - [x] Download as .txt file
- [ ] Implement Plain Text export - [x] Implement PDF export
- [ ] Strip all formatting - [x] Install html2pdf.js
- [ ] Copy to clipboard - [x] Inject CSS print media rules to prevent pre overflow
- [ ] Download as .txt file - [x] Download as .pdf file
- [ ] Implement PDF export
- [ ] Install html2pdf.js
- [ ] Convert HTML to PDF
- [ ] Maintain formatting
- [ ] Download as .pdf file
- [ ] Implement DOCX export
- [ ] Install docx library
- [ ] Convert markdown to DOCX
- [ ] Maintain formatting
- [ ] Download as .docx file
#### Conversion Features
- [ ] HTML to Markdown conversion
- [ ] Install turndown
- [ ] Convert on paste (if HTML detected)
- [ ] Convert on file open (.html)
- [ ] DOCX to Markdown conversion
- [ ] Install mammoth.js
- [ ] Convert on file open (.docx)
- [ ] Extract text and formatting
#### Usage Tips
- [ ] Create collapsible Usage Tips section
- [ ] Add Input Methods tips
- [ ] Add Editor Features tips
- [ ] Add Markdown Syntax tips
- [ ] Add Export Options tips
- [ ] Add Data Privacy tips
#### Data Loss Prevention #### Data Loss Prevention
- [ ] Implement hasUserData() function - [x] Implement `hasUserData()` function
- [ ] Implement hasModifiedData() function - [x] Implement `hasModifiedData()` function
- [ ] Add confirmation modal for tab changes - [x] Add confirmation modal for tab changes
- [ ] Add confirmation for Create New buttons - [x] Add confirmation for Create New buttons
#### Testing #### Testing
- [ ] Test all input methods - [x] Test all input methods
- [ ] Test markdown rendering - [x] Test Tiptap to Markdown serialization
- [ ] Test all export formats - [x] Test all export formats
- [ ] Test HTML to Markdown conversion - [x] Test code syntax highlighting
- [ ] Test DOCX import - [x] Test view mode toggle
- [ ] Test mermaid diagrams - [x] Test toolbar buttons
- [ ] Test code syntax highlighting - [x] Test responsive design
- [ ] Test Table of Contents - [x] Test dark mode
- [ ] Test view mode toggle
- [ ] Test toolbar buttons
- [ ] Test keyboard shortcuts
- [ ] Test responsive design
- [ ] Test dark mode
- [ ] Test on mobile devices
#### Documentation
- [ ] Add to EDITOR_TOOL_GUIDE.md
- [ ] Create user guide
- [ ] Add screenshots
- [ ] Create tutorial video (optional)
--- ---
### 📝 Markdown Editor - Post-MVP (Future) ### 📝 Markdown Editor - Post-MVP (Future)
#### Advanced Markdown Features #### Advanced Markdown Features
- [ ] Add table support (GitHub-style)
- [ ] Add task lists (checkboxes)
- [ ] Add footnotes support - [ ] Add footnotes support
- [ ] Add emoji support (:smile:) - [ ] Add emoji support (WYSIWYG picker)
- [ ] Add math equations (KaTeX) - [ ] Add math equations (KaTeX)
- [ ] Install katex - [ ] Add mermaid diagram rendering
- [ ] Detect math blocks - [ ] Implement Table of Contents auto-generation
- [ ] Render equations
#### Templates
- [ ] Create README.md template
- [ ] Create Documentation template
- [ ] Create Blog post template
- [ ] Create Meeting notes template
- [ ] Create Project proposal template
- [ ] Add template selector UI
- [ ] Allow custom templates
#### Utilities #### Utilities
- [ ] Add markdown linter - [ ] Add markdown linter
- [ ] Check for common issues
- [ ] Suggest improvements
- [ ] Show warnings
- [ ] Add link checker
- [ ] Validate URLs
- [ ] Check for broken links
- [ ] Show status
- [ ] Add format beautifier - [ ] Add format beautifier
- [ ] Clean up markdown
- [ ] Consistent formatting
- [ ] Fix indentation
- [ ] Add image optimizer - [ ] Add image optimizer
- [ ] Compress images
- [ ] Convert to base64
- [ ] Optimize for web
#### Enhanced Features #### Enhanced Features
- [ ] Add keyboard shortcuts
- [ ] Add auto-save (localStorage) - [ ] Add auto-save (localStorage)
- [ ] Add export history
- [ ] Add version history - [ ] Add version history
- [ ] Add collaborative editing (future)
--- ---

2425
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -18,22 +18,37 @@
"@dnd-kit/core": "^6.3.1", "@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0", "@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2", "@dnd-kit/utilities": "^3.2.2",
"@tailwindcss/typography": "^0.5.20",
"@testing-library/jest-dom": "^6.8.0", "@testing-library/jest-dom": "^6.8.0",
"@testing-library/react": "^16.3.0", "@testing-library/react": "^16.3.0",
"@testing-library/user-event": "^14.6.1", "@testing-library/user-event": "^14.6.1",
"@tiptap/extension-code-block-lowlight": "^3.26.1",
"@tiptap/extension-image": "^3.26.1",
"@tiptap/extension-link": "^3.26.1",
"@tiptap/extension-table": "^3.26.1",
"@tiptap/extension-table-cell": "^3.26.1",
"@tiptap/extension-table-header": "^3.26.1",
"@tiptap/extension-table-row": "^3.26.1",
"@tiptap/extension-task-item": "^3.26.1",
"@tiptap/extension-task-list": "^3.26.1",
"@tiptap/react": "^3.26.1",
"@tiptap/starter-kit": "^3.26.1",
"@uiw/react-codemirror": "^4.25.1", "@uiw/react-codemirror": "^4.25.1",
"codemirror": "^6.0.2", "codemirror": "^6.0.2",
"diff-match-patch": "^1.0.5", "diff-match-patch": "^1.0.5",
"dompurify": "^3.3.0", "dompurify": "^3.3.0",
"file-saver": "^2.0.5", "file-saver": "^2.0.5",
"highlight.js": "^11.11.1", "highlight.js": "^11.11.1",
"html-to-image": "^1.11.13",
"html2pdf.js": "^0.12.1", "html2pdf.js": "^0.12.1",
"js-beautify": "^1.15.4", "js-beautify": "^1.15.4",
"jspdf": "^3.0.3", "jspdf": "^3.0.3",
"jspdf-autotable": "^5.0.2", "jspdf-autotable": "^5.0.2",
"lowlight": "^3.3.0",
"lucide-react": "^0.540.0", "lucide-react": "^0.540.0",
"marked": "^16.4.1", "marked": "^16.4.1",
"marked-emoji": "^2.0.1", "marked-emoji": "^2.0.1",
"mermaid": "^11.15.0",
"papaparse": "^5.5.3", "papaparse": "^5.5.3",
"react": "18.3.1", "react": "18.3.1",
"react-diff-view": "^3.3.2", "react-diff-view": "^3.3.2",
@@ -41,9 +56,12 @@
"react-helmet-async": "^2.0.5", "react-helmet-async": "^2.0.5",
"react-router-dom": "6.26.2", "react-router-dom": "6.26.2",
"react-snap": "^1.23.0", "react-snap": "^1.23.0",
"react-zoom-pan-pinch": "^4.0.3",
"reactflow": "^11.11.4", "reactflow": "^11.11.4",
"serialize-javascript": "^6.0.0", "serialize-javascript": "^6.0.0",
"serve": "^14.2.4", "serve": "^14.2.4",
"tailwindcss-typography": "^3.1.0",
"tiptap-markdown": "^0.9.0",
"turndown": "^7.2.1", "turndown": "^7.2.1",
"web-vitals": "^2.1.4" "web-vitals": "^2.1.4"
}, },

View File

@@ -1,5 +1,22 @@
{ {
"changelog": [ "changelog": [
{
"date": "2026-06-14",
"changes": [
{
"datetime": "2026-06-14T10:00:00+07:00",
"type": "feature",
"title": "Major Markdown Editor Rewrite: WYSIWYG Experience",
"description": "Completely rebuilt the Markdown Editor to feature a true WYSIWYG (What You See Is What You Get) interface using Tiptap. You can now edit rich text visually like a Word document, while seamlessly converting back and forth to raw Markdown and clean HTML."
},
{
"datetime": "2026-06-14T09:30:00+07:00",
"type": "enhancement",
"title": "Object Editor: Data Preview & Multidimensional Search",
"description": "The Object Editor now defaults to a fast Read-Only preview when you paste JSON data. We also added an incredibly powerful multidimensional search bar that instantly filters, highlights, and expands nested nodes matching your query."
}
]
},
{ {
"date": "2026-02-18", "date": "2026-02-18",
"changes": [ "changes": [

View File

@@ -1,29 +1,37 @@
import React, { useEffect, Suspense, lazy } from 'react'; import React, { useEffect, Suspense, lazy } from "react";
import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom'; import {
import { HelmetProvider } from 'react-helmet-async'; BrowserRouter as Router,
import Layout from './components/Layout'; Routes,
import ErrorBoundary from './components/ErrorBoundary'; Route,
import Loading from './components/Loading'; Navigate,
import { initGA } from './utils/analytics'; } from "react-router-dom";
import { HelmetProvider } from "react-helmet-async";
import Layout from "./components/Layout";
import ErrorBoundary from "./components/ErrorBoundary";
import Loading from "./components/Loading";
import { initGA } from "./utils/analytics";
import './index.css'; import "./index.css";
const Home = lazy(() => import('./pages/Home')); const Home = lazy(() => import("./pages/Home"));
const UrlTool = lazy(() => import('./pages/UrlTool')); const UrlTool = lazy(() => import("./pages/UrlTool"));
const Base64Tool = lazy(() => import('./pages/Base64Tool')); const Base64Tool = lazy(() => import("./pages/Base64Tool"));
const BeautifierTool = lazy(() => import('./pages/BeautifierTool')); const BeautifierTool = lazy(() => import("./pages/BeautifierTool"));
const DiffTool = lazy(() => import('./pages/DiffTool')); const DiffTool = lazy(() => import("./pages/DiffTool"));
const TextLengthTool = lazy(() => import('./pages/TextLengthTool')); const TextLengthTool = lazy(() => import("./pages/TextLengthTool"));
const ObjectEditor = lazy(() => import('./pages/ObjectEditor')); const ObjectEditor = lazy(() => import("./pages/ObjectEditor"));
const TableEditor = lazy(() => import('./pages/TableEditor')); const TableEditor = lazy(() => import("./pages/TableEditor"));
const InvoiceEditor = lazy(() => import('./pages/InvoiceEditor')); const InvoiceEditor = lazy(() => import("./pages/InvoiceEditor"));
const MarkdownEditor = lazy(() => import('./pages/MarkdownEditor')); const MarkdownEditor = lazy(() => import("./pages/MarkdownEditor"));
const InvoicePreview = lazy(() => import('./pages/InvoicePreview')); const DiagramEditor = lazy(() => import("./pages/DiagramEditor"));
const InvoicePreviewMinimal = lazy(() => import('./pages/InvoicePreviewMinimal')); const InvoicePreview = lazy(() => import("./pages/InvoicePreview"));
const ReleaseNotes = lazy(() => import('./pages/ReleaseNotes')); const InvoicePreviewMinimal = lazy(
const TermsOfService = lazy(() => import('./pages/TermsOfService')); () => import("./pages/InvoicePreviewMinimal"),
const PrivacyPolicy = lazy(() => import('./pages/PrivacyPolicy')); );
const NotFound = lazy(() => import('./pages/NotFound')); const ReleaseNotes = lazy(() => import("./pages/ReleaseNotes"));
const TermsOfService = lazy(() => import("./pages/TermsOfService"));
const PrivacyPolicy = lazy(() => import("./pages/PrivacyPolicy"));
const NotFound = lazy(() => import("./pages/NotFound"));
function App() { function App() {
// Initialize Google Analytics on app startup // Initialize Google Analytics on app startup
@@ -48,9 +56,16 @@ function App() {
<Route path="/table-editor" element={<TableEditor />} /> <Route path="/table-editor" element={<TableEditor />} />
<Route path="/invoice-editor" element={<InvoiceEditor />} /> <Route path="/invoice-editor" element={<InvoiceEditor />} />
<Route path="/markdown-editor" element={<MarkdownEditor />} /> <Route path="/markdown-editor" element={<MarkdownEditor />} />
<Route path="/diagram-editor" element={<DiagramEditor />} />
<Route path="/invoice-preview" element={<InvoicePreview />} /> <Route path="/invoice-preview" element={<InvoicePreview />} />
<Route path="/invoice-preview-minimal" element={<InvoicePreviewMinimal />} /> <Route
<Route path="/whats-new" element={<Navigate to="/release-notes" replace />} /> path="/invoice-preview-minimal"
element={<InvoicePreviewMinimal />}
/>
<Route
path="/whats-new"
element={<Navigate to="/release-notes" replace />}
/>
<Route path="/release-notes" element={<ReleaseNotes />} /> <Route path="/release-notes" element={<ReleaseNotes />} />
<Route path="/privacy" element={<PrivacyPolicy />} /> <Route path="/privacy" element={<PrivacyPolicy />} />
<Route path="/terms" element={<TermsOfService />} /> <Route path="/terms" element={<TermsOfService />} />

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -3,7 +3,7 @@ import React, { useEffect, useRef } from "react";
const AdBlock = ({ const AdBlock = ({
className = "", className = "",
adKey = "e0ca7c61c83457f093bbc2e261b43d31", adKey = "e0ca7c61c83457f093bbc2e261b43d31",
adDomain = "www.highperformanceformat.com", adDomain = "downconvenientmagnetic.com",
}) => { }) => {
const iframeRef = useRef(null); const iframeRef = useRef(null);

View File

@@ -0,0 +1,34 @@
import React from "react";
import { NodeViewWrapper, NodeViewContent } from "@tiptap/react";
const CodeBlockComponent = ({ node, updateAttributes, extension }) => {
return (
<NodeViewWrapper className="code-block-wrapper relative bg-[#0d1117] rounded-md overflow-hidden mb-[0.65em]">
<div className="code-block-header flex justify-between items-center px-4 py-2 bg-[#161b22] border border-[#30363d] border-b-0 rounded-t-md text-xs font-mono">
<select
contentEditable={false}
value={node.attrs.language || "text"}
onChange={(event) =>
updateAttributes({ language: event.target.value })
}
className="bg-transparent text-[#8b949e] border-none outline-none focus:ring-0 uppercase tracking-wider cursor-pointer"
>
<option value="text">text</option>
<option value="javascript">javascript</option>
<option value="typescript">typescript</option>
<option value="html">html</option>
<option value="css">css</option>
<option value="json">json</option>
<option value="bash">bash</option>
<option value="python">python</option>
<option value="sql">sql</option>
</select>
</div>
<pre className="!mt-0 !rounded-t-none !bg-transparent !border-[#30363d] !p-4 !text-[#e6edf3]">
<NodeViewContent as="code" />
</pre>
</NodeViewWrapper>
);
};
export default CodeBlockComponent;

View File

@@ -0,0 +1,107 @@
import React, { useEffect, useRef } from "react";
const FullscreenAdBanner = () => {
const desktopIframeRef = useRef(null);
const mobileIframeRef = useRef(null);
useEffect(() => {
// Initialize Desktop/Tablet Ad (728x90)
if (desktopIframeRef.current) {
const iframe = desktopIframeRef.current;
const doc = iframe.contentDocument || iframe.contentWindow.document;
doc.open();
doc.write(`
<!DOCTYPE html>
<html>
<head>
<style>
body { margin: 0; padding: 0; display: block; background: transparent; overflow: hidden; height: 100vh; }
</style>
</head>
<body>
<script type="text/javascript">
atOptions = {
'key' : '5d1186bf7f51a6e8732651b00fefc51b',
'format' : 'iframe',
'height' : 90,
'width' : 728,
'params' : {}
};
</script>
<script type="text/javascript" src="https://downconvenientmagnetic.com/5d1186bf7f51a6e8732651b00fefc51b/invoke.js"></script>
</body>
</html>
`);
doc.close();
}
// Initialize Mobile Ad (320x50) using the existing mobile key
if (mobileIframeRef.current) {
const iframe = mobileIframeRef.current;
const doc = iframe.contentDocument || iframe.contentWindow.document;
doc.open();
doc.write(`
<!DOCTYPE html>
<html>
<head>
<style>
body { margin: 0; padding: 0; display: block; background: transparent; overflow: hidden; height: 100vh; }
</style>
</head>
<body>
<script type="text/javascript">
atOptions = {
'key' : '2965bcf877388cafa84160592c550f5a',
'format' : 'iframe',
'height' : 50,
'width' : 320,
'params' : {}
};
</script>
<script type="text/javascript" src="https://downconvenientmagnetic.com/2965bcf877388cafa84160592c550f5a/invoke.js"></script>
</body>
</html>
`);
doc.close();
}
}, []);
return (
<>
{/* Desktop & Tablet View (>= 768px) */}
<div className="absolute bottom-0 left-0 right-0 z-50 justify-center bg-gray-50 dark:bg-gray-900 border-t border-gray-200 dark:border-gray-700 py-2 shadow-lg hidden md:flex">
<iframe
ref={desktopIframeRef}
style={{
width: "728px",
height: "90px",
border: "none",
maxWidth: "100%",
}}
title="Fullscreen Advertisement Desktop"
sandbox="allow-scripts allow-same-origin"
/>
</div>
{/* Mobile View (< 768px) */}
<div className="absolute bottom-0 left-0 right-0 z-50 flex justify-center items-end bg-white dark:bg-gray-900 border-t border-gray-200 dark:border-gray-700 shadow-lg md:hidden h-[51px]">
<iframe
ref={mobileIframeRef}
style={{
width: "320px",
height: "50px",
border: "none",
maxWidth: "100%",
display: "block",
}}
title="Fullscreen Advertisement Mobile"
sandbox="allow-scripts allow-same-origin"
/>
</div>
</>
);
};
export default FullscreenAdBanner;

View File

@@ -1,18 +1,30 @@
import React, { useState, useEffect, useRef } from 'react'; import React, { useState, useEffect, useRef } from "react";
import { useLocation } from 'react-router-dom'; import { useLocation } from "react-router-dom";
import ToolSidebar from './ToolSidebar'; import ToolSidebar from "./ToolSidebar";
import NavigationConfirmModal from './NavigationConfirmModal'; import NavigationConfirmModal from "./NavigationConfirmModal";
import useNavigationGuard from '../hooks/useNavigationGuard'; import useNavigationGuard from "../hooks/useNavigationGuard";
import { Menu, X, ChevronDown, Terminal, Sparkles, Home } from 'lucide-react'; import { Menu, X, ChevronDown, Terminal, Sparkles, Home } from "lucide-react";
import ThemeToggle from './ThemeToggle'; import ThemeToggle from "./ThemeToggle";
import SEOHead from './SEOHead'; import SEOHead from "./SEOHead";
import ConsentBanner from './ConsentBanner'; import ConsentBanner from "./ConsentBanner";
import { NON_TOOLS, TOOLS, SITE_CONFIG, getCategoryConfig } from '../config/tools'; import {
import { useAnalytics } from '../hooks/useAnalytics'; NON_TOOLS,
TOOLS,
SITE_CONFIG,
getCategoryConfig,
} from "../config/tools";
import { useAnalytics } from "../hooks/useAnalytics";
const Layout = ({ children }) => { const Layout = ({ children }) => {
const location = useLocation(); const location = useLocation();
const { showModal, pendingNavigation, handleConfirm, handleCancel, hasUnsavedData, navigateWithGuard } = useNavigationGuard(); const {
showModal,
pendingNavigation,
handleConfirm,
handleCancel,
hasUnsavedData,
navigateWithGuard,
} = useNavigationGuard();
const [isDropdownOpen, setIsDropdownOpen] = useState(false); const [isDropdownOpen, setIsDropdownOpen] = useState(false);
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false); const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
const dropdownRef = useRef(null); const dropdownRef = useRef(null);
@@ -32,9 +44,9 @@ const Layout = ({ children }) => {
} }
}; };
document.addEventListener('mousedown', handleClickOutside); document.addEventListener("mousedown", handleClickOutside);
return () => { return () => {
document.removeEventListener('mousedown', handleClickOutside); document.removeEventListener("mousedown", handleClickOutside);
}; };
}, []); }, []);
@@ -45,10 +57,10 @@ const Layout = ({ children }) => {
}, [location.pathname]); }, [location.pathname]);
// Check if we're on a tool page (not homepage) // Check if we're on a tool page (not homepage)
const isToolPage = location.pathname !== '/'; const isToolPage = location.pathname !== "/";
// Check if we're on invoice preview page (no sidebar needed) // Check if we're on invoice preview page (no sidebar needed)
const isInvoicePreviewPage = location.pathname === '/invoice-preview'; const isInvoicePreviewPage = location.pathname === "/invoice-preview";
return ( 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 flex flex-col"> <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 flex flex-col">
@@ -57,9 +69,18 @@ const Layout = ({ children }) => {
{/* Header */} {/* Header */}
<header className="fixed top-0 left-0 right-0 z-50 bg-white/80 dark:bg-slate-800/80 backdrop-blur-md shadow-lg border-b border-slate-200/50 dark:border-slate-700/50 flex-shrink-0"> <header className="fixed top-0 left-0 right-0 z-50 bg-white/80 dark:bg-slate-800/80 backdrop-blur-md shadow-lg border-b border-slate-200/50 dark:border-slate-700/50 flex-shrink-0">
<div className={isToolPage ? "px-4 sm:px-6 lg:px-8" : "max-w-7xl mx-auto px-4 sm:px-6 lg:px-8"}> <div
className={
isToolPage
? "px-4 sm:px-6 lg:px-8"
: "max-w-7xl mx-auto px-4 sm:px-6 lg:px-8"
}
>
<div className="flex justify-between items-center h-16"> <div className="flex justify-between items-center h-16">
<button onClick={() => navigateWithGuard('/')} className="flex items-center group"> <button
onClick={() => navigateWithGuard("/")}
className="flex items-center group"
>
<div className="relative"> <div className="relative">
<div className="absolute inset-0 rounded-lg blur opacity-20 group-hover:opacity-40 transition-opacity"></div> <div className="absolute inset-0 rounded-lg blur opacity-20 group-hover:opacity-40 transition-opacity"></div>
<div className="relative p-2"> <div className="relative p-2">
@@ -67,11 +88,11 @@ const Layout = ({ children }) => {
src="/logo.svg" src="/logo.svg"
alt={SITE_CONFIG.title} alt={SITE_CONFIG.title}
className="h-8 w-auto" className="h-8 w-auto"
style={{ maxWidth: '150px' }} style={{ maxWidth: "150px" }}
onError={(e) => { onError={(e) => {
// Fallback to Terminal icon with text if logo fails to load // Fallback to Terminal icon with text if logo fails to load
e.target.style.display = 'none'; e.target.style.display = "none";
e.target.nextSibling.style.display = 'flex'; e.target.nextSibling.style.display = "flex";
}} }}
/> />
<div className="hidden items-center space-x-3"> <div className="hidden items-center space-x-3">
@@ -91,12 +112,12 @@ const Layout = ({ children }) => {
<button <button
onClick={() => { onClick={() => {
setIsDropdownOpen(false); setIsDropdownOpen(false);
navigateWithGuard('/'); navigateWithGuard("/");
}} }}
className={`flex items-center space-x-2 px-4 py-2 rounded-xl text-sm font-medium transition-all duration-300 ${ className={`flex items-center space-x-2 px-4 py-2 rounded-xl text-sm font-medium transition-all duration-300 ${
isActive('/') isActive("/")
? 'bg-gradient-to-r from-blue-500 to-purple-500 text-white shadow-lg' ? "bg-gradient-to-r from-blue-500 to-purple-500 text-white shadow-lg"
: 'text-slate-600 dark:text-slate-300 hover:text-blue-600 dark:hover:text-blue-400 hover:bg-white/50 dark:hover:bg-slate-700/50' : "text-slate-600 dark:text-slate-300 hover:text-blue-600 dark:hover:text-blue-400 hover:bg-white/50 dark:hover:bg-slate-700/50"
}`} }`}
> >
<Home className="h-4 w-4" /> <Home className="h-4 w-4" />
@@ -113,9 +134,11 @@ const Layout = ({ children }) => {
> >
<Sparkles className="h-4 w-4" /> <Sparkles className="h-4 w-4" />
<span>Tools</span> <span>Tools</span>
<ChevronDown className={`h-4 w-4 transition-transform duration-300 ${ <ChevronDown
isDropdownOpen ? 'rotate-180' : '' className={`h-4 w-4 transition-transform duration-300 ${
}`} /> isDropdownOpen ? "rotate-180" : ""
}`}
/>
</button> </button>
{/* Dropdown Menu */} {/* Dropdown Menu */}
@@ -125,7 +148,9 @@ const Layout = ({ children }) => {
<div className="relative"> <div className="relative">
{TOOLS.map((tool) => { {TOOLS.map((tool) => {
const IconComponent = tool.icon; const IconComponent = tool.icon;
const categoryConfig = getCategoryConfig(tool.category); const categoryConfig = getCategoryConfig(
tool.category,
);
return ( return (
<button <button
@@ -136,16 +161,20 @@ const Layout = ({ children }) => {
}} }}
className={`group flex items-center space-x-4 px-4 py-3 text-sm hover:bg-white/50 dark:hover:bg-slate-700/50 transition-all duration-300 w-full text-left ${ className={`group flex items-center space-x-4 px-4 py-3 text-sm hover:bg-white/50 dark:hover:bg-slate-700/50 transition-all duration-300 w-full text-left ${
isActive(tool.path) isActive(tool.path)
? 'bg-gradient-to-r from-blue-50 to-purple-50 dark:from-slate-700 dark:to-slate-600 text-blue-700 dark:text-blue-300' ? "bg-gradient-to-r from-blue-50 to-purple-50 dark:from-slate-700 dark:to-slate-600 text-blue-700 dark:text-blue-300"
: 'text-slate-700 dark:text-slate-300' : "text-slate-700 dark:text-slate-300"
}`} }`}
> >
<div className={`p-2 rounded-lg bg-gradient-to-br ${categoryConfig.color} shadow-sm group-hover:scale-110 transition-transform duration-300`}> <div
className={`p-2 rounded-lg bg-gradient-to-br ${categoryConfig.color} shadow-sm group-hover:scale-110 transition-transform duration-300`}
>
<IconComponent className="h-4 w-4 text-white" /> <IconComponent className="h-4 w-4 text-white" />
</div> </div>
<div className="flex-1"> <div className="flex-1">
<div className="font-medium">{tool.name}</div> <div className="font-medium">{tool.name}</div>
<div className="text-xs text-slate-600 dark:text-slate-600">{tool.description}</div> <div className="text-xs text-slate-600 dark:text-slate-600">
{tool.description}
</div>
</div> </div>
<div className="opacity-0 group-hover:opacity-100 transition-opacity duration-300"> <div className="opacity-0 group-hover:opacity-100 transition-opacity duration-300">
<ChevronDown className="h-4 w-4 -rotate-90 text-slate-600" /> <ChevronDown className="h-4 w-4 -rotate-90 text-slate-600" />
@@ -169,7 +198,11 @@ const Layout = ({ children }) => {
aria-label={isMobileMenuOpen ? "Close menu" : "Open menu"} aria-label={isMobileMenuOpen ? "Close menu" : "Open menu"}
aria-expanded={isMobileMenuOpen} aria-expanded={isMobileMenuOpen}
> >
{isMobileMenuOpen ? <X className="h-6 w-6" /> : <Menu className="h-6 w-6" />} {isMobileMenuOpen ? (
<X className="h-6 w-6" />
) : (
<Menu className="h-6 w-6" />
)}
</button> </button>
</div> </div>
</div> </div>
@@ -202,12 +235,16 @@ const Layout = ({ children }) => {
}} }}
className={`flex items-center space-x-3 px-4 py-3 rounded-xl text-sm font-medium transition-all duration-300 w-full text-left ${ className={`flex items-center space-x-3 px-4 py-3 rounded-xl text-sm font-medium transition-all duration-300 w-full text-left ${
isActive(tool.path) isActive(tool.path)
? 'bg-gradient-to-r from-indigo-500 to-purple-500 text-white shadow-lg' ? "bg-gradient-to-r from-indigo-500 to-purple-500 text-white shadow-lg"
: 'text-slate-600 hover:text-slate-900 dark:text-slate-300 dark:hover:text-white hover:bg-white/50 dark:hover:bg-slate-700/50' : "text-slate-600 hover:text-slate-900 dark:text-slate-300 dark:hover:text-white hover:bg-white/50 dark:hover:bg-slate-700/50"
}`} }`}
> >
<div className={`p-2 rounded-lg ${isActive(tool.path) ? 'bg-white/20' : 'bg-gradient-to-br from-indigo-500 to-purple-500'} shadow-sm`}> <div
<IconComponent className={`h-4 w-4 ${isActive(tool.path) ? 'text-white' : 'text-white'}`} /> className={`p-2 rounded-lg ${isActive(tool.path) ? "bg-white/20" : "bg-gradient-to-br from-indigo-500 to-purple-500"} shadow-sm`}
>
<IconComponent
className={`h-4 w-4 ${isActive(tool.path) ? "text-white" : "text-white"}`}
/>
</div> </div>
<span>{tool.name}</span> <span>{tool.name}</span>
</button> </button>
@@ -217,7 +254,7 @@ const Layout = ({ children }) => {
<div className="border-t border-slate-200/50 dark:border-slate-700/50 pt-4 mt-4"> <div className="border-t border-slate-200/50 dark:border-slate-700/50 pt-4 mt-4">
<div className="text-xs font-semibold text-slate-600 dark:text-slate-600 uppercase tracking-wider px-4 py-2 flex items-center gap-2"> <div className="text-xs font-semibold text-slate-600 dark:text-slate-600 uppercase tracking-wider px-4 py-2 flex items-center gap-2">
<Sparkles className="h-3 w-3" /> <Sparkles className="h-3 w-3" />
{isToolPage ? 'Switch Tools' : 'Tools'} {isToolPage ? "Switch Tools" : "Tools"}
</div> </div>
{TOOLS.map((tool) => { {TOOLS.map((tool) => {
const IconComponent = tool.icon; const IconComponent = tool.icon;
@@ -232,16 +269,20 @@ const Layout = ({ children }) => {
}} }}
className={`flex items-center space-x-4 px-4 py-3 rounded-xl text-sm font-medium transition-all duration-300 w-full text-left ${ className={`flex items-center space-x-4 px-4 py-3 rounded-xl text-sm font-medium transition-all duration-300 w-full text-left ${
isActive(tool.path) isActive(tool.path)
? 'bg-gradient-to-r from-blue-50 to-purple-50 dark:from-slate-700 dark:to-slate-600 text-blue-700 dark:text-blue-300' ? "bg-gradient-to-r from-blue-50 to-purple-50 dark:from-slate-700 dark:to-slate-600 text-blue-700 dark:text-blue-300"
: 'text-slate-600 hover:text-slate-900 dark:text-slate-300 dark:hover:text-white hover:bg-white/50 dark:hover:bg-slate-700/50' : "text-slate-600 hover:text-slate-900 dark:text-slate-300 dark:hover:text-white hover:bg-white/50 dark:hover:bg-slate-700/50"
}`} }`}
> >
<div className={`p-2 rounded-lg bg-gradient-to-br ${categoryConfig.color} shadow-sm`}> <div
className={`p-2 rounded-lg bg-gradient-to-br ${categoryConfig.color} shadow-sm`}
>
<IconComponent className="h-4 w-4 text-white" /> <IconComponent className="h-4 w-4 text-white" />
</div> </div>
<div className="flex-1"> <div className="flex-1">
<div className="font-medium">{tool.name}</div> <div className="font-medium">{tool.name}</div>
<div className="text-xs text-slate-600 dark:text-slate-600">{tool.description}</div> <div className="text-xs text-slate-600 dark:text-slate-600">
{tool.description}
</div>
</div> </div>
</button> </button>
); );
@@ -254,7 +295,7 @@ const Layout = ({ children }) => {
)} )}
{/* Main Content */} {/* Main Content */}
<div className="flex flex-1 pt-16 min-w-0 w-full max-w-full overflow-x-hidden"> <div className="flex flex-1 pt-16 min-w-0 w-full max-w-full">
{/* Main Content Area */} {/* Main Content Area */}
<main className="flex-1 flex flex-col min-w-0 w-full max-w-full"> <main className="flex-1 flex flex-col min-w-0 w-full max-w-full">
{isToolPage && !isInvoicePreviewPage ? ( {isToolPage && !isInvoicePreviewPage ? (
@@ -263,22 +304,18 @@ const Layout = ({ children }) => {
<ToolSidebar navigateWithGuard={navigateWithGuard} /> <ToolSidebar navigateWithGuard={navigateWithGuard} />
</div> </div>
<div className="flex-1 flex flex-col pl-0 lg:pl-16 min-w-0"> <div className="flex-1 flex flex-col pl-0 lg:pl-16 min-w-0">
<div className="p-4 sm:p-6 w-full min-w-0 max-w-full overflow-x-hidden"> <div className="p-4 sm:p-6 w-full min-w-0 max-w-full">
{children} {children}
</div> </div>
</div> </div>
</div> </div>
) : isInvoicePreviewPage ? ( ) : isInvoicePreviewPage ? (
<div className="flex-1 flex flex-col"> <div className="flex-1 flex flex-col">
<div className="flex-1"> <div className="flex-1">{children}</div>
{children}
</div>
</div> </div>
) : ( ) : (
<div className="flex-1 flex flex-col"> <div className="flex-1 flex flex-col">
<div className="flex-1"> <div className="flex-1">{children}</div>
{children}
</div>
{/* Global Footer for Homepage */} {/* Global Footer for Homepage */}
<footer className="bg-white/30 dark:bg-slate-800/30 backdrop-blur-sm border-t border-slate-200/30 dark:border-slate-700/30 mt-20"> <footer className="bg-white/30 dark:bg-slate-800/30 backdrop-blur-sm border-t border-slate-200/30 dark:border-slate-700/30 mt-20">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12"> <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
@@ -291,11 +328,11 @@ const Layout = ({ children }) => {
src="/icon-192x192.png" src="/icon-192x192.png"
alt={SITE_CONFIG.title} alt={SITE_CONFIG.title}
className="h-16 w-auto" className="h-16 w-auto"
style={{ maxWidth: '100px' }} style={{ maxWidth: "100px" }}
onError={(e) => { onError={(e) => {
// Fallback to Terminal icon with text if logo fails to load // Fallback to Terminal icon with text if logo fails to load
e.target.style.display = 'none'; e.target.style.display = "none";
e.target.nextSibling.style.display = 'flex'; e.target.nextSibling.style.display = "flex";
}} }}
/> />
<div className="hidden items-center gap-3"> <div className="hidden items-center gap-3">
@@ -334,21 +371,25 @@ const Layout = ({ children }) => {
</div> </div>
<div className="flex items-center gap-4 text-xs"> <div className="flex items-center gap-4 text-xs">
<button <button
onClick={() => navigateWithGuard('/release-notes')} onClick={() => navigateWithGuard("/release-notes")}
className="text-slate-600 hover:text-slate-600 dark:hover:text-slate-300 transition-colors" className="text-slate-600 hover:text-slate-600 dark:hover:text-slate-300 transition-colors"
> >
Release Notes Release Notes
</button> </button>
<span className="text-slate-300 dark:text-slate-600"></span> <span className="text-slate-300 dark:text-slate-600">
</span>
<button <button
onClick={() => navigateWithGuard('/privacy')} onClick={() => navigateWithGuard("/privacy")}
className="text-slate-600 hover:text-slate-600 dark:hover:text-slate-300 transition-colors" className="text-slate-600 hover:text-slate-600 dark:hover:text-slate-300 transition-colors"
> >
Privacy Policy Privacy Policy
</button> </button>
<span className="text-slate-300 dark:text-slate-600"></span> <span className="text-slate-300 dark:text-slate-600">
</span>
<button <button
onClick={() => navigateWithGuard('/terms')} onClick={() => navigateWithGuard("/terms")}
className="text-slate-600 hover:text-slate-600 dark:hover:text-slate-300 transition-colors" className="text-slate-600 hover:text-slate-600 dark:hover:text-slate-300 transition-colors"
> >
Terms of Service Terms of Service
@@ -373,11 +414,11 @@ const Layout = ({ children }) => {
src="/icon-192x192.png" src="/icon-192x192.png"
alt={SITE_CONFIG.title} alt={SITE_CONFIG.title}
className="h-16 w-auto" className="h-16 w-auto"
style={{ maxWidth: '100px' }} style={{ maxWidth: "100px" }}
onError={(e) => { onError={(e) => {
// Fallback to Terminal icon with text if logo fails to load // Fallback to Terminal icon with text if logo fails to load
e.target.style.display = 'none'; e.target.style.display = "none";
e.target.nextSibling.style.display = 'flex'; e.target.nextSibling.style.display = "flex";
}} }}
/> />
</div> </div>
@@ -390,21 +431,21 @@ const Layout = ({ children }) => {
</div> </div>
<div className="flex items-center justify-center gap-4 text-xs"> <div className="flex items-center justify-center gap-4 text-xs">
<button <button
onClick={() => navigateWithGuard('/release-notes')} onClick={() => navigateWithGuard("/release-notes")}
className="text-slate-600 hover:text-slate-600 dark:hover:text-slate-300 transition-colors" className="text-slate-600 hover:text-slate-600 dark:hover:text-slate-300 transition-colors"
> >
Release Notes Release Notes
</button> </button>
<span className="text-slate-300 dark:text-slate-600"></span> <span className="text-slate-300 dark:text-slate-600"></span>
<button <button
onClick={() => navigateWithGuard('/privacy')} onClick={() => navigateWithGuard("/privacy")}
className="text-slate-600 hover:text-slate-600 dark:hover:text-slate-300 transition-colors" className="text-slate-600 hover:text-slate-600 dark:hover:text-slate-300 transition-colors"
> >
Privacy Policy Privacy Policy
</button> </button>
<span className="text-slate-300 dark:text-slate-600"></span> <span className="text-slate-300 dark:text-slate-600"></span>
<button <button
onClick={() => navigateWithGuard('/terms')} onClick={() => navigateWithGuard("/terms")}
className="text-slate-600 hover:text-slate-600 dark:hover:text-slate-300 transition-colors" className="text-slate-600 hover:text-slate-600 dark:hover:text-slate-300 transition-colors"
> >
Terms of Service Terms of Service

View File

@@ -1,21 +1,10 @@
import React, { useEffect, useRef, useState } from "react"; import React, { useEffect, useRef } from "react";
import { X } from "lucide-react";
const MobileAdBanner = () => { const MobileAdBanner = () => {
const [visible, setVisible] = useState(true);
const [closed, setClosed] = useState(false);
const iframeRef = useRef(null); const iframeRef = useRef(null);
useEffect(() => { useEffect(() => {
const wasClosed = sessionStorage.getItem("mobileAdClosed"); if (!iframeRef.current) return;
if (wasClosed === "true") {
setClosed(true);
setVisible(false);
}
}, []);
useEffect(() => {
if (!visible || closed || !iframeRef.current) return;
const timer = setTimeout(() => { const timer = setTimeout(() => {
if (!iframeRef.current) return; if (!iframeRef.current) return;
@@ -29,7 +18,7 @@ const MobileAdBanner = () => {
<html> <html>
<head> <head>
<style> <style>
body { margin: 0; padding: 0; display: flex; justify-content: center; align-items: center; } body { margin: 0; padding: 0; display: block; background: transparent; overflow: hidden; height: 100vh; }
</style> </style>
</head> </head>
<body> <body>
@@ -42,7 +31,7 @@ const MobileAdBanner = () => {
'params' : {} 'params' : {}
}; };
</script> </script>
<script type="text/javascript" src="https://www.highperformanceformat.com/2965bcf877388cafa84160592c550f5a/invoke.js"></script> <script type="text/javascript" src="https://downconvenientmagnetic.com/2965bcf877388cafa84160592c550f5a/invoke.js"></script>
</body> </body>
</html> </html>
`); `);
@@ -50,34 +39,22 @@ const MobileAdBanner = () => {
}, 500); }, 500);
return () => clearTimeout(timer); return () => clearTimeout(timer);
}, [visible, closed]); }, []);
const handleClose = () => {
setVisible(false);
setClosed(true);
sessionStorage.setItem("mobileAdClosed", "true");
};
if (!visible || closed) return null;
return ( return (
<div className="lg:hidden fixed bottom-0 left-0 right-0 z-50 bg-white dark:bg-gray-900 shadow-lg border-t border-gray-200 dark:border-gray-700"> <div className="lg:hidden fixed bottom-0 left-0 right-0 z-50 bg-white dark:bg-gray-900 shadow-lg border-t border-gray-200 dark:border-gray-700 h-[51px] flex justify-center items-end">
<button
onClick={handleClose}
className="absolute -top-2 right-2 p-1 text-gray-600 hover:text-gray-700 dark:text-gray-600 dark:hover:text-gray-200 bg-white dark:bg-gray-800 rounded-full shadow-sm z-10"
aria-label="Close ad"
>
<X className="h-4 w-4" />
</button>
<div className="flex justify-center items-center py-2">
<iframe <iframe
ref={iframeRef} ref={iframeRef}
style={{ width: "320px", height: "50px", border: "none" }} style={{
width: "320px",
height: "50px",
border: "none",
display: "block",
}}
title="Mobile Advertisement" title="Mobile Advertisement"
sandbox="allow-scripts allow-same-origin" sandbox="allow-scripts allow-same-origin"
/> />
</div> </div>
</div>
); );
}; };

View File

@@ -0,0 +1,289 @@
import React, { useEffect } from "react";
import { useEditor, EditorContent, ReactNodeViewRenderer } from "@tiptap/react";
import StarterKit from "@tiptap/starter-kit";
import Link from "@tiptap/extension-link";
import Image from "@tiptap/extension-image";
import { Table } from "@tiptap/extension-table";
import { TableRow } from "@tiptap/extension-table-row";
import { TableHeader } from "@tiptap/extension-table-header";
import { TableCell } from "@tiptap/extension-table-cell";
import { TaskList } from "@tiptap/extension-task-list";
import { TaskItem } from "@tiptap/extension-task-item";
import CodeBlockLowlight from "@tiptap/extension-code-block-lowlight";
import { common, createLowlight } from "lowlight";
import CodeBlockComponent from "./CodeBlockComponent";
import { Markdown } from "tiptap-markdown";
import {
Bold,
Italic,
Strikethrough,
Code,
Heading1,
Heading2,
Heading3,
List,
ListOrdered,
CheckSquare,
Quote,
Link2,
Image as ImageIcon,
Table as TableIcon,
Minus,
} from "lucide-react";
// Set up lowlight for syntax highlighting in Tiptap
const lowlight = createLowlight(common);
const MenuBar = ({ editor }) => {
if (!editor) return null;
return (
<div className="flex flex-wrap justify-center items-center gap-1 p-2 bg-gray-50 dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700 sticky top-0 z-10 w-full">
<div className="flex flex-wrap items-center gap-1 w-full max-w-[85ch]">
<button
onClick={() => editor.chain().focus().toggleBold().run()}
disabled={!editor.can().chain().focus().toggleBold().run()}
className={`p-2 rounded hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors ${editor.isActive("bold") ? "bg-gray-200 dark:bg-gray-700 text-blue-600 dark:text-blue-400" : "text-gray-600 dark:text-gray-300"}`}
title="Bold"
>
<Bold className="h-4 w-4" />
</button>
<button
onClick={() => editor.chain().focus().toggleItalic().run()}
disabled={!editor.can().chain().focus().toggleItalic().run()}
className={`p-2 rounded hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors ${editor.isActive("italic") ? "bg-gray-200 dark:bg-gray-700 text-blue-600 dark:text-blue-400" : "text-gray-600 dark:text-gray-300"}`}
title="Italic"
>
<Italic className="h-4 w-4" />
</button>
<button
onClick={() => editor.chain().focus().toggleStrike().run()}
disabled={!editor.can().chain().focus().toggleStrike().run()}
className={`p-2 rounded hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors ${editor.isActive("strike") ? "bg-gray-200 dark:bg-gray-700 text-blue-600 dark:text-blue-400" : "text-gray-600 dark:text-gray-300"}`}
title="Strikethrough"
>
<Strikethrough className="h-4 w-4" />
</button>
<button
onClick={() => editor.chain().focus().toggleCode().run()}
disabled={!editor.can().chain().focus().toggleCode().run()}
className={`p-2 rounded hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors ${editor.isActive("code") ? "bg-gray-200 dark:bg-gray-700 text-blue-600 dark:text-blue-400" : "text-gray-600 dark:text-gray-300"}`}
title="Inline Code"
>
<Code className="h-4 w-4" />
</button>
<div className="w-px h-6 bg-gray-300 dark:bg-gray-600 mx-1" />
<button
onClick={() =>
editor.chain().focus().toggleHeading({ level: 1 }).run()
}
className={`p-2 rounded hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors ${editor.isActive("heading", { level: 1 }) ? "bg-gray-200 dark:bg-gray-700 text-blue-600 dark:text-blue-400" : "text-gray-600 dark:text-gray-300"}`}
title="Heading 1"
>
<Heading1 className="h-4 w-4" />
</button>
<button
onClick={() =>
editor.chain().focus().toggleHeading({ level: 2 }).run()
}
className={`p-2 rounded hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors ${editor.isActive("heading", { level: 2 }) ? "bg-gray-200 dark:bg-gray-700 text-blue-600 dark:text-blue-400" : "text-gray-600 dark:text-gray-300"}`}
title="Heading 2"
>
<Heading2 className="h-4 w-4" />
</button>
<button
onClick={() =>
editor.chain().focus().toggleHeading({ level: 3 }).run()
}
className={`p-2 rounded hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors ${editor.isActive("heading", { level: 3 }) ? "bg-gray-200 dark:bg-gray-700 text-blue-600 dark:text-blue-400" : "text-gray-600 dark:text-gray-300"}`}
title="Heading 3"
>
<Heading3 className="h-4 w-4" />
</button>
<div className="w-px h-6 bg-gray-300 dark:bg-gray-600 mx-1" />
<button
onClick={() => editor.chain().focus().toggleBulletList().run()}
className={`p-2 rounded hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors ${editor.isActive("bulletList") ? "bg-gray-200 dark:bg-gray-700 text-blue-600 dark:text-blue-400" : "text-gray-600 dark:text-gray-300"}`}
title="Bullet List"
>
<List className="h-4 w-4" />
</button>
<button
onClick={() => editor.chain().focus().toggleOrderedList().run()}
className={`p-2 rounded hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors ${editor.isActive("orderedList") ? "bg-gray-200 dark:bg-gray-700 text-blue-600 dark:text-blue-400" : "text-gray-600 dark:text-gray-300"}`}
title="Ordered List"
>
<ListOrdered className="h-4 w-4" />
</button>
<button
onClick={() => editor.chain().focus().toggleTaskList().run()}
className={`p-2 rounded hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors ${editor.isActive("taskList") ? "bg-gray-200 dark:bg-gray-700 text-blue-600 dark:text-blue-400" : "text-gray-600 dark:text-gray-300"}`}
title="Task List"
>
<CheckSquare className="h-4 w-4" />
</button>
<div className="w-px h-6 bg-gray-300 dark:bg-gray-600 mx-1" />
<button
onClick={() => editor.chain().focus().toggleBlockquote().run()}
className={`p-2 rounded hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors ${editor.isActive("blockquote") ? "bg-gray-200 dark:bg-gray-700 text-blue-600 dark:text-blue-400" : "text-gray-600 dark:text-gray-300"}`}
title="Blockquote"
>
<Quote className="h-4 w-4" />
</button>
<button
onClick={() => editor.chain().focus().toggleCodeBlock().run()}
className={`p-2 rounded hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors ${editor.isActive("codeBlock") ? "bg-gray-200 dark:bg-gray-700 text-blue-600 dark:text-blue-400" : "text-gray-600 dark:text-gray-300"}`}
title="Code Block"
>
<Code className="h-4 w-4" />
</button>
<button
onClick={() => editor.chain().focus().setHorizontalRule().run()}
className="p-2 rounded hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors text-gray-600 dark:text-gray-300"
title="Horizontal Rule"
>
<Minus className="h-4 w-4" />
</button>
<div className="w-px h-6 bg-gray-300 dark:bg-gray-600 mx-1" />
<button
onClick={() => {
const url = window.prompt("URL");
if (url) {
editor
.chain()
.focus()
.extendMarkRange("link")
.setLink({ href: url })
.run();
}
}}
className={`p-2 rounded hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors ${editor.isActive("link") ? "bg-gray-200 dark:bg-gray-700 text-blue-600 dark:text-blue-400" : "text-gray-600 dark:text-gray-300"}`}
title="Add Link"
>
<Link2 className="h-4 w-4" />
</button>
<button
onClick={() => {
const url = window.prompt("Image URL");
if (url) {
editor.chain().focus().setImage({ src: url }).run();
}
}}
className="p-2 rounded hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors text-gray-600 dark:text-gray-300"
title="Add Image"
>
<ImageIcon className="h-4 w-4" />
</button>
<button
onClick={() =>
editor
.chain()
.focus()
.insertTable({ rows: 3, cols: 3, withHeaderRow: true })
.run()
}
className="p-2 rounded hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors text-gray-600 dark:text-gray-300"
title="Insert Table"
>
<TableIcon className="h-4 w-4" />
</button>
</div>
</div>
);
};
const RichMarkdownEditor = ({
initialContent,
onChange,
className = "",
height = "600px",
isFullscreen = false,
}) => {
const editor = useEditor({
extensions: [
StarterKit.configure({
codeBlock: false, // We'll use our own codeblock extension
}),
CodeBlockLowlight.extend({
addNodeView() {
return ReactNodeViewRenderer(CodeBlockComponent);
},
}).configure({
lowlight,
}),
Link.configure({
openOnClick: false,
}),
Image,
Table.configure({
resizable: true,
}),
TableRow,
TableHeader,
TableCell,
TaskList,
TaskItem.configure({
nested: true,
}),
Markdown.configure({
html: true,
tightLists: true,
tightListClass: "tight",
bulletListMarker: "-",
linkify: true,
breaks: false,
}),
],
content: initialContent,
onUpdate: ({ editor }) => {
// Serialize back to markdown and send to parent
const markdownOutput = editor.storage.markdown.getMarkdown();
const htmlOutput = editor.getHTML();
onChange(markdownOutput, htmlOutput);
},
editorProps: {
attributes: {
class:
"prose prose-sm sm:prose dark:prose-invert prose-blue focus:outline-none w-full max-w-none",
},
},
});
// Update editor content when initialContent prop completely changes from outside (e.g. loading a template)
useEffect(() => {
if (editor && initialContent !== undefined) {
const currentMarkdown = editor.storage.markdown.getMarkdown();
if (initialContent !== currentMarkdown) {
editor.commands.setContent(initialContent);
}
}
}, [editor, initialContent]);
return (
<div
className={`flex flex-col bg-white dark:bg-gray-900 overflow-hidden ${className}`}
>
<MenuBar editor={editor} />
<div
className={`overflow-y-auto w-full custom-scrollbar flex justify-center p-6`}
style={{ height }}
>
<div
className={`w-full max-w-[85ch] markdown-content-wrapper ${isFullscreen ? "is-fullscreen" : "is-normal"} is-edit-mode`}
>
<EditorContent editor={editor} />
</div>
</div>
</div>
);
};
export default RichMarkdownEditor;

View File

@@ -1,19 +1,45 @@
import React, { useState, useEffect, useRef } from 'react'; import React, { useState, useEffect, useRef } from "react";
import { Plus, Minus, ChevronDown, ChevronRight, Type, Hash, ToggleLeft, List, Braces, Edit3, X, Eye, Pencil } from 'lucide-react'; import {
Plus,
Minus,
ChevronDown,
ChevronRight,
ChevronsUpDown,
ChevronsDownUp,
Type,
Hash,
ToggleLeft,
List,
Braces,
Edit3,
X,
Eye,
Pencil,
Search,
} from "lucide-react";
const StructuredEditor = ({ onDataChange, initialData = {}, readOnly: readOnlyProp = false }) => { const StructuredEditor = ({
onDataChange,
initialData = {},
readOnly: readOnlyProp = false,
}) => {
const [data, setData] = useState(initialData); const [data, setData] = useState(initialData);
const [expandedNodes, setExpandedNodes] = useState(new Set(['root'])); const [expandedNodes, setExpandedNodes] = useState(new Set(["root"]));
const [fieldTypes, setFieldTypes] = useState({}); // Track intended types for fields const [fieldTypes, setFieldTypes] = useState({}); // Track intended types for fields
const isInternalUpdate = useRef(false); const isInternalUpdate = useRef(false);
const [nestedEditModal, setNestedEditModal] = useState(null); // { path, value, type: 'json' | 'serialized' } const [nestedEditModal, setNestedEditModal] = useState(null); // { path, value, type: 'json' | 'serialized' }
const [nestedData, setNestedData] = useState(null); const [nestedData, setNestedData] = useState(null);
// Start in edit mode if readOnly is false // Start in preview mode if readOnly is false
const [editMode, setEditMode] = useState(readOnlyProp === false); const [editMode, setEditMode] = useState(
readOnlyProp === false ? false : !readOnlyProp,
);
// Use internal editMode if readOnlyProp is not explicitly set, otherwise use prop // Use internal editMode if readOnlyProp is not explicitly set, otherwise use prop
const readOnly = readOnlyProp !== false ? readOnlyProp : !editMode; const readOnly = readOnlyProp !== false ? readOnlyProp : !editMode;
const [searchQuery, setSearchQuery] = useState("");
const [searchResults, setSearchResults] = useState(new Set());
// Update internal data when initialData prop changes (but not from internal updates) // Update internal data when initialData prop changes (but not from internal updates)
useEffect(() => { useEffect(() => {
// Skip update if this change came from internal editor actions // Skip update if this change came from internal editor actions
@@ -25,7 +51,7 @@ const StructuredEditor = ({ onDataChange, initialData = {}, readOnly: readOnlyPr
setData(initialData); setData(initialData);
// Expand root node if there's data // Expand root node if there's data
if (Object.keys(initialData).length > 0) { if (Object.keys(initialData).length > 0) {
setExpandedNodes(new Set(['root'])); setExpandedNodes(new Set(["root"]));
} }
}, [initialData]); }, [initialData]);
@@ -37,13 +63,13 @@ const StructuredEditor = ({ onDataChange, initialData = {}, readOnly: readOnlyPr
// PHP serialize/unserialize functions // PHP serialize/unserialize functions
const phpSerialize = (data) => { const phpSerialize = (data) => {
if (data === null) return 'N;'; if (data === null) return "N;";
if (typeof data === 'boolean') return data ? 'b:1;' : 'b:0;'; if (typeof data === "boolean") return data ? "b:1;" : "b:0;";
if (typeof data === 'number') { if (typeof data === "number") {
return Number.isInteger(data) ? `i:${data};` : `d:${data};`; return Number.isInteger(data) ? `i:${data};` : `d:${data};`;
} }
if (typeof data === 'string') { if (typeof data === "string") {
const escapedData = data.replace(/\\/g, '\\\\').replace(/"/g, '\\"'); const escapedData = data.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
const byteLength = new TextEncoder().encode(escapedData).length; const byteLength = new TextEncoder().encode(escapedData).length;
return `s:${byteLength}:"${escapedData}";`; return `s:${byteLength}:"${escapedData}";`;
} }
@@ -52,72 +78,78 @@ const StructuredEditor = ({ onDataChange, initialData = {}, readOnly: readOnlyPr
data.forEach((item, index) => { data.forEach((item, index) => {
result += phpSerialize(index) + phpSerialize(item); result += phpSerialize(index) + phpSerialize(item);
}); });
result += '}'; result += "}";
return result; return result;
} }
if (typeof data === 'object') { if (typeof data === "object") {
const keys = Object.keys(data); const keys = Object.keys(data);
let result = `a:${keys.length}:{`; let result = `a:${keys.length}:{`;
keys.forEach(key => { keys.forEach((key) => {
result += phpSerialize(key) + phpSerialize(data[key]); result += phpSerialize(key) + phpSerialize(data[key]);
}); });
result += '}'; result += "}";
return result; return result;
} }
return 'N;'; return "N;";
}; };
const phpUnserialize = (str) => { const phpUnserialize = (str) => {
let index = 0; let index = 0;
const parseValue = () => { const parseValue = () => {
if (index >= str.length) throw new Error('Unexpected end of string'); if (index >= str.length) throw new Error("Unexpected end of string");
const type = str[index]; const type = str[index];
if (type === 'N') { if (type === "N") {
index += 2; index += 2;
return null; return null;
} }
if (str[index + 1] !== ':') throw new Error(`Expected ':' after type '${type}'`); if (str[index + 1] !== ":")
throw new Error(`Expected ':' after type '${type}'`);
index += 2; index += 2;
switch (type) { switch (type) {
case 'b': case "b":
const boolVal = str[index] === '1'; const boolVal = str[index] === "1";
index += 2; index += 2;
return boolVal; return boolVal;
case 'i': case "i":
let intStr = ''; let intStr = "";
while (index < str.length && str[index] !== ';') intStr += str[index++]; while (index < str.length && str[index] !== ";")
intStr += str[index++];
index++; index++;
return parseInt(intStr); return parseInt(intStr);
case 'd': case "d":
let floatStr = ''; let floatStr = "";
while (index < str.length && str[index] !== ';') floatStr += str[index++]; while (index < str.length && str[index] !== ";")
floatStr += str[index++];
index++; index++;
return parseFloat(floatStr); return parseFloat(floatStr);
case 's': case "s":
let lenStr = ''; let lenStr = "";
while (index < str.length && str[index] !== ':') lenStr += str[index++]; while (index < str.length && str[index] !== ":")
lenStr += str[index++];
index++; index++;
if (str[index] !== '"') throw new Error('Expected opening quote'); if (str[index] !== '"') throw new Error("Expected opening quote");
index++; index++;
const byteLength = parseInt(lenStr); const byteLength = parseInt(lenStr);
if (byteLength === 0) { if (byteLength === 0) {
index += 2; index += 2;
return ''; return "";
} }
let endQuotePos = -1; let endQuotePos = -1;
for (let i = index; i < str.length - 1; i++) { for (let i = index; i < str.length - 1; i++) {
if (str[i] === '"' && str[i + 1] === ';') { if (str[i] === '"' && str[i + 1] === ";") {
endQuotePos = i; endQuotePos = i;
break; break;
} }
} }
if (endQuotePos === -1) throw new Error('Could not find closing quote'); if (endQuotePos === -1)
throw new Error("Could not find closing quote");
const strValue = str.substring(index, endQuotePos); const strValue = str.substring(index, endQuotePos);
index = endQuotePos + 2; index = endQuotePos + 2;
return strValue.replace(/\\"/g, '"').replace(/\\\\/g, '\\'); return strValue.replace(/\\"/g, '"').replace(/\\\\/g, "\\");
case 'a': case "a":
let countStr = ''; let countStr = "";
while (index < str.length && str[index] !== ':') countStr += str[index++]; while (index < str.length && str[index] !== ":")
countStr += str[index++];
const count = parseInt(countStr); const count = parseInt(countStr);
index += 2; index += 2;
const result = {}; const result = {};
@@ -139,13 +171,13 @@ const StructuredEditor = ({ onDataChange, initialData = {}, readOnly: readOnlyPr
// Detect if a string contains JSON or serialized data // Detect if a string contains JSON or serialized data
const detectNestedData = (value) => { const detectNestedData = (value) => {
if (typeof value !== 'string' || value.length < 5) return null; if (typeof value !== "string" || value.length < 5) return null;
// Try JSON first // Try JSON first
try { try {
const parsed = JSON.parse(value); const parsed = JSON.parse(value);
if (typeof parsed === 'object' && parsed !== null) { if (typeof parsed === "object" && parsed !== null) {
return { type: 'json', data: parsed }; return { type: "json", data: parsed };
} }
} catch (e) { } catch (e) {
// Not JSON, continue // Not JSON, continue
@@ -156,8 +188,8 @@ const StructuredEditor = ({ onDataChange, initialData = {}, readOnly: readOnlyPr
// Check if it looks like PHP serialized format // Check if it looks like PHP serialized format
if (/^[abidsNO]:[^;]*;/.test(value) || /^a:\d+:\{/.test(value)) { if (/^[abidsNO]:[^;]*;/.test(value) || /^a:\d+:\{/.test(value)) {
const parsed = phpUnserialize(value); const parsed = phpUnserialize(value);
if (typeof parsed === 'object' && parsed !== null) { if (typeof parsed === "object" && parsed !== null) {
return { type: 'serialized', data: parsed }; return { type: "serialized", data: parsed };
} }
} }
} catch (e) { } catch (e) {
@@ -182,9 +214,9 @@ const StructuredEditor = ({ onDataChange, initialData = {}, readOnly: readOnlyPr
// Convert back to string based on type // Convert back to string based on type
let stringValue; let stringValue;
if (nestedEditModal.type === 'json') { if (nestedEditModal.type === "json") {
stringValue = JSON.stringify(nestedData); stringValue = JSON.stringify(nestedData);
} else if (nestedEditModal.type === 'serialized') { } else if (nestedEditModal.type === "serialized") {
stringValue = phpSerialize(nestedData); stringValue = phpSerialize(nestedData);
} }
@@ -212,8 +244,112 @@ const StructuredEditor = ({ onDataChange, initialData = {}, readOnly: readOnlyPr
setExpandedNodes(newExpanded); setExpandedNodes(newExpanded);
}; };
const expandAll = () => {
const allPaths = new Set(["root"]);
// Helper to traverse and collect all paths
const traverse = (obj, currentPath) => {
if (typeof obj === "object" && obj !== null) {
if (Array.isArray(obj)) {
obj.forEach((item, index) => {
const path = `${currentPath}.${index}`;
if (typeof item === "object" && item !== null) {
allPaths.add(path);
traverse(item, path);
}
});
} else {
Object.entries(obj).forEach(([key, value]) => {
const path = `${currentPath}.${key}`;
if (typeof value === "object" && value !== null) {
allPaths.add(path);
traverse(value, path);
}
});
}
}
};
traverse(data, "root");
setExpandedNodes(allPaths);
};
const collapseAll = () => {
setExpandedNodes(new Set(["root"]));
};
// Search effect to auto-expand paths containing matches
useEffect(() => {
if (!searchQuery.trim()) {
setSearchResults(new Set());
return;
}
const query = searchQuery.toLowerCase();
const results = new Set();
const pathsToExpand = new Set(["root"]);
// Returns true if a match is found in this node or its descendants
const searchTraverse = (obj, currentPath) => {
let foundInCurrent = false;
if (typeof obj === "object" && obj !== null) {
if (Array.isArray(obj)) {
obj.forEach((item, index) => {
const path = `${currentPath}.${index}`;
const keyMatches = index.toString().includes(query);
let foundInChild = false;
if (typeof item === "object" && item !== null) {
foundInChild = searchTraverse(item, path);
} else {
const valueStr = getDisplayValue(item).toLowerCase();
if (valueStr.includes(query)) foundInChild = true;
}
if (keyMatches || foundInChild) {
results.add(path);
pathsToExpand.add(currentPath);
pathsToExpand.add(path);
foundInCurrent = true;
}
});
} else {
Object.entries(obj).forEach(([key, value]) => {
const path = `${currentPath}.${key}`;
const keyMatches = key.toLowerCase().includes(query);
let foundInChild = false;
if (typeof value === "object" && value !== null) {
foundInChild = searchTraverse(value, path);
} else {
const valueStr = getDisplayValue(value).toLowerCase();
if (valueStr.includes(query)) foundInChild = true;
}
if (keyMatches || foundInChild) {
results.add(path);
pathsToExpand.add(currentPath);
pathsToExpand.add(path);
foundInCurrent = true;
}
});
}
}
return foundInCurrent;
};
searchTraverse(data, "root");
setSearchResults(results);
// Merge expanded nodes with paths that need to be expanded for search
if (results.size > 0) {
setExpandedNodes((prev) => new Set([...prev, ...pathsToExpand]));
}
}, [searchQuery, data]);
const addProperty = (obj, path) => { const addProperty = (obj, path) => {
const pathParts = path.split('.'); const pathParts = path.split(".");
const newData = { ...data }; const newData = { ...data };
let current = newData; let current = newData;
@@ -225,15 +361,15 @@ const StructuredEditor = ({ onDataChange, initialData = {}, readOnly: readOnlyPr
// Add new property to the target object // Add new property to the target object
const keys = Object.keys(current); const keys = Object.keys(current);
const newKey = `property${keys.length + 1}`; const newKey = `property${keys.length + 1}`;
current[newKey] = ''; current[newKey] = "";
updateData(newData); updateData(newData);
setExpandedNodes(new Set([...expandedNodes, path])); setExpandedNodes(new Set([...expandedNodes, path]));
}; };
const addArrayItem = (arr, path) => { const addArrayItem = (arr, path) => {
const newArr = [...arr, '']; const newArr = [...arr, ""];
const pathParts = path.split('.'); const pathParts = path.split(".");
const newData = { ...data }; const newData = { ...data };
let current = newData; let current = newData;
@@ -251,7 +387,7 @@ const StructuredEditor = ({ onDataChange, initialData = {}, readOnly: readOnlyPr
}; };
const removeProperty = (key, parentPath) => { const removeProperty = (key, parentPath) => {
const pathParts = parentPath.split('.'); const pathParts = parentPath.split(".");
const newData = { ...data }; const newData = { ...data };
let current = newData; let current = newData;
@@ -261,7 +397,8 @@ const StructuredEditor = ({ onDataChange, initialData = {}, readOnly: readOnlyPr
} }
// Remove field type tracking for the removed property // Remove field type tracking for the removed property
const removedPath = parentPath === 'root' ? `root.${key}` : `${parentPath}.${key}`; const removedPath =
parentPath === "root" ? `root.${key}` : `${parentPath}.${key}`;
const newFieldTypes = { ...fieldTypes }; const newFieldTypes = { ...fieldTypes };
delete newFieldTypes[removedPath]; delete newFieldTypes[removedPath];
setFieldTypes(newFieldTypes); setFieldTypes(newFieldTypes);
@@ -276,16 +413,16 @@ const StructuredEditor = ({ onDataChange, initialData = {}, readOnly: readOnlyPr
} }
// Check if we're removing from root level and it's the last property // Check if we're removing from root level and it's the last property
if (parentPath === 'root' && Object.keys(newData).length === 0) { if (parentPath === "root" && Object.keys(newData).length === 0) {
// Add an empty property to maintain initial state, like TableEditor maintains at least one row // Add an empty property to maintain initial state, like TableEditor maintains at least one row
newData[''] = ''; newData[""] = "";
} }
updateData(newData); updateData(newData);
}; };
const updateValue = (value, path) => { const updateValue = (value, path) => {
const pathParts = path.split('.'); const pathParts = path.split(".");
const newData = { ...data }; const newData = { ...data };
let current = newData; let current = newData;
@@ -298,29 +435,30 @@ const StructuredEditor = ({ onDataChange, initialData = {}, readOnly: readOnlyPr
const currentType = typeof currentValue; const currentType = typeof currentValue;
// Preserve the current type when updating value // Preserve the current type when updating value
if (currentType === 'boolean') { if (currentType === "boolean") {
current[key] = value === 'true'; current[key] = value === "true";
} else if (currentType === 'number') { } else if (currentType === "number") {
const numValue = Number(value); const numValue = Number(value);
current[key] = isNaN(numValue) ? 0 : numValue; current[key] = isNaN(numValue) ? 0 : numValue;
} else if (currentValue === null) { } else if (currentValue === null) {
current[key] = value === 'null' ? null : value; current[key] = value === "null" ? null : value;
} else { } else {
// For strings and initial empty values, use smart detection // For strings and initial empty values, use smart detection
if (currentValue === '' || currentValue === undefined) { if (currentValue === "" || currentValue === undefined) {
// Check if this is a newly added property (starts with "property" + number) // Check if this is a newly added property (starts with "property" + number)
const isNewProperty = typeof key === 'string' && key.match(/^property\d+$/); const isNewProperty =
typeof key === "string" && key.match(/^property\d+$/);
if (isNewProperty) { if (isNewProperty) {
// New properties added by user are always strings (no auto-detection) // New properties added by user are always strings (no auto-detection)
current[key] = value; current[key] = value;
} else { } else {
// Existing properties from loaded data - use auto-detection // Existing properties from loaded data - use auto-detection
if (value === 'true' || value === 'false') { if (value === "true" || value === "false") {
current[key] = value === 'true'; current[key] = value === "true";
} else if (value === 'null') { } else if (value === "null") {
current[key] = null; current[key] = null;
} else if (!isNaN(value) && value !== '' && value.trim() !== '') { } else if (!isNaN(value) && value !== "" && value.trim() !== "") {
current[key] = Number(value); current[key] = Number(value);
} else { } else {
current[key] = value; current[key] = value;
@@ -336,7 +474,7 @@ const StructuredEditor = ({ onDataChange, initialData = {}, readOnly: readOnlyPr
}; };
const changeType = (newType, path) => { const changeType = (newType, path) => {
const pathParts = path.split('.'); const pathParts = path.split(".");
const newData = { ...data }; const newData = { ...data };
let current = newData; let current = newData;
@@ -354,69 +492,98 @@ const StructuredEditor = ({ onDataChange, initialData = {}, readOnly: readOnlyPr
// Try to preserve value when changing types if possible // Try to preserve value when changing types if possible
switch (newType) { switch (newType) {
case 'string': case "string":
case 'longtext': case "longtext":
current[key] = currentValue === null ? '' : currentValue.toString(); current[key] = currentValue === null ? "" : currentValue.toString();
break; break;
case 'number': case "number":
if (typeof currentValue === 'string' && !isNaN(currentValue) && currentValue.trim() !== '') { if (
typeof currentValue === "string" &&
!isNaN(currentValue) &&
currentValue.trim() !== ""
) {
current[key] = Number(currentValue); current[key] = Number(currentValue);
} else if (typeof currentValue === 'boolean') { } else if (typeof currentValue === "boolean") {
current[key] = currentValue ? 1 : 0; current[key] = currentValue ? 1 : 0;
} else { } else {
current[key] = 0; current[key] = 0;
} }
break; break;
case 'boolean': case "boolean":
if (typeof currentValue === 'string') { if (typeof currentValue === "string") {
current[key] = currentValue.toLowerCase() === 'true'; current[key] = currentValue.toLowerCase() === "true";
} else if (typeof currentValue === 'number') { } else if (typeof currentValue === "number") {
current[key] = currentValue !== 0; current[key] = currentValue !== 0;
} else { } else {
current[key] = false; current[key] = false;
} }
break; break;
case 'array': case "array":
current[key] = []; current[key] = [];
break; break;
case 'object': case "object":
current[key] = {}; current[key] = {};
break; break;
case 'null': case "null":
current[key] = null; current[key] = null;
break; break;
default: default:
current[key] = ''; current[key] = "";
} }
updateData(newData); updateData(newData);
setExpandedNodes(new Set([...expandedNodes, path])); setExpandedNodes(new Set([...expandedNodes, path]));
}; };
// Helper function to display string values with proper unescaping // Helper function to display string values with proper unescaping
const getDisplayValue = (value) => { const getDisplayValue = (value) => {
if (value === null) return 'null'; if (value === null) return "null";
if (value === undefined) return ''; if (value === undefined) return "";
const stringValue = value.toString(); const stringValue = value.toString();
// If it's a string, unescape common JSON escape sequences for display // If it's a string, unescape common JSON escape sequences for display
if (typeof value === 'string') { if (typeof value === "string") {
return stringValue return stringValue
.replace(/\\"/g, '"') // Unescape quotes .replace(/\\"/g, '"') // Unescape quotes
.replace(/\\'/g, "'") // Unescape single quotes .replace(/\\'/g, "'") // Unescape single quotes
.replace(/\\\//g, '/') // Unescape forward slashes .replace(/\\\//g, "/") // Unescape forward slashes
.replace(/\\\\/g, '\\'); // Unescape backslashes (do this last) .replace(/\\\\/g, "\\"); // Unescape backslashes (do this last)
} }
return stringValue; return stringValue;
}; };
// Helper function to render text with search highlighting
const renderHighlightedText = (text) => {
if (!searchQuery.trim() || typeof text !== "string") return text;
const query = searchQuery.toLowerCase();
const textLower = text.toLowerCase();
const index = textLower.indexOf(query);
if (index === -1) return text;
const before = text.substring(0, index);
const match = text.substring(index, index + query.length);
const after = text.substring(index + query.length);
return (
<>
{before}
<span className="bg-yellow-200 dark:bg-yellow-800 text-black dark:text-white rounded-sm">
{match}
</span>
{renderHighlightedText(after)}{" "}
{/* Handle multiple matches in same string if needed */}
</>
);
};
const renameKey = (oldKey, newKey, path) => { const renameKey = (oldKey, newKey, path) => {
if (oldKey === newKey || !newKey.trim()) return; if (oldKey === newKey || !newKey.trim()) return;
const pathParts = path.split('.'); const pathParts = path.split(".");
const newData = { ...data }; const newData = { ...data };
let current = newData; let current = newData;
@@ -465,7 +632,7 @@ const StructuredEditor = ({ onDataChange, initialData = {}, readOnly: readOnlyPr
); );
} }
if (typeof value === 'string') { if (typeof value === "string") {
return ( return (
<span className="inline-flex items-center justify-center w-6 h-6 rounded-full bg-purple-100 text-purple-800 dark:bg-purple-900/20 dark:text-purple-300"> <span className="inline-flex items-center justify-center w-6 h-6 rounded-full bg-purple-100 text-purple-800 dark:bg-purple-900/20 dark:text-purple-300">
<Type className="h-3 w-3" /> <Type className="h-3 w-3" />
@@ -473,7 +640,7 @@ const StructuredEditor = ({ onDataChange, initialData = {}, readOnly: readOnlyPr
); );
} }
if (typeof value === 'number') { if (typeof value === "number") {
return ( return (
<span className="inline-flex items-center justify-center w-6 h-6 rounded-full bg-orange-100 text-orange-800 dark:bg-orange-900/20 dark:text-orange-300"> <span className="inline-flex items-center justify-center w-6 h-6 rounded-full bg-orange-100 text-orange-800 dark:bg-orange-900/20 dark:text-orange-300">
<Hash className="h-3 w-3" /> <Hash className="h-3 w-3" />
@@ -481,7 +648,7 @@ const StructuredEditor = ({ onDataChange, initialData = {}, readOnly: readOnlyPr
); );
} }
if (typeof value === 'boolean') { if (typeof value === "boolean") {
return ( return (
<span className="inline-flex items-center justify-center w-6 h-6 rounded-full bg-yellow-100 text-yellow-800 dark:bg-yellow-900/20 dark:text-yellow-300"> <span className="inline-flex items-center justify-center w-6 h-6 rounded-full bg-yellow-100 text-yellow-800 dark:bg-yellow-900/20 dark:text-yellow-300">
<ToggleLeft className="h-3 w-3" /> <ToggleLeft className="h-3 w-3" />
@@ -497,7 +664,7 @@ const StructuredEditor = ({ onDataChange, initialData = {}, readOnly: readOnlyPr
); );
} }
if (typeof value === 'object') { if (typeof value === "object") {
return ( return (
<span className="inline-flex items-center justify-center w-6 h-6 rounded-full bg-blue-100 text-blue-800 dark:bg-blue-900/20 dark:text-blue-300"> <span className="inline-flex items-center justify-center w-6 h-6 rounded-full bg-blue-100 text-blue-800 dark:bg-blue-900/20 dark:text-blue-300">
<Braces className="h-3 w-3" /> <Braces className="h-3 w-3" />
@@ -514,16 +681,41 @@ const StructuredEditor = ({ onDataChange, initialData = {}, readOnly: readOnlyPr
const renderValue = (value, key, path, parentPath) => { const renderValue = (value, key, path, parentPath) => {
const isExpanded = expandedNodes.has(path); const isExpanded = expandedNodes.has(path);
const canExpand = typeof value === 'object' && value !== null; const canExpand = typeof value === "object" && value !== null;
// Check if this node matches the search query (if active)
// A node matches if its path is in searchResults
const isSearchActive = searchQuery.trim() !== "";
const isMatch = isSearchActive && searchResults.has(path);
// Check if any of its children match
let hasMatchingChildren = false;
if (isSearchActive && canExpand) {
// Look through searchResults to see if any path starts with this node's path
hasMatchingChildren = Array.from(searchResults).some((resPath) =>
resPath.startsWith(`${path}.`),
);
}
// Hide node if:
// 1. Search is active AND
// 2. Node itself doesn't match AND
// 3. Node has no matching children AND
// 4. Node is not the root (we always render root level if it matches something to keep structure)
// Exception: If we're at root level but the node has no matches and no matching children, hide it
const isHiddenBySearch = isSearchActive && !isMatch && !hasMatchingChildren;
// If there is an active search and this node doesn't match and has no matching children, don't render it
if (isHiddenBySearch) return null;
// Check if parent is an array by looking at the parent path // Check if parent is an array by looking at the parent path
const isArrayItem = (() => { const isArrayItem = (() => {
if (parentPath === 'root') { if (parentPath === "root") {
// If parent is root, check if root data is an array // If parent is root, check if root data is an array
return Array.isArray(data); return Array.isArray(data);
} else { } else {
// Navigate to parent and check if it's an array // Navigate to parent and check if it's an array
const parentPathParts = parentPath.split('.'); const parentPathParts = parentPath.split(".");
let current = data; let current = data;
for (let i = 1; i < parentPathParts.length; i++) { for (let i = 1; i < parentPathParts.length; i++) {
current = current[parentPathParts[i]]; current = current[parentPathParts[i]];
@@ -533,7 +725,10 @@ const StructuredEditor = ({ onDataChange, initialData = {}, readOnly: readOnlyPr
})(); })();
return ( return (
<div key={path} className="ml-4 border-l border-gray-200 dark:border-gray-700 pl-4 overflow-hidden"> <div
key={path}
className="ml-4 border-l border-gray-200 dark:border-gray-700 pl-4 overflow-hidden"
>
<div className="flex items-center space-x-2 mb-2"> <div className="flex items-center space-x-2 mb-2">
{canExpand && ( {canExpand && (
<button <button
@@ -553,38 +748,41 @@ const StructuredEditor = ({ onDataChange, initialData = {}, readOnly: readOnlyPr
<div className="flex items-center space-x-2 flex-1"> <div className="flex items-center space-x-2 flex-1">
{isArrayItem ? ( {isArrayItem ? (
// Array items: icon + index span (compact) // Array items: icon + index span (compact)
<> <div className="flex items-center space-x-1 w-[120px] shrink-0">
{getTypeIcon(value)} {getTypeIcon(value)}
<span className="px-2 py-1 text-sm text-gray-600 dark:text-gray-600 font-mono whitespace-nowrap"> <span className="text-gray-500 dark:text-gray-400 font-mono text-sm">
[{key}] {renderHighlightedText(key)}
</span> </span>
</> <span className="text-gray-600 inline">:</span>
</div>
) : ( ) : (
// Object properties: icon + editable key + colon (compact) // Object properties: icon + editable key input
<> <>
{getTypeIcon(value)} {getTypeIcon(value)}
{readOnly ? ( {readOnly ? (
<span className="px-2 py-1 text-sm text-gray-900 dark:text-gray-100 font-mono"> <span
{key} className="px-2 py-1 text-sm font-medium text-gray-900 dark:text-gray-100 min-w-0 break-all"
style={{ width: "120px" }}
>
{renderHighlightedText(key)}
</span> </span>
) : ( ) : (
<input <input
type="text" type="text"
defaultValue={key} defaultValue={key}
onBlur={(e) => { onBlur={(e) => {
const newKey = e.target.value.trim(); if (e.target.value !== key) {
if (newKey && newKey !== key) { renameKey(key, e.target.value, path);
renameKey(key, newKey, path);
} }
}} }}
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === 'Enter') { if (e.key === "Enter") {
e.target.blur(); // Trigger blur to save changes 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" 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" placeholder="Property name"
style={{width: '120px'}} // Fixed width for consistency style={{ width: "120px" }} // Fixed width for consistency
/> />
)} )}
<span className="text-gray-600 inline">:</span> <span className="text-gray-600 inline">:</span>
@@ -592,23 +790,23 @@ const StructuredEditor = ({ onDataChange, initialData = {}, readOnly: readOnlyPr
)} )}
{!canExpand ? ( {!canExpand ? (
typeof value === 'boolean' ? ( typeof value === "boolean" ? (
<div className="flex-1 flex items-center space-x-2"> <div className="flex-1 flex items-center space-x-2">
{readOnly ? ( {readOnly ? (
<span className="text-sm text-gray-900 dark:text-gray-100 font-mono"> <span className="text-sm text-gray-900 dark:text-gray-100 font-mono">
{value.toString()} {renderHighlightedText(value.toString())}
</span> </span>
) : ( ) : (
<> <>
<button <button
onClick={() => updateValue((!value).toString(), path)} 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 ${ 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' value ? "bg-blue-600" : "bg-gray-200 dark:bg-gray-600"
}`} }`}
> >
<span <span
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${ className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
value ? 'translate-x-6' : 'translate-x-1' value ? "translate-x-6" : "translate-x-1"
}`} }`}
/> />
</button> </button>
@@ -621,22 +819,23 @@ const StructuredEditor = ({ onDataChange, initialData = {}, readOnly: readOnlyPr
) : ( ) : (
<div className="flex-1 flex items-center gap-2"> <div className="flex-1 flex items-center gap-2">
{readOnly ? ( {readOnly ? (
typeof value === 'string' && detectNestedData(value) ? ( typeof value === "string" && detectNestedData(value) ? (
<span <span
onClick={() => openNestedEditor(value, path)} 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" 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`} title={`Click to view nested ${detectNestedData(value).type} data`}
> >
{getDisplayValue(value)} {renderHighlightedText(getDisplayValue(value))}
</span> </span>
) : ( ) : (
<span className="px-2 py-1 text-sm text-gray-900 dark:text-gray-100 font-mono break-all"> <span className="px-2 py-1 text-sm text-gray-900 dark:text-gray-100 font-mono break-all">
{getDisplayValue(value)} {renderHighlightedText(getDisplayValue(value))}
</span> </span>
) )
) : ( ) : (
<> <>
{(fieldTypes[path] === 'longtext' || (typeof value === 'string' && value.includes('\n'))) ? ( {fieldTypes[path] === "longtext" ||
(typeof value === "string" && value.includes("\n")) ? (
<textarea <textarea
value={getDisplayValue(value)} value={getDisplayValue(value)}
onChange={(e) => updateValue(e.target.value, path)} onChange={(e) => updateValue(e.target.value, path)}
@@ -653,7 +852,7 @@ const StructuredEditor = ({ onDataChange, initialData = {}, readOnly: readOnlyPr
placeholder="Value" placeholder="Value"
/> />
)} )}
{typeof value === 'string' && detectNestedData(value) && ( {typeof value === "string" && detectNestedData(value) && (
<button <button
onClick={() => openNestedEditor(value, path)} onClick={() => openNestedEditor(value, path)}
className="p-1 text-blue-600 hover:bg-blue-100 dark:hover:bg-blue-900 rounded flex-shrink-0" className="p-1 text-blue-600 hover:bg-blue-100 dark:hover:bg-blue-900 rounded flex-shrink-0"
@@ -668,7 +867,9 @@ const StructuredEditor = ({ onDataChange, initialData = {}, readOnly: readOnlyPr
) )
) : ( ) : (
<span className="flex-1 text-sm text-gray-600 dark:text-gray-600"> <span className="flex-1 text-sm text-gray-600 dark:text-gray-600">
{Array.isArray(value) ? `Array (${value.length} items)` : `Object (${Object.keys(value).length} properties)`} {Array.isArray(value)
? `Array (${value.length} items)`
: `Object (${Object.keys(value).length} properties)`}
</span> </span>
)} )}
@@ -676,14 +877,22 @@ const StructuredEditor = ({ onDataChange, initialData = {}, readOnly: readOnlyPr
<div className="flex items-center space-x-2 sm:space-x-2"> <div className="flex items-center space-x-2 sm:space-x-2">
<select <select
value={ value={
fieldTypes[path] || ( fieldTypes[path] ||
value === null ? 'null' : (value === null
value === undefined ? 'string' : ? "null"
typeof value === 'string' ? (value.includes('\n') ? 'longtext' : 'string') : : value === undefined
typeof value === 'number' ? 'number' : ? "string"
typeof value === 'boolean' ? 'boolean' : : typeof value === "string"
Array.isArray(value) ? 'array' : 'object' ? 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)} 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" 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"
@@ -714,7 +923,7 @@ const StructuredEditor = ({ onDataChange, initialData = {}, readOnly: readOnlyPr
{Array.isArray(value) ? ( {Array.isArray(value) ? (
<> <>
{value.map((item, index) => {value.map((item, index) =>
renderValue(item, index.toString(), `${path}.${index}`, path) renderValue(item, index.toString(), `${path}.${index}`, path),
)} )}
{!readOnly && ( {!readOnly && (
<button <button
@@ -729,7 +938,7 @@ const StructuredEditor = ({ onDataChange, initialData = {}, readOnly: readOnlyPr
) : ( ) : (
<> <>
{Object.entries(value).map(([k, v]) => {Object.entries(value).map(([k, v]) =>
renderValue(v, k, `${path}.${k}`, path) renderValue(v, k, `${path}.${k}`, path),
)} )}
{!readOnly && ( {!readOnly && (
<button <button
@@ -751,18 +960,63 @@ const StructuredEditor = ({ onDataChange, initialData = {}, readOnly: readOnlyPr
return ( return (
<div className="min-h-96 w-full"> <div className="min-h-96 w-full">
<div className="mb-4"> <div className="mb-4">
<div className="flex flex-col gap-3 mb-3"> <div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3 mb-3">
<h3 className="text-lg font-medium text-gray-900 dark:text-gray-100">Structured Data Editor</h3> <h3 className="text-lg font-medium text-gray-900 dark:text-gray-100 whitespace-nowrap">
Structured Data Editor
</h3>
<div className="flex flex-wrap items-center gap-3 w-full sm:w-auto sm:justify-end">
{/* Search Bar Inline */}
<div className="relative flex-grow max-w-[200px] sm:max-w-xs order-last sm:order-first">
<div className="absolute inset-y-0 left-0 pl-2.5 flex items-center pointer-events-none">
<Search className="h-3.5 w-3.5 text-gray-400" />
</div>
<input
type="text"
placeholder="Search..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full pl-8 pr-8 py-1.5 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-xs text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors"
/>
{searchQuery && (
<button
onClick={() => setSearchQuery("")}
className="absolute inset-y-0 right-0 pr-2.5 flex items-center text-gray-400 hover:text-gray-600 dark:hover:text-gray-200"
>
<X className="h-3.5 w-3.5" />
</button>
)}
</div>
{/* Expand/Collapse All Buttons */}
<div className="flex border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden bg-white dark:bg-gray-800 shadow-sm shrink-0">
<button
onClick={expandAll}
className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium 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 transition-colors"
title="Expand All"
>
<ChevronsUpDown className="h-3.5 w-3.5" />
<span className="hidden lg:inline">Expand All</span>
</button>
<button
onClick={collapseAll}
className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium 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 transition-colors border-l border-gray-200 dark:border-gray-700"
title="Collapse All"
>
<ChevronsDownUp className="h-3.5 w-3.5" />
<span className="hidden lg:inline">Collapse All</span>
</button>
</div>
{/* Mode Toggle - Below title on mobile, inline on desktop */} {/* Mode Toggle - Below title on mobile, inline on desktop */}
{readOnlyProp === false && ( {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"> <div className="flex border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden bg-white dark:bg-gray-800 shadow-sm shrink-0">
<button <button
onClick={() => setEditMode(false)} onClick={() => setEditMode(false)}
className={`flex items-center gap-2 px-3 py-1.5 text-xs font-medium transition-colors ${ className={`flex items-center gap-2 px-3 py-1.5 text-xs font-medium transition-colors ${
!editMode !editMode
? 'bg-green-50 dark:bg-green-900/20 text-green-700 dark:text-green-300' ? "bg-green-50 dark:bg-green-900/20 text-green-700 dark:text-green-300"
: 'text-gray-600 dark:text-gray-600 hover:text-gray-900 dark:hover:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-700' : "text-gray-600 dark:text-gray-600 hover:text-gray-900 dark:hover:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-700"
}`} }`}
> >
<Eye className="h-3.5 w-3.5" /> <Eye className="h-3.5 w-3.5" />
@@ -772,8 +1026,8 @@ const StructuredEditor = ({ onDataChange, initialData = {}, readOnly: readOnlyPr
onClick={() => setEditMode(true)} 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 ${ 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 editMode
? 'bg-orange-50 dark:bg-orange-900/20 text-orange-700 dark:text-orange-300' ? "bg-orange-50 dark:bg-orange-900/20 text-orange-700 dark:text-orange-300"
: 'text-gray-600 dark:text-gray-600 hover:text-gray-900 dark:hover:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-700' : "text-gray-600 dark:text-gray-600 hover:text-gray-900 dark:hover:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-700"
}`} }`}
> >
<Pencil className="h-3.5 w-3.5" /> <Pencil className="h-3.5 w-3.5" />
@@ -783,6 +1037,7 @@ const StructuredEditor = ({ onDataChange, initialData = {}, readOnly: readOnlyPr
)} )}
</div> </div>
</div> </div>
</div>
<div className="w-full overflow-hidden"> <div className="w-full overflow-hidden">
<div className="w-full overflow-x-auto"> <div className="w-full overflow-x-auto">
@@ -790,18 +1045,21 @@ const StructuredEditor = ({ onDataChange, initialData = {}, readOnly: readOnlyPr
{Object.keys(data).length === 0 ? ( {Object.keys(data).length === 0 ? (
<div className="text-center text-gray-600 dark:text-gray-600 py-8"> <div className="text-center text-gray-600 dark:text-gray-600 py-8">
<Braces className="h-12 w-12 mx-auto mb-2 opacity-50" /> <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> <p>
No properties yet. Click "Add Property" to start building your
data structure.
</p>
</div> </div>
) : ( ) : (
Object.entries(data).map(([key, value]) => Object.entries(data).map(([key, value]) =>
renderValue(value, key, `root.${key}`, 'root') renderValue(value, key, `root.${key}`, "root"),
) )
)} )}
{/* Root level Add Property button */} {/* Root level Add Property button */}
{!readOnly && ( {!readOnly && (
<button <button
onClick={() => addProperty(data, 'root')} 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" 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" /> <Plus className="h-4 w-4" />
@@ -820,10 +1078,13 @@ const StructuredEditor = ({ onDataChange, initialData = {}, readOnly: readOnlyPr
<div className="px-6 py-4 border-b border-gray-200 dark:border-gray-700 bg-blue-50 dark:bg-blue-900/20 flex items-center justify-between"> <div className="px-6 py-4 border-b border-gray-200 dark:border-gray-700 bg-blue-50 dark:bg-blue-900/20 flex items-center justify-between">
<div> <div>
<h3 className="text-lg font-medium text-gray-900 dark:text-gray-100"> <h3 className="text-lg font-medium text-gray-900 dark:text-gray-100">
Edit Nested {nestedEditModal.type === 'json' ? 'JSON' : 'Serialized'} Data Edit Nested{" "}
{nestedEditModal.type === "json" ? "JSON" : "Serialized"} Data
</h3> </h3>
<p className="text-sm text-gray-600 dark:text-gray-600 mt-1"> <p className="text-sm text-gray-600 dark:text-gray-600 mt-1">
Changes will be saved back as a {nestedEditModal.type === 'json' ? 'JSON' : 'serialized'} string Changes will be saved back as a{" "}
{nestedEditModal.type === "json" ? "JSON" : "serialized"}{" "}
string
</p> </p>
</div> </div>
<button <button

View File

@@ -13,7 +13,7 @@ const TabletAdSection = () => {
<div className="flex-shrink-0"> <div className="flex-shrink-0">
<AdBlock <AdBlock
adKey="7c55aebcdd74f6e9a8dc24bd13e7d949" adKey="7c55aebcdd74f6e9a8dc24bd13e7d949"
adDomain="www.highperformanceformat.com" adDomain="downconvenientmagnetic.com"
/> />
</div> </div>
<div className="flex-shrink-0"> <div className="flex-shrink-0">

View File

@@ -1,163 +1,185 @@
import { Edit3, Table, LinkIcon, Hash, Wand2, GitCompare, Type, Home, FileText } from 'lucide-react'; import {
Edit3,
Table,
LinkIcon,
Hash,
Wand2,
GitCompare,
Type,
Home,
FileText,
} from "lucide-react";
// Master tools configuration - single source of truth // Master tools configuration - single source of truth
export const TOOL_CATEGORIES = { export const TOOL_CATEGORIES = {
navigation: { navigation: {
name: 'Navigation', name: "Navigation",
color: 'from-slate-500 to-slate-600', color: "from-slate-500 to-slate-600",
hoverColor: 'slate-600', hoverColor: "slate-600",
textColor: 'text-slate-600', textColor: "text-slate-600",
hoverTextColor: 'hover:text-slate-700 dark:hover:text-slate-600' hoverTextColor: "hover:text-slate-700 dark:hover:text-slate-600",
}, },
editor: { editor: {
name: 'Editor', name: "Editor",
color: 'from-blue-500 to-cyan-500', color: "from-blue-500 to-cyan-500",
hoverColor: 'blue-600', hoverColor: "blue-600",
textColor: 'text-blue-600', textColor: "text-blue-600",
hoverTextColor: 'hover:text-blue-700 dark:hover:text-blue-400' hoverTextColor: "hover:text-blue-700 dark:hover:text-blue-400",
}, },
encoder: { encoder: {
name: 'Encoder', name: "Encoder",
color: 'from-purple-500 to-pink-500', color: "from-purple-500 to-pink-500",
hoverColor: 'purple-600', hoverColor: "purple-600",
textColor: 'text-purple-600', textColor: "text-purple-600",
hoverTextColor: 'hover:text-purple-700 dark:hover:text-purple-400' hoverTextColor: "hover:text-purple-700 dark:hover:text-purple-400",
}, },
formatter: { formatter: {
name: 'Formatter', name: "Formatter",
color: 'from-green-500 to-emerald-500', color: "from-green-500 to-emerald-500",
hoverColor: 'green-600', hoverColor: "green-600",
textColor: 'text-green-600', textColor: "text-green-600",
hoverTextColor: 'hover:text-green-700 dark:hover:text-green-400' hoverTextColor: "hover:text-green-700 dark:hover:text-green-400",
}, },
analyzer: { analyzer: {
name: 'Analyzer', name: "Analyzer",
color: 'from-orange-500 to-red-500', color: "from-orange-500 to-red-500",
hoverColor: 'orange-600', hoverColor: "orange-600",
textColor: 'text-orange-600', textColor: "text-orange-600",
hoverTextColor: 'hover:text-orange-700 dark:hover:text-orange-400' hoverTextColor: "hover:text-orange-700 dark:hover:text-orange-400",
}, },
non_tools: { non_tools: {
name: 'Site Navigation', name: "Site Navigation",
color: 'from-indigo-500 to-purple-500', color: "from-indigo-500 to-purple-500",
hoverColor: 'indigo-600', hoverColor: "indigo-600",
textColor: 'text-indigo-600', textColor: "text-indigo-600",
hoverTextColor: 'hover:text-indigo-700 dark:hover:text-indigo-400' hoverTextColor: "hover:text-indigo-700 dark:hover:text-indigo-400",
} },
}; };
export const TOOLS = [ export const TOOLS = [
{ {
path: '/object-editor', path: "/object-editor",
name: 'Object Editor', name: "Object Editor",
icon: Edit3, icon: Edit3,
description: 'Visual editor for JSON and PHP serialized objects with mindmap visualization', description:
tags: ['Visual', 'JSON', 'PHP', 'Objects', 'Editor'], "Visual editor for JSON and PHP serialized objects with mindmap visualization",
category: 'editor' tags: ["Visual", "JSON", "PHP", "Objects", "Editor"],
category: "editor",
}, },
{ {
path: '/table-editor', path: "/table-editor",
name: 'Table Editor', name: "Table Editor",
icon: Table, icon: Table,
description: 'Import, edit, and export tabular data from URLs, files, or paste CSV/JSON', description:
tags: ['Table', 'CSV', 'JSON', 'Data', 'Editor'], "Import, edit, and export tabular data from URLs, files, or paste CSV/JSON",
category: 'editor' tags: ["Table", "CSV", "JSON", "Data", "Editor"],
category: "editor",
}, },
{ {
path: '/markdown-editor', path: "/markdown-editor",
name: 'Markdown Editor', name: "Markdown Editor",
icon: FileText, icon: FileText,
description: 'Write and preview markdown with live rendering, syntax highlighting, and export options', description:
tags: ['Markdown', 'Editor', 'Preview', 'Export', 'GFM'], "Write and preview markdown with live rendering, syntax highlighting, and export options",
category: 'editor' tags: ["Markdown", "Editor", "Preview", "Export", "GFM"],
category: "editor",
}, },
{ {
path: '/invoice-editor', path: "/diagram-editor",
name: 'Invoice Editor', name: "Diagram Editor",
icon: Edit3,
description:
"Create diagrams as code using Mermaid.js with live preview and multi-format export",
tags: ["Diagram", "Mermaid", "Architecture", "Flowchart", "Visual"],
category: "editor",
},
{
path: "/invoice-editor",
name: "Invoice Editor",
icon: FileText, icon: FileText,
description: 'Create, edit, and export professional invoices with PDF generation', description:
tags: ['Invoice', 'PDF', 'Business', 'Billing', 'Export'], "Create, edit, and export professional invoices with PDF generation",
category: 'editor' tags: ["Invoice", "PDF", "Business", "Billing", "Export"],
category: "editor",
}, },
{ {
path: '/url', path: "/url",
name: 'URL Encoder/Decoder', name: "URL Encoder/Decoder",
icon: LinkIcon, icon: LinkIcon,
description: 'Encode and decode URLs and query parameters', description: "Encode and decode URLs and query parameters",
tags: ['URL', 'Encode', 'Decode'], tags: ["URL", "Encode", "Decode"],
category: 'encoder' category: "encoder",
}, },
{ {
path: '/base64', path: "/base64",
name: 'Base64 Encoder/Decoder', name: "Base64 Encoder/Decoder",
icon: Hash, icon: Hash,
description: 'Convert text to Base64 and back with support for files', description: "Convert text to Base64 and back with support for files",
tags: ['Base64', 'Encode', 'Binary'], tags: ["Base64", "Encode", "Binary"],
category: 'encoder' category: "encoder",
}, },
{ {
path: '/beautifier', path: "/beautifier",
name: 'Code Beautifier/Minifier', name: "Code Beautifier/Minifier",
icon: Wand2, icon: Wand2,
description: 'Format and minify JSON, XML, SQL, CSS, and HTML code', description: "Format and minify JSON, XML, SQL, CSS, and HTML code",
tags: ['Format', 'Minify', 'Beautify'], tags: ["Format", "Minify", "Beautify"],
category: 'formatter' category: "formatter",
}, },
{ {
path: '/diff', path: "/diff",
name: 'Text Diff Checker', name: "Text Diff Checker",
icon: GitCompare, icon: GitCompare,
description: 'Compare two texts and highlight differences line by line', description: "Compare two texts and highlight differences line by line",
tags: ['Diff', 'Compare', 'Text'], tags: ["Diff", "Compare", "Text"],
category: 'analyzer' category: "analyzer",
}, },
{ {
path: '/text-length', path: "/text-length",
name: 'Text Length Checker', name: "Text Length Checker",
icon: Type, icon: Type,
description: 'Analyze text length, word count, and other text statistics', description: "Analyze text length, word count, and other text statistics",
tags: ['Text', 'Length', 'Statistics'], tags: ["Text", "Length", "Statistics"],
category: 'analyzer' category: "analyzer",
} },
]; ];
// Non-tool navigation items (homepage, what's new, etc.) // Non-tool navigation items (homepage, what's new, etc.)
export const NON_TOOLS = [ export const NON_TOOLS = [
{ {
path: '/', path: "/",
name: 'Home', name: "Home",
icon: Home, icon: Home,
description: 'Back to homepage', description: "Back to homepage",
category: 'non_tools' category: "non_tools",
} },
]; ];
// Navigation tools (for sidebar) - combines non-tools and tools // Navigation tools (for sidebar) - combines non-tools and tools
export const NAVIGATION_TOOLS = [ export const NAVIGATION_TOOLS = [...NON_TOOLS, ...TOOLS];
...NON_TOOLS,
...TOOLS
];
// Site configuration // Site configuration
export const SITE_CONFIG = { export const SITE_CONFIG = {
domain: 'https://dewe.dev', domain: "https://dewe.dev",
title: 'Dewe.Dev', title: "Dewe.Dev",
subtitle: 'Professional Developer Utilities', subtitle: "Professional Developer Utilities",
slogan: 'Code faster, debug smarter, ship better', slogan: "Code faster, debug smarter, ship better",
description: 'Professional-grade utilities for modern developers', description: "Professional-grade utilities for modern developers",
year: new Date().getFullYear(), year: new Date().getFullYear(),
totalTools: TOOLS.length totalTools: TOOLS.length,
}; };
// Helper functions // Helper functions
export const getCategoryConfig = (categoryKey) => TOOL_CATEGORIES[categoryKey] || TOOL_CATEGORIES.navigation; export const getCategoryConfig = (categoryKey) =>
TOOL_CATEGORIES[categoryKey] || TOOL_CATEGORIES.navigation;
export const getToolsByCategory = (categoryKey) => TOOLS.filter(tool => tool.category === categoryKey); export const getToolsByCategory = (categoryKey) =>
TOOLS.filter((tool) => tool.category === categoryKey);
export const getCategoryStats = () => { export const getCategoryStats = () => {
const stats = {}; const stats = {};
Object.keys(TOOL_CATEGORIES).forEach(key => { Object.keys(TOOL_CATEGORIES).forEach((key) => {
if (key !== 'navigation') { if (key !== "navigation") {
stats[key] = getToolsByCategory(key).length; stats[key] = getToolsByCategory(key).length;
} }
}); });

View File

@@ -2,31 +2,37 @@
@tailwind components; @tailwind components;
@tailwind utilities; @tailwind utilities;
@import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600&display=swap'); @import url("https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600&display=swap");
@layer base { @layer base {
html { html {
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; font-family:
overflow-x: hidden; system-ui,
-apple-system,
BlinkMacSystemFont,
"Segoe UI",
Roboto,
sans-serif;
width: 100%; width: 100%;
max-width: 100vw; max-width: 100vw;
} }
body { body {
overflow-x: hidden;
width: 100%; width: 100%;
max-width: 100vw; max-width: 100vw;
} }
#root { #root {
overflow-x: hidden;
width: 100%; width: 100%;
max-width: 100vw; max-width: 100vw;
min-width: 0; min-width: 0;
} }
code, pre { code,
font-family: 'JetBrains Mono', Monaco, 'Cascadia Code', 'Segoe UI Mono', 'Roboto Mono', monospace; pre {
font-family:
"JetBrains Mono", Monaco, "Cascadia Code", "Segoe UI Mono",
"Roboto Mono", monospace;
} }
} }

586
src/pages/DiagramEditor.js Executable file
View File

@@ -0,0 +1,586 @@
import React, { useState, useRef, useEffect } from "react";
import {
GitGraph,
Plus,
Upload,
Download,
Globe,
Type,
Columns,
Maximize2,
Minimize2,
FileImage,
FileCode2,
Eye,
AlertTriangle,
FileText,
Copy,
} from "lucide-react";
import ToolLayout from "../components/ToolLayout";
import CodeMirrorEditor from "../components/CodeMirrorEditor";
import SEO from "../components/SEO";
import RelatedTools from "../components/RelatedTools";
import ReactFlowEditor from "../components/diagram/ReactFlowEditor";
import FullscreenAdBanner from "../components/FullscreenAdBanner";
import { toPng } from "html-to-image";
import { generateMermaidFromGraph } from "../utils/mermaidGenerator";
const DIAGRAM_TEMPLATES = {
flowchart: {
nodes: [
{
id: "1",
type: "process",
position: { x: 250, y: 50 },
data: { label: "Start" },
},
{
id: "2",
type: "decision",
position: { x: 250, y: 150 },
data: { label: "Is it OK?" },
},
{
id: "3",
type: "process",
position: { x: 100, y: 250 },
data: { label: "Fix it" },
},
{
id: "4",
type: "database",
position: { x: 400, y: 250 },
data: { label: "Save to DB" },
},
{
id: "5",
type: "process",
position: { x: 250, y: 350 },
data: { label: "End" },
},
],
edges: [
{ id: "e1-2", source: "1", target: "2" },
{
id: "e2-3",
source: "2",
target: "3",
sourceHandle: "left",
label: "No",
},
{
id: "e2-4",
source: "2",
target: "4",
sourceHandle: "right",
label: "Yes",
},
{ id: "e3-2", source: "3", target: "2", targetHandle: "left" },
{
id: "e2-5",
source: "2",
target: "5",
sourceHandle: "bottom",
targetHandle: "top",
},
],
},
database: {
nodes: [
{
id: "users",
type: "database",
position: { x: 100, y: 100 },
data: { label: "Users Table" },
},
{
id: "orders",
type: "database",
position: { x: 400, y: 100 },
data: { label: "Orders Table" },
},
{
id: "process",
type: "process",
position: { x: 250, y: 250 },
data: { label: "Order Processor" },
},
],
edges: [
{ id: "eu-p", source: "users", target: "process", label: "Read" },
{ id: "ep-o", source: "process", target: "orders", label: "Write" },
],
},
};
const DiagramEditor = () => {
const [graphData, setGraphData] = useState(DIAGRAM_TEMPLATES.flowchart);
const [code, setCode] = useState(
JSON.stringify(DIAGRAM_TEMPLATES.flowchart, null, 2),
);
const [activeTab, setActiveTab] = useState("create");
const [viewMode, setViewMode] = useState(() =>
window.innerWidth < 1024 ? "editor" : "split",
);
const [isFullscreen, setIsFullscreen] = useState(false);
const [error, setError] = useState(null);
const [fetchUrl, setFetchUrl] = useState("");
const [fetching, setFetching] = useState(false);
const [selectedTemplate, setSelectedTemplate] = useState("flowchart");
const reactFlowWrapper = useRef(null);
const fileInputRef = useRef(null);
// Sync JSON text input to Graph data
useEffect(() => {
try {
const parsed = JSON.parse(code);
if (parsed.nodes && parsed.edges) {
setGraphData(parsed);
setError(null);
}
} catch (err) {
setError(err.message);
}
}, [code]);
const handleGraphChange = (newGraphData) => {
setGraphData(newGraphData);
setCode(JSON.stringify(newGraphData, null, 2));
};
// Input Handlers
const handleTemplateChange = (e) => {
const template = e.target.value;
setSelectedTemplate(template);
if (DIAGRAM_TEMPLATES[template]) {
setGraphData(DIAGRAM_TEMPLATES[template]);
setCode(JSON.stringify(DIAGRAM_TEMPLATES[template], null, 2));
}
};
const handleFileUpload = (e) => {
const file = e.target.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (e) => {
setCode(e.target.result);
setActiveTab("create");
};
reader.onerror = () => setError("Failed to read file");
reader.readAsText(file);
e.target.value = ""; // Reset input
};
const handleFetchFromURL = async () => {
if (!fetchUrl.trim()) return;
setFetching(true);
try {
let url = fetchUrl.trim();
if (
url.includes("github.com") &&
!url.includes("raw.githubusercontent.com")
) {
url = url
.replace("github.com", "raw.githubusercontent.com")
.replace("/blob/", "/");
}
const response = await fetch(url);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const text = await response.text();
setCode(text);
setActiveTab("create");
} catch (err) {
setError(`Fetch failed: ${err.message}`);
} finally {
setFetching(false);
}
};
// Export Handlers (Updated for ReactFlow)
const downloadFile = (content, filename, mimeType) => {
const blob = new Blob([content], { type: mimeType });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
};
const exportJSON = () => {
downloadFile(code, "diagram.json", "application/json");
};
const exportMermaid = () => {
const mermaidCode = generateMermaidFromGraph(
graphData.nodes,
graphData.edges,
);
downloadFile(mermaidCode, "diagram.mmd", "text/plain");
};
const copyMermaid = () => {
const mermaidCode = generateMermaidFromGraph(
graphData.nodes,
graphData.edges,
);
navigator.clipboard
.writeText(mermaidCode)
.then(() => {
// Could add toast notification here
console.log("Mermaid code copied!");
})
.catch((err) => console.error("Failed to copy", err));
};
const exportPNG = () => {
const flowElement = document.querySelector(".react-flow");
if (!flowElement) return;
toPng(flowElement, {
backgroundColor: "#ffffff",
width: flowElement.offsetWidth,
height: flowElement.offsetHeight,
style: {
width: "100%",
height: "100%",
transform: "translate(0, 0)",
},
})
.then((dataUrl) => {
const a = document.createElement("a");
a.setAttribute("download", "diagram.png");
a.setAttribute("href", dataUrl);
a.click();
})
.catch((error) => {
setError("Failed to export PNG: " + error.message);
});
};
return (
<>
<SEO
title="Mermaid Diagram Editor & Viewer"
description="Write Mermaid.js diagram code with live visual preview. Pan, zoom, and export your flowcharts, sequence diagrams, and architecture maps to PNG or SVG."
keywords="mermaid editor, diagram editor, diagram as code, mermaidjs, flowchart generator, sequence diagram, architecture diagram, export svg"
path="/diagram-editor"
toolId="diagram-editor"
/>
<ToolLayout
title="Diagram Tool"
description="Create diagrams as code using Mermaid.js with live preview and multi-format export."
icon={GitGraph}
>
{/* Input Section - Always visible */}
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 overflow-hidden mb-6">
<div className="p-4 border-b border-gray-200 dark:border-gray-700">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
Get Started
</h3>
</div>
{/* Tab Navigation */}
<div className="flex border-b border-gray-200 dark:border-gray-700 overflow-x-auto scrollbar-hide">
<button
onClick={() => setActiveTab("create")}
className={`flex items-center gap-1 sm:gap-2 px-3 sm:px-4 py-3 text-sm font-medium transition-colors whitespace-nowrap ${
activeTab === "create"
? "bg-blue-50 dark:bg-blue-900/20 text-blue-700 dark:text-blue-300 border-b-2 border-blue-500"
: "text-gray-600 hover:text-gray-900 dark:text-gray-400 dark:hover:text-gray-200"
}`}
>
<Plus className="h-4 w-4 flex-shrink-0" />
Create New
</button>
<button
onClick={() => setActiveTab("url")}
className={`flex items-center gap-1 sm:gap-2 px-3 sm:px-4 py-3 text-sm font-medium transition-colors whitespace-nowrap ${
activeTab === "url"
? "bg-blue-50 dark:bg-blue-900/20 text-blue-700 dark:text-blue-300 border-b-2 border-blue-500"
: "text-gray-600 hover:text-gray-900 dark:text-gray-400 dark:hover:text-gray-200"
}`}
>
<Globe className="h-4 w-4 flex-shrink-0" />
URL Fetch
</button>
<button
onClick={() => setActiveTab("paste")}
className={`flex items-center gap-1 sm:gap-2 px-3 sm:px-4 py-3 text-sm font-medium transition-colors whitespace-nowrap ${
activeTab === "paste"
? "bg-blue-50 dark:bg-blue-900/20 text-blue-700 dark:text-blue-300 border-b-2 border-blue-500"
: "text-gray-600 hover:text-gray-900 dark:text-gray-400 dark:hover:text-gray-200"
}`}
>
<FileText className="h-4 w-4 flex-shrink-0" />
Paste
</button>
<button
onClick={() => setActiveTab("open")}
className={`flex items-center gap-1 sm:gap-2 px-3 sm:px-4 py-3 text-sm font-medium transition-colors whitespace-nowrap ${
activeTab === "open"
? "bg-blue-50 dark:bg-blue-900/20 text-blue-700 dark:text-blue-300 border-b-2 border-blue-500"
: "text-gray-600 hover:text-gray-900 dark:text-gray-400 dark:hover:text-gray-200"
}`}
>
<Upload className="h-4 w-4 flex-shrink-0" />
Open File
</button>
</div>
<div className="p-4">
{activeTab === "create" && (
<div className="flex flex-col sm:flex-row gap-4 items-start sm:items-center">
<div className="flex-1">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Load Boilerplate Template
</label>
<select
value={selectedTemplate}
onChange={handleTemplateChange}
className="tool-input w-full max-w-xs"
>
<option value="flowchart">Flowchart</option>
<option value="database">Database/ER</option>
</select>
</div>
<button
onClick={() => {
setGraphData({ nodes: [], edges: [] });
setCode(JSON.stringify({ nodes: [], edges: [] }, null, 2));
}}
className="px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-md text-sm font-medium hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
>
Clear Editor
</button>
</div>
)}
{activeTab === "url" && (
<div className="space-y-3">
<div className="flex gap-2">
<input
type="url"
value={fetchUrl}
onChange={(e) => setFetchUrl(e.target.value)}
onKeyPress={(e) =>
e.key === "Enter" && !fetching && handleFetchFromURL()
}
placeholder="https://raw.githubusercontent.com/.../diagram.mmd"
className="tool-input flex-1"
/>
<button
onClick={handleFetchFromURL}
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 whitespace-nowrap"
>
{fetching ? "Fetching..." : "Fetch"}
</button>
</div>
</div>
)}
{activeTab === "paste" && (
<div className="space-y-3">
<div className="flex gap-2">
<textarea
value={code}
onChange={(e) => setCode(e.target.value)}
placeholder="Paste your diagram JSON here..."
className="tool-input h-32 flex-1"
/>
</div>
</div>
)}
{activeTab === "open" && (
<div className="space-y-3">
<input
ref={fileInputRef}
type="file"
accept=".mmd,.mermaid,.txt"
onChange={handleFileUpload}
className="tool-input"
/>
</div>
)}
</div>
</div>
{/* Main Editor Section */}
<div
className={`bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 overflow-hidden min-w-0 w-full max-w-full ${isFullscreen ? "fixed inset-0 z-[9999] flex flex-col !mt-0" : "mb-6"}`}
>
{/* Header & Controls */}
<div className="px-4 py-3 border-b border-gray-200 dark:border-gray-700 sticky top-0 bg-white dark:bg-gray-800 z-10 flex flex-wrap justify-between items-center gap-3">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white flex items-center gap-2">
<GitGraph className="h-5 w-5 text-blue-600 dark:text-blue-400" />
Diagram Editor
</h3>
<div className="flex items-center gap-2">
<div className="flex border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden bg-white dark:bg-gray-800 shadow-sm">
<button
onClick={() => setViewMode("editor")}
className={`flex items-center gap-2 px-3 py-1.5 text-sm font-medium transition-colors ${viewMode === "editor" ? "bg-blue-50 dark:bg-blue-900/20 text-blue-700 dark:text-blue-300" : "text-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700"}`}
>
<Type className="h-4 w-4" />{" "}
<span className="hidden sm:inline">Code</span>
</button>
<button
onClick={() => setViewMode("split")}
className={`hidden lg:flex items-center gap-2 px-3 py-1.5 text-sm font-medium transition-colors border-l border-gray-200 dark:border-gray-700 ${viewMode === "split" ? "bg-blue-50 dark:bg-blue-900/20 text-blue-700 dark:text-blue-300" : "text-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700"}`}
>
<Columns className="h-4 w-4" />{" "}
<span className="hidden sm:inline">Split</span>
</button>
<button
onClick={() => setViewMode("preview")}
className={`flex items-center gap-2 px-3 py-1.5 text-sm font-medium transition-colors border-l border-gray-200 dark:border-gray-700 ${viewMode === "preview" ? "bg-blue-50 dark:bg-blue-900/20 text-blue-700 dark:text-blue-300" : "text-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700"}`}
>
<Eye className="h-4 w-4" />{" "}
<span className="hidden sm:inline">Preview</span>
</button>
</div>
<button
onClick={() => setIsFullscreen(!isFullscreen)}
className="p-2 text-gray-600 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors"
title={isFullscreen ? "Exit Fullscreen" : "Fullscreen"}
>
{isFullscreen ? (
<Minimize2 className="h-4 w-4" />
) : (
<Maximize2 className="h-4 w-4" />
)}
</button>
</div>
</div>
{/* Split View Content */}
<div
className={`${viewMode === "split" ? "grid grid-cols-1 lg:grid-cols-2" : ""} overflow-hidden min-w-0 w-full flex-1 relative`}
>
{isFullscreen && <FullscreenAdBanner />}
{/* Editor Pane */}
{(viewMode === "editor" || viewMode === "split") && (
<div
className={`${viewMode === "split" ? "border-r border-gray-200 dark:border-gray-700" : ""} ${isFullscreen ? "h-[calc(100vh-60px)] pb-[90px]" : "h-[600px]"} w-full min-w-0 flex flex-col relative`}
>
<CodeMirrorEditor
value={code}
onChange={setCode}
language="json"
placeholder="Write your diagram JSON here..."
showToggle={false}
maxLines={999}
height="100%"
className="flex-1 h-full"
/>
</div>
)}
{/* Preview Pane */}
{(viewMode === "preview" || viewMode === "split") && (
<div
className={`${isFullscreen ? "h-[calc(100vh-60px)] pb-[90px]" : "h-[600px]"} w-full min-w-0 bg-slate-50 dark:bg-slate-900 flex flex-col relative`}
>
{error && (
<div className="absolute inset-0 flex flex-col items-center justify-center p-6 text-center z-10 bg-white/80 dark:bg-gray-900/80 backdrop-blur-sm">
<div className="p-4 bg-red-50 dark:bg-red-900/20 rounded-full mb-3">
<AlertTriangle className="h-8 w-8 text-red-500" />
</div>
<h4 className="text-lg font-medium text-gray-900 dark:text-white mb-2">
Syntax Error
</h4>
<p className="text-sm text-red-600 dark:text-red-400 max-w-md">
{error}
</p>
</div>
)}
<div
className="flex-1 relative overflow-hidden"
ref={reactFlowWrapper}
>
<ReactFlowEditor
initialNodes={graphData.nodes}
initialEdges={graphData.edges}
onGraphChange={handleGraphChange}
/>
</div>
</div>
)}
</div>
</div>
{/* Export Section */}
{code.trim() && (
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 overflow-hidden mt-6">
<div className="px-4 py-3 border-b border-gray-200 dark:border-gray-700">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white flex items-center gap-2">
<Download className="h-5 w-5 text-blue-600 dark:text-blue-400" />
Export Diagram
</h3>
</div>
<div className="p-4 sm:p-6 grid grid-cols-1 sm:grid-cols-2 md:grid-cols-4 gap-4">
<button
onClick={exportPNG}
className="flex flex-col items-center p-4 border border-gray-200 dark:border-gray-700 rounded-lg hover:border-blue-500 hover:bg-blue-50 dark:hover:bg-blue-900/20 transition-all group"
>
<FileImage className="h-6 w-6 text-gray-500 group-hover:text-blue-500 mb-2" />
<span className="font-medium text-gray-900 dark:text-white">
PNG Image
</span>
<span className="text-xs text-gray-500">
High-res rendering
</span>
</button>
<button
onClick={exportJSON}
className="flex flex-col items-center p-4 border border-gray-200 dark:border-gray-700 rounded-lg hover:border-purple-500 hover:bg-purple-50 dark:hover:bg-purple-900/20 transition-all group"
>
<FileCode2 className="h-6 w-6 text-gray-500 group-hover:text-purple-500 mb-2" />
<span className="font-medium text-gray-900 dark:text-white">
JSON Schema
</span>
<span className="text-xs text-gray-500">.json data format</span>
</button>
<button
onClick={exportMermaid}
className="flex flex-col items-center p-4 border border-gray-200 dark:border-gray-700 rounded-lg hover:border-green-500 hover:bg-green-50 dark:hover:bg-green-900/20 transition-all group"
>
<FileCode2 className="h-6 w-6 text-gray-500 group-hover:text-green-500 mb-2" />
<span className="font-medium text-gray-900 dark:text-white">
Mermaid File
</span>
<span className="text-xs text-gray-500">.mmd raw code</span>
</button>
<button
onClick={copyMermaid}
className="flex flex-col items-center p-4 border border-gray-200 dark:border-gray-700 rounded-lg hover:border-orange-500 hover:bg-orange-50 dark:hover:bg-orange-900/20 transition-all group"
>
<Copy className="h-6 w-6 text-gray-500 group-hover:text-orange-500 mb-2" />
<span className="font-medium text-gray-900 dark:text-white">
Copy Mermaid
</span>
<span className="text-xs text-gray-500">To clipboard</span>
</button>
</div>
</div>
)}
<RelatedTools toolId="diagram-editor" />
</ToolLayout>
</>
);
};
export default DiagramEditor;

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,9 @@
/* GitHub-style Markdown Preview Styling */ /* GitHub-style Markdown Preview Styling */
.markdown-preview { .markdown-preview {
color: #24292f; color: #24292f;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Noto Sans", Helvetica, Arial, sans-serif; font-family:
-apple-system, BlinkMacSystemFont, "Segoe UI", "Noto Sans", Helvetica,
Arial, sans-serif;
font-size: 16px; font-size: 16px;
line-height: 1.6; line-height: 1.6;
word-wrap: break-word; word-wrap: break-word;
@@ -85,7 +87,9 @@
font-size: 85%; font-size: 85%;
background-color: rgba(175, 184, 193, 0.2); background-color: rgba(175, 184, 193, 0.2);
border-radius: 6px; border-radius: 6px;
font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace; font-family:
ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas,
"Liberation Mono", monospace;
} }
.dark .markdown-preview code { .dark .markdown-preview code {
@@ -378,3 +382,95 @@
border-radius: 6px; border-radius: 6px;
margin: 16px 0; margin: 16px 0;
} }
/* Tiptap specific styling overrides to match prose */
.tiptap p.is-editor-empty:first-child::before {
color: #adb5bd;
content: attr(data-placeholder);
float: left;
height: 0;
pointer-events: none;
}
.tiptap {
outline: none;
}
.tiptap ul[data-type="taskList"] {
list-style: none;
padding: 0;
}
.tiptap ul[data-type="taskList"] li {
display: flex;
align-items: flex-start;
margin-top: 0;
margin-bottom: 0;
}
.tiptap ul[data-type="taskList"] li > label {
flex: 0 0 auto;
margin-right: 0.5rem;
user-select: none;
margin-top: 0.2rem;
}
.tiptap ul[data-type="taskList"] li > div {
flex: 1 1 auto;
margin: 0;
}
.tiptap ul[data-type="taskList"] li > div > p {
margin: 0;
}
.tiptap p {
margin-top: 0;
margin-bottom: 0.65em;
}
/* Printing logic for PDF export */
@media print {
.tiptap pre,
.markdown-preview pre {
white-space: pre-wrap !important;
word-wrap: break-word !important;
break-inside: avoid !important;
}
.code-block-header {
display: none !important;
}
}
/* Custom Node Views (Code Block) */
.tiptap .code-block-wrapper {
margin-bottom: 0.65em;
border-radius: 6px;
background-color: #0d1117;
overflow: hidden;
}
.tiptap .code-block-wrapper pre {
margin: 0 !important;
padding: 1rem;
border-radius: 0 0 6px 6px;
background: transparent;
}
/* Markdown Content Wrapper Padding Strategies */
.markdown-content-wrapper.is-normal.is-read-mode > .prose {
padding-bottom: 3rem; /* 48px */
}
.markdown-content-wrapper.is-fullscreen.is-read-mode > .prose {
padding-bottom: 12rem; /* 192px (Accounts for 90px banner ad + spacing) */
}
.markdown-content-wrapper.is-normal.is-edit-mode > div {
padding-bottom: 3rem; /* 48px */
}
.markdown-content-wrapper.is-fullscreen.is-edit-mode > div {
padding-bottom: 12rem; /* 192px (Accounts for 90px banner ad + spacing) */
}

View File

@@ -1,34 +1,165 @@
/** @type {import('tailwindcss').Config} */ /** @type {import('tailwindcss').Config} */
module.exports = { module.exports = {
content: [ content: ["./src/**/*.{js,jsx,ts,tsx}"],
"./src/**/*.{js,jsx,ts,tsx}", darkMode: "class", // Enable manual dark mode control via class
],
darkMode: 'class', // Enable manual dark mode control via class
theme: { theme: {
extend: { extend: {
colors: { colors: {
primary: { primary: {
50: '#f0f9ff', 50: "#f0f9ff",
100: '#e0f2fe', 100: "#e0f2fe",
200: '#bae6fd', 200: "#bae6fd",
300: '#7dd3fc', 300: "#7dd3fc",
400: '#38bdf8', 400: "#38bdf8",
500: '#0ea5e9', 500: "#0ea5e9",
600: '#0284c7', 600: "#0284c7",
700: '#0369a1', 700: "#0369a1",
800: '#075985', 800: "#075985",
900: '#0c4a6e', 900: "#0c4a6e",
} },
}, },
fontFamily: { fontFamily: {
mono: ['JetBrains Mono', 'Monaco', 'Cascadia Code', 'Segoe UI Mono', 'Roboto Mono', 'Oxygen Mono', 'Ubuntu Monospace', 'Source Code Pro', 'Fira Code', 'Droid Sans Mono', 'Courier New', 'monospace'], mono: [
"JetBrains Mono",
"Monaco",
"Cascadia Code",
"Segoe UI Mono",
"Roboto Mono",
"Oxygen Mono",
"Ubuntu Monospace",
"Source Code Pro",
"Fira Code",
"Droid Sans Mono",
"Courier New",
"monospace",
],
}, },
maxWidth: { typography: (theme) => ({
'1/4': '25%', DEFAULT: {
'1/2': '50%', css: {
'3/4': '75%', "--tw-prose-body": "#24292f",
} "--tw-prose-headings": "#24292f",
"--tw-prose-lead": "#57606a",
"--tw-prose-links": "#0969da",
"--tw-prose-bold": "#24292f",
"--tw-prose-counters": "#57606a",
"--tw-prose-bullets": "#d0d7de",
"--tw-prose-hr": "#d0d7de",
"--tw-prose-quotes": "#57606a",
"--tw-prose-quote-borders": "#d0d7de",
"--tw-prose-captions": "#57606a",
"--tw-prose-code": "#24292f",
"--tw-prose-pre-code": "#24292f",
"--tw-prose-pre-bg": "#f6f8fa",
"--tw-prose-th-borders": "#d0d7de",
"--tw-prose-td-borders": "#d0d7de",
// Invert colors for dark mode
"--tw-prose-invert-body": "#c9d1d9",
"--tw-prose-invert-headings": "#c9d1d9",
"--tw-prose-invert-lead": "#8b949e",
"--tw-prose-invert-links": "#58a6ff",
"--tw-prose-invert-bold": "#c9d1d9",
"--tw-prose-invert-counters": "#8b949e",
"--tw-prose-invert-bullets": "#30363d",
"--tw-prose-invert-hr": "#21262d",
"--tw-prose-invert-quotes": "#8b949e",
"--tw-prose-invert-quote-borders": "#30363d",
"--tw-prose-invert-captions": "#8b949e",
"--tw-prose-invert-code": "#c9d1d9",
"--tw-prose-invert-pre-code": "#c9d1d9",
"--tw-prose-invert-pre-bg": "#161b22",
"--tw-prose-invert-th-borders": "#30363d",
"--tw-prose-invert-td-borders": "#30363d",
// Adjust margins and sizes (Standardizing to GitHub Markdown / Modern defaults)
maxWidth: "none",
lineHeight: "1.4",
p: {
marginTop: "0",
marginBottom: "0.65em",
},
"h1, h2, h3, h4, h5, h6": {
marginTop: "1em",
marginBottom: "0.65em",
fontWeight: "600",
lineHeight: "1.2",
},
h1: {
fontSize: "2em",
paddingBottom: "0.2em",
borderBottomWidth: "1px",
},
h2: {
fontSize: "1.5em",
paddingBottom: "0.2em",
borderBottomWidth: "1px",
},
h3: { fontSize: "1.25em" },
h4: { fontSize: "1em" },
h5: { fontSize: "0.875em" },
h6: { fontSize: "0.85em", color: "var(--tw-prose-lead)" },
"ul, ol": {
marginTop: "0",
marginBottom: "0.65em",
paddingLeft: "1.5em",
},
li: {
marginTop: "0.15em",
marginBottom: "0.15em",
},
"li > p": {
marginTop: "0",
marginBottom: "0",
},
blockquote: {
marginTop: "0",
marginBottom: "0.65em",
paddingLeft: "1em",
fontStyle: "normal",
borderLeftWidth: "4px",
},
pre: {
marginTop: "0",
marginBottom: "0.65em",
padding: "0.75em",
borderRadius: "6px",
},
code: {
backgroundColor: "rgba(175, 184, 193, 0.2)",
padding: "0.2em 0.4em",
borderRadius: "6px",
fontWeight: "inherit",
},
"code::before": { content: '""' },
"code::after": { content: '""' },
"pre code": {
backgroundColor: "transparent",
padding: "0",
},
table: {
marginTop: "0",
marginBottom: "0.65em",
},
"thead th": {
padding: "0.4em 0.75em",
borderWidth: "1px",
},
"tbody td": {
padding: "0.4em 0.75em",
borderWidth: "1px",
},
hr: {
marginTop: "1em",
marginBottom: "1em",
height: "0.25em",
borderWidth: "0",
backgroundColor: "var(--tw-prose-hr)",
}, },
}, },
plugins: [], },
} }),
},
},
plugins: [require("@tailwindcss/typography")],
};

View File

@@ -1 +0,0 @@
<!DOCTYPE html><html><head><title>Test Page</title></head><body><h1>Test Heading</h1><p>Test paragraph</p><div class="container">Test div</div></body></html>