Extract shared actionBtn and iconBtn constants into lib/utils/styles.ts and replace all 11 local definitions across tool components. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
197 lines
7.4 KiB
TypeScript
197 lines
7.4 KiB
TypeScript
'use client';
|
|
|
|
import * as React from 'react';
|
|
import { Download, CheckCircle, XCircle, Loader2, Clock, TrendingUp, RefreshCw } from 'lucide-react';
|
|
import { cn, actionBtn } from '@/lib/utils';
|
|
import { downloadBlob, formatFileSize, generateOutputFilename } from '@/lib/media/utils/fileUtils';
|
|
import type { ConversionJob } from '@/types/media';
|
|
|
|
export interface ConversionPreviewProps {
|
|
job: ConversionJob;
|
|
onRetry?: () => void;
|
|
}
|
|
|
|
export function ConversionPreview({ job, onRetry }: ConversionPreviewProps) {
|
|
const [previewUrl, setPreviewUrl] = React.useState<string | null>(null);
|
|
const [elapsedTime, setElapsedTime] = React.useState(0);
|
|
const [estimatedRemaining, setEstimatedRemaining] = React.useState<number | null>(null);
|
|
|
|
React.useEffect(() => {
|
|
if (job.status === 'processing' || job.status === 'loading') {
|
|
const interval = setInterval(() => {
|
|
if (job.startTime) {
|
|
const elapsed = Date.now() - job.startTime;
|
|
setElapsedTime(elapsed);
|
|
if (job.progress > 5 && job.progress < 100) {
|
|
const rate = job.progress / elapsed;
|
|
setEstimatedRemaining((100 - job.progress) / rate);
|
|
}
|
|
}
|
|
}, 100);
|
|
return () => clearInterval(interval);
|
|
} else {
|
|
setEstimatedRemaining(null);
|
|
}
|
|
}, [job.status, job.startTime, job.progress]);
|
|
|
|
React.useEffect(() => {
|
|
if (job.result && job.status === 'completed') {
|
|
const url = URL.createObjectURL(job.result);
|
|
setPreviewUrl(url);
|
|
return () => URL.revokeObjectURL(url);
|
|
} else {
|
|
setPreviewUrl(null);
|
|
}
|
|
}, [job.result, job.status]);
|
|
|
|
const handleDownload = () => {
|
|
if (job.result) {
|
|
downloadBlob(job.result, generateOutputFilename(job.inputFile.name, job.outputFormat.extension));
|
|
}
|
|
};
|
|
|
|
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`;
|
|
};
|
|
|
|
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 (
|
|
<div className="glass rounded-xl p-3 border border-border/20 space-y-3">
|
|
{/* Header row */}
|
|
<div className="flex items-center justify-between gap-2">
|
|
<div className="flex items-center gap-2 min-w-0">
|
|
{(job.status === 'loading' || job.status === 'processing') && (
|
|
<Loader2 className="w-3 h-3 animate-spin text-primary shrink-0" />
|
|
)}
|
|
{job.status === 'completed' && (
|
|
<CheckCircle className="w-3 h-3 text-emerald-400 shrink-0" />
|
|
)}
|
|
{job.status === 'error' && (
|
|
<XCircle className="w-3 h-3 text-rose-400 shrink-0" />
|
|
)}
|
|
<span className="text-xs font-mono text-foreground/70 truncate">{job.inputFile.name}</span>
|
|
</div>
|
|
<span className="text-[10px] font-mono text-muted-foreground/40 shrink-0">
|
|
{job.inputFormat.extension} → {job.outputFormat.extension}
|
|
</span>
|
|
</div>
|
|
|
|
{/* Loading state */}
|
|
{job.status === 'loading' && (
|
|
<div className="flex items-center gap-1.5 text-[10px] text-muted-foreground/50 font-mono">
|
|
<Clock className="w-3 h-3" />
|
|
<span>Loading converter… {fmt(elapsedTime)}</span>
|
|
</div>
|
|
)}
|
|
|
|
{/* Processing state */}
|
|
{job.status === 'processing' && (
|
|
<div className="space-y-1.5">
|
|
<div className="flex items-center justify-between text-[10px] font-mono text-muted-foreground/50">
|
|
<div className="flex items-center gap-1.5">
|
|
<Clock className="w-3 h-3" />
|
|
<span>{fmt(elapsedTime)}</span>
|
|
{estimatedRemaining && (
|
|
<>
|
|
<TrendingUp className="w-3 h-3 ml-1" />
|
|
<span>~{fmt(estimatedRemaining)} left</span>
|
|
</>
|
|
)}
|
|
</div>
|
|
<span className="tabular-nums">{job.progress}%</span>
|
|
</div>
|
|
<div className="w-full h-1 rounded-full overflow-hidden bg-white/5">
|
|
<div
|
|
className="h-full bg-primary/65 transition-all duration-300"
|
|
style={{ width: `${job.progress}%` }}
|
|
/>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Completed state */}
|
|
{job.status === 'completed' && (
|
|
<div className="space-y-3">
|
|
{/* Size stats */}
|
|
<div className="flex items-center gap-3 text-[10px] font-mono">
|
|
<span className="text-muted-foreground/40">{formatFileSize(inputSize)}</span>
|
|
<span className="text-muted-foreground/25">→</span>
|
|
<span className="text-foreground/60">{formatFileSize(outputSize)}</span>
|
|
{Math.abs(sizeReduction) > 1 && (
|
|
<span className={cn(
|
|
'px-1.5 py-0.5 rounded font-mono text-[9px]',
|
|
sizeReduction > 0 ? 'bg-emerald-500/15 text-emerald-400' : 'bg-white/5 text-muted-foreground/50'
|
|
)}>
|
|
{sizeReduction > 0 ? '↓' : '↑'}{Math.abs(sizeReduction).toFixed(0)}%
|
|
</span>
|
|
)}
|
|
{job.startTime && job.endTime && (
|
|
<span className="text-muted-foreground/25 ml-auto">
|
|
{((job.endTime - job.startTime) / 1000).toFixed(1)}s
|
|
</span>
|
|
)}
|
|
</div>
|
|
|
|
{/* Media preview */}
|
|
{previewUrl && (() => {
|
|
switch (job.outputFormat.category) {
|
|
case 'image':
|
|
return (
|
|
<div className="rounded-lg overflow-hidden border border-white/5 bg-white/3 flex items-center justify-center p-2 max-h-48">
|
|
<img src={previewUrl} alt="Preview" className="max-w-full max-h-44 object-contain rounded" />
|
|
</div>
|
|
);
|
|
case 'video':
|
|
return (
|
|
<div className="rounded-lg overflow-hidden border border-white/5 bg-black/20">
|
|
<video src={previewUrl} controls className="w-full max-h-48">
|
|
Video not supported
|
|
</video>
|
|
</div>
|
|
);
|
|
case 'audio':
|
|
return (
|
|
<div className="rounded-lg border border-white/5 bg-white/3 p-3">
|
|
<audio src={previewUrl} controls className="w-full h-8" />
|
|
</div>
|
|
);
|
|
default: return null;
|
|
}
|
|
})()}
|
|
|
|
{/* Download */}
|
|
<button onClick={handleDownload} className={cn(actionBtn, 'w-full justify-center')}>
|
|
<Download className="w-3 h-3" />
|
|
<span className="truncate min-w-0">{filename}</span>
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{/* Error state */}
|
|
{job.status === 'error' && (
|
|
<div className="space-y-2">
|
|
{job.error && (
|
|
<div className="rounded-lg border border-rose-500/20 bg-rose-500/8 p-2.5">
|
|
<p className="text-[10px] font-mono text-rose-400/80">{job.error}</p>
|
|
</div>
|
|
)}
|
|
{onRetry && (
|
|
<button onClick={onRetry} className={cn(actionBtn, 'w-full justify-center')}>
|
|
<RefreshCw className="w-3 h-3" />
|
|
Retry
|
|
</button>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|