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:
noah
2026-03-09 18:07:47 +01:00
commit 7659136045
31 changed files with 9871 additions and 0 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

+730
View File
@@ -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;
}
+49
View File
@@ -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>
);
}
+167
View File
@@ -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>
);
}
+150
View File
@@ -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>
);
}
+185
View File
@@ -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>
);
}
+54
View File
@@ -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>
);
}
+127
View File
@@ -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,
};
}
+147
View File
@@ -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,
};
}
+89
View File
@@ -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];
}
+114
View File
@@ -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),
};
}
+216
View File
@@ -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(/&nbsp;/g, ' ');
md = md.replace(/&amp;/g, '&');
md = md.replace(/&lt;/g, '<');
md = md.replace(/&gt;/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, '&lt;').replace(/>/g, '&gt;')}</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),
};
}
+68
View File
@@ -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);
});
}
+159
View 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;
}
+38
View File
@@ -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;
}
+60
View File
@@ -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}`;
}
+53
View File
@@ -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',
};