'use client'; import * as React from 'react'; import { ArrowRight, ArrowDown, Keyboard } 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 { ConversionOptionsPanel } from './ConversionOptions'; import { FileInfo } from './FileInfo'; import { FormatPresets } from './FormatPresets'; 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 { addToHistory } from '@/lib/storage/history'; import { downloadBlobsAsZip, generateOutputFilename } from '@/lib/utils/fileUtils'; import { getPresetById, type FormatPreset } from '@/lib/utils/formatPresets'; import { useKeyboardShortcuts, type KeyboardShortcut } from '@/lib/hooks/useKeyboardShortcuts'; import { KeyboardShortcutsModal } from '@/components/ui/KeyboardShortcutsModal'; import type { ConversionJob, ConversionFormat, ConversionOptions } from '@/types/conversion'; export function FileConverter() { const { addToast } = useToast(); const [selectedFiles, setSelectedFiles] = React.useState([]); const [inputFormat, setInputFormat] = React.useState(); const [outputFormat, setOutputFormat] = React.useState(); const [compatibleFormats, setCompatibleFormats] = React.useState([]); const [conversionJobs, setConversionJobs] = React.useState([]); const [conversionOptions, setConversionOptions] = React.useState({}); const [showShortcutsModal, setShowShortcutsModal] = React.useState(false); const fileInputRef = React.useRef(null); // Detect input format when files are selected React.useEffect(() => { if (selectedFiles.length === 0) { setInputFormat(undefined); setOutputFormat(undefined); setCompatibleFormats([]); setConversionJobs([]); return; } // Use first file to detect format (assume all files same format for batch) const firstFile = selectedFiles[0]; // Try to detect format from extension const ext = firstFile.name.split('.').pop()?.toLowerCase(); let format = ext ? getFormatByExtension(ext) : undefined; // Fallback to MIME type if (!format) { format = getFormatByMimeType(firstFile.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} (${selectedFiles.length} file${selectedFiles.length > 1 ? 's' : ''})`, 'success'); } else { addToast('Could not detect file format', 'error'); setInputFormat(undefined); setCompatibleFormats([]); } }, [selectedFiles]); const handleConvert = async () => { if (selectedFiles.length === 0 || !inputFormat || !outputFormat) { addToast('Please select files and output format', 'error'); return; } // Create conversion jobs for all files const jobs: ConversionJob[] = selectedFiles.map((file) => ({ id: Math.random().toString(36).substring(7), inputFile: file, inputFormat, outputFormat, options: conversionOptions, status: 'pending', progress: 0, startTime: Date.now(), })); setConversionJobs(jobs); // Track success/failure counts let successCount = 0; let failureCount = 0; // Convert files sequentially for (let i = 0; i < jobs.length; i++) { const job = jobs[i]; try { // Update job to loading setConversionJobs((prev) => prev.map((j, idx) => idx === i ? { ...j, status: 'loading' as const } : j) ); // Update job to processing setConversionJobs((prev) => prev.map((j, idx) => idx === i ? { ...j, status: 'processing' as const, progress: 10 } : j) ); // Call appropriate converter let result; switch (outputFormat.converter) { case 'ffmpeg': result = await convertWithFFmpeg(job.inputFile, outputFormat.extension, conversionOptions, (progress) => { setConversionJobs((prev) => prev.map((j, idx) => idx === i ? { ...j, progress } : j) ); }); break; case 'imagemagick': result = await convertWithImageMagick( job.inputFile, outputFormat.extension, conversionOptions, (progress) => { setConversionJobs((prev) => prev.map((j, idx) => idx === i ? { ...j, progress } : j) ); } ); break; default: throw new Error(`Unknown converter: ${outputFormat.converter}`); } // Update job with result if (result.success && result.blob) { successCount++; setConversionJobs((prev) => prev.map((j, idx) => idx === i ? { ...j, status: 'completed' as const, progress: 100, result: result.blob, endTime: Date.now(), } : j) ); // Add to history addToHistory({ inputFileName: job.inputFile.name, inputFormat: inputFormat.name, outputFormat: outputFormat.name, outputFileName: `output.${outputFormat.extension}`, fileSize: result.blob.size, result: result.blob, }); } else { failureCount++; setConversionJobs((prev) => prev.map((j, idx) => idx === i ? { ...j, status: 'error' as const, error: result.error || 'Unknown error', endTime: Date.now(), } : j) ); } } catch (error) { failureCount++; const errorMessage = error instanceof Error ? error.message : 'Unknown error'; setConversionJobs((prev) => prev.map((j, idx) => idx === i ? { ...j, status: 'error' as const, error: errorMessage, endTime: Date.now(), } : j) ); } } // Show completion message if (successCount === jobs.length) { addToast(`All ${jobs.length} files converted successfully!`, 'success'); } else if (successCount > 0) { addToast(`${successCount}/${jobs.length} files converted successfully`, 'info'); } else { addToast('All conversions failed', 'error'); } }; const handleReset = () => { setSelectedFiles([]); setInputFormat(undefined); setOutputFormat(undefined); setCompatibleFormats([]); setConversionJobs([]); setConversionOptions({}); }; const handleFileSelect = (files: File[]) => { setSelectedFiles((prev) => [...prev, ...files]); }; const handleFileRemove = (index: number) => { setSelectedFiles((prev) => prev.filter((_, i) => i !== index)); }; const handlePresetSelect = (preset: FormatPreset) => { // Find the output format that matches the preset const format = compatibleFormats.find(f => f.extension === preset.outputFormat); if (format) { setOutputFormat(format); setConversionOptions(preset.options); addToast(`Applied ${preset.name} preset`, 'success'); } }; const handleDownloadAll = async () => { if (!outputFormat) return; const completedJobs = conversionJobs.filter(job => job.status === 'completed' && job.result); if (completedJobs.length === 0) { addToast('No files to download', 'error'); return; } if (completedJobs.length === 1) { // Just download the single file const job = completedJobs[0]; const filename = generateOutputFilename(job.inputFile.name, outputFormat.extension); const url = URL.createObjectURL(job.result!); const a = document.createElement('a'); a.href = url; a.download = filename; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); return; } // Download multiple files as ZIP const files = completedJobs.map(job => ({ blob: job.result!, filename: generateOutputFilename(job.inputFile.name, outputFormat.extension), })); await downloadBlobsAsZip(files, `converted-files.zip`); addToast(`Downloaded ${files.length} files as ZIP`, 'success'); }; const handleRetry = async (jobId: string) => { const jobIndex = conversionJobs.findIndex(j => j.id === jobId); if (jobIndex === -1 || !outputFormat) return; const job = conversionJobs[jobIndex]; try { // Reset job to loading setConversionJobs((prev) => prev.map((j, idx) => idx === jobIndex ? { ...j, status: 'loading' as const, progress: 0, error: undefined, startTime: Date.now(), } : j) ); // Update to processing setConversionJobs((prev) => prev.map((j, idx) => idx === jobIndex ? { ...j, status: 'processing' as const, progress: 10 } : j) ); // Call appropriate converter let result; switch (outputFormat.converter) { case 'ffmpeg': result = await convertWithFFmpeg(job.inputFile, outputFormat.extension, conversionOptions, (progress) => { setConversionJobs((prev) => prev.map((j, idx) => idx === jobIndex ? { ...j, progress } : j) ); }); break; case 'imagemagick': result = await convertWithImageMagick( job.inputFile, outputFormat.extension, conversionOptions, (progress) => { setConversionJobs((prev) => prev.map((j, idx) => idx === jobIndex ? { ...j, progress } : j) ); } ); break; default: throw new Error(`Unknown converter: ${outputFormat.converter}`); } // Update job with result if (result.success && result.blob) { setConversionJobs((prev) => prev.map((j, idx) => idx === jobIndex ? { ...j, status: 'completed' as const, progress: 100, result: result.blob, endTime: Date.now(), } : j) ); addToast('Conversion completed successfully!', 'success'); // Add to history addToHistory({ inputFileName: job.inputFile.name, inputFormat: job.inputFormat.name, outputFormat: outputFormat.name, outputFileName: `output.${outputFormat.extension}`, fileSize: result.blob.size, result: result.blob, }); } else { setConversionJobs((prev) => prev.map((j, idx) => idx === jobIndex ? { ...j, status: 'error' as const, error: result.error || 'Unknown error', endTime: Date.now(), } : j) ); addToast(result.error || 'Retry failed', 'error'); } } catch (error) { const errorMessage = error instanceof Error ? error.message : 'Unknown error'; setConversionJobs((prev) => prev.map((j, idx) => idx === jobIndex ? { ...j, status: 'error' as const, error: errorMessage, endTime: Date.now(), } : j) ); addToast(`Retry failed: ${errorMessage}`, 'error'); } }; const isConverting = conversionJobs.some(job => job.status === 'loading' || job.status === 'processing'); const isConvertDisabled = selectedFiles.length === 0 || !outputFormat || isConverting; const completedCount = conversionJobs.filter(job => job.status === 'completed').length; // Define keyboard shortcuts const shortcuts: KeyboardShortcut[] = [ { key: 'o', ctrl: true, description: 'Open file dialog', action: () => { if (!isConverting) { fileInputRef.current?.click(); } }, }, { key: 'Enter', ctrl: true, description: 'Start conversion', action: () => { if (!isConvertDisabled) { handleConvert(); } }, }, { key: 's', ctrl: true, description: 'Download results', action: () => { if (completedCount > 0) { handleDownloadAll(); } }, }, { key: 'r', ctrl: true, description: 'Reset converter', action: () => { if (!isConverting) { handleReset(); } }, }, { key: '/', ctrl: true, description: 'Show keyboard shortcuts', action: () => setShowShortcutsModal(true), }, { key: 'Escape', description: 'Close shortcuts modal', action: () => setShowShortcutsModal(false), }, { key: '?', description: 'Show keyboard shortcuts', action: () => setShowShortcutsModal(true), }, ]; // Enable keyboard shortcuts useKeyboardShortcuts(shortcuts, !showShortcutsModal); return (
{/* Header */} File Converter Convert videos, audio, and images directly in your browser using WebAssembly {/* File upload */} {/* File Info - show first file */} {selectedFiles.length > 0 && inputFormat && ( )} {/* Format Presets */} {inputFormat && ( )} {/* Format selection */} {inputFormat && compatibleFormats.length > 0 && (
{/* Input format */}

{inputFormat.name}

{inputFormat.description}

{/* Arrow - horizontal on desktop, vertical on mobile */}
{/* Output format */}
)} {/* Conversion Options */} {inputFormat && outputFormat && ( )} {/* Convert button */} {inputFormat && outputFormat && (
)}
{/* Download All Button */} {completedCount > 0 && ( )} {/* Conversion previews */} {conversionJobs.length > 0 && (
{conversionJobs.map((job) => ( handleRetry(job.id)} /> ))}
)} {/* Keyboard Shortcuts Button */} {/* Keyboard Shortcuts Modal */} setShowShortcutsModal(false)} />
); }