diff --git a/app/javascript/flavours/glitch/api/collections.ts b/app/javascript/flavours/glitch/api/collections.ts index 1aace93d2d..76a29f2d66 100644 --- a/app/javascript/flavours/glitch/api/collections.ts +++ b/app/javascript/flavours/glitch/api/collections.ts @@ -34,11 +34,16 @@ export const apiGetCollection = (collectionId: string) => `v1_alpha/collections/${collectionId}`, ); -export const apiGetAccountCollections = (accountId: string) => +export const apiGetCollectionsCreatedByAccount = (accountId: string) => apiRequestGet( `v1_alpha/accounts/${accountId}/collections`, ); +export const apiGetCollectionsFeaturingAccount = (accountId: string) => + apiRequestGet( + `v1_alpha/accounts/${accountId}/in_collections`, + ); + export const apiAddCollectionItem = (collectionId: string, accountId: string) => apiRequestPost( `v1_alpha/collections/${collectionId}/items`, diff --git a/app/javascript/flavours/glitch/components/empty_state/empty_state.module.scss b/app/javascript/flavours/glitch/components/empty_state/empty_state.module.scss index b58c565ac5..96aea81d1c 100644 --- a/app/javascript/flavours/glitch/components/empty_state/empty_state.module.scss +++ b/app/javascript/flavours/glitch/components/empty_state/empty_state.module.scss @@ -2,6 +2,7 @@ display: flex; flex-direction: column; align-items: center; + justify-content: center; max-width: 600px; padding: 24px; gap: 16px; diff --git a/app/javascript/flavours/glitch/components/empty_state/index.tsx b/app/javascript/flavours/glitch/components/empty_state/index.tsx index 0ef0d67995..e332aaedb5 100644 --- a/app/javascript/flavours/glitch/components/empty_state/index.tsx +++ b/app/javascript/flavours/glitch/components/empty_state/index.tsx @@ -1,5 +1,7 @@ import { FormattedMessage } from 'react-intl'; +import classNames from 'classnames'; + import ElephantImage from '@/images/elephant_ui.svg?react'; import classes from './empty_state.module.scss'; @@ -19,6 +21,7 @@ export const EmptyState: React.FC<{ title?: React.ReactNode; message?: React.ReactNode; children?: React.ReactNode; + className?: string; }> = ({ image = 'default', title = ( @@ -26,11 +29,12 @@ export const EmptyState: React.FC<{ ), message, children, + className, }) => { const imageToRender = typeof image === 'string' ? images[image] : image; return ( -
+
{(title || message || imageToRender) && (
{imageToRender} diff --git a/app/javascript/flavours/glitch/components/tab_list/styles.module.scss b/app/javascript/flavours/glitch/components/tab_list/styles.module.scss index aeadd95f08..95a71114fd 100644 --- a/app/javascript/flavours/glitch/components/tab_list/styles.module.scss +++ b/app/javascript/flavours/glitch/components/tab_list/styles.module.scss @@ -27,6 +27,12 @@ text-decoration: none; } + &:focus { + // Override silly global border radius on focused links + border-radius: 0; + outline-offset: 2px; + } + &:not(:global(.active)):is(:hover, :focus) { color: var(--color-text-brand-soft); } diff --git a/app/javascript/flavours/glitch/features/account_featured/index.tsx b/app/javascript/flavours/glitch/features/account_featured/index.tsx index f38a4eda40..d76a6de0ee 100644 --- a/app/javascript/flavours/glitch/features/account_featured/index.tsx +++ b/app/javascript/flavours/glitch/features/account_featured/index.tsx @@ -27,8 +27,8 @@ import { useAccountVisibility } from '@/flavours/glitch/hooks/useAccountVisibili import { useAppDispatch, useAppSelector } from '@/flavours/glitch/store'; import AddIcon from '@/material-icons/400-24px/add.svg?react'; -import { useAccountCollections } from '../collections'; import { CollectionListItem } from '../collections/components/collection_list_item'; +import { useCollectionsCreatedBy } from '../collections/overview/created_by_you'; import { areCollectionsEnabled } from '../collections/utils'; import { EmptyMessage } from './components/empty_message'; @@ -67,7 +67,7 @@ const AccountFeatured: React.FC<{ multiColumn: boolean }> = ({ ) as ImmutableList, ); const { collections, status: collectionsLoadStatus } = - useAccountCollections(accountId); + useCollectionsCreatedBy(accountId); const { listedCollections = [], unlistedCollections = [] } = Object.groupBy( collections, diff --git a/app/javascript/flavours/glitch/features/collections/components/collection_lockup.tsx b/app/javascript/flavours/glitch/features/collections/components/collection_lockup.tsx index 1f006252ad..1aa95c3383 100644 --- a/app/javascript/flavours/glitch/features/collections/components/collection_lockup.tsx +++ b/app/javascript/flavours/glitch/features/collections/components/collection_lockup.tsx @@ -114,7 +114,7 @@ export const CollectionLockup: React.FC = ({ sideContent={sideContent} > diff --git a/app/javascript/flavours/glitch/features/collections/editor/index.tsx b/app/javascript/flavours/glitch/features/collections/editor/index.tsx index f994039ff2..c50edc8ba7 100644 --- a/app/javascript/flavours/glitch/features/collections/editor/index.tsx +++ b/app/javascript/flavours/glitch/features/collections/editor/index.tsx @@ -25,7 +25,7 @@ import { } from 'flavours/glitch/reducers/slices/collections'; import { useAppDispatch, useAppSelector } from 'flavours/glitch/store'; -import { useAccountCollections } from '..'; +import { useCollectionsCreatedBy } from '../overview/created_by_you'; import { CollectionAccounts } from './accounts'; import { CollectionDetails } from './details'; @@ -86,7 +86,7 @@ export const CollectionEditorPage: React.FC<{ // When creating a new collection, we load the current account's collections // to determine if they're allowed to create more. const { collections: collectionList, status: collectionListStatus } = - useAccountCollections(isEditMode ? null : accountId); + useCollectionsCreatedBy(isEditMode ? null : accountId); const isLoading = (isEditMode && !collection) || diff --git a/app/javascript/flavours/glitch/features/collections/index.tsx b/app/javascript/flavours/glitch/features/collections/index.tsx index 6be1ef9158..b38978f313 100644 --- a/app/javascript/flavours/glitch/features/collections/index.tsx +++ b/app/javascript/flavours/glitch/features/collections/index.tsx @@ -1,41 +1,22 @@ -import { useEffect } from 'react'; - -import { defineMessages, useIntl, FormattedMessage } from 'react-intl'; +import { defineMessages, useIntl } from 'react-intl'; import { Helmet } from 'react-helmet'; -import { Link } from 'react-router-dom'; +import { Route, Switch, useRouteMatch } from 'react-router-dom'; -import { EmptyState } from '@/flavours/glitch/components/empty_state'; -import { LoadingIndicator } from '@/flavours/glitch/components/loading_indicator'; import { TabLink, TabList } from '@/flavours/glitch/components/tab_list'; -import AddIcon from '@/material-icons/400-24px/add.svg?react'; import { Column } from 'flavours/glitch/components/column'; import { ColumnHeader } from 'flavours/glitch/components/column_header'; import { DisplayNameSimple } from 'flavours/glitch/components/display_name/simple'; -import { Icon } from 'flavours/glitch/components/icon'; -import { - ItemList, - Scrollable, -} from 'flavours/glitch/components/scrollable_list/components'; +import { Scrollable } from 'flavours/glitch/components/scrollable_list/components'; import { useAccount } from 'flavours/glitch/hooks/useAccount'; import { useAccountId, useCurrentAccountId, } from 'flavours/glitch/hooks/useAccountId'; -import { - fetchAccountCollections, - selectAccountCollections, -} from 'flavours/glitch/reducers/slices/collections'; -import { useAppSelector, useAppDispatch } from 'flavours/glitch/store'; -import { CollectionListItem } from './components/collection_list_item'; -import { - messages as editorMessages, - MaxCollectionsCallout, - userCollectionLimit, -} from './editor'; +import { CollectionsCreatedByYou } from './overview/created_by_you'; +import { CollectionsFeaturingYou } from './overview/featuring_you'; import classes from './styles.module.scss'; -import { areCollectionsEnabled } from './utils'; const messages = defineMessages({ headingMe: { @@ -60,25 +41,6 @@ const messages = defineMessages({ }, }); -const CreateButton: React.FC = () => ( - - - - -); - -export function useAccountCollections(accountId: string | null | undefined) { - const dispatch = useAppDispatch(); - - useEffect(() => { - if (accountId && areCollectionsEnabled()) { - void dispatch(fetchAccountCollections({ accountId })); - } - }, [dispatch, accountId]); - - return useAppSelector((state) => selectAccountCollections(state, accountId)); -} - export const Collections: React.FC<{ multiColumn?: boolean; }> = ({ multiColumn }) => { @@ -86,15 +48,11 @@ export const Collections: React.FC<{ const me = useCurrentAccountId(); const accountId = useAccountId(); const account = useAccount(accountId); + const { path } = useRouteMatch(); - const { collections, status } = useAccountCollections(accountId); + const isOwnCollectionsPage = accountId === me; - const canCreateMoreCollections = collections.length < userCollectionLimit; - const isOwnCollection = accountId === me; - const showCreateButton = - isOwnCollection && status === 'idle' && canCreateMoreCollections; - - const titleMessage = isOwnCollection + const titleMessage = isOwnCollectionsPage ? messages.headingMe : messages.headingOther; @@ -105,18 +63,10 @@ export const Collections: React.FC<{ name: , }); - const tabMessage = isOwnCollection + const createdByTabMessage = isOwnCollectionsPage ? messages.createdByYou : messages.createdByAuthor; - const errorMessage = (status === 'error' || !accountId) && ( - - ); - return ( @@ -126,64 +76,28 @@ export const Collections: React.FC<{

{pageTitleHtml}

- {intl.formatMessage(tabMessage, { + {intl.formatMessage(createdByTabMessage, { name: , })} + {isOwnCollectionsPage && ( + + {intl.formatMessage(messages.featuringYou)} + + )} - {status === 'loading' && } - {status === 'idle' && - (collections.length > 0 ? ( - <> -
-

- -

- {showCreateButton && } -
- - {!canCreateMoreCollections && ( - - )} - {collections.map((item, index) => ( - - ))} - - - ) : ( - - } - message={ - - } - > - - - ))} + + + + diff --git a/app/javascript/flavours/glitch/features/collections/overview/created_by_you.tsx b/app/javascript/flavours/glitch/features/collections/overview/created_by_you.tsx new file mode 100644 index 0000000000..d7372dbff9 --- /dev/null +++ b/app/javascript/flavours/glitch/features/collections/overview/created_by_you.tsx @@ -0,0 +1,136 @@ +import { useEffect } from 'react'; + +import { FormattedMessage } from 'react-intl'; + +import { Link } from 'react-router-dom'; + +import AddIcon from '@/material-icons/400-24px/add.svg?react'; +import { EmptyState } from 'flavours/glitch/components/empty_state'; +import { Icon } from 'flavours/glitch/components/icon'; +import { LoadingIndicator } from 'flavours/glitch/components/loading_indicator'; +import { ItemList } from 'flavours/glitch/components/scrollable_list/components'; +import { + useAccountId, + useCurrentAccountId, +} from 'flavours/glitch/hooks/useAccountId'; +import { + fetchCollectionsCreatedByAccount, + selectAccountCollections, +} from 'flavours/glitch/reducers/slices/collections'; +import { useAppSelector, useAppDispatch } from 'flavours/glitch/store'; + +import { CollectionListItem } from '../components/collection_list_item'; +import { + messages as editorMessages, + MaxCollectionsCallout, + userCollectionLimit, +} from '../editor'; +import classes from '../styles.module.scss'; +import { areCollectionsEnabled } from '../utils'; + +const CreateButton: React.FC = () => ( + + + + +); + +export const CollectionListError: React.FC = () => ( + + } + /> +); + +export function useCollectionsCreatedBy(accountId: string | null | undefined) { + const dispatch = useAppDispatch(); + + useEffect(() => { + if (accountId && areCollectionsEnabled()) { + void dispatch(fetchCollectionsCreatedByAccount({ accountId })); + } + }, [dispatch, accountId]); + + return useAppSelector((state) => + selectAccountCollections(state, accountId, 'createdBy'), + ); +} + +export const CollectionsCreatedByYou: React.FC = () => { + const me = useCurrentAccountId(); + const accountId = useAccountId(); + + const { collections, status } = useCollectionsCreatedBy(accountId); + + const canCreateMoreCollections = collections.length < userCollectionLimit; + const isOwnCollectionPage = accountId === me; + const showCreateButton = + isOwnCollectionPage && status === 'idle' && canCreateMoreCollections; + + if (status === 'error' || !accountId) { + return ; + } + + if (status === 'loading') { + return ; + } + + if (collections.length === 0) { + return ( + + } + message={ + + } + > + + + ); + } + + return ( + <> +
+

+ +

+ {showCreateButton && } +
+ + {!canCreateMoreCollections && ( + + )} + {collections.map((item, index) => ( + + ))} + + + ); +}; diff --git a/app/javascript/flavours/glitch/features/collections/overview/featuring_you.tsx b/app/javascript/flavours/glitch/features/collections/overview/featuring_you.tsx new file mode 100644 index 0000000000..60425e83f8 --- /dev/null +++ b/app/javascript/flavours/glitch/features/collections/overview/featuring_you.tsx @@ -0,0 +1,87 @@ +import { useEffect } from 'react'; + +import { FormattedMessage } from 'react-intl'; + +import { EmptyState } from 'flavours/glitch/components/empty_state'; +import { LoadingIndicator } from 'flavours/glitch/components/loading_indicator'; +import { ItemList } from 'flavours/glitch/components/scrollable_list/components'; +import { useAccountId } from 'flavours/glitch/hooks/useAccountId'; +import { + fetchCollectionsFeaturingAccount, + selectAccountCollections, +} from 'flavours/glitch/reducers/slices/collections'; +import { useAppSelector, useAppDispatch } from 'flavours/glitch/store'; + +import { CollectionListItem } from '../components/collection_list_item'; +import classes from '../styles.module.scss'; +import { areCollectionsEnabled } from '../utils'; + +import { CollectionListError } from './created_by_you'; + +function useCollectionsFeaturing(accountId: string | null | undefined) { + const dispatch = useAppDispatch(); + + useEffect(() => { + if (accountId && areCollectionsEnabled()) { + void dispatch(fetchCollectionsFeaturingAccount({ accountId })); + } + }, [dispatch, accountId]); + + return useAppSelector((state) => + selectAccountCollections(state, accountId, 'featuring'), + ); +} + +export const CollectionsFeaturingYou: React.FC = () => { + const accountId = useAccountId(); + + const { collections, status } = useCollectionsFeaturing(accountId); + + if (status === 'error' || !accountId) { + return ; + } + + if (status === 'loading') { + return ; + } + + if (collections.length === 0) { + return ( + + } + /> + ); + } + + return ( + <> +
+

+ +

+
+ + {collections.map((item, index) => ( + + ))} + + + ); +}; diff --git a/app/javascript/flavours/glitch/features/collections/styles.module.scss b/app/javascript/flavours/glitch/features/collections/styles.module.scss index 9ded10dcc2..ed2ba13a09 100644 --- a/app/javascript/flavours/glitch/features/collections/styles.module.scss +++ b/app/javascript/flavours/glitch/features/collections/styles.module.scss @@ -33,3 +33,7 @@ .maxCollectionsError { margin: 8px 16px; } + +.error { + flex-grow: 1; +} diff --git a/app/javascript/flavours/glitch/reducers/slices/collections.ts b/app/javascript/flavours/glitch/reducers/slices/collections.ts index a95f19dc3a..1b08694fc8 100644 --- a/app/javascript/flavours/glitch/reducers/slices/collections.ts +++ b/app/javascript/flavours/glitch/reducers/slices/collections.ts @@ -4,7 +4,8 @@ import { createSlice } from '@reduxjs/toolkit'; import { importFetchedAccounts } from '@/flavours/glitch/actions/importer'; import { apiCreateCollection, - apiGetAccountCollections, + apiGetCollectionsCreatedByAccount, + apiGetCollectionsFeaturingAccount, apiUpdateCollection, apiGetCollection, apiDeleteCollection, @@ -28,17 +29,22 @@ import { inputToHashtag } from '@/flavours/glitch/utils/hashtags'; type QueryStatus = 'idle' | 'loading' | 'error'; +// Lists of collection ids and their loading status mapped by account id +type CollectionsByAccountId = Record< + string, + { + collectionIds: string[]; + status: QueryStatus; + } +>; + interface CollectionState { - // Collections mapped by collection id + // Full collections mapped by collection id collections: Record; - // Lists of collection ids mapped by account id - accountCollections: Record< - string, - { - collectionIds: string[]; - status: QueryStatus; - } - >; + // Collections created by an account, mapped by account id + createdBy: CollectionsByAccountId; + // Collections that feature an account, mapped by account id + featuring: CollectionsByAccountId; editor: EditorState; } @@ -70,7 +76,8 @@ interface UpdateEditorFieldPayload { const initialState: CollectionState = { collections: {}, - accountCollections: {}, + createdBy: {}, + featuring: {}, editor: { id: null, name: '', @@ -114,44 +121,101 @@ const collectionSlice = createSlice({ }, extraReducers(builder) { /** - * Fetching account collections + * Fetching collections created by account */ - builder.addCase(fetchAccountCollections.pending, (state, action) => { - const { accountId } = action.meta.arg; - state.accountCollections[accountId] ??= { - status: 'loading', - collectionIds: [], - }; - state.accountCollections[accountId].status = 'loading'; - }); + builder.addCase( + fetchCollectionsCreatedByAccount.pending, + (state, action) => { + const { accountId } = action.meta.arg; + state.createdBy[accountId] ??= { + status: 'loading', + collectionIds: [], + }; + state.createdBy[accountId].status = 'loading'; + }, + ); - builder.addCase(fetchAccountCollections.rejected, (state, action) => { - const { accountId } = action.meta.arg; - state.accountCollections[accountId] = { - status: 'error', - collectionIds: [], - }; - }); + builder.addCase( + fetchCollectionsCreatedByAccount.rejected, + (state, action) => { + const { accountId } = action.meta.arg; + state.createdBy[accountId] = { + status: 'error', + collectionIds: [], + }; + }, + ); - builder.addCase(fetchAccountCollections.fulfilled, (state, action) => { - const { collections } = action.payload; + builder.addCase( + fetchCollectionsCreatedByAccount.fulfilled, + (state, action) => { + const { collections } = action.payload; - const collectionsMap: Record = - state.collections; - const collectionIds: string[] = []; + const collectionsMap: Record = + state.collections; + const collectionIds: string[] = []; - collections.forEach((collection) => { - const { id } = collection; - collectionsMap[id] = collection; - collectionIds.push(id); - }); + collections.forEach((collection) => { + const { id } = collection; + collectionsMap[id] = collection; + collectionIds.push(id); + }); - state.collections = collectionsMap; - state.accountCollections[action.meta.arg.accountId] = { - collectionIds, - status: 'idle', - }; - }); + state.collections = collectionsMap; + state.createdBy[action.meta.arg.accountId] = { + collectionIds, + status: 'idle', + }; + }, + ); + /** + * Fetching collections featuring an account + */ + builder.addCase( + fetchCollectionsFeaturingAccount.pending, + (state, action) => { + const { accountId } = action.meta.arg; + state.featuring[accountId] ??= { + status: 'loading', + collectionIds: [], + }; + state.featuring[accountId].status = 'loading'; + }, + ); + + builder.addCase( + fetchCollectionsFeaturingAccount.rejected, + (state, action) => { + const { accountId } = action.meta.arg; + state.featuring[accountId] = { + status: 'error', + collectionIds: [], + }; + }, + ); + + builder.addCase( + fetchCollectionsFeaturingAccount.fulfilled, + (state, action) => { + const { collections } = action.payload; + + const collectionsMap: Record = + state.collections; + const collectionIds: string[] = []; + + collections.forEach((collection) => { + const { id } = collection; + collectionsMap[id] = collection; + collectionIds.push(id); + }); + + state.collections = collectionsMap; + state.featuring[action.meta.arg.accountId] = { + collectionIds, + status: 'idle', + }; + }, + ); /** * Fetching a single collection @@ -181,7 +245,7 @@ const collectionSlice = createSlice({ // eslint-disable-next-line @typescript-eslint/no-dynamic-delete delete state.collections[collectionId]; if (me) { - let accountCollectionIds = state.accountCollections[me]?.collectionIds; + let accountCollectionIds = state.createdBy[me]?.collectionIds; if (accountCollectionIds) { accountCollectionIds = accountCollectionIds.filter( (id) => id !== collectionId, @@ -200,12 +264,12 @@ const collectionSlice = createSlice({ state.collections[collection.id] = collection; state.editor = initialState.editor; - if (state.accountCollections[collection.account_id]) { - state.accountCollections[collection.account_id]?.collectionIds.unshift( + if (state.createdBy[collection.account_id]) { + state.createdBy[collection.account_id]?.collectionIds.unshift( collection.id, ); } else { - state.accountCollections[collection.account_id] = { + state.createdBy[collection.account_id] = { collectionIds: [collection.id], status: 'idle', }; @@ -253,9 +317,16 @@ const collectionSlice = createSlice({ }, }); -export const fetchAccountCollections = createDataLoadingThunk( - `${collectionSlice.name}/fetchAccountCollections`, - ({ accountId }: { accountId: string }) => apiGetAccountCollections(accountId), +export const fetchCollectionsCreatedByAccount = createDataLoadingThunk( + `${collectionSlice.name}/fetchCollectionsCreatedByAccount`, + ({ accountId }: { accountId: string }) => + apiGetCollectionsCreatedByAccount(accountId), +); + +export const fetchCollectionsFeaturingAccount = createDataLoadingThunk( + `${collectionSlice.name}/fetchCollectionsFeaturingAccount`, + ({ accountId }: { accountId: string }) => + apiGetCollectionsFeaturingAccount(accountId), ); export const fetchCollection = createDataLoadingThunk( @@ -323,7 +394,7 @@ interface AccountCollectionQuery { export const selectAccountCollections = createAppSelector( [ (_, accountId?: string | null) => accountId, - (state) => state.collections.accountCollections, + (state, _, query: 'createdBy' | 'featuring') => state.collections[query], (state) => state.collections.collections, ], (accountId, collectionsByAccountId, collectionsMap) => {