feat: add error recovery with retry functionality

Error recovery features:
- **Retry button**: Appears on failed conversions
- **Smart retry logic**: Re-attempts conversion with same settings
- **State management**: Properly resets job state before retry
- **Progress tracking**: Shows progress during retry attempt
- **Toast notifications**: Informs user of retry success/failure
- **History updates**: Successful retries added to history
- **Visual feedback**: RefreshCw icon with clear button label

Implementation:
- Added onRetry prop to ConversionPreview component
- Implemented handleRetry function in FileConverter
- Reuses existing conversion logic for consistency
- Maintains all conversion options during retry
- Updates job status through proper state flow:
  error → loading → processing → completed/error

User experience:
- One-click retry for failed conversions
- No need to re-upload file or reconfigure settings
- Clear visual indication when retry is in progress
- Helpful error messages if retry also fails

🤖 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:22:44 +01:00
parent 649cd3cd7b
commit ba2e011f4a
2 changed files with 119 additions and 3 deletions

View File

@@ -1,7 +1,7 @@
'use client';
import * as React from 'react';
import { Download, CheckCircle, XCircle, Loader2, Clock, TrendingUp, FileCheck2, ArrowRight } from 'lucide-react';
import { Download, CheckCircle, XCircle, Loader2, Clock, TrendingUp, FileCheck2, ArrowRight, RefreshCw } from 'lucide-react';
import { cn } from '@/lib/utils/cn';
import { Button } from '@/components/ui/Button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/Card';
@@ -12,9 +12,10 @@ import type { ConversionJob } from '@/types/conversion';
export interface ConversionPreviewProps {
job: ConversionJob;
onDownload?: () => void;
onRetry?: () => void;
}
export function ConversionPreview({ job, onDownload }: ConversionPreviewProps) {
export function ConversionPreview({ job, onDownload, onRetry }: ConversionPreviewProps) {
const [previewUrl, setPreviewUrl] = React.useState<string | null>(null);
const [elapsedTime, setElapsedTime] = React.useState(0);
const [estimatedTimeRemaining, setEstimatedTimeRemaining] = React.useState<number | null>(null);
@@ -260,6 +261,14 @@ export function ConversionPreview({ job, onDownload }: ConversionPreviewProps) {
</div>
)}
{/* Retry button */}
{job.status === 'error' && onRetry && (
<Button onClick={onRetry} variant="outline" className="w-full gap-2">
<RefreshCw className="h-4 w-4" />
Retry Conversion
</Button>
)}
{/* Preview */}
{job.status === 'completed' && renderPreview()}

View File

@@ -244,6 +244,109 @@ export function FileConverter() {
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;
@@ -356,7 +459,11 @@ export function FileConverter() {
{conversionJobs.length > 0 && (
<div className="space-y-4">
{conversionJobs.map((job) => (
<ConversionPreview key={job.id} job={job} />
<ConversionPreview
key={job.id}
job={job}
onRetry={() => handleRetry(job.id)}
/>
))}
</div>
)}