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,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 <File className={cls} />;
switch (format.category) {
case 'video': return <FileVideo className={cls} />;
case 'audio': return <FileAudio className={cls} />;
case 'image': return <FileImage className={cls} />;
default: return <File className={cls} />;
}
}
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<Record<number, any>>({});
const localFileInputRef = React.useRef<HTMLInputElement>(null);
const fileInputRef = inputRef || localFileInputRef;
const [fileMetadata, setFileMetadata] = React.useState<Record<number, Record<string, string>>>({});
const localRef = React.useRef<HTMLInputElement>(null);
const fileInputRef = inputRef || localRef;
// Extract metadata for files
React.useEffect(() => {
const extractMetadata = async () => {
if (selectedFiles.length === 0 || !inputFormat) {
setFileMetadata({});
return;
}
const metadata: Record<number, any> = {};
const extract = async () => {
if (selectedFiles.length === 0 || !inputFormat) { setFileMetadata({}); return; }
const out: Record<number, Record<string, string>> = {};
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<any>((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<any>((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<any>((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 <File className="h-5 w-5 text-primary" />;
switch (inputFormat.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" />;
}
};
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<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);
};
const triggerInput = () => { if (!disabled) fileInputRef.current?.click(); };
return (
<div className="w-full space-y-3">
<div className="w-full flex flex-col gap-2 flex-1 min-h-0">
<input
ref={fileInputRef}
type="file"
multiple
className="hidden"
accept={accept}
onChange={handleFileInput}
onChange={(e) => handleFiles(Array.from(e.target.files || []))}
disabled={disabled}
/>
{selectedFiles.length > 0 ? (
<div className="space-y-3">
{selectedFiles.map((file, index) => {
const metadata = fileMetadata[index];
return (
<div key={`${file.name}-${index}`} className="border border-border rounded-xl p-4 bg-card/50 backdrop-blur-sm">
<div className="flex items-start gap-3">
<div className="p-2 bg-primary/10 rounded-lg shrink-0">
{getCategoryIcon()}
{selectedFiles.length === 0 ? (
/* ── Drop zone ─────────────────────────────────────── */
<div
onClick={triggerInput}
onDragEnter={(e) => { 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'
)}
>
<div className={cn(
'w-14 h-14 rounded-full flex items-center justify-center mb-4 transition-colors',
isDragging ? 'bg-primary/25' : 'bg-primary/10'
)}>
<Upload className={cn('w-6 h-6 transition-colors', isDragging ? 'text-primary' : 'text-primary/60')} />
</div>
<p className="text-sm font-medium text-foreground/70 mb-1">
{isDragging ? 'Drop to upload' : 'Drop files or click to browse'}
</p>
<p className="text-[10px] text-muted-foreground/35 font-mono">
Video · Audio · Image · Max {maxSizeMB}MB
</p>
</div>
) : (
/* ── File list ─────────────────────────────────────── */
<div className="flex flex-col gap-2 flex-1 min-h-0">
<div className="flex-1 min-h-0 overflow-y-auto scrollbar-thin scrollbar-thumb-primary/20 scrollbar-track-transparent space-y-2 pr-0.5">
{selectedFiles.map((file, idx) => {
const meta = fileMetadata[idx];
return (
<div
key={`${file.name}-${idx}`}
className="flex items-start gap-3 p-3 rounded-xl border border-border/25 bg-primary/3"
>
<div className="w-8 h-8 rounded-lg bg-primary/10 flex items-center justify-center shrink-0">
<CategoryIcon format={inputFormat} className="w-4 h-4" />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-start justify-between gap-2">
<p className="text-sm font-medium text-foreground truncate" title={file.name}>
<p className="text-xs font-mono text-foreground/80 truncate" title={file.name}>
{file.name}
</p>
<Button
variant="ghost"
size="icon-xs"
onClick={handleRemove(index)}
<button
onClick={(e) => { e.stopPropagation(); onFileRemove(idx); }}
disabled={disabled}
className="rounded-full hover:bg-destructive/10 hover:text-destructive shrink-0"
className="shrink-0 w-5 h-5 flex items-center justify-center rounded text-muted-foreground/30 hover:text-rose-400 transition-colors"
>
<X className="h-3.5 w-3.5" />
<span className="sr-only">Remove file</span>
</Button>
<X className="w-3 h-3" />
</button>
</div>
{metadata && (
<div className="mt-1.5 flex flex-wrap gap-3 text-[10px] text-muted-foreground">
{/* File Size */}
<div className="flex items-center gap-1">
<HardDrive className="h-3 w-3" />
<span>{metadata.size}</span>
</div>
{/* Type */}
<div className="flex items-center gap-1">
<File className="h-3 w-3" />
<span>{metadata.type}</span>
</div>
{/* Duration (for video/audio) */}
{metadata.duration && (
<div className="flex items-center gap-1">
<Clock className="h-3 w-3" />
<span>{metadata.duration}</span>
</div>
)}
{/* Dimensions */}
{metadata.dimensions && (
<div className="flex items-center gap-1">
{inputFormat?.category === 'video' ? (
<Film className="h-3 w-3" />
) : (
<FileImage className="h-3 w-3" />
)}
<span>{metadata.dimensions}</span>
</div>
)}
{meta && (
<div className="mt-1 flex flex-wrap gap-2.5 text-[10px] text-muted-foreground/40 font-mono">
<span className="flex items-center gap-1"><HardDrive className="w-2.5 h-2.5" />{meta.size}</span>
{meta.duration && <span className="flex items-center gap-1"><Clock className="w-2.5 h-2.5" />{meta.duration}</span>}
{meta.dimensions && <span className="flex items-center gap-1"><Film className="w-2.5 h-2.5" />{meta.dimensions}</span>}
</div>
)}
</div>
</div>
</div>
);
})}
{/* Add more files button */}
<Button
variant="outline"
onClick={handleClick}
disabled={disabled}
className="w-full"
>
<Upload className="h-3.5 w-3.5 mr-1.5" />
Add More Files
</Button>
</div>
) : (
<div
onClick={handleClick}
onDragEnter={handleDragEnter}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
className={cn(
'border-2 border-dashed rounded-xl p-8 text-center cursor-pointer transition-all duration-200',
'hover:border-primary/40 hover:bg-primary/5',
{
'border-primary bg-primary/10 scale-[0.98]': isDragging,
'border-border/50': !isDragging,
'opacity-50 cursor-not-allowed': disabled,
}
)}
>
<div className="bg-primary/10 w-12 h-12 rounded-full flex items-center justify-center mx-auto mb-3">
<Upload className="h-6 w-6 text-primary" />
);
})}
</div>
<p className="text-sm font-medium text-foreground mb-0.5">
Drop files here or click to browse
</p>
<p className="text-[10px] text-muted-foreground">
Max {maxSizeMB}MB per file
</p>
{/* Add more */}
<button
onClick={triggerInput}
disabled={disabled}
className="shrink-0 w-full py-2 rounded-xl border border-dashed border-border/30 text-xs text-muted-foreground/40 hover:text-foreground hover:border-primary/30 transition-all flex items-center justify-center gap-1.5 font-mono"
>
<Upload className="w-3 h-3" />
Add more files
</button>
</div>
)}
</div>