ldc-ovoteam/resources/ts/back/Components/Inputs/SimpleInputFile.tsx
Jérémy Munsch ca9c0fbc4e
feat(policy): allow ProductDto and RecipeDto in policies
Updated ProductPolicy and RecipePolicy to accept ProductDto in addition to Product. This change enhances the flexibility of the policies by allowing DTOs to be used for authorization checks.

refactor(provider): simplify service provider bindings

Refactored AppServiceProvider to simplify the binding of ProductDirectory and RecipeDirectory. Removed unnecessary dependencies and improved clarity.

feat(route): update route bindings for front-end

Changed route bindings for products and recipes to use frontProduct and frontRecipe. This improves clarity and consistency in route handling.

fix(breadcrumbs): update breadcrumb checks for DTOs

Updated breadcrumb generation to check for ProductDto and RecipeDto instead of their model counterparts. This ensures breadcrumbs function correctly with the new DTO structure.

refactor(types): remove FilePath type and update schemas

Removed the FilePath type definition and updated related schemas to use string directly. This simplifies type handling for file paths.

style(entities): standardize naming conventions

Updated naming conventions in Product and Recipe entities to use snake_case for consistency across the codebase.

feat(frontend): update product and recipe pages for new DTO structure

Modified front-end components to accommodate changes in the product and recipe data structures, ensuring compatibility with the new DTOs.
2026-11-05 16:49:42 +01:00

483 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 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);
const preview = /^https?:\/\//i.test(localData)
? localData
: `${url}/${localData.replace(/^\/+/, "")}`;
return {
preview,
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>
);
}