2025-07-03 18:09:01 +02:00

244 lines
7.2 KiB
TypeScript

import Typography, { ETypo, ETypoColor } from "@Front/Components/DesignSystem/Typography";
import { DocumentPlusIcon } from "@heroicons/react/24/outline";
import classNames from "classnames";
import React, { useCallback, useEffect, useRef, useState } from "react";
import Button, { EButtonSize, EButtonstyletype, EButtonVariant } from "../Button";
import Separator, { ESeperatorColor, ESeperatorDirection } from "../Separator";
import classes from "./classes.module.scss";
import DocumentFileElement from "./DocumentFileElement";
/**
* @description Drag and drop component to upload files
* @param {string} title - Title of the component
* @param {string} description - Description of the component
* @param {IDocumentFileWithUid[]} defaultFiles - Default files to display
* @param {(fileUid: string) => Promise<any>} onDelete - Function to delete a file (must be used with defaultFiles)
* @param {(file: File) => Promise<any>} onAddFile - Function to add a file (must be used with defaultFiles)
*/
type IProps = {
title: string;
description?: string;
defaultFiles?: IDocumentFileWithUid[];
onDelete?: (fileUid: string) => Promise<any>;
onAddFile?: (file: File) => Promise<any>;
name?: string;
onChange?: (files: File[]) => void;
} & (
| { onDelete: (fileUid: string) => Promise<any>; onAddFile?: never; defaultFiles: IDocumentFileWithUid[] }
| { onDelete?: never; onAddFile: (file: File) => Promise<any>; defaultFiles: IDocumentFileWithUid[] }
| { onDelete?: (fileUid: string) => Promise<any>; onAddFile?: (file: File) => Promise<any>; defaultFiles: IDocumentFileWithUid[] }
| { onDelete?: never; onAddFile?: never; defaultFiles?: never }
);
type IMimeTypes = {
extension: string;
size: number;
};
const mimeTypesAccepted: { [key: string]: IMimeTypes } = {
"application/pdf": {
extension: "pdf",
size: 104857600, // 100MB
},
"image/jpeg": {
extension: "jpeg",
size: 104857600, // 100MB
},
"image/png": {
extension: "png",
size: 104857600, // 100MB
},
"image/jpg": {
extension: "jpg",
size: 104857600, // 100MB
},
"text/csv": {
extension: "csv",
size: 104857600, // 100MB
},
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": {
extension: "xlsx",
size: 104857600, // 100MB
},
"application/vnd.ms-excel": {
extension: "xls",
size: 104857600, // 100MB
},
"application/msword": {
extension: "doc",
size: 104857600, // 100MB
},
"application/vnd.openxmlformats-officedocument.wordprocessingml.document": {
extension: "docx",
size: 104857600, // 100MB
},
"text/plain": {
extension: "txt",
size: 104857600, // 100MB
},
};
type IDocumentFileBase = {
id: string;
file: File | null;
uid?: string;
isLoading?: boolean;
error?: string;
};
export type IDocumentFileWithUid = IDocumentFileBase & {
uid: string;
};
type IDocumentFile = IDocumentFileBase | IDocumentFileWithUid;
export default function DragAndDrop(props: IProps) {
const { title, description, defaultFiles, onDelete, onAddFile, name, onChange } = props;
const fileInputRef = useRef<HTMLInputElement>(null);
const [documentFiles, setDocumentFiles] = useState<IDocumentFile[]>([]);
useEffect(() => {
if (defaultFiles) {
setDocumentFiles(defaultFiles);
}
}, [defaultFiles]);
useEffect(() => onChange?.(documentFiles.map((doc) => doc.file).filter((file) => file !== null) as File[]), [documentFiles, onChange]);
const handleAddFiles = useCallback(
(files: File[]) => {
files.forEach((file) => {
setDocumentFiles((prevDocs) => [...prevDocs, { id: file.name, file: file, isLoading: true }]);
try {
if (!mimeTypesAccepted[file.type]) {
throw new Error("Type de fichier non accepté");
}
const newDoc: IDocumentFile = { id: file.name, file, isLoading: false };
if (onAddFile) {
// As onAddFile is used along defaultFiles prop we dont need to update the state here but the parent component should update the defaultFiles prop
return onAddFile(file);
}
return setTimeout(async () => {
setDocumentFiles((prevDocs) => prevDocs.map((doc) => (doc.id === newDoc.id ? newDoc : doc)));
}, 1000);
} catch (error: any) {
const errorDoc: IDocumentFile = { id: file.name, file: null, isLoading: false, error: error.message };
return setDocumentFiles((prevDocs) => prevDocs.map((doc) => (doc.id === errorDoc.id ? errorDoc : doc)));
}
});
},
[onAddFile],
);
const handleDrop = useCallback(
(event: React.DragEvent<HTMLDivElement>) => {
event.preventDefault();
const files = Array.from(event.dataTransfer.files);
handleAddFiles(files);
},
[handleAddFiles],
);
const handleRemove = useCallback(
(documentFile: IDocumentFile) => {
const loadingDoc = { ...documentFile, isLoading: true };
setDocumentFiles((prevDocs) => prevDocs.map((doc) => (doc.id === documentFile.id ? loadingDoc : doc)));
if (documentFile.uid) {
return onDelete?.(documentFile.uid);
}
return setDocumentFiles((prevDocs) => prevDocs.filter((doc) => doc.id !== documentFile.id));
},
[onDelete],
);
const handleBrowse = useCallback(
(event: React.ChangeEvent<HTMLInputElement>) => {
const files = Array.from(event.target.files || []);
handleAddFiles(files);
},
[handleAddFiles],
);
const triggerFileInput = () => {
if (fileInputRef.current) {
fileInputRef.current.click();
}
};
return (
<div
className={classNames(classes["root"], documentFiles.length > 0 && classes["filled"])}
onDrop={handleDrop}
onDragOver={(e) => e.preventDefault()}>
<div className={classes["content"]}>
<DocumentPlusIcon />
<Separator direction={ESeperatorDirection.VERTICAL} color={ESeperatorColor.STRONG} size={64} />
<div className={classes["browse-document-container"]}>
<div className={classNames(classes["browse-document"], classes["desktop"])}>
<Typography typo={ETypo.TEXT_LG_SEMIBOLD} color={ETypoColor.TEXT_PRIMARY}>
{title}
</Typography>
<Button
variant={EButtonVariant.PRIMARY}
styletype={EButtonstyletype.TEXT}
size={EButtonSize.SM}
onClick={triggerFileInput}>
{inputFile()}
parcourir
</Button>
</div>
<div className={classNames(classes["browse-document"], classes["mobile"])}>
<Button
variant={EButtonVariant.PRIMARY}
styletype={EButtonstyletype.TEXT}
size={EButtonSize.SM}
onClick={triggerFileInput}>
{inputFile()}
Ajouter un document
</Button>
</div>
{description && (
<Typography typo={ETypo.TEXT_MD_LIGHT} color={ETypoColor.TEXT_SECONDARY}>
{description}
</Typography>
)}
</div>
</div>
{documentFiles.length > 0 && (
<div className={classes["documents"]}>
{documentFiles.map((documentFile, index) => (
<DocumentFileElement
key={documentFile.uid || `${documentFile.id}-${index}`}
isLoading={documentFile.isLoading}
file={documentFile.file}
onRemove={() => handleRemove(documentFile)}
error={documentFile.error}
/>
))}
</div>
)}
</div>
);
function inputFile() {
return (
<input
name={name}
ref={fileInputRef}
type="file"
multiple
accept={Object.keys(mimeTypesAccepted).join(",")}
onChange={handleBrowse}
hidden
/>
);
}
}