Fixed bug where conversion success toast was always showing "all conversions failed" even when conversions succeeded. The issue was that the completion message was checking the local jobs array (which still had status: 'pending') instead of tracking actual conversion results. Now using local successCount and failureCount variables that are incremented during the conversion loop to accurately reflect conversion outcomes. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
588 lines
18 KiB
TypeScript
588 lines
18 KiB
TypeScript
'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<File[]>([]);
|
|
const [inputFormat, setInputFormat] = React.useState<ConversionFormat | undefined>();
|
|
const [outputFormat, setOutputFormat] = React.useState<ConversionFormat | undefined>();
|
|
const [compatibleFormats, setCompatibleFormats] = React.useState<ConversionFormat[]>([]);
|
|
const [conversionJobs, setConversionJobs] = React.useState<ConversionJob[]>([]);
|
|
const [conversionOptions, setConversionOptions] = React.useState<ConversionOptions>({});
|
|
const [showShortcutsModal, setShowShortcutsModal] = React.useState(false);
|
|
|
|
const fileInputRef = React.useRef<HTMLInputElement>(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 (
|
|
<div className="w-full max-w-4xl mx-auto space-y-6">
|
|
{/* Header */}
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>File Converter</CardTitle>
|
|
<CardDescription>
|
|
Convert videos, audio, and images directly in your browser using WebAssembly
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<CardContent className="space-y-6">
|
|
{/* File upload */}
|
|
<FileUpload
|
|
onFileSelect={handleFileSelect}
|
|
onFileRemove={handleFileRemove}
|
|
selectedFiles={selectedFiles}
|
|
disabled={isConverting}
|
|
inputRef={fileInputRef}
|
|
/>
|
|
|
|
{/* File Info - show first file */}
|
|
{selectedFiles.length > 0 && inputFormat && (
|
|
<FileInfo file={selectedFiles[0]} format={inputFormat} />
|
|
)}
|
|
|
|
{/* Format Presets */}
|
|
{inputFormat && (
|
|
<FormatPresets
|
|
inputFormat={inputFormat}
|
|
onPresetSelect={handlePresetSelect}
|
|
disabled={isConverting}
|
|
/>
|
|
)}
|
|
|
|
{/* 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 - horizontal on desktop, vertical on mobile */}
|
|
<div className="flex md:hidden items-center justify-center py-2">
|
|
<ArrowDown className="h-5 w-5 text-muted-foreground" />
|
|
</div>
|
|
<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={isConverting}
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{/* Conversion Options */}
|
|
{inputFormat && outputFormat && (
|
|
<ConversionOptionsPanel
|
|
inputFormat={inputFormat}
|
|
outputFormat={outputFormat}
|
|
options={conversionOptions}
|
|
onOptionsChange={setConversionOptions}
|
|
disabled={isConverting}
|
|
/>
|
|
)}
|
|
|
|
{/* Convert button */}
|
|
{inputFormat && outputFormat && (
|
|
<div className="flex gap-3">
|
|
<Button
|
|
onClick={handleConvert}
|
|
disabled={isConvertDisabled}
|
|
className="flex-1"
|
|
size="lg"
|
|
>
|
|
{isConverting
|
|
? 'Converting...'
|
|
: `Convert ${selectedFiles.length} File${selectedFiles.length > 1 ? 's' : ''}`}
|
|
</Button>
|
|
<Button onClick={handleReset} variant="outline" size="lg">
|
|
Reset
|
|
</Button>
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* Download All Button */}
|
|
{completedCount > 0 && (
|
|
<Card>
|
|
<CardContent className="pt-6">
|
|
<Button
|
|
onClick={handleDownloadAll}
|
|
className="w-full"
|
|
size="lg"
|
|
variant="default"
|
|
>
|
|
Download All ({completedCount} file{completedCount > 1 ? 's' : ''})
|
|
{completedCount > 1 && ' as ZIP'}
|
|
</Button>
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
|
|
{/* Conversion previews */}
|
|
{conversionJobs.length > 0 && (
|
|
<div className="space-y-4">
|
|
{conversionJobs.map((job) => (
|
|
<ConversionPreview
|
|
key={job.id}
|
|
job={job}
|
|
onRetry={() => handleRetry(job.id)}
|
|
/>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{/* Keyboard Shortcuts Button */}
|
|
<Button
|
|
onClick={() => setShowShortcutsModal(true)}
|
|
className="fixed bottom-6 right-6 rounded-full w-12 h-12 p-0 shadow-lg"
|
|
title="Keyboard Shortcuts (Ctrl+/)"
|
|
>
|
|
<Keyboard className="h-5 w-5" />
|
|
</Button>
|
|
|
|
{/* Keyboard Shortcuts Modal */}
|
|
<KeyboardShortcutsModal
|
|
shortcuts={shortcuts}
|
|
isOpen={showShortcutsModal}
|
|
onClose={() => setShowShortcutsModal(false)}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|