[Glitch] Add "Featuring you" tab to Collections page

Port b076808fd288df8e0c50e3b8fd03cab1cace8bdd to glitch-soc

Signed-off-by: Claire <claire.github-309c@sitedethib.com>
This commit is contained in:
diondiondion 2026-04-30 16:10:25 +02:00 committed by Claire
parent d33d25121b
commit 0ec0d84c67
12 changed files with 398 additions and 170 deletions

View File

@ -34,11 +34,16 @@ export const apiGetCollection = (collectionId: string) =>
`v1_alpha/collections/${collectionId}`,
);
export const apiGetAccountCollections = (accountId: string) =>
export const apiGetCollectionsCreatedByAccount = (accountId: string) =>
apiRequestGet<ApiCollectionsJSON>(
`v1_alpha/accounts/${accountId}/collections`,
);
export const apiGetCollectionsFeaturingAccount = (accountId: string) =>
apiRequestGet<ApiCollectionsJSON>(
`v1_alpha/accounts/${accountId}/in_collections`,
);
export const apiAddCollectionItem = (collectionId: string, accountId: string) =>
apiRequestPost<WrappedCollectionAccountItem>(
`v1_alpha/collections/${collectionId}/items`,

View File

@ -2,6 +2,7 @@
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
max-width: 600px;
padding: 24px;
gap: 16px;

View File

@ -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 (
<div className={classes.wrapper}>
<div className={classNames(classes.wrapper, className)}>
{(title || message || imageToRender) && (
<div className={classes.content}>
{imageToRender}

View File

@ -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);
}

View File

@ -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<string>,
);
const { collections, status: collectionsLoadStatus } =
useAccountCollections(accountId);
useCollectionsCreatedBy(accountId);
const { listedCollections = [], unlistedCollections = [] } = Object.groupBy(
collections,

View File

@ -114,7 +114,7 @@ export const CollectionLockup: React.FC<CollectionLockupProps> = ({
sideContent={sideContent}
>
<ListItemLink
as='h2'
as='h3'
to={getCollectionPath(id)}
subtitle={collectionInfo}
>

View File

@ -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) ||

View File

@ -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 = () => (
<Link to='/collections/new' className='button button--compact'>
<Icon id='plus' icon={AddIcon} />
<FormattedMessage {...editorMessages.newCollection} />
</Link>
);
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: <DisplayNameSimple account={account} />,
});
const tabMessage = isOwnCollection
const createdByTabMessage = isOwnCollectionsPage
? messages.createdByYou
: messages.createdByAuthor;
const errorMessage = (status === 'error' || !accountId) && (
<FormattedMessage
id='collections.error_loading_collections'
defaultMessage='There was an error when trying to load your collections.'
tagName='span'
/>
);
return (
<Column bindToDocument={!multiColumn} label={pageTitle}>
<ColumnHeader showBackButton multiColumn={multiColumn} />
@ -126,64 +76,28 @@ export const Collections: React.FC<{
<h1 className={classes.heading}>{pageTitleHtml}</h1>
<TabList plain>
<TabLink exact to={`/@${account?.acct}/collections`}>
{intl.formatMessage(tabMessage, {
{intl.formatMessage(createdByTabMessage, {
name: <DisplayNameSimple account={account} />,
})}
</TabLink>
{isOwnCollectionsPage && (
<TabLink
exact
to={`/@${account?.acct}/collections/featuring-you`}
>
{intl.formatMessage(messages.featuringYou)}
</TabLink>
)}
</TabList>
</header>
{status === 'loading' && <LoadingIndicator />}
{status === 'idle' &&
(collections.length > 0 ? (
<>
<div className={classes.listHeader}>
<h2 className={classes.subHeading}>
<FormattedMessage
id='collections.list.collections_with_count'
defaultMessage='{count, plural, one {# Collection} other {# Collections}}'
values={{
count: collections.length,
}}
/>
</h2>
{showCreateButton && <CreateButton />}
</div>
<ItemList emptyMessage={errorMessage}>
{!canCreateMoreCollections && (
<MaxCollectionsCallout
className={classes.maxCollectionsError}
/>
)}
{collections.map((item, index) => (
<CollectionListItem
withTimestamp
withAuthorHandle={false}
key={item.id}
collection={item}
positionInList={index + 1}
listSize={collections.length}
/>
))}
</ItemList>
</>
) : (
<EmptyState
title={
<FormattedMessage
id='empty_column.account_featured_self.showcase_accounts'
defaultMessage='Showcase your favorite accounts'
/>
}
message={
<FormattedMessage
id='empty_column.account_featured_self.showcase_accounts_desc'
defaultMessage='Collections are curated lists of accounts to help others discover more of the Fediverse.'
/>
}
>
<CreateButton />
</EmptyState>
))}
<Switch>
<Route exact path={path} component={CollectionsCreatedByYou} />
<Route
exact
path={`${path}/featuring-you`}
component={CollectionsFeaturingYou}
/>
</Switch>
</Scrollable>
<Helmet>

View File

@ -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 = () => (
<Link to='/collections/new' className='button button--compact'>
<Icon id='plus' icon={AddIcon} />
<FormattedMessage {...editorMessages.newCollection} />
</Link>
);
export const CollectionListError: React.FC = () => (
<EmptyState
image={null}
className={classes.error}
message={
<FormattedMessage
id='collections.error_loading_collections'
defaultMessage='There was an error when trying to load these collections.'
/>
}
/>
);
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 <CollectionListError />;
}
if (status === 'loading') {
return <LoadingIndicator />;
}
if (collections.length === 0) {
return (
<EmptyState
title={
<FormattedMessage
id='empty_column.account_featured_self.showcase_accounts'
defaultMessage='Showcase your favorite accounts'
/>
}
message={
<FormattedMessage
id='empty_column.account_featured_self.showcase_accounts_desc'
defaultMessage='Collections are curated lists of accounts to help others discover more of the Fediverse.'
/>
}
>
<CreateButton />
</EmptyState>
);
}
return (
<>
<div className={classes.listHeader}>
<h2 className={classes.subHeading}>
<FormattedMessage
id='collections.list.collections_with_count'
defaultMessage='{count, plural, one {# Collection} other {# Collections}}'
values={{
count: collections.length,
}}
/>
</h2>
{showCreateButton && <CreateButton />}
</div>
<ItemList>
{!canCreateMoreCollections && (
<MaxCollectionsCallout className={classes.maxCollectionsError} />
)}
{collections.map((item, index) => (
<CollectionListItem
withTimestamp
withAuthorHandle={false}
key={item.id}
collection={item}
positionInList={index + 1}
listSize={collections.length}
/>
))}
</ItemList>
</>
);
};

View File

@ -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 <CollectionListError />;
}
if (status === 'loading') {
return <LoadingIndicator />;
}
if (collections.length === 0) {
return (
<EmptyState
message={
<FormattedMessage
id='empty_column.collections.featured_in'
defaultMessage='You have not been added to any collections yet.'
/>
}
/>
);
}
return (
<>
<div className={classes.listHeader}>
<h2 className={classes.subHeading}>
<FormattedMessage
id='collections.list.collections_with_count'
defaultMessage='{count, plural, one {# Collection} other {# Collections}}'
values={{
count: collections.length,
}}
/>
</h2>
</div>
<ItemList>
{collections.map((item, index) => (
<CollectionListItem
withAuthorHandle
key={item.id}
collection={item}
positionInList={index + 1}
listSize={collections.length}
/>
))}
</ItemList>
</>
);
};

View File

@ -33,3 +33,7 @@
.maxCollectionsError {
margin: 8px 16px;
}
.error {
flex-grow: 1;
}

View File

@ -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<string, ApiCollectionJSON>;
// 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<K extends keyof EditorState> {
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<string, ApiCollectionJSON> =
state.collections;
const collectionIds: string[] = [];
const collectionsMap: Record<string, ApiCollectionJSON> =
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<string, ApiCollectionJSON> =
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) => {