[Glitch] Profile editing: Edit image menu

Port 420136e83b to glitch-soc

Co-authored-by: diondiondion <mail@diondiondion.com>
Signed-off-by: Claire <claire.github-309c@sitedethib.com>
This commit is contained in:
Echo
2026-03-12 11:42:29 +01:00
committed by Claire
parent ba7db10fd0
commit a7974df51a
12 changed files with 265 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 '@/flavours/glitch/actions/modal';
import { openModal } from '@/flavours/glitch/actions/modal';
import { Dropdown } from '@/flavours/glitch/components/dropdown_menu';
import { IconButton } from '@/flavours/glitch/components/icon_button';
import type { MenuItem } from '@/flavours/glitch/models/dropdown_menu';
import {
createAppSelector,
useAppDispatch,
useAppSelector,
} from '@/flavours/glitch/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

@@ -110,6 +110,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('@/flavours/glitch/features/account_edit/modals')} type */

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%;