// * Packages import { usePage } from "@inertiajs/react"; import { FileCheck, RefreshCwIcon, Trash, UploadIcon, EyeIcon } from "lucide-react"; import { 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 useTrans from "@/hooks/use-translations"; import { cn, GetTranslation } 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 = parentErrors ?? errors; 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); } }); }; }, []); 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 = useTrans("uwil-backend::back.image_cropper.select_file_zone"); const transValidationAllFormats = str( useTrans("uwil-backend::back.image_cropper.validation.all_formats"), ).ucFirst().value(); const transCancel = useTrans("uwil-backend::back.cancel"); const transPreview = useTrans("uwil-backend::back.preview"); 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 = 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(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[]) => { if (!acceptedFiles.length) { toast({ title: "Oups !", description: dropErrorMessages.noFile }); 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, localMaxFileSize, formatFileSize, name, setData], ); 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): React.MouseEventHandler { return (_: React.MouseEvent): void => { const newFiles = selectedFiles.filter((_, i) => i !== index); setSelectedFiles(newFiles); if (multiple) { setData(name, newFiles.length > 0 ? newFiles.map(f => f.dbPath || (f as File)) : []); } else { setData(name, null); } } } function onButtonClickMultipleFiles(originalData: string | string[]): React.MouseEventHandler { return (_: React.MouseEvent):void => { const orig = Array.isArray(originalData) ? originalData : [originalData]; const files = orig.map(transValueIntoFile).filter(v => v !== null); setSelectedFiles(files); setData(name, multiple ? orig : originalData); 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()} /> )}
); }