Events page
Signed-off-by: marcin mikołajczak <git@mkljczk.pl>
This commit is contained in:
parent
859d93b289
commit
c61dcddd81
11 changed files with 261 additions and 22 deletions
|
@ -4,7 +4,7 @@ import api, { getLinks } from 'soapbox/api';
|
|||
import { formatBytes } from 'soapbox/utils/media';
|
||||
import resizeImage from 'soapbox/utils/resize-image';
|
||||
|
||||
import { importFetchedAccounts, importFetchedStatus } from './importer';
|
||||
import { importFetchedAccounts, importFetchedStatus, importFetchedStatuses } from './importer';
|
||||
import { fetchMedia, uploadMedia } from './media';
|
||||
import { closeModal, openModal } from './modals';
|
||||
import snackbar from './snackbar';
|
||||
|
@ -76,6 +76,13 @@ const EVENT_COMPOSE_CANCEL = 'EVENT_COMPOSE_CANCEL';
|
|||
|
||||
const EVENT_FORM_SET = 'EVENT_FORM_SET';
|
||||
|
||||
const RECENT_EVENTS_FETCH_REQUEST = 'RECENT_EVENTS_FETCH_REQUEST';
|
||||
const RECENT_EVENTS_FETCH_SUCCESS = 'RECENT_EVENTS_FETCH_SUCCESS';
|
||||
const RECENT_EVENTS_FETCH_FAIL = 'RECENT_EVENTS_FETCH_FAIL';
|
||||
const JOINED_EVENTS_FETCH_REQUEST = 'JOINED_EVENTS_FETCH_REQUEST';
|
||||
const JOINED_EVENTS_FETCH_SUCCESS = 'JOINED_EVENTS_FETCH_SUCCESS';
|
||||
const JOINED_EVENTS_FETCH_FAIL = 'JOINED_EVENTS_FETCH_FAIL';
|
||||
|
||||
const noOp = () => new Promise(f => f(undefined));
|
||||
|
||||
const messages = defineMessages({
|
||||
|
@ -579,6 +586,48 @@ const editEvent = (id: string) => (dispatch: AppDispatch, getState: () => RootSt
|
|||
});
|
||||
};
|
||||
|
||||
const fetchRecentEvents = () =>
|
||||
(dispatch: AppDispatch, getState: () => RootState) => {
|
||||
if (getState().status_lists.get('recent_events')?.isLoading) {
|
||||
return;
|
||||
}
|
||||
|
||||
dispatch({ type: RECENT_EVENTS_FETCH_REQUEST });
|
||||
|
||||
api(getState).get('/api/v1/timelines/public?only_events=true').then(response => {
|
||||
const next = getLinks(response).refs.find(link => link.rel === 'next');
|
||||
dispatch(importFetchedStatuses(response.data));
|
||||
dispatch({
|
||||
type: RECENT_EVENTS_FETCH_SUCCESS,
|
||||
statuses: response.data,
|
||||
next: next ? next.uri : null,
|
||||
});
|
||||
}).catch(error => {
|
||||
dispatch({ type: RECENT_EVENTS_FETCH_FAIL, error });
|
||||
});
|
||||
};
|
||||
|
||||
const fetchJoinedEvents = () =>
|
||||
(dispatch: AppDispatch, getState: () => RootState) => {
|
||||
if (getState().status_lists.get('joined_events')?.isLoading) {
|
||||
return;
|
||||
}
|
||||
|
||||
dispatch({ type: JOINED_EVENTS_FETCH_REQUEST });
|
||||
|
||||
api(getState).get('/api/v1/pleroma/events/joined_events').then(response => {
|
||||
const next = getLinks(response).refs.find(link => link.rel === 'next');
|
||||
dispatch(importFetchedStatuses(response.data));
|
||||
dispatch({
|
||||
type: JOINED_EVENTS_FETCH_SUCCESS,
|
||||
statuses: response.data,
|
||||
next: next ? next.uri : null,
|
||||
});
|
||||
}).catch(error => {
|
||||
dispatch({ type: JOINED_EVENTS_FETCH_FAIL, error });
|
||||
});
|
||||
};
|
||||
|
||||
export {
|
||||
LOCATION_SEARCH_REQUEST,
|
||||
LOCATION_SEARCH_SUCCESS,
|
||||
|
@ -624,6 +673,12 @@ export {
|
|||
EVENT_PARTICIPATION_REQUEST_REJECT_FAIL,
|
||||
EVENT_COMPOSE_CANCEL,
|
||||
EVENT_FORM_SET,
|
||||
RECENT_EVENTS_FETCH_REQUEST,
|
||||
RECENT_EVENTS_FETCH_SUCCESS,
|
||||
RECENT_EVENTS_FETCH_FAIL,
|
||||
JOINED_EVENTS_FETCH_REQUEST,
|
||||
JOINED_EVENTS_FETCH_SUCCESS,
|
||||
JOINED_EVENTS_FETCH_FAIL,
|
||||
locationSearch,
|
||||
changeEditEventName,
|
||||
changeEditEventDescription,
|
||||
|
@ -677,4 +732,6 @@ export {
|
|||
fetchEventIcs,
|
||||
cancelEventCompose,
|
||||
editEvent,
|
||||
fetchRecentEvents,
|
||||
fetchJoinedEvents,
|
||||
};
|
||||
|
|
|
@ -35,7 +35,7 @@ const EventPreview: React.FC<IEventPreview> = ({ status, className, hideAction }
|
|||
const banner = status.media_attachments?.find(({ description }) => description === 'Banner');
|
||||
|
||||
return (
|
||||
<div className={classNames('rounded-lg bg-gray-100 dark:bg-primary-800 relative overflow-hidden', className)}>
|
||||
<div className={classNames('w-full rounded-lg bg-gray-100 dark:bg-primary-800 relative overflow-hidden', className)}>
|
||||
<div className='absolute top-28 right-3'>
|
||||
{!hideAction && (account.id === me ? (
|
||||
<Button
|
||||
|
|
|
@ -87,9 +87,14 @@ const CardTitle: React.FC<ICardTitle> = ({ title }): JSX.Element => (
|
|||
<Text size='xl' weight='bold' tag='h1' data-testid='card-title' truncate>{title}</Text>
|
||||
);
|
||||
|
||||
interface ICardBody {
|
||||
/** Classnames for the <div> element. */
|
||||
className?: string
|
||||
}
|
||||
|
||||
/** A card's body. */
|
||||
const CardBody: React.FC = ({ children }): JSX.Element => (
|
||||
<div data-testid='card-body'>{children}</div>
|
||||
const CardBody: React.FC<ICardBody> = ({ className, children }): JSX.Element => (
|
||||
<div data-testid='card-body' className={className}>{children}</div>
|
||||
);
|
||||
|
||||
export { Card, CardHeader, CardTitle, CardBody };
|
||||
|
|
|
@ -68,7 +68,6 @@ const messages = defineMessages({
|
|||
userUnendorsed: { id: 'account.unendorse.success', defaultMessage: 'You are no longer featuring @{acct}' },
|
||||
profileExternal: { id: 'account.profile_external', defaultMessage: 'View profile on {domain}' },
|
||||
header: { id: 'account.header.alt', defaultMessage: 'Profile header' },
|
||||
composeEvent: { od: 'navigation.compose_event', defaultMessage: 'Create new event' },
|
||||
});
|
||||
|
||||
interface IHeader {
|
||||
|
@ -214,10 +213,6 @@ const Header: React.FC<IHeader> = ({ account }) => {
|
|||
history.push('/search');
|
||||
};
|
||||
|
||||
const onComposeEvent = () => {
|
||||
dispatch(openModal('COMPOSE_EVENT'));
|
||||
};
|
||||
|
||||
const onAvatarClick = () => {
|
||||
const avatar = normalizeAttachment({
|
||||
type: 'image',
|
||||
|
@ -302,13 +297,6 @@ const Header: React.FC<IHeader> = ({ account }) => {
|
|||
to: '/blocks',
|
||||
icon: require('@tabler/icons/ban.svg'),
|
||||
});
|
||||
if (features.events) {
|
||||
menu.push({
|
||||
text: intl.formatMessage(messages.composeEvent),
|
||||
action: onComposeEvent,
|
||||
icon: require('@tabler/icons/calendar.svg'),
|
||||
});
|
||||
}
|
||||
} else {
|
||||
menu.push({
|
||||
text: intl.formatMessage(messages.mention, { name: account.username }),
|
||||
|
|
|
@ -342,7 +342,7 @@ const EventHeader: React.FC<IEventHeader> = ({ status }) => {
|
|||
<StillImage
|
||||
src={banner.url}
|
||||
alt={intl.formatMessage(messages.bannerHeader)}
|
||||
className='absolute inset-0 object-cover md:rounded-t-xl'
|
||||
className='absolute inset-0 object-cover md:rounded-t-xl h-full'
|
||||
/>
|
||||
</a>
|
||||
)}
|
||||
|
|
|
@ -175,7 +175,7 @@ const EventDiscussion: React.FC<IEventDiscussion> = (props) => {
|
|||
|
||||
return (
|
||||
<Stack space={2}>
|
||||
{me && <div className='sm:p-2 pt-0 border-b border-solid border-gray-200 dark:border-gray-800'>
|
||||
{me && <div className='p-2 pt-0 border-b border-solid border-gray-200 dark:border-gray-800'>
|
||||
<ComposeForm id={`reply:${status.id}`} autoFocus={false} event={status.id} />
|
||||
</div>}
|
||||
<div ref={node} className='thread p-0 sm:p-2 shadow-none'>
|
||||
|
|
85
app/soapbox/features/events/components/event-carousel.tsx
Normal file
85
app/soapbox/features/events/components/event-carousel.tsx
Normal file
|
@ -0,0 +1,85 @@
|
|||
import React, { useCallback, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import ReactSwipeableViews from 'react-swipeable-views';
|
||||
|
||||
import EventPreview from 'soapbox/components/event-preview';
|
||||
import { Card, Icon } from 'soapbox/components/ui';
|
||||
import { useAppSelector } from 'soapbox/hooks';
|
||||
import { makeGetStatus } from 'soapbox/selectors';
|
||||
|
||||
import PlaceholderEventPreview from '../../placeholder/components/placeholder-event-preview';
|
||||
|
||||
import type { OrderedSet as ImmutableOrderedSet } from 'immutable';
|
||||
|
||||
const Event = ({ id }: { id: string }) => {
|
||||
const getStatus = useCallback(makeGetStatus(), []);
|
||||
const status = useAppSelector(state => getStatus(state, { id }));
|
||||
|
||||
if (!status) return null;
|
||||
|
||||
return (
|
||||
<Link
|
||||
className='w-full px-1'
|
||||
to={`/@${status.getIn(['account', 'acct'])}/events/${status.id}`}
|
||||
>
|
||||
<EventPreview status={status} />
|
||||
</Link>
|
||||
);
|
||||
};
|
||||
|
||||
interface IEventCarousel {
|
||||
statusIds: ImmutableOrderedSet<string>
|
||||
isLoading?: boolean | null
|
||||
emptyMessage: React.ReactNode
|
||||
}
|
||||
|
||||
const EventCarousel: React.FC<IEventCarousel> = ({ statusIds, isLoading, emptyMessage }) => {
|
||||
const [index, setIndex] = useState(0);
|
||||
|
||||
const handleChangeIndex = (index: number) => {
|
||||
setIndex(index % statusIds.size);
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return <PlaceholderEventPreview />;
|
||||
}
|
||||
|
||||
if (statusIds.size === 0) {
|
||||
return (
|
||||
<Card variant='rounded' size='lg'>
|
||||
{emptyMessage}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className='relative -mx-1'>
|
||||
{index !== 0 && (
|
||||
<div className='z-10 absolute left-3 top-1/2 -mt-4'>
|
||||
<button
|
||||
data-testid='prev-page'
|
||||
onClick={() => handleChangeIndex(index - 1)}
|
||||
className='bg-white/50 dark:bg-gray-900/50 backdrop-blur rounded-full h-8 w-8 flex items-center justify-center'
|
||||
>
|
||||
<Icon src={require('@tabler/icons/chevron-left.svg')} className='text-black dark:text-white h-6 w-6' />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<ReactSwipeableViews animateHeight index={index} onChangeIndex={handleChangeIndex}>
|
||||
{statusIds.map(statusId => <Event key={statusId} id={statusId} />)}
|
||||
</ReactSwipeableViews>
|
||||
{index !== statusIds.size - 1 && (
|
||||
<div className='z-10 absolute right-3 top-1/2 -mt-4'>
|
||||
<button
|
||||
data-testid='next-page'
|
||||
onClick={() => handleChangeIndex(index + 1)}
|
||||
className='bg-white/50 dark:bg-gray-900/50 backdrop-blur rounded-full h-8 w-8 flex items-center justify-center'
|
||||
>
|
||||
<Icon src={require('@tabler/icons/chevron-right.svg')} className='text-black dark:text-white h-6 w-6' />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default EventCarousel;
|
|
@ -1,7 +1,12 @@
|
|||
import React from 'react';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
|
||||
|
||||
import { Column } from 'soapbox/components/ui';
|
||||
import { fetchJoinedEvents, fetchRecentEvents } from 'soapbox/actions/events';
|
||||
import { openModal } from 'soapbox/actions/modals';
|
||||
import { Button, CardBody, CardHeader, CardTitle, Column, HStack } from 'soapbox/components/ui';
|
||||
import { useAppDispatch, useAppSelector } from 'soapbox/hooks';
|
||||
|
||||
import EventCarousel from './components/event-carousel';
|
||||
|
||||
const messages = defineMessages({
|
||||
title: { id: 'column.events', defaultMessage: 'Events' },
|
||||
|
@ -10,8 +15,53 @@ const messages = defineMessages({
|
|||
const Events = () => {
|
||||
const intl = useIntl();
|
||||
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const recentEvents = useAppSelector((state) => state.status_lists.get('recent_events')!.items);
|
||||
const recentEventsLoading = useAppSelector((state) => state.status_lists.get('recent_events')!.isLoading);
|
||||
const joinedEvents = useAppSelector((state) => state.status_lists.get('joined_events')!.items);
|
||||
const joinedEventsLoading = useAppSelector((state) => state.status_lists.get('joined_events')!.isLoading);
|
||||
|
||||
const onComposeEvent = () => {
|
||||
dispatch(openModal('COMPOSE_EVENT'));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
dispatch(fetchRecentEvents());
|
||||
dispatch(fetchJoinedEvents());
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Column label={intl.formatMessage(messages.title)} />
|
||||
<Column label={intl.formatMessage(messages.title)}>
|
||||
<HStack className='mb-4' space={2} justifyContent='between'>
|
||||
<CardTitle title='Recent events' />
|
||||
<Button
|
||||
className='ml-auto'
|
||||
theme='primary'
|
||||
size='sm'
|
||||
onClick={onComposeEvent}
|
||||
>
|
||||
Create event
|
||||
</Button>
|
||||
</HStack>
|
||||
<CardBody className='mb-2'>
|
||||
<EventCarousel
|
||||
statusIds={recentEvents}
|
||||
isLoading={recentEventsLoading}
|
||||
emptyMessage={<FormattedMessage id='events.recent_events.empty' defaultMessage='There are no public events yet.' />}
|
||||
/>
|
||||
</CardBody>
|
||||
<CardHeader>
|
||||
<CardTitle title='Joined events' />
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
<EventCarousel
|
||||
statusIds={joinedEvents}
|
||||
isLoading={joinedEventsLoading}
|
||||
emptyMessage={<FormattedMessage id='events.joined_events.empty' defaultMessage="You haven't joined any event yet." />}
|
||||
/>
|
||||
</CardBody>
|
||||
</Column>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
@ -0,0 +1,29 @@
|
|||
import React from 'react';
|
||||
|
||||
import { Stack, Text } from 'soapbox/components/ui';
|
||||
|
||||
import { generateText, randomIntFromInterval } from '../utils';
|
||||
|
||||
const PlaceholderEventPreview = () => {
|
||||
const eventNameLength = randomIntFromInterval(5, 25);
|
||||
const nameLength = randomIntFromInterval(5, 15);
|
||||
|
||||
return (
|
||||
<div className='w-full rounded-lg bg-gray-100 dark:bg-primary-800 relative overflow-hidden animate-pulse text-primary-50 dark:text-primary-800'>
|
||||
<div className='bg-primary-200 dark:bg-gray-600 h-40'>
|
||||
{/* <img className='h-full w-full object-cover' src={banner.url} alt={intl.formatMessage(messages.bannerHeader)} />} */}
|
||||
</div>
|
||||
<Stack className='p-2.5' space={2}>
|
||||
<Text weight='semibold'>{generateText(eventNameLength)}</Text>
|
||||
|
||||
<div className='flex gap-y-1 gap-x-2 flex-wrap text-gray-700 dark:text-gray-600'>
|
||||
<span>{generateText(nameLength)}</span>
|
||||
<span>{generateText(nameLength)}</span>
|
||||
<span>{generateText(nameLength)}</span>
|
||||
</div>
|
||||
</Stack>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PlaceholderEventPreview;
|
|
@ -43,6 +43,9 @@ const LinkFooter: React.FC = (): JSX.Element => {
|
|||
{features.profileDirectory && (
|
||||
<FooterLink to='/directory'><FormattedMessage id='navigation_bar.profile_directory' defaultMessage='Profile directory' /></FooterLink>
|
||||
)}
|
||||
{features.events && (
|
||||
<FooterLink to='/events'><FormattedMessage id='navigation_bar.events' defaultMessage='Events' /></FooterLink>
|
||||
)}
|
||||
<FooterLink to='/blocks'><FormattedMessage id='navigation_bar.blocks' defaultMessage='Blocks' /></FooterLink>
|
||||
<FooterLink to='/mutes'><FormattedMessage id='navigation_bar.mutes' defaultMessage='Mutes' /></FooterLink>
|
||||
{features.filters && (
|
||||
|
|
|
@ -12,6 +12,14 @@ import {
|
|||
BOOKMARKED_STATUSES_EXPAND_SUCCESS,
|
||||
BOOKMARKED_STATUSES_EXPAND_FAIL,
|
||||
} from '../actions/bookmarks';
|
||||
import {
|
||||
RECENT_EVENTS_FETCH_REQUEST,
|
||||
RECENT_EVENTS_FETCH_SUCCESS,
|
||||
RECENT_EVENTS_FETCH_FAIL,
|
||||
JOINED_EVENTS_FETCH_REQUEST,
|
||||
JOINED_EVENTS_FETCH_SUCCESS,
|
||||
JOINED_EVENTS_FETCH_FAIL,
|
||||
} from '../actions/events';
|
||||
import {
|
||||
FAVOURITED_STATUSES_FETCH_REQUEST,
|
||||
FAVOURITED_STATUSES_FETCH_SUCCESS,
|
||||
|
@ -68,6 +76,8 @@ const initialState: State = ImmutableMap({
|
|||
bookmarks: StatusListRecord(),
|
||||
pins: StatusListRecord(),
|
||||
scheduled_statuses: StatusListRecord(),
|
||||
recent_events: StatusListRecord(),
|
||||
joined_events: StatusListRecord(),
|
||||
});
|
||||
|
||||
const getStatusId = (status: string | StatusEntity) => typeof status === 'string' ? status : status.id;
|
||||
|
@ -168,6 +178,18 @@ export default function statusLists(state = initialState, action: AnyAction) {
|
|||
case SCHEDULED_STATUS_CANCEL_REQUEST:
|
||||
case SCHEDULED_STATUS_CANCEL_SUCCESS:
|
||||
return removeOneFromList(state, 'scheduled_statuses', action.id || action.status.id);
|
||||
case RECENT_EVENTS_FETCH_REQUEST:
|
||||
return setLoading(state, 'recent_events', true);
|
||||
case RECENT_EVENTS_FETCH_FAIL:
|
||||
return setLoading(state, 'recent_events', false);
|
||||
case RECENT_EVENTS_FETCH_SUCCESS:
|
||||
return normalizeList(state, 'recent_events', action.statuses, action.next);
|
||||
case JOINED_EVENTS_FETCH_REQUEST:
|
||||
return setLoading(state, 'joined_events', true);
|
||||
case JOINED_EVENTS_FETCH_FAIL:
|
||||
return setLoading(state, 'joined_events', false);
|
||||
case JOINED_EVENTS_FETCH_SUCCESS:
|
||||
return normalizeList(state, 'joined_events', action.statuses, action.next);
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue