Groups: UI improvements
Signed-off-by: marcin mikołajczak <git@mkljczk.pl>
This commit is contained in:
parent
683504c997
commit
d524a7c700
25 changed files with 681 additions and 279 deletions
|
@ -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';
|
||||
|
@ -95,15 +101,22 @@ const GROUP_MEMBERSHIP_REQUEST_REJECT_REQUEST = 'GROUP_MEMBERSHIP_REQUEST_REJECT
|
|||
const GROUP_MEMBERSHIP_REQUEST_REJECT_SUCCESS = 'GROUP_MEMBERSHIP_REQUEST_REJECT_SUCCESS';
|
||||
const GROUP_MEMBERSHIP_REQUEST_REJECT_FAIL = 'GROUP_MEMBERSHIP_REQUEST_REJECT_FAIL';
|
||||
|
||||
const GROUP_EDITOR_TITLE_CHANGE = 'GROUP_EDITOR_TITLE_CHANGE';
|
||||
const GROUP_EDITOR_TITLE_CHANGE = 'GROUP_EDITOR_TITLE_CHANGE';
|
||||
const GROUP_EDITOR_DESCRIPTION_CHANGE = 'GROUP_EDITOR_DESCRIPTION_CHANGE';
|
||||
const GROUP_EDITOR_RESET = 'GROUP_EDITOR_RESET';
|
||||
const GROUP_EDITOR_PRIVACY_CHANGE = 'GROUP_EDITOR_PRIVACY_CHANGE';
|
||||
const GROUP_EDITOR_MEDIA_CHANGE = 'GROUP_EDITOR_MEDIA_CHANGE';
|
||||
|
||||
const createGroup = (displayName: string, note: string, shouldReset?: boolean) =>
|
||||
const GROUP_EDITOR_RESET = 'GROUP_EDITOR_RESET';
|
||||
|
||||
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,
|
||||
};
|
||||
|
|
|
@ -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'>
|
||||
|
|
58
app/soapbox/components/group-card.tsx
Normal file
58
app/soapbox/components/group-card.tsx
Normal 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;
|
|
@ -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')}
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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 className='absolute left-1/2 bottom-0 -translate-x-1/2 translate-y-1/2'>
|
||||
<a href={group.avatar} onClick={handleAvatarClick} target='_blank'>
|
||||
<Avatar className='ring-[3px] ring-white dark:ring-primary-900' src={group.avatar} size={72} />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='px-4 sm:px-6'>
|
||||
<HStack className='-mt-12' alignItems='bottom' space={5}>
|
||||
<div className='flex'>
|
||||
<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'
|
||||
/>
|
||||
</a>
|
||||
</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>
|
||||
</HStack>
|
||||
</Comp>
|
||||
);
|
||||
}
|
||||
})}
|
||||
</MenuList>
|
||||
</Menu>
|
||||
)}
|
||||
|
||||
{actionButton}
|
||||
<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>
|
||||
</div>
|
||||
) : 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>
|
||||
</div>
|
||||
<Text theme='muted' dangerouslySetInnerHTML={{ __html: group.note_emojified }} />
|
||||
{actionButton}
|
||||
</Stack>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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;
|
|
@ -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} />
|
||||
</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.' />}
|
||||
divideType='space'
|
||||
/>
|
||||
</div>
|
||||
{!!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>
|
||||
)}
|
||||
<Timeline
|
||||
scrollKey='group_timeline'
|
||||
timelineId={`group:${groupId}`}
|
||||
onLoadMore={handleLoadMore}
|
||||
emptyMessage={<FormattedMessage id='empty_column.group' defaultMessage='There are no posts in this group yet.' />}
|
||||
divideType='space'
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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'
|
||||
>
|
||||
{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 }} />
|
||||
</Link>
|
||||
))}
|
||||
</ScrollableList>
|
||||
</div>
|
||||
</Column>
|
||||
<ScrollableList
|
||||
scrollKey='lists'
|
||||
emptyMessage={emptyMessage}
|
||||
itemClassName='py-3 last:pb-0'
|
||||
>
|
||||
{groups.map((group) => (
|
||||
<Link to={`/groups/${group.id}`}>
|
||||
<GroupCard group={group as GroupEntity} />
|
||||
</Link>
|
||||
))}
|
||||
</ScrollableList>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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,27 +27,25 @@ 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>
|
||||
<input
|
||||
ref={fileElement}
|
||||
type='file'
|
||||
accept={attachmentTypes && attachmentTypes.toArray().join(',')}
|
||||
onChange={handleChange}
|
||||
disabled={disabled}
|
||||
className='hidden'
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<Text size='sm' theme='primary' weight='semibold' transform='uppercase'>
|
||||
<FormattedMessage id='compose_event.upload_banner' defaultMessage='Upload photo' />
|
||||
</Text>
|
||||
<input
|
||||
ref={fileElement}
|
||||
type='file'
|
||||
accept={attachmentTypes && attachmentTypes.toArray().join(',')}
|
||||
onChange={handleChange}
|
||||
disabled={disabled}
|
||||
className='hidden'
|
||||
/>
|
||||
</HStack>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -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;
|
|
@ -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;
|
|
@ -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;
|
|
@ -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 />
|
||||
|
|
|
@ -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');
|
||||
}
|
||||
|
|
|
@ -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} />
|
||||
<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>
|
||||
{children}
|
||||
</Column>
|
||||
|
||||
{!me && (
|
||||
|
@ -77,4 +87,4 @@ const ProfilePage: React.FC<IGroupPage> = ({ params, children }) => {
|
|||
);
|
||||
};
|
||||
|
||||
export default ProfilePage;
|
||||
export default GroupPage;
|
||||
|
|
62
app/soapbox/pages/groups-page.tsx
Normal file
62
app/soapbox/pages/groups-page.tsx
Normal 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;
|
|
@ -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);
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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"
|
||||
|
|
Loading…
Reference in a new issue