Add collection detail page (#37897)

This commit is contained in:
diondiondion 2026-02-18 14:19:39 +01:00 committed by GitHub
parent bd64ca2583
commit 488e0b2617
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
17 changed files with 459 additions and 144 deletions

View File

@ -11,14 +11,14 @@ export interface ApiCollectionJSON {
account_id: string; account_id: string;
id: string; id: string;
uri: string; uri: string | null;
local: boolean; local: boolean;
item_count: number; item_count: number;
name: string; name: string;
description: string; description: string;
tag?: ApiTagJSON; tag: ApiTagJSON | null;
language: string; language: string | null;
sensitive: boolean; sensitive: boolean;
discoverable: boolean; discoverable: boolean;

View File

@ -1,7 +1,7 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`<Avatar /> > Autoplay > renders a animated avatar 1`] = ` exports[`<Avatar /> > Autoplay > renders a animated avatar 1`] = `
<div <span
className="account__avatar account__avatar--loading" className="account__avatar account__avatar--loading"
onMouseEnter={[Function]} onMouseEnter={[Function]}
onMouseLeave={[Function]} onMouseLeave={[Function]}
@ -18,11 +18,11 @@ exports[`<Avatar /> > Autoplay > renders a animated avatar 1`] = `
onLoad={[Function]} onLoad={[Function]}
src="/animated/alice.gif" src="/animated/alice.gif"
/> />
</div> </span>
`; `;
exports[`<Avatar /> > Still > renders a still avatar 1`] = ` exports[`<Avatar /> > Still > renders a still avatar 1`] = `
<div <span
className="account__avatar account__avatar--loading" className="account__avatar account__avatar--loading"
onMouseEnter={[Function]} onMouseEnter={[Function]}
onMouseLeave={[Function]} onMouseLeave={[Function]}
@ -39,5 +39,5 @@ exports[`<Avatar /> > Still > renders a still avatar 1`] = `
onLoad={[Function]} onLoad={[Function]}
src="/static/alice.jpg" src="/static/alice.jpg"
/> />
</div> </span>
`; `;

View File

@ -297,7 +297,7 @@ export const Account: React.FC<AccountProps> = ({
> >
<div className='account__info-wrapper'> <div className='account__info-wrapper'>
<Link <Link
className='account__display-name' className='account__display-name focusable'
title={account?.acct} title={account?.acct}
to={`/@${account?.acct}`} to={`/@${account?.acct}`}
data-hover-card-account={id} data-hover-card-account={id}

View File

@ -53,7 +53,7 @@ export const Avatar: React.FC<Props> = ({
}, [setError]); }, [setError]);
const avatar = ( const avatar = (
<div <span
className={classNames(className, 'account__avatar', { className={classNames(className, 'account__avatar', {
'account__avatar--inline': inline, 'account__avatar--inline': inline,
'account__avatar--loading': loading, 'account__avatar--loading': loading,
@ -67,14 +67,14 @@ export const Avatar: React.FC<Props> = ({
)} )}
{counter && ( {counter && (
<div <span
className='account__avatar__counter' className='account__avatar__counter'
style={{ borderColor: counterBorderColor }} style={{ borderColor: counterBorderColor }}
> >
{counter} {counter}
</div> </span>
)} )}
</div> </span>
); );
if (withLink) { if (withLink) {

View File

@ -1,4 +1,4 @@
.collectionItemWrapper { .wrapper {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 16px; gap: 16px;
@ -7,13 +7,13 @@
border-bottom: 1px solid var(--color-border-primary); border-bottom: 1px solid var(--color-border-primary);
} }
.collectionItemContent { .content {
position: relative; position: relative;
flex-grow: 1; flex-grow: 1;
padding: 15px 5px; padding: 15px 5px;
} }
.collectionItemLink { .link {
display: block; display: block;
margin-bottom: 2px; margin-bottom: 2px;
font-size: 15px; font-size: 15px;
@ -33,16 +33,19 @@
} }
} }
.collectionItemInfo { .info {
font-size: 13px;
color: var(--color-text-secondary);
}
.metaList {
--gap: 0.75ch; --gap: 0.75ch;
display: flex; display: flex;
gap: var(--gap); gap: var(--gap);
font-size: 13px;
color: var(--color-text-secondary);
& > li:not(:first-child)::before { & > li:not(:last-child)::after {
content: '·'; content: '·';
margin-inline-end: var(--gap); margin-inline-start: var(--gap);
} }
} }

View File

@ -0,0 +1,67 @@
import { useId } from 'react';
import { FormattedMessage } from 'react-intl';
import classNames from 'classnames';
import { Link } from 'react-router-dom';
import type { ApiCollectionJSON } from 'mastodon/api_types/collections';
import { RelativeTimestamp } from 'mastodon/components/relative_timestamp';
import classes from './collection_list_item.module.scss';
import { CollectionMenu } from './collection_menu';
export const CollectionMetaData: React.FC<{
collection: ApiCollectionJSON;
className?: string;
}> = ({ collection, className }) => {
return (
<ul className={classNames(classes.metaList, className)}>
<FormattedMessage
id='collections.account_count'
defaultMessage='{count, plural, one {# account} other {# accounts}}'
values={{ count: collection.item_count }}
tagName='li'
/>
<FormattedMessage
id='collections.last_updated_at'
defaultMessage='Last updated: {date}'
values={{
date: (
<RelativeTimestamp
timestamp={collection.updated_at}
short={false}
/>
),
}}
tagName='li'
/>
</ul>
);
};
export const CollectionListItem: React.FC<{
collection: ApiCollectionJSON;
}> = ({ collection }) => {
const { id, name } = collection;
const linkId = useId();
return (
<article
className={classNames(classes.wrapper, 'focusable')}
tabIndex={-1}
aria-labelledby={linkId}
>
<div className={classes.content}>
<h2 id={linkId}>
<Link to={`/collections/${id}`} className={classes.link}>
{name}
</Link>
</h2>
<CollectionMetaData collection={collection} className={classes.info} />
</div>
<CollectionMenu context='list' collection={collection} />
</article>
);
};

View File

@ -0,0 +1,91 @@
import { useCallback, useMemo } from 'react';
import { defineMessages, useIntl } from 'react-intl';
import MoreVertIcon from '@/material-icons/400-24px/more_vert.svg?react';
import { openModal } from 'mastodon/actions/modal';
import type { ApiCollectionJSON } from 'mastodon/api_types/collections';
import { Dropdown } from 'mastodon/components/dropdown_menu';
import { IconButton } from 'mastodon/components/icon_button';
import { useAppDispatch } from 'mastodon/store';
import { messages as editorMessages } from '../editor';
const messages = defineMessages({
view: {
id: 'collections.view_collection',
defaultMessage: 'View collection',
},
delete: {
id: 'collections.delete_collection',
defaultMessage: 'Delete collection',
},
more: { id: 'status.more', defaultMessage: 'More' },
});
export const CollectionMenu: React.FC<{
collection: ApiCollectionJSON;
context: 'list' | 'collection';
className?: string;
}> = ({ collection, context, className }) => {
const dispatch = useAppDispatch();
const intl = useIntl();
const { id, name } = collection;
const handleDeleteClick = useCallback(() => {
dispatch(
openModal({
modalType: 'CONFIRM_DELETE_COLLECTION',
modalProps: {
name,
id,
},
}),
);
}, [dispatch, id, name]);
const menu = useMemo(() => {
const commonItems = [
{
text: intl.formatMessage(editorMessages.manageAccounts),
to: `/collections/${id}/edit`,
},
{
text: intl.formatMessage(editorMessages.editDetails),
to: `/collections/${id}/edit/details`,
},
{
text: intl.formatMessage(editorMessages.editSettings),
to: `/collections/${id}/edit/settings`,
},
null,
{
text: intl.formatMessage(messages.delete),
action: handleDeleteClick,
dangerous: true,
},
];
if (context === 'list') {
return [
{ text: intl.formatMessage(messages.view), to: `/collections/${id}` },
null,
...commonItems,
];
} else {
return commonItems;
}
}, [intl, id, handleDeleteClick, context]);
return (
<Dropdown scrollKey='collections' items={menu}>
<IconButton
icon='menu-icon'
iconComponent={MoreVertIcon}
title={intl.formatMessage(messages.more)}
className={className}
/>
</Dropdown>
);
};

View File

@ -0,0 +1,178 @@
import { useCallback, useEffect } from 'react';
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
import { Helmet } from 'react-helmet';
import { useParams } from 'react-router';
import ListAltIcon from '@/material-icons/400-24px/list_alt.svg?react';
import ShareIcon from '@/material-icons/400-24px/share.svg?react';
import { showAlert } from 'mastodon/actions/alerts';
import type { ApiCollectionJSON } from 'mastodon/api_types/collections';
import { Account } from 'mastodon/components/account';
import { Avatar } from 'mastodon/components/avatar';
import { Column } from 'mastodon/components/column';
import { ColumnHeader } from 'mastodon/components/column_header';
import { LinkedDisplayName } from 'mastodon/components/display_name';
import { IconButton } from 'mastodon/components/icon_button';
import ScrollableList from 'mastodon/components/scrollable_list';
import { Tag } from 'mastodon/components/tags/tag';
import { useAccount } from 'mastodon/hooks/useAccount';
import { me } from 'mastodon/initial_state';
import { fetchCollection } from 'mastodon/reducers/slices/collections';
import { useAppDispatch, useAppSelector } from 'mastodon/store';
import { CollectionMetaData } from './collection_list_item';
import { CollectionMenu } from './collection_menu';
import classes from './styles.module.scss';
const messages = defineMessages({
empty: {
id: 'collections.accounts.empty_title',
defaultMessage: 'This collection is empty',
},
loading: {
id: 'collections.detail.loading',
defaultMessage: 'Loading collection…',
},
share: {
id: 'collections.detail.share',
defaultMessage: 'Share this collection',
},
accounts: {
id: 'collections.detail.accounts_heading',
defaultMessage: 'Accounts',
},
});
const AuthorNote: React.FC<{ id: string }> = ({ id }) => {
const account = useAccount(id);
const author = (
<span className={classes.displayNameWithAvatar}>
<Avatar size={18} account={account} />
<LinkedDisplayName displayProps={{ account, variant: 'simple' }} />
</span>
);
if (id === me) {
return (
<p className={classes.authorNote}>
<FormattedMessage
id='collections.detail.curated_by_you'
defaultMessage='Curated by you'
/>
</p>
);
}
return (
<p className={classes.authorNote}>
<FormattedMessage
id='collections.detail.curated_by_author'
defaultMessage='Curated by {author}'
values={{ author }}
/>
</p>
);
};
const CollectionHeader: React.FC<{ collection: ApiCollectionJSON }> = ({
collection,
}) => {
const intl = useIntl();
const { name, description, tag } = collection;
const dispatch = useAppDispatch();
const handleShare = useCallback(() => {
dispatch(showAlert({ message: 'Collection sharing not yet implemented' }));
}, [dispatch]);
return (
<div 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} />
)}
<h2 className={classes.name}>{name}</h2>
</div>
<div className={classes.headerButtonWrapper}>
<IconButton
iconComponent={ShareIcon}
icon='share-icon'
title={intl.formatMessage(messages.share)}
className={classes.iconButton}
onClick={handleShare}
/>
<CollectionMenu
context='collection'
collection={collection}
className={classes.iconButton}
/>
</div>
</div>
{description && <p className={classes.description}>{description}</p>}
<AuthorNote id={collection.account_id} />
<CollectionMetaData
collection={collection}
className={classes.metaData}
/>
<h2 className='sr-only'>{intl.formatMessage(messages.accounts)}</h2>
</div>
);
};
export const CollectionDetailPage: React.FC<{
multiColumn?: boolean;
}> = ({ multiColumn }) => {
const intl = useIntl();
const dispatch = useAppDispatch();
const { id } = useParams<{ id?: string }>();
const collection = useAppSelector((state) =>
id ? state.collections.collections[id] : undefined,
);
const isLoading = !!id && !collection;
useEffect(() => {
if (id) {
void dispatch(fetchCollection({ collectionId: id }));
}
}, [dispatch, id]);
const pageTitle = collection?.name ?? intl.formatMessage(messages.loading);
return (
<Column bindToDocument={!multiColumn} label={pageTitle}>
<ColumnHeader
showBackButton
title={pageTitle}
icon='collection-icon'
iconComponent={ListAltIcon}
multiColumn={multiColumn}
/>
<ScrollableList
scrollKey='collection-detail'
emptyMessage={intl.formatMessage(messages.empty)}
showLoading={isLoading}
bindToDocument={!multiColumn}
alwaysPrepend
prepend={
collection ? <CollectionHeader collection={collection} /> : null
}
>
{collection?.items.map(({ account_id }) =>
account_id ? (
<Account key={account_id} minimal id={account_id} />
) : null,
)}
</ScrollableList>
<Helmet>
<title>{pageTitle}</title>
<meta name='robots' content='noindex' />
</Helmet>
</Column>
);
};

View File

@ -0,0 +1,74 @@
.header {
padding: 16px;
border-bottom: 1px solid var(--color-border-primary);
}
.titleWithMenu {
display: flex;
align-items: start;
gap: 10px;
}
.titleWrapper {
flex-grow: 1;
min-width: 0;
}
.tag {
margin-bottom: 4px;
margin-inline-start: -8px;
}
.name {
font-size: 28px;
line-height: 1.2;
overflow-wrap: anywhere;
}
.description {
font-size: 15px;
margin-top: 8px;
}
.headerButtonWrapper {
display: flex;
gap: 8px;
}
.iconButton {
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);
}
.metaData {
margin-top: 16px;
font-size: 15px;
}
.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;
}
}

View File

@ -68,7 +68,7 @@ export const CollectionDetails: React.FC<{
}; };
void dispatch(updateCollection({ payload })).then(() => { void dispatch(updateCollection({ payload })).then(() => {
history.push(`/collections`); history.push(`/collections/${id}`);
}); });
} else { } else {
const payload: Partial<ApiCreateCollectionPayload> = { const payload: Partial<ApiCreateCollectionPayload> = {

View File

@ -68,7 +68,7 @@ export const CollectionSettings: React.FC<{
}; };
void dispatch(updateCollection({ payload })).then(() => { void dispatch(updateCollection({ payload })).then(() => {
history.push(`/collections`); history.push(`/collections/${id}`);
}); });
} else { } else {
const payload: ApiCreateCollectionPayload = { const payload: ApiCreateCollectionPayload = {

View File

@ -1,22 +1,16 @@
import { useEffect, useMemo, useCallback, useId } from 'react'; import { useEffect } from 'react';
import { defineMessages, useIntl, FormattedMessage } from 'react-intl'; import { defineMessages, useIntl, FormattedMessage } from 'react-intl';
import classNames from 'classnames';
import { Helmet } from 'react-helmet'; import { Helmet } from 'react-helmet';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import AddIcon from '@/material-icons/400-24px/add.svg?react'; import AddIcon from '@/material-icons/400-24px/add.svg?react';
import ListAltIcon from '@/material-icons/400-24px/list_alt.svg?react'; import ListAltIcon from '@/material-icons/400-24px/list_alt.svg?react';
import MoreHorizIcon from '@/material-icons/400-24px/more_horiz.svg?react';
import SquigglyArrow from '@/svg-icons/squiggly_arrow.svg?react'; import SquigglyArrow from '@/svg-icons/squiggly_arrow.svg?react';
import { openModal } from 'mastodon/actions/modal';
import type { ApiCollectionJSON } from 'mastodon/api_types/collections';
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 { Dropdown } from 'mastodon/components/dropdown_menu';
import { Icon } from 'mastodon/components/icon'; import { Icon } from 'mastodon/components/icon';
import { RelativeTimestamp } from 'mastodon/components/relative_timestamp';
import ScrollableList from 'mastodon/components/scrollable_list'; import ScrollableList from 'mastodon/components/scrollable_list';
import { import {
fetchAccountCollections, fetchAccountCollections,
@ -24,119 +18,13 @@ import {
} from 'mastodon/reducers/slices/collections'; } from 'mastodon/reducers/slices/collections';
import { useAppSelector, useAppDispatch } from 'mastodon/store'; import { useAppSelector, useAppDispatch } from 'mastodon/store';
import { CollectionListItem } from './detail/collection_list_item';
import { messages as editorMessages } from './editor'; import { messages as editorMessages } from './editor';
import classes from './styles.module.scss';
const messages = defineMessages({ const messages = defineMessages({
heading: { id: 'column.collections', defaultMessage: 'My collections' }, heading: { id: 'column.collections', defaultMessage: 'My collections' },
view: {
id: 'collections.view_collection',
defaultMessage: 'View collection',
},
delete: {
id: 'collections.delete_collection',
defaultMessage: 'Delete collection',
},
more: { id: 'status.more', defaultMessage: 'More' },
}); });
const CollectionItem: React.FC<{
collection: ApiCollectionJSON;
}> = ({ collection }) => {
const dispatch = useAppDispatch();
const intl = useIntl();
const { id, name } = collection;
const handleDeleteClick = useCallback(() => {
dispatch(
openModal({
modalType: 'CONFIRM_DELETE_COLLECTION',
modalProps: {
name,
id,
},
}),
);
}, [dispatch, id, name]);
const menu = useMemo(
() => [
{ text: intl.formatMessage(messages.view), to: `/collections/${id}` },
null,
{
text: intl.formatMessage(editorMessages.manageAccounts),
to: `/collections/${id}/edit`,
},
{
text: intl.formatMessage(editorMessages.editDetails),
to: `/collections/${id}/edit/details`,
},
{
text: intl.formatMessage(editorMessages.editSettings),
to: `/collections/${id}/edit/settings`,
},
null,
{
text: intl.formatMessage(messages.delete),
action: handleDeleteClick,
dangerous: true,
},
],
[intl, id, handleDeleteClick],
);
const linkId = useId();
return (
<article
className={classNames(classes.collectionItemWrapper, 'focusable')}
tabIndex={-1}
aria-labelledby={linkId}
>
<div className={classes.collectionItemContent}>
<h2 id={linkId}>
<Link
to={`/collections/${id}/edit/details`}
className={classes.collectionItemLink}
>
{name}
</Link>
</h2>
<ul className={classes.collectionItemInfo}>
<FormattedMessage
id='collections.account_count'
defaultMessage='{count, plural, one {# account} other {# accounts}}'
values={{ count: collection.item_count }}
tagName='li'
/>
<FormattedMessage
id='collections.last_updated_at'
defaultMessage='Last updated: {date}'
values={{
date: (
<RelativeTimestamp
timestamp={collection.updated_at}
short={false}
/>
),
}}
tagName='li'
/>
</ul>
</div>
<Dropdown
scrollKey='collections'
items={menu}
icon='ellipsis-h'
iconComponent={MoreHorizIcon}
title={intl.formatMessage(messages.more)}
/>
</article>
);
};
export const Collections: React.FC<{ export const Collections: React.FC<{
multiColumn?: boolean; multiColumn?: boolean;
}> = ({ multiColumn }) => { }> = ({ multiColumn }) => {
@ -202,7 +90,7 @@ export const Collections: React.FC<{
bindToDocument={!multiColumn} bindToDocument={!multiColumn}
> >
{collections.map((item) => ( {collections.map((item) => (
<CollectionItem key={item.id} collection={item} /> <CollectionListItem key={item.id} collection={item} />
))} ))}
</ScrollableList> </ScrollableList>

View File

@ -65,6 +65,7 @@ import {
ListEdit, ListEdit,
ListMembers, ListMembers,
Collections, Collections,
CollectionDetail,
CollectionsEditor, CollectionsEditor,
Blocks, Blocks,
DomainBlocks, DomainBlocks,
@ -269,12 +270,12 @@ class SwitchingColumnsArea extends PureComponent {
<WrappedRoute path='/mutes' component={Mutes} content={children} /> <WrappedRoute path='/mutes' component={Mutes} content={children} />
<WrappedRoute path='/lists' component={Lists} content={children} /> <WrappedRoute path='/lists' component={Lists} content={children} />
{areCollectionsEnabled() && {areCollectionsEnabled() &&
<WrappedRoute path={['/collections/new', '/collections/:id/edit']} component={CollectionsEditor} content={children} /> [
<WrappedRoute path={['/collections/new', '/collections/:id/edit']} component={CollectionsEditor} content={children} />,
<WrappedRoute path='/collections/:id' component={CollectionDetail} content={children} />,
<WrappedRoute path='/collections' component={Collections} content={children} />
]
} }
{areCollectionsEnabled() &&
<WrappedRoute path='/collections' component={Collections} content={children} />
}
<Route component={BundleColumnError} /> <Route component={BundleColumnError} />
</WrappedSwitch> </WrappedSwitch>
</ColumnsAreaContainer> </ColumnsAreaContainer>

View File

@ -44,13 +44,19 @@ export function Lists () {
return import('../../lists'); return import('../../lists');
} }
export function Collections () { export function Collections() {
return import('../../collections').then( return import('../../collections').then(
module => ({default: module.Collections}) module => ({default: module.Collections})
); );
} }
export function CollectionsEditor () { export function CollectionDetail() {
return import('../../collections/detail/index').then(
module => ({default: module.CollectionDetailPage})
);
}
export function CollectionsEditor() {
return import('../../collections/editor').then( return import('../../collections/editor').then(
module => ({default: module.CollectionEditorPage}) module => ({default: module.CollectionEditorPage})
); );

View File

@ -263,6 +263,11 @@
"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",
"collections.detail.accounts_heading": "Accounts",
"collections.detail.curated_by_author": "Curated by {author}",
"collections.detail.curated_by_you": "Curated by you",
"collections.detail.loading": "Loading collection…",
"collections.detail.share": "Share this collection",
"collections.edit_details": "Edit basic details", "collections.edit_details": "Edit basic details",
"collections.edit_settings": "Edit settings", "collections.edit_settings": "Edit settings",
"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.",

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 -960 960 960" width="24"><path d="M480-160q-33 0-56.5-23.5T400-240q0-33 23.5-56.5T480-320q33 0 56.5 23.5T560-240q0 33-23.5 56.5T480-160Zm0-240q-33 0-56.5-23.5T400-480q0-33 23.5-56.5T480-560q33 0 56.5 23.5T560-480q0 33-23.5 56.5T480-400Zm0-240q-33 0-56.5-23.5T400-720q0-33 23.5-56.5T480-800q33 0 56.5 23.5T560-720q0 33-23.5 56.5T480-640Z"/></svg>

After

Width:  |  Height:  |  Size: 408 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 -960 960 960" width="24"><path d="M480-160q-33 0-56.5-23.5T400-240q0-33 23.5-56.5T480-320q33 0 56.5 23.5T560-240q0 33-23.5 56.5T480-160Zm0-240q-33 0-56.5-23.5T400-480q0-33 23.5-56.5T480-560q33 0 56.5 23.5T560-480q0 33-23.5 56.5T480-400Zm0-240q-33 0-56.5-23.5T400-720q0-33 23.5-56.5T480-800q33 0 56.5 23.5T560-720q0 33-23.5 56.5T480-640Z"/></svg>

After

Width:  |  Height:  |  Size: 408 B