first commit

This commit is contained in:
ssbg04 committed 2026-10-08 23:13:33 +08:00
commit 71a3b5dedb
24 files changed
+3719

No files matched your search

+24
View File
@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
+8
View File
@@ -0,0 +1,8 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["react", "typescript", "oxc"],
"rules": {
"react/rules-of-hooks": "error",
"react/only-export-components": ["warn", { "allowConstantExport": true }]
}
}
+64
View File
@@ -0,0 +1,64 @@
# Multi-Converter — Monetization & Architecture Plan
## 1. Monetization Strategy
The Multi-Converter uses a freemium model:
- **Core conversion tools remain free.**
- **Free users support the platform via advertisements.**
- **Premium features (Pro)** will be introduced later as an optional paid upgrade.
- **Initial MVP prioritizes usability, near-zero operating costs, and user acquisition.**
> **Main Principle:** *«Free users get useful conversion tools with reasonable limits. Pro users get a faster, cleaner, and more powerful experience.»*
---
## 2. Free Plan vs Future Pro Plan
| Feature | Free (MVP) | Future Pro |
|---|---|---|
| Image conversion (WebP, PNG, JPEG) | Yes | Yes |
| Image compression & resizing | Yes | Yes (Advanced options) |
| Images → PDF | Yes (Up to 5 images) | Yes (Unlimited) |
| JSON ↔ YAML | Yes | Yes |
| Base64 & URL Encoding | Yes | Yes |
| UUID Generator | Yes | Yes |
| Ads | Yes | No Ads |
| Account Requirement | No (Frictionless) | Yes (for subscription & API) |
| Max File Size | 25 MB | 500 MB+ |
| Max Batch Total | 50 MB | 2 GB+ |
| Permanent File Storage | None (Client-Local) | Optional Cloud Sync |
---
## 3. Advertising Strategy & Layout
### 3.1 Header Advertisement
- Positioned below the brand header and above the converter workspace.
- `728x90` on desktop, `320x50` / `300x50` responsive banner on mobile.
### 3.2 Content Section Advertisement
- Placed between converter functionality and informational/educational/SEO sections.
- Non-blocking, preserves user reading flow.
### 3.3 Desktop Sidebar Advertisement
- `300x250` Medium Rectangle in a sticky desktop sidebar.
- Automatically hidden on tablet/mobile screens (`@media (max-width: 1024px)`).
### 3.4 Post-Conversion Advertisement
- Placed **strictly below** the file download action area.
- Download button is never hidden or gated behind ad clicks.
---
## 4. Ads That Must Never Be Used
- No forced ad clicks or artificial wait timers.
- No fake download buttons or disguised system notifications.
- No pop-unders or full-screen interstitials during processing.
---
## 5. Technical Architecture: Client-Side Processing
All core MVP conversions are handled in-browser:
- **Images:** HTML5 Canvas API + `createImageBitmap()` (lossless/lossy compression, WebP/JPEG/PNG).
- **Documents:** `pdf-lib` (client-side PDF generation & image embedding).
- **Data formats:** `js-yaml` + native Web Crypto (`crypto.randomUUID()`) + `btoa`/`atob`.
- **Zero server costs:** User device handles memory and compute. Zero backend bandwidth bills for conversions.
+32
View File
@@ -0,0 +1,32 @@
# React + TypeScript + Vite
This template provides a minimal setup to get React working in Vite with HMR and some Oxlint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
## React Compiler
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
## Expanding the Oxlint configuration
If you are developing a production application, we recommend enabling type-aware lint rules by installing `oxlint-tsgolint` and editing `.oxlintrc.json`:
```json
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["react", "typescript", "oxc"],
"options": {
"typeAware": true
},
"rules": {
"react/rules-of-hooks": "error",
"react/only-export-components": ["warn", { "allowConstantExport": true }]
}
}
```
See the [Oxlint rules documentation](https://oxc.rs/docs/guide/usage/linter/rules) for the full list of rules and categories.
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<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>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+2043
View File
File diff suppressed because it is too large. Load diff
+31
View File
@@ -0,0 +1,31 @@
{
"name": "multi-converter",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "oxlint",
"preview": "vite preview"
},
"dependencies": {
"js-yaml": "^5.4.3",
"lucide-react": "^1.52.0",
"pdf-lib": "^1.17.1",
"react": "^19.2.8",
"react-dom": "^19.2.8"
},
"devDependencies": {
"@tailwindcss/vite": "^4.3.3",
"@types/js-yaml": "^4.0.9",
"@types/node": "^24.13.3",
"@types/react": "^19.2.18",
"@types/react-dom": "^19.2.7",
"@vitejs/plugin-react": "^6.1.1",
"oxlint": "^1.81.0",
"tailwindcss": "^4.3.3",
"typescript": "~6.0.2",
"vite": "^8.3.0"
}
}
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="46" fill="none" viewBox="0 0 48 46"><path fill="#863bff" d="M25.946 44.938c-.664.845-2.021.375-2.021-.698V33.937a2.26 2.26 0 0 0-2.262-2.262H10.287c-.92 0-1.456-1.04-.92-1.788l7.48-10.471c1.07-1.497 0-3.578-1.842-3.578H1.237c-.92 0-1.456-1.04-.92-1.788L10.013.474c.214-.297.556-.474.92-.474h28.894c.92 0 1.456 1.04.92 1.788l-7.48 10.471c-1.07 1.498 0 3.579 1.842 3.579h11.377c.943 0 1.473 1.088.89 1.83L25.947 44.94z" style="fill:#863bff;fill:color(display-p3 .5252 .23 1);fill-opacity:1"/><mask id="a" width="48" height="46" x="0" y="0" maskUnits="userSpaceOnUse" style="mask-type:alpha"><path fill="#000" d="M25.842 44.938c-.664.844-2.021.375-2.021-.698V33.937a2.26 2.26 0 0 0-2.262-2.262H10.183c-.92 0-1.456-1.04-.92-1.788l7.48-10.471c1.07-1.498 0-3.579-1.842-3.579H1.133c-.92 0-1.456-1.04-.92-1.787L9.91.473c.214-.297.556-.474.92-.474h28.894c.92 0 1.456 1.04.92 1.788l-7.48 10.471c-1.07 1.498 0 3.578 1.842 3.578h11.377c.943 0 1.473 1.088.89 1.832L25.843 44.94z" style="fill:#000;fill-opacity:1"/></mask><g mask="url(#a)"><g filter="url(#b)"><ellipse cx="5.508" cy="14.704" fill="#ede6ff" rx="5.508" ry="14.704" style="fill:#ede6ff;fill:color(display-p3 .9275 .9033 1);fill-opacity:1" transform="matrix(.00324 1 1 -.00324 -4.47 31.516)"/></g><g filter="url(#c)"><ellipse cx="10.399" cy="29.851" fill="#ede6ff" rx="10.399" ry="29.851" style="fill:#ede6ff;fill:color(display-p3 .9275 .9033 1);fill-opacity:1" transform="matrix(.00324 1 1 -.00324 -39.328 7.883)"/></g><g filter="url(#d)"><ellipse cx="5.508" cy="30.487" fill="#7e14ff" rx="5.508" ry="30.487" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(89.814 -25.913 -14.639)scale(1 -1)"/></g><g filter="url(#e)"><ellipse cx="5.508" cy="30.599" fill="#7e14ff" rx="5.508" ry="30.599" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(89.814 -32.644 -3.334)scale(1 -1)"/></g><g filter="url(#f)"><ellipse cx="5.508" cy="30.599" fill="#7e14ff" rx="5.508" ry="30.599" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="matrix(.00324 1 1 -.00324 -34.34 30.47)"/></g><g filter="url(#g)"><ellipse cx="14.072" cy="22.078" fill="#ede6ff" rx="14.072" ry="22.078" style="fill:#ede6ff;fill:color(display-p3 .9275 .9033 1);fill-opacity:1" transform="rotate(93.35 24.506 48.493)scale(-1 1)"/></g><g filter="url(#h)"><ellipse cx="3.47" cy="21.501" fill="#7e14ff" rx="3.47" ry="21.501" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(89.009 28.708 47.59)scale(-1 1)"/></g><g filter="url(#i)"><ellipse cx="3.47" cy="21.501" fill="#7e14ff" rx="3.47" ry="21.501" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(89.009 28.708 47.59)scale(-1 1)"/></g><g filter="url(#j)"><ellipse cx=".387" cy="8.972" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(39.51 .387 8.972)"/></g><g filter="url(#k)"><ellipse cx="47.523" cy="-6.092" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(37.892 47.523 -6.092)"/></g><g filter="url(#l)"><ellipse cx="41.412" cy="6.333" fill="#47bfff" rx="5.971" ry="9.665" style="fill:#47bfff;fill:color(display-p3 .2799 .748 1);fill-opacity:1" transform="rotate(37.892 41.412 6.333)"/></g><g filter="url(#m)"><ellipse cx="-1.879" cy="38.332" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(37.892 -1.88 38.332)"/></g><g filter="url(#n)"><ellipse cx="-1.879" cy="38.332" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(37.892 -1.88 38.332)"/></g><g filter="url(#o)"><ellipse cx="35.651" cy="29.907" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(37.892 35.651 29.907)"/></g><g filter="url(#p)"><ellipse cx="38.418" cy="32.4" fill="#47bfff" rx="5.971" ry="15.297" style="fill:#47bfff;fill:color(display-p3 .2799 .748 1);fill-opacity:1" transform="rotate(37.892 38.418 32.4)"/></g></g><defs><filter id="b" width="60.045" height="41.654" x="-19.77" y="16.149" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="7.659"/></filter><filter id="c" width="90.34" height="51.437" x="-54.613" y="-7.533" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="7.659"/></filtLine truncated

After

Width:  |  Height:  |  Size: 9.3 KiB

+24
View File
@@ -0,0 +1,24 @@
<svg xmlns="http://www.w3.org/2000/svg">
<symbol id="bluesky-icon" viewBox="0 0 16 17">
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
</symbol>
<symbol id="discord-icon" viewBox="0 0 20 19">
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
</symbol>
<symbol id="documentation-icon" viewBox="0 0 21 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
</symbol>
<symbol id="github-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
</symbol>
<symbol id="social-icon" viewBox="0 0 20 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
</symbol>
<symbol id="x-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
</symbol>
</svg>

After

Width:  |  Height:  |  Size: 4.9 KiB

+184
View File
@@ -0,0 +1,184 @@
.counter {
font-size: 16px;
padding: 5px 10px;
border-radius: 5px;
color: var(--accent);
background: var(--accent-bg);
border: 2px solid transparent;
transition: border-color 0.3s;
margin-bottom: 24px;
&:hover {
border-color: var(--accent-border);
}
&:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
}
.hero {
position: relative;
.base,
.framework,
.vite {
inset-inline: 0;
margin: 0 auto;
}
.base {
width: 170px;
position: relative;
z-index: 0;
}
.framework,
.vite {
position: absolute;
}
.framework {
z-index: 1;
top: 34px;
height: 28px;
transform: perspective(2000px) rotateZ(300deg) rotateX(44deg) rotateY(39deg)
scale(1.4);
}
.vite {
z-index: 0;
top: 107px;
height: 26px;
width: auto;
transform: perspective(2000px) rotateZ(300deg) rotateX(40deg) rotateY(39deg)
scale(0.8);
}
}
#center {
display: flex;
flex-direction: column;
gap: 25px;
place-content: center;
place-items: center;
flex-grow: 1;
@media (max-width: 1024px) {
padding: 32px 20px 24px;
gap: 18px;
}
}
#next-steps {
display: flex;
border-top: 1px solid var(--border);
text-align: left;
& > div {
flex: 1 1 0;
padding: 32px;
@media (max-width: 1024px) {
padding: 24px 20px;
}
}
.icon {
margin-bottom: 16px;
width: 22px;
height: 22px;
}
@media (max-width: 1024px) {
flex-direction: column;
text-align: center;
}
}
#docs {
border-right: 1px solid var(--border);
@media (max-width: 1024px) {
border-right: none;
border-bottom: 1px solid var(--border);
}
}
#next-steps ul {
list-style: none;
padding: 0;
display: flex;
gap: 8px;
margin: 32px 0 0;
.logo {
height: 18px;
}
a {
color: var(--text-h);
font-size: 16px;
border-radius: 6px;
background: var(--social-bg);
display: flex;
padding: 6px 12px;
align-items: center;
gap: 8px;
text-decoration: none;
transition: box-shadow 0.3s;
&:hover {
box-shadow: var(--shadow);
}
.button-icon {
height: 18px;
width: 18px;
}
}
@media (max-width: 1024px) {
margin-top: 20px;
flex-wrap: wrap;
justify-content: center;
li {
flex: 1 1 calc(50% - 8px);
}
a {
width: 100%;
justify-content: center;
box-sizing: border-box;
}
}
}
#spacer {
height: 88px;
border-top: 1px solid var(--border);
@media (max-width: 1024px) {
height: 48px;
}
}
.ticks {
position: relative;
width: 100%;
&::before,
&::after {
content: '';
position: absolute;
top: -4.5px;
border: 5px solid transparent;
}
&::before {
left: 0;
border-left-color: var(--border);
}
&::after {
right: 0;
border-right-color: var(--border);
}
}
+672
View File
@@ -0,0 +1,672 @@
import React, { useState, useEffect } from 'react';
import {
FileImage,
FileText,
Code2,
ArrowRightLeft,
Link,
Hash,
UploadCloud,
Check,
Copy,
Download,
AlertCircle,
Sun,
Moon,
Shield,
Layers,
ChevronRight,
RotateCcw,
} from 'lucide-react';
import { AdSlot } from './components/AdSlot';
import { CategoryFilter } from './components/CategoryFilter';
import type { ToolType, ConversionResult } from './services/conversionService';
import {
convertImage,
convertImagesToPdf,
convertJsonYaml,
processBase64,
processUrlEncode,
generateUUIDs,
} from './services/conversionService';
import { APP_LIMITS } from './config/limits';
type Category = 'media' | 'developer' | 'security';
interface ToolMeta {
id: ToolType;
title: string;
category: Category;
shortDesc: string;
badge: string;
}
const TOOLS: ToolMeta[] = [
{
id: 'image-convert',
title: 'Image Converter & Resizer',
category: 'media',
shortDesc: 'WebP, PNG, JPG with compression and dimension scaling',
badge: 'Media',
},
{
id: 'images-to-pdf',
title: 'Images to PDF Document',
category: 'media',
shortDesc: 'Bundle multiple pictures into a single organized PDF',
badge: 'Media',
},
{
id: 'json-yaml',
title: 'JSON ⇄ YAML Transformer',
category: 'developer',
shortDesc: 'Bi-directional serialization parsing and syntax formatting',
badge: 'Data',
},
{
id: 'base64',
title: 'Base64 Encoder / Decoder',
category: 'security',
shortDesc: 'Safely encode binary or text representations',
badge: 'Encoding',
},
{
id: 'url-encode',
title: 'URL Percent Encoder',
category: 'developer',
shortDesc: 'Query param escaping & URI string decoding',
badge: 'Web',
},
{
id: 'uuid',
title: 'UUID v4 Identifier Generator',
category: 'developer',
shortDesc: 'Cryptographically secure standard random tokens',
badge: 'Crypto',
},
];
const CATEGORIES: { id: Category; label: string; count: number }[] = [
{ id: 'media', label: 'Media & Documents', count: 2 },
{ id: 'developer', label: 'Data & Formats', count: 3 },
{ id: 'security', label: 'Encoding & Security', count: 1 },
];
export function App() {
// Theme state
const [isDark, setIsDark] = useState<boolean>(() => {
if (typeof window !== 'undefined') {
const saved = localStorage.getItem('mc_theme');
if (saved) return saved === 'dark';
return window.matchMedia('(prefers-color-scheme: dark)').matches;
}
return true;
});
// Selected tool & category filter
const [selectedCategory, setSelectedCategory] = useState<Category | 'all'>('all');
const [activeTool, setActiveTool] = useState<ToolType>('image-convert');
// File state
const [files, setFiles] = useState<File[]>([]);
const [targetImageFormat, setTargetImageFormat] = useState<'image/webp' | 'image/png' | 'image/jpeg'>('image/webp');
const [imageQuality, setImageQuality] = useState<number>(0.85);
const [isProcessing, setIsProcessing] = useState<boolean>(false);
const [result, setResult] = useState<ConversionResult | null>(null);
const [errorMsg, setErrorMsg] = useState<string | null>(null);
// Text/utility state
const [textInput, setTextInput] = useState<string>('');
const [textOutput, setTextOutput] = useState<string>('');
const [copied, setCopied] = useState<boolean>(false);
const [jsonDirection, setJsonDirection] = useState<'json-to-yaml' | 'yaml-to-json'>('json-to-yaml');
const [base64Mode, setBase64Mode] = useState<'encode' | 'decode'>('encode');
const [urlMode, setUrlMode] = useState<'encode' | 'decode'>('encode');
const [uuidCount, setUuidCount] = useState<number>(5);
useEffect(() => {
const root = document.documentElement;
if (isDark) {
root.classList.add('dark');
localStorage.setItem('mc_theme', 'dark');
} else {
root.classList.remove('dark');
localStorage.setItem('mc_theme', 'light');
}
}, [isDark]);
const currentToolMeta = TOOLS.find((t) => t.id === activeTool)!;
const handleToolSelect = (toolId: ToolType) => {
setActiveTool(toolId);
setFiles([]);
setResult(null);
setErrorMsg(null);
setTextInput('');
setTextOutput('');
};
const handleFileDrop = (e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault();
if (e.dataTransfer.files?.length) {
validateAndSetFiles(Array.from(e.dataTransfer.files));
}
};
const handleFileInput = (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files?.length) {
validateAndSetFiles(Array.from(e.target.files));
}
};
const validateAndSetFiles = (incoming: File[]) => {
setErrorMsg(null);
setResult(null);
const limits = APP_LIMITS.free;
if (incoming.length > limits.maxBatchCount) {
setErrorMsg(`Batch limit exceeded: Maximum ${limits.maxBatchCount} files on the Free plan.`);
return;
}
const totalBytes = incoming.reduce((acc, f) => acc + f.size, 0);
if (totalBytes > limits.maxBatchTotalMB * 1024 * 1024) {
setErrorMsg(`Limit exceeded: Maximum total size is ${limits.maxBatchTotalMB} MB.`);
return;
}
for (const file of incoming) {
if (file.size > limits.maxSingleFileMB * 1024 * 1024) {
setErrorMsg(`File "${file.name}" exceeds the ${limits.maxSingleFileMB} MB limit.`);
return;
}
}
setFiles(incoming);
};
const handleConvert = async () => {
setErrorMsg(null);
setIsProcessing(true);
try {
if (activeTool === 'image-convert') {
if (!files.length) throw new Error('Please select an image file to proceed.');
const res = await convertImage(files[0], targetImageFormat, imageQuality);
setResult(res);
} else if (activeTool === 'images-to-pdf') {
if (!files.length) throw new Error('Select at least one image to compile.');
const res = await convertImagesToPdf(files);
setResult(res);
} else if (activeTool === 'json-yaml') {
const out = convertJsonYaml(textInput, jsonDirection);
setTextOutput(out);
} else if (activeTool === 'base64') {
const out = processBase64(textInput, base64Mode);
setTextOutput(out);
} else if (activeTool === 'url-encode') {
const out = processUrlEncode(textInput, urlMode);
setTextOutput(out);
} else if (activeTool === 'uuid') {
const uuids = generateUUIDs(uuidCount);
setTextOutput(uuids.join('\n'));
}
} catch (err: any) {
setErrorMsg(err.message || 'An error occurred during conversion.');
} finally {
setIsProcessing(false);
}
};
const copyToClipboard = (text: string) => {
navigator.clipboard.writeText(text);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
const filteredTools = selectedCategory === 'all'
? TOOLS
: TOOLS.filter(t => t.category === selectedCategory);
return (
<div className="min-h-screen bg-neutral-50 dark:bg-neutral-950 text-neutral-900 dark:text-neutral-100 flex flex-col font-sans transition-colors duration-200">
{/* Minimalist Top App Bar */}
<header className="border-b border-neutral-200/80 dark:border-neutral-800/80 bg-white/70 dark:bg-neutral-900/60 backdrop-blur-md sticky top-0 z-30">
<div className="max-w-7xl mx-auto px-4 sm:px-6 h-16 flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-lg bg-neutral-900 dark:bg-neutral-100 flex items-center justify-center text-white dark:text-neutral-900">
<Layers className="w-4 h-4 stroke-[2.2]" />
</div>
<div>
<span className="font-semibold tracking-tight text-sm sm:text-base text-neutral-950 dark:text-neutral-50">
Multi-Converter
</span>
<span className="hidden sm:inline-block ml-2 text-xs text-neutral-400 dark:text-neutral-500 font-mono">
client-local
</span>
</div>
</div>
<div className="flex items-center gap-3">
<div className="hidden sm:flex items-center gap-1.5 px-2.5 py-1 rounded-full text-[11px] font-medium bg-neutral-100 dark:bg-neutral-800 text-neutral-600 dark:text-neutral-300 border border-neutral-200/80 dark:border-neutral-700/80">
<Shield className="w-3 h-3 text-neutral-500 dark:text-neutral-400" />
<span>Zero-Upload Guarantee</span>
</div>
{/* Theme Toggle Button */}
<button
onClick={() => setIsDark(!isDark)}
aria-label="Toggle Theme"
className="w-9 h-9 rounded-lg border border-neutral-200 dark:border-neutral-800 flex items-center justify-center text-neutral-600 dark:text-neutral-400 hover:bg-neutral-100 dark:hover:bg-neutral-800 transition"
>
{isDark ? <Sun className="w-4 h-4" /> : <Moon className="w-4 h-4" />}
</button>
</div>
</div>
</header>
{/* 3.1 Header Advertisement (Subtle & Non-Intrusive) */}
<section className="px-4 pt-4 sm:pt-6">
<AdSlot placement="header" />
</section>
{/* Main Workspace Layout */}
<main className="flex-1 max-w-7xl w-full mx-auto px-4 sm:px-6 py-6 sm:py-8 grid grid-cols-1 lg:grid-cols-12 gap-8 items-start">
{/* Left Column: Categorized Tool Drawer & Catalog (lg:col-span-4) */}
<div className="lg:col-span-4 space-y-5">
{/* Category Filter */}
<CategoryFilter
categories={CATEGORIES}
selectedCategory={selectedCategory}
onSelectCategory={setSelectedCategory}
totalToolsCount={TOOLS.length}
/>
{/* Categorized Tool Cards List */}
<div className="space-y-2">
{filteredTools.map((tool) => {
const isSelected = activeTool === tool.id;
return (
<button
key={tool.id}
onClick={() => handleToolSelect(tool.id)}
className={`w-full text-left p-3.5 rounded-xl border transition-all flex items-center justify-between group ${
isSelected
? 'border-neutral-900 dark:border-neutral-100 bg-white dark:bg-neutral-900 shadow-xs'
: 'border-neutral-200/80 dark:border-neutral-850 bg-white/60 dark:bg-neutral-900/30 hover:border-neutral-300 dark:hover:border-neutral-700'
}`}
>
<div className="flex items-center gap-3 min-w-0">
<div
className={`w-9 h-9 rounded-lg flex items-center justify-center shrink-0 transition-colors ${
isSelected
? 'bg-neutral-900 text-white dark:bg-neutral-100 dark:text-neutral-900'
: 'bg-neutral-100 text-neutral-600 dark:bg-neutral-800 dark:text-neutral-400 group-hover:bg-neutral-200/70 dark:group-hover:bg-neutral-700'
}`}
>
{tool.id === 'image-convert' && <FileImage className="w-4 h-4" />}
{tool.id === 'images-to-pdf' && <FileText className="w-4 h-4" />}
{tool.id === 'json-yaml' && <ArrowRightLeft className="w-4 h-4" />}
{tool.id === 'base64' && <Code2 className="w-4 h-4" />}
{tool.id === 'url-encode' && <Link className="w-4 h-4" />}
{tool.id === 'uuid' && <Hash className="w-4 h-4" />}
</div>
<div className="min-w-0">
<div className="flex items-center gap-2">
<span className="font-medium text-xs sm:text-sm truncate text-neutral-900 dark:text-neutral-100">
{tool.title}
</span>
</div>
<p className="text-[11px] text-neutral-500 dark:text-neutral-400 truncate mt-0.5">
{tool.shortDesc}
</p>
</div>
</div>
<ChevronRight
className={`w-4 h-4 shrink-0 transition-transform ${
isSelected
? 'text-neutral-900 dark:text-white translate-x-0.5'
: 'text-neutral-400 opacity-40 group-hover:opacity-100'
}`}
/>
</button>
);
})}
</div>
{/* 3.3 Sticky Sidebar Sponsor Card */}
<div className="hidden lg:block pt-3">
<AdSlot placement="sidebar" />
</div>
</div>
{/* Right Column: Active Utility Workspace (lg:col-span-8) */}
<div className="lg:col-span-8 space-y-6">
{/* Active Tool Header Badge */}
<div className="bg-white dark:bg-neutral-900 border border-neutral-200/80 dark:border-neutral-800 rounded-2xl p-5 sm:p-6 shadow-xs">
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3 pb-4 border-b border-neutral-100 dark:border-neutral-800">
<div>
<div className="flex items-center gap-2">
<span className="text-[10px] uppercase font-mono tracking-wider px-2 py-0.5 rounded bg-neutral-100 dark:bg-neutral-800 text-neutral-600 dark:text-neutral-400 border border-neutral-200/60 dark:border-neutral-700/60">
{currentToolMeta.badge}
</span>
<h2 className="text-base sm:text-lg font-semibold text-neutral-950 dark:text-neutral-50">
{currentToolMeta.title}
</h2>
</div>
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-1">
{currentToolMeta.shortDesc}
</p>
</div>
<button
onClick={() => handleToolSelect(activeTool)}
className="self-start sm:self-auto inline-flex items-center gap-1.5 px-2.5 py-1 text-xs text-neutral-500 hover:text-neutral-900 dark:hover:text-white rounded-md transition"
>
<RotateCcw className="w-3.5 h-3.5" />
<span>Reset</span>
</button>
</div>
{/* Error Message Alert */}
{errorMsg && (
<div className="mt-4 flex items-center gap-2.5 p-3.5 bg-red-50 dark:bg-red-950/20 border border-red-200 dark:border-red-900/40 text-red-700 dark:text-red-300 rounded-xl text-xs sm:text-sm">
<AlertCircle className="w-4 h-4 shrink-0" />
<span>{errorMsg}</span>
</div>
)}
{/* Media Tools (Image / PDF) Workspace */}
{(activeTool === 'image-convert' || activeTool === 'images-to-pdf') && (
<div className="mt-5 space-y-5">
{/* Drag & Drop Canvas */}
<div
onDragOver={(e) => e.preventDefault()}
onDrop={handleFileDrop}
onClick={() => document.getElementById('file-upload-input')?.click()}
className="group border border-dashed border-neutral-300 dark:border-neutral-750 hover:border-neutral-900 dark:hover:border-neutral-400 bg-neutral-50/50 dark:bg-neutral-950/40 rounded-xl p-8 flex flex-col items-center justify-center text-center cursor-pointer transition-all"
>
<div className="w-12 h-12 rounded-full bg-neutral-100 dark:bg-neutral-800 text-neutral-700 dark:text-neutral-300 flex items-center justify-center mb-3 group-hover:scale-105 transition-transform">
<UploadCloud className="w-6 h-6 stroke-[1.8]" />
</div>
<p className="text-sm font-medium text-neutral-900 dark:text-neutral-100">
Drop your {activeTool === 'image-convert' ? 'image' : 'images'} here, or{' '}
<span className="underline underline-offset-4 decoration-neutral-400">browse files</span>
</p>
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-1">
Free Limit: Up to {APP_LIMITS.free.maxSingleFileMB}MB per file • Processed 100% locally
</p>
<input
id="file-upload-input"
type="file"
multiple={activeTool === 'images-to-pdf'}
accept={activeTool === 'image-convert' ? 'image/*' : 'image/png,image/jpeg'}
className="hidden"
onChange={handleFileInput}
/>
</div>
{/* Selected Files Preview List */}
{files.length > 0 && (
<div className="rounded-xl border border-neutral-200 dark:border-neutral-800 bg-neutral-50/50 dark:bg-neutral-950/30 p-3.5">
<div className="text-[11px] font-mono uppercase tracking-wider text-neutral-500 mb-2">
Selected File{files.length > 1 ? 's' : ''} ({files.length})
</div>
<ul className="space-y-1.5 text-xs text-neutral-700 dark:text-neutral-300">
{files.map((f, i) => (
<li key={i} className="flex items-center justify-between py-1 px-2 rounded bg-white dark:bg-neutral-900 border border-neutral-200/50 dark:border-neutral-800/50">
<span className="truncate max-w-[240px] sm:max-w-md font-mono">{f.name}</span>
<span className="text-neutral-400 font-mono">{(f.size / (1024 * 1024)).toFixed(2)} MB</span>
</li>
))}
</ul>
</div>
)}
{/* Additional Settings for Image Converter */}
{activeTool === 'image-convert' && (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 pt-2">
<div className="space-y-1.5">
<label className="text-xs font-medium text-neutral-700 dark:text-neutral-300">
Target Format
</label>
<select
value={targetImageFormat}
onChange={(e) => setTargetImageFormat(e.target.value as any)}
className="w-full bg-neutral-50 dark:bg-neutral-950 border border-neutral-200 dark:border-neutral-800 rounded-lg px-3 py-2 text-xs sm:text-sm text-neutral-900 dark:text-neutral-100 outline-none focus:border-neutral-900 dark:focus:border-neutral-100"
>
<option value="image/webp">WebP (Optimized / Lightest)</option>
<option value="image/png">PNG (Lossless)</option>
<option value="image/jpeg">JPEG (Standard Photo)</option>
</select>
</div>
<div className="space-y-1.5">
<div className="flex justify-between text-xs font-medium text-neutral-700 dark:text-neutral-300">
<span>Quality</span>
<span className="font-mono">{Math.round(imageQuality * 100)}%</span>
</div>
<input
type="range"
min="0.1"
max="1.0"
step="0.05"
value={imageQuality}
onChange={(e) => setImageQuality(parseFloat(e.target.value))}
className="w-full accent-neutral-900 dark:accent-neutral-100 h-2 mt-2"
/>
</div>
</div>
)}
{/* Conversion Trigger Button */}
<button
disabled={!files.length || isProcessing}
onClick={handleConvert}
className="w-full py-3 bg-neutral-950 text-white dark:bg-neutral-50 dark:text-neutral-950 disabled:opacity-40 disabled:cursor-not-allowed font-medium text-sm rounded-xl transition hover:opacity-90 flex items-center justify-center gap-2 shadow-xs"
>
{isProcessing ? 'Processing in Browser...' : activeTool === 'image-convert' ? 'Convert Image' : 'Merge into PDF'}
</button>
{/* Result Section & Safe Download Button */}
{result && (
<div className="mt-4 p-5 rounded-xl border border-neutral-200 dark:border-neutral-800 bg-neutral-50/60 dark:bg-neutral-950/60 space-y-3.5">
<div className="flex items-center justify-between text-xs text-neutral-600 dark:text-neutral-400">
<span className="font-mono truncate max-w-[260px] sm:max-w-md">{result.filename}</span>
{result.sizeBytes && (
<span className="font-mono text-neutral-500">
{(result.sizeBytes / (1024 * 1024)).toFixed(2)} MB
</span>
)}
</div>
<a
href={result.downloadUrl}
download={result.filename}
className="w-full py-2.5 px-4 bg-neutral-900 text-white dark:bg-neutral-100 dark:text-neutral-950 font-medium text-xs sm:text-sm rounded-lg flex items-center justify-center gap-2 hover:opacity-90 transition"
>
<Download className="w-4 h-4" />
<span>Download File</span>
</a>
{/* 3.4 Non-Intrusive Post-Conversion Sponsor */}
<AdSlot placement="post-conversion" />
</div>
)}
</div>
)}
{/* Developer Utility Tools Workspace */}
{['json-yaml', 'base64', 'url-encode', 'uuid'].includes(activeTool) && (
<div className="mt-5 space-y-4">
{/* Mode Selectors */}
<div className="flex flex-wrap items-center gap-3">
{activeTool === 'json-yaml' && (
<div className="inline-flex rounded-lg border border-neutral-200 dark:border-neutral-800 p-0.5 text-xs bg-neutral-100/50 dark:bg-neutral-950">
<button
onClick={() => setJsonDirection('json-to-yaml')}
className={`px-3 py-1.5 rounded-md font-medium transition ${
jsonDirection === 'json-to-yaml'
? 'bg-white dark:bg-neutral-800 text-neutral-900 dark:text-white shadow-xs'
: 'text-neutral-500'
}`}
>
JSON → YAML
</button>
<button
onClick={() => setJsonDirection('yaml-to-json')}
className={`px-3 py-1.5 rounded-md font-medium transition ${
jsonDirection === 'yaml-to-json'
? 'bg-white dark:bg-neutral-800 text-neutral-900 dark:text-white shadow-xs'
: 'text-neutral-500'
}`}
>
YAML → JSON
</button>
</div>
)}
{activeTool === 'base64' && (
<div className="inline-flex rounded-lg border border-neutral-200 dark:border-neutral-800 p-0.5 text-xs bg-neutral-100/50 dark:bg-neutral-950">
<button
onClick={() => setBase64Mode('encode')}
className={`px-3 py-1.5 rounded-md font-medium transition ${
base64Mode === 'encode'
? 'bg-white dark:bg-neutral-800 text-neutral-900 dark:text-white shadow-xs'
: 'text-neutral-500'
}`}
>
Encode
</button>
<button
onClick={() => setBase64Mode('decode')}
className={`px-3 py-1.5 rounded-md font-medium transition ${
base64Mode === 'decode'
? 'bg-white dark:bg-neutral-800 text-neutral-900 dark:text-white shadow-xs'
: 'text-neutral-500'
}`}
>
Decode
</button>
</div>
)}
{activeTool === 'url-encode' && (
<div className="inline-flex rounded-lg border border-neutral-200 dark:border-neutral-800 p-0.5 text-xs bg-neutral-100/50 dark:bg-neutral-950">
<button
onClick={() => setUrlMode('encode')}
className={`px-3 py-1.5 rounded-md font-medium transition ${
urlMode === 'encode'
? 'bg-white dark:bg-neutral-800 text-neutral-900 dark:text-white shadow-xs'
: 'text-neutral-500'
}`}
>
Encode
</button>
<button
onClick={() => setUrlMode('decode')}
className={`px-3 py-1.5 rounded-md font-medium transition ${
urlMode === 'decode'
? 'bg-white dark:bg-neutral-800 text-neutral-900 dark:text-white shadow-xs'
: 'text-neutral-500'
}`}
>
Decode
</button>
</div>
)}
{activeTool === 'uuid' && (
<div className="flex items-center gap-2 text-xs">
<span className="text-neutral-500">Count:</span>
<select
value={uuidCount}
onChange={(e) => setUuidCount(parseInt(e.target.value))}
className="bg-neutral-50 dark:bg-neutral-950 border border-neutral-200 dark:border-neutral-800 rounded-lg px-2.5 py-1 text-xs text-neutral-900 dark:text-neutral-100 outline-none"
>
<option value={1}>1</option>
<option value={5}>5</option>
<option value={10}>10</option>
<option value={20}>20</option>
</select>
</div>
)}
</div>
{/* Text Input Area */}
{activeTool !== 'uuid' && (
<div>
<textarea
rows={6}
value={textInput}
onChange={(e) => setTextInput(e.target.value)}
placeholder="Paste text, JSON, or code string here..."
className="w-full bg-neutral-50 dark:bg-neutral-950 border border-neutral-200 dark:border-neutral-800 rounded-xl p-3.5 font-mono text-xs sm:text-sm text-neutral-900 dark:text-neutral-100 outline-none focus:border-neutral-900 dark:focus:border-neutral-100 transition resize-y"
/>
</div>
)}
<button
onClick={handleConvert}
className="w-full py-2.5 bg-neutral-950 text-white dark:bg-neutral-50 dark:text-neutral-950 font-medium text-xs sm:text-sm rounded-xl transition hover:opacity-90 flex items-center justify-center gap-2"
>
{activeTool === 'uuid' ? 'Generate UUIDs' : 'Transform Input'}
</button>
{/* Text Output Result */}
{textOutput && (
<div className="space-y-2 pt-2">
<div className="flex items-center justify-between">
<span className="text-[11px] font-mono uppercase tracking-wider text-neutral-500">
Result
</span>
<button
onClick={() => copyToClipboard(textOutput)}
className="inline-flex items-center gap-1 text-xs text-neutral-600 dark:text-neutral-300 hover:text-neutral-900 dark:hover:text-white transition font-medium"
>
{copied ? <Check className="w-3.5 h-3.5 text-emerald-500" /> : <Copy className="w-3.5 h-3.5" />}
<span>{copied ? 'Copied' : 'Copy'}</span>
</button>
</div>
<textarea
rows={6}
readOnly
value={textOutput}
className="w-full bg-neutral-100 dark:bg-neutral-950/80 border border-neutral-200 dark:border-neutral-800 rounded-xl p-3.5 font-mono text-xs sm:text-sm text-neutral-900 dark:text-neutral-200 outline-none resize-y"
/>
<AdSlot placement="post-conversion" />
</div>
)}
</div>
)}
</div>
{/* 3.2 In-Feed Section Sponsor */}
<AdSlot placement="between-content" />
{/* Privacy & Educational Note */}
<div className="rounded-2xl border border-neutral-200/80 dark:border-neutral-800 bg-white/40 dark:bg-neutral-900/40 p-5 sm:p-6 text-xs text-neutral-500 dark:text-neutral-400 space-y-2">
<h3 className="font-semibold text-neutral-900 dark:text-neutral-100 text-sm">
Architecture & Security
</h3>
<p className="leading-relaxed">
Every operation runs locally inside your browser sandbox via JavaScript and Web APIs. Neither your images,
documents, nor secret text strings are uploaded to any server. This guarantees zero latency and 100% data privacy.
</p>
</div>
</div>
</main>
{/* Minimal Footer */}
<footer className="border-t border-neutral-200/60 dark:border-neutral-800/60 py-6 px-4 text-center text-xs text-neutral-400 dark:text-neutral-500">
<div className="max-w-7xl mx-auto flex flex-col sm:flex-row items-center justify-between gap-3">
<span>Multi-Converter • Client-Side Utility Platform</span>
<span>Free Plan with Transparent Ads • No Tracking</span>
</div>
</footer>
</div>
);
}
export default App;
Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="77" height="47" fill="none" aria-labelledby="vite-logo-title" viewBox="0 0 77 47"><title id="vite-logo-title">Vite</title><style>.parenthesis{fill:#000}@media (prefers-color-scheme:dark){.parenthesis{fill:#fff}}</style><path fill="#9135ff" d="M40.151 45.71c-.663.844-2.02.374-2.02-.699V34.708a2.26 2.26 0 0 0-2.262-2.262H24.493c-.92 0-1.457-1.04-.92-1.788l7.479-10.471c1.07-1.498 0-3.578-1.842-3.578H15.443c-.92 0-1.456-1.04-.92-1.788l9.696-13.576c.213-.297.556-.474.92-.474h28.894c.92 0 1.456 1.04.92 1.788l-7.48 10.472c-1.07 1.497 0 3.578 1.842 3.578h11.376c.944 0 1.474 1.087.89 1.83L40.153 45.712z"/><mask id="a" width="48" height="47" x="14" y="0" maskUnits="userSpaceOnUse" style="mask-type:alpha"><path fill="#000" d="M40.047 45.71c-.663.843-2.02.374-2.02-.699V34.708a2.26 2.26 0 0 0-2.262-2.262H24.389c-.92 0-1.457-1.04-.92-1.788l7.479-10.472c1.07-1.497 0-3.578-1.842-3.578H15.34c-.92 0-1.456-1.04-.92-1.788l9.696-13.575c.213-.297.556-.474.92-.474H53.93c.92 0 1.456 1.04.92 1.788L47.37 13.03c-1.07 1.498 0 3.578 1.842 3.578h11.376c.944 0 1.474 1.088.89 1.831L40.049 45.712z"/></mask><g mask="url(#a)"><g filter="url(#b)"><ellipse cx="5.508" cy="14.704" fill="#eee6ff" rx="5.508" ry="14.704" transform="rotate(269.814 20.96 11.29)scale(-1 1)"/></g><g filter="url(#c)"><ellipse cx="10.399" cy="29.851" fill="#eee6ff" rx="10.399" ry="29.851" transform="rotate(89.814 -16.902 -8.275)scale(1 -1)"/></g><g filter="url(#d)"><ellipse cx="5.508" cy="30.487" fill="#8900ff" rx="5.508" ry="30.487" transform="rotate(89.814 -19.197 -7.127)scale(1 -1)"/></g><g filter="url(#e)"><ellipse cx="5.508" cy="30.599" fill="#8900ff" rx="5.508" ry="30.599" transform="rotate(89.814 -25.928 4.177)scale(1 -1)"/></g><g filter="url(#f)"><ellipse cx="5.508" cy="30.599" fill="#8900ff" rx="5.508" ry="30.599" transform="rotate(89.814 -25.738 5.52)scale(1 -1)"/></g><g filter="url(#g)"><ellipse cx="14.072" cy="22.078" fill="#eee6ff" rx="14.072" ry="22.078" transform="rotate(93.35 31.245 55.578)scale(-1 1)"/></g><g filter="url(#h)"><ellipse cx="3.47" cy="21.501" fill="#8900ff" rx="3.47" ry="21.501" transform="rotate(89.009 35.419 55.202)scale(-1 1)"/></g><g filter="url(#i)"><ellipse cx="3.47" cy="21.501" fill="#8900ff" rx="3.47" ry="21.501" transform="rotate(89.009 35.419 55.202)scale(-1 1)"/></g><g filter="url(#j)"><ellipse cx="14.592" cy="9.743" fill="#8900ff" rx="4.407" ry="29.108" transform="rotate(39.51 14.592 9.743)"/></g><g filter="url(#k)"><ellipse cx="61.728" cy="-5.321" fill="#8900ff" rx="4.407" ry="29.108" transform="rotate(37.892 61.728 -5.32)"/></g><g filter="url(#l)"><ellipse cx="55.618" cy="7.104" fill="#00c2ff" rx="5.971" ry="9.665" transform="rotate(37.892 55.618 7.104)"/></g><g filter="url(#m)"><ellipse cx="12.326" cy="39.103" fill="#8900ff" rx="4.407" ry="29.108" transform="rotate(37.892 12.326 39.103)"/></g><g filter="url(#n)"><ellipse cx="12.326" cy="39.103" fill="#8900ff" rx="4.407" ry="29.108" transform="rotate(37.892 12.326 39.103)"/></g><g filter="url(#o)"><ellipse cx="49.857" cy="30.678" fill="#8900ff" rx="4.407" ry="29.108" transform="rotate(37.892 49.857 30.678)"/></g><g filter="url(#p)"><ellipse cx="52.623" cy="33.171" fill="#00c2ff" rx="5.971" ry="15.297" transform="rotate(37.892 52.623 33.17)"/></g></g><path d="M6.919 0c-9.198 13.166-9.252 33.575 0 46.789h6.215c-9.25-13.214-9.196-33.623 0-46.789zm62.424 0h-6.215c9.198 13.166 9.252 33.575 0 46.789h6.215c9.25-13.214 9.196-33.623 0-46.789" class="parenthesis"/><defs><filter id="b" width="60.045" height="41.654" x="-5.564" y="16.92" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17286" stdDeviation="7.659"/></filter><filter id="c" width="90.34" height="51.437" x="-40.407" y="-6.762" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17286" stdDeviation="7.659"/></filter><filter id="d" width="79.355" height="29.4" x="-35.435" y="2.801" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17286" stdDeviation="4.596"/></filter><filter id="e" width="79.579" height="29.4" x="-30.84" y="20.8" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17286" stdDeviation="4.596"/></filter><filter id="f" width="79.579" height="29.4" x="-29.307" y="21.949" color-interpolation-filters="sRGB" filterUnits="usLine truncated

After

Width:  |  Height:  |  Size: 8.5 KiB

+51
View File
@@ -0,0 +1,51 @@
import React from 'react';
interface AdSlotProps {
placement: 'header' | 'sidebar' | 'between-content' | 'post-conversion';
className?: string;
}
export const AdSlot: React.FC<AdSlotProps> = ({ placement, className = '' }) => {
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',
},
sidebar: {
type: 'Partner Spotlight',
dimensions: '300×250 Rectangle',
containerClass: 'w-full h-64',
},
'between-content': {
type: 'Editorial Recommendation',
dimensions: 'Fluid Feed Unit',
containerClass: 'h-24 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',
},
};
const item = configs[placement];
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}`}
>
<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>
);
};
+289
View File
@@ -0,0 +1,289 @@
import React, { useRef, useState, useEffect, useCallback } from 'react';
import {
ChevronLeft,
ChevronRight,
Layers,
FileImage,
Code2,
Shield,
LayoutGrid,
ListFilter,
} from 'lucide-react';
export type CategoryId = 'all' | 'media' | 'developer' | 'security';
export interface CategoryItem {
id: CategoryId;
label: string;
count: number;
}
interface CategoryFilterProps {
categories: { id: 'media' | 'developer' | 'security'; label: string; count: number }[];
selectedCategory: CategoryId;
onSelectCategory: (id: CategoryId) => void;
totalToolsCount: number;
}
const CATEGORY_ICONS: Record<CategoryId, React.ComponentType<{ className?: string }>> = {
all: Layers,
media: FileImage,
developer: Code2,
security: Shield,
};
export const CategoryFilter: React.FC<CategoryFilterProps> = ({
categories,
selectedCategory,
onSelectCategory,
totalToolsCount,
}) => {
const containerRef = useRef<HTMLDivElement>(null);
const [canScrollLeft, setCanScrollLeft] = useState(false);
const [canScrollRight, setCanScrollRight] = useState(false);
const [isWrapped, setIsWrapped] = useState<boolean>(() => {
if (typeof window !== 'undefined') {
const saved = localStorage.getItem('mc_cat_view');
// Default to wrap on desktop if saved or auto (desktop sidebar fits 2x2 grid best)
return saved === 'wrap';
}
return false;
});
// Drag-to-scroll state
const isDraggingRef = useRef(false);
const startXRef = useRef(0);
const scrollLeftRef = useRef(0);
const hasMovedRef = useRef(false);
// Check scroll position to toggle chevron buttons and edge fades
const checkScroll = useCallback(() => {
const el = containerRef.current;
if (!el || isWrapped) {
setCanScrollLeft(false);
setCanScrollRight(false);
return;
}
const { scrollLeft, scrollWidth, clientWidth } = el;
setCanScrollLeft(scrollLeft > 4);
setCanScrollRight(scrollLeft < scrollWidth - clientWidth - 4);
}, [isWrapped]);
useEffect(() => {
checkScroll();
const el = containerRef.current;
if (!el) return;
const resizeObserver = new ResizeObserver(() => {
checkScroll();
});
resizeObserver.observe(el);
return () => resizeObserver.disconnect();
}, [checkScroll, isWrapped]);
// Handle native wheel event to allow horizontal scrolling on desktop mouse wheel
useEffect(() => {
const el = containerRef.current;
if (!el || isWrapped) return;
const onWheel = (e: WheelEvent) => {
// If horizontal overflow exists
if (el.scrollWidth <= el.clientWidth) return;
// Translate vertical wheel delta to horizontal scroll
if (Math.abs(e.deltaY) > Math.abs(e.deltaX)) {
e.preventDefault();
el.scrollLeft += e.deltaY;
checkScroll();
}
};
el.addEventListener('wheel', onWheel, { passive: false });
return () => el.removeEventListener('wheel', onWheel);
}, [isWrapped, checkScroll]);
// Toggle wrap / carousel view
const toggleWrap = () => {
setIsWrapped((prev) => {
const next = !prev;
localStorage.setItem('mc_cat_view', next ? 'wrap' : 'carousel');
return next;
});
};
// Scroll buttons
const handleScroll = (direction: 'left' | 'right') => {
const el = containerRef.current;
if (!el) return;
const distance = 160;
el.scrollBy({
left: direction === 'left' ? -distance : distance,
behavior: 'smooth',
});
setTimeout(checkScroll, 200);
};
// Drag handlers
const handleMouseDown = (e: React.MouseEvent) => {
if (isWrapped) return;
const el = containerRef.current;
if (!el) return;
isDraggingRef.current = true;
hasMovedRef.current = false;
startXRef.current = e.pageX - el.offsetLeft;
scrollLeftRef.current = el.scrollLeft;
};
const handleMouseMove = (e: React.MouseEvent) => {
if (!isDraggingRef.current || isWrapped) return;
const el = containerRef.current;
if (!el) return;
e.preventDefault();
const x = e.pageX - el.offsetLeft;
const walk = x - startXRef.current;
if (Math.abs(walk) > 4) {
hasMovedRef.current = true;
}
el.scrollLeft = scrollLeftRef.current - walk;
checkScroll();
};
const handleMouseUp = () => {
isDraggingRef.current = false;
};
// Pill click helper
const handlePillClick = (id: CategoryId, e: React.MouseEvent<HTMLButtonElement>) => {
if (hasMovedRef.current) {
// It was a drag gesture, prevent selection
hasMovedRef.current = false;
return;
}
onSelectCategory(id);
// Auto-scroll selected button into view if in carousel mode
if (!isWrapped) {
e.currentTarget.scrollIntoView({
behavior: 'smooth',
block: 'nearest',
inline: 'center',
});
setTimeout(checkScroll, 250);
}
};
const allItems: { id: CategoryId; label: string; count: number }[] = [
{ id: 'all', label: 'All Tools', count: totalToolsCount },
...categories,
];
return (
<div className="w-full">
{/* Category Header with Title & View Mode Toggle */}
<div className="flex items-center justify-between mb-2 px-1">
<div className="flex items-center gap-1.5 text-xs font-semibold text-neutral-600 dark:text-neutral-400">
<ListFilter className="w-3.5 h-3.5 text-neutral-500" />
<span>Filter by Category</span>
</div>
<button
onClick={toggleWrap}
type="button"
title={isWrapped ? 'Switch to compact scrollable bar' : 'Expand all categories (grid view)'}
className="flex items-center gap-1 text-[11px] font-medium text-neutral-500 hover:text-neutral-900 dark:hover:text-white px-2 py-0.5 rounded-md hover:bg-neutral-200/60 dark:hover:bg-neutral-800 transition"
>
<LayoutGrid className="w-3 h-3" />
<span>{isWrapped ? 'Compact' : 'Show All'}</span>
</button>
</div>
{/* Main Filter Container */}
<div className="relative group/filter rounded-xl bg-neutral-200/60 dark:bg-neutral-900 border border-neutral-300/40 dark:border-neutral-800/80 p-1">
{/* Left Scroll Chevron & Gradient Fade (Carousel mode only) */}
{!isWrapped && (
<div
className={`absolute left-0 top-0 bottom-0 z-10 flex items-center pl-0.5 pr-3 rounded-l-xl bg-gradient-to-r from-neutral-200/95 via-neutral-200/80 to-transparent dark:from-neutral-900 dark:via-neutral-900/80 transition-opacity duration-200 ${
canScrollLeft ? 'opacity-100 pointer-events-auto' : 'opacity-0 pointer-events-none'
}`}
>
<button
onClick={() => handleScroll('left')}
type="button"
aria-label="Scroll categories left"
className="w-6 h-6 rounded-lg bg-white/90 dark:bg-neutral-800/90 shadow-xs border border-neutral-300/60 dark:border-neutral-700/60 flex items-center justify-center text-neutral-700 dark:text-neutral-200 hover:scale-105 active:scale-95 transition"
>
<ChevronLeft className="w-3.5 h-3.5" />
</button>
</div>
)}
{/* Categories List: Either 2-Column Grid/Wrap OR Horizontal Scroll Carousel */}
<div
ref={containerRef}
onScroll={checkScroll}
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
onMouseLeave={handleMouseUp}
className={`text-xs font-medium ${
isWrapped
? 'grid grid-cols-2 gap-1.5'
: 'flex items-center gap-1.5 overflow-x-auto scrollbar-none select-none cursor-grab active:cursor-grabbing scroll-smooth'
}`}
>
{allItems.map((cat) => {
const isSelected = selectedCategory === cat.id;
const Icon = CATEGORY_ICONS[cat.id];
return (
<button
key={cat.id}
onClick={(e) => handlePillClick(cat.id, e)}
type="button"
className={`flex items-center justify-between gap-2 px-2.5 py-1.5 rounded-lg transition-all ${
isWrapped ? 'w-full' : 'shrink-0'
} ${
isSelected
? 'bg-white dark:bg-neutral-800 text-neutral-950 dark:text-white shadow-xs font-medium border border-neutral-300/40 dark:border-neutral-700/50'
: 'text-neutral-600 dark:text-neutral-400 hover:text-neutral-900 dark:hover:text-white hover:bg-white/40 dark:hover:bg-neutral-800/40'
}`}
>
<div className="flex items-center gap-1.5 truncate">
<Icon className={`w-3.5 h-3.5 shrink-0 ${isSelected ? 'text-neutral-950 dark:text-white' : 'text-neutral-400 dark:text-neutral-500'}`} />
<span className="truncate">{cat.label}</span>
</div>
<span
className={`text-[10px] px-1.5 py-0.2 rounded-full font-mono transition-colors shrink-0 ${
isSelected
? 'bg-neutral-100 dark:bg-neutral-700/90 text-neutral-900 dark:text-neutral-100 font-semibold'
: 'bg-neutral-300/50 dark:bg-neutral-800/80 text-neutral-500 dark:text-neutral-400'
}`}
>
{cat.count}
</span>
</button>
);
})}
</div>
{/* Right Scroll Chevron & Gradient Fade (Carousel mode only) */}
{!isWrapped && (
<div
className={`absolute right-0 top-0 bottom-0 z-10 flex items-center pr-0.5 pl-3 rounded-r-xl bg-gradient-to-l from-neutral-200/95 via-neutral-200/80 to-transparent dark:from-neutral-900 dark:via-neutral-900/80 transition-opacity duration-200 ${
canScrollRight ? 'opacity-100 pointer-events-auto' : 'opacity-0 pointer-events-none'
}`}
>
<button
onClick={() => handleScroll('right')}
type="button"
aria-label="Scroll categories right"
className="w-6 h-6 rounded-lg bg-white/90 dark:bg-neutral-800/90 shadow-xs border border-neutral-300/60 dark:border-neutral-700/60 flex items-center justify-center text-neutral-700 dark:text-neutral-200 hover:scale-105 active:scale-95 transition"
>
<ChevronRight className="w-3.5 h-3.5" />
</button>
</div>
)}
</div>
</div>
);
};
+26
View File
@@ -0,0 +1,26 @@
export interface UsageLimits {
maxSingleFileMB: number;
maxBatchTotalMB: number;
maxBatchCount: number;
maxConcurrent: number;
}
export const APP_LIMITS: { free: UsageLimits; pro: UsageLimits } = {
free: {
maxSingleFileMB: 25,
maxBatchTotalMB: 50,
maxBatchCount: 5,
maxConcurrent: 2,
},
pro: {
maxSingleFileMB: 500,
maxBatchTotalMB: 2000,
maxBatchCount: 100,
maxConcurrent: 10,
},
};
export const AD_CONFIG = {
enabled: true,
showMockBannersInDev: true,
};
+10
View File
@@ -0,0 +1,10 @@
@import "tailwindcss";
@custom-variant dark (&:where(.dark, .dark *));
@layer base {
body {
@apply min-h-screen antialiased transition-colors duration-200 selection:bg-neutral-800 selection:text-white dark:selection:bg-neutral-200 dark:selection:text-neutral-900;
font-feature-settings: "cv02", "cv03", "cv04", "cv11";
}
}
+10
View File
@@ -0,0 +1,10 @@
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>,
)
+170
View File
@@ -0,0 +1,170 @@
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';
export interface ConversionResult {
filename: string;
blob?: Blob;
downloadUrl?: string;
textOutput?: string;
sizeBytes?: number;
}
/** Convert image format (PNG, JPEG, WebP) and quality entirely in-browser */
export async function convertImage(
file: File,
targetFormat: 'image/png' | 'image/jpeg' | 'image/webp',
quality: number = 0.85,
maxWidth?: number,
maxHeight?: number
): Promise<ConversionResult> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = (e) => {
const img = new Image();
img.onload = () => {
let width = img.naturalWidth;
let height = img.naturalHeight;
if (maxWidth && width > maxWidth) {
height = Math.round((height * maxWidth) / width);
width = maxWidth;
}
if (maxHeight && height > maxHeight) {
width = Math.round((width * maxHeight) / height);
height = maxHeight;
}
const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext('2d');
if (!ctx) {
return reject(new Error('Canvas context unavailable'));
}
// Fill white background for JPEG conversions if alpha exists
if (targetFormat === 'image/jpeg') {
ctx.fillStyle = '#ffffff';
ctx.fillRect(0, 0, width, height);
}
ctx.drawImage(img, 0, 0, width, height);
canvas.toBlob(
(blob) => {
if (!blob) return reject(new Error('Conversion failed'));
const ext = targetFormat === 'image/png' ? 'png' : targetFormat === 'image/jpeg' ? 'jpg' : 'webp';
const originalBase = file.name.replace(/\.[^/.]+$/, '');
const newName = `${originalBase}-converted.${ext}`;
const downloadUrl = URL.createObjectURL(blob);
resolve({
filename: newName,
blob,
downloadUrl,
sizeBytes: blob.size,
});
},
targetFormat,
quality
);
};
img.onerror = () => reject(new Error('Unable to read image file'));
img.src = e.target?.result as string;
};
reader.onerror = () => reject(new Error('File reading error'));
reader.readAsDataURL(file);
});
}
/** Combine multiple images into a single PDF document in-browser */
export async function convertImagesToPdf(files: File[]): Promise<ConversionResult> {
const pdfDoc = await PDFDocument.create();
for (const file of files) {
const arrayBuffer = await file.arrayBuffer();
const isPng = file.type === 'image/png' || file.name.toLowerCase().endsWith('.png');
let embeddedImage;
try {
if (isPng) {
embeddedImage = await pdfDoc.embedPng(arrayBuffer);
} else {
embeddedImage = await pdfDoc.embedJpg(arrayBuffer);
}
} catch {
// Fallback: convert via canvas to png first
const fallbackResult = await convertImage(file, 'image/png');
if (fallbackResult.blob) {
const fbBuf = await fallbackResult.blob.arrayBuffer();
embeddedImage = await pdfDoc.embedPng(fbBuf);
} else {
continue;
}
}
const { width, height } = embeddedImage;
const page = pdfDoc.addPage([width, height]);
page.drawImage(embeddedImage, {
x: 0,
y: 0,
width,
height,
});
}
const pdfBytes = await pdfDoc.save();
const blob = new Blob([new Uint8Array(pdfBytes)], { type: 'application/pdf' });
const downloadUrl = URL.createObjectURL(blob);
return {
filename: 'merged-documents.pdf',
blob,
downloadUrl,
sizeBytes: blob.size,
};
}
/** JSON <-> YAML converter */
export function convertJsonYaml(input: string, direction: 'json-to-yaml' | 'yaml-to-json'): string {
if (!input.trim()) return '';
if (direction === 'json-to-yaml') {
const parsed = JSON.parse(input);
return dump(parsed, { indent: 2 });
} else {
const parsed = load(input);
return JSON.stringify(parsed, null, 2);
}
}
/** Base64 Encoder / Decoder */
export function processBase64(input: string, mode: 'encode' | 'decode'): string {
if (!input) return '';
if (mode === 'encode') {
return btoa(unescape(encodeURIComponent(input)));
} else {
return decodeURIComponent(escape(atob(input.trim())));
}
}
/** URL Encoder / Decoder */
export function processUrlEncode(input: string, mode: 'encode' | 'decode'): string {
if (!input) return '';
return mode === 'encode' ? encodeURIComponent(input) : decodeURIComponent(input);
}
/** UUID Generator */
export function generateUUIDs(count: number = 5): string[] {
const result: string[] = [];
for (let i = 0; i < count; i++) {
result.push(crypto.randomUUID());
}
return result;
}
+26
View File
@@ -0,0 +1,26 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "es2023",
"lib": ["ES2023", "DOM"],
"module": "esnext",
"types": ["vite/client"],
"allowArbitraryExtensions": true,
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"]
}
+7
View File
@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}
+23
View File
@@ -0,0 +1,23 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "es2023",
"lib": ["ES2023"],
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"module": "nodenext",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["vite.config.ts"]
}
+9
View File
@@ -0,0 +1,9 @@
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()],
})