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;
38 interface UploadedLogo {
44 const OrganizationLogoModal = ({ onClose, organization, app, ...rest }: Props) => {
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 () => {
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 }));
71 metrics.core_lightLabelling_logoUpload_total.increment({
75 sendOrgLogoUploadSuccessReport();
76 createNotification({ text: c('Success').t`Organization logo updated` });
79 observeApiError(error, (status) =>
80 metrics.core_lightLabelling_logoUpload_total.increment({
85 sendOrgLogoUploadFailureReport();
90 sendOrgLogoUploadStartProcessReport();
93 if (uploadedUrl.current) {
94 URL.revokeObjectURL(uploadedUrl.current);
100 let timeout: NodeJS.Timeout;
103 timeout = setTimeout(() => setShowUploadLoader(true), 500);
105 setShowUploadLoader(false);
108 return () => clearTimeout(timeout);
111 const handleClose = loading ? noop : onClose;
113 const removeLogo = () => {
114 setUploadedLogo(undefined);
115 uploadedUrl.current = '';
118 const processAndUploadLogo = async (files: File[] | FileList | null) => {
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;
127 const targetImages = files ? [...files].filter(({ type }) => /^image\//i.test(type)) : [];
129 if (!targetImages.length) {
131 return setError(c('Error').t`No image selected`);
134 if (targetImages.length > 1) {
136 return setError(c('Error').t`Please upload only 1 file`);
139 const logo = targetImages[0];
141 if (!ALLOWED_FILES_TYPES.includes(logo.type)) {
143 return setError(c('Error').t`Incorrect file type. Upload a file in PNG or JPEG format.`);
146 if (logo.size > MAX_FILE_SIZE) {
148 return setError(c('Error').t`File too large. Your logo must be 30 KB or smaller.`);
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) => {
159 URL.revokeObjectURL(imageUrl);
163 if (image.width !== image.height) {
164 handleImageUploadError(c('Error').t`Please upload a square file`);
168 if (image.width < MIN_FILE_DIMENSIONS) {
169 handleImageUploadError(
171 .jt`The file is too small, the minimum size is ${MIN_FILE_DIMENSIONS}x${MIN_FILE_DIMENSIONS}`
176 if (image.width > MAX_FILE_DIMENSIONS) {
177 handleImageUploadError(
179 .jt`The file is too big, the maximum size is ${MAX_FILE_DIMENSIONS}x${MAX_FILE_DIMENSIONS}`
185 const base64str = await toBase64(logo);
186 const rezisedImage = await resizeImage({
188 maxWidth: MIN_FILE_DIMENSIONS,
189 maxHeight: MIN_FILE_DIMENSIONS,
190 finalMimeType: logo.type,
191 encoderOptions: 0.99,
192 transparencyAllowed: false,
195 const url = URL.createObjectURL(toBlob(rezisedImage));
196 const processedLogo = {
201 if (uploadedUrl.current) {
202 URL.revokeObjectURL(uploadedUrl.current);
204 uploadedUrl.current = url;
205 setUploadedLogo(processedLogo);
209 metrics.core_lightLabelling_imageProcessing_total.increment({
213 setError(c('Error').t`Failed to process the logo`);
215 metrics.core_lightLabelling_imageProcessing_total.increment({
219 URL.revokeObjectURL(imageUrl);
223 image.onerror = reject;
224 image.src = imageUrl;
231 const handleChange = async ({ target }: ChangeEvent<HTMLInputElement>) => {
233 await processAndUploadLogo(target.files);
236 const handleDrop = async (files: File[]) => {
238 await processAndUploadLogo(files);
241 const logoUrl = uploadedUrl?.current || '';
243 const renderFileStatusBox = () => {
244 if (showUploadLoader) {
245 return <CircleLoader className="color-primary" size="medium" />;
250 <div className="flex flex-column items-center w-full gap-2 mt-2">
253 data-testid="llb:image"
255 className="w-custom h-custom border shrink-0 grow-0"
257 '--w-custom': '2.25rem',
258 '--h-custom': '2.25rem',
262 <div className="flex flex-column flex-nowrap text-sm w-full">
263 <span className="text-ellipsis" data-testid="llb:fileName">
266 <span className="color-weak text-nowrap block shrink-0">
268 bytes: uploadedLogo.size,
275 <Button onClick={removeLogo} shape="ghost" size="small" icon className="top-0 right-0 absolute">
276 <Icon name="cross" />
282 return <Icon name="arrow-up-line" />;
285 const DropzoneContent = () => {
287 <div className="flex justify-center md:justify-start items-center gap-4 w-full">
290 'relative flex justify-center items-center ratio-square w-custom border rounded grow-0 p-2',
291 !logoUrl && 'bg-weak'
293 style={{ '--w-custom': '7rem' }}
295 {renderFileStatusBox()}
298 <p className="mt-0 mb-2">
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`
306 accept="image/png, image/jpeg"
308 onChange={handleChange}
313 className="m-0 p-1 inline-block mb-0.5"
316 // Translator: Full sentence Drop image file here to upload or select file
317 c('Action').t`select file`
321 <ul className="unstyled text-sm color-weak text-left m-0">
323 <Icon name="checkmark" className="shrink-0 mr-1" />
324 {c('Organization logo upload modal').t`Square image of at least 128 pixels`}
327 <Icon name="checkmark" className="shrink-0 mr-1" />
328 {c('Organization logo upload modal').t`File in PNG or JPEG format`}
331 <Icon name="checkmark" className="shrink-0 mr-1" />
332 {c('Organization logo upload modal').t`File not larger than 30 KB`}
344 if (!onFormSubmit()) {
347 void withLoading(handleSubmit());
349 onClose={handleClose}
352 <ModalHeader title={c('Title').t`Upload your organization’s logo`} />
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>
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>
368 'rounded-xl p-4 flex flex-column items-center justify-center text-center',
369 error ? 'border border-danger' : 'border-dashed border-weak'
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" />
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">
390 organizationName={organization.Name}
392 organizationNameDataTestId="llb:organization-name-1"
397 organizationName={organization.Name}
399 organizationNameDataTestId="llb:organization-name-2"
406 <Button onClick={handleClose} disabled={loading}>
407 {c('Action').t`Cancel`}
409 <Button loading={loading} type="submit" color="norm" disabled={!uploadedLogo}>
410 {c('Action').t`Save`}
417 export default OrganizationLogoModal;