Files
kit-ui/components/media/FileUpload.tsx

353 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client';
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 {
onFileSelect: (files: File[]) => void;
onFileRemove: (index: number) => void;
selectedFiles?: File[];
accept?: string;
maxSizeMB?: number;
disabled?: boolean;
inputRef?: React.RefObject<HTMLInputElement | null>;
inputFormat?: ConversionFormat;
}
export function FileUpload({
onFileSelect,
onFileRemove,
selectedFiles = [],
accept,
maxSizeMB = 500,
disabled = false,
inputRef,
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;
// Extract metadata for files
React.useEffect(() => {
const extractMetadata = async () => {
if (selectedFiles.length === 0 || !inputFormat) {
setFileMetadata({});
return;
}
const metadata: Record<number, any> = {};
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`,
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);
};
});
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);
};
});
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);
};
});
img.src = URL.createObjectURL(file);
metadata[i] = await metadataPromise;
} catch (error) {
metadata[i] = baseMetadata;
}
} else {
metadata[i] = baseMetadata;
}
}
setFileMetadata(metadata);
};
extractMetadata();
}, [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 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);
}
};
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);
};
return (
<div className="w-full space-y-3">
<input
ref={fileInputRef}
type="file"
multiple
className="hidden"
accept={accept}
onChange={handleFileInput}
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-4">
<div className="p-2 bg-primary/10 rounded-lg shrink-0">
{getCategoryIcon()}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-start justify-between gap-2">
<p className="text-sm font-semibold text-foreground truncate" title={file.name}>
{file.name}
</p>
<Button
variant="ghost"
size="icon-xs"
onClick={handleRemove(index)}
disabled={disabled}
className="rounded-full hover:bg-destructive/10 hover:text-destructive shrink-0"
>
<X className="h-3.5 w-3.5" />
<span className="sr-only">Remove file</span>
</Button>
</div>
{metadata && (
<div className="mt-2 flex flex-wrap gap-4 text-[10px] text-muted-foreground uppercase tracking-wider font-bold">
{/* File Size */}
<div className="flex items-center gap-1.5">
<HardDrive className="h-3 w-3" />
<span>{metadata.size}</span>
</div>
{/* Type */}
<div className="flex items-center gap-1.5">
<File className="h-3 w-3" />
<span>{metadata.type}</span>
</div>
{/* Duration (for video/audio) */}
{metadata.duration && (
<div className="flex items-center gap-1.5">
<Clock className="h-3 w-3" />
<span>{metadata.duration}</span>
</div>
)}
{/* Dimensions */}
{metadata.dimensions && (
<div className="flex items-center gap-1.5">
{inputFormat?.category === 'video' ? (
<Film className="h-3 w-3" />
) : (
<FileImage className="h-3 w-3" />
)}
<span>{metadata.dimensions}</span>
</div>
)}
</div>
)}
</div>
</div>
</div>
);
})}
{/* Add more files button */}
<Button
variant="outline"
onClick={handleClick}
disabled={disabled}
className="w-full rounded-xl"
size="lg"
>
<Upload className="h-4 w-4 mr-2" />
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-10 text-center cursor-pointer transition-all duration-300',
'hover:border-primary/50 hover:bg-primary/5',
{
'border-primary bg-primary/10 scale-[0.98]': isDragging,
'border-border bg-muted/30': !isDragging,
'opacity-50 cursor-not-allowed': disabled,
}
)}
>
<div className="bg-primary/10 w-16 h-16 rounded-full flex items-center justify-center mx-auto mb-4">
<Upload className="h-8 w-8 text-primary" />
</div>
<p className="text-sm font-semibold text-foreground mb-1">
Drop your files here or click to browse
</p>
<p className="text-xs text-muted-foreground">
Maximum file size: {maxSizeMB}MB per file
</p>
</div>
)}
</div>
);
}