From 2763b76abe41ceb79d1c82bea145cce30b19ef34 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Kr=C3=BCger?= Date: Sun, 1 Mar 2026 08:22:17 +0100 Subject: [PATCH] refactor: refactor media tool to match calculate blueprint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rewrites all three media components to use the glass panel design language, fixed-height two-panel layout, and glass action buttons. - FileConverter: lg:grid-cols-5 split — left 2/5 is the upload zone; right 3/5 has output format pill grid + codec/quality options + convert/reset buttons + scrollable results panel; mobile 'Upload | Convert' tab switcher auto-advances on file selection; removed all Card/Button/Label/Input shadcn imports; keeps Select+Slider for codec/quality controls - FileUpload: large flex-1 drop zone fills the left panel; file list shows glass item cards with metadata chips; native buttons; removes shadcn Button dependency - ConversionPreview: glass card replaces Card; native progress bar (div with bg-primary/65) replaces shadcn Progress; size reduction shown as emerald/muted badge; media previews in dark-bordered containers; all buttons are glass action buttons Co-Authored-By: Claude Sonnet 4.6 --- components/media/ConversionPreview.tsx | 358 ++++----- components/media/FileConverter.tsx | 1028 ++++++++++-------------- components/media/FileUpload.tsx | 392 +++------ 3 files changed, 708 insertions(+), 1070 deletions(-) diff --git a/components/media/ConversionPreview.tsx b/components/media/ConversionPreview.tsx index 714a361..2a259e1 100644 --- a/components/media/ConversionPreview.tsx +++ b/components/media/ConversionPreview.tsx @@ -3,59 +3,45 @@ import * as React from 'react'; import { Download, CheckCircle, XCircle, Loader2, Clock, TrendingUp, 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 actionBtn = + 'flex items-center justify-center gap-1.5 px-3 py-1.5 text-xs glass rounded-md border border-border/30 text-muted-foreground hover:text-primary hover:border-primary/30 hover:bg-primary/10 transition-all disabled:opacity-40 disabled:cursor-not-allowed'; + +export function ConversionPreview({ job, onRetry }: ConversionPreviewProps) { const [previewUrl, setPreviewUrl] = React.useState(null); const [elapsedTime, setElapsedTime] = React.useState(0); - const [estimatedTimeRemaining, setEstimatedTimeRemaining] = React.useState(null); + const [estimatedRemaining, setEstimatedRemaining] = React.useState(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); + const rate = job.progress / elapsed; + setEstimatedRemaining((100 - job.progress) / rate); } } }, 100); - return () => clearInterval(interval); } else { - setEstimatedTimeRemaining(null); + setEstimatedRemaining(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); - }; + return () => URL.revokeObjectURL(url); } else { setPreviewUrl(null); } @@ -63,215 +49,151 @@ export function ConversionPreview({ job, onDownload, onRetry }: ConversionPrevie const handleDownload = () => { if (job.result) { - const filename = generateOutputFilename(job.inputFile.name, job.outputFormat.extension); - downloadBlob(job.result, filename); - onDownload?.(); + downloadBlob(job.result, generateOutputFilename(job.inputFile.name, job.outputFormat.extension)); } }; - 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 ( -
- Converted image preview { - 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'); - }} - /> -
- ); - - case 'video': - return ( -
- -
- ); - - case 'audio': - return ( -
- -
- ); - - default: - return null; - } + const fmt = (ms: number) => { + const s = Math.floor(ms / 1000); + if (s < 60) return `${s}s`; + return `${Math.floor(s / 60)}m ${s % 60}s`; }; - 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`; - }; + if (job.status === 'pending') return null; - const renderStatus = () => { - switch (job.status) { - case 'loading': - return ( -
-
- - Loading converter... -
-
- - {formatTime(elapsedTime)} -
-
- ); - - case 'processing': - return ( -
-
-
- - Converting... -
- {job.progress}% -
- -
-
- - {formatTime(elapsedTime)} -
- {estimatedTimeRemaining && ( -
- - ~{formatTime(estimatedTimeRemaining)} left -
- )} -
-
- ); - - case 'completed': - const inputSize = job.inputFile.size; - const outputSize = job.result?.size || 0; - const sizeReduction = inputSize > 0 ? ((inputSize - outputSize) / inputSize) * 100 : 0; - - return ( -
-
- - Complete -
- -
-
- Input - {formatFileSize(inputSize)} -
-
- Output -
- {formatFileSize(outputSize)} - {Math.abs(sizeReduction) > 1 && ( - 0 - ? "bg-primary/10 text-primary" - : "bg-muted text-muted-foreground" - )}> - {sizeReduction > 0 ? '-' : '+'}{Math.abs(sizeReduction).toFixed(0)}% - - )} -
-
-
-
- ); - - case 'error': - return ( -
- - Conversion failed -
- ); - - default: - return null; - } - }; - - if (job.status === 'pending') { - return null; - } + const inputSize = job.inputFile.size; + const outputSize = job.result?.size ?? 0; + const sizeReduction = inputSize > 0 ? ((inputSize - outputSize) / inputSize) * 100 : 0; + const filename = generateOutputFilename(job.inputFile.name, job.outputFormat.extension); return ( - - - Conversion - - -
- {renderStatus()} +
+ {/* Header row */} +
+
+ {(job.status === 'loading' || job.status === 'processing') && ( + + )} + {job.status === 'completed' && ( + + )} + {job.status === 'error' && ( + + )} + {job.inputFile.name} +
+ + {job.inputFormat.extension} → {job.outputFormat.extension} + +
+ {/* Loading state */} + {job.status === 'loading' && ( +
+ + Loading converter… {fmt(elapsedTime)} +
+ )} + + {/* Processing state */} + {job.status === 'processing' && ( +
+
+
+ + {fmt(elapsedTime)} + {estimatedRemaining && ( + <> + + ~{fmt(estimatedRemaining)} left + + )} +
+ {job.progress}% +
+
+
+
+
+ )} + + {/* Completed state */} + {job.status === 'completed' && ( +
+ {/* Size stats */} +
+ {formatFileSize(inputSize)} + + {formatFileSize(outputSize)} + {Math.abs(sizeReduction) > 1 && ( + 0 ? 'bg-emerald-500/15 text-emerald-400' : 'bg-white/5 text-muted-foreground/50' + )}> + {sizeReduction > 0 ? '↓' : '↑'}{Math.abs(sizeReduction).toFixed(0)}% + + )} + {job.startTime && job.endTime && ( + + {((job.endTime - job.startTime) / 1000).toFixed(1)}s + + )} +
+ + {/* Media preview */} + {previewUrl && (() => { + switch (job.outputFormat.category) { + case 'image': + return ( +
+ Preview +
+ ); + case 'video': + return ( +
+ +
+ ); + case 'audio': + return ( +
+
+ ); + default: return null; + } + })()} + + {/* Download */} + +
+ )} + + {/* Error state */} + {job.status === 'error' && ( +
{job.error && ( -
-

{job.error}

+
+

{job.error}

)} - - {job.status === 'error' && onRetry && ( - - )} - - {job.status === 'completed' && renderPreview()} - - {job.status === 'completed' && job.result && ( - - )} - - {job.status === 'completed' && job.startTime && job.endTime && ( -

- {((job.endTime - job.startTime) / 1000).toFixed(1)}s -

+ )}
- - + )} +
); } diff --git a/components/media/FileConverter.tsx b/components/media/FileConverter.tsx index 3a93bd0..92cb44f 100644 --- a/components/media/FileConverter.tsx +++ b/components/media/FileConverter.tsx @@ -1,11 +1,6 @@ 'use client'; import * as React from 'react'; -import { Button } from '@/components/ui/button'; -import { Input } from '@/components/ui/input'; -import { Label } from '@/components/ui/label'; -import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; -import { Slider } from '@/components/ui/slider'; import { Select, SelectContent, @@ -13,6 +8,7 @@ import { SelectTrigger, SelectValue, } from '@/components/ui/select'; +import { Slider } from '@/components/ui/slider'; import { FileUpload } from './FileUpload'; import { ConversionPreview } from './ConversionPreview'; import { toast } from 'sonner'; @@ -26,6 +22,13 @@ import { convertWithImageMagick } from '@/lib/media/converters/imagemagickServic import { addToHistory } from '@/lib/media/storage/history'; import { downloadBlobsAsZip, generateOutputFilename } from '@/lib/media/utils/fileUtils'; import type { ConversionJob, ConversionFormat, ConversionOptions } from '@/types/media'; +import { ShieldCheck, Download, RotateCcw, Loader2 } from 'lucide-react'; +import { cn } from '@/lib/utils'; + +type MobileTab = 'upload' | 'convert'; + +const actionBtn = + 'flex items-center justify-center gap-1.5 px-3 py-1.5 text-xs glass rounded-md border border-border/30 text-muted-foreground hover:text-primary hover:border-primary/30 hover:bg-primary/10 transition-all disabled:opacity-40 disabled:cursor-not-allowed'; export function FileConverter() { const [selectedFiles, setSelectedFiles] = React.useState([]); @@ -34,10 +37,10 @@ export function FileConverter() { const [compatibleFormats, setCompatibleFormats] = React.useState([]); const [conversionJobs, setConversionJobs] = React.useState([]); const [conversionOptions, setConversionOptions] = React.useState({}); - + const [mobileTab, setMobileTab] = React.useState('upload'); const fileInputRef = React.useRef(null); - // Detect input format when files are selected + // Detect format when files change React.useEffect(() => { if (selectedFiles.length === 0) { setInputFormat(undefined); @@ -46,165 +49,104 @@ export function FileConverter() { 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' : ''})`); + const first = selectedFiles[0]; + const ext = first.name.split('.').pop()?.toLowerCase(); + const fmt = (ext ? getFormatByExtension(ext) : undefined) ?? getFormatByMimeType(first.type); + if (fmt) { + setInputFormat(fmt); + const compat = getCompatibleFormats(fmt); + setCompatibleFormats(compat); + if (compat.length > 0 && !outputFormat) setOutputFormat(compat[0]); + toast.success(`Detected: ${fmt.name} · ${selectedFiles.length} file${selectedFiles.length > 1 ? 's' : ''}`); + // Auto-advance to convert tab on mobile + setMobileTab('convert'); } else { toast.error('Could not detect file format'); setInputFormat(undefined); setCompatibleFormats([]); } + // eslint-disable-next-line react-hooks/exhaustive-deps }, [selectedFiles]); + const runConversion = async ( + jobIndex: number, + jobs: ConversionJob[], + outFmt: ConversionFormat + ) => { + const job = jobs[jobIndex]; + const updateJob = (patch: Partial) => + setConversionJobs((prev) => prev.map((j, i) => (i === jobIndex ? { ...j, ...patch } : j))); + + try { + updateJob({ status: 'loading' }); + updateJob({ status: 'processing', progress: 10 }); + + const onProgress = (progress: number) => updateJob({ progress }); + const result = + outFmt.converter === 'ffmpeg' + ? await convertWithFFmpeg(job.inputFile, outFmt.extension, conversionOptions, onProgress) + : await convertWithImageMagick(job.inputFile, outFmt.extension, conversionOptions, onProgress); + + if (result.success && result.blob) { + updateJob({ status: 'completed', progress: 100, result: result.blob, endTime: Date.now() }); + addToHistory({ + inputFileName: job.inputFile.name, + inputFormat: job.inputFormat.name, + outputFormat: outFmt.name, + outputFileName: generateOutputFilename(job.inputFile.name, outFmt.extension), + fileSize: result.blob.size, + result: result.blob, + }); + return true; + } else { + updateJob({ status: 'error', error: result.error || 'Unknown error', endTime: Date.now() }); + return false; + } + } catch (err) { + updateJob({ status: 'error', error: err instanceof Error ? err.message : 'Unknown error', endTime: Date.now() }); + return false; + } + }; + const handleConvert = async () => { - if (selectedFiles.length === 0 || !inputFormat || !outputFormat) { - toast.error('Please select files and output format'); + if (!selectedFiles.length || !inputFormat || !outputFormat) { + toast.error('Please select files and an output format'); return; } - - // Create conversion jobs for all files const jobs: ConversionJob[] = selectedFiles.map((file) => ({ - id: Math.random().toString(36).substring(7), + id: Math.random().toString(36).slice(2, 9), inputFile: file, - inputFormat, + inputFormat: 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 + let ok = 0; 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) - ); - } + const success = await runConversion(i, jobs, outputFormat); + if (success) ok++; } - // 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'); - } + if (ok === jobs.length) toast.success(`All ${jobs.length} file${jobs.length > 1 ? 's' : ''} converted!`); + else if (ok > 0) toast.info(`${ok}/${jobs.length} files converted`); + else toast.error('All conversions failed'); + }; + + const handleRetry = async (jobId: string) => { + const idx = conversionJobs.findIndex((j) => j.id === jobId); + if (idx === -1 || !outputFormat) return; + setConversionJobs((prev) => + prev.map((j, i) => + i === idx ? { ...j, status: 'loading', progress: 0, error: undefined, startTime: Date.now() } : j + ) + ); + const success = await runConversion(idx, conversionJobs, outputFormat); + if (success) toast.success('Conversion completed!'); + else toast.error('Retry failed'); }; const handleReset = () => { @@ -214,486 +156,402 @@ export function FileConverter() { setCompatibleFormats([]); setConversionJobs([]); setConversionOptions({}); - }; - - const handleFileSelect = (files: File[]) => { - setSelectedFiles((prev) => [...prev, ...files]); - }; - - const handleFileRemove = (index: number) => { - setSelectedFiles((prev) => prev.filter((_, i) => i !== index)); + setMobileTab('upload'); }; 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 done = conversionJobs.filter((j) => j.status === 'completed' && j.result); + if (!done.length) { toast.error('No completed files'); return; } + if (done.length === 1) { + const url = URL.createObjectURL(done[0].result!); const a = document.createElement('a'); a.href = url; - a.download = filename; - document.body.appendChild(a); + a.download = generateOutputFilename(done[0].inputFile.name, outputFormat.extension); 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`); + await downloadBlobsAsZip( + done.map((j) => ({ blob: j.result!, filename: generateOutputFilename(j.inputFile.name, outputFormat.extension) })), + 'converted-files.zip' + ); + toast.success(`Downloaded ${done.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; + const isConverting = conversionJobs.some((j) => j.status === 'loading' || j.status === 'processing'); + const completedCount = conversionJobs.filter((j) => j.status === 'completed').length; + const setOpt = (patch: Partial) => + setConversionOptions((prev) => ({ ...prev, ...patch })); return ( -
- {/* Left Column: Upload and Conversion Options */} -
- {/* Upload Card */} - - +
+ + {/* ── Mobile tab switcher ─────────────────────────────── */} +
+ {(['upload', 'convert'] as MobileTab[]).map((t) => ( + + ))} +
+ + {/* ── Main layout ─────────────────────────────────────── */} +
+ + {/* Left: upload zone */} +
+
+
+ + Upload + + + + Zero uploads + +
setSelectedFiles((prev) => [...prev, ...files])} + onFileRemove={(i) => setSelectedFiles((prev) => prev.filter((_, idx) => idx !== i))} selectedFiles={selectedFiles} disabled={isConverting} inputRef={fileInputRef} inputFormat={inputFormat} /> - - +
+
- {/* Conversion Options Card */} - {inputFormat && compatibleFormats.length > 0 && ( - - - Options - - - {/* Output Format Select */} -
- - + {/* Right: options + results */} +
+ {/* Options panel */} + {inputFormat && compatibleFormats.length > 0 ? ( +
+ {/* Detected format */} +
+ + Output Format + + {inputFormat && ( + + {inputFormat.name} + + )} +
+ + {/* Format pill grid */} +
+ {compatibleFormats.map((fmt) => ( + + ))}
- {/* Advanced Options - Inline */} {outputFormat && ( <> - {outputFormat.category === 'video' && ( -
- {/* Video Codec */} -
- - -
+
- {/* Video Bitrate */} -
-
- - {conversionOptions.videoBitrate || '2M'} + {/* Video options */} + {outputFormat.category === 'video' && ( + <> +
+ Video Codec +
- setConversionOptions({ ...conversionOptions, videoBitrate: `${vals[0]}M` })} - disabled={isConverting} - /> -

Higher bitrate = better quality, larger file

-
- {/* Resolution */} -
- - -
- - {/* FPS */} -
- - -
- - {/* Audio Bitrate */} -
-
- - {conversionOptions.audioBitrate || '128k'} +
+
+ Video Bitrate + {conversionOptions.videoBitrate || '2M'} +
+ setOpt({ videoBitrate: `${v[0]}M` })} + disabled={isConverting} + />
- setConversionOptions({ ...conversionOptions, audioBitrate: `${vals[0]}k` })} - disabled={isConverting} - /> -
-
- )} - {outputFormat.category === 'audio' && ( -
- {/* Audio Codec */} -
- - -
- - {/* Bitrate */} -
-
- - {conversionOptions.audioBitrate || '192k'} +
+
+ Resolution + +
+
+ FPS + +
- setConversionOptions({ ...conversionOptions, audioBitrate: `${vals[0]}k` })} - disabled={isConverting} - /> -
- {/* Sample Rate */} -
- - -
- - {/* Channels */} -
- - -
-
- )} - - {outputFormat.category === 'image' && ( -
- {/* Quality */} -
-
- - {conversionOptions.imageQuality || 85}% +
+
+ Audio Bitrate + {conversionOptions.audioBitrate || '128k'} +
+ setOpt({ audioBitrate: `${v[0]}k` })} + disabled={isConverting} + />
- setConversionOptions({ ...conversionOptions, imageQuality: vals[0] })} - disabled={isConverting} - /> -
+ + )} - {/* Width */} -
- - setConversionOptions({ ...conversionOptions, imageWidth: e.target.value ? parseInt(e.target.value) : undefined })} - placeholder="Original" - disabled={isConverting} - /> -

Leave empty to keep original

-
+ {/* Audio options */} + {outputFormat.category === 'audio' && ( + <> +
+ Codec + +
- {/* Height */} -
- - setConversionOptions({ ...conversionOptions, imageHeight: e.target.value ? parseInt(e.target.value) : undefined })} - placeholder="Original" - disabled={isConverting} - /> -

Leave empty to maintain aspect ratio

-
-
- )} +
+
+ Bitrate + {conversionOptions.audioBitrate || '192k'} +
+ setOpt({ audioBitrate: `${v[0]}k` })} + disabled={isConverting} + /> +
+ +
+
+ Sample Rate + +
+
+ Channels + +
+
+ + )} + + {/* Image options */} + {outputFormat.category === 'image' && ( + <> +
+
+ Quality + {conversionOptions.imageQuality ?? 85}% +
+ setOpt({ imageQuality: v[0] })} + disabled={isConverting} + /> +
+ +
+ {(['imageWidth', 'imageHeight'] as const).map((key) => ( +
+ + {key === 'imageWidth' ? 'Width (px)' : 'Height (px)'} + + setOpt({ [key]: e.target.value ? parseInt(e.target.value) : undefined })} + placeholder="Original" + disabled={isConverting} + className="w-full bg-transparent border border-border/40 rounded-lg px-2.5 py-1.5 text-xs font-mono outline-none focus:border-primary/50 transition-colors placeholder:text-muted-foreground/30 disabled:opacity-40" + /> +
+ ))} +
+ + )} +
+ + {/* Action buttons */} +
+ + +
)} +
+ ) : ( + /* No files yet — right panel placeholder */ +
+
+ +
+

+ Upload files to see conversion options +

+
+ )} - {/* Convert button */} - - - - - )} -
- - {/* Right Column: Results */} -
- {/* Download All Button */} - {completedCount > 0 && ( - - - - - - )} - - {/* Conversion previews */} - {conversionJobs.length > 0 && ( -
- {conversionJobs.map((job) => ( - handleRetry(job.id)} - /> - ))} -
- )} + {/* Results panel */} + {conversionJobs.length > 0 && ( +
+
+ + Results + + {completedCount > 0 && ( + + )} +
+
+ {conversionJobs.map((job) => ( + handleRetry(job.id)} + /> + ))} +
+
+ )} +
); diff --git a/components/media/FileUpload.tsx b/components/media/FileUpload.tsx index 490fa27..cbbdd88 100644 --- a/components/media/FileUpload.tsx +++ b/components/media/FileUpload.tsx @@ -3,7 +3,6 @@ import * as React from 'react'; import { Upload, X, File, FileVideo, FileAudio, FileImage, Clock, HardDrive, Film } from 'lucide-react'; import { cn } from '@/lib/utils/cn'; -import { Button } from '@/components/ui/button'; import type { ConversionFormat } from '@/types/media'; export interface FileUploadProps { @@ -17,6 +16,17 @@ export interface FileUploadProps { inputFormat?: ConversionFormat; } +function CategoryIcon({ format, className }: { format?: ConversionFormat; className?: string }) { + const cls = cn('text-primary', className); + if (!format) return ; + switch (format.category) { + case 'video': return ; + case 'audio': return ; + case 'image': return ; + default: return ; + } +} + export function FileUpload({ onFileSelect, onFileRemove, @@ -28,322 +38,170 @@ export function FileUpload({ inputFormat, }: FileUploadProps) { const [isDragging, setIsDragging] = React.useState(false); - const [fileMetadata, setFileMetadata] = React.useState>({}); - const localFileInputRef = React.useRef(null); - const fileInputRef = inputRef || localFileInputRef; + const [fileMetadata, setFileMetadata] = React.useState>>({}); + const localRef = React.useRef(null); + const fileInputRef = inputRef || localRef; - // Extract metadata for files React.useEffect(() => { - const extractMetadata = async () => { - if (selectedFiles.length === 0 || !inputFormat) { - setFileMetadata({}); - return; - } - - const metadata: Record = {}; - + const extract = async () => { + if (selectedFiles.length === 0 || !inputFormat) { setFileMetadata({}); return; } + const out: Record> = {}; for (let i = 0; i < selectedFiles.length; i++) { const file = selectedFiles[i]; - const baseMetadata = { - name: file.name, - size: file.size < 1024 * 1024 ? `${(file.size / 1024).toFixed(2)} KB` : `${(file.size / (1024 * 1024)).toFixed(2)} MB`, + const base = { + size: file.size < 1024 * 1024 ? `${(file.size / 1024).toFixed(1)} KB` : `${(file.size / (1024 * 1024)).toFixed(1)} MB`, type: inputFormat.name, }; - - // Extract media-specific metadata if (inputFormat.category === 'video' && file.type.startsWith('video/')) { - try { - const video = document.createElement('video'); - video.preload = 'metadata'; - - const metadataPromise = new Promise((resolve) => { - video.onloadedmetadata = () => { - const duration = video.duration; - const minutes = Math.floor(duration / 60); - const seconds = Math.floor(duration % 60); - resolve({ - ...baseMetadata, - duration: `${minutes}:${seconds.toString().padStart(2, '0')}`, - dimensions: `${video.videoWidth} × ${video.videoHeight}`, - }); - URL.revokeObjectURL(video.src); - }; - - video.onerror = () => { - resolve(baseMetadata); - URL.revokeObjectURL(video.src); - }; - }); - + const video = document.createElement('video'); + video.preload = 'metadata'; + out[i] = await new Promise((res) => { + video.onloadedmetadata = () => { + const d = video.duration, m = Math.floor(d / 60), s = Math.floor(d % 60); + res({ ...base, duration: `${m}:${s.toString().padStart(2, '0')}`, dimensions: `${video.videoWidth}×${video.videoHeight}` }); + URL.revokeObjectURL(video.src); + }; + video.onerror = () => { res(base); URL.revokeObjectURL(video.src); }; video.src = URL.createObjectURL(file); - metadata[i] = await metadataPromise; - } catch (error) { - metadata[i] = baseMetadata; - } + }); } else if (inputFormat.category === 'audio' && file.type.startsWith('audio/')) { - try { - const audio = document.createElement('audio'); - audio.preload = 'metadata'; - - const metadataPromise = new Promise((resolve) => { - audio.onloadedmetadata = () => { - const duration = audio.duration; - const minutes = Math.floor(duration / 60); - const seconds = Math.floor(duration % 60); - resolve({ - ...baseMetadata, - duration: `${minutes}:${seconds.toString().padStart(2, '0')}`, - }); - URL.revokeObjectURL(audio.src); - }; - - audio.onerror = () => { - resolve(baseMetadata); - URL.revokeObjectURL(audio.src); - }; - }); - + const audio = document.createElement('audio'); + audio.preload = 'metadata'; + out[i] = await new Promise((res) => { + audio.onloadedmetadata = () => { + const d = audio.duration, m = Math.floor(d / 60), s = Math.floor(d % 60); + res({ ...base, duration: `${m}:${s.toString().padStart(2, '0')}` }); + URL.revokeObjectURL(audio.src); + }; + audio.onerror = () => { res(base); URL.revokeObjectURL(audio.src); }; audio.src = URL.createObjectURL(file); - metadata[i] = await metadataPromise; - } catch (error) { - metadata[i] = baseMetadata; - } + }); } else if (inputFormat.category === 'image' && file.type.startsWith('image/')) { - try { - const img = new Image(); - - const metadataPromise = new Promise((resolve) => { - img.onload = () => { - resolve({ - ...baseMetadata, - dimensions: `${img.width} × ${img.height}`, - }); - URL.revokeObjectURL(img.src); - }; - - img.onerror = () => { - resolve(baseMetadata); - URL.revokeObjectURL(img.src); - }; - }); - + const img = new Image(); + out[i] = await new Promise((res) => { + img.onload = () => { res({ ...base, dimensions: `${img.width}×${img.height}` }); URL.revokeObjectURL(img.src); }; + img.onerror = () => { res(base); URL.revokeObjectURL(img.src); }; img.src = URL.createObjectURL(file); - metadata[i] = await metadataPromise; - } catch (error) { - metadata[i] = baseMetadata; - } + }); } else { - metadata[i] = baseMetadata; + out[i] = base; } } - - setFileMetadata(metadata); + setFileMetadata(out); }; - - extractMetadata(); + extract(); }, [selectedFiles, inputFormat]); - const getCategoryIcon = () => { - if (!inputFormat) return ; - switch (inputFormat.category) { - case 'video': - return ; - case 'audio': - return ; - case 'image': - return ; - default: - return ; - } - }; - - 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 handleFiles = (files: File[]) => { + const maxBytes = maxSizeMB * 1024 * 1024; + const valid = files.filter((f) => { + if (f.size > maxBytes) { alert(`${f.name} exceeds ${maxSizeMB}MB limit.`); return false; } + return true; + }); + if (valid.length > 0) onFileSelect(valid); + if (fileInputRef.current) fileInputRef.current.value = ''; }; 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); - } + if (!disabled) handleFiles(Array.from(e.dataTransfer.files)); }; - const handleFileInput = (e: React.ChangeEvent) => { - 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); - }; + const triggerInput = () => { if (!disabled) fileInputRef.current?.click(); }; return ( -
+
handleFiles(Array.from(e.target.files || []))} disabled={disabled} /> - {selectedFiles.length > 0 ? ( -
- {selectedFiles.map((file, index) => { - const metadata = fileMetadata[index]; - return ( -
-
-
- {getCategoryIcon()} + {selectedFiles.length === 0 ? ( + /* ── Drop zone ─────────────────────────────────────── */ +
{ e.preventDefault(); if (!disabled) setIsDragging(true); }} + onDragLeave={(e) => { e.preventDefault(); setIsDragging(false); }} + onDragOver={(e) => e.preventDefault()} + onDrop={handleDrop} + className={cn( + 'flex-1 flex flex-col items-center justify-center rounded-xl border-2 border-dashed transition-all cursor-pointer', + 'text-center select-none', + isDragging + ? 'border-primary bg-primary/10 scale-[0.99]' + : 'border-border/35 hover:border-primary/40 hover:bg-primary/5', + disabled && 'opacity-50 cursor-not-allowed pointer-events-none' + )} + > +
+ +
+

+ {isDragging ? 'Drop to upload' : 'Drop files or click to browse'} +

+

+ Video · Audio · Image · Max {maxSizeMB}MB +

+
+ ) : ( + /* ── File list ─────────────────────────────────────── */ +
+
+ {selectedFiles.map((file, idx) => { + const meta = fileMetadata[idx]; + return ( +
+
+
-

+

{file.name}

- + +
- {metadata && ( -
- {/* File Size */} -
- - {metadata.size} -
- - {/* Type */} -
- - {metadata.type} -
- - {/* Duration (for video/audio) */} - {metadata.duration && ( -
- - {metadata.duration} -
- )} - - {/* Dimensions */} - {metadata.dimensions && ( -
- {inputFormat?.category === 'video' ? ( - - ) : ( - - )} - {metadata.dimensions} -
- )} + {meta && ( +
+ {meta.size} + {meta.duration && {meta.duration}} + {meta.dimensions && {meta.dimensions}}
)}
-
- ); - })} - - {/* Add more files button */} - -
- ) : ( -
-
- + ); + })}
-

- Drop files here or click to browse -

-

- Max {maxSizeMB}MB per file -

+ + {/* Add more */} +
)}