- Implemented Create and Edit pages for products with forms. - Added validation and permissions handling for product actions. - Introduced new product model and related types for better structure. - Enhanced sidebar registry to remove auth dependency for link resolution. - Updated routes to handle precognitive requests for product forms.
387 lines
14 KiB
TypeScript
387 lines
14 KiB
TypeScript
// * Packages
|
|
import { usePage } from "@inertiajs/react";
|
|
import { FileCheck, RefreshCwIcon, Trash, UploadIcon, EyeIcon } from "lucide-react";
|
|
import { 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 = parentErrors ?? errors;
|
|
|
|
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);
|
|
}
|
|
});
|
|
};
|
|
}, []);
|
|
|
|
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 onDrop = useCallback(
|
|
(acceptedFiles: FileWithPath[]) => {
|
|
if (!acceptedFiles.length) {
|
|
toast({ title: "Oups !", description: dropErrorMessages.noFile });
|
|
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, localMaxFileSize, formatFileSize, name, setData],
|
|
);
|
|
|
|
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): React.MouseEventHandler<HTMLButtonElement> {
|
|
return (_: React.MouseEvent<HTMLButtonElement>): void => {
|
|
const newFiles = selectedFiles.filter((_, i) => i !== index);
|
|
setSelectedFiles(newFiles);
|
|
if (multiple) {
|
|
setData(name, newFiles.length > 0 ? newFiles.map(f => f.dbPath || (f as File)) : []);
|
|
} else {
|
|
setData(name, null);
|
|
}
|
|
}
|
|
}
|
|
|
|
function onButtonClickMultipleFiles(originalData: string | string[]): React.MouseEventHandler<HTMLButtonElement> {
|
|
return (_: React.MouseEvent<HTMLButtonElement>):void => {
|
|
const orig = Array.isArray(originalData) ? originalData : [originalData];
|
|
const files = orig.map(transValueIntoFile).filter(v => v !== null);
|
|
setSelectedFiles(files);
|
|
setData(name, multiple ? orig : originalData);
|
|
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.name + index} 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={true}
|
|
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",
|
|
)}
|
|
{...getRootProps()}
|
|
>
|
|
<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] },
|
|
)}
|
|
{...getRootProps()}
|
|
>
|
|
<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>
|
|
);
|
|
}
|