Implement new collection page design (#38450)

This commit is contained in:
diondiondion
2026-03-27 16:30:06 +01:00
committed by GitHub
parent 5a880ff995
commit 098d698a7e
9 changed files with 158 additions and 208 deletions

View File

@@ -75,6 +75,7 @@ interface AccountProps {
withMenu?: boolean;
withBorder?: boolean;
extraAccountInfo?: React.ReactNode;
className?: string;
children?: React.ReactNode;
}
@@ -88,6 +89,7 @@ export const Account: React.FC<AccountProps> = ({
withMenu = true,
withBorder = true,
extraAccountInfo,
className,
children,
}) => {
const intl = useIntl();
@@ -290,7 +292,7 @@ export const Account: React.FC<AccountProps> = ({
return (
<div
className={classNames('account', {
className={classNames('account', className, {
'account--minimal': minimal,
'account--without-border': !withBorder,
})}

View File

@@ -6,6 +6,7 @@
background-color: var(--color-bg-brand-softest);
color: var(--color-text-primary);
border-radius: 12px;
font-size: 15px;
}
.icon {

View File

@@ -2,6 +2,8 @@ import { useCallback, useRef, useState } from 'react';
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
import { Callout } from '@/mastodon/components/callout';
import { FollowButton } from '@/mastodon/components/follow_button';
import { openModal } from 'mastodon/actions/modal';
import type {
ApiCollectionJSON,
@@ -27,10 +29,6 @@ const messages = defineMessages({
id: 'collections.accounts.empty_title',
defaultMessage: 'This collection is empty',
},
accounts: {
id: 'collections.detail.accounts_heading',
defaultMessage: 'Accounts',
},
});
const SimpleAuthorName: React.FC<{ id: string }> = ({ id }) => {
@@ -41,8 +39,9 @@ const SimpleAuthorName: React.FC<{ id: string }> = ({ id }) => {
const AccountItem: React.FC<{
accountId: string | undefined;
collectionOwnerId: string;
withBio?: boolean;
withBorder?: boolean;
}> = ({ accountId, withBorder = true, collectionOwnerId }) => {
}> = ({ accountId, withBio = true, withBorder = true, collectionOwnerId }) => {
const relationship = useRelationship(accountId);
if (!accountId) {
@@ -59,12 +58,17 @@ const AccountItem: React.FC<{
(relationship.following || relationship.requested));
return (
<div className={classes.accountItemWrapper} data-with-border={withBorder}>
<Account
minimal={withoutButton}
withMenu={false}
withBorder={withBorder}
minimal
id={accountId}
withBio={withBio}
withBorder={false}
withMenu={false}
className={classes.accountItem}
/>
{!withoutButton && <FollowButton accountId={accountId} />}
</div>
);
};
@@ -131,19 +135,27 @@ const SensitiveScreen: React.FC<{
}
return (
<div className={classes.sensitiveWarning}>
<Callout
variant='warning'
title={
<FormattedMessage
id='collections.detail.sensitive_content'
defaultMessage='Sensitive content'
/>
}
primaryLabel={
<FormattedMessage
id='content_warning.show_short'
defaultMessage='Show'
/>
}
onPrimary={showAnyway}
>
<FormattedMessage
id='collections.detail.sensitive_note'
defaultMessage='This collection contains accounts and content that may be sensitive to some users.'
tagName='p'
defaultMessage='The description and accounts may not be suitable for all viewers.'
/>
<Button onClick={showAnyway}>
<FormattedMessage
id='content_warning.show'
defaultMessage='Show anyway'
/>
</Button>
</div>
</Callout>
);
};
@@ -192,13 +204,14 @@ export const CollectionAccountsList: React.FC<{
<ItemList
isLoading={isLoading}
emptyMessage={intl.formatMessage(messages.empty)}
className={classes.itemList}
>
{collection && currentUserInCollection ? (
<>
<h3 className={classes.columnSubheading}>
<FormattedMessage
id='collections.detail.author_added_you'
defaultMessage='{author} added you to this collection'
id='collections.detail.you_were_added_to_this_collection'
defaultMessage='You were added to this collection'
values={{
author: <SimpleAuthorName id={collection.account_id} />,
}}
@@ -208,9 +221,11 @@ export const CollectionAccountsList: React.FC<{
key={currentUserInCollection.account_id}
aria-posinset={1}
aria-setsize={items.length}
className={classes.youWereAddedWrapper}
>
<AccountItem
withBorder={false}
withBio={false}
accountId={currentUserInCollection.account_id}
collectionOwnerId={collection.account_id}
/>
@@ -225,18 +240,30 @@ export const CollectionAccountsList: React.FC<{
ref={listHeadingRef}
>
<FormattedMessage
id='collections.detail.other_accounts_in_collection'
defaultMessage='Others in this collection:'
id='collections.detail.other_accounts_count'
defaultMessage='{count, plural, one {# other account} other {# other accounts}}'
values={{ count: collection.item_count - 1 }}
/>
</h3>
</>
) : (
<h3
className='column-subheading sr-only'
className={classes.columnSubheading}
tabIndex={-1}
ref={listHeadingRef}
>
{intl.formatMessage(messages.accounts)}
{collection ? (
<FormattedMessage
id='collections.account_count'
defaultMessage='{count, plural, one {# account} other {# accounts}}'
values={{ count: collection.item_count }}
/>
) : (
<FormattedMessage
id='collections.detail.accounts_heading'
defaultMessage='Accounts'
/>
)}
</h3>
)}
{collection && (

View File

@@ -43,7 +43,7 @@
width: 18px;
height: 18px;
border-radius: 8px;
color: var(--color-text-primary);
fill: var(--color-text-primary);
background: var(--color-bg-warning-softest);
}

View File

@@ -4,24 +4,19 @@ import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
import { Helmet } from 'react-helmet';
import { useHistory, useLocation, useParams } from 'react-router';
import { Link } from 'react-router-dom';
import { openModal } from '@/mastodon/actions/modal';
import { RelativeTimestamp } from '@/mastodon/components/relative_timestamp';
import { useAccountHandle } from '@/mastodon/components/display_name/default';
import ListAltIcon from '@/material-icons/400-24px/list_alt.svg?react';
import ShareIcon from '@/material-icons/400-24px/share.svg?react';
import type { ApiCollectionJSON } from 'mastodon/api_types/collections';
import { Avatar } from 'mastodon/components/avatar';
import { Column } from 'mastodon/components/column';
import { ColumnHeader } from 'mastodon/components/column_header';
import {
DisplayName,
LinkedDisplayName,
} from 'mastodon/components/display_name';
import { IconButton } from 'mastodon/components/icon_button';
import { Scrollable } from 'mastodon/components/scrollable_list/components';
import { Tag } from 'mastodon/components/tags/tag';
import { useAccount } from 'mastodon/hooks/useAccount';
import { me } from 'mastodon/initial_state';
import { domain } from 'mastodon/initial_state';
import { fetchCollection } from 'mastodon/reducers/slices/collections';
import { useAppDispatch, useAppSelector } from 'mastodon/store';
@@ -40,88 +35,29 @@ const messages = defineMessages({
},
});
const CollectionMetaData: React.FC<{
collection: ApiCollectionJSON;
extended?: boolean;
}> = ({ collection, extended }) => {
return (
<ul className={classes.metaList}>
<FormattedMessage
id='collections.account_count'
defaultMessage='{count, plural, one {# account} other {# accounts}}'
values={{ count: collection.item_count }}
tagName='li'
/>
{extended && (
<>
{collection.discoverable ? (
<FormattedMessage
id='collections.visibility_public'
defaultMessage='Public'
tagName='li'
/>
) : (
<FormattedMessage
id='collections.visibility_unlisted'
defaultMessage='Unlisted'
tagName='li'
/>
)}
{collection.sensitive && (
<FormattedMessage
id='collections.sensitive'
defaultMessage='Sensitive'
tagName='li'
/>
)}
</>
)}
<FormattedMessage
id='collections.last_updated_at'
defaultMessage='Last updated: {date}'
values={{
date: <RelativeTimestamp timestamp={collection.updated_at} long />,
}}
tagName='li'
/>
</ul>
);
};
export const AuthorNote: React.FC<{ id: string; previewMode?: boolean }> = ({
id,
// When previewMode is enabled, your own display name
// will not be replaced with "you"
previewMode = false,
}) => {
export const AuthorNote: React.FC<{ id: string }> = ({ id }) => {
const account = useAccount(id);
const authorHandle = useAccountHandle(account, domain);
if (!account) {
return null;
}
const author = (
<span className={classes.displayNameWithAvatar}>
<Avatar size={18} account={account} />
{previewMode ? (
<DisplayName account={account} variant='simple' />
) : (
<LinkedDisplayName displayProps={{ account, variant: 'simple' }} />
)}
</span>
<Link to={`/@${account.acct}`} data-hover-card-account={account.id}>
{authorHandle}
</Link>
);
const displayAsYou = id === me && !previewMode;
return (
<p className={previewMode ? classes.previewAuthorNote : classes.authorNote}>
{displayAsYou ? (
<p className={classes.authorNote}>
<FormattedMessage
id='collections.detail.curated_by_you'
defaultMessage='Curated by you'
id='collections.by_account'
defaultMessage='by {account_handle}'
values={{
account_handle: author,
}}
/>
) : (
<FormattedMessage
id='collections.detail.curated_by_author'
defaultMessage='Curated by {author}'
values={{ author }}
/>
)}
</p>
);
};
@@ -156,14 +92,12 @@ const CollectionHeader: React.FC<{ collection: ApiCollectionJSON }> = ({
}, [history, handleShare, isNewCollection, location.pathname]);
return (
<div className={classes.header}>
<header className={classes.header}>
<div className={classes.titleWithMenu}>
<div className={classes.titleWrapper}>
{tag && (
// TODO: Make non-interactive tag component
<Tag name={tag.name} className={classes.tag} />
)}
{tag && <span className={classes.tag}>#{tag.name}</span>}
<h2 className={classes.name}>{name}</h2>
<AuthorNote id={account_id} />
</div>
<div className={classes.headerButtonWrapper}>
<IconButton
@@ -181,12 +115,7 @@ const CollectionHeader: React.FC<{ collection: ApiCollectionJSON }> = ({
</div>
</div>
{description && <p className={classes.description}>{description}</p>}
<AuthorNote id={collection.account_id} />
<CollectionMetaData
extended={account_id === me}
collection={collection}
/>
</div>
</header>
);
};

View File

@@ -97,7 +97,7 @@ export const CollectionShareModal: React.FC<{
<div className={classes.preview}>
<div>
<h2 className={classes.previewHeading}>{collection.name}</h2>
<AuthorNote previewMode id={collection.account_id} />
<AuthorNote id={collection.account_id} />
</div>
<AvatarGroup>
{collection.items.slice(0, 5).map(({ account_id }) => {

View File

@@ -1,6 +1,5 @@
.header {
padding: 16px;
border-bottom: 1px solid var(--color-border-primary);
padding: 24px;
}
.titleWithMenu {
@@ -11,23 +10,46 @@
.titleWrapper {
flex-grow: 1;
display: flex;
flex-direction: column;
align-items: start;
gap: 4px;
min-width: 0;
}
.tag {
margin-bottom: 4px;
margin-inline-start: -8px;
display: inline-block;
padding: 4px;
font-size: 13px;
font-weight: 500;
color: var(--color-text-secondary);
background: var(--color-bg-secondary);
}
.name {
font-size: 28px;
font-size: 22px;
font-weight: 500;
line-height: 1.2;
overflow-wrap: anywhere;
}
.authorNote {
font-size: 13px;
color: var(--color-text-secondary);
a {
color: inherit;
text-decoration-color: rgb(from var(--color-text-secondary) r g b / 50%);
&:hover {
text-decoration: none;
}
}
}
.description {
font-size: 15px;
margin-top: 8px;
margin-top: 12px;
}
.headerButtonWrapper {
@@ -39,79 +61,41 @@
box-sizing: content-box;
padding: 5px;
border-radius: 4px;
border: 1px solid var(--color-border-primary);
}
.authorNote {
margin-top: 8px;
font-size: 13px;
color: var(--color-text-secondary);
}
.previewAuthorNote {
font-size: 13px;
}
.metaList {
--gap: 0.75ch;
display: flex;
flex-wrap: wrap;
margin-top: 16px;
gap: var(--gap);
font-size: 15px;
& > li:not(:last-child)::after {
content: '·';
margin-inline-start: var(--gap);
}
.itemList {
padding-inline: 24px;
}
.columnSubheading {
background: var(--color-bg-secondary);
padding: 15px 20px;
padding-bottom: 12px;
font-size: 15px;
font-weight: 500;
&:focus-visible {
outline: var(--outline-focus-default);
outline-offset: -2px;
}
}
.displayNameWithAvatar {
display: inline-flex;
gap: 4px;
align-items: baseline;
a {
color: inherit;
text-decoration: underline;
&:hover,
&:focus {
text-decoration: none;
}
}
> :global(.account__avatar) {
align-self: center;
}
}
.sensitiveWarning {
.accountItemWrapper {
display: flex;
flex-direction: column;
align-items: center;
max-width: 460px;
margin: auto;
padding: 60px 30px;
gap: 20px;
text-align: center;
text-wrap: balance;
font-size: 15px;
line-height: 1.5;
cursor: default;
align-items: start;
padding-block: 16px;
&[data-with-border='true'] {
border-bottom: 1px solid var(--color-border-primary);
}
}
.accountItem {
--account-name-size: 15px;
--account-handle-color: var(--color-text-secondary);
--account-handle-size: 13px;
--account-bio-color: var(--color-text-primary);
--account-bio-size: 13px;
--account-outer-spacing: 0;
flex-grow: 1;
}
.youWereAddedWrapper {
padding-bottom: 16px;
}
.revokeControlWrapper {
@@ -119,9 +103,7 @@
flex-wrap: wrap;
align-items: center;
gap: 10px;
margin-top: -10px;
padding-bottom: 16px;
padding-inline: calc(26px + var(--avatar-width)) 16px;
margin-bottom: 8px;
:global(.button) {
min-width: 30%;

View File

@@ -378,14 +378,13 @@
"collections.description_length_hint": "100 characters limit",
"collections.detail.accept_inclusion": "Okay",
"collections.detail.accounts_heading": "Accounts",
"collections.detail.author_added_you": "{author} added you to this collection",
"collections.detail.curated_by_author": "Curated by {author}",
"collections.detail.curated_by_you": "Curated by you",
"collections.detail.loading": "Loading collection…",
"collections.detail.other_accounts_in_collection": "Others in this collection:",
"collections.detail.other_accounts_count": "{count, plural, one {# other account} other {# other accounts}}",
"collections.detail.revoke_inclusion": "Remove me",
"collections.detail.sensitive_content": "Sensitive content",
"collections.detail.sensitive_note": "This collection contains accounts and content that may be sensitive to some users.",
"collections.detail.share": "Share this collection",
"collections.detail.you_were_added_to_this_collection": "You were added to this collection",
"collections.edit_details": "Edit details",
"collections.error_loading_collections": "There was an error when trying to load your collections.",
"collections.hints.accounts_counter": "{count} / {max} accounts",
@@ -541,6 +540,7 @@
"content_warning.hide": "Hide post",
"content_warning.show": "Show anyway",
"content_warning.show_more": "Show more",
"content_warning.show_short": "Show",
"conversation.delete": "Delete conversation",
"conversation.mark_as_read": "Mark as read",
"conversation.open": "View conversation",

View File

@@ -2011,7 +2011,15 @@ body > [data-popper-placement] {
}
.account {
padding: 16px;
--account-outer-spacing: 16px;
--account-name-color: var(--color-text-primary);
--account-name-size: 14px;
--account-handle-color: var(--color-text-secondary);
--account-handle-size: 14px;
--account-bio-color: var(--color-text-secondary);
--account-bio-size: 14px;
padding: var(--account-outer-spacing);
// Using :where keeps specificity low, allowing for existing
// .account overrides to still apply
@@ -2024,10 +2032,10 @@ body > [data-popper-placement] {
display: flex;
align-items: center;
gap: 10px;
color: var(--color-text-secondary);
color: var(--account-handle-color);
overflow: hidden;
text-decoration: none;
font-size: 14px;
font-size: var(--account-handle-size);
.display-name {
margin-bottom: 4px;
@@ -2035,7 +2043,8 @@ body > [data-popper-placement] {
.display-name strong {
display: inline;
color: var(--color-text-primary);
font-size: var(--account-name-size);
color: var(--account-name-color);
}
}
@@ -2173,7 +2182,7 @@ body > [data-popper-placement] {
}
&__note {
font-size: 14px;
font-size: var(--account-bio-size);
font-weight: 400;
overflow: hidden;
text-overflow: ellipsis;
@@ -2182,7 +2191,7 @@ body > [data-popper-placement] {
-webkit-line-clamp: 1;
-webkit-box-orient: vertical;
margin-top: 10px;
color: var(--color-text-secondary);
color: var(--account-bio-color);
&--missing {
color: var(--color-text-tertiary);