fix: landing page Unicode rendering, orbit overlap, and add PDF OCR fallback
- Fix Unicode escapes (\u26A1, \u2192, \u{1F6E1}) rendered as literal text in JSX
by wrapping them in JSX expressions
- Increase orbit radii (520/320px -> 720/480px) so items don't overlap hero text
- Add orbit animation keyframes (orbit-slow, orbit-med) to globals.css
- Add Tesseract.js OCR fallback for scanned/image-based PDFs that have no
extractable text layer — renders pages to canvas then runs browser-based OCR
- Thread onProgress through all PDF conversion functions for OCR progress
This commit is contained in:
Generated
+110
-2
@@ -29,6 +29,7 @@
|
|||||||
"react": "19.2.3",
|
"react": "19.2.3",
|
||||||
"react-dom": "19.2.3",
|
"react-dom": "19.2.3",
|
||||||
"smol-toml": "^1.6.0",
|
"smol-toml": "^1.6.0",
|
||||||
|
"tesseract.js": "^7.0.0",
|
||||||
"woff2-encoder": "^2.0.0",
|
"woff2-encoder": "^2.0.0",
|
||||||
"xlsx": "^0.18.5"
|
"xlsx": "^0.18.5"
|
||||||
},
|
},
|
||||||
@@ -2862,6 +2863,12 @@
|
|||||||
"integrity": "sha512-iD3898SR7sWVRHbiQv+sHUtHnMvC1o3nW5rAcqnq3uOn07DSAppZYUkIGslDz6gXC7HfunPe7YVBgoEJASPcHA==",
|
"integrity": "sha512-iD3898SR7sWVRHbiQv+sHUtHnMvC1o3nW5rAcqnq3uOn07DSAppZYUkIGslDz6gXC7HfunPe7YVBgoEJASPcHA==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/bmp-js": {
|
||||||
|
"version": "0.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/bmp-js/-/bmp-js-0.1.0.tgz",
|
||||||
|
"integrity": "sha512-vHdS19CnY3hwiNdkaqk93DvjVLfbEcI8mys4UjuWrlX1haDmroo8o4xCzh4wD6DGV6HxRCyauwhHRqMTfERtjw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/brace-expansion": {
|
"node_modules/brace-expansion": {
|
||||||
"version": "1.1.12",
|
"version": "1.1.12",
|
||||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
|
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
|
||||||
@@ -4635,6 +4642,12 @@
|
|||||||
"integrity": "sha512-4EC57ddXrkaF0x83Oj8sM6SLQHAWXw90Skqu2M4AEWENZ3F02dFJE/GARA8igO79tcgYqGrD7ae4f5L3um2lgg==",
|
"integrity": "sha512-4EC57ddXrkaF0x83Oj8sM6SLQHAWXw90Skqu2M4AEWENZ3F02dFJE/GARA8igO79tcgYqGrD7ae4f5L3um2lgg==",
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
"node_modules/idb-keyval": {
|
||||||
|
"version": "6.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/idb-keyval/-/idb-keyval-6.2.2.tgz",
|
||||||
|
"integrity": "sha512-yjD9nARJ/jb1g+CvD0tlhUHOrJ9Sy0P8T9MF3YaLlHnSRpwPfpTX0XIvpmw3gAJUmEu3FiICLBDPXVwyEvrleg==",
|
||||||
|
"license": "Apache-2.0"
|
||||||
|
},
|
||||||
"node_modules/ignore": {
|
"node_modules/ignore": {
|
||||||
"version": "5.3.2",
|
"version": "5.3.2",
|
||||||
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
|
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
|
||||||
@@ -5089,6 +5102,12 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/is-url": {
|
||||||
|
"version": "1.2.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/is-url/-/is-url-1.2.4.tgz",
|
||||||
|
"integrity": "sha512-ITvGim8FhRiYe4IQ5uHSkj7pVaPDrCTkNd3yq3cV7iZAcJdHTUMPMEHcqSOy9xZ9qFenQCvi+2wjH9a1nXqHww==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/is-weakmap": {
|
"node_modules/is-weakmap": {
|
||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz",
|
||||||
@@ -5946,6 +5965,26 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/node-fetch": {
|
||||||
|
"version": "2.7.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
|
||||||
|
"integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"whatwg-url": "^5.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "4.x || >=6.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"encoding": "^0.1.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"encoding": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/node-readable-to-web-readable-stream": {
|
"node_modules/node-readable-to-web-readable-stream": {
|
||||||
"version": "0.4.2",
|
"version": "0.4.2",
|
||||||
"resolved": "https://registry.npmjs.org/node-readable-to-web-readable-stream/-/node-readable-to-web-readable-stream-0.4.2.tgz",
|
"resolved": "https://registry.npmjs.org/node-readable-to-web-readable-stream/-/node-readable-to-web-readable-stream-0.4.2.tgz",
|
||||||
@@ -6083,6 +6122,15 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/opencollective-postinstall": {
|
||||||
|
"version": "2.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/opencollective-postinstall/-/opencollective-postinstall-2.0.3.tgz",
|
||||||
|
"integrity": "sha512-8AV/sCtuzUeTo8gQK5qDZzARrulB3egtLzFgteqB2tcT4Mw7B8Kt7JcDHmltjz6FOAHsvTevk70gZEbhM4ZS9Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"bin": {
|
||||||
|
"opencollective-postinstall": "index.js"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/opentype.js": {
|
"node_modules/opentype.js": {
|
||||||
"version": "1.3.4",
|
"version": "1.3.4",
|
||||||
"resolved": "https://registry.npmjs.org/opentype.js/-/opentype.js-1.3.4.tgz",
|
"resolved": "https://registry.npmjs.org/opentype.js/-/opentype.js-1.3.4.tgz",
|
||||||
@@ -6487,8 +6535,7 @@
|
|||||||
"version": "0.13.11",
|
"version": "0.13.11",
|
||||||
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz",
|
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz",
|
||||||
"integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==",
|
"integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==",
|
||||||
"license": "MIT",
|
"license": "MIT"
|
||||||
"optional": true
|
|
||||||
},
|
},
|
||||||
"node_modules/regexp.prototype.flags": {
|
"node_modules/regexp.prototype.flags": {
|
||||||
"version": "1.5.4",
|
"version": "1.5.4",
|
||||||
@@ -7208,6 +7255,30 @@
|
|||||||
"url": "https://opencollective.com/webpack"
|
"url": "https://opencollective.com/webpack"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/tesseract.js": {
|
||||||
|
"version": "7.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/tesseract.js/-/tesseract.js-7.0.0.tgz",
|
||||||
|
"integrity": "sha512-exPBkd+z+wM1BuMkx/Bjv43OeLBxhL5kKWsz/9JY+DXcXdiBjiAch0V49QR3oAJqCaL5qURE0vx9Eo+G5YE7mA==",
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"bmp-js": "^0.1.0",
|
||||||
|
"idb-keyval": "^6.2.0",
|
||||||
|
"is-url": "^1.2.4",
|
||||||
|
"node-fetch": "^2.6.9",
|
||||||
|
"opencollective-postinstall": "^2.0.3",
|
||||||
|
"regenerator-runtime": "^0.13.3",
|
||||||
|
"tesseract.js-core": "^7.0.0",
|
||||||
|
"wasm-feature-detect": "^1.8.0",
|
||||||
|
"zlibjs": "^0.3.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/tesseract.js-core": {
|
||||||
|
"version": "7.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/tesseract.js-core/-/tesseract.js-core-7.0.0.tgz",
|
||||||
|
"integrity": "sha512-WnNH518NzmbSq9zgTPeoF8c+xmilS8rFIl1YKbk/ptuuc7p6cLNELNuPAzcmsYw450ca6bLa8j3t0VAtq435Vw==",
|
||||||
|
"license": "Apache-2.0"
|
||||||
|
},
|
||||||
"node_modules/text-segmentation": {
|
"node_modules/text-segmentation": {
|
||||||
"version": "1.0.3",
|
"version": "1.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/text-segmentation/-/text-segmentation-1.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/text-segmentation/-/text-segmentation-1.0.3.tgz",
|
||||||
@@ -7284,6 +7355,12 @@
|
|||||||
"node": ">=8.0"
|
"node": ">=8.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/tr46": {
|
||||||
|
"version": "0.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
|
||||||
|
"integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/ts-api-utils": {
|
"node_modules/ts-api-utils": {
|
||||||
"version": "2.4.0",
|
"version": "2.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.4.0.tgz",
|
||||||
@@ -7580,6 +7657,28 @@
|
|||||||
"base64-arraybuffer": "^1.0.2"
|
"base64-arraybuffer": "^1.0.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/wasm-feature-detect": {
|
||||||
|
"version": "1.8.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/wasm-feature-detect/-/wasm-feature-detect-1.8.0.tgz",
|
||||||
|
"integrity": "sha512-zksaLKM2fVlnB5jQQDqKXXwYHLQUVH9es+5TOOHwGOVJOCeRBCiPjwSg+3tN2AdTCzjgli4jijCH290kXb/zWQ==",
|
||||||
|
"license": "Apache-2.0"
|
||||||
|
},
|
||||||
|
"node_modules/webidl-conversions": {
|
||||||
|
"version": "3.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
|
||||||
|
"integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==",
|
||||||
|
"license": "BSD-2-Clause"
|
||||||
|
},
|
||||||
|
"node_modules/whatwg-url": {
|
||||||
|
"version": "5.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
|
||||||
|
"integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"tr46": "~0.0.3",
|
||||||
|
"webidl-conversions": "^3.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/which": {
|
"node_modules/which": {
|
||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||||
@@ -7790,6 +7889,15 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/zlibjs": {
|
||||||
|
"version": "0.3.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/zlibjs/-/zlibjs-0.3.1.tgz",
|
||||||
|
"integrity": "sha512-+J9RrgTKOmlxFSDHo0pI1xM6BLVUv+o0ZT9ANtCxGkjIVCCUdx9alUF8Gm+dGLKbkkkidWIHFDZHDMpfITt4+w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/zod": {
|
"node_modules/zod": {
|
||||||
"version": "4.3.6",
|
"version": "4.3.6",
|
||||||
"resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz",
|
"resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz",
|
||||||
|
|||||||
@@ -30,6 +30,7 @@
|
|||||||
"react": "19.2.3",
|
"react": "19.2.3",
|
||||||
"react-dom": "19.2.3",
|
"react-dom": "19.2.3",
|
||||||
"smol-toml": "^1.6.0",
|
"smol-toml": "^1.6.0",
|
||||||
|
"tesseract.js": "^7.0.0",
|
||||||
"woff2-encoder": "^2.0.0",
|
"woff2-encoder": "^2.0.0",
|
||||||
"xlsx": "^0.18.5"
|
"xlsx": "^0.18.5"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -71,6 +71,33 @@ body {
|
|||||||
animation: pulse-soft 2s ease-in-out infinite;
|
animation: pulse-soft 2s ease-in-out infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---- Orbit animations for landing page constellation ---- */
|
||||||
|
@keyframes orbit-slow {
|
||||||
|
from { transform: rotate(0deg); }
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes orbit-med {
|
||||||
|
from { transform: rotate(0deg); }
|
||||||
|
to { transform: rotate(-360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-orbit-slow {
|
||||||
|
animation: orbit-slow 60s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-counter-orbit-slow {
|
||||||
|
animation: orbit-slow 60s linear infinite reverse;
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-orbit-med {
|
||||||
|
animation: orbit-med 40s linear infinite reverse;
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-counter-orbit-med {
|
||||||
|
animation: orbit-med 40s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
/* ---- Dot pattern background ---- */
|
/* ---- Dot pattern background ---- */
|
||||||
.bg-dots {
|
.bg-dots {
|
||||||
background-image: radial-gradient(circle, rgba(180, 140, 100, 0.12) 1px, transparent 1px);
|
background-image: radial-gradient(circle, rgba(180, 140, 100, 0.12) 1px, transparent 1px);
|
||||||
|
|||||||
+327
-60
@@ -1,65 +1,103 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { motion } from 'framer-motion';
|
import { useState, useEffect } from 'react';
|
||||||
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
|
||||||
const floatingBadges = [
|
/* ─── Orbiting Constellation Data ─── */
|
||||||
{ label: 'PNG', color: 'bg-pink-200', dot: 'bg-pink-400', top: '18%', left: '8%', delay: 0 },
|
|
||||||
{ label: 'MP4', color: 'bg-orange-100', dot: 'bg-orange-400', top: '22%', right: '10%', delay: 0.5 },
|
const orbitItems = [
|
||||||
{ label: 'CSV', color: 'bg-emerald-100', dot: 'bg-emerald-400', bottom: '32%', left: '6%', delay: 1.0 },
|
{ icon: '\u{1F5BC}', label: 'PNG', angle: 0 },
|
||||||
{ label: 'PDF', color: 'bg-blue-100', dot: 'bg-blue-400', bottom: '28%', right: '8%', delay: 0.3 },
|
{ icon: '\u{1F3B5}', label: 'MP3', angle: 45 },
|
||||||
{ label: 'WAV', color: 'bg-purple-100', dot: 'bg-purple-400', top: '42%', left: '3%', delay: 0.7 },
|
{ icon: '\u{1F4C4}', label: 'PDF', angle: 90 },
|
||||||
{ label: 'WEBP', color: 'bg-pink-100', dot: 'bg-pink-400', top: '35%', right: '4%', delay: 1.2 },
|
{ icon: '\u{1F3AC}', label: 'MP4', angle: 135 },
|
||||||
|
{ icon: '\u{1F4CA}', label: 'CSV', angle: 180 },
|
||||||
|
{ icon: '\u{1F310}', label: 'SVG', angle: 225 },
|
||||||
|
{ icon: '\u{1F4D6}', label: 'EPUB', angle: 270 },
|
||||||
|
{ icon: '\u{1F3A8}', label: 'PSD', angle: 315 },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const innerOrbitItems = [
|
||||||
|
{ icon: '\u{2728}', label: 'WebP', angle: 30 },
|
||||||
|
{ icon: '\u{1F4DD}', label: 'DOCX', angle: 120 },
|
||||||
|
{ icon: '\u{1F4BE}', label: 'JSON', angle: 210 },
|
||||||
|
{ icon: '\u{1F399}', label: 'WAV', angle: 300 },
|
||||||
|
];
|
||||||
|
|
||||||
|
/* ─── Format Ticker Data ─── */
|
||||||
|
|
||||||
|
const conversionPairs = [
|
||||||
|
{ from: 'PNG', to: 'WebP', icon: '\u{1F5BC}', color: '#f472b6' },
|
||||||
|
{ from: 'DOCX', to: 'PDF', icon: '\u{1F4C4}', color: '#60a5fa' },
|
||||||
|
{ from: 'MP4', to: 'GIF', icon: '\u{1F3AC}', color: '#fb923c' },
|
||||||
|
{ from: 'CSV', to: 'JSON', icon: '\u{1F4CA}', color: '#34d399' },
|
||||||
|
{ from: 'WAV', to: 'MP3', icon: '\u{1F3B5}', color: '#a78bfa' },
|
||||||
|
{ from: 'HEIC', to: 'JPG', icon: '\u{1F4F7}', color: '#f472b6' },
|
||||||
|
{ from: 'XLSX', to: 'CSV', icon: '\u{1F4CA}', color: '#34d399' },
|
||||||
|
{ from: 'TTF', to: 'WOFF2', icon: '\u{1F524}', color: '#2dd4bf' },
|
||||||
|
{ from: 'EPUB', to: 'PDF', icon: '\u{1F4D6}', color: '#60a5fa' },
|
||||||
|
{ from: 'YAML', to: 'JSON', icon: '\u{2699}', color: '#34d399' },
|
||||||
|
{ from: 'PSD', to: 'PNG', icon: '\u{1F3A8}', color: '#f472b6' },
|
||||||
|
{ from: 'MKV', to: 'MP4', icon: '\u{1F39E}', color: '#fb923c' },
|
||||||
|
];
|
||||||
|
|
||||||
|
/* ─── Conversion Flow Data ─── */
|
||||||
|
|
||||||
|
const flowSteps = [
|
||||||
|
{ inputIcon: '\u{1F5BC}', inputLabel: '.PNG', outputIcon: '\u{2728}', outputLabel: '.WebP' },
|
||||||
|
{ inputIcon: '\u{1F4C4}', inputLabel: '.DOCX', outputIcon: '\u{1F4D1}', outputLabel: '.PDF' },
|
||||||
|
{ inputIcon: '\u{1F3AC}', inputLabel: '.MKV', outputIcon: '\u{1F4F1}', outputLabel: '.MP4' },
|
||||||
|
{ inputIcon: '\u{1F4CA}', inputLabel: '.CSV', outputIcon: '\u{1F4CB}', outputLabel: '.JSON' },
|
||||||
|
{ inputIcon: '\u{1F3B5}', inputLabel: '.FLAC', outputIcon: '\u{1F3A7}', outputLabel: '.MP3' },
|
||||||
|
];
|
||||||
|
|
||||||
|
/* ─── Features ─── */
|
||||||
|
|
||||||
const features = [
|
const features = [
|
||||||
{
|
{
|
||||||
icon: '\u{1F5BC}',
|
icon: '\u{1F5BC}',
|
||||||
title: 'Images',
|
title: 'Images',
|
||||||
desc: 'PNG, JPG, WebP, GIF, BMP, AVIF, SVG \u2014 convert between any format using Canvas API.',
|
desc: 'PNG, JPG, WebP, GIF, BMP, AVIF, SVG, PSD, HEIC \u2014 convert between any format.',
|
||||||
bg: 'bg-pink-50',
|
|
||||||
iconBg: 'bg-pink-100',
|
iconBg: 'bg-pink-100',
|
||||||
formats: ['PNG', 'JPG', 'WebP', 'GIF', 'AVIF', 'SVG'],
|
formats: ['PNG', 'JPG', 'WebP', 'GIF', 'AVIF', 'SVG', 'PSD', 'HEIC'],
|
||||||
wide: true,
|
wide: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: '\u{1F4C4}',
|
icon: '\u{1F4C4}',
|
||||||
title: 'Documents',
|
title: 'Documents',
|
||||||
desc: 'DOCX, PDF, Markdown, HTML, TXT \u2014 preserves formatting with styled rendering.',
|
desc: 'DOCX, PDF, Markdown, HTML, TXT, PPTX, EPUB \u2014 preserves formatting.',
|
||||||
bg: 'bg-blue-50',
|
|
||||||
iconBg: 'bg-blue-100',
|
iconBg: 'bg-blue-100',
|
||||||
formats: ['DOCX', 'PDF', 'MD', 'HTML', 'TXT'],
|
formats: ['DOCX', 'PDF', 'MD', 'HTML', 'TXT', 'PPTX', 'EPUB'],
|
||||||
wide: false,
|
wide: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: '\u{1F3B5}',
|
icon: '\u{1F3B5}',
|
||||||
title: 'Audio',
|
title: 'Audio',
|
||||||
desc: 'MP3, WAV, OGG, AAC, FLAC, M4A \u2014 powered by FFmpeg WebAssembly.',
|
desc: 'MP3, WAV, OGG, AAC, FLAC, M4A \u2014 powered by FFmpeg WebAssembly.',
|
||||||
bg: 'bg-purple-50',
|
|
||||||
iconBg: 'bg-purple-100',
|
iconBg: 'bg-purple-100',
|
||||||
formats: ['MP3', 'WAV', 'OGG', 'FLAC'],
|
formats: ['MP3', 'WAV', 'OGG', 'FLAC', 'AAC'],
|
||||||
wide: false,
|
wide: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: '\u{1F3AC}',
|
icon: '\u{1F3AC}',
|
||||||
title: 'Video',
|
title: 'Video',
|
||||||
desc: 'MP4, WebM, AVI, MOV, MKV \u2014 full video transcoding in your browser.',
|
desc: 'MP4, WebM, AVI, MOV, MKV \u2014 full video transcoding in your browser.',
|
||||||
bg: 'bg-orange-50',
|
|
||||||
iconBg: 'bg-orange-100',
|
iconBg: 'bg-orange-100',
|
||||||
formats: ['MP4', 'WebM', 'AVI', 'MOV'],
|
formats: ['MP4', 'WebM', 'AVI', 'MOV', 'MKV'],
|
||||||
wide: false,
|
wide: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: '\u{1F4CA}',
|
icon: '\u{1F4CA}',
|
||||||
title: 'Data',
|
title: 'Data & Fonts',
|
||||||
desc: 'CSV, JSON, XML, YAML, TSV \u2014 smart parsing with structure preservation.',
|
desc: 'CSV, JSON, XML, YAML, XLSX, TTF, OTF, WOFF2 \u2014 smart structure preservation.',
|
||||||
bg: 'bg-emerald-50',
|
|
||||||
iconBg: 'bg-emerald-100',
|
iconBg: 'bg-emerald-100',
|
||||||
formats: ['CSV', 'JSON', 'XML', 'YAML', 'TSV'],
|
formats: ['CSV', 'JSON', 'XML', 'YAML', 'XLSX', 'TTF', 'WOFF2'],
|
||||||
wide: true,
|
wide: true,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/* ─── Animation Variants ─── */
|
||||||
|
|
||||||
const stagger = {
|
const stagger = {
|
||||||
hidden: {},
|
hidden: {},
|
||||||
visible: { transition: { staggerChildren: 0.08 } },
|
visible: { transition: { staggerChildren: 0.08 } },
|
||||||
@@ -74,6 +112,236 @@ const fadeUp = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/* ─── Orbiting Constellation Component ─── */
|
||||||
|
|
||||||
|
function OrbitingConstellation() {
|
||||||
|
return (
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center pointer-events-none select-none opacity-50">
|
||||||
|
{/* Center glow */}
|
||||||
|
<div className="absolute w-20 h-20 rounded-full bg-pink/8 blur-xl" />
|
||||||
|
<div className="absolute w-10 h-10 rounded-full bg-purple/10 blur-lg" />
|
||||||
|
|
||||||
|
{/* Outer orbit ring (visual) */}
|
||||||
|
<div className="absolute w-[720px] h-[720px] rounded-full border border-border-soft/40 hidden md:block" />
|
||||||
|
{/* Inner orbit ring */}
|
||||||
|
<div className="absolute w-[480px] h-[480px] rounded-full border border-border-soft/25 hidden md:block" />
|
||||||
|
|
||||||
|
{/* Outer orbit items */}
|
||||||
|
<div className="absolute w-[720px] h-[720px] hidden md:block animate-orbit-slow">
|
||||||
|
{orbitItems.map((item) => {
|
||||||
|
const rad = (item.angle * Math.PI) / 180;
|
||||||
|
const x = Math.cos(rad) * 360;
|
||||||
|
const y = Math.sin(rad) * 360;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={item.label}
|
||||||
|
className="absolute animate-counter-orbit-slow"
|
||||||
|
style={{
|
||||||
|
left: `calc(50% + ${x}px - 24px)`,
|
||||||
|
top: `calc(50% + ${y}px - 24px)`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col items-center gap-0.5">
|
||||||
|
<div className="w-12 h-12 rounded-2xl bg-white border border-border-soft shadow-[0_2px_8px_rgba(160,120,80,0.06)] flex items-center justify-center text-lg">
|
||||||
|
{item.icon}
|
||||||
|
</div>
|
||||||
|
<span className="font-mono text-[9px] font-bold text-text-light tracking-wider">{item.label}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Inner orbit items */}
|
||||||
|
<div className="absolute w-[480px] h-[480px] hidden md:block animate-orbit-med">
|
||||||
|
{innerOrbitItems.map((item) => {
|
||||||
|
const rad = (item.angle * Math.PI) / 180;
|
||||||
|
const x = Math.cos(rad) * 240;
|
||||||
|
const y = Math.sin(rad) * 240;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={item.label}
|
||||||
|
className="absolute animate-counter-orbit-med"
|
||||||
|
style={{
|
||||||
|
left: `calc(50% + ${x}px - 18px)`,
|
||||||
|
top: `calc(50% + ${y}px - 18px)`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col items-center gap-0.5">
|
||||||
|
<div className="w-9 h-9 rounded-xl bg-white/80 border border-border-soft/60 shadow-[0_1px_4px_rgba(160,120,80,0.04)] flex items-center justify-center text-sm">
|
||||||
|
{item.icon}
|
||||||
|
</div>
|
||||||
|
<span className="font-mono text-[8px] font-bold text-text-light/70 tracking-wider">{item.label}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Format Ticker Component ─── */
|
||||||
|
|
||||||
|
function FormatTicker() {
|
||||||
|
const [index, setIndex] = useState(0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
setIndex((prev) => (prev + 1) % conversionPairs.length);
|
||||||
|
}, 2200);
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const pair = conversionPairs[index];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="inline-flex items-center gap-3 px-5 py-2.5 bg-white border border-border-soft rounded-2xl shadow-[0_2px_8px_rgba(160,120,80,0.06)] min-w-[280px] justify-center">
|
||||||
|
<span className="text-lg">{pair.icon}</span>
|
||||||
|
<div className="flex items-center gap-2 font-mono text-sm font-bold overflow-hidden h-6">
|
||||||
|
<AnimatePresence mode="popLayout">
|
||||||
|
<motion.span
|
||||||
|
key={`from-${index}`}
|
||||||
|
className="text-text-dark"
|
||||||
|
initial={{ y: 20, opacity: 0 }}
|
||||||
|
animate={{ y: 0, opacity: 1 }}
|
||||||
|
exit={{ y: -20, opacity: 0 }}
|
||||||
|
transition={{ duration: 0.35, ease: [0.16, 1, 0.3, 1] as const }}
|
||||||
|
>
|
||||||
|
.{pair.from}
|
||||||
|
</motion.span>
|
||||||
|
</AnimatePresence>
|
||||||
|
<motion.span
|
||||||
|
className="text-text-light"
|
||||||
|
animate={{ x: [0, 4, 0] }}
|
||||||
|
transition={{ duration: 1.2, repeat: Infinity, ease: 'easeInOut' }}
|
||||||
|
>
|
||||||
|
{'\u2192'}
|
||||||
|
</motion.span>
|
||||||
|
<AnimatePresence mode="popLayout">
|
||||||
|
<motion.span
|
||||||
|
key={`to-${index}`}
|
||||||
|
style={{ color: pair.color }}
|
||||||
|
className="font-extrabold"
|
||||||
|
initial={{ y: 20, opacity: 0 }}
|
||||||
|
animate={{ y: 0, opacity: 1 }}
|
||||||
|
exit={{ y: -20, opacity: 0 }}
|
||||||
|
transition={{ duration: 0.35, ease: [0.16, 1, 0.3, 1] as const, delay: 0.08 }}
|
||||||
|
>
|
||||||
|
.{pair.to}
|
||||||
|
</motion.span>
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Conversion Flow Animation ─── */
|
||||||
|
|
||||||
|
function ConversionFlow() {
|
||||||
|
const [step, setStep] = useState(0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
setStep((prev) => (prev + 1) % flowSteps.length);
|
||||||
|
}, 3000);
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const current = flowSteps[step];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
className="relative w-full max-w-[600px] mx-auto py-8"
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
whileInView={{ opacity: 1 }}
|
||||||
|
viewport={{ once: true }}
|
||||||
|
transition={{ duration: 0.6 }}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-center gap-4 sm:gap-8">
|
||||||
|
{/* Input file */}
|
||||||
|
<AnimatePresence mode="popLayout">
|
||||||
|
<motion.div
|
||||||
|
key={`in-${step}`}
|
||||||
|
className="flex flex-col items-center gap-1.5"
|
||||||
|
initial={{ x: -40, opacity: 0, scale: 0.8 }}
|
||||||
|
animate={{ x: 0, opacity: 1, scale: 1 }}
|
||||||
|
exit={{ x: -20, opacity: 0, scale: 0.9 }}
|
||||||
|
transition={{ duration: 0.5, ease: [0.16, 1, 0.3, 1] as const }}
|
||||||
|
>
|
||||||
|
<div className="w-16 h-16 sm:w-20 sm:h-20 rounded-2xl bg-white border-2 border-border-soft shadow-[0_4px_16px_rgba(160,120,80,0.08)] flex items-center justify-center text-2xl sm:text-3xl">
|
||||||
|
{current.inputIcon}
|
||||||
|
</div>
|
||||||
|
<span className="font-mono text-[11px] font-bold text-text-mid">{current.inputLabel}</span>
|
||||||
|
</motion.div>
|
||||||
|
</AnimatePresence>
|
||||||
|
|
||||||
|
{/* Arrow + bolt animation */}
|
||||||
|
<div className="flex items-center gap-2 sm:gap-3">
|
||||||
|
{/* Traveling dots */}
|
||||||
|
<div className="relative w-12 sm:w-20 h-[2px] bg-border-soft overflow-hidden rounded">
|
||||||
|
<motion.div
|
||||||
|
className="absolute top-[-2px] w-2 h-2 rounded-full bg-pink"
|
||||||
|
animate={{ x: [0, 48, 80], opacity: [0, 1, 0] }}
|
||||||
|
transition={{ duration: 1.2, repeat: Infinity, ease: 'easeInOut' }}
|
||||||
|
/>
|
||||||
|
<motion.div
|
||||||
|
className="absolute top-[-2px] w-1.5 h-1.5 rounded-full bg-purple"
|
||||||
|
animate={{ x: [0, 48, 80], opacity: [0, 1, 0] }}
|
||||||
|
transition={{ duration: 1.2, repeat: Infinity, ease: 'easeInOut', delay: 0.4 }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Center bolt */}
|
||||||
|
<motion.div
|
||||||
|
className="w-12 h-12 sm:w-14 sm:h-14 rounded-full bg-white border-2 border-pink/30 shadow-[0_4px_20px_rgba(244,114,182,0.15)] flex items-center justify-center text-xl sm:text-2xl flex-shrink-0"
|
||||||
|
animate={{
|
||||||
|
scale: [1, 1.1, 1],
|
||||||
|
borderColor: ['rgba(244,114,182,0.3)', 'rgba(167,139,250,0.4)', 'rgba(244,114,182,0.3)'],
|
||||||
|
}}
|
||||||
|
transition={{ duration: 2, repeat: Infinity, ease: 'easeInOut' }}
|
||||||
|
>
|
||||||
|
{'\u26A1'}
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
{/* More traveling dots */}
|
||||||
|
<div className="relative w-12 sm:w-20 h-[2px] bg-border-soft overflow-hidden rounded">
|
||||||
|
<motion.div
|
||||||
|
className="absolute top-[-2px] w-2 h-2 rounded-full bg-purple"
|
||||||
|
animate={{ x: [0, 48, 80], opacity: [0, 1, 0] }}
|
||||||
|
transition={{ duration: 1.2, repeat: Infinity, ease: 'easeInOut', delay: 0.6 }}
|
||||||
|
/>
|
||||||
|
<motion.div
|
||||||
|
className="absolute top-[-2px] w-1.5 h-1.5 rounded-full bg-mint"
|
||||||
|
animate={{ x: [0, 48, 80], opacity: [0, 1, 0] }}
|
||||||
|
transition={{ duration: 1.2, repeat: Infinity, ease: 'easeInOut', delay: 1.0 }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Output file */}
|
||||||
|
<AnimatePresence mode="popLayout">
|
||||||
|
<motion.div
|
||||||
|
key={`out-${step}`}
|
||||||
|
className="flex flex-col items-center gap-1.5"
|
||||||
|
initial={{ x: 40, opacity: 0, scale: 0.8 }}
|
||||||
|
animate={{ x: 0, opacity: 1, scale: 1 }}
|
||||||
|
exit={{ x: 20, opacity: 0, scale: 0.9 }}
|
||||||
|
transition={{ duration: 0.5, ease: [0.16, 1, 0.3, 1] as const, delay: 0.15 }}
|
||||||
|
>
|
||||||
|
<div className="w-16 h-16 sm:w-20 sm:h-20 rounded-2xl bg-white border-2 border-mint/30 shadow-[0_4px_16px_rgba(52,211,153,0.12)] flex items-center justify-center text-2xl sm:text-3xl">
|
||||||
|
{current.outputIcon}
|
||||||
|
</div>
|
||||||
|
<span className="font-mono text-[11px] font-bold text-mint">{current.outputLabel}</span>
|
||||||
|
</motion.div>
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Main Page ─── */
|
||||||
|
|
||||||
export default function LandingPage() {
|
export default function LandingPage() {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen relative bg-bg-cream">
|
<div className="min-h-screen relative bg-bg-cream">
|
||||||
@@ -84,6 +352,7 @@ export default function LandingPage() {
|
|||||||
{/* ──── NAV ──── */}
|
{/* ──── NAV ──── */}
|
||||||
<header className="sticky top-0 z-50 flex items-center justify-between px-6 py-4 bg-bg-cream/80 backdrop-blur-xl border-b border-border-soft">
|
<header className="sticky top-0 z-50 flex items-center justify-between px-6 py-4 bg-bg-cream/80 backdrop-blur-xl border-b border-border-soft">
|
||||||
<Link href="/" className="flex items-center gap-2.5 no-underline">
|
<Link href="/" className="flex items-center gap-2.5 no-underline">
|
||||||
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
<img src="/logo.png" alt="Transmute" className="w-8 h-8 rounded-[10px]" />
|
<img src="/logo.png" alt="Transmute" className="w-8 h-8 rounded-[10px]" />
|
||||||
<span className="font-serif font-extrabold text-xl tracking-tight text-text-dark">Transmute</span>
|
<span className="font-serif font-extrabold text-xl tracking-tight text-text-dark">Transmute</span>
|
||||||
</Link>
|
</Link>
|
||||||
@@ -97,27 +366,8 @@ export default function LandingPage() {
|
|||||||
|
|
||||||
{/* ──── HERO ──── */}
|
{/* ──── HERO ──── */}
|
||||||
<section className="relative flex flex-col items-center justify-center min-h-screen px-6 pt-32 pb-20 text-center overflow-hidden">
|
<section className="relative flex flex-col items-center justify-center min-h-screen px-6 pt-32 pb-20 text-center overflow-hidden">
|
||||||
{/* Floating format badges */}
|
{/* Orbiting constellation behind hero text */}
|
||||||
{floatingBadges.map((badge) => (
|
<OrbitingConstellation />
|
||||||
<motion.div
|
|
||||||
key={badge.label}
|
|
||||||
className="absolute hidden md:flex items-center gap-1.5 px-3.5 py-2 bg-white border border-border-soft rounded-2xl font-mono text-xs font-semibold text-text-mid shadow-[0_4px_12px_rgba(160,120,80,0.08)] pointer-events-none select-none"
|
|
||||||
style={{
|
|
||||||
top: badge.top,
|
|
||||||
bottom: badge.bottom,
|
|
||||||
left: badge.left,
|
|
||||||
right: badge.right,
|
|
||||||
} as React.CSSProperties}
|
|
||||||
initial={{ opacity: 0, scale: 0.8 }}
|
|
||||||
animate={{ opacity: 1, scale: 1 }}
|
|
||||||
transition={{ delay: 0.8 + badge.delay, duration: 0.5, ease: 'easeOut' }}
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-1.5">
|
|
||||||
<span className={`w-2 h-2 rounded-sm ${badge.dot}`} />
|
|
||||||
.{badge.label}
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
))}
|
|
||||||
|
|
||||||
<motion.div
|
<motion.div
|
||||||
variants={stagger}
|
variants={stagger}
|
||||||
@@ -152,8 +402,13 @@ export default function LandingPage() {
|
|||||||
right in your browser. No uploads. No accounts. No limits.
|
right in your browser. No uploads. No accounts. No limits.
|
||||||
</motion.p>
|
</motion.p>
|
||||||
|
|
||||||
|
{/* Format Ticker */}
|
||||||
|
<motion.div className="mt-7" variants={fadeUp}>
|
||||||
|
<FormatTicker />
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
{/* CTAs */}
|
{/* CTAs */}
|
||||||
<motion.div className="flex items-center gap-4 mt-10 flex-wrap justify-center" variants={fadeUp}>
|
<motion.div className="flex items-center gap-4 mt-8 flex-wrap justify-center" variants={fadeUp}>
|
||||||
<Link
|
<Link
|
||||||
href="/convert"
|
href="/convert"
|
||||||
className="inline-flex items-center gap-2.5 px-8 py-3.5 text-base font-bold text-white bg-pink rounded-2xl no-underline shadow-[0_4px_24px_rgba(244,114,182,0.3)] hover:shadow-[0_8px_36px_rgba(244,114,182,0.4)] hover:-translate-y-0.5 transition-all"
|
className="inline-flex items-center gap-2.5 px-8 py-3.5 text-base font-bold text-white bg-pink rounded-2xl no-underline shadow-[0_4px_24px_rgba(244,114,182,0.3)] hover:shadow-[0_8px_36px_rgba(244,114,182,0.4)] hover:-translate-y-0.5 transition-all"
|
||||||
@@ -176,6 +431,23 @@ export default function LandingPage() {
|
|||||||
</motion.div>
|
</motion.div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
{/* ──── CONVERSION FLOW ──── */}
|
||||||
|
<section className="relative z-10 flex flex-col items-center px-6 -mt-10 mb-4">
|
||||||
|
<motion.div
|
||||||
|
className="text-center flex flex-col items-center gap-2 mb-2"
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
whileInView={{ opacity: 1, y: 0 }}
|
||||||
|
viewport={{ once: true, margin: '-80px' }}
|
||||||
|
transition={{ duration: 0.6 }}
|
||||||
|
>
|
||||||
|
<span className="inline-flex items-center gap-2 px-3.5 py-1.5 bg-purple/10 rounded-full font-mono text-[11px] font-semibold uppercase tracking-wider text-purple">
|
||||||
|
Live Preview
|
||||||
|
</span>
|
||||||
|
<p className="text-sm text-text-mid">Watch files transform in real time</p>
|
||||||
|
</motion.div>
|
||||||
|
<ConversionFlow />
|
||||||
|
</section>
|
||||||
|
|
||||||
{/* ──── FEATURES ──── */}
|
{/* ──── FEATURES ──── */}
|
||||||
<section
|
<section
|
||||||
id="features"
|
id="features"
|
||||||
@@ -195,7 +467,7 @@ export default function LandingPage() {
|
|||||||
Every format you need
|
Every format you need
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-[17px] text-text-mid leading-relaxed max-w-[520px]">
|
<p className="text-[17px] text-text-mid leading-relaxed max-w-[520px]">
|
||||||
40+ file formats across 5 categories, all converted instantly with zero quality loss.
|
70+ file formats across 5 categories, all converted instantly with zero quality loss.
|
||||||
</p>
|
</p>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
@@ -257,15 +529,16 @@ export default function LandingPage() {
|
|||||||
viewport={{ once: true, margin: '-60px' }}
|
viewport={{ once: true, margin: '-60px' }}
|
||||||
>
|
>
|
||||||
{[
|
{[
|
||||||
{ num: '1', title: 'Drop your files', desc: 'Drag and drop any file \u2014 or click to browse. We accept everything.' },
|
{ num: '1', icon: '\u{1F4E5}', title: 'Drop your files', desc: 'Drag and drop any file \u2014 or click to browse. We accept everything.' },
|
||||||
{ num: '2', title: 'Pick a format', desc: 'Choose your target format from smart suggestions based on file type.' },
|
{ num: '2', icon: '\u{2699}', title: 'Pick a format', desc: 'Choose your target format from smart suggestions based on file type.' },
|
||||||
{ num: '3', title: 'Download', desc: 'Hit convert and download instantly. Files never leave your browser.' },
|
{ num: '3', icon: '\u{2B07}', title: 'Download', desc: 'Hit convert and download instantly. Files never leave your browser.' },
|
||||||
].map((step, i) => (
|
].map((step, i) => (
|
||||||
<motion.div
|
<motion.div
|
||||||
key={step.num}
|
key={step.num}
|
||||||
className="flex-1 relative bg-white border border-border-soft rounded-3xl p-8 text-center shadow-[0_1px_3px_rgba(160,120,80,0.06)] hover:shadow-[0_12px_32px_rgba(160,120,80,0.1)] hover:-translate-y-1 transition-all duration-300"
|
className="flex-1 relative bg-white border border-border-soft rounded-3xl p-8 text-center shadow-[0_1px_3px_rgba(160,120,80,0.06)] hover:shadow-[0_12px_32px_rgba(160,120,80,0.1)] hover:-translate-y-1 transition-all duration-300"
|
||||||
variants={fadeUp}
|
variants={fadeUp}
|
||||||
>
|
>
|
||||||
|
<div className="text-3xl mb-3">{step.icon}</div>
|
||||||
<div className="w-10 h-10 rounded-full inline-flex items-center justify-center font-serif font-extrabold text-lg text-white bg-pink mb-4">
|
<div className="w-10 h-10 rounded-full inline-flex items-center justify-center font-serif font-extrabold text-lg text-white bg-pink mb-4">
|
||||||
{step.num}
|
{step.num}
|
||||||
</div>
|
</div>
|
||||||
@@ -292,11 +565,7 @@ export default function LandingPage() {
|
|||||||
viewport={{ once: true, margin: '-80px' }}
|
viewport={{ once: true, margin: '-80px' }}
|
||||||
transition={{ duration: 0.6, ease: [0.16, 1, 0.3, 1] as const }}
|
transition={{ duration: 0.6, ease: [0.16, 1, 0.3, 1] as const }}
|
||||||
>
|
>
|
||||||
<div className="w-16 h-16 mx-auto mb-5 rounded-full bg-emerald-50 flex items-center justify-center">
|
<div className="text-4xl mb-4">{'\u{1F6E1}'}</div>
|
||||||
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="#2d1f14" strokeWidth="2">
|
|
||||||
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z" strokeLinecap="round" strokeLinejoin="round" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<h2 className="font-serif font-extrabold text-[28px] text-text-dark mb-3">Your files stay yours</h2>
|
<h2 className="font-serif font-extrabold text-[28px] text-text-dark mb-3">Your files stay yours</h2>
|
||||||
<p className="text-base text-text-mid leading-[1.7] max-w-[500px] mx-auto">
|
<p className="text-base text-text-mid leading-[1.7] max-w-[500px] mx-auto">
|
||||||
Every conversion happens entirely in your browser using WebAssembly and Canvas APIs.
|
Every conversion happens entirely in your browser using WebAssembly and Canvas APIs.
|
||||||
@@ -304,16 +573,14 @@ export default function LandingPage() {
|
|||||||
</p>
|
</p>
|
||||||
<div className="flex justify-center gap-6 mt-7 flex-wrap">
|
<div className="flex justify-center gap-6 mt-7 flex-wrap">
|
||||||
{[
|
{[
|
||||||
{ label: 'No uploads', color: 'bg-emerald-50', stroke: '#34d399' },
|
{ icon: '\u{1F6AB}', label: 'No uploads', color: 'bg-emerald-50' },
|
||||||
{ label: 'No servers', color: 'bg-blue-50', stroke: '#60a5fa' },
|
{ icon: '\u{1F4BB}', label: 'No servers', color: 'bg-blue-50' },
|
||||||
{ label: 'No tracking', color: 'bg-purple-50', stroke: '#a78bfa' },
|
{ icon: '\u{1F440}', label: 'No tracking', color: 'bg-purple-50' },
|
||||||
{ label: 'No limits', color: 'bg-orange-50', stroke: '#fb923c' },
|
{ icon: '\u{267E}', label: 'No limits', color: 'bg-orange-50' },
|
||||||
].map((b) => (
|
].map((b) => (
|
||||||
<div key={b.label} className="flex items-center gap-2 text-sm font-semibold text-text-mid">
|
<div key={b.label} className="flex items-center gap-2 text-sm font-semibold text-text-mid">
|
||||||
<div className={`w-8 h-8 rounded-[10px] flex items-center justify-center ${b.color}`}>
|
<div className={`w-8 h-8 rounded-[10px] flex items-center justify-center ${b.color}`}>
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke={b.stroke} strokeWidth="2.5">
|
<span className="text-base">{b.icon}</span>
|
||||||
<path d="M20 6L9 17l-5-5" strokeLinecap="round" strokeLinejoin="round" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
</div>
|
||||||
{b.label}
|
{b.label}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -100,9 +100,11 @@ function escapeHtml(text: string): string {
|
|||||||
|
|
||||||
/* ============================================
|
/* ============================================
|
||||||
PDF text extraction via pdfjs-dist
|
PDF text extraction via pdfjs-dist
|
||||||
|
With OCR fallback via Tesseract.js for
|
||||||
|
scanned/image-based PDFs
|
||||||
============================================ */
|
============================================ */
|
||||||
|
|
||||||
async function pdfToText(file: File): Promise<string> {
|
async function pdfToText(file: File, onProgress?: (progress: number) => void): Promise<string> {
|
||||||
const pdfjsLib = await import('pdfjs-dist');
|
const pdfjsLib = await import('pdfjs-dist');
|
||||||
|
|
||||||
// Try loading the worker from CDN; if it fails, run without worker (main thread)
|
// Try loading the worker from CDN; if it fails, run without worker (main thread)
|
||||||
@@ -133,11 +135,61 @@ async function pdfToText(file: File): Promise<string> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (textParts.length === 0) {
|
// If we got text via normal extraction, return it
|
||||||
return `[This PDF contains no extractable text — it may be image-based/scanned.]`;
|
if (textParts.length > 0) {
|
||||||
|
return textParts.join('\n\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
return textParts.join('\n\n');
|
// ── OCR fallback for scanned/image-based PDFs ──
|
||||||
|
// Render each page to canvas, then run Tesseract.js OCR
|
||||||
|
onProgress?.(35);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const Tesseract = await import('tesseract.js');
|
||||||
|
const ocrTextParts: string[] = [];
|
||||||
|
|
||||||
|
// Create a single worker for all pages
|
||||||
|
const worker = await Tesseract.createWorker('eng');
|
||||||
|
|
||||||
|
for (let i = 1; i <= pdf.numPages; i++) {
|
||||||
|
const page = await pdf.getPage(i);
|
||||||
|
const viewport = page.getViewport({ scale: 2.0 }); // 2x for better OCR quality
|
||||||
|
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
canvas.width = viewport.width;
|
||||||
|
canvas.height = viewport.height;
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
|
||||||
|
if (!ctx) continue;
|
||||||
|
|
||||||
|
await page.render({
|
||||||
|
canvas,
|
||||||
|
canvasContext: ctx,
|
||||||
|
viewport,
|
||||||
|
} as Parameters<typeof page.render>[0]).promise;
|
||||||
|
|
||||||
|
// Run OCR on the rendered page
|
||||||
|
const { data } = await worker.recognize(canvas);
|
||||||
|
if (data.text.trim()) {
|
||||||
|
ocrTextParts.push(data.text.trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update progress (spread OCR progress from 35% to 85%)
|
||||||
|
const pageProgress = 35 + ((i / pdf.numPages) * 50);
|
||||||
|
onProgress?.(Math.round(pageProgress));
|
||||||
|
}
|
||||||
|
|
||||||
|
await worker.terminate();
|
||||||
|
|
||||||
|
if (ocrTextParts.length > 0) {
|
||||||
|
return ocrTextParts.join('\n\n');
|
||||||
|
}
|
||||||
|
} catch (ocrError) {
|
||||||
|
console.warn('OCR fallback failed:', ocrError);
|
||||||
|
// Fall through to the error message below
|
||||||
|
}
|
||||||
|
|
||||||
|
return `[This PDF contains no extractable text and OCR could not recover text. It may contain only vector graphics or be empty.]`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ============================================
|
/* ============================================
|
||||||
@@ -145,8 +197,8 @@ async function pdfToText(file: File): Promise<string> {
|
|||||||
Extracts text per page, wraps in styled HTML
|
Extracts text per page, wraps in styled HTML
|
||||||
============================================ */
|
============================================ */
|
||||||
|
|
||||||
async function pdfToHtml(file: File): Promise<string> {
|
async function pdfToHtml(file: File, onProgress?: (progress: number) => void): Promise<string> {
|
||||||
const text = await pdfToText(file);
|
const text = await pdfToText(file, onProgress);
|
||||||
const paragraphs = text.split(/\n\n+/).filter(Boolean);
|
const paragraphs = text.split(/\n\n+/).filter(Boolean);
|
||||||
const bodyHtml = paragraphs.map((p) => `<p>${escapeHtml(p)}</p>`).join('\n');
|
const bodyHtml = paragraphs.map((p) => `<p>${escapeHtml(p)}</p>`).join('\n');
|
||||||
return wrapInStyledHtml(bodyHtml, file.name.replace(/\.pdf$/i, ''));
|
return wrapInStyledHtml(bodyHtml, file.name.replace(/\.pdf$/i, ''));
|
||||||
@@ -156,8 +208,8 @@ async function pdfToHtml(file: File): Promise<string> {
|
|||||||
PDF → Markdown
|
PDF → Markdown
|
||||||
============================================ */
|
============================================ */
|
||||||
|
|
||||||
async function pdfToMarkdown(file: File): Promise<string> {
|
async function pdfToMarkdown(file: File, onProgress?: (progress: number) => void): Promise<string> {
|
||||||
const text = await pdfToText(file);
|
const text = await pdfToText(file, onProgress);
|
||||||
// Attempt to detect headings (ALL CAPS lines, short lines)
|
// Attempt to detect headings (ALL CAPS lines, short lines)
|
||||||
const lines = text.split('\n');
|
const lines = text.split('\n');
|
||||||
const mdLines: string[] = [];
|
const mdLines: string[] = [];
|
||||||
@@ -184,8 +236,8 @@ async function pdfToMarkdown(file: File): Promise<string> {
|
|||||||
Extracts text, builds DOCX using docx package
|
Extracts text, builds DOCX using docx package
|
||||||
============================================ */
|
============================================ */
|
||||||
|
|
||||||
async function pdfToDocx(file: File): Promise<Blob> {
|
async function pdfToDocx(file: File, onProgress?: (progress: number) => void): Promise<Blob> {
|
||||||
const text = await pdfToText(file);
|
const text = await pdfToText(file, onProgress);
|
||||||
return textToDocx(text);
|
return textToDocx(text);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -711,17 +763,17 @@ export async function convertDocument(
|
|||||||
/* ---- PDF source ---- */
|
/* ---- PDF source ---- */
|
||||||
case 'pdf': {
|
case 'pdf': {
|
||||||
if (targetFormat === 'txt') {
|
if (targetFormat === 'txt') {
|
||||||
const text = await pdfToText(file);
|
const text = await pdfToText(file, onProgress);
|
||||||
resultBlob = new Blob([text], { type: 'text/plain' });
|
resultBlob = new Blob([text], { type: 'text/plain' });
|
||||||
} else if (targetFormat === 'html') {
|
} else if (targetFormat === 'html') {
|
||||||
const html = await pdfToHtml(file);
|
const html = await pdfToHtml(file, onProgress);
|
||||||
resultBlob = new Blob([html], { type: 'text/html' });
|
resultBlob = new Blob([html], { type: 'text/html' });
|
||||||
} else if (targetFormat === 'md') {
|
} else if (targetFormat === 'md') {
|
||||||
const md = await pdfToMarkdown(file);
|
const md = await pdfToMarkdown(file, onProgress);
|
||||||
resultBlob = new Blob([md], { type: 'text/markdown' });
|
resultBlob = new Blob([md], { type: 'text/markdown' });
|
||||||
} else if (targetFormat === 'docx') {
|
} else if (targetFormat === 'docx') {
|
||||||
onProgress?.(50);
|
onProgress?.(50);
|
||||||
resultBlob = await pdfToDocx(file);
|
resultBlob = await pdfToDocx(file, onProgress);
|
||||||
} else {
|
} else {
|
||||||
throw new Error(`Unsupported: pdf → ${targetFormat}`);
|
throw new Error(`Unsupported: pdf → ${targetFormat}`);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user