Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 9 additions & 4 deletions frontend/src/components/FileUploadArea.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ const FileUploadArea = ({
handleDrop,
handleAreaClick,
accept = "image/*",
multiple = false,
files = [],
inputId = "file-input",
defaultIcon,
defaultText,
Expand All @@ -23,7 +25,7 @@ const FileUploadArea = ({
return (
<div
ref={dropAreaRef}
className={`w-full border-2 border-dashed rounded-2xl p-8 mb-8 cursor-pointer transition-transform duration-300 flex flex-col items-center select-none ${
className={`w-full border-2 border-dashed rounded-2xl p-8 mb-8 cursor-pointer transition-transform duration-300 flex flex-col items-center select-none focus-within:ring-2 focus-within:ring-[var(--color-app-primary)] ${
isDragging
? "border-[var(--color-app-primary)] bg-[var(--color-app-surface-soft)] scale-[1.02]"
: "border-[var(--color-app-border-strong)] bg-[var(--color-app-surface)] hover:border-[var(--color-app-primary)] hover:-translate-y-1 hover:shadow-[0_8px_15px_rgba(67,97,238,0.1)] hover:bg-[var(--color-app-surface-soft)] active:translate-y-0 active:shadow-[0_4px_8px_rgba(67,97,238,0.08)]"
Expand All @@ -37,10 +39,11 @@ const FileUploadArea = ({
<input
type="file"
accept={accept}
multiple={multiple}
onChange={handleFileChange}
id={inputId}
ref={fileInputRef}
className="hidden"
className="sr-only"
/>
<label
htmlFor={inputId}
Expand Down Expand Up @@ -127,9 +130,11 @@ const FileUploadArea = ({
</div>
<div
className="bg-[var(--color-app-surface-soft)] px-4 py-2 rounded-lg mt-4 text-[#0369a1] dark:text-sky-300 font-semibold shadow-[0_2px_5px_rgba(0,0,0,0.05)] border-l-[3px] border-[#0ea5e9] dark:border-sky-500 max-w-full overflow-hidden text-ellipsis whitespace-nowrap"
title={file.name}
title={files && files.length > 1 ? `${files.length} files selected` : file.name}
>
{file.name.length > 30
{files && files.length > 1
? `${files.length} files selected`
: file.name.length > 30
? `${file.name.substring(0, 27)}...`
: file.name}
</div>
Expand Down
125 changes: 78 additions & 47 deletions frontend/src/hooks/useFileUpload.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,21 @@ import { toastError, toastInfo } from "../utils/toast";
/**
* Custom hook for handling file uploads, previews, and drag-and-drop logic.
* @param {Function} validateFile - Callback to validate the selected file. Should return { isValid: boolean, message: string }.
* @param {Object} options - Configuration options.
* @param {number} options.maxSize - Maximum file size in bytes (default: 10 MB).
* @param {number} options.maxFiles - Maximum number of files allowed (default: 1).
* @param {boolean} options.multiple - Whether to allow multiple file uploads (default: false).
*/
export const useFileUpload = (validateFile) => {
const [file, setFile] = useState(null);
export const useFileUpload = (validateFile, options = {}) => {
const {
maxSize = 10 * 1024 * 1024,
maxFiles = 1,
multiple = false,
} = options;

const [file, setFile] = useState(null); // Keeps first file for backward compatibility
const [files, setFiles] = useState([]); // Array for multi-file support

const [loading, setLoading] = useState(false);
const [isDragging, setIsDragging] = useState(false);
const [statusMessage, setStatusMessage] = useState("");
Expand All @@ -27,6 +39,7 @@ export const useFileUpload = (validateFile) => {
(e) => {
if (e) e.stopPropagation();
setFile(null);
setFiles([]);
if (previewUrl) {
URL.revokeObjectURL(previewUrl);
setPreviewUrl(null);
Expand All @@ -39,65 +52,80 @@ export const useFileUpload = (validateFile) => {
[previewUrl],
);

const processFile = useCallback(
async (selectedFile) => {
if (!selectedFile) return;

// 1. File size limit check (10 MB) — toast instead of inline message
const MAX_SIZE = 10 * 1024 * 1024;
if (selectedFile.size > MAX_SIZE) {
toastError(
`File "${selectedFile.name}" exceeds the 10 MB size limit. Please choose a smaller file.`,
);
const processFiles = useCallback(
async (selectedFilesArray) => {
if (!selectedFilesArray || selectedFilesArray.length === 0) return;

const newFiles = multiple ? Array.from(selectedFilesArray) : [selectedFilesArray[0]];

if (multiple && files.length + newFiles.length > maxFiles) {
toastError(`You can only upload up to ${maxFiles} files.`);
return;
}

// 2. Async validation (type check, PDF page load, etc.)
const validation = await validateFile(selectedFile);
const validFiles = [];
for (const f of newFiles) {
if (f.size > maxSize) {
toastError(`File "${f.name}" exceeds the limit. Please choose a smaller file.`);
continue;
}

const validation = await validateFile(f);
if (validation.isValid) {
validFiles.push({ file: f, message: validation.message });
} else {
toastError(validation.message || "Invalid file type. Please select a supported format.");
}
}

if (validation.isValid) {
setFile(selectedFile);
if (validFiles.length > 0) {
const firstValid = validFiles[0].file;
setFile(firstValid);
setFiles(prev => multiple ? [...prev, ...validFiles.map(v => v.file)] : [firstValid]);

// 3. Image and PDF preview logic
if (selectedFile.type.startsWith("image/")) {
if (previewUrl) URL.revokeObjectURL(previewUrl);
const url = URL.createObjectURL(selectedFile);
setPreviewUrl(url);
// Inline status — shows filename inside the upload area widget
setStatusMessage(
validation.message || `File "${selectedFile.name}" selected`,
);
} else if (selectedFile.type === "application/pdf") {
if (firstValid.type.startsWith("image/") || firstValid.type === "application/pdf") {
if (previewUrl) URL.revokeObjectURL(previewUrl);
const url = URL.createObjectURL(selectedFile);
setPreviewUrl(url);
setStatusMessage(
validation.message || `File "${selectedFile.name}" selected`,
);
setPreviewUrl(URL.createObjectURL(firstValid));
} else {
setPreviewUrl(null);
setStatusMessage(
validation.message || `File "${selectedFile.name}" selected`,
);
// Toast confirmation for non-image/pdf types (docx, etc.)
toastInfo(validation.message || `File "${selectedFile.name}" ready`);
toastInfo(validFiles[0].message || `File ready`);
}

if (multiple && validFiles.length > 1) {
setStatusMessage(`${validFiles.length} files selected`);
} else {
setStatusMessage(validFiles[0].message || `File "${firstValid.name}" selected`);
}
} else {
// Invalid file type — dismissable error toast
toastError(
validation.message ||
"Invalid file type. Please select a supported format.",
);
}
},
[validateFile, previewUrl],
[validateFile, previewUrl, multiple, maxSize, maxFiles, files]
);

const processFile = useCallback(
(selectedFile) => processFiles([selectedFile]),
[processFiles]
);

const handleFileChange = (e) => {
const selectedFile = e.target.files[0];
processFile(selectedFile);
processFiles(e.target.files);
};

// Clipboard paste support
useEffect(() => {
const handlePaste = (e) => {
// Prevent handling paste if user is typing in an input or textarea
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;

const clipboardFiles = e.clipboardData?.files;
if (clipboardFiles && clipboardFiles.length > 0) {
processFiles(clipboardFiles);
}
};

window.addEventListener("paste", handlePaste);
return () => window.removeEventListener("paste", handlePaste);
}, [processFiles]);

const handleDragEnter = (e) => {
e.preventDefault();
e.stopPropagation();
Expand Down Expand Up @@ -125,11 +153,11 @@ export const useFileUpload = (validateFile) => {
setIsDragging(false);

if (e.dataTransfer.files && e.dataTransfer.files.length > 0) {
processFile(e.dataTransfer.files[0]);
processFiles(e.dataTransfer.files);
e.dataTransfer.clearData();
}
},
[processFile],
[processFiles],
);

const handleAreaClick = (e) => {
Expand All @@ -146,7 +174,9 @@ export const useFileUpload = (validateFile) => {

return {
file,
files,
setFile,
setFiles,
loading,
setLoading,
isDragging,
Expand All @@ -164,5 +194,6 @@ export const useFileUpload = (validateFile) => {
handleDrop,
handleAreaClick,
processFile,
processFiles,
};
};