294 lines
8.7 KiB
TypeScript
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>
|
|
)
|
|
}
|