ldc-ovoteam/resources/ts/front/Components/Base/Inputs/SimpleImage.tsx
2026-04-02 17:29:11 +02:00

294 lines
8.7 KiB
TypeScript

import { Button as ShadcnButton } from "@/components/ui/button";
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 { FileCheck, Image, Trash, UploadIcon } from "lucide-react";
import { ReactNode, useCallback, useEffect, useState } from "react";
import { ErrorCode, FileWithPath, useDropzone } from "react-dropzone";
import BtnOptionnalLinkWithTooltip from "../BtnOptionnalLinkWithTooltip";
type FileWithPreview = FileWithPath & {
preview: string;
};
type FileValidation = {
validationRules?: {
width: string | null;
height: string | null;
} | null;
maxFileSize: string | null;
allowedFormats: string | null;
};
type SimpleImageProps = 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<string, string> | null;
setData: (name: string, value: File | string | null) => void;
reset: (name?: string) => void;
hasFile?: (hasFile: boolean) => void;
}
export default function SimpleImage({
value,
setData,
originalData,
name = "defaultName",
onRefreshFile,
label = "defaultLabel",
required = false,
maxFileSize,
validationRules,
allowedFormats,
reset,
fileName = "image",
hasRefreshBtn = true,
hasFile,
parentErrors,
}: SimpleImageProps) {
const { toast } = useToast();
const [selectedFile, setSelectedFile] = useState<FileWithPreview | null>(null);
const [accept, setAccept] = useState<Record<string, Array<string>>>({
"image/*": [],
});
const [extensions, setExtensions] = useState<string>();
const localMaxFileSize = maxFileSize
? parseInt(maxFileSize, 10) * 1024
: 50000;
// 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 dropErrorMessages = {
noFile: str(useTrans("front.image.toast.no_file"))
.ucFirst()
.value(),
tooLarge: str(
useTrans("front.image.toast.too_big", {
params: {
maxSize: formatFileSize(localMaxFileSize),
},
}),
)
.ucFirst()
.value(),
forbidden: str(
useTrans("front.image.toast.forbidden_file"),
)
.ucFirst()
.value(),
tooMany: str(useTrans("front.image.toast.too_many"))
.ucFirst()
.value(),
default: str(
useTrans("front.image.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);
setData(name, file);
},
// 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,
});
}
},
});
// Translations
const transSelectImgZone = GetTranslation(
"front.image.select_file_zone"
);
const transValidationAllFormats = str(
useTrans("front.image.validation.all_formats"),
)
.ucFirst()
.value();
const transCropperFileUploaded = useTrans(
"front.image.state_file_uploaded",
);
const transCancel = useTrans("uwil-backend::back.cancel");
const getTransValidationAllowedFormats = (targetExtensions: string) => {
return str(
GetTranslation(
"front.image.validation.allowed_formats",
{
params: {
formats: targetExtensions,
},
},
),
)
.ucFirst()
.value();
};
const transRulesWeight = GetTranslation(
"front.image.validation.rules_weight",
{
params: {
size: formatFileSize(localMaxFileSize).toString(),
},
},
);
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
}, []);
return (
<div className="w-full">
{selectedFile ? (
<div className="relative">
<div
className={cn(
"text-sm flex justify-center border border-dashed overflow-hidden whitespace-normal [&_svg]:!size-auto [&_svg]:!shrink text-muted-foreground w-full h-36",
)}
>
<div className="flex flex-col justify-center items-center">
<FileCheck />
<p className="text-center break-normal w-full mt-2">
{str(transCropperFileUploaded).ucFirst().value()}
</p>
<small>{selectedFile.name}</small>
</div>
</div>
<BtnOptionnalLinkWithTooltip
className="absolute top-2 right-2"
variant="destructive"
content={<Trash className="w-4 h-4 m-auto" />}
onClick={() => {
setSelectedFile(null);
setData(name, null);
}}
tooltipContent={str(transCancel).ucFirst().value()}
/>
</div>
) : (
<ShadcnButton
variant="ghost"
type="button"
className={cn(
"relative rounded-full bg-muted w-[450px] h-[450px] flex border border-dashed overflow-hidden whitespace-normal [&_svg]:!pointer-events-auto [&_svg]:size-36 [&_svg]:!shrink text-muted-foreground",
// { "border-destructive": localErrors[name] },
)}
// eslint-disable-next-line react/jsx-props-no-spreading
{...getRootProps()}
>
{/* eslint-disable-next-line react/jsx-props-no-spreading */}
<input name={"file"} {...getInputProps()} />
<div className="flex flex-col justify-center items-center absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2">
<Image className="text-background stroke-[1.5px]" />
<p className="text-center break-normal w-full mt-2">
{transSelectImgZone}
</p>
<small>
{extensions
? getTransValidationAllowedFormats(extensions)
: transValidationAllFormats}
. {str(transRulesWeight).ucFirst().value()}
</small>
</div>
</ShadcnButton>
)}
</div>
)
}