// * Packages import { ReactNode, useCallback, useEffect, useState } from "react"; import { FileCheck, RefreshCwIcon, Trash, UploadIcon } from "lucide-react"; import { ErrorCode, FileWithPath, useDropzone } from "react-dropzone"; import "react-image-crop/dist/ReactCrop.css"; import { usePage } from "@inertiajs/react"; // * Components shadcn import { Label } from "@/components/ui/label"; import { Button } from "@/components/ui/button"; // * Other import InputError from "@/back/Components/InputError"; import ImageCropper from "@/back/Components/Inputs/ImageCropper"; import BtnOptionnalLinkWithTooltip from "@/back/Components/BtnOptionnalLinkWithTooltip"; import useTrans from "@/hooks/use-translations"; import str from "@/hooks/use-string"; import { useToast } from "@/hooks/use-toast"; import { AppEnv } from "@/types/global"; import { cn, GetTranslation } from "@/lib/utils"; type FileWithPreview = FileWithPath & { preview: string; }; type FileValidation = { validationRules?: { width: string | null; height: string | null; } | null; maxFileSize: string | null; allowedFormats: string | null; inputType?: "image" | "file"; }; /* eslint-disable no-unused-vars, @typescript-eslint/no-unused-vars */ type CompleteInputFileProps = FileValidation & { name: string; value?: string | File | null; /** Original data is the data directly from the model, not in the form state */ originalData?: string | undefined; /** Add custom event when refresh to the originalData */ onRefreshFile?: () => void; label?: ReactNode | string; required?: boolean; fileName?: string; hasRefreshBtn?: boolean; parentErrors?: Record | null; setData: (name: string, value: File | string | null) => void; reset: (name?: string) => void; hasFile?: (hasFile: boolean) => void; }; /* eslint-enable no-unused-vars, @typescript-eslint/no-unused-vars */ export default function CompleteInputFile(props: CompleteInputFileProps) { const { value, setData, originalData, name = "defaultName", onRefreshFile, label = "defaultLabel", required = false, maxFileSize, validationRules, allowedFormats, reset, inputType = "image", fileName = "image", hasRefreshBtn = true, hasFile, parentErrors, } = props; const { toast } = useToast(); const { errors } = usePage().props; const { url } = usePage().props.appEnv as AppEnv; const [isReset, setIsReset] = useState(false); const [isDialogOpen, setDialogOpen] = useState(false); const [accept, setAccept] = useState>>({ "image/*": [], }); const [extensions, setExtensions] = useState(); const finalFileName = `${fileName}.png`; const isRefreshDisabled = value != null && typeof value === "string"; const localErrors = parentErrors ?? errors; const localValidationRules = { width: validationRules?.width ? parseInt(validationRules.width, 10) : 250, height: validationRules?.height ? parseInt(validationRules?.height, 10) : 250, }; const localMaxFileSize = maxFileSize ? parseInt(maxFileSize, 10) * 1024 : 50000; /** * Get the fileName from the path given by the database value * @param path string|null * @return string|null */ const getFileNameFromPath = (path?: string | null): string | null => { if (!path || typeof path !== "string") return null; const cleanPath = path.replace(/\/+$/, ""); const segments = cleanPath.split("/"); const last = segments[segments.length - 1] || ""; return last || null; }; const transValueIntoFile = (localData: string | undefined | null) => { if (localData) { const currentFileName = getFileNameFromPath(localData); return { preview: `${url}/${localData}`, name: currentFileName ?? "", } as FileWithPreview; } return null; }; const [selectedFile, setSelectedFile] = useState( () => { // When there is an image selected but not stored yet. if (value instanceof File) { return Object.assign(value, { preview: URL.createObjectURL(value), }); } // When there is already an image stored. return transValueIntoFile(value); }, ); // Fonction utilitaire pour formater la taille de fichier 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 = parseFloat((bytes / k ** i).toFixed(2)); return `${currentBytes} ${currentSize}`; }; // Translations const transSelectImgZone = useTrans( inputType === "file" ? "uwil-backend::back.image_cropper.select_file_zone" : "uwil-backend::back.image_cropper.select_img_zone", ); const transValidationAllFormats = str( useTrans("uwil-backend::back.image_cropper.validation.all_formats"), ) .ucFirst() .value(); const transCropperFileUploaded = useTrans( "uwil-backend::back.image_cropper.state_file_uploaded", ); const transCancel = useTrans("uwil-backend::back.cancel"); const transRulesWeight = useTrans( "uwil-backend::back.image_cropper.validation.rules_weight", { params: { size: formatFileSize(localMaxFileSize).toString(), }, }, ); const transRefresh = useTrans( "uwil-backend::back.image_cropper.refresh_original", ); const getTransValidationAllowedFormats = (targetExtensions: string) => { return str( GetTranslation( "uwil-backend::back.image_cropper.validation.allowed_formats", { params: { formats: targetExtensions, }, }, ), ) .ucFirst() .value(); }; const buildDropzoneAccept = (): Record => { if (!allowedFormats) return {}; const currentExtensions = allowedFormats.split(","); setExtensions(allowedFormats); const mimeMap: Record = { jpg: "image/jpeg", jpeg: "image/jpeg", png: "image/png", gif: "image/gif", pdf: "application/pdf", svg: "image/svg+xml", webp: "image/webp", }; const result: Record> = {}; currentExtensions.forEach((ext) => { const cleanExt = ext.trim().toLowerCase(); const mime = mimeMap[cleanExt]; if (mime) { if (!result[mime]) { result[mime] = new Set(); } result[mime].add(`.${cleanExt}`); } }); // Convert Set to Array for React Dropzone const currentAccept: Record = {}; Object.entries(result).forEach(([mime, extSet]) => { currentAccept[mime] = Array.from(extSet); }); setAccept(currentAccept); return currentAccept; }; useEffect(() => { buildDropzoneAccept(); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); useEffect(() => { hasFile?.(!!selectedFile); }, [selectedFile, hasFile]); // This error message for the useDropZone can not be used directly in the hook because or react hook error, // they have to be used as they actually are. const dropErrorMessages = { noFile: str(useTrans("uwil-backend::back.image_cropper.toast.no_file")) .ucFirst() .value(), tooLarge: str( useTrans("uwil-backend::back.image_cropper.toast.too_big", { params: { maxSize: formatFileSize(localMaxFileSize), }, }), ) .ucFirst() .value(), forbidden: str( useTrans("uwil-backend::back.image_cropper.toast.forbidden_file"), ) .ucFirst() .value(), tooMany: str(useTrans("uwil-backend::back.image_cropper.toast.too_many")) .ucFirst() .value(), default: str( useTrans("uwil-backend::back.image_cropper.toast.defaultError"), ) .ucFirst() .value(), }; const onDrop = useCallback( (acceptedFiles: FileWithPath[]) => { const file = acceptedFiles[0]; if (!file) { toast({ title: "Oups !", description: dropErrorMessages.noFile, }); return; } const fileWithPreview = Object.assign(file, { preview: URL.createObjectURL(file), }); setSelectedFile(fileWithPreview); if (inputType === "file") { setData(name, file); } setDialogOpen(true); }, // eslint-disable-next-line react-hooks/exhaustive-deps [maxFileSize, formatFileSize], ); const { getRootProps, getInputProps } = useDropzone({ onDrop, accept, multiple: false, 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, }); } }, }); return (
{selectedFile ? (
{inputType === "image" && ( )} {inputType === "file" && (

{str(transCropperFileUploaded).ucFirst().value()}

{selectedFile.name}
)} } onClick={() => { setSelectedFile(null); setData(name, null); }} tooltipContent={str(transCancel).ucFirst().value()} />
) : ( )}
{hasRefreshBtn && originalData && ( { setData(name, originalData); setSelectedFile(transValueIntoFile(originalData)); if (onRefreshFile) { onRefreshFile(); } }} content={} tooltipContent={str(transRefresh).ucFirst().value()} /> )}
); }