feat: initialize Convert UI - browser-based file conversion app

- Add Next.js 16 with Turbopack and React 19
- Add Tailwind CSS 4 with OKLCH color system
- Implement FFmpeg.wasm for video/audio conversion
- Implement ImageMagick WASM for image conversion
- Add file upload with drag-and-drop
- Add format selector with fuzzy search
- Add conversion preview and download
- Add conversion history with localStorage
- Add dark/light theme support
- Support 22+ file formats across video, audio, and images

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-11-17 10:44:49 +01:00
commit 1771ca42eb
32 changed files with 7098 additions and 0 deletions

View File

@@ -0,0 +1,170 @@
'use client';
import * as React from 'react';
import { Download, CheckCircle, XCircle, Loader2 } from 'lucide-react';
import { cn } from '@/lib/utils/cn';
import { Button } from '@/components/ui/Button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/Card';
import { Progress } from '@/components/ui/Progress';
import { downloadBlob, formatFileSize, generateOutputFilename } from '@/lib/utils/fileUtils';
import type { ConversionJob } from '@/types/conversion';
export interface ConversionPreviewProps {
job: ConversionJob;
onDownload?: () => void;
}
export function ConversionPreview({ job, onDownload }: ConversionPreviewProps) {
const [previewUrl, setPreviewUrl] = React.useState<string | null>(null);
// Create preview URL for result
React.useEffect(() => {
if (job.result && job.status === 'completed') {
const url = URL.createObjectURL(job.result);
setPreviewUrl(url);
return () => {
URL.revokeObjectURL(url);
};
}
}, [job.result, job.status]);
const handleDownload = () => {
if (job.result) {
const filename = generateOutputFilename(job.inputFile.name, job.outputFormat.extension);
downloadBlob(job.result, filename);
onDownload?.();
}
};
const renderPreview = () => {
if (!previewUrl || !job.result) return null;
const category = job.outputFormat.category;
switch (category) {
case 'image':
return (
<div className="mt-4 rounded-lg overflow-hidden bg-muted/30 flex items-center justify-center p-4">
<img
src={previewUrl}
alt="Converted image preview"
className="max-w-full max-h-64 object-contain"
/>
</div>
);
case 'video':
return (
<div className="mt-4 rounded-lg overflow-hidden bg-muted/30">
<video src={previewUrl} controls className="w-full max-h-64">
Your browser does not support video playback.
</video>
</div>
);
case 'audio':
return (
<div className="mt-4 rounded-lg overflow-hidden bg-muted/30 p-4">
<audio src={previewUrl} controls className="w-full">
Your browser does not support audio playback.
</audio>
</div>
);
default:
return null;
}
};
const renderStatus = () => {
switch (job.status) {
case 'loading':
return (
<div className="flex items-center gap-2 text-info">
<Loader2 className="h-4 w-4 animate-spin" />
<span className="text-sm font-medium">Loading converter...</span>
</div>
);
case 'processing':
return (
<div>
<div className="flex items-center gap-2 text-info mb-2">
<Loader2 className="h-4 w-4 animate-spin" />
<span className="text-sm font-medium">Converting...</span>
</div>
<Progress value={job.progress} showLabel />
</div>
);
case 'completed':
return (
<div className="flex items-center gap-2 text-success">
<CheckCircle className="h-4 w-4" />
<span className="text-sm font-medium">Conversion complete!</span>
{job.result && (
<span className="text-xs text-muted-foreground ml-auto">
{formatFileSize(job.result.size)}
</span>
)}
</div>
);
case 'error':
return (
<div className="flex items-center gap-2 text-destructive">
<XCircle className="h-4 w-4" />
<span className="text-sm font-medium">Conversion failed</span>
</div>
);
default:
return null;
}
};
if (job.status === 'pending') {
return null;
}
return (
<Card className="animate-fadeIn">
<CardHeader>
<CardTitle className="text-lg">Conversion Status</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-4">
{/* Status */}
{renderStatus()}
{/* Error message */}
{job.error && (
<div className="bg-destructive/10 border border-destructive/20 rounded-md p-3">
<p className="text-sm text-destructive">{job.error}</p>
</div>
)}
{/* Preview */}
{job.status === 'completed' && renderPreview()}
{/* Download button */}
{job.status === 'completed' && job.result && (
<Button onClick={handleDownload} className="w-full gap-2">
<Download className="h-4 w-4" />
Download{' '}
{generateOutputFilename(job.inputFile.name, job.outputFormat.extension)}
</Button>
)}
{/* Duration */}
{job.status === 'completed' && job.startTime && job.endTime && (
<p className="text-xs text-muted-foreground text-center">
Completed in {((job.endTime - job.startTime) / 1000).toFixed(2)}s
</p>
)}
</div>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,252 @@
'use client';
import * as React from 'react';
import { ArrowRight } from 'lucide-react';
import { Button } from '@/components/ui/Button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/Card';
import { FileUpload } from './FileUpload';
import { FormatSelector } from './FormatSelector';
import { ConversionPreview } from './ConversionPreview';
import { useToast } from '@/components/ui/Toast';
import {
SUPPORTED_FORMATS,
getFormatByExtension,
getFormatByMimeType,
getCompatibleFormats,
} from '@/lib/utils/formatMappings';
import { convertWithFFmpeg } from '@/lib/converters/ffmpegService';
import { convertWithImageMagick } from '@/lib/converters/imagemagickService';
import { convertWithPandoc } from '@/lib/converters/pandocService';
import { addToHistory } from '@/lib/storage/history';
import type { ConversionJob, ConversionFormat } from '@/types/conversion';
export function FileConverter() {
const { addToast } = useToast();
const [selectedFile, setSelectedFile] = React.useState<File | undefined>();
const [inputFormat, setInputFormat] = React.useState<ConversionFormat | undefined>();
const [outputFormat, setOutputFormat] = React.useState<ConversionFormat | undefined>();
const [compatibleFormats, setCompatibleFormats] = React.useState<ConversionFormat[]>([]);
const [conversionJob, setConversionJob] = React.useState<ConversionJob | undefined>();
// Detect input format when file is selected
React.useEffect(() => {
if (!selectedFile) {
setInputFormat(undefined);
setOutputFormat(undefined);
setCompatibleFormats([]);
setConversionJob(undefined);
return;
}
// Try to detect format from extension
const ext = selectedFile.name.split('.').pop()?.toLowerCase();
let format = ext ? getFormatByExtension(ext) : undefined;
// Fallback to MIME type
if (!format) {
format = getFormatByMimeType(selectedFile.type);
}
if (format) {
setInputFormat(format);
const compatible = getCompatibleFormats(format);
setCompatibleFormats(compatible);
// Auto-select first compatible format
if (compatible.length > 0 && !outputFormat) {
setOutputFormat(compatible[0]);
}
addToast(`Detected format: ${format.name}`, 'success');
} else {
addToast('Could not detect file format', 'error');
setInputFormat(undefined);
setCompatibleFormats([]);
}
}, [selectedFile]);
const handleConvert = async () => {
if (!selectedFile || !inputFormat || !outputFormat) {
addToast('Please select a file and output format', 'error');
return;
}
// Create conversion job
const job: ConversionJob = {
id: Math.random().toString(36).substring(7),
inputFile: selectedFile,
inputFormat,
outputFormat,
options: {},
status: 'loading',
progress: 0,
startTime: Date.now(),
};
setConversionJob(job);
try {
// Update status to processing
setConversionJob((prev) => prev && { ...prev, status: 'processing', progress: 10 });
// Call appropriate converter
let result;
switch (outputFormat.converter) {
case 'ffmpeg':
result = await convertWithFFmpeg(selectedFile, outputFormat.extension, {}, (progress) => {
setConversionJob((prev) => prev && { ...prev, progress });
});
break;
case 'imagemagick':
result = await convertWithImageMagick(
selectedFile,
outputFormat.extension,
{},
(progress) => {
setConversionJob((prev) => prev && { ...prev, progress });
}
);
break;
case 'pandoc':
result = await convertWithPandoc(selectedFile, outputFormat.extension, {}, (progress) => {
setConversionJob((prev) => prev && { ...prev, progress });
});
break;
default:
throw new Error(`Unknown converter: ${outputFormat.converter}`);
}
// Update job with result
if (result.success && result.blob) {
setConversionJob((prev) => prev && {
...prev,
status: 'completed',
progress: 100,
result: result.blob,
endTime: Date.now(),
});
addToast('Conversion completed successfully!', 'success');
// Add to history
addToHistory({
inputFileName: selectedFile.name,
inputFormat: inputFormat.name,
outputFormat: outputFormat.name,
outputFileName: `output.${outputFormat.extension}`,
fileSize: result.blob.size,
result: result.blob,
});
} else {
setConversionJob((prev) => prev && {
...prev,
status: 'error',
error: result.error || 'Unknown error',
endTime: Date.now(),
});
addToast(result.error || 'Conversion failed', 'error');
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
setConversionJob((prev) => prev && {
...prev,
status: 'error',
error: errorMessage,
endTime: Date.now(),
});
addToast(`Conversion failed: ${errorMessage}`, 'error');
}
};
const handleReset = () => {
setSelectedFile(undefined);
setInputFormat(undefined);
setOutputFormat(undefined);
setCompatibleFormats([]);
setConversionJob(undefined);
};
const isConvertDisabled =
!selectedFile || !outputFormat || conversionJob?.status === 'loading' || conversionJob?.status === 'processing';
return (
<div className="w-full max-w-4xl mx-auto space-y-6">
{/* Header */}
<Card>
<CardHeader>
<CardTitle>File Converter</CardTitle>
<CardDescription>
Convert videos, images, and documents directly in your browser using WebAssembly
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
{/* File upload */}
<FileUpload
onFileSelect={setSelectedFile}
onFileRemove={handleReset}
selectedFile={selectedFile}
disabled={conversionJob?.status === 'processing' || conversionJob?.status === 'loading'}
/>
{/* Format selection */}
{inputFormat && compatibleFormats.length > 0 && (
<div className="grid grid-cols-1 md:grid-cols-[1fr_auto_1fr] gap-4 items-start">
{/* Input format */}
<div>
<label className="text-sm font-medium text-foreground mb-2 block">Input Format</label>
<Card className="p-4">
<p className="font-medium">{inputFormat.name}</p>
<p className="text-xs text-muted-foreground mt-1">{inputFormat.description}</p>
</Card>
</div>
{/* Arrow */}
<div className="hidden md:flex items-center justify-center pt-8">
<ArrowRight className="h-5 w-5 text-muted-foreground" />
</div>
{/* Output format */}
<FormatSelector
formats={compatibleFormats}
selectedFormat={outputFormat}
onFormatSelect={setOutputFormat}
label="Output Format"
disabled={conversionJob?.status === 'processing' || conversionJob?.status === 'loading'}
/>
</div>
)}
{/* Convert button */}
{inputFormat && outputFormat && (
<div className="flex gap-3">
<Button
onClick={handleConvert}
disabled={isConvertDisabled}
className="flex-1"
size="lg"
>
{conversionJob?.status === 'loading' || conversionJob?.status === 'processing'
? 'Converting...'
: 'Convert'}
</Button>
<Button onClick={handleReset} variant="outline" size="lg">
Reset
</Button>
</div>
)}
</CardContent>
</Card>
{/* Conversion preview */}
{conversionJob && <ConversionPreview job={conversionJob} />}
</div>
);
}

View File

@@ -0,0 +1,153 @@
'use client';
import * as React from 'react';
import { Upload, X } from 'lucide-react';
import { cn } from '@/lib/utils/cn';
import { formatFileSize } from '@/lib/utils/fileUtils';
import { Button } from '@/components/ui/Button';
export interface FileUploadProps {
onFileSelect: (file: File) => void;
onFileRemove: () => void;
selectedFile?: File;
accept?: string;
maxSizeMB?: number;
disabled?: boolean;
}
export function FileUpload({
onFileSelect,
onFileRemove,
selectedFile,
accept,
maxSizeMB = 500,
disabled = false,
}: FileUploadProps) {
const [isDragging, setIsDragging] = React.useState(false);
const fileInputRef = React.useRef<HTMLInputElement>(null);
const handleDragEnter = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
if (!disabled) {
setIsDragging(true);
}
};
const handleDragLeave = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(false);
};
const handleDragOver = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
};
const handleDrop = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(false);
if (disabled) return;
const files = Array.from(e.dataTransfer.files);
if (files.length > 0) {
handleFile(files[0]);
}
};
const handleFileInput = (e: React.ChangeEvent<HTMLInputElement>) => {
const files = Array.from(e.target.files || []);
if (files.length > 0) {
handleFile(files[0]);
}
};
const handleFile = (file: File) => {
// Check file size
const maxBytes = maxSizeMB * 1024 * 1024;
if (file.size > maxBytes) {
alert(`File size exceeds ${maxSizeMB}MB limit. Please choose a smaller file.`);
return;
}
onFileSelect(file);
};
const handleClick = () => {
if (!disabled) {
fileInputRef.current?.click();
}
};
const handleRemove = (e: React.MouseEvent) => {
e.stopPropagation();
onFileRemove();
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
};
return (
<div className="w-full">
<input
ref={fileInputRef}
type="file"
className="hidden"
accept={accept}
onChange={handleFileInput}
disabled={disabled}
/>
{selectedFile ? (
<div className="border-2 border-border rounded-lg p-6 bg-card">
<div className="flex items-center justify-between">
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-foreground truncate">{selectedFile.name}</p>
<p className="text-xs text-muted-foreground mt-1">
{formatFileSize(selectedFile.size)}
</p>
</div>
<Button
variant="ghost"
size="icon"
onClick={handleRemove}
disabled={disabled}
className="ml-4"
>
<X className="h-4 w-4" />
<span className="sr-only">Remove file</span>
</Button>
</div>
</div>
) : (
<div
onClick={handleClick}
onDragEnter={handleDragEnter}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
className={cn(
'border-2 border-dashed rounded-lg p-12 text-center cursor-pointer transition-colors',
'hover:border-primary hover:bg-primary/5',
{
'border-primary bg-primary/10': isDragging,
'border-border bg-background': !isDragging,
'opacity-50 cursor-not-allowed': disabled,
}
)}
>
<Upload className="mx-auto h-12 w-12 text-muted-foreground mb-4" />
<p className="text-sm font-medium text-foreground mb-1">
Drop your file here or click to browse
</p>
<p className="text-xs text-muted-foreground">
Maximum file size: {maxSizeMB}MB
</p>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,136 @@
'use client';
import * as React from 'react';
import Fuse from 'fuse.js';
import { Search } from 'lucide-react';
import { cn } from '@/lib/utils/cn';
import { Input } from '@/components/ui/Input';
import { Card } from '@/components/ui/Card';
import type { ConversionFormat } from '@/types/conversion';
export interface FormatSelectorProps {
formats: ConversionFormat[];
selectedFormat?: ConversionFormat;
onFormatSelect: (format: ConversionFormat) => void;
label?: string;
disabled?: boolean;
}
export function FormatSelector({
formats,
selectedFormat,
onFormatSelect,
label = 'Select format',
disabled = false,
}: FormatSelectorProps) {
const [searchQuery, setSearchQuery] = React.useState('');
const [filteredFormats, setFilteredFormats] = React.useState<ConversionFormat[]>(formats);
// Set up Fuse.js for fuzzy search
const fuse = React.useMemo(() => {
return new Fuse(formats, {
keys: ['name', 'extension', 'description'],
threshold: 0.3,
includeScore: true,
});
}, [formats]);
// Filter formats based on search query
React.useEffect(() => {
if (!searchQuery.trim()) {
setFilteredFormats(formats);
return;
}
const results = fuse.search(searchQuery);
setFilteredFormats(results.map((result) => result.item));
}, [searchQuery, formats, fuse]);
// Group formats by category
const groupedFormats = React.useMemo(() => {
const groups: Record<string, ConversionFormat[]> = {};
filteredFormats.forEach((format) => {
if (!groups[format.category]) {
groups[format.category] = [];
}
groups[format.category].push(format);
});
return groups;
}, [filteredFormats]);
return (
<div className="w-full">
<label className="text-sm font-medium text-foreground mb-2 block">{label}</label>
{/* Search input */}
<div className="relative mb-3">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
type="text"
placeholder="Search formats..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
disabled={disabled}
className="pl-10"
/>
</div>
{/* Format list */}
<Card className="max-h-64 overflow-y-auto custom-scrollbar">
{Object.entries(groupedFormats).length === 0 ? (
<div className="p-4 text-center text-sm text-muted-foreground">
No formats found matching "{searchQuery}"
</div>
) : (
<div className="p-2">
{Object.entries(groupedFormats).map(([category, categoryFormats]) => (
<div key={category} className="mb-3 last:mb-0">
<h3 className="text-xs font-semibold text-muted-foreground uppercase tracking-wide mb-2 px-2">
{category}
</h3>
<div className="space-y-1">
{categoryFormats.map((format) => (
<button
key={format.id}
onClick={() => !disabled && onFormatSelect(format)}
disabled={disabled}
className={cn(
'w-full text-left px-3 py-2 rounded-md transition-colors',
'hover:bg-accent hover:text-accent-foreground',
'disabled:opacity-50 disabled:cursor-not-allowed',
{
'bg-primary text-primary-foreground hover:bg-primary/90':
selectedFormat?.id === format.id,
}
)}
>
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium">{format.name}</p>
{format.description && (
<p className="text-xs opacity-75 mt-0.5">{format.description}</p>
)}
</div>
<span className="text-xs font-mono opacity-75">.{format.extension}</span>
</div>
</button>
))}
</div>
</div>
))}
</div>
)}
</Card>
{/* Selected format display */}
{selectedFormat && (
<div className="mt-2 text-xs text-muted-foreground">
Selected: <span className="font-medium text-foreground">{selectedFormat.name}</span> (.
{selectedFormat.extension})
</div>
)}
</div>
);
}