// * Packages import { usePage } from "@inertiajs/react"; import { EyeIcon, FileCheck, RefreshCwIcon, Trash, UploadIcon, } from "lucide-react"; import { MouseEvent, MouseEventHandler, ReactNode, useCallback, useEffect, useState, } from "react"; import { ErrorCode, FileWithPath, useDropzone } from "react-dropzone"; // * Components shadcn import { Button } from "@/components/ui/button"; import { Label } from "@/components/ui/label"; // * Other import BtnOptionnalLinkWithTooltip from "@/back/Components/BtnOptionnalLinkWithTooltip"; import InputError from "@/back/Components/InputError"; import str from "@/hooks/use-string"; import { useToast } from "@/hooks/use-toast"; import trans from "@/hooks/use-translations"; import { cn } from "@/lib/utils"; import { AppEnv } from "@/types/global"; export enum InputFileMimeType { JPG = "image/jpeg", PNG = "image/png", GIF = "image/gif", PDF = "application/pdf", SVG = "image/svg+xml", WEBP = "image/webp", DOC = "application/msword", DOCX = "application/vnd.openxmlformats-officedocument.wordprocessingml.document", XLS = "application/vnd.ms-excel", XLSX = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", } export const MimeTypeExtensions: Record = { [InputFileMimeType.JPG]: [".jpg", ".jpeg"], [InputFileMimeType.PNG]: [".png"], [InputFileMimeType.GIF]: [".gif"], [InputFileMimeType.PDF]: [".pdf"], [InputFileMimeType.SVG]: [".svg"], [InputFileMimeType.WEBP]: [".webp"], [InputFileMimeType.DOC]: [".doc"], [InputFileMimeType.DOCX]: [".docx"], [InputFileMimeType.XLS]: [".xls"], [InputFileMimeType.XLSX]: [".xlsx"], }; type FileWithPreview = FileWithPath & { preview: string; dbPath?: string; }; type FileValidation = { maxFileSize?: string | null; acceptedMimeTypes: InputFileMimeType[]; }; type SimpleInputFileProps = FileValidation & { name: string; value?: string | File | (string | File)[] | null; originalData?: string | string[]; onRefreshFile?: () => void; label?: ReactNode | string; required?: boolean; multiple?: boolean; hasRefreshBtn?: boolean; parentErrors?: Record | null; setData: ( name: string, value: File | string | (File | string)[] | null, ) => void; hasFile?: (hasFile: boolean) => void; }; export default function SimpleInputFile(props: Readonly) { const { value, setData, originalData, name = "defaultName", onRefreshFile, label = "defaultLabel", required = false, multiple = false, maxFileSize, acceptedMimeTypes, hasRefreshBtn = true, hasFile, parentErrors, } = props; const { toast } = useToast(); const { errors } = usePage().props; const { url } = usePage().props.appEnv as AppEnv; const [accept, setAccept] = useState>>({}); const [extensions, setExtensions] = useState(""); const [selectedFiles, setSelectedFiles] = useState([]); const isRefreshDisabled = multiple ? Array.isArray(value) && value.every((v) => typeof v === "string") : value != null && typeof value === "string"; const localErrors = { ...errors, ...parentErrors }; const localMaxFileSize = maxFileSize ? Number.parseInt(maxFileSize, 10) * 1024 : 50000; const getFileNameFromPath = useCallback( (path?: string | null): string | null => { if (!path || typeof path !== "string") return null; const cleanPath = path.replace(/\/+$/, ""); const segments = cleanPath.split("/"); const last = segments.at(-1) || ""; return last || null; }, [], ); const transValueIntoFile = useCallback( (localData: string | undefined | null) => { if (localData) { const currentFileName = getFileNameFromPath(localData); return { preview: `${url}/${localData}`, name: currentFileName ?? "", dbPath: localData, } as FileWithPreview; } return null; }, [getFileNameFromPath, url], ); const resolveFile = useCallback( (v: string | File, prev: FileWithPreview[]): FileWithPreview | null => { if (v instanceof File) { const existing = prev.find((f) => f === v); if (existing) return existing; return Object.assign(v, { preview: URL.createObjectURL(v), }) as FileWithPreview; } const existingPath = prev.find((f) => f.dbPath === v); if (existingPath) return existingPath; return transValueIntoFile(v); }, [transValueIntoFile], ); useEffect(() => { let vals: (string | File)[] = []; if (Array.isArray(value)) { vals = value; } else if (value) { vals = [value]; } setSelectedFiles((prev) => { const newSelected = vals.map((v) => resolveFile(v, prev)); return newSelected.filter((item): item is FileWithPreview => Boolean(item), ); }); }, [value, resolveFile]); useEffect(() => { return () => { selectedFiles.forEach((f) => { if (f.preview?.startsWith("blob:")) { URL.revokeObjectURL(f.preview); } }); }; }, [selectedFiles]); const formatFileSize = (bytes: number): string => { if (bytes === 0) return "0 Bytes"; const k = 1024; const sizes = ["Bytes", "KB", "MB", "GB"]; const i = Math.floor(Math.log(bytes) / Math.log(k)); const currentSize = sizes[i]; const currentBytes = Number.parseFloat((bytes / k ** i).toFixed(2)); return `${currentBytes} ${currentSize}`; }; const transSelectFileZone = trans( "uwil-backend::back.image_cropper.select_file_zone", ); const transValidationAllFormats = str( trans("uwil-backend::back.image_cropper.validation.all_formats"), ) .ucFirst() .value(); const transCancel = trans("uwil-backend::back.cancel"); const transPreview = trans("uwil-backend::back.preview"); const transRulesWeight = trans( "uwil-backend::back.image_cropper.validation.rules_weight", { params: { size: formatFileSize(localMaxFileSize).toString() }, }, ); const transRefresh = trans( "uwil-backend::back.image_cropper.refresh_original", ); const getTransValidationAllowedFormats = (targetExtensions: string) => { return str( trans("uwil-backend::back.image_cropper.validation.allowed_formats", { params: { formats: targetExtensions }, }), ) .ucFirst() .value(); }; const buildDropzoneAccept = useCallback(() => { const currentAccept: Record = {}; const exts: string[] = []; acceptedMimeTypes.forEach((mime) => { const mimeExts = MimeTypeExtensions[mime]; if (mimeExts) { currentAccept[mime] = mimeExts; exts.push(...mimeExts.map((e) => e.replace(".", ""))); } }); setExtensions(Array.from(new Set(exts)).join(", ")); setAccept(currentAccept); }, [acceptedMimeTypes]); useEffect(() => { buildDropzoneAccept(); }, [buildDropzoneAccept]); useEffect(() => { hasFile?.(selectedFiles.length > 0); }, [selectedFiles.length, hasFile]); const dropErrorMessages = { noFile: str(trans("uwil-backend::back.image_cropper.toast.no_file")) .ucFirst() .value(), tooLarge: str( trans("uwil-backend::back.image_cropper.toast.too_big", { params: { maxSize: formatFileSize(localMaxFileSize) }, }), ) .ucFirst() .value(), forbidden: str( trans("uwil-backend::back.image_cropper.toast.forbidden_file"), ) .ucFirst() .value(), tooMany: str(trans("uwil-backend::back.image_cropper.toast.too_many")) .ucFirst() .value(), default: str(trans("uwil-backend::back.image_cropper.toast.defaultError")) .ucFirst() .value(), }; const noFileMessage = dropErrorMessages.noFile; const onDrop = useCallback( (acceptedFiles: FileWithPath[]) => { if (!acceptedFiles.length) { toast({ title: "Oups !", description: noFileMessage }); return; } const newFiles = acceptedFiles.map( (file) => Object.assign(file, { preview: URL.createObjectURL(file), }) as FileWithPreview, ); if (multiple) { const nextFiles = [...selectedFiles, ...newFiles]; setSelectedFiles(nextFiles); setData( name, nextFiles.map((f) => f.dbPath || (f as File)), ); } else { setSelectedFiles([newFiles[0]]); setData(name, newFiles[0]); } }, [multiple, selectedFiles, name, setData, noFileMessage, toast], ); const { getRootProps, getInputProps } = useDropzone({ onDrop, accept, multiple, maxSize: localMaxFileSize, onDropRejected: (fileRejections) => { const rejection = fileRejections[0]; const rejectionCode = rejection?.errors?.[0]?.code; if (rejectionCode === ErrorCode.FileTooLarge) { toast({ title: "Oups !", description: `${dropErrorMessages.tooLarge} (${formatFileSize(rejection.file.size)}).`, }); } else if (rejectionCode === ErrorCode.FileInvalidType) { toast({ title: "Oups !", description: dropErrorMessages.forbidden }); } else if (rejectionCode === ErrorCode.TooManyFiles) { toast({ title: "Oups !", description: dropErrorMessages.tooMany }); } else { toast({ title: "Oups !", description: dropErrorMessages.default }); } }, }); function onButtonClickSingleFile( index: number, ): MouseEventHandler { return (_event: MouseEvent): void => { const newFiles = selectedFiles.filter((_file, fileIndex) => { return fileIndex !== index; }); setSelectedFiles(newFiles); if (multiple) { setData( name, newFiles.length > 0 ? newFiles.map((f) => f.dbPath || (f as File)) : [], ); } else { setData(name, null); } }; } function onButtonClickMultipleFiles( originalValues: string | string[], ): MouseEventHandler { return (_event: MouseEvent): void => { const orig = Array.isArray(originalValues) ? originalValues : [originalValues]; const files = orig.map(transValueIntoFile).filter((v) => v !== null); setSelectedFiles(files); setData(name, multiple ? orig : originalValues); if (onRefreshFile) { onRefreshFile(); } }; } return (
{selectedFiles.length > 0 ? (
{selectedFiles.map((file, index) => (
{file.name}
{file.preview && ( } tooltipContent={str( transPreview !== "uwil-backend::back.preview" ? transPreview : "Aperçu", ) .ucFirst() .value()} /> )} } onClick={onButtonClickSingleFile(index)} tooltipContent={str(transCancel).ucFirst().value()} />
))} {multiple && ( )}
) : ( )}
{hasRefreshBtn && originalData && ( } tooltipContent={str(transRefresh).ucFirst().value()} /> )}
); }