Add profile setup to onboarding in web UI (#27829)
This commit is contained in:
parent
d8074128f9
commit
d67bd44ca1
|
@ -16,6 +16,8 @@ class Api::V1::Accounts::CredentialsController < Api::BaseController
|
||||||
current_user.update(user_params) if user_params
|
current_user.update(user_params) if user_params
|
||||||
ActivityPub::UpdateDistributionWorker.perform_async(@account.id)
|
ActivityPub::UpdateDistributionWorker.perform_async(@account.id)
|
||||||
render json: @account, serializer: REST::CredentialAccountSerializer
|
render json: @account, serializer: REST::CredentialAccountSerializer
|
||||||
|
rescue ActiveRecord::RecordInvalid => e
|
||||||
|
render json: ValidationErrorFormatter.new(e).as_json, status: 422
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
|
@ -661,3 +661,18 @@ export function unpinAccountFail(error) {
|
||||||
error,
|
error,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const updateAccount = ({ displayName, note, avatar, header, discoverable, indexable }) => (dispatch, getState) => {
|
||||||
|
const data = new FormData();
|
||||||
|
|
||||||
|
data.append('display_name', displayName);
|
||||||
|
data.append('note', note);
|
||||||
|
if (avatar) data.append('avatar', avatar);
|
||||||
|
if (header) data.append('header', header);
|
||||||
|
data.append('discoverable', discoverable);
|
||||||
|
data.append('indexable', indexable);
|
||||||
|
|
||||||
|
return api(getState).patch('/api/v1/accounts/update_credentials', data).then(response => {
|
||||||
|
dispatch(importFetchedAccount(response.data));
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
|
@ -20,6 +20,7 @@ export interface ApiAccountJSON {
|
||||||
bot: boolean;
|
bot: boolean;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
discoverable: boolean;
|
discoverable: boolean;
|
||||||
|
indexable: boolean;
|
||||||
display_name: string;
|
display_name: string;
|
||||||
emojis: ApiCustomEmojiJSON[];
|
emojis: ApiCustomEmojiJSON[];
|
||||||
fields: ApiAccountFieldJSON[];
|
fields: ApiAccountFieldJSON[];
|
||||||
|
|
|
@ -51,7 +51,7 @@ export default class Retention extends PureComponent {
|
||||||
let content;
|
let content;
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
content = <FormattedMessage id='loading_indicator.label' defaultMessage='Loading...' />;
|
content = <FormattedMessage id='loading_indicator.label' defaultMessage='Loading…' />;
|
||||||
} else {
|
} else {
|
||||||
content = (
|
content = (
|
||||||
<table className='retention__table'>
|
<table className='retention__table'>
|
||||||
|
|
|
@ -1,7 +1,23 @@
|
||||||
|
import { useIntl, defineMessages } from 'react-intl';
|
||||||
|
|
||||||
import { CircularProgress } from './circular_progress';
|
import { CircularProgress } from './circular_progress';
|
||||||
|
|
||||||
export const LoadingIndicator: React.FC = () => (
|
const messages = defineMessages({
|
||||||
<div className='loading-indicator'>
|
loading: { id: 'loading_indicator.label', defaultMessage: 'Loading…' },
|
||||||
|
});
|
||||||
|
|
||||||
|
export const LoadingIndicator: React.FC = () => {
|
||||||
|
const intl = useIntl();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className='loading-indicator'
|
||||||
|
role='progressbar'
|
||||||
|
aria-busy
|
||||||
|
aria-live='polite'
|
||||||
|
aria-label={intl.formatMessage(messages.loading)}
|
||||||
|
>
|
||||||
<CircularProgress size={50} strokeWidth={6} />
|
<CircularProgress size={50} strokeWidth={6} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
};
|
||||||
|
|
|
@ -1,29 +0,0 @@
|
||||||
import PropTypes from 'prop-types';
|
|
||||||
import { Fragment } from 'react';
|
|
||||||
|
|
||||||
import classNames from 'classnames';
|
|
||||||
|
|
||||||
import { ReactComponent as CheckIcon } from '@material-symbols/svg-600/outlined/done.svg';
|
|
||||||
|
|
||||||
import { Icon } from 'mastodon/components/icon';
|
|
||||||
|
|
||||||
const ProgressIndicator = ({ steps, completed }) => (
|
|
||||||
<div className='onboarding__progress-indicator'>
|
|
||||||
{(new Array(steps)).fill().map((_, i) => (
|
|
||||||
<Fragment key={i}>
|
|
||||||
{i > 0 && <div className={classNames('onboarding__progress-indicator__line', { active: completed > i })} />}
|
|
||||||
|
|
||||||
<div className={classNames('onboarding__progress-indicator__step', { active: completed > i })}>
|
|
||||||
{completed > i && <Icon icon={CheckIcon} />}
|
|
||||||
</div>
|
|
||||||
</Fragment>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
ProgressIndicator.propTypes = {
|
|
||||||
steps: PropTypes.number.isRequired,
|
|
||||||
completed: PropTypes.number,
|
|
||||||
};
|
|
||||||
|
|
||||||
export default ProgressIndicator;
|
|
|
@ -1,11 +1,13 @@
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
|
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
|
||||||
import { ReactComponent as ArrowRightAltIcon } from '@material-symbols/svg-600/outlined/arrow_right_alt.svg';
|
import { ReactComponent as ArrowRightAltIcon } from '@material-symbols/svg-600/outlined/arrow_right_alt.svg';
|
||||||
import { ReactComponent as CheckIcon } from '@material-symbols/svg-600/outlined/done.svg';
|
import { ReactComponent as CheckIcon } from '@material-symbols/svg-600/outlined/done.svg';
|
||||||
|
|
||||||
import { Icon } from 'mastodon/components/icon';
|
import { Icon } from 'mastodon/components/icon';
|
||||||
|
|
||||||
const Step = ({ label, description, icon, iconComponent, completed, onClick, href }) => {
|
export const Step = ({ label, description, icon, iconComponent, completed, onClick, href, to }) => {
|
||||||
const content = (
|
const content = (
|
||||||
<>
|
<>
|
||||||
<div className='onboarding__steps__item__icon'>
|
<div className='onboarding__steps__item__icon'>
|
||||||
|
@ -29,6 +31,12 @@ const Step = ({ label, description, icon, iconComponent, completed, onClick, hre
|
||||||
{content}
|
{content}
|
||||||
</a>
|
</a>
|
||||||
);
|
);
|
||||||
|
} else if (to) {
|
||||||
|
return (
|
||||||
|
<Link to={to} className='onboarding__steps__item'>
|
||||||
|
{content}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
@ -45,7 +53,6 @@ Step.propTypes = {
|
||||||
iconComponent: PropTypes.func,
|
iconComponent: PropTypes.func,
|
||||||
completed: PropTypes.bool,
|
completed: PropTypes.bool,
|
||||||
href: PropTypes.string,
|
href: PropTypes.string,
|
||||||
|
to: PropTypes.string,
|
||||||
onClick: PropTypes.func,
|
onClick: PropTypes.func,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default Step;
|
|
||||||
|
|
|
@ -1,44 +1,31 @@
|
||||||
import PropTypes from 'prop-types';
|
import { useEffect } from 'react';
|
||||||
import { PureComponent } from 'react';
|
|
||||||
|
|
||||||
import { FormattedMessage } from 'react-intl';
|
import { FormattedMessage } from 'react-intl';
|
||||||
|
|
||||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
import { Link } from 'react-router-dom';
|
||||||
import { connect } from 'react-redux';
|
|
||||||
|
import { useDispatch } from 'react-redux';
|
||||||
|
|
||||||
|
|
||||||
import { fetchSuggestions } from 'mastodon/actions/suggestions';
|
import { fetchSuggestions } from 'mastodon/actions/suggestions';
|
||||||
import { markAsPartial } from 'mastodon/actions/timelines';
|
import { markAsPartial } from 'mastodon/actions/timelines';
|
||||||
import Column from 'mastodon/components/column';
|
|
||||||
import { ColumnBackButton } from 'mastodon/components/column_back_button';
|
import { ColumnBackButton } from 'mastodon/components/column_back_button';
|
||||||
import { EmptyAccount } from 'mastodon/components/empty_account';
|
import { EmptyAccount } from 'mastodon/components/empty_account';
|
||||||
import Account from 'mastodon/containers/account_container';
|
import Account from 'mastodon/containers/account_container';
|
||||||
|
import { useAppSelector } from 'mastodon/store';
|
||||||
|
|
||||||
const mapStateToProps = state => ({
|
export const Follows = () => {
|
||||||
suggestions: state.getIn(['suggestions', 'items']),
|
const dispatch = useDispatch();
|
||||||
isLoading: state.getIn(['suggestions', 'isLoading']),
|
const isLoading = useAppSelector(state => state.getIn(['suggestions', 'isLoading']));
|
||||||
});
|
const suggestions = useAppSelector(state => state.getIn(['suggestions', 'items']));
|
||||||
|
|
||||||
class Follows extends PureComponent {
|
useEffect(() => {
|
||||||
|
|
||||||
static propTypes = {
|
|
||||||
onBack: PropTypes.func,
|
|
||||||
dispatch: PropTypes.func.isRequired,
|
|
||||||
suggestions: ImmutablePropTypes.list,
|
|
||||||
isLoading: PropTypes.bool,
|
|
||||||
};
|
|
||||||
|
|
||||||
componentDidMount () {
|
|
||||||
const { dispatch } = this.props;
|
|
||||||
dispatch(fetchSuggestions(true));
|
dispatch(fetchSuggestions(true));
|
||||||
}
|
|
||||||
|
|
||||||
componentWillUnmount () {
|
return () => {
|
||||||
const { dispatch } = this.props;
|
|
||||||
dispatch(markAsPartial('home'));
|
dispatch(markAsPartial('home'));
|
||||||
}
|
};
|
||||||
|
}, [dispatch]);
|
||||||
render () {
|
|
||||||
const { onBack, isLoading, suggestions } = this.props;
|
|
||||||
|
|
||||||
let loadedContent;
|
let loadedContent;
|
||||||
|
|
||||||
|
@ -51,8 +38,8 @@ class Follows extends PureComponent {
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Column>
|
<>
|
||||||
<ColumnBackButton onClick={onBack} />
|
<ColumnBackButton />
|
||||||
|
|
||||||
<div className='scrollable privacy-policy'>
|
<div className='scrollable privacy-policy'>
|
||||||
<div className='column-title'>
|
<div className='column-title'>
|
||||||
|
@ -67,13 +54,9 @@ class Follows extends PureComponent {
|
||||||
<p className='onboarding__lead'><FormattedMessage id='onboarding.tips.accounts_from_other_servers' defaultMessage='<strong>Did you know?</strong> Since Mastodon is decentralized, some profiles you come across will be hosted on servers other than yours. And yet you can interact with them seamlessly! Their server is in the second half of their username!' values={{ strong: chunks => <strong>{chunks}</strong> }} /></p>
|
<p className='onboarding__lead'><FormattedMessage id='onboarding.tips.accounts_from_other_servers' defaultMessage='<strong>Did you know?</strong> Since Mastodon is decentralized, some profiles you come across will be hosted on servers other than yours. And yet you can interact with them seamlessly! Their server is in the second half of their username!' values={{ strong: chunks => <strong>{chunks}</strong> }} /></p>
|
||||||
|
|
||||||
<div className='onboarding__footer'>
|
<div className='onboarding__footer'>
|
||||||
<button className='link-button' onClick={onBack}><FormattedMessage id='onboarding.actions.back' defaultMessage='Take me back' /></button>
|
<Link className='link-button' to='/start'><FormattedMessage id='onboarding.actions.back' defaultMessage='Take me back' /></Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Column>
|
</>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
export default connect(mapStateToProps)(Follows);
|
|
||||||
|
|
|
@ -1,116 +1,50 @@
|
||||||
import PropTypes from 'prop-types';
|
import { useCallback } from 'react';
|
||||||
|
|
||||||
import { FormattedMessage, injectIntl, defineMessages } from 'react-intl';
|
import { FormattedMessage, useIntl, defineMessages } from 'react-intl';
|
||||||
|
|
||||||
import { Helmet } from 'react-helmet';
|
import { Helmet } from 'react-helmet';
|
||||||
import { Link, withRouter } from 'react-router-dom';
|
import { Link, Switch, Route, useHistory } from 'react-router-dom';
|
||||||
|
|
||||||
|
import { useDispatch } from 'react-redux';
|
||||||
|
|
||||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
|
||||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
|
||||||
import { connect } from 'react-redux';
|
|
||||||
|
|
||||||
import { ReactComponent as AccountCircleIcon } from '@material-symbols/svg-600/outlined/account_circle.svg';
|
import { ReactComponent as AccountCircleIcon } from '@material-symbols/svg-600/outlined/account_circle.svg';
|
||||||
import { ReactComponent as ArrowRightAltIcon } from '@material-symbols/svg-600/outlined/arrow_right_alt.svg';
|
import { ReactComponent as ArrowRightAltIcon } from '@material-symbols/svg-600/outlined/arrow_right_alt.svg';
|
||||||
import { ReactComponent as ContentCopyIcon } from '@material-symbols/svg-600/outlined/content_copy.svg';
|
import { ReactComponent as ContentCopyIcon } from '@material-symbols/svg-600/outlined/content_copy.svg';
|
||||||
import { ReactComponent as EditNoteIcon } from '@material-symbols/svg-600/outlined/edit_note.svg';
|
import { ReactComponent as EditNoteIcon } from '@material-symbols/svg-600/outlined/edit_note.svg';
|
||||||
import { ReactComponent as PersonAddIcon } from '@material-symbols/svg-600/outlined/person_add.svg';
|
import { ReactComponent as PersonAddIcon } from '@material-symbols/svg-600/outlined/person_add.svg';
|
||||||
import { debounce } from 'lodash';
|
|
||||||
|
|
||||||
import illustration from 'mastodon/../images/elephant_ui_conversation.svg';
|
import illustration from 'mastodon/../images/elephant_ui_conversation.svg';
|
||||||
import { fetchAccount } from 'mastodon/actions/accounts';
|
|
||||||
import { focusCompose } from 'mastodon/actions/compose';
|
import { focusCompose } from 'mastodon/actions/compose';
|
||||||
import { closeOnboarding } from 'mastodon/actions/onboarding';
|
|
||||||
import { Icon } from 'mastodon/components/icon';
|
import { Icon } from 'mastodon/components/icon';
|
||||||
import Column from 'mastodon/features/ui/components/column';
|
import Column from 'mastodon/features/ui/components/column';
|
||||||
import { me } from 'mastodon/initial_state';
|
import { me } from 'mastodon/initial_state';
|
||||||
import { makeGetAccount } from 'mastodon/selectors';
|
import { useAppSelector } from 'mastodon/store';
|
||||||
import { assetHost } from 'mastodon/utils/config';
|
import { assetHost } from 'mastodon/utils/config';
|
||||||
import { WithRouterPropTypes } from 'mastodon/utils/react_router';
|
|
||||||
|
|
||||||
import Step from './components/step';
|
import { Step } from './components/step';
|
||||||
import Follows from './follows';
|
import { Follows } from './follows';
|
||||||
import Share from './share';
|
import { Profile } from './profile';
|
||||||
|
import { Share } from './share';
|
||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages({
|
||||||
template: { id: 'onboarding.compose.template', defaultMessage: 'Hello #Mastodon!' },
|
template: { id: 'onboarding.compose.template', defaultMessage: 'Hello #Mastodon!' },
|
||||||
});
|
});
|
||||||
|
|
||||||
const mapStateToProps = () => {
|
const Onboarding = () => {
|
||||||
const getAccount = makeGetAccount();
|
const account = useAppSelector(state => state.getIn(['accounts', me]));
|
||||||
|
const dispatch = useDispatch();
|
||||||
return state => ({
|
const intl = useIntl();
|
||||||
account: getAccount(state, me),
|
const history = useHistory();
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
class Onboarding extends ImmutablePureComponent {
|
|
||||||
static propTypes = {
|
|
||||||
dispatch: PropTypes.func.isRequired,
|
|
||||||
account: ImmutablePropTypes.record,
|
|
||||||
...WithRouterPropTypes,
|
|
||||||
};
|
|
||||||
|
|
||||||
state = {
|
|
||||||
step: null,
|
|
||||||
profileClicked: false,
|
|
||||||
shareClicked: false,
|
|
||||||
};
|
|
||||||
|
|
||||||
handleClose = () => {
|
|
||||||
const { dispatch, history } = this.props;
|
|
||||||
|
|
||||||
dispatch(closeOnboarding());
|
|
||||||
history.push('/home');
|
|
||||||
};
|
|
||||||
|
|
||||||
handleProfileClick = () => {
|
|
||||||
this.setState({ profileClicked: true });
|
|
||||||
};
|
|
||||||
|
|
||||||
handleFollowClick = () => {
|
|
||||||
this.setState({ step: 'follows' });
|
|
||||||
};
|
|
||||||
|
|
||||||
handleComposeClick = () => {
|
|
||||||
const { dispatch, intl, history } = this.props;
|
|
||||||
|
|
||||||
|
const handleComposeClick = useCallback(() => {
|
||||||
dispatch(focusCompose(history, intl.formatMessage(messages.template)));
|
dispatch(focusCompose(history, intl.formatMessage(messages.template)));
|
||||||
};
|
}, [dispatch, intl, history]);
|
||||||
|
|
||||||
handleShareClick = () => {
|
|
||||||
this.setState({ step: 'share', shareClicked: true });
|
|
||||||
};
|
|
||||||
|
|
||||||
handleBackClick = () => {
|
|
||||||
this.setState({ step: null });
|
|
||||||
};
|
|
||||||
|
|
||||||
handleWindowFocus = debounce(() => {
|
|
||||||
const { dispatch, account } = this.props;
|
|
||||||
dispatch(fetchAccount(account.get('id')));
|
|
||||||
}, 1000, { trailing: true });
|
|
||||||
|
|
||||||
componentDidMount () {
|
|
||||||
window.addEventListener('focus', this.handleWindowFocus, false);
|
|
||||||
}
|
|
||||||
|
|
||||||
componentWillUnmount () {
|
|
||||||
window.removeEventListener('focus', this.handleWindowFocus);
|
|
||||||
}
|
|
||||||
|
|
||||||
render () {
|
|
||||||
const { account } = this.props;
|
|
||||||
const { step, shareClicked } = this.state;
|
|
||||||
|
|
||||||
switch(step) {
|
|
||||||
case 'follows':
|
|
||||||
return <Follows onBack={this.handleBackClick} />;
|
|
||||||
case 'share':
|
|
||||||
return <Share onBack={this.handleBackClick} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Column>
|
<Column>
|
||||||
|
<Switch>
|
||||||
|
<Route path='/start' exact>
|
||||||
<div className='scrollable privacy-policy'>
|
<div className='scrollable privacy-policy'>
|
||||||
<div className='column-title'>
|
<div className='column-title'>
|
||||||
<img src={illustration} alt='' className='onboarding__illustration' />
|
<img src={illustration} alt='' className='onboarding__illustration' />
|
||||||
|
@ -119,10 +53,10 @@ class Onboarding extends ImmutablePureComponent {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className='onboarding__steps'>
|
<div className='onboarding__steps'>
|
||||||
<Step onClick={this.handleProfileClick} href='/settings/profile' completed={(!account.get('avatar').endsWith('missing.png')) || (account.get('display_name').length > 0 && account.get('note').length > 0)} icon='address-book-o' iconComponent={AccountCircleIcon} label={<FormattedMessage id='onboarding.steps.setup_profile.title' defaultMessage='Customize your profile' />} description={<FormattedMessage id='onboarding.steps.setup_profile.body' defaultMessage='Others are more likely to interact with you with a filled out profile.' />} />
|
<Step to='/start/profile' completed={(!account.get('avatar').endsWith('missing.png')) || (account.get('display_name').length > 0 && account.get('note').length > 0)} icon='address-book-o' iconComponent={AccountCircleIcon} label={<FormattedMessage id='onboarding.steps.setup_profile.title' defaultMessage='Customize your profile' />} description={<FormattedMessage id='onboarding.steps.setup_profile.body' defaultMessage='Others are more likely to interact with you with a filled out profile.' />} />
|
||||||
<Step onClick={this.handleFollowClick} completed={(account.get('following_count') * 1) >= 7} icon='user-plus' iconComponent={PersonAddIcon} label={<FormattedMessage id='onboarding.steps.follow_people.title' defaultMessage='Find at least {count, plural, one {one person} other {# people}} to follow' values={{ count: 7 }} />} description={<FormattedMessage id='onboarding.steps.follow_people.body' defaultMessage="You curate your own home feed. Let's fill it with interesting people." />} />
|
<Step to='/start/follows' completed={(account.get('following_count') * 1) >= 1} icon='user-plus' iconComponent={PersonAddIcon} label={<FormattedMessage id='onboarding.steps.follow_people.title' defaultMessage='Find at least {count, plural, one {one person} other {# people}} to follow' values={{ count: 7 }} />} description={<FormattedMessage id='onboarding.steps.follow_people.body' defaultMessage="You curate your own home feed. Let's fill it with interesting people." />} />
|
||||||
<Step onClick={this.handleComposeClick} completed={(account.get('statuses_count') * 1) >= 1} icon='pencil-square-o' iconComponent={EditNoteIcon} label={<FormattedMessage id='onboarding.steps.publish_status.title' defaultMessage='Make your first post' />} description={<FormattedMessage id='onboarding.steps.publish_status.body' defaultMessage='Say hello to the world.' values={{ emoji: <img className='emojione' alt='🐘' src={`${assetHost}/emoji/1f418.svg`} /> }} />} />
|
<Step onClick={handleComposeClick} completed={(account.get('statuses_count') * 1) >= 1} icon='pencil-square-o' iconComponent={EditNoteIcon} label={<FormattedMessage id='onboarding.steps.publish_status.title' defaultMessage='Make your first post' />} description={<FormattedMessage id='onboarding.steps.publish_status.body' defaultMessage='Say hello to the world.' values={{ emoji: <img className='emojione' alt='🐘' src={`${assetHost}/emoji/1f418.svg`} /> }} />} />
|
||||||
<Step onClick={this.handleShareClick} completed={shareClicked} icon='copy' iconComponent={ContentCopyIcon} label={<FormattedMessage id='onboarding.steps.share_profile.title' defaultMessage='Share your profile' />} description={<FormattedMessage id='onboarding.steps.share_profile.body' defaultMessage='Let your friends know how to find you on Mastodon!' />} />
|
<Step to='/start/share' icon='copy' iconComponent={ContentCopyIcon} label={<FormattedMessage id='onboarding.steps.share_profile.title' defaultMessage='Share your profile' />} description={<FormattedMessage id='onboarding.steps.share_profile.body' defaultMessage='Let your friends know how to find you on Mastodon!' />} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className='onboarding__lead'><FormattedMessage id='onboarding.start.skip' defaultMessage="Don't need help getting started?" /></p>
|
<p className='onboarding__lead'><FormattedMessage id='onboarding.start.skip' defaultMessage="Don't need help getting started?" /></p>
|
||||||
|
@ -139,14 +73,18 @@ class Onboarding extends ImmutablePureComponent {
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</Route>
|
||||||
|
|
||||||
|
<Route path='/start/profile' component={Profile} />
|
||||||
|
<Route path='/start/follows' component={Follows} />
|
||||||
|
<Route path='/start/share' component={Share} />
|
||||||
|
</Switch>
|
||||||
|
|
||||||
<Helmet>
|
<Helmet>
|
||||||
<meta name='robots' content='noindex' />
|
<meta name='robots' content='noindex' />
|
||||||
</Helmet>
|
</Helmet>
|
||||||
</Column>
|
</Column>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
}
|
export default Onboarding;
|
||||||
|
|
||||||
export default withRouter(connect(mapStateToProps)(injectIntl(Onboarding)));
|
|
||||||
|
|
|
@ -0,0 +1,162 @@
|
||||||
|
import { useState, useMemo, useCallback, createRef } from 'react';
|
||||||
|
|
||||||
|
import { useIntl, defineMessages, FormattedMessage } from 'react-intl';
|
||||||
|
|
||||||
|
import classNames from 'classnames';
|
||||||
|
import { useHistory } from 'react-router-dom';
|
||||||
|
|
||||||
|
import { useDispatch } from 'react-redux';
|
||||||
|
|
||||||
|
|
||||||
|
import { ReactComponent as AddPhotoAlternateIcon } from '@material-symbols/svg-600/outlined/add_photo_alternate.svg';
|
||||||
|
import { ReactComponent as EditIcon } from '@material-symbols/svg-600/outlined/edit.svg';
|
||||||
|
import Toggle from 'react-toggle';
|
||||||
|
|
||||||
|
import { updateAccount } from 'mastodon/actions/accounts';
|
||||||
|
import { Button } from 'mastodon/components/button';
|
||||||
|
import { ColumnBackButton } from 'mastodon/components/column_back_button';
|
||||||
|
import { Icon } from 'mastodon/components/icon';
|
||||||
|
import { LoadingIndicator } from 'mastodon/components/loading_indicator';
|
||||||
|
import { me } from 'mastodon/initial_state';
|
||||||
|
import { useAppSelector } from 'mastodon/store';
|
||||||
|
import { unescapeHTML } from 'mastodon/utils/html';
|
||||||
|
|
||||||
|
const messages = defineMessages({
|
||||||
|
uploadHeader: { id: 'onboarding.profile.upload_header', defaultMessage: 'Upload profile header' },
|
||||||
|
uploadAvatar: { id: 'onboarding.profile.upload_avatar', defaultMessage: 'Upload profile picture' },
|
||||||
|
});
|
||||||
|
|
||||||
|
export const Profile = () => {
|
||||||
|
const account = useAppSelector(state => state.getIn(['accounts', me]));
|
||||||
|
const [displayName, setDisplayName] = useState(account.get('display_name'));
|
||||||
|
const [note, setNote] = useState(unescapeHTML(account.get('note')));
|
||||||
|
const [avatar, setAvatar] = useState(null);
|
||||||
|
const [header, setHeader] = useState(null);
|
||||||
|
const [discoverable, setDiscoverable] = useState(account.get('discoverable'));
|
||||||
|
const [indexable, setIndexable] = useState(account.get('indexable'));
|
||||||
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
|
const [errors, setErrors] = useState();
|
||||||
|
const avatarFileRef = createRef();
|
||||||
|
const headerFileRef = createRef();
|
||||||
|
const dispatch = useDispatch();
|
||||||
|
const intl = useIntl();
|
||||||
|
const history = useHistory();
|
||||||
|
|
||||||
|
const handleDisplayNameChange = useCallback(e => {
|
||||||
|
setDisplayName(e.target.value);
|
||||||
|
}, [setDisplayName]);
|
||||||
|
|
||||||
|
const handleNoteChange = useCallback(e => {
|
||||||
|
setNote(e.target.value);
|
||||||
|
}, [setNote]);
|
||||||
|
|
||||||
|
const handleDiscoverableChange = useCallback(e => {
|
||||||
|
setDiscoverable(e.target.checked);
|
||||||
|
}, [setDiscoverable]);
|
||||||
|
|
||||||
|
const handleIndexableChange = useCallback(e => {
|
||||||
|
setIndexable(e.target.checked);
|
||||||
|
}, [setIndexable]);
|
||||||
|
|
||||||
|
const handleAvatarChange = useCallback(e => {
|
||||||
|
setAvatar(e.target?.files?.[0]);
|
||||||
|
}, [setAvatar]);
|
||||||
|
|
||||||
|
const handleHeaderChange = useCallback(e => {
|
||||||
|
setHeader(e.target?.files?.[0]);
|
||||||
|
}, [setHeader]);
|
||||||
|
|
||||||
|
const avatarPreview = useMemo(() => avatar ? URL.createObjectURL(avatar) : account.get('avatar'), [avatar, account]);
|
||||||
|
const headerPreview = useMemo(() => header ? URL.createObjectURL(header) : account.get('header'), [header, account]);
|
||||||
|
|
||||||
|
const handleSubmit = useCallback(() => {
|
||||||
|
setIsSaving(true);
|
||||||
|
|
||||||
|
dispatch(updateAccount({
|
||||||
|
displayName,
|
||||||
|
note,
|
||||||
|
avatar,
|
||||||
|
header,
|
||||||
|
discoverable,
|
||||||
|
indexable,
|
||||||
|
})).then(() => history.push('/start/follows')).catch(err => {
|
||||||
|
setIsSaving(false);
|
||||||
|
setErrors(err.response.data.details);
|
||||||
|
});
|
||||||
|
}, [dispatch, displayName, note, avatar, header, discoverable, indexable, history]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<ColumnBackButton />
|
||||||
|
|
||||||
|
<div className='scrollable privacy-policy'>
|
||||||
|
<div className='column-title'>
|
||||||
|
<h3><FormattedMessage id='onboarding.profile.title' defaultMessage='Profile setup' /></h3>
|
||||||
|
<p><FormattedMessage id='onboarding.profile.lead' defaultMessage='You can always complete this later in the settings, where even more customization options are available.' /></p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='simple_form'>
|
||||||
|
<div className='onboarding__profile'>
|
||||||
|
<label className={classNames('app-form__header-input', { selected: !!headerPreview, invalid: !!errors?.header })} title={intl.formatMessage(messages.uploadHeader)}>
|
||||||
|
<input
|
||||||
|
type='file'
|
||||||
|
hidden
|
||||||
|
ref={headerFileRef}
|
||||||
|
accept='image/*'
|
||||||
|
onChange={handleHeaderChange}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{headerPreview && <img src={headerPreview} alt='' />}
|
||||||
|
|
||||||
|
<Icon icon={headerPreview ? EditIcon : AddPhotoAlternateIcon} />
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className={classNames('app-form__avatar-input', { selected: !!avatarPreview, invalid: !!errors?.avatar })} title={intl.formatMessage(messages.uploadAvatar)}>
|
||||||
|
<input
|
||||||
|
type='file'
|
||||||
|
hidden
|
||||||
|
ref={avatarFileRef}
|
||||||
|
accept='image/*'
|
||||||
|
onChange={handleAvatarChange}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{avatarPreview && <img src={avatarPreview} alt='' />}
|
||||||
|
|
||||||
|
<Icon icon={avatarPreview ? EditIcon : AddPhotoAlternateIcon} />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={classNames('input with_block_label', { field_with_errors: !!errors?.display_name })}>
|
||||||
|
<label htmlFor='display_name'><FormattedMessage id='onboarding.profile.display_name' defaultMessage='Display name' /></label>
|
||||||
|
<span className='hint'><FormattedMessage id='onboarding.profile.display_name_hint' defaultMessage='Your full name or your fun name…' /></span>
|
||||||
|
<div className='label_input'>
|
||||||
|
<input id='display_name' type='text' value={displayName} onChange={handleDisplayNameChange} maxLength={30} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={classNames('input with_block_label', { field_with_errors: !!errors?.note })}>
|
||||||
|
<label htmlFor='note'><FormattedMessage id='onboarding.profile.note' defaultMessage='Bio' /></label>
|
||||||
|
<span className='hint'><FormattedMessage id='onboarding.profile.note_hint' defaultMessage='You can @mention other people or #hashtags…' /></span>
|
||||||
|
<div className='label_input'>
|
||||||
|
<textarea id='note' value={note} onChange={handleNoteChange} maxLength={500} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label className='report-dialog-modal__toggle'>
|
||||||
|
<Toggle checked={discoverable} onChange={handleDiscoverableChange} />
|
||||||
|
<FormattedMessage id='onboarding.profile.discoverable' defaultMessage='Feature profile and posts in discovery algorithms' />
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className='report-dialog-modal__toggle'>
|
||||||
|
<Toggle checked={indexable} onChange={handleIndexableChange} />
|
||||||
|
<FormattedMessage id='onboarding.profile.indexable' defaultMessage='Include public posts in search results' />
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div className='onboarding__footer'>
|
||||||
|
<Button block onClick={handleSubmit} disabled={isSaving}>{isSaving ? <LoadingIndicator /> : <FormattedMessage id='onboarding.profile.save_and_continue' defaultMessage='Save and continue' />}</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
|
@ -1,31 +1,25 @@
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
import { PureComponent } from 'react';
|
import { PureComponent } from 'react';
|
||||||
|
|
||||||
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
|
import { defineMessages, useIntl, FormattedMessage } from 'react-intl';
|
||||||
|
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
|
|
||||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
|
||||||
import { connect } from 'react-redux';
|
|
||||||
|
|
||||||
import { ReactComponent as ArrowRightAltIcon } from '@material-symbols/svg-600/outlined/arrow_right_alt.svg';
|
import { ReactComponent as ArrowRightAltIcon } from '@material-symbols/svg-600/outlined/arrow_right_alt.svg';
|
||||||
import { ReactComponent as ContentCopyIcon } from '@material-symbols/svg-600/outlined/content_copy.svg';
|
import { ReactComponent as ContentCopyIcon } from '@material-symbols/svg-600/outlined/content_copy.svg';
|
||||||
import SwipeableViews from 'react-swipeable-views';
|
import SwipeableViews from 'react-swipeable-views';
|
||||||
|
|
||||||
import Column from 'mastodon/components/column';
|
|
||||||
import { ColumnBackButton } from 'mastodon/components/column_back_button';
|
import { ColumnBackButton } from 'mastodon/components/column_back_button';
|
||||||
import { Icon } from 'mastodon/components/icon';
|
import { Icon } from 'mastodon/components/icon';
|
||||||
import { me, domain } from 'mastodon/initial_state';
|
import { me, domain } from 'mastodon/initial_state';
|
||||||
|
import { useAppSelector } from 'mastodon/store';
|
||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages({
|
||||||
shareableMessage: { id: 'onboarding.share.message', defaultMessage: 'I\'m {username} on #Mastodon! Come follow me at {url}' },
|
shareableMessage: { id: 'onboarding.share.message', defaultMessage: 'I\'m {username} on #Mastodon! Come follow me at {url}' },
|
||||||
});
|
});
|
||||||
|
|
||||||
const mapStateToProps = state => ({
|
|
||||||
account: state.getIn(['accounts', me]),
|
|
||||||
});
|
|
||||||
|
|
||||||
class CopyPasteText extends PureComponent {
|
class CopyPasteText extends PureComponent {
|
||||||
|
|
||||||
static propTypes = {
|
static propTypes = {
|
||||||
|
@ -141,22 +135,14 @@ class TipCarousel extends PureComponent {
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
class Share extends PureComponent {
|
export const Share = () => {
|
||||||
|
const account = useAppSelector(state => state.getIn(['accounts', me]));
|
||||||
static propTypes = {
|
const intl = useIntl();
|
||||||
onBack: PropTypes.func,
|
|
||||||
account: ImmutablePropTypes.record,
|
|
||||||
intl: PropTypes.object,
|
|
||||||
};
|
|
||||||
|
|
||||||
render () {
|
|
||||||
const { onBack, account, intl } = this.props;
|
|
||||||
|
|
||||||
const url = (new URL(`/@${account.get('username')}`, document.baseURI)).href;
|
const url = (new URL(`/@${account.get('username')}`, document.baseURI)).href;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Column>
|
<>
|
||||||
<ColumnBackButton onClick={onBack} />
|
<ColumnBackButton />
|
||||||
|
|
||||||
<div className='scrollable privacy-policy'>
|
<div className='scrollable privacy-policy'>
|
||||||
<div className='column-title'>
|
<div className='column-title'>
|
||||||
|
@ -187,13 +173,9 @@ class Share extends PureComponent {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className='onboarding__footer'>
|
<div className='onboarding__footer'>
|
||||||
<button className='link-button' onClick={onBack}><FormattedMessage id='onboarding.action.back' defaultMessage='Take me back' /></button>
|
<Link className='link-button' to='/start'><FormattedMessage id='onboarding.action.back' defaultMessage='Take me back' /></Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Column>
|
</>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
export default connect(mapStateToProps)(injectIntl(Share));
|
|
||||||
|
|
|
@ -210,7 +210,7 @@ class SwitchingColumnsArea extends PureComponent {
|
||||||
<WrappedRoute path='/bookmarks' component={BookmarkedStatuses} content={children} />
|
<WrappedRoute path='/bookmarks' component={BookmarkedStatuses} content={children} />
|
||||||
<WrappedRoute path='/pinned' component={PinnedStatuses} content={children} />
|
<WrappedRoute path='/pinned' component={PinnedStatuses} content={children} />
|
||||||
|
|
||||||
<WrappedRoute path='/start' exact component={Onboarding} content={children} />
|
<WrappedRoute path='/start' component={Onboarding} content={children} />
|
||||||
<WrappedRoute path='/directory' component={Directory} content={children} />
|
<WrappedRoute path='/directory' component={Directory} content={children} />
|
||||||
<WrappedRoute path={['/explore', '/search']} component={Explore} content={children} />
|
<WrappedRoute path={['/explore', '/search']} component={Explore} content={children} />
|
||||||
<WrappedRoute path={['/publish', '/statuses/new']} component={Compose} content={children} />
|
<WrappedRoute path={['/publish', '/statuses/new']} component={Compose} content={children} />
|
||||||
|
|
|
@ -390,7 +390,7 @@
|
||||||
"lists.search": "Search among people you follow",
|
"lists.search": "Search among people you follow",
|
||||||
"lists.subheading": "Your lists",
|
"lists.subheading": "Your lists",
|
||||||
"load_pending": "{count, plural, one {# new item} other {# new items}}",
|
"load_pending": "{count, plural, one {# new item} other {# new items}}",
|
||||||
"loading_indicator.label": "Loading...",
|
"loading_indicator.label": "Loading…",
|
||||||
"media_gallery.toggle_visible": "{number, plural, one {Hide image} other {Hide images}}",
|
"media_gallery.toggle_visible": "{number, plural, one {Hide image} other {Hide images}}",
|
||||||
"moved_to_account_banner.text": "Your account {disabledAccount} is currently disabled because you moved to {movedToAccount}.",
|
"moved_to_account_banner.text": "Your account {disabledAccount} is currently disabled because you moved to {movedToAccount}.",
|
||||||
"mute_modal.duration": "Duration",
|
"mute_modal.duration": "Duration",
|
||||||
|
@ -479,6 +479,17 @@
|
||||||
"onboarding.follows.empty": "Unfortunately, no results can be shown right now. You can try using search or browsing the explore page to find people to follow, or try again later.",
|
"onboarding.follows.empty": "Unfortunately, no results can be shown right now. You can try using search or browsing the explore page to find people to follow, or try again later.",
|
||||||
"onboarding.follows.lead": "Your home feed is the primary way to experience Mastodon. The more people you follow, the more active and interesting it will be. To get you started, here are some suggestions:",
|
"onboarding.follows.lead": "Your home feed is the primary way to experience Mastodon. The more people you follow, the more active and interesting it will be. To get you started, here are some suggestions:",
|
||||||
"onboarding.follows.title": "Personalize your home feed",
|
"onboarding.follows.title": "Personalize your home feed",
|
||||||
|
"onboarding.profile.discoverable": "Feature profile and posts in discovery algorithms",
|
||||||
|
"onboarding.profile.display_name": "Display name",
|
||||||
|
"onboarding.profile.display_name_hint": "Your full name or your fun name…",
|
||||||
|
"onboarding.profile.indexable": "Include public posts in search results",
|
||||||
|
"onboarding.profile.lead": "You can always complete this later in the settings, where even more customization options are available.",
|
||||||
|
"onboarding.profile.note": "Bio",
|
||||||
|
"onboarding.profile.note_hint": "You can @mention other people or #hashtags…",
|
||||||
|
"onboarding.profile.save_and_continue": "Save and continue",
|
||||||
|
"onboarding.profile.title": "Profile setup",
|
||||||
|
"onboarding.profile.upload_avatar": "Upload profile picture",
|
||||||
|
"onboarding.profile.upload_header": "Upload profile header",
|
||||||
"onboarding.share.lead": "Let people know how they can find you on Mastodon!",
|
"onboarding.share.lead": "Let people know how they can find you on Mastodon!",
|
||||||
"onboarding.share.message": "I'm {username} on #Mastodon! Come follow me at {url}",
|
"onboarding.share.message": "I'm {username} on #Mastodon! Come follow me at {url}",
|
||||||
"onboarding.share.next_steps": "Possible next steps:",
|
"onboarding.share.next_steps": "Possible next steps:",
|
||||||
|
|
|
@ -67,6 +67,7 @@ export const accountDefaultValues: AccountShape = {
|
||||||
bot: false,
|
bot: false,
|
||||||
created_at: '',
|
created_at: '',
|
||||||
discoverable: false,
|
discoverable: false,
|
||||||
|
indexable: false,
|
||||||
display_name: '',
|
display_name: '',
|
||||||
display_name_html: '',
|
display_name_html: '',
|
||||||
emojis: List<CustomEmoji>(),
|
emojis: List<CustomEmoji>(),
|
||||||
|
|
|
@ -2552,7 +2552,7 @@ $ui-header-height: 55px;
|
||||||
|
|
||||||
.column-title {
|
.column-title {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
padding-bottom: 40px;
|
padding-bottom: 32px;
|
||||||
|
|
||||||
h3 {
|
h3 {
|
||||||
font-size: 24px;
|
font-size: 24px;
|
||||||
|
@ -2743,58 +2743,6 @@ $ui-header-height: 55px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.onboarding__progress-indicator {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
margin-bottom: 30px;
|
|
||||||
position: sticky;
|
|
||||||
background: $ui-base-color;
|
|
||||||
|
|
||||||
@media screen and (width >= 600) {
|
|
||||||
padding: 0 40px;
|
|
||||||
}
|
|
||||||
|
|
||||||
&__line {
|
|
||||||
height: 4px;
|
|
||||||
flex: 1 1 auto;
|
|
||||||
background: lighten($ui-base-color, 4%);
|
|
||||||
}
|
|
||||||
|
|
||||||
&__step {
|
|
||||||
flex: 0 0 auto;
|
|
||||||
width: 30px;
|
|
||||||
height: 30px;
|
|
||||||
background: lighten($ui-base-color, 4%);
|
|
||||||
border-radius: 50%;
|
|
||||||
color: $primary-text-color;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
|
|
||||||
svg {
|
|
||||||
width: 15px;
|
|
||||||
height: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.active {
|
|
||||||
background: $valid-value-color;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&__step.active,
|
|
||||||
&__line.active {
|
|
||||||
background: $valid-value-color;
|
|
||||||
background-image: linear-gradient(
|
|
||||||
90deg,
|
|
||||||
$valid-value-color,
|
|
||||||
lighten($valid-value-color, 8%),
|
|
||||||
$valid-value-color
|
|
||||||
);
|
|
||||||
background-size: 200px 100%;
|
|
||||||
animation: skeleton 1.2s ease-in-out infinite;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.follow-recommendations {
|
.follow-recommendations {
|
||||||
background: darken($ui-base-color, 4%);
|
background: darken($ui-base-color, 4%);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
|
@ -2871,6 +2819,28 @@ $ui-header-height: 55px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.onboarding__profile {
|
||||||
|
position: relative;
|
||||||
|
margin-bottom: 40px + 20px;
|
||||||
|
|
||||||
|
.app-form__avatar-input {
|
||||||
|
border: 2px solid $ui-base-color;
|
||||||
|
position: absolute;
|
||||||
|
inset-inline-start: -2px;
|
||||||
|
bottom: -40px;
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-form__header-input {
|
||||||
|
margin: 0 -20px;
|
||||||
|
border-radius: 0;
|
||||||
|
|
||||||
|
img {
|
||||||
|
border-radius: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.compose-form__highlightable {
|
.compose-form__highlightable {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
@ -3145,6 +3115,7 @@ $ui-header-height: 55px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
background-color: transparent;
|
background-color: transparent;
|
||||||
border: 0;
|
border: 0;
|
||||||
|
border-radius: 10px;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
-webkit-tap-highlight-color: rgba($base-overlay-background, 0);
|
-webkit-tap-highlight-color: rgba($base-overlay-background, 0);
|
||||||
|
@ -3169,81 +3140,41 @@ $ui-header-height: 55px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.react-toggle-track {
|
.react-toggle-track {
|
||||||
width: 50px;
|
width: 32px;
|
||||||
height: 24px;
|
height: 20px;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
border-radius: 30px;
|
border-radius: 10px;
|
||||||
background-color: $ui-base-color;
|
background-color: #626982;
|
||||||
transition: background-color 0.2s ease;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.react-toggle:is(:hover, :focus-within):not(.react-toggle--disabled)
|
.react-toggle--focus {
|
||||||
.react-toggle-track {
|
outline: $ui-button-focus-outline;
|
||||||
background-color: darken($ui-base-color, 10%);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.react-toggle--checked .react-toggle-track {
|
.react-toggle--checked .react-toggle-track {
|
||||||
background-color: darken($ui-highlight-color, 2%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.react-toggle--checked:is(:hover, :focus-within):not(.react-toggle--disabled)
|
|
||||||
.react-toggle-track {
|
|
||||||
background-color: $ui-highlight-color;
|
background-color: $ui-highlight-color;
|
||||||
}
|
}
|
||||||
|
|
||||||
.react-toggle-track-check {
|
.react-toggle-track-check,
|
||||||
position: absolute;
|
|
||||||
width: 14px;
|
|
||||||
height: 10px;
|
|
||||||
top: 0;
|
|
||||||
bottom: 0;
|
|
||||||
margin-top: auto;
|
|
||||||
margin-bottom: auto;
|
|
||||||
line-height: 0;
|
|
||||||
inset-inline-start: 8px;
|
|
||||||
opacity: 0;
|
|
||||||
transition: opacity 0.25s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.react-toggle--checked .react-toggle-track-check {
|
|
||||||
opacity: 1;
|
|
||||||
transition: opacity 0.25s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.react-toggle-track-x {
|
.react-toggle-track-x {
|
||||||
position: absolute;
|
display: none;
|
||||||
width: 10px;
|
|
||||||
height: 10px;
|
|
||||||
top: 0;
|
|
||||||
bottom: 0;
|
|
||||||
margin-top: auto;
|
|
||||||
margin-bottom: auto;
|
|
||||||
line-height: 0;
|
|
||||||
inset-inline-end: 10px;
|
|
||||||
opacity: 1;
|
|
||||||
transition: opacity 0.25s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.react-toggle--checked .react-toggle-track-x {
|
|
||||||
opacity: 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.react-toggle-thumb {
|
.react-toggle-thumb {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 1px;
|
top: 2px;
|
||||||
inset-inline-start: 1px;
|
inset-inline-start: 2px;
|
||||||
width: 22px;
|
width: 16px;
|
||||||
height: 22px;
|
height: 16px;
|
||||||
border: 1px solid $ui-base-color;
|
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background-color: darken($simple-background-color, 2%);
|
background-color: $primary-text-color;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
transition: all 0.25s ease;
|
transition: all 0.25s ease;
|
||||||
transition-property: border-color, left;
|
transition-property: border-color, left;
|
||||||
}
|
}
|
||||||
|
|
||||||
.react-toggle--checked .react-toggle-thumb {
|
.react-toggle--checked .react-toggle-thumb {
|
||||||
inset-inline-start: 27px;
|
inset-inline-start: 32px - 16px - 2px;
|
||||||
border-color: $ui-highlight-color;
|
border-color: $ui-highlight-color;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -4066,6 +3997,17 @@ a.status-card {
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.button .loading-indicator {
|
||||||
|
position: static;
|
||||||
|
transform: none;
|
||||||
|
|
||||||
|
.circular-progress {
|
||||||
|
color: $primary-text-color;
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.circular-progress {
|
.circular-progress {
|
||||||
color: lighten($ui-base-color, 26%);
|
color: lighten($ui-base-color, 26%);
|
||||||
animation: 1.4s linear 0s infinite normal none running simple-rotate;
|
animation: 1.4s linear 0s infinite normal none running simple-rotate;
|
||||||
|
@ -5799,12 +5741,14 @@ a.status-card {
|
||||||
&__toggle {
|
&__toggle {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
margin-bottom: 10px;
|
margin-bottom: 16px;
|
||||||
|
gap: 8px;
|
||||||
|
|
||||||
& > span {
|
& > span {
|
||||||
font-size: 17px;
|
display: block;
|
||||||
|
font-size: 14px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
margin-inline-start: 10px;
|
line-height: 20px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -36,7 +36,7 @@ code {
|
||||||
}
|
}
|
||||||
|
|
||||||
.input {
|
.input {
|
||||||
margin-bottom: 15px;
|
margin-bottom: 16px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
|
||||||
&.hidden {
|
&.hidden {
|
||||||
|
@ -266,12 +266,13 @@ code {
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
color: $primary-text-color;
|
color: $primary-text-color;
|
||||||
display: block;
|
display: block;
|
||||||
font-weight: 500;
|
font-weight: 600;
|
||||||
padding-top: 5px;
|
line-height: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.hint {
|
.hint {
|
||||||
margin-bottom: 15px;
|
line-height: 16px;
|
||||||
|
margin-bottom: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
ul {
|
ul {
|
||||||
|
@ -427,7 +428,8 @@ code {
|
||||||
input[type='datetime-local'],
|
input[type='datetime-local'],
|
||||||
textarea {
|
textarea {
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
font-size: 16px;
|
font-size: 14px;
|
||||||
|
line-height: 20px;
|
||||||
color: $primary-text-color;
|
color: $primary-text-color;
|
||||||
display: block;
|
display: block;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
@ -435,9 +437,9 @@ code {
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
resize: vertical;
|
resize: vertical;
|
||||||
background: darken($ui-base-color, 10%);
|
background: darken($ui-base-color, 10%);
|
||||||
border: 1px solid darken($ui-base-color, 14%);
|
border: 1px solid darken($ui-base-color, 10%);
|
||||||
border-radius: 4px;
|
border-radius: 8px;
|
||||||
padding: 10px;
|
padding: 10px 16px;
|
||||||
|
|
||||||
&::placeholder {
|
&::placeholder {
|
||||||
color: lighten($darker-text-color, 4%);
|
color: lighten($darker-text-color, 4%);
|
||||||
|
@ -451,14 +453,13 @@ code {
|
||||||
border-color: $valid-value-color;
|
border-color: $valid-value-color;
|
||||||
}
|
}
|
||||||
|
|
||||||
&:hover {
|
|
||||||
border-color: darken($ui-base-color, 20%);
|
|
||||||
}
|
|
||||||
|
|
||||||
&:active,
|
&:active,
|
||||||
&:focus {
|
&:focus {
|
||||||
border-color: $highlight-text-color;
|
border-color: $highlight-text-color;
|
||||||
background: darken($ui-base-color, 8%);
|
}
|
||||||
|
|
||||||
|
@media screen and (width <= 600px) {
|
||||||
|
font-size: 16px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -524,12 +525,11 @@ code {
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
background: $ui-button-background-color;
|
background: $ui-button-background-color;
|
||||||
color: $ui-button-color;
|
color: $ui-button-color;
|
||||||
font-size: 18px;
|
font-size: 15px;
|
||||||
line-height: inherit;
|
line-height: 22px;
|
||||||
height: auto;
|
height: auto;
|
||||||
padding: 10px;
|
padding: 7px 18px;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
text-transform: uppercase;
|
|
||||||
text-align: center;
|
text-align: center;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
@ -1220,3 +1220,74 @@ code {
|
||||||
background: $highlight-text-color;
|
background: $highlight-text-color;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.app-form {
|
||||||
|
& > * {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__avatar-input,
|
||||||
|
&__header-input {
|
||||||
|
display: block;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--dropdown-background-color);
|
||||||
|
position: relative;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
img {
|
||||||
|
position: absolute;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
border-radius: 8px;
|
||||||
|
z-index: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon {
|
||||||
|
position: absolute;
|
||||||
|
inset-inline-start: 50%;
|
||||||
|
top: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
color: $darker-text-color;
|
||||||
|
z-index: 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.selected .icon {
|
||||||
|
color: $primary-text-color;
|
||||||
|
transform: none;
|
||||||
|
inset-inline-start: auto;
|
||||||
|
inset-inline-end: 8px;
|
||||||
|
top: auto;
|
||||||
|
bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.invalid img {
|
||||||
|
outline: 1px solid $error-value-color;
|
||||||
|
outline-offset: -1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.invalid::before {
|
||||||
|
display: block;
|
||||||
|
content: '';
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
position: absolute;
|
||||||
|
background: rgba($error-value-color, 0.25);
|
||||||
|
z-index: 2;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: var(--dropdown-border-color);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__avatar-input {
|
||||||
|
width: 80px;
|
||||||
|
height: 80px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__header-input {
|
||||||
|
aspect-ratio: 580/193;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -6,7 +6,7 @@ class REST::AccountSerializer < ActiveModel::Serializer
|
||||||
|
|
||||||
# Please update `app/javascript/mastodon/api_types/accounts.ts` when making changes to the attributes
|
# Please update `app/javascript/mastodon/api_types/accounts.ts` when making changes to the attributes
|
||||||
|
|
||||||
attributes :id, :username, :acct, :display_name, :locked, :bot, :discoverable, :group, :created_at,
|
attributes :id, :username, :acct, :display_name, :locked, :bot, :discoverable, :indexable, :group, :created_at,
|
||||||
:note, :url, :uri, :avatar, :avatar_static, :header, :header_static,
|
:note, :url, :uri, :avatar, :avatar_static, :header, :header_static,
|
||||||
:followers_count, :following_count, :statuses_count, :last_status_at, :hide_collections
|
:followers_count, :following_count, :statuses_count, :last_status_at, :hide_collections
|
||||||
|
|
||||||
|
@ -112,6 +112,10 @@ class REST::AccountSerializer < ActiveModel::Serializer
|
||||||
object.suspended? ? false : object.discoverable
|
object.suspended? ? false : object.discoverable
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def indexable
|
||||||
|
object.suspended? ? false : object.indexable
|
||||||
|
end
|
||||||
|
|
||||||
def moved_to_account
|
def moved_to_account
|
||||||
object.suspended? ? nil : AccountDecorator.new(object.moved_to_account)
|
object.suspended? ? nil : AccountDecorator.new(object.moved_to_account)
|
||||||
end
|
end
|
||||||
|
|
|
@ -31,7 +31,7 @@ Rails.application.routes.draw do
|
||||||
/favourites
|
/favourites
|
||||||
/bookmarks
|
/bookmarks
|
||||||
/pinned
|
/pinned
|
||||||
/start
|
/start/(*any)
|
||||||
/directory
|
/directory
|
||||||
/explore/(*any)
|
/explore/(*any)
|
||||||
/search
|
/search
|
||||||
|
|
Loading…
Reference in New Issue