useFileUpload.ts•10.6 kB
import { computed, ref } from 'vue';
export interface FileUploadOptions {
maxFileSize?: number;
acceptedTypes?: string[];
accept?: string;
multiple?: boolean;
webkitdirectory?: boolean;
validateFile?: (file: File) => { valid: boolean; error?: string };
validateFiles?: (files: File[]) => { valid: boolean; error?: string };
preprocessFiles?: (files: File[], folderName?: string) => Promise<File>;
}
export interface FileUploadConfig {
mode: 'file' | 'folder';
placeholder: string;
icon: 'file' | 'folder';
options: FileUploadOptions;
}
export function useFileUpload(config: FileUploadConfig) {
const {
maxFileSize = 10 * 1024 * 1024, // 10MB default
acceptedTypes = [],
accept = '',
multiple = false,
webkitdirectory = false,
validateFile,
validateFiles,
preprocessFiles,
} = config.options;
// Reactive state
const fileInput = ref<HTMLInputElement | null>(null);
const dragActive = ref(false);
const selectedItem = ref<string | null>(null);
const errorMessage = ref<string | null>(null);
const isProcessing = ref(false);
// Computed
const hasError = computed(() => !!errorMessage.value);
const hasSelection = computed(() => !!selectedItem.value);
const isValid = computed(() => hasSelection.value && !hasError.value);
// Default file validation
function defaultValidateFile(file: File): { valid: boolean; error?: string } {
if (acceptedTypes.length > 0) {
const isValidType = acceptedTypes.some((type) => {
if (type.startsWith('.')) {
return file.name.toLowerCase().endsWith(type.toLowerCase());
}
return file.type === type;
});
if (!isValidType) {
return {
valid: false,
error: `Please upload a ${acceptedTypes.join(' or ')} file`,
};
}
}
if (file.size > maxFileSize) {
const sizeMB = (file.size / (1024 * 1024)).toFixed(1);
const limitMB = (maxFileSize / (1024 * 1024)).toFixed(0);
return {
valid: false,
error: `File size (${sizeMB}MB) exceeds the ${limitMB}MB limit`,
};
}
return { valid: true };
}
// Default files validation (for folder/multiple files)
function defaultValidateFiles(files: File[]): { valid: boolean; error?: string } {
if (files.length === 0) {
return { valid: false, error: 'No files found' };
}
const totalSize = files.reduce((sum, file) => sum + file.size, 0);
if (totalSize > maxFileSize) {
const sizeMB = (totalSize / (1024 * 1024)).toFixed(1);
const limitMB = (maxFileSize / (1024 * 1024)).toFixed(0);
return {
valid: false,
error: `Total size (${sizeMB}MB) exceeds the ${limitMB}MB limit`,
};
}
return { valid: true };
}
// Clear error and selection
function clearError() {
errorMessage.value = null;
}
function clearSelection() {
selectedItem.value = null;
errorMessage.value = null;
// Clear file input to prevent re-selection issues
if (fileInput.value) {
fileInput.value.value = '';
}
}
// Validate and process files
async function processFiles(
files: File[],
folderName?: string,
): Promise<{ success: boolean; result?: File; error?: string }> {
clearError();
isProcessing.value = true;
try {
// Validation
const validator =
config.mode === 'folder' || multiple
? validateFiles || defaultValidateFiles
: validateFile || defaultValidateFile;
let validationResult: { valid: boolean; error?: string };
if (config.mode === 'folder' || multiple) {
validationResult = (validator as typeof defaultValidateFiles)(files);
} else {
validationResult = (validator as typeof defaultValidateFile)(files[0]);
}
if (!validationResult.valid) {
errorMessage.value = validationResult.error || 'Validation failed';
return { success: false, error: validationResult.error };
}
// Preprocessing (e.g., ZIP creation for folders)
let resultFile: File;
if (preprocessFiles) {
resultFile = await preprocessFiles(files, folderName);
} else {
if ((config.mode === 'folder' || multiple) && files.length > 1) {
throw new Error('Multiple files require a preprocessor function');
}
resultFile = files[0];
}
// Update selection
selectedItem.value = folderName || resultFile.name;
// Clear file input to prevent re-selection issues
if (fileInput.value) {
fileInput.value.value = '';
}
return { success: true, result: resultFile };
} catch (error) {
const errorMsg = error instanceof Error ? error.message : 'Processing failed';
errorMessage.value = errorMsg;
return { success: false, error: errorMsg };
} finally {
isProcessing.value = false;
}
}
// Handle file input selection
async function handleFileSelect(
files: FileList | null,
): Promise<{ success: boolean; result?: File; error?: string }> {
if (!files || files.length === 0) {
return { success: false, error: 'No files selected' };
}
const fileArray = Array.from(files);
let folderName: string | undefined;
if (config.mode === 'folder' && files[0].webkitRelativePath) {
folderName = files[0].webkitRelativePath.split('/')[0];
}
return await processFiles(fileArray, folderName);
}
// Handle drag and drop
function handleDragOver(event: DragEvent) {
event.preventDefault();
dragActive.value = true;
}
function handleDragLeave() {
dragActive.value = false;
}
async function handleDrop(event: DragEvent): Promise<{ success: boolean; result?: File; error?: string }> {
event.preventDefault();
dragActive.value = false;
if (config.mode === 'folder') {
return await handleFolderDrop(event);
}
return await handleFileSelect(event.dataTransfer?.files || null);
}
// Specialized folder drop handling
async function handleFolderDrop(event: DragEvent): Promise<{ success: boolean; result?: File; error?: string }> {
if (!event.dataTransfer?.items?.length) {
return { success: false, error: 'No items found' };
}
// Check directory reading capability
if (!('webkitGetAsEntry' in DataTransferItem.prototype)) {
const error = "Your browser doesn't support folder drop. Please use the browse button instead.";
errorMessage.value = error;
return { success: false, error };
}
const entry = event.dataTransfer.items[0].webkitGetAsEntry();
if (!entry?.isDirectory) {
const error = 'Please drop a folder, not a file.';
errorMessage.value = error;
return { success: false, error };
}
try {
const files = await collectFilesFromEntry(entry);
return await processFiles(files, entry.name);
} catch {
const errorMsg = 'Failed to process the folder. Please try again or use the browse button.';
errorMessage.value = errorMsg;
return { success: false, error: errorMsg };
}
}
// Constants for safety limits
const MAX_DEPTH = 20;
const MAX_FILES = 10000;
// Helper functions for folder processing
async function collectFilesFromEntry(
entry: FileSystemEntry,
path = '',
depth = 0,
fileCount = { current: 0 },
): Promise<File[]> {
// Check depth limit
if (depth > MAX_DEPTH) {
throw new Error(`Directory structure too deep (max depth: ${MAX_DEPTH})`);
}
// Check file count limit
if (fileCount.current > MAX_FILES) {
throw new Error(`Too many files in directory structure (max files: ${MAX_FILES})`);
}
if (entry.isFile) {
return new Promise((resolve, reject) => {
(entry as FileSystemFileEntry).file((file: File) => {
// Check file count before adding
if (fileCount.current >= MAX_FILES) {
reject(new Error(`Too many files in directory structure (max files: ${MAX_FILES})`));
return;
}
fileCount.current++;
const customFile = new File([file], file.name, {
type: file.type,
lastModified: file.lastModified,
});
Object.defineProperty(customFile, 'webkitRelativePath', {
value: path ? `${path}/${file.name}` : file.name,
});
resolve([customFile]);
}, reject);
});
}
if (entry.isDirectory && (entry as FileSystemDirectoryEntry).createReader) {
return new Promise((resolve, reject) => {
const dirReader = (entry as FileSystemDirectoryEntry).createReader();
const allFiles: File[] = [];
function readEntries() {
dirReader.readEntries(async (entries: FileSystemEntry[]) => {
if (entries.length === 0) {
resolve(allFiles);
} else {
try {
for (const childEntry of entries) {
// Check limits before processing each entry
if (depth + 1 > MAX_DEPTH) {
throw new Error(`Directory structure too deep (max depth: ${MAX_DEPTH})`);
}
if (fileCount.current > MAX_FILES) {
throw new Error(`Too many files in directory structure (max files: ${MAX_FILES})`);
}
const newPath = path ? `${path}/${childEntry.name}` : childEntry.name;
const files = await collectFilesFromEntry(childEntry, newPath, depth + 1, fileCount);
allFiles.push(...files);
}
readEntries();
} catch (error) {
reject(error);
}
}
}, reject);
}
readEntries();
});
}
return [];
}
// Trigger file input
function triggerFileInput() {
fileInput.value?.click();
}
// Input attributes for template
const inputAttributes = computed(() => ({
type: 'file',
accept,
multiple,
webkitdirectory: webkitdirectory || config.mode === 'folder',
...(config.mode === 'folder' && {
directory: true,
mozdirectory: true,
}),
}));
return {
// Refs
fileInput,
dragActive,
selectedItem,
errorMessage,
isProcessing,
// Computed
hasError,
hasSelection,
isValid,
inputAttributes,
// Methods
handleFileSelect,
handleDragOver,
handleDragLeave,
handleDrop,
triggerFileInput,
clearError,
clearSelection,
processFiles,
};
}