Allow users to change how accounts are sorted when viewing a collection (#39073)
This commit is contained in:
parent
07a05e1edf
commit
c26003af21
@ -48,10 +48,14 @@
|
|||||||
|
|
||||||
.status {
|
.status {
|
||||||
// If there's no content, we need to compensate for the parent's
|
// If there's no content, we need to compensate for the parent's
|
||||||
// flex gap to avoid extra spacing below the field.
|
// flex gap to avoid extra spacing below or next to the field.
|
||||||
&:empty {
|
&:empty {
|
||||||
margin-top: calc(-1 * var(--form-field-label-gap));
|
margin-top: calc(-1 * var(--form-field-label-gap));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[data-input-placement^='inline'] &:empty {
|
||||||
|
margin-inline-start: calc(-1 * var(--form-field-label-gap));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.inputWrapper {
|
.inputWrapper {
|
||||||
|
|||||||
@ -24,7 +24,7 @@ export interface FieldStatus {
|
|||||||
message?: string;
|
message?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface FieldWrapperProps {
|
export interface FieldWrapperProps {
|
||||||
label: ReactNode;
|
label: ReactNode;
|
||||||
hint?: ReactNode;
|
hint?: ReactNode;
|
||||||
required?: boolean;
|
required?: boolean;
|
||||||
|
|||||||
@ -4,11 +4,17 @@ import { forwardRef } from 'react';
|
|||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
|
|
||||||
import { FormFieldWrapper } from './form_field_wrapper';
|
import { FormFieldWrapper } from './form_field_wrapper';
|
||||||
import type { CommonFieldWrapperProps } from './form_field_wrapper';
|
import type {
|
||||||
|
CommonFieldWrapperProps,
|
||||||
|
FieldWrapperProps,
|
||||||
|
} from './form_field_wrapper';
|
||||||
import classes from './select.module.scss';
|
import classes from './select.module.scss';
|
||||||
|
|
||||||
interface Props
|
interface Props
|
||||||
extends ComponentPropsWithoutRef<'select'>, CommonFieldWrapperProps {}
|
extends
|
||||||
|
ComponentPropsWithoutRef<'select'>,
|
||||||
|
CommonFieldWrapperProps,
|
||||||
|
Pick<FieldWrapperProps, 'inputPlacement'> {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A simple form field for single-item selections.
|
* A simple form field for single-item selections.
|
||||||
@ -19,13 +25,28 @@ interface Props
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
export const SelectField = forwardRef<HTMLSelectElement, Props>(
|
export const SelectField = forwardRef<HTMLSelectElement, Props>(
|
||||||
({ id, label, hint, required, status, children, ...otherProps }, ref) => (
|
(
|
||||||
|
{
|
||||||
|
id,
|
||||||
|
label,
|
||||||
|
hint,
|
||||||
|
required,
|
||||||
|
status,
|
||||||
|
inputPlacement,
|
||||||
|
children,
|
||||||
|
wrapperClassName,
|
||||||
|
...otherProps
|
||||||
|
},
|
||||||
|
ref,
|
||||||
|
) => (
|
||||||
<FormFieldWrapper
|
<FormFieldWrapper
|
||||||
label={label}
|
label={label}
|
||||||
hint={hint}
|
hint={hint}
|
||||||
required={required}
|
required={required}
|
||||||
status={status}
|
status={status}
|
||||||
inputId={id}
|
inputId={id}
|
||||||
|
inputPlacement={inputPlacement}
|
||||||
|
className={wrapperClassName}
|
||||||
>
|
>
|
||||||
{(inputProps) => (
|
{(inputProps) => (
|
||||||
<Select {...otherProps} {...inputProps} ref={ref}>
|
<Select {...otherProps} {...inputProps} ref={ref}>
|
||||||
|
|||||||
@ -3,6 +3,8 @@ import { useCallback, useMemo, useRef } from 'react';
|
|||||||
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
|
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
|
||||||
|
|
||||||
import { PendingBadge } from '@/mastodon/components/badge';
|
import { PendingBadge } from '@/mastodon/components/badge';
|
||||||
|
import { SelectField } from '@/mastodon/components/form_fields';
|
||||||
|
import { useSearchParam } from '@/mastodon/hooks/useSearchParam';
|
||||||
import VisibilityOffIcon from '@/material-icons/400-24px/visibility_off.svg?react';
|
import VisibilityOffIcon from '@/material-icons/400-24px/visibility_off.svg?react';
|
||||||
import type {
|
import type {
|
||||||
ApiCollectionJSON,
|
ApiCollectionJSON,
|
||||||
@ -53,6 +55,43 @@ const getCollectionItems = createAppSelector(
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
function sortAccounts(
|
||||||
|
accounts: CollectionItemWithAccount[],
|
||||||
|
sortBy?: string,
|
||||||
|
): CollectionItemWithAccount[] {
|
||||||
|
if (!sortBy || sortBy === 'date_added') {
|
||||||
|
return accounts;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sorted = [...accounts];
|
||||||
|
|
||||||
|
switch (sortBy) {
|
||||||
|
case 'alphabetical':
|
||||||
|
return sorted.sort((a, b) => {
|
||||||
|
const nameA = a.account?.display_name ?? '';
|
||||||
|
const nameB = b.account?.display_name ?? '';
|
||||||
|
return nameA.localeCompare(nameB);
|
||||||
|
});
|
||||||
|
|
||||||
|
case 'last_active':
|
||||||
|
return sorted.sort((a, b) => {
|
||||||
|
const dateA = a.account?.last_status_at ?? '';
|
||||||
|
const dateB = b.account?.last_status_at ?? '';
|
||||||
|
return new Date(dateB).getTime() - new Date(dateA).getTime();
|
||||||
|
});
|
||||||
|
|
||||||
|
case 'most_followers':
|
||||||
|
return sorted.sort((a, b) => {
|
||||||
|
const followersA = a.account?.followers_count ?? 0;
|
||||||
|
const followersB = b.account?.followers_count ?? 0;
|
||||||
|
return followersB - followersA;
|
||||||
|
});
|
||||||
|
|
||||||
|
default:
|
||||||
|
return accounts;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export const CollectionAccountsList: React.FC<{
|
export const CollectionAccountsList: React.FC<{
|
||||||
collection: ApiCollectionJSON;
|
collection: ApiCollectionJSON;
|
||||||
}> = ({ collection }) => {
|
}> = ({ collection }) => {
|
||||||
@ -68,11 +107,20 @@ export const CollectionAccountsList: React.FC<{
|
|||||||
getCollectionItems(state, id),
|
getCollectionItems(state, id),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const [sortBy, setSortBy] = useSearchParam('sort', 'date_added');
|
||||||
|
const changeSortBy = useCallback(
|
||||||
|
(event: React.ChangeEvent<HTMLSelectElement>) => {
|
||||||
|
setSortBy(event.target.value);
|
||||||
|
},
|
||||||
|
[setSortBy],
|
||||||
|
);
|
||||||
|
const sortedAccounts = sortAccounts(collectionAccounts, sortBy);
|
||||||
|
|
||||||
const { visibleAccounts, hiddenAccounts } = useMemo(() => {
|
const { visibleAccounts, hiddenAccounts } = useMemo(() => {
|
||||||
const visibleAccounts: CollectionItemWithAccount[] = [];
|
const visibleAccounts: CollectionItemWithAccount[] = [];
|
||||||
const hiddenAccounts: CollectionItemWithAccount[] = [];
|
const hiddenAccounts: CollectionItemWithAccount[] = [];
|
||||||
|
|
||||||
collectionAccounts.forEach((item) => {
|
sortedAccounts.forEach((item) => {
|
||||||
const { account, account_id } = item;
|
const { account, account_id } = item;
|
||||||
|
|
||||||
if (!isOwnCollection && !account) {
|
if (!isOwnCollection && !account) {
|
||||||
@ -89,7 +137,7 @@ export const CollectionAccountsList: React.FC<{
|
|||||||
});
|
});
|
||||||
|
|
||||||
return { visibleAccounts, hiddenAccounts };
|
return { visibleAccounts, hiddenAccounts };
|
||||||
}, [collectionAccounts, isOwnCollection, relationships]);
|
}, [sortedAccounts, isOwnCollection, relationships]);
|
||||||
|
|
||||||
const renderAccountItemButton = useCallback(
|
const renderAccountItemButton = useCallback(
|
||||||
({ relationship, accountId }: RenderButtonOptions) => {
|
({ relationship, accountId }: RenderButtonOptions) => {
|
||||||
@ -147,17 +195,57 @@ export const CollectionAccountsList: React.FC<{
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<h3
|
<div className={classes.subheadingWithSelect}>
|
||||||
className={classes.columnSubheading}
|
<h3
|
||||||
tabIndex={-1}
|
className={classes.columnSubheading}
|
||||||
ref={listHeadingRef}
|
tabIndex={-1}
|
||||||
>
|
ref={listHeadingRef}
|
||||||
<FormattedMessage
|
>
|
||||||
id='collections.account_count'
|
<FormattedMessage
|
||||||
defaultMessage='{count, plural, one {# account} other {# accounts}}'
|
id='collections.account_count'
|
||||||
values={{ count: collection.item_count }}
|
defaultMessage='{count, plural, one {# account} other {# accounts}}'
|
||||||
/>
|
values={{ count: collection.item_count }}
|
||||||
</h3>
|
/>
|
||||||
|
</h3>
|
||||||
|
<SelectField
|
||||||
|
label={
|
||||||
|
<FormattedMessage
|
||||||
|
id='collections.sort_by'
|
||||||
|
defaultMessage='Sort by:'
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
value={sortBy}
|
||||||
|
onChange={changeSortBy}
|
||||||
|
inputPlacement='inline-end'
|
||||||
|
className={classes.select}
|
||||||
|
wrapperClassName={classes.selectWrapper}
|
||||||
|
>
|
||||||
|
<option value='alphabetical'>
|
||||||
|
<FormattedMessage
|
||||||
|
id='collections.sort_alphabetical'
|
||||||
|
defaultMessage='Alphabetical'
|
||||||
|
/>
|
||||||
|
</option>
|
||||||
|
<option value='last_active'>
|
||||||
|
<FormattedMessage
|
||||||
|
id='collections.sort_last_active'
|
||||||
|
defaultMessage='Last active'
|
||||||
|
/>
|
||||||
|
</option>
|
||||||
|
<option value='most_followers'>
|
||||||
|
<FormattedMessage
|
||||||
|
id='collections.sort_most_followers'
|
||||||
|
defaultMessage='Most followers'
|
||||||
|
/>
|
||||||
|
</option>
|
||||||
|
<option value='date_added'>
|
||||||
|
<FormattedMessage
|
||||||
|
id='collections.sort_date_added'
|
||||||
|
defaultMessage='Date added'
|
||||||
|
/>
|
||||||
|
</option>
|
||||||
|
</SelectField>
|
||||||
|
</div>
|
||||||
<ItemList emptyMessage={intl.formatMessage(messages.empty)}>
|
<ItemList emptyMessage={intl.formatMessage(messages.empty)}>
|
||||||
<TruncatedListItems
|
<TruncatedListItems
|
||||||
visibleItems={visibleAccounts}
|
visibleItems={visibleAccounts}
|
||||||
|
|||||||
@ -67,8 +67,27 @@
|
|||||||
margin-inline: 16px;
|
margin-inline: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.columnSubheading {
|
.subheadingWithSelect {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px 16px;
|
||||||
padding-inline: 16px;
|
padding-inline: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selectWrapper {
|
||||||
|
// Align to right edge even when wrapped to new line
|
||||||
|
margin-inline-start: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.select {
|
||||||
|
height: 36px;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 15px;
|
||||||
|
background-color: var(--color-bg-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.columnSubheading {
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -425,6 +425,11 @@
|
|||||||
"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",
|
||||||
|
"collections.sort_alphabetical": "Alphabetical",
|
||||||
|
"collections.sort_by": "Sort by:",
|
||||||
|
"collections.sort_date_added": "Date added",
|
||||||
|
"collections.sort_last_active": "Last active",
|
||||||
|
"collections.sort_most_followers": "Most followers",
|
||||||
"collections.suggestions.can_not_add": "Can’t be added",
|
"collections.suggestions.can_not_add": "Can’t be added",
|
||||||
"collections.suggestions.can_not_add_desc": "These accounts may have opted out of discovery, or they might be on a server that doesn’t support collections.",
|
"collections.suggestions.can_not_add_desc": "These accounts may have opted out of discovery, or they might be on a server that doesn’t support collections.",
|
||||||
"collections.suggestions.must_follow": "Must follow first",
|
"collections.suggestions.must_follow": "Must follow first",
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user