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:
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',
|
||||
};
|
||||
Reference in New Issue
Block a user