485 lines
16 KiB
TypeScript
485 lines
16 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 useTrans from "@/hooks/use-translations";
|
|
import { cn, GetTranslation } 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 = useTrans(
|
|
"uwil-backend::back.image_cropper.select_file_zone",
|
|
);
|
|
const transValidationAllFormats = str(
|
|
useTrans("uwil-backend::back.image_cropper.validation.all_formats"),
|
|
)
|
|
.ucFirst()
|
|
.value();
|
|
const transCancel = useTrans("uwil-backend::back.cancel");
|
|
const transPreview = useTrans("uwil-backend::back.preview");
|
|
const transRulesWeight = useTrans(
|
|
"uwil-backend::back.image_cropper.validation.rules_weight",
|
|
{
|
|
params: { size: formatFileSize(localMaxFileSize).toString() },
|
|
},
|
|
);
|
|
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();
|
|
};
|
|
|
|
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(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 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}>
|
|
{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>
|
|
);
|
|
}
|