188 lines
5.4 KiB
TypeScript
188 lines
5.4 KiB
TypeScript
import React, { useState, useRef } from 'react';
|
|
import { Upload, File, X, CheckCircle2, AlertCircle } from 'lucide-react';
|
|
import { Box } from '@/ui/Box';
|
|
import { Text } from '@/ui/Text';
|
|
import { Icon } from '@/ui/Icon';
|
|
import { Button } from '@/ui/Button';
|
|
|
|
export interface UploadDropzoneProps {
|
|
onFilesSelected: (files: File[]) => void;
|
|
accept?: string;
|
|
multiple?: boolean;
|
|
maxSize?: number; // in bytes
|
|
isLoading?: boolean;
|
|
error?: string;
|
|
}
|
|
|
|
export function UploadDropzone({
|
|
onFilesSelected,
|
|
accept,
|
|
multiple = false,
|
|
maxSize,
|
|
isLoading,
|
|
error,
|
|
}: UploadDropzoneProps) {
|
|
const [isDragging, setIsDragging] = useState(false);
|
|
const [selectedFiles, setSelectedFiles] = useState<File[]>([]);
|
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
|
|
const handleDragOver = (e: React.DragEvent) => {
|
|
e.preventDefault();
|
|
setIsDragging(true);
|
|
};
|
|
|
|
const handleDragLeave = () => {
|
|
setIsDragging(false);
|
|
};
|
|
|
|
const handleDrop = (e: React.DragEvent) => {
|
|
e.preventDefault();
|
|
setIsDragging(false);
|
|
const files = Array.from(e.dataTransfer.files);
|
|
validateAndSelectFiles(files);
|
|
};
|
|
|
|
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
if (e.target.files) {
|
|
const files = Array.from(e.target.files);
|
|
validateAndSelectFiles(files);
|
|
}
|
|
};
|
|
|
|
const validateAndSelectFiles = (files: File[]) => {
|
|
let filteredFiles = files;
|
|
|
|
if (accept) {
|
|
const acceptedTypes = accept.split(',').map(t => t.trim());
|
|
filteredFiles = filteredFiles.filter(file => {
|
|
return acceptedTypes.some(type => {
|
|
if (type.startsWith('.')) {
|
|
return file.name.endsWith(type);
|
|
}
|
|
if (type.endsWith('/*')) {
|
|
return file.type.startsWith(type.replace('/*', ''));
|
|
}
|
|
return file.type === type;
|
|
});
|
|
});
|
|
}
|
|
|
|
if (maxSize) {
|
|
filteredFiles = filteredFiles.filter(file => file.size <= maxSize);
|
|
}
|
|
|
|
if (!multiple) {
|
|
filteredFiles = filteredFiles.slice(0, 1);
|
|
}
|
|
|
|
setSelectedFiles(filteredFiles);
|
|
onFilesSelected(filteredFiles);
|
|
};
|
|
|
|
const removeFile = (index: number) => {
|
|
const newFiles = [...selectedFiles];
|
|
newFiles.splice(index, 1);
|
|
setSelectedFiles(newFiles);
|
|
onFilesSelected(newFiles);
|
|
};
|
|
|
|
return (
|
|
<Box w="full">
|
|
<Box
|
|
onDragOver={handleDragOver}
|
|
onDragLeave={handleDragLeave}
|
|
onDrop={handleDrop}
|
|
onClick={() => fileInputRef.current?.click()}
|
|
display="flex"
|
|
flexDirection="col"
|
|
alignItems="center"
|
|
justifyContent="center"
|
|
p={8}
|
|
border
|
|
borderStyle="dashed"
|
|
borderColor={isDragging ? 'border-blue-500' : error ? 'border-amber-500' : 'border-charcoal-outline'}
|
|
bg={isDragging ? 'bg-blue-500/5' : 'bg-charcoal-outline/10'}
|
|
rounded="xl"
|
|
cursor="pointer"
|
|
transition
|
|
hoverBg="bg-charcoal-outline/20"
|
|
>
|
|
<Box as="input"
|
|
type="file"
|
|
ref={fileInputRef}
|
|
onChange={handleFileSelect}
|
|
accept={accept}
|
|
multiple={multiple}
|
|
display="none"
|
|
/>
|
|
|
|
<Icon
|
|
icon={isLoading ? Upload : (selectedFiles.length > 0 ? CheckCircle2 : Upload)}
|
|
size={10}
|
|
color={isDragging ? 'text-blue-500' : error ? 'text-amber-500' : 'text-gray-500'}
|
|
animate={isLoading ? 'pulse' : 'none'}
|
|
mb={4}
|
|
/>
|
|
|
|
<Text weight="semibold" size="lg" mb={1}>
|
|
{isDragging ? 'Drop files here' : 'Click or drag to upload'}
|
|
</Text>
|
|
|
|
<Text size="sm" color="text-gray-500" align="center">
|
|
{accept ? `Accepted formats: ${accept}` : 'All file types accepted'}
|
|
{maxSize && ` (Max ${Math.round(maxSize / 1024 / 1024)}MB)`}
|
|
</Text>
|
|
|
|
{error && (
|
|
<Box display="flex" alignItems="center" gap={2} mt={4} color="text-amber-500">
|
|
<Icon icon={AlertCircle} size={4} />
|
|
<Text size="sm" weight="medium">{error}</Text>
|
|
</Box>
|
|
)}
|
|
</Box>
|
|
|
|
{selectedFiles.length > 0 && (
|
|
<Box mt={4} display="flex" flexDirection="col" gap={2}>
|
|
{selectedFiles.map((file, index) => (
|
|
<Box
|
|
key={`${file.name}-${index}`}
|
|
display="flex"
|
|
alignItems="center"
|
|
justifyContent="between"
|
|
p={3}
|
|
bg="bg-charcoal-outline/20"
|
|
rounded="lg"
|
|
border
|
|
borderColor="border-charcoal-outline/30"
|
|
>
|
|
<Box display="flex" alignItems="center" gap={3}>
|
|
<Icon icon={File} size={5} color="text-gray-400" />
|
|
<Box>
|
|
<Text block size="sm" weight="medium" truncate maxWidth="200px">
|
|
{file.name}
|
|
</Text>
|
|
<Text block size="xs" color="text-gray-500">
|
|
{Math.round(file.size / 1024)} KB
|
|
</Text>
|
|
</Box>
|
|
</Box>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
removeFile(index);
|
|
}}
|
|
p={1}
|
|
h="auto"
|
|
>
|
|
<Icon icon={X} size={4} />
|
|
</Button>
|
|
</Box>
|
|
))}
|
|
</Box>
|
|
)}
|
|
</Box>
|
|
);
|
|
}
|