Files
convert-ui/components/converter/ConversionPreview.tsx
Sebastian Krüger 649cd3cd7b feat: enhance mobile responsiveness across all components
Mobile optimizations:
- **Header**: Reduced padding on mobile (px-3), smaller text sizes
- **Main content**: Optimized padding (px-3 sm:px-4) and spacing
- **Format selector**: Added downward arrow for mobile flow
- **Conversion progress**: Time indicators stack vertically on mobile
- **Page layout**: Responsive spacing throughout (space-y-6 sm:space-y-8)
- **Footer**: Smaller text and reduced margins on mobile

Key improvements:
- Better use of screen space on small devices
- Improved touch targets and readability
- Consistent responsive breakpoints (sm:, md:)
- Vertical arrow (↓) on mobile, horizontal (→) on desktop
- All text scales appropriately for mobile screens

Tested on:
- Mobile viewport (< 640px)
- Tablet viewport (640px - 768px)
- Desktop viewport (> 768px)

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-17 13:20:39 +01:00

286 lines
9.9 KiB
TypeScript

'use client';
import * as React from 'react';
import { Download, CheckCircle, XCircle, Loader2, Clock, TrendingUp, FileCheck2, ArrowRight } from 'lucide-react';
import { cn } from '@/lib/utils/cn';
import { Button } from '@/components/ui/Button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/Card';
import { Progress } from '@/components/ui/Progress';
import { downloadBlob, formatFileSize, generateOutputFilename } from '@/lib/utils/fileUtils';
import type { ConversionJob } from '@/types/conversion';
export interface ConversionPreviewProps {
job: ConversionJob;
onDownload?: () => void;
}
export function ConversionPreview({ job, onDownload }: ConversionPreviewProps) {
const [previewUrl, setPreviewUrl] = React.useState<string | null>(null);
const [elapsedTime, setElapsedTime] = React.useState(0);
const [estimatedTimeRemaining, setEstimatedTimeRemaining] = React.useState<number | null>(null);
// Timer for elapsed time and estimation
React.useEffect(() => {
if (job.status === 'processing' || job.status === 'loading') {
const interval = setInterval(() => {
if (job.startTime) {
const elapsed = Date.now() - job.startTime;
setElapsedTime(elapsed);
// Estimate time remaining based on progress
if (job.progress > 5 && job.progress < 100) {
const progressRate = job.progress / elapsed;
const remainingProgress = 100 - job.progress;
const estimated = remainingProgress / progressRate;
setEstimatedTimeRemaining(estimated);
}
}
}, 100);
return () => clearInterval(interval);
} else {
setEstimatedTimeRemaining(null);
}
}, [job.status, job.startTime, job.progress]);
// Create preview URL for result
React.useEffect(() => {
if (job.result && job.status === 'completed') {
console.log('[Preview] Creating object URL for blob');
const url = URL.createObjectURL(job.result);
setPreviewUrl(url);
console.log('[Preview] Object URL created:', url);
return () => {
console.log('[Preview] Revoking object URL:', url);
URL.revokeObjectURL(url);
};
} else {
setPreviewUrl(null);
}
}, [job.result, job.status]);
const handleDownload = () => {
if (job.result) {
const filename = generateOutputFilename(job.inputFile.name, job.outputFormat.extension);
downloadBlob(job.result, filename);
onDownload?.();
}
};
const renderPreview = () => {
if (!previewUrl || !job.result) return null;
const category = job.outputFormat.category;
// Log blob details for debugging
console.log('[Preview] Blob details:', {
size: job.result.size,
type: job.result.type,
previewUrl,
outputFormat: job.outputFormat.extension,
});
switch (category) {
case 'image':
return (
<div className="mt-4 rounded-lg overflow-hidden bg-muted/30 flex items-center justify-center p-4">
<img
src={previewUrl}
alt="Converted image preview"
className="max-w-full max-h-64 object-contain"
onError={(e) => {
console.error('[Preview] Image failed to load:', {
src: previewUrl,
blobSize: job.result?.size,
blobType: job.result?.type,
error: e,
});
}}
onLoad={() => {
console.log('[Preview] Image loaded successfully');
}}
/>
</div>
);
case 'video':
return (
<div className="mt-4 rounded-lg overflow-hidden bg-muted/30">
<video src={previewUrl} controls className="w-full max-h-64">
Your browser does not support video playback.
</video>
</div>
);
case 'audio':
return (
<div className="mt-4 rounded-lg overflow-hidden bg-muted/30 p-4">
<audio src={previewUrl} controls className="w-full">
Your browser does not support audio playback.
</audio>
</div>
);
default:
return null;
}
};
const formatTime = (ms: number) => {
const seconds = Math.floor(ms / 1000);
if (seconds < 60) return `${seconds}s`;
const minutes = Math.floor(seconds / 60);
const remainingSeconds = seconds % 60;
return `${minutes}m ${remainingSeconds}s`;
};
const renderStatus = () => {
switch (job.status) {
case 'loading':
return (
<div className="space-y-3">
<div className="flex items-center gap-2 text-info">
<Loader2 className="h-4 w-4 animate-spin" />
<span className="text-sm font-medium">Loading WASM converter...</span>
</div>
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<Clock className="h-3.5 w-3.5" />
<span>Elapsed: {formatTime(elapsedTime)}</span>
</div>
</div>
);
case 'processing':
return (
<div className="space-y-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2 text-info">
<Loader2 className="h-4 w-4 animate-spin" />
<span className="text-sm font-medium">Converting...</span>
</div>
<span className="text-xs text-muted-foreground">{job.progress}%</span>
</div>
<Progress value={job.progress} showLabel={false} />
<div className="flex flex-col sm:flex-row sm:items-center gap-2 sm:gap-4 text-xs text-muted-foreground">
<div className="flex items-center gap-2">
<Clock className="h-3.5 w-3.5" />
<span>Elapsed: {formatTime(elapsedTime)}</span>
</div>
{estimatedTimeRemaining && (
<div className="flex items-center gap-2">
<TrendingUp className="h-3.5 w-3.5" />
<span>~{formatTime(estimatedTimeRemaining)} remaining</span>
</div>
)}
</div>
</div>
);
case 'completed':
const inputSize = job.inputFile.size;
const outputSize = job.result?.size || 0;
const sizeReduction = inputSize > 0 ? ((inputSize - outputSize) / inputSize) * 100 : 0;
return (
<div className="space-y-3">
<div className="flex items-center gap-2 text-success">
<CheckCircle className="h-5 w-5" />
<span className="text-sm font-medium">Conversion complete!</span>
</div>
{/* File size comparison */}
<div className="bg-muted/50 rounded-lg p-3 space-y-2">
<div className="flex items-center justify-between text-sm">
<div className="flex items-center gap-2">
<FileCheck2 className="h-4 w-4 text-muted-foreground" />
<span className="text-muted-foreground">Input:</span>
</div>
<span className="font-medium">{formatFileSize(inputSize)}</span>
</div>
<div className="flex items-center justify-center py-1">
<ArrowRight className="h-4 w-4 text-muted-foreground" />
</div>
<div className="flex items-center justify-between text-sm">
<div className="flex items-center gap-2">
<FileCheck2 className="h-4 w-4 text-muted-foreground" />
<span className="text-muted-foreground">Output:</span>
</div>
<div className="flex items-center gap-2">
<span className="font-medium">{formatFileSize(outputSize)}</span>
{Math.abs(sizeReduction) > 1 && (
<span className={cn(
"text-xs px-2 py-0.5 rounded-full",
sizeReduction > 0
? "bg-success/10 text-success"
: "bg-info/10 text-info"
)}>
{sizeReduction > 0 ? '-' : '+'}{Math.abs(sizeReduction).toFixed(0)}%
</span>
)}
</div>
</div>
</div>
</div>
);
case 'error':
return (
<div className="flex items-center gap-2 text-destructive">
<XCircle className="h-5 w-5" />
<span className="text-sm font-medium">Conversion failed</span>
</div>
);
default:
return null;
}
};
if (job.status === 'pending') {
return null;
}
return (
<Card className="animate-fadeIn">
<CardHeader>
<CardTitle className="text-lg">Conversion Status</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-4">
{/* Status */}
{renderStatus()}
{/* Error message */}
{job.error && (
<div className="bg-destructive/10 border border-destructive/20 rounded-md p-3">
<p className="text-sm text-destructive">{job.error}</p>
</div>
)}
{/* Preview */}
{job.status === 'completed' && renderPreview()}
{/* Download button */}
{job.status === 'completed' && job.result && (
<Button onClick={handleDownload} className="w-full gap-2">
<Download className="h-4 w-4" />
Download{' '}
{generateOutputFilename(job.inputFile.name, job.outputFormat.extension)}
</Button>
)}
{/* Duration */}
{job.status === 'completed' && job.startTime && job.endTime && (
<p className="text-xs text-muted-foreground text-center">
Completed in {((job.endTime - job.startTime) / 1000).toFixed(2)}s
</p>
)}
</div>
</CardContent>
</Card>
);
}