feat: initial Transmute app — universal client-side file converter
Full-stack client-side file converter with Next.js 15 static export. Supports images (Canvas API), documents (mammoth/pdf-lib/jspdf), audio/video (ffmpeg.wasm), and data formats (papaparse/yaml/xml). Dark industrial UI with Space Grotesk + JetBrains Mono, animated drop zone, glassmorphism file cards, progress rings, and ZIP downloads. Zero server dependencies — files never leave the browser.
This commit is contained in:
+41
@@ -0,0 +1,41 @@
|
|||||||
|
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||||
|
|
||||||
|
# dependencies
|
||||||
|
/node_modules
|
||||||
|
/.pnp
|
||||||
|
.pnp.*
|
||||||
|
.yarn/*
|
||||||
|
!.yarn/patches
|
||||||
|
!.yarn/plugins
|
||||||
|
!.yarn/releases
|
||||||
|
!.yarn/versions
|
||||||
|
|
||||||
|
# testing
|
||||||
|
/coverage
|
||||||
|
|
||||||
|
# next.js
|
||||||
|
/.next/
|
||||||
|
/out/
|
||||||
|
|
||||||
|
# production
|
||||||
|
/build
|
||||||
|
|
||||||
|
# misc
|
||||||
|
.DS_Store
|
||||||
|
*.pem
|
||||||
|
|
||||||
|
# debug
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
.pnpm-debug.log*
|
||||||
|
|
||||||
|
# env files (can opt-in for committing if needed)
|
||||||
|
.env*
|
||||||
|
|
||||||
|
# vercel
|
||||||
|
.vercel
|
||||||
|
|
||||||
|
# typescript
|
||||||
|
*.tsbuildinfo
|
||||||
|
next-env.d.ts
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
|
||||||
|
|
||||||
|
## Getting Started
|
||||||
|
|
||||||
|
First, run the development server:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run dev
|
||||||
|
# or
|
||||||
|
yarn dev
|
||||||
|
# or
|
||||||
|
pnpm dev
|
||||||
|
# or
|
||||||
|
bun dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
||||||
|
|
||||||
|
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
|
||||||
|
|
||||||
|
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
|
||||||
|
|
||||||
|
## Learn More
|
||||||
|
|
||||||
|
To learn more about Next.js, take a look at the following resources:
|
||||||
|
|
||||||
|
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
|
||||||
|
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
||||||
|
|
||||||
|
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
|
||||||
|
|
||||||
|
## Deploy on Vercel
|
||||||
|
|
||||||
|
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
|
||||||
|
|
||||||
|
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { defineConfig, globalIgnores } from "eslint/config";
|
||||||
|
import nextVitals from "eslint-config-next/core-web-vitals";
|
||||||
|
import nextTs from "eslint-config-next/typescript";
|
||||||
|
|
||||||
|
const eslintConfig = defineConfig([
|
||||||
|
...nextVitals,
|
||||||
|
...nextTs,
|
||||||
|
// Override default ignores of eslint-config-next.
|
||||||
|
globalIgnores([
|
||||||
|
// Default ignores of eslint-config-next:
|
||||||
|
".next/**",
|
||||||
|
"out/**",
|
||||||
|
"build/**",
|
||||||
|
"next-env.d.ts",
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
|
||||||
|
export default eslintConfig;
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import type { NextConfig } from "next";
|
||||||
|
|
||||||
|
const nextConfig: NextConfig = {
|
||||||
|
output: "export",
|
||||||
|
images: {
|
||||||
|
unoptimized: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default nextConfig;
|
||||||
Generated
+7264
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,39 @@
|
|||||||
|
{
|
||||||
|
"name": "transmute",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"dev": "next dev",
|
||||||
|
"build": "next build",
|
||||||
|
"start": "next start",
|
||||||
|
"lint": "eslint"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@ffmpeg/ffmpeg": "^0.12.15",
|
||||||
|
"@ffmpeg/util": "^0.12.2",
|
||||||
|
"fast-xml-parser": "^5.4.2",
|
||||||
|
"framer-motion": "^12.35.2",
|
||||||
|
"js-yaml": "^4.1.1",
|
||||||
|
"jspdf": "^4.2.0",
|
||||||
|
"jszip": "^3.10.1",
|
||||||
|
"mammoth": "^1.11.0",
|
||||||
|
"marked": "^17.0.4",
|
||||||
|
"next": "16.1.6",
|
||||||
|
"papaparse": "^5.5.3",
|
||||||
|
"pdf-lib": "^1.17.1",
|
||||||
|
"react": "19.2.3",
|
||||||
|
"react-dom": "19.2.3"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@tailwindcss/postcss": "^4",
|
||||||
|
"@types/js-yaml": "^4.0.9",
|
||||||
|
"@types/node": "^20",
|
||||||
|
"@types/papaparse": "^5.5.2",
|
||||||
|
"@types/react": "^19",
|
||||||
|
"@types/react-dom": "^19",
|
||||||
|
"eslint": "^9",
|
||||||
|
"eslint-config-next": "16.1.6",
|
||||||
|
"tailwindcss": "^4",
|
||||||
|
"typescript": "^5"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
const config = {
|
||||||
|
plugins: {
|
||||||
|
"@tailwindcss/postcss": {},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>
|
||||||
|
After Width: | Height: | Size: 391 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>
|
||||||
|
After Width: | Height: | Size: 1.0 KiB |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>
|
||||||
|
After Width: | Height: | Size: 1.3 KiB |
@@ -0,0 +1 @@
|
|||||||
|
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>
|
||||||
|
After Width: | Height: | Size: 128 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>
|
||||||
|
After Width: | Height: | Size: 385 B |
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
@@ -0,0 +1,730 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
TRANSMUTE — Dark Industrial Theme
|
||||||
|
============================================ */
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--bg-primary: #09090b;
|
||||||
|
--bg-secondary: #111114;
|
||||||
|
--bg-tertiary: #18181c;
|
||||||
|
--bg-card: rgba(255, 255, 255, 0.025);
|
||||||
|
--bg-card-hover: rgba(255, 255, 255, 0.05);
|
||||||
|
|
||||||
|
--border: rgba(255, 255, 255, 0.07);
|
||||||
|
--border-hover: rgba(255, 255, 255, 0.14);
|
||||||
|
|
||||||
|
--text-primary: #f0f0f2;
|
||||||
|
--text-secondary: #7a7a85;
|
||||||
|
--text-muted: #45454d;
|
||||||
|
|
||||||
|
--accent: #00f0ff;
|
||||||
|
--accent-glow: rgba(0, 240, 255, 0.15);
|
||||||
|
--accent-dim: rgba(0, 240, 255, 0.5);
|
||||||
|
|
||||||
|
--cat-image: #10b981;
|
||||||
|
--cat-document: #0ea5e9;
|
||||||
|
--cat-audio: #8b5cf6;
|
||||||
|
--cat-video: #f43f5e;
|
||||||
|
--cat-data: #f59e0b;
|
||||||
|
|
||||||
|
--radius-sm: 6px;
|
||||||
|
--radius-md: 10px;
|
||||||
|
--radius-lg: 16px;
|
||||||
|
--radius-xl: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@theme inline {
|
||||||
|
--color-background: var(--bg-primary);
|
||||||
|
--color-foreground: var(--text-primary);
|
||||||
|
--font-sans: var(--font-space-grotesk);
|
||||||
|
--font-mono: var(--font-jetbrains-mono);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Base ---- */
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
background: var(--bg-primary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-family: var(--font-space-grotesk), system-ui, sans-serif;
|
||||||
|
overflow-x: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Noise texture overlay */
|
||||||
|
.noise-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 0;
|
||||||
|
opacity: 0.035;
|
||||||
|
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
|
||||||
|
background-repeat: repeat;
|
||||||
|
background-size: 256px 256px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Gradient mesh background */
|
||||||
|
.bg-mesh {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 0;
|
||||||
|
background:
|
||||||
|
radial-gradient(ellipse 60% 50% at 20% 0%, rgba(0, 240, 255, 0.04) 0%, transparent 60%),
|
||||||
|
radial-gradient(ellipse 50% 40% at 80% 100%, rgba(139, 92, 246, 0.03) 0%, transparent 60%);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Scrollbar ---- */
|
||||||
|
::-webkit-scrollbar { width: 6px; }
|
||||||
|
::-webkit-scrollbar-track { background: transparent; }
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
Header
|
||||||
|
============================================ */
|
||||||
|
.app-header {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 50;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 16px 24px;
|
||||||
|
background: rgba(9, 9, 11, 0.8);
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
font-family: var(--font-space-grotesk), sans-serif;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 18px;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo-icon {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
border-radius: 7px;
|
||||||
|
background: linear-gradient(135deg, var(--accent), #0090ff);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: #000;
|
||||||
|
font-weight: 800;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-tag {
|
||||||
|
font-family: var(--font-jetbrains-mono), monospace;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
padding: 3px 8px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 20px;
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
Drop Zone — Hero (no files)
|
||||||
|
============================================ */
|
||||||
|
.drop-zone-hero {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: calc(100vh - 57px);
|
||||||
|
padding: 40px 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drop-zone-inner {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 640px;
|
||||||
|
padding: 64px 40px;
|
||||||
|
border: 1.5px dashed var(--border-hover);
|
||||||
|
border-radius: var(--radius-xl);
|
||||||
|
background: var(--bg-card);
|
||||||
|
transition: all 0.4s cubic-bezier(0.16, 1, 0.3, 1);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drop-zone-inner.dragging {
|
||||||
|
border-color: var(--accent);
|
||||||
|
background: var(--accent-glow);
|
||||||
|
box-shadow:
|
||||||
|
0 0 40px rgba(0, 240, 255, 0.08),
|
||||||
|
inset 0 0 40px rgba(0, 240, 255, 0.03);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Corner accents */
|
||||||
|
.corner-accent {
|
||||||
|
position: absolute;
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
border-color: var(--accent-dim);
|
||||||
|
transition: border-color 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drop-zone-inner.dragging .corner-accent {
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.corner-accent.top-left {
|
||||||
|
top: -1px; left: -1px;
|
||||||
|
border-top: 2px solid;
|
||||||
|
border-left: 2px solid;
|
||||||
|
border-top-left-radius: var(--radius-xl);
|
||||||
|
}
|
||||||
|
.corner-accent.top-right {
|
||||||
|
top: -1px; right: -1px;
|
||||||
|
border-top: 2px solid;
|
||||||
|
border-right: 2px solid;
|
||||||
|
border-top-right-radius: var(--radius-xl);
|
||||||
|
}
|
||||||
|
.corner-accent.bottom-left {
|
||||||
|
bottom: -1px; left: -1px;
|
||||||
|
border-bottom: 2px solid;
|
||||||
|
border-left: 2px solid;
|
||||||
|
border-bottom-left-radius: var(--radius-xl);
|
||||||
|
}
|
||||||
|
.corner-accent.bottom-right {
|
||||||
|
bottom: -1px; right: -1px;
|
||||||
|
border-bottom: 2px solid;
|
||||||
|
border-right: 2px solid;
|
||||||
|
border-bottom-right-radius: var(--radius-xl);
|
||||||
|
}
|
||||||
|
|
||||||
|
.drop-zone-content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
text-align: center;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-icon {
|
||||||
|
color: var(--accent);
|
||||||
|
margin-bottom: 8px;
|
||||||
|
filter: drop-shadow(0 0 12px rgba(0, 240, 255, 0.3));
|
||||||
|
}
|
||||||
|
|
||||||
|
.drop-zone-title {
|
||||||
|
font-family: var(--font-space-grotesk), sans-serif;
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: -0.03em;
|
||||||
|
color: var(--text-primary);
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drop-zone-subtitle {
|
||||||
|
font-size: 15px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
max-width: 380px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.browse-button {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 10px 24px;
|
||||||
|
margin-top: 8px;
|
||||||
|
font-family: var(--font-space-grotesk), sans-serif;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #000;
|
||||||
|
background: var(--accent);
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
box-shadow: 0 0 20px rgba(0, 240, 255, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.browse-button:hover {
|
||||||
|
box-shadow: 0 0 30px rgba(0, 240, 255, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.drop-zone-hint {
|
||||||
|
font-family: var(--font-jetbrains-mono), monospace;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin-top: 8px;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
Drop Zone — Compact (has files)
|
||||||
|
============================================ */
|
||||||
|
.drop-zone-compact {
|
||||||
|
padding: 16px 24px;
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compact-drop-area {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 14px;
|
||||||
|
border: 1.5px dashed var(--border);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background: var(--bg-card);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.3s;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compact-drop-area:hover {
|
||||||
|
border-color: var(--border-hover);
|
||||||
|
background: var(--bg-card-hover);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.compact-drop-area.dragging {
|
||||||
|
border-color: var(--accent);
|
||||||
|
background: var(--accent-glow);
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
File Cards
|
||||||
|
============================================ */
|
||||||
|
.file-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||||||
|
gap: 16px;
|
||||||
|
padding: 0 24px 120px;
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-card {
|
||||||
|
position: relative;
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
padding: 16px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
transition: all 0.25s ease;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-card:hover {
|
||||||
|
background: var(--bg-card-hover);
|
||||||
|
border-color: var(--border-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-accent-line {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
height: 2px;
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.category-badge {
|
||||||
|
font-family: var(--font-jetbrains-mono), monospace;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
padding: 3px 8px;
|
||||||
|
border-radius: 20px;
|
||||||
|
border: 1px solid;
|
||||||
|
}
|
||||||
|
|
||||||
|
.remove-btn {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 6px;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.remove-btn:hover {
|
||||||
|
color: #f43f5e;
|
||||||
|
background: rgba(244, 63, 94, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Preview area */
|
||||||
|
.card-preview {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
height: 100px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-image {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-icon {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-ext {
|
||||||
|
font-family: var(--font-jetbrains-mono), monospace;
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 700;
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-overlay,
|
||||||
|
.done-overlay,
|
||||||
|
.error-overlay {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 4px;
|
||||||
|
background: rgba(9, 9, 11, 0.75);
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-text {
|
||||||
|
font-family: var(--font-jetbrains-mono), monospace;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* File info */
|
||||||
|
.card-info {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-name {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
line-height: 1.3;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-size {
|
||||||
|
font-family: var(--font-jetbrains-mono), monospace;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-message {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #f43f5e;
|
||||||
|
background: rgba(244, 63, 94, 0.08);
|
||||||
|
padding: 6px 8px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Format selector */
|
||||||
|
.format-selector {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.format-from {
|
||||||
|
font-family: var(--font-jetbrains-mono), monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
padding: 4px 8px;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.format-arrow {
|
||||||
|
color: var(--text-muted);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.format-select {
|
||||||
|
flex: 1;
|
||||||
|
font-family: var(--font-jetbrains-mono), monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--accent);
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border: 1px solid;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
padding: 4px 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
outline: none;
|
||||||
|
appearance: none;
|
||||||
|
-webkit-appearance: none;
|
||||||
|
background-image: url("data:image/svg+xml,%3Csvg width='10' height='6' viewBox='0 0 10 6' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M1 1L5 5L9 1' stroke='%237a7a85' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
background-position: right 8px center;
|
||||||
|
padding-right: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.format-select option {
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.download-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 6px;
|
||||||
|
width: 100%;
|
||||||
|
padding: 8px;
|
||||||
|
font-family: var(--font-space-grotesk), sans-serif;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #000;
|
||||||
|
background: #10b981;
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.download-btn:hover {
|
||||||
|
background: #34d399;
|
||||||
|
box-shadow: 0 0 16px rgba(16, 185, 129, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.unsupported-msg {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
text-align: center;
|
||||||
|
padding: 6px;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
Action Bar
|
||||||
|
============================================ */
|
||||||
|
.action-bar {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
z-index: 40;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 16px 24px;
|
||||||
|
background: rgba(9, 9, 11, 0.85);
|
||||||
|
backdrop-filter: blur(16px);
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-bar-info {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-bar-stat {
|
||||||
|
font-family: var(--font-jetbrains-mono), monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-bar-stat strong {
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-divider {
|
||||||
|
width: 1px;
|
||||||
|
height: 16px;
|
||||||
|
background: var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-bar-buttons {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 8px 16px;
|
||||||
|
font-family: var(--font-space-grotesk), sans-serif;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary:hover {
|
||||||
|
color: var(--text-primary);
|
||||||
|
border-color: var(--border-hover);
|
||||||
|
background: var(--bg-card);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-convert {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 10px 28px;
|
||||||
|
font-family: var(--font-space-grotesk), sans-serif;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #000;
|
||||||
|
background: var(--accent);
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
box-shadow: 0 0 24px rgba(0, 240, 255, 0.2);
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-convert:hover:not(:disabled) {
|
||||||
|
box-shadow: 0 0 36px rgba(0, 240, 255, 0.35);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-convert:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-convert.converting {
|
||||||
|
background: var(--accent-dim);
|
||||||
|
animation: pulse-glow 2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse-glow {
|
||||||
|
0%, 100% { box-shadow: 0 0 24px rgba(0, 240, 255, 0.2); }
|
||||||
|
50% { box-shadow: 0 0 40px rgba(0, 240, 255, 0.4); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-download-all {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 10px 20px;
|
||||||
|
font-family: var(--font-space-grotesk), sans-serif;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #000;
|
||||||
|
background: #10b981;
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
box-shadow: 0 0 16px rgba(16, 185, 129, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-download-all:hover {
|
||||||
|
background: #34d399;
|
||||||
|
box-shadow: 0 0 24px rgba(16, 185, 129, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
Responsive
|
||||||
|
============================================ */
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.file-grid {
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
padding: 0 16px 120px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drop-zone-hero {
|
||||||
|
padding: 24px 16px;
|
||||||
|
min-height: calc(100vh - 57px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.drop-zone-inner {
|
||||||
|
padding: 48px 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drop-zone-title {
|
||||||
|
font-size: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-bar {
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-bar-buttons {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-convert {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-header {
|
||||||
|
padding: 12px 16px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
Utility animations
|
||||||
|
============================================ */
|
||||||
|
@keyframes fade-in {
|
||||||
|
from { opacity: 0; }
|
||||||
|
to { opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-fade-in {
|
||||||
|
animation: fade-in 0.5s ease both;
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import { Space_Grotesk, JetBrains_Mono } from "next/font/google";
|
||||||
|
import "./globals.css";
|
||||||
|
|
||||||
|
const spaceGrotesk = Space_Grotesk({
|
||||||
|
variable: "--font-space-grotesk",
|
||||||
|
subsets: ["latin"],
|
||||||
|
weight: ["400", "500", "600", "700"],
|
||||||
|
});
|
||||||
|
|
||||||
|
const jetbrainsMono = JetBrains_Mono({
|
||||||
|
variable: "--font-jetbrains-mono",
|
||||||
|
subsets: ["latin"],
|
||||||
|
weight: ["400", "500", "600", "700"],
|
||||||
|
});
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "Transmute — Universal File Converter",
|
||||||
|
description:
|
||||||
|
"Convert any file to any format. Images, documents, audio, video, data — all in your browser. No uploads, no servers, 100% private.",
|
||||||
|
keywords: [
|
||||||
|
"file converter",
|
||||||
|
"image converter",
|
||||||
|
"video converter",
|
||||||
|
"audio converter",
|
||||||
|
"document converter",
|
||||||
|
"csv to json",
|
||||||
|
"png to webp",
|
||||||
|
"mp4 to mp3",
|
||||||
|
"online converter",
|
||||||
|
"browser converter",
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function RootLayout({
|
||||||
|
children,
|
||||||
|
}: Readonly<{
|
||||||
|
children: React.ReactNode;
|
||||||
|
}>) {
|
||||||
|
return (
|
||||||
|
<html lang="en">
|
||||||
|
<body
|
||||||
|
className={`${spaceGrotesk.variable} ${jetbrainsMono.variable} antialiased`}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { AnimatePresence, motion } from 'framer-motion';
|
||||||
|
import { DropZone } from '@/components/DropZone';
|
||||||
|
import { FileCard } from '@/components/FileCard';
|
||||||
|
import { useFileUpload } from '@/hooks/useFileUpload';
|
||||||
|
import { useConversion } from '@/hooks/useConversion';
|
||||||
|
import { formatFileSize } from '@/lib/utils';
|
||||||
|
|
||||||
|
export default function Home() {
|
||||||
|
const {
|
||||||
|
files,
|
||||||
|
isDragging,
|
||||||
|
inputRef,
|
||||||
|
handleDragEnter,
|
||||||
|
handleDragLeave,
|
||||||
|
handleDragOver,
|
||||||
|
handleDrop,
|
||||||
|
handleFileInput,
|
||||||
|
openFilePicker,
|
||||||
|
removeFile,
|
||||||
|
updateFile,
|
||||||
|
setTargetFormat,
|
||||||
|
clearAll,
|
||||||
|
} = useFileUpload();
|
||||||
|
|
||||||
|
const {
|
||||||
|
isConverting,
|
||||||
|
convertAll,
|
||||||
|
downloadFile,
|
||||||
|
downloadAllAsZip,
|
||||||
|
} = useConversion(updateFile);
|
||||||
|
|
||||||
|
const hasFiles = files.length > 0;
|
||||||
|
const convertableCount = files.filter(
|
||||||
|
(f) => f.targetFormat && f.status !== 'done' && f.availableFormats.length > 0
|
||||||
|
).length;
|
||||||
|
const completedCount = files.filter((f) => f.status === 'done').length;
|
||||||
|
const totalSize = files.reduce((acc, f) => acc + f.size, 0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen relative">
|
||||||
|
{/* Atmospheric backgrounds */}
|
||||||
|
<div className="bg-mesh" />
|
||||||
|
<div className="noise-overlay" />
|
||||||
|
|
||||||
|
{/* Header */}
|
||||||
|
<header className="app-header">
|
||||||
|
<div className="logo">
|
||||||
|
<div className="logo-icon">T</div>
|
||||||
|
<span>Transmute</span>
|
||||||
|
</div>
|
||||||
|
<span className="header-tag">v1.0 / client-side</span>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* Drop Zone */}
|
||||||
|
<DropZone
|
||||||
|
isDragging={isDragging}
|
||||||
|
hasFiles={hasFiles}
|
||||||
|
inputRef={inputRef}
|
||||||
|
onDragEnter={handleDragEnter}
|
||||||
|
onDragLeave={handleDragLeave}
|
||||||
|
onDragOver={handleDragOver}
|
||||||
|
onDrop={handleDrop}
|
||||||
|
onFileInput={handleFileInput}
|
||||||
|
onBrowse={openFilePicker}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* File Grid */}
|
||||||
|
{hasFiles && (
|
||||||
|
<motion.div
|
||||||
|
className="file-grid"
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
transition={{ duration: 0.3 }}
|
||||||
|
>
|
||||||
|
<AnimatePresence mode="popLayout">
|
||||||
|
{files.map((file, index) => (
|
||||||
|
<FileCard
|
||||||
|
key={file.id}
|
||||||
|
file={file}
|
||||||
|
index={index}
|
||||||
|
onSetFormat={setTargetFormat}
|
||||||
|
onRemove={removeFile}
|
||||||
|
onDownload={downloadFile}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</AnimatePresence>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Action Bar */}
|
||||||
|
{hasFiles && (
|
||||||
|
<motion.div
|
||||||
|
className="action-bar"
|
||||||
|
initial={{ y: 80, opacity: 0 }}
|
||||||
|
animate={{ y: 0, opacity: 1 }}
|
||||||
|
transition={{ duration: 0.4, ease: [0.16, 1, 0.3, 1] }}
|
||||||
|
>
|
||||||
|
<div className="action-bar-info">
|
||||||
|
<span className="action-bar-stat">
|
||||||
|
<strong>{files.length}</strong> file{files.length !== 1 ? 's' : ''}
|
||||||
|
</span>
|
||||||
|
<div className="stat-divider" />
|
||||||
|
<span className="action-bar-stat">
|
||||||
|
<strong>{formatFileSize(totalSize)}</strong>
|
||||||
|
</span>
|
||||||
|
{completedCount > 0 && (
|
||||||
|
<>
|
||||||
|
<div className="stat-divider" />
|
||||||
|
<span className="action-bar-stat">
|
||||||
|
<strong style={{ color: '#10b981' }}>{completedCount}</strong> converted
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="action-bar-buttons">
|
||||||
|
<button className="btn-secondary" onClick={clearAll}>
|
||||||
|
Clear all
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{completedCount > 0 && (
|
||||||
|
<motion.button
|
||||||
|
className="btn-download-all"
|
||||||
|
onClick={() => downloadAllAsZip(files)}
|
||||||
|
initial={{ opacity: 0, scale: 0.9 }}
|
||||||
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
|
whileHover={{ scale: 1.03 }}
|
||||||
|
whileTap={{ scale: 0.97 }}
|
||||||
|
>
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4M7 10l5 5 5-5M12 15V3" />
|
||||||
|
</svg>
|
||||||
|
Download ZIP
|
||||||
|
</motion.button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<motion.button
|
||||||
|
className={`btn-convert ${isConverting ? 'converting' : ''}`}
|
||||||
|
onClick={() => convertAll(files)}
|
||||||
|
disabled={isConverting || convertableCount === 0}
|
||||||
|
whileHover={!isConverting && convertableCount > 0 ? { scale: 1.03 } : {}}
|
||||||
|
whileTap={!isConverting && convertableCount > 0 ? { scale: 0.97 } : {}}
|
||||||
|
>
|
||||||
|
{isConverting ? (
|
||||||
|
<>
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className="animate-spin">
|
||||||
|
<path d="M12 2v4M12 18v4M4.93 4.93l2.83 2.83M16.24 16.24l2.83 2.83M2 12h4M18 12h4M4.93 19.07l2.83-2.83M16.24 7.76l2.83-2.83" />
|
||||||
|
</svg>
|
||||||
|
Converting...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
|
||||||
|
<path d="M13 2L3 14h9l-1 8 10-12h-9l1-8z" strokeLinecap="round" strokeLinejoin="round" />
|
||||||
|
</svg>
|
||||||
|
Transmute {convertableCount > 0 ? `(${convertableCount})` : ''}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</motion.button>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { motion } from 'framer-motion';
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
interface DropZoneProps {
|
||||||
|
isDragging: boolean;
|
||||||
|
hasFiles: boolean;
|
||||||
|
inputRef: React.RefObject<HTMLInputElement | null>;
|
||||||
|
onDragEnter: (e: React.DragEvent) => void;
|
||||||
|
onDragLeave: (e: React.DragEvent) => void;
|
||||||
|
onDragOver: (e: React.DragEvent) => void;
|
||||||
|
onDrop: (e: React.DragEvent) => void;
|
||||||
|
onFileInput: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
||||||
|
onBrowse: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DropZone({
|
||||||
|
isDragging,
|
||||||
|
hasFiles,
|
||||||
|
inputRef,
|
||||||
|
onDragEnter,
|
||||||
|
onDragLeave,
|
||||||
|
onDragOver,
|
||||||
|
onDrop,
|
||||||
|
onFileInput,
|
||||||
|
onBrowse,
|
||||||
|
}: DropZoneProps) {
|
||||||
|
if (hasFiles) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="drop-zone-compact"
|
||||||
|
onDragEnter={onDragEnter}
|
||||||
|
onDragLeave={onDragLeave}
|
||||||
|
onDragOver={onDragOver}
|
||||||
|
onDrop={onDrop}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type="file"
|
||||||
|
multiple
|
||||||
|
onChange={onFileInput}
|
||||||
|
className="hidden"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className={`compact-drop-area ${isDragging ? 'dragging' : ''}`}
|
||||||
|
onClick={onBrowse}
|
||||||
|
>
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<path d="M12 5v14M5 12h14" />
|
||||||
|
</svg>
|
||||||
|
<span>Drop more files or click to browse</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="drop-zone-hero"
|
||||||
|
onDragEnter={onDragEnter}
|
||||||
|
onDragLeave={onDragLeave}
|
||||||
|
onDragOver={onDragOver}
|
||||||
|
onDrop={onDrop}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type="file"
|
||||||
|
multiple
|
||||||
|
onChange={onFileInput}
|
||||||
|
className="hidden"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<motion.div
|
||||||
|
className={`drop-zone-inner ${isDragging ? 'dragging' : ''}`}
|
||||||
|
initial={{ opacity: 0, scale: 0.95 }}
|
||||||
|
animate={{
|
||||||
|
opacity: 1,
|
||||||
|
scale: isDragging ? 1.02 : 1,
|
||||||
|
}}
|
||||||
|
transition={{ duration: 0.4, ease: [0.16, 1, 0.3, 1] }}
|
||||||
|
>
|
||||||
|
{/* Animated corner accents */}
|
||||||
|
<div className="corner-accent top-left" />
|
||||||
|
<div className="corner-accent top-right" />
|
||||||
|
<div className="corner-accent bottom-left" />
|
||||||
|
<div className="corner-accent bottom-right" />
|
||||||
|
|
||||||
|
<motion.div
|
||||||
|
className="drop-zone-content"
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ delay: 0.2, duration: 0.6 }}
|
||||||
|
>
|
||||||
|
{/* Upload icon */}
|
||||||
|
<motion.div
|
||||||
|
className="upload-icon"
|
||||||
|
animate={{
|
||||||
|
y: isDragging ? -8 : 0,
|
||||||
|
scale: isDragging ? 1.15 : 1,
|
||||||
|
}}
|
||||||
|
transition={{ type: 'spring', stiffness: 300, damping: 20 }}
|
||||||
|
>
|
||||||
|
<svg width="48" height="48" viewBox="0 0 48 48" fill="none">
|
||||||
|
<path
|
||||||
|
d="M24 32V8M24 8L16 16M24 8L32 16"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2.5"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M8 28v8a4 4 0 004 4h24a4 4 0 004-4v-8"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2.5"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
<h2 className="drop-zone-title">
|
||||||
|
{isDragging ? 'Release to transmute' : 'Drop anything.'}
|
||||||
|
</h2>
|
||||||
|
<p className="drop-zone-subtitle">
|
||||||
|
{isDragging
|
||||||
|
? 'Your files are ready for transformation'
|
||||||
|
: 'Images, documents, audio, video, data \u2014 all formats welcome'}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<motion.button
|
||||||
|
className="browse-button"
|
||||||
|
onClick={onBrowse}
|
||||||
|
whileHover={{ scale: 1.04 }}
|
||||||
|
whileTap={{ scale: 0.97 }}
|
||||||
|
>
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4M7 10l5 5 5-5M12 15V3" />
|
||||||
|
</svg>
|
||||||
|
Browse files
|
||||||
|
</motion.button>
|
||||||
|
|
||||||
|
<p className="drop-zone-hint">
|
||||||
|
100% client-side \u2014 your files never leave your browser
|
||||||
|
</p>
|
||||||
|
</motion.div>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { motion } from 'framer-motion';
|
||||||
|
import { UploadedFile, CATEGORY_COLORS, CATEGORY_LABELS } from '@/types';
|
||||||
|
import { formatFileSize, truncateFilename } from '@/lib/utils';
|
||||||
|
import { ProgressRing } from './ProgressRing';
|
||||||
|
|
||||||
|
interface FileCardProps {
|
||||||
|
file: UploadedFile;
|
||||||
|
index: number;
|
||||||
|
onSetFormat: (id: string, format: string) => void;
|
||||||
|
onRemove: (id: string) => void;
|
||||||
|
onDownload: (file: UploadedFile) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FileCard({
|
||||||
|
file,
|
||||||
|
index,
|
||||||
|
onSetFormat,
|
||||||
|
onRemove,
|
||||||
|
onDownload,
|
||||||
|
}: FileCardProps) {
|
||||||
|
const categoryColor = CATEGORY_COLORS[file.category];
|
||||||
|
const categoryLabel = CATEGORY_LABELS[file.category];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
className="file-card"
|
||||||
|
initial={{ opacity: 0, y: 20, scale: 0.95 }}
|
||||||
|
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||||
|
exit={{ opacity: 0, y: -10, scale: 0.95 }}
|
||||||
|
transition={{
|
||||||
|
duration: 0.4,
|
||||||
|
delay: index * 0.05,
|
||||||
|
ease: [0.16, 1, 0.3, 1],
|
||||||
|
}}
|
||||||
|
layout
|
||||||
|
style={{
|
||||||
|
'--card-accent': categoryColor,
|
||||||
|
} as React.CSSProperties}
|
||||||
|
>
|
||||||
|
{/* Top accent line */}
|
||||||
|
<div
|
||||||
|
className="card-accent-line"
|
||||||
|
style={{ background: categoryColor }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Header: category badge + remove */}
|
||||||
|
<div className="card-header">
|
||||||
|
<span
|
||||||
|
className="category-badge"
|
||||||
|
style={{
|
||||||
|
background: `${categoryColor}18`,
|
||||||
|
color: categoryColor,
|
||||||
|
borderColor: `${categoryColor}30`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{categoryLabel}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{file.status !== 'converting' && (
|
||||||
|
<button
|
||||||
|
className="remove-btn"
|
||||||
|
onClick={() => onRemove(file.id)}
|
||||||
|
aria-label="Remove file"
|
||||||
|
>
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<path d="M18 6L6 18M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* File preview / icon */}
|
||||||
|
<div className="card-preview">
|
||||||
|
{file.preview ? (
|
||||||
|
/* eslint-disable-next-line @next/next/no-img-element */
|
||||||
|
<img
|
||||||
|
src={file.preview}
|
||||||
|
alt={file.name}
|
||||||
|
className="preview-image"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
className="preview-icon"
|
||||||
|
style={{ color: categoryColor }}
|
||||||
|
>
|
||||||
|
<span className="file-ext">.{file.extension}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Progress overlay */}
|
||||||
|
{file.status === 'converting' && (
|
||||||
|
<div className="progress-overlay">
|
||||||
|
<ProgressRing progress={file.progress} color={categoryColor} />
|
||||||
|
<span className="progress-text">{Math.round(file.progress)}%</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Done overlay */}
|
||||||
|
{file.status === 'done' && (
|
||||||
|
<motion.div
|
||||||
|
className="done-overlay"
|
||||||
|
initial={{ opacity: 0, scale: 0.5 }}
|
||||||
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
|
transition={{ type: 'spring', stiffness: 400, damping: 15 }}
|
||||||
|
>
|
||||||
|
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="#10b981" strokeWidth="2.5">
|
||||||
|
<path d="M20 6L9 17l-5-5" strokeLinecap="round" strokeLinejoin="round" />
|
||||||
|
</svg>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Error overlay */}
|
||||||
|
{file.status === 'error' && (
|
||||||
|
<div className="error-overlay">
|
||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#f43f5e" strokeWidth="2">
|
||||||
|
<circle cx="12" cy="12" r="10" />
|
||||||
|
<path d="M12 8v4M12 16h.01" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* File info */}
|
||||||
|
<div className="card-info">
|
||||||
|
<p className="file-name" title={file.name}>
|
||||||
|
{truncateFilename(file.name)}
|
||||||
|
</p>
|
||||||
|
<p className="file-size">{formatFileSize(file.size)}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Error message */}
|
||||||
|
{file.status === 'error' && file.error && (
|
||||||
|
<p className="error-message">{file.error}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Format selector */}
|
||||||
|
{file.availableFormats.length > 0 && file.status !== 'done' && (
|
||||||
|
<div className="format-selector">
|
||||||
|
<span className="format-from">.{file.extension}</span>
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className="format-arrow">
|
||||||
|
<path d="M5 12h14M12 5l7 7-7 7" />
|
||||||
|
</svg>
|
||||||
|
<select
|
||||||
|
value={file.targetFormat || ''}
|
||||||
|
onChange={(e) => onSetFormat(file.id, e.target.value)}
|
||||||
|
className="format-select"
|
||||||
|
style={{ borderColor: `${categoryColor}40` }}
|
||||||
|
>
|
||||||
|
{file.availableFormats.map((fmt) => (
|
||||||
|
<option key={fmt} value={fmt}>
|
||||||
|
.{fmt}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Download button */}
|
||||||
|
{file.status === 'done' && (
|
||||||
|
<motion.button
|
||||||
|
className="download-btn"
|
||||||
|
onClick={() => onDownload(file)}
|
||||||
|
initial={{ opacity: 0, y: 5 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
whileHover={{ scale: 1.02 }}
|
||||||
|
whileTap={{ scale: 0.98 }}
|
||||||
|
>
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4M7 10l5 5 5-5M12 15V3" />
|
||||||
|
</svg>
|
||||||
|
Download .{file.targetFormat}
|
||||||
|
</motion.button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Unsupported message */}
|
||||||
|
{file.availableFormats.length === 0 && (
|
||||||
|
<p className="unsupported-msg">
|
||||||
|
Format not supported for conversion
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
interface ProgressRingProps {
|
||||||
|
progress: number;
|
||||||
|
size?: number;
|
||||||
|
strokeWidth?: number;
|
||||||
|
color?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ProgressRing({
|
||||||
|
progress,
|
||||||
|
size = 36,
|
||||||
|
strokeWidth = 3,
|
||||||
|
color = '#00F0FF',
|
||||||
|
}: ProgressRingProps) {
|
||||||
|
const radius = (size - strokeWidth) / 2;
|
||||||
|
const circumference = 2 * Math.PI * radius;
|
||||||
|
const offset = circumference - (progress / 100) * circumference;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
width={size}
|
||||||
|
height={size}
|
||||||
|
className="progress-ring"
|
||||||
|
style={{ transform: 'rotate(-90deg)' }}
|
||||||
|
>
|
||||||
|
{/* Background ring */}
|
||||||
|
<circle
|
||||||
|
cx={size / 2}
|
||||||
|
cy={size / 2}
|
||||||
|
r={radius}
|
||||||
|
fill="none"
|
||||||
|
stroke="rgba(255,255,255,0.08)"
|
||||||
|
strokeWidth={strokeWidth}
|
||||||
|
/>
|
||||||
|
{/* Progress ring */}
|
||||||
|
<circle
|
||||||
|
cx={size / 2}
|
||||||
|
cy={size / 2}
|
||||||
|
r={radius}
|
||||||
|
fill="none"
|
||||||
|
stroke={color}
|
||||||
|
strokeWidth={strokeWidth}
|
||||||
|
strokeDasharray={circumference}
|
||||||
|
strokeDashoffset={offset}
|
||||||
|
strokeLinecap="round"
|
||||||
|
style={{
|
||||||
|
transition: 'stroke-dashoffset 0.3s ease',
|
||||||
|
filter: `drop-shadow(0 0 4px ${color}40)`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useCallback, useState } from 'react';
|
||||||
|
import { UploadedFile } from '@/types';
|
||||||
|
import { convertImage } from '@/lib/converters/imageConverter';
|
||||||
|
import { convertData } from '@/lib/converters/dataConverter';
|
||||||
|
import { convertDocument } from '@/lib/converters/documentConverter';
|
||||||
|
import { convertMedia } from '@/lib/converters/mediaConverter';
|
||||||
|
|
||||||
|
export function useConversion(
|
||||||
|
updateFile: (id: string, updates: Partial<UploadedFile>) => void
|
||||||
|
) {
|
||||||
|
const [isConverting, setIsConverting] = useState(false);
|
||||||
|
|
||||||
|
const convertSingleFile = useCallback(
|
||||||
|
async (file: UploadedFile) => {
|
||||||
|
if (!file.targetFormat || file.status === 'done') return;
|
||||||
|
|
||||||
|
updateFile(file.id, { status: 'converting', progress: 0, error: undefined });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const onProgress = (progress: number) => {
|
||||||
|
updateFile(file.id, { progress });
|
||||||
|
};
|
||||||
|
|
||||||
|
let result;
|
||||||
|
switch (file.category) {
|
||||||
|
case 'image':
|
||||||
|
result = await convertImage(file.file, file.targetFormat, onProgress);
|
||||||
|
break;
|
||||||
|
case 'data':
|
||||||
|
result = await convertData(file.file, file.targetFormat, onProgress);
|
||||||
|
break;
|
||||||
|
case 'document':
|
||||||
|
result = await convertDocument(file.file, file.targetFormat, onProgress);
|
||||||
|
break;
|
||||||
|
case 'audio':
|
||||||
|
case 'video':
|
||||||
|
result = await convertMedia(file.file, file.targetFormat, onProgress);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
throw new Error(`Unsupported file category: ${file.category}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
updateFile(file.id, {
|
||||||
|
status: 'done',
|
||||||
|
progress: 100,
|
||||||
|
convertedBlob: result.blob,
|
||||||
|
convertedName: result.filename,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
updateFile(file.id, {
|
||||||
|
status: 'error',
|
||||||
|
progress: 0,
|
||||||
|
error: error instanceof Error ? error.message : 'Conversion failed',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[updateFile]
|
||||||
|
);
|
||||||
|
|
||||||
|
const convertAll = useCallback(
|
||||||
|
async (files: UploadedFile[]) => {
|
||||||
|
setIsConverting(true);
|
||||||
|
const toConvert = files.filter(
|
||||||
|
(f) => f.targetFormat && f.status !== 'done' && f.availableFormats.length > 0
|
||||||
|
);
|
||||||
|
|
||||||
|
// Convert in parallel batches of 3
|
||||||
|
const batchSize = 3;
|
||||||
|
for (let i = 0; i < toConvert.length; i += batchSize) {
|
||||||
|
const batch = toConvert.slice(i, i + batchSize);
|
||||||
|
await Promise.all(batch.map((f) => convertSingleFile(f)));
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsConverting(false);
|
||||||
|
},
|
||||||
|
[convertSingleFile]
|
||||||
|
);
|
||||||
|
|
||||||
|
const downloadFile = useCallback((file: UploadedFile) => {
|
||||||
|
if (!file.convertedBlob || !file.convertedName) return;
|
||||||
|
|
||||||
|
const url = URL.createObjectURL(file.convertedBlob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = file.convertedName;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
document.body.removeChild(a);
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const downloadAllAsZip = useCallback(async (files: UploadedFile[]) => {
|
||||||
|
const completedFiles = files.filter(
|
||||||
|
(f) => f.status === 'done' && f.convertedBlob
|
||||||
|
);
|
||||||
|
if (completedFiles.length === 0) return;
|
||||||
|
|
||||||
|
const JSZip = (await import('jszip')).default;
|
||||||
|
const zip = new JSZip();
|
||||||
|
|
||||||
|
completedFiles.forEach((f) => {
|
||||||
|
if (f.convertedBlob && f.convertedName) {
|
||||||
|
zip.file(f.convertedName, f.convertedBlob);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const blob = await zip.generateAsync({ type: 'blob' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = 'transmute-converted.zip';
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
document.body.removeChild(a);
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
isConverting,
|
||||||
|
convertAll,
|
||||||
|
convertSingleFile,
|
||||||
|
downloadFile,
|
||||||
|
downloadAllAsZip,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useCallback, useRef } from 'react';
|
||||||
|
import { UploadedFile } from '@/types';
|
||||||
|
import { detectCategory, getExtension, generateId } from '@/lib/fileDetector';
|
||||||
|
import { getAvailableFormats, getDefaultTarget } from '@/lib/conversionMap';
|
||||||
|
|
||||||
|
export function useFileUpload() {
|
||||||
|
const [files, setFiles] = useState<UploadedFile[]>([]);
|
||||||
|
const [isDragging, setIsDragging] = useState(false);
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const dragCountRef = useRef(0);
|
||||||
|
|
||||||
|
const processFiles = useCallback((fileList: FileList | File[]) => {
|
||||||
|
const newFiles: UploadedFile[] = Array.from(fileList).map((file) => {
|
||||||
|
const category = detectCategory(file);
|
||||||
|
const extension = getExtension(file.name);
|
||||||
|
const availableFormats = getAvailableFormats(category, extension);
|
||||||
|
const targetFormat = getDefaultTarget(category, extension);
|
||||||
|
|
||||||
|
// Generate preview for images
|
||||||
|
let preview: string | undefined;
|
||||||
|
if (category === 'image') {
|
||||||
|
preview = URL.createObjectURL(file);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: generateId(),
|
||||||
|
file,
|
||||||
|
name: file.name,
|
||||||
|
size: file.size,
|
||||||
|
type: file.type,
|
||||||
|
category,
|
||||||
|
extension,
|
||||||
|
preview,
|
||||||
|
targetFormat,
|
||||||
|
availableFormats,
|
||||||
|
status: 'idle' as const,
|
||||||
|
progress: 0,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
setFiles((prev) => [...prev, ...newFiles]);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleDragEnter = useCallback((e: React.DragEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
dragCountRef.current++;
|
||||||
|
setIsDragging(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleDragLeave = useCallback((e: React.DragEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
dragCountRef.current--;
|
||||||
|
if (dragCountRef.current === 0) {
|
||||||
|
setIsDragging(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleDragOver = useCallback((e: React.DragEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleDrop = useCallback(
|
||||||
|
(e: React.DragEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
dragCountRef.current = 0;
|
||||||
|
setIsDragging(false);
|
||||||
|
if (e.dataTransfer.files.length > 0) {
|
||||||
|
processFiles(e.dataTransfer.files);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[processFiles]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleFileInput = useCallback(
|
||||||
|
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
if (e.target.files && e.target.files.length > 0) {
|
||||||
|
processFiles(e.target.files);
|
||||||
|
e.target.value = '';
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[processFiles]
|
||||||
|
);
|
||||||
|
|
||||||
|
const openFilePicker = useCallback(() => {
|
||||||
|
inputRef.current?.click();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const removeFile = useCallback((id: string) => {
|
||||||
|
setFiles((prev) => {
|
||||||
|
const file = prev.find((f) => f.id === id);
|
||||||
|
if (file?.preview) URL.revokeObjectURL(file.preview);
|
||||||
|
return prev.filter((f) => f.id !== id);
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const updateFile = useCallback((id: string, updates: Partial<UploadedFile>) => {
|
||||||
|
setFiles((prev) =>
|
||||||
|
prev.map((f) => (f.id === id ? { ...f, ...updates } : f))
|
||||||
|
);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const setTargetFormat = useCallback((id: string, format: string) => {
|
||||||
|
setFiles((prev) =>
|
||||||
|
prev.map((f) => (f.id === id ? { ...f, targetFormat: format } : f))
|
||||||
|
);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const clearAll = useCallback(() => {
|
||||||
|
files.forEach((f) => {
|
||||||
|
if (f.preview) URL.revokeObjectURL(f.preview);
|
||||||
|
});
|
||||||
|
setFiles([]);
|
||||||
|
}, [files]);
|
||||||
|
|
||||||
|
const clearCompleted = useCallback(() => {
|
||||||
|
setFiles((prev) => {
|
||||||
|
prev.forEach((f) => {
|
||||||
|
if (f.status === 'done' && f.preview) URL.revokeObjectURL(f.preview);
|
||||||
|
});
|
||||||
|
return prev.filter((f) => f.status !== 'done');
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
files,
|
||||||
|
isDragging,
|
||||||
|
inputRef,
|
||||||
|
handleDragEnter,
|
||||||
|
handleDragLeave,
|
||||||
|
handleDragOver,
|
||||||
|
handleDrop,
|
||||||
|
handleFileInput,
|
||||||
|
openFilePicker,
|
||||||
|
removeFile,
|
||||||
|
updateFile,
|
||||||
|
setTargetFormat,
|
||||||
|
clearAll,
|
||||||
|
clearCompleted,
|
||||||
|
setFiles,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import { FileCategory } from '@/types';
|
||||||
|
|
||||||
|
const IMAGE_CONVERSIONS: Record<string, string[]> = {
|
||||||
|
png: ['jpg', 'webp', 'gif', 'bmp', 'avif'],
|
||||||
|
jpg: ['png', 'webp', 'gif', 'bmp', 'avif'],
|
||||||
|
jpeg: ['png', 'webp', 'gif', 'bmp', 'avif'],
|
||||||
|
webp: ['png', 'jpg', 'gif', 'bmp', 'avif'],
|
||||||
|
gif: ['png', 'jpg', 'webp', 'bmp'],
|
||||||
|
bmp: ['png', 'jpg', 'webp', 'gif'],
|
||||||
|
tiff: ['png', 'jpg', 'webp'],
|
||||||
|
tif: ['png', 'jpg', 'webp'],
|
||||||
|
avif: ['png', 'jpg', 'webp'],
|
||||||
|
svg: ['png', 'jpg', 'webp'],
|
||||||
|
ico: ['png', 'jpg', 'webp'],
|
||||||
|
};
|
||||||
|
|
||||||
|
const DOCUMENT_CONVERSIONS: Record<string, string[]> = {
|
||||||
|
docx: ['html', 'txt', 'pdf'],
|
||||||
|
md: ['html', 'pdf', 'txt'],
|
||||||
|
html: ['pdf', 'txt', 'md'],
|
||||||
|
htm: ['pdf', 'txt', 'md'],
|
||||||
|
txt: ['pdf', 'html', 'md'],
|
||||||
|
pdf: ['txt'],
|
||||||
|
};
|
||||||
|
|
||||||
|
const AUDIO_CONVERSIONS: Record<string, string[]> = {
|
||||||
|
mp3: ['wav', 'ogg', 'aac', 'flac', 'm4a'],
|
||||||
|
wav: ['mp3', 'ogg', 'aac', 'flac', 'm4a'],
|
||||||
|
flac: ['mp3', 'wav', 'ogg', 'aac', 'm4a'],
|
||||||
|
ogg: ['mp3', 'wav', 'aac', 'flac', 'm4a'],
|
||||||
|
aac: ['mp3', 'wav', 'ogg', 'flac', 'm4a'],
|
||||||
|
m4a: ['mp3', 'wav', 'ogg', 'flac', 'aac'],
|
||||||
|
wma: ['mp3', 'wav', 'ogg', 'flac'],
|
||||||
|
opus: ['mp3', 'wav', 'ogg', 'flac'],
|
||||||
|
};
|
||||||
|
|
||||||
|
const VIDEO_CONVERSIONS: Record<string, string[]> = {
|
||||||
|
mp4: ['webm', 'avi', 'mov', 'gif', 'mp3'],
|
||||||
|
webm: ['mp4', 'avi', 'mov', 'gif', 'mp3'],
|
||||||
|
avi: ['mp4', 'webm', 'mov', 'gif', 'mp3'],
|
||||||
|
mov: ['mp4', 'webm', 'avi', 'gif', 'mp3'],
|
||||||
|
mkv: ['mp4', 'webm', 'avi', 'gif', 'mp3'],
|
||||||
|
flv: ['mp4', 'webm', 'avi', 'mp3'],
|
||||||
|
wmv: ['mp4', 'webm', 'avi', 'mp3'],
|
||||||
|
m4v: ['mp4', 'webm', 'avi', 'mp3'],
|
||||||
|
};
|
||||||
|
|
||||||
|
const DATA_CONVERSIONS: Record<string, string[]> = {
|
||||||
|
csv: ['json', 'xml', 'yaml', 'tsv'],
|
||||||
|
json: ['csv', 'xml', 'yaml'],
|
||||||
|
xml: ['json', 'csv', 'yaml'],
|
||||||
|
yaml: ['json', 'csv', 'xml'],
|
||||||
|
yml: ['json', 'csv', 'xml'],
|
||||||
|
tsv: ['csv', 'json', 'xml', 'yaml'],
|
||||||
|
};
|
||||||
|
|
||||||
|
const ALL_CONVERSIONS: Record<FileCategory, Record<string, string[]>> = {
|
||||||
|
image: IMAGE_CONVERSIONS,
|
||||||
|
document: DOCUMENT_CONVERSIONS,
|
||||||
|
audio: AUDIO_CONVERSIONS,
|
||||||
|
video: VIDEO_CONVERSIONS,
|
||||||
|
data: DATA_CONVERSIONS,
|
||||||
|
unknown: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
export function getAvailableFormats(category: FileCategory, extension: string): string[] {
|
||||||
|
return ALL_CONVERSIONS[category]?.[extension] || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getDefaultTarget(category: FileCategory, extension: string): string | null {
|
||||||
|
const formats = getAvailableFormats(category, extension);
|
||||||
|
if (formats.length === 0) return null;
|
||||||
|
|
||||||
|
const defaults: Record<string, string> = {
|
||||||
|
// Images → WebP (modern, smaller)
|
||||||
|
png: 'webp', jpg: 'webp', jpeg: 'webp', gif: 'webp',
|
||||||
|
bmp: 'png', tiff: 'png', tif: 'png', avif: 'png', svg: 'png', ico: 'png',
|
||||||
|
// Documents → PDF
|
||||||
|
docx: 'pdf', md: 'html', html: 'pdf', txt: 'pdf', pdf: 'txt',
|
||||||
|
// Audio → MP3
|
||||||
|
wav: 'mp3', flac: 'mp3', ogg: 'mp3', aac: 'mp3', m4a: 'mp3', wma: 'mp3', opus: 'mp3', mp3: 'wav',
|
||||||
|
// Video → MP4
|
||||||
|
avi: 'mp4', mov: 'mp4', mkv: 'mp4', flv: 'mp4', wmv: 'mp4', m4v: 'mp4', mp4: 'webm', webm: 'mp4',
|
||||||
|
// Data → JSON
|
||||||
|
csv: 'json', xml: 'json', yaml: 'json', yml: 'json', tsv: 'csv', json: 'csv',
|
||||||
|
};
|
||||||
|
|
||||||
|
return defaults[extension] || formats[0];
|
||||||
|
}
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
import Papa from 'papaparse';
|
||||||
|
import yaml from 'js-yaml';
|
||||||
|
import { XMLParser, XMLBuilder } from 'fast-xml-parser';
|
||||||
|
import { ConversionResult } from '@/types';
|
||||||
|
import { buildOutputFilename, getMimeType } from '@/lib/utils';
|
||||||
|
import { getExtension } from '@/lib/fileDetector';
|
||||||
|
|
||||||
|
async function readFileAsText(file: File): Promise<string> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = (e) => resolve(e.target?.result as string);
|
||||||
|
reader.onerror = () => reject(new Error('Failed to read file'));
|
||||||
|
reader.readAsText(file);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function csvToJson(text: string): object[] {
|
||||||
|
const result = Papa.parse(text, { header: true, skipEmptyLines: true });
|
||||||
|
return result.data as object[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function jsonToCsv(data: unknown): string {
|
||||||
|
const arr = Array.isArray(data) ? data : [data];
|
||||||
|
return Papa.unparse(arr);
|
||||||
|
}
|
||||||
|
|
||||||
|
function tsvToJson(text: string): object[] {
|
||||||
|
const result = Papa.parse(text, { header: true, skipEmptyLines: true, delimiter: '\t' });
|
||||||
|
return result.data as object[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function jsonToTsv(data: unknown): string {
|
||||||
|
const arr = Array.isArray(data) ? data : [data];
|
||||||
|
return Papa.unparse(arr, { delimiter: '\t' });
|
||||||
|
}
|
||||||
|
|
||||||
|
function xmlToJson(text: string): unknown {
|
||||||
|
const parser = new XMLParser({ ignoreAttributes: false });
|
||||||
|
return parser.parse(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
function jsonToXml(data: unknown): string {
|
||||||
|
const builder = new XMLBuilder({ ignoreAttributes: false, format: true });
|
||||||
|
return builder.build(typeof data === 'string' ? JSON.parse(data) : data);
|
||||||
|
}
|
||||||
|
|
||||||
|
function jsonToYaml(data: unknown): string {
|
||||||
|
return yaml.dump(typeof data === 'string' ? JSON.parse(data) : data);
|
||||||
|
}
|
||||||
|
|
||||||
|
function yamlToJson(text: string): unknown {
|
||||||
|
return yaml.load(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toIntermediate(file: File, ext: string): Promise<unknown> {
|
||||||
|
const text = await readFileAsText(file);
|
||||||
|
|
||||||
|
switch (ext) {
|
||||||
|
case 'json':
|
||||||
|
return JSON.parse(text);
|
||||||
|
case 'csv':
|
||||||
|
return csvToJson(text);
|
||||||
|
case 'tsv':
|
||||||
|
return tsvToJson(text);
|
||||||
|
case 'xml':
|
||||||
|
return xmlToJson(text);
|
||||||
|
case 'yaml':
|
||||||
|
case 'yml':
|
||||||
|
return yamlToJson(text);
|
||||||
|
default:
|
||||||
|
throw new Error(`Unsupported source format: ${ext}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function fromIntermediate(data: unknown, targetFormat: string): string {
|
||||||
|
switch (targetFormat) {
|
||||||
|
case 'json':
|
||||||
|
return JSON.stringify(data, null, 2);
|
||||||
|
case 'csv':
|
||||||
|
return jsonToCsv(data);
|
||||||
|
case 'tsv':
|
||||||
|
return jsonToTsv(data);
|
||||||
|
case 'xml':
|
||||||
|
return jsonToXml(data);
|
||||||
|
case 'yaml':
|
||||||
|
case 'yml':
|
||||||
|
return jsonToYaml(data);
|
||||||
|
default:
|
||||||
|
throw new Error(`Unsupported target format: ${targetFormat}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function convertData(
|
||||||
|
file: File,
|
||||||
|
targetFormat: string,
|
||||||
|
onProgress?: (progress: number) => void
|
||||||
|
): Promise<ConversionResult> {
|
||||||
|
onProgress?.(20);
|
||||||
|
|
||||||
|
const ext = getExtension(file.name);
|
||||||
|
const intermediate = await toIntermediate(file, ext);
|
||||||
|
onProgress?.(60);
|
||||||
|
|
||||||
|
const output = fromIntermediate(intermediate, targetFormat);
|
||||||
|
onProgress?.(90);
|
||||||
|
|
||||||
|
const blob = new Blob([output], { type: getMimeType(targetFormat) });
|
||||||
|
onProgress?.(100);
|
||||||
|
|
||||||
|
return {
|
||||||
|
blob,
|
||||||
|
filename: buildOutputFilename(file.name, targetFormat),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,216 @@
|
|||||||
|
import { ConversionResult } from '@/types';
|
||||||
|
import { buildOutputFilename } from '@/lib/utils';
|
||||||
|
import { getExtension } from '@/lib/fileDetector';
|
||||||
|
|
||||||
|
async function readFileAsText(file: File): Promise<string> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = (e) => resolve(e.target?.result as string);
|
||||||
|
reader.onerror = () => reject(new Error('Failed to read file'));
|
||||||
|
reader.readAsText(file);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readFileAsArrayBuffer(file: File): Promise<ArrayBuffer> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = (e) => resolve(e.target?.result as ArrayBuffer);
|
||||||
|
reader.onerror = () => reject(new Error('Failed to read file'));
|
||||||
|
reader.readAsArrayBuffer(file);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function docxToHtml(file: File): Promise<string> {
|
||||||
|
const mammoth = await import('mammoth');
|
||||||
|
const arrayBuffer = await readFileAsArrayBuffer(file);
|
||||||
|
const result = await mammoth.convertToHtml({ arrayBuffer });
|
||||||
|
return result.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function docxToText(file: File): Promise<string> {
|
||||||
|
const mammoth = await import('mammoth');
|
||||||
|
const arrayBuffer = await readFileAsArrayBuffer(file);
|
||||||
|
const result = await mammoth.extractRawText({ arrayBuffer });
|
||||||
|
return result.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function markdownToHtml(text: string): Promise<string> {
|
||||||
|
const { marked } = await import('marked');
|
||||||
|
return await marked(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
function htmlToText(html: string): string {
|
||||||
|
const parser = new DOMParser();
|
||||||
|
const doc = parser.parseFromString(html, 'text/html');
|
||||||
|
return doc.body.textContent || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function htmlToMarkdown(html: string): string {
|
||||||
|
let md = html;
|
||||||
|
md = md.replace(/<h1[^>]*>(.*?)<\/h1>/gi, '# $1\n\n');
|
||||||
|
md = md.replace(/<h2[^>]*>(.*?)<\/h2>/gi, '## $1\n\n');
|
||||||
|
md = md.replace(/<h3[^>]*>(.*?)<\/h3>/gi, '### $1\n\n');
|
||||||
|
md = md.replace(/<h4[^>]*>(.*?)<\/h4>/gi, '#### $1\n\n');
|
||||||
|
md = md.replace(/<strong[^>]*>(.*?)<\/strong>/gi, '**$1**');
|
||||||
|
md = md.replace(/<b[^>]*>(.*?)<\/b>/gi, '**$1**');
|
||||||
|
md = md.replace(/<em[^>]*>(.*?)<\/em>/gi, '*$1*');
|
||||||
|
md = md.replace(/<i[^>]*>(.*?)<\/i>/gi, '*$1*');
|
||||||
|
md = md.replace(/<a[^>]*href="([^"]*)"[^>]*>(.*?)<\/a>/gi, '[$2]($1)');
|
||||||
|
md = md.replace(/<br\s*\/?>/gi, '\n');
|
||||||
|
md = md.replace(/<p[^>]*>(.*?)<\/p>/gi, '$1\n\n');
|
||||||
|
md = md.replace(/<li[^>]*>(.*?)<\/li>/gi, '- $1\n');
|
||||||
|
md = md.replace(/<[^>]+>/g, '');
|
||||||
|
md = md.replace(/ /g, ' ');
|
||||||
|
md = md.replace(/&/g, '&');
|
||||||
|
md = md.replace(/</g, '<');
|
||||||
|
md = md.replace(/>/g, '>');
|
||||||
|
return md.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function textToPdf(text: string): Promise<Blob> {
|
||||||
|
const { jsPDF } = await import('jspdf');
|
||||||
|
const doc = new jsPDF();
|
||||||
|
const lines = doc.splitTextToSize(text, 180);
|
||||||
|
let y = 15;
|
||||||
|
const pageHeight = doc.internal.pageSize.getHeight();
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
if (y > pageHeight - 15) {
|
||||||
|
doc.addPage();
|
||||||
|
y = 15;
|
||||||
|
}
|
||||||
|
doc.text(line, 15, y);
|
||||||
|
y += 7;
|
||||||
|
}
|
||||||
|
|
||||||
|
return doc.output('blob');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function htmlToPdf(html: string): Promise<Blob> {
|
||||||
|
const text = htmlToText(html);
|
||||||
|
return textToPdf(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pdfToText(file: File): Promise<string> {
|
||||||
|
const { PDFDocument } = await import('pdf-lib');
|
||||||
|
const arrayBuffer = await readFileAsArrayBuffer(file);
|
||||||
|
const pdfDoc = await PDFDocument.load(arrayBuffer);
|
||||||
|
const pages = pdfDoc.getPages();
|
||||||
|
|
||||||
|
let text = `PDF Document: ${file.name}\n`;
|
||||||
|
text += `Pages: ${pages.length}\n\n`;
|
||||||
|
|
||||||
|
const form = pdfDoc.getForm();
|
||||||
|
try {
|
||||||
|
const fields = form.getFields();
|
||||||
|
if (fields.length > 0) {
|
||||||
|
text += `Form Fields:\n`;
|
||||||
|
fields.forEach((field) => {
|
||||||
|
text += `- ${field.getName()}\n`;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// No form fields
|
||||||
|
}
|
||||||
|
|
||||||
|
text += `\nNote: Full text extraction from PDF requires OCR. This extracts metadata and structure.\n`;
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function convertDocument(
|
||||||
|
file: File,
|
||||||
|
targetFormat: string,
|
||||||
|
onProgress?: (progress: number) => void
|
||||||
|
): Promise<ConversionResult> {
|
||||||
|
onProgress?.(10);
|
||||||
|
|
||||||
|
const sourceExt = getExtension(file.name);
|
||||||
|
let resultBlob: Blob;
|
||||||
|
|
||||||
|
onProgress?.(30);
|
||||||
|
|
||||||
|
switch (sourceExt) {
|
||||||
|
case 'docx': {
|
||||||
|
if (targetFormat === 'html') {
|
||||||
|
const html = await docxToHtml(file);
|
||||||
|
resultBlob = new Blob([html], { type: 'text/html' });
|
||||||
|
} else if (targetFormat === 'txt') {
|
||||||
|
const text = await docxToText(file);
|
||||||
|
resultBlob = new Blob([text], { type: 'text/plain' });
|
||||||
|
} else if (targetFormat === 'pdf') {
|
||||||
|
const html = await docxToHtml(file);
|
||||||
|
resultBlob = await htmlToPdf(html);
|
||||||
|
} else {
|
||||||
|
throw new Error(`Unsupported: docx to ${targetFormat}`);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'md': {
|
||||||
|
const mdText = await readFileAsText(file);
|
||||||
|
if (targetFormat === 'html') {
|
||||||
|
const html = await markdownToHtml(mdText);
|
||||||
|
resultBlob = new Blob([html], { type: 'text/html' });
|
||||||
|
} else if (targetFormat === 'pdf') {
|
||||||
|
resultBlob = await textToPdf(mdText);
|
||||||
|
} else if (targetFormat === 'txt') {
|
||||||
|
resultBlob = new Blob([mdText], { type: 'text/plain' });
|
||||||
|
} else {
|
||||||
|
throw new Error(`Unsupported: md to ${targetFormat}`);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'html':
|
||||||
|
case 'htm': {
|
||||||
|
const html = await readFileAsText(file);
|
||||||
|
if (targetFormat === 'pdf') {
|
||||||
|
resultBlob = await htmlToPdf(html);
|
||||||
|
} else if (targetFormat === 'txt') {
|
||||||
|
const text = htmlToText(html);
|
||||||
|
resultBlob = new Blob([text], { type: 'text/plain' });
|
||||||
|
} else if (targetFormat === 'md') {
|
||||||
|
const md = htmlToMarkdown(html);
|
||||||
|
resultBlob = new Blob([md], { type: 'text/markdown' });
|
||||||
|
} else {
|
||||||
|
throw new Error(`Unsupported: html to ${targetFormat}`);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'txt': {
|
||||||
|
const text = await readFileAsText(file);
|
||||||
|
if (targetFormat === 'pdf') {
|
||||||
|
resultBlob = await textToPdf(text);
|
||||||
|
} else if (targetFormat === 'html') {
|
||||||
|
const html = `<!DOCTYPE html><html><head><meta charset="utf-8"></head><body><pre>${text.replace(/</g, '<').replace(/>/g, '>')}</pre></body></html>`;
|
||||||
|
resultBlob = new Blob([html], { type: 'text/html' });
|
||||||
|
} else if (targetFormat === 'md') {
|
||||||
|
resultBlob = new Blob([text], { type: 'text/markdown' });
|
||||||
|
} else {
|
||||||
|
throw new Error(`Unsupported: txt to ${targetFormat}`);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'pdf': {
|
||||||
|
if (targetFormat === 'txt') {
|
||||||
|
const text = await pdfToText(file);
|
||||||
|
resultBlob = new Blob([text], { type: 'text/plain' });
|
||||||
|
} else {
|
||||||
|
throw new Error(`Unsupported: pdf to ${targetFormat}`);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
|
throw new Error(`Unsupported source format: ${sourceExt}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
onProgress?.(100);
|
||||||
|
|
||||||
|
return {
|
||||||
|
blob: resultBlob,
|
||||||
|
filename: buildOutputFilename(file.name, targetFormat),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import { ConversionResult } from '@/types';
|
||||||
|
import { buildOutputFilename, getMimeType } from '@/lib/utils';
|
||||||
|
|
||||||
|
export async function convertImage(
|
||||||
|
file: File,
|
||||||
|
targetFormat: string,
|
||||||
|
onProgress?: (progress: number) => void
|
||||||
|
): Promise<ConversionResult> {
|
||||||
|
onProgress?.(10);
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const img = new Image();
|
||||||
|
img.crossOrigin = 'anonymous';
|
||||||
|
|
||||||
|
img.onload = () => {
|
||||||
|
onProgress?.(50);
|
||||||
|
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
canvas.width = img.naturalWidth;
|
||||||
|
canvas.height = img.naturalHeight;
|
||||||
|
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
if (!ctx) {
|
||||||
|
reject(new Error('Could not get canvas context'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// White background for formats that don't support transparency
|
||||||
|
if (['jpg', 'jpeg', 'bmp'].includes(targetFormat)) {
|
||||||
|
ctx.fillStyle = '#FFFFFF';
|
||||||
|
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.drawImage(img, 0, 0);
|
||||||
|
onProgress?.(80);
|
||||||
|
|
||||||
|
const mimeType = getMimeType(targetFormat);
|
||||||
|
const quality = ['jpg', 'jpeg', 'webp', 'avif'].includes(targetFormat) ? 0.92 : undefined;
|
||||||
|
|
||||||
|
canvas.toBlob(
|
||||||
|
(blob) => {
|
||||||
|
if (!blob) {
|
||||||
|
reject(new Error(`Failed to convert to ${targetFormat}. Your browser may not support this format.`));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onProgress?.(100);
|
||||||
|
resolve({
|
||||||
|
blob,
|
||||||
|
filename: buildOutputFilename(file.name, targetFormat),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
mimeType,
|
||||||
|
quality
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
img.onerror = () => {
|
||||||
|
reject(new Error('Failed to load image'));
|
||||||
|
};
|
||||||
|
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = (e) => {
|
||||||
|
img.src = e.target?.result as string;
|
||||||
|
};
|
||||||
|
reader.onerror = () => reject(new Error('Failed to read file'));
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
import { ConversionResult } from '@/types';
|
||||||
|
import { buildOutputFilename, getMimeType } from '@/lib/utils';
|
||||||
|
import { getExtension } from '@/lib/fileDetector';
|
||||||
|
|
||||||
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||||
|
let ffmpegInstance: any = null;
|
||||||
|
let ffmpegLoadPromise: Promise<any> | null = null;
|
||||||
|
|
||||||
|
async function getFFmpeg(onLoadProgress?: (msg: string) => void) {
|
||||||
|
if (ffmpegInstance) return ffmpegInstance;
|
||||||
|
|
||||||
|
if (ffmpegLoadPromise) {
|
||||||
|
return ffmpegLoadPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
ffmpegLoadPromise = (async () => {
|
||||||
|
onLoadProgress?.('Loading conversion engine...');
|
||||||
|
const { FFmpeg } = await import('@ffmpeg/ffmpeg');
|
||||||
|
const { toBlobURL } = await import('@ffmpeg/util');
|
||||||
|
|
||||||
|
const ffmpeg = new FFmpeg();
|
||||||
|
|
||||||
|
const baseURL = 'https://unpkg.com/@ffmpeg/core@0.12.6/dist/esm';
|
||||||
|
|
||||||
|
await ffmpeg.load({
|
||||||
|
coreURL: await toBlobURL(`${baseURL}/ffmpeg-core.js`, 'text/javascript'),
|
||||||
|
wasmURL: await toBlobURL(`${baseURL}/ffmpeg-core.wasm`, 'application/wasm'),
|
||||||
|
});
|
||||||
|
|
||||||
|
ffmpegInstance = ffmpeg;
|
||||||
|
return ffmpeg;
|
||||||
|
})();
|
||||||
|
|
||||||
|
return ffmpegLoadPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getOutputMimeType(targetFormat: string): string {
|
||||||
|
return getMimeType(targetFormat);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getFFmpegArgs(sourceExt: string, targetFormat: string): string[] {
|
||||||
|
const audioFormats = ['mp3', 'wav', 'flac', 'ogg', 'aac', 'm4a', 'wma', 'opus'];
|
||||||
|
const videoFormats = ['mp4', 'webm', 'avi', 'mov', 'mkv', 'flv', 'wmv', 'm4v'];
|
||||||
|
|
||||||
|
// Audio → Audio
|
||||||
|
if (audioFormats.includes(sourceExt) && audioFormats.includes(targetFormat)) {
|
||||||
|
const args = ['-i', `input.${sourceExt}`];
|
||||||
|
switch (targetFormat) {
|
||||||
|
case 'mp3':
|
||||||
|
args.push('-codec:a', 'libmp3lame', '-b:a', '192k');
|
||||||
|
break;
|
||||||
|
case 'aac':
|
||||||
|
case 'm4a':
|
||||||
|
args.push('-codec:a', 'aac', '-b:a', '192k');
|
||||||
|
break;
|
||||||
|
case 'ogg':
|
||||||
|
args.push('-codec:a', 'libvorbis', '-b:a', '192k');
|
||||||
|
break;
|
||||||
|
case 'flac':
|
||||||
|
args.push('-codec:a', 'flac');
|
||||||
|
break;
|
||||||
|
case 'wav':
|
||||||
|
args.push('-codec:a', 'pcm_s16le');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
args.push(`output.${targetFormat}`);
|
||||||
|
return args;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Video → Video
|
||||||
|
if (videoFormats.includes(sourceExt) && videoFormats.includes(targetFormat)) {
|
||||||
|
return [
|
||||||
|
'-i', `input.${sourceExt}`,
|
||||||
|
'-c:v', 'libx264', '-preset', 'fast',
|
||||||
|
'-c:a', 'aac',
|
||||||
|
`output.${targetFormat}`,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Video → Audio (extract)
|
||||||
|
if (videoFormats.includes(sourceExt) && audioFormats.includes(targetFormat)) {
|
||||||
|
const args = ['-i', `input.${sourceExt}`, '-vn'];
|
||||||
|
if (targetFormat === 'mp3') args.push('-codec:a', 'libmp3lame', '-b:a', '192k');
|
||||||
|
else if (targetFormat === 'aac' || targetFormat === 'm4a') args.push('-codec:a', 'aac', '-b:a', '192k');
|
||||||
|
else if (targetFormat === 'ogg') args.push('-codec:a', 'libvorbis');
|
||||||
|
else if (targetFormat === 'wav') args.push('-codec:a', 'pcm_s16le');
|
||||||
|
else if (targetFormat === 'flac') args.push('-codec:a', 'flac');
|
||||||
|
args.push(`output.${targetFormat}`);
|
||||||
|
return args;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Video → GIF
|
||||||
|
if (videoFormats.includes(sourceExt) && targetFormat === 'gif') {
|
||||||
|
return [
|
||||||
|
'-i', `input.${sourceExt}`,
|
||||||
|
'-vf', 'fps=10,scale=480:-1:flags=lanczos',
|
||||||
|
'-t', '10',
|
||||||
|
`output.gif`,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback
|
||||||
|
return ['-i', `input.${sourceExt}`, `output.${targetFormat}`];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function convertMedia(
|
||||||
|
file: File,
|
||||||
|
targetFormat: string,
|
||||||
|
onProgress?: (progress: number) => void
|
||||||
|
): Promise<ConversionResult> {
|
||||||
|
onProgress?.(5);
|
||||||
|
|
||||||
|
const ffmpeg = await getFFmpeg();
|
||||||
|
onProgress?.(20);
|
||||||
|
|
||||||
|
const sourceExt = getExtension(file.name);
|
||||||
|
const inputName = `input.${sourceExt}`;
|
||||||
|
const outputName = `output.${targetFormat}`;
|
||||||
|
|
||||||
|
// Write input file to ffmpeg virtual FS
|
||||||
|
const { fetchFile } = await import('@ffmpeg/util');
|
||||||
|
await ffmpeg.writeFile(inputName, await fetchFile(file));
|
||||||
|
onProgress?.(30);
|
||||||
|
|
||||||
|
// Progress tracking
|
||||||
|
ffmpeg.on('progress', ({ progress }: { progress: number }) => {
|
||||||
|
const clampedProgress = Math.min(Math.max(progress, 0), 1);
|
||||||
|
onProgress?.(30 + Math.round(clampedProgress * 60));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Execute conversion
|
||||||
|
const args = getFFmpegArgs(sourceExt, targetFormat);
|
||||||
|
await ffmpeg.exec(args);
|
||||||
|
onProgress?.(92);
|
||||||
|
|
||||||
|
// Read output
|
||||||
|
const data = await ffmpeg.readFile(outputName);
|
||||||
|
|
||||||
|
// Clean up
|
||||||
|
try {
|
||||||
|
await ffmpeg.deleteFile(inputName);
|
||||||
|
await ffmpeg.deleteFile(outputName);
|
||||||
|
} catch {
|
||||||
|
// Ignore cleanup errors
|
||||||
|
}
|
||||||
|
|
||||||
|
onProgress?.(100);
|
||||||
|
|
||||||
|
const blob = new Blob([data], { type: getOutputMimeType(targetFormat) });
|
||||||
|
|
||||||
|
return {
|
||||||
|
blob,
|
||||||
|
filename: buildOutputFilename(file.name, targetFormat),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isFFmpegLoaded(): boolean {
|
||||||
|
return ffmpegInstance !== null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { FileCategory } from '@/types';
|
||||||
|
|
||||||
|
const EXTENSION_MAP: Record<string, FileCategory> = {
|
||||||
|
// Images
|
||||||
|
png: 'image', jpg: 'image', jpeg: 'image', webp: 'image', gif: 'image',
|
||||||
|
bmp: 'image', tiff: 'image', tif: 'image', avif: 'image', svg: 'image',
|
||||||
|
ico: 'image',
|
||||||
|
// Documents
|
||||||
|
pdf: 'document', docx: 'document', doc: 'document', txt: 'document',
|
||||||
|
md: 'document', html: 'document', htm: 'document', rtf: 'document',
|
||||||
|
// Audio
|
||||||
|
mp3: 'audio', wav: 'audio', flac: 'audio', ogg: 'audio', aac: 'audio',
|
||||||
|
m4a: 'audio', wma: 'audio', opus: 'audio',
|
||||||
|
// Video
|
||||||
|
mp4: 'video', webm: 'video', avi: 'video', mov: 'video', mkv: 'video',
|
||||||
|
flv: 'video', wmv: 'video', m4v: 'video',
|
||||||
|
// Data
|
||||||
|
csv: 'data', json: 'data', xml: 'data', yaml: 'data', yml: 'data',
|
||||||
|
tsv: 'data', toml: 'data',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function getExtension(filename: string): string {
|
||||||
|
return filename.split('.').pop()?.toLowerCase() || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function detectCategory(file: File): FileCategory {
|
||||||
|
const ext = getExtension(file.name);
|
||||||
|
return EXTENSION_MAP[ext] || 'unknown';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function generateId(): string {
|
||||||
|
return `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isSupported(filename: string): boolean {
|
||||||
|
const ext = getExtension(filename);
|
||||||
|
return ext in EXTENSION_MAP;
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
export function formatFileSize(bytes: number): string {
|
||||||
|
if (bytes === 0) return '0 B';
|
||||||
|
const k = 1024;
|
||||||
|
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||||
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||||
|
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getFileNameWithoutExtension(filename: string): string {
|
||||||
|
const lastDot = filename.lastIndexOf('.');
|
||||||
|
return lastDot > 0 ? filename.substring(0, lastDot) : filename;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildOutputFilename(originalName: string, targetFormat: string): string {
|
||||||
|
return `${getFileNameWithoutExtension(originalName)}.${targetFormat}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getMimeType(format: string): string {
|
||||||
|
const mimeMap: Record<string, string> = {
|
||||||
|
png: 'image/png',
|
||||||
|
jpg: 'image/jpeg',
|
||||||
|
jpeg: 'image/jpeg',
|
||||||
|
webp: 'image/webp',
|
||||||
|
gif: 'image/gif',
|
||||||
|
bmp: 'image/bmp',
|
||||||
|
avif: 'image/avif',
|
||||||
|
svg: 'image/svg+xml',
|
||||||
|
mp3: 'audio/mpeg',
|
||||||
|
wav: 'audio/wav',
|
||||||
|
flac: 'audio/flac',
|
||||||
|
ogg: 'audio/ogg',
|
||||||
|
aac: 'audio/aac',
|
||||||
|
m4a: 'audio/mp4',
|
||||||
|
mp4: 'video/mp4',
|
||||||
|
webm: 'video/webm',
|
||||||
|
avi: 'video/x-msvideo',
|
||||||
|
mov: 'video/quicktime',
|
||||||
|
mkv: 'video/x-matroska',
|
||||||
|
pdf: 'application/pdf',
|
||||||
|
html: 'text/html',
|
||||||
|
htm: 'text/html',
|
||||||
|
txt: 'text/plain',
|
||||||
|
md: 'text/markdown',
|
||||||
|
json: 'application/json',
|
||||||
|
csv: 'text/csv',
|
||||||
|
xml: 'application/xml',
|
||||||
|
yaml: 'application/x-yaml',
|
||||||
|
yml: 'application/x-yaml',
|
||||||
|
tsv: 'text/tab-separated-values',
|
||||||
|
};
|
||||||
|
return mimeMap[format] || 'application/octet-stream';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function truncateFilename(name: string, maxLength: number = 28): string {
|
||||||
|
if (name.length <= maxLength) return name;
|
||||||
|
const ext = name.split('.').pop() || '';
|
||||||
|
const baseName = name.substring(0, name.length - ext.length - 1);
|
||||||
|
const truncatedBase = baseName.substring(0, maxLength - ext.length - 4);
|
||||||
|
return `${truncatedBase}...${ext}`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
export type FileCategory = 'image' | 'document' | 'audio' | 'video' | 'data' | 'unknown';
|
||||||
|
|
||||||
|
export type ConversionStatus = 'idle' | 'converting' | 'done' | 'error';
|
||||||
|
|
||||||
|
export interface UploadedFile {
|
||||||
|
id: string;
|
||||||
|
file: File;
|
||||||
|
name: string;
|
||||||
|
size: number;
|
||||||
|
type: string;
|
||||||
|
category: FileCategory;
|
||||||
|
extension: string;
|
||||||
|
preview?: string;
|
||||||
|
targetFormat: string | null;
|
||||||
|
availableFormats: string[];
|
||||||
|
status: ConversionStatus;
|
||||||
|
progress: number;
|
||||||
|
convertedBlob?: Blob;
|
||||||
|
convertedName?: string;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ConversionResult {
|
||||||
|
blob: Blob;
|
||||||
|
filename: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CATEGORY_COLORS: Record<FileCategory, string> = {
|
||||||
|
image: '#10b981',
|
||||||
|
document: '#0ea5e9',
|
||||||
|
audio: '#8b5cf6',
|
||||||
|
video: '#f43f5e',
|
||||||
|
data: '#f59e0b',
|
||||||
|
unknown: '#6b7280',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const CATEGORY_ICONS: Record<FileCategory, string> = {
|
||||||
|
image: '\u{1F5BC}',
|
||||||
|
document: '\u{1F4C4}',
|
||||||
|
audio: '\u{1F3B5}',
|
||||||
|
video: '\u{1F3AC}',
|
||||||
|
data: '\u{1F4CA}',
|
||||||
|
unknown: '\u{1F4C1}',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const CATEGORY_LABELS: Record<FileCategory, string> = {
|
||||||
|
image: 'Image',
|
||||||
|
document: 'Document',
|
||||||
|
audio: 'Audio',
|
||||||
|
video: 'Video',
|
||||||
|
data: 'Data',
|
||||||
|
unknown: 'File',
|
||||||
|
};
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2017",
|
||||||
|
"lib": ["dom", "dom.iterable", "esnext"],
|
||||||
|
"allowJs": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"strict": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"module": "esnext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"incremental": true,
|
||||||
|
"plugins": [
|
||||||
|
{
|
||||||
|
"name": "next"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./src/*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"next-env.d.ts",
|
||||||
|
"**/*.ts",
|
||||||
|
"**/*.tsx",
|
||||||
|
".next/types/**/*.ts",
|
||||||
|
".next/dev/types/**/*.ts",
|
||||||
|
"**/*.mts"
|
||||||
|
],
|
||||||
|
"exclude": ["node_modules"]
|
||||||
|
}
|
||||||
+11
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"headers": [
|
||||||
|
{
|
||||||
|
"source": "/(.*)",
|
||||||
|
"headers": [
|
||||||
|
{ "key": "Cross-Origin-Embedder-Policy", "value": "require-corp" },
|
||||||
|
{ "key": "Cross-Origin-Opener-Policy", "value": "same-origin" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user