diff --git a/package-lock.json b/package-lock.json index fe38277..91d3fb7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,7 @@ "@radix-ui/react-dialog": "^1.0.5", "@radix-ui/react-dropdown-menu": "^2.0.6", "@radix-ui/react-slot": "^1.0.2", + "@radix-ui/react-toast": "^1.2.14", "class-variance-authority": "^0.7.0", "clsx": "^2.0.0", "html2canvas": "^1.4.1", @@ -2104,6 +2105,40 @@ } } }, + "node_modules/@radix-ui/react-toast": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@radix-ui/react-toast/-/react-toast-1.2.14.tgz", + "integrity": "sha512-nAP5FBxBJGQ/YfUB+r+O6USFVkWq3gAInkxyEnmvEV5jtSbfDhfa4hwX8CraCnbjMLsE7XSf/K75l9xXY7joWg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.2", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-dismissable-layer": "1.1.10", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.4", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-layout-effect": "1.1.1", + "@radix-ui/react-visually-hidden": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-use-callback-ref": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz", @@ -2225,6 +2260,29 @@ } } }, + "node_modules/@radix-ui/react-visually-hidden": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.2.3.tgz", + "integrity": "sha512-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/rect": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.1.tgz", diff --git a/package.json b/package.json index 5169a31..90a39a5 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "@radix-ui/react-dialog": "^1.0.5", "@radix-ui/react-dropdown-menu": "^2.0.6", "@radix-ui/react-slot": "^1.0.2", + "@radix-ui/react-toast": "^1.2.14", "class-variance-authority": "^0.7.0", "clsx": "^2.0.0", "html2canvas": "^1.4.1", diff --git a/src/App.tsx b/src/App.tsx index a98875a..54a5d0d 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,10 +1,12 @@ -import React, { useState } from 'react'; +import { useState } from 'react'; import Sidebar from './components/Sidebar'; import JsonVisualizer from './components/JsonVisualizer'; import DiffChecker from './components/DiffChecker'; import MermaidVisualizer from './components/MermaidVisualizer'; +import EncodeDecodeTools from './components/EncodeDecodeTools'; import { ThemeProvider } from './hooks/useTheme'; import { UtilityTab } from './types'; +import { ToastProvider } from './components/ui/Toast'; const utilities: UtilityTab[] = [ { @@ -25,6 +27,12 @@ const utilities: UtilityTab[] = [ icon: 'workflow', component: MermaidVisualizer, }, + { + id: 'encode-decode', + name: 'Encode / Decode', + icon: 'code', + component: EncodeDecodeTools, + }, ]; function App() { @@ -36,27 +44,29 @@ function App() { return ( -
- {/* Background Effects */} -
-
- - {/* Sidebar */} - setSidebarCollapsed(!sidebarCollapsed)} - /> - - {/* Main Content */} -
-
- + +
+ {/* Background Effects */} +
+
+ + {/* Sidebar */} + setSidebarCollapsed(!sidebarCollapsed)} + /> + + {/* Main Content */} +
+
+ +
-
+ ); } diff --git a/src/components/EncodeDecodeTools.tsx b/src/components/EncodeDecodeTools.tsx new file mode 100644 index 0000000..4ca1e3d --- /dev/null +++ b/src/components/EncodeDecodeTools.tsx @@ -0,0 +1,586 @@ +import { useState, useEffect, useCallback, useMemo } from 'react'; +import { Copy, RefreshCw, ArrowRight, ArrowLeft, Info, Shuffle, Sparkles, Code, Lock, Globe, Hash, Binary, Shield, FileText } from 'lucide-react'; +import TextArea from './ui/TextArea'; +import { useToast } from '@/hooks/useToast'; +import { ToastComponent } from './ui/Toast'; + +const EncodeDecodeTools = () => { + const { toasts, addToast, closeToast } = useToast(); + const [input, setInput] = useState(''); + const [output, setOutput] = useState(''); + const [activeTab, setActiveTab] = useState('base64'); + const [isProcessing, setIsProcessing] = useState(false); + + + const convertFunctions = useMemo(() => ({ + base64: { + encode: (str: string) => btoa(unescape(encodeURIComponent(str))), + decode: (str: string) => { + try { + return decodeURIComponent(escape(atob(str.replace(/\s/g, '')))); + } catch (error) { + throw new Error('Invalid Base64 string'); + } + }, + description: 'Base64 encoding is commonly used for encoding binary data in text format', + isValidEncoded: (str: string) => { + const cleaned = str.replace(/\s/g, ''); + // Only consider it valid Base64 if it's longer than 8 characters + // and properly formatted + if (cleaned.length < 8 || cleaned.length % 4 !== 0) { + return false; + } + if (!/^[A-Za-z0-9+/]*={0,2}$/.test(cleaned)) { + return false; + } + // Additional check: try to decode it + try { + atob(cleaned); + return true; + } catch { + return false; + } + }, + example: { input: 'Hello World!', output: 'SGVsbG8gV29ybGQh' }, + icon: Code + }, + url: { + encode: (str: string) => encodeURIComponent(str), + decode: (str: string) => { + try { + return decodeURIComponent(str); + } catch (error) { + throw new Error('Invalid URL encoded string'); + } + }, + description: 'URL encoding replaces unsafe ASCII characters with % followed by hex digits', + isValidEncoded: (str: string) => { + // Only auto-decode if it has URL encoded characters and is at least somewhat substantial + return /%[0-9A-Fa-f]{2}/.test(str) && str.length > 3; + }, + example: { input: 'Hello World!', output: 'Hello%20World%21' }, + icon: Globe + }, + html: { + encode: (str: string) => { + const div = document.createElement('div'); + div.textContent = str; + return div.innerHTML; + }, + decode: (str: string) => { + try { + const div = document.createElement('div'); + div.innerHTML = str; + return div.textContent || ''; + } catch (error) { + throw new Error('Invalid HTML entities'); + } + }, + description: 'HTML encoding converts special characters to HTML entities', + isValidEncoded: (str: string) => { + // Only auto-decode if it has HTML entities and is substantial + return /&[a-zA-Z]+;|&#\d+;|&#x[0-9A-Fa-f]+;/.test(str) && str.length > 4; + }, + example: { input: '', output: '<script>alert("hi")</script>' }, + icon: Code + }, + hex: { + encode: (str: string) => { + return str.split('').map(c => c.charCodeAt(0).toString(16).padStart(2, '0')).join(''); + }, + decode: (str: string) => { + const cleaned = str.replace(/[\s\n]/g, ''); + if (cleaned.length % 2 !== 0) throw new Error('Invalid hex string length'); + try { + return cleaned.match(/.{1,2}/g) + ?.map(byte => String.fromCharCode(parseInt(byte, 16))) + .join('') || ''; + } catch (error) { + throw new Error('Invalid hex characters'); + } + }, + description: 'Hexadecimal encoding converts each character to its hex representation', + isValidEncoded: (str: string) => { + const cleaned = str.replace(/\s/g, ''); + // Must be all hex chars, even length, and at least 4 chars long + return /^[0-9A-Fa-f]+$/.test(cleaned) && + cleaned.length >= 4 && + cleaned.length % 2 === 0; + }, + example: { input: 'Hello', output: '48656c6c6f' }, + icon: Hash + }, + binary: { + encode: (str: string) => { + return str.split('').map(char => char.charCodeAt(0).toString(2).padStart(8, '0')).join(' '); + }, + decode: (str: string) => { + const cleaned = str.replace(/[^01]/g, ''); + if (cleaned.length % 8 !== 0) throw new Error('Invalid binary string length'); + try { + return cleaned.match(/.{1,8}/g) + ?.map(byte => String.fromCharCode(parseInt(byte, 2))) + .join('') || ''; + } catch (error) { + throw new Error('Invalid binary characters'); + } + }, + description: 'Binary encoding converts each character to its 8-bit binary representation', + isValidEncoded: (str: string) => { + const cleaned = str.replace(/\s/g, ''); + // Must be all binary chars, divisible by 8, and at least 8 chars long + return /^[01]+$/.test(cleaned) && + cleaned.length >= 8 && + cleaned.length % 8 === 0; + }, + example: { input: 'Hi', output: '01001000 01101001' }, + icon: Binary + }, + jwt: { + encode: (str: string) => { + throw new Error('JWT encoding not supported - paste a JWT token to decode it'); + }, + decode: (str: string) => { + try { + const parts = str.split('.'); + if (parts.length !== 3) throw new Error('Invalid JWT format - must have 3 parts separated by dots'); + + const [headerB64, payloadB64, signature] = parts; + + // Handle URL-safe Base64 padding + const padHeader = headerB64.replace(/-/g, '+').replace(/_/g, '/').padEnd(headerB64.length + (4 - headerB64.length % 4) % 4, '='); + const padPayload = payloadB64.replace(/-/g, '+').replace(/_/g, '/').padEnd(payloadB64.length + (4 - payloadB64.length % 4) % 4, '='); + + const header = JSON.parse(atob(padHeader)); + const payload = JSON.parse(atob(padPayload)); + + return JSON.stringify({ + header, + payload, + signature: signature || 'No signature' + }, null, 2); + } catch (error) { + throw new Error('Invalid JWT token format or corrupted data'); + } + }, + description: 'JSON Web Token decoder - paste a JWT to see its header and payload', + isValidEncoded: (str: string) => { + const parts = str.split('.'); + // Must have exactly 3 parts, each part must be URL-safe Base64, and reasonable length + return parts.length === 3 && + parts.every(part => part.length > 0 && /^[A-Za-z0-9_-]+$/.test(part)) && + str.length > 50; // JWTs are typically much longer + }, + example: { input: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c', output: 'Decoded JWT content' }, + icon: Shield + } + }), []); + + const performEncode = useCallback((text: string, tab: string) => { + try { + setIsProcessing(true); + const result = convertFunctions[tab as keyof typeof convertFunctions].encode(text); + setOutput(result); + // addToast('Text encoded successfully', 'success'); + } catch (error) { + const message = error instanceof Error ? error.message : 'Encoding failed'; + // addToast(`Error: ${message}`, 'error'); + console.error(error); + } finally { + setIsProcessing(false); + } + }, [convertFunctions]); + + const performDecode = useCallback((text: string, tab: string) => { + try { + setIsProcessing(true); + const { decode, isValidEncoded } = convertFunctions[tab as keyof typeof convertFunctions]; + + if (isValidEncoded && !isValidEncoded(text.trim())) { + throw new Error(`Input doesn't appear to be valid encoded text`); + } + + const result = decode(text); + setOutput(result); + addToast('Text decoded successfully', 'success'); + } catch (error) { + const message = error instanceof Error ? error.message : 'Decoding failed'; + addToast(`Error: ${message}`, 'error'); + console.error(error); + } finally { + setIsProcessing(false); + } + }, [convertFunctions]); + + const handleAutoProcess = useCallback(() => { + if (!input.trim()) return; + const { isValidEncoded } = convertFunctions[activeTab as keyof typeof convertFunctions]; + const shouldDecode = isValidEncoded ? isValidEncoded(input.trim()) : false; + + if (shouldDecode) { + performDecode(input, activeTab); + } else { + performEncode(input, activeTab); + } + }, [input, activeTab, performDecode, performEncode, convertFunctions]); + + useEffect(() => { + if (input.trim() && activeTab !== 'jwt') { + const timer = setTimeout(() => { + handleAutoProcess(); + }, 300); + return () => clearTimeout(timer); + } else if (!input.trim()) { + setOutput(''); + } + }, [input, activeTab, handleAutoProcess]); + + const handleClearInput = () => { + setInput(''); + setOutput(''); + }; + + const handleClearOutput = () => setOutput(''); + + const handleCopyInput = async () => { + try { + await navigator.clipboard.writeText(input); + addToast('Input copied to clipboard', 'success'); + } catch (err) { + addToast('Failed to copy to clipboard', 'error'); + } + }; + + const handleCopyOutput = async () => { + try { + await navigator.clipboard.writeText(output); + addToast('Output copied to clipboard', 'success'); + } catch (err) { + addToast('Failed to copy to clipboard', 'error'); + } + }; + + const handleEncode = () => { + if (!input.trim()) { + addToast('Please enter some text to encode', 'warning'); + return; + } + performEncode(input, activeTab); + }; + + const handleDecode = () => { + if (!input.trim()) { + addToast('Please enter some text to decode', 'warning'); + return; + } + performDecode(input, activeTab); + }; + + const handleSwap = () => { + const temp = input; + setInput(output); + setOutput(temp); + addToast('Input and output swapped', 'success'); + }; + + const loadExample = () => { + const example = convertFunctions[activeTab as keyof typeof convertFunctions].example; + if (example) { + setInput(example.input); + addToast('Example loaded', 'success'); + } + }; + + const tabConfigs = [ + { id: 'base64', name: 'Base64', color: 'from-blue-500 to-blue-600', hoverColor: 'hover:from-blue-600 hover:to-blue-700', ring: 'ring-blue-500' }, + { id: 'url', name: 'URL', color: 'from-emerald-500 to-emerald-600', hoverColor: 'hover:from-emerald-600 hover:to-emerald-700', ring: 'ring-emerald-500' }, + { id: 'html', name: 'HTML', color: 'from-purple-500 to-purple-600', hoverColor: 'hover:from-purple-600 hover:to-purple-700', ring: 'ring-purple-500' }, + { id: 'hex', name: 'Hex', color: 'from-amber-500 to-amber-600', hoverColor: 'hover:from-amber-600 hover:to-amber-700', ring: 'ring-amber-500' }, + { id: 'binary', name: 'Binary', color: 'from-pink-500 to-pink-600', hoverColor: 'hover:from-pink-600 hover:to-pink-700', ring: 'ring-pink-500' }, + { id: 'jwt', name: 'JWT', color: 'from-orange-500 to-orange-600', hoverColor: 'hover:from-orange-600 hover:to-orange-700', ring: 'ring-orange-500' }, + ]; + + const currentConfig = convertFunctions[activeTab as keyof typeof convertFunctions]; + const currentTabConfig = tabConfigs.find(t => t.id === activeTab)!; + const isJWT = activeTab === 'jwt'; + const hasExample = currentConfig.example; + const IconComponent = currentConfig.icon; + + const inputLooksEncoded = currentConfig.isValidEncoded ? currentConfig.isValidEncoded(input.trim()) : false; + + return ( +
+
+ {/* Header */} +
+
+
+

Encode / Decode Tool

+

Transform text between different encoding formats

+
+
+ + + {input && output && ( + + )} +
+
+
+
+ {/* Tab Selection */} +
+ {tabConfigs.map((tab) => { + const TabIcon = convertFunctions[tab.id as keyof typeof convertFunctions].icon; + return ( + + ); + })} +
+ + {/* Description */} + {currentConfig.description && ( +
+
+
+ +
+
+

+ {currentConfig.description} +

+ {hasExample && ( +
+ Example: "{currentConfig.example.input}" → "{currentConfig.example.output.length > 15 ? currentConfig.example.output.substring(0, 15) + '...' : currentConfig.example.output}" +
+ )} +
+
+
+ )} + + {/* Main Content - Input/Output */} +
+ {/* Input Section */} +
+
+
+

Input

+ {input && inputLooksEncoded && ( +
+ + Encoded +
+ )} +
+
+ {input && ( + <> + + + + )} +
+
+
+