Flavien modal two
[ProtonMail-WebClient.git] / packages / components / containers / organization / logoUpload / OrganizationLogoModal.tsx
blob63303b8b4678af9bea52dea08ca52a26238c80b9
1 import type { ChangeEvent, ReactNode } from 'react';
2 import { useEffect, useRef, useState } from 'react';
4 import { c } from 'ttag';
6 import { Button, CircleLoader } from '@proton/atoms';
7 import Dropzone from '@proton/components/components/dropzone/Dropzone';
8 import Form from '@proton/components/components/form/Form';
9 import Icon from '@proton/components/components/icon/Icon';
10 import FileInput from '@proton/components/components/input/FileInput';
11 import type { ModalProps } from '@proton/components/components/modalTwo/Modal';
12 import Modal from '@proton/components/components/modalTwo/Modal';
13 import ModalContent from '@proton/components/components/modalTwo/ModalContent';
14 import ModalFooter from '@proton/components/components/modalTwo/ModalFooter';
15 import ModalHeader from '@proton/components/components/modalTwo/ModalHeader';
16 import { useLoading } from '@proton/hooks';
17 import metrics, { observeApiError } from '@proton/metrics';
18 import { updateOrganizationLogo, updateOrganizationSettings } from '@proton/shared/lib/api/organization';
19 import type { APP_NAMES } from '@proton/shared/lib/constants';
20 import { BRAND_NAME } from '@proton/shared/lib/constants';
21 import { toBase64 } from '@proton/shared/lib/helpers/file';
22 import humanSize from '@proton/shared/lib/helpers/humanSize';
23 import { extractBase64Image, resizeImage, toBlob } from '@proton/shared/lib/helpers/image';
24 import type { Organization } from '@proton/shared/lib/interfaces';
25 import clsx from '@proton/utils/clsx';
26 import noop from '@proton/utils/noop';
28 import { useFormErrors } from '../../../components';
29 import { useApi, useEventManager, useNotifications } from '../../../hooks';
30 import SidebarPreview from './SidebarPreview';
31 import useOrgLogoUploadTelemetry from './useOrgLogoUploadTelemetry';
33 interface Props extends ModalProps {
34     organization: Organization;
35     app: APP_NAMES;
38 interface UploadedLogo {
39     name: string;
40     size: number;
41     image: string;
44 const OrganizationLogoModal = ({ onClose, organization, app, ...rest }: Props) => {
45     const api = useApi();
46     const { call } = useEventManager();
47     const [loading, withLoading] = useLoading();
48     const { onFormSubmit } = useFormErrors();
49     const [uploadedLogo, setUploadedLogo] = useState<UploadedLogo | undefined>();
50     const uploadedUrl = useRef<string | undefined>(undefined);
51     const [uploading, setUploading] = useState<boolean>(false);
52     const [showUploadLoader, setShowUploadLoader] = useState<boolean>(false);
53     const [error, setError] = useState<ReactNode>();
54     const { createNotification } = useNotifications();
56     const { sendOrgLogoUploadStartProcessReport, sendOrgLogoUploadSuccessReport, sendOrgLogoUploadFailureReport } =
57         useOrgLogoUploadTelemetry();
59     const handleSubmit = async () => {
60         if (!uploadedLogo) {
61             return;
62         }
64         try {
65             const { base64 } = extractBase64Image(uploadedLogo.image);
66             await api(updateOrganizationLogo(base64));
67             // Set ShowName to true here, this might get its own setting in the future
68             await api(updateOrganizationSettings({ ShowName: true }));
69             await call();
71             metrics.core_lightLabelling_logoUpload_total.increment({
72                 status: 'success',
73             });
75             sendOrgLogoUploadSuccessReport();
76             createNotification({ text: c('Success').t`Organization logo updated` });
77             onClose?.();
78         } catch (error) {
79             observeApiError(error, (status) =>
80                 metrics.core_lightLabelling_logoUpload_total.increment({
81                     status,
82                 })
83             );
85             sendOrgLogoUploadFailureReport();
86         }
87     };
89     useEffect(() => {
90         sendOrgLogoUploadStartProcessReport();
92         return () => {
93             if (uploadedUrl.current) {
94                 URL.revokeObjectURL(uploadedUrl.current);
95             }
96         };
97     }, []);
99     useEffect(() => {
100         let timeout: NodeJS.Timeout;
102         if (uploading) {
103             timeout = setTimeout(() => setShowUploadLoader(true), 500);
104         } else {
105             setShowUploadLoader(false);
106         }
108         return () => clearTimeout(timeout);
109     }, [uploading]);
111     const handleClose = loading ? noop : onClose;
113     const removeLogo = () => {
114         setUploadedLogo(undefined);
115         uploadedUrl.current = '';
116     };
118     const processAndUploadLogo = async (files: File[] | FileList | null) => {
119         setUploading(true);
121         const MAX_FILE_SIZE = 30 * 1024; // 30kb in bytes
122         const ALLOWED_FILES_TYPES = ['image/jpeg', 'image/png'];
123         const MIN_FILE_DIMENSIONS = 128;
124         const MAX_FILE_DIMENSIONS = 1024;
126         try {
127             const targetImages = files ? [...files].filter(({ type }) => /^image\//i.test(type)) : [];
129             if (!targetImages.length) {
130                 removeLogo();
131                 return setError(c('Error').t`No image selected`);
132             }
134             if (targetImages.length > 1) {
135                 removeLogo();
136                 return setError(c('Error').t`Please upload only 1 file`);
137             }
139             const logo = targetImages[0];
141             if (!ALLOWED_FILES_TYPES.includes(logo.type)) {
142                 removeLogo();
143                 return setError(c('Error').t`Incorrect file type. Upload a file in PNG or JPEG format.`);
144             }
146             if (logo.size > MAX_FILE_SIZE) {
147                 removeLogo();
148                 return setError(c('Error').t`File too large. Your logo must be 30 KB or smaller.`);
149             }
151             await new Promise<void>((resolve, reject) => {
152                 const image = new Image();
153                 const imageUrl = URL.createObjectURL(logo);
155                 image.onload = async () => {
156                     const handleImageUploadError = (message: ReactNode) => {
157                         removeLogo();
158                         setError(message);
159                         URL.revokeObjectURL(imageUrl);
160                         setUploading(false);
161                     };
163                     if (image.width !== image.height) {
164                         handleImageUploadError(c('Error').t`Please upload a square file`);
165                         return;
166                     }
168                     if (image.width < MIN_FILE_DIMENSIONS) {
169                         handleImageUploadError(
170                             c('Error')
171                                 .jt`The file is too small, the minimum size is ${MIN_FILE_DIMENSIONS}x${MIN_FILE_DIMENSIONS}`
172                         );
173                         return;
174                     }
176                     if (image.width > MAX_FILE_DIMENSIONS) {
177                         handleImageUploadError(
178                             c('Error')
179                                 .jt`The file is too big, the maximum size is ${MAX_FILE_DIMENSIONS}x${MAX_FILE_DIMENSIONS}`
180                         );
181                         return;
182                     }
184                     try {
185                         const base64str = await toBase64(logo);
186                         const rezisedImage = await resizeImage({
187                             original: base64str,
188                             maxWidth: MIN_FILE_DIMENSIONS,
189                             maxHeight: MIN_FILE_DIMENSIONS,
190                             finalMimeType: logo.type,
191                             encoderOptions: 0.99,
192                             transparencyAllowed: false,
193                         });
195                         const url = URL.createObjectURL(toBlob(rezisedImage));
196                         const processedLogo = {
197                             name: logo.name,
198                             size: logo.size,
199                             image: rezisedImage,
200                         };
201                         if (uploadedUrl.current) {
202                             URL.revokeObjectURL(uploadedUrl.current);
203                         }
204                         uploadedUrl.current = url;
205                         setUploadedLogo(processedLogo);
206                         setError(null);
207                         resolve();
209                         metrics.core_lightLabelling_imageProcessing_total.increment({
210                             status: 'success',
211                         });
212                     } catch (error) {
213                         setError(c('Error').t`Failed to process the logo`);
215                         metrics.core_lightLabelling_imageProcessing_total.increment({
216                             status: 'failure',
217                         });
218                     } finally {
219                         URL.revokeObjectURL(imageUrl);
220                     }
221                 };
223                 image.onerror = reject;
224                 image.src = imageUrl;
225             });
226         } finally {
227             setUploading(false);
228         }
229     };
231     const handleChange = async ({ target }: ChangeEvent<HTMLInputElement>) => {
232         setError(null);
233         await processAndUploadLogo(target.files);
234     };
236     const handleDrop = async (files: File[]) => {
237         setError(null);
238         await processAndUploadLogo(files);
239     };
241     const logoUrl = uploadedUrl?.current || '';
243     const renderFileStatusBox = () => {
244         if (showUploadLoader) {
245             return <CircleLoader className="color-primary" size="medium" />;
246         }
248         if (logoUrl) {
249             return (
250                 <div className="flex flex-column items-center w-full gap-2 mt-2">
251                     <img
252                         src={logoUrl}
253                         data-testid="llb:image"
254                         alt=""
255                         className="w-custom h-custom border shrink-0 grow-0"
256                         style={{
257                             '--w-custom': '2.25rem',
258                             '--h-custom': '2.25rem',
259                         }}
260                     />
261                     {uploadedLogo && (
262                         <div className="flex flex-column flex-nowrap text-sm w-full">
263                             <span className="text-ellipsis" data-testid="llb:fileName">
264                                 {uploadedLogo.name}
265                             </span>
266                             <span className="color-weak text-nowrap block shrink-0">
267                                 {humanSize({
268                                     bytes: uploadedLogo.size,
269                                     unit: 'KB',
270                                     fraction: 1,
271                                 })}
272                             </span>
273                         </div>
274                     )}
275                     <Button onClick={removeLogo} shape="ghost" size="small" icon className="top-0 right-0 absolute">
276                         <Icon name="cross" />
277                     </Button>
278                 </div>
279             );
280         }
282         return <Icon name="arrow-up-line" />;
283     };
285     const DropzoneContent = () => {
286         return (
287             <div className="flex justify-center md:justify-start items-center gap-4 w-full">
288                 <div
289                     className={clsx(
290                         'relative flex justify-center items-center ratio-square w-custom border rounded grow-0 p-2',
291                         !logoUrl && 'bg-weak'
292                     )}
293                     style={{ '--w-custom': '7rem' }}
294                 >
295                     {renderFileStatusBox()}
296                 </div>
297                 <div>
298                     <p className="mt-0 mb-2">
299                         <span>
300                             {
301                                 // Translator: Full sentence Drop image file here to upload or select file
302                                 c('Organization logo upload modal').t`Drop image file here to upload or`
303                             }
304                         </span>
305                         <FileInput
306                             accept="image/png, image/jpeg"
307                             id="upload-logo"
308                             onChange={handleChange}
309                             disabled={uploading}
310                             loading={uploading}
311                             shape="underline"
312                             color="norm"
313                             className="m-0 p-1 inline-block mb-0.5"
314                         >
315                             {
316                                 // Translator: Full sentence Drop image file here to upload or select file
317                                 c('Action').t`select file`
318                             }
319                         </FileInput>
320                     </p>
321                     <ul className="unstyled text-sm color-weak text-left m-0">
322                         <li>
323                             <Icon name="checkmark" className="shrink-0 mr-1" />
324                             {c('Organization logo upload modal').t`Square image of at least 128 pixels`}
325                         </li>
326                         <li>
327                             <Icon name="checkmark" className="shrink-0 mr-1" />
328                             {c('Organization logo upload modal').t`File in PNG or JPEG format`}
329                         </li>
330                         <li>
331                             <Icon name="checkmark" className="shrink-0 mr-1" />
332                             {c('Organization logo upload modal').t`File not larger than 30 KB`}
333                         </li>
334                     </ul>
335                 </div>
336             </div>
337         );
338     };
340     return (
341         <Modal
342             as={Form}
343             onSubmit={() => {
344                 if (!onFormSubmit()) {
345                     return;
346                 }
347                 void withLoading(handleSubmit());
348             }}
349             onClose={handleClose}
350             {...rest}
351         >
352             <ModalHeader title={c('Title').t`Upload your organization’s logo`} />
353             <ModalContent>
354                 <p>{c('Organization logo upload modal')
355                     .t`Users will see your logo instead of the ${BRAND_NAME} icon when signed in on our web apps.`}</p>
357                 <Dropzone
358                     onDrop={handleDrop}
359                     shape="flashy"
360                     customContent={
361                         <div className="w-full h-full flex flex-column items-center justify-center text-center">
362                             <div>{c('Info').t`Drop file here to upload`}</div>
363                         </div>
364                     }
365                 >
366                     <div
367                         className={clsx(
368                             'rounded-xl p-4 flex flex-column items-center justify-center text-center',
369                             error ? 'border border-danger' : 'border-dashed border-weak'
370                         )}
371                     >
372                         <DropzoneContent />
373                     </div>
374                 </Dropzone>
375                 {error && (
376                     <p className="text-sm text-semibold color-danger my-1 flex items-center">
377                         <Icon name="exclamation-circle-filled" className="shrink-0 mr-1" />
378                         <span>{error}</span>
379                     </p>
380                 )}
382                 {logoUrl && (
383                     <div className="mt-6">
384                         <h3 className="text-rg text-bold mb-2">{c('Info').t`Preview`}</h3>
386                         <div className="w-full flex flex-column md:flex-row flex-nowrap md:justify-space-between gap-4">
387                             <SidebarPreview
388                                 app={app}
389                                 imageUrl={logoUrl}
390                                 organizationName={organization.Name}
391                                 variant="dark"
392                                 organizationNameDataTestId="llb:organization-name-1"
393                             />
394                             <SidebarPreview
395                                 app={app}
396                                 imageUrl={logoUrl}
397                                 organizationName={organization.Name}
398                                 variant="light"
399                                 organizationNameDataTestId="llb:organization-name-2"
400                             />
401                         </div>
402                     </div>
403                 )}
404             </ModalContent>
405             <ModalFooter>
406                 <Button onClick={handleClose} disabled={loading}>
407                     {c('Action').t`Cancel`}
408                 </Button>
409                 <Button loading={loading} type="submit" color="norm" disabled={!uploadedLogo}>
410                     {c('Action').t`Save`}
411                 </Button>
412             </ModalFooter>
413         </Modal>
414     );
417 export default OrganizationLogoModal;