feat: major update - Markdown Editor, CodeMirror upgrades, SEO improvements, tool cleanup

- Added new Markdown Editor with live preview, GFM support, PDF/HTML/DOCX export
- Upgraded all paste fields to CodeMirror with syntax highlighting and expand/collapse
- Enhanced Object Editor with advanced URL fetching and preview mode
- Improved export views with syntax highlighting in Table/Object editors
- Implemented SEO improvements (FAQ schema, breadcrumbs, internal linking)
- Added Related Tools recommendations component
- Created custom 404 page with tool suggestions
- Consolidated tools: removed JSON, Serialize, CSV-JSON (merged into main editors)
- Updated documentation and cleaned up redundant files
- Updated release notes with user-centric improvements
This commit is contained in:
dwindown
2025-10-22 15:20:22 +07:00
parent 08d345eaeb
commit fb9c944366
40 changed files with 6927 additions and 1714 deletions

36
src/components/AdBlock.js Normal file
View File

@@ -0,0 +1,36 @@
import React, { useEffect } from 'react';
/**
* AdBlock Component - Individual ad unit
* Displays a single Google AdSense ad
*/
const AdBlock = ({ slot, size = '300x250', className = '' }) => {
useEffect(() => {
try {
// Push ad to AdSense queue
(window.adsbygoogle = window.adsbygoogle || []).push({});
} catch (e) {
console.error('AdSense error:', e);
}
}, []);
const [width, height] = size.split('x');
return (
<div className={`bg-gray-100 dark:bg-gray-800 rounded-lg overflow-hidden ${className}`}>
<ins
className="adsbygoogle"
style={{
display: 'block',
width: `${width}px`,
height: `${height}px`
}}
data-ad-client="ca-pub-8644544686212757"
data-ad-slot={slot}
data-ad-format="fixed"
/>
</div>
);
};
export default AdBlock;

View File

@@ -0,0 +1,32 @@
import React from 'react';
import AdBlock from './AdBlock';
/**
* AdColumn Component - Desktop sidebar with 3 ad units
* Hidden on mobile/tablet, visible on desktop (1200px+)
* All ads are 300x250 (Medium Rectangle) to comply with Google AdSense policies
* - Ads must be fully viewable without scrolling
* - No scrollable containers allowed
*/
const AdColumn = ({
slot1 = 'REPLACE_WITH_SLOT_1',
slot2 = 'REPLACE_WITH_SLOT_2',
slot3 = 'REPLACE_WITH_SLOT_3'
}) => {
return (
<aside className="hidden xl:block w-[300px] flex-shrink-0">
<div className="fixed top-20 right-8 w-[300px] space-y-5">
{/* Ad 1: Medium Rectangle */}
<AdBlock slot={slot1} size="300x250" />
{/* Ad 2: Medium Rectangle */}
<AdBlock slot={slot2} size="300x250" />
{/* Ad 3: Medium Rectangle */}
<AdBlock slot={slot3} size="300x250" />
</div>
</aside>
);
};
export default AdColumn;

View File

@@ -0,0 +1,531 @@
import React, { useState, useEffect } from 'react';
import { ChevronDown, ChevronUp, Plus, X, Save, FolderOpen, Braces, Code } from 'lucide-react';
import ProBadge, { ProFeatureLock } from './ProBadge';
import { isFeatureEnabled, getFeatureInfo } from '../config/features';
import CodeMirrorEditor from './CodeMirrorEditor';
import StructuredEditor from './StructuredEditor';
const AdvancedURLFetch = ({
url,
onUrlChange,
onFetch,
fetching,
showAdvanced = false,
onToggleAdvanced,
onUpgrade = null, // Callback for upgrade action
onEditBodyVisually = null // Callback to switch to visual editor
}) => {
const isProFeatureEnabled = isFeatureEnabled('ADVANCED_URL_FETCH');
const featureInfo = getFeatureInfo('ADVANCED_URL_FETCH');
const [method, setMethod] = useState('GET');
const [headers, setHeaders] = useState([{ key: '', value: '', enabled: true }]);
const [body, setBody] = useState('');
const [bodyViewMode, setBodyViewMode] = useState('raw'); // 'raw' or 'visual'
const [bodyStructuredData, setBodyStructuredData] = useState({});
const [queryParams, setQueryParams] = useState([{ key: '', value: '', enabled: true }]);
const [authType, setAuthType] = useState('none');
const [authToken, setAuthToken] = useState('');
const [authUsername, setAuthUsername] = useState('');
const [authPassword, setAuthPassword] = useState('');
const [presetName, setPresetName] = useState('');
const [savedPresets, setSavedPresets] = useState([]);
// Load saved presets from localStorage
useEffect(() => {
const saved = localStorage.getItem('urlFetchPresets');
if (saved) {
try {
setSavedPresets(JSON.parse(saved));
} catch (e) {
console.error('Failed to load presets:', e);
}
}
}, []);
// Add header row
const addHeader = () => {
setHeaders([...headers, { key: '', value: '', enabled: true }]);
};
// Remove header row
const removeHeader = (index) => {
setHeaders(headers.filter((_, i) => i !== index));
};
// Update header
const updateHeader = (index, field, value) => {
const newHeaders = [...headers];
newHeaders[index][field] = value;
setHeaders(newHeaders);
};
// Add query param row
const addQueryParam = () => {
setQueryParams([...queryParams, { key: '', value: '', enabled: true }]);
};
// Remove query param row
const removeQueryParam = (index) => {
setQueryParams(queryParams.filter((_, i) => i !== index));
};
// Update query param
const updateQueryParam = (index, field, value) => {
const newParams = [...queryParams];
newParams[index][field] = value;
setQueryParams(newParams);
};
// Build final URL with query params
const buildFinalUrl = () => {
const baseUrl = url.trim();
const enabledParams = queryParams.filter(p => p.enabled && p.key.trim());
if (enabledParams.length === 0) return baseUrl;
const urlObj = new URL(baseUrl.startsWith('http') ? baseUrl : 'https://' + baseUrl);
enabledParams.forEach(param => {
urlObj.searchParams.append(param.key, param.value);
});
return urlObj.toString();
};
// Build headers object
const buildHeaders = () => {
const headersObj = {};
// Add custom headers
headers
.filter(h => h.enabled && h.key.trim())
.forEach(h => {
headersObj[h.key] = h.value;
});
// Add auth headers
if (authType === 'bearer' && authToken.trim()) {
headersObj['Authorization'] = `Bearer ${authToken}`;
} else if (authType === 'apikey' && authToken.trim()) {
headersObj['X-API-Key'] = authToken;
} else if (authType === 'basic' && authUsername.trim()) {
const credentials = btoa(`${authUsername}:${authPassword}`);
headersObj['Authorization'] = `Basic ${credentials}`;
}
// Add content-type for POST/PUT/PATCH with body
if (['POST', 'PUT', 'PATCH'].includes(method) && body.trim() && !headersObj['Content-Type']) {
headersObj['Content-Type'] = 'application/json';
}
return headersObj;
};
// Handle fetch with advanced options
const handleAdvancedFetch = () => {
const finalUrl = buildFinalUrl();
const finalHeaders = buildHeaders();
const finalBody = ['POST', 'PUT', 'PATCH'].includes(method) && body.trim() ? body : undefined;
onFetch({
url: finalUrl,
method,
headers: finalHeaders,
body: finalBody
});
};
// Save preset
const savePreset = () => {
if (!presetName.trim()) {
alert('Please enter a preset name');
return;
}
const preset = {
name: presetName,
url,
method,
headers: headers.filter(h => h.key.trim()),
body,
queryParams: queryParams.filter(p => p.key.trim()),
authType,
authToken,
authUsername,
// Don't save password for security
timestamp: Date.now()
};
const newPresets = [...savedPresets, preset];
setSavedPresets(newPresets);
localStorage.setItem('urlFetchPresets', JSON.stringify(newPresets));
setPresetName('');
alert('Preset saved!');
};
// Load preset
const loadPreset = (preset) => {
onUrlChange(preset.url);
setMethod(preset.method);
setHeaders(preset.headers.length > 0 ? preset.headers : [{ key: '', value: '', enabled: true }]);
setBody(preset.body || '');
setQueryParams(preset.queryParams.length > 0 ? preset.queryParams : [{ key: '', value: '', enabled: true }]);
setAuthType(preset.authType);
setAuthToken(preset.authToken || '');
setAuthUsername(preset.authUsername || '');
};
// Delete preset
const deletePreset = (index) => {
// eslint-disable-next-line no-restricted-globals
if (confirm('Delete this preset?')) {
const newPresets = savedPresets.filter((_, i) => i !== index);
setSavedPresets(newPresets);
localStorage.setItem('urlFetchPresets', JSON.stringify(newPresets));
}
};
return (
<div className="space-y-3">
{/* Basic URL Input */}
<div className="flex gap-2">
<select
value={method}
onChange={(e) => setMethod(e.target.value)}
className="tool-input w-24"
disabled={!showAdvanced || !isProFeatureEnabled}
title={!isProFeatureEnabled ? "PRO feature - Upgrade to use other methods" : ""}
>
<option value="GET">GET</option>
<option value="POST">POST</option>
<option value="PUT">PUT</option>
<option value="PATCH">PATCH</option>
<option value="DELETE">DELETE</option>
</select>
<div className="relative flex-1">
<input
type="url"
value={url}
onChange={(e) => onUrlChange(e.target.value)}
placeholder="https://api.example.com/endpoint"
className="tool-input w-full"
onKeyPress={(e) => e.key === 'Enter' && (showAdvanced ? handleAdvancedFetch() : onFetch())}
/>
</div>
<button
onClick={showAdvanced ? handleAdvancedFetch : onFetch}
disabled={fetching || !url.trim()}
className="bg-blue-600 hover:bg-blue-700 disabled:bg-gray-400 text-white font-medium px-4 py-2 rounded-md transition-colors flex items-center whitespace-nowrap"
>
{fetching ? 'Fetching...' : 'Fetch Data'}
</button>
</div>
{/* Toggle Advanced */}
{/* Advanced Options Toggle - Hidden for now */}
{false && (
<button
onClick={onToggleAdvanced}
className="text-sm text-blue-600 dark:text-blue-400 hover:underline flex items-center gap-2"
>
{showAdvanced ? <ChevronUp className="h-4 w-4" /> : <ChevronDown className="h-4 w-4" />}
{showAdvanced ? 'Hide' : 'Show'} Advanced Options
{!isProFeatureEnabled && <ProBadge size="xs" />}
</button>
)}
{/* Advanced Options */}
{showAdvanced && (
isProFeatureEnabled ? (
<div className="space-y-4 p-4 bg-gray-50 dark:bg-gray-700/50 rounded-lg border border-gray-200 dark:border-gray-600">
{/* Query Parameters */}
<div>
<div className="flex items-center justify-between mb-2">
<label className="text-sm font-medium text-gray-700 dark:text-gray-300">
Query Parameters
</label>
<button
onClick={addQueryParam}
className="text-xs text-blue-600 dark:text-blue-400 hover:underline flex items-center gap-1"
>
<Plus className="h-3 w-3" /> Add
</button>
</div>
<div className="space-y-2">
{queryParams.map((param, index) => (
<div key={index} className="flex gap-2 items-center">
<input
type="checkbox"
checked={param.enabled}
onChange={(e) => updateQueryParam(index, 'enabled', e.target.checked)}
className="w-4 h-4"
/>
<input
type="text"
value={param.key}
onChange={(e) => updateQueryParam(index, 'key', e.target.value)}
placeholder="key"
className="tool-input flex-1 text-sm"
/>
<input
type="text"
value={param.value}
onChange={(e) => updateQueryParam(index, 'value', e.target.value)}
placeholder="value"
className="tool-input flex-1 text-sm"
/>
<button
onClick={() => removeQueryParam(index)}
className="text-red-600 hover:text-red-700 dark:text-red-400"
>
<X className="h-4 w-4" />
</button>
</div>
))}
</div>
</div>
{/* Headers */}
<div>
<div className="flex items-center justify-between mb-2">
<label className="text-sm font-medium text-gray-700 dark:text-gray-300">
Headers
</label>
<button
onClick={addHeader}
className="text-xs text-blue-600 dark:text-blue-400 hover:underline flex items-center gap-1"
>
<Plus className="h-3 w-3" /> Add
</button>
</div>
<div className="space-y-2">
{headers.map((header, index) => (
<div key={index} className="flex gap-2 items-center">
<input
type="checkbox"
checked={header.enabled}
onChange={(e) => updateHeader(index, 'enabled', e.target.checked)}
className="w-4 h-4"
/>
<input
type="text"
value={header.key}
onChange={(e) => updateHeader(index, 'key', e.target.value)}
placeholder="Header-Name"
className="tool-input flex-1 text-sm"
/>
<input
type="text"
value={header.value}
onChange={(e) => updateHeader(index, 'value', e.target.value)}
placeholder="value"
className="tool-input flex-1 text-sm"
/>
<button
onClick={() => removeHeader(index)}
className="text-red-600 hover:text-red-700 dark:text-red-400"
>
<X className="h-4 w-4" />
</button>
</div>
))}
</div>
</div>
{/* Authentication */}
<div>
<label className="text-sm font-medium text-gray-700 dark:text-gray-300 block mb-2">
Authentication
</label>
<select
value={authType}
onChange={(e) => setAuthType(e.target.value)}
className="tool-input w-full mb-2"
>
<option value="none">No Auth</option>
<option value="bearer">Bearer Token</option>
<option value="apikey">API Key</option>
<option value="basic">Basic Auth</option>
</select>
{authType === 'bearer' && (
<input
type="text"
value={authToken}
onChange={(e) => setAuthToken(e.target.value)}
placeholder="Enter bearer token"
className="tool-input w-full"
/>
)}
{authType === 'apikey' && (
<input
type="text"
value={authToken}
onChange={(e) => setAuthToken(e.target.value)}
placeholder="Enter API key"
className="tool-input w-full"
/>
)}
{authType === 'basic' && (
<div className="space-y-2">
<input
type="text"
value={authUsername}
onChange={(e) => setAuthUsername(e.target.value)}
placeholder="Username"
className="tool-input w-full"
/>
<input
type="password"
value={authPassword}
onChange={(e) => setAuthPassword(e.target.value)}
placeholder="Password"
className="tool-input w-full"
/>
</div>
)}
</div>
{/* Request Body */}
{['POST', 'PUT', 'PATCH'].includes(method) && (
<div>
<div className="flex items-center justify-between mb-2">
<label className="text-sm font-medium text-gray-700 dark:text-gray-300">
Request Body (JSON)
</label>
<div className="flex items-center gap-2">
<button
onClick={() => {
if (bodyViewMode === 'raw') {
// Switch to visual: parse JSON
try {
const data = JSON.parse(body || '{}');
setBodyStructuredData(data);
setBodyViewMode('visual');
} catch (e) {
alert('Invalid JSON. Please fix syntax errors first.');
}
} else {
// Switch to raw: stringify structured data
setBody(JSON.stringify(bodyStructuredData, null, 2));
setBodyViewMode('raw');
}
}}
className="text-xs text-blue-600 dark:text-blue-400 hover:underline flex items-center gap-1"
title={bodyViewMode === 'raw' ? 'Switch to visual editor' : 'Switch to raw JSON'}
>
{bodyViewMode === 'raw' ? (
<>
<Braces className="h-3 w-3" /> Visual Editor
</>
) : (
<>
<Code className="h-3 w-3" /> Raw JSON
</>
)}
</button>
</div>
</div>
{bodyViewMode === 'raw' ? (
<div className="border border-gray-300 dark:border-gray-600 rounded-md overflow-hidden">
<CodeMirrorEditor
value={body}
onChange={setBody}
language="json"
placeholder='{"key": "value"}'
height="150px"
/>
</div>
) : (
<div className="border border-gray-300 dark:border-gray-600 rounded-md p-3 bg-white dark:bg-gray-800 max-h-96 overflow-y-auto">
<StructuredEditor
initialData={bodyStructuredData}
onDataChange={(newData) => {
setBodyStructuredData(newData);
setBody(JSON.stringify(newData, null, 2));
}}
readOnly={false}
/>
</div>
)}
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
💡 Tip: Toggle between raw JSON and visual tree editor for easier editing
</p>
</div>
)}
{/* Save/Load Presets */}
<div className="border-t border-gray-300 dark:border-gray-600 pt-4">
<label className="text-sm font-medium text-gray-700 dark:text-gray-300 block mb-2">
Request Presets
</label>
<div className="flex gap-2 mb-3">
<input
type="text"
value={presetName}
onChange={(e) => setPresetName(e.target.value)}
placeholder="Preset name"
className="tool-input flex-1 text-sm"
/>
<button
onClick={savePreset}
className="bg-green-600 hover:bg-green-700 text-white px-3 py-1 rounded text-sm flex items-center gap-1"
>
<Save className="h-4 w-4" /> Save
</button>
</div>
{savedPresets.length > 0 && (
<div className="space-y-1">
{savedPresets.map((preset, index) => (
<div
key={index}
className="flex items-center justify-between p-2 bg-white dark:bg-gray-800 rounded border border-gray-200 dark:border-gray-600"
>
<button
onClick={() => loadPreset(preset)}
className="flex-1 text-left text-sm text-gray-700 dark:text-gray-300 hover:text-blue-600 dark:hover:text-blue-400 flex items-center gap-2"
>
<FolderOpen className="h-4 w-4" />
<span className="font-medium">{preset.name}</span>
<span className="text-xs text-gray-500">({preset.method})</span>
</button>
<button
onClick={() => deletePreset(index)}
className="text-red-600 hover:text-red-700 dark:text-red-400"
>
<X className="h-4 w-4" />
</button>
</div>
))}
</div>
)}
</div>
</div>
) : (
<ProFeatureLock
featureName={featureInfo?.name || 'Advanced URL Fetch'}
featureDescription={featureInfo?.description || 'Unlock custom HTTP methods, headers, authentication, and request body configuration'}
onUpgrade={onUpgrade}
/>
)
)}
<p className="text-xs text-gray-500 dark:text-gray-400">
{showAdvanced
? 'Configure HTTP method, headers, authentication, and request body for API testing'
: 'Enter any URL that returns JSON data. Examples: Telegram Bot API, JSONPlaceholder, GitHub API, etc.'
}
</p>
</div>
);
};
export default AdvancedURLFetch;

View File

@@ -1,10 +1,12 @@
import React, { useRef, useEffect, useState, useCallback } from 'react';
import { EditorView } from '@codemirror/view';
import { EditorView, keymap } from '@codemirror/view';
import { EditorState } from '@codemirror/state';
import { basicSetup } from 'codemirror';
import { json } from '@codemirror/lang-json';
import { sql } from '@codemirror/lang-sql';
import { markdown } from '@codemirror/lang-markdown';
import { oneDark } from '@codemirror/theme-one-dark';
import { indentWithTab } from '@codemirror/commands';
import { Maximize2, Minimize2 } from 'lucide-react';
const CodeMirrorEditor = ({
@@ -15,7 +17,8 @@ const CodeMirrorEditor = ({
language = 'json',
maxLines = 12,
showToggle = true,
cardRef = null // Reference to the card header for scroll target
cardRef = null, // Reference to the card header for scroll target
height = '350px' // Configurable height, default 350px
}) => {
const editorRef = useRef(null);
const viewRef = useRef(null);
@@ -53,33 +56,42 @@ const CodeMirrorEditor = ({
langExtension = [json()];
} else if (language === 'sql') {
langExtension = [sql()];
} else if (language === 'markdown') {
langExtension = [markdown()];
}
const extensions = [
basicSetup,
...langExtension,
// Enable line wrapping for single-line content
...(isSingleLine ? [EditorView.lineWrapping] : []),
// Enable Tab key to insert spaces (2 spaces for indentation)
keymap.of([indentWithTab]),
// Enable line wrapping for single-line content OR markdown
...(isSingleLine || language === 'markdown' ? [EditorView.lineWrapping] : []),
EditorView.theme({
'&': {
fontSize: '14px',
width: '100%',
maxWidth: '100%',
},
'.cm-content': {
padding: '12px',
maxWidth: '100%',
},
'.cm-focused': {
outline: 'none',
},
'.cm-editor': {
borderRadius: '6px',
maxWidth: '100%',
},
'.cm-scroller': {
overflowY: 'auto',
overflowX: 'auto',
height: '100%',
maxWidth: '100%',
},
'.cm-line': {
wordBreak: isSingleLine ? 'break-word' : 'normal',
wordBreak: isSingleLine || language === 'markdown' ? 'break-word' : 'normal',
}
}),
EditorView.updateListener.of((update) => {
@@ -102,14 +114,22 @@ const CodeMirrorEditor = ({
viewRef.current = view;
// Expose view on the DOM element for toolbar access
if (editorRef.current) {
const cmEditor = editorRef.current.querySelector('.cm-editor');
if (cmEditor) {
cmEditor.cmView = { view };
}
}
// Apply styles immediately after editor creation
setTimeout(() => {
const editorElement = editorRef.current?.querySelector('.cm-editor');
const scrollerElement = editorRef.current?.querySelector('.cm-scroller');
if (editorElement) {
editorElement.style.height = '350px';
editorElement.style.maxHeight = '350px';
editorElement.style.height = height;
editorElement.style.maxHeight = height;
}
if (scrollerElement) {
@@ -128,7 +148,7 @@ const CodeMirrorEditor = ({
}
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isDark, isSingleLine]); // Recreate when theme or line count changes
}, [isDark, isSingleLine, height, language]); // Recreate when theme, line count, height, or language changes
// Apply overflow and height styles
const applyEditorStyles = useCallback(() => {
@@ -142,8 +162,8 @@ const CodeMirrorEditor = ({
editorElement.style.height = 'auto';
editorElement.style.maxHeight = 'none';
} else {
editorElement.style.height = '350px';
editorElement.style.maxHeight = '350px';
editorElement.style.height = height;
editorElement.style.maxHeight = height;
}
}
@@ -152,7 +172,7 @@ const CodeMirrorEditor = ({
scrollerElement.style.overflowY = isExpanded ? 'visible' : 'auto';
scrollerElement.style.height = '100%';
}
}, [isExpanded]);
}, [isExpanded, height]);
// Apply styles on mount, expand/collapse, and content changes
useEffect(() => {
@@ -182,7 +202,7 @@ const CodeMirrorEditor = ({
ref={editorRef}
className={`dewedev-code-mirror border border-gray-300 dark:border-gray-600 rounded-md overflow-hidden ${
isDark ? 'bg-gray-900' : 'bg-white'
} ${isExpanded ? 'h-auto' : 'h-[350px]'}`}
} ${isExpanded ? 'h-auto' : `h-[${height}]`}`}
/>
{showToggle && (
<button

View File

@@ -57,7 +57,7 @@ const Layout = ({ children }) => {
{/* 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">
<div className="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">
<button onClick={() => navigateWithGuard('/')} className="flex items-center group">
<div className="relative">
@@ -250,16 +250,16 @@ const Layout = ({ children }) => {
)}
{/* Main Content */}
<div className="flex flex-1 pt-16">
<div className="flex flex-1 pt-16 min-w-0 w-full max-w-full overflow-x-hidden">
{/* Main Content Area */}
<main className="flex-1 flex flex-col">
<main className="flex-1 flex flex-col min-w-0 w-full max-w-full">
{isToolPage && !isInvoicePreviewPage ? (
<div className="block">
<div className="hidden lg:block fixed top-16 left-0 z-[9999]">
<ToolSidebar navigateWithGuard={navigateWithGuard} />
</div>
<div className="flex-1 flex flex-col min-h-0 pl-0 lg:pl-16">
<div className="flex-1 p-4 sm:p-6 w-full min-w-0 overflow-auto">
<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">
{children}
</div>
</div>

View File

@@ -0,0 +1,66 @@
import React, { useEffect, useState } from 'react';
import { X } from 'lucide-react';
/**
* MobileAdBanner Component - Sticky bottom banner for mobile
* Visible only on mobile/tablet, hidden on desktop
* Includes close button for better UX
*/
const MobileAdBanner = ({ slot = 'REPLACE_WITH_MOBILE_SLOT' }) => {
const [visible, setVisible] = useState(true);
const [closed, setClosed] = useState(false);
useEffect(() => {
// Check if user previously closed the banner (session storage)
const wasClosed = sessionStorage.getItem('mobileAdClosed');
if (wasClosed === 'true') {
setClosed(true);
setVisible(false);
}
}, []);
useEffect(() => {
if (visible && !closed) {
try {
(window.adsbygoogle = window.adsbygoogle || []).push({});
} catch (e) {
console.error('AdSense error:', e);
}
}
}, [visible, closed]);
const handleClose = () => {
setVisible(false);
setClosed(true);
sessionStorage.setItem('mobileAdClosed', 'true');
};
if (!visible || closed) return null;
return (
<div className="xl: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">
<button
onClick={handleClose}
className="absolute top-1 right-1 p-1 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 bg-white dark:bg-gray-800 rounded-full shadow-sm"
aria-label="Close ad"
>
<X className="h-4 w-4" />
</button>
<div className="flex justify-center py-2">
<ins
className="adsbygoogle"
style={{
display: 'inline-block',
width: '320px',
height: '50px'
}}
data-ad-client="ca-pub-8644544686212757"
data-ad-slot={slot}
data-ad-format="fixed"
/>
</div>
</div>
);
};
export default MobileAdBanner;

123
src/components/ProBadge.js Normal file
View File

@@ -0,0 +1,123 @@
import React from 'react';
import { Crown, Lock } from 'lucide-react';
/**
* ProBadge Component
*
* Displays a PRO badge for premium features
* Can be used inline or as a button to trigger upgrade flow
*/
const ProBadge = ({
variant = 'badge', // 'badge' | 'button' | 'inline'
size = 'sm', // 'xs' | 'sm' | 'md' | 'lg'
onClick = null,
showIcon = true,
className = ''
}) => {
const sizeClasses = {
xs: 'text-xs px-1.5 py-0.5',
sm: 'text-xs px-2 py-1',
md: 'text-sm px-3 py-1.5',
lg: 'text-base px-4 py-2'
};
const iconSizes = {
xs: 'h-2.5 w-2.5',
sm: 'h-3 w-3',
md: 'h-4 w-4',
lg: 'h-5 w-5'
};
if (variant === 'inline') {
return (
<span className={`inline-flex items-center gap-1 text-amber-600 dark:text-amber-400 font-semibold ${className}`}>
{showIcon && <Crown className={iconSizes[size]} />}
<span className={sizeClasses[size]}>PRO</span>
</span>
);
}
if (variant === 'button') {
return (
<button
onClick={onClick}
className={`inline-flex items-center gap-1.5 bg-gradient-to-r from-amber-500 to-orange-500 hover:from-amber-600 hover:to-orange-600 text-white font-semibold rounded-full transition-all transform hover:scale-105 ${sizeClasses[size]} ${className}`}
>
{showIcon && <Crown className={iconSizes[size]} />}
Upgrade to PRO
</button>
);
}
// Default badge variant
return (
<span className={`inline-flex items-center gap-1 bg-gradient-to-r from-amber-500 to-orange-500 text-white font-bold rounded-full ${sizeClasses[size]} ${className}`}>
{showIcon && <Crown className={iconSizes[size]} />}
PRO
</span>
);
};
/**
* ProFeatureLock Component
*
* Displays a locked feature message with upgrade prompt
*/
export const ProFeatureLock = ({
featureName,
featureDescription,
onUpgrade = null,
compact = false
}) => {
const handleUpgrade = () => {
if (onUpgrade) {
onUpgrade();
} else {
// Default: scroll to top and show upgrade info
window.scrollTo({ top: 0, behavior: 'smooth' });
alert('Upgrade to PRO to unlock this feature!\n\nPRO features will be available soon.');
}
};
if (compact) {
return (
<div className="flex items-center gap-2 p-2 bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-700 rounded-lg">
<Lock className="h-4 w-4 text-amber-600 dark:text-amber-400 flex-shrink-0" />
<span className="text-sm text-amber-800 dark:text-amber-300 flex-1">
<ProBadge variant="inline" size="xs" showIcon={false} /> feature
</span>
<button
onClick={handleUpgrade}
className="text-xs text-amber-700 dark:text-amber-300 hover:underline font-medium whitespace-nowrap"
>
Upgrade
</button>
</div>
);
}
return (
<div className="p-4 bg-gradient-to-br from-amber-50 to-orange-50 dark:from-amber-900/20 dark:to-orange-900/20 border-2 border-amber-200 dark:border-amber-700 rounded-lg">
<div className="flex items-start gap-3">
<div className="p-2 bg-amber-100 dark:bg-amber-800/50 rounded-lg">
<Lock className="h-5 w-5 text-amber-600 dark:text-amber-400" />
</div>
<div className="flex-1">
<div className="flex items-center gap-2 mb-1">
<h4 className="font-semibold text-gray-900 dark:text-gray-100">
{featureName}
</h4>
<ProBadge size="sm" />
</div>
<p className="text-sm text-gray-600 dark:text-gray-400 mb-3">
{featureDescription}
</p>
<ProBadge variant="button" size="md" onClick={handleUpgrade} />
</div>
</div>
</div>
);
};
export default ProBadge;

View File

@@ -0,0 +1,96 @@
import React from 'react';
import { Link } from 'react-router-dom';
import { ArrowRight } from 'lucide-react';
/**
* Related Tools Component
* Shows related tools at the bottom of each tool page for internal linking
*/
const RELATED_TOOLS = {
'markdown-editor': [
{ name: 'Code Beautifier', path: '/beautifier', desc: 'Format and beautify your code' },
{ name: 'Text Length Checker', path: '/text-length', desc: 'Count words and characters' },
{ name: 'Diff Tool', path: '/diff', desc: 'Compare text differences' }
],
'object-editor': [
{ name: 'Table Editor', path: '/table-editor', desc: 'Edit JSON as table' },
{ name: 'Code Beautifier', path: '/beautifier', desc: 'Format & beautify code' },
{ name: 'Markdown Editor', path: '/markdown-editor', desc: 'Write documentation' }
],
'table-editor': [
{ name: 'Object Editor', path: '/object-editor', desc: 'Edit JSON visually' },
{ name: 'Code Beautifier', path: '/beautifier', desc: 'Format & beautify code' },
{ name: 'Markdown Editor', path: '/markdown-editor', desc: 'Create documentation' }
],
'invoice-editor': [
{ name: 'Markdown Editor', path: '/markdown-editor', desc: 'Create documentation' },
{ name: 'Table Editor', path: '/table-editor', desc: 'Manage data tables' },
{ name: 'Object Editor', path: '/object-editor', desc: 'Edit JSON data' }
],
'beautifier': [
{ name: 'Markdown Editor', path: '/markdown-editor', desc: 'Write documentation' },
{ name: 'Diff Tool', path: '/diff', desc: 'Compare code changes' },
{ name: 'Object Editor', path: '/object-editor', desc: 'Edit JSON visually' }
],
'diff': [
{ name: 'Beautifier', path: '/beautifier', desc: 'Format code first' },
{ name: 'Markdown Editor', path: '/markdown-editor', desc: 'Document changes' },
{ name: 'Text Length', path: '/text-length', desc: 'Analyze text' }
],
'text-length': [
{ name: 'Markdown Editor', path: '/markdown-editor', desc: 'Write content' },
{ name: 'Diff Tool', path: '/diff', desc: 'Compare texts' },
{ name: 'Beautifier', path: '/beautifier', desc: 'Format code' }
],
'url': [
{ name: 'Base64 Encoder', path: '/base64', desc: 'Encode/decode data' },
{ name: 'Code Beautifier', path: '/beautifier', desc: 'Format code' },
{ name: 'Object Editor', path: '/object-editor', desc: 'Edit JSON data' }
],
'base64': [
{ name: 'URL Encoder', path: '/url', desc: 'Encode URLs' },
{ name: 'Object Editor', path: '/object-editor', desc: 'Edit JSON & PHP data' },
{ name: 'Code Beautifier', path: '/beautifier', desc: 'Format code' }
]
};
const RelatedTools = ({ toolId }) => {
const relatedTools = RELATED_TOOLS[toolId];
if (!relatedTools || relatedTools.length === 0) {
return null;
}
return (
<div className="mt-8 bg-gradient-to-br from-blue-50 to-indigo-50 dark:from-gray-800 dark:to-gray-900 rounded-lg border border-blue-100 dark:border-gray-700 p-6">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-4 flex items-center gap-2">
<span className="text-blue-600 dark:text-blue-400"></span>
You Might Also Like
</h3>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{relatedTools.map((tool) => (
<Link
key={tool.path}
to={tool.path}
className="group bg-white dark:bg-gray-800 rounded-lg p-4 border border-gray-200 dark:border-gray-700 hover:border-blue-500 dark:hover:border-blue-500 hover:shadow-md transition-all duration-200"
>
<div className="flex items-start justify-between">
<div className="flex-1">
<h4 className="font-medium text-gray-900 dark:text-white group-hover:text-blue-600 dark:group-hover:text-blue-400 transition-colors">
{tool.name}
</h4>
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">
{tool.desc}
</p>
</div>
<ArrowRight className="h-5 w-5 text-gray-400 group-hover:text-blue-600 dark:group-hover:text-blue-400 group-hover:translate-x-1 transition-all flex-shrink-0 ml-2" />
</div>
</Link>
))}
</div>
</div>
);
};
export default RelatedTools;

View File

@@ -1,5 +1,6 @@
import React from 'react';
import { Helmet } from 'react-helmet-async';
import TOOL_FAQS from '../data/faqs';
const SEO = ({
title,
@@ -7,7 +8,8 @@ const SEO = ({
keywords,
path = '/',
type = 'website',
image = 'https://dewe.dev/og-image.png'
image = 'https://dewe.dev/og-image.png',
toolId = null
}) => {
const siteUrl = 'https://dewe.dev';
const fullUrl = `${siteUrl}${path}`;
@@ -17,6 +19,47 @@ const SEO = ({
const defaultKeywords = 'developer tools, json editor, csv converter, base64 encoder, url encoder, code beautifier, diff tool, web developer utilities, online tools';
const metaKeywords = keywords || defaultKeywords;
// Get FAQ data for this tool
const faqs = toolId && TOOL_FAQS[toolId] ? TOOL_FAQS[toolId] : null;
// Generate breadcrumb schema
const breadcrumbSchema = {
"@context": "https://schema.org",
"@type": "BreadcrumbList",
"itemListElement": [
{
"@type": "ListItem",
"position": 1,
"name": "Home",
"item": siteUrl
}
]
};
// Add current page to breadcrumb if not homepage
if (path !== '/') {
breadcrumbSchema.itemListElement.push({
"@type": "ListItem",
"position": 2,
"name": title || "Page",
"item": fullUrl
});
}
// Generate FAQ schema if FAQs exist
const faqSchema = faqs ? {
"@context": "https://schema.org",
"@type": "FAQPage",
"mainEntity": faqs.map(faq => ({
"@type": "Question",
"name": faq.question,
"acceptedAnswer": {
"@type": "Answer",
"text": faq.answer
}
}))
} : null;
return (
<Helmet>
{/* Basic Meta Tags */}
@@ -46,7 +89,7 @@ const SEO = ({
<meta name="author" content="Developer Tools" />
<meta name="language" content="English" />
{/* JSON-LD Structured Data */}
{/* JSON-LD Structured Data - WebApplication */}
<script type="application/ld+json">
{JSON.stringify({
"@context": "https://schema.org",
@@ -68,6 +111,18 @@ const SEO = ({
}
})}
</script>
{/* JSON-LD Structured Data - Breadcrumb */}
<script type="application/ld+json">
{JSON.stringify(breadcrumbSchema)}
</script>
{/* JSON-LD Structured Data - FAQ (if available) */}
{faqSchema && (
<script type="application/ld+json">
{JSON.stringify(faqSchema)}
</script>
)}
</Helmet>
);
};

View File

@@ -8,7 +8,8 @@ const StructuredEditor = ({ onDataChange, initialData = {}, readOnly: readOnlyPr
const isInternalUpdate = useRef(false);
const [nestedEditModal, setNestedEditModal] = useState(null); // { path, value, type: 'json' | 'serialized' }
const [nestedData, setNestedData] = useState(null);
const [editMode, setEditMode] = useState(false); // Internal edit mode state
// Start in edit mode if readOnly is false
const [editMode, setEditMode] = useState(readOnlyProp === false);
// Use internal editMode if readOnlyProp is not explicitly set, otherwise use prop
const readOnly = readOnlyProp !== false ? readOnlyProp : !editMode;

View File

@@ -1,29 +1,45 @@
import React from 'react';
import AdColumn from './AdColumn';
import MobileAdBanner from './MobileAdBanner';
const ToolLayout = ({ title, description, children, icon: Icon }) => {
return (
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 w-full" style={{ maxWidth: 'min(80rem, calc(100vw - 2rem))' }}>
{/* Header */}
<div className="mb-6 sm:mb-8">
<div className="flex items-center space-x-2 sm:space-x-3 mb-2">
{Icon && <Icon className="h-6 w-6 sm:h-8 sm:w-8 text-primary-600 flex-shrink-0" />}
<h1 className="text-2xl sm:text-3xl font-bold text-gray-900 dark:text-white truncate">
{title}
</h1>
<>
<div className="w-full max-w-full px-0 sm:px-6 lg:px-8 flex gap-5 min-w-0">
{/* Main Content */}
<div className="flex-1 min-w-0 max-w-full">
{/* Header */}
<div className="mb-6 sm:mb-8 min-w-0">
<div className="flex items-center space-x-2 sm:space-x-3 mb-2 min-w-0">
{Icon && <Icon className="h-6 w-6 sm:h-8 sm:w-8 text-primary-600 flex-shrink-0" />}
<h1 className="text-2xl sm:text-3xl font-bold text-gray-900 dark:text-white truncate min-w-0">
{title}
</h1>
</div>
{description && (
<p className="text-gray-600 dark:text-gray-300 text-base sm:text-lg break-words">
{description}
</p>
)}
</div>
{/* Tool Content */}
<div className="space-y-4 sm:space-y-6 w-full max-w-full min-w-0">
{children}
</div>
</div>
{description && (
<p className="text-gray-600 dark:text-gray-300 text-base sm:text-lg">
{description}
</p>
)}
{/* Desktop Ad Column - Hidden on mobile */}
<AdColumn />
</div>
{/* Tool Content */}
<div className="space-y-4 sm:space-y-6 w-full">
{children}
</div>
</div>
{/* Mobile Ad Banner - Hidden on desktop */}
<MobileAdBanner />
{/* Add padding to bottom on mobile to prevent content overlap with sticky ad */}
<div className="xl:hidden h-16" />
</>
);
};