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:
@@ -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 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 category = job.outputFormat.category;
|
||||
if (job.status === 'pending') return null;
|
||||
|
||||
// Log blob details for debugging
|
||||
console.log('[Preview] Blob details:', {
|
||||
size: job.result.size,
|
||||
type: job.result.type,
|
||||
previewUrl,
|
||||
outputFormat: job.outputFormat.extension,
|
||||
});
|
||||
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);
|
||||
|
||||
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 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="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.
|
||||
<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="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 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;
|
||||
default: return null;
|
||||
}
|
||||
};
|
||||
})()}
|
||||
|
||||
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`;
|
||||
};
|
||||
|
||||
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>
|
||||
{/* 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>
|
||||
)}
|
||||
</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 (
|
||||
{/* Error state */}
|
||||
{job.status === 'error' && (
|
||||
<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;
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="animate-fade-in">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-sm">Conversion</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-3">
|
||||
{renderStatus()}
|
||||
|
||||
{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>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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) => {
|
||||
out[i] = await new Promise((res) => {
|
||||
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}`,
|
||||
});
|
||||
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 = () => {
|
||||
resolve(baseMetadata);
|
||||
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) => {
|
||||
out[i] = await new Promise((res) => {
|
||||
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')}`,
|
||||
});
|
||||
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 = () => {
|
||||
resolve(baseMetadata);
|
||||
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);
|
||||
};
|
||||
});
|
||||
|
||||
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];
|
||||
{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}-${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()}
|
||||
<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>
|
||||
{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>
|
||||
)}
|
||||
|
||||
{/* 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>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Add more files button */}
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleClick}
|
||||
{/* Add more */}
|
||||
<button
|
||||
onClick={triggerInput}
|
||||
disabled={disabled}
|
||||
className="w-full"
|
||||
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="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>
|
||||
<Upload className="w-3 h-3" />
|
||||
Add more files
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user