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:
2025-11-17 11:46:10 +01:00
parent 594a0ca314
commit f253285c25
6 changed files with 864 additions and 22 deletions

View 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>
);
}

View File

@@ -1,7 +1,7 @@
'use client'; 'use client';
import * as React from 'react'; 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 { 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';
@@ -16,6 +16,32 @@ export interface ConversionPreviewProps {
export function ConversionPreview({ job, onDownload }: ConversionPreviewProps) { export function ConversionPreview({ job, onDownload }: ConversionPreviewProps) {
const [previewUrl, setPreviewUrl] = React.useState<string | null>(null); 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 // Create preview URL for result
React.useEffect(() => { 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 = () => { const renderStatus = () => {
switch (job.status) { switch (job.status) {
case 'loading': case 'loading':
return ( return (
<div className="space-y-3">
<div className="flex items-center gap-2 text-info"> <div className="flex items-center gap-2 text-info">
<Loader2 className="h-4 w-4 animate-spin" /> <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> </div>
); );
case 'processing': case 'processing':
return ( return (
<div> <div className="space-y-3">
<div className="flex items-center gap-2 text-info mb-2"> <div className="flex items-center justify-between">
<div className="flex items-center gap-2 text-info">
<Loader2 className="h-4 w-4 animate-spin" /> <Loader2 className="h-4 w-4 animate-spin" />
<span className="text-sm font-medium">Converting...</span> <span className="text-sm font-medium">Converting...</span>
</div> </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> </div>
); );
case 'completed': case 'completed':
const inputSize = job.inputFile.size;
const outputSize = job.result?.size || 0;
const sizeReduction = inputSize > 0 ? ((inputSize - outputSize) / inputSize) * 100 : 0;
return ( return (
<div className="space-y-3">
<div className="flex items-center gap-2 text-success"> <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> <span className="text-sm font-medium">Conversion complete!</span>
{job.result && ( </div>
<span className="text-xs text-muted-foreground ml-auto">
{formatFileSize(job.result.size)} {/* 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> </span>
)} )}
</div> </div>
</div>
</div>
</div>
); );
case 'error': case 'error':
return ( return (
<div className="flex items-center gap-2 text-destructive"> <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> <span className="text-sm font-medium">Conversion failed</span>
</div> </div>
); );

View File

@@ -7,6 +7,8 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/com
import { FileUpload } from './FileUpload'; import { FileUpload } from './FileUpload';
import { FormatSelector } from './FormatSelector'; import { FormatSelector } from './FormatSelector';
import { ConversionPreview } from './ConversionPreview'; import { ConversionPreview } from './ConversionPreview';
import { ConversionOptionsPanel } from './ConversionOptions';
import { FileInfo } from './FileInfo';
import { useToast } from '@/components/ui/Toast'; import { useToast } from '@/components/ui/Toast';
import { import {
SUPPORTED_FORMATS, SUPPORTED_FORMATS,
@@ -17,7 +19,7 @@ import {
import { convertWithFFmpeg } from '@/lib/converters/ffmpegService'; import { convertWithFFmpeg } from '@/lib/converters/ffmpegService';
import { convertWithImageMagick } from '@/lib/converters/imagemagickService'; import { convertWithImageMagick } from '@/lib/converters/imagemagickService';
import { addToHistory } from '@/lib/storage/history'; import { addToHistory } from '@/lib/storage/history';
import type { ConversionJob, ConversionFormat } from '@/types/conversion'; import type { ConversionJob, ConversionFormat, ConversionOptions } from '@/types/conversion';
export function FileConverter() { export function FileConverter() {
const { addToast } = useToast(); const { addToast } = useToast();
@@ -27,6 +29,7 @@ export function FileConverter() {
const [outputFormat, setOutputFormat] = React.useState<ConversionFormat | undefined>(); const [outputFormat, setOutputFormat] = React.useState<ConversionFormat | undefined>();
const [compatibleFormats, setCompatibleFormats] = React.useState<ConversionFormat[]>([]); const [compatibleFormats, setCompatibleFormats] = React.useState<ConversionFormat[]>([]);
const [conversionJob, setConversionJob] = React.useState<ConversionJob | undefined>(); const [conversionJob, setConversionJob] = React.useState<ConversionJob | undefined>();
const [conversionOptions, setConversionOptions] = React.useState<ConversionOptions>({});
// Detect input format when file is selected // Detect input format when file is selected
React.useEffect(() => { React.useEffect(() => {
@@ -77,7 +80,7 @@ export function FileConverter() {
inputFile: selectedFile, inputFile: selectedFile,
inputFormat, inputFormat,
outputFormat, outputFormat,
options: {}, options: conversionOptions,
status: 'loading', status: 'loading',
progress: 0, progress: 0,
startTime: Date.now(), startTime: Date.now(),
@@ -94,7 +97,7 @@ export function FileConverter() {
switch (outputFormat.converter) { switch (outputFormat.converter) {
case 'ffmpeg': case 'ffmpeg':
result = await convertWithFFmpeg(selectedFile, outputFormat.extension, {}, (progress) => { result = await convertWithFFmpeg(selectedFile, outputFormat.extension, conversionOptions, (progress) => {
setConversionJob((prev) => prev && { ...prev, progress }); setConversionJob((prev) => prev && { ...prev, progress });
}); });
break; break;
@@ -103,7 +106,7 @@ export function FileConverter() {
result = await convertWithImageMagick( result = await convertWithImageMagick(
selectedFile, selectedFile,
outputFormat.extension, outputFormat.extension,
{}, conversionOptions,
(progress) => { (progress) => {
setConversionJob((prev) => prev && { ...prev, progress }); setConversionJob((prev) => prev && { ...prev, progress });
} }
@@ -165,6 +168,7 @@ export function FileConverter() {
setOutputFormat(undefined); setOutputFormat(undefined);
setCompatibleFormats([]); setCompatibleFormats([]);
setConversionJob(undefined); setConversionJob(undefined);
setConversionOptions({});
}; };
const isConvertDisabled = const isConvertDisabled =
@@ -189,6 +193,11 @@ export function FileConverter() {
disabled={conversionJob?.status === 'processing' || conversionJob?.status === 'loading'} disabled={conversionJob?.status === 'processing' || conversionJob?.status === 'loading'}
/> />
{/* File Info */}
{selectedFile && inputFormat && (
<FileInfo file={selectedFile} format={inputFormat} />
)}
{/* Format selection */} {/* Format selection */}
{inputFormat && compatibleFormats.length > 0 && ( {inputFormat && compatibleFormats.length > 0 && (
<div className="grid grid-cols-1 md:grid-cols-[1fr_auto_1fr] gap-4 items-start"> <div className="grid grid-cols-1 md:grid-cols-[1fr_auto_1fr] gap-4 items-start">
@@ -217,6 +226,17 @@ export function FileConverter() {
</div> </div>
)} )}
{/* Conversion Options */}
{inputFormat && outputFormat && (
<ConversionOptionsPanel
inputFormat={inputFormat}
outputFormat={outputFormat}
options={conversionOptions}
onOptionsChange={setConversionOptions}
disabled={conversionJob?.status === 'processing' || conversionJob?.status === 'loading'}
/>
)}
{/* Convert button */} {/* Convert button */}
{inputFormat && outputFormat && ( {inputFormat && outputFormat && (
<div className="flex gap-3"> <div className="flex gap-3">

View 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
View 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
View 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 };