veza/apps/web/src/components/ui/file-upload/useFileUpload.ts
senke bfe3d17927 refactor(ui): decompose FileUpload into file-upload module
- 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>
2026-02-05 21:09:08 +01:00

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,
};
}