Merge branch 'edit-profile-pickers' into 'develop'
Use AvatarPicker/HeaderPicker on Edit Profile page See merge request soapbox-pub/soapbox!2594
This commit is contained in:
commit
542924fab0
|
@ -6,8 +6,8 @@ import { groupSchema } from 'soapbox/schemas';
|
|||
interface UpdateGroupParams {
|
||||
display_name?: string
|
||||
note?: string
|
||||
avatar?: File
|
||||
header?: File
|
||||
avatar?: File | ''
|
||||
header?: File | ''
|
||||
group_visibility?: string
|
||||
discoverable?: boolean
|
||||
tags?: string[]
|
||||
|
@ -30,4 +30,4 @@ function useUpdateGroup(groupId: string) {
|
|||
};
|
||||
}
|
||||
|
||||
export { useUpdateGroup };
|
||||
export { useUpdateGroup };
|
||||
|
|
|
@ -67,7 +67,7 @@ const StillImage: React.FC<IStillImage> = ({ alt, className, src, style, letterb
|
|||
<canvas
|
||||
ref={canvas}
|
||||
className={clsx(baseClassName, {
|
||||
'group-hover:invisible': hoverToPlay,
|
||||
'absolute group-hover:invisible top-0': hoverToPlay,
|
||||
})}
|
||||
/>
|
||||
)}
|
||||
|
|
|
@ -1,19 +1,25 @@
|
|||
import clsx from 'clsx';
|
||||
import React from 'react';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
import Icon from 'soapbox/components/icon';
|
||||
import { Avatar, HStack } from 'soapbox/components/ui';
|
||||
import { Avatar, Icon, HStack } from 'soapbox/components/ui';
|
||||
|
||||
interface IMediaInput {
|
||||
className?: string
|
||||
src: string | undefined
|
||||
accept: string
|
||||
onChange: React.ChangeEventHandler<HTMLInputElement>
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
const AvatarPicker = React.forwardRef<HTMLInputElement, IMediaInput>(({ src, onChange, accept, disabled }, ref) => {
|
||||
const AvatarPicker = React.forwardRef<HTMLInputElement, IMediaInput>(({ className, src, onChange, accept, disabled }, ref) => {
|
||||
return (
|
||||
<label className='absolute bottom-0 left-1/2 h-20 w-20 -translate-x-1/2 translate-y-1/2 cursor-pointer rounded-full bg-primary-500 ring-2 ring-white dark:ring-primary-900'>
|
||||
<label
|
||||
className={clsx(
|
||||
'absolute bottom-0 left-1/2 h-20 w-20 -translate-x-1/2 translate-y-1/2 cursor-pointer rounded-full bg-primary-500 ring-2 ring-white dark:ring-primary-900',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{src && <Avatar src={src} size={80} />}
|
||||
<HStack
|
||||
alignItems='center'
|
||||
|
@ -28,7 +34,7 @@ const AvatarPicker = React.forwardRef<HTMLInputElement, IMediaInput>(({ src, onC
|
|||
className='h-5 w-5 text-white'
|
||||
/>
|
||||
</HStack>
|
||||
<span className='sr-only'>Upload avatar</span>
|
||||
<span className='sr-only'><FormattedMessage id='group.upload_avatar' defaultMessage='Upload avatar' /></span>
|
||||
<input
|
||||
ref={ref}
|
||||
name='avatar'
|
||||
|
@ -42,4 +48,4 @@ const AvatarPicker = React.forwardRef<HTMLInputElement, IMediaInput>(({ src, onC
|
|||
);
|
||||
});
|
||||
|
||||
export default AvatarPicker;
|
||||
export default AvatarPicker;
|
|
@ -1,21 +1,28 @@
|
|||
import clsx from 'clsx';
|
||||
import React from 'react';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
import { FormattedMessage, defineMessages, useIntl } from 'react-intl';
|
||||
|
||||
import Icon from 'soapbox/components/icon';
|
||||
import { HStack, Text } from 'soapbox/components/ui';
|
||||
import { HStack, Icon, IconButton, Text } from 'soapbox/components/ui';
|
||||
|
||||
const messages = defineMessages({
|
||||
title: { id: 'group.upload_banner.title', defaultMessage: 'Upload background picture' },
|
||||
});
|
||||
|
||||
interface IMediaInput {
|
||||
src: string | undefined
|
||||
accept: string
|
||||
onChange: React.ChangeEventHandler<HTMLInputElement>
|
||||
onClear?: () => void
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
const HeaderPicker = React.forwardRef<HTMLInputElement, IMediaInput>(({ src, onChange, accept, disabled }, ref) => {
|
||||
const HeaderPicker = React.forwardRef<HTMLInputElement, IMediaInput>(({ src, onChange, onClear, accept, disabled }, ref) => {
|
||||
const intl = useIntl();
|
||||
|
||||
return (
|
||||
<label
|
||||
className='dark:sm:shadow-inset relative h-24 w-full cursor-pointer overflow-hidden rounded-lg bg-primary-100 text-primary-500 dark:bg-gray-800 dark:text-accent-blue sm:h-36 sm:shadow'
|
||||
title={intl.formatMessage(messages.title)}
|
||||
>
|
||||
{src && <img className='h-full w-full object-cover' src={src} alt='' />}
|
||||
<HStack
|
||||
|
@ -45,8 +52,17 @@ const HeaderPicker = React.forwardRef<HTMLInputElement, IMediaInput>(({ src, onC
|
|||
className='hidden'
|
||||
/>
|
||||
</HStack>
|
||||
{onClear && src && (
|
||||
<IconButton
|
||||
onClick={onClear}
|
||||
src={require('@tabler/icons/x.svg')}
|
||||
theme='dark'
|
||||
className='absolute right-2 top-2 z-10 hover:scale-105 hover:bg-gray-900'
|
||||
iconClassName='h-5 w-5'
|
||||
/>
|
||||
)}
|
||||
</label>
|
||||
);
|
||||
});
|
||||
|
||||
export default HeaderPicker;
|
||||
export default HeaderPicker;
|
|
@ -1,49 +0,0 @@
|
|||
import React from 'react';
|
||||
|
||||
import StillImage from 'soapbox/components/still-image';
|
||||
import { Avatar, HStack, Stack, Text } from 'soapbox/components/ui';
|
||||
import VerificationBadge from 'soapbox/components/verification-badge';
|
||||
import { useSoapboxConfig } from 'soapbox/hooks';
|
||||
|
||||
import type { Account } from 'soapbox/types/entities';
|
||||
|
||||
interface IProfilePreview {
|
||||
account: Pick<Account, 'acct' | 'fqn' | 'avatar' | 'header' | 'verified' | 'display_name_html'>
|
||||
}
|
||||
|
||||
/** Displays a preview of the user's account, including avatar, banner, etc. */
|
||||
const ProfilePreview: React.FC<IProfilePreview> = ({ account }) => {
|
||||
const { displayFqn } = useSoapboxConfig();
|
||||
|
||||
return (
|
||||
<div className='dark:sm:shadow-inset overflow-hidden rounded-lg bg-white text-black dark:bg-gray-800 dark:text-white sm:shadow'>
|
||||
<div className='relative isolate h-32 w-full overflow-hidden bg-gray-200 dark:bg-gray-900/50 md:rounded-t-lg'>
|
||||
<StillImage src={account.header} />
|
||||
</div>
|
||||
|
||||
<HStack space={3} alignItems='center' className='p-3'>
|
||||
<div className='relative'>
|
||||
<Avatar className='bg-gray-400' src={account.avatar} />
|
||||
|
||||
{account.verified && (
|
||||
<div className='absolute -right-1.5 -top-1.5'>
|
||||
<VerificationBadge />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Stack className='truncate'>
|
||||
<Text
|
||||
weight='medium'
|
||||
size='sm'
|
||||
truncate
|
||||
dangerouslySetInnerHTML={{ __html: account.display_name_html }}
|
||||
/>
|
||||
<Text theme='muted' size='sm'>@{displayFqn ? account.fqn : account.acct}</Text>
|
||||
</Stack>
|
||||
</HStack>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProfilePreview;
|
|
@ -1,4 +1,4 @@
|
|||
import React, { useState, useEffect, useMemo } from 'react';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { defineMessages, useIntl, FormattedMessage } from 'react-intl';
|
||||
|
||||
import { updateNotificationSettings } from 'soapbox/actions/accounts';
|
||||
|
@ -8,7 +8,6 @@ import List, { ListItem } from 'soapbox/components/list';
|
|||
import {
|
||||
Button,
|
||||
Column,
|
||||
FileInput,
|
||||
Form,
|
||||
FormActions,
|
||||
FormGroup,
|
||||
|
@ -18,16 +17,21 @@ import {
|
|||
Textarea,
|
||||
Toggle,
|
||||
} from 'soapbox/components/ui';
|
||||
import { useAppDispatch, useOwnAccount, useFeatures, useInstance } from 'soapbox/hooks';
|
||||
import { accountSchema } from 'soapbox/schemas';
|
||||
import { useAppDispatch, useOwnAccount, useFeatures, useInstance, useAppSelector } from 'soapbox/hooks';
|
||||
import { useImageField } from 'soapbox/hooks/forms';
|
||||
import toast from 'soapbox/toast';
|
||||
import resizeImage from 'soapbox/utils/resize-image';
|
||||
import { isDefaultAvatar, isDefaultHeader } from 'soapbox/utils/accounts';
|
||||
|
||||
import ProfilePreview from './components/profile-preview';
|
||||
import AvatarPicker from './components/avatar-picker';
|
||||
import HeaderPicker from './components/header-picker';
|
||||
|
||||
import type { List as ImmutableList } from 'immutable';
|
||||
import type { StreamfieldComponent } from 'soapbox/components/ui/streamfield/streamfield';
|
||||
import type { Account } from 'soapbox/schemas';
|
||||
|
||||
const nonDefaultAvatar = (url: string | undefined) => url && isDefaultAvatar(url) ? undefined : url;
|
||||
const nonDefaultHeader = (url: string | undefined) => url && isDefaultHeader(url) ? undefined : url;
|
||||
|
||||
/**
|
||||
* Whether the user is hiding their follows and/or followers.
|
||||
* Pleroma's config is granular, but we simplify it into one setting.
|
||||
|
@ -88,9 +92,9 @@ interface AccountCredentials {
|
|||
/** The account bio. */
|
||||
note?: string
|
||||
/** Avatar image encoded using multipart/form-data */
|
||||
avatar?: File
|
||||
avatar?: File | ''
|
||||
/** Header image encoded using multipart/form-data */
|
||||
header?: File
|
||||
header?: File | ''
|
||||
/** Whether manual approval of follow requests is required. */
|
||||
locked?: boolean
|
||||
/** Private information (settings) about the account. */
|
||||
|
@ -181,10 +185,17 @@ const EditProfile: React.FC = () => {
|
|||
const features = useFeatures();
|
||||
const maxFields = instance.pleroma.getIn(['metadata', 'fields_limits', 'max_fields'], 4) as number;
|
||||
|
||||
const attachmentTypes = useAppSelector(
|
||||
state => state.instance.configuration.getIn(['media_attachments', 'supported_mime_types']) as ImmutableList<string>,
|
||||
)?.filter(type => type.startsWith('image/')).toArray().join(',');
|
||||
|
||||
const [isLoading, setLoading] = useState(false);
|
||||
const [data, setData] = useState<AccountCredentials>({});
|
||||
const [muteStrangers, setMuteStrangers] = useState(false);
|
||||
|
||||
const avatar = useImageField({ maxPixels: 400 * 400, preview: nonDefaultAvatar(account?.avatar) });
|
||||
const header = useImageField({ maxPixels: 1920 * 1080, preview: nonDefaultHeader(account?.header) });
|
||||
|
||||
useEffect(() => {
|
||||
if (account) {
|
||||
const credentials = accountToCredentials(account);
|
||||
|
@ -206,6 +217,8 @@ const EditProfile: React.FC = () => {
|
|||
|
||||
const params = { ...data };
|
||||
if (params.fields_attributes?.length === 0) params.fields_attributes = [{ name: '', value: '' }];
|
||||
if (header.file !== undefined) params.header = header.file || '';
|
||||
if (avatar.file !== undefined) params.avatar = avatar.file || '';
|
||||
|
||||
promises.push(dispatch(patchMe(params, true)));
|
||||
|
||||
|
@ -259,20 +272,6 @@ const EditProfile: React.FC = () => {
|
|||
});
|
||||
};
|
||||
|
||||
const handleFileChange = (
|
||||
name: keyof AccountCredentials,
|
||||
maxPixels: number,
|
||||
): React.ChangeEventHandler<HTMLInputElement> => {
|
||||
return e => {
|
||||
const f = e.target.files?.item(0);
|
||||
if (!f) return;
|
||||
|
||||
resizeImage(f, maxPixels).then(file => {
|
||||
updateData(name, file);
|
||||
}).catch(console.error);
|
||||
};
|
||||
};
|
||||
|
||||
const handleFieldsChange = (fields: AccountCredentialsField[]) => {
|
||||
updateData('fields_attributes', fields);
|
||||
};
|
||||
|
@ -290,48 +289,12 @@ const EditProfile: React.FC = () => {
|
|||
updateData('fields_attributes', fields);
|
||||
};
|
||||
|
||||
/** Memoized avatar preview URL. */
|
||||
const avatarUrl = useMemo(() => {
|
||||
return data.avatar ? URL.createObjectURL(data.avatar) : account?.avatar;
|
||||
}, [data.avatar, account?.avatar]);
|
||||
|
||||
/** Memoized header preview URL. */
|
||||
const headerUrl = useMemo(() => {
|
||||
return data.header ? URL.createObjectURL(data.header) : account?.header;
|
||||
}, [data.header, account?.header]);
|
||||
|
||||
/** Preview account data. */
|
||||
const previewAccount = useMemo(() => {
|
||||
return accountSchema.parse({
|
||||
id: '1',
|
||||
...account,
|
||||
...data,
|
||||
avatar: avatarUrl,
|
||||
header: headerUrl,
|
||||
});
|
||||
}, [account?.id, data.display_name, avatarUrl, headerUrl]);
|
||||
|
||||
return (
|
||||
<Column label={intl.formatMessage(messages.header)}>
|
||||
<Form onSubmit={handleSubmit}>
|
||||
<div className='grid grid-cols-1 gap-4 md:grid-cols-2'>
|
||||
<ProfilePreview account={previewAccount} />
|
||||
|
||||
<div className='space-y-4'>
|
||||
<FormGroup
|
||||
labelText={<FormattedMessage id='edit_profile.fields.header_label' defaultMessage='Choose Background Picture' />}
|
||||
hintText={<FormattedMessage id='edit_profile.hints.header' defaultMessage='PNG, GIF or JPG. Will be downscaled to {size}' values={{ size: '1920x1080px' }} />}
|
||||
>
|
||||
<FileInput onChange={handleFileChange('header', 1920 * 1080)} />
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup
|
||||
labelText={<FormattedMessage id='edit_profile.fields.avatar_label' defaultMessage='Choose Profile Picture' />}
|
||||
hintText={<FormattedMessage id='edit_profile.hints.avatar' defaultMessage='PNG, GIF or JPG. Will be downscaled to {size}' values={{ size: '400x400px' }} />}
|
||||
>
|
||||
<FileInput onChange={handleFileChange('avatar', 400 * 400)} />
|
||||
</FormGroup>
|
||||
</div>
|
||||
<div className='relative mb-12 flex'>
|
||||
<HeaderPicker accept={attachmentTypes} disabled={isLoading} {...header} />
|
||||
<AvatarPicker className='!sm:left-6 !left-4 !translate-x-0' accept={attachmentTypes} disabled={isLoading} {...avatar} />
|
||||
</div>
|
||||
|
||||
<FormGroup
|
||||
|
|
|
@ -8,8 +8,9 @@ import { useImageField, useTextField } from 'soapbox/hooks/forms';
|
|||
import toast from 'soapbox/toast';
|
||||
import { isDefaultAvatar, isDefaultHeader } from 'soapbox/utils/accounts';
|
||||
|
||||
import AvatarPicker from './components/group-avatar-picker';
|
||||
import HeaderPicker from './components/group-header-picker';
|
||||
import AvatarPicker from '../edit-profile/components/avatar-picker';
|
||||
import HeaderPicker from '../edit-profile/components/header-picker';
|
||||
|
||||
import GroupTagsField from './components/group-tags-field';
|
||||
|
||||
import type { List as ImmutableList } from 'immutable';
|
||||
|
@ -60,8 +61,8 @@ const EditGroup: React.FC<IEditGroup> = ({ params: { groupId } }) => {
|
|||
await updateGroup({
|
||||
display_name: displayName.value,
|
||||
note: note.value,
|
||||
avatar: avatar.file,
|
||||
header: header.file,
|
||||
avatar: avatar.file === null ? '' : avatar.file,
|
||||
header: header.file === null ? '' : header.file,
|
||||
tags,
|
||||
}, {
|
||||
onSuccess() {
|
||||
|
|
|
@ -3,8 +3,8 @@ import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
|
|||
|
||||
import { CreateGroupParams, useGroupValidation } from 'soapbox/api/hooks';
|
||||
import { Form, FormGroup, Input, Textarea } from 'soapbox/components/ui';
|
||||
import AvatarPicker from 'soapbox/features/group/components/group-avatar-picker';
|
||||
import HeaderPicker from 'soapbox/features/group/components/group-header-picker';
|
||||
import AvatarPicker from 'soapbox/features/edit-profile/components/avatar-picker';
|
||||
import HeaderPicker from 'soapbox/features/edit-profile/components/header-picker';
|
||||
import GroupTagsField from 'soapbox/features/group/components/group-tags-field';
|
||||
import { useAppSelector, useDebounce, useInstance } from 'soapbox/hooks';
|
||||
import { usePreview } from 'soapbox/hooks/forms';
|
||||
|
@ -66,6 +66,8 @@ const DetailsStep: React.FC<IDetailsStep> = ({ params, onChange }) => {
|
|||
};
|
||||
};
|
||||
|
||||
const handleImageClear = (property: keyof CreateGroupParams) => () => onChange({ [property]: undefined });
|
||||
|
||||
const handleTagsChange = (tags: string[]) => {
|
||||
onChange({
|
||||
...params,
|
||||
|
@ -92,7 +94,7 @@ const DetailsStep: React.FC<IDetailsStep> = ({ params, onChange }) => {
|
|||
return (
|
||||
<Form>
|
||||
<div className='relative mb-12 flex'>
|
||||
<HeaderPicker src={headerSrc} accept={attachmentTypes} onChange={handleImageChange('header', 1920 * 1080)} />
|
||||
<HeaderPicker src={headerSrc} accept={attachmentTypes} onChange={handleImageChange('header', 1920 * 1080)} onClear={handleImageClear('header')} />
|
||||
<AvatarPicker src={avatarSrc} accept={attachmentTypes} onChange={handleImageChange('avatar', 400 * 400)} />
|
||||
</div>
|
||||
|
||||
|
|
|
@ -13,8 +13,9 @@ interface UseImageFieldOpts {
|
|||
|
||||
/** Returns props for `<input type="file">`, and optionally resizes the file. */
|
||||
function useImageField(opts: UseImageFieldOpts = {}) {
|
||||
const [file, setFile] = useState<File>();
|
||||
const src = usePreview(file) || opts.preview;
|
||||
const [file, setFile] = useState<File | null>();
|
||||
const src = usePreview(file) || (file === null ? undefined : opts.preview);
|
||||
console.log(file, src);
|
||||
|
||||
const onChange: React.ChangeEventHandler<HTMLInputElement> = async ({ target: { files } }) => {
|
||||
const file = files?.item(0);
|
||||
|
@ -27,12 +28,15 @@ function useImageField(opts: UseImageFieldOpts = {}) {
|
|||
}
|
||||
};
|
||||
|
||||
const onClear = () => setFile(null);
|
||||
|
||||
return {
|
||||
src,
|
||||
file,
|
||||
onChange,
|
||||
onClear,
|
||||
};
|
||||
}
|
||||
|
||||
export { useImageField };
|
||||
export type { UseImageFieldOpts };
|
||||
export type { UseImageFieldOpts };
|
||||
|
|
|
@ -9,4 +9,4 @@ function usePreview(file: File | null | undefined): string | undefined {
|
|||
}, [file]);
|
||||
}
|
||||
|
||||
export { usePreview };
|
||||
export { usePreview };
|
||||
|
|
|
@ -580,7 +580,6 @@
|
|||
"edit_password.header": "Change Password",
|
||||
"edit_profile.error": "Profile update failed",
|
||||
"edit_profile.fields.accepts_email_list_label": "Subscribe to newsletter",
|
||||
"edit_profile.fields.avatar_label": "Choose Profile Picture",
|
||||
"edit_profile.fields.bio_label": "Bio",
|
||||
"edit_profile.fields.bio_placeholder": "Tell us about yourself.",
|
||||
"edit_profile.fields.birthday_label": "Birthday",
|
||||
|
@ -589,7 +588,6 @@
|
|||
"edit_profile.fields.discoverable_label": "Allow account discovery",
|
||||
"edit_profile.fields.display_name_label": "Display name",
|
||||
"edit_profile.fields.display_name_placeholder": "Name",
|
||||
"edit_profile.fields.header_label": "Choose Background Picture",
|
||||
"edit_profile.fields.hide_network_label": "Hide network",
|
||||
"edit_profile.fields.location_label": "Location",
|
||||
"edit_profile.fields.location_placeholder": "Location",
|
||||
|
@ -602,10 +600,8 @@
|
|||
"edit_profile.fields.website_placeholder": "Display a Link",
|
||||
"edit_profile.header": "Edit Profile",
|
||||
"edit_profile.hints.accepts_email_list": "Opt-in to news and marketing updates.",
|
||||
"edit_profile.hints.avatar": "PNG, GIF or JPG. Will be downscaled to {size}",
|
||||
"edit_profile.hints.bot": "This account mainly performs automated actions and might not be monitored",
|
||||
"edit_profile.hints.discoverable": "Display account in profile directory and allow indexing by external services",
|
||||
"edit_profile.hints.header": "PNG, GIF or JPG. Will be downscaled to {size}",
|
||||
"edit_profile.hints.hide_network": "Who you follow and who follows you will not be shown on your profile",
|
||||
"edit_profile.hints.locked": "Requires you to manually approve followers",
|
||||
"edit_profile.hints.meta_fields": "You can have up to {count, plural, one {# custom field} other {# custom fields}} displayed on your profile.",
|
||||
|
@ -838,7 +834,9 @@
|
|||
"group.unmute.long_label": "Unmute Group",
|
||||
"group.unmute.success": "Unmuted the group",
|
||||
"group.update.success": "Group successfully saved",
|
||||
"group.upload_avatar": "Upload avatar",
|
||||
"group.upload_banner": "Upload photo",
|
||||
"group.upload_banner.title": "Upload background picture",
|
||||
"groups.discover.popular.empty": "Unable to fetch popular groups at this time. Please check back later.",
|
||||
"groups.discover.popular.show_more": "Show More",
|
||||
"groups.discover.popular.title": "Popular Groups",
|
||||
|
|
Loading…
Reference in New Issue