feat: initialize Convert UI - browser-based file conversion app
- Add Next.js 16 with Turbopack and React 19 - Add Tailwind CSS 4 with OKLCH color system - Implement FFmpeg.wasm for video/audio conversion - Implement ImageMagick WASM for image conversion - Add file upload with drag-and-drop - Add format selector with fuzzy search - Add conversion preview and download - Add conversion history with localStorage - Add dark/light theme support - Support 22+ file formats across video, audio, and images 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
170
components/converter/ConversionPreview.tsx
Normal file
170
components/converter/ConversionPreview.tsx
Normal file
@@ -0,0 +1,170 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { Download, CheckCircle, XCircle, Loader2 } 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);
|
||||
|
||||
// Create preview URL for result
|
||||
React.useEffect(() => {
|
||||
if (job.result && job.status === 'completed') {
|
||||
const url = URL.createObjectURL(job.result);
|
||||
setPreviewUrl(url);
|
||||
|
||||
return () => {
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
}
|
||||
}, [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;
|
||||
|
||||
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"
|
||||
/>
|
||||
</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 renderStatus = () => {
|
||||
switch (job.status) {
|
||||
case 'loading':
|
||||
return (
|
||||
<div className="flex items-center gap-2 text-info">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
<span className="text-sm font-medium">Loading converter...</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'processing':
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center gap-2 text-info mb-2">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
<span className="text-sm font-medium">Converting...</span>
|
||||
</div>
|
||||
<Progress value={job.progress} showLabel />
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'completed':
|
||||
return (
|
||||
<div className="flex items-center gap-2 text-success">
|
||||
<CheckCircle className="h-4 w-4" />
|
||||
<span className="text-sm font-medium">Conversion complete!</span>
|
||||
{job.result && (
|
||||
<span className="text-xs text-muted-foreground ml-auto">
|
||||
{formatFileSize(job.result.size)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'error':
|
||||
return (
|
||||
<div className="flex items-center gap-2 text-destructive">
|
||||
<XCircle className="h-4 w-4" />
|
||||
<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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user