feat: add comprehensive conversion options and enhanced UI
This major update adds powerful format-specific controls, quality presets, file metadata display, and enhanced progress feedback to significantly improve the user experience. New Components: - ConversionOptionsPanel: Format-specific controls with collapsible advanced options - Video options: codec selection (H.264, H.265, VP8, VP9), bitrate, resolution, FPS - Audio options: codec selection, bitrate, sample rate, channels - Image options: quality slider, width/height controls - Quality Presets: One-click presets (High Quality, Balanced, Small File, Web Optimized) - FileInfo: Displays file metadata including size, duration, dimensions - Slider: Reusable slider component for quality/bitrate controls - Select: Reusable dropdown component for codec/format selection Enhanced Features: - ConversionPreview improvements: - Real-time elapsed time display - Estimated time remaining calculation - File size comparison (input vs output with % reduction/increase) - Better visual status indicators with icons - Enhanced loading states with detailed progress - FileConverter integration: - Passes conversion options to converter services - Manages conversion options state - Resets options on file reset UI/UX Improvements: - Format-specific option panels that adapt to selected output format - Visual preset buttons with icons and descriptions - Collapsible advanced options to reduce clutter - Better progress feedback with time estimates - File size comparison badges showing compression results - Smooth animations and transitions (existing animations already in place) - Responsive design for all new components Technical Details: - Options are properly typed and integrated with ConversionOptions interface - All components support disabled states during conversion - Preview component calculates speed and estimates remaining time - Metadata extraction for video/audio/image files using browser APIs - Proper cleanup of object URLs and timers User Benefits: - Power users can fine-tune codec, bitrate, resolution settings - Beginners can use quality presets with confidence - Better understanding of conversion progress and file size impact - Informed decisions with file metadata display - Professional-grade control over output quality 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
406
components/converter/ConversionOptions.tsx
Normal file
406
components/converter/ConversionOptions.tsx
Normal file
@@ -0,0 +1,406 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { ChevronDown, ChevronUp, Sparkles } from 'lucide-react';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Slider } from '@/components/ui/Slider';
|
||||
import { Select } from '@/components/ui/Select';
|
||||
import type { ConversionOptions, ConversionFormat } from '@/types/conversion';
|
||||
|
||||
interface ConversionOptionsProps {
|
||||
inputFormat: ConversionFormat;
|
||||
outputFormat: ConversionFormat;
|
||||
options: ConversionOptions;
|
||||
onOptionsChange: (options: ConversionOptions) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
interface QualityPreset {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
icon: string;
|
||||
options: ConversionOptions;
|
||||
}
|
||||
|
||||
export function ConversionOptionsPanel({
|
||||
inputFormat,
|
||||
outputFormat,
|
||||
options,
|
||||
onOptionsChange,
|
||||
disabled = false,
|
||||
}: ConversionOptionsProps) {
|
||||
const [isExpanded, setIsExpanded] = React.useState(false);
|
||||
const [selectedPreset, setSelectedPreset] = React.useState<string | null>(null);
|
||||
|
||||
// Quality presets based on output format category
|
||||
const getPresets = (): QualityPreset[] => {
|
||||
const category = outputFormat.category;
|
||||
|
||||
if (category === 'video') {
|
||||
return [
|
||||
{
|
||||
id: 'high-quality',
|
||||
name: 'High Quality',
|
||||
description: 'Best quality, larger file size',
|
||||
icon: '⭐',
|
||||
options: {
|
||||
videoBitrate: '5M',
|
||||
videoCodec: outputFormat.extension === 'webm' ? 'libvpx' : 'libx264',
|
||||
audioBitrate: '192k',
|
||||
audioCodec: outputFormat.extension === 'webm' ? 'libvorbis' : 'aac',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'balanced',
|
||||
name: 'Balanced',
|
||||
description: 'Good quality, moderate size',
|
||||
icon: '⚖️',
|
||||
options: {
|
||||
videoBitrate: '2M',
|
||||
videoCodec: outputFormat.extension === 'webm' ? 'libvpx' : 'libx264',
|
||||
audioBitrate: '128k',
|
||||
audioCodec: outputFormat.extension === 'webm' ? 'libvorbis' : 'aac',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'small-file',
|
||||
name: 'Small File',
|
||||
description: 'Smaller size, lower quality',
|
||||
icon: '📦',
|
||||
options: {
|
||||
videoBitrate: '1M',
|
||||
videoCodec: outputFormat.extension === 'webm' ? 'libvpx' : 'libx264',
|
||||
audioBitrate: '96k',
|
||||
audioCodec: outputFormat.extension === 'webm' ? 'libvorbis' : 'aac',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'web-optimized',
|
||||
name: 'Web Optimized',
|
||||
description: 'Fast loading for web',
|
||||
icon: '🌐',
|
||||
options: {
|
||||
videoBitrate: '1.5M',
|
||||
videoCodec: 'libvpx',
|
||||
audioBitrate: '128k',
|
||||
audioCodec: 'libvorbis',
|
||||
videoResolution: '720x-1',
|
||||
},
|
||||
},
|
||||
];
|
||||
} else if (category === 'audio') {
|
||||
return [
|
||||
{
|
||||
id: 'high-quality',
|
||||
name: 'High Quality',
|
||||
description: 'Best audio quality',
|
||||
icon: '⭐',
|
||||
options: {
|
||||
audioBitrate: '320k',
|
||||
audioCodec: outputFormat.extension === 'mp3' ? 'libmp3lame' : 'default',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'balanced',
|
||||
name: 'Balanced',
|
||||
description: 'Good quality, smaller size',
|
||||
icon: '⚖️',
|
||||
options: {
|
||||
audioBitrate: '192k',
|
||||
audioCodec: outputFormat.extension === 'mp3' ? 'libmp3lame' : 'default',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'small-file',
|
||||
name: 'Small File',
|
||||
description: 'Minimum file size',
|
||||
icon: '📦',
|
||||
options: {
|
||||
audioBitrate: '128k',
|
||||
audioCodec: outputFormat.extension === 'mp3' ? 'libmp3lame' : 'default',
|
||||
},
|
||||
},
|
||||
];
|
||||
} else if (category === 'image') {
|
||||
return [
|
||||
{
|
||||
id: 'high-quality',
|
||||
name: 'High Quality',
|
||||
description: 'Best image quality',
|
||||
icon: '⭐',
|
||||
options: {
|
||||
imageQuality: 95,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'balanced',
|
||||
name: 'Balanced',
|
||||
description: 'Good quality',
|
||||
icon: '⚖️',
|
||||
options: {
|
||||
imageQuality: 85,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'web-optimized',
|
||||
name: 'Web Optimized',
|
||||
description: 'Optimized for web',
|
||||
icon: '🌐',
|
||||
options: {
|
||||
imageQuality: 75,
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
return [];
|
||||
};
|
||||
|
||||
const presets = getPresets();
|
||||
|
||||
const handlePresetClick = (preset: QualityPreset) => {
|
||||
setSelectedPreset(preset.id);
|
||||
onOptionsChange({ ...options, ...preset.options });
|
||||
};
|
||||
|
||||
const handleOptionChange = (key: string, value: any) => {
|
||||
setSelectedPreset(null); // Clear preset when manual change
|
||||
onOptionsChange({ ...options, [key]: value });
|
||||
};
|
||||
|
||||
const renderVideoOptions = () => (
|
||||
<div className="space-y-4">
|
||||
{/* Video Codec */}
|
||||
<Select
|
||||
label="Video Codec"
|
||||
value={options.videoCodec || 'default'}
|
||||
onValueChange={(value) => handleOptionChange('videoCodec', value === 'default' ? undefined : value)}
|
||||
options={[
|
||||
{ value: 'default', label: 'Auto (Recommended)' },
|
||||
{ value: 'libx264', label: 'H.264 (MP4, AVI, MOV)' },
|
||||
{ value: 'libx265', label: 'H.265 (MP4)' },
|
||||
{ value: 'libvpx', label: 'VP8 (WebM)' },
|
||||
{ value: 'libvpx-vp9', label: 'VP9 (WebM)' },
|
||||
]}
|
||||
disabled={disabled}
|
||||
/>
|
||||
|
||||
{/* Video Bitrate */}
|
||||
<div>
|
||||
<label className="text-sm font-medium text-foreground mb-2 block">Video Bitrate</label>
|
||||
<div className="space-y-2">
|
||||
<Slider
|
||||
min={0.5}
|
||||
max={10}
|
||||
step={0.5}
|
||||
value={parseFloat(options.videoBitrate?.replace('M', '') || '2')}
|
||||
onValueChange={(value) => handleOptionChange('videoBitrate', `${value}M`)}
|
||||
showValue={true}
|
||||
unit="M"
|
||||
disabled={disabled}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">Higher bitrate = better quality, larger file</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Resolution */}
|
||||
<Select
|
||||
label="Resolution"
|
||||
value={options.videoResolution || 'original'}
|
||||
onValueChange={(value) => handleOptionChange('videoResolution', value === 'original' ? undefined : value)}
|
||||
options={[
|
||||
{ value: 'original', label: 'Original' },
|
||||
{ value: '1920x-1', label: '1080p (1920x1080)' },
|
||||
{ value: '1280x-1', label: '720p (1280x720)' },
|
||||
{ value: '854x-1', label: '480p (854x480)' },
|
||||
{ value: '640x-1', label: '360p (640x360)' },
|
||||
]}
|
||||
disabled={disabled}
|
||||
/>
|
||||
|
||||
{/* FPS */}
|
||||
<Select
|
||||
label="Frame Rate (FPS)"
|
||||
value={options.videoFps?.toString() || 'original'}
|
||||
onValueChange={(value) => handleOptionChange('videoFps', value === 'original' ? undefined : parseInt(value))}
|
||||
options={[
|
||||
{ value: 'original', label: 'Original' },
|
||||
{ value: '60', label: '60 fps' },
|
||||
{ value: '30', label: '30 fps' },
|
||||
{ value: '24', label: '24 fps' },
|
||||
{ value: '15', label: '15 fps' },
|
||||
]}
|
||||
disabled={disabled}
|
||||
/>
|
||||
|
||||
{/* Audio Bitrate */}
|
||||
<Slider
|
||||
label="Audio Bitrate"
|
||||
min={64}
|
||||
max={320}
|
||||
step={32}
|
||||
value={parseInt(options.audioBitrate?.replace('k', '') || '128')}
|
||||
onValueChange={(value) => handleOptionChange('audioBitrate', `${value}k`)}
|
||||
showValue={true}
|
||||
unit="k"
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderAudioOptions = () => (
|
||||
<div className="space-y-4">
|
||||
{/* Audio Codec */}
|
||||
<Select
|
||||
label="Audio Codec"
|
||||
value={options.audioCodec || 'default'}
|
||||
onValueChange={(value) => handleOptionChange('audioCodec', value === 'default' ? undefined : value)}
|
||||
options={[
|
||||
{ value: 'default', label: 'Auto (Recommended)' },
|
||||
{ value: 'libmp3lame', label: 'MP3 (LAME)' },
|
||||
{ value: 'aac', label: 'AAC' },
|
||||
{ value: 'libvorbis', label: 'Vorbis (OGG)' },
|
||||
{ value: 'libopus', label: 'Opus' },
|
||||
{ value: 'flac', label: 'FLAC (Lossless)' },
|
||||
]}
|
||||
disabled={disabled}
|
||||
/>
|
||||
|
||||
{/* Bitrate */}
|
||||
<Slider
|
||||
label="Bitrate"
|
||||
min={64}
|
||||
max={320}
|
||||
step={32}
|
||||
value={parseInt(options.audioBitrate?.replace('k', '') || '192')}
|
||||
onValueChange={(value) => handleOptionChange('audioBitrate', `${value}k`)}
|
||||
showValue={true}
|
||||
unit="k"
|
||||
disabled={disabled}
|
||||
/>
|
||||
|
||||
{/* Sample Rate */}
|
||||
<Select
|
||||
label="Sample Rate"
|
||||
value={options.audioSampleRate?.toString() || 'original'}
|
||||
onValueChange={(value) => handleOptionChange('audioSampleRate', value === 'original' ? undefined : parseInt(value))}
|
||||
options={[
|
||||
{ value: 'original', label: 'Original' },
|
||||
{ value: '48000', label: '48 kHz (Studio)' },
|
||||
{ value: '44100', label: '44.1 kHz (CD Quality)' },
|
||||
{ value: '22050', label: '22.05 kHz' },
|
||||
]}
|
||||
disabled={disabled}
|
||||
/>
|
||||
|
||||
{/* Channels */}
|
||||
<Select
|
||||
label="Channels"
|
||||
value={options.audioChannels?.toString() || 'original'}
|
||||
onValueChange={(value) => handleOptionChange('audioChannels', value === 'original' ? undefined : parseInt(value))}
|
||||
options={[
|
||||
{ value: 'original', label: 'Original' },
|
||||
{ value: '2', label: 'Stereo (2 channels)' },
|
||||
{ value: '1', label: 'Mono (1 channel)' },
|
||||
]}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderImageOptions = () => (
|
||||
<div className="space-y-4">
|
||||
{/* Quality */}
|
||||
<Slider
|
||||
label="Quality"
|
||||
min={1}
|
||||
max={100}
|
||||
step={1}
|
||||
value={options.imageQuality || 85}
|
||||
onValueChange={(value) => handleOptionChange('imageQuality', value)}
|
||||
showValue={true}
|
||||
unit="%"
|
||||
disabled={disabled}
|
||||
/>
|
||||
|
||||
{/* Width */}
|
||||
<div>
|
||||
<label className="text-sm font-medium text-foreground mb-2 block">Width (px)</label>
|
||||
<input
|
||||
type="number"
|
||||
value={options.imageWidth || ''}
|
||||
onChange={(e) => handleOptionChange('imageWidth', e.target.value ? parseInt(e.target.value) : undefined)}
|
||||
placeholder="Original"
|
||||
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
|
||||
disabled={disabled}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground mt-1">Leave empty to keep original</p>
|
||||
</div>
|
||||
|
||||
{/* Height */}
|
||||
<div>
|
||||
<label className="text-sm font-medium text-foreground mb-2 block">Height (px)</label>
|
||||
<input
|
||||
type="number"
|
||||
value={options.imageHeight || ''}
|
||||
onChange={(e) => handleOptionChange('imageHeight', e.target.value ? parseInt(e.target.value) : undefined)}
|
||||
placeholder="Original"
|
||||
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
|
||||
disabled={disabled}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground mt-1">Leave empty to maintain aspect ratio</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<Card className="p-4">
|
||||
{/* Presets Section */}
|
||||
{presets.length > 0 && (
|
||||
<div className="mb-4">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Sparkles className="h-4 w-4 text-primary" />
|
||||
<h3 className="text-sm font-semibold">Quality Presets</h3>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-2">
|
||||
{presets.map((preset) => (
|
||||
<Button
|
||||
key={preset.id}
|
||||
onClick={() => handlePresetClick(preset)}
|
||||
variant={selectedPreset === preset.id ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
className="flex flex-col h-auto py-3 px-3 text-left items-start"
|
||||
disabled={disabled}
|
||||
>
|
||||
<span className="text-base mb-1">{preset.icon}</span>
|
||||
<span className="text-xs font-medium">{preset.name}</span>
|
||||
<span className="text-xs text-muted-foreground mt-1">{preset.description}</span>
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Advanced Options Toggle */}
|
||||
<button
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
className="w-full flex items-center justify-between text-sm font-medium text-foreground hover:text-primary transition-colors mb-3"
|
||||
disabled={disabled}
|
||||
>
|
||||
<span>Advanced Options</span>
|
||||
{isExpanded ? <ChevronUp className="h-4 w-4" /> : <ChevronDown className="h-4 w-4" />}
|
||||
</button>
|
||||
|
||||
{/* Advanced Options Panel */}
|
||||
{isExpanded && (
|
||||
<div className="pt-3 border-t border-border">
|
||||
{outputFormat.category === 'video' && renderVideoOptions()}
|
||||
{outputFormat.category === 'audio' && renderAudioOptions()}
|
||||
{outputFormat.category === 'image' && renderImageOptions()}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { Download, CheckCircle, XCircle, Loader2 } from 'lucide-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';
|
||||
@@ -16,6 +16,32 @@ export interface ConversionPreviewProps {
|
||||
|
||||
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(() => {
|
||||
@@ -77,44 +103,109 @@ export function ConversionPreview({ job, onDownload }: ConversionPreviewProps) {
|
||||
}
|
||||
};
|
||||
|
||||
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 converter...</span>
|
||||
<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>
|
||||
<div className="flex items-center gap-2 text-info mb-2">
|
||||
<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>
|
||||
<Progress value={job.progress} showLabel />
|
||||
<span className="text-xs text-muted-foreground">{job.progress}%</span>
|
||||
</div>
|
||||
<Progress value={job.progress} showLabel={false} />
|
||||
<div className="flex items-center 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-4 w-4" />
|
||||
<CheckCircle className="h-5 w-5" />
|
||||
<span className="text-sm font-medium">Conversion complete!</span>
|
||||
{job.result && (
|
||||
<span className="text-xs text-muted-foreground ml-auto">
|
||||
{formatFileSize(job.result.size)}
|
||||
</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-4 w-4" />
|
||||
<XCircle className="h-5 w-5" />
|
||||
<span className="text-sm font-medium">Conversion failed</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -7,6 +7,8 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/com
|
||||
import { FileUpload } from './FileUpload';
|
||||
import { FormatSelector } from './FormatSelector';
|
||||
import { ConversionPreview } from './ConversionPreview';
|
||||
import { ConversionOptionsPanel } from './ConversionOptions';
|
||||
import { FileInfo } from './FileInfo';
|
||||
import { useToast } from '@/components/ui/Toast';
|
||||
import {
|
||||
SUPPORTED_FORMATS,
|
||||
@@ -17,7 +19,7 @@ import {
|
||||
import { convertWithFFmpeg } from '@/lib/converters/ffmpegService';
|
||||
import { convertWithImageMagick } from '@/lib/converters/imagemagickService';
|
||||
import { addToHistory } from '@/lib/storage/history';
|
||||
import type { ConversionJob, ConversionFormat } from '@/types/conversion';
|
||||
import type { ConversionJob, ConversionFormat, ConversionOptions } from '@/types/conversion';
|
||||
|
||||
export function FileConverter() {
|
||||
const { addToast } = useToast();
|
||||
@@ -27,6 +29,7 @@ export function FileConverter() {
|
||||
const [outputFormat, setOutputFormat] = React.useState<ConversionFormat | undefined>();
|
||||
const [compatibleFormats, setCompatibleFormats] = React.useState<ConversionFormat[]>([]);
|
||||
const [conversionJob, setConversionJob] = React.useState<ConversionJob | undefined>();
|
||||
const [conversionOptions, setConversionOptions] = React.useState<ConversionOptions>({});
|
||||
|
||||
// Detect input format when file is selected
|
||||
React.useEffect(() => {
|
||||
@@ -77,7 +80,7 @@ export function FileConverter() {
|
||||
inputFile: selectedFile,
|
||||
inputFormat,
|
||||
outputFormat,
|
||||
options: {},
|
||||
options: conversionOptions,
|
||||
status: 'loading',
|
||||
progress: 0,
|
||||
startTime: Date.now(),
|
||||
@@ -94,7 +97,7 @@ export function FileConverter() {
|
||||
|
||||
switch (outputFormat.converter) {
|
||||
case 'ffmpeg':
|
||||
result = await convertWithFFmpeg(selectedFile, outputFormat.extension, {}, (progress) => {
|
||||
result = await convertWithFFmpeg(selectedFile, outputFormat.extension, conversionOptions, (progress) => {
|
||||
setConversionJob((prev) => prev && { ...prev, progress });
|
||||
});
|
||||
break;
|
||||
@@ -103,7 +106,7 @@ export function FileConverter() {
|
||||
result = await convertWithImageMagick(
|
||||
selectedFile,
|
||||
outputFormat.extension,
|
||||
{},
|
||||
conversionOptions,
|
||||
(progress) => {
|
||||
setConversionJob((prev) => prev && { ...prev, progress });
|
||||
}
|
||||
@@ -165,6 +168,7 @@ export function FileConverter() {
|
||||
setOutputFormat(undefined);
|
||||
setCompatibleFormats([]);
|
||||
setConversionJob(undefined);
|
||||
setConversionOptions({});
|
||||
};
|
||||
|
||||
const isConvertDisabled =
|
||||
@@ -189,6 +193,11 @@ export function FileConverter() {
|
||||
disabled={conversionJob?.status === 'processing' || conversionJob?.status === 'loading'}
|
||||
/>
|
||||
|
||||
{/* File Info */}
|
||||
{selectedFile && inputFormat && (
|
||||
<FileInfo file={selectedFile} format={inputFormat} />
|
||||
)}
|
||||
|
||||
{/* Format selection */}
|
||||
{inputFormat && compatibleFormats.length > 0 && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-[1fr_auto_1fr] gap-4 items-start">
|
||||
@@ -217,6 +226,17 @@ export function FileConverter() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Conversion Options */}
|
||||
{inputFormat && outputFormat && (
|
||||
<ConversionOptionsPanel
|
||||
inputFormat={inputFormat}
|
||||
outputFormat={outputFormat}
|
||||
options={conversionOptions}
|
||||
onOptionsChange={setConversionOptions}
|
||||
disabled={conversionJob?.status === 'processing' || conversionJob?.status === 'loading'}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Convert button */}
|
||||
{inputFormat && outputFormat && (
|
||||
<div className="flex gap-3">
|
||||
|
||||
210
components/converter/FileInfo.tsx
Normal file
210
components/converter/FileInfo.tsx
Normal file
@@ -0,0 +1,210 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { File, FileVideo, FileAudio, FileImage, Clock, HardDrive, Film, Music } from 'lucide-react';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import type { ConversionFormat } from '@/types/conversion';
|
||||
|
||||
interface FileInfoProps {
|
||||
file: File;
|
||||
format: ConversionFormat;
|
||||
}
|
||||
|
||||
interface FileMetadata {
|
||||
name: string;
|
||||
size: string;
|
||||
type: string;
|
||||
category: string;
|
||||
duration?: string;
|
||||
dimensions?: string;
|
||||
}
|
||||
|
||||
export function FileInfo({ file, format }: FileInfoProps) {
|
||||
const [metadata, setMetadata] = React.useState<FileMetadata | null>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
extractMetadata(file, format);
|
||||
}, [file, format]);
|
||||
|
||||
const extractMetadata = async (file: File, format: ConversionFormat) => {
|
||||
const sizeInMB = (file.size / (1024 * 1024)).toFixed(2);
|
||||
const baseMetadata: FileMetadata = {
|
||||
name: file.name,
|
||||
size: file.size < 1024 * 1024 ? `${(file.size / 1024).toFixed(2)} KB` : `${sizeInMB} MB`,
|
||||
type: format.name,
|
||||
category: format.category,
|
||||
};
|
||||
|
||||
// Try to extract media-specific metadata
|
||||
if (format.category === 'video' && file.type.startsWith('video/')) {
|
||||
try {
|
||||
const video = document.createElement('video');
|
||||
video.preload = 'metadata';
|
||||
|
||||
const promise = new Promise<FileMetadata>((resolve) => {
|
||||
video.onloadedmetadata = () => {
|
||||
const duration = video.duration;
|
||||
const minutes = Math.floor(duration / 60);
|
||||
const seconds = Math.floor(duration % 60);
|
||||
const durationStr = `${minutes}:${seconds.toString().padStart(2, '0')}`;
|
||||
|
||||
resolve({
|
||||
...baseMetadata,
|
||||
duration: durationStr,
|
||||
dimensions: `${video.videoWidth} × ${video.videoHeight}`,
|
||||
});
|
||||
|
||||
URL.revokeObjectURL(video.src);
|
||||
};
|
||||
|
||||
video.onerror = () => {
|
||||
resolve(baseMetadata);
|
||||
URL.revokeObjectURL(video.src);
|
||||
};
|
||||
});
|
||||
|
||||
video.src = URL.createObjectURL(file);
|
||||
const result = await promise;
|
||||
setMetadata(result);
|
||||
return;
|
||||
} catch (error) {
|
||||
console.error('Failed to extract video metadata:', error);
|
||||
}
|
||||
} else if (format.category === 'audio' && file.type.startsWith('audio/')) {
|
||||
try {
|
||||
const audio = document.createElement('audio');
|
||||
audio.preload = 'metadata';
|
||||
|
||||
const promise = new Promise<FileMetadata>((resolve) => {
|
||||
audio.onloadedmetadata = () => {
|
||||
const duration = audio.duration;
|
||||
const minutes = Math.floor(duration / 60);
|
||||
const seconds = Math.floor(duration % 60);
|
||||
const durationStr = `${minutes}:${seconds.toString().padStart(2, '0')}`;
|
||||
|
||||
resolve({
|
||||
...baseMetadata,
|
||||
duration: durationStr,
|
||||
});
|
||||
|
||||
URL.revokeObjectURL(audio.src);
|
||||
};
|
||||
|
||||
audio.onerror = () => {
|
||||
resolve(baseMetadata);
|
||||
URL.revokeObjectURL(audio.src);
|
||||
};
|
||||
});
|
||||
|
||||
audio.src = URL.createObjectURL(file);
|
||||
const result = await promise;
|
||||
setMetadata(result);
|
||||
return;
|
||||
} catch (error) {
|
||||
console.error('Failed to extract audio metadata:', error);
|
||||
}
|
||||
} else if (format.category === 'image' && file.type.startsWith('image/')) {
|
||||
try {
|
||||
const img = new Image();
|
||||
|
||||
const promise = new Promise<FileMetadata>((resolve) => {
|
||||
img.onload = () => {
|
||||
resolve({
|
||||
...baseMetadata,
|
||||
dimensions: `${img.width} × ${img.height}`,
|
||||
});
|
||||
|
||||
URL.revokeObjectURL(img.src);
|
||||
};
|
||||
|
||||
img.onerror = () => {
|
||||
resolve(baseMetadata);
|
||||
URL.revokeObjectURL(img.src);
|
||||
};
|
||||
});
|
||||
|
||||
img.src = URL.createObjectURL(file);
|
||||
const result = await promise;
|
||||
setMetadata(result);
|
||||
return;
|
||||
} catch (error) {
|
||||
console.error('Failed to extract image metadata:', error);
|
||||
}
|
||||
}
|
||||
|
||||
setMetadata(baseMetadata);
|
||||
};
|
||||
|
||||
const getCategoryIcon = () => {
|
||||
switch (format.category) {
|
||||
case 'video':
|
||||
return <FileVideo className="h-5 w-5 text-primary" />;
|
||||
case 'audio':
|
||||
return <FileAudio className="h-5 w-5 text-primary" />;
|
||||
case 'image':
|
||||
return <FileImage className="h-5 w-5 text-primary" />;
|
||||
default:
|
||||
return <File className="h-5 w-5 text-primary" />;
|
||||
}
|
||||
};
|
||||
|
||||
if (!metadata) {
|
||||
return (
|
||||
<Card className="p-4 animate-pulse">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="w-5 h-5 bg-secondary rounded"></div>
|
||||
<div className="flex-1 space-y-2">
|
||||
<div className="h-4 bg-secondary rounded w-3/4"></div>
|
||||
<div className="h-3 bg-secondary rounded w-1/2"></div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="mt-0.5">{getCategoryIcon()}</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="text-sm font-medium text-foreground truncate" title={metadata.name}>
|
||||
{metadata.name}
|
||||
</h3>
|
||||
<div className="mt-2 grid grid-cols-2 gap-2 text-xs">
|
||||
{/* File Size */}
|
||||
<div className="flex items-center gap-2 text-muted-foreground">
|
||||
<HardDrive className="h-3.5 w-3.5" />
|
||||
<span>{metadata.size}</span>
|
||||
</div>
|
||||
|
||||
{/* Type */}
|
||||
<div className="flex items-center gap-2 text-muted-foreground">
|
||||
<File className="h-3.5 w-3.5" />
|
||||
<span>{metadata.type}</span>
|
||||
</div>
|
||||
|
||||
{/* Duration (for video/audio) */}
|
||||
{metadata.duration && (
|
||||
<div className="flex items-center gap-2 text-muted-foreground">
|
||||
<Clock className="h-3.5 w-3.5" />
|
||||
<span>{metadata.duration}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Dimensions */}
|
||||
{metadata.dimensions && (
|
||||
<div className="flex items-center gap-2 text-muted-foreground">
|
||||
{format.category === 'video' ? (
|
||||
<Film className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<FileImage className="h-3.5 w-3.5" />
|
||||
)}
|
||||
<span>{metadata.dimensions}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
51
components/ui/Select.tsx
Normal file
51
components/ui/Select.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
import * as React from 'react';
|
||||
import { cn } from '@/lib/utils/cn';
|
||||
|
||||
export interface SelectOption {
|
||||
value: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export interface SelectProps extends Omit<React.SelectHTMLAttributes<HTMLSelectElement>, 'onChange'> {
|
||||
options: SelectOption[];
|
||||
onValueChange?: (value: string) => void;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
const Select = React.forwardRef<HTMLSelectElement, SelectProps>(
|
||||
({ className, options, onValueChange, label, value, ...props }, ref) => {
|
||||
const handleChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
|
||||
onValueChange?.(e.target.value);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{label && <label className="text-sm font-medium text-foreground">{label}</label>}
|
||||
<select
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
className={cn(
|
||||
'flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm',
|
||||
'ring-offset-background focus-visible:outline-none focus-visible:ring-2',
|
||||
'focus-visible:ring-ring focus-visible:ring-offset-2',
|
||||
'disabled:cursor-not-allowed disabled:opacity-50',
|
||||
'cursor-pointer',
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
>
|
||||
{options.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
Select.displayName = 'Select';
|
||||
|
||||
export { Select };
|
||||
64
components/ui/Slider.tsx
Normal file
64
components/ui/Slider.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
import * as React from 'react';
|
||||
import { cn } from '@/lib/utils/cn';
|
||||
|
||||
export interface SliderProps extends Omit<React.InputHTMLAttributes<HTMLInputElement>, 'type'> {
|
||||
min?: number;
|
||||
max?: number;
|
||||
step?: number;
|
||||
value?: number;
|
||||
onValueChange?: (value: number) => void;
|
||||
showValue?: boolean;
|
||||
label?: string;
|
||||
unit?: string;
|
||||
}
|
||||
|
||||
const Slider = React.forwardRef<HTMLInputElement, SliderProps>(
|
||||
({ className, min = 0, max = 100, step = 1, value = 50, onValueChange, showValue = true, label, unit = '', ...props }, ref) => {
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const newValue = parseFloat(e.target.value);
|
||||
onValueChange?.(newValue);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{label && (
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-sm font-medium text-foreground">{label}</label>
|
||||
{showValue && (
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{value}{unit}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<input
|
||||
type="range"
|
||||
min={min}
|
||||
max={max}
|
||||
step={step}
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
className={cn(
|
||||
'w-full h-2 bg-secondary rounded-lg appearance-none cursor-pointer',
|
||||
'[&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-4 [&::-webkit-slider-thumb]:h-4',
|
||||
'[&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-primary',
|
||||
'[&::-webkit-slider-thumb]:cursor-pointer [&::-webkit-slider-thumb]:transition-all',
|
||||
'[&::-webkit-slider-thumb]:hover:scale-110',
|
||||
'[&::-moz-range-thumb]:w-4 [&::-moz-range-thumb]:h-4 [&::-moz-range-thumb]:rounded-full',
|
||||
'[&::-moz-range-thumb]:bg-primary [&::-moz-range-thumb]:border-0',
|
||||
'[&::-moz-range-thumb]:cursor-pointer [&::-moz-range-thumb]:transition-all',
|
||||
'[&::-moz-range-thumb]:hover:scale-110',
|
||||
'disabled:opacity-50 disabled:cursor-not-allowed',
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
Slider.displayName = 'Slider';
|
||||
|
||||
export { Slider };
|
||||
Reference in New Issue
Block a user