feat: initialize Convert UI - browser-based file conversion app
- Add Next.js 16 with Turbopack and React 19 - Add Tailwind CSS 4 with OKLCH color system - Implement FFmpeg.wasm for video/audio conversion - Implement ImageMagick WASM for image conversion - Add file upload with drag-and-drop - Add format selector with fuzzy search - Add conversion preview and download - Add conversion history with localStorage - Add dark/light theme support - Support 22+ file formats across video, audio, and images 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
170
components/converter/ConversionPreview.tsx
Normal file
170
components/converter/ConversionPreview.tsx
Normal file
@@ -0,0 +1,170 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { Download, CheckCircle, XCircle, Loader2 } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils/cn';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/Card';
|
||||
import { Progress } from '@/components/ui/Progress';
|
||||
import { downloadBlob, formatFileSize, generateOutputFilename } from '@/lib/utils/fileUtils';
|
||||
import type { ConversionJob } from '@/types/conversion';
|
||||
|
||||
export interface ConversionPreviewProps {
|
||||
job: ConversionJob;
|
||||
onDownload?: () => void;
|
||||
}
|
||||
|
||||
export function ConversionPreview({ job, onDownload }: ConversionPreviewProps) {
|
||||
const [previewUrl, setPreviewUrl] = React.useState<string | null>(null);
|
||||
|
||||
// Create preview URL for result
|
||||
React.useEffect(() => {
|
||||
if (job.result && job.status === 'completed') {
|
||||
const url = URL.createObjectURL(job.result);
|
||||
setPreviewUrl(url);
|
||||
|
||||
return () => {
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
}
|
||||
}, [job.result, job.status]);
|
||||
|
||||
const handleDownload = () => {
|
||||
if (job.result) {
|
||||
const filename = generateOutputFilename(job.inputFile.name, job.outputFormat.extension);
|
||||
downloadBlob(job.result, filename);
|
||||
onDownload?.();
|
||||
}
|
||||
};
|
||||
|
||||
const renderPreview = () => {
|
||||
if (!previewUrl || !job.result) return null;
|
||||
|
||||
const category = job.outputFormat.category;
|
||||
|
||||
switch (category) {
|
||||
case 'image':
|
||||
return (
|
||||
<div className="mt-4 rounded-lg overflow-hidden bg-muted/30 flex items-center justify-center p-4">
|
||||
<img
|
||||
src={previewUrl}
|
||||
alt="Converted image preview"
|
||||
className="max-w-full max-h-64 object-contain"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'video':
|
||||
return (
|
||||
<div className="mt-4 rounded-lg overflow-hidden bg-muted/30">
|
||||
<video src={previewUrl} controls className="w-full max-h-64">
|
||||
Your browser does not support video playback.
|
||||
</video>
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'audio':
|
||||
return (
|
||||
<div className="mt-4 rounded-lg overflow-hidden bg-muted/30 p-4">
|
||||
<audio src={previewUrl} controls className="w-full">
|
||||
Your browser does not support audio playback.
|
||||
</audio>
|
||||
</div>
|
||||
);
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const renderStatus = () => {
|
||||
switch (job.status) {
|
||||
case 'loading':
|
||||
return (
|
||||
<div className="flex items-center gap-2 text-info">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
<span className="text-sm font-medium">Loading converter...</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'processing':
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center gap-2 text-info mb-2">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
<span className="text-sm font-medium">Converting...</span>
|
||||
</div>
|
||||
<Progress value={job.progress} showLabel />
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'completed':
|
||||
return (
|
||||
<div className="flex items-center gap-2 text-success">
|
||||
<CheckCircle className="h-4 w-4" />
|
||||
<span className="text-sm font-medium">Conversion complete!</span>
|
||||
{job.result && (
|
||||
<span className="text-xs text-muted-foreground ml-auto">
|
||||
{formatFileSize(job.result.size)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'error':
|
||||
return (
|
||||
<div className="flex items-center gap-2 text-destructive">
|
||||
<XCircle className="h-4 w-4" />
|
||||
<span className="text-sm font-medium">Conversion failed</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
if (job.status === 'pending') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="animate-fadeIn">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">Conversion Status</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
{/* Status */}
|
||||
{renderStatus()}
|
||||
|
||||
{/* Error message */}
|
||||
{job.error && (
|
||||
<div className="bg-destructive/10 border border-destructive/20 rounded-md p-3">
|
||||
<p className="text-sm text-destructive">{job.error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Preview */}
|
||||
{job.status === 'completed' && renderPreview()}
|
||||
|
||||
{/* Download button */}
|
||||
{job.status === 'completed' && job.result && (
|
||||
<Button onClick={handleDownload} className="w-full gap-2">
|
||||
<Download className="h-4 w-4" />
|
||||
Download{' '}
|
||||
{generateOutputFilename(job.inputFile.name, job.outputFormat.extension)}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Duration */}
|
||||
{job.status === 'completed' && job.startTime && job.endTime && (
|
||||
<p className="text-xs text-muted-foreground text-center">
|
||||
Completed in {((job.endTime - job.startTime) / 1000).toFixed(2)}s
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
252
components/converter/FileConverter.tsx
Normal file
252
components/converter/FileConverter.tsx
Normal file
@@ -0,0 +1,252 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { ArrowRight } 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 { useToast } from '@/components/ui/Toast';
|
||||
import {
|
||||
SUPPORTED_FORMATS,
|
||||
getFormatByExtension,
|
||||
getFormatByMimeType,
|
||||
getCompatibleFormats,
|
||||
} from '@/lib/utils/formatMappings';
|
||||
import { convertWithFFmpeg } from '@/lib/converters/ffmpegService';
|
||||
import { convertWithImageMagick } from '@/lib/converters/imagemagickService';
|
||||
import { convertWithPandoc } from '@/lib/converters/pandocService';
|
||||
import { addToHistory } from '@/lib/storage/history';
|
||||
import type { ConversionJob, ConversionFormat } from '@/types/conversion';
|
||||
|
||||
export function FileConverter() {
|
||||
const { addToast } = useToast();
|
||||
|
||||
const [selectedFile, setSelectedFile] = React.useState<File | undefined>();
|
||||
const [inputFormat, setInputFormat] = React.useState<ConversionFormat | undefined>();
|
||||
const [outputFormat, setOutputFormat] = React.useState<ConversionFormat | undefined>();
|
||||
const [compatibleFormats, setCompatibleFormats] = React.useState<ConversionFormat[]>([]);
|
||||
const [conversionJob, setConversionJob] = React.useState<ConversionJob | undefined>();
|
||||
|
||||
// Detect input format when file is selected
|
||||
React.useEffect(() => {
|
||||
if (!selectedFile) {
|
||||
setInputFormat(undefined);
|
||||
setOutputFormat(undefined);
|
||||
setCompatibleFormats([]);
|
||||
setConversionJob(undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
// Try to detect format from extension
|
||||
const ext = selectedFile.name.split('.').pop()?.toLowerCase();
|
||||
let format = ext ? getFormatByExtension(ext) : undefined;
|
||||
|
||||
// Fallback to MIME type
|
||||
if (!format) {
|
||||
format = getFormatByMimeType(selectedFile.type);
|
||||
}
|
||||
|
||||
if (format) {
|
||||
setInputFormat(format);
|
||||
const compatible = getCompatibleFormats(format);
|
||||
setCompatibleFormats(compatible);
|
||||
|
||||
// Auto-select first compatible format
|
||||
if (compatible.length > 0 && !outputFormat) {
|
||||
setOutputFormat(compatible[0]);
|
||||
}
|
||||
|
||||
addToast(`Detected format: ${format.name}`, 'success');
|
||||
} else {
|
||||
addToast('Could not detect file format', 'error');
|
||||
setInputFormat(undefined);
|
||||
setCompatibleFormats([]);
|
||||
}
|
||||
}, [selectedFile]);
|
||||
|
||||
const handleConvert = async () => {
|
||||
if (!selectedFile || !inputFormat || !outputFormat) {
|
||||
addToast('Please select a file and output format', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
// Create conversion job
|
||||
const job: ConversionJob = {
|
||||
id: Math.random().toString(36).substring(7),
|
||||
inputFile: selectedFile,
|
||||
inputFormat,
|
||||
outputFormat,
|
||||
options: {},
|
||||
status: 'loading',
|
||||
progress: 0,
|
||||
startTime: Date.now(),
|
||||
};
|
||||
|
||||
setConversionJob(job);
|
||||
|
||||
try {
|
||||
// Update status to processing
|
||||
setConversionJob((prev) => prev && { ...prev, status: 'processing', progress: 10 });
|
||||
|
||||
// Call appropriate converter
|
||||
let result;
|
||||
|
||||
switch (outputFormat.converter) {
|
||||
case 'ffmpeg':
|
||||
result = await convertWithFFmpeg(selectedFile, outputFormat.extension, {}, (progress) => {
|
||||
setConversionJob((prev) => prev && { ...prev, progress });
|
||||
});
|
||||
break;
|
||||
|
||||
case 'imagemagick':
|
||||
result = await convertWithImageMagick(
|
||||
selectedFile,
|
||||
outputFormat.extension,
|
||||
{},
|
||||
(progress) => {
|
||||
setConversionJob((prev) => prev && { ...prev, progress });
|
||||
}
|
||||
);
|
||||
break;
|
||||
|
||||
case 'pandoc':
|
||||
result = await convertWithPandoc(selectedFile, outputFormat.extension, {}, (progress) => {
|
||||
setConversionJob((prev) => prev && { ...prev, progress });
|
||||
});
|
||||
break;
|
||||
|
||||
default:
|
||||
throw new Error(`Unknown converter: ${outputFormat.converter}`);
|
||||
}
|
||||
|
||||
// Update job with result
|
||||
if (result.success && result.blob) {
|
||||
setConversionJob((prev) => prev && {
|
||||
...prev,
|
||||
status: 'completed',
|
||||
progress: 100,
|
||||
result: result.blob,
|
||||
endTime: Date.now(),
|
||||
});
|
||||
|
||||
addToast('Conversion completed successfully!', 'success');
|
||||
|
||||
// Add to history
|
||||
addToHistory({
|
||||
inputFileName: selectedFile.name,
|
||||
inputFormat: inputFormat.name,
|
||||
outputFormat: outputFormat.name,
|
||||
outputFileName: `output.${outputFormat.extension}`,
|
||||
fileSize: result.blob.size,
|
||||
result: result.blob,
|
||||
});
|
||||
} else {
|
||||
setConversionJob((prev) => prev && {
|
||||
...prev,
|
||||
status: 'error',
|
||||
error: result.error || 'Unknown error',
|
||||
endTime: Date.now(),
|
||||
});
|
||||
|
||||
addToast(result.error || 'Conversion failed', 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
|
||||
setConversionJob((prev) => prev && {
|
||||
...prev,
|
||||
status: 'error',
|
||||
error: errorMessage,
|
||||
endTime: Date.now(),
|
||||
});
|
||||
|
||||
addToast(`Conversion failed: ${errorMessage}`, 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
setSelectedFile(undefined);
|
||||
setInputFormat(undefined);
|
||||
setOutputFormat(undefined);
|
||||
setCompatibleFormats([]);
|
||||
setConversionJob(undefined);
|
||||
};
|
||||
|
||||
const isConvertDisabled =
|
||||
!selectedFile || !outputFormat || conversionJob?.status === 'loading' || conversionJob?.status === 'processing';
|
||||
|
||||
return (
|
||||
<div className="w-full max-w-4xl mx-auto space-y-6">
|
||||
{/* Header */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>File Converter</CardTitle>
|
||||
<CardDescription>
|
||||
Convert videos, images, and documents directly in your browser using WebAssembly
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
{/* File upload */}
|
||||
<FileUpload
|
||||
onFileSelect={setSelectedFile}
|
||||
onFileRemove={handleReset}
|
||||
selectedFile={selectedFile}
|
||||
disabled={conversionJob?.status === 'processing' || conversionJob?.status === 'loading'}
|
||||
/>
|
||||
|
||||
{/* 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 */}
|
||||
<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={conversionJob?.status === 'processing' || conversionJob?.status === 'loading'}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Convert button */}
|
||||
{inputFormat && outputFormat && (
|
||||
<div className="flex gap-3">
|
||||
<Button
|
||||
onClick={handleConvert}
|
||||
disabled={isConvertDisabled}
|
||||
className="flex-1"
|
||||
size="lg"
|
||||
>
|
||||
{conversionJob?.status === 'loading' || conversionJob?.status === 'processing'
|
||||
? 'Converting...'
|
||||
: 'Convert'}
|
||||
</Button>
|
||||
<Button onClick={handleReset} variant="outline" size="lg">
|
||||
Reset
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Conversion preview */}
|
||||
{conversionJob && <ConversionPreview job={conversionJob} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
153
components/converter/FileUpload.tsx
Normal file
153
components/converter/FileUpload.tsx
Normal file
@@ -0,0 +1,153 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { Upload, X } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils/cn';
|
||||
import { formatFileSize } from '@/lib/utils/fileUtils';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
|
||||
export interface FileUploadProps {
|
||||
onFileSelect: (file: File) => void;
|
||||
onFileRemove: () => void;
|
||||
selectedFile?: File;
|
||||
accept?: string;
|
||||
maxSizeMB?: number;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export function FileUpload({
|
||||
onFileSelect,
|
||||
onFileRemove,
|
||||
selectedFile,
|
||||
accept,
|
||||
maxSizeMB = 500,
|
||||
disabled = false,
|
||||
}: FileUploadProps) {
|
||||
const [isDragging, setIsDragging] = React.useState(false);
|
||||
const fileInputRef = 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) {
|
||||
handleFile(files[0]);
|
||||
}
|
||||
};
|
||||
|
||||
const handleFileInput = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = Array.from(e.target.files || []);
|
||||
if (files.length > 0) {
|
||||
handleFile(files[0]);
|
||||
}
|
||||
};
|
||||
|
||||
const handleFile = (file: File) => {
|
||||
// Check file size
|
||||
const maxBytes = maxSizeMB * 1024 * 1024;
|
||||
if (file.size > maxBytes) {
|
||||
alert(`File size exceeds ${maxSizeMB}MB limit. Please choose a smaller file.`);
|
||||
return;
|
||||
}
|
||||
|
||||
onFileSelect(file);
|
||||
};
|
||||
|
||||
const handleClick = () => {
|
||||
if (!disabled) {
|
||||
fileInputRef.current?.click();
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemove = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
onFileRemove();
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
className="hidden"
|
||||
accept={accept}
|
||||
onChange={handleFileInput}
|
||||
disabled={disabled}
|
||||
/>
|
||||
|
||||
{selectedFile ? (
|
||||
<div className="border-2 border-border rounded-lg p-6 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">{selectedFile.name}</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{formatFileSize(selectedFile.size)}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={handleRemove}
|
||||
disabled={disabled}
|
||||
className="ml-4"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Remove file</span>
|
||||
</Button>
|
||||
</div>
|
||||
</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 file here or click to browse
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Maximum file size: {maxSizeMB}MB
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
136
components/converter/FormatSelector.tsx
Normal file
136
components/converter/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/conversion';
|
||||
|
||||
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