Update design of Collections list page (#38822)

This commit is contained in:
diondiondion 2026-04-28 13:32:27 +02:00 committed by GitHub
parent 1f1653e039
commit 3d5cb624ba
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 305 additions and 122 deletions

View File

@ -276,9 +276,11 @@ export const ColumnHeader: React.FC<Props> = ({
</> </>
); );
const HeadingElement = hasTitle ? 'h1' : 'div';
const component = ( const component = (
<div className={wrapperClassName}> <div className={wrapperClassName}>
<h1 className={buttonClassName}> <HeadingElement className={buttonClassName}>
{hasTitle && ( {hasTitle && (
<> <>
{backButton} {backButton}
@ -311,7 +313,7 @@ export const ColumnHeader: React.FC<Props> = ({
{extraButton} {extraButton}
{collapseButton} {collapseButton}
</div> </div>
</h1> </HeadingElement>
<div <div
className={collapsibleClassName} className={collapsibleClassName}

View File

@ -1,8 +1,10 @@
import type { ComponentPropsWithoutRef } from 'react'; import type { ComponentPropsWithoutRef } from 'react';
import { Children, forwardRef } from 'react'; import { forwardRef } from 'react';
import classNames from 'classnames'; import classNames from 'classnames';
import { hasReactChildren } from '@/mastodon/utils/has_react_children';
import { LoadingIndicator } from '../loading_indicator'; import { LoadingIndicator } from '../loading_indicator';
export const Scrollable = forwardRef< export const Scrollable = forwardRef<
@ -36,7 +38,7 @@ export const ItemList = forwardRef<
emptyMessage?: React.ReactNode; emptyMessage?: React.ReactNode;
} }
>(({ isLoading, emptyMessage, className, children, ...otherProps }, ref) => { >(({ isLoading, emptyMessage, className, children, ...otherProps }, ref) => {
if (!isLoading && Children.count(children) === 0 && emptyMessage) { if (!isLoading && !hasReactChildren(children) && emptyMessage) {
return ( return (
<div className='empty-column-indicator'> <div className='empty-column-indicator'>
<span>{emptyMessage}</span> <span>{emptyMessage}</span>

View File

@ -0,0 +1,51 @@
import type { ComponentPropsWithoutRef, FC } from 'react';
import classNames from 'classnames';
import type { NavLinkProps } from 'react-router-dom';
import { NavLink } from 'react-router-dom';
import classes from './styles.module.scss';
interface TabListProps {
/**
* Setting this will remove the default border and
* horizontal padding from the component
*/
plain?: boolean;
}
/**
* Display a simple row of links as tabs.
* The current page will be highlighted automatically based on the link destination.
*/
export const TabList: FC<TabListProps & ComponentPropsWithoutRef<'div'>> = ({
plain,
className,
children,
...otherProps
}) => {
return (
<div
{...otherProps}
className={classNames(
className,
classes.tabList,
!plain && classes.withSpaceAndBorder,
)}
>
{children}
</div>
);
};
export const TabLink: FC<NavLinkProps> = ({
className,
children,
...otherProps
}) => {
return (
<NavLink className={classNames(classes.tab, className)} {...otherProps}>
{children}
</NavLink>
);
};

View File

@ -0,0 +1,39 @@
.tabList {
display: flex;
gap: 16px;
}
.withSpaceAndBorder {
padding-inline: 16px;
border-bottom: 1px solid var(--color-border-primary);
}
.tab {
display: block;
padding: 18px 0;
color: var(--color-text-primary);
font-size: 15px;
font-weight: 500;
text-decoration: none;
border-radius: 0;
transition: color 0.2s ease-in-out;
@container (width < 500px) {
flex: 1 1 0px;
text-align: center;
}
&:hover {
text-decoration: none;
}
&:not(:global(.active)):is(:hover, :focus) {
color: var(--color-text-brand-soft);
}
&:global(.active) {
color: var(--color-text-brand);
border-bottom: 4px solid var(--color-border-brand);
padding-bottom: 14px;
}
}

View File

@ -0,0 +1,33 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { TabList, TabLink } from './index';
const meta = {
title: 'Components/TabList',
component: TabList,
subcomponents: { TabLink },
} satisfies Meta<typeof TabList>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {
render: () => (
<TabList>
<TabLink to='/'>Activity</TabLink>
<TabLink to='/media'>Media</TabLink>
<TabLink to='/featured'>Featured</TabLink>
</TabList>
),
};
export const Plain: Story = {
render: () => (
<TabList plain>
<TabLink to='/'>Activity</TabLink>
<TabLink to='/media'>Media</TabLink>
<TabLink to='/featured'>Featured</TabLink>
</TabList>
),
};

View File

@ -355,48 +355,10 @@ $button-fallback-breakpoint: $button-breakpoint + 55px;
} }
} }
.tabs,
.noTabs {
border-bottom: 1px solid var(--color-border-primary);
}
.tabs {
display: flex;
gap: 16px;
padding: 0 16px;
@container (width < 500px) {
a {
flex: 1 1 0px;
text-align: center;
}
}
a {
display: block;
font-size: 15px;
font-weight: 500;
padding: 18px 0;
text-decoration: none;
color: var(--color-text-primary);
border-radius: 0;
transition: color 0.2s ease-in-out;
&:not([aria-current='page']):is(:hover, :focus) {
color: var(--color-text-brand-soft);
}
}
:global(.active) {
color: var(--color-text-brand);
border-bottom: 4px solid var(--color-border-brand);
padding-bottom: 14px;
}
}
.noTabs { .noTabs {
width: 100%; width: 100%;
border-width: 0 0 1px; border-width: 0 0 1px;
border-bottom: 1px solid var(--color-border-primary);
} }
.bannerBase { .bannerBase {

View File

@ -3,8 +3,8 @@ import type { FC } from 'react';
import { FormattedMessage } from 'react-intl'; import { FormattedMessage } from 'react-intl';
import type { NavLinkProps } from 'react-router-dom'; import type { NavLinkProps } from 'react-router-dom';
import { NavLink } from 'react-router-dom';
import { TabLink, TabList } from '@/mastodon/components/tab_list';
import { useAccount } from '@/mastodon/hooks/useAccount'; import { useAccount } from '@/mastodon/hooks/useAccount';
import { useAccountId } from '@/mastodon/hooks/useAccountId'; import { useAccountId } from '@/mastodon/hooks/useAccountId';
@ -28,20 +28,20 @@ export const AccountTabs: FC = () => {
} }
return ( return (
<div className={classes.tabs}> <TabList>
<NavLink isActive={isActive} to={`/@${acct}`}> <TabLink isActive={isActive} to={`/@${acct}`}>
<FormattedMessage id='account.activity' defaultMessage='Activity' /> <FormattedMessage id='account.activity' defaultMessage='Activity' />
</NavLink> </TabLink>
{show_media && ( {show_media && (
<NavLink exact to={`/@${acct}/media`}> <TabLink exact to={`/@${acct}/media`}>
<FormattedMessage id='account.media' defaultMessage='Media' /> <FormattedMessage id='account.media' defaultMessage='Media' />
</NavLink> </TabLink>
)} )}
{show_featured && ( {show_featured && (
<NavLink exact to={`/@${acct}/featured`}> <TabLink exact to={`/@${acct}/featured`}>
<FormattedMessage id='account.featured' defaultMessage='Featured' /> <FormattedMessage id='account.featured' defaultMessage='Featured' />
</NavLink> </TabLink>
)} )}
</div> </TabList>
); );
}; };

View File

@ -1,6 +1,5 @@
.wrapper { .wrapper {
--list-item-padding: 16px; --list-item-padding: 16px;
--list-item-padding-block: 12px;
--list-item-gap: 16px; --list-item-gap: 16px;
&:not(.wrapperWithoutBorder) { &:not(.wrapperWithoutBorder) {

View File

@ -144,7 +144,7 @@ export const CollectionEditorPage: React.FC<{
/> />
</Switch> </Switch>
) : ( ) : (
<MaxCollectionsCallout /> <MaxCollectionsCallout className={classes.maxCollectionsError} />
)} )}
</div> </div>
@ -156,9 +156,11 @@ export const CollectionEditorPage: React.FC<{
); );
}; };
export const MaxCollectionsCallout: React.FC = () => ( export const MaxCollectionsCallout: React.FC<{ className?: string }> = ({
className,
}) => (
<Callout <Callout
className={classes.maxCollectionsError} className={className}
title={ title={
<FormattedMessage <FormattedMessage
id='collections.maximum_collection_count_reached' id='collections.maximum_collection_count_reached'

View File

@ -5,9 +5,9 @@ import { defineMessages, useIntl, FormattedMessage } from 'react-intl';
import { Helmet } from 'react-helmet'; import { Helmet } from 'react-helmet';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { EmptyState } from '@/mastodon/components/empty_state';
import { TabLink, TabList } from '@/mastodon/components/tab_list';
import AddIcon from '@/material-icons/400-24px/add.svg?react'; import AddIcon from '@/material-icons/400-24px/add.svg?react';
import CollectionsFilledIcon from '@/material-icons/400-24px/category-fill.svg?react';
import SquigglyArrow from '@/svg-icons/squiggly_arrow.svg?react';
import { Column } from 'mastodon/components/column'; import { Column } from 'mastodon/components/column';
import { ColumnHeader } from 'mastodon/components/column_header'; import { ColumnHeader } from 'mastodon/components/column_header';
import { DisplayNameSimple } from 'mastodon/components/display_name/simple'; import { DisplayNameSimple } from 'mastodon/components/display_name/simple';
@ -30,16 +30,39 @@ import {
MaxCollectionsCallout, MaxCollectionsCallout,
userCollectionLimit, userCollectionLimit,
} from './editor'; } from './editor';
import classes from './styles.module.scss';
import { areCollectionsEnabled } from './utils'; import { areCollectionsEnabled } from './utils';
const messages = defineMessages({ const messages = defineMessages({
headingMe: { id: 'column.my_collections', defaultMessage: 'My collections' }, headingMe: {
id: 'column.your_collections',
defaultMessage: 'Your Collections',
},
headingOther: { headingOther: {
id: 'column.other_collections', id: 'column.other_collections',
defaultMessage: 'Collections by {name}', defaultMessage: "{name}'s Collections",
},
createdByYou: {
id: 'collections.list.created_by_you',
defaultMessage: 'Created by you',
},
createdByAuthor: {
id: 'collections.list.created_by_author',
defaultMessage: 'Created by {name}',
},
featuringYou: {
id: 'collections.list.featuring_you',
defaultMessage: 'Featuring you',
}, },
}); });
const CreateButton: React.FC = () => (
<Link to='/collections/new' className='button button--compact'>
<Icon id='plus' icon={AddIcon} />
<FormattedMessage {...editorMessages.newCollection} />
</Link>
);
export function useAccountCollections(accountId: string | null | undefined) { export function useAccountCollections(accountId: string | null | undefined) {
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
@ -62,33 +85,11 @@ export const Collections: React.FC<{
const { collections, status } = useAccountCollections(accountId); const { collections, status } = useAccountCollections(accountId);
const emptyMessage =
status === 'error' || !accountId ? (
<FormattedMessage
id='collections.error_loading_collections'
defaultMessage='There was an error when trying to load your collections.'
tagName='span'
/>
) : (
<>
<span>
<FormattedMessage
id='collections.no_collections_yet'
defaultMessage='No collections yet.'
/>
<br />
<FormattedMessage
id='collections.create_a_collection_hint'
defaultMessage='Create a collection to recommend or share your favourite accounts with others.'
/>
</span>
<SquigglyArrow className='empty-column-indicator__arrow' />
</>
);
const canCreateMoreCollections = collections.length < userCollectionLimit; const canCreateMoreCollections = collections.length < userCollectionLimit;
const isOwnCollection = accountId === me; const isOwnCollection = accountId === me;
const showCreateButton =
isOwnCollection && status === 'idle' && canCreateMoreCollections;
const titleMessage = isOwnCollection const titleMessage = isOwnCollection
? messages.headingMe ? messages.headingMe
: messages.headingOther; : messages.headingOther;
@ -100,45 +101,88 @@ export const Collections: React.FC<{
name: <DisplayNameSimple account={account} />, name: <DisplayNameSimple account={account} />,
}); });
const tabMessage = isOwnCollection
? messages.createdByYou
: messages.createdByAuthor;
const errorMessage = (status === 'error' || !accountId) && (
<FormattedMessage
id='collections.error_loading_collections'
defaultMessage='There was an error when trying to load your collections.'
tagName='span'
/>
);
return ( return (
<Column bindToDocument={!multiColumn} label={pageTitle}> <Column bindToDocument={!multiColumn} label={pageTitle}>
<ColumnHeader <ColumnHeader showBackButton multiColumn={multiColumn} />
title={pageTitleHtml}
icon='collections'
iconComponent={CollectionsFilledIcon}
multiColumn={multiColumn}
extraButton={
isOwnCollection &&
status === 'idle' &&
canCreateMoreCollections && (
<Link
to='/collections/new'
className='column-header__button'
title={intl.formatMessage(editorMessages.create)}
aria-label={intl.formatMessage(editorMessages.create)}
>
<Icon id='plus' icon={AddIcon} />
</Link>
)
}
/>
<Scrollable> <Scrollable>
{status === 'idle' && !canCreateMoreCollections && ( <header className={classes.header}>
<MaxCollectionsCallout /> <h1 className={classes.heading}>{pageTitleHtml}</h1>
<TabList plain>
<TabLink exact to={`/@${account?.acct}/collections`}>
{intl.formatMessage(tabMessage, {
name: <DisplayNameSimple account={account} />,
})}
</TabLink>
</TabList>
</header>
{collections.length > 0 ? (
<>
{status === 'idle' && (
<div className={classes.listHeader}>
<h2 className={classes.subHeading}>
<FormattedMessage
id='collections.list.collections_with_count'
defaultMessage='{count, plural, one {# Collection} other {# Collections}}'
values={{
count: collections.length,
}}
/>
</h2>
{showCreateButton && <CreateButton />}
</div>
)}
<ItemList
emptyMessage={errorMessage}
isLoading={status === 'loading'}
>
{!canCreateMoreCollections && (
<MaxCollectionsCallout
className={classes.maxCollectionsError}
/>
)}
{collections.map((item, index) => (
<CollectionListItem
withTimestamp
withAuthorHandle={false}
key={item.id}
collection={item}
positionInList={index + 1}
listSize={collections.length}
/>
))}
</ItemList>
</>
) : (
<EmptyState
title={
<FormattedMessage
id='empty_column.account_featured_self.showcase_accounts'
defaultMessage='Showcase your favorite accounts'
/>
}
message={
<FormattedMessage
id='empty_column.account_featured_self.showcase_accounts_desc'
defaultMessage='Collections are curated lists of accounts to help others discover more of the Fediverse.'
/>
}
>
<CreateButton />
</EmptyState>
)} )}
<ItemList emptyMessage={emptyMessage} isLoading={status === 'loading'}>
{collections.map((item, index) => (
<CollectionListItem
withTimestamp
withAuthorHandle={false}
key={item.id}
collection={item}
positionInList={index + 1}
listSize={collections.length}
/>
))}
</ItemList>
</Scrollable> </Scrollable>
<Helmet> <Helmet>

View File

@ -0,0 +1,35 @@
.header {
display: flex;
flex-direction: column;
gap: 8px;
padding-top: 24px;
padding-inline: 16px;
border-bottom: 1px solid var(--color-border-primary);
}
.heading {
font-size: 22px;
font-weight: 500;
line-height: 1.2;
overflow-wrap: break-word;
}
.listHeader {
display: flex;
justify-content: space-between;
align-items: center;
min-height: 28px;
padding-top: 16px;
padding-inline: 16px;
}
.subHeading {
font-size: 15px;
font-weight: 500;
line-height: 1.2;
overflow-wrap: break-word;
}
.maxCollectionsError {
margin: 8px 16px;
}

View File

@ -387,7 +387,6 @@
"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}",
"collections.create_a_collection_hint": "Create a collection to recommend or share your favourite accounts with others.",
"collections.create_collection": "Create collection", "collections.create_collection": "Create collection",
"collections.delete_collection": "Delete collection", "collections.delete_collection": "Delete collection",
"collections.description_length_hint": "100 characters limit", "collections.description_length_hint": "100 characters limit",
@ -405,6 +404,10 @@
"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.list.collections_with_count": "{count, plural, one {# Collection} other {# Collections}}",
"collections.list.created_by_author": "Created by {name}",
"collections.list.created_by_you": "Created by you",
"collections.list.featuring_you": "Featuring you",
"collections.manage_accounts": "Manage accounts", "collections.manage_accounts": "Manage accounts",
"collections.mark_as_sensitive": "Mark as sensitive", "collections.mark_as_sensitive": "Mark as sensitive",
"collections.mark_as_sensitive_hint": "Hides the collection's description and accounts behind a content warning. The collection name will still be visible.", "collections.mark_as_sensitive_hint": "Hides the collection's description and accounts behind a content warning. The collection name will still be visible.",
@ -412,7 +415,6 @@
"collections.maximum_collection_count_reached": "You have created the maximum number of collections", "collections.maximum_collection_count_reached": "You have created the maximum number of collections",
"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.remove_account": "Remove", "collections.remove_account": "Remove",
"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",
@ -454,11 +456,11 @@
"column.list_members": "Manage list members", "column.list_members": "Manage list members",
"column.lists": "Lists", "column.lists": "Lists",
"column.mutes": "Muted users", "column.mutes": "Muted users",
"column.my_collections": "My collections",
"column.notifications": "Notifications", "column.notifications": "Notifications",
"column.other_collections": "Collections by {name}", "column.other_collections": "Collections by {name}",
"column.pins": "Pinned posts", "column.pins": "Pinned posts",
"column.public": "Federated timeline", "column.public": "Federated timeline",
"column.your_collections": "Your Collections",
"column_back_button.label": "Back", "column_back_button.label": "Back",
"column_header.hide_settings": "Hide settings", "column_header.hide_settings": "Hide settings",
"column_header.moveLeft_settings": "Move column to the left", "column_header.moveLeft_settings": "Move column to the left",

View File

@ -0,0 +1,12 @@
import { Children } from 'react';
import type { ReactNode } from 'react';
export function hasReactChildren(children: ReactNode): boolean {
if (!children) {
return false;
}
const childrenCount = Children.toArray(children).filter(Boolean).length;
return childrenCount > 0;
}