feat: add media converter app and fix compilation errors
This commit is contained in:
173
components/media/ConversionHistory.tsx
Normal file
173
components/media/ConversionHistory.tsx
Normal file
@@ -0,0 +1,173 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { History, Trash2, ArrowRight, Clock } from 'lucide-react';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { formatFileSize } from '@/lib/media/utils/fileUtils';
|
||||
import { getHistory, clearHistory, removeHistoryItem } from '@/lib/media/storage/history';
|
||||
import type { ConversionHistoryItem } from '@/types/media';
|
||||
|
||||
export function ConversionHistory() {
|
||||
const [history, setHistory] = React.useState<ConversionHistoryItem[]>([]);
|
||||
|
||||
// Load history on mount and listen for updates
|
||||
React.useEffect(() => {
|
||||
const loadHistory = () => {
|
||||
const items = getHistory();
|
||||
setHistory(items);
|
||||
};
|
||||
|
||||
loadHistory();
|
||||
|
||||
// Listen for storage changes (e.g., from other tabs)
|
||||
const handleStorageChange = (e: StorageEvent) => {
|
||||
if (e.key === 'convert-ui-history') {
|
||||
loadHistory();
|
||||
}
|
||||
};
|
||||
|
||||
// Listen for custom event (same-page updates)
|
||||
const handleHistoryUpdate = () => {
|
||||
loadHistory();
|
||||
};
|
||||
|
||||
window.addEventListener('storage', handleStorageChange);
|
||||
window.addEventListener('conversionHistoryUpdated', handleHistoryUpdate);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('storage', handleStorageChange);
|
||||
window.removeEventListener('conversionHistoryUpdated', handleHistoryUpdate);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleClearHistory = () => {
|
||||
if (confirm('Are you sure you want to clear all conversion history?')) {
|
||||
clearHistory();
|
||||
setHistory([]);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveItem = (id: string) => {
|
||||
removeHistoryItem(id);
|
||||
setHistory((prev) => prev.filter((item) => item.id !== id));
|
||||
};
|
||||
|
||||
const formatTimestamp = (timestamp: number) => {
|
||||
const date = new Date(timestamp);
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - date.getTime();
|
||||
const diffMins = Math.floor(diffMs / 60000);
|
||||
const diffHours = Math.floor(diffMs / 3600000);
|
||||
const diffDays = Math.floor(diffMs / 86400000);
|
||||
|
||||
if (diffMins < 1) return 'Just now';
|
||||
if (diffMins < 60) return `${diffMins} minute${diffMins > 1 ? 's' : ''} ago`;
|
||||
if (diffHours < 24) return `${diffHours} hour${diffHours > 1 ? 's' : ''} ago`;
|
||||
if (diffDays < 7) return `${diffDays} day${diffDays > 1 ? 's' : ''} ago`;
|
||||
|
||||
return date.toLocaleDateString();
|
||||
};
|
||||
|
||||
if (history.length === 0) {
|
||||
return (
|
||||
<div className="w-full max-w-4xl mx-auto">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<History className="h-5 w-5" />
|
||||
Conversion History
|
||||
</CardTitle>
|
||||
<CardDescription>Your recent conversions will appear here</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-center py-12 text-muted-foreground">
|
||||
<History className="h-12 w-12 mx-auto mb-4 opacity-50" />
|
||||
<p>No conversion history yet</p>
|
||||
<p className="text-sm mt-1">Convert some files to see them here</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full max-w-4xl mx-auto">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<History className="h-5 w-5" />
|
||||
Conversion History
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Recent conversions ({history.length} item{history.length > 1 ? 's' : ''})
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={handleClearHistory}>
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
Clear All
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-3">
|
||||
{history.map((item) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className="border border-border rounded-lg p-4 hover:bg-muted/50 transition-colors"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex-1 min-w-0">
|
||||
{/* File conversion info */}
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className="text-sm font-medium text-foreground truncate">
|
||||
{item.inputFileName}
|
||||
</span>
|
||||
<ArrowRight className="h-3.5 w-3.5 text-muted-foreground flex-shrink-0" />
|
||||
<span className="text-sm font-medium text-foreground truncate">
|
||||
{item.outputFileName}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Format conversion */}
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground mb-1">
|
||||
<span className="px-2 py-0.5 bg-muted rounded">
|
||||
{item.inputFormat}
|
||||
</span>
|
||||
<ArrowRight className="h-3 w-3" />
|
||||
<span className="px-2 py-0.5 bg-muted rounded">
|
||||
{item.outputFormat}
|
||||
</span>
|
||||
<span>•</span>
|
||||
<span>{formatFileSize(item.fileSize)}</span>
|
||||
</div>
|
||||
|
||||
{/* Timestamp */}
|
||||
<div className="flex items-center gap-1 text-xs text-muted-foreground">
|
||||
<Clock className="h-3 w-3" />
|
||||
<span>{formatTimestamp(item.timestamp)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Remove button */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => handleRemoveItem(item.id)}
|
||||
className="flex-shrink-0"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
<span className="sr-only">Remove</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
456
components/media/ConversionOptions.tsx
Normal file
456
components/media/ConversionOptions.tsx
Normal file
@@ -0,0 +1,456 @@
|
||||
'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,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import type { ConversionOptions, ConversionFormat } from '@/types/media';
|
||||
|
||||
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 */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-foreground block">Video Codec</label>
|
||||
<Select
|
||||
value={options.videoCodec || 'default'}
|
||||
onValueChange={(value) => handleOptionChange('videoCodec', value === 'default' ? undefined : value)}
|
||||
disabled={disabled}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select video codec" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="default">Auto (Recommended)</SelectItem>
|
||||
<SelectItem value="libx264">H.264 (MP4, AVI, MOV)</SelectItem>
|
||||
<SelectItem value="libx265">H.265 (MP4)</SelectItem>
|
||||
<SelectItem value="libvpx">VP8 (WebM)</SelectItem>
|
||||
<SelectItem value="libvpx-vp9">VP9 (WebM)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Video Bitrate */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-sm font-medium text-foreground">Video Bitrate</label>
|
||||
<span className="text-xs text-muted-foreground">{options.videoBitrate || '2M'}</span>
|
||||
</div>
|
||||
<Slider
|
||||
min={0.5}
|
||||
max={10}
|
||||
step={0.5}
|
||||
value={[parseFloat(options.videoBitrate?.replace('M', '') || '2')]}
|
||||
onValueChange={(vals) => handleOptionChange('videoBitrate', `${vals[0]}M`)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">Higher bitrate = better quality, larger file</p>
|
||||
</div>
|
||||
|
||||
{/* Resolution */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-foreground block">Resolution</label>
|
||||
<Select
|
||||
value={options.videoResolution || 'original'}
|
||||
onValueChange={(value) => handleOptionChange('videoResolution', value === 'original' ? undefined : value)}
|
||||
disabled={disabled}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select resolution" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="original">Original</SelectItem>
|
||||
<SelectItem value="1920x-1">1080p (1920x1080)</SelectItem>
|
||||
<SelectItem value="1280x-1">720p (1280x720)</SelectItem>
|
||||
<SelectItem value="854x-1">480p (854x480)</SelectItem>
|
||||
<SelectItem value="640x-1">360p (640x360)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* FPS */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-foreground block">Frame Rate (FPS)</label>
|
||||
<Select
|
||||
value={options.videoFps?.toString() || 'original'}
|
||||
onValueChange={(value) => handleOptionChange('videoFps', value === 'original' ? undefined : parseInt(value))}
|
||||
disabled={disabled}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select frame rate" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="original">Original</SelectItem>
|
||||
<SelectItem value="60">60 fps</SelectItem>
|
||||
<SelectItem value="30">30 fps</SelectItem>
|
||||
<SelectItem value="24">24 fps</SelectItem>
|
||||
<SelectItem value="15">15 fps</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Audio Bitrate */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-sm font-medium text-foreground">Audio Bitrate</label>
|
||||
<span className="text-xs text-muted-foreground">{options.audioBitrate || '128k'}</span>
|
||||
</div>
|
||||
<Slider
|
||||
min={64}
|
||||
max={320}
|
||||
step={32}
|
||||
value={[parseInt(options.audioBitrate?.replace('k', '') || '128')]}
|
||||
onValueChange={(vals) => handleOptionChange('audioBitrate', `${vals[0]}k`)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderAudioOptions = () => (
|
||||
<div className="space-y-4">
|
||||
{/* Audio Codec */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-foreground block">Audio Codec</label>
|
||||
<Select
|
||||
value={options.audioCodec || 'default'}
|
||||
onValueChange={(value) => handleOptionChange('audioCodec', value === 'default' ? undefined : value)}
|
||||
disabled={disabled}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select audio codec" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="default">Auto (Recommended)</SelectItem>
|
||||
<SelectItem value="libmp3lame">MP3 (LAME)</SelectItem>
|
||||
<SelectItem value="aac">AAC</SelectItem>
|
||||
<SelectItem value="libvorbis">Vorbis (OGG)</SelectItem>
|
||||
<SelectItem value="libopus">Opus</SelectItem>
|
||||
<SelectItem value="flac">FLAC (Lossless)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Bitrate */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-sm font-medium text-foreground">Bitrate</label>
|
||||
<span className="text-xs text-muted-foreground">{options.audioBitrate || '192k'}</span>
|
||||
</div>
|
||||
<Slider
|
||||
min={64}
|
||||
max={320}
|
||||
step={32}
|
||||
value={[parseInt(options.audioBitrate?.replace('k', '') || '192')]}
|
||||
onValueChange={(vals) => handleOptionChange('audioBitrate', `${vals[0]}k`)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Sample Rate */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-foreground block">Sample Rate</label>
|
||||
<Select
|
||||
value={options.audioSampleRate?.toString() || 'original'}
|
||||
onValueChange={(value) => handleOptionChange('audioSampleRate', value === 'original' ? undefined : parseInt(value))}
|
||||
disabled={disabled}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select sample rate" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="original">Original</SelectItem>
|
||||
<SelectItem value="48000">48 kHz (Studio)</SelectItem>
|
||||
<SelectItem value="44100">44.1 kHz (CD Quality)</SelectItem>
|
||||
<SelectItem value="22050">22.05 kHz</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Channels */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-foreground block">Channels</label>
|
||||
<Select
|
||||
value={options.audioChannels?.toString() || 'original'}
|
||||
onValueChange={(value) => handleOptionChange('audioChannels', value === 'original' ? undefined : parseInt(value))}
|
||||
disabled={disabled}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select channels" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="original">Original</SelectItem>
|
||||
<SelectItem value="2">Stereo (2 channels)</SelectItem>
|
||||
<SelectItem value="1">Mono (1 channel)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderImageOptions = () => (
|
||||
<div className="space-y-4">
|
||||
{/* Quality */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-sm font-medium text-foreground">Quality</label>
|
||||
<span className="text-xs text-muted-foreground">{options.imageQuality || 85}%</span>
|
||||
</div>
|
||||
<Slider
|
||||
min={1}
|
||||
max={100}
|
||||
step={1}
|
||||
value={[options.imageQuality || 85]}
|
||||
onValueChange={(vals) => handleOptionChange('imageQuality', vals[0])}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 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>
|
||||
);
|
||||
}
|
||||
294
components/media/ConversionPreview.tsx
Normal file
294
components/media/ConversionPreview.tsx
Normal file
@@ -0,0 +1,294 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { Download, CheckCircle, XCircle, Loader2, Clock, TrendingUp, FileCheck2, ArrowRight, RefreshCw } 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/media/utils/fileUtils';
|
||||
import type { ConversionJob } from '@/types/media';
|
||||
|
||||
export interface ConversionPreviewProps {
|
||||
job: ConversionJob;
|
||||
onDownload?: () => void;
|
||||
onRetry?: () => void;
|
||||
}
|
||||
|
||||
export function ConversionPreview({ job, onDownload, onRetry }: 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} />
|
||||
<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>
|
||||
)}
|
||||
|
||||
{/* 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 */}
|
||||
{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>
|
||||
);
|
||||
}
|
||||
587
components/media/FileConverter.tsx
Normal file
587
components/media/FileConverter.tsx
Normal file
@@ -0,0 +1,587 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { ArrowRight, ArrowDown, Keyboard } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { FileUpload } from './FileUpload';
|
||||
import { FormatSelector } from './FormatSelector';
|
||||
import { ConversionPreview } from './ConversionPreview';
|
||||
import { ConversionOptionsPanel } from './ConversionOptions';
|
||||
import { FileInfo } from './FileInfo';
|
||||
import { FormatPresets } from './FormatPresets';
|
||||
import { toast } from 'sonner';
|
||||
import {
|
||||
SUPPORTED_FORMATS,
|
||||
getFormatByExtension,
|
||||
getFormatByMimeType,
|
||||
getCompatibleFormats,
|
||||
} from '@/lib/media/utils/formatMappings';
|
||||
import { convertWithFFmpeg } from '@/lib/media/converters/ffmpegService';
|
||||
import { convertWithImageMagick } from '@/lib/media/converters/imagemagickService';
|
||||
import { addToHistory } from '@/lib/media/storage/history';
|
||||
import { downloadBlobsAsZip, generateOutputFilename } from '@/lib/media/utils/fileUtils';
|
||||
import { getPresetById, type FormatPreset } from '@/lib/media/utils/formatPresets';
|
||||
import { useKeyboardShortcuts, type KeyboardShortcut } from '@/lib/media/hooks/useKeyboardShortcuts';
|
||||
import { KeyboardShortcutsModal } from '@/components/ui/KeyboardShortcutsModal';
|
||||
import type { ConversionJob, ConversionFormat, ConversionOptions } from '@/types/media';
|
||||
|
||||
export function FileConverter() {
|
||||
const [selectedFiles, setSelectedFiles] = React.useState<File[]>([]);
|
||||
const [inputFormat, setInputFormat] = React.useState<ConversionFormat | undefined>();
|
||||
const [outputFormat, setOutputFormat] = React.useState<ConversionFormat | undefined>();
|
||||
const [compatibleFormats, setCompatibleFormats] = React.useState<ConversionFormat[]>([]);
|
||||
const [conversionJobs, setConversionJobs] = React.useState<ConversionJob[]>([]);
|
||||
const [conversionOptions, setConversionOptions] = React.useState<ConversionOptions>({});
|
||||
const [showShortcutsModal, setShowShortcutsModal] = React.useState(false);
|
||||
|
||||
const fileInputRef = React.useRef<HTMLInputElement>(null);
|
||||
|
||||
// Detect input format when files are selected
|
||||
React.useEffect(() => {
|
||||
if (selectedFiles.length === 0) {
|
||||
setInputFormat(undefined);
|
||||
setOutputFormat(undefined);
|
||||
setCompatibleFormats([]);
|
||||
setConversionJobs([]);
|
||||
return;
|
||||
}
|
||||
|
||||
// Use first file to detect format (assume all files same format for batch)
|
||||
const firstFile = selectedFiles[0];
|
||||
|
||||
// Try to detect format from extension
|
||||
const ext = firstFile.name.split('.').pop()?.toLowerCase();
|
||||
let format = ext ? getFormatByExtension(ext) : undefined;
|
||||
|
||||
// Fallback to MIME type
|
||||
if (!format) {
|
||||
format = getFormatByMimeType(firstFile.type);
|
||||
}
|
||||
|
||||
if (format) {
|
||||
setInputFormat(format);
|
||||
const compatible = getCompatibleFormats(format);
|
||||
setCompatibleFormats(compatible);
|
||||
|
||||
// Auto-select first compatible format
|
||||
if (compatible.length > 0 && !outputFormat) {
|
||||
setOutputFormat(compatible[0]);
|
||||
}
|
||||
|
||||
toast.success(`Detected format: ${format.name} (${selectedFiles.length} file${selectedFiles.length > 1 ? 's' : ''})`);
|
||||
} else {
|
||||
toast.error('Could not detect file format');
|
||||
setInputFormat(undefined);
|
||||
setCompatibleFormats([]);
|
||||
}
|
||||
}, [selectedFiles]);
|
||||
|
||||
const handleConvert = async () => {
|
||||
if (selectedFiles.length === 0 || !inputFormat || !outputFormat) {
|
||||
toast.error('Please select files and output format');
|
||||
return;
|
||||
}
|
||||
|
||||
// Create conversion jobs for all files
|
||||
const jobs: ConversionJob[] = selectedFiles.map((file) => ({
|
||||
id: Math.random().toString(36).substring(7),
|
||||
inputFile: file,
|
||||
inputFormat,
|
||||
outputFormat,
|
||||
options: conversionOptions,
|
||||
status: 'pending',
|
||||
progress: 0,
|
||||
startTime: Date.now(),
|
||||
}));
|
||||
|
||||
setConversionJobs(jobs);
|
||||
|
||||
// Track success/failure counts
|
||||
let successCount = 0;
|
||||
let failureCount = 0;
|
||||
|
||||
// Convert files sequentially
|
||||
for (let i = 0; i < jobs.length; i++) {
|
||||
const job = jobs[i];
|
||||
|
||||
try {
|
||||
// Update job to loading
|
||||
setConversionJobs((prev) =>
|
||||
prev.map((j, idx) => idx === i ? { ...j, status: 'loading' as const } : j)
|
||||
);
|
||||
|
||||
// Update job to processing
|
||||
setConversionJobs((prev) =>
|
||||
prev.map((j, idx) => idx === i ? { ...j, status: 'processing' as const, progress: 10 } : j)
|
||||
);
|
||||
|
||||
// Call appropriate converter
|
||||
let result;
|
||||
|
||||
if (!outputFormat) throw new Error('Output format not selected');
|
||||
|
||||
switch (outputFormat.converter) {
|
||||
case 'ffmpeg':
|
||||
result = await convertWithFFmpeg(job.inputFile, outputFormat.extension, conversionOptions, (progress) => {
|
||||
setConversionJobs((prev) =>
|
||||
prev.map((j, idx) => idx === i ? { ...j, progress } : j)
|
||||
);
|
||||
});
|
||||
break;
|
||||
|
||||
case 'imagemagick':
|
||||
result = await convertWithImageMagick(
|
||||
job.inputFile,
|
||||
outputFormat.extension,
|
||||
conversionOptions,
|
||||
(progress) => {
|
||||
setConversionJobs((prev) =>
|
||||
prev.map((j, idx) => idx === i ? { ...j, progress } : j)
|
||||
);
|
||||
}
|
||||
);
|
||||
break;
|
||||
|
||||
default:
|
||||
throw new Error(`Unknown converter: ${outputFormat.converter}`);
|
||||
}
|
||||
|
||||
// Update job with result
|
||||
if (result.success && result.blob) {
|
||||
successCount++;
|
||||
|
||||
setConversionJobs((prev) =>
|
||||
prev.map((j, idx) => idx === i ? {
|
||||
...j,
|
||||
status: 'completed' as const,
|
||||
progress: 100,
|
||||
result: result.blob,
|
||||
endTime: Date.now(),
|
||||
} : j)
|
||||
);
|
||||
|
||||
// Add to history
|
||||
addToHistory({
|
||||
inputFileName: job.inputFile.name,
|
||||
inputFormat: inputFormat.name,
|
||||
outputFormat: outputFormat.name,
|
||||
outputFileName: `output.${outputFormat.extension}`,
|
||||
fileSize: result.blob.size,
|
||||
result: result.blob,
|
||||
});
|
||||
} else {
|
||||
failureCount++;
|
||||
|
||||
setConversionJobs((prev) =>
|
||||
prev.map((j, idx) => idx === i ? {
|
||||
...j,
|
||||
status: 'error' as const,
|
||||
error: result.error || 'Unknown error',
|
||||
endTime: Date.now(),
|
||||
} : j)
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
failureCount++;
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
|
||||
setConversionJobs((prev) =>
|
||||
prev.map((j, idx) => idx === i ? {
|
||||
...j,
|
||||
status: 'error' as const,
|
||||
error: errorMessage,
|
||||
endTime: Date.now(),
|
||||
} : j)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Show completion message
|
||||
if (successCount === jobs.length) {
|
||||
toast.success(`All ${jobs.length} files converted successfully!`);
|
||||
} else if (successCount > 0) {
|
||||
toast.info(`${successCount}/${jobs.length} files converted successfully`);
|
||||
} else {
|
||||
toast.error('All conversions failed');
|
||||
}
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
setSelectedFiles([]);
|
||||
setInputFormat(undefined);
|
||||
setOutputFormat(undefined);
|
||||
setCompatibleFormats([]);
|
||||
setConversionJobs([]);
|
||||
setConversionOptions({});
|
||||
};
|
||||
|
||||
const handleFileSelect = (files: File[]) => {
|
||||
setSelectedFiles((prev) => [...prev, ...files]);
|
||||
};
|
||||
|
||||
const handleFileRemove = (index: number) => {
|
||||
setSelectedFiles((prev) => prev.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
const handlePresetSelect = (preset: FormatPreset) => {
|
||||
// Find the output format that matches the preset
|
||||
const format = compatibleFormats.find(f => f.extension === preset.outputFormat);
|
||||
|
||||
if (format) {
|
||||
setOutputFormat(format);
|
||||
setConversionOptions(preset.options);
|
||||
toast.success(`Applied ${preset.name} preset`);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDownloadAll = async () => {
|
||||
if (!outputFormat) return;
|
||||
|
||||
const completedJobs = conversionJobs.filter(job => job.status === 'completed' && job.result);
|
||||
|
||||
if (completedJobs.length === 0) {
|
||||
toast.error('No files to download');
|
||||
return;
|
||||
}
|
||||
|
||||
if (completedJobs.length === 1) {
|
||||
// Just download the single file
|
||||
const job = completedJobs[0];
|
||||
const filename = generateOutputFilename(job.inputFile.name, outputFormat.extension);
|
||||
const url = URL.createObjectURL(job.result!);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
return;
|
||||
}
|
||||
|
||||
// Download multiple files as ZIP
|
||||
const files = completedJobs.map(job => ({
|
||||
blob: job.result!,
|
||||
filename: generateOutputFilename(job.inputFile.name, outputFormat.extension),
|
||||
}));
|
||||
|
||||
await downloadBlobsAsZip(files, `converted-files.zip`);
|
||||
toast.success(`Downloaded ${files.length} files as ZIP`);
|
||||
};
|
||||
|
||||
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)
|
||||
);
|
||||
|
||||
toast.success('Conversion completed successfully!');
|
||||
|
||||
// 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)
|
||||
);
|
||||
|
||||
toast.error(result.error || 'Retry failed');
|
||||
}
|
||||
} 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)
|
||||
);
|
||||
|
||||
toast.error(`Retry failed: ${errorMessage}`);
|
||||
}
|
||||
};
|
||||
|
||||
const isConverting = conversionJobs.some(job => job.status === 'loading' || job.status === 'processing');
|
||||
const isConvertDisabled = selectedFiles.length === 0 || !outputFormat || isConverting;
|
||||
const completedCount = conversionJobs.filter(job => job.status === 'completed').length;
|
||||
|
||||
// Define keyboard shortcuts
|
||||
const shortcuts: KeyboardShortcut[] = [
|
||||
{
|
||||
key: 'o',
|
||||
ctrl: true,
|
||||
description: 'Open file dialog',
|
||||
action: () => {
|
||||
if (!isConverting) {
|
||||
fileInputRef.current?.click();
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'Enter',
|
||||
ctrl: true,
|
||||
description: 'Start conversion',
|
||||
action: () => {
|
||||
if (!isConvertDisabled) {
|
||||
handleConvert();
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 's',
|
||||
ctrl: true,
|
||||
description: 'Download results',
|
||||
action: () => {
|
||||
if (completedCount > 0) {
|
||||
handleDownloadAll();
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'r',
|
||||
ctrl: true,
|
||||
description: 'Reset converter',
|
||||
action: () => {
|
||||
if (!isConverting) {
|
||||
handleReset();
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
key: '/',
|
||||
ctrl: true,
|
||||
description: 'Show keyboard shortcuts',
|
||||
action: () => setShowShortcutsModal(true),
|
||||
},
|
||||
{
|
||||
key: 'Escape',
|
||||
description: 'Close shortcuts modal',
|
||||
action: () => setShowShortcutsModal(false),
|
||||
},
|
||||
{
|
||||
key: '?',
|
||||
description: 'Show keyboard shortcuts',
|
||||
action: () => setShowShortcutsModal(true),
|
||||
},
|
||||
];
|
||||
|
||||
// Enable keyboard shortcuts
|
||||
useKeyboardShortcuts(shortcuts, !showShortcutsModal);
|
||||
|
||||
return (
|
||||
<div className="w-full max-w-4xl mx-auto space-y-6">
|
||||
{/* Header */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>File Converter</CardTitle>
|
||||
<CardDescription>
|
||||
Convert videos, audio, and images directly in your browser using WebAssembly
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
{/* File upload */}
|
||||
<FileUpload
|
||||
onFileSelect={handleFileSelect}
|
||||
onFileRemove={handleFileRemove}
|
||||
selectedFiles={selectedFiles}
|
||||
disabled={isConverting}
|
||||
inputRef={fileInputRef}
|
||||
/>
|
||||
|
||||
{/* File Info - show first file */}
|
||||
{selectedFiles.length > 0 && inputFormat && (
|
||||
<FileInfo file={selectedFiles[0]} format={inputFormat} />
|
||||
)}
|
||||
|
||||
{/* Format Presets */}
|
||||
{inputFormat && (
|
||||
<FormatPresets
|
||||
inputFormat={inputFormat}
|
||||
onPresetSelect={handlePresetSelect}
|
||||
disabled={isConverting}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Format selection */}
|
||||
{inputFormat && compatibleFormats.length > 0 && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-[1fr_auto_1fr] gap-4 items-start">
|
||||
{/* Input format */}
|
||||
<div>
|
||||
<label className="text-sm font-medium text-foreground mb-2 block">Input Format</label>
|
||||
<Card className="p-4">
|
||||
<p className="font-medium">{inputFormat.name}</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">{inputFormat.description}</p>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Arrow - horizontal on desktop, vertical on mobile */}
|
||||
<div className="flex md:hidden items-center justify-center py-2">
|
||||
<ArrowDown className="h-5 w-5 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="hidden md:flex items-center justify-center pt-8">
|
||||
<ArrowRight className="h-5 w-5 text-muted-foreground" />
|
||||
</div>
|
||||
|
||||
{/* Output format */}
|
||||
<FormatSelector
|
||||
formats={compatibleFormats}
|
||||
selectedFormat={outputFormat}
|
||||
onFormatSelect={setOutputFormat}
|
||||
label="Output Format"
|
||||
disabled={isConverting}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Conversion Options */}
|
||||
{inputFormat && outputFormat && (
|
||||
<ConversionOptionsPanel
|
||||
inputFormat={inputFormat}
|
||||
outputFormat={outputFormat}
|
||||
options={conversionOptions}
|
||||
onOptionsChange={setConversionOptions}
|
||||
disabled={isConverting}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Convert button */}
|
||||
{inputFormat && outputFormat && (
|
||||
<div className="flex gap-3">
|
||||
<Button
|
||||
onClick={handleConvert}
|
||||
disabled={isConvertDisabled}
|
||||
className="flex-1"
|
||||
size="lg"
|
||||
>
|
||||
{isConverting
|
||||
? 'Converting...'
|
||||
: `Convert ${selectedFiles.length} File${selectedFiles.length > 1 ? 's' : ''}`}
|
||||
</Button>
|
||||
<Button onClick={handleReset} variant="outline" size="lg">
|
||||
Reset
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Download All Button */}
|
||||
{completedCount > 0 && (
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<Button
|
||||
onClick={handleDownloadAll}
|
||||
className="w-full"
|
||||
size="lg"
|
||||
variant="default"
|
||||
>
|
||||
Download All ({completedCount} file{completedCount > 1 ? 's' : ''})
|
||||
{completedCount > 1 && ' as ZIP'}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Conversion previews */}
|
||||
{conversionJobs.length > 0 && (
|
||||
<div className="space-y-4">
|
||||
{conversionJobs.map((job) => (
|
||||
<ConversionPreview
|
||||
key={job.id}
|
||||
job={job}
|
||||
onRetry={() => handleRetry(job.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Keyboard Shortcuts Button */}
|
||||
<Button
|
||||
onClick={() => setShowShortcutsModal(true)}
|
||||
className="fixed bottom-6 right-6 rounded-full w-12 h-12 p-0 shadow-lg"
|
||||
title="Keyboard Shortcuts (Ctrl+/)"
|
||||
>
|
||||
<Keyboard className="h-5 w-5" />
|
||||
</Button>
|
||||
|
||||
{/* Keyboard Shortcuts Modal */}
|
||||
<KeyboardShortcutsModal
|
||||
shortcuts={shortcuts}
|
||||
isOpen={showShortcutsModal}
|
||||
onClose={() => setShowShortcutsModal(false)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
210
components/media/FileInfo.tsx
Normal file
210
components/media/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/media';
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
178
components/media/FileUpload.tsx
Normal file
178
components/media/FileUpload.tsx
Normal file
@@ -0,0 +1,178 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { Upload, X } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils/cn';
|
||||
import { formatFileSize } from '@/lib/media/utils/fileUtils';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
export interface FileUploadProps {
|
||||
onFileSelect: (files: File[]) => void;
|
||||
onFileRemove: (index: number) => void;
|
||||
selectedFiles?: File[];
|
||||
accept?: string;
|
||||
maxSizeMB?: number;
|
||||
disabled?: boolean;
|
||||
inputRef?: React.RefObject<HTMLInputElement | null>;
|
||||
}
|
||||
|
||||
export function FileUpload({
|
||||
onFileSelect,
|
||||
onFileRemove,
|
||||
selectedFiles = [],
|
||||
accept,
|
||||
maxSizeMB = 500,
|
||||
disabled = false,
|
||||
inputRef,
|
||||
}: FileUploadProps) {
|
||||
const [isDragging, setIsDragging] = React.useState(false);
|
||||
const fileInputRef = inputRef || React.useRef<HTMLInputElement>(null);
|
||||
|
||||
const handleDragEnter = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (!disabled) {
|
||||
setIsDragging(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDragLeave = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDragging(false);
|
||||
};
|
||||
|
||||
const handleDragOver = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
};
|
||||
|
||||
const handleDrop = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDragging(false);
|
||||
|
||||
if (disabled) return;
|
||||
|
||||
const files = Array.from(e.dataTransfer.files);
|
||||
if (files.length > 0) {
|
||||
handleFiles(files);
|
||||
}
|
||||
};
|
||||
|
||||
const handleFileInput = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = Array.from(e.target.files || []);
|
||||
if (files.length > 0) {
|
||||
handleFiles(files);
|
||||
}
|
||||
};
|
||||
|
||||
const handleFiles = (files: File[]) => {
|
||||
// Check file sizes
|
||||
const maxBytes = maxSizeMB * 1024 * 1024;
|
||||
const validFiles = files.filter(file => {
|
||||
if (file.size > maxBytes) {
|
||||
alert(`${file.name} exceeds ${maxSizeMB}MB limit and will be skipped.`);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
if (validFiles.length > 0) {
|
||||
onFileSelect(validFiles);
|
||||
}
|
||||
|
||||
// Reset input
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
const handleClick = () => {
|
||||
if (!disabled) {
|
||||
fileInputRef.current?.click();
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemove = (index: number) => (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
onFileRemove(index);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full space-y-3">
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
className="hidden"
|
||||
accept={accept}
|
||||
onChange={handleFileInput}
|
||||
disabled={disabled}
|
||||
/>
|
||||
|
||||
{selectedFiles.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{selectedFiles.map((file, index) => (
|
||||
<div key={`${file.name}-${index}`} className="border-2 border-border rounded-lg p-4 bg-card">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-foreground truncate">{file.name}</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{formatFileSize(file.size)}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={handleRemove(index)}
|
||||
disabled={disabled}
|
||||
className="ml-4"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Remove file</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Add more files button */}
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleClick}
|
||||
disabled={disabled}
|
||||
className="w-full"
|
||||
>
|
||||
<Upload className="h-4 w-4 mr-2" />
|
||||
Add More Files
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
onClick={handleClick}
|
||||
onDragEnter={handleDragEnter}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
className={cn(
|
||||
'border-2 border-dashed rounded-lg p-12 text-center cursor-pointer transition-colors',
|
||||
'hover:border-primary hover:bg-primary/5',
|
||||
{
|
||||
'border-primary bg-primary/10': isDragging,
|
||||
'border-border bg-background': !isDragging,
|
||||
'opacity-50 cursor-not-allowed': disabled,
|
||||
}
|
||||
)}
|
||||
>
|
||||
<Upload className="mx-auto h-12 w-12 text-muted-foreground mb-4" />
|
||||
<p className="text-sm font-medium text-foreground mb-1">
|
||||
Drop your files here or click to browse
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Maximum file size: {maxSizeMB}MB per file
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
67
components/media/FormatPresets.tsx
Normal file
67
components/media/FormatPresets.tsx
Normal file
@@ -0,0 +1,67 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { Sparkles } from 'lucide-react';
|
||||
import { Card } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { cn } from '@/lib/utils/cn';
|
||||
import { getPresetsByCategory, type FormatPreset } from '@/lib/media/utils/formatPresets';
|
||||
import type { ConversionFormat } from '@/types/media';
|
||||
|
||||
interface FormatPresetsProps {
|
||||
inputFormat: ConversionFormat;
|
||||
onPresetSelect: (preset: FormatPreset) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export function FormatPresets({ inputFormat, onPresetSelect, disabled = false }: FormatPresetsProps) {
|
||||
const [selectedPresetId, setSelectedPresetId] = React.useState<string | null>(null);
|
||||
|
||||
// Get presets for the input format's category
|
||||
const presets = getPresetsByCategory(inputFormat.category);
|
||||
|
||||
if (presets.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const handlePresetClick = (preset: FormatPreset) => {
|
||||
setSelectedPresetId(preset.id);
|
||||
onPresetSelect(preset);
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="p-4">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<Sparkles className="h-5 w-5 text-primary" />
|
||||
<h3 className="text-sm font-semibold">Quick Presets</h3>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||
{presets.map((preset) => (
|
||||
<Button
|
||||
key={preset.id}
|
||||
onClick={() => handlePresetClick(preset)}
|
||||
variant={selectedPresetId === preset.id ? 'default' : 'outline'}
|
||||
className={cn(
|
||||
'h-auto py-4 px-4 flex flex-col items-start text-left gap-2',
|
||||
selectedPresetId === preset.id && 'ring-2 ring-primary ring-offset-2'
|
||||
)}
|
||||
disabled={disabled}
|
||||
>
|
||||
<div className="flex items-center gap-2 w-full">
|
||||
<span className="text-2xl">{preset.icon}</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-semibold text-sm">{preset.name}</div>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground w-full">{preset.description}</p>
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-3 text-xs text-muted-foreground">
|
||||
Select a preset to automatically configure optimal settings for your use case
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
136
components/media/FormatSelector.tsx
Normal file
136
components/media/FormatSelector.tsx
Normal file
@@ -0,0 +1,136 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import Fuse from 'fuse.js';
|
||||
import { Search } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils/cn';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Card } from '@/components/ui/card';
|
||||
import type { ConversionFormat } from '@/types/media';
|
||||
|
||||
export interface FormatSelectorProps {
|
||||
formats: ConversionFormat[];
|
||||
selectedFormat?: ConversionFormat;
|
||||
onFormatSelect: (format: ConversionFormat) => void;
|
||||
label?: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export function FormatSelector({
|
||||
formats,
|
||||
selectedFormat,
|
||||
onFormatSelect,
|
||||
label = 'Select format',
|
||||
disabled = false,
|
||||
}: FormatSelectorProps) {
|
||||
const [searchQuery, setSearchQuery] = React.useState('');
|
||||
const [filteredFormats, setFilteredFormats] = React.useState<ConversionFormat[]>(formats);
|
||||
|
||||
// Set up Fuse.js for fuzzy search
|
||||
const fuse = React.useMemo(() => {
|
||||
return new Fuse(formats, {
|
||||
keys: ['name', 'extension', 'description'],
|
||||
threshold: 0.3,
|
||||
includeScore: true,
|
||||
});
|
||||
}, [formats]);
|
||||
|
||||
// Filter formats based on search query
|
||||
React.useEffect(() => {
|
||||
if (!searchQuery.trim()) {
|
||||
setFilteredFormats(formats);
|
||||
return;
|
||||
}
|
||||
|
||||
const results = fuse.search(searchQuery);
|
||||
setFilteredFormats(results.map((result) => result.item));
|
||||
}, [searchQuery, formats, fuse]);
|
||||
|
||||
// Group formats by category
|
||||
const groupedFormats = React.useMemo(() => {
|
||||
const groups: Record<string, ConversionFormat[]> = {};
|
||||
|
||||
filteredFormats.forEach((format) => {
|
||||
if (!groups[format.category]) {
|
||||
groups[format.category] = [];
|
||||
}
|
||||
groups[format.category].push(format);
|
||||
});
|
||||
|
||||
return groups;
|
||||
}, [filteredFormats]);
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
<label className="text-sm font-medium text-foreground mb-2 block">{label}</label>
|
||||
|
||||
{/* Search input */}
|
||||
<div className="relative mb-3">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Search formats..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
disabled={disabled}
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Format list */}
|
||||
<Card className="max-h-64 overflow-y-auto custom-scrollbar">
|
||||
{Object.entries(groupedFormats).length === 0 ? (
|
||||
<div className="p-4 text-center text-sm text-muted-foreground">
|
||||
No formats found matching "{searchQuery}"
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-2">
|
||||
{Object.entries(groupedFormats).map(([category, categoryFormats]) => (
|
||||
<div key={category} className="mb-3 last:mb-0">
|
||||
<h3 className="text-xs font-semibold text-muted-foreground uppercase tracking-wide mb-2 px-2">
|
||||
{category}
|
||||
</h3>
|
||||
<div className="space-y-1">
|
||||
{categoryFormats.map((format) => (
|
||||
<button
|
||||
key={format.id}
|
||||
onClick={() => !disabled && onFormatSelect(format)}
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
'w-full text-left px-3 py-2 rounded-md transition-colors',
|
||||
'hover:bg-accent hover:text-accent-foreground',
|
||||
'disabled:opacity-50 disabled:cursor-not-allowed',
|
||||
{
|
||||
'bg-primary text-primary-foreground hover:bg-primary/90':
|
||||
selectedFormat?.id === format.id,
|
||||
}
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium">{format.name}</p>
|
||||
{format.description && (
|
||||
<p className="text-xs opacity-75 mt-0.5">{format.description}</p>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-xs font-mono opacity-75">.{format.extension}</span>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Selected format display */}
|
||||
{selectedFormat && (
|
||||
<div className="mt-2 text-xs text-muted-foreground">
|
||||
Selected: <span className="font-medium text-foreground">{selectedFormat.name}</span> (.
|
||||
{selectedFormat.extension})
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user