diff --git a/index.html b/index.html index 6af2b51..ec52a29 100644 --- a/index.html +++ b/index.html @@ -4,7 +4,50 @@ - multi-converter + Multi-Converter — Free, Client-Side Media & Developer Utilities + + + + + + + + + + + + + + + + + + + + + + + + + + + +
diff --git a/package-lock.json b/package-lock.json index 6bb9bd2..380486d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "0.0.0", "dependencies": { "js-yaml": "^5.4.3", + "jszip": "^3.10.2", "lucide-react": "^1.52.0", "pdf-lib": "^1.17.1", "react": "^19.2.8", @@ -17,6 +18,7 @@ "devDependencies": { "@tailwindcss/vite": "^4.3.3", "@types/js-yaml": "^4.0.9", + "@types/jszip": "^3.4.0", "@types/node": "^24.13.3", "@types/react": "^19.2.18", "@types/react-dom": "^19.2.7", @@ -1230,6 +1232,16 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/jszip": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/@types/jszip/-/jszip-3.4.0.tgz", + "integrity": "sha512-GFHqtQQP3R4NNuvZH3hNCYD0NbyBZ42bkN7kO3NDrU/SnvIZWMS8Bp38XCsRKBT5BXvgm0y1zqpZWp/ZkRzBzg==", + "dev": true, + "license": "MIT", + "dependencies": { + "jszip": "*" + } + }, "node_modules/@types/node": { "version": "24.19.1", "resolved": "https://registry.npmjs.org/@types/node/-/node-24.19.1.tgz", @@ -1298,6 +1310,12 @@ "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", "license": "Python-2.0" }, + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", + "license": "MIT" + }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", @@ -1369,6 +1387,24 @@ "dev": true, "license": "ISC" }, + "node_modules/immediate": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", + "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==", + "license": "MIT" + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "license": "MIT" + }, "node_modules/jiti": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", @@ -1401,6 +1437,27 @@ "js-yaml": "bin/js-yaml.mjs" } }, + "node_modules/jszip": { + "version": "3.10.2", + "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.2.tgz", + "integrity": "sha512-3l+rb15IOWtUhU0H5MFqES/T6Kh7abYwjosBey/vD6hDt8zoEffkSC5Ws5SGtgVw3gBx2NEbhTeSW1+kWkpyTQ==", + "license": "(MIT OR GPL-3.0-or-later)", + "dependencies": { + "lie": "~3.3.0", + "pako": "~1.0.2", + "readable-stream": "~2.3.6", + "setimmediate": "^1.0.5" + } + }, + "node_modules/lie": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz", + "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==", + "license": "MIT", + "dependencies": { + "immediate": "~3.0.5" + } + }, "node_modules/lightningcss": { "version": "1.33.0", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", @@ -1823,6 +1880,12 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "license": "MIT" + }, "node_modules/react": { "version": "19.3.0", "resolved": "https://registry.npmjs.org/react/-/react-19.3.0.tgz", @@ -1845,6 +1908,21 @@ "react": "^19.3.0" } }, + "node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, "node_modules/rolldown": { "version": "1.2.12", "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.12.tgz", @@ -1879,12 +1957,24 @@ "@rolldown/binding-win32-x64-msvc": "1.2.12" } }, + "node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, "node_modules/scheduler": { "version": "0.28.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.28.0.tgz", "integrity": "sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw==", "license": "MIT" }, + "node_modules/setimmediate": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz", + "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==", + "license": "MIT" + }, "node_modules/source-map-js": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", @@ -1895,6 +1985,15 @@ "node": ">=0.10.0" } }, + "node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, "node_modules/tailwindcss": { "version": "4.3.3", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.3.tgz", @@ -1960,6 +2059,12 @@ "dev": true, "license": "MIT" }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, "node_modules/vite": { "version": "8.3.3", "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.3.tgz", diff --git a/package.json b/package.json index 497a666..eb1bcfb 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,7 @@ }, "dependencies": { "js-yaml": "^5.4.3", + "jszip": "^3.10.2", "lucide-react": "^1.52.0", "pdf-lib": "^1.17.1", "react": "^19.2.8", @@ -19,6 +20,7 @@ "devDependencies": { "@tailwindcss/vite": "^4.3.3", "@types/js-yaml": "^4.0.9", + "@types/jszip": "^3.4.0", "@types/node": "^24.13.3", "@types/react": "^19.2.18", "@types/react-dom": "^19.2.7", diff --git a/public/manifest.json b/public/manifest.json new file mode 100644 index 0000000..be179e6 --- /dev/null +++ b/public/manifest.json @@ -0,0 +1,21 @@ +{ + "name": "Multi-Converter — Client-Side Utilities", + "short_name": "Multi-Converter", + "description": "Fast, 100% private in-browser conversions for Images, PDFs, JSON, YAML, Base64, and UUIDs. Zero server uploads.", + "start_url": "/", + "id": "/", + "scope": "/", + "display": "standalone", + "orientation": "any", + "background_color": "#0a0a0a", + "theme_color": "#0a0a0a", + "categories": ["utilities", "productivity", "developer tools"], + "icons": [ + { + "src": "/favicon.svg", + "sizes": "48x48 72x72 96x96 128x128 192x192 256x256 512x512", + "type": "image/svg+xml", + "purpose": "any maskable" + } + ] +} diff --git a/public/sw.js b/public/sw.js new file mode 100644 index 0000000..7daf6ea --- /dev/null +++ b/public/sw.js @@ -0,0 +1,102 @@ +const CACHE_NAME = 'mc-cache-v2'; +const PRECACHE_ASSETS = [ + '/', + '/index.html', + '/manifest.json', + '/favicon.svg', +]; + +// Domains that should NEVER be cached or block offline usage (e.g. ad networks) +const IGNORE_HOSTS = [ + 'media.ethicalads.io', + 'server.ethicalads.io', + 'pagead2.googlesyndication.com', + 'googleads.g.doubleclick.net', +]; + +// Install: Pre-cache shell assets +self.addEventListener('install', (event) => { + event.waitUntil( + caches.open(CACHE_NAME).then((cache) => { + return cache.addAll(PRECACHE_ASSETS); + }).then(() => self.skipWaiting()) + ); +}); + +// Activate: Clean up old versions +self.addEventListener('activate', (event) => { + event.waitUntil( + caches.keys().then((keys) => { + return Promise.all( + keys.filter((key) => key !== CACHE_NAME).map((key) => caches.delete(key)) + ); + }).then(() => self.clients.claim()) + ); +}); + +// Fetch: Caching strategies +self.addEventListener('fetch', (event) => { + const { request } = event; + const url = new URL(request.url); + + // Ignore non-http(s) requests or ad scripts + if (!url.protocol.startsWith('http')) return; + if (IGNORE_HOSTS.some((host) => url.hostname.includes(host))) { + return; // Let standard browser network handle without intercepting + } + + // 1. Navigation requests (HTML document): Network-first with offline fallback + if (request.mode === 'navigate') { + event.respondWith( + fetch(request) + .then((networkResponse) => { + if (networkResponse && networkResponse.status === 200) { + const responseClone = networkResponse.clone(); + caches.open(CACHE_NAME).then((cache) => cache.put(request, responseClone)); + } + return networkResponse; + }) + .catch(() => { + return caches.match('/index.html').then((cached) => cached || caches.match('/')); + }) + ); + return; + } + + // 2. Static Assets (JS, CSS, SVGs, Fonts): Stale-While-Revalidate + if ( + url.origin === self.location.origin && + (url.pathname.startsWith('/assets/') || url.pathname.endsWith('.svg') || url.pathname.endsWith('.json')) + ) { + event.respondWith( + caches.match(request).then((cachedResponse) => { + const fetchPromise = fetch(request) + .then((networkResponse) => { + if (networkResponse && networkResponse.status === 200) { + const responseClone = networkResponse.clone(); + caches.open(CACHE_NAME).then((cache) => cache.put(request, responseClone)); + } + return networkResponse; + }) + .catch(() => null); + + // Return cached asset immediately if available, otherwise wait for network + return cachedResponse || fetchPromise; + }) + ); + return; + } + + // Default: Network with Cache Fallback + event.respondWith( + fetch(request) + .then((networkResponse) => { + if (networkResponse && networkResponse.status === 200 && request.method === 'GET') { + const responseClone = networkResponse.clone(); + caches.open(CACHE_NAME).then((cache) => cache.put(request, responseClone)); + } + return networkResponse; + }) + .catch(() => caches.match(request)) + ); +}); diff --git a/src/App.tsx b/src/App.tsx index 79a8930..c2d4530 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect } from 'react'; +import React, { useState, useEffect, useRef, useCallback } from 'react'; import { FileImage, FileText, @@ -17,10 +17,22 @@ import { Layers, ChevronRight, RotateCcw, + Share2, + DownloadCloud, + WifiOff, + FolderArchive, + Palette, + BookOpen, + Search, + X, + Table, + Fingerprint, + ShieldCheck, } from 'lucide-react'; import { AdSlot } from './components/AdSlot'; import { CategoryFilter } from './components/CategoryFilter'; -import type { ToolType, ConversionResult } from './services/conversionService'; +import { PrivacyModal } from './components/PrivacyModal'; +import type { ToolType, ConversionResult, ColorFormats, CsvParseResult, HashResult } from './services/conversionService'; import { convertImage, convertImagesToPdf, @@ -28,6 +40,13 @@ import { processBase64, processUrlEncode, generateUUIDs, + createZipArchive, + parseColor, + markdownToHtml, + htmlToMarkdown, + parseCsv, + jsonToCsv, + computeAllHashes, } from './services/conversionService'; import { APP_LIMITS } from './config/limits'; @@ -84,12 +103,40 @@ const TOOLS: ToolMeta[] = [ shortDesc: 'Cryptographically secure standard random tokens', badge: 'Crypto', }, + { + id: 'color-convert', + title: 'Color Format Converter', + category: 'developer', + shortDesc: 'Instant bidirectional HEX, RGB, HSL, and CMYK with visual color picker', + badge: 'Design', + }, + { + id: 'markdown-html', + title: 'Markdown ⇄ HTML Live Editor', + category: 'developer', + shortDesc: 'Live bidirectional Markdown and rendered HTML with syntax formatting', + badge: 'Text', + }, + { + id: 'csv-json', + title: 'CSV ⇄ JSON & Data Table', + category: 'developer', + shortDesc: 'Parse CSV/TSV spreadsheets into JSON arrays with interactive data table & delimiter choices', + badge: 'Data', + }, + { + id: 'hash-calc', + title: 'Cryptographic Hash & Checksum', + category: 'security', + shortDesc: 'Compute SHA-256, SHA-512, MD5, and verify file or text integrity checksums', + badge: 'Crypto', + }, ]; const CATEGORIES: { id: Category; label: string; count: number }[] = [ { id: 'media', label: 'Media & Documents', count: 2 }, - { id: 'developer', label: 'Data & Formats', count: 3 }, - { id: 'security', label: 'Encoding & Security', count: 1 }, + { id: 'developer', label: 'Data & Formats', count: 6 }, + { id: 'security', label: 'Encoding & Security', count: 2 }, ]; export function App() { @@ -103,9 +150,17 @@ export function App() { return true; }); - // Selected tool & category filter + // Selected tool & category filter (initialized from URL hash if present) const [selectedCategory, setSelectedCategory] = useState('all'); - const [activeTool, setActiveTool] = useState('image-convert'); + const [activeTool, setActiveTool] = useState(() => { + if (typeof window !== 'undefined' && window.location.hash) { + const hash = window.location.hash.replace('#', '') as ToolType; + if (TOOLS.some((t) => t.id === hash)) { + return hash; + } + } + return 'image-convert'; + }); // File state const [files, setFiles] = useState([]); @@ -113,16 +168,53 @@ export function App() { const [imageQuality, setImageQuality] = useState(0.85); const [isProcessing, setIsProcessing] = useState(false); const [result, setResult] = useState(null); + const [batchResults, setBatchResults] = useState([]); + const [isZipping, setIsZipping] = useState(false); const [errorMsg, setErrorMsg] = useState(null); - // Text/utility state + // Text, Color & Markdown utility state const [textInput, setTextInput] = useState(''); const [textOutput, setTextOutput] = useState(''); const [copied, setCopied] = useState(false); + const [searchQuery, setSearchQuery] = useState(''); + const [markdownDirection, setMarkdownDirection] = useState<'md-to-html' | 'html-to-md'>('md-to-html'); + const [markdownTab, setMarkdownTab] = useState<'preview' | 'raw'>('preview'); + const [colorInput, setColorInput] = useState('#6366F1'); + const [colorData, setColorData] = useState(() => parseColor('#6366F1')); + const [copiedFormat, setCopiedFormat] = useState(null); const [jsonDirection, setJsonDirection] = useState<'json-to-yaml' | 'yaml-to-json'>('json-to-yaml'); const [base64Mode, setBase64Mode] = useState<'encode' | 'decode'>('encode'); const [urlMode, setUrlMode] = useState<'encode' | 'decode'>('encode'); const [uuidCount, setUuidCount] = useState(5); + // CSV / TSV / Table utility state + const [csvMode, setCsvMode] = useState<'csv-to-json' | 'json-to-csv'>('csv-to-json'); + const [csvDelimiter, setCsvDelimiter] = useState(','); + const [csvHasHeader, setCsvHasHeader] = useState(true); + const [csvIndentation, setCsvIndentation] = useState(2); + const [csvParsedResult, setCsvParsedResult] = useState(null); + const [csvOutputTab, setCsvOutputTab] = useState<'table' | 'code'>('table'); + const [tableFilter, setTableFilter] = useState(''); + const csvFileInputRef = useRef(null); + // Hash & Checksum utility state + const [hashSourceType, setHashSourceType] = useState<'text' | 'file'>('text'); + const [hashTextInput, setHashTextInput] = useState('Hello, Multi-Converter!'); + const [hashFile, setHashFile] = useState(null); + const [hashResult, setHashResult] = useState(null); + const [hashUppercase, setHashUppercase] = useState(false); + const [hashVerifyInput, setHashVerifyInput] = useState(''); + const [hashCopiedAlgo, setHashCopiedAlgo] = useState(null); + const [isHashingFile, setIsHashingFile] = useState(false); + const hashFileInputRef = useRef(null); + const [isPrivacyOpen, setIsPrivacyOpen] = useState(false); + const [linkCopied, setLinkCopied] = useState(false); + const [isOnline, setIsOnline] = useState(() => + typeof navigator !== 'undefined' ? navigator.onLine : true + ); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const [installPrompt, setInstallPrompt] = useState(null); + const [isDraggingWindow, setIsDraggingWindow] = useState(false); + const [stripExif, setStripExif] = useState(true); + const dragCounter = useRef(0); useEffect(() => { const root = document.documentElement; @@ -137,13 +229,102 @@ export function App() { const currentToolMeta = TOOLS.find((t) => t.id === activeTool)!; + // Sync activeTool with URL hash, document title, and SEO meta tags + useEffect(() => { + if (currentToolMeta) { + document.title = `${currentToolMeta.title} — Multi-Converter`; + const metaDesc = document.querySelector('meta[name="description"]'); + if (metaDesc) { + metaDesc.setAttribute( + 'content', + `${currentToolMeta.shortDesc}. Fast, 100% private in-browser tool with zero server uploads.` + ); + } + if (typeof window !== 'undefined' && window.location.hash !== `#${activeTool}`) { + window.history.replaceState(null, '', `#${activeTool}`); + } + } + }, [activeTool, currentToolMeta]); + + // Listen to browser Back / Forward navigation + useEffect(() => { + const onHashChange = () => { + const hash = window.location.hash.replace('#', '') as ToolType; + if (TOOLS.some((t) => t.id === hash)) { + setActiveTool(hash); + } + }; + window.addEventListener('hashchange', onHashChange); + return () => window.removeEventListener('hashchange', onHashChange); + }, []); + + // Listen to Online/Offline connectivity and PWA install prompt + useEffect(() => { + const handleOnline = () => setIsOnline(true); + const handleOffline = () => setIsOnline(false); + + window.addEventListener('online', handleOnline); + window.addEventListener('offline', handleOffline); + + const handleBeforeInstall = (e: Event) => { + e.preventDefault(); + setInstallPrompt(e); + }; + + window.addEventListener('beforeinstallprompt', handleBeforeInstall); + const handleAppInstalled = () => setInstallPrompt(null); + window.addEventListener('appinstalled', handleAppInstalled); + + return () => { + window.removeEventListener('online', handleOnline); + window.removeEventListener('offline', handleOffline); + window.removeEventListener('beforeinstallprompt', handleBeforeInstall); + window.removeEventListener('appinstalled', handleAppInstalled); + }; + }, []); + + + + const handleInstallApp = async () => { + if (!installPrompt) return; + installPrompt.prompt(); + const { outcome } = await installPrompt.userChoice; + if (outcome === 'accepted') { + setInstallPrompt(null); + } + }; + + const handleShareTool = () => { + const url = `${window.location.origin}${window.location.pathname}#${activeTool}`; + navigator.clipboard.writeText(url); + setLinkCopied(true); + setTimeout(() => setLinkCopied(false), 2000); + }; + const handleToolSelect = (toolId: ToolType) => { setActiveTool(toolId); setFiles([]); setResult(null); + setBatchResults([]); setErrorMsg(null); - setTextInput(''); - setTextOutput(''); + if (toolId === 'markdown-html') { + const sample = `# Multi-Converter Markdown\n\nFast, **100% private** in-browser text transformation.\n\n- Zero server uploads\n- Instant live preview\n- Clean HTML export\n\n> "Security and privacy without compromise."\n\n\`\`\`javascript\nconsole.log("Hello from browser sandbox!");\n\`\`\``; + setTextInput(sample); + setTextOutput(markdownToHtml(sample)); + } else if (toolId === 'csv-json') { + const sample = `id,name,role,department,active\n101,Alex Morgan,Staff Engineer,Infrastructure,true\n102,Sophia Chen,Product Lead,Growth,true\n103,David Miller,Security Analyst,SecOps,false\n104,Elena Rostova,UX Designer,Creative,true\n105,Marcus Vance,Data Scientist,Analytics,true`; + setTextInput(sample); + const parsed = parseCsv(sample, ',', true); + setCsvParsedResult(parsed); + setTextOutput(JSON.stringify(parsed.rows, null, csvIndentation)); + } else if (toolId === 'hash-calc') { + const sample = 'Hello, Multi-Converter!'; + setHashTextInput(sample); + computeAllHashes(sample).then(setHashResult); + } else { + setTextInput(''); + setTextOutput(''); + } }; const handleFileDrop = (e: React.DragEvent) => { @@ -162,6 +343,7 @@ export function App() { const validateAndSetFiles = (incoming: File[]) => { setErrorMsg(null); setResult(null); + setBatchResults([]); const limits = APP_LIMITS.free; if (incoming.length > limits.maxBatchCount) { @@ -185,20 +367,26 @@ export function App() { setFiles(incoming); }; - const handleConvert = async () => { + const handleConvert = useCallback(async () => { setErrorMsg(null); setIsProcessing(true); try { if (activeTool === 'image-convert') { - if (!files.length) throw new Error('Please select an image file to proceed.'); - const res = await convertImage(files[0], targetImageFormat, imageQuality); - setResult(res); + if (!files.length) throw new Error('Please select at least one image file to proceed.'); + const convertedItems: ConversionResult[] = []; + for (const file of files) { + const res = await convertImage(file, targetImageFormat, imageQuality); + convertedItems.push(res); + } + setBatchResults(convertedItems); + setResult(convertedItems[0]); } else if (activeTool === 'images-to-pdf') { if (!files.length) throw new Error('Select at least one image to compile.'); const res = await convertImagesToPdf(files); setResult(res); + setBatchResults([res]); } else if (activeTool === 'json-yaml') { - const out = convertJsonYaml(textInput, jsonDirection); + const out = await convertJsonYaml(textInput, jsonDirection); setTextOutput(out); } else if (activeTool === 'base64') { const out = processBase64(textInput, base64Mode); @@ -209,12 +397,79 @@ export function App() { } else if (activeTool === 'uuid') { const uuids = generateUUIDs(uuidCount); setTextOutput(uuids.join('\n')); + } else if (activeTool === 'markdown-html') { + if (markdownDirection === 'md-to-html') { + const out = markdownToHtml(textInput); + setTextOutput(out); + } else { + const out = htmlToMarkdown(textInput); + setTextOutput(out); + } + } else if (activeTool === 'csv-json') { + if (!textInput.trim()) throw new Error('Please provide CSV or JSON data to convert.'); + if (csvMode === 'csv-to-json') { + const parsed = parseCsv(textInput, csvDelimiter, csvHasHeader); + if (parsed.error) throw new Error(parsed.error); + setCsvParsedResult(parsed); + setTextOutput(JSON.stringify(parsed.rows, null, csvIndentation)); + } else { + const res = jsonToCsv(textInput, csvDelimiter); + if (res.error) throw new Error(res.error); + setTextOutput(res.csv); + const parsed = parseCsv(res.csv, csvDelimiter, true); + setCsvParsedResult(parsed); + } } } catch (err: any) { setErrorMsg(err.message || 'An error occurred during conversion.'); } finally { setIsProcessing(false); } + }, [ + activeTool, + files, + targetImageFormat, + imageQuality, + textInput, + jsonDirection, + base64Mode, + urlMode, + uuidCount, + markdownDirection, + csvMode, + csvDelimiter, + csvHasHeader, + csvIndentation, + ]); + + const handleDownloadZip = async () => { + if (!batchResults.length) return; + try { + setIsZipping(true); + const zipRes = await createZipArchive(batchResults, `converted-images-${Date.now()}.zip`); + const a = document.createElement('a'); + a.href = zipRes.downloadUrl!; + a.download = zipRes.filename; + a.click(); + } catch (err: any) { + setErrorMsg('Failed to build ZIP archive: ' + err.message); + } finally { + setIsZipping(false); + } + }; + + const handleColorChange = (value: string) => { + setColorInput(value); + const parsed = parseColor(value); + if (parsed) { + setColorData(parsed); + } + }; + + const copyColor = (text: string, format: string) => { + navigator.clipboard.writeText(text); + setCopiedFormat(format); + setTimeout(() => setCopiedFormat(null), 2000); }; const copyToClipboard = (text: string) => { @@ -223,9 +478,159 @@ export function App() { setTimeout(() => setCopied(false), 2000); }; - const filteredTools = selectedCategory === 'all' - ? TOOLS - : TOOLS.filter(t => t.category === selectedCategory); + const handleHashFile = useCallback((file: File) => { + setHashFile(file); + setIsHashingFile(true); + setErrorMsg(null); + const reader = new FileReader(); + reader.onload = async (e) => { + const buffer = e.target?.result as ArrayBuffer; + if (buffer) { + try { + const res = await computeAllHashes(buffer); + setHashResult(res); + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : 'Failed to hash file'; + setErrorMsg(msg); + } finally { + setIsHashingFile(false); + } + } + }; + reader.onerror = () => { + setErrorMsg('Error reading file for checksum computation.'); + setIsHashingFile(false); + }; + reader.readAsArrayBuffer(file); + }, []); + + // Real-time Text Hashing + useEffect(() => { + if (activeTool === 'hash-calc' && hashSourceType === 'text') { + let isMounted = true; + computeAllHashes(hashTextInput) + .then((res) => { + if (isMounted) setHashResult(res); + }) + .catch(() => {}); + return () => { + isMounted = false; + }; + } + }, [activeTool, hashSourceType, hashTextInput]); + + // Window-wide Drag and Drop overlay & auto-routing + useEffect(() => { + const handleDragEnter = (e: DragEvent) => { + e.preventDefault(); + dragCounter.current += 1; + if (e.dataTransfer?.types?.includes('Files')) { + setIsDraggingWindow(true); + } + }; + + const handleDragLeave = (e: DragEvent) => { + e.preventDefault(); + dragCounter.current -= 1; + if (dragCounter.current <= 0) { + dragCounter.current = 0; + setIsDraggingWindow(false); + } + }; + + const handleDragOver = (e: DragEvent) => { + e.preventDefault(); + }; + + const handleWindowDrop = (e: DragEvent) => { + e.preventDefault(); + dragCounter.current = 0; + setIsDraggingWindow(false); + if (e.dataTransfer?.files?.length) { + const incoming = Array.from(e.dataTransfer.files); + if (incoming.some((f) => f.type.startsWith('image/'))) { + if (activeTool !== 'images-to-pdf') { + setActiveTool('image-convert'); + } + validateAndSetFiles(incoming); + } else if (incoming.some((f) => f.name.endsWith('.csv') || f.name.endsWith('.tsv') || f.name.endsWith('.json'))) { + const file = incoming[0]; + setActiveTool('csv-json'); + const reader = new FileReader(); + reader.onload = (event) => { + const text = event.target?.result as string; + if (text) { + setTextInput(text); + if (file.name.endsWith('.json')) { + setCsvMode('json-to-csv'); + const res = jsonToCsv(text, ','); + if (!res.error) { + setTextOutput(res.csv); + const parsed = parseCsv(res.csv, ',', true); + setCsvParsedResult(parsed); + } + } else { + setCsvMode('csv-to-json'); + const isTab = file.name.endsWith('.tsv') || text.includes('\t'); + const delim = isTab ? '\t' : ','; + setCsvDelimiter(delim); + const parsed = parseCsv(text, delim, true); + setCsvParsedResult(parsed); + setTextOutput(JSON.stringify(parsed.rows, null, 2)); + } + } + }; + reader.readAsText(file); + } else if (activeTool === 'hash-calc') { + setHashSourceType('file'); + handleHashFile(incoming[0]); + } + } + }; + + window.addEventListener('dragenter', handleDragEnter); + window.addEventListener('dragleave', handleDragLeave); + window.addEventListener('dragover', handleDragOver); + window.addEventListener('drop', handleWindowDrop); + + return () => { + window.removeEventListener('dragenter', handleDragEnter); + window.removeEventListener('dragleave', handleDragLeave); + window.removeEventListener('dragover', handleDragOver); + window.removeEventListener('drop', handleWindowDrop); + }; + }, [activeTool, handleHashFile]); + + // Global Keyboard Shortcuts (Ctrl+Enter to execute, '/' to search) + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') { + e.preventDefault(); + handleConvert(); + } else if (e.key === '/' && document.activeElement?.tagName !== 'INPUT' && document.activeElement?.tagName !== 'TEXTAREA') { + e.preventDefault(); + document.getElementById('tool-search-input')?.focus(); + } + }; + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [handleConvert]); + + const filteredRows = (csvParsedResult?.rows || []).filter((row) => { + if (!tableFilter.trim()) return true; + const q = tableFilter.toLowerCase(); + return Object.values(row).some((val) => String(val).toLowerCase().includes(q)); + }); + + const filteredTools = TOOLS.filter((tool) => { + const matchesCategory = selectedCategory === 'all' || tool.category === selectedCategory; + const matchesSearch = + !searchQuery.trim() || + tool.title.toLowerCase().includes(searchQuery.toLowerCase()) || + tool.shortDesc.toLowerCase().includes(searchQuery.toLowerCase()) || + tool.badge.toLowerCase().includes(searchQuery.toLowerCase()); + return matchesCategory && matchesSearch; + }); return (
@@ -246,7 +651,20 @@ export function App() {
-
+
+ {/* Install PWA Button */} + {installPrompt && ( + + )} +
Zero-Upload Guarantee @@ -264,6 +682,20 @@ export function App() {
+ {/* Offline Status Notification Banner */} + {!isOnline && ( + + )} + {/* 3.1 Header Advertisement (Subtle & Non-Intrusive) */}
@@ -273,7 +705,7 @@ export function App() {
{/* Left Column: Categorized Tool Drawer & Catalog (lg:col-span-4) */} -
+
{/* Category Filter */} + {/* Quick Search Tool Input */} +
+ + setSearchQuery(e.target.value)} + placeholder="Filter tools... (Press '/' to search)" + className="w-full bg-white/70 dark:bg-neutral-900/60 border border-neutral-200/80 dark:border-neutral-800 rounded-xl pl-8 pr-7 py-2 text-xs text-neutral-900 dark:text-neutral-100 placeholder:text-neutral-400 outline-none focus:border-neutral-900 dark:focus:border-neutral-400 transition" + /> + {searchQuery && ( + + )} +
+ {/* Categorized Tool Cards List */}
- {filteredTools.map((tool) => { - const isSelected = activeTool === tool.id; - return ( -
+ ) : ( + filteredTools.map((tool) => { + const isSelected = activeTool === tool.id; + return ( + - ); - })} + > +
+
+ {tool.id === 'image-convert' && } + {tool.id === 'images-to-pdf' && } + {tool.id === 'json-yaml' && } + {tool.id === 'base64' && } + {tool.id === 'url-encode' && } + {tool.id === 'uuid' && } + {tool.id === 'color-convert' && } + {tool.id === 'markdown-html' && } + {tool.id === 'csv-json' && } + {tool.id === 'hash-calc' && } + +
+
+ + {tool.title} + +
+

+ {tool.shortDesc} +

+
+ + + + ); + }) + )} {/* 3.3 Sticky Sidebar Sponsor Card */} @@ -360,13 +824,33 @@ export function App() {

- +
+ + + +
{/* Error Message Alert */} @@ -400,7 +884,7 @@ export function App() { + + {/* EXIF Privacy Stripper Option */} +
+
+ +
+

+ Strip EXIF & GPS Metadata +

+

+ Removes private camera, date, and geolocation tags from output image. +

+
+
+ setStripExif(e.target.checked)} + className="w-4 h-4 rounded text-neutral-900 dark:text-white accent-neutral-900 dark:accent-neutral-100 cursor-pointer" + /> +
)} @@ -466,25 +971,152 @@ export function App() { onClick={handleConvert} className="w-full py-3 bg-neutral-950 text-white dark:bg-neutral-50 dark:text-neutral-950 disabled:opacity-40 disabled:cursor-not-allowed font-medium text-sm rounded-xl transition hover:opacity-90 flex items-center justify-center gap-2 shadow-xs" > - {isProcessing ? 'Processing in Browser...' : activeTool === 'image-convert' ? 'Convert Image' : 'Merge into PDF'} + + {isProcessing + ? 'Processing in Browser...' + : activeTool === 'image-convert' + ? `Convert ${files.length > 1 ? `${files.length} Images` : 'Image'}` + : 'Merge into PDF'} + + + Ctrl+↵ + - {/* Result Section & Safe Download Button */} - {result && ( -
-
- {result.filename} - {result.sizeBytes && ( - - {(result.sizeBytes / (1024 * 1024)).toFixed(2)} MB - - )} + {/* Batch Image Conversion Results */} + {batchResults.length > 1 && activeTool === 'image-convert' && ( +
+
+
+

+ Batch Output ({batchResults.length} files) +

+

+ Converted to {targetImageFormat.split('/')[1].toUpperCase()} locally in-browser +

+
+ + {/* Total Savings Badge */} + {(() => { + const totalOrig = batchResults.reduce((sum, item) => sum + (item.originalSizeBytes || 0), 0); + const totalNew = batchResults.reduce((sum, item) => sum + (item.sizeBytes || 0), 0); + const diff = totalOrig - totalNew; + const pct = totalOrig > 0 ? Math.round((diff / totalOrig) * 100) : 0; + if (pct > 0) { + return ( + + Saved {pct}% ({(diff / (1024 * 1024)).toFixed(2)} MB less) + + ); + } + return null; + })()}
+ {/* Download All as ZIP Button */} + + + {/* Itemized Converted Files List */} +
+ {batchResults.map((item, idx) => ( +
+
+ {item.downloadUrl && ( + thumb + )} +
+

+ {item.filename} +

+ {item.sizeBytes && ( +

+ {(item.sizeBytes / 1024).toFixed(1)} KB + {item.originalSizeBytes && item.sizeBytes < item.originalSizeBytes && ( + + (-{Math.round(((item.originalSizeBytes - item.sizeBytes) / item.originalSizeBytes) * 100)}%) + + )} +

+ )} +
+
+ + + + Download + +
+ ))} +
+ + +
+ )} + + {/* Single Result Section (for Single Image or PDF) */} + {result && (batchResults.length <= 1 || activeTool === 'images-to-pdf') && ( +
+
+
+ + {result.filename} + + {result.sizeBytes && ( + + {(result.sizeBytes / (1024 * 1024)).toFixed(2)} MB + + )} +
+ + {/* Compression Savings Badge */} + {files.length > 0 && result.sizeBytes && (() => { + const originalBytes = files.reduce((acc, f) => acc + f.size, 0); + const diff = originalBytes - result.sizeBytes; + const pct = Math.round((diff / originalBytes) * 100); + if (pct > 0) { + return ( + + Saved {pct}% ({(diff / (1024 * 1024)).toFixed(2)} MB less) + + ); + } + return null; + })()} +
+ + {/* Image Preview Thumbnail */} + {activeTool === 'image-convert' && result.downloadUrl && ( +
+ Converted file preview +
+ )} + Download File @@ -611,7 +1243,10 @@ export function App() { onClick={handleConvert} className="w-full py-2.5 bg-neutral-950 text-white dark:bg-neutral-50 dark:text-neutral-950 font-medium text-xs sm:text-sm rounded-xl transition hover:opacity-90 flex items-center justify-center gap-2" > - {activeTool === 'uuid' ? 'Generate UUIDs' : 'Transform Input'} + {activeTool === 'uuid' ? 'Generate UUIDs' : 'Transform Input'} + + Ctrl+↵ + {/* Text Output Result */} @@ -640,16 +1275,889 @@ export function App() { )}
)} + + {/* Color Format Converter Workspace */} + {activeTool === 'color-convert' && ( +
+ {/* Visual Swatch & Native Color Picker */} +
+
+
document.getElementById('native-color-picker')?.click()} + > + +
+ handleColorChange(e.target.value)} + className="opacity-0 absolute inset-0 w-full h-full cursor-pointer" + /> +
+ +
+ +
+ handleColorChange(e.target.value)} + placeholder="e.g. #6366F1, rgb(99, 102, 241), or hsl(239, 84%, 67%)" + className="flex-1 bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-xl px-3.5 py-2.5 text-xs sm:text-sm font-mono text-neutral-900 dark:text-neutral-100 outline-none focus:border-neutral-900 dark:focus:border-neutral-400" + /> +
+

+ Click the color swatch to open the visual picker, or type any color code. +

+
+
+ + {/* Quick Palette Presets */} +
+ + Quick Presets + +
+ {[ + { name: 'Indigo', hex: '#6366F1' }, + { name: 'Emerald', hex: '#10B981' }, + { name: 'Sky', hex: '#0EA5E9' }, + { name: 'Amber', hex: '#F59E0B' }, + { name: 'Rose', hex: '#F43F5E' }, + { name: 'Purple', hex: '#A855F7' }, + { name: 'Slate', hex: '#475569' }, + ].map((preset) => ( + + ))} +
+
+ + {/* Converted Format Grid */} + {colorData && ( +
+ {[ + { label: 'HEX', val: colorData.hex }, + { label: 'RGB', val: colorData.rgb }, + { label: 'HSL', val: colorData.hsl }, + { label: 'CMYK', val: colorData.cmyk }, + ].map((fmt) => ( +
+
+ + {fmt.label} + +

+ {fmt.val} +

+
+ +
+ ))} +
+ )} + + +
+ )} + + {/* Markdown ⇄ HTML Live Editor Workspace */} + {activeTool === 'markdown-html' && ( +
+ {/* Control Bar: Mode Toggle + Sample Loader */} +
+
+ + +
+ + +
+ + {/* Editor & Preview Panes */} +
+ {/* Left: Source Text Input */} +
+
+ + {markdownDirection === 'md-to-html' ? 'Markdown Source' : 'HTML Source'} + + + {textInput.length} chars + +
+