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
+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>
);
}