Files
srbhr--resume-matcher/apps/frontend/hooks/use-file-upload.ts
T
wehub-resource-sync 5bdf4cc89a
Publish Docker Image / publish (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:39:36 +08:00

630 lines
22 KiB
TypeScript

'use client';
import type React from 'react';
import {
useCallback,
useRef,
useState,
type ChangeEvent,
type DragEvent,
type InputHTMLAttributes,
} from 'react';
export type FileMetadata = {
name: string;
size: number;
type: string;
url: string; // Can be server URL post-upload or preview URL
id: string; // Should be unique identifier for the file entry
uploaded?: boolean; // To track successful upload
uploadError?: string; // To store upload specific error
};
export type FileWithPreview = {
file: File | FileMetadata; // Can be File initially, then FileMetadata post-upload
id: string; // Unique ID for this FileWithPreview item
preview?: string; // URL for preview, e.g., object URL for images
};
export type FileUploadOptions = {
maxFiles?: number;
maxSize?: number; // in bytes
accept?: string; // Comma-separated string of accepted file types
multiple?: boolean;
initialFiles?: FileMetadata[]; // To initialize with already uploaded files
onFilesChange?: (files: FileWithPreview[]) => void;
onFilesAdded?: (addedFiles: FileWithPreview[]) => void; // Called with newly added valid files
onUploadSuccess?: (uploadedFile: FileWithPreview, response: Record<string, unknown>) => void;
onUploadError?: (file: FileWithPreview, error: string) => void;
uploadUrl?: string; // API endpoint for uploading
};
export type FileUploadState = {
files: FileWithPreview[];
isDragging: boolean;
errors: string[]; // For validation or general errors
isUploadingGlobal: boolean; // Global flag for ongoing upload(s)
};
export type FileUploadActions = {
addFiles: (files: FileList | File[]) => void; // Public API to add and initiate upload
removeFile: (id: string) => void;
clearFiles: () => void;
clearErrors: () => void;
handleDragEnter: (e: DragEvent<HTMLElement>) => void;
handleDragLeave: (e: DragEvent<HTMLElement>) => void;
handleDragOver: (e: DragEvent<HTMLElement>) => void;
handleDrop: (e: DragEvent<HTMLElement>) => void;
// handleFileChange is usually internal, triggered by getInputProps
openFileDialog: () => void;
getInputProps: (
props?: InputHTMLAttributes<HTMLInputElement>
) => InputHTMLAttributes<HTMLInputElement> & {
ref: React.Ref<HTMLInputElement>;
};
};
export const formatBytes = (bytes: number, decimals = 2): string => {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const dm = decimals < 0 ? 0 : decimals;
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return `${parseFloat((bytes / k ** i).toFixed(dm))} ${sizes[i]}`;
};
export const useFileUpload = (
options: FileUploadOptions = {}
): [FileUploadState, FileUploadActions] => {
const {
maxFiles = Infinity,
maxSize = Infinity,
accept = '*/*', // More common default
multiple = false,
initialFiles = [],
onFilesChange,
onFilesAdded,
onUploadSuccess,
onUploadError,
uploadUrl,
} = options;
const [state, setState] = useState<FileUploadState>({
files: initialFiles.map((fileMeta) => ({
// initialFiles are FileMetadata
file: fileMeta,
id: fileMeta.id, // Ensure initialFiles have an id
preview: fileMeta.url, // Use url from FileMetadata as preview
})),
isDragging: false,
errors: [],
isUploadingGlobal: false,
});
const inputRef = useRef<HTMLInputElement>(null);
const inFlightUploadsRef = useRef(0);
const markUploadStarted = useCallback(() => {
const nextCount = inFlightUploadsRef.current + 1;
inFlightUploadsRef.current = nextCount;
if (nextCount === 1) {
setState((prev) => (prev.isUploadingGlobal ? prev : { ...prev, isUploadingGlobal: true }));
}
}, []);
const markUploadFinished = useCallback(() => {
const nextCount = Math.max(0, inFlightUploadsRef.current - 1);
inFlightUploadsRef.current = nextCount;
if (nextCount === 0) {
setState((prev) => (prev.isUploadingGlobal ? { ...prev, isUploadingGlobal: false } : prev));
}
}, []);
const validateFile = useCallback(
(file: File): string | null => {
// Simplified: always validates a File object
if (file.size > maxSize) {
return `File "${file.name}" exceeds the maximum size of ${formatBytes(maxSize)}.`;
}
if (accept !== '*/*' && accept !== '*') {
const acceptedTypes = accept.split(',').map((type) => type.trim().toLowerCase());
const fileType = file.type.toLowerCase();
const fileName = file.name.toLowerCase();
const fileExtension = `.${fileName.split('.').pop()}`;
const isAccepted = acceptedTypes.some((type) => {
if (type.startsWith('.')) {
// e.g., .pdf
return fileExtension === type;
}
if (type.endsWith('/*')) {
// e.g., image/*
const baseType = type.slice(0, -2); // image
return fileType.startsWith(`${baseType}/`);
}
return fileType === type; // e.g., application/pdf
});
if (!isAccepted) {
return `File "${file.name}" type not accepted. Accepted types: ${accept}`;
}
}
return null;
},
[accept, maxSize]
);
const createPreview = useCallback((file: File): string | undefined => {
if (file.type.startsWith('image/')) {
return URL.createObjectURL(file);
}
return undefined;
}, []);
const generateUniqueId = useCallback((file: File): string => {
return `${file.name}-${file.size}-${file.lastModified}-${Math.random().toString(36).substring(2, 9)}`;
}, []);
const uploadFileInternal = useCallback(
async (fileToUpload: FileWithPreview) => {
// Ensure fileToUpload.file is a File instance for upload
if (!(fileToUpload.file instanceof File)) {
const errorMsg = `Cannot upload "${(fileToUpload.file as FileMetadata).name}"; it's not a valid file object for direct upload.`;
console.error(errorMsg, fileToUpload);
// Update this specific file's metadata with an error
const updatedFileWithMetaError: FileWithPreview = {
...fileToUpload,
file: {
...(fileToUpload.file as FileMetadata), // Keep existing metadata
uploadError: errorMsg,
uploaded: false,
},
};
setState((prev) => ({
...prev,
files: prev.files.map((f) =>
f.id === updatedFileWithMetaError.id ? updatedFileWithMetaError : f
),
errors: [...prev.errors, errorMsg], // Add to general errors too
}));
onUploadError?.(updatedFileWithMetaError, errorMsg);
return;
}
if (!uploadUrl) {
const errorMsg = 'Upload URL is not configured.';
console.warn(errorMsg, 'File not uploaded:', fileToUpload.file.name);
// Update file metadata to reflect it wasn't uploaded due to config
const fileWithConfigError: FileWithPreview = {
...fileToUpload,
file: {
// Convert to FileMetadata with error
name: fileToUpload.file.name,
size: fileToUpload.file.size,
type: fileToUpload.file.type,
id: fileToUpload.id,
url: fileToUpload.preview || '',
uploaded: false,
uploadError: errorMsg,
},
};
setState((prev) => ({
...prev,
files: prev.files.map((f) => (f.id === fileWithConfigError.id ? fileWithConfigError : f)),
errors: [...prev.errors, errorMsg],
}));
onUploadError?.(fileWithConfigError, errorMsg);
return;
}
const formData = new FormData();
formData.append('file', fileToUpload.file); // FastAPI expects 'file' field
markUploadStarted();
try {
const response = await fetch(uploadUrl, {
method: 'POST',
body: formData,
});
let responseData: Record<string, unknown> = {}; // Initialize for broader scope
const contentType = response.headers.get('content-type');
if (!response.ok) {
let errorDetail = `Upload failed for ${fileToUpload.file.name}. Status: ${response.status} ${response.statusText}`;
try {
const errorText = await response.text();
errorDetail += ` - Server response: ${errorText.substring(0, 200)}${errorText.length > 200 ? '...' : ''}`;
} catch (textError: unknown) {
console.warn('Could not read error response text:', textError);
}
throw new Error(errorDetail);
}
if (contentType && contentType.includes('application/json')) {
responseData = (await response.json()) as Record<string, unknown>;
} else {
// Handle non-JSON or missing Content-Type response if necessary,
// or assume success if response.ok and no JSON is expected for some cases.
// For now, we'll assume JSON is expected on success.
console.warn(
`Response for ${fileToUpload.file.name} was not JSON. Content-Type: ${contentType}`
);
// If JSON is strictly required, this could be an error condition:
// throw new Error(`Unexpected response type: ${contentType}. Expected JSON.`);
}
const successfullyUploadedFile: FileWithPreview = {
...fileToUpload,
file: {
// This is FileMetadata
name: fileToUpload.file.name,
size: fileToUpload.file.size,
type: fileToUpload.file.type,
id: fileToUpload.id, // Use existing FileWithPreview ID as FileMetadata ID
url:
typeof responseData.file_url === 'string'
? responseData.file_url // Assuming server returns 'file_url'
: typeof responseData.url === 'string'
? responseData.url
: fileToUpload.preview || '',
uploaded: true,
},
};
setState((prev) => {
const updatedFiles = prev.files.map((f) =>
f.id === successfullyUploadedFile.id ? successfullyUploadedFile : f
);
onUploadSuccess?.(successfullyUploadedFile, responseData);
return { ...prev, files: updatedFiles };
});
} catch (error: unknown) {
const errorMessage =
error instanceof Error
? error.message
: `Error uploading ${(fileToUpload.file as File).name}.`;
const fileWithError: FileWithPreview = {
...fileToUpload,
file: {
// This is FileMetadata
name: (fileToUpload.file as File).name,
size: (fileToUpload.file as File).size,
type: (fileToUpload.file as File).type,
id: fileToUpload.id,
url: fileToUpload.preview || '',
uploaded: false,
uploadError: errorMessage,
},
};
setState((prev) => {
const updatedFiles = prev.files.map((f) =>
f.id === fileWithError.id ? fileWithError : f
);
// Add to general errors in addition to specific file error
const newErrors = prev.errors.filter((e) => !e.includes(fileWithError.file.name)); // Avoid duplicate general messages for the same file
newErrors.push(errorMessage);
onUploadError?.(fileWithError, errorMessage);
return { ...prev, files: updatedFiles, errors: newErrors };
});
} finally {
markUploadFinished();
}
},
[markUploadFinished, markUploadStarted, onUploadError, onUploadSuccess, uploadUrl]
);
const addFilesAndUpload = useCallback(
(newFilesInput: FileList | File[]) => {
if (state.isUploadingGlobal && !multiple) return; // Don't add if already uploading (single mode)
if (!newFilesInput || newFilesInput.length === 0) return;
const newFilesArray = Array.from(newFilesInput);
const currentValidationErrors: string[] = []; // Local to this call
// For single file mode, if a file already exists (even if being uploaded or failed), replace it.
if (!multiple && state.files.length > 0) {
state.files.forEach((fwp) => {
// Revoke old preview
if (fwp.preview && fwp.file instanceof File && fwp.file.type.startsWith('image/')) {
URL.revokeObjectURL(fwp.preview);
}
});
setState((prev) => ({ ...prev, files: [], errors: [] })); // Clear existing files and their errors
} else {
setState((prev) => ({ ...prev, errors: [] })); // Clear general errors for new batch
}
if (!multiple && newFilesArray.length > 1) {
currentValidationErrors.push('Please select only one file.');
setState((prev) => ({ ...prev, errors: currentValidationErrors }));
if (inputRef.current) inputRef.current.value = '';
return;
}
if (
multiple &&
maxFiles !== Infinity &&
state.files.length + newFilesArray.length > maxFiles
) {
currentValidationErrors.push(`You can only upload a maximum of ${maxFiles} files.`);
setState((prev) => ({ ...prev, errors: [...prev.errors, ...currentValidationErrors] }));
if (inputRef.current) inputRef.current.value = '';
return;
}
const filesToProcess: FileWithPreview[] = [];
for (const file of newFilesArray) {
if (!(file instanceof File)) continue; // Skip if not a File object
// Duplicate check (more robust for multiple additions)
const isDuplicate =
!multiple && state.files.length > 0
? false // In single mode, we already cleared
: state.files.some(
(existingFwp) =>
existingFwp.file.name === file.name &&
existingFwp.file.size === file.size &&
(existingFwp.file instanceof File
? existingFwp.file.lastModified === file.lastModified
: true)
);
if (isDuplicate) continue;
const validationError = validateFile(file);
if (validationError) {
currentValidationErrors.push(validationError);
} else {
filesToProcess.push({
file, // Actual File object
id: generateUniqueId(file),
preview: createPreview(file),
});
}
}
if (currentValidationErrors.length > 0) {
setState((prev) => ({ ...prev, errors: [...prev.errors, ...currentValidationErrors] }));
if (inputRef.current) inputRef.current.value = '';
return;
}
if (filesToProcess.length > 0) {
const filesToAddUpdate = !multiple ? filesToProcess.slice(0, 1) : filesToProcess;
setState((prev) => {
const updatedFilesState = !multiple
? filesToAddUpdate
: [...prev.files, ...filesToAddUpdate];
onFilesChange?.(updatedFilesState); // Call with the full list
onFilesAdded?.(filesToAddUpdate); // Call with only the newly added valid files
return {
...prev,
files: updatedFilesState,
};
});
if (uploadUrl) {
filesToAddUpdate.forEach((fileToUpload) => uploadFileInternal(fileToUpload));
} else {
console.warn('uploadUrl not provided. Files added locally but not uploaded.');
// If no uploadUrl, mark files as 'pending' or similar if needed, or convert to FileMetadata locally
const filesAsMetadataLocally = filesToAddUpdate.map((fwp) => ({
...fwp,
file: {
// Convert to FileMetadata to indicate they are processed by the hook
name: (fwp.file as File).name,
size: (fwp.file as File).size,
type: (fwp.file as File).type,
id: fwp.id,
url: fwp.preview || '', // Use preview as URL if no upload
uploaded: false, // Not uploaded
uploadError: 'Upload URL not configured',
} as FileMetadata,
}));
setState((prev) => {
const updatedFiles = prev.files.map((existingFile) => {
const found = filesAsMetadataLocally.find(
(processedFile) => processedFile.id === existingFile.id
);
return found || existingFile;
});
return { ...prev, files: updatedFiles };
});
}
}
if (inputRef.current) {
inputRef.current.value = '';
}
},
[
state.isUploadingGlobal,
state.files, // Critical for logic within
multiple,
maxFiles,
uploadUrl,
validateFile,
generateUniqueId,
createPreview, // Other useCallback deps
onFilesChange,
onFilesAdded, // Callbacks
uploadFileInternal,
// setState itself is stable.
]
);
const removeFile = useCallback(
(id: string) => {
setState((prev) => {
const fileToRemove = prev.files.find((file) => file.id === id);
if (
fileToRemove?.preview &&
fileToRemove.file instanceof File &&
fileToRemove.file.type.startsWith('image/')
) {
URL.revokeObjectURL(fileToRemove.preview);
}
const newFiles = prev.files.filter((file) => file.id !== id);
onFilesChange?.(newFiles);
let updatedErrors = prev.errors;
if (fileToRemove?.file?.name) {
// Remove errors specifically mentioning this file by name
updatedErrors = prev.errors.filter((err) => !err.includes(fileToRemove.file.name));
}
// If all files are removed, clear all errors
if (newFiles.length === 0) {
updatedErrors = [];
}
if (inputRef.current && newFiles.length === 0) {
inputRef.current.value = '';
}
return {
...prev,
files: newFiles,
errors: updatedErrors,
};
});
},
[onFilesChange] // setState is stable
);
const clearFiles = useCallback(() => {
inFlightUploadsRef.current = 0;
setState((prev) => {
prev.files.forEach((fwp) => {
if (fwp.preview && fwp.file instanceof File && fwp.file.type.startsWith('image/')) {
URL.revokeObjectURL(fwp.preview);
}
});
if (inputRef.current) {
inputRef.current.value = '';
}
const newState = { ...prev, files: [], errors: [], isUploadingGlobal: false };
onFilesChange?.(newState.files);
return newState;
});
}, [onFilesChange]); // setState is stable
const clearErrors = useCallback(() => {
setState((prev) => ({ ...prev, errors: [] }));
}, []); // setState is stable
const handleDragEnter = useCallback(
(e: DragEvent<HTMLElement>) => {
e.preventDefault();
e.stopPropagation();
if (state.isUploadingGlobal && !multiple) return;
if (!multiple && state.files.length > 0) return; // Don't allow drag if single file already present
setState((prev) => ({ ...prev, isDragging: true }));
},
[state.isUploadingGlobal, state.files.length, multiple]
);
const handleDragLeave = useCallback(
(e: DragEvent<HTMLElement>) => {
e.preventDefault();
e.stopPropagation();
if (state.isUploadingGlobal && !multiple) return;
if (e.currentTarget.contains(e.relatedTarget as Node)) {
return;
}
setState((prev) => ({ ...prev, isDragging: false }));
},
[state.isUploadingGlobal, multiple]
);
const handleDragOver = useCallback(
(e: DragEvent<HTMLElement>) => {
e.preventDefault();
e.stopPropagation();
if (state.isUploadingGlobal && !multiple) {
e.dataTransfer.dropEffect = 'none';
return;
}
if (!multiple && state.files.length > 0) {
e.dataTransfer.dropEffect = 'none';
return;
}
e.dataTransfer.dropEffect = 'copy'; // Explicitly show copy cursor
setState((prev) => ({ ...prev, isDragging: true }));
},
[state.isUploadingGlobal, state.files.length, multiple]
);
const handleDrop = useCallback(
(e: DragEvent<HTMLElement>) => {
e.preventDefault();
e.stopPropagation();
setState((prev) => ({ ...prev, isDragging: false }));
if (state.isUploadingGlobal && !multiple) return;
if (!multiple && state.files.length > 0) return;
if (e.dataTransfer.files && e.dataTransfer.files.length > 0) {
addFilesAndUpload(e.dataTransfer.files); // This is addFilesAndUpload
e.dataTransfer.clearData();
}
},
[addFilesAndUpload, state.isUploadingGlobal, state.files.length, multiple] // addFilesAndUpload is from useCallback
);
const handleFileChange = useCallback(
(e: ChangeEvent<HTMLInputElement>) => {
// isUploadingGlobal check is handled within addFilesAndUpload for single mode
if (e.target.files && e.target.files.length > 0) {
addFilesAndUpload(e.target.files); // This is addFilesAndUpload
}
},
[addFilesAndUpload] // addFilesAndUpload is from useCallback
);
const openFileDialog = useCallback(() => {
if (state.isUploadingGlobal && !multiple) return;
if (!multiple && state.files.length > 0) return; // Prevent opening if single file already selected
if (inputRef.current) {
inputRef.current.click();
}
}, [state.isUploadingGlobal, state.files.length, multiple]);
const getInputProps = useCallback(
(props?: InputHTMLAttributes<HTMLInputElement>) => ({
...props,
ref: inputRef,
type: 'file',
onChange: handleFileChange,
accept: accept,
multiple: multiple,
// Disable if global upload is happening (especially for single mode)
// or if it's single mode and a file is already present.
disabled: (state.isUploadingGlobal && !multiple) || (!multiple && state.files.length > 0),
style: { display: 'none', ...props?.style },
}),
[handleFileChange, accept, multiple, state.isUploadingGlobal, state.files.length]
);
return [
state,
{
addFiles: addFilesAndUpload, // Expose internal addFilesAndUpload as addFiles
removeFile,
clearFiles,
clearErrors,
handleDragEnter,
handleDragLeave,
handleDragOver,
handleDrop,
openFileDialog,
getInputProps,
},
];
};