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:
@@ -1,7 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import * as React from 'react';
|
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 { cn } from '@/lib/utils/cn';
|
||||||
import { Button } from '@/components/ui/Button';
|
import { Button } from '@/components/ui/Button';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/Card';
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/Card';
|
||||||
@@ -12,9 +12,10 @@ import type { ConversionJob } from '@/types/conversion';
|
|||||||
export interface ConversionPreviewProps {
|
export interface ConversionPreviewProps {
|
||||||
job: ConversionJob;
|
job: ConversionJob;
|
||||||
onDownload?: () => void;
|
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 [previewUrl, setPreviewUrl] = React.useState<string | null>(null);
|
||||||
const [elapsedTime, setElapsedTime] = React.useState(0);
|
const [elapsedTime, setElapsedTime] = React.useState(0);
|
||||||
const [estimatedTimeRemaining, setEstimatedTimeRemaining] = React.useState<number | null>(null);
|
const [estimatedTimeRemaining, setEstimatedTimeRemaining] = React.useState<number | null>(null);
|
||||||
@@ -260,6 +261,14 @@ export function ConversionPreview({ job, onDownload }: ConversionPreviewProps) {
|
|||||||
</div>
|
</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 */}
|
{/* Preview */}
|
||||||
{job.status === 'completed' && renderPreview()}
|
{job.status === 'completed' && renderPreview()}
|
||||||
|
|
||||||
|
|||||||
@@ -244,6 +244,109 @@ export function FileConverter() {
|
|||||||
addToast(`Downloaded ${files.length} files as ZIP`, 'success');
|
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 isConverting = conversionJobs.some(job => job.status === 'loading' || job.status === 'processing');
|
||||||
const isConvertDisabled = selectedFiles.length === 0 || !outputFormat || isConverting;
|
const isConvertDisabled = selectedFiles.length === 0 || !outputFormat || isConverting;
|
||||||
const completedCount = conversionJobs.filter(job => job.status === 'completed').length;
|
const completedCount = conversionJobs.filter(job => job.status === 'completed').length;
|
||||||
@@ -356,7 +459,11 @@ export function FileConverter() {
|
|||||||
{conversionJobs.length > 0 && (
|
{conversionJobs.length > 0 && (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{conversionJobs.map((job) => (
|
{conversionJobs.map((job) => (
|
||||||
<ConversionPreview key={job.id} job={job} />
|
<ConversionPreview
|
||||||
|
key={job.id}
|
||||||
|
job={job}
|
||||||
|
onRetry={() => handleRetry(job.id)}
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
Reference in New Issue
Block a user