ldc-laravel-base-project/resources/ts/back/Components/Inputs/CompleteInputFile.tsx
2026-07-09 17:32:17 +02:00

411 lines
13 KiB
TypeScript

import "react-image-crop/dist/ReactCrop.css";
import { usePage } from "@inertiajs/react";
import { FileCheck, RefreshCwIcon, Trash, UploadIcon } from "lucide-react";
import { type ReactNode, useCallback, useEffect, useState } from "react";
import { ErrorCode, type FileWithPath, useDropzone } from "react-dropzone";
import BtnOptionnalLinkWithTooltip from "@/back/Components/BtnOptionnalLinkWithTooltip";
import InputError from "@/back/Components/InputError";
import ImageCropper from "@/back/Components/Inputs/ImageCropper";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import str from "@/hooks/use-string";
import { useToast } from "@/hooks/use-toast";
import useTrans from "@/hooks/use-translations";
import { cn, GetTranslation } from "@/lib/utils";
type FileWithPreview = FileWithPath & {
preview: string;
};
interface FileValidation {
validationRules?: {
width: string | null;
height: string | null;
} | null;
maxFileSize: string | null;
allowedFormats: string | null;
inputType?: "image" | "file";
}
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<string, string> | null;
setData: (name: string, value: File | string | null) => void;
reset: (name?: string) => void;
hasFile?: (hasFile: boolean) => void;
};
export default function CompleteInputFile(props: CompleteInputFileProps) {
const {
value,
setData,
originalData,
name,
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<boolean>(false);
const [isDialogOpen, setDialogOpen] = useState<boolean>(false);
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<FileWithPreview | null>(
() => {
// 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 `${String(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),
},
},
);
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();
};
function buildDropzoneAccept(allowedFormats: string | null): {
accept: Record<string, string[]>;
extensions: string;
} {
if (!allowedFormats) return { accept: {}, extensions: "" };
const currentExtensions = allowedFormats.split(",");
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) {
result[mime] ??= new Set();
result[mime].add(`.${cleanExt}`);
}
});
// Convert Set to Array for React Dropzone
const accept: Record<string, string[]> = {};
Object.entries(result).forEach(([mime, extSet]) => {
accept[mime] = Array.from(extSet);
});
return { accept, extensions: allowedFormats };
}
const { accept: initialAccept, extensions: initialExtensions } =
buildDropzoneAccept(allowedFormats);
const [accept] = useState<Record<string, string[]>>(initialAccept);
const [extensions] = useState<string>(initialExtensions);
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] as FileWithPath | null;
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 as ErrorCode | undefined;
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 className="flex flex-col gap-1.5 w-full">
<div className="w-full flex gap-1 items-end">
<div className="w-full">
<Label htmlFor={name}>
{label}
{required && " *"}
</Label>
{selectedFile ? (
<div className="relative">
{inputType === "image" && (
<ImageCropper
dialogOpen={isDialogOpen}
value={value}
reset={reset}
setData={setData}
setDialogOpen={setDialogOpen}
selectedFile={selectedFile}
validationRules={{
width: localValidationRules.width,
height: localValidationRules.height,
}}
name={name}
url={url}
isReset={isReset}
setIsReset={setIsReset}
finalFileName={finalFileName}
/>
)}
{inputType === "file" && (
<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>
) : (
<Button
variant="ghost"
type="button"
className={cn(
"flex border border-dashed overflow-hidden whitespace-normal [&_svg]:!pointer-events-auto [&_svg]:!size-auto [&_svg]:!shrink text-muted-foreground w-full h-36",
{ "border-destructive": localErrors[name] },
)}
{...getRootProps()}
>
<input name={name} {...getInputProps()} />
<div className="flex flex-col justify-center items-center">
<UploadIcon />
<p className="text-center break-normal w-full mt-2">
{transSelectImgZone}
</p>
<small>
{extensions
? getTransValidationAllowedFormats(extensions)
: transValidationAllFormats}
. {str(transRulesWeight).ucFirst().value()}
</small>
</div>
</Button>
)}
</div>
{hasRefreshBtn && originalData && (
<BtnOptionnalLinkWithTooltip
disabled={isRefreshDisabled}
className="h-36"
variant="secondary"
onClick={() => {
setData(name, originalData);
setSelectedFile(transValueIntoFile(originalData));
if (onRefreshFile) {
onRefreshFile();
}
}}
content={<RefreshCwIcon />}
tooltipContent={str(transRefresh).ucFirst().value()}
/>
)}
</div>
<InputError message={localErrors[name]} />
</div>
);
}