175 lines
5.1 KiB
TypeScript
175 lines
5.1 KiB
TypeScript
import { Box } from '@/ui/Box';
|
|
import { Icon } from '@/ui/Icon';
|
|
import { IconButton } from '@/ui/IconButton';
|
|
import { ListItem, ListItemActions, ListItemInfo } from '@/ui/ListItem';
|
|
import { Surface } from '@/ui/Surface';
|
|
import { Text } from '@/ui/Text';
|
|
import { AlertCircle, CheckCircle2, Upload, X } from 'lucide-react';
|
|
import React, { useRef, useState } from 'react';
|
|
|
|
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
|
|
fullWidth
|
|
onDragOver={handleDragOver}
|
|
onDragLeave={handleDragLeave}
|
|
onDrop={handleDrop}
|
|
>
|
|
<Surface
|
|
variant="muted"
|
|
rounded="xl"
|
|
padding={8}
|
|
onClick={() => fileInputRef.current?.click()}
|
|
style={{
|
|
border: `2px dashed ${isDragging ? 'var(--ui-color-intent-primary)' : (error ? 'var(--ui-color-intent-critical)' : 'var(--ui-color-border-default)')}`,
|
|
backgroundColor: isDragging ? 'rgba(25, 140, 255, 0.05)' : 'var(--ui-color-bg-surface-muted)',
|
|
textAlign: 'center',
|
|
cursor: 'pointer'
|
|
}}
|
|
>
|
|
<Box
|
|
as="input"
|
|
type="file"
|
|
ref={fileInputRef}
|
|
onChange={handleFileSelect}
|
|
accept={accept}
|
|
multiple={multiple}
|
|
style={{ display: 'none' }}
|
|
/>
|
|
|
|
<Box display="flex" flexDirection="col" alignItems="center" gap={4}>
|
|
<Icon
|
|
icon={isLoading ? Upload : (selectedFiles.length > 0 ? CheckCircle2 : Upload)}
|
|
size={10}
|
|
intent={isDragging ? 'primary' : (error ? 'critical' : 'low')}
|
|
animate={isLoading ? 'pulse' : 'none'}
|
|
/>
|
|
|
|
<Box>
|
|
<Text weight="bold" variant="high" size="lg" block marginBottom={1}>
|
|
{isDragging ? 'Drop files here' : 'Click or drag to upload'}
|
|
</Text>
|
|
|
|
<Text size="sm" variant="low" block>
|
|
{accept ? `Accepted formats: ${accept}` : 'All file types accepted'}
|
|
{maxSize && ` (Max ${Math.round(maxSize / 1024 / 1024)}MB)`}
|
|
</Text>
|
|
</Box>
|
|
|
|
{error && (
|
|
<Box display="flex" alignItems="center" gap={2} marginTop={2}>
|
|
<Icon icon={AlertCircle} size={4} intent="warning" />
|
|
<Text size="sm" variant="warning" weight="medium">{error}</Text>
|
|
</Box>
|
|
)}
|
|
</Box>
|
|
</Surface>
|
|
|
|
{selectedFiles.length > 0 && (
|
|
<Box marginTop={4} display="flex" flexDirection="col" gap={2}>
|
|
{selectedFiles.map((file, index) => (
|
|
<ListItem key={`${file.name}-${index}`}>
|
|
<ListItemInfo
|
|
title={file.name}
|
|
description={`${Math.round(file.size / 1024)} KB`}
|
|
/>
|
|
<ListItemActions>
|
|
<IconButton
|
|
icon={X}
|
|
size="sm"
|
|
variant="ghost"
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
removeFile(index);
|
|
}}
|
|
title="Remove"
|
|
/>
|
|
</ListItemActions>
|
|
</ListItem>
|
|
))}
|
|
</Box>
|
|
)}
|
|
</Box>
|
|
);
|
|
}
|