Profile editing: Edit image menu (#38156)

Co-authored-by: diondiondion <mail@diondiondion.com>
This commit is contained in:
Echo
2026-03-12 11:42:29 +01:00
committed by GitHub
parent 353c8b2abf
commit 420136e83b
16 changed files with 273 additions and 7 deletions

View File

@@ -296,6 +296,7 @@ interface DropdownProps<Item extends object | null = MenuItem> {
children?: React.ReactElement;
icon?: string;
iconComponent?: IconProp;
iconClassName?: string;
items?: Item[];
loading?: boolean;
title?: string;
@@ -326,6 +327,7 @@ export const Dropdown = <Item extends object | null = MenuItem>({
children,
icon,
iconComponent,
iconClassName,
items,
loading,
title = 'Menu',
@@ -499,6 +501,7 @@ export const Dropdown = <Item extends object | null = MenuItem>({
iconComponent={iconComponent}
title={title}
active={open}
className={iconClassName}
{...buttonProps}
/>
);

View File

@@ -0,0 +1,154 @@
import { useCallback, useMemo } from 'react';
import type { FC } from 'react';
import { defineMessages, useIntl } from 'react-intl';
import classNames from 'classnames';
import type { OffsetValue } from 'react-overlays/esm/usePopper';
import type { ModalType } from '@/mastodon/actions/modal';
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 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';
import CameraIcon from '@/material-icons/400-24px/photo_camera.svg?react';
import ReplaceImageIcon from '@/material-icons/400-24px/replace_image.svg?react';
import classes from '../styles.module.scss';
const messages = defineMessages({
add: {
id: 'account_edit.image_edit.add_button',
defaultMessage: 'Add image',
},
replace: {
id: 'account_edit.image_edit.replace_button',
defaultMessage: 'Replace image',
},
altAdd: {
id: 'account_edit.image_edit.alt_add_button',
description: 'Alt is short for "alternative".',
defaultMessage: 'Add alt text',
},
altEdit: {
id: 'account_edit.image_edit.alt_edit_button',
description: 'Alt is short for "alternative".',
defaultMessage: 'Edit alt text',
},
remove: {
id: 'account_edit.image_edit.remove_button',
defaultMessage: 'Remove image',
},
});
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) =>
selectImageInfo(state, location),
);
const dispatch = useAppDispatch();
const handleModal = useCallback(
(type: ModalType) => {
dispatch(openModal({ modalType: type, modalProps: { location } }));
},
[dispatch, location],
);
const items = useMemo(
() =>
[
{
text: intl.formatMessage(messages.replace),
action: () => {
handleModal('ACCOUNT_EDIT_IMAGE_UPLOAD');
},
icon: ReplaceImageIcon,
},
{
text: intl.formatMessage(hasAlt ? messages.altEdit : messages.altAdd),
action: () => {
handleModal('ACCOUNT_EDIT_IMAGE_ALT');
},
icon: hasAlt ? EditIcon : AddIcon,
},
null,
{
text: intl.formatMessage(messages.remove),
action: () => {
handleModal('ACCOUNT_EDIT_IMAGE_DELETE');
},
icon: DeleteIcon,
dangerous: true,
},
] satisfies MenuItem[],
[handleModal, hasAlt, intl],
);
const handleAddImage = useCallback(() => {
handleModal('ACCOUNT_EDIT_IMAGE_UPLOAD');
}, [handleModal]);
const iconClassName = classNames(classes.imageButton, className);
if (!hasImage) {
return (
<IconButton
title={intl.formatMessage(messages.add)}
icon='camera'
iconComponent={CameraIcon}
className={iconClassName}
onClick={handleAddImage}
/>
);
}
return (
<Dropdown
items={items}
placement={location === 'header' ? 'bottom-end' : 'bottom-start'}
offset={popperOffset}
className={classes.imageMenu}
icon='camera'
title={intl.formatMessage(messages.replace)}
iconComponent={CameraIcon}
iconClassName={iconClassName}
/>
);
};
const popperOffset = [0, 6] as OffsetValue;

View File

@@ -23,6 +23,7 @@ import { AccountEditColumn, AccountEditEmptyColumn } from './components/column';
import { EditButton } from './components/edit_button';
import { AccountField } from './components/field';
import { AccountFieldActions } from './components/field_actions';
import { AccountImageEdit } from './components/image_edit';
import { AccountEditSection } from './components/section';
import classes from './styles.module.scss';
@@ -164,8 +165,12 @@ export const AccountEdit: FC = () => {
<header>
<div className={classes.profileImage}>
{headerSrc && <img src={headerSrc} alt='' />}
<AccountImageEdit location='header' />
</div>
<div className={classes.avatar}>
<Avatar account={account} size={80} />
<AccountImageEdit location='avatar' />
</div>
<Avatar account={account} size={80} className={classes.avatar} />
</header>
<CustomEmojiProvider emojis={emojis}>

View File

@@ -0,0 +1,11 @@
import type { FC } from 'react';
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 }
> = ({ onClose }) => {
return <DialogModal title='TODO' onClose={onClose} />;
};

View File

@@ -0,0 +1,11 @@
import type { FC } from 'react';
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 }
> = ({ onClose }) => {
return <DialogModal title='TODO' onClose={onClose} />;
};

View File

@@ -0,0 +1,11 @@
import type { FC } from 'react';
import { DialogModal } from '../../ui/components/dialog_modal';
import type { DialogModalProps } from '../../ui/components/dialog_modal';
import type { ImageLocation } from '../components/image_edit';
export const ImageUploadModal: FC<
DialogModalProps & { location: ImageLocation }
> = ({ onClose }) => {
return <DialogModal title='TODO' onClose={onClose} />;
};

View File

@@ -1,6 +1,9 @@
export * from './bio_modal';
export * from './fields_modals';
export * from './fields_reorder_modal';
export * from './image_alt';
export * from './image_delete';
export * from './image_upload';
export * from './name_modal';
export * from './profile_display_modal';
export * from './verified_modal';

View File

@@ -5,6 +5,7 @@
background: var(--color-bg-secondary);
border-bottom: 1px solid var(--color-border-primary);
overflow: hidden;
position: relative;
@container (width >= 500px) {
height: 160px;
@@ -16,12 +17,27 @@
width: 100%;
height: 100%;
}
.imageButton {
top: 16px;
right: 24px;
}
}
.avatar {
margin-top: -64px;
margin-left: 18px;
border: 1px solid var(--color-border-primary);
position: relative;
width: 82px;
> :global(.account__avatar) {
border: 1px solid var(--color-border-primary);
}
.imageButton {
bottom: -8px;
right: -8px;
}
}
.field {
@@ -153,6 +169,41 @@
font-size: 15px;
}
// Image edit component
.imageButton {
--default-bg-color: var(--color-bg-primary);
&,
&:global(.active) {
// Overrides the transparent background added by default with .active
--hover-bg-color: var(--color-bg-brand-softer-solid);
}
position: absolute;
width: 28px;
height: 28px;
border: 1px solid var(--color-border-primary);
border-radius: 9999px;
box-sizing: border-box;
padding: 4px;
transition:
color 0.2s ease-in-out,
background-color 0.2s ease-in-out;
svg {
width: 18px;
height: 18px;
}
}
.imageMenu {
svg {
width: 20px;
height: 20px;
}
}
// Item list component
.itemList {

View File

@@ -293,11 +293,7 @@ svg.badgeIcon {
.fieldOverflowButton {
--default-bg-color: var(--color-bg-secondary-solid);
--hover-bg-color: color-mix(
in oklab,
var(--color-bg-brand-base),
var(--default-bg-color) var(--overlay-strength-brand)
);
--hover-bg-color: var(--color-bg-brand-softer-solid);
position: absolute;
right: 8px;

View File

@@ -102,6 +102,9 @@ export const MODAL_COMPONENTS = {
'ACCOUNT_EDIT_FIELD_EDIT': accountEditModal('EditFieldModal'),
'ACCOUNT_EDIT_FIELD_DELETE': accountEditModal('DeleteFieldModal'),
'ACCOUNT_EDIT_FIELDS_REORDER': accountEditModal('ReorderFieldsModal'),
'ACCOUNT_EDIT_IMAGE_ALT': accountEditModal('ImageAltModal'),
'ACCOUNT_EDIT_IMAGE_DELETE': accountEditModal('ImageDeleteModal'),
'ACCOUNT_EDIT_IMAGE_UPLOAD': accountEditModal('ImageUploadModal'),
};
/** @arg {keyof import('@/mastodon/features/account_edit/modals')} type */

View File

@@ -184,6 +184,11 @@
"account_edit.field_reorder_modal.drag_start": "Picked up field \"{item}\".",
"account_edit.field_reorder_modal.handle_label": "Drag field \"{item}\"",
"account_edit.field_reorder_modal.title": "Rearrange fields",
"account_edit.image_edit.add_button": "Add image",
"account_edit.image_edit.alt_add_button": "Add alt text",
"account_edit.image_edit.alt_edit_button": "Edit alt text",
"account_edit.image_edit.remove_button": "Remove image",
"account_edit.image_edit.replace_button": "Replace image",
"account_edit.name_modal.add_title": "Add display name",
"account_edit.name_modal.edit_title": "Edit display name",
"account_edit.profile_tab.button_label": "Customize",

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 -960 960 960" width="24"><path d="M480-260q75 0 127.5-52.5T660-440q0-75-52.5-127.5T480-620q-75 0-127.5 52.5T300-440q0 75 52.5 127.5T480-260Zm0-80q-42 0-71-29t-29-71q0-42 29-71t71-29q42 0 71 29t29 71q0 42-29 71t-71 29ZM160-120q-33 0-56.5-23.5T80-200v-480q0-33 23.5-56.5T160-760h126l74-80h240l74 80h126q33 0 56.5 23.5T880-680v480q0 33-23.5 56.5T800-120H160Z"/></svg>

After

Width:  |  Height:  |  Size: 427 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 -960 960 960" width="24"><path d="M360-200h240l-79-103-58 69-39-52-64 86ZM320-80q-33 0-56.5-23.5T240-160v-320q0-33 23.5-56.5T320-560h320q33 0 56.5 23.5T720-480v320q0 33-23.5 56.5T640-80H320ZM140-640q38-109 131.5-174.5T480-880q82 0 155.5 35T760-746v-134h80v240H600v-80h76q-39-39-90-59.5T480-800q-81 0-149.5 43T227-640h-87Z"/></svg>

After

Width:  |  Height:  |  Size: 393 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 -960 960 960" width="24"><path d="M360-200h240l-79-103-58 69-39-52-64 86ZM320-80q-33 0-56.5-23.5T240-160v-320q0-33 23.5-56.5T320-560h320q33 0 56.5 23.5T720-480v320q0 33-23.5 56.5T640-80H320Zm0-80h320v-320H320v320ZM140-640q38-109 131.5-174.5T480-880q82 0 155.5 35T760-746v-134h80v240H600v-80h76q-39-39-90-59.5T480-800q-81 0-149.5 43T227-640h-87Zm180 480v-320 320Z"/></svg>

After

Width:  |  Height:  |  Size: 434 B

View File

@@ -81,6 +81,11 @@
var(--color-bg-brand-base),
var(--overlay-strength-brand)
)};
--color-bg-brand-softer-solid: color-mix(
in srgb,
var(--color-bg-primary),
var(--color-bg-brand-base) var(--overlay-strength-brand)
);
// Error
--overlay-strength-error: 10%;

View File

@@ -78,6 +78,11 @@
#0012d8,
var(--overlay-strength-brand)
)};
--color-bg-brand-softer-solid: color-mix(
in srgb,
var(--color-bg-primary),
var(--color-bg-brand-base) var(--overlay-strength-brand)
);
// Error
--overlay-strength-error: 5%;