diff --git a/app/javascript/mastodon/api_types/collections.ts b/app/javascript/mastodon/api_types/collections.ts index ec6eaabaa0..23f835f5fc 100644 --- a/app/javascript/mastodon/api_types/collections.ts +++ b/app/javascript/mastodon/api_types/collections.ts @@ -11,14 +11,14 @@ export interface ApiCollectionJSON { account_id: string; id: string; - uri: string; + uri: string | null; local: boolean; item_count: number; name: string; description: string; - tag?: ApiTagJSON; - language: string; + tag: ApiTagJSON | null; + language: string | null; sensitive: boolean; discoverable: boolean; diff --git a/app/javascript/mastodon/components/__tests__/__snapshots__/avatar-test.jsx.snap b/app/javascript/mastodon/components/__tests__/__snapshots__/avatar-test.jsx.snap index c25dc59c86..1b6647f91f 100644 --- a/app/javascript/mastodon/components/__tests__/__snapshots__/avatar-test.jsx.snap +++ b/app/javascript/mastodon/components/__tests__/__snapshots__/avatar-test.jsx.snap @@ -1,7 +1,7 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html exports[` > Autoplay > renders a animated avatar 1`] = ` -
> Autoplay > renders a animated avatar 1`] = ` onLoad={[Function]} src="/animated/alice.gif" /> -
+ `; exports[` > Still > renders a still avatar 1`] = ` -
> Still > renders a still avatar 1`] = ` onLoad={[Function]} src="/static/alice.jpg" /> -
+ `; diff --git a/app/javascript/mastodon/components/account/index.tsx b/app/javascript/mastodon/components/account/index.tsx index dafad0f707..7cceb2ce25 100644 --- a/app/javascript/mastodon/components/account/index.tsx +++ b/app/javascript/mastodon/components/account/index.tsx @@ -297,7 +297,7 @@ export const Account: React.FC = ({ >
= ({ }, [setError]); const avatar = ( -
= ({ )} {counter && ( -
{counter} -
+ )} -
+ ); if (withLink) { diff --git a/app/javascript/mastodon/features/collections/styles.module.scss b/app/javascript/mastodon/features/collections/detail/collection_list_item.module.scss similarity index 80% rename from app/javascript/mastodon/features/collections/styles.module.scss rename to app/javascript/mastodon/features/collections/detail/collection_list_item.module.scss index ebcab70d59..74eac9f3e1 100644 --- a/app/javascript/mastodon/features/collections/styles.module.scss +++ b/app/javascript/mastodon/features/collections/detail/collection_list_item.module.scss @@ -1,4 +1,4 @@ -.collectionItemWrapper { +.wrapper { display: flex; align-items: center; gap: 16px; @@ -7,13 +7,13 @@ border-bottom: 1px solid var(--color-border-primary); } -.collectionItemContent { +.content { position: relative; flex-grow: 1; padding: 15px 5px; } -.collectionItemLink { +.link { display: block; margin-bottom: 2px; font-size: 15px; @@ -33,16 +33,19 @@ } } -.collectionItemInfo { +.info { + font-size: 13px; + color: var(--color-text-secondary); +} + +.metaList { --gap: 0.75ch; display: flex; gap: var(--gap); - font-size: 13px; - color: var(--color-text-secondary); - & > li:not(:first-child)::before { + & > li:not(:last-child)::after { content: '·'; - margin-inline-end: var(--gap); + margin-inline-start: var(--gap); } } diff --git a/app/javascript/mastodon/features/collections/detail/collection_list_item.tsx b/app/javascript/mastodon/features/collections/detail/collection_list_item.tsx new file mode 100644 index 0000000000..51a7e67254 --- /dev/null +++ b/app/javascript/mastodon/features/collections/detail/collection_list_item.tsx @@ -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 ( +
    + + + ), + }} + tagName='li' + /> +
+ ); +}; + +export const CollectionListItem: React.FC<{ + collection: ApiCollectionJSON; +}> = ({ collection }) => { + const { id, name } = collection; + const linkId = useId(); + + return ( +
+
+

+ + {name} + +

+ +
+ + +
+ ); +}; diff --git a/app/javascript/mastodon/features/collections/detail/collection_menu.tsx b/app/javascript/mastodon/features/collections/detail/collection_menu.tsx new file mode 100644 index 0000000000..cf65be5496 --- /dev/null +++ b/app/javascript/mastodon/features/collections/detail/collection_menu.tsx @@ -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 ( + + + + ); +}; diff --git a/app/javascript/mastodon/features/collections/detail/index.tsx b/app/javascript/mastodon/features/collections/detail/index.tsx new file mode 100644 index 0000000000..7b04800aab --- /dev/null +++ b/app/javascript/mastodon/features/collections/detail/index.tsx @@ -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 = ( + + + + + ); + + if (id === me) { + return ( +

+ +

+ ); + } + return ( +

+ +

+ ); +}; + +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 ( +
+
+
+ {tag && ( + // TODO: Make non-interactive tag component + + )} +

{name}

+
+
+ + +
+
+ {description &&

{description}

} + + +

{intl.formatMessage(messages.accounts)}

+
+ ); +}; + +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 ( + + + + : null + } + > + {collection?.items.map(({ account_id }) => + account_id ? ( + + ) : null, + )} + + + + {pageTitle} + + + + ); +}; diff --git a/app/javascript/mastodon/features/collections/detail/styles.module.scss b/app/javascript/mastodon/features/collections/detail/styles.module.scss new file mode 100644 index 0000000000..cb94f2894c --- /dev/null +++ b/app/javascript/mastodon/features/collections/detail/styles.module.scss @@ -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; + } +} diff --git a/app/javascript/mastodon/features/collections/editor/details.tsx b/app/javascript/mastodon/features/collections/editor/details.tsx index f931a42c6c..9d5a94b8aa 100644 --- a/app/javascript/mastodon/features/collections/editor/details.tsx +++ b/app/javascript/mastodon/features/collections/editor/details.tsx @@ -68,7 +68,7 @@ export const CollectionDetails: React.FC<{ }; void dispatch(updateCollection({ payload })).then(() => { - history.push(`/collections`); + history.push(`/collections/${id}`); }); } else { const payload: Partial = { diff --git a/app/javascript/mastodon/features/collections/editor/settings.tsx b/app/javascript/mastodon/features/collections/editor/settings.tsx index 10fb295f83..184b51187c 100644 --- a/app/javascript/mastodon/features/collections/editor/settings.tsx +++ b/app/javascript/mastodon/features/collections/editor/settings.tsx @@ -68,7 +68,7 @@ export const CollectionSettings: React.FC<{ }; void dispatch(updateCollection({ payload })).then(() => { - history.push(`/collections`); + history.push(`/collections/${id}`); }); } else { const payload: ApiCreateCollectionPayload = { diff --git a/app/javascript/mastodon/features/collections/index.tsx b/app/javascript/mastodon/features/collections/index.tsx index dd26174f38..607d7fe4f3 100644 --- a/app/javascript/mastodon/features/collections/index.tsx +++ b/app/javascript/mastodon/features/collections/index.tsx @@ -1,22 +1,16 @@ -import { useEffect, useMemo, useCallback, useId } from 'react'; +import { useEffect } from 'react'; import { defineMessages, useIntl, FormattedMessage } from 'react-intl'; -import classNames from 'classnames'; import { Helmet } from 'react-helmet'; import { Link } from 'react-router-dom'; import AddIcon from '@/material-icons/400-24px/add.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 { openModal } from 'mastodon/actions/modal'; -import type { ApiCollectionJSON } from 'mastodon/api_types/collections'; import { Column } from 'mastodon/components/column'; import { ColumnHeader } from 'mastodon/components/column_header'; -import { Dropdown } from 'mastodon/components/dropdown_menu'; import { Icon } from 'mastodon/components/icon'; -import { RelativeTimestamp } from 'mastodon/components/relative_timestamp'; import ScrollableList from 'mastodon/components/scrollable_list'; import { fetchAccountCollections, @@ -24,119 +18,13 @@ import { } from 'mastodon/reducers/slices/collections'; import { useAppSelector, useAppDispatch } from 'mastodon/store'; +import { CollectionListItem } from './detail/collection_list_item'; import { messages as editorMessages } from './editor'; -import classes from './styles.module.scss'; const messages = defineMessages({ 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 ( -
-
-

- - {name} - -

-
    - - - ), - }} - tagName='li' - /> -
-
- - -
- ); -}; - export const Collections: React.FC<{ multiColumn?: boolean; }> = ({ multiColumn }) => { @@ -202,7 +90,7 @@ export const Collections: React.FC<{ bindToDocument={!multiColumn} > {collections.map((item) => ( - + ))} diff --git a/app/javascript/mastodon/features/ui/index.jsx b/app/javascript/mastodon/features/ui/index.jsx index 089e5764bc..5a9cebe5f4 100644 --- a/app/javascript/mastodon/features/ui/index.jsx +++ b/app/javascript/mastodon/features/ui/index.jsx @@ -65,6 +65,7 @@ import { ListEdit, ListMembers, Collections, + CollectionDetail, CollectionsEditor, Blocks, DomainBlocks, @@ -269,12 +270,12 @@ class SwitchingColumnsArea extends PureComponent { {areCollectionsEnabled() && - + [ + , + , + + ] } - {areCollectionsEnabled() && - - } - diff --git a/app/javascript/mastodon/features/ui/util/async-components.js b/app/javascript/mastodon/features/ui/util/async-components.js index 6b0188f9b6..2beedaba26 100644 --- a/app/javascript/mastodon/features/ui/util/async-components.js +++ b/app/javascript/mastodon/features/ui/util/async-components.js @@ -44,13 +44,19 @@ export function Lists () { return import('../../lists'); } -export function Collections () { +export function Collections() { return import('../../collections').then( 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( module => ({default: module.CollectionEditorPage}) ); diff --git a/app/javascript/mastodon/locales/en.json b/app/javascript/mastodon/locales/en.json index 91e0fb79b2..9352ae5166 100644 --- a/app/javascript/mastodon/locales/en.json +++ b/app/javascript/mastodon/locales/en.json @@ -263,6 +263,11 @@ "collections.create_collection": "Create collection", "collections.delete_collection": "Delete collection", "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_settings": "Edit settings", "collections.error_loading_collections": "There was an error when trying to load your collections.", diff --git a/app/javascript/material-icons/400-24px/more_vert-fill.svg b/app/javascript/material-icons/400-24px/more_vert-fill.svg new file mode 100644 index 0000000000..e172f878a6 --- /dev/null +++ b/app/javascript/material-icons/400-24px/more_vert-fill.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/javascript/material-icons/400-24px/more_vert.svg b/app/javascript/material-icons/400-24px/more_vert.svg new file mode 100644 index 0000000000..e172f878a6 --- /dev/null +++ b/app/javascript/material-icons/400-24px/more_vert.svg @@ -0,0 +1 @@ + \ No newline at end of file