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;
40 interface UploadedLogo {
46 const OrganizationLogoModal = ({ onClose, organization, app, ...rest }: Props) => {
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 () => {
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 }));
73 metrics.core_lightLabelling_logoUpload_total.increment({
77 sendOrgLogoUploadSuccessReport();
78 createNotification({ text: c('Success').t`Organization logo updated` });
81 observeApiError(error, (status) =>
82 metrics.core_lightLabelling_logoUpload_total.increment({
87 sendOrgLogoUploadFailureReport();
92 sendOrgLogoUploadStartProcessReport();
95 if (uploadedUrl.current) {
96 URL.revokeObjectURL(uploadedUrl.current);
102 let timeout: NodeJS.Timeout;
105 timeout = setTimeout(() => setShowUploadLoader(true), 500);
107 setShowUploadLoader(false);
110 return () => clearTimeout(timeout);
113 const handleClose = loading ? noop : onClose;
115 const removeLogo = () => {
116 setUploadedLogo(undefined);
117 uploadedUrl.current = '';
120 const processAndUploadLogo = async (files: File[] | FileList | null) => {
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;
129 const targetImages = files ? [...files].filter(({ type }) => /^image\//i.test(type)) : [];
131 if (!targetImages.length) {
133 return setError(c('Error').t`No image selected`);
136 if (targetImages.length > 1) {
138 return setError(c('Error').t`Please upload only 1 file`);
141 const logo = targetImages[0];
143 if (!ALLOWED_FILES_TYPES.includes(logo.type)) {
145 return setError(c('Error').t`Incorrect file type. Upload a file in PNG or JPEG format.`);
148 if (logo.size > MAX_FILE_SIZE) {
150 return setError(c('Error').t`File too large. Your logo must be 30 KB or smaller.`);
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) => {
161 URL.revokeObjectURL(imageUrl);
165 if (image.width !== image.height) {
166 handleImageUploadError(c('Error').t`Please upload a square logo`);
170 if (image.width < MIN_FILE_DIMENSIONS) {
171 handleImageUploadError(
173 .jt`The file is too small, the minimum size is ${MIN_FILE_DIMENSIONS}x${MIN_FILE_DIMENSIONS}`
178 if (image.width > MAX_FILE_DIMENSIONS) {
179 handleImageUploadError(
181 .jt`The file is too big, the maximum size is ${MAX_FILE_DIMENSIONS}x${MAX_FILE_DIMENSIONS}`
187 const base64str = await toBase64(logo);
188 const rezisedImage = await resizeImage({
190 maxWidth: MIN_FILE_DIMENSIONS,
191 maxHeight: MIN_FILE_DIMENSIONS,
192 finalMimeType: logo.type,
193 encoderOptions: 0.99,
194 transparencyAllowed: false,
197 const url = URL.createObjectURL(toBlob(rezisedImage));
198 const processedLogo = {
203 if (uploadedUrl.current) {
204 URL.revokeObjectURL(uploadedUrl.current);
206 uploadedUrl.current = url;
207 setUploadedLogo(processedLogo);
211 metrics.core_lightLabelling_imageProcessing_total.increment({
215 setError(c('Error').t`Failed to process the logo`);
217 metrics.core_lightLabelling_imageProcessing_total.increment({
221 URL.revokeObjectURL(imageUrl);
225 image.onerror = reject;
226 image.src = imageUrl;
233 const handleChange = async ({ target }: ChangeEvent<HTMLInputElement>) => {
235 await processAndUploadLogo(target.files);
238 const handleDrop = async (files: File[]) => {
240 await processAndUploadLogo(files);
243 const logoUrl = uploadedUrl?.current || '';
245 const renderFileStatusBox = () => {
246 if (showUploadLoader) {
247 return <CircleLoader className="color-primary" size="medium" />;
252 <div className="flex flex-column items-center w-full gap-2 mt-2">
255 data-testid="llb:image"
257 className="w-custom h-custom border shrink-0 grow-0"
259 '--w-custom': '2.25rem',
260 '--h-custom': '2.25rem',
264 <div className="flex flex-column flex-nowrap text-sm w-full">
265 <span className="text-ellipsis" data-testid="llb:fileName">
268 <span className="color-weak text-nowrap block shrink-0">
270 bytes: uploadedLogo.size,
277 <Button onClick={removeLogo} shape="ghost" size="small" icon className="top-0 right-0 absolute">
278 <Icon name="cross" />
284 return <Icon name="arrow-up-line" />;
287 const DropzoneContent = () => {
289 <div className="flex justify-center md:justify-start items-center gap-4 w-full">
292 'relative flex justify-center items-center ratio-square w-custom border rounded grow-0 p-2',
293 !logoUrl && 'bg-weak'
295 style={{ '--w-custom': '7rem' }}
297 {renderFileStatusBox()}
300 <p className="mt-0 mb-2">
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`
308 accept="image/png, image/jpeg"
310 onChange={handleChange}
315 className="m-0 p-1 inline-block mb-0.5"
318 // Translator: Full sentence Drop image file here to upload or select file
319 c('Action').t`select file`
323 <ul className="unstyled text-sm color-weak text-left m-0">
325 <Icon name="checkmark" className="shrink-0 mr-1" />
326 {c('Organization logo upload modal').t`Square image of at least 128 pixels`}
329 <Icon name="checkmark" className="shrink-0 mr-1" />
330 {c('Organization logo upload modal').t`File in PNG or JPEG format`}
333 <Icon name="checkmark" className="shrink-0 mr-1" />
334 {c('Organization logo upload modal').t`File not larger than 30 KB`}
346 if (!onFormSubmit()) {
349 void withLoading(handleSubmit());
351 onClose={handleClose}
354 <ModalHeader title={c('Title').t`Upload your organization’s logo`} />
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>
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>
370 'rounded-xl p-4 flex flex-column items-center justify-center text-center',
371 error ? 'border border-danger' : 'border-dashed border-weak'
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" />
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">
392 organizationName={organization.Name}
394 organizationNameDataTestId="llb:organization-name-1"
399 organizationName={organization.Name}
401 organizationNameDataTestId="llb:organization-name-2"
408 <Button onClick={handleClose} disabled={loading}>
409 {c('Action').t`Cancel`}
411 <Button loading={loading} type="submit" color="norm" disabled={!uploadedLogo}>
412 {c('Action').t`Save`}
419 export default OrganizationLogoModal;