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