Add Trending and Suggested Groups to discovery
This commit is contained in:
parent
6413bed23f
commit
0414c36a1e
21 changed files with 655 additions and 109 deletions
|
@ -17,43 +17,53 @@ const GroupCard: React.FC<IGroupCard> = ({ group }) => {
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='overflow-hidden'>
|
<Stack className='relative h-[240px] rounded-lg border border-solid border-gray-300 bg-white dark:border-primary-800 dark:bg-primary-900'>
|
||||||
<Stack className='rounded-lg border border-solid border-gray-300 bg-white dark:border-primary-800 dark:bg-primary-900 sm:rounded-xl'>
|
{/* Group Cover Image */}
|
||||||
<div className='relative -m-[1px] mb-0 h-[120px] rounded-t-lg bg-primary-100 dark:bg-gray-800 sm:rounded-t-xl'>
|
<Stack grow className='relative basis-1/2 rounded-t-lg bg-primary-100 dark:bg-gray-800'>
|
||||||
{group.header && <img className='h-full w-full rounded-t-lg object-cover sm:rounded-t-xl' src={group.header} alt={intl.formatMessage(messages.groupHeader)} />}
|
{group.header && (
|
||||||
<div className='absolute left-1/2 bottom-0 -translate-x-1/2 translate-y-1/2'>
|
<img
|
||||||
|
className='absolute inset-0 h-full w-full rounded-t-lg object-cover'
|
||||||
|
src={group.header} alt={intl.formatMessage(messages.groupHeader)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
{/* Group Avatar */}
|
||||||
|
<div className='absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2'>
|
||||||
<Avatar className='ring-2 ring-white dark:ring-primary-900' src={group.avatar} size={64} />
|
<Avatar className='ring-2 ring-white dark:ring-primary-900' src={group.avatar} size={64} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
<Stack className='p-3 pt-9' alignItems='center' space={3}>
|
{/* Group Info */}
|
||||||
|
<Stack alignItems='center' justifyContent='end' grow className='basis-1/2 py-4' space={0.5}>
|
||||||
<Text size='lg' weight='bold' dangerouslySetInnerHTML={{ __html: group.display_name_html }} />
|
<Text size='lg' weight='bold' dangerouslySetInnerHTML={{ __html: group.display_name_html }} />
|
||||||
|
|
||||||
<HStack className='text-gray-700 dark:text-gray-600' space={3} wrap>
|
<HStack className='text-gray-700 dark:text-gray-600' space={3} wrap>
|
||||||
{group.relationship?.role === 'admin' ? (
|
{group.relationship?.role === 'admin' ? (
|
||||||
<HStack space={1} alignItems='center'>
|
<HStack space={1} alignItems='center'>
|
||||||
<Icon className='h-4 w-4' src={require('@tabler/icons/users.svg')} />
|
<Icon className='h-4 w-4' src={require('@tabler/icons/users.svg')} />
|
||||||
<span><FormattedMessage id='group.role.admin' defaultMessage='Admin' /></span>
|
<Text theme='inherit'><FormattedMessage id='group.role.admin' defaultMessage='Admin' /></Text>
|
||||||
</HStack>
|
</HStack>
|
||||||
) : group.relationship?.role === 'moderator' && (
|
) : group.relationship?.role === 'moderator' && (
|
||||||
<HStack space={1} alignItems='center'>
|
<HStack space={1} alignItems='center'>
|
||||||
<Icon className='h-4 w-4' src={require('@tabler/icons/gavel.svg')} />
|
<Icon className='h-4 w-4' src={require('@tabler/icons/gavel.svg')} />
|
||||||
<span><FormattedMessage id='group.role.moderator' defaultMessage='Moderator' /></span>
|
<Text theme='inherit'><FormattedMessage id='group.role.moderator' defaultMessage='Moderator' /></Text>
|
||||||
</HStack>
|
</HStack>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{group.locked ? (
|
{group.locked ? (
|
||||||
<HStack space={1} alignItems='center'>
|
<HStack space={1} alignItems='center'>
|
||||||
<Icon className='h-4 w-4' src={require('@tabler/icons/lock.svg')} />
|
<Icon className='h-4 w-4' src={require('@tabler/icons/lock.svg')} />
|
||||||
<span><FormattedMessage id='group.privacy.locked' defaultMessage='Private' /></span>
|
<Text theme='inherit'><FormattedMessage id='group.privacy.locked' defaultMessage='Private' /></Text>
|
||||||
</HStack>
|
</HStack>
|
||||||
) : (
|
) : (
|
||||||
<HStack space={1} alignItems='center'>
|
<HStack space={1} alignItems='center'>
|
||||||
<Icon className='h-4 w-4' src={require('@tabler/icons/world.svg')} />
|
<Icon className='h-4 w-4' src={require('@tabler/icons/world.svg')} />
|
||||||
<span><FormattedMessage id='group.privacy.public' defaultMessage='Public' /></span>
|
<Text theme='inherit'><FormattedMessage id='group.privacy.public' defaultMessage='Public' /></Text>
|
||||||
</HStack>
|
</HStack>
|
||||||
)}
|
)}
|
||||||
</HStack>
|
</HStack>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Stack>
|
</Stack>
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
109
app/soapbox/components/ui/carousel/carousel.tsx
Normal file
109
app/soapbox/components/ui/carousel/carousel.tsx
Normal file
|
@ -0,0 +1,109 @@
|
||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
import { useDimensions } from 'soapbox/hooks';
|
||||||
|
|
||||||
|
import HStack from '../hstack/hstack';
|
||||||
|
import Icon from '../icon/icon';
|
||||||
|
|
||||||
|
interface ICarousel {
|
||||||
|
children: any
|
||||||
|
/** Optional height to force on controls */
|
||||||
|
controlsHeight?: number
|
||||||
|
/** How many items in the carousel */
|
||||||
|
itemCount: number
|
||||||
|
/** The minimum width per item */
|
||||||
|
itemWidth: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Carousel
|
||||||
|
*/
|
||||||
|
const Carousel: React.FC<ICarousel> = (props): JSX.Element => {
|
||||||
|
const { children, controlsHeight, itemCount, itemWidth } = props;
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
|
const [_ref, setContainerRef, { width: containerWidth }] = useDimensions();
|
||||||
|
|
||||||
|
const [pageSize, setPageSize] = useState<number>(0);
|
||||||
|
const [currentPage, setCurrentPage] = useState<number>(1);
|
||||||
|
|
||||||
|
const numberOfPages = Math.ceil(itemCount / pageSize);
|
||||||
|
const width = containerWidth / (Math.floor(containerWidth / itemWidth));
|
||||||
|
|
||||||
|
const hasNextPage = currentPage < numberOfPages && numberOfPages > 1;
|
||||||
|
const hasPrevPage = currentPage > 1 && numberOfPages > 1;
|
||||||
|
|
||||||
|
const handleNextPage = () => setCurrentPage((prevPage) => prevPage + 1);
|
||||||
|
const handlePrevPage = () => setCurrentPage((prevPage) => prevPage - 1);
|
||||||
|
|
||||||
|
const renderChildren = () => {
|
||||||
|
if (typeof children === 'function') {
|
||||||
|
return children({ width: width || 'auto' });
|
||||||
|
}
|
||||||
|
|
||||||
|
return children;
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (containerWidth) {
|
||||||
|
setPageSize(Math.round(containerWidth / width));
|
||||||
|
}
|
||||||
|
}, [containerWidth, width]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<HStack alignItems='stretch'>
|
||||||
|
<div
|
||||||
|
className='z-10 flex w-5 items-center justify-center self-stretch rounded-l-xl bg-white dark:bg-primary-900'
|
||||||
|
style={{
|
||||||
|
height: controlsHeight || 'auto',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
data-testid='prev-page'
|
||||||
|
onClick={handlePrevPage}
|
||||||
|
className='flex h-full w-7 items-center justify-center transition-opacity duration-500 disabled:opacity-25'
|
||||||
|
disabled={!hasPrevPage}
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
src={require('@tabler/icons/chevron-left.svg')}
|
||||||
|
className='h-5 w-5 text-black dark:text-white'
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='relative w-full overflow-hidden'>
|
||||||
|
<HStack
|
||||||
|
alignItems='center'
|
||||||
|
style={{
|
||||||
|
transform: `translateX(-${(currentPage - 1) * 100}%)`,
|
||||||
|
}}
|
||||||
|
className='transition-all duration-500 ease-out'
|
||||||
|
ref={setContainerRef}
|
||||||
|
>
|
||||||
|
{renderChildren()}
|
||||||
|
</HStack>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className='z-10 flex w-5 items-center justify-center self-stretch rounded-r-xl bg-white dark:bg-primary-900'
|
||||||
|
style={{
|
||||||
|
height: controlsHeight || 'auto',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
data-testid='next-page'
|
||||||
|
onClick={handleNextPage}
|
||||||
|
className='flex h-full w-7 items-center justify-center transition-opacity duration-500 disabled:opacity-25'
|
||||||
|
disabled={!hasNextPage}
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
src={require('@tabler/icons/chevron-right.svg')}
|
||||||
|
className='h-5 w-5 text-black dark:text-white'
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</HStack>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Carousel;
|
|
@ -2,6 +2,7 @@ export { default as Accordion } from './accordion/accordion';
|
||||||
export { default as Avatar } from './avatar/avatar';
|
export { default as Avatar } from './avatar/avatar';
|
||||||
export { default as Banner } from './banner/banner';
|
export { default as Banner } from './banner/banner';
|
||||||
export { default as Button } from './button/button';
|
export { default as Button } from './button/button';
|
||||||
|
export { default as Carousel } from './carousel/carousel';
|
||||||
export { Card, CardBody, CardHeader, CardTitle } from './card/card';
|
export { Card, CardBody, CardHeader, CardTitle } from './card/card';
|
||||||
export { default as Checkbox } from './checkbox/checkbox';
|
export { default as Checkbox } from './checkbox/checkbox';
|
||||||
export { Column, ColumnHeader } from './column/column';
|
export { Column, ColumnHeader } from './column/column';
|
||||||
|
|
93
app/soapbox/features/groups/components/discover/group.tsx
Normal file
93
app/soapbox/features/groups/components/discover/group.tsx
Normal file
|
@ -0,0 +1,93 @@
|
||||||
|
import React, { forwardRef } from 'react';
|
||||||
|
import { FormattedMessage } from 'react-intl';
|
||||||
|
|
||||||
|
import { Avatar, Button, HStack, Icon, Stack, Text } from 'soapbox/components/ui';
|
||||||
|
import { Group as GroupEntity } from 'soapbox/types/entities';
|
||||||
|
import { shortNumberFormat } from 'soapbox/utils/numbers';
|
||||||
|
|
||||||
|
|
||||||
|
interface IGroup {
|
||||||
|
group: GroupEntity
|
||||||
|
width: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const Group = forwardRef((props: IGroup, ref: React.ForwardedRef<HTMLDivElement>) => {
|
||||||
|
const { group, width = 'auto' } = props;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={group.id}
|
||||||
|
className='relative flex shrink-0 flex-col space-y-2 px-0.5'
|
||||||
|
style={{
|
||||||
|
width,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Stack
|
||||||
|
className='aspect-w-10 aspect-h-7 h-full w-full overflow-hidden rounded-lg'
|
||||||
|
ref={ref}
|
||||||
|
style={{ minHeight: 180 }}
|
||||||
|
>
|
||||||
|
{group.header && (
|
||||||
|
<img
|
||||||
|
src={group.header}
|
||||||
|
alt='Group cover'
|
||||||
|
className='absolute inset-0 object-cover'
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Stack justifyContent='end' className='z-10 p-4 text-white' space={3}>
|
||||||
|
<Avatar
|
||||||
|
className='ring-2 ring-white'
|
||||||
|
src={group.avatar}
|
||||||
|
size={44}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Stack space={1}>
|
||||||
|
<Text
|
||||||
|
weight='bold'
|
||||||
|
dangerouslySetInnerHTML={{ __html: group.display_name_html }}
|
||||||
|
theme='inherit'
|
||||||
|
truncate
|
||||||
|
/>
|
||||||
|
|
||||||
|
<HStack space={1} alignItems='center'>
|
||||||
|
<Icon
|
||||||
|
className='h-4.5 w-4.5'
|
||||||
|
src={group.locked ? require('@tabler/icons/lock.svg') : require('@tabler/icons/world.svg')}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{typeof group.members_count === 'undefined' ? (
|
||||||
|
<Text theme='inherit' tag='span' size='sm'>
|
||||||
|
{group.locked ? (
|
||||||
|
<FormattedMessage id='group.privacy.locked' defaultMessage='Private' />
|
||||||
|
) : (
|
||||||
|
<FormattedMessage id='group.privacy.public' defaultMessage='Public' />
|
||||||
|
)}
|
||||||
|
</Text>
|
||||||
|
) : (
|
||||||
|
<Text theme='inherit' tag='span' size='sm'>
|
||||||
|
{shortNumberFormat(group.members_count)}
|
||||||
|
{' '}
|
||||||
|
members
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</HStack>
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className='absolute inset-x-0 bottom-0 z-0 flex justify-center rounded-b-lg bg-gradient-to-t from-gray-900 to-transparent pt-12 pb-8 transition-opacity duration-500'
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
theme='primary'
|
||||||
|
block
|
||||||
|
>
|
||||||
|
Join Group
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
export default Group;
|
|
@ -0,0 +1,54 @@
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
|
||||||
|
import { Carousel, Stack, Text } from 'soapbox/components/ui';
|
||||||
|
import PlaceholderGroupDiscover from 'soapbox/features/placeholder/components/placeholder-group-discover';
|
||||||
|
import { usePopularGroups } from 'soapbox/queries/groups';
|
||||||
|
|
||||||
|
import Group from './group';
|
||||||
|
|
||||||
|
const PopularGroups = () => {
|
||||||
|
const { groups, isFetching } = usePopularGroups();
|
||||||
|
|
||||||
|
const [groupCover, setGroupCover] = useState<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack space={4}>
|
||||||
|
<Text size='xl' weight='bold'>
|
||||||
|
Popular Groups
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Carousel
|
||||||
|
itemWidth={250}
|
||||||
|
itemCount={groups.length}
|
||||||
|
controlsHeight={groupCover?.clientHeight}
|
||||||
|
>
|
||||||
|
{({ width }: { width: number }) => (
|
||||||
|
<>
|
||||||
|
{isFetching ? (
|
||||||
|
new Array(20).fill(0).map((_, idx) => (
|
||||||
|
<div
|
||||||
|
className='relative flex shrink-0 flex-col space-y-2 px-0.5'
|
||||||
|
style={{ width: width || 'auto' }}
|
||||||
|
key={idx}
|
||||||
|
>
|
||||||
|
<PlaceholderGroupDiscover />
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
groups.map((group) => (
|
||||||
|
<Group
|
||||||
|
key={group.id}
|
||||||
|
group={group}
|
||||||
|
width={width}
|
||||||
|
ref={setGroupCover}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Carousel>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default PopularGroups;
|
|
@ -0,0 +1,54 @@
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
|
||||||
|
import { Carousel, Stack, Text } from 'soapbox/components/ui';
|
||||||
|
import PlaceholderGroupDiscover from 'soapbox/features/placeholder/components/placeholder-group-discover';
|
||||||
|
import { useSuggestedGroups } from 'soapbox/queries/groups';
|
||||||
|
|
||||||
|
import Group from './group';
|
||||||
|
|
||||||
|
const SuggestedGroups = () => {
|
||||||
|
const { groups, isFetching } = useSuggestedGroups();
|
||||||
|
|
||||||
|
const [groupCover, setGroupCover] = useState<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack space={4}>
|
||||||
|
<Text size='xl' weight='bold'>
|
||||||
|
Suggested For You
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Carousel
|
||||||
|
itemWidth={250}
|
||||||
|
itemCount={groups.length}
|
||||||
|
controlsHeight={groupCover?.clientHeight}
|
||||||
|
>
|
||||||
|
{({ width }: { width: number }) => (
|
||||||
|
<>
|
||||||
|
{isFetching ? (
|
||||||
|
new Array(20).fill(0).map((_, idx) => (
|
||||||
|
<div
|
||||||
|
className='relative flex shrink-0 flex-col space-y-2 px-0.5'
|
||||||
|
style={{ width: width || 'auto' }}
|
||||||
|
key={idx}
|
||||||
|
>
|
||||||
|
<PlaceholderGroupDiscover />
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
groups.map((group) => (
|
||||||
|
<Group
|
||||||
|
key={group.id}
|
||||||
|
group={group}
|
||||||
|
width={width}
|
||||||
|
ref={setGroupCover}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Carousel>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SuggestedGroups;
|
41
app/soapbox/features/groups/components/tab-bar.tsx
Normal file
41
app/soapbox/features/groups/components/tab-bar.tsx
Normal file
|
@ -0,0 +1,41 @@
|
||||||
|
import React, { useMemo } from 'react';
|
||||||
|
import { useHistory } from 'react-router-dom';
|
||||||
|
|
||||||
|
import { Tabs } from 'soapbox/components/ui';
|
||||||
|
|
||||||
|
import type { Item } from 'soapbox/components/ui/tabs/tabs';
|
||||||
|
|
||||||
|
export enum TabItems {
|
||||||
|
MY_GROUPS = 'MY_GROUPS',
|
||||||
|
FIND_GROUPS = 'FIND_GROUPS'
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ITabBar {
|
||||||
|
activeTab: TabItems
|
||||||
|
}
|
||||||
|
|
||||||
|
const TabBar = ({ activeTab }: ITabBar) => {
|
||||||
|
const history = useHistory();
|
||||||
|
|
||||||
|
const tabItems: Item[] = useMemo(() => ([
|
||||||
|
{
|
||||||
|
text: 'My Groups',
|
||||||
|
action: () => history.push('/groups'),
|
||||||
|
name: TabItems.MY_GROUPS,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: 'Find Groups',
|
||||||
|
action: () => history.push('/groups/discover'),
|
||||||
|
name: TabItems.FIND_GROUPS,
|
||||||
|
},
|
||||||
|
]), []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tabs
|
||||||
|
items={tabItems}
|
||||||
|
activeItem={activeTab}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default TabBar;
|
22
app/soapbox/features/groups/discover.tsx
Normal file
22
app/soapbox/features/groups/discover.tsx
Normal file
|
@ -0,0 +1,22 @@
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
import { Stack } from 'soapbox/components/ui';
|
||||||
|
|
||||||
|
import PopularGroups from './components/discover/popular-groups';
|
||||||
|
import SuggestedGroups from './components/discover/suggested-groups';
|
||||||
|
import TabBar, { TabItems } from './components/tab-bar';
|
||||||
|
|
||||||
|
const Discover: React.FC = () => {
|
||||||
|
return (
|
||||||
|
<Stack space={4}>
|
||||||
|
<TabBar activeTab={TabItems.FIND_GROUPS} />
|
||||||
|
|
||||||
|
<Stack space={6}>
|
||||||
|
<PopularGroups />
|
||||||
|
<SuggestedGroups />
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Discover;
|
|
@ -1,57 +1,32 @@
|
||||||
import React, { useEffect } from 'react';
|
import React from 'react';
|
||||||
import { FormattedMessage } from 'react-intl';
|
import { FormattedMessage } from 'react-intl';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import { createSelector } from 'reselect';
|
|
||||||
|
|
||||||
import { fetchGroups } from 'soapbox/actions/groups';
|
|
||||||
import { openModal } from 'soapbox/actions/modals';
|
import { openModal } from 'soapbox/actions/modals';
|
||||||
import GroupCard from 'soapbox/components/group-card';
|
import GroupCard from 'soapbox/components/group-card';
|
||||||
import ScrollableList from 'soapbox/components/scrollable-list';
|
import ScrollableList from 'soapbox/components/scrollable-list';
|
||||||
import { Button, Column, Spinner, Stack, Text } from 'soapbox/components/ui';
|
import { Button, Stack, Text } from 'soapbox/components/ui';
|
||||||
import { useAppDispatch, useAppSelector } from 'soapbox/hooks';
|
import { useAppDispatch, useAppSelector, useFeatures } from 'soapbox/hooks';
|
||||||
|
import { useGroups } from 'soapbox/queries/groups';
|
||||||
import { PERMISSION_CREATE_GROUPS, hasPermission } from 'soapbox/utils/permissions';
|
import { PERMISSION_CREATE_GROUPS, hasPermission } from 'soapbox/utils/permissions';
|
||||||
|
|
||||||
import PlaceholderGroupCard from '../placeholder/components/placeholder-group-card';
|
import PlaceholderGroupCard from '../placeholder/components/placeholder-group-card';
|
||||||
|
|
||||||
import type { List as ImmutableList } from 'immutable';
|
import TabBar, { TabItems } from './components/tab-bar';
|
||||||
import type { RootState } from 'soapbox/store';
|
|
||||||
import type { Group as GroupEntity } from 'soapbox/types/entities';
|
import type { Group as GroupEntity } from 'soapbox/types/entities';
|
||||||
|
|
||||||
const getOrderedGroups = createSelector([
|
// const getOrderedGroups = createSelector([
|
||||||
(state: RootState) => state.groups.items,
|
// (state: RootState) => state.groups.items,
|
||||||
(state: RootState) => state.groups.isLoading,
|
// (state: RootState) => state.group_relationships,
|
||||||
(state: RootState) => state.group_relationships,
|
// ], (groups, group_relationships) => ({
|
||||||
], (groups, isLoading, group_relationships) => ({
|
// groups: (groups.toList().filter((item: GroupEntity | false) => !!item) as ImmutableList<GroupEntity>)
|
||||||
groups: (groups.toList().filter((item: GroupEntity | false) => !!item) as ImmutableList<GroupEntity>)
|
// .map((item) => item.set('relationship', group_relationships.get(item.id) || null))
|
||||||
.map((item) => item.set('relationship', group_relationships.get(item.id) || null))
|
// .filter((item) => item.relationship?.member)
|
||||||
.filter((item) => item.relationship?.member)
|
// .sort((a, b) => a.display_name.localeCompare(b.display_name)),
|
||||||
.sort((a, b) => a.display_name.localeCompare(b.display_name)),
|
// }));
|
||||||
isLoading,
|
|
||||||
}));
|
|
||||||
|
|
||||||
const Groups: React.FC = () => {
|
const EmptyMessage = () => (
|
||||||
const dispatch = useAppDispatch();
|
|
||||||
|
|
||||||
const { groups, isLoading } = useAppSelector((state) => getOrderedGroups(state));
|
|
||||||
const canCreateGroup = useAppSelector((state) => hasPermission(state, PERMISSION_CREATE_GROUPS));
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
dispatch(fetchGroups());
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const createGroup = () => {
|
|
||||||
dispatch(openModal('MANAGE_GROUP'));
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!groups) {
|
|
||||||
return (
|
|
||||||
<Column>
|
|
||||||
<Spinner />
|
|
||||||
</Column>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const emptyMessage = (
|
|
||||||
<Stack space={6} alignItems='center' justifyContent='center' className='h-full p-6'>
|
<Stack space={6} alignItems='center' justifyContent='center' className='h-full p-6'>
|
||||||
<Stack space={2} className='max-w-sm'>
|
<Stack space={2} className='max-w-sm'>
|
||||||
<Text size='2xl' weight='bold' tag='h2' align='center'>
|
<Text size='2xl' weight='bold' tag='h2' align='center'>
|
||||||
|
@ -69,10 +44,22 @@ const Groups: React.FC = () => {
|
||||||
</Text>
|
</Text>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const Groups: React.FC = () => {
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
const features = useFeatures();
|
||||||
|
|
||||||
|
const canCreateGroup = useAppSelector((state) => hasPermission(state, PERMISSION_CREATE_GROUPS));
|
||||||
|
|
||||||
|
const { groups, isLoading } = useGroups();
|
||||||
|
|
||||||
|
const createGroup = () => {
|
||||||
|
dispatch(openModal('MANAGE_GROUP'));
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack className='gap-4'>
|
<Stack space={4}>
|
||||||
{canCreateGroup && (
|
{canCreateGroup && (
|
||||||
<Button
|
<Button
|
||||||
className='sm:w-fit sm:self-end xl:hidden'
|
className='sm:w-fit sm:self-end xl:hidden'
|
||||||
|
@ -81,15 +68,20 @@ const Groups: React.FC = () => {
|
||||||
theme='secondary'
|
theme='secondary'
|
||||||
block
|
block
|
||||||
>
|
>
|
||||||
<FormattedMessage id='new_group_panel.action' defaultMessage='Create group' />
|
<FormattedMessage id='new_group_panel.action' defaultMessage='Create Group' />
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{features.groupsDiscovery && (
|
||||||
|
<TabBar activeTab={TabItems.MY_GROUPS} />
|
||||||
|
)}
|
||||||
|
|
||||||
<ScrollableList
|
<ScrollableList
|
||||||
scrollKey='groups'
|
scrollKey='groups'
|
||||||
emptyMessage={emptyMessage}
|
emptyMessage={<EmptyMessage />}
|
||||||
itemClassName='py-3 first:pt-0 last:pb-0'
|
itemClassName='py-3 first:pt-0 last:pb-0'
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
showLoading={isLoading && !groups.count()}
|
showLoading={isLoading && groups.length === 0}
|
||||||
placeholderComponent={PlaceholderGroupCard}
|
placeholderComponent={PlaceholderGroupCard}
|
||||||
placeholderCount={3}
|
placeholderCount={3}
|
||||||
>
|
>
|
||||||
|
|
|
@ -5,23 +5,26 @@ import { HStack, Stack, Text } from 'soapbox/components/ui';
|
||||||
import { generateText, randomIntFromInterval } from '../utils';
|
import { generateText, randomIntFromInterval } from '../utils';
|
||||||
|
|
||||||
const PlaceholderGroupCard = () => {
|
const PlaceholderGroupCard = () => {
|
||||||
const groupNameLength = randomIntFromInterval(5, 25);
|
const groupNameLength = randomIntFromInterval(12, 20);
|
||||||
const roleLength = randomIntFromInterval(5, 15);
|
|
||||||
const privacyLength = randomIntFromInterval(5, 15);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='animate-pulse overflow-hidden'>
|
<div className='animate-pulse'>
|
||||||
<Stack className='rounded-lg border border-solid border-gray-300 bg-white dark:border-primary-800 dark:bg-primary-900 sm:rounded-xl'>
|
<Stack className='relative h-[240px] rounded-lg border border-solid border-gray-300 bg-white dark:border-primary-800 dark:bg-primary-900'>
|
||||||
<div className='relative m-[-1px] mb-0 h-[120px] rounded-t-lg bg-primary-100 dark:bg-gray-800 sm:rounded-t-xl'>
|
{/* Group Cover Image */}
|
||||||
<div className='absolute left-1/2 bottom-0 -translate-x-1/2 translate-y-1/2'>
|
<div className='relative grow basis-1/2 rounded-t-lg bg-gray-300 dark:bg-gray-800' />
|
||||||
<div className='h-16 w-16 rounded-full bg-primary-500 ring-2 ring-white dark:ring-primary-900' />
|
|
||||||
|
{/* Group Avatar */}
|
||||||
|
<div className='absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2'>
|
||||||
|
<div className='h-16 w-16 rounded-full bg-gray-500 ring-2 ring-white dark:bg-primary-800 dark:ring-primary-900' />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
<Stack className='p-3 pt-9' alignItems='center' space={3}>
|
{/* Group Info */}
|
||||||
<Text size='lg' weight='bold'>{generateText(groupNameLength)}</Text>
|
<Stack alignItems='center' justifyContent='end' grow className='basis-1/2 py-4' space={0.5}>
|
||||||
<HStack className='text-gray-700 dark:text-gray-600' space={3} wrap>
|
<Text size='lg' theme='subtle' weight='bold'>{generateText(groupNameLength)}</Text>
|
||||||
<span>{generateText(roleLength)}</span>
|
|
||||||
<span>{generateText(privacyLength)}</span>
|
<HStack className='text-gray-400 dark:text-gray-600' space={3} wrap>
|
||||||
|
<span>{generateText(6)}</span>
|
||||||
|
<span>{generateText(6)}</span>
|
||||||
</HStack>
|
</HStack>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|
|
@ -0,0 +1,38 @@
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
import { HStack, Stack, Text } from 'soapbox/components/ui';
|
||||||
|
|
||||||
|
import { generateText, randomIntFromInterval } from '../utils';
|
||||||
|
|
||||||
|
const PlaceholderGroupDiscover = () => {
|
||||||
|
const groupNameLength = randomIntFromInterval(12, 20);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack space={2} className='animate-pulse'>
|
||||||
|
<Stack className='aspect-w-10 aspect-h-7 h-full w-full overflow-hidden rounded-lg'>
|
||||||
|
{/* Group Cover Image */}
|
||||||
|
<div className='absolute inset-0 rounded-t-lg bg-gray-300 object-cover dark:bg-gray-800' />
|
||||||
|
|
||||||
|
<Stack justifyContent='end' className='z-10 p-4 text-white' space={3}>
|
||||||
|
{/* Group Avatar */}
|
||||||
|
<div className='h-11 w-11 rounded-full bg-gray-500 dark:bg-gray-700 dark:ring-primary-900' />
|
||||||
|
|
||||||
|
{/* Group Info */}
|
||||||
|
<Stack space={1} className='text-gray-500 dark:text-gray-700'>
|
||||||
|
<Text theme='inherit' weight='bold' truncate>{generateText(groupNameLength)}</Text>
|
||||||
|
|
||||||
|
<HStack space={3} wrap>
|
||||||
|
<Text tag='span' theme='inherit'>{generateText(6)}</Text>
|
||||||
|
<Text tag='span' theme='inherit'>{generateText(6)}</Text>
|
||||||
|
</HStack>
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
{/* Join Group Button */}
|
||||||
|
<div className='h-10 w-full rounded-full bg-gray-300 dark:bg-gray-800' />
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default PlaceholderGroupDiscover;
|
|
@ -21,7 +21,7 @@ const NewGroupPanel = () => {
|
||||||
<Stack space={2}>
|
<Stack space={2}>
|
||||||
<Stack>
|
<Stack>
|
||||||
<Text size='lg' weight='bold'>
|
<Text size='lg' weight='bold'>
|
||||||
<FormattedMessage id='new_group_panel.title' defaultMessage='Create New Group' />
|
<FormattedMessage id='new_group_panel.title' defaultMessage='Create Group' />
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
<Text theme='muted' size='sm'>
|
<Text theme='muted' size='sm'>
|
||||||
|
@ -30,12 +30,11 @@ const NewGroupPanel = () => {
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
icon={require('@tabler/icons/circles.svg')}
|
|
||||||
onClick={createGroup}
|
onClick={createGroup}
|
||||||
theme='secondary'
|
theme='secondary'
|
||||||
block
|
block
|
||||||
>
|
>
|
||||||
<FormattedMessage id='new_group_panel.action' defaultMessage='Create group' />
|
<FormattedMessage id='new_group_panel.action' defaultMessage='Create Group' />
|
||||||
</Button>
|
</Button>
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
|
|
|
@ -115,6 +115,7 @@ import {
|
||||||
EventDiscussion,
|
EventDiscussion,
|
||||||
Events,
|
Events,
|
||||||
Groups,
|
Groups,
|
||||||
|
GroupsDiscover,
|
||||||
GroupMembers,
|
GroupMembers,
|
||||||
GroupTimeline,
|
GroupTimeline,
|
||||||
ManageGroup,
|
ManageGroup,
|
||||||
|
@ -282,6 +283,7 @@ const SwitchingColumnsArea: React.FC<ISwitchingColumnsArea> = ({ children }) =>
|
||||||
<Redirect from='/@:username/:statusId' to='/@:username/posts/:statusId' />
|
<Redirect from='/@:username/:statusId' to='/@:username/posts/:statusId' />
|
||||||
|
|
||||||
{features.groups && <WrappedRoute path='/groups' exact page={GroupsPage} component={Groups} content={children} />}
|
{features.groups && <WrappedRoute path='/groups' exact page={GroupsPage} component={Groups} content={children} />}
|
||||||
|
{features.groupsDiscovery && <WrappedRoute path='/groups/discover' exact page={GroupsPage} component={GroupsDiscover} 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} />}
|
||||||
{features.groups && <WrappedRoute path='/groups/:id/manage' exact page={DefaultPage} component={ManageGroup} content={children} />}
|
{features.groups && <WrappedRoute path='/groups/:id/manage' exact page={DefaultPage} component={ManageGroup} content={children} />}
|
||||||
|
|
|
@ -542,6 +542,10 @@ export function Groups() {
|
||||||
return import(/* webpackChunkName: "features/groups" */'../../groups');
|
return import(/* webpackChunkName: "features/groups" */'../../groups');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function GroupsDiscover() {
|
||||||
|
return import(/* webpackChunkName: "features/groups/discover" */'../../groups/discover');
|
||||||
|
}
|
||||||
|
|
||||||
export function GroupMembers() {
|
export function GroupMembers() {
|
||||||
return import(/* webpackChunkName: "features/groups" */'../../group/group-members');
|
return import(/* webpackChunkName: "features/groups" */'../../group/group-members');
|
||||||
}
|
}
|
||||||
|
|
|
@ -987,9 +987,9 @@
|
||||||
"new_event_panel.action": "Create event",
|
"new_event_panel.action": "Create event",
|
||||||
"new_event_panel.subtitle": "Can't find what you're looking for? Schedule your own event.",
|
"new_event_panel.subtitle": "Can't find what you're looking for? Schedule your own event.",
|
||||||
"new_event_panel.title": "Create New Event",
|
"new_event_panel.title": "Create New Event",
|
||||||
"new_group_panel.action": "Create group",
|
"new_group_panel.action": "Create Group",
|
||||||
"new_group_panel.subtitle": "Can't find what you're looking for? Start your own private or public group.",
|
"new_group_panel.subtitle": "Can't find what you're looking for? Start your own private or public group.",
|
||||||
"new_group_panel.title": "Create New Group",
|
"new_group_panel.title": "Create Group",
|
||||||
"notification.favourite": "{name} liked your post",
|
"notification.favourite": "{name} liked your post",
|
||||||
"notification.follow": "{name} followed you",
|
"notification.follow": "{name} followed you",
|
||||||
"notification.follow_request": "{name} has requested to follow you",
|
"notification.follow_request": "{name} has requested to follow you",
|
||||||
|
|
|
@ -29,6 +29,7 @@ export const GroupRecord = ImmutableRecord({
|
||||||
id: '',
|
id: '',
|
||||||
locked: false,
|
locked: false,
|
||||||
membership_required: false,
|
membership_required: false,
|
||||||
|
members_count: undefined as number | undefined,
|
||||||
note: '',
|
note: '',
|
||||||
statuses_visibility: 'public',
|
statuses_visibility: 'public',
|
||||||
uri: '',
|
uri: '',
|
||||||
|
|
111
app/soapbox/queries/groups.ts
Normal file
111
app/soapbox/queries/groups.ts
Normal file
|
@ -0,0 +1,111 @@
|
||||||
|
import { useInfiniteQuery, useQuery } from '@tanstack/react-query';
|
||||||
|
|
||||||
|
import { fetchGroupRelationships } from 'soapbox/actions/groups';
|
||||||
|
import { importFetchedGroups } from 'soapbox/actions/importer';
|
||||||
|
import { getNextLink } from 'soapbox/api';
|
||||||
|
import { useApi, useAppDispatch, useFeatures, useOwnAccount } from 'soapbox/hooks';
|
||||||
|
import { normalizeGroup } from 'soapbox/normalizers';
|
||||||
|
import { Group } from 'soapbox/types/entities';
|
||||||
|
import { flattenPages, PaginatedResult } from 'soapbox/utils/queries';
|
||||||
|
|
||||||
|
const GroupKeys = {
|
||||||
|
myGroups: (userId: string) => ['groups', userId] as const,
|
||||||
|
popularGroups: ['groups', 'popular'] as const,
|
||||||
|
suggestedGroups: ['groups', 'suggested'] as const,
|
||||||
|
};
|
||||||
|
|
||||||
|
const useGroups = () => {
|
||||||
|
const api = useApi();
|
||||||
|
const account = useOwnAccount();
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
|
||||||
|
const getGroups = async (pageParam?: any): Promise<PaginatedResult<Group>> => {
|
||||||
|
const endpoint = '/api/mock/groups'; // '/api/v1/groups';
|
||||||
|
const nextPageLink = pageParam?.link;
|
||||||
|
const uri = nextPageLink || endpoint;
|
||||||
|
const response = await api.get<Group[]>(uri);
|
||||||
|
const { data } = response;
|
||||||
|
|
||||||
|
const link = getNextLink(response);
|
||||||
|
const hasMore = !!link;
|
||||||
|
const result = data.map(normalizeGroup);
|
||||||
|
|
||||||
|
// Note: Temporary while part of Groups is using Redux
|
||||||
|
dispatch(importFetchedGroups(result));
|
||||||
|
dispatch(fetchGroupRelationships(result.map((item) => item.id)));
|
||||||
|
|
||||||
|
return {
|
||||||
|
result,
|
||||||
|
hasMore,
|
||||||
|
link,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const queryInfo = useInfiniteQuery(
|
||||||
|
GroupKeys.myGroups(account?.id as string),
|
||||||
|
({ pageParam }: any) => getGroups(pageParam),
|
||||||
|
{
|
||||||
|
enabled: !!account,
|
||||||
|
keepPreviousData: true,
|
||||||
|
getNextPageParam: (config) => {
|
||||||
|
if (config?.hasMore) {
|
||||||
|
return { nextLink: config?.link };
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = flattenPages(queryInfo.data);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...queryInfo,
|
||||||
|
groups: data || [],
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const usePopularGroups = () => {
|
||||||
|
const api = useApi();
|
||||||
|
const features = useFeatures();
|
||||||
|
|
||||||
|
const getQuery = async () => {
|
||||||
|
const { data } = await api.get<Group[]>('/api/mock/groups'); // '/api/v1/truth/trends/groups'
|
||||||
|
const result = data.map(normalizeGroup);
|
||||||
|
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
|
||||||
|
const queryInfo = useQuery<Group[]>(GroupKeys.popularGroups, getQuery, {
|
||||||
|
enabled: features.groupsDiscovery,
|
||||||
|
placeholderData: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
groups: queryInfo.data || [],
|
||||||
|
...queryInfo,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const useSuggestedGroups = () => {
|
||||||
|
const api = useApi();
|
||||||
|
const features = useFeatures();
|
||||||
|
|
||||||
|
const getQuery = async () => {
|
||||||
|
const { data } = await api.get<Group[]>('/api/mock/groups'); // /api/v1/truth/suggestions/groups
|
||||||
|
const result = data.map(normalizeGroup);
|
||||||
|
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
|
||||||
|
const queryInfo = useQuery<Group[]>(GroupKeys.suggestedGroups, getQuery, {
|
||||||
|
enabled: features.groupsDiscovery,
|
||||||
|
placeholderData: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
groups: queryInfo.data || [],
|
||||||
|
...queryInfo,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export { useGroups, usePopularGroups, useSuggestedGroups };
|
|
@ -499,6 +499,11 @@ const getInstanceFeatures = (instance: Instance) => {
|
||||||
*/
|
*/
|
||||||
groups: false,
|
groups: false,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Can see trending/suggested Groups.
|
||||||
|
*/
|
||||||
|
groupsDiscovery: v.software === TRUTHSOCIAL,
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Can hide follows/followers lists and counts.
|
* Can hide follows/followers lists and counts.
|
||||||
* @see PATCH /api/v1/accounts/update_credentials
|
* @see PATCH /api/v1/accounts/update_credentials
|
||||||
|
|
|
@ -205,6 +205,7 @@
|
||||||
"@storybook/manager-webpack5": "^6.5.16",
|
"@storybook/manager-webpack5": "^6.5.16",
|
||||||
"@storybook/react": "^6.5.16",
|
"@storybook/react": "^6.5.16",
|
||||||
"@storybook/testing-library": "^0.0.13",
|
"@storybook/testing-library": "^0.0.13",
|
||||||
|
"@tailwindcss/aspect-ratio": "^0.4.2",
|
||||||
"@testing-library/jest-dom": "^5.16.4",
|
"@testing-library/jest-dom": "^5.16.4",
|
||||||
"@testing-library/react-hooks": "^8.0.1",
|
"@testing-library/react-hooks": "^8.0.1",
|
||||||
"@testing-library/user-event": "^14.0.3",
|
"@testing-library/user-event": "^14.0.3",
|
||||||
|
|
|
@ -100,5 +100,6 @@ module.exports = {
|
||||||
require('@tailwindcss/forms'),
|
require('@tailwindcss/forms'),
|
||||||
require('@tailwindcss/line-clamp'),
|
require('@tailwindcss/line-clamp'),
|
||||||
require('@tailwindcss/typography'),
|
require('@tailwindcss/typography'),
|
||||||
|
require('@tailwindcss/aspect-ratio'),
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
|
@ -3831,6 +3831,11 @@
|
||||||
resolved "https://registry.yarnpkg.com/@tabler/icons/-/icons-2.4.0.tgz#34b1b0d818dc00926b956c3424bff48b89a5b439"
|
resolved "https://registry.yarnpkg.com/@tabler/icons/-/icons-2.4.0.tgz#34b1b0d818dc00926b956c3424bff48b89a5b439"
|
||||||
integrity sha512-JZY9Kk3UsQoqp7Rw/BuWw1PrkRwv5h0psjJBbj+Cn9UVyhdzr5vztg2mywXBAJ+jFBUL/pjnVcIvOzKFw4CXng==
|
integrity sha512-JZY9Kk3UsQoqp7Rw/BuWw1PrkRwv5h0psjJBbj+Cn9UVyhdzr5vztg2mywXBAJ+jFBUL/pjnVcIvOzKFw4CXng==
|
||||||
|
|
||||||
|
"@tailwindcss/aspect-ratio@^0.4.2":
|
||||||
|
version "0.4.2"
|
||||||
|
resolved "https://registry.yarnpkg.com/@tailwindcss/aspect-ratio/-/aspect-ratio-0.4.2.tgz#9ffd52fee8e3c8b20623ff0dcb29e5c21fb0a9ba"
|
||||||
|
integrity sha512-8QPrypskfBa7QIMuKHg2TA7BqES6vhBrDLOv8Unb6FcFyd3TjKbc6lcmb9UPQHxfl24sXoJ41ux/H7qQQvfaSQ==
|
||||||
|
|
||||||
"@tailwindcss/forms@^0.5.3":
|
"@tailwindcss/forms@^0.5.3":
|
||||||
version "0.5.3"
|
version "0.5.3"
|
||||||
resolved "https://registry.yarnpkg.com/@tailwindcss/forms/-/forms-0.5.3.tgz#e4d7989686cbcaf416c53f1523df5225332a86e7"
|
resolved "https://registry.yarnpkg.com/@tailwindcss/forms/-/forms-0.5.3.tgz#e4d7989686cbcaf416c53f1523df5225332a86e7"
|
||||||
|
|
Loading…
Reference in a new issue