Add collection detail page (#37897)
This commit is contained in:
parent
bd64ca2583
commit
488e0b2617
@ -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;
|
||||||
|
|
||||||
|
|||||||
@ -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>
|
||||||
`;
|
`;
|
||||||
|
|||||||
@ -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}
|
||||||
|
|||||||
@ -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) {
|
||||||
|
|||||||
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
178
app/javascript/mastodon/features/collections/detail/index.tsx
Normal file
178
app/javascript/mastodon/features/collections/detail/index.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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> = {
|
||||||
|
|||||||
@ -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 = {
|
||||||
|
|||||||
@ -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>
|
||||||
|
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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})
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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.",
|
||||||
|
|||||||
@ -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 |
1
app/javascript/material-icons/400-24px/more_vert.svg
Normal file
1
app/javascript/material-icons/400-24px/more_vert.svg
Normal 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 |
Loading…
x
Reference in New Issue
Block a user