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