Skip to content

Commit 938f53a

Browse files
ChaosExAnimaClearlyClaire
authored andcommitted
[Glitch] Profile editing: Finish image editing
Port 4328807 to glitch-soc Signed-off-by: Claire <claire.github-309c@sitedethib.com>
1 parent 59879b7 commit 938f53a

15 files changed

Lines changed: 364 additions & 100 deletions

File tree

app/javascript/flavours/glitch/api/accounts.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,3 +69,9 @@ export const apiGetProfile = () => apiRequestGet<ApiProfileJSON>('v1/profile');
6969

7070
export const apiPatchProfile = (params: ApiProfileUpdateParams | FormData) =>
7171
apiRequestPatch<ApiProfileJSON>('v1/profile', params);
72+
73+
export const apiDeleteProfileAvatar = () =>
74+
apiRequestDelete('v1/profile/avatar');
75+
76+
export const apiDeleteProfileHeader = () =>
77+
apiRequestDelete('v1/profile/header');

app/javascript/flavours/glitch/api_types/profile.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,8 @@ export interface ApiProfileJSON {
2727
export type ApiProfileUpdateParams = Partial<
2828
Pick<
2929
ApiProfileJSON,
30+
| 'avatar_description'
31+
| 'header_description'
3032
| 'display_name'
3133
| 'note'
3234
| 'locked'
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import type { Meta, StoryObj } from '@storybook/react-vite';
2+
3+
import { Details } from './index';
4+
5+
const meta = {
6+
component: Details,
7+
title: 'Components/Details',
8+
args: {
9+
summary: 'Here is the summary title',
10+
children: (
11+
<p>
12+
And here are the details that are hidden until you click the summary.
13+
</p>
14+
),
15+
},
16+
render(props) {
17+
return (
18+
<div style={{ width: '400px' }}>
19+
<Details {...props} />
20+
</div>
21+
);
22+
},
23+
} satisfies Meta<typeof Details>;
24+
25+
export default meta;
26+
27+
type Story = StoryObj<typeof meta>;
28+
29+
export const Plain: Story = {};
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import { forwardRef } from 'react';
2+
import type { ComponentPropsWithoutRef, ReactNode } from 'react';
3+
4+
import classNames from 'classnames';
5+
6+
import ExpandArrowIcon from '@/material-icons/400-24px/expand_more.svg?react';
7+
8+
import { Icon } from '../icon';
9+
10+
import classes from './styles.module.scss';
11+
12+
export const Details = forwardRef<
13+
HTMLDetailsElement,
14+
{
15+
summary: ReactNode;
16+
children: ReactNode;
17+
className?: string;
18+
} & ComponentPropsWithoutRef<'details'>
19+
>(({ summary, children, className, ...rest }, ref) => {
20+
return (
21+
<details
22+
ref={ref}
23+
className={classNames(classes.details, className)}
24+
{...rest}
25+
>
26+
<summary>
27+
{summary}
28+
<Icon icon={ExpandArrowIcon} id='arrow' />
29+
</summary>
30+
31+
{children}
32+
</details>
33+
);
34+
});
35+
Details.displayName = 'Details';
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
.details {
2+
color: var(--color-text-secondary);
3+
font-size: 13px;
4+
margin-top: 8px;
5+
6+
summary {
7+
cursor: pointer;
8+
font-weight: 600;
9+
list-style: none;
10+
margin-bottom: 8px;
11+
text-decoration: underline;
12+
text-decoration-style: dotted;
13+
}
14+
15+
:global(.icon) {
16+
width: 1.4em;
17+
height: 1.4em;
18+
vertical-align: middle;
19+
transition: transform 0.2s ease-in-out;
20+
}
21+
22+
&[open] :global(.icon) {
23+
transform: rotate(-180deg);
24+
}
25+
}

app/javascript/flavours/glitch/features/account_edit/index.tsx

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { useHistory } from 'react-router-dom';
77

88
import type { ModalType } from '@/flavours/glitch/actions/modal';
99
import { openModal } from '@/flavours/glitch/actions/modal';
10+
import { AccountBio } from '@/flavours/glitch/components/account_bio';
1011
import { Avatar } from '@/flavours/glitch/components/avatar';
1112
import { Button } from '@/flavours/glitch/components/button';
1213
import { DismissibleCallout } from '@/flavours/glitch/components/callout/dismissible';
@@ -201,7 +202,11 @@ export const AccountEdit: FC = () => {
201202
/>
202203
}
203204
>
204-
<EmojiHTML htmlString={profile.bio} {...htmlHandlers} />
205+
<AccountBio
206+
showDropdown
207+
accountId={profile.id}
208+
className={classes.bio}
209+
/>
205210
</AccountEditSection>
206211

207212
<AccountEditSection
Lines changed: 139 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,147 @@
1-
import type { FC } from 'react';
1+
import type { ChangeEventHandler, FC } from 'react';
2+
import { useCallback, useState } from 'react';
23

4+
import { FormattedMessage } from 'react-intl';
5+
6+
import { CharacterCounter } from '@/flavours/glitch/components/character_counter';
7+
import { Details } from '@/flavours/glitch/components/details';
8+
import { TextAreaField } from '@/flavours/glitch/components/form_fields';
9+
import { LoadingIndicator } from '@/flavours/glitch/components/loading_indicator';
10+
import { patchProfile } from '@/flavours/glitch/reducers/slices/profile_edit';
311
import type { ImageLocation } from '@/flavours/glitch/reducers/slices/profile_edit';
12+
import { useAppDispatch, useAppSelector } from '@/flavours/glitch/store';
413

5-
import { DialogModal } from '../../ui/components/dialog_modal';
14+
import { ConfirmationModal } from '../../ui/components/confirmation_modals';
615
import type { DialogModalProps } from '../../ui/components/dialog_modal';
716

17+
import classes from './styles.module.scss';
18+
819
export const ImageAltModal: FC<
920
DialogModalProps & { location: ImageLocation }
10-
> = ({ onClose }) => {
11-
return <DialogModal title='TODO' onClose={onClose} />;
21+
> = ({ onClose, location }) => {
22+
const { profile, isPending } = useAppSelector((state) => state.profileEdit);
23+
24+
const initialAlt = profile?.[`${location}Description`];
25+
const imageSrc = profile?.[`${location}Static`];
26+
27+
const [altText, setAltText] = useState(initialAlt ?? '');
28+
29+
const dispatch = useAppDispatch();
30+
const handleSave = useCallback(() => {
31+
void dispatch(
32+
patchProfile({
33+
[`${location}_description`]: altText,
34+
}),
35+
).then(onClose);
36+
}, [altText, dispatch, location, onClose]);
37+
38+
if (!imageSrc) {
39+
return <LoadingIndicator />;
40+
}
41+
42+
return (
43+
<ConfirmationModal
44+
title={
45+
initialAlt ? (
46+
<FormattedMessage
47+
id='account_edit.image_alt_modal.edit_title'
48+
defaultMessage='Edit alt text'
49+
/>
50+
) : (
51+
<FormattedMessage
52+
id='account_edit.image_alt_modal.add_title'
53+
defaultMessage='Add alt text'
54+
/>
55+
)
56+
}
57+
onClose={onClose}
58+
onConfirm={handleSave}
59+
confirm={
60+
<FormattedMessage
61+
id='account_edit.upload_modal.done'
62+
defaultMessage='Done'
63+
/>
64+
}
65+
updating={isPending}
66+
>
67+
<div className={classes.wrapper}>
68+
<ImageAltTextField
69+
imageSrc={imageSrc}
70+
altText={altText}
71+
onChange={setAltText}
72+
/>
73+
</div>
74+
</ConfirmationModal>
75+
);
76+
};
77+
78+
export const ImageAltTextField: FC<{
79+
imageSrc: string;
80+
altText: string;
81+
onChange: (altText: string) => void;
82+
}> = ({ imageSrc, altText, onChange }) => {
83+
const altLimit = useAppSelector(
84+
(state) =>
85+
state.server.getIn(
86+
['server', 'configuration', 'media_attachments', 'description_limit'],
87+
150,
88+
) as number,
89+
);
90+
91+
const handleChange: ChangeEventHandler<HTMLTextAreaElement> = useCallback(
92+
(event) => {
93+
onChange(event.currentTarget.value);
94+
},
95+
[onChange],
96+
);
97+
98+
return (
99+
<>
100+
<img src={imageSrc} alt='' className={classes.altImage} />
101+
102+
<div>
103+
<TextAreaField
104+
label={
105+
<FormattedMessage
106+
id='account_edit.image_alt_modal.text_label'
107+
defaultMessage='Alt text'
108+
/>
109+
}
110+
hint={
111+
<FormattedMessage
112+
id='account_edit.image_alt_modal.text_hint'
113+
defaultMessage='Alt text helps screen reader users to understand your content.'
114+
/>
115+
}
116+
onChange={handleChange}
117+
value={altText}
118+
/>
119+
<CharacterCounter
120+
currentString={altText}
121+
maxLength={altLimit}
122+
className={classes.altCounter}
123+
/>
124+
</div>
125+
126+
<Details
127+
summary={
128+
<FormattedMessage
129+
id='account_edit.image_alt_modal.details_title'
130+
defaultMessage='Tips: Alt text for profile photos'
131+
/>
132+
}
133+
className={classes.altHint}
134+
>
135+
<FormattedMessage
136+
id='account_edit.image_alt_modal.details_content'
137+
defaultMessage='DO: <ul> <li>Describe yourself as pictured</li> <li>Use third person language (e.g. “Alex” instead of “me”)</li> <li>Be succinct – a few words is often enough</li> </ul> DON’T: <ul> <li>Start with “Photo of” – it’s redundant for screen readers</li> </ul> EXAMPLE: <ul> <li>“Alex wearing a green shirt and glasses”</li> </ul>'
138+
values={{
139+
ul: (chunks) => <ul>{chunks}</ul>,
140+
li: (chunks) => <li>{chunks}</li>,
141+
}}
142+
tagName='div'
143+
/>
144+
</Details>
145+
</>
146+
);
12147
};
Lines changed: 38 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,48 @@
1+
import { useCallback } from 'react';
12
import type { FC } from 'react';
23

4+
import { FormattedMessage } from 'react-intl';
5+
6+
import { Button } from '@/flavours/glitch/components/button';
7+
import { deleteImage } from '@/flavours/glitch/reducers/slices/profile_edit';
38
import type { ImageLocation } from '@/flavours/glitch/reducers/slices/profile_edit';
9+
import { useAppDispatch, useAppSelector } from '@/flavours/glitch/store';
410

511
import { DialogModal } from '../../ui/components/dialog_modal';
612
import type { DialogModalProps } from '../../ui/components/dialog_modal';
713

814
export const ImageDeleteModal: FC<
915
DialogModalProps & { location: ImageLocation }
10-
> = ({ onClose }) => {
11-
return <DialogModal title='TODO' onClose={onClose} />;
16+
> = ({ onClose, location }) => {
17+
const isPending = useAppSelector((state) => state.profileEdit.isPending);
18+
const dispatch = useAppDispatch();
19+
const handleDelete = useCallback(() => {
20+
void dispatch(deleteImage({ location })).then(onClose);
21+
}, [dispatch, location, onClose]);
22+
23+
return (
24+
<DialogModal
25+
onClose={onClose}
26+
title={
27+
<FormattedMessage
28+
id='account_edit.image_delete_modal.title'
29+
defaultMessage='Delete image?'
30+
/>
31+
}
32+
buttons={
33+
<Button dangerous onClick={handleDelete} disabled={isPending}>
34+
<FormattedMessage
35+
id='account_edit.image_delete_modal.delete_button'
36+
defaultMessage='Delete'
37+
/>
38+
</Button>
39+
}
40+
>
41+
<FormattedMessage
42+
id='account_edit.image_delete_modal.confirm'
43+
defaultMessage='Are you sure you want to delete this image? This action can’t be undone.'
44+
tagName='p'
45+
/>
46+
</DialogModal>
47+
);
1248
};

0 commit comments

Comments
 (0)