Profile editing: Uploading avatar and header images (#38189)

This commit is contained in:
Echo 2026-03-16 12:39:52 +01:00 committed by GitHub
parent 9c8be1e721
commit 21c27eb3af
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 647 additions and 39 deletions

View File

@ -273,3 +273,7 @@ export const quoteComposeCancel = createAction('compose/quoteComposeCancel');
export const setComposeQuotePolicy = createAction<ApiQuotePolicy>(
'compose/setQuotePolicy',
);
export const setDragUploadEnabled = createAction<boolean>(
'compose/setDragUploadEnabled',
);

View File

@ -67,5 +67,5 @@ export const apiGetFamiliarFollowers = (id: string) =>
export const apiGetProfile = () => apiRequestGet<ApiProfileJSON>('v1/profile');
export const apiPatchProfile = (params: ApiProfileUpdateParams) =>
export const apiPatchProfile = (params: ApiProfileUpdateParams | FormData) =>
apiRequestPatch<ApiProfileJSON>('v1/profile', params);

View File

@ -26,6 +26,7 @@ export const CharacterCounter = polymorphicForwardRef<
maxLength,
as: Component = 'span',
recommended = false,
className,
...props
},
ref,
@ -39,6 +40,7 @@ export const CharacterCounter = polymorphicForwardRef<
{...props}
ref={ref}
className={classNames(
className,
classes.counter,
currentLength > maxLength && !recommended && classes.counterError,
)}

View File

@ -12,11 +12,9 @@ import { openModal } from '@/mastodon/actions/modal';
import { Dropdown } from '@/mastodon/components/dropdown_menu';
import { IconButton } from '@/mastodon/components/icon_button';
import type { MenuItem } from '@/mastodon/models/dropdown_menu';
import {
createAppSelector,
useAppDispatch,
useAppSelector,
} from '@/mastodon/store';
import type { ImageLocation } from '@/mastodon/reducers/slices/profile_edit';
import { selectImageInfo } from '@/mastodon/reducers/slices/profile_edit';
import { useAppDispatch, useAppSelector } from '@/mastodon/store';
import AddIcon from '@/material-icons/400-24px/add.svg?react';
import DeleteIcon from '@/material-icons/400-24px/delete.svg?react';
import EditIcon from '@/material-icons/400-24px/edit.svg?react';
@ -50,36 +48,15 @@ const messages = defineMessages({
},
});
export type ImageLocation = 'avatar' | 'header';
const selectImageInfo = createAppSelector(
[
(state) => state.profileEdit.profile,
(_, location: ImageLocation) => location,
],
(profile, location) => {
if (!profile) {
return {
hasImage: false,
hasAlt: false,
};
}
return {
hasImage: !!profile[`${location}Static`],
hasAlt: !!profile[`${location}Description`],
};
},
);
export const AccountImageEdit: FC<{
className?: string;
location: ImageLocation;
}> = ({ className, location }) => {
const intl = useIntl();
const { hasAlt, hasImage } = useAppSelector((state) =>
const { alt, src } = useAppSelector((state) =>
selectImageInfo(state, location),
);
const hasAlt = !!alt;
const dispatch = useAppDispatch();
const handleModal = useCallback(
@ -125,7 +102,7 @@ export const AccountImageEdit: FC<{
const iconClassName = classNames(classes.imageButton, className);
if (!hasImage) {
if (!src) {
return (
<IconButton
title={intl.formatMessage(messages.add)}

View File

@ -1,8 +1,9 @@
import type { FC } from 'react';
import type { ImageLocation } from '@/mastodon/reducers/slices/profile_edit';
import { DialogModal } from '../../ui/components/dialog_modal';
import type { DialogModalProps } from '../../ui/components/dialog_modal';
import type { ImageLocation } from '../components/image_edit';
export const ImageAltModal: FC<
DialogModalProps & { location: ImageLocation }

View File

@ -1,8 +1,9 @@
import type { FC } from 'react';
import type { ImageLocation } from '@/mastodon/reducers/slices/profile_edit';
import { DialogModal } from '../../ui/components/dialog_modal';
import type { DialogModalProps } from '../../ui/components/dialog_modal';
import type { ImageLocation } from '../components/image_edit';
export const ImageDeleteModal: FC<
DialogModalProps & { location: ImageLocation }

View File

@ -1,11 +1,487 @@
import type { FC } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import type { ChangeEventHandler, FC } from 'react';
import { defineMessage, FormattedMessage, useIntl } from 'react-intl';
import type { Area } from 'react-easy-crop';
import Cropper from 'react-easy-crop';
import { setDragUploadEnabled } from '@/mastodon/actions/compose_typed';
import { Button } from '@/mastodon/components/button';
import { Callout } from '@/mastodon/components/callout';
import { CharacterCounter } from '@/mastodon/components/character_counter';
import { TextAreaField } from '@/mastodon/components/form_fields';
import { RangeInput } from '@/mastodon/components/form_fields/range_input_field';
import {
selectImageInfo,
uploadImage,
} from '@/mastodon/reducers/slices/profile_edit';
import type { ImageLocation } from '@/mastodon/reducers/slices/profile_edit';
import { useAppDispatch, useAppSelector } from '@/mastodon/store';
import { DialogModal } from '../../ui/components/dialog_modal';
import type { DialogModalProps } from '../../ui/components/dialog_modal';
import type { ImageLocation } from '../components/image_edit';
import classes from './styles.module.scss';
import 'react-easy-crop/react-easy-crop.css';
export const ImageUploadModal: FC<
DialogModalProps & { location: ImageLocation }
> = ({ onClose }) => {
return <DialogModal title='TODO' onClose={onClose} />;
> = ({ onClose, location }) => {
const { src: oldSrc } = useAppSelector((state) =>
selectImageInfo(state, location),
);
const hasImage = !!oldSrc;
const [step, setStep] = useState<'select' | 'crop' | 'alt'>('select');
// State for individual steps.
const [imageSrc, setImageSrc] = useState<string | null>(null);
const [imageBlob, setImageBlob] = useState<Blob | null>(null);
const handleFile = useCallback((file: File) => {
const reader = new FileReader();
reader.addEventListener('load', () => {
const result = reader.result;
if (typeof result === 'string' && result.length > 0) {
setImageSrc(result);
setStep('crop');
}
});
reader.readAsDataURL(file);
}, []);
const handleCrop = useCallback(
(crop: Area) => {
if (!imageSrc) {
setStep('select');
return;
}
void calculateCroppedImage(imageSrc, crop).then((blob) => {
setImageBlob(blob);
setStep('alt');
});
},
[imageSrc],
);
const dispatch = useAppDispatch();
const handleSave = useCallback(
(altText: string) => {
if (!imageBlob) {
setStep('crop');
return;
}
void dispatch(uploadImage({ location, imageBlob, altText })).then(
onClose,
);
},
[dispatch, imageBlob, location, onClose],
);
const handleCancel = useCallback(() => {
switch (step) {
case 'crop':
setImageSrc(null);
setStep('select');
break;
case 'alt':
setImageBlob(null);
setStep('crop');
break;
default:
onClose();
}
}, [onClose, step]);
return (
<DialogModal
title={
hasImage ? (
<FormattedMessage
id='account_edit.upload_modal.title_replace'
defaultMessage='Replace profile photo'
/>
) : (
<FormattedMessage
id='account_edit.upload_modal.title_add'
defaultMessage='Add profile photo'
/>
)
}
onClose={onClose}
wrapperClassName={classes.uploadWrapper}
noCancelButton
>
{step === 'select' && (
<StepUpload location={location} onFile={handleFile} />
)}
{step === 'crop' && imageSrc && (
<StepCrop
src={imageSrc}
location={location}
onCancel={handleCancel}
onComplete={handleCrop}
/>
)}
{step === 'alt' && imageBlob && (
<StepAlt
imageBlob={imageBlob}
onCancel={handleCancel}
onComplete={handleSave}
/>
)}
</DialogModal>
);
};
// Taken from app/models/concerns/account/header.rb and app/models/concerns/account/avatar.rb
const ALLOWED_MIME_TYPES = [
'image/jpeg',
'image/png',
'image/gif',
'image/webp',
];
const StepUpload: FC<{
location: ImageLocation;
onFile: (file: File) => void;
}> = ({ location, onFile }) => {
const inputRef = useRef<HTMLInputElement>(null);
const handleUploadClick = useCallback(() => {
inputRef.current?.click();
}, []);
const handleFileChange: ChangeEventHandler<HTMLInputElement> = useCallback(
(event) => {
const file = event.currentTarget.files?.[0];
if (!file || !ALLOWED_MIME_TYPES.includes(file.type)) {
return;
}
onFile(file);
},
[onFile],
);
// Handle drag and drop
const [isDragging, setDragging] = useState(false);
const handleDragOver = useCallback((event: DragEvent) => {
event.preventDefault();
if (!event.dataTransfer?.types.includes('Files')) {
return;
}
const items = Array.from(event.dataTransfer.items);
if (
!items.some(
(item) =>
item.kind === 'file' && ALLOWED_MIME_TYPES.includes(item.type),
)
) {
return;
}
setDragging(true);
}, []);
const handleDragDrop = useCallback(
(event: DragEvent) => {
event.preventDefault();
setDragging(false);
if (!event.dataTransfer?.files) {
return;
}
const file = Array.from(event.dataTransfer.files).find((f) =>
ALLOWED_MIME_TYPES.includes(f.type),
);
if (!file) {
return;
}
onFile(file);
},
[onFile],
);
const handleDragLeave = useCallback((event: DragEvent) => {
event.preventDefault();
setDragging(false);
}, []);
const dispatch = useAppDispatch();
useEffect(() => {
dispatch(setDragUploadEnabled(false));
document.addEventListener('dragover', handleDragOver);
document.addEventListener('drop', handleDragDrop);
document.addEventListener('dragleave', handleDragLeave);
return () => {
document.removeEventListener('dragover', handleDragOver);
document.removeEventListener('drop', handleDragDrop);
document.removeEventListener('dragleave', handleDragLeave);
dispatch(setDragUploadEnabled(true));
};
}, [handleDragLeave, handleDragDrop, handleDragOver, dispatch]);
if (isDragging) {
return (
<div className={classes.uploadStepSelect}>
<FormattedMessage
id='account_edit.upload_modal.step_upload.dragging'
defaultMessage='Drop to upload'
tagName='h2'
/>
</div>
);
}
return (
<div className={classes.uploadStepSelect}>
<FormattedMessage
id='account_edit.upload_modal.step_upload.header'
defaultMessage='Choose an image'
tagName='h2'
/>
<FormattedMessage
id='account_edit.upload_modal.step_upload.hint'
defaultMessage='WEBP, PNG, GIF or JPG format, up to {limit}MB.{br}Image will be scaled to {width}x{height}px.'
description='Guideline for avatar and header images.'
values={{
br: <br />,
limit: 8,
width: location === 'avatar' ? 400 : 1500,
height: location === 'avatar' ? 400 : 500,
}}
tagName='p'
/>
<Button
onClick={handleUploadClick}
// eslint-disable-next-line jsx-a11y/no-autofocus -- This is the main input, so auto-focus on it.
autoFocus
>
<FormattedMessage
id='account_edit.upload_modal.step_upload.button'
defaultMessage='Browse files'
/>
</Button>
<input
hidden
type='file'
ref={inputRef}
accept={ALLOWED_MIME_TYPES.join(',')}
onChange={handleFileChange}
/>
</div>
);
};
const zoomLabel = defineMessage({
id: 'account_edit.upload_modal.step_crop.zoom',
defaultMessage: 'Zoom',
});
const StepCrop: FC<{
src: string;
location: ImageLocation;
onCancel: () => void;
onComplete: (crop: Area) => void;
}> = ({ src, location, onCancel, onComplete }) => {
const [crop, setCrop] = useState({ x: 0, y: 0 });
const [croppedArea, setCroppedArea] = useState<Area | null>(null);
const [zoom, setZoom] = useState(1);
const intl = useIntl();
const handleZoomChange: ChangeEventHandler<HTMLInputElement> = useCallback(
(event) => {
setZoom(event.currentTarget.valueAsNumber);
},
[],
);
const handleCropComplete = useCallback((_: Area, croppedAreaPixels: Area) => {
setCroppedArea(croppedAreaPixels);
}, []);
const handleNext = useCallback(() => {
if (croppedArea) {
onComplete(croppedArea);
}
}, [croppedArea, onComplete]);
return (
<>
<div className={classes.cropContainer}>
<Cropper
image={src}
crop={crop}
zoom={zoom}
onCropChange={setCrop}
onCropComplete={handleCropComplete}
aspect={location === 'avatar' ? 1 : 3 / 1}
disableAutomaticStylesInjection
/>
</div>
<div className={classes.cropActions}>
<RangeInput
min={1}
max={3}
step={0.1}
value={zoom}
onChange={handleZoomChange}
className={classes.zoomControl}
aria-label={intl.formatMessage(zoomLabel)}
/>
<Button onClick={onCancel} secondary>
<FormattedMessage
id='account_edit.upload_modal.back'
defaultMessage='Back'
/>
</Button>
<Button onClick={handleNext} disabled={!croppedArea}>
<FormattedMessage
id='account_edit.upload_modal.next'
defaultMessage='Next'
/>
</Button>
</div>
</>
);
};
const StepAlt: FC<{
imageBlob: Blob;
onCancel: () => void;
onComplete: (altText: string) => void;
}> = ({ imageBlob, onCancel, onComplete }) => {
const [altText, setAltText] = useState('');
const handleChange: ChangeEventHandler<HTMLTextAreaElement> = useCallback(
(event) => {
setAltText(event.currentTarget.value);
},
[],
);
const handleComplete = useCallback(() => {
onComplete(altText);
}, [altText, onComplete]);
const imageSrc = useMemo(() => URL.createObjectURL(imageBlob), [imageBlob]);
const altLimit = useAppSelector(
(state) =>
state.server.getIn(
['server', 'configuration', 'media_attachments', 'description_limit'],
150,
) as number,
);
return (
<>
<img src={imageSrc} alt='' className={classes.altImage} />
<div>
<TextAreaField
label={
<FormattedMessage
id='account_edit.upload_modal.step_alt.text_label'
defaultMessage='Alt text'
/>
}
hint={
<FormattedMessage
id='account_edit.upload_modal.step_alt.text_hint'
defaultMessage='E.g. “Close-up photo of me wearing glasses and a blue shirt”'
/>
}
onChange={handleChange}
/>
<CharacterCounter
currentString={altText}
maxLength={altLimit}
className={classes.altCounter}
/>
</div>
<Callout
title={
<FormattedMessage
id='account_edit.upload_modal.step_alt.callout_title'
defaultMessage='Lets make Mastodon accessible for all'
/>
}
>
<FormattedMessage
id='account_edit.upload_modal.step_alt.callout_text'
defaultMessage='Adding alt text to media helps people using screen readers to understand your content.'
/>
</Callout>
<div className={classes.cropActions}>
<Button onClick={onCancel} secondary>
<FormattedMessage
id='account_edit.upload_modal.back'
defaultMessage='Back'
/>
</Button>
<Button onClick={handleComplete}>
<FormattedMessage
id='account_edit.upload_modal.done'
defaultMessage='Done'
/>
</Button>
</div>
</>
);
};
async function calculateCroppedImage(
imageSrc: string,
crop: Area,
): Promise<Blob> {
const image = await dataUriToImage(imageSrc);
const canvas = new OffscreenCanvas(crop.width, crop.height);
const ctx = canvas.getContext('2d');
if (!ctx) {
throw new Error('Failed to get canvas context');
}
ctx.imageSmoothingQuality = 'high';
// Draw the image
ctx.drawImage(
image,
crop.x,
crop.y,
crop.width,
crop.height,
0,
0,
crop.width,
crop.height,
);
return canvas.convertToBlob({
quality: 0.7,
type: 'image/jpeg',
});
}
function dataUriToImage(dataUri: string) {
return new Promise<HTMLImageElement>((resolve, reject) => {
const image = new Image();
image.addEventListener('load', () => {
resolve(image);
});
image.addEventListener('error', (event) => {
if (event.error instanceof Error) {
reject(event.error);
} else {
reject(new Error('Failed to load image'));
}
});
image.src = dataUri;
});
}

View File

@ -80,6 +80,55 @@
}
}
.uploadWrapper {
min-height: min(400px, 70vh);
justify-content: center;
}
.uploadStepSelect {
text-align: center;
h2 {
color: var(--color-text-primary);
font-size: 15px;
font-weight: 600;
margin-bottom: 4px;
}
button {
margin-top: 16px;
}
}
.cropContainer {
position: relative;
width: 100%;
height: 300px;
overflow: hidden;
}
.cropActions {
margin-top: 8px; // 16px gap from DialogModal, plus 8px = 24px to look like normal action buttons.
display: flex;
gap: 8px;
align-items: center;
justify-content: flex-end;
.zoomControl {
width: min(100%, 200px);
margin-right: auto;
}
}
.altImage {
max-height: 300px;
object-fit: contain;
}
.altCounter {
color: var(--color-text-secondary);
}
.verifiedSteps {
font-size: 15px;

View File

@ -103,7 +103,11 @@ const mapStateToProps = state => ({
layout: state.getIn(['meta', 'layout']),
isComposing: state.getIn(['compose', 'is_composing']),
hasComposingContents: state.getIn(['compose', 'text']).trim().length !== 0 || state.getIn(['compose', 'media_attachments']).size > 0 || state.getIn(['compose', 'poll']) !== null || state.getIn(['compose', 'quoted_status_id']) !== null,
canUploadMore: !state.getIn(['compose', 'media_attachments']).some(x => ['audio', 'video'].includes(x.get('type'))) && state.getIn(['compose', 'media_attachments']).size < state.getIn(['server', 'server', 'configuration', 'statuses', 'max_media_attachments']),
canUploadMore:
!state.getIn(['compose', 'media_attachments']).some(x => ['audio', 'video'].includes(x.get('type')))
&& state.getIn(['compose', 'media_attachments']).size < state.getIn(['server', 'server', 'configuration', 'statuses', 'max_media_attachments']),
isUploadEnabled:
state.getIn(['compose', 'isDragDisabled']) !== true,
firstLaunch: state.getIn(['settings', 'introductionVersion'], 0) < INTRODUCTION_VERSION,
newAccount: !state.getIn(['accounts', me, 'note']) && !state.getIn(['accounts', me, 'bot']) && state.getIn(['accounts', me, 'following_count'], 0) === 0 && state.getIn(['accounts', me, 'statuses_count'], 0) === 0,
username: state.getIn(['accounts', me, 'username']),
@ -324,6 +328,9 @@ class UI extends PureComponent {
};
handleDragEnter = (e) => {
if (!this.props.isUploadEnabled) {
return;
}
e.preventDefault();
if (!this.dragTargets) {
@ -340,6 +347,9 @@ class UI extends PureComponent {
};
handleDragOver = (e) => {
if (!this.props.isUploadEnabled) {
return;
}
if (this.dataTransferIsText(e.dataTransfer)) return false;
e.preventDefault();
@ -355,6 +365,9 @@ class UI extends PureComponent {
};
handleDrop = (e) => {
if (!this.props.isUploadEnabled) {
return;
}
if (this.dataTransferIsText(e.dataTransfer)) return;
e.preventDefault();
@ -429,7 +442,6 @@ class UI extends PureComponent {
document.addEventListener('dragover', this.handleDragOver, false);
document.addEventListener('drop', this.handleDrop, false);
document.addEventListener('dragleave', this.handleDragLeave, false);
document.addEventListener('dragend', this.handleDragEnd, false);
if ('serviceWorker' in navigator) {
navigator.serviceWorker.addEventListener('message', this.handleServiceWorkerPostMessage);
@ -456,7 +468,6 @@ class UI extends PureComponent {
document.removeEventListener('dragover', this.handleDragOver);
document.removeEventListener('drop', this.handleDrop);
document.removeEventListener('dragleave', this.handleDragLeave);
document.removeEventListener('dragend', this.handleDragEnd);
}
setRef = c => {

View File

@ -203,6 +203,20 @@
"account_edit.profile_tab.subtitle": "Customize the tabs on your profile and what they display.",
"account_edit.profile_tab.title": "Profile tab settings",
"account_edit.save": "Save",
"account_edit.upload_modal.back": "Back",
"account_edit.upload_modal.done": "Done",
"account_edit.upload_modal.next": "Next",
"account_edit.upload_modal.step_alt.callout_text": "Adding alt text to media helps people using screen readers to understand your content.",
"account_edit.upload_modal.step_alt.callout_title": "Lets make Mastodon accessible for all",
"account_edit.upload_modal.step_alt.text_hint": "E.g. “Close-up photo of me wearing glasses and a blue shirt”",
"account_edit.upload_modal.step_alt.text_label": "Alt text",
"account_edit.upload_modal.step_crop.zoom": "Zoom",
"account_edit.upload_modal.step_upload.button": "Browse files",
"account_edit.upload_modal.step_upload.dragging": "Drop to upload",
"account_edit.upload_modal.step_upload.header": "Choose an image",
"account_edit.upload_modal.step_upload.hint": "WEBP, PNG, GIF or JPG format, up to {limit}MB.{br}Image will be scaled to {width}x{height}px.",
"account_edit.upload_modal.title_add": "Add profile photo",
"account_edit.upload_modal.title_replace": "Replace profile photo",
"account_edit.verified_modal.details": "Add credibility to your Mastodon profile by verifying links to personal websites. Heres how it works:",
"account_edit.verified_modal.invisible_link.details": "Add the link to your header. The important part is rel=\"me\" which prevents impersonation on websites with user-generated content. You can even use a link tag in the header of the page instead of {tag}, but the HTML must be accessible without executing JavaScript.",
"account_edit.verified_modal.invisible_link.summary": "How do I make the link invisible?",

View File

@ -8,6 +8,7 @@ import {
setComposeQuotePolicy,
pasteLinkCompose,
cancelPasteLinkCompose,
setDragUploadEnabled,
} from '@/mastodon/actions/compose_typed';
import { timelineDelete } from 'mastodon/actions/timelines_typed';
@ -75,6 +76,7 @@ const initialState = ImmutableMap({
is_submitting: false,
is_changing_upload: false,
is_uploading: false,
isDragDisabled: false,
should_redirect_to_compose_page: false,
progress: 0,
isUploadingThumbnail: false,
@ -132,6 +134,7 @@ function clearAll(state) {
map.set('idempotencyKey', uuid());
map.set('quoted_status_id', null);
map.set('quote_policy', state.get('default_quote_policy'));
map.set('isDragDisabled', false);
});
}
@ -359,6 +362,8 @@ export const composeReducer = (state = initialState, action) => {
return action.meta.requestId === state.get('fetching_link') ? state.set('fetching_link', null) : state;
} else if (cancelPasteLinkCompose.match(action)) {
return state.set('fetching_link', null);
} else if (setDragUploadEnabled.match(action)) {
return state.set('isDragDisabled', !action.payload);
}
switch(action.type) {

View File

@ -109,6 +109,17 @@ const profileEditSlice = createSlice({
state.isPending = false;
});
builder.addCase(uploadImage.pending, (state) => {
state.isPending = true;
});
builder.addCase(uploadImage.rejected, (state) => {
state.isPending = false;
});
builder.addCase(uploadImage.fulfilled, (state, action) => {
state.profile = action.payload;
state.isPending = false;
});
builder.addCase(addFeaturedTag.pending, (state) => {
state.isPending = true;
});
@ -229,6 +240,41 @@ export const patchProfile = createDataLoadingThunk(
},
);
export type ImageLocation = 'avatar' | 'header';
export const selectImageInfo = createAppSelector(
[
(state) => state.profileEdit.profile,
(_, location: ImageLocation) => location,
],
(profile, location) => {
if (!profile) {
return {};
}
return {
src: profile[location],
static: profile[`${location}Static`],
alt: profile[`${location}Description`],
};
},
);
export const uploadImage = createDataLoadingThunk(
`${profileEditSlice.name}/uploadImage`,
(arg: { location: ImageLocation; imageBlob: Blob; altText: string }) => {
// Note: Alt text is not actually supported by the API yet.
const formData = new FormData();
formData.append(arg.location, arg.imageBlob);
return apiPatchProfile(formData);
},
transformProfile,
{
useLoadingBar: false,
},
);
export const selectFieldById = createAppSelector(
[(state) => state.profileEdit.profile?.fields, (_, id?: string) => id],
(fields, fieldId) => {

View File

@ -91,6 +91,7 @@
"punycode": "^2.3.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-easy-crop": "^5.5.6",
"react-helmet": "^6.1.0",
"react-immutable-proptypes": "^2.2.0",
"react-immutable-pure-component": "^2.2.2",

View File

@ -2919,6 +2919,7 @@ __metadata:
punycode: "npm:^2.3.0"
react: "npm:^18.2.0"
react-dom: "npm:^18.2.0"
react-easy-crop: "npm:^5.5.6"
react-helmet: "npm:^6.1.0"
react-immutable-proptypes: "npm:^2.2.0"
react-immutable-pure-component: "npm:^2.2.2"
@ -10244,6 +10245,13 @@ __metadata:
languageName: node
linkType: hard
"normalize-wheel@npm:^1.0.1":
version: 1.0.1
resolution: "normalize-wheel@npm:1.0.1"
checksum: 10c0/5daf4c97e39f36658a5263a6499bbc148676ae2bd85f12c8d03c46ffe7bc3c68d44564c00413d88d0457ac0d94450559bb1c24c2ce7ae0c107031f82d093ac06
languageName: node
linkType: hard
"object-assign@npm:^4, object-assign@npm:^4.1.1":
version: 4.1.1
resolution: "object-assign@npm:4.1.1"
@ -11703,6 +11711,19 @@ __metadata:
languageName: node
linkType: hard
"react-easy-crop@npm:^5.5.6":
version: 5.5.6
resolution: "react-easy-crop@npm:5.5.6"
dependencies:
normalize-wheel: "npm:^1.0.1"
tslib: "npm:^2.0.1"
peerDependencies:
react: ">=16.4.0"
react-dom: ">=16.4.0"
checksum: 10c0/ce623791d31559fc46f210ece7b22c0f659710d5de219ef9fb05650940f50445d5e6573ed229b66fad06dfda9651ae458c0f5efb8e1cabdf01511dc32942cdc8
languageName: node
linkType: hard
"react-fast-compare@npm:^3.1.1":
version: 3.2.2
resolution: "react-fast-compare@npm:3.2.2"