feat: add ethical ads, privacy policy, PWA offline, CSV table grid, hash verifier, and SEO
This commit is contained in:
1 parent
25e7fae05b
commit
cf533096a3
13 files changed
+2652
-125
No files matched your search
+44
-1
@@ -4,7 +4,50 @@
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>multi-converter</title>
|
||||
<title>Multi-Converter — Free, Client-Side Media & Developer Utilities</title>
|
||||
<meta name="description" content="Fast, 100% private in-browser conversions for Images (WebP/PNG/JPG), PDF bundling, CSV ⇄ JSON table grids, Markdown, Color palettes, and Developer Encoders." />
|
||||
|
||||
<link rel="canonical" href="https://isalin.us.ci" />
|
||||
|
||||
<!-- Open Graph / Facebook / Discord -->
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:url" content="https://isalin.us.ci" />
|
||||
<meta property="og:title" content="Multi-Converter — Free, Client-Side Media & Developer Utilities" />
|
||||
<meta property="og:description" content="Fast, 100% private in-browser conversions for Images (WebP/PNG/JPG), PDF bundling, CSV ⇄ JSON table grids, Markdown, Color palettes, and Developer Encoders." />
|
||||
<meta property="og:site_name" content="Multi-Converter" />
|
||||
|
||||
<!-- Twitter / X Cards -->
|
||||
<meta name="twitter:card" content="summary" />
|
||||
<meta name="twitter:title" content="Multi-Converter — Private In-Browser Utilities" />
|
||||
<meta name="twitter:description" content="Convert Images, PDFs, CSV, JSON, Markdown, and Colors with zero server uploads." />
|
||||
|
||||
<!-- PWA & Mobile Web App Meta Tags -->
|
||||
<link rel="manifest" href="/manifest.json" />
|
||||
<meta name="theme-color" content="#0a0a0a" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
<meta name="apple-mobile-web-app-title" content="Multi-Converter" />
|
||||
<link rel="apple-touch-icon" href="/favicon.svg" />
|
||||
|
||||
<!-- Structured Data for Search Engines -->
|
||||
<script type="application/ld+json">
|
||||
{
|
||||
"@context": "https://schema.org",
|
||||
"@type": "WebApplication",
|
||||
"name": "Multi-Converter",
|
||||
"url": "https://isalin.us.ci",
|
||||
"description": "Fast, private in-browser conversions for Images, PDF bundling, CSV/TSV table grids, JSON/YAML transformers, Markdown, and Developer Encoders.",
|
||||
"applicationCategory": "UtilitiesApplication",
|
||||
"operatingSystem": "All",
|
||||
"offers": {
|
||||
"@type": "Offer",
|
||||
"price": "0",
|
||||
"priceCurrency": "USD"
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<!-- EthicalAds Privacy-Preserving Ad Network Script -->
|
||||
<script async src="https://media.ethicalads.io/media/client/ethicalads.min.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
Generated
+105
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
+102
@@ -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))
|
||||
);
|
||||
});
|
||||
+1622
-91
File diff suppressed because it is too large.
Load diff
Binary file not shown.
|
After Width: | Height: | Size: 1.2 MiB |
+58
-15
@@ -1,4 +1,15 @@
|
||||
import React from 'react';
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import { AD_CONFIG } from '../config/limits';
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
ethicalads?: {
|
||||
load: () => void;
|
||||
wait?: Promise<void>;
|
||||
reload?: () => void;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
interface AdSlotProps {
|
||||
placement: 'header' | 'sidebar' | 'between-content' | 'post-conversion';
|
||||
@@ -6,46 +17,78 @@ interface AdSlotProps {
|
||||
}
|
||||
|
||||
export const AdSlot: React.FC<AdSlotProps> = ({ placement, className = '' }) => {
|
||||
const adContainerRef = useRef<HTMLDivElement>(null);
|
||||
const isPrimary = placement === AD_CONFIG.primaryPlacement;
|
||||
|
||||
const configs = {
|
||||
header: {
|
||||
type: 'Display Banner',
|
||||
dimensions: 'Responsive 728×90 / 320×50',
|
||||
containerClass: 'h-20 sm:h-24 w-full max-w-3xl mx-auto',
|
||||
containerClass: 'min-h-[80px] sm:min-h-[96px] w-full max-w-3xl mx-auto',
|
||||
},
|
||||
sidebar: {
|
||||
type: 'Partner Spotlight',
|
||||
dimensions: '300×250 Rectangle',
|
||||
containerClass: 'w-full h-64',
|
||||
containerClass: 'w-full min-h-[250px]',
|
||||
},
|
||||
'between-content': {
|
||||
type: 'Editorial Recommendation',
|
||||
dimensions: 'Fluid Feed Unit',
|
||||
containerClass: 'h-24 w-full max-w-3xl mx-auto my-8',
|
||||
containerClass: 'min-h-[96px] w-full max-w-3xl mx-auto my-8',
|
||||
},
|
||||
'post-conversion': {
|
||||
type: 'Suggested Utility',
|
||||
dimensions: 'Non-blocking Card',
|
||||
containerClass: 'h-28 w-full max-w-xl mx-auto mt-6',
|
||||
containerClass: 'min-h-[112px] w-full max-w-xl mx-auto mt-6',
|
||||
},
|
||||
};
|
||||
|
||||
const item = configs[placement];
|
||||
|
||||
useEffect(() => {
|
||||
if (AD_CONFIG.enabled && isPrimary && typeof window !== 'undefined') {
|
||||
try {
|
||||
if (window.ethicalads) {
|
||||
window.ethicalads.load();
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('EthicalAds loading error:', err);
|
||||
}
|
||||
}
|
||||
}, [isPrimary]);
|
||||
|
||||
return (
|
||||
<aside
|
||||
role="region"
|
||||
aria-label="Sponsor"
|
||||
className={`relative flex flex-col items-center justify-center rounded-xl border border-neutral-200/80 bg-neutral-50/60 dark:border-neutral-800/80 dark:bg-neutral-900/40 p-4 text-center select-none transition-colors ${item.containerClass} ${className}`}
|
||||
className={`relative flex flex-col items-center justify-center rounded-xl border border-neutral-200/80 bg-neutral-50/60 dark:border-neutral-800/80 dark:bg-neutral-900/40 p-3 text-center transition-colors ${item.containerClass} ${className}`}
|
||||
>
|
||||
<span className="absolute top-2 right-2.5 text-[9px] uppercase tracking-wider text-neutral-400 dark:text-neutral-500 font-medium">
|
||||
Sponsored
|
||||
</span>
|
||||
<p className="text-xs font-medium text-neutral-600 dark:text-neutral-300">
|
||||
{item.type}
|
||||
</p>
|
||||
<p className="text-[11px] text-neutral-400 dark:text-neutral-500 mt-0.5">
|
||||
{item.dimensions}
|
||||
</p>
|
||||
{/* If EthicalAds is active on this placement, render the data-ea-publisher container */}
|
||||
{AD_CONFIG.enabled && isPrimary ? (
|
||||
<div className="w-full flex flex-col items-center justify-center">
|
||||
<div
|
||||
ref={adContainerRef}
|
||||
id={`ea-${placement}`}
|
||||
data-ea-publisher={AD_CONFIG.ethicalAdsPublisher}
|
||||
data-ea-type="image"
|
||||
data-ea-style="fixedfont"
|
||||
className="adaptive w-full flex justify-center text-xs"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
/* Secondary / fallback placeholder */
|
||||
<>
|
||||
<span className="absolute top-2 right-2.5 text-[9px] uppercase tracking-wider text-neutral-400 dark:text-neutral-500 font-medium">
|
||||
Sponsored
|
||||
</span>
|
||||
<p className="text-xs font-medium text-neutral-600 dark:text-neutral-300">
|
||||
{item.type}
|
||||
</p>
|
||||
<p className="text-[11px] text-neutral-400 dark:text-neutral-500 mt-0.5">
|
||||
{item.dimensions}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</aside>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,140 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { Shield, X, Lock, EyeOff, Server, CheckCircle2 } from 'lucide-react';
|
||||
|
||||
interface PrivacyModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export const PrivacyModal: React.FC<PrivacyModalProps> = ({ isOpen, onClose }) => {
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose();
|
||||
};
|
||||
if (isOpen) {
|
||||
document.body.style.overflow = 'hidden';
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
}
|
||||
return () => {
|
||||
document.body.style.overflow = '';
|
||||
window.removeEventListener('keydown', handleKeyDown);
|
||||
};
|
||||
}, [isOpen, onClose]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="privacy-modal-title"
|
||||
className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm animate-fade-in"
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
className="relative w-full max-w-2xl max-h-[90vh] overflow-y-auto rounded-2xl border border-neutral-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 shadow-2xl p-6 sm:p-8 text-neutral-800 dark:text-neutral-200"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between pb-4 border-b border-neutral-100 dark:border-neutral-800">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2.5 rounded-xl bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border border-emerald-500/20">
|
||||
<Shield className="w-5 h-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 id="privacy-modal-title" className="text-lg font-bold text-neutral-900 dark:text-white">
|
||||
Privacy Policy & Data Security
|
||||
</h2>
|
||||
<p className="text-xs text-neutral-500 dark:text-neutral-400">
|
||||
Last updated: October 2026 • 100% Client-Side Processing
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
aria-label="Close dialog"
|
||||
className="p-1.5 rounded-lg text-neutral-400 hover:text-neutral-700 dark:hover:text-white hover:bg-neutral-100 dark:hover:bg-neutral-800 transition"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="space-y-6 pt-5 text-xs sm:text-sm leading-relaxed">
|
||||
{/* Section 1 */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2 text-neutral-900 dark:text-white font-semibold">
|
||||
<Lock className="w-4 h-4 text-emerald-500" />
|
||||
<h3>1. Zero Server Uploads (Client-Side Sandboxing)</h3>
|
||||
</div>
|
||||
<p className="text-neutral-600 dark:text-neutral-300">
|
||||
All conversions—including image manipulation, PDF generation, JSON/YAML parsing, Base64 encoding, and UUID generation—run <strong>entirely inside your browser's local sandbox</strong>.
|
||||
</p>
|
||||
<ul className="space-y-1.5 pl-6 list-disc text-neutral-600 dark:text-neutral-400 text-xs">
|
||||
<li>Your files and text inputs are never uploaded to our servers or third-party cloud storage.</li>
|
||||
<li>Calculations utilize HTML5 Canvas, <code className="text-neutral-800 dark:text-neutral-200">crypto.randomUUID()</code>, and in-memory Web APIs.</li>
|
||||
<li>Data is automatically discarded from browser memory as soon as you clear the input or refresh the page.</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Section 2 */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2 text-neutral-900 dark:text-white font-semibold">
|
||||
<EyeOff className="w-4 h-4 text-sky-500" />
|
||||
<h3>2. Privacy-Respecting Advertising (EthicalAds)</h3>
|
||||
</div>
|
||||
<p className="text-neutral-600 dark:text-neutral-300">
|
||||
To keep all conversion tools free and maintain fast serverless infrastructure, we partner with <strong>EthicalAds</strong>:
|
||||
</p>
|
||||
<div className="rounded-xl border border-neutral-200 dark:border-neutral-800 bg-neutral-50 dark:bg-neutral-950/50 p-3.5 space-y-2 text-xs">
|
||||
<div className="flex items-start gap-2">
|
||||
<CheckCircle2 className="w-4 h-4 text-emerald-500 shrink-0 mt-0.5" />
|
||||
<span><strong>No Tracking Cookies:</strong> EthicalAds does not place third-party cookies or track you across the web.</span>
|
||||
</div>
|
||||
<div className="flex items-start gap-2">
|
||||
<CheckCircle2 className="w-4 h-4 text-emerald-500 shrink-0 mt-0.5" />
|
||||
<span><strong>Contextual Only:</strong> Ads are selected purely based on page context (developer and digital media tools) and coarse geographic region (e.g., country level), never your personal behavioral history.</span>
|
||||
</div>
|
||||
<div className="flex items-start gap-2">
|
||||
<CheckCircle2 className="w-4 h-4 text-emerald-500 shrink-0 mt-0.5" />
|
||||
<span><strong>No Invasive Scripts:</strong> EthicalAds respects "Do Not Track" browser signals and complies strictly with GDPR, CCPA, and Acceptable Ads standards.</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Section 3 */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2 text-neutral-900 dark:text-white font-semibold">
|
||||
<Server className="w-4 h-4 text-indigo-500" />
|
||||
<h3>3. Hosting & Server Infrastructure</h3>
|
||||
</div>
|
||||
<p className="text-neutral-600 dark:text-neutral-300 text-xs">
|
||||
This site is hosted globally via Vercel's Edge Network. When your browser requests static assets (HTML, CSS, JS), temporary standard server access logs (such as IP address, user-agent, and timestamp) are processed exclusively for security, rate-limiting, and DDoS defense. No analytics tracking pixels (such as Google Analytics or Facebook Pixel) are present.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Section 4 */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2 text-neutral-900 dark:text-white font-semibold">
|
||||
<Shield className="w-4 h-4 text-amber-500" />
|
||||
<h3>4. Local Storage Usage</h3>
|
||||
</div>
|
||||
<p className="text-neutral-600 dark:text-neutral-300 text-xs">
|
||||
We only use your browser's <code className="text-neutral-800 dark:text-neutral-200">localStorage</code> to remember your interface preferences (such as dark mode vs light mode). This information never leaves your device.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="mt-8 pt-4 border-t border-neutral-100 dark:border-neutral-800 flex justify-end">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-5 py-2.5 rounded-xl bg-neutral-900 hover:bg-neutral-800 dark:bg-white dark:hover:bg-neutral-100 text-white dark:text-neutral-900 text-xs sm:text-sm font-semibold transition"
|
||||
>
|
||||
I Understand & Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -22,5 +22,11 @@ export const APP_LIMITS: { free: UsageLimits; pro: UsageLimits } = {
|
||||
|
||||
export const AD_CONFIG = {
|
||||
enabled: true,
|
||||
showMockBannersInDev: true,
|
||||
// Official EthicalAds demo publisher is 'ethicalads' (shows live test ads).
|
||||
// Once your site is approved by EthicalAds, replace 'ethicalads' with your custom Publisher ID.
|
||||
ethicalAdsPublisher: 'ethicalads',
|
||||
// EthicalAds recommends 1 prominent placement per page view (e.g. 'sidebar' or 'header')
|
||||
primaryPlacement: 'sidebar' as 'header' | 'sidebar' | 'between-content' | 'post-conversion',
|
||||
// Show clean mock banner card if ads are blocked or loading
|
||||
showMockFallback: true,
|
||||
};
|
||||
+16
-5
@@ -1,10 +1,21 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import './index.css'
|
||||
import App from './App.tsx'
|
||||
import { StrictMode } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import './index.css';
|
||||
import App from './App.tsx';
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
)
|
||||
);
|
||||
|
||||
// Register service worker for offline PWA support
|
||||
if ('serviceWorker' in navigator && typeof window !== 'undefined') {
|
||||
window.addEventListener('load', () => {
|
||||
navigator.serviceWorker
|
||||
.register('/sw.js')
|
||||
.catch((err) => {
|
||||
console.warn('Service worker registration failed:', err);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -1,13 +1,14 @@
|
||||
import { PDFDocument } from 'pdf-lib';
|
||||
import { dump, load } from 'js-yaml';
|
||||
|
||||
export type ToolType =
|
||||
| 'image-convert'
|
||||
| 'images-to-pdf'
|
||||
| 'json-yaml'
|
||||
| 'base64'
|
||||
| 'url-encode'
|
||||
| 'uuid';
|
||||
| 'uuid'
|
||||
| 'color-convert'
|
||||
| 'markdown-html'
|
||||
| 'csv-json'
|
||||
| 'hash-calc';
|
||||
|
||||
export interface ConversionResult {
|
||||
filename: string;
|
||||
@@ -15,6 +16,17 @@ export interface ConversionResult {
|
||||
downloadUrl?: string;
|
||||
textOutput?: string;
|
||||
sizeBytes?: number;
|
||||
originalSizeBytes?: number;
|
||||
}
|
||||
|
||||
export interface ColorFormats {
|
||||
hex: string;
|
||||
rgb: string;
|
||||
hsl: string;
|
||||
cmyk: string;
|
||||
r: number;
|
||||
g: number;
|
||||
b: number;
|
||||
}
|
||||
|
||||
/** Convert image format (PNG, JPEG, WebP) and quality entirely in-browser */
|
||||
@@ -70,6 +82,7 @@ export async function convertImage(
|
||||
blob,
|
||||
downloadUrl,
|
||||
sizeBytes: blob.size,
|
||||
originalSizeBytes: file.size,
|
||||
});
|
||||
},
|
||||
targetFormat,
|
||||
@@ -84,8 +97,9 @@ export async function convertImage(
|
||||
});
|
||||
}
|
||||
|
||||
/** Combine multiple images into a single PDF document in-browser */
|
||||
/** Combine multiple images into a single PDF document in-browser (dynamically loads pdf-lib) */
|
||||
export async function convertImagesToPdf(files: File[]): Promise<ConversionResult> {
|
||||
const { PDFDocument } = await import('pdf-lib');
|
||||
const pdfDoc = await PDFDocument.create();
|
||||
|
||||
for (const file of files) {
|
||||
@@ -123,18 +137,46 @@ export async function convertImagesToPdf(files: File[]): Promise<ConversionResul
|
||||
const pdfBytes = await pdfDoc.save();
|
||||
const blob = new Blob([new Uint8Array(pdfBytes)], { type: 'application/pdf' });
|
||||
const downloadUrl = URL.createObjectURL(blob);
|
||||
const totalOriginalSize = files.reduce((acc, f) => acc + f.size, 0);
|
||||
|
||||
return {
|
||||
filename: 'merged-documents.pdf',
|
||||
blob,
|
||||
downloadUrl,
|
||||
sizeBytes: blob.size,
|
||||
originalSizeBytes: totalOriginalSize,
|
||||
};
|
||||
}
|
||||
|
||||
/** JSON <-> YAML converter */
|
||||
export function convertJsonYaml(input: string, direction: 'json-to-yaml' | 'yaml-to-json'): string {
|
||||
/** Create a downloadable ZIP archive containing all converted files (dynamically loads jszip) */
|
||||
export async function createZipArchive(
|
||||
items: ConversionResult[],
|
||||
archiveName: string = 'multi-converter-batch.zip'
|
||||
): Promise<ConversionResult> {
|
||||
const JSZip = (await import('jszip')).default;
|
||||
const zip = new JSZip();
|
||||
|
||||
for (const item of items) {
|
||||
if (item.blob) {
|
||||
zip.file(item.filename, item.blob);
|
||||
}
|
||||
}
|
||||
|
||||
const zipBlob = await zip.generateAsync({ type: 'blob' });
|
||||
const downloadUrl = URL.createObjectURL(zipBlob);
|
||||
|
||||
return {
|
||||
filename: archiveName,
|
||||
blob: zipBlob,
|
||||
downloadUrl,
|
||||
sizeBytes: zipBlob.size,
|
||||
};
|
||||
}
|
||||
|
||||
/** JSON <-> YAML converter (dynamically loads js-yaml) */
|
||||
export async function convertJsonYaml(input: string, direction: 'json-to-yaml' | 'yaml-to-json'): Promise<string> {
|
||||
if (!input.trim()) return '';
|
||||
const { dump, load } = await import('js-yaml');
|
||||
if (direction === 'json-to-yaml') {
|
||||
const parsed = JSON.parse(input);
|
||||
return dump(parsed, { indent: 2 });
|
||||
@@ -168,3 +210,474 @@ export function generateUUIDs(count: number = 5): string[] {
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Color Converter: Parses HEX, RGB, or HSL and computes all formats */
|
||||
export function parseColor(rawInput: string): ColorFormats | null {
|
||||
const input = rawInput.trim();
|
||||
let r = 0, g = 0, b = 0;
|
||||
|
||||
// 1. HEX match (#fff or #ffffff)
|
||||
const hexMatch = input.match(/^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i) ||
|
||||
input.match(/^#?([a-f\d])([a-f\d])([a-f\d])$/i);
|
||||
if (hexMatch) {
|
||||
if (hexMatch[1].length === 1) {
|
||||
r = parseInt(hexMatch[1] + hexMatch[1], 16);
|
||||
g = parseInt(hexMatch[2] + hexMatch[2], 16);
|
||||
b = parseInt(hexMatch[3] + hexMatch[3], 16);
|
||||
} else {
|
||||
r = parseInt(hexMatch[1], 16);
|
||||
g = parseInt(hexMatch[2], 16);
|
||||
b = parseInt(hexMatch[3], 16);
|
||||
}
|
||||
} else {
|
||||
// 2. RGB match: rgb(255, 255, 255) or 255, 255, 255
|
||||
const rgbMatch = input.match(/rgba?\(?\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})/i);
|
||||
if (rgbMatch) {
|
||||
r = Math.min(255, parseInt(rgbMatch[1], 10));
|
||||
g = Math.min(255, parseInt(rgbMatch[2], 10));
|
||||
b = Math.min(255, parseInt(rgbMatch[3], 10));
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Format outputs
|
||||
const toHex = (n: number) => n.toString(16).padStart(2, '0').toUpperCase();
|
||||
const hex = `#${toHex(r)}${toHex(g)}${toHex(b)}`;
|
||||
const rgb = `rgb(${r}, ${g}, ${b})`;
|
||||
|
||||
// Calculate HSL
|
||||
const rn = r / 255, gn = g / 255, bn = b / 255;
|
||||
const max = Math.max(rn, gn, bn), min = Math.min(rn, gn, bn);
|
||||
let h = 0, s = 0;
|
||||
const l = (max + min) / 2;
|
||||
|
||||
if (max !== min) {
|
||||
const d = max - min;
|
||||
s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
|
||||
switch (max) {
|
||||
case rn: h = (gn - bn) / d + (gn < bn ? 6 : 0); break;
|
||||
case gn: h = (bn - rn) / d + 2; break;
|
||||
case bn: h = (rn - gn) / d + 4; break;
|
||||
}
|
||||
h = Math.round(h * 60);
|
||||
}
|
||||
const hsl = `hsl(${h}, ${Math.round(s * 100)}%, ${Math.round(l * 100)}%)`;
|
||||
|
||||
// Calculate CMYK
|
||||
let c = 0, m = 0, y = 0, k = 0;
|
||||
if (r === 0 && g === 0 && b === 0) {
|
||||
k = 100;
|
||||
} else {
|
||||
const cVal = 1 - rn;
|
||||
const mVal = 1 - gn;
|
||||
const yVal = 1 - bn;
|
||||
const kVal = Math.min(cVal, mVal, yVal);
|
||||
c = Math.round(((cVal - kVal) / (1 - kVal)) * 100);
|
||||
m = Math.round(((mVal - kVal) / (1 - kVal)) * 100);
|
||||
y = Math.round(((yVal - kVal) / (1 - kVal)) * 100);
|
||||
k = Math.round(kVal * 100);
|
||||
}
|
||||
const cmyk = `cmyk(${c}%, ${m}%, ${y}%, ${k}%)`;
|
||||
|
||||
return { hex, rgb, hsl, cmyk, r, g, b };
|
||||
}
|
||||
|
||||
/** Fast, secure client-side Markdown to HTML converter */
|
||||
export function markdownToHtml(rawMd: string): string {
|
||||
if (!rawMd.trim()) return '';
|
||||
|
||||
let html = rawMd
|
||||
.replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, '')
|
||||
.replace(/on\w+="[^"]*"/gi, '')
|
||||
.replace(/javascript:[^"']*/gi, '');
|
||||
|
||||
// Fenced Code blocks
|
||||
html = html.replace(/```([a-z0-9_-]*)\n([\s\S]*?)```/g, (_match, _lang, code) => {
|
||||
const escaped = code
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>');
|
||||
return `<pre class="my-3 p-3.5 rounded-xl bg-neutral-900 text-neutral-100 font-mono text-xs overflow-x-auto"><code>${escaped}</code></pre>`;
|
||||
});
|
||||
|
||||
// Headers (# H1 to ###### H6)
|
||||
html = html
|
||||
.replace(/^###### (.*$)/gim, '<h6 class="text-xs font-bold my-2 text-neutral-800 dark:text-neutral-200">$1</h6>')
|
||||
.replace(/^##### (.*$)/gim, '<h5 class="text-sm font-bold my-2 text-neutral-800 dark:text-neutral-200">$1</h5>')
|
||||
.replace(/^#### (.*$)/gim, '<h4 class="text-base font-bold my-2.5 text-neutral-900 dark:text-neutral-100">$1</h4>')
|
||||
.replace(/^### (.*$)/gim, '<h3 class="text-lg font-bold my-3 text-neutral-900 dark:text-neutral-100">$1</h3>')
|
||||
.replace(/^## (.*$)/gim, '<h2 class="text-xl font-bold my-3.5 border-b border-neutral-200 dark:border-neutral-800 pb-1 text-neutral-900 dark:text-neutral-100">$1</h2>')
|
||||
.replace(/^# (.*$)/gim, '<h1 class="text-2xl font-extrabold my-4 border-b border-neutral-200 dark:border-neutral-800 pb-2 text-neutral-950 dark:text-white">$1</h1>');
|
||||
|
||||
// Blockquotes
|
||||
html = html.replace(/^> (.*$)/gim, '<blockquote class="border-l-4 border-emerald-500 pl-3 my-2 text-neutral-600 dark:text-neutral-400 italic text-xs">$1</blockquote>');
|
||||
|
||||
// Horizontal Rules
|
||||
html = html.replace(/^(?:---|\*\*\*|___)\s*$/gim, '<hr class="my-4 border-neutral-200 dark:border-neutral-800" />');
|
||||
|
||||
// Bold & Italic & Strikethrough
|
||||
html = html
|
||||
.replace(/\*\*\*(.*?)\*\*\*/g, '<strong><em>$1</em></strong>')
|
||||
.replace(/\*\*(.*?)\*\*/g, '<strong class="font-bold text-neutral-900 dark:text-white">$1</strong>')
|
||||
.replace(/\*(.*?)\*/g, '<em class="italic">$1</em>')
|
||||
.replace(/~~(.*?)~~/g, '<del class="line-through text-neutral-400">$1</del>');
|
||||
|
||||
// Inline Code
|
||||
html = html.replace(/`([^`]+)`/g, '<code class="px-1.5 py-0.5 rounded bg-neutral-200/70 dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100 font-mono text-xs">$1</code>');
|
||||
|
||||
// Images and Links
|
||||
html = html
|
||||
.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, '<img src="$2" alt="$1" class="my-3 max-h-60 rounded-lg border border-neutral-200 dark:border-neutral-800 object-contain" />')
|
||||
.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank" rel="noopener noreferrer" class="text-emerald-600 dark:text-emerald-400 underline underline-offset-2 hover:opacity-80">$1</a>');
|
||||
|
||||
// Unordered list items (- or *)
|
||||
html = html.replace(/^\s*[-*]\s+(.*)$/gim, '<li class="ml-4 list-disc text-neutral-700 dark:text-neutral-300 my-0.5">$1</li>');
|
||||
|
||||
// Paragraphs
|
||||
html = html.replace(/\n{2,}/g, '</p><p class="my-2 leading-relaxed text-neutral-700 dark:text-neutral-300">');
|
||||
|
||||
return `<div class="prose-clean space-y-1">${html}</div>`;
|
||||
}
|
||||
|
||||
/** Basic HTML to Markdown converter */
|
||||
export function htmlToMarkdown(html: string): string {
|
||||
if (!html.trim()) return '';
|
||||
return html
|
||||
.replace(/<h1[^>]*>(.*?)<\/h1>/gi, '# $1\n\n')
|
||||
.replace(/<h2[^>]*>(.*?)<\/h2>/gi, '## $1\n\n')
|
||||
.replace(/<h3[^>]*>(.*?)<\/h3>/gi, '### $1\n\n')
|
||||
.replace(/<h4[^>]*>(.*?)<\/h4>/gi, '#### $1\n\n')
|
||||
.replace(/<h5[^>]*>(.*?)<\/h5>/gi, '##### $1\n\n')
|
||||
.replace(/<h6[^>]*>(.*?)<\/h6>/gi, '###### $1\n\n')
|
||||
.replace(/<strong[^>]*>(.*?)<\/strong>/gi, '**$1**')
|
||||
.replace(/<b[^>]*>(.*?)<\/b>/gi, '**$1**')
|
||||
.replace(/<em[^>]*>(.*?)<\/em>/gi, '*$1*')
|
||||
.replace(/<i[^>]*>(.*?)<\/i>/gi, '*$1*')
|
||||
.replace(/<del[^>]*>(.*?)<\/del>/gi, '~~$1~~')
|
||||
.replace(/<code[^>]*>(.*?)<\/code>/gi, '`$1`')
|
||||
.replace(/<pre[^>]*><code[^>]*>([\s\S]*?)<\/code><\/pre>/gi, '```\n$1\n```\n\n')
|
||||
.replace(/<blockquote[^>]*>(.*?)<\/blockquote>/gi, '> $1\n\n')
|
||||
.replace(/<a\s+[^>]*href="([^"]*)"[^>]*>(.*?)<\/a>/gi, '[$2]($1)')
|
||||
.replace(/<img\s+[^>]*src="([^"]*)"[^>]*alt="([^"]*)"[^>]*\/?>/gi, '')
|
||||
.replace(/<li[^>]*>(.*?)<\/li>/gi, '- $1\n')
|
||||
.replace(/<hr\s*\/?>/gi, '---\n\n')
|
||||
.replace(/<p[^>]*>(.*?)<\/p>/gi, '$1\n\n')
|
||||
.replace(/<br\s*\/?>/gi, '\n')
|
||||
.replace(/<[^>]+>/g, '')
|
||||
.trim();
|
||||
}
|
||||
|
||||
/** Parse CSV / TSV string into an array of objects or rows */
|
||||
export interface CsvParseResult {
|
||||
headers: string[];
|
||||
rows: Record<string, string | number | boolean | null>[];
|
||||
rawRows: string[][];
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export function parseCsv(
|
||||
csvText: string,
|
||||
delimiter: string = ',',
|
||||
hasHeader: boolean = true
|
||||
): CsvParseResult {
|
||||
if (!csvText || !csvText.trim()) {
|
||||
return { headers: [], rows: [], rawRows: [] };
|
||||
}
|
||||
|
||||
// Tokenize CSV according to RFC 4180
|
||||
const rows: string[][] = [];
|
||||
let currentRow: string[] = [];
|
||||
let currentVal = '';
|
||||
let insideQuote = false;
|
||||
|
||||
const d = delimiter;
|
||||
for (let i = 0; i < csvText.length; i++) {
|
||||
const char = csvText[i];
|
||||
const nextChar = csvText[i + 1];
|
||||
|
||||
if (char === '"') {
|
||||
if (insideQuote && nextChar === '"') {
|
||||
currentVal += '"';
|
||||
i++; // skip escaped quote
|
||||
} else {
|
||||
insideQuote = !insideQuote;
|
||||
}
|
||||
} else if (char === d && !insideQuote) {
|
||||
currentRow.push(currentVal.trim());
|
||||
currentVal = '';
|
||||
} else if ((char === '\r' || char === '\n') && !insideQuote) {
|
||||
if (char === '\r' && nextChar === '\n') {
|
||||
i++;
|
||||
}
|
||||
currentRow.push(currentVal.trim());
|
||||
currentVal = '';
|
||||
if (currentRow.some((c) => c !== '')) {
|
||||
rows.push(currentRow);
|
||||
}
|
||||
currentRow = [];
|
||||
} else {
|
||||
currentVal += char;
|
||||
}
|
||||
}
|
||||
|
||||
if (currentVal || currentRow.length > 0) {
|
||||
currentRow.push(currentVal.trim());
|
||||
if (currentRow.some((c) => c !== '')) {
|
||||
rows.push(currentRow);
|
||||
}
|
||||
}
|
||||
|
||||
if (rows.length === 0) {
|
||||
return { headers: [], rows: [], rawRows: [] };
|
||||
}
|
||||
|
||||
let headers: string[] = [];
|
||||
let dataRows: string[][] = [];
|
||||
|
||||
if (hasHeader) {
|
||||
headers = rows[0].map((h, idx) => (h ? h : `col_${idx + 1}`));
|
||||
dataRows = rows.slice(1);
|
||||
} else {
|
||||
const maxCols = Math.max(...rows.map((r) => r.length));
|
||||
headers = Array.from({ length: maxCols }, (_, idx) => `col_${idx + 1}`);
|
||||
dataRows = rows;
|
||||
}
|
||||
|
||||
const objects: Record<string, string | number | boolean | null>[] = dataRows.map((row) => {
|
||||
const obj: Record<string, string | number | boolean | null> = {};
|
||||
headers.forEach((header, colIndex) => {
|
||||
const val = row[colIndex] ?? '';
|
||||
if (val === '') {
|
||||
obj[header] = '';
|
||||
} else if (val.toLowerCase() === 'true') {
|
||||
obj[header] = true;
|
||||
} else if (val.toLowerCase() === 'false') {
|
||||
obj[header] = false;
|
||||
} else if (val.toLowerCase() === 'null') {
|
||||
obj[header] = null;
|
||||
} else if (!isNaN(Number(val)) && val.trim() !== '') {
|
||||
obj[header] = Number(val);
|
||||
} else {
|
||||
obj[header] = val;
|
||||
}
|
||||
});
|
||||
return obj;
|
||||
});
|
||||
|
||||
return { headers, rows: objects, rawRows: rows };
|
||||
}
|
||||
|
||||
/** Convert JSON array of objects to CSV/TSV */
|
||||
export function jsonToCsv(jsonInput: string, delimiter: string = ','): { csv: string; error?: string } {
|
||||
try {
|
||||
const parsed = JSON.parse(jsonInput);
|
||||
let items: Record<string, unknown>[] = [];
|
||||
if (Array.isArray(parsed)) {
|
||||
items = parsed.filter((item): item is Record<string, unknown> => typeof item === 'object' && item !== null);
|
||||
} else if (typeof parsed === 'object' && parsed !== null) {
|
||||
items = [parsed as Record<string, unknown>];
|
||||
} else {
|
||||
return { csv: '', error: 'Input JSON must be an array of objects or an object.' };
|
||||
}
|
||||
|
||||
if (items.length === 0) return { csv: '' };
|
||||
|
||||
// Extract all unique keys
|
||||
const headersSet = new Set<string>();
|
||||
items.forEach((item) => {
|
||||
Object.keys(item).forEach((k) => headersSet.add(k));
|
||||
});
|
||||
const headers = Array.from(headersSet);
|
||||
if (headers.length === 0) return { csv: '', error: 'No properties found in JSON objects.' };
|
||||
|
||||
const formatCell = (val: unknown): string => {
|
||||
if (val === null || val === undefined) return '';
|
||||
let str = typeof val === 'object' ? JSON.stringify(val) : String(val);
|
||||
if (str.includes(delimiter) || str.includes('"') || str.includes('\n') || str.includes('\r')) {
|
||||
str = '"' + str.replace(/"/g, '""') + '"';
|
||||
}
|
||||
return str;
|
||||
};
|
||||
|
||||
const headerLine = headers.map(formatCell).join(delimiter);
|
||||
const rowLines = items.map((item) => {
|
||||
return headers
|
||||
.map((h) => {
|
||||
const val = item[h];
|
||||
return formatCell(val);
|
||||
})
|
||||
.join(delimiter);
|
||||
});
|
||||
|
||||
return { csv: [headerLine, ...rowLines].join('\n') };
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : 'Invalid JSON format';
|
||||
return { csv: '', error: message };
|
||||
}
|
||||
}
|
||||
|
||||
export interface HashResult {
|
||||
sha256: string;
|
||||
sha512: string;
|
||||
sha384: string;
|
||||
sha1: string;
|
||||
md5: string;
|
||||
}
|
||||
|
||||
/** Lightweight RFC 1321 MD5 implementation */
|
||||
export function md5(input: string | Uint8Array): string {
|
||||
const bytes = typeof input === 'string' ? new TextEncoder().encode(input) : input;
|
||||
|
||||
function safeAdd(x: number, y: number) {
|
||||
const lsw = (x & 0xffff) + (y & 0xffff);
|
||||
const msw = (x >> 16) + (y >> 16) + (lsw >> 16);
|
||||
return (msw << 16) | (lsw & 0xffff);
|
||||
}
|
||||
function bitRol(num: number, cnt: number) {
|
||||
return (num << cnt) | (num >>> (32 - cnt));
|
||||
}
|
||||
function cmn(q: number, a: number, b: number, x: number, s: number, t: number) {
|
||||
return safeAdd(bitRol(safeAdd(safeAdd(a, q), safeAdd(x, t)), s), b);
|
||||
}
|
||||
function ff(a: number, b: number, c: number, d: number, x: number, s: number, t: number) {
|
||||
return cmn((b & c) | (~b & d), a, b, x, s, t);
|
||||
}
|
||||
function gg(a: number, b: number, c: number, d: number, x: number, s: number, t: number) {
|
||||
return cmn((b & d) | (c & ~d), a, b, x, s, t);
|
||||
}
|
||||
function hh(a: number, b: number, c: number, d: number, x: number, s: number, t: number) {
|
||||
return cmn(b ^ c ^ d, a, b, x, s, t);
|
||||
}
|
||||
function ii(a: number, b: number, c: number, d: number, x: number, s: number, t: number) {
|
||||
return cmn(c ^ (b | ~d), a, b, x, s, t);
|
||||
}
|
||||
|
||||
const n = bytes.length;
|
||||
const words: number[] = [];
|
||||
for (let i = 0; i < n; i++) {
|
||||
words[i >> 2] |= bytes[i] << ((i % 4) * 8);
|
||||
}
|
||||
words[n >> 2] |= 0x80 << ((n % 4) * 8);
|
||||
words[(((n + 8) >> 6) << 4) + 14] = n * 8;
|
||||
|
||||
let a = 1732584193;
|
||||
let b = -271733879;
|
||||
let c = -1732584194;
|
||||
let d = 271733878;
|
||||
|
||||
for (let i = 0; i < words.length; i += 16) {
|
||||
const olda = a, oldb = b, oldc = c, oldd = d;
|
||||
|
||||
a = ff(a, b, c, d, words[i] || 0, 7, -680876936);
|
||||
d = ff(d, a, b, c, words[i + 1] || 0, 12, -389564586);
|
||||
c = ff(c, d, a, b, words[i + 2] || 0, 17, 606105819);
|
||||
b = ff(b, c, d, a, words[i + 3] || 0, 22, -1044525330);
|
||||
a = ff(a, b, c, d, words[i + 4] || 0, 7, -176418897);
|
||||
d = ff(d, a, b, c, words[i + 5] || 0, 12, 1200080426);
|
||||
c = ff(c, d, a, b, words[i + 6] || 0, 17, -1473231341);
|
||||
b = ff(b, c, d, a, words[i + 7] || 0, 22, -45705983);
|
||||
a = ff(a, b, c, d, words[i + 8] || 0, 7, 1770035416);
|
||||
d = ff(d, a, b, c, words[i + 9] || 0, 12, -1958414417);
|
||||
c = ff(c, d, a, b, words[i + 10] || 0, 17, -42063);
|
||||
b = ff(b, c, d, a, words[i + 11] || 0, 22, -1990404162);
|
||||
a = ff(a, b, c, d, words[i + 12] || 0, 7, 1804603682);
|
||||
d = ff(d, a, b, c, words[i + 13] || 0, 12, -40341101);
|
||||
c = ff(c, d, a, b, words[i + 14] || 0, 17, -1502002290);
|
||||
b = ff(b, c, d, a, words[i + 15] || 0, 22, 1236535329);
|
||||
|
||||
a = gg(a, b, c, d, words[i + 1] || 0, 5, -165796510);
|
||||
d = gg(d, a, b, c, words[i + 6] || 0, 9, -1069501632);
|
||||
c = gg(c, d, a, b, words[i + 11] || 0, 14, 643717713);
|
||||
b = gg(b, c, d, a, words[i] || 0, 20, -373897302);
|
||||
a = gg(a, b, c, d, words[i + 5] || 0, 5, -701558691);
|
||||
d = gg(d, a, b, c, words[i + 10] || 0, 9, 38016083);
|
||||
c = gg(c, d, a, b, words[i + 15] || 0, 14, -660478335);
|
||||
b = gg(b, c, d, a, words[i + 4] || 0, 20, -405537848);
|
||||
a = gg(a, b, c, d, words[i + 9] || 0, 5, 568446438);
|
||||
d = gg(d, a, b, c, words[i + 14] || 0, 9, -1019803690);
|
||||
c = gg(c, d, a, b, words[i + 3] || 0, 14, -187363961);
|
||||
b = gg(b, c, d, a, words[i + 8] || 0, 20, 1163531501);
|
||||
a = gg(a, b, c, d, words[i + 13] || 0, 5, -1444681467);
|
||||
d = gg(d, a, b, c, words[i + 2] || 0, 9, -51403784);
|
||||
c = gg(c, d, a, b, words[i + 7] || 0, 14, 1735328473);
|
||||
b = gg(b, c, d, a, words[i + 12] || 0, 20, -1926607734);
|
||||
|
||||
a = hh(a, b, c, d, words[i + 5] || 0, 4, -378558);
|
||||
d = hh(d, a, b, c, words[i + 8] || 0, 11, -2022574463);
|
||||
c = hh(c, d, a, b, words[i + 11] || 0, 16, 1839030562);
|
||||
b = hh(b, c, d, a, words[i + 14] || 0, 23, -35309556);
|
||||
a = hh(a, b, c, d, words[i + 1] || 0, 4, -1530992060);
|
||||
d = hh(d, a, b, c, words[i + 4] || 0, 11, 1272893353);
|
||||
c = hh(c, d, a, b, words[i + 7] || 0, 16, -155497632);
|
||||
b = hh(b, c, d, a, words[i + 10] || 0, 23, -1094730640);
|
||||
a = hh(a, b, c, d, words[i + 13] || 0, 4, 681279174);
|
||||
d = hh(d, a, b, c, words[i] || 0, 11, -358537222);
|
||||
c = hh(c, d, a, b, words[i + 3] || 0, 16, -722521979);
|
||||
b = hh(b, c, d, a, words[i + 6] || 0, 23, 76029189);
|
||||
a = hh(a, b, c, d, words[i + 9] || 0, 4, -640364487);
|
||||
d = hh(d, a, b, c, words[i + 12] || 0, 11, -421815835);
|
||||
c = hh(c, d, a, b, words[i + 15] || 0, 16, 530742520);
|
||||
b = hh(b, c, d, a, words[i + 2] || 0, 23, -995338651);
|
||||
|
||||
a = ii(a, b, c, d, words[i] || 0, 6, -198630844);
|
||||
d = ii(d, a, b, c, words[i + 7] || 0, 10, 1126891415);
|
||||
c = ii(c, d, a, b, words[i + 14] || 0, 15, -1416354905);
|
||||
b = ii(b, c, d, a, words[i + 5] || 0, 21, -57434055);
|
||||
a = ii(a, b, c, d, words[i + 12] || 0, 6, 1700485571);
|
||||
d = ii(d, a, b, c, words[i + 3] || 0, 10, -1894986606);
|
||||
c = ii(c, d, a, b, words[i + 10] || 0, 15, -1051523);
|
||||
b = ii(b, c, d, a, words[i + 1] || 0, 21, -2054922799);
|
||||
a = ii(a, b, c, d, words[i + 8] || 0, 6, 1873313359);
|
||||
d = ii(d, a, b, c, words[i + 15] || 0, 10, -30611744);
|
||||
c = ii(c, d, a, b, words[i + 6] || 0, 15, -1560198380);
|
||||
b = ii(b, c, d, a, words[i + 13] || 0, 21, 1309151649);
|
||||
a = ii(a, b, c, d, words[i + 4] || 0, 6, -145523070);
|
||||
d = ii(d, a, b, c, words[i + 11] || 0, 10, -1120210379);
|
||||
c = ii(c, d, a, b, words[i + 2] || 0, 15, 718787259);
|
||||
b = ii(b, c, d, a, words[i + 9] || 0, 21, -343485551);
|
||||
|
||||
a = safeAdd(a, olda);
|
||||
b = safeAdd(b, oldb);
|
||||
c = safeAdd(c, oldc);
|
||||
d = safeAdd(d, oldd);
|
||||
}
|
||||
|
||||
function rhex(n: number) {
|
||||
let s = '';
|
||||
for (let j = 0; j <= 3; j++) {
|
||||
s += ((n >> (j * 8 + 4)) & 0x0f).toString(16) + ((n >> (j * 8)) & 0x0f).toString(16);
|
||||
}
|
||||
return s;
|
||||
}
|
||||
return (rhex(a) + rhex(b) + rhex(c) + rhex(d)).toLowerCase();
|
||||
}
|
||||
|
||||
/** Compute SHA-256, SHA-512, SHA-384, SHA-1, and MD5 simultaneously */
|
||||
export async function computeAllHashes(data: string | ArrayBuffer): Promise<HashResult> {
|
||||
const buffer = typeof data === 'string' ? new TextEncoder().encode(data) : data;
|
||||
const uint8 = new Uint8Array(buffer);
|
||||
|
||||
const [sha256Buf, sha512Buf, sha384Buf, sha1Buf] = await Promise.all([
|
||||
crypto.subtle.digest('SHA-256', buffer),
|
||||
crypto.subtle.digest('SHA-512', buffer),
|
||||
crypto.subtle.digest('SHA-384', buffer),
|
||||
crypto.subtle.digest('SHA-1', buffer),
|
||||
]);
|
||||
|
||||
const toHex = (buf: ArrayBuffer) =>
|
||||
Array.from(new Uint8Array(buf))
|
||||
.map((b) => b.toString(16).padStart(2, '0'))
|
||||
.join('');
|
||||
|
||||
return {
|
||||
sha256: toHex(sha256Buf),
|
||||
sha512: toHex(sha512Buf),
|
||||
sha384: toHex(sha384Buf),
|
||||
sha1: toHex(sha1Buf),
|
||||
md5: md5(uint8),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
|
||||
+15
-5
@@ -1,9 +1,19 @@
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import { defineConfig } from 'vite'
|
||||
import tailwindcss from '@tailwindcss/vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import { defineConfig } from 'vite';
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react(), tailwindcss()],
|
||||
})
|
||||
|
||||
build: {
|
||||
rollupOptions: {
|
||||
output: {
|
||||
manualChunks(id) {
|
||||
if (id.includes('node_modules/react') || id.includes('node_modules/react-dom')) {
|
||||
return 'react-vendor';
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in new issue
Block a user