ldc-groupe/resources/ts/back/Components/Inputs/ImageCropper/ImageCropperWrapper.tsx
2025-11-12 16:48:20 +01:00

285 lines
8.3 KiB
TypeScript

// * Packages
import { useCallback, useEffect, useState } from "react";
import { Upload } 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";
// * Other
import InputError from "@/back/Components/InputError";
import useTrans from "@/hooks/use-translations";
import str from "@/hooks/use-string";
import { useToast } from "@/hooks/use-toast";
import { AppEnv } from "@/back/types/global";
import ImageCropperLogic from "./ImageCropperLogic";
type FileWithPreview = FileWithPath & {
preview: string;
};
/* eslint-disable no-unused-vars, @typescript-eslint/no-unused-vars */
type ImageCropperWrapperProps = {
name: string;
value?: string | null;
label?: string;
required?: boolean;
validationRules?: {
width: string | null;
height: string | null;
} | null;
maxFileSize: string | null;
allowedFormats: string | null;
fileName?: string;
setData: (name: string, value: File | string | null) => void;
reset: (name?: string) => void;
};
/* eslint-enable no-unused-vars, @typescript-eslint/no-unused-vars */
export default function ImageCropperWrapper(props: ImageCropperWrapperProps) {
const {
value,
setData,
name = "defaultName",
label = "defaultLabel",
required = false,
maxFileSize,
validationRules,
allowedFormats,
reset,
fileName = "image",
} = props;
const { toast } = useToast();
const { errors } = usePage().props;
const { url } = usePage().props.appEnv as AppEnv;
const [isReset, setIsReset] = useState<boolean>(false);
const finalFileName = `${fileName}.png`;
const [selectedFile, setSelectedFile] = useState<FileWithPreview | null>(
value
? ({
preview: `${url}/${value}`,
} as unknown as FileWithPreview)
: null,
);
const [isDialogOpen, setDialogOpen] = useState(false);
const [accept, setAccept] = useState<Record<string, Array<string>>>({
"image/*": [],
});
const [extensions, setExtensions] = useState<string>();
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;
const transValidationAllFormats = str(
useTrans("back.image_cropper.validation.all_formats"),
)
.ucFirst()
.value();
const getTransValidationAllowedFormats = (extensions: string) => {
return str(
useTrans("back.image_cropper.validation.allowed_formats", {
params: {
formats: extensions,
},
}),
)
.ucFirst()
.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}`;
};
const buildDropzoneAccept = (): Record<string, string[]> => {
if (!allowedFormats) return {};
const currentExtensions = allowedFormats.split(",");
setExtensions(allowedFormats);
const mimeMap: Record<string, string> = {
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<string, Set<string>> = {};
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<string, string[]> = {};
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
}, []);
// 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("back.image_cropper.toast.no_file")).ucFirst().value(),
tooLarge: str(
useTrans("back.image_cropper.toast.too_big", {
params: {
maxSize: formatFileSize(localMaxFileSize),
},
}),
)
.ucFirst()
.value(),
forbidden: str(useTrans("back.image_cropper.toast.forbidden_file"))
.ucFirst()
.value(),
tooMany: str(useTrans("back.image_cropper.toast.too_many"))
.ucFirst()
.value(),
default: str(useTrans("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);
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 (
<div>
<Label htmlFor={name}>{label + (required ? " *" : "")}</Label>
<div className="relative flex flex-col gap-1">
{selectedFile ? (
<ImageCropperLogic
dialogOpen={isDialogOpen}
value={value}
reset={reset}
setData={setData}
setDialogOpen={setDialogOpen}
selectedFile={selectedFile}
setSelectedFile={setSelectedFile}
validationRules={{
width: localValidationRules.width,
height: localValidationRules.height,
}}
name={name}
url={url}
isReset={isReset}
setIsReset={setIsReset}
finalFileName={finalFileName}
/>
) : (
<div
// eslint-disable-next-line react/jsx-props-no-spreading
{...getRootProps()}
className="size-36 cursor-pointer outline-offset-2 outline-gray-200 outline-dashed outline-2 rounded-none flex items-center justify-center"
>
{/* eslint-disable-next-line react/jsx-props-no-spreading */}
<input name={name} {...getInputProps()} />
<div className="rounded-none">
<Upload />
</div>
</div>
)}
</div>
<small>
{extensions
? getTransValidationAllowedFormats(extensions)
: transValidationAllFormats}
. <br />
{str(
useTrans("back.image_cropper.validation.rules_weight", {
params: {
size: formatFileSize(localMaxFileSize).toString(),
},
}),
)
.ucFirst()
.value()}
</small>
<InputError message={errors[name]} />
</div>
);
}