Groups: UI improvements

Signed-off-by: marcin mikołajczak <git@mkljczk.pl>
This commit is contained in:
marcin mikołajczak 2022-12-15 23:51:30 +01:00
parent 683504c997
commit d524a7c700
25 changed files with 681 additions and 279 deletions

View file

@ -11,10 +11,16 @@ import type { AxiosError } from 'axios';
import type { AppDispatch, RootState } from 'soapbox/store';
import type { APIEntity } from 'soapbox/types/entities';
type GroupMedia = 'header' | 'avatar';
const GROUP_CREATE_REQUEST = 'GROUP_CREATE_REQUEST';
const GROUP_CREATE_SUCCESS = 'GROUP_CREATE_SUCCESS';
const GROUP_CREATE_FAIL = 'GROUP_CREATE_FAIL';
const GROUP_UPDATE_REQUEST = 'GROUP_UPDATE_REQUEST';
const GROUP_UPDATE_SUCCESS = 'GROUP_UPDATE_SUCCESS';
const GROUP_UPDATE_FAIL = 'GROUP_UPDATE_FAIL';
const GROUP_DELETE_REQUEST = 'GROUP_DELETE_REQUEST';
const GROUP_DELETE_SUCCESS = 'GROUP_DELETE_SUCCESS';
const GROUP_DELETE_FAIL = 'GROUP_DELETE_FAIL';
@ -97,13 +103,20 @@ const GROUP_MEMBERSHIP_REQUEST_REJECT_FAIL = 'GROUP_MEMBERSHIP_REQUEST_REJECT
const GROUP_EDITOR_TITLE_CHANGE = 'GROUP_EDITOR_TITLE_CHANGE';
const GROUP_EDITOR_DESCRIPTION_CHANGE = 'GROUP_EDITOR_DESCRIPTION_CHANGE';
const GROUP_EDITOR_PRIVACY_CHANGE = 'GROUP_EDITOR_PRIVACY_CHANGE';
const GROUP_EDITOR_MEDIA_CHANGE = 'GROUP_EDITOR_MEDIA_CHANGE';
const GROUP_EDITOR_RESET = 'GROUP_EDITOR_RESET';
const createGroup = (displayName: string, note: string, shouldReset?: boolean) =>
const createGroup = (params: Record<string, any>, shouldReset?: boolean) =>
(dispatch: AppDispatch, getState: () => RootState) => {
dispatch(createGroupRequest());
api(getState).post('/api/v1/groups', { display_name: displayName, note })
api(getState).post('/api/v1/groups', params, {
headers: {
'Content-Type': 'multipart/form-data',
},
})
.then(({ data }) => {
dispatch(importFetchedGroups([data]));
dispatch(createGroupSuccess(data));
@ -129,6 +142,36 @@ const createGroupFail = (error: AxiosError) => ({
error,
});
const updateGroup = (id: string, params: Record<string, any>, shouldReset?: boolean) =>
(dispatch: AppDispatch, getState: () => RootState) => {
dispatch(updateGroupRequest());
api(getState).put(`/api/v1/groups/${id}`, params)
.then(({ data }) => {
dispatch(importFetchedGroups([data]));
dispatch(updateGroupSuccess(data));
if (shouldReset) {
dispatch(resetGroupEditor());
}
dispatch(closeModal('MANAGE_GROUP'));
}).catch(err => dispatch(updateGroupFail(err)));
};
const updateGroupRequest = () => ({
type: GROUP_UPDATE_REQUEST,
});
const updateGroupSuccess = (group: APIEntity) => ({
type: GROUP_UPDATE_SUCCESS,
group,
});
const updateGroupFail = (error: AxiosError) => ({
type: GROUP_UPDATE_FAIL,
error,
});
const deleteGroup = (id: string) => (dispatch: AppDispatch, getState: () => RootState) => {
dispatch(deleteGroupRequest(id));
@ -768,6 +811,17 @@ const changeGroupEditorDescription = (value: string) => ({
value,
});
const changeGroupEditorPrivacy = (value: boolean) => ({
type: GROUP_EDITOR_PRIVACY_CHANGE,
value,
});
const changeGroupEditorMedia = (mediaType: GroupMedia, file: File) => ({
type: GROUP_EDITOR_MEDIA_CHANGE,
mediaType,
value: file,
});
const resetGroupEditor = () => ({
type: GROUP_EDITOR_RESET,
});
@ -776,9 +830,19 @@ const submitGroupEditor = (shouldReset?: boolean) => (dispatch: AppDispatch, get
const groupId = getState().group_editor.groupId;
const displayName = getState().group_editor.displayName;
const note = getState().group_editor.note;
const avatar = getState().group_editor.avatar;
const header = getState().group_editor.header;
const params: Record<string, any> = {
display_name: displayName,
note,
};
if (avatar) params.avatar = avatar;
if (header) params.header = header;
if (groupId === null) {
dispatch(createGroup(displayName, note, shouldReset));
dispatch(createGroup(params, shouldReset));
} else {
// TODO: dispatch(updateList(listId, title, shouldReset));
}
@ -788,6 +852,9 @@ export {
GROUP_CREATE_REQUEST,
GROUP_CREATE_SUCCESS,
GROUP_CREATE_FAIL,
GROUP_UPDATE_REQUEST,
GROUP_UPDATE_SUCCESS,
GROUP_UPDATE_FAIL,
GROUP_DELETE_REQUEST,
GROUP_DELETE_SUCCESS,
GROUP_DELETE_FAIL,
@ -850,11 +917,17 @@ export {
GROUP_MEMBERSHIP_REQUEST_REJECT_FAIL,
GROUP_EDITOR_TITLE_CHANGE,
GROUP_EDITOR_DESCRIPTION_CHANGE,
GROUP_EDITOR_PRIVACY_CHANGE,
GROUP_EDITOR_MEDIA_CHANGE,
GROUP_EDITOR_RESET,
createGroup,
createGroupRequest,
createGroupSuccess,
createGroupFail,
updateGroup,
updateGroupRequest,
updateGroupSuccess,
updateGroupFail,
deleteGroup,
deleteGroupRequest,
deleteGroupSuccess,
@ -937,6 +1010,8 @@ export {
rejectGroupMembershipRequestFail,
changeGroupEditorTitle,
changeGroupEditorDescription,
changeGroupEditorPrivacy,
changeGroupEditorMedia,
resetGroupEditor,
submitGroupEditor,
};

View file

@ -13,7 +13,7 @@ import VerificationBadge from './verification-badge';
import type { Account as AccountEntity, Status as StatusEntity } from 'soapbox/types/entities';
const messages = defineMessages({
bannerHeader: { id: 'event.banner', defaultMessage: 'Event banner' },
eventBanner: { id: 'event.banner', defaultMessage: 'Event banner' },
leaveConfirm: { id: 'confirmations.leave_event.confirm', defaultMessage: 'Leave event' },
leaveMessage: { id: 'confirmations.leave_event.message', defaultMessage: 'If you want to rejoin the event, the request will be manually reviewed again. Are you sure you want to proceed?' },
});
@ -56,7 +56,7 @@ const EventPreview: React.FC<IEventPreview> = ({ status, className, hideAction,
{floatingAction && action}
</div>
<div className='bg-primary-200 dark:bg-gray-600 h-40'>
{banner && <img className='h-full w-full object-cover' src={banner.url} alt={intl.formatMessage(messages.bannerHeader)} />}
{banner && <img className='h-full w-full object-cover' src={banner.url} alt={intl.formatMessage(messages.eventBanner)} />}
</div>
<Stack className='p-2.5' space={2}>
<HStack space={2} alignItems='center' justifyContent='between'>

View file

@ -0,0 +1,58 @@
import React from 'react';
import { defineMessages, useIntl } from 'react-intl';
import { Avatar, HStack, Icon, Stack, Text } from './ui';
import type { Group as GroupEntity } from 'soapbox/types/entities';
const messages = defineMessages({
groupHeader: { id: 'group.header.alt', defaultMessage: 'Group header' },
});
interface IGroupCard {
group: GroupEntity
}
const GroupCard: React.FC<IGroupCard> = ({ group }) => {
const intl = useIntl();
return (
<Stack className='rounded-lg sm:rounded-xl shadow-lg dark:shadow-none overflow-hidden'>
<div className='bg-primary-100 dark:bg-gray-800 h-[120px] relative'>
{group.header && <img className='h-full w-full object-cover' src={group.header} alt={intl.formatMessage(messages.groupHeader)} />}
<div className='absolute 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} />
</div>
</div>
<Stack className='p-3 pt-9' alignItems='center' space={3}>
<Text size='lg' weight='bold' dangerouslySetInnerHTML={{ __html: group.display_name_html }} />
<HStack className='text-gray-700 dark:text-gray-600' space={3} wrap>
{group.relationship?.role === 'admin' ? (
<HStack space={1} alignItems='center'>
<Icon className='h-4 w-4' src={require('@tabler/icons/users.svg')} />
<span>Owner</span>
</HStack>
) : group.relationship?.role === 'moderator' && (
<HStack space={1} alignItems='center'>
<Icon className='h-4 w-4' src={require('@tabler/icons/gavel.svg')} />
<span>Moderator</span>
</HStack>
)}
{group.locked ? (
<HStack space={1} alignItems='center'>
<Icon className='h-4 w-4' src={require('@tabler/icons/lock.svg')} />
<span>Private</span>
</HStack>
) : (
<HStack space={1} alignItems='center'>
<Icon className='h-4 w-4' src={require('@tabler/icons/world.svg')} />
<span>Public</span>
</HStack>
)}
</HStack>
</Stack>
</Stack>
);
};
export default GroupCard;

View file

@ -134,6 +134,14 @@ const SidebarNavigation = () => {
{renderMessagesLink()}
{features.groups && (
<SidebarNavigationLink
to='/groups'
icon={require('@tabler/icons/circles.svg')}
text={<FormattedMessage id='tabs_bar.groups' defaultMessage='Groups' />}
/>
)}
<SidebarNavigationLink
to={`/@${account.acct}`}
icon={require('@tabler/icons/user.svg')}

View file

@ -26,7 +26,7 @@ import { Card, HStack, Stack, Text } from './ui';
import type { Map as ImmutableMap } from 'immutable';
import type {
Account as AccountEntity,
Group as GroupEntity,
// Group as GroupEntity,
Status as StatusEntity,
} from 'soapbox/types/entities';
@ -300,7 +300,7 @@ const Status: React.FC<IStatus> = (props) => {
}
}
const group = actualStatus.group as GroupEntity | null;
// const group = actualStatus.group as GroupEntity | null;
const handlers = muted ? undefined : {
reply: handleHotkeyReply,
@ -345,10 +345,10 @@ const Status: React.FC<IStatus> = (props) => {
</div>
)}
{group && (
{/* {group && (
<div className='pt-4 px-4'>
<HStack alignItems='center' space={1}>
<Icon src={require('@tabler/icons/users.svg')} className='text-gray-600 dark:text-gray-400' />
<Icon src={require('@tabler/icons/circles.svg')} className='text-gray-600 dark:text-gray-400' />
<Text size='sm' theme='muted' weight='medium'>
<FormattedMessage
@ -363,7 +363,7 @@ const Status: React.FC<IStatus> = (props) => {
</Text>
</HStack>
</div>
)}
)} */}
<Card
variant={variant}

View file

@ -40,6 +40,8 @@ interface IModal {
confirmationText?: React.ReactNode,
/** Confirmation button theme. */
confirmationTheme?: ButtonThemes,
/** Whether to use full width style for confirmation button. */
confirmationFullWidth?: boolean,
/** Callback when the modal is closed. */
onClose?: () => void,
/** Callback when the secondary action is chosen. */
@ -65,6 +67,7 @@ const Modal: React.FC<IModal> = ({
confirmationDisabled,
confirmationText,
confirmationTheme,
confirmationFullWidth,
onClose,
secondaryAction,
secondaryDisabled = false,
@ -117,7 +120,7 @@ const Modal: React.FC<IModal> = ({
{confirmationAction && (
<HStack className='mt-5' justifyContent='between' data-testid='modal-actions'>
<div className='flex-grow'>
<div className={classNames({ 'flex-grow': !confirmationFullWidth })}>
{cancelAction && (
<Button
theme='tertiary'
@ -128,7 +131,7 @@ const Modal: React.FC<IModal> = ({
)}
</div>
<HStack space={2}>
<HStack space={2} className={classNames({ 'flex-grow': confirmationFullWidth })}>
{secondaryAction && (
<Button
theme='secondary'
@ -144,6 +147,7 @@ const Modal: React.FC<IModal> = ({
onClick={confirmationAction}
disabled={confirmationDisabled}
ref={buttonRef}
block={confirmationFullWidth}
>
{confirmationText}
</Button>

View file

@ -1,16 +1,13 @@
import { List as ImmutableList } from 'immutable';
import React from 'react';
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
import { Link } from 'react-router-dom';
import { openModal } from 'soapbox/actions/modals';
import StillImage from 'soapbox/components/still-image';
import { Avatar, Button, HStack, IconButton, Menu, MenuButton, MenuDivider, MenuItem, MenuLink, MenuList } from 'soapbox/components/ui';
import SvgIcon from 'soapbox/components/ui/icon/svg-icon';
import { useAppDispatch, useOwnAccount } from 'soapbox/hooks';
import { Avatar, Button, HStack, Icon, Stack, Text } from 'soapbox/components/ui';
import { useAppDispatch } from 'soapbox/hooks';
import { normalizeAttachment } from 'soapbox/normalizers';
import type { Menu as MenuType } from 'soapbox/components/dropdown-menu';
import type { Group } from 'soapbox/types/entities';
const messages = defineMessages({
@ -25,8 +22,6 @@ const GroupHeader: React.FC<IGroupHeader> = ({ group }) => {
const intl = useIntl();
const dispatch = useAppDispatch();
const ownAccount = useOwnAccount();
if (!group) {
return (
<div className='-mt-4 -mx-4'>
@ -77,21 +72,14 @@ const GroupHeader: React.FC<IGroupHeader> = ({ group }) => {
}
};
const makeMenu = () => {
const menu: MenuType = [];
return menu;
};
const makeActionButton = () => {
if (group.relationship?.role === 'admin') {
return (
<Button
size='sm'
theme='primary'
theme='secondary'
// to={`/@${account.acct}/events/${status.id}`}
>
<FormattedMessage id='group.manage' defaultMessage='Manage' />
<FormattedMessage id='group.manage' defaultMessage='Edit group' />
</Button>
);
}
@ -99,13 +87,12 @@ const GroupHeader: React.FC<IGroupHeader> = ({ group }) => {
return null;
};
const menu = makeMenu();
const actionButton = makeActionButton();
return (
<div className='-mt-4 -mx-4'>
<div>
<div className='relative flex flex-col justify-center h-32 w-full lg:h-48 md:rounded-t-xl bg-gray-200 dark:bg-gray-900/50 overflow-hidden isolate'>
<div className='relative'>
<div className='relative flex flex-col justify-center h-32 w-full lg:h-[200px] md:rounded-t-xl bg-gray-200 dark:bg-gray-900/50 overflow-hidden isolate'>
{group.header && (
<a href={group.header} onClick={handleHeaderClick} target='_blank'>
<StillImage
@ -114,70 +101,43 @@ const GroupHeader: React.FC<IGroupHeader> = ({ group }) => {
/>
</a>
)}
<div className='absolute top-2 left-2'>
<HStack alignItems='center' space={1}>
{/* {info} */}
</HStack>
</div>
</div>
</div>
<div className='px-4 sm:px-6'>
<HStack className='-mt-12' alignItems='bottom' space={5}>
<div className='flex'>
<div className='absolute left-1/2 bottom-0 -translate-x-1/2 translate-y-1/2'>
<a href={group.avatar} onClick={handleAvatarClick} target='_blank'>
<Avatar
src={group.avatar}
size={96}
className='relative h-24 w-24 rounded-full ring-4 ring-white dark:ring-primary-900'
/>
<Avatar className='ring-[3px] ring-white dark:ring-primary-900' src={group.avatar} size={72} />
</a>
</div>
</div>
<div className='mt-6 flex justify-end w-full sm:pb-1'>
<HStack space={2} className='mt-10'>
{ownAccount && (
<Menu>
<MenuButton
as={IconButton}
src={require('@tabler/icons/dots.svg')}
theme='outlined'
className='px-2'
iconClassName='w-4 h-4'
children={null}
/>
<MenuList className='w-56'>
{menu.map((menuItem, idx) => {
if (typeof menuItem?.text === 'undefined') {
return <MenuDivider key={idx} />;
} else {
const Comp = (menuItem.action ? MenuItem : MenuLink) as any;
const itemProps = menuItem.action ? { onSelect: menuItem.action } : { to: menuItem.to, as: Link, target: menuItem.newTab ? '_blank' : '_self' };
return (
<Comp key={idx} {...itemProps} className='group'>
<HStack space={3} alignItems='center'>
{menuItem.icon && (
<SvgIcon src={menuItem.icon} className='h-5 w-5 text-gray-400 flex-none group-hover:text-gray-500' />
)}
<div className='truncate'>{menuItem.text}</div>
<Stack className='p-3 pt-12' alignItems='center' space={2}>
<Text className='mb-1' size='xl' weight='bold' dangerouslySetInnerHTML={{ __html: group.display_name_html }} />
<HStack className='text-gray-700 dark:text-gray-600' space={3} wrap>
{group.relationship?.role === 'admin' ? (
<HStack space={1} alignItems='center'>
<Icon className='h-4 w-4' src={require('@tabler/icons/users.svg')} />
<span>Owner</span>
</HStack>
) : group.relationship?.role === 'moderator' && (
<HStack space={1} alignItems='center'>
<Icon className='h-4 w-4' src={require('@tabler/icons/gavel.svg')} />
<span>Moderator</span>
</HStack>
</Comp>
);
}
})}
</MenuList>
</Menu>
)}
{group.locked ? (
<HStack space={1} alignItems='center'>
<Icon className='h-4 w-4' src={require('@tabler/icons/lock.svg')} />
<span>Private</span>
</HStack>
) : (
<HStack space={1} alignItems='center'>
<Icon className='h-4 w-4' src={require('@tabler/icons/world.svg')} />
<span>Public</span>
</HStack>
)}
</HStack>
<Text theme='muted' dangerouslySetInnerHTML={{ __html: group.note_emojified }} />
{actionButton}
</HStack>
</div>
</HStack>
</div>
</Stack>
</div>
);
};

View file

@ -1,27 +0,0 @@
import React from 'react';
import Markup from 'soapbox/components/markup';
import { HStack, Stack, Text } from 'soapbox/components/ui';
import { Group } from 'soapbox/types/entities';
interface IGroupInfoPanel {
group: Group,
}
const GroupInfoPanel: React.FC<IGroupInfoPanel> = ({ group }) => (
<div className='mt-6 min-w-0 flex-1 sm:px-2'>
<Stack space={2}>
<Stack>
<HStack space={1} alignItems='center'>
<Text size='lg' weight='bold' dangerouslySetInnerHTML={{ __html: group.display_name_html }} />
</HStack>
</Stack>
{group.note.length > 0 && (
<Markup size='sm' dangerouslySetInnerHTML={{ __html: group.note_emojified }} />
)}
</Stack>
</div>
);
export default GroupInfoPanel;

View file

@ -1,13 +1,14 @@
import React, { useEffect } from 'react';
import { FormattedMessage } from 'react-intl';
import { Link } from 'react-router-dom';
import { groupCompose } from 'soapbox/actions/compose';
import { fetchGroup } from 'soapbox/actions/groups';
import { connectGroupStream } from 'soapbox/actions/streaming';
import { expandGroupTimeline } from 'soapbox/actions/timelines';
import { Stack } from 'soapbox/components/ui';
import { Avatar, HStack, Stack } from 'soapbox/components/ui';
import ComposeForm from 'soapbox/features/compose/components/compose-form';
import { useAppDispatch } from 'soapbox/hooks';
import { useAppDispatch, useOwnAccount } from 'soapbox/hooks';
import Timeline from '../ui/components/timeline';
@ -18,6 +19,7 @@ interface IGroupTimeline {
}
const GroupTimeline: React.FC<IGroupTimeline> = (props) => {
const account = useOwnAccount();
const dispatch = useAppDispatch();
const groupId = props.params.id;
@ -43,18 +45,29 @@ const GroupTimeline: React.FC<IGroupTimeline> = (props) => {
return (
<Stack space={2}>
<div className='p-2 pt-0 border-b border-solid border-gray-200 dark:border-gray-800'>
<ComposeForm id={`group:${groupId}`} autoFocus={false} group={groupId} />
{!!account && (
<div className='px-2 py-4 border-b border-solid border-gray-200 dark:border-gray-800'>
<HStack alignItems='start' space={4}>
<Link to={`/@${account.acct}`}>
<Avatar src={account.avatar} size={46} />
</Link>
<ComposeForm
id={`group:${groupId}`}
shouldCondense
autoFocus={false}
group={groupId}
/>
</HStack>
</div>
<div className='p-0 sm:p-2 shadow-none'>
)}
<Timeline
scrollKey='group_timeline'
timelineId={`group:${groupId}`}
onLoadMore={handleLoadMore}
emptyMessage={<FormattedMessage id='empty_column.group' defaultMessage='There is no post in this group yet.' />}
emptyMessage={<FormattedMessage id='empty_column.group' defaultMessage='There are no posts in this group yet.' />}
divideType='space'
/>
</div>
</Stack>
);
};

View file

@ -1,21 +1,18 @@
import React, { useEffect } from 'react';
import { defineMessages, useIntl, FormattedMessage } from 'react-intl';
import { FormattedMessage } from 'react-intl';
import { useDispatch } from 'react-redux';
import { Link } from 'react-router-dom';
import { createSelector } from 'reselect';
import { fetchGroups } from 'soapbox/actions/groups';
import { openModal } from 'soapbox/actions/modals';
import Icon from 'soapbox/components/icon';
import GroupCard from 'soapbox/components/group-card';
import ScrollableList from 'soapbox/components/scrollable-list';
import { Button, Column, HStack, Spinner } from 'soapbox/components/ui';
import { Column, Spinner, Stack, Text } from 'soapbox/components/ui';
import { useAppSelector } from 'soapbox/hooks';
import type { List as ImmutableList } from 'immutable';
import type { RootState } from 'soapbox/store';
const messages = defineMessages({
heading: { id: 'column.groups', defaultMessage: 'Groups' },
});
import type { Group as GroupEntity } from 'soapbox/types/entities';
const getOrderedGroups = createSelector([
(state: RootState) => state.groups,
@ -25,12 +22,16 @@ const getOrderedGroups = createSelector([
return groups;
}
return groups.toList().filter(item => !!item && group_relationships.get(item.id)?.member).sort((a, b) => a.display_name.localeCompare(b.display_name));
return (groups
.toList()
.filter((item: GroupEntity | false) => !!item) as ImmutableList<GroupEntity>)
.map((item) => item.set('relationship', group_relationships.get(item.id) || null))
.filter((item) => item.relationship?.member)
.sort((a, b) => a.display_name.localeCompare(b.display_name));
});
const Lists: React.FC = () => {
const dispatch = useDispatch();
const intl = useIntl();
const groups = useAppSelector((state) => getOrderedGroups(state));
@ -38,10 +39,6 @@ const Lists: React.FC = () => {
dispatch(fetchGroups());
}, []);
const onCreateGroup = () => {
dispatch(openModal('MANAGE_GROUP'));
};
if (!groups) {
return (
<Column>
@ -50,35 +47,38 @@ const Lists: React.FC = () => {
);
}
const emptyMessage = <FormattedMessage id='empty_column.groups' defaultMessage='You are not in any group yet. When you join one, it will show up here.' />;
const emptyMessage = (
<Stack space={6} alignItems='center' justifyContent='center' className='p-6 h-full'>
<Stack space={2} className='max-w-sm'>
<Text size='2xl' weight='bold' tag='h2' align='center'>
<FormattedMessage
id='groups.empty.title'
defaultMessage='No Groups yet'
/>
</Text>
<Text size='sm' theme='muted' align='center'>
<FormattedMessage
id='groups.empty.subtitle'
defaultMessage='Start discovering groups to join or create your own.'
/>
</Text>
</Stack>
</Stack>
);
return (
<Column label={intl.formatMessage(messages.heading)}>
<HStack>
<Button
className='ml-auto'
theme='primary'
size='sm'
onClick={onCreateGroup}
>
<FormattedMessage id='groups.create_group' defaultMessage='Create group' />
</Button>
</HStack>
<div className='space-y-4'>
<ScrollableList
scrollKey='lists'
emptyMessage={emptyMessage}
itemClassName='py-2'
itemClassName='py-3 last:pb-0'
>
{groups.map((group: any) => (
<Link key={group.id} to={`/groups/${group.id}`} className='flex items-center gap-1.5 p-2 text-gray-900 dark:text-gray-100 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-lg'>
<Icon src={require('@tabler/icons/users.svg')} fixedWidth />
<span className='flex-grow' dangerouslySetInnerHTML={{ __html: group.display_name_html }} />
{groups.map((group) => (
<Link to={`/groups/${group.id}`}>
<GroupCard group={group as GroupEntity} />
</Link>
))}
</ScrollableList>
</div>
</Column>
);
};

View file

@ -214,7 +214,7 @@ const ComposeEventModal: React.FC<IComposeEventModal> = ({ onClose }) => {
<FormGroup
labelText={<FormattedMessage id='compose_event.fields.banner_label' defaultMessage='Event banner' />}
>
<div className='flex items-center justify-center bg-gray-200 dark:bg-gray-900/50 rounded-lg text-black dark:text-white sm:shadow dark:sm:shadow-inset overflow-hidden h-24 sm:h-32 relative'>
<div className='flex items-center justify-center bg-primary-100 dark:bg-gray-800 rounded-lg text-primary-500 dark:text-white sm:shadow dark:sm:shadow-inset overflow-hidden h-24 sm:h-32 relative'>
{banner ? (
<>
<img className='h-full w-full object-cover' src={banner.url} alt='' />
@ -223,7 +223,6 @@ const ComposeEventModal: React.FC<IComposeEventModal> = ({ onClose }) => {
) : (
<UploadButton disabled={isUploading} onSelectFile={handleFiles} />
)}
</div>
</FormGroup>
<FormGroup

View file

@ -1,23 +1,18 @@
import React, { useRef } from 'react';
import { defineMessages, useIntl } from 'react-intl';
import { FormattedMessage } from 'react-intl';
import { IconButton } from 'soapbox/components/ui';
import Icon from 'soapbox/components/icon';
import { HStack, Text } from 'soapbox/components/ui';
import { useAppSelector } from 'soapbox/hooks';
import type { List as ImmutableList } from 'immutable';
const messages = defineMessages({
upload: { id: 'compose_event.upload_banner', defaultMessage: 'Upload event banner' },
});
interface IUploadButton {
disabled?: boolean,
onSelectFile: (files: FileList) => void,
}
const UploadButton: React.FC<IUploadButton> = ({ disabled, onSelectFile }) => {
const intl = useIntl();
const fileElement = useRef<HTMLInputElement>(null);
const attachmentTypes = useAppSelector(state => state.instance.configuration.getIn(['media_attachments', 'supported_mime_types']) as ImmutableList<string>)?.filter(type => type.startsWith('image/'));
@ -32,17 +27,16 @@ const UploadButton: React.FC<IUploadButton> = ({ disabled, onSelectFile }) => {
};
return (
<div>
<IconButton
<HStack className='h-full w-full text-primary-500 dark:text-accent-blue cursor-pointer' space={3} alignItems='center' justifyContent='center' element='label'>
<Icon
src={require('@tabler/icons/photo-plus.svg')}
className='h-8 w-8 text-gray-600 hover:text-gray-700 dark:hover:text-gray-500'
title={intl.formatMessage(messages.upload)}
disabled={disabled}
className='h-7 w-7'
onClick={handleClick}
/>
<label>
<span className='sr-only'>{intl.formatMessage(messages.upload)}</span>
<Text size='sm' theme='primary' weight='semibold' transform='uppercase'>
<FormattedMessage id='compose_event.upload_banner' defaultMessage='Upload photo' />
</Text>
<input
ref={fileElement}
type='file'
@ -51,8 +45,7 @@ const UploadButton: React.FC<IUploadButton> = ({ disabled, onSelectFile }) => {
disabled={disabled}
className='hidden'
/>
</label>
</div>
</HStack>
);
};

View file

@ -1,19 +1,29 @@
import React from 'react';
import React, { useMemo, useState } from 'react';
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
import {
changeGroupEditorTitle,
changeGroupEditorDescription,
submitGroupEditor,
} from 'soapbox/actions/groups';
import { Form, FormGroup, Input, Modal, Stack, Textarea } from 'soapbox/components/ui';
import { submitGroupEditor } from 'soapbox/actions/groups';
import { Modal, Stack } from 'soapbox/components/ui';
import { useAppDispatch, useAppSelector } from 'soapbox/hooks';
import DetailsStep from './steps/details-step';
import PrivacyStep from './steps/privacy-step';
const messages = defineMessages({
groupNamePlaceholder: { id: 'manage_group.fields.name_placeholder', defaultMessage: 'Name' },
groupDescriptionPlaceholder: { id: 'manage_group.fields.description_placeholder', defaultMessage: 'Description' },
next: { id: 'manage_group.next', defaultMessage: 'Next' },
create: { id: 'manage_group.create', defaultMessage: 'Create' },
update: { id: 'manage_group.update', defaultMessage: 'Update' },
});
enum Steps {
ONE = 'ONE',
TWO = 'TWO',
}
const manageGroupSteps = {
ONE: PrivacyStep,
TWO: DetailsStep,
};
interface IManageGroupModal {
onClose: (type?: string) => void,
}
@ -22,20 +32,11 @@ const ManageGroupModal: React.FC<IManageGroupModal> = ({ onClose }) => {
const intl = useIntl();
const dispatch = useAppDispatch();
const name = useAppSelector((state) => state.group_editor.displayName);
const description = useAppSelector((state) => state.group_editor.note);
const id = useAppSelector((state) => state.group_editor.groupId);
const isSubmitting = useAppSelector((state) => state.group_editor.isSubmitting);
const onChangeName: React.ChangeEventHandler<HTMLInputElement> = ({ target }) => {
dispatch(changeGroupEditorTitle(target.value));
};
const onChangeDescription: React.ChangeEventHandler<HTMLTextAreaElement> = ({ target }) => {
dispatch(changeGroupEditorDescription(target.value));
};
const [currentStep, setCurrentStep] = useState<Steps>(id ? Steps.TWO : Steps.ONE);
const onClickClose = () => {
onClose('manage_group');
@ -45,45 +46,44 @@ const ManageGroupModal: React.FC<IManageGroupModal> = ({ onClose }) => {
dispatch(submitGroupEditor(true));
};
const body = (
<Form>
<FormGroup
labelText={<FormattedMessage id='manage_group.fields.name_label' defaultMessage='Group name' />}
>
<Input
type='text'
placeholder={intl.formatMessage(messages.groupNamePlaceholder)}
value={name}
onChange={onChangeName}
/>
</FormGroup>
<FormGroup
labelText={<FormattedMessage id='manage_group.fields.description_label' defaultMessage='Group description' />}
>
<Textarea
autoComplete='off'
placeholder={intl.formatMessage(messages.groupDescriptionPlaceholder)}
value={description}
onChange={onChangeDescription}
/>
</FormGroup>
</Form>
);
const confirmationText = useMemo(() => {
switch (currentStep) {
case Steps.TWO:
return intl.formatMessage(id ? messages.update : messages.create);
default:
return intl.formatMessage(messages.next);
}
}, [currentStep]);
const handleNextStep = () => {
switch (currentStep) {
case Steps.ONE:
setCurrentStep(Steps.TWO);
break;
case Steps.TWO:
handleSubmit();
onClose();
break;
default:
break;
}
};
const StepToRender = manageGroupSteps[currentStep];
return (
<Modal
title={id
? <FormattedMessage id='navigation_bar.manage_group' defaultMessage='Manage group' />
: <FormattedMessage id='navigation_bar.create_group' defaultMessage='Create new group' />}
confirmationAction={handleSubmit}
confirmationText={id
? <FormattedMessage id='manage_group.update' defaultMessage='Update' />
: <FormattedMessage id='manage_group.create' defaultMessage='Create' />}
? <FormattedMessage id='navigation_bar.manage_group' defaultMessage='Manage Group' />
: <FormattedMessage id='navigation_bar.create_group' defaultMessage='Create Group' />}
confirmationAction={handleNextStep}
confirmationText={confirmationText}
confirmationDisabled={isSubmitting}
confirmationFullWidth
onClose={onClickClose}
>
<Stack space={2}>
{body}
<StepToRender />
</Stack>
</Modal>
);

View file

@ -0,0 +1,133 @@
import React, { useState } from 'react';
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
import {
changeGroupEditorTitle,
changeGroupEditorDescription,
changeGroupEditorMedia,
} from 'soapbox/actions/groups';
import Icon from 'soapbox/components/icon';
import { Avatar, Form, FormGroup, HStack, IconButton, Input, Text, Textarea } from 'soapbox/components/ui';
import { useAppDispatch, useAppSelector } from 'soapbox/hooks';
import resizeImage from 'soapbox/utils/resize-image';
import type { List as ImmutableList } from 'immutable';
const messages = defineMessages({
groupNamePlaceholder: { id: 'manage_group.fields.name_placeholder', defaultMessage: 'Group Name' },
groupDescriptionPlaceholder: { id: 'manage_group.fields.description_placeholder', defaultMessage: 'Description' },
});
const DetailsStep = () => {
const intl = useIntl();
const dispatch = useAppDispatch();
const isUploading = useAppSelector((state) => state.group_editor.isUploading);
const name = useAppSelector((state) => state.group_editor.displayName);
const description = useAppSelector((state) => state.group_editor.note);
const [avatarSrc, setAvatarSrc] = useState<string | null>(null);
const [headerSrc, setHeaderSrc] = useState<string | null>(null);
const attachmentTypes = useAppSelector(state => state.instance.configuration.getIn(['media_attachments', 'supported_mime_types']) as ImmutableList<string>)?.filter(type => type.startsWith('image/'));
const onChangeName: React.ChangeEventHandler<HTMLInputElement> = ({ target }) => {
dispatch(changeGroupEditorTitle(target.value));
};
const onChangeDescription: React.ChangeEventHandler<HTMLTextAreaElement> = ({ target }) => {
dispatch(changeGroupEditorDescription(target.value));
};
const handleFileChange: React.ChangeEventHandler<HTMLInputElement> = e => {
const rawFile = e.target.files?.item(0);
if (!rawFile) return;
if (e.target.name === 'avatar') {
resizeImage(rawFile, 400 * 400).then(file => {
dispatch(changeGroupEditorMedia('avatar', file));
setAvatarSrc(URL.createObjectURL(file));
}).catch(console.error);
} else {
resizeImage(rawFile, 1920 * 1080).then(file => {
dispatch(changeGroupEditorMedia('header', file));
setHeaderSrc(URL.createObjectURL(file));
}).catch(console.error);
}
};
return (
<Form>
<div className='flex items-center justify-center mb-12 bg-primary-100 dark:bg-gray-800 rounded-lg text-black dark:text-white sm:shadow dark:sm:shadow-inset h-24 sm:h-36 relative'>
{headerSrc ? (
<>
<img className='h-full w-full object-cover' src={headerSrc} alt='' />
<IconButton className='absolute top-2 right-2' src={require('@tabler/icons/x.svg')} onClick={() => {}} />
</>
) : (
<HStack className='h-full w-full text-primary-500 dark:text-accent-blue cursor-pointer' space={3} alignItems='center' justifyContent='center' element='label'>
<Icon
src={require('@tabler/icons/photo-plus.svg')}
className='h-7 w-7'
/>
<Text size='sm' theme='primary' weight='semibold' transform='uppercase'>
<FormattedMessage id='compose_event.upload_banner' defaultMessage='Upload photo' />
</Text>
<input
name='header'
type='file'
accept={attachmentTypes && attachmentTypes.toArray().join(',')}
onChange={handleFileChange}
disabled={isUploading}
className='hidden'
/>
</HStack>
)}
<div className='absolute left-1/2 bottom-0 -translate-x-1/2 translate-y-1/2'>
{avatarSrc ? (
<Avatar className='ring-2 ring-white dark:ring-primary-900' src={avatarSrc} size={72} />
) : (
<label className='flex items-center justify-center h-[72px] w-[72px] bg-primary-500 rounded-full ring-2 ring-white dark:ring-primary-900'>
<Icon
src={require('@tabler/icons/camera-plus.svg')}
className='h-7 w-7 text-white'
/>
<span className='sr-only'>Upload avatar</span>
<input
name='avatar'
type='file'
accept={attachmentTypes && attachmentTypes.toArray().join(',')}
onChange={handleFileChange}
disabled={isUploading}
className='hidden'
/>
</label>
)}
</div>
</div>
<FormGroup
labelText={<FormattedMessage id='manage_group.fields.name_label' defaultMessage='Group name (required)' />}
>
<Input
type='text'
placeholder={intl.formatMessage(messages.groupNamePlaceholder)}
value={name}
onChange={onChangeName}
/>
</FormGroup>
<FormGroup
labelText={<FormattedMessage id='manage_group.fields.description_label' defaultMessage='Description' />}
>
<Textarea
autoComplete='off'
placeholder={intl.formatMessage(messages.groupDescriptionPlaceholder)}
value={description}
onChange={onChangeDescription}
/>
</FormGroup>
</Form>
);
};
export default DetailsStep;

View file

@ -0,0 +1,54 @@
import React from 'react';
import { FormattedMessage } from 'react-intl';
import { changeGroupEditorPrivacy } from 'soapbox/actions/groups';
import List, { ListItem } from 'soapbox/components/list';
import { Form, FormGroup, Stack, Text } from 'soapbox/components/ui';
import { useAppDispatch, useAppSelector } from 'soapbox/hooks';
const PrivacyStep = () => {
const dispatch = useAppDispatch();
const locked = useAppSelector((state) => state.group_editor.locked);
const onChangePrivacy = (value: boolean) => {
dispatch(changeGroupEditorPrivacy(value));
};
return (
<>
<Stack className='max-w-sm mx-auto' space={2}>
<Text size='3xl' weight='bold' align='center'>
<FormattedMessage id='manage_group.get_started' defaultMessage="Let's get started!" />
</Text>
<Text size='lg' theme='muted' align='center'>
<FormattedMessage id='manage_group.tagline' defaultMessage='Groups connect you with others based on shared interests.' />
</Text>
</Stack>
<Form>
<FormGroup
labelText='Privacy settings'
>
<List>
<ListItem
label='Public'
hint='Discoverable. Anyone can join.'
onSelect={() => onChangePrivacy(false)}
isSelected={!locked}
/>
<ListItem
label='Private (Owner approval required)'
hint='Discoverable. Users can join after their request is approved.'
onSelect={() => onChangePrivacy(true)}
isSelected={locked}
/>
</List>
</FormGroup>
<Text size='sm' theme='muted' align='center'>These settings cannot be changed later.</Text>
</Form>
</>
);
};
export default PrivacyStep;

View file

@ -0,0 +1,39 @@
import React from 'react';
import { FormattedMessage } from 'react-intl';
import { openModal } from 'soapbox/actions/modals';
import { Button, Stack, Text } from 'soapbox/components/ui';
import { useAppDispatch } from 'soapbox/hooks';
const NewGroupPanel = () => {
const dispatch = useAppDispatch();
const createGroup = () => {
dispatch(openModal('MANAGE_GROUP'));
};
return (
<Stack space={2}>
<Stack>
<Text size='lg' weight='bold'>
<FormattedMessage id='new_group_panel.title' defaultMessage='Create New Group' />
</Text>
<Text theme='muted' size='sm'>
<FormattedMessage id='new_group_panel.subtitle' defaultMessage="Can't find what you're looking for? Start your own private or public group." />
</Text>
</Stack>
<Button
icon={require('@tabler/icons/circles.svg')}
onClick={createGroup}
theme='secondary'
block
>
<FormattedMessage id='new_group_panel.action' defaultMessage='Create group' />
</Button>
</Stack>
);
};
export default NewGroupPanel;

View file

@ -31,6 +31,7 @@ import ChatsPage from 'soapbox/pages/chats-page';
import DefaultPage from 'soapbox/pages/default-page';
import EventPage from 'soapbox/pages/event-page';
import GroupPage from 'soapbox/pages/group-page';
import GroupsPage from 'soapbox/pages/groups-page';
import HomePage from 'soapbox/pages/home-page';
import ProfilePage from 'soapbox/pages/profile-page';
import RemoteInstancePage from 'soapbox/pages/remote-instance-page';
@ -275,7 +276,7 @@ const SwitchingColumnsArea: React.FC = ({ children }) => {
<WrappedRoute path='/@:username/events/:statusId/discussion' publicRoute exact page={EventPage} component={EventDiscussion} content={children} />
<Redirect from='/@:username/:statusId' to='/@:username/posts/:statusId' />
<WrappedRoute path='/groups' exact page={DefaultPage} component={Groups} content={children} />
<WrappedRoute path='/groups' exact page={GroupsPage} component={Groups} content={children} />
<WrappedRoute path='/groups/:id' exact page={GroupPage} component={GroupTimeline} content={children} />
<WrappedRoute path='/statuses/new' page={DefaultPage} component={NewStatus} content={children} exact />

View file

@ -550,10 +550,10 @@ export function GroupTimeline() {
return import(/* webpackChunkName: "features/groups" */'../../group/group-timeline');
}
export function GroupInfoPanel() {
return import(/* webpackChunkName: "features/groups" */'../../group/components/group-info-panel');
}
export function ManageGroupModal() {
return import(/* webpackChunkName: "features/manage_group_modal" */'../components/modals/manage-group-modal/manage-group-modal');
}
export function NewGroupPanel() {
return import(/* webpackChunkName: "features/groups" */'../components/panels/new-group-panel');
}

View file

@ -1,4 +1,5 @@
import React, { useCallback, useEffect } from 'react';
import { useRouteMatch } from 'react-router-dom';
import { fetchGroup } from 'soapbox/actions/groups';
import MissingIndicator from 'soapbox/components/missing-indicator';
@ -7,13 +8,14 @@ import GroupHeader from 'soapbox/features/group/components/group-header';
import LinkFooter from 'soapbox/features/ui/components/link-footer';
import BundleContainer from 'soapbox/features/ui/containers/bundle-container';
import {
GroupInfoPanel,
SignUpPanel,
CtaBanner,
} from 'soapbox/features/ui/util/async-components';
import { useAppDispatch, useAppSelector } from 'soapbox/hooks';
import { makeGetGroup } from 'soapbox/selectors';
import { Tabs } from '../components/ui';
interface IGroupPage {
params?: {
id?: string,
@ -21,21 +23,21 @@ interface IGroupPage {
}
/** Page to display a group. */
const ProfilePage: React.FC<IGroupPage> = ({ params, children }) => {
const id = params?.id || '';
const GroupPage: React.FC<IGroupPage> = ({ params, children }) => {
const match = useRouteMatch();
const dispatch = useAppDispatch();
const id = params?.id || '';
const getGroup = useCallback(makeGetGroup(), []);
const group = useAppSelector(state => getGroup(state, id));
const me = useAppSelector(state => state.me);
useEffect(() => {
dispatch(fetchGroup(id));
}, [id]);
if (group === false) {
if ((group as any) === false) {
return (
<MissingIndicator />
);
@ -45,17 +47,25 @@ const ProfilePage: React.FC<IGroupPage> = ({ params, children }) => {
<>
<Layout.Main>
<Column label={group ? group.display_name : ''} withHeader={false}>
<div className='space-y-4'>
<GroupHeader group={group} />
{group && (
<BundleContainer fetchComponent={GroupInfoPanel}>
{Component => <Component group={group} />}
</BundleContainer>
)}
<Tabs
items={[
{
text: 'All',
to: `/groups/${group?.id}`,
name: '/groups/:id',
},
{
text: 'Members',
to: `/groups/${group?.id}/members`,
name: '/groups/:id/members',
},
]}
activeItem={match.path}
/>
{children}
</div>
</Column>
{!me && (
@ -77,4 +87,4 @@ const ProfilePage: React.FC<IGroupPage> = ({ params, children }) => {
);
};
export default ProfilePage;
export default GroupPage;

View file

@ -0,0 +1,62 @@
import React from 'react';
// import { useRouteMatch } from 'react-router-dom';
import { Column, Layout } from 'soapbox/components/ui';
import LinkFooter from 'soapbox/features/ui/components/link-footer';
import BundleContainer from 'soapbox/features/ui/containers/bundle-container';
import {
NewGroupPanel,
CtaBanner,
} from 'soapbox/features/ui/util/async-components';
import { useAppSelector } from 'soapbox/hooks';
// import { Tabs } from '../components/ui';
/** Page to display groups. */
const GroupsPage: React.FC = ({ children }) => {
const me = useAppSelector(state => state.me);
// const match = useRouteMatch();
return (
<>
<Layout.Main>
<Column withHeader={false}>
<div className='space-y-4'>
{/* <Tabs
items={[
{
text: 'My Groups',
to: '/groups',
name: '/groups',
},
{
text: 'Find Groups',
to: '/groups/explore',
name: '/groups/explore',
},
]}
activeItem={match.path}
/> */}
{children}
</div>
</Column>
{!me && (
<BundleContainer fetchComponent={CtaBanner}>
{Component => <Component key='cta-banner' />}
</BundleContainer>
)}
</Layout.Main>
<Layout.Aside>
<BundleContainer fetchComponent={NewGroupPanel}>
{Component => <Component key='new-group-panel' />}
</BundleContainer>
<LinkFooter key='link-footer' />
</Layout.Aside>
</>
);
};
export default GroupsPage;

View file

@ -4,22 +4,28 @@ import {
GROUP_EDITOR_RESET,
GROUP_EDITOR_TITLE_CHANGE,
GROUP_EDITOR_DESCRIPTION_CHANGE,
GROUP_EDITOR_PRIVACY_CHANGE,
GROUP_EDITOR_MEDIA_CHANGE,
GROUP_CREATE_REQUEST,
GROUP_CREATE_FAIL,
GROUP_CREATE_SUCCESS,
GROUP_UPDATE_REQUEST,
GROUP_UPDATE_FAIL,
GROUP_UPDATE_SUCCESS,
} from 'soapbox/actions/groups';
import type { AnyAction } from 'redux';
const ReducerRecord = ImmutableRecord({
groupId: null as string | null,
progress: 0,
isUploading: false,
isSubmitting: false,
isChanged: false,
displayName: '',
note: '',
avatar: null,
header: null,
avatar: null as File | null,
header: null as File | null,
locked: false,
});
@ -39,14 +45,24 @@ export default function groupEditor(state: State = ReducerRecord(), action: AnyA
map.set('note', action.value);
map.set('isChanged', true);
});
case GROUP_EDITOR_PRIVACY_CHANGE:
return state.withMutations(map => {
map.set('locked', action.value);
map.set('isChanged', true);
});
case GROUP_EDITOR_MEDIA_CHANGE:
return state.set(action.mediaType, action.value);
case GROUP_CREATE_REQUEST:
case GROUP_UPDATE_REQUEST:
return state.withMutations(map => {
map.set('isSubmitting', true);
map.set('isChanged', false);
});
case GROUP_CREATE_FAIL:
case GROUP_UPDATE_FAIL:
return state.set('isSubmitting', false);
case GROUP_CREATE_SUCCESS:
case GROUP_UPDATE_SUCCESS:
return state.withMutations(map => {
map.set('isSubmitting', false);
map.set('groupId', action.group.id);

View file

@ -2,6 +2,7 @@ import { Map as ImmutableMap } from 'immutable';
import {
GROUP_CREATE_SUCCESS,
GROUP_UPDATE_SUCCESS,
GROUP_DELETE_SUCCESS,
GROUP_RELATIONSHIPS_FETCH_SUCCESS,
GROUP_JOIN_REQUEST,
@ -32,6 +33,7 @@ const normalizeRelationships = (state: State, relationships: APIEntities) => {
export default function groupRelationships(state: State = ImmutableMap(), action: AnyAction) {
switch (action.type) {
case GROUP_CREATE_SUCCESS:
case GROUP_UPDATE_SUCCESS:
return state.set(action.group.id, normalizeGroupRelationship({ id: action.group.id, member: true, requested: false, role: 'admin' }));
case GROUP_DELETE_SUCCESS:
return state.delete(action.id);

View file

@ -404,6 +404,8 @@ const getInstanceFeatures = (instance: Instance) => {
*/
frontendConfigurations: v.software === PLEROMA,
groups: v.software === MASTODON,
/**
* Can hide follows/followers lists and counts.
* @see PATCH /api/v1/accounts/update_credentials

View file

@ -67,7 +67,7 @@
"@sentry/browser": "^7.11.1",
"@sentry/react": "^7.11.1",
"@sentry/tracing": "^7.11.1",
"@tabler/icons": "^1.113.0",
"@tabler/icons": "^1.117.0",
"@tailwindcss/forms": "^0.5.3",
"@tailwindcss/line-clamp": "^0.4.2",
"@tailwindcss/typography": "^0.5.7",

View file

@ -2272,10 +2272,10 @@
remark "^13.0.0"
unist-util-find-all-after "^3.0.2"
"@tabler/icons@^1.113.0":
version "1.113.0"
resolved "https://registry.yarnpkg.com/@tabler/icons/-/icons-1.113.0.tgz#aeee5f38284d9996abec1bda46c237ef53cde8d4"
integrity sha512-DjxsvR/0HFHD/utQlM+q3wpl1W2n+jgEZkyfkCkc295rCoAfeXHIBfz/9ROrSHkr205Kq/M8KpQR0Nd4kjwODQ==
"@tabler/icons@^1.117.0":
version "1.117.0"
resolved "https://registry.yarnpkg.com/@tabler/icons/-/icons-1.117.0.tgz#2ffafca94f868940cf84a839e284c243e095c45a"
integrity sha512-4UGF8fMcROiy++CCNlzTz6p22rxFQD/fAMfaw/8Uanopl41X2SCZTmpnotS3C6Qdrk99m8eMZySa5w1y99gFqQ==
"@tailwindcss/forms@^0.5.3":
version "0.5.3"