- Add file-upload/ with useFileUpload, types, utils, and presentational components: FileUploadDropzone, FileUploadErrorList, FileUploadFileList - Remove monolithic file-upload.tsx; entry point is file-upload/index.ts - Backward compatible: imports from './file-upload' resolve to module Co-authored-by: Cursor <cursoragent@cursor.com>
145 lines
4.2 KiB
TypeScript
145 lines
4.2 KiB
TypeScript
import { useState, useRef, useCallback } from 'react';
|
|
import type { FileUploadProps } from './types';
|
|
import type { FileWithPreview } from './types';
|
|
import { formatFileSize } from './utils';
|
|
import { createPreview } from './utils';
|
|
|
|
export function useFileUpload({
|
|
onFileSelect,
|
|
accept,
|
|
multiple = false,
|
|
maxSize,
|
|
showPreview = true,
|
|
disabled = false,
|
|
}: FileUploadProps) {
|
|
const [dragActive, setDragActive] = useState(false);
|
|
const [files, setFiles] = useState<FileWithPreview[]>([]);
|
|
const [errors, setErrors] = useState<string[]>([]);
|
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
|
|
const validateFile = useCallback(
|
|
(file: File): string | null => {
|
|
if (accept) {
|
|
const acceptedTypes = accept.split(',').map((t) => t.trim());
|
|
const fileExtension = `.${file.name.split('.').pop()?.toLowerCase()}`;
|
|
const fileType = file.type.toLowerCase();
|
|
const isAccepted = acceptedTypes.some((type) => {
|
|
if (type.startsWith('.')) return type.toLowerCase() === fileExtension;
|
|
if (type.includes('/')) {
|
|
return (
|
|
fileType === type.toLowerCase() ||
|
|
fileType.startsWith(`${type.toLowerCase().split('/')[0]}/`)
|
|
);
|
|
}
|
|
return false;
|
|
});
|
|
if (!isAccepted) {
|
|
return `File type ${file.type || 'unknown'} is not allowed. Accepted types: ${accept}`;
|
|
}
|
|
}
|
|
if (maxSize && file.size > maxSize) {
|
|
return `File size ${formatFileSize(file.size)} exceeds maximum size ${formatFileSize(maxSize)}`;
|
|
}
|
|
return null;
|
|
},
|
|
[accept, maxSize],
|
|
);
|
|
|
|
const processFiles = useCallback(
|
|
async (fileList: File[]) => {
|
|
const newErrors: string[] = [];
|
|
const validFiles: FileWithPreview[] = [];
|
|
const filesToProcess: File[] = [];
|
|
|
|
fileList.forEach((file) => {
|
|
const error = validateFile(file);
|
|
if (error) newErrors.push(`${file.name}: ${error}`);
|
|
else filesToProcess.push(file);
|
|
});
|
|
|
|
for (const file of filesToProcess) {
|
|
const fileWithPreview = file as FileWithPreview;
|
|
fileWithPreview.status = 'pending';
|
|
fileWithPreview.progress = 0;
|
|
if (showPreview) {
|
|
const preview = await createPreview(file);
|
|
if (preview) fileWithPreview.preview = preview;
|
|
}
|
|
validFiles.push(fileWithPreview);
|
|
}
|
|
|
|
setErrors(newErrors);
|
|
if (validFiles.length > 0) {
|
|
const updatedFiles = multiple ? [...files, ...validFiles] : validFiles;
|
|
setFiles(updatedFiles);
|
|
onFileSelect(updatedFiles.slice() as File[]);
|
|
}
|
|
},
|
|
[files, multiple, showPreview, onFileSelect, validateFile],
|
|
);
|
|
|
|
const handleDrag = useCallback((e: React.DragEvent) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
if (e.type === 'dragenter' || e.type === 'dragover') setDragActive(true);
|
|
else if (e.type === 'dragleave') setDragActive(false);
|
|
}, []);
|
|
|
|
const handleDrop = useCallback(
|
|
(e: React.DragEvent) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
setDragActive(false);
|
|
if (disabled) return;
|
|
if (e.dataTransfer.files?.length) {
|
|
processFiles(Array.from(e.dataTransfer.files));
|
|
}
|
|
},
|
|
[disabled, processFiles],
|
|
);
|
|
|
|
const handleFileInput = useCallback(
|
|
(e: React.ChangeEvent<HTMLInputElement>) => {
|
|
if (e.target.files?.length) {
|
|
processFiles(Array.from(e.target.files));
|
|
if (fileInputRef.current) fileInputRef.current.value = '';
|
|
}
|
|
},
|
|
[processFiles],
|
|
);
|
|
|
|
const handleRemoveFile = useCallback(
|
|
(index: number) => {
|
|
const updatedFiles = files.filter((_, i) => i !== index);
|
|
setFiles(updatedFiles);
|
|
onFileSelect(
|
|
updatedFiles.map((f) => {
|
|
const { preview, status, progress, error, ...file } = f;
|
|
return file as File;
|
|
}),
|
|
);
|
|
},
|
|
[files, onFileSelect],
|
|
);
|
|
|
|
const handleClick = useCallback(() => {
|
|
if (!disabled && fileInputRef.current) fileInputRef.current.click();
|
|
}, [disabled]);
|
|
|
|
return {
|
|
dragActive,
|
|
files,
|
|
errors,
|
|
fileInputRef,
|
|
accept,
|
|
multiple,
|
|
maxSize,
|
|
showPreview,
|
|
disabled,
|
|
handleDrag,
|
|
handleDrop,
|
|
handleFileInput,
|
|
handleRemoveFile,
|
|
handleClick,
|
|
};
|
|
}
|