Files
convert-ui/components/converter/FileConverter.tsx
Sebastian Krüger a7a46a8ebc fix: correct success/failure toast message for batch conversions
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>
2025-11-17 14:07:53 +01:00

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