2026-07-09 17:31:24 +02:00
|
|
|
import "react-image-crop/dist/ReactCrop.css";
|
2026-07-09 17:30:05 +02:00
|
|
|
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>
|
|
|
|
|
);
|
|
|
|
|
}
|