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 = (
<div className={wrapperClassName}>
<h1 className={buttonClassName}>
<HeadingElement className={buttonClassName}>
{hasTitle && (
<>
{backButton}
@ -311,7 +313,7 @@ export const ColumnHeader: React.FC<Props> = ({
{extraButton}
{collapseButton}
</div>
</h1>
</HeadingElement>
<div
className={collapsibleClassName}

View File

@ -1,8 +1,10 @@
import type { ComponentPropsWithoutRef } from 'react';
import { Children, forwardRef } from 'react';
import { forwardRef } from 'react';
import classNames from 'classnames';
import { hasReactChildren } from '@/mastodon/utils/has_react_children';
import { LoadingIndicator } from '../loading_indicator';
export const Scrollable = forwardRef<
@ -36,7 +38,7 @@ export const ItemList = forwardRef<
emptyMessage?: React.ReactNode;
}
>(({ isLoading, emptyMessage, className, children, ...otherProps }, ref) => {
if (!isLoading && Children.count(children) === 0 && emptyMessage) {
if (!isLoading && !hasReactChildren(children) && emptyMessage) {
return (
<div className='empty-column-indicator'>
<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 {
width: 100%;
border-width: 0 0 1px;
border-bottom: 1px solid var(--color-border-primary);
}
.bannerBase {

View File

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

View File

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

View File

@ -144,7 +144,7 @@ export const CollectionEditorPage: React.FC<{
/>
</Switch>
) : (
<MaxCollectionsCallout />
<MaxCollectionsCallout className={classes.maxCollectionsError} />
)}
</div>
@ -156,9 +156,11 @@ export const CollectionEditorPage: React.FC<{
);
};
export const MaxCollectionsCallout: React.FC = () => (
export const MaxCollectionsCallout: React.FC<{ className?: string }> = ({
className,
}) => (
<Callout
className={classes.maxCollectionsError}
className={className}
title={
<FormattedMessage
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 { 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 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 { ColumnHeader } from 'mastodon/components/column_header';
import { DisplayNameSimple } from 'mastodon/components/display_name/simple';
@ -30,16 +30,39 @@ import {
MaxCollectionsCallout,
userCollectionLimit,
} from './editor';
import classes from './styles.module.scss';
import { areCollectionsEnabled } from './utils';
const messages = defineMessages({
headingMe: { id: 'column.my_collections', defaultMessage: 'My collections' },
headingMe: {
id: 'column.your_collections',
defaultMessage: 'Your Collections',
},
headingOther: {
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) {
const dispatch = useAppDispatch();
@ -62,33 +85,11 @@ export const Collections: React.FC<{
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 isOwnCollection = accountId === me;
const showCreateButton =
isOwnCollection && status === 'idle' && canCreateMoreCollections;
const titleMessage = isOwnCollection
? messages.headingMe
: messages.headingOther;
@ -100,45 +101,88 @@ export const Collections: React.FC<{
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 (
<Column bindToDocument={!multiColumn} label={pageTitle}>
<ColumnHeader
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>
)
}
/>
<ColumnHeader showBackButton multiColumn={multiColumn} />
<Scrollable>
{status === 'idle' && !canCreateMoreCollections && (
<MaxCollectionsCallout />
<header className={classes.header}>
<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>
<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.basic_details_title": "Basic details",
"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.delete_collection": "Delete collection",
"collections.description_length_hint": "100 characters limit",
@ -405,6 +404,10 @@
"collections.hidden_accounts_link": "{count, plural, one {# hidden account} other {# hidden accounts}}",
"collections.hints.accounts_counter": "{count}/{max} accounts",
"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.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.",
@ -412,7 +415,6 @@
"collections.maximum_collection_count_reached": "You have created the maximum number of collections",
"collections.name_length_hint": "40 characters limit",
"collections.new_collection": "New collection",
"collections.no_collections_yet": "No collections yet.",
"collections.remove_account": "Remove",
"collections.report_collection": "Report this collection",
"collections.revoke_collection_inclusion": "Remove myself from this collection",
@ -454,11 +456,11 @@
"column.list_members": "Manage list members",
"column.lists": "Lists",
"column.mutes": "Muted users",
"column.my_collections": "My collections",
"column.notifications": "Notifications",
"column.other_collections": "Collections by {name}",
"column.pins": "Pinned posts",
"column.public": "Federated timeline",
"column.your_collections": "Your Collections",
"column_back_button.label": "Back",
"column_header.hide_settings": "Hide settings",
"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;
}