ldc-ovoteam/resources/ts/back/Components/Inputs/SimpleInputFile.tsx
Jérémy Munsch cd2d8ed397
refactor(i18n): replace GetTranslation with trans function
- Updated translation utility to improve consistency and reduce redundancy.
- Removed deprecated useTrans hook and related code.
- Introduced a new Zustand store for managing translations and locale settings.
- Cleaned up unused hooks and files related to localization.
2026-10-19 17:43:33 +02:00

480 lines
15 KiB
TypeScript

// * Packages
import { usePage } from "@inertiajs/react";
import {
EyeIcon,
FileCheck,
RefreshCwIcon,
Trash,
UploadIcon,
} from "lucide-react";
import {
MouseEvent,
MouseEventHandler,
ReactNode,
useCallback,
useEffect,
useState,
} from "react";
import { ErrorCode, FileWithPath, useDropzone } from "react-dropzone";
// * Components shadcn
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
// * Other
import BtnOptionnalLinkWithTooltip from "@/back/Components/BtnOptionnalLinkWithTooltip";
import InputError from "@/back/Components/InputError";
import str from "@/hooks/use-string";
import { useToast } from "@/hooks/use-toast";
import trans from "@/hooks/use-translations";
import { cn } from "@/lib/utils";
import { AppEnv } from "@/types/global";
export enum InputFileMimeType {
JPG = "image/jpeg",
PNG = "image/png",
GIF = "image/gif",
PDF = "application/pdf",
SVG = "image/svg+xml",
WEBP = "image/webp",
DOC = "application/msword",
DOCX = "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
XLS = "application/vnd.ms-excel",
XLSX = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
}
export const MimeTypeExtensions: Record<InputFileMimeType, string[]> = {
[InputFileMimeType.JPG]: [".jpg", ".jpeg"],
[InputFileMimeType.PNG]: [".png"],
[InputFileMimeType.GIF]: [".gif"],
[InputFileMimeType.PDF]: [".pdf"],
[InputFileMimeType.SVG]: [".svg"],
[InputFileMimeType.WEBP]: [".webp"],
[InputFileMimeType.DOC]: [".doc"],
[InputFileMimeType.DOCX]: [".docx"],
[InputFileMimeType.XLS]: [".xls"],
[InputFileMimeType.XLSX]: [".xlsx"],
};
type FileWithPreview = FileWithPath & {
preview: string;
dbPath?: string;
};
type FileValidation = {
maxFileSize?: string | null;
acceptedMimeTypes: InputFileMimeType[];
};
type SimpleInputFileProps = FileValidation & {
name: string;
value?: string | File | (string | File)[] | null;
originalData?: string | string[];
onRefreshFile?: () => void;
label?: ReactNode | string;
required?: boolean;
multiple?: boolean;
hasRefreshBtn?: boolean;
parentErrors?: Record<string, string> | null;
setData: (
name: string,
value: File | string | (File | string)[] | null,
) => void;
hasFile?: (hasFile: boolean) => void;
};
export default function SimpleInputFile(props: Readonly<SimpleInputFileProps>) {
const {
value,
setData,
originalData,
name = "defaultName",
onRefreshFile,
label = "defaultLabel",
required = false,
multiple = false,
maxFileSize,
acceptedMimeTypes,
hasRefreshBtn = true,
hasFile,
parentErrors,
} = props;
const { toast } = useToast();
const { errors } = usePage().props;
const { url } = usePage().props.appEnv as AppEnv;
const [accept, setAccept] = useState<Record<string, Array<string>>>({});
const [extensions, setExtensions] = useState<string>("");
const [selectedFiles, setSelectedFiles] = useState<FileWithPreview[]>([]);
const isRefreshDisabled = multiple
? Array.isArray(value) && value.every((v) => typeof v === "string")
: value != null && typeof value === "string";
const localErrors = { ...errors, ...parentErrors };
const localMaxFileSize = maxFileSize
? Number.parseInt(maxFileSize, 10) * 1024
: 50000;
const getFileNameFromPath = useCallback(
(path?: string | null): string | null => {
if (!path || typeof path !== "string") return null;
const cleanPath = path.replace(/\/+$/, "");
const segments = cleanPath.split("/");
const last = segments.at(-1) || "";
return last || null;
},
[],
);
const transValueIntoFile = useCallback(
(localData: string | undefined | null) => {
if (localData) {
const currentFileName = getFileNameFromPath(localData);
return {
preview: `${url}/${localData}`,
name: currentFileName ?? "",
dbPath: localData,
} as FileWithPreview;
}
return null;
},
[getFileNameFromPath, url],
);
const resolveFile = useCallback(
(v: string | File, prev: FileWithPreview[]): FileWithPreview | null => {
if (v instanceof File) {
const existing = prev.find((f) => f === v);
if (existing) return existing;
return Object.assign(v, {
preview: URL.createObjectURL(v),
}) as FileWithPreview;
}
const existingPath = prev.find((f) => f.dbPath === v);
if (existingPath) return existingPath;
return transValueIntoFile(v);
},
[transValueIntoFile],
);
useEffect(() => {
let vals: (string | File)[] = [];
if (Array.isArray(value)) {
vals = value;
} else if (value) {
vals = [value];
}
setSelectedFiles((prev) => {
const newSelected = vals.map((v) => resolveFile(v, prev));
return newSelected.filter((item): item is FileWithPreview =>
Boolean(item),
);
});
}, [value, resolveFile]);
useEffect(() => {
return () => {
selectedFiles.forEach((f) => {
if (f.preview?.startsWith("blob:")) {
URL.revokeObjectURL(f.preview);
}
});
};
}, [selectedFiles]);
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 = Number.parseFloat((bytes / k ** i).toFixed(2));
return `${currentBytes} ${currentSize}`;
};
const transSelectFileZone = trans(
"uwil-backend::back.image_cropper.select_file_zone",
);
const transValidationAllFormats = str(
trans("uwil-backend::back.image_cropper.validation.all_formats"),
)
.ucFirst()
.value();
const transCancel = trans("uwil-backend::back.cancel");
const transPreview = trans("uwil-backend::back.preview");
const transRulesWeight = trans(
"uwil-backend::back.image_cropper.validation.rules_weight",
{
params: { size: formatFileSize(localMaxFileSize).toString() },
},
);
const transRefresh = trans(
"uwil-backend::back.image_cropper.refresh_original",
);
const getTransValidationAllowedFormats = (targetExtensions: string) => {
return str(
trans("uwil-backend::back.image_cropper.validation.allowed_formats", {
params: { formats: targetExtensions },
}),
)
.ucFirst()
.value();
};
const buildDropzoneAccept = useCallback(() => {
const currentAccept: Record<string, string[]> = {};
const exts: string[] = [];
acceptedMimeTypes.forEach((mime) => {
const mimeExts = MimeTypeExtensions[mime];
if (mimeExts) {
currentAccept[mime] = mimeExts;
exts.push(...mimeExts.map((e) => e.replace(".", "")));
}
});
setExtensions(Array.from(new Set(exts)).join(", "));
setAccept(currentAccept);
}, [acceptedMimeTypes]);
useEffect(() => {
buildDropzoneAccept();
}, [buildDropzoneAccept]);
useEffect(() => {
hasFile?.(selectedFiles.length > 0);
}, [selectedFiles.length, hasFile]);
const dropErrorMessages = {
noFile: str(trans("uwil-backend::back.image_cropper.toast.no_file"))
.ucFirst()
.value(),
tooLarge: str(
trans("uwil-backend::back.image_cropper.toast.too_big", {
params: { maxSize: formatFileSize(localMaxFileSize) },
}),
)
.ucFirst()
.value(),
forbidden: str(
trans("uwil-backend::back.image_cropper.toast.forbidden_file"),
)
.ucFirst()
.value(),
tooMany: str(trans("uwil-backend::back.image_cropper.toast.too_many"))
.ucFirst()
.value(),
default: str(trans("uwil-backend::back.image_cropper.toast.defaultError"))
.ucFirst()
.value(),
};
const noFileMessage = dropErrorMessages.noFile;
const onDrop = useCallback(
(acceptedFiles: FileWithPath[]) => {
if (!acceptedFiles.length) {
toast({ title: "Oups !", description: noFileMessage });
return;
}
const newFiles = acceptedFiles.map(
(file) =>
Object.assign(file, {
preview: URL.createObjectURL(file),
}) as FileWithPreview,
);
if (multiple) {
const nextFiles = [...selectedFiles, ...newFiles];
setSelectedFiles(nextFiles);
setData(
name,
nextFiles.map((f) => f.dbPath || (f as File)),
);
} else {
setSelectedFiles([newFiles[0]]);
setData(name, newFiles[0]);
}
},
[multiple, selectedFiles, name, setData, noFileMessage, toast],
);
const { getRootProps, getInputProps } = useDropzone({
onDrop,
accept,
multiple,
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 });
}
},
});
function onButtonClickSingleFile(
index: number,
): MouseEventHandler<HTMLButtonElement> {
return (_event: MouseEvent<HTMLButtonElement>): void => {
const newFiles = selectedFiles.filter((_file, fileIndex) => {
return fileIndex !== index;
});
setSelectedFiles(newFiles);
if (multiple) {
setData(
name,
newFiles.length > 0
? newFiles.map((f) => f.dbPath || (f as File))
: [],
);
} else {
setData(name, null);
}
};
}
function onButtonClickMultipleFiles(
originalValues: string | string[],
): MouseEventHandler<HTMLButtonElement> {
return (_event: MouseEvent<HTMLButtonElement>): void => {
const orig = Array.isArray(originalValues)
? originalValues
: [originalValues];
const files = orig.map(transValueIntoFile).filter((v) => v !== null);
setSelectedFiles(files);
setData(name, multiple ? orig : originalValues);
if (onRefreshFile) {
onRefreshFile();
}
};
}
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} className="font-bold">
{label}
{required && " *"}
</Label>
{selectedFiles.length > 0 ? (
<div className="relative mt-1.5 flex flex-col gap-2">
{selectedFiles.map((file, index) => (
<div
key={file.dbPath ?? file.preview ?? file.name}
className="relative"
>
<div className="text-sm flex justify-center border border-dashed rounded-md overflow-hidden whitespace-normal [&_svg]:!size-auto [&_svg]:!shrink text-muted-foreground w-full h-16 bg-background/50">
<div className="flex flex-row justify-center items-center gap-2 px-4 text-center">
<FileCheck className="w-6 h-6 text-primary" />
<small className="max-w-[90%] truncate block text-muted-foreground">
{file.name}
</small>
</div>
</div>
<div className="absolute top-1/2 right-2 -translate-y-1/2 flex items-center gap-1">
{file.preview && (
<BtnOptionnalLinkWithTooltip
link={file.preview}
externalLink
variant="secondary"
content={<EyeIcon className="w-4 h-4 m-auto" />}
tooltipContent={str(
transPreview !== "uwil-backend::back.preview"
? transPreview
: "Aperçu",
)
.ucFirst()
.value()}
/>
)}
<BtnOptionnalLinkWithTooltip
variant="destructive"
content={<Trash className="w-4 h-4 m-auto" />}
onClick={onButtonClickSingleFile(index)}
tooltipContent={str(transCancel).ucFirst().value()}
/>
</div>
</div>
))}
{multiple && (
<Button
variant="ghost"
type="button"
className={cn(
"flex mt-1.5 border border-dashed rounded-md overflow-hidden whitespace-normal [&_svg]:!pointer-events-auto [&_svg]:!size-auto [&_svg]:!shrink text-muted-foreground w-full h-24 hover:bg-accent/50",
)}
// eslint-disable-next-line react/jsx-props-no-spreading
{...getRootProps()}
>
{/* eslint-disable-next-line react/jsx-props-no-spreading */}
<input name={name} {...getInputProps()} />
<div className="flex flex-col justify-center items-center gap-2">
<UploadIcon className="w-6 h-6 mb-1 opacity-70" />
<p className="text-center break-normal w-full mt-1 font-medium text-xs">
{transSelectFileZone}
</p>
</div>
</Button>
)}
</div>
) : (
<Button
variant="ghost"
type="button"
className={cn(
"flex mt-1.5 border border-dashed rounded-md overflow-hidden whitespace-normal [&_svg]:!pointer-events-auto [&_svg]:!size-auto [&_svg]:!shrink text-muted-foreground w-full h-36 hover:bg-accent/50",
{ "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={name} {...getInputProps()} />
<div className="flex flex-col justify-center items-center gap-2">
<UploadIcon className="w-6 h-6 mb-1 opacity-70" />
<p className="text-center break-normal w-full mt-1 font-medium">
{transSelectFileZone}
</p>
<small className="text-center opacity-70">
{extensions
? getTransValidationAllowedFormats(extensions)
: transValidationAllFormats}
. {str(transRulesWeight).ucFirst().value()}
</small>
</div>
</Button>
)}
</div>
{hasRefreshBtn && originalData && (
<BtnOptionnalLinkWithTooltip
disabled={isRefreshDisabled}
className="h-36 mt-1.5"
variant="secondary"
onClick={onButtonClickMultipleFiles(originalData)}
content={<RefreshCwIcon />}
tooltipContent={str(transRefresh).ucFirst().value()}
/>
)}
</div>
<InputError message={localErrors[name]} />
</div>
);
}