Add topic pages

This commit is contained in:
Chewbacca 2023-04-13 09:28:40 -04:00
parent 8702c16998
commit bb70c1ea3c
9 changed files with 189 additions and 81 deletions

View File

@ -0,0 +1,117 @@
import clsx from 'clsx';
import React, { useCallback, useState } from 'react';
import { Components, Virtuoso, VirtuosoGrid } from 'react-virtuoso';
import { Column, HStack, Icon } from 'soapbox/components/ui';
import { useGroupTag, useGroupsFromTag } from 'soapbox/hooks/api';
import GroupGridItem from './components/discover/group-grid-item';
import GroupListItem from './components/discover/group-list-item';
import type { Group } from 'soapbox/schemas';
enum Layout {
LIST = 'LIST',
GRID = 'GRID'
}
const GridList: Components['List'] = React.forwardRef((props, ref) => {
const { context, ...rest } = props;
return <div ref={ref} {...rest} className='flex flex-wrap' />;
});
interface ITag {
params: { id: string }
}
const Tag: React.FC<ITag> = (props) => {
const tagId = props.params.id;
const [layout, setLayout] = useState<Layout>(Layout.LIST);
const { tag, isLoading } = useGroupTag(tagId);
const { groups, hasNextPage, fetchNextPage } = useGroupsFromTag(tagId);
const handleLoadMore = () => {
if (hasNextPage) {
fetchNextPage();
}
};
const renderGroupList = useCallback((group: Group, index: number) => (
<div
className={
clsx({
'pt-4': index !== 0,
})
}
>
<GroupListItem group={group} withJoinAction />
</div>
), []);
const renderGroupGrid = useCallback((group: Group, index: number) => (
<div className='pb-4'>
<GroupGridItem group={group} />
</div>
), []);
if (isLoading || !tag) {
return null;
}
return (
<Column
label={`#${tag.name}`}
action={
<HStack alignItems='center'>
<button onClick={() => setLayout(Layout.LIST)}>
<Icon
src={require('@tabler/icons/layout-list.svg')}
className={
clsx('h-5 w-5 text-gray-600', {
'text-primary-600': layout === Layout.LIST,
})
}
/>
</button>
<button onClick={() => setLayout(Layout.GRID)}>
<Icon
src={require('@tabler/icons/layout-grid.svg')}
className={
clsx('h-5 w-5 text-gray-600', {
'text-primary-600': layout === Layout.GRID,
})
}
/>
</button>
</HStack>
}
>
{layout === Layout.LIST ? (
<Virtuoso
useWindowScroll
data={groups}
itemContent={(index, group) => renderGroupList(group, index)}
endReached={handleLoadMore}
/>
) : (
<VirtuosoGrid
useWindowScroll
data={groups}
itemContent={(index, group) => renderGroupGrid(group, index)}
components={{
Item: (props) => (
<div {...props} className='w-1/2 flex-none' />
),
List: GridList,
}}
endReached={handleLoadMore}
/>
)}
</Column>
);
};
export default Tag;

View File

@ -1,35 +1,24 @@
import clsx from 'clsx'; import clsx from 'clsx';
import React, { useCallback, useState } from 'react'; import React from 'react';
import { Components, Virtuoso, VirtuosoGrid } from 'react-virtuoso'; import { FormattedMessage, defineMessages, useIntl } from 'react-intl';
import { Virtuoso } from 'react-virtuoso';
import { Column, HStack, Icon } from 'soapbox/components/ui'; import { Column, Text } from 'soapbox/components/ui';
import { useGroupsFromTag } from 'soapbox/hooks/api'; import { usePopularTags } from 'soapbox/hooks/api';
import GroupGridItem from './components/discover/group-grid-item'; import TagListItem from './components/discover/tag-list-item';
import GroupListItem from './components/discover/group-list-item';
import type { Group } from 'soapbox/schemas'; import type { GroupTag } from 'soapbox/schemas';
enum Layout { const messages = defineMessages({
LIST = 'LIST', title: { id: 'groups.tags.title', defaultMessage: 'Browse Topics' },
GRID = 'GRID'
}
const GridList: Components['List'] = React.forwardRef((props, ref) => {
const { context, ...rest } = props;
return <div ref={ref} {...rest} className='flex flex-wrap' />;
}); });
interface ITags { const Tags: React.FC = () => {
params: { id: string } const intl = useIntl();
}
const Tags: React.FC<ITags> = (props) => { const { tags, isFetched, isError, hasNextPage, fetchNextPage } = usePopularTags();
const tagId = props.params.id; const isEmpty = (isFetched && tags.length === 0) || isError;
const [layout, setLayout] = useState<Layout>(Layout.LIST);
const { groups, hasNextPage, fetchNextPage } = useGroupsFromTag(tagId);
const handleLoadMore = () => { const handleLoadMore = () => {
if (hasNextPage) { if (hasNextPage) {
@ -37,7 +26,7 @@ const Tags: React.FC<ITags> = (props) => {
} }
}; };
const renderGroupList = useCallback((group: Group, index: number) => ( const renderItem = (index: number, tag: GroupTag) => (
<div <div
className={ className={
clsx({ clsx({
@ -45,63 +34,24 @@ const Tags: React.FC<ITags> = (props) => {
}) })
} }
> >
<GroupListItem group={group} withJoinAction /> <TagListItem key={tag.id} tag={tag} />
</div> </div>
), []); );
const renderGroupGrid = useCallback((group: Group, index: number) => (
<div className='pb-4'>
<GroupGridItem group={group} />
</div>
), []);
return ( return (
<Column <Column label={intl.formatMessage(messages.title)}>
label={`#${tagId}`} {isEmpty ? (
action={ <Text theme='muted'>
<HStack alignItems='center'> <FormattedMessage
<button onClick={() => setLayout(Layout.LIST)}> id='groups.discover.tags.empty'
<Icon defaultMessage='Unable to fetch popular topics at this time. Please check back later.'
src={require('@tabler/icons/layout-list.svg')} />
className={ </Text>
clsx('h-5 w-5 text-gray-600', { ) : (
'text-primary-600': layout === Layout.LIST,
})
}
/>
</button>
<button onClick={() => setLayout(Layout.GRID)}>
<Icon
src={require('@tabler/icons/layout-grid.svg')}
className={
clsx('h-5 w-5 text-gray-600', {
'text-primary-600': layout === Layout.GRID,
})
}
/>
</button>
</HStack>
}
>
{layout === Layout.LIST ? (
<Virtuoso <Virtuoso
useWindowScroll useWindowScroll
data={groups} data={tags}
itemContent={(index, group) => renderGroupList(group, index)} itemContent={renderItem}
endReached={handleLoadMore}
/>
) : (
<VirtuosoGrid
useWindowScroll
data={groups}
itemContent={(index, group) => renderGroupGrid(group, index)}
components={{
Item: (props) => (
<div {...props} className='w-1/2 flex-none' />
),
List: GridList,
}}
endReached={handleLoadMore} endReached={handleLoadMore}
/> />
)} )}

View File

@ -122,6 +122,7 @@ import {
GroupsDiscover, GroupsDiscover,
GroupsPopular, GroupsPopular,
GroupsSuggested, GroupsSuggested,
GroupsTag,
GroupsTags, GroupsTags,
PendingGroupRequests, PendingGroupRequests,
GroupMembers, GroupMembers,
@ -297,7 +298,8 @@ const SwitchingColumnsArea: React.FC<ISwitchingColumnsArea> = ({ children }) =>
{features.groupsDiscovery && <WrappedRoute path='/groups/discover' exact page={GroupsPage} component={GroupsDiscover} content={children} />} {features.groupsDiscovery && <WrappedRoute path='/groups/discover' exact page={GroupsPage} component={GroupsDiscover} content={children} />}
{features.groupsDiscovery && <WrappedRoute path='/groups/popular' exact page={GroupsPendingPage} component={GroupsPopular} content={children} />} {features.groupsDiscovery && <WrappedRoute path='/groups/popular' exact page={GroupsPendingPage} component={GroupsPopular} content={children} />}
{features.groupsDiscovery && <WrappedRoute path='/groups/suggested' exact page={GroupsPendingPage} component={GroupsSuggested} content={children} />} {features.groupsDiscovery && <WrappedRoute path='/groups/suggested' exact page={GroupsPendingPage} component={GroupsSuggested} content={children} />}
{features.groupsDiscovery && <WrappedRoute path='/groups/discover/tags/:id' exact page={GroupsPendingPage} component={GroupsTags} content={children} />} {features.groupsDiscovery && <WrappedRoute path='/groups/tags' exact page={GroupsPendingPage} component={GroupsTags} content={children} />}
{features.groupsDiscovery && <WrappedRoute path='/groups/discover/tags/:id' exact page={GroupsPendingPage} component={GroupsTag} content={children} />}
{features.groupsPending && <WrappedRoute path='/groups/pending-requests' exact page={GroupsPendingPage} component={PendingGroupRequests} content={children} />} {features.groupsPending && <WrappedRoute path='/groups/pending-requests' exact page={GroupsPendingPage} component={PendingGroupRequests} content={children} />}
{features.groups && <WrappedRoute path='/groups/:id' exact page={GroupPage} component={GroupTimeline} content={children} />} {features.groups && <WrappedRoute path='/groups/:id' exact page={GroupPage} component={GroupTimeline} content={children} />}
{features.groups && <WrappedRoute path='/groups/:id/members' exact page={GroupPage} component={GroupMembers} content={children} />} {features.groups && <WrappedRoute path='/groups/:id/members' exact page={GroupPage} component={GroupMembers} content={children} />}

View File

@ -562,6 +562,10 @@ export function GroupsSuggested() {
return import(/* webpackChunkName: "features/groups" */'../../groups/suggested'); return import(/* webpackChunkName: "features/groups" */'../../groups/suggested');
} }
export function GroupsTag() {
return import(/* webpackChunkName: "features/groups" */'../../groups/tag');
}
export function GroupsTags() { export function GroupsTags() {
return import(/* webpackChunkName: "features/groups" */'../../groups/tags'); return import(/* webpackChunkName: "features/groups" */'../../groups/tags');
} }

View File

@ -0,0 +1,21 @@
import { Entities } from 'soapbox/entity-store/entities';
import { useEntity } from 'soapbox/entity-store/hooks';
import { useApi } from 'soapbox/hooks';
import { type GroupTag, groupTagSchema } from 'soapbox/schemas';
function useGroupTag(tagId: string) {
const api = useApi();
const { entity: tag, ...result } = useEntity<GroupTag>(
[Entities.GROUP_TAGS, tagId],
() => api.get(`/api/v1/tags/${tagId }`),
{ schema: groupTagSchema },
);
return {
...result,
tag,
};
}
export { useGroupTag };

View File

@ -5,6 +5,8 @@ import { groupSchema } from 'soapbox/schemas';
import { useApi } from '../../useApi'; import { useApi } from '../../useApi';
import { useFeatures } from '../../useFeatures'; import { useFeatures } from '../../useFeatures';
import { useGroupRelationships } from './useGroups';
import type { Group } from 'soapbox/schemas'; import type { Group } from 'soapbox/schemas';
function useGroupsFromTag(tagId: string) { function useGroupsFromTag(tagId: string) {
@ -13,16 +15,22 @@ function useGroupsFromTag(tagId: string) {
const { entities, ...result } = useEntities<Group>( const { entities, ...result } = useEntities<Group>(
[Entities.GROUPS, 'tags', tagId], [Entities.GROUPS, 'tags', tagId],
() => api.get(`/api/mock/tags/${tagId}/groups`), () => api.get(`/api/v1/tags/${tagId}/groups`),
{ {
schema: groupSchema, schema: groupSchema,
enabled: features.groupsDiscovery, enabled: features.groupsDiscovery,
}, },
); );
const { relationships } = useGroupRelationships(entities.map(entity => entity.id));
const groups = entities.map((group) => ({
...group,
relationship: relationships[group.id] || null,
}));
return { return {
...result, ...result,
groups: entities, groups,
}; };
} }

View File

@ -11,7 +11,7 @@ function usePopularTags() {
const { entities, ...result } = useEntities<GroupTag>( const { entities, ...result } = useEntities<GroupTag>(
[Entities.GROUP_TAGS], [Entities.GROUP_TAGS],
() => api.get('/api/mock/groups/tags'), () => api.get('/api/v1/groups/tags'),
{ {
schema: groupTagSchema, schema: groupTagSchema,
enabled: features.groupsDiscovery, enabled: features.groupsDiscovery,

View File

@ -15,6 +15,7 @@ export { useGroupMedia } from './groups/useGroupMedia';
export { useGroup, useGroups } from './groups/useGroups'; export { useGroup, useGroups } from './groups/useGroups';
export { useGroupMembershipRequests } from './groups/useGroupMembershipRequests'; export { useGroupMembershipRequests } from './groups/useGroupMembershipRequests';
export { useGroupSearch } from './groups/useGroupSearch'; export { useGroupSearch } from './groups/useGroupSearch';
export { useGroupTag } from './groups/useGroupTag';
export { useGroupValidation } from './groups/useGroupValidation'; export { useGroupValidation } from './groups/useGroupValidation';
export { useGroupsFromTag } from './groups/useGroupsFromTag'; export { useGroupsFromTag } from './groups/useGroupsFromTag';
export { useJoinGroup } from './groups/useJoinGroup'; export { useJoinGroup } from './groups/useJoinGroup';

View File

@ -830,6 +830,10 @@
"groups.discover.suggested.empty": "Unable to fetch suggested groups at this time. Please check back later.", "groups.discover.suggested.empty": "Unable to fetch suggested groups at this time. Please check back later.",
"groups.discover.suggested.show_more": "Show More", "groups.discover.suggested.show_more": "Show More",
"groups.discover.suggested.title": "Suggested For You", "groups.discover.suggested.title": "Suggested For You",
"groups.discover.tags.empty": "Unable to fetch popular topics at this time. Please check back later.",
"groups.discover.tags.show_more": "Show More",
"groups.discover.tags.title": "Browse Topics",
"groups.discovery.tags.no_of_groups": "Number of groups",
"groups.empty.subtitle": "Start discovering groups to join or create your own.", "groups.empty.subtitle": "Start discovering groups to join or create your own.",
"groups.empty.title": "No Groups yet", "groups.empty.title": "No Groups yet",
"groups.pending.count": "{number, plural, one {# pending request} other {# pending requests}}", "groups.pending.count": "{number, plural, one {# pending request} other {# pending requests}}",
@ -838,6 +842,7 @@
"groups.pending.label": "Pending Requests", "groups.pending.label": "Pending Requests",
"groups.popular.label": "Suggested Groups", "groups.popular.label": "Suggested Groups",
"groups.search.placeholder": "Search My Groups", "groups.search.placeholder": "Search My Groups",
"groups.tags.title": "Browse Topics",
"hashtag.column_header.tag_mode.all": "and {additional}", "hashtag.column_header.tag_mode.all": "and {additional}",
"hashtag.column_header.tag_mode.any": "or {additional}", "hashtag.column_header.tag_mode.any": "or {additional}",
"hashtag.column_header.tag_mode.none": "without {additional}", "hashtag.column_header.tag_mode.none": "without {additional}",