feat: add batch conversion support with multi-file upload and ZIP download

Major improvements:
- Updated FileUpload component to support multiple file selection
  - Added drag-and-drop for multiple files
  - Individual file removal
  - "Add More Files" button when files are selected
- Modified FileConverter to handle batch conversions
  - Sequential conversion of multiple files
  - Progress tracking for each file individually
  - All files share same output format and settings
- Added batch download functionality
  - Single file: direct download
  - Multiple files: ZIP archive download
  - Uses jszip library for ZIP creation
- Enhanced UI/UX
  - Show count of selected files in convert button
  - Display all conversion jobs with individual previews
  - "Download All" button for completed conversions
  - Conversion status messages show success/failure counts

Dependencies added:
- jszip@3.10.1 for ZIP file creation

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-11-17 13:13:52 +01:00
parent a43a336965
commit 67a1c47396
5 changed files with 362 additions and 137 deletions

View File

@@ -19,35 +19,39 @@ import {
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 type { ConversionJob, ConversionFormat, ConversionOptions } from '@/types/conversion';
export function FileConverter() {
const { addToast } = useToast();
const [selectedFile, setSelectedFile] = React.useState<File | undefined>();
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 [conversionJob, setConversionJob] = React.useState<ConversionJob | undefined>();
const [conversionJobs, setConversionJobs] = React.useState<ConversionJob[]>([]);
const [conversionOptions, setConversionOptions] = React.useState<ConversionOptions>({});
// Detect input format when file is selected
// Detect input format when files are selected
React.useEffect(() => {
if (!selectedFile) {
if (selectedFiles.length === 0) {
setInputFormat(undefined);
setOutputFormat(undefined);
setCompatibleFormats([]);
setConversionJob(undefined);
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 = selectedFile.name.split('.').pop()?.toLowerCase();
const ext = firstFile.name.split('.').pop()?.toLowerCase();
let format = ext ? getFormatByExtension(ext) : undefined;
// Fallback to MIME type
if (!format) {
format = getFormatByMimeType(selectedFile.type);
format = getFormatByMimeType(firstFile.type);
}
if (format) {
@@ -60,119 +64,189 @@ export function FileConverter() {
setOutputFormat(compatible[0]);
}
addToast(`Detected format: ${format.name}`, 'success');
addToast(`Detected format: ${format.name} (${selectedFiles.length} file${selectedFiles.length > 1 ? 's' : ''})`, 'success');
} else {
addToast('Could not detect file format', 'error');
setInputFormat(undefined);
setCompatibleFormats([]);
}
}, [selectedFile]);
}, [selectedFiles]);
const handleConvert = async () => {
if (!selectedFile || !inputFormat || !outputFormat) {
addToast('Please select a file and output format', 'error');
if (selectedFiles.length === 0 || !inputFormat || !outputFormat) {
addToast('Please select files and output format', 'error');
return;
}
// Create conversion job
const job: ConversionJob = {
// Create conversion jobs for all files
const jobs: ConversionJob[] = selectedFiles.map((file) => ({
id: Math.random().toString(36).substring(7),
inputFile: selectedFile,
inputFile: file,
inputFormat,
outputFormat,
options: conversionOptions,
status: 'loading',
status: 'pending',
progress: 0,
startTime: Date.now(),
};
}));
setConversionJob(job);
setConversionJobs(jobs);
try {
// Update status to processing
setConversionJob((prev) => prev && { ...prev, status: 'processing', progress: 10 });
// Convert files sequentially
for (let i = 0; i < jobs.length; i++) {
const job = jobs[i];
// Call appropriate converter
let result;
try {
// Update job to loading
setConversionJobs((prev) =>
prev.map((j, idx) => idx === i ? { ...j, status: 'loading' as const } : j)
);
switch (outputFormat.converter) {
case 'ffmpeg':
result = await convertWithFFmpeg(selectedFile, outputFormat.extension, conversionOptions, (progress) => {
setConversionJob((prev) => prev && { ...prev, progress });
});
break;
// Update job to processing
setConversionJobs((prev) =>
prev.map((j, idx) => idx === i ? { ...j, status: 'processing' as const, progress: 10 } : j)
);
case 'imagemagick':
result = await convertWithImageMagick(
selectedFile,
outputFormat.extension,
conversionOptions,
(progress) => {
setConversionJob((prev) => prev && { ...prev, progress });
}
// 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) {
setConversionJobs((prev) =>
prev.map((j, idx) => idx === i ? {
...j,
status: 'completed' as const,
progress: 100,
result: result.blob,
endTime: Date.now(),
} : j)
);
break;
default:
throw new Error(`Unknown converter: ${outputFormat.converter}`);
// 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 {
setConversionJobs((prev) =>
prev.map((j, idx) => idx === i ? {
...j,
status: 'error' as const,
error: result.error || 'Unknown error',
endTime: Date.now(),
} : j)
);
}
} catch (error) {
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)
);
}
}
// 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');
// Show completion message
const successCount = jobs.filter(j => j.status === 'completed').length;
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 = () => {
setSelectedFile(undefined);
setSelectedFiles([]);
setInputFormat(undefined);
setOutputFormat(undefined);
setCompatibleFormats([]);
setConversionJob(undefined);
setConversionJobs([]);
setConversionOptions({});
};
const isConvertDisabled =
!selectedFile || !outputFormat || conversionJob?.status === 'loading' || conversionJob?.status === 'processing';
const handleFileSelect = (files: File[]) => {
setSelectedFiles((prev) => [...prev, ...files]);
};
const handleFileRemove = (index: number) => {
setSelectedFiles((prev) => prev.filter((_, i) => i !== index));
};
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 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;
return (
<div className="w-full max-w-4xl mx-auto space-y-6">
@@ -187,15 +261,15 @@ export function FileConverter() {
<CardContent className="space-y-6">
{/* File upload */}
<FileUpload
onFileSelect={setSelectedFile}
onFileRemove={handleReset}
selectedFile={selectedFile}
disabled={conversionJob?.status === 'processing' || conversionJob?.status === 'loading'}
onFileSelect={handleFileSelect}
onFileRemove={handleFileRemove}
selectedFiles={selectedFiles}
disabled={isConverting}
/>
{/* File Info */}
{selectedFile && inputFormat && (
<FileInfo file={selectedFile} format={inputFormat} />
{/* File Info - show first file */}
{selectedFiles.length > 0 && inputFormat && (
<FileInfo file={selectedFiles[0]} format={inputFormat} />
)}
{/* Format selection */}
@@ -221,7 +295,7 @@ export function FileConverter() {
selectedFormat={outputFormat}
onFormatSelect={setOutputFormat}
label="Output Format"
disabled={conversionJob?.status === 'processing' || conversionJob?.status === 'loading'}
disabled={isConverting}
/>
</div>
)}
@@ -233,7 +307,7 @@ export function FileConverter() {
outputFormat={outputFormat}
options={conversionOptions}
onOptionsChange={setConversionOptions}
disabled={conversionJob?.status === 'processing' || conversionJob?.status === 'loading'}
disabled={isConverting}
/>
)}
@@ -246,9 +320,9 @@ export function FileConverter() {
className="flex-1"
size="lg"
>
{conversionJob?.status === 'loading' || conversionJob?.status === 'processing'
{isConverting
? 'Converting...'
: 'Convert'}
: `Convert ${selectedFiles.length} File${selectedFiles.length > 1 ? 's' : ''}`}
</Button>
<Button onClick={handleReset} variant="outline" size="lg">
Reset
@@ -258,8 +332,31 @@ export function FileConverter() {
</CardContent>
</Card>
{/* Conversion preview */}
{conversionJob && <ConversionPreview job={conversionJob} />}
{/* 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} />
))}
</div>
)}
</div>
);
}

View File

@@ -7,9 +7,9 @@ import { formatFileSize } from '@/lib/utils/fileUtils';
import { Button } from '@/components/ui/Button';
export interface FileUploadProps {
onFileSelect: (file: File) => void;
onFileRemove: () => void;
selectedFile?: File;
onFileSelect: (files: File[]) => void;
onFileRemove: (index: number) => void;
selectedFiles?: File[];
accept?: string;
maxSizeMB?: number;
disabled?: boolean;
@@ -18,7 +18,7 @@ export interface FileUploadProps {
export function FileUpload({
onFileSelect,
onFileRemove,
selectedFile,
selectedFiles = [],
accept,
maxSizeMB = 500,
disabled = false,
@@ -54,26 +54,36 @@ export function FileUpload({
const files = Array.from(e.dataTransfer.files);
if (files.length > 0) {
handleFile(files[0]);
handleFiles(files);
}
};
const handleFileInput = (e: React.ChangeEvent<HTMLInputElement>) => {
const files = Array.from(e.target.files || []);
if (files.length > 0) {
handleFile(files[0]);
handleFiles(files);
}
};
const handleFile = (file: File) => {
// Check file size
const handleFiles = (files: File[]) => {
// Check file sizes
const maxBytes = maxSizeMB * 1024 * 1024;
if (file.size > maxBytes) {
alert(`File size exceeds ${maxSizeMB}MB limit. Please choose a smaller file.`);
return;
const validFiles = files.filter(file => {
if (file.size > maxBytes) {
alert(`${file.name} exceeds ${maxSizeMB}MB limit and will be skipped.`);
return false;
}
return true;
});
if (validFiles.length > 0) {
onFileSelect(validFiles);
}
onFileSelect(file);
// Reset input
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
};
const handleClick = () => {
@@ -82,45 +92,58 @@ export function FileUpload({
}
};
const handleRemove = (e: React.MouseEvent) => {
const handleRemove = (index: number) => (e: React.MouseEvent) => {
e.stopPropagation();
onFileRemove();
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
onFileRemove(index);
};
return (
<div className="w-full">
<div className="w-full space-y-3">
<input
ref={fileInputRef}
type="file"
multiple
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>
{selectedFiles.length > 0 ? (
<div className="space-y-2">
{selectedFiles.map((file, index) => (
<div key={`${file.name}-${index}`} className="border-2 border-border rounded-lg p-4 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">{file.name}</p>
<p className="text-xs text-muted-foreground mt-1">
{formatFileSize(file.size)}
</p>
</div>
<Button
variant="ghost"
size="icon"
onClick={handleRemove(index)}
disabled={disabled}
className="ml-4"
>
<X className="h-4 w-4" />
<span className="sr-only">Remove file</span>
</Button>
</div>
</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>
))}
{/* Add more files button */}
<Button
variant="outline"
onClick={handleClick}
disabled={disabled}
className="w-full"
>
<Upload className="h-4 w-4 mr-2" />
Add More Files
</Button>
</div>
) : (
<div
@@ -141,10 +164,10 @@ export function FileUpload({
>
<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
Drop your files here or click to browse
</p>
<p className="text-xs text-muted-foreground">
Maximum file size: {maxSizeMB}MB
Maximum file size: {maxSizeMB}MB per file
</p>
</div>
)}