From 995030a72c7b94bfe341fb5663afe0d4032f5a68 Mon Sep 17 00:00:00 2001 From: ghost2023 Date: Wed, 20 May 2026 16:08:38 +0300 Subject: [PATCH] feat: build a global smart file input field --- .../src/components/SmartFileInput/index.tsx | 426 ++++++++++++++++++ packages/ui-common/src/index.ts | 4 + 2 files changed, 430 insertions(+) create mode 100644 packages/ui-common/src/components/SmartFileInput/index.tsx diff --git a/packages/ui-common/src/components/SmartFileInput/index.tsx b/packages/ui-common/src/components/SmartFileInput/index.tsx new file mode 100644 index 000000000..f025e2465 --- /dev/null +++ b/packages/ui-common/src/components/SmartFileInput/index.tsx @@ -0,0 +1,426 @@ +import React, { useState, useMemo, useRef } from "react"; +import { + IFileUploadSetting, + IFileUploadField, +} from "@edr/types/freight"; +import { + UploadCloud, + FileText, + Image as ImageIcon, + File, + Trash2, + AlertCircle, + CheckCircle2, +} from "lucide-react"; +import { cn } from "../../lib/utils"; +import { Button } from "../button"; + +export interface SmartFileInputProps { + /** The settings object containing features and their upload fields config. */ + file: IFileUploadSetting; + /** Controlled value: maps each fileKey to the selected File or File[]. */ + value?: Record; + /** Callback triggered when any field's files change. */ + onChange?: (value: Record) => void; + /** External form errors mapped by fileKey. */ + errors?: Record; + /** Disabled state for the entire file input group. */ + disabled?: boolean; + /** Display variant style. Default is "default" (large dropzone). Minimal renders a compact upload button. */ + variant?: "default" | "minimal"; + /** Optional custom container CSS classes. */ + className?: string; +} + +/** Helper to format file sizes in bytes to a human-readable string. */ +function formatBytes(bytes: number, decimals = 2) { + if (bytes === 0) return "0 Bytes"; + const k = 1024; + const dm = decimals < 0 ? 0 : decimals; + const sizes = ["Bytes", "KB", "MB", "GB"]; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + " " + sizes[i]; +} + +/** Render a suitable icon based on file extension. */ +function FileIcon({ name, className }: { name: string; className?: string }) { + const ext = name.split(".").pop()?.toLowerCase() || ""; + + if (ext === "pdf") { + return ; + } + + if (["png", "jpg", "jpeg", "webp", "svg", "gif"].includes(ext)) { + return ; + } + + if (["csv", "xls", "xlsx"].includes(ext)) { + return ; + } + + if (["zip", "rar", "tar", "gz", "7z"].includes(ext)) { + return ; + } + + return ; +} + +export function SmartFileInput({ + file, + value, + onChange, + errors, + disabled = false, + variant = "default", + className, +}: SmartFileInputProps) { + // Local state to manage files when the component is used in an uncontrolled manner + const [internalFiles, setInternalFiles] = useState>({}); + + // Local validation errors + const [localErrors, setLocalErrors] = useState>({}); + + // Drag-and-drop state active per field + const [dragActive, setDragActive] = useState>({}); + + // File input refs for programmatic clicks in minimal variant + const fileInputRefs = useRef>({}); + + // Memoize fields sorted by the order property (ascending) + const sortedFields = useMemo(() => { + return [...file.fields].sort((a, b) => a.order - b.order); + }, [file.fields]); + + // Create a map of fields for quick lookup + const fieldsMap = useMemo(() => { + return file.fields.reduce((acc, currentField) => { + acc[currentField.fileKey] = currentField; + return acc; + }, {} as Record); + }, [file.fields]); + + // Resolve current files list for a field + const getFilesForField = (fieldKey: string): File[] => { + const val = value ? value[fieldKey] : internalFiles[fieldKey]; + if (!val) return []; + return Array.isArray(val) ? val : [val]; + }; + + const handleFilesChange = (fieldKey: string, newFiles: File[]) => { + const field = fieldsMap[fieldKey]; + if (!field) return; + + const newValue = field.isMultiple ? newFiles : (newFiles[0] || null); + + if (onChange) { + const updatedValues = { + ...(value || {}), + [fieldKey]: newValue, + }; + onChange(updatedValues); + } else { + setInternalFiles((prev) => ({ + ...prev, + [fieldKey]: newFiles, + })); + } + }; + + const processFiles = (field: IFileUploadField, incomingFiles: File[]) => { + const currentFiles = getFilesForField(field.fileKey); + const maxAllowed = field.isMultiple ? Math.max(1, field.maxFiles) : 1; + + // Clean up extensions (e.g. '.pdf' or 'pdf' -> 'pdf') + const allowedExts = field.allowedExtensions.map((ext) => + ext.toLowerCase().replace(/^\./, "") + ); + + let validIncoming: File[] = []; + let errorMsg = ""; + + for (const fileObj of incomingFiles) { + const ext = fileObj.name.split(".").pop()?.toLowerCase() || ""; + const isExtValid = + allowedExts.length === 0 || allowedExts.includes(ext); + const isSizeValid = fileObj.size <= field.maxSizeMb * 1024 * 1024; + + if (!isExtValid) { + errorMsg = `Invalid file extension. Allowed: ${field.allowedExtensions.join( + ", " + )}`; + break; + } + + if (!isSizeValid) { + errorMsg = `File size exceeds the limit of ${field.maxSizeMb}MB`; + break; + } + + validIncoming.push(fileObj); + } + + if (errorMsg) { + setLocalErrors((prev) => ({ ...prev, [field.fileKey]: errorMsg })); + return; + } + + // Clear local error on successful file addition + setLocalErrors((prev) => { + const updated = { ...prev }; + delete updated[field.fileKey]; + return updated; + }); + + let newFilesList: File[] = []; + if (field.isMultiple) { + newFilesList = [...currentFiles, ...validIncoming].slice(0, maxAllowed); + if (currentFiles.length + validIncoming.length > maxAllowed) { + setLocalErrors((prev) => ({ + ...prev, + [field.fileKey]: `Only up to ${maxAllowed} files are allowed for this field. Excess files were ignored.`, + })); + } + } else { + newFilesList = validIncoming.slice(0, 1); + } + + handleFilesChange(field.fileKey, newFilesList); + }; + + const handleDrag = (e: React.DragEvent, fieldKey: string, active: boolean) => { + e.preventDefault(); + e.stopPropagation(); + if (disabled) return; + setDragActive((prev) => ({ ...prev, [fieldKey]: active })); + }; + + const handleDrop = (e: React.DragEvent, field: IFileUploadField) => { + e.preventDefault(); + e.stopPropagation(); + if (disabled) return; + setDragActive((prev) => ({ ...prev, [field.fileKey]: false })); + + if (e.dataTransfer.files && e.dataTransfer.files.length > 0) { + const filesArray = Array.from(e.dataTransfer.files); + processFiles(field, filesArray); + } + }; + + const handleFileSelect = ( + e: React.ChangeEvent, + field: IFileUploadField + ) => { + if (e.target.files && e.target.files.length > 0) { + const filesArray = Array.from(e.target.files); + processFiles(field, filesArray); + e.target.value = ""; // reset so same file can be selected again + } + }; + + const removeFile = (fieldKey: string, indexToRemove: number) => { + if (disabled) return; + const currentFiles = getFilesForField(fieldKey); + const updatedFiles = currentFiles.filter((_, idx) => idx !== indexToRemove); + + const field = fieldsMap[fieldKey]; + if (field && field.isRequired && updatedFiles.length === 0) { + setLocalErrors((prev) => ({ + ...prev, + [fieldKey]: "This file is required", + })); + } else { + setLocalErrors((prev) => { + const updated = { ...prev }; + delete updated[fieldKey]; + return updated; + }); + } + + handleFilesChange(fieldKey, updatedFiles); + }; + + return ( +
+ {file.description && ( +
+ {file.description} +
+ )} + + {sortedFields.map((field) => { + const currentFiles = getFilesForField(field.fileKey); + const maxFiles = field.isMultiple ? Math.max(1, field.maxFiles) : 1; + const reachedLimit = currentFiles.length >= maxFiles; + const fieldError = errors?.[field.fileKey] || localErrors[field.fileKey]; + const isDragOver = dragActive[field.fileKey]; + + // Format accepted files for the HTML input element + const acceptString = field.allowedExtensions + .map((ext) => (ext.startsWith(".") ? ext : `.${ext}`)) + .join(","); + + return ( +
+ {/* Field Header */} +
+ + + + Max size: {field.maxSizeMb}MB + {field.isMultiple && ` • Files: ${currentFiles.length}/${maxFiles}`} + +
+ + {/* Help / Description Text */} + {field.helpText && ( +

{field.helpText}

+ )} + + {/* Selected Files List */} + {currentFiles.length > 0 && ( +
+ {currentFiles.map((fileObj, idx) => ( +
+
+
+ +
+ +
+

+ {fileObj.name} +

+
+ + {formatBytes(fileObj.size)} + + + Ready + +
+
+
+ + + + {/* Hidden inputs to represent file details in traditional form submissions */} + +
+ ))} +
+ )} + + {/* Dropzone area */} + {!reachedLimit && ( + variant === "minimal" ? ( +
+ + { + if (fileInputRefs.current) { + fileInputRefs.current[field.fileKey] = el; + } + }} + multiple={field.isMultiple} + accept={acceptString} + disabled={disabled} + onChange={(e) => handleFileSelect(e, field)} + className="hidden" + /> + + Accepts: {field.allowedExtensions.join(", ").toUpperCase() || "All"} + +
+ ) : ( +
handleDrag(e, field.fileKey, true)} + onDragLeave={(e) => handleDrag(e, field.fileKey, false)} + onDrop={(e) => handleDrop(e, field)} + className={cn( + "relative border-2 border-dashed rounded-lg p-6 flex flex-col items-center justify-center text-center transition-all bg-card/50", + isDragOver + ? "border-primary bg-primary/5 dark:bg-primary/10" + : "border-border hover:border-primary/50 hover:bg-muted/10", + fieldError && "border-destructive hover:border-destructive/80", + disabled && "opacity-50 pointer-events-none cursor-not-allowed" + )} + > + handleFileSelect(e, field)} + id={`file-input-${field.fileKey}`} + className="absolute inset-0 w-full h-full opacity-0 cursor-pointer disabled:cursor-not-allowed" + /> + +
+ +
+ +

+ Drag & drop your file here, or browse +

+ +

+ Supported formats: {field.allowedExtensions.join(", ").toUpperCase() || "All"} +

+
+ ) + )} + + {/* Validation Error Message */} + {fieldError && ( +
+ + {fieldError} +
+ )} +
+ ); + })} +
+ ); +} + +export default SmartFileInput; diff --git a/packages/ui-common/src/index.ts b/packages/ui-common/src/index.ts index 39405a088..640ec92ba 100644 --- a/packages/ui-common/src/index.ts +++ b/packages/ui-common/src/index.ts @@ -4,6 +4,9 @@ export type { TableProps, TableColumn } from "./components/Table"; export { FormField } from "./components/Form"; export type { FormFieldProps } from "./components/Form"; +export { default as SmartFileInput } from "./components/SmartFileInput"; +export type { SmartFileInputProps } from "./components/SmartFileInput"; + export { default as Modal } from "./components/Modal"; export type { ModalProps } from "./components/Modal"; @@ -26,3 +29,4 @@ export * from "./components/card"; export * from "./components/dropdown-menu"; export * from "./components/data-table"; export * from "./components/dialog"; +export * from "./components/SmartFileInput";