Files
convert-ui/components/converter/FileConverter.tsx
Sebastian Krüger 1771ca42eb 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>
2025-11-17 10:44:49 +01:00

253 lines
8.1 KiB
TypeScript

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