refactor: refactor media tool to match calculate blueprint

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 <noreply@anthropic.com>
This commit is contained in:
2026-03-01 08:22:17 +01:00
parent 0727ec7675
commit 2763b76abe
3 changed files with 708 additions and 1070 deletions

View File

@@ -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<string | null>(null);
const [elapsedTime, setElapsedTime] = React.useState(0);
const [estimatedTimeRemaining, setEstimatedTimeRemaining] = React.useState<number | null>(null);
const [estimatedRemaining, setEstimatedRemaining] = 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);
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 (
<div className="mt-3 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-3 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 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 (
<div className="space-y-2">
<div className="flex items-center gap-2 text-muted-foreground">
<Loader2 className="h-3.5 w-3.5 animate-spin text-primary" />
<span className="text-xs font-medium">Loading converter...</span>
</div>
<div className="flex items-center gap-1.5 text-[10px] text-muted-foreground">
<Clock className="h-3 w-3" />
<span>{formatTime(elapsedTime)}</span>
</div>
</div>
);
case 'processing':
return (
<div className="space-y-2">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2 text-muted-foreground">
<Loader2 className="h-3.5 w-3.5 animate-spin text-primary" />
<span className="text-xs font-medium">Converting...</span>
</div>
<span className="text-[10px] text-muted-foreground tabular-nums">{job.progress}%</span>
</div>
<Progress value={job.progress} className="h-1" />
<div className="flex items-center gap-3 text-[10px] text-muted-foreground">
<div className="flex items-center gap-1.5">
<Clock className="h-3 w-3" />
<span>{formatTime(elapsedTime)}</span>
</div>
{estimatedTimeRemaining && (
<div className="flex items-center gap-1.5">
<TrendingUp className="h-3 w-3" />
<span>~{formatTime(estimatedTimeRemaining)} left</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-2">
<div className="flex items-center gap-2">
<CheckCircle className="h-3.5 w-3.5 text-primary" />
<span className="text-xs font-medium">Complete</span>
</div>
<div className="bg-muted/50 rounded-lg p-2.5 space-y-1">
<div className="flex items-center justify-between text-xs">
<span className="text-muted-foreground">Input</span>
<span className="font-medium tabular-nums">{formatFileSize(inputSize)}</span>
</div>
<div className="flex items-center justify-between text-xs">
<span className="text-muted-foreground">Output</span>
<div className="flex items-center gap-1.5">
<span className="font-medium tabular-nums">{formatFileSize(outputSize)}</span>
{Math.abs(sizeReduction) > 1 && (
<span className={cn(
"text-[10px] px-1.5 py-0.5 rounded-full",
sizeReduction > 0
? "bg-primary/10 text-primary"
: "bg-muted text-muted-foreground"
)}>
{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-3.5 w-3.5" />
<span className="text-xs font-medium">Conversion failed</span>
</div>
);
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 (
<Card className="animate-fade-in">
<CardHeader>
<CardTitle className="text-sm">Conversion</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-3">
{renderStatus()}
<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')}>
<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="bg-destructive/10 border border-destructive/20 rounded-md p-2.5">
<p className="text-xs text-destructive">{job.error}</p>
<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>
)}
{job.status === 'error' && onRetry && (
<Button onClick={onRetry} variant="outline" className="w-full">
<RefreshCw className="h-3.5 w-3.5 mr-1.5" />
{onRetry && (
<button onClick={onRetry} className={cn(actionBtn, 'w-full')}>
<RefreshCw className="w-3 h-3" />
Retry
</Button>
)}
{job.status === 'completed' && renderPreview()}
{job.status === 'completed' && job.result && (
<Button onClick={handleDownload} className="w-full">
<Download className="h-3.5 w-3.5 shrink-0 mr-1.5" />
<span className="truncate min-w-0">
{generateOutputFilename(job.inputFile.name, job.outputFormat.extension)}
</span>
</Button>
)}
{job.status === 'completed' && job.startTime && job.endTime && (
<p className="text-[10px] text-muted-foreground text-center">
{((job.endTime - job.startTime) / 1000).toFixed(1)}s
</p>
</button>
)}
</div>
</CardContent>
</Card>
)}
</div>
);
}