Update design of collection accounts editor (#38712)
This commit is contained in:
parent
0ef00be494
commit
5a38246ee8
@ -35,3 +35,12 @@
|
|||||||
text-wrap: pretty;
|
text-wrap: pretty;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[data-color-scheme='dark'] .defaultImage {
|
||||||
|
--color-skin-1: #3a3a50;
|
||||||
|
--color-skin-2: #67678e;
|
||||||
|
--color-skin-3: #44445f;
|
||||||
|
--color-outline: #21212c;
|
||||||
|
--color-shadow: #181820;
|
||||||
|
--color-highlight: #b2b1c8;
|
||||||
|
}
|
||||||
|
|||||||
@ -29,12 +29,6 @@ export const Default: Story = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export const WithoutMessage: Story = {
|
|
||||||
args: {
|
|
||||||
message: undefined,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
export const WithAction: Story = {
|
export const WithAction: Story = {
|
||||||
args: {
|
args: {
|
||||||
...Default.args,
|
...Default.args,
|
||||||
@ -42,3 +36,17 @@ export const WithAction: Story = {
|
|||||||
children: <Button onClick={() => action('Refresh')}>Refresh</Button>,
|
children: <Button onClick={() => action('Refresh')}>Refresh</Button>,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const WithoutImage: Story = {
|
||||||
|
args: {
|
||||||
|
...Default.args,
|
||||||
|
image: null,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const WithoutMessage: Story = {
|
||||||
|
args: {
|
||||||
|
...Default.args,
|
||||||
|
message: undefined,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|||||||
@ -1,31 +1,39 @@
|
|||||||
import { FormattedMessage } from 'react-intl';
|
import { FormattedMessage } from 'react-intl';
|
||||||
|
|
||||||
|
import ElephantImage from '@/images/elephant_ui.svg?react';
|
||||||
|
|
||||||
import classes from './empty_state.module.scss';
|
import classes from './empty_state.module.scss';
|
||||||
|
|
||||||
|
const images = {
|
||||||
|
default: <ElephantImage className={classes.defaultImage} />,
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Simple empty state component with a neutral default title and customisable message.
|
* Simple empty state component with a neutral default title and customisable message.
|
||||||
*
|
*
|
||||||
* Action buttons can be passed as `children`
|
* Action buttons can be passed as `children`.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export const EmptyState: React.FC<{
|
export const EmptyState: React.FC<{
|
||||||
image?: React.ReactNode;
|
image?: keyof typeof images | React.ReactElement | null;
|
||||||
title?: React.ReactNode;
|
title?: React.ReactNode;
|
||||||
message?: React.ReactNode;
|
message?: React.ReactNode;
|
||||||
children?: React.ReactNode;
|
children?: React.ReactNode;
|
||||||
}> = ({
|
}> = ({
|
||||||
image,
|
image = 'default',
|
||||||
title = (
|
title = (
|
||||||
<FormattedMessage id='empty_state.no_results' defaultMessage='No results' />
|
<FormattedMessage id='empty_state.no_results' defaultMessage='No results' />
|
||||||
),
|
),
|
||||||
message,
|
message,
|
||||||
children,
|
children,
|
||||||
}) => {
|
}) => {
|
||||||
|
const imageToRender = typeof image === 'string' ? images[image] : image;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={classes.wrapper}>
|
<div className={classes.wrapper}>
|
||||||
{(title || message || image) && (
|
{(title || message || imageToRender) && (
|
||||||
<div className={classes.content}>
|
<div className={classes.content}>
|
||||||
{image}
|
{imageToRender}
|
||||||
{!!title && <h3>{title}</h3>}
|
{!!title && <h3>{title}</h3>}
|
||||||
{!!message && <p>{message}</p>}
|
{!!message && <p>{message}</p>}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,8 +0,0 @@
|
|||||||
[data-color-scheme='dark'] .image {
|
|
||||||
--color-skin-1: #3a3a50;
|
|
||||||
--color-skin-2: #67678e;
|
|
||||||
--color-skin-3: #44445f;
|
|
||||||
--color-outline: #21212c;
|
|
||||||
--color-shadow: #181820;
|
|
||||||
--color-highlight: #b2b1c8;
|
|
||||||
}
|
|
||||||
@ -5,7 +5,6 @@ import { FormattedMessage } from 'react-intl';
|
|||||||
import { useParams } from 'react-router';
|
import { useParams } from 'react-router';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
|
|
||||||
import ElephantImage from '@/images/elephant_ui.svg?react';
|
|
||||||
import { openModal } from '@/mastodon/actions/modal';
|
import { openModal } from '@/mastodon/actions/modal';
|
||||||
import { Button } from '@/mastodon/components/button';
|
import { Button } from '@/mastodon/components/button';
|
||||||
import { EmptyState } from '@/mastodon/components/empty_state';
|
import { EmptyState } from '@/mastodon/components/empty_state';
|
||||||
@ -14,8 +13,6 @@ import { areCollectionsEnabled } from '@/mastodon/features/collections/utils';
|
|||||||
import { useCurrentAccountId } from '@/mastodon/hooks/useAccountId';
|
import { useCurrentAccountId } from '@/mastodon/hooks/useAccountId';
|
||||||
import { useAppDispatch } from '@/mastodon/store';
|
import { useAppDispatch } from '@/mastodon/store';
|
||||||
|
|
||||||
import classes from './empty_message.module.scss';
|
|
||||||
|
|
||||||
interface EmptyMessageProps {
|
interface EmptyMessageProps {
|
||||||
suspended: boolean;
|
suspended: boolean;
|
||||||
hidden: boolean;
|
hidden: boolean;
|
||||||
@ -54,14 +51,11 @@ export const EmptyMessage: React.FC<EmptyMessageProps> = ({
|
|||||||
|
|
||||||
const hasCollections = areCollectionsEnabled();
|
const hasCollections = areCollectionsEnabled();
|
||||||
|
|
||||||
const image = <ElephantImage className={classes.image} />;
|
|
||||||
|
|
||||||
if (me === accountId) {
|
if (me === accountId) {
|
||||||
if (hasCollections) {
|
if (hasCollections) {
|
||||||
// Return only here to insert the "Create a collection" button as the action for the empty state.
|
// Return only here to insert the "Create a collection" button as the action for the empty state.
|
||||||
return (
|
return (
|
||||||
<EmptyState
|
<EmptyState
|
||||||
image={image}
|
|
||||||
title={
|
title={
|
||||||
<FormattedMessage
|
<FormattedMessage
|
||||||
id='empty_column.account_featured_self.showcase_accounts'
|
id='empty_column.account_featured_self.showcase_accounts'
|
||||||
@ -140,5 +134,5 @@ export const EmptyMessage: React.FC<EmptyMessageProps> = ({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return <EmptyState title={title} message={message} image={image} />;
|
return <EmptyState title={title} message={message} />;
|
||||||
};
|
};
|
||||||
|
|||||||
@ -4,22 +4,16 @@ import { FormattedMessage, useIntl } from 'react-intl';
|
|||||||
|
|
||||||
import { useHistory } from 'react-router-dom';
|
import { useHistory } from 'react-router-dom';
|
||||||
|
|
||||||
import CancelIcon from '@/material-icons/400-24px/cancel.svg?react';
|
|
||||||
import CheckIcon from '@/material-icons/400-24px/check.svg?react';
|
|
||||||
import WarningIcon from '@/material-icons/400-24px/warning.svg?react';
|
|
||||||
import { showAlertForError } from 'mastodon/actions/alerts';
|
import { showAlertForError } from 'mastodon/actions/alerts';
|
||||||
import { openModal } from 'mastodon/actions/modal';
|
import { openModal } from 'mastodon/actions/modal';
|
||||||
import { apiFollowAccount } from 'mastodon/api/accounts';
|
import { apiFollowAccount } from 'mastodon/api/accounts';
|
||||||
import type { ApiCollectionJSON } from 'mastodon/api_types/collections';
|
import type { ApiCollectionJSON } from 'mastodon/api_types/collections';
|
||||||
import { Account } from 'mastodon/components/account';
|
import { Account } from 'mastodon/components/account';
|
||||||
import { Avatar } from 'mastodon/components/avatar';
|
import { Avatar } from 'mastodon/components/avatar';
|
||||||
import { Badge } from 'mastodon/components/badge';
|
|
||||||
import { Button } from 'mastodon/components/button';
|
import { Button } from 'mastodon/components/button';
|
||||||
import { DisplayName } from 'mastodon/components/display_name';
|
import { DisplayName } from 'mastodon/components/display_name';
|
||||||
import { EmptyState } from 'mastodon/components/empty_state';
|
import { EmptyState } from 'mastodon/components/empty_state';
|
||||||
import { FormStack, Combobox } from 'mastodon/components/form_fields';
|
import { FormStack, ComboboxField } from 'mastodon/components/form_fields';
|
||||||
import { Icon } from 'mastodon/components/icon';
|
|
||||||
import { IconButton } from 'mastodon/components/icon_button';
|
|
||||||
import {
|
import {
|
||||||
Article,
|
Article,
|
||||||
ItemList,
|
ItemList,
|
||||||
@ -37,75 +31,35 @@ import {
|
|||||||
import { store, useAppDispatch, useAppSelector } from 'mastodon/store';
|
import { store, useAppDispatch, useAppSelector } from 'mastodon/store';
|
||||||
|
|
||||||
import classes from './styles.module.scss';
|
import classes from './styles.module.scss';
|
||||||
import { WizardStepHeader } from './wizard_step_header';
|
import { WizardStepTitle } from './wizard_step_title';
|
||||||
|
|
||||||
const MAX_ACCOUNT_COUNT = 25;
|
const MAX_ACCOUNT_COUNT = 3;
|
||||||
|
|
||||||
function isOlderThanAWeek(date?: string): boolean {
|
|
||||||
if (!date) return false;
|
|
||||||
|
|
||||||
const targetDate = new Date(date);
|
|
||||||
const sevenDaysAgo = new Date();
|
|
||||||
sevenDaysAgo.setDate(sevenDaysAgo.getDate() - 7);
|
|
||||||
return targetDate < sevenDaysAgo;
|
|
||||||
}
|
|
||||||
|
|
||||||
const AddedAccountItem: React.FC<{
|
const AddedAccountItem: React.FC<{
|
||||||
accountId: string;
|
accountId: string;
|
||||||
onRemove: (id: string) => void;
|
onRemove: (id: string) => void;
|
||||||
}> = ({ accountId, onRemove }) => {
|
}> = ({ accountId, onRemove }) => {
|
||||||
const intl = useIntl();
|
|
||||||
const account = useAccount(accountId);
|
|
||||||
|
|
||||||
const handleRemoveAccount = useCallback(() => {
|
const handleRemoveAccount = useCallback(() => {
|
||||||
onRemove(accountId);
|
onRemove(accountId);
|
||||||
}, [accountId, onRemove]);
|
}, [accountId, onRemove]);
|
||||||
|
|
||||||
const lastStatusAt = account?.last_status_at;
|
|
||||||
|
|
||||||
const lastPostHint = useMemo(
|
|
||||||
() =>
|
|
||||||
(!lastStatusAt || isOlderThanAWeek(lastStatusAt)) && (
|
|
||||||
<Badge
|
|
||||||
label={
|
|
||||||
<FormattedMessage
|
|
||||||
id='collections.old_last_post_note'
|
|
||||||
defaultMessage='Last posted over a week ago'
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
icon={<WarningIcon />}
|
|
||||||
className={classes.accountBadge}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
[lastStatusAt],
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Account
|
<Account minimal key={accountId} id={accountId}>
|
||||||
minimal
|
<Button compact secondary onClick={handleRemoveAccount}>
|
||||||
key={accountId}
|
<FormattedMessage
|
||||||
id={accountId}
|
id='collections.remove_account'
|
||||||
extraAccountInfo={lastPostHint}
|
defaultMessage='Remove'
|
||||||
>
|
/>
|
||||||
<IconButton
|
</Button>
|
||||||
title={intl.formatMessage({
|
|
||||||
id: 'collections.remove_account',
|
|
||||||
defaultMessage: 'Remove this account',
|
|
||||||
})}
|
|
||||||
icon='remove'
|
|
||||||
iconComponent={CancelIcon}
|
|
||||||
onClick={handleRemoveAccount}
|
|
||||||
/>
|
|
||||||
</Account>
|
</Account>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
interface SuggestionItem {
|
interface SuggestionItem {
|
||||||
id: string;
|
id: string;
|
||||||
isSelected: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const SuggestedAccountItem: React.FC<SuggestionItem> = ({ id, isSelected }) => {
|
const SuggestedAccountItem: React.FC<SuggestionItem> = ({ id }) => {
|
||||||
const account = useAccount(id);
|
const account = useAccount(id);
|
||||||
|
|
||||||
if (!account) return null;
|
if (!account) return null;
|
||||||
@ -114,23 +68,15 @@ const SuggestedAccountItem: React.FC<SuggestionItem> = ({ id, isSelected }) => {
|
|||||||
<>
|
<>
|
||||||
<Avatar account={account} />
|
<Avatar account={account} />
|
||||||
<DisplayName account={account} />
|
<DisplayName account={account} />
|
||||||
{isSelected && (
|
|
||||||
<Icon
|
|
||||||
id='checked'
|
|
||||||
icon={CheckIcon}
|
|
||||||
className={classes.selectedSuggestionIcon}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const renderAccountItem = (item: SuggestionItem) => (
|
const renderAccountItem = (item: SuggestionItem) => (
|
||||||
<SuggestedAccountItem id={item.id} isSelected={item.isSelected} />
|
<SuggestedAccountItem id={item.id} />
|
||||||
);
|
);
|
||||||
|
|
||||||
const getItemId = (item: SuggestionItem) => item.id;
|
const getItemId = (item: SuggestionItem) => item.id;
|
||||||
const getIsItemSelected = (item: SuggestionItem) => item.isSelected;
|
|
||||||
|
|
||||||
export const CollectionAccounts: React.FC<{
|
export const CollectionAccounts: React.FC<{
|
||||||
collection?: ApiCollectionJSON | null;
|
collection?: ApiCollectionJSON | null;
|
||||||
@ -139,9 +85,8 @@ export const CollectionAccounts: React.FC<{
|
|||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
const history = useHistory();
|
const history = useHistory();
|
||||||
|
|
||||||
const { id, items } = collection ?? {};
|
const { id, items: collectionItems } = collection ?? {};
|
||||||
const isEditMode = !!id;
|
const isEditMode = !!id;
|
||||||
const collectionItems = items;
|
|
||||||
|
|
||||||
const addedAccountIds = useAppSelector(
|
const addedAccountIds = useAppSelector(
|
||||||
(state) => state.collections.editor.accountIds,
|
(state) => state.collections.editor.accountIds,
|
||||||
@ -157,22 +102,25 @@ export const CollectionAccounts: React.FC<{
|
|||||||
|
|
||||||
const [searchValue, setSearchValue] = useState('');
|
const [searchValue, setSearchValue] = useState('');
|
||||||
|
|
||||||
|
const hasAccounts = accountIds.length > 0;
|
||||||
const hasMaxAccounts = accountIds.length === MAX_ACCOUNT_COUNT;
|
const hasMaxAccounts = accountIds.length === MAX_ACCOUNT_COUNT;
|
||||||
|
|
||||||
const {
|
const {
|
||||||
accountIds: suggestedAccountIds,
|
accountIds: suggestedAccountIds,
|
||||||
isLoading: isLoadingSuggestions,
|
isLoading: isLoadingSuggestions,
|
||||||
searchAccounts,
|
searchAccounts,
|
||||||
|
resetAccounts,
|
||||||
} = useSearchAccounts({
|
} = useSearchAccounts({
|
||||||
withRelationships: true,
|
withRelationships: true,
|
||||||
filterResults: (account) =>
|
filterResults: (account) =>
|
||||||
|
!accountIds.includes(account.id) &&
|
||||||
// Only suggest accounts who allow being featured/recommended
|
// Only suggest accounts who allow being featured/recommended
|
||||||
account.feature_approval.current_user === 'automatic',
|
account.feature_approval.current_user === 'automatic',
|
||||||
});
|
});
|
||||||
|
|
||||||
const suggestedItems = suggestedAccountIds.map((id) => ({
|
const suggestedItems = suggestedAccountIds.map((id) => ({
|
||||||
id,
|
id,
|
||||||
isSelected: accountIds.includes(id),
|
isDisabled: accountIds.includes(id),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const handleSearchValueChange = useCallback(
|
const handleSearchValueChange = useCallback(
|
||||||
@ -242,12 +190,12 @@ export const CollectionAccounts: React.FC<{
|
|||||||
);
|
);
|
||||||
|
|
||||||
const addAccountItem = useCallback(
|
const addAccountItem = useCallback(
|
||||||
(accountId: string) => {
|
(item: SuggestionItem) => {
|
||||||
confirmFollowStatus(accountId, () => {
|
confirmFollowStatus(item.id, () => {
|
||||||
dispatch(
|
dispatch(
|
||||||
updateCollectionEditorField({
|
updateCollectionEditorField({
|
||||||
field: 'accountIds',
|
field: 'accountIds',
|
||||||
value: [...accountIds, accountId],
|
value: [...accountIds, item.id],
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@ -255,17 +203,6 @@ export const CollectionAccounts: React.FC<{
|
|||||||
[accountIds, confirmFollowStatus, dispatch],
|
[accountIds, confirmFollowStatus, dispatch],
|
||||||
);
|
);
|
||||||
|
|
||||||
const toggleAccountItem = useCallback(
|
|
||||||
(item: SuggestionItem) => {
|
|
||||||
if (accountIds.includes(item.id)) {
|
|
||||||
removeAccountItem(item.id);
|
|
||||||
} else {
|
|
||||||
addAccountItem(item.id);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[accountIds, addAccountItem, removeAccountItem],
|
|
||||||
);
|
|
||||||
|
|
||||||
const instantRemoveAccountItem = useCallback(
|
const instantRemoveAccountItem = useCallback(
|
||||||
(accountId: string) => {
|
(accountId: string) => {
|
||||||
const itemId = collectionItems?.find(
|
const itemId = collectionItems?.find(
|
||||||
@ -289,23 +226,16 @@ export const CollectionAccounts: React.FC<{
|
|||||||
);
|
);
|
||||||
|
|
||||||
const instantAddAccountItem = useCallback(
|
const instantAddAccountItem = useCallback(
|
||||||
(collectionId: string, accountId: string) => {
|
(item: SuggestionItem) => {
|
||||||
confirmFollowStatus(accountId, () => {
|
confirmFollowStatus(item.id, () => {
|
||||||
void dispatch(addCollectionItem({ collectionId, accountId }));
|
if (id) {
|
||||||
|
void dispatch(
|
||||||
|
addCollectionItem({ collectionId: id, accountId: item.id }),
|
||||||
|
);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[confirmFollowStatus, dispatch],
|
[confirmFollowStatus, dispatch, id],
|
||||||
);
|
|
||||||
|
|
||||||
const instantToggleAccountItem = useCallback(
|
|
||||||
(item: SuggestionItem) => {
|
|
||||||
if (accountIds.includes(item.id)) {
|
|
||||||
instantRemoveAccountItem(item.id);
|
|
||||||
} else if (id) {
|
|
||||||
instantAddAccountItem(id, item.id);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[accountIds, id, instantAddAccountItem, instantRemoveAccountItem],
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleRemoveAccountItem = useCallback(
|
const handleRemoveAccountItem = useCallback(
|
||||||
@ -319,6 +249,20 @@ export const CollectionAccounts: React.FC<{
|
|||||||
[isEditMode, instantRemoveAccountItem, removeAccountItem],
|
[isEditMode, instantRemoveAccountItem, removeAccountItem],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const handleSelectItem = useCallback(
|
||||||
|
(item: SuggestionItem) => {
|
||||||
|
if (isEditMode) {
|
||||||
|
instantAddAccountItem(item);
|
||||||
|
} else {
|
||||||
|
addAccountItem(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
setSearchValue('');
|
||||||
|
resetAccounts();
|
||||||
|
},
|
||||||
|
[addAccountItem, instantAddAccountItem, isEditMode, resetAccounts],
|
||||||
|
);
|
||||||
|
|
||||||
const handleSubmit = useCallback(
|
const handleSubmit = useCallback(
|
||||||
(e: React.FormEvent) => {
|
(e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@ -333,117 +277,114 @@ export const CollectionAccounts: React.FC<{
|
|||||||
);
|
);
|
||||||
|
|
||||||
const inputId = useId();
|
const inputId = useId();
|
||||||
const inputLabel = intl.formatMessage({
|
const AccountsHeadingElement = id ? 'h2' : 'h3';
|
||||||
id: 'collections.search_accounts_label',
|
|
||||||
defaultMessage: 'Search for accounts to add…',
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form onSubmit={handleSubmit} className={classes.form}>
|
<form onSubmit={handleSubmit} className={classes.form}>
|
||||||
<FormStack className={classes.formFieldStack}>
|
<FormStack className={classes.formFieldStack}>
|
||||||
{!id && (
|
<header className={classes.header}>
|
||||||
<WizardStepHeader
|
{!id && (
|
||||||
step={1}
|
<WizardStepTitle
|
||||||
title={
|
step={1}
|
||||||
<FormattedMessage
|
title={
|
||||||
id='collections.create.accounts_title'
|
|
||||||
defaultMessage='Who will you feature in this collection?'
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
description={
|
|
||||||
<FormattedMessage
|
|
||||||
id='collections.create.accounts_subtitle'
|
|
||||||
defaultMessage='Only accounts you follow who have opted into discovery can be added.'
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<label htmlFor={inputId} className='sr-only'>
|
|
||||||
{inputLabel}
|
|
||||||
</label>
|
|
||||||
<Combobox
|
|
||||||
id={inputId}
|
|
||||||
placeholder={inputLabel}
|
|
||||||
value={hasMaxAccounts ? '' : searchValue}
|
|
||||||
onChange={handleSearchValueChange}
|
|
||||||
onKeyDown={handleSearchKeyDown}
|
|
||||||
disabled={hasMaxAccounts}
|
|
||||||
isLoading={isLoadingSuggestions}
|
|
||||||
items={suggestedItems}
|
|
||||||
getItemId={getItemId}
|
|
||||||
getIsItemSelected={getIsItemSelected}
|
|
||||||
renderItem={renderAccountItem}
|
|
||||||
onSelectItem={
|
|
||||||
isEditMode ? instantToggleAccountItem : toggleAccountItem
|
|
||||||
}
|
|
||||||
closeOnSelect={false}
|
|
||||||
/>
|
|
||||||
{hasMaxAccounts && (
|
|
||||||
<FormattedMessage
|
|
||||||
id='collections.search_accounts_max_reached'
|
|
||||||
defaultMessage='You have added the maximum number of accounts'
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Scrollable className={classes.scrollableWrapper}>
|
|
||||||
<ItemList
|
|
||||||
className={classes.scrollableInner}
|
|
||||||
emptyMessage={
|
|
||||||
<EmptyState
|
|
||||||
title={
|
|
||||||
<FormattedMessage
|
|
||||||
id='collections.accounts.empty_title'
|
|
||||||
defaultMessage='This collection is empty'
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
message={
|
|
||||||
<FormattedMessage
|
|
||||||
id='collections.accounts.empty_description'
|
|
||||||
defaultMessage='Add up to {count} accounts you follow'
|
|
||||||
values={{
|
|
||||||
count: MAX_ACCOUNT_COUNT,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{accountIds.map((accountId, index) => (
|
|
||||||
<Article
|
|
||||||
key={accountId}
|
|
||||||
aria-posinset={index}
|
|
||||||
aria-setsize={accountIds.length}
|
|
||||||
>
|
|
||||||
<AddedAccountItem
|
|
||||||
accountId={accountId}
|
|
||||||
onRemove={handleRemoveAccountItem}
|
|
||||||
/>
|
|
||||||
</Article>
|
|
||||||
))}
|
|
||||||
</ItemList>
|
|
||||||
</Scrollable>
|
|
||||||
</FormStack>
|
|
||||||
{!isEditMode && (
|
|
||||||
<div className={classes.stickyFooter}>
|
|
||||||
<div className={classes.actionWrapper}>
|
|
||||||
<FormattedMessage
|
|
||||||
id='collections.hints.accounts_counter'
|
|
||||||
defaultMessage='{count} / {max} accounts'
|
|
||||||
values={{ count: accountIds.length, max: MAX_ACCOUNT_COUNT }}
|
|
||||||
>
|
|
||||||
{(text) => <div className={classes.itemCountReadout}>{text}</div>}
|
|
||||||
</FormattedMessage>
|
|
||||||
<Button type='submit'>
|
|
||||||
{id ? (
|
|
||||||
<FormattedMessage id='lists.save' defaultMessage='Save' />
|
|
||||||
) : (
|
|
||||||
<FormattedMessage
|
<FormattedMessage
|
||||||
id='collections.continue'
|
id='collections.create.accounts_title'
|
||||||
defaultMessage='Continue'
|
defaultMessage='Who will you feature in this collection?'
|
||||||
/>
|
/>
|
||||||
)}
|
}
|
||||||
</Button>
|
/>
|
||||||
</div>
|
)}
|
||||||
|
<ComboboxField
|
||||||
|
id={inputId}
|
||||||
|
label={intl.formatMessage({
|
||||||
|
id: 'collections.search_accounts_label',
|
||||||
|
defaultMessage: 'Search for an account to add',
|
||||||
|
})}
|
||||||
|
value={hasMaxAccounts ? '' : searchValue}
|
||||||
|
onChange={handleSearchValueChange}
|
||||||
|
onKeyDown={handleSearchKeyDown}
|
||||||
|
disabled={hasMaxAccounts}
|
||||||
|
isLoading={isLoadingSuggestions}
|
||||||
|
items={suggestedItems}
|
||||||
|
getItemId={getItemId}
|
||||||
|
renderItem={renderAccountItem}
|
||||||
|
onSelectItem={handleSelectItem}
|
||||||
|
status={
|
||||||
|
hasMaxAccounts
|
||||||
|
? {
|
||||||
|
variant: 'warning',
|
||||||
|
message: intl.formatMessage({
|
||||||
|
id: 'collections.search_accounts_max_reached',
|
||||||
|
defaultMessage:
|
||||||
|
'You have added the maximum number of accounts',
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
: null
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
{hasAccounts && (
|
||||||
|
<AccountsHeadingElement className={classes.listHeading}>
|
||||||
|
<FormattedMessage
|
||||||
|
id='collections.hints.accounts_counter'
|
||||||
|
defaultMessage='{count}/{max} accounts'
|
||||||
|
values={{ count: accountIds.length, max: MAX_ACCOUNT_COUNT }}
|
||||||
|
/>
|
||||||
|
</AccountsHeadingElement>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Scrollable className={classes.scrollableWrapper}>
|
||||||
|
<ItemList
|
||||||
|
emptyMessage={
|
||||||
|
<EmptyState
|
||||||
|
title={
|
||||||
|
<FormattedMessage
|
||||||
|
id='collections.accounts.empty_editor_title'
|
||||||
|
defaultMessage='No one is in this collection yet'
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
message={
|
||||||
|
<FormattedMessage
|
||||||
|
id='collections.accounts.empty_description'
|
||||||
|
defaultMessage='Add up to {count} accounts'
|
||||||
|
values={{
|
||||||
|
count: MAX_ACCOUNT_COUNT,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{accountIds.map((accountId, index) => (
|
||||||
|
<Article
|
||||||
|
key={accountId}
|
||||||
|
aria-posinset={index}
|
||||||
|
aria-setsize={accountIds.length}
|
||||||
|
>
|
||||||
|
<AddedAccountItem
|
||||||
|
accountId={accountId}
|
||||||
|
onRemove={handleRemoveAccountItem}
|
||||||
|
/>
|
||||||
|
</Article>
|
||||||
|
))}
|
||||||
|
</ItemList>
|
||||||
|
</Scrollable>
|
||||||
|
</div>
|
||||||
|
</FormStack>
|
||||||
|
{!isEditMode && hasAccounts && (
|
||||||
|
<div className={classes.stickyFooter}>
|
||||||
|
<Button type='submit'>
|
||||||
|
{id ? (
|
||||||
|
<FormattedMessage id='lists.save' defaultMessage='Save' />
|
||||||
|
) : (
|
||||||
|
<FormattedMessage
|
||||||
|
id='collections.continue'
|
||||||
|
defaultMessage='Continue'
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@ -36,7 +36,7 @@ import {
|
|||||||
import { useAppDispatch, useAppSelector } from 'mastodon/store';
|
import { useAppDispatch, useAppSelector } from 'mastodon/store';
|
||||||
|
|
||||||
import classes from './styles.module.scss';
|
import classes from './styles.module.scss';
|
||||||
import { WizardStepHeader } from './wizard_step_header';
|
import { WizardStepTitle } from './wizard_step_title';
|
||||||
|
|
||||||
export const CollectionDetails: React.FC = () => {
|
export const CollectionDetails: React.FC = () => {
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
@ -152,7 +152,7 @@ export const CollectionDetails: React.FC = () => {
|
|||||||
<form onSubmit={handleSubmit} className={classes.form}>
|
<form onSubmit={handleSubmit} className={classes.form}>
|
||||||
<FormStack className={classes.formFieldStack}>
|
<FormStack className={classes.formFieldStack}>
|
||||||
{!id && (
|
{!id && (
|
||||||
<WizardStepHeader
|
<WizardStepTitle
|
||||||
step={2}
|
step={2}
|
||||||
title={
|
title={
|
||||||
<FormattedMessage
|
<FormattedMessage
|
||||||
|
|||||||
@ -44,7 +44,7 @@ export const messages = defineMessages({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
function usePageTitle(id: string | undefined) {
|
function usePageTitle(id: string | null) {
|
||||||
const { path } = useRouteMatch();
|
const { path } = useRouteMatch();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
|
||||||
@ -66,7 +66,7 @@ export const CollectionEditorPage: React.FC<{
|
|||||||
}> = ({ multiColumn }) => {
|
}> = ({ multiColumn }) => {
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
const { id } = useParams<{ id?: string }>();
|
const { id = null } = useParams<{ id?: string }>();
|
||||||
const { path } = useRouteMatch();
|
const { path } = useRouteMatch();
|
||||||
const collection = useAppSelector((state) =>
|
const collection = useAppSelector((state) =>
|
||||||
id ? state.collections.collections[id] : undefined,
|
id ? state.collections.collections[id] : undefined,
|
||||||
|
|||||||
@ -1,10 +1,17 @@
|
|||||||
|
.header {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
.step {
|
.step {
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
color: var(--color-text-secondary);
|
color: var(--color-text-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.title {
|
.title {
|
||||||
font-size: 22px;
|
font-size: 17px;
|
||||||
|
font-weight: 500;
|
||||||
line-height: 1.2;
|
line-height: 1.2;
|
||||||
margin-top: 4px;
|
margin-top: 4px;
|
||||||
}
|
}
|
||||||
@ -14,79 +21,40 @@
|
|||||||
margin-top: 8px;
|
margin-top: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Make form stretch full height of the column */
|
.listHeading {
|
||||||
|
margin-bottom: 8px;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 500;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Ensure sticky footer isn't covered by mobile bottom nav */
|
||||||
.form {
|
.form {
|
||||||
--bottom-spacing: 0;
|
--bottom-spacing: 0;
|
||||||
|
|
||||||
box-sizing: border-box;
|
padding-bottom: 0;
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
min-height: 100%;
|
|
||||||
padding-bottom: var(--bottom-spacing);
|
|
||||||
|
|
||||||
@media (width < 760px) {
|
@media (width < 760px) {
|
||||||
--bottom-spacing: var(--mobile-bottom-nav-height);
|
--bottom-spacing: var(--mobile-bottom-nav-height);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.selectedSuggestionIcon {
|
|
||||||
box-sizing: border-box;
|
|
||||||
width: 18px;
|
|
||||||
height: 18px;
|
|
||||||
margin-left: auto;
|
|
||||||
padding: 2px;
|
|
||||||
border-radius: 100%;
|
|
||||||
color: var(--color-text-on-brand-base);
|
|
||||||
background: var(--color-bg-brand-base);
|
|
||||||
|
|
||||||
[data-highlighted='true'] & {
|
|
||||||
color: var(--color-bg-brand-base);
|
|
||||||
background: var(--color-text-on-brand-base);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.formFieldStack {
|
|
||||||
flex-grow: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.scrollableWrapper,
|
|
||||||
.scrollableInner {
|
|
||||||
margin-inline: -8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.submitDisabledCallout {
|
|
||||||
align-self: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stickyFooter {
|
.stickyFooter {
|
||||||
position: sticky;
|
position: sticky;
|
||||||
bottom: var(--bottom-spacing);
|
bottom: var(--bottom-spacing);
|
||||||
|
margin-top: -16px;
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
background-image: linear-gradient(
|
background-image: linear-gradient(
|
||||||
to bottom,
|
to bottom,
|
||||||
transparent,
|
transparent,
|
||||||
var(--color-bg-primary) 32px
|
var(--color-bg-primary) 24px
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
.itemCountReadout {
|
.formFieldStack {
|
||||||
text-align: center;
|
gap: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.actionWrapper {
|
.scrollableWrapper {
|
||||||
display: flex;
|
margin-inline: -16px;
|
||||||
flex-direction: column;
|
|
||||||
width: min-content;
|
|
||||||
min-width: 240px;
|
|
||||||
margin-inline: auto;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.accountBadge {
|
|
||||||
margin-inline-start: 56px;
|
|
||||||
|
|
||||||
@container (width < 360px) {
|
|
||||||
margin-top: 4px;
|
|
||||||
margin-inline-start: 46px;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,23 +0,0 @@
|
|||||||
import { FormattedMessage } from 'react-intl';
|
|
||||||
|
|
||||||
import classes from './styles.module.scss';
|
|
||||||
|
|
||||||
export const WizardStepHeader: React.FC<{
|
|
||||||
step: number;
|
|
||||||
title: React.ReactElement;
|
|
||||||
description?: React.ReactElement;
|
|
||||||
}> = ({ step, title, description }) => {
|
|
||||||
return (
|
|
||||||
<header>
|
|
||||||
<FormattedMessage
|
|
||||||
id='collections.create.steps'
|
|
||||||
defaultMessage='Step {step}/{total}'
|
|
||||||
values={{ step, total: 2 }}
|
|
||||||
>
|
|
||||||
{(content) => <p className={classes.step}>{content}</p>}
|
|
||||||
</FormattedMessage>
|
|
||||||
<h2 className={classes.title}>{title}</h2>
|
|
||||||
{!!description && <p className={classes.description}>{description}</p>}
|
|
||||||
</header>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@ -0,0 +1,21 @@
|
|||||||
|
import { FormattedMessage } from 'react-intl';
|
||||||
|
|
||||||
|
import classes from './styles.module.scss';
|
||||||
|
|
||||||
|
export const WizardStepTitle: React.FC<{
|
||||||
|
step: number;
|
||||||
|
title: React.ReactElement;
|
||||||
|
}> = ({ step, title }) => {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<p className={classes.step}>
|
||||||
|
<FormattedMessage
|
||||||
|
id='collections.create.steps'
|
||||||
|
defaultMessage='Step {step}/{total}'
|
||||||
|
values={{ step, total: 2 }}
|
||||||
|
/>
|
||||||
|
</p>
|
||||||
|
<h2 className={classes.title}>{title}</h2>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -1,4 +1,4 @@
|
|||||||
import { useRef, useState } from 'react';
|
import { useCallback, useRef, useState } from 'react';
|
||||||
|
|
||||||
import { useDebouncedCallback } from 'use-debounce';
|
import { useDebouncedCallback } from 'use-debounce';
|
||||||
|
|
||||||
@ -73,8 +73,13 @@ export function useSearchAccounts({
|
|||||||
{ leading: true, trailing: true },
|
{ leading: true, trailing: true },
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const resetAccounts = useCallback(() => {
|
||||||
|
setAccountIds([]);
|
||||||
|
}, []);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
searchAccounts,
|
searchAccounts,
|
||||||
|
resetAccounts,
|
||||||
accountIds,
|
accountIds,
|
||||||
isLoading: loadingState === 'loading',
|
isLoading: loadingState === 'loading',
|
||||||
isError: loadingState === 'error',
|
isError: loadingState === 'error',
|
||||||
|
|||||||
@ -359,7 +359,8 @@
|
|||||||
"collection.share_template_other": "Check out this cool collection: {link}",
|
"collection.share_template_other": "Check out this cool collection: {link}",
|
||||||
"collection.share_template_own": "Check out my new collection: {link}",
|
"collection.share_template_own": "Check out my new collection: {link}",
|
||||||
"collections.account_count": "{count, plural, one {# account} other {# accounts}}",
|
"collections.account_count": "{count, plural, one {# account} other {# accounts}}",
|
||||||
"collections.accounts.empty_description": "Add up to {count} accounts you follow",
|
"collections.accounts.empty_description": "Add up to {count} accounts",
|
||||||
|
"collections.accounts.empty_editor_title": "No one is in this collection yet",
|
||||||
"collections.accounts.empty_title": "This collection is empty",
|
"collections.accounts.empty_title": "This collection is empty",
|
||||||
"collections.block_collection_owner": "Block account",
|
"collections.block_collection_owner": "Block account",
|
||||||
"collections.by_account": "by {account_handle}",
|
"collections.by_account": "by {account_handle}",
|
||||||
@ -373,7 +374,6 @@
|
|||||||
"collections.continue": "Continue",
|
"collections.continue": "Continue",
|
||||||
"collections.copy_link": "Copy link",
|
"collections.copy_link": "Copy link",
|
||||||
"collections.copy_link_confirmation": "Copied collection link to clipboard",
|
"collections.copy_link_confirmation": "Copied collection link to clipboard",
|
||||||
"collections.create.accounts_subtitle": "Only accounts you follow who have opted into discovery can be added.",
|
|
||||||
"collections.create.accounts_title": "Who will you feature in this collection?",
|
"collections.create.accounts_title": "Who will you feature in this collection?",
|
||||||
"collections.create.basic_details_title": "Basic details",
|
"collections.create.basic_details_title": "Basic details",
|
||||||
"collections.create.steps": "Step {step}/{total}",
|
"collections.create.steps": "Step {step}/{total}",
|
||||||
@ -393,7 +393,7 @@
|
|||||||
"collections.error_loading_collections": "There was an error when trying to load your collections.",
|
"collections.error_loading_collections": "There was an error when trying to load your collections.",
|
||||||
"collections.hidden_accounts_description": "You’ve blocked or muted {count, plural, one {this user} other {these users}}",
|
"collections.hidden_accounts_description": "You’ve blocked or muted {count, plural, one {this user} other {these users}}",
|
||||||
"collections.hidden_accounts_link": "{count, plural, one {# hidden account} other {# hidden accounts}}",
|
"collections.hidden_accounts_link": "{count, plural, one {# hidden account} other {# hidden accounts}}",
|
||||||
"collections.hints.accounts_counter": "{count} / {max} accounts",
|
"collections.hints.accounts_counter": "{count}/{max} accounts",
|
||||||
"collections.last_updated_at": "Last updated: {date}",
|
"collections.last_updated_at": "Last updated: {date}",
|
||||||
"collections.manage_accounts": "Manage accounts",
|
"collections.manage_accounts": "Manage accounts",
|
||||||
"collections.mark_as_sensitive": "Mark as sensitive",
|
"collections.mark_as_sensitive": "Mark as sensitive",
|
||||||
@ -401,13 +401,12 @@
|
|||||||
"collections.name_length_hint": "40 characters limit",
|
"collections.name_length_hint": "40 characters limit",
|
||||||
"collections.new_collection": "New collection",
|
"collections.new_collection": "New collection",
|
||||||
"collections.no_collections_yet": "No collections yet.",
|
"collections.no_collections_yet": "No collections yet.",
|
||||||
"collections.old_last_post_note": "Last posted over a week ago",
|
"collections.remove_account": "Remove",
|
||||||
"collections.remove_account": "Remove this account",
|
|
||||||
"collections.report_collection": "Report this collection",
|
"collections.report_collection": "Report this collection",
|
||||||
"collections.revoke_collection_inclusion": "Remove myself from this collection",
|
"collections.revoke_collection_inclusion": "Remove myself from this collection",
|
||||||
"collections.revoke_inclusion.confirmation": "You've been removed from \"{collection}\"",
|
"collections.revoke_inclusion.confirmation": "You've been removed from \"{collection}\"",
|
||||||
"collections.revoke_inclusion.error": "There was an error, please try again later.",
|
"collections.revoke_inclusion.error": "There was an error, please try again later.",
|
||||||
"collections.search_accounts_label": "Search for accounts to add…",
|
"collections.search_accounts_label": "Search for an account to add",
|
||||||
"collections.search_accounts_max_reached": "You have added the maximum number of accounts",
|
"collections.search_accounts_max_reached": "You have added the maximum number of accounts",
|
||||||
"collections.sensitive": "Sensitive",
|
"collections.sensitive": "Sensitive",
|
||||||
"collections.share_short": "Share",
|
"collections.share_short": "Share",
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user