feat(pages): add /application/[guildId]/[channelId] (#4)

This commit is contained in:
Divlo 2022-01-01 20:42:25 +01:00 committed by GitHub
parent 91e246b759
commit fdc2a2d1de
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
118 changed files with 6040 additions and 2094 deletions

View File

@ -27,6 +27,7 @@
} }
} }
} }
] ],
"@typescript-eslint/no-namespace": "off"
} }
} }

View File

@ -1,15 +1,15 @@
FROM node:16.11.0 AS dependencies FROM node:16.13.1 AS dependencies
WORKDIR /usr/src/app WORKDIR /usr/src/app
COPY ./package*.json ./ COPY ./package*.json ./
RUN npm clean-install RUN npm clean-install
FROM node:16.11.0 AS builder FROM node:16.13.1 AS builder
WORKDIR /usr/src/app WORKDIR /usr/src/app
COPY ./ ./ COPY ./ ./
COPY --from=dependencies /usr/src/app/node_modules ./node_modules COPY --from=dependencies /usr/src/app/node_modules ./node_modules
RUN npm run build RUN npm run build
FROM node:16.11.0 AS runner FROM node:16.13.1 AS runner
WORKDIR /usr/src/app WORKDIR /usr/src/app
ENV NODE_ENV=production ENV NODE_ENV=production
COPY --from=builder /usr/src/app/next.config.js ./next.config.js COPY --from=builder /usr/src/app/next.config.js ./next.config.js

View File

@ -1,13 +1,6 @@
import { useState, useEffect, useMemo } from 'react' import { useState, useEffect } from 'react'
import Image from 'next/image' import Image from 'next/image'
import useTranslation from 'next-translate/useTranslation' import { PlusIcon, MenuIcon, UsersIcon, XIcon } from '@heroicons/react/solid'
import {
CogIcon,
PlusIcon,
MenuIcon,
UsersIcon,
XIcon
} from '@heroicons/react/solid'
import classNames from 'classnames' import classNames from 'classnames'
import { useMediaQuery } from 'react-responsive' import { useMediaQuery } from 'react-responsive'
import { useSwipeable } from 'react-swipeable' import { useSwipeable } from 'react-swipeable'
@ -15,31 +8,33 @@ import { useSwipeable } from 'react-swipeable'
import { Sidebar, DirectionSidebar } from './Sidebar' import { Sidebar, DirectionSidebar } from './Sidebar'
import { IconButton } from 'components/design/IconButton' import { IconButton } from 'components/design/IconButton'
import { IconLink } from 'components/design/IconLink' import { IconLink } from 'components/design/IconLink'
import { Channels } from './Channels'
import { Guilds } from './Guilds/Guilds' import { Guilds } from './Guilds/Guilds'
import { Divider } from '../design/Divider' import { Divider } from '../design/Divider'
import { Members } from './Members' import { Members } from './Members'
import { useAuthentication } from 'utils/authentication' import { useAuthentication } from 'tools/authentication'
import { API_URL } from 'utils/api' import { API_URL } from 'tools/api'
export interface GuildsChannelsPath { export interface GuildsChannelsPath {
guildId: number guildId: number
channelId: number channelId: number
} }
export interface ApplicationProps { export type ApplicationPath =
path:
| '/application' | '/application'
| '/application/guilds/join' | '/application/guilds/join'
| '/application/guilds/create' | '/application/guilds/create'
| '/application/users/[userId]' | `/application/users/${number}`
| GuildsChannelsPath | GuildsChannelsPath
export interface ApplicationProps {
path: ApplicationPath
guildLeftSidebar?: React.ReactNode
title: string
} }
export const Application: React.FC<ApplicationProps> = (props) => { export const Application: React.FC<ApplicationProps> = (props) => {
const { children, path } = props const { children, path, guildLeftSidebar, title } = props
const { t } = useTranslation()
const { user } = useAuthentication() const { user } = useAuthentication()
const [visibleSidebars, setVisibleSidebars] = useState({ const [visibleSidebars, setVisibleSidebars] = useState({
@ -129,23 +124,6 @@ export const Application: React.FC<ApplicationProps> = (props) => {
} }
}) })
const title = useMemo(() => {
if (typeof path !== 'string') {
// TODO: Returns the real name of the channel when doing APIs calls
return `# Channel ${path.channelId}`
}
if (path.startsWith('/application/users/')) {
return 'Settings'
}
if (path === '/application/guilds/join') {
return 'Join a Guild'
}
if (path === '/application/guilds/create') {
return t('application:create-a-guild')
}
return 'Application'
}, [path, t])
useEffect(() => { useEffect(() => {
setMounted(true) setMounted(true)
}, []) }, [])
@ -163,12 +141,16 @@ export const Application: React.FC<ApplicationProps> = (props) => {
> >
{!visibleSidebars.left ? <MenuIcon /> : <XIcon />} {!visibleSidebars.left ? <MenuIcon /> : <XIcon />}
</IconButton> </IconButton>
<div className='text-md text-green-800 dark:text-green-400 font-semibold'> <div
data-cy='application-title'
className='text-md text-green-800 dark:text-green-400 font-semibold'
>
{title} {title}
</div> </div>
<div className='flex space-x-2'> <div className='flex space-x-2'>
{title.startsWith('#') && ( {title.startsWith('#') && (
<IconButton <IconButton
data-cy='icon-button-right-sidebar-members'
className='p-2 h-10 w-10' className='p-2 h-10 w-10'
onClick={() => handleToggleSidebars('right')} onClick={() => handleToggleSidebars('right')}
> >
@ -201,7 +183,8 @@ export const Application: React.FC<ApplicationProps> = (props) => {
? '/images/data/user-default.png' ? '/images/data/user-default.png'
: API_URL + user.logo : API_URL + user.logo
} }
alt='logo' alt={"Users's profil picture"}
draggable={false}
width={48} width={48}
height={48} height={48}
/> />
@ -217,26 +200,7 @@ export const Application: React.FC<ApplicationProps> = (props) => {
<Guilds path={path} /> <Guilds path={path} />
</div> </div>
{typeof path !== 'string' && ( {guildLeftSidebar}
<div className='flex flex-col justify-between w-full mt-2'>
<div className='text-center p-2 mx-8 mt-2'>
<h2 className='text-xl'>Guild Name</h2>
</div>
<Divider />
<div className='scrollbar-firefox-support overflow-y-auto'>
<Channels path={path} />
</div>
<Divider />
<div className='flex justify-center items-center p-2 mb-1 space-x-6'>
<IconButton className='h-10 w-10' title='Add a Channel'>
<PlusIcon />
</IconButton>
<IconButton className='h-7 w-7' title='Settings'>
<CogIcon />
</IconButton>
</div>
</div>
)}
</Sidebar> </Sidebar>
<div <div
@ -252,6 +216,7 @@ export const Application: React.FC<ApplicationProps> = (props) => {
{children} {children}
</div> </div>
{typeof path !== 'string' && (
<Sidebar <Sidebar
direction='right' direction='right'
visible={visibleSidebars.right} visible={visibleSidebars.right}
@ -259,6 +224,7 @@ export const Application: React.FC<ApplicationProps> = (props) => {
> >
<Members /> <Members />
</Sidebar> </Sidebar>
)}
</main> </main>
</> </>
) )

View File

@ -0,0 +1,16 @@
import { Meta, Story } from '@storybook/react'
import { channelExample } from '../../../../cypress/fixtures/channels/channel'
import { Channel as Component, ChannelProps } from './Channel'
const Stories: Meta = {
title: 'Channel',
component: Component
}
export default Stories
export const Channel: Story<ChannelProps> = (arguments_) => {
return <Component {...arguments_} />
}
Channel.args = { path: { channelId: 1, guildId: 1 }, channel: channelExample }

View File

@ -0,0 +1,13 @@
import { render } from '@testing-library/react'
import { channelExample } from 'cypress/fixtures/channels/channel'
import { Channel } from './Channel'
describe('<Channel />', () => {
it('should render successfully', () => {
const { baseElement } = render(
<Channel channel={channelExample} path={{ channelId: 1, guildId: 1 }} />
)
expect(baseElement).toBeTruthy()
})
})

View File

@ -0,0 +1,32 @@
import classNames from 'classnames'
import Link from 'next/link'
import { GuildsChannelsPath } from '../../Application'
import { Channel as ChannelType } from '../../../../models/Channel'
export interface ChannelProps {
path: GuildsChannelsPath
channel: ChannelType
selected?: boolean
}
export const Channel: React.FC<ChannelProps> = (props) => {
const { channel, path, selected = false } = props
return (
<Link href={`/application/${path.guildId}/${channel.id}`}>
<a
className={classNames(
'hover:bg-gray-100 group flex items-center justify-between text-sm py-2 my-3 mx-3 transition-colors dark:hover:bg-gray-600 duration-200 rounded-lg',
{
'text-green-800 dark:text-green-400 font-semibold': selected
}
)}
>
<span className='ml-2 mr-4' data-cy='channel-name'>
# {channel.name}
</span>
</a>
</Link>
)
}

View File

@ -0,0 +1 @@
export * from './Channel'

View File

@ -1,15 +0,0 @@
import { Meta, Story } from '@storybook/react'
import { Channels as Component, ChannelsProps } from './'
const Stories: Meta = {
title: 'Channels',
component: Component
}
export default Stories
export const Channels: Story<ChannelsProps> = (arguments_) => (
<Component {...arguments_} />
)
Channels.args = { path: { channelId: 1, guildId: 2 } }

View File

@ -1,12 +0,0 @@
import { render } from '@testing-library/react'
import { Channels } from './'
describe('<Channels />', () => {
it('should render successfully', () => {
const { baseElement } = render(
<Channels path={{ channelId: 1, guildId: 2 }} />
)
expect(baseElement).toBeTruthy()
})
})

View File

@ -1,7 +1,9 @@
import Link from 'next/link' import InfiniteScroll from 'react-infinite-scroll-component'
import classNames from 'classnames'
import { GuildsChannelsPath } from '../Application' import { GuildsChannelsPath } from '../Application'
import { Loader } from 'components/design/Loader'
import { Channel } from './Channel'
import { useChannels } from 'contexts/Channels'
export interface ChannelsProps { export interface ChannelsProps {
path: GuildsChannelsPath path: GuildsChannelsPath
@ -10,27 +12,33 @@ export interface ChannelsProps {
export const Channels: React.FC<ChannelsProps> = (props) => { export const Channels: React.FC<ChannelsProps> = (props) => {
const { path } = props const { path } = props
const { channels, hasMore, nextPage } = useChannels()
return ( return (
<nav className='w-full'> <div
{new Array(100).fill(null).map((_, index) => { id='channels'
return ( className='scrollbar-firefox-support overflow-y-auto flex-1 flex flex-col'
<Link key={index} href={`/application/${path.guildId}/${index}`}>
<a
className={classNames(
'hover:bg-gray-100 group flex items-center justify-between text-sm py-2 my-3 mx-3 transition-colors dark:hover:bg-gray-600 duration-200 rounded-lg',
{
'text-green-800 dark:text-green-400 font-semibold':
typeof path !== 'string' && path.channelId === index,
'text-gray-600 dark:text-gray-400 hover:text-gray-800 dark:hover:text-white font-normal':
typeof path === 'string'
}
)}
> >
<span className='ml-2 mr-4'># Channel {index}</span> <InfiniteScroll
</a> className='w-full channels-list'
</Link> scrollableTarget='channels'
dataLength={channels.length}
next={nextPage}
hasMore={hasMore}
loader={<Loader />}
>
{channels.map((channel) => {
const selected = channel.id === path.channelId
return (
<Channel
key={channel.id}
channel={channel}
path={path}
selected={selected}
/>
) )
})} })}
</nav> </InfiniteScroll>
</div>
) )
} }

View File

@ -4,7 +4,7 @@ import { Form } from 'react-component-form'
import TextareaAutosize from 'react-textarea-autosize' import TextareaAutosize from 'react-textarea-autosize'
import { AxiosResponse } from 'axios' import { AxiosResponse } from 'axios'
import { useAuthentication } from '../../../utils/authentication' import { useAuthentication } from '../../../tools/authentication'
import { HandleSubmitCallback, useForm } from '../../../hooks/useForm' import { HandleSubmitCallback, useForm } from '../../../hooks/useForm'
import { GuildComplete, guildSchema } from '../../../models/Guild' import { GuildComplete, guildSchema } from '../../../models/Guild'
import { Input } from '../../design/Input' import { Input } from '../../design/Input'

View File

@ -0,0 +1,38 @@
import { CogIcon, PlusIcon } from '@heroicons/react/solid'
import { useGuildMember } from 'contexts/GuildMember'
import { Divider } from 'components/design/Divider'
import { Channels } from 'components/Application/Channels'
import { IconButton } from 'components/design/IconButton'
import { GuildsChannelsPath } from '..'
export interface GuildLeftSidebarProps {
path: GuildsChannelsPath
}
export const GuildLeftSidebar: React.FC<GuildLeftSidebarProps> = (props) => {
const { path } = props
const { guild } = useGuildMember()
return (
<div className='flex flex-col justify-between w-full mt-2'>
<div className='text-center p-2 mx-8 mt-2'>
<h2 data-cy='guild-left-sidebar-title' className='text-xl'>
{guild.name}
</h2>
</div>
<Divider />
<Channels path={path} />
<Divider />
<div className='flex justify-center items-center p-2 mb-1 space-x-6'>
<IconButton className='h-10 w-10' title='Add a Channel'>
<PlusIcon />
</IconButton>
<IconButton className='h-7 w-7' title='Settings'>
<CogIcon />
</IconButton>
</div>
</div>
)
}

View File

@ -0,0 +1 @@
export * from './GuildLeftSidebar'

View File

@ -1,6 +1,7 @@
import { Meta, Story } from '@storybook/react' import { Meta, Story } from '@storybook/react'
import { Guild as Component, GuildProps } from './Guild' import { Guild as Component, GuildProps } from './Guild'
import { guildExample } from '../../../../cypress/fixtures/guilds/guild'
const Stories: Meta = { const Stories: Meta = {
title: 'Guild', title: 'Guild',
@ -14,12 +15,7 @@ export const Guild: Story<GuildProps> = (arguments_) => {
} }
Guild.args = { Guild.args = {
guild: { guild: {
id: 1, ...guildExample,
name: 'GuildExample', defaultChannelId: 1
description: 'guild example.',
icon: null,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
membersCount: 1
} }
} }

View File

@ -1,19 +1,15 @@
import { render } from '@testing-library/react' import { render } from '@testing-library/react'
import { Guild } from './Guild' import { Guild } from './Guild'
import { guildExample } from '../../../../cypress/fixtures/guilds/guild'
describe('<Guild />', () => { describe('<Guild />', () => {
it('should render successfully', () => { it('should render successfully', () => {
const { baseElement } = render( const { baseElement } = render(
<Guild <Guild
guild={{ guild={{
id: 1, ...guildExample,
name: 'GuildExample', defaultChannelId: 1
description: 'guild example.',
icon: null,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
membersCount: 1
}} }}
/> />
) )

View File

@ -0,0 +1,35 @@
import Image from 'next/image'
import { GuildWithDefaultChannelId } from '../../../../models/Guild'
import { IconLink } from '../../../design/IconLink'
export interface GuildProps {
guild: GuildWithDefaultChannelId
selected?: boolean
}
export const Guild: React.FC<GuildProps> = (props) => {
const { guild, selected } = props
return (
<IconLink
className='mt-2'
href={`/application/${guild.id}/${guild.defaultChannelId}`}
selected={selected}
title={guild.name}
>
<div className='pl-[6px]'>
<Image
className='rounded-full'
src={
guild.icon != null ? guild.icon : '/images/data/guild-default.png'
}
alt='logo'
width={48}
height={48}
draggable={false}
/>
</div>
</IconLink>
)
}

View File

@ -1,15 +0,0 @@
import { Meta, Story } from '@storybook/react'
import { Guilds as Component, GuildsProps } from './'
const Stories: Meta = {
title: 'Guilds',
component: Component
}
export default Stories
export const Guilds: Story<GuildsProps> = (arguments_) => (
<Component {...arguments_} />
)
Guilds.args = { path: { channelId: 1, guildId: 2 } }

View File

@ -1,12 +0,0 @@
import { render } from '@testing-library/react'
import { Guilds } from './'
describe('<Guilds />', () => {
it('should render successfully', () => {
const { baseElement } = render(
<Guilds path={{ channelId: 1, guildId: 2 }} />
)
expect(baseElement).toBeTruthy()
})
})

View File

@ -1,34 +1,37 @@
import Image from 'next/image' import InfiniteScroll from 'react-infinite-scroll-component'
import { ApplicationProps } from '../Application' import { Loader } from 'components/design/Loader'
import { IconLink } from '../../design/IconLink' import { Guild } from './Guild'
import { useGuilds } from 'contexts/Guilds'
import { GuildsChannelsPath } from '..'
export interface GuildsProps extends ApplicationProps {} export interface GuildsProps {
path: GuildsChannelsPath | string
}
export const Guilds: React.FC<GuildsProps> = (props) => { export const Guilds: React.FC<GuildsProps> = (props) => {
const { path } = props const { path } = props
const { guilds, hasMore, nextPage } = useGuilds()
return ( return (
<div className='min-w-[92px] mt-[130px] pt-2 h-full border-r-2 border-gray-500 dark:border-white/20 space-y-2 scrollbar-firefox-support overflow-y-auto'> <div
{new Array(100).fill(null).map((_, index) => { id='guilds-list'
return ( className='min-w-[92px] mt-[130px] pt-2 h-full border-r-2 border-gray-500 dark:border-white/20 space-y-2 scrollbar-firefox-support overflow-y-auto'
<IconLink
key={index}
href={`/application/${index}/0`}
selected={typeof path !== 'string' && path.guildId === index}
title='Guild Name'
> >
<div className='pl-[6px]'> <InfiniteScroll
<Image className='guilds-list'
src='/images/icons/Thream.png' dataLength={guilds.length}
alt='logo' next={nextPage}
width={48} hasMore={hasMore}
height={48} scrollableTarget='guilds-list'
/> loader={<Loader />}
</div> >
</IconLink> {guilds.map((guild) => {
) const selected = typeof path !== 'string' && path.guildId === guild.id
return <Guild key={guild.id} guild={guild} selected={selected} />
})} })}
</InfiniteScroll>
</div> </div>
) )
} }

View File

@ -0,0 +1,21 @@
import { Meta, Story } from '@storybook/react'
import { GuildPublic as Component, GuildPublicProps } from './GuildPublic'
import { guildExample } from '../../../../cypress/fixtures/guilds/guild'
const Stories: Meta = {
title: 'GuildPublic',
component: Component
}
export default Stories
export const GuildPublic: Story<GuildPublicProps> = (arguments_) => {
return <Component {...arguments_} />
}
GuildPublic.args = {
guild: {
...guildExample,
membersCount: 1
}
}

View File

@ -0,0 +1,18 @@
import { render } from '@testing-library/react'
import { GuildPublic } from './GuildPublic'
import { guildExample } from '../../../../cypress/fixtures/guilds/guild'
describe('<GuildPublic />', () => {
it('should render successfully', () => {
const { baseElement } = render(
<GuildPublic
guild={{
...guildExample,
membersCount: 1
}}
/>
)
expect(baseElement).toBeTruthy()
})
})

View File

@ -1,19 +1,19 @@
import Image from 'next/image' import Image from 'next/image'
import useTranslation from 'next-translate/useTranslation'
import { GuildPublic } from 'models/Guild' import { GuildPublic as GuildPublicType } from '../../../../models/Guild'
export interface GuildProps { export interface GuildPublicProps {
guild: GuildPublic guild: GuildPublicType
} }
export const Guild: React.FC<GuildProps> = (props) => { export const GuildPublic: React.FC<GuildPublicProps> = (props) => {
const { guild } = props const { guild } = props
const { t } = useTranslation()
return ( return (
<div <div className='max-w-sm flex flex-col items-center justify-center border-gray-500 dark:border-gray-700 p-4 cursor-pointer rounded shadow-lg border transition duration-200 ease-in-out hover:-translate-y-2 hover:shadow-none'>
key={guild.id}
className='max-w-sm flex flex-col items-center justify-center border-gray-500 dark:border-gray-700 p-4 cursor-pointer rounded shadow-lg border transition duration-200 ease-in-out hover:-translate-y-2 hover:shadow-none'
>
<Image <Image
className='rounded-full' className='rounded-full'
src={guild.icon != null ? guild.icon : '/images/data/guild-default.png'} src={guild.icon != null ? guild.icon : '/images/data/guild-default.png'}
@ -28,7 +28,7 @@ export const Guild: React.FC<GuildProps> = (props) => {
<p className='text-base w-11/12 mx-auto'>{guild.description}</p> <p className='text-base w-11/12 mx-auto'>{guild.description}</p>
</div> </div>
<p className='flex flex-col text-green-800 dark:text-green-400 mt-4'> <p className='flex flex-col text-green-800 dark:text-green-400 mt-4'>
{guild.membersCount} members {guild.membersCount} {t('application:members')}
</p> </p>
</div> </div>
) )

View File

@ -0,0 +1 @@
export * from './GuildPublic'

View File

@ -1,49 +1,31 @@
import { useCallback, useEffect, useState, useRef } from 'react' import useTranslation from 'next-translate/useTranslation'
import { useEffect, useState } from 'react'
import InfiniteScroll from 'react-infinite-scroll-component' import InfiniteScroll from 'react-infinite-scroll-component'
import { useAuthentication } from 'utils/authentication' import { useAuthentication } from 'tools/authentication'
import { GuildPublic } from 'models/Guild' import { GuildPublic as GuildPublicType } from 'models/Guild'
import { Loader } from 'components/design/Loader' import { Loader } from 'components/design/Loader'
import { useFetchState } from 'hooks/useFetchState' import { GuildPublic } from './GuildPublic'
import { Guild } from './Guild' import { usePagination } from 'hooks/usePagination'
export const JoinGuildsPublic: React.FC = () => { export const JoinGuildsPublic: React.FC = () => {
const [guilds, setGuilds] = useState<GuildPublic[]>([]) const [search, setSearch] = useState('')
const [hasMore, setHasMore] = useState(true)
const [inputSearch, setInputSearch] = useState('')
const [fetchState, setFetchState] = useFetchState('idle')
const afterId = useRef<number | null>(null)
const { authentication } = useAuthentication() const { authentication } = useAuthentication()
const { t } = useTranslation()
const fetchGuilds = useCallback(async (): Promise<void> => { const { items, hasMore, nextPage, resetPagination } =
if (fetchState !== 'idle') { usePagination<GuildPublicType>({
return api: authentication.api,
} url: '/guilds/public'
setFetchState('loading')
const { data } = await authentication.api.get<GuildPublic[]>(
`/guilds/public?limit=20&search=${inputSearch}${
afterId.current != null ? `&after=${afterId.current}` : ''
}`
)
afterId.current = data.length > 0 ? data[data.length - 1].id : null
setGuilds((oldGuilds) => {
return [...oldGuilds, ...data]
}) })
setHasMore(data.length > 0)
setFetchState('idle')
}, [authentication, fetchState, setFetchState, inputSearch])
useEffect(() => { useEffect(() => {
afterId.current = null resetPagination()
setGuilds([]) nextPage({ search })
fetchGuilds().catch((error) => { }, [resetPagination, nextPage, search])
console.error(error)
})
}, [inputSearch]) // eslint-disable-line react-hooks/exhaustive-deps
const handleChange = (event: React.ChangeEvent<HTMLInputElement>): void => { const handleChange = (event: React.ChangeEvent<HTMLInputElement>): void => {
setInputSearch(event.target.value) setSearch(event.target.value)
} }
return ( return (
@ -54,19 +36,19 @@ export const JoinGuildsPublic: React.FC = () => {
className='w-10/12 sm:w-8/12 md:w-6/12 lg:w-5/12 bg-white dark:bg-[#3B3B3B] border-gray-500 dark:border-gray-700 p-3 my-6 mt-16 mx-auto rounded-md border' className='w-10/12 sm:w-8/12 md:w-6/12 lg:w-5/12 bg-white dark:bg-[#3B3B3B] border-gray-500 dark:border-gray-700 p-3 my-6 mt-16 mx-auto rounded-md border'
type='search' type='search'
name='search-guild' name='search-guild'
placeholder='🔎 Search...' placeholder={`🔎 ${t('application:search')}...`}
/> />
<div className='w-full flex items-center justify-center p-12'> <div className='w-full flex items-center justify-center p-12'>
<InfiniteScroll <InfiniteScroll
className='guilds-list max-w-[1600px] grid grid-cols-1 xl:grid-cols-3 md:grid-cols-2 sm:grid-cols-1 gap-8 !overflow-hidden' className='guilds-public-list max-w-[1600px] grid grid-cols-1 xl:grid-cols-3 md:grid-cols-2 sm:grid-cols-1 gap-8 !overflow-hidden'
dataLength={guilds.length} dataLength={items.length}
next={fetchGuilds} next={nextPage}
scrollableTarget='application-page-content' scrollableTarget='application-page-content'
hasMore={hasMore} hasMore={hasMore}
loader={<Loader />} loader={<Loader />}
> >
{guilds.map((guild) => { {items.map((guild) => {
return <Guild guild={guild} key={guild.id} /> return <GuildPublic guild={guild} key={guild.id} />
})} })}
</InfiniteScroll> </InfiniteScroll>
</div> </div>

View File

@ -0,0 +1,16 @@
import { Meta, Story } from '@storybook/react'
import { Member as Component, MemberProps } from './Member'
import { memberExampleComplete } from '../../../../cypress/fixtures/members/member'
const Stories: Meta = {
title: 'Member',
component: Component
}
export default Stories
export const Member: Story<MemberProps> = (arguments_) => {
return <Component {...arguments_} />
}
Member.args = { member: memberExampleComplete }

View File

@ -0,0 +1,11 @@
import { render } from '@testing-library/react'
import { Member } from './Member'
import { memberExampleComplete } from '../../../../cypress/fixtures/members/member'
describe('<Member />', () => {
it('should render successfully', () => {
const { baseElement } = render(<Member member={memberExampleComplete} />)
expect(baseElement).toBeTruthy()
})
})

View File

@ -0,0 +1,45 @@
import Image from 'next/image'
import Link from 'next/link'
import { MemberWithPublicUser } from '../../../../models/Member'
import { API_URL } from '../../../../tools/api'
export interface MemberProps {
member: MemberWithPublicUser
}
export const Member: React.FC<MemberProps> = (props) => {
const { member } = props
return (
<Link href={`/application/users/${member.user.id}`}>
<a>
<div className='flex items-center cursor-pointer py-2 px-4 pr-10 rounded hover:bg-gray-300 dark:hover:bg-gray-900'>
<div className='min-w-[50px] flex rounded-full'>
<Image
src={
member.user.logo == null
? '/images/data/user-default.png'
: API_URL + member.user.logo
}
alt={"Users's profil picture"}
height={50}
width={50}
draggable={false}
className='rounded-full'
/>
</div>
<div className='max-w-[145px] ml-4'>
<p
data-cy='member-user-name'
className='overflow-hidden whitespace-nowrap overflow-ellipsis'
>
{member.user.name}
</p>
{member.user.status != null && <span>{member.user.status}</span>}
</div>
</div>
</a>
</Link>
)
}

View File

@ -0,0 +1 @@
export * from './Member'

View File

@ -1,10 +0,0 @@
import { render } from '@testing-library/react'
import { Members } from './Members'
describe('<Members />', () => {
it('should render successfully', () => {
const { baseElement } = render(<Members />)
expect(baseElement).toBeTruthy()
})
})

View File

@ -1,57 +1,36 @@
import Image from 'next/image' import useTranslation from 'next-translate/useTranslation'
import InfiniteScroll from 'react-infinite-scroll-component'
import { Divider } from '../../design/Divider' import { Divider } from '../../design/Divider'
import { Loader } from 'components/design/Loader'
import { useMembers } from 'contexts/Members'
import { Member } from './Member'
import { capitalize } from '../../../tools/utils/capitalize'
export const Members: React.FC = () => { export const Members: React.FC = () => {
const { members, hasMore, nextPage } = useMembers()
const { t } = useTranslation()
return ( return (
<> <>
<div className='mb-2'> <div className='mb-2'>
<h1 className='text-center pt-2 my-2 text-xl'>Members</h1> <h1 data-cy='members-title' className='text-center pt-2 my-2 text-xl'>
{capitalize(t('application:members'))}
</h1>
<Divider /> <Divider />
</div> </div>
<div className='flex items-center cursor-pointer py-2 px-4 pr-10 rounded hover:bg-gray-300 dark:hover:bg-gray-900'> <InfiniteScroll
<div className='min-w-[50px] flex rounded-full border-2 border-green-500'> className='members-list'
<Image dataLength={members.length}
src='/images/data/divlo.png' next={nextPage}
alt={"Users's profil picture"} hasMore={hasMore}
height={50} loader={<Loader />}
width={50}
draggable='false'
className='rounded-full'
/>
</div>
<div className='max-w-[145px] ml-4'>
<p className='overflow-hidden whitespace-nowrap overflow-ellipsis'>
Walidouxssssssssssss
</p>
<span className='text-green-600 dark:text-green-400'>Online</span>
</div>
</div>
{new Array(100).fill(null).map((_, index) => {
return (
<div
key={index}
className='flex items-center cursor-pointer py-2 px-4 pr-10 rounded opacity-40 hover:bg-gray-300 dark:hover:bg-gray-900'
> >
<div className='min-w-[50px] flex rounded-full border-2 border-transparent drop-shadow-md'> {members.map((member) => {
<Image return <Member key={member.id} member={member} />
src='/images/data/divlo.png'
alt={"Users's profil picture"}
height={50}
width={50}
draggable='false'
className='rounded-full'
/>
</div>
<div className='max-w-[145px] ml-4'>
<p className='overflow-hidden whitespace-nowrap overflow-ellipsis'>
Walidouxssssssssssssssssssssssssssssss
</p>
<span className='text-red-800 dark:text-red-400'>Offline</span>
</div>
</div>
)
})} })}
</InfiniteScroll>
</> </>
) )
} }

View File

@ -0,0 +1,16 @@
import { Meta, Story } from '@storybook/react'
import { Message as Component, MessageProps } from './Message'
import { messageExampleComplete } from '../../../../cypress/fixtures/messages/message'
const Stories: Meta = {
title: 'Message',
component: Component
}
export default Stories
export const Message: Story<MessageProps> = (arguments_) => {
return <Component {...arguments_} />
}
Message.args = { message: messageExampleComplete }

View File

@ -0,0 +1,61 @@
import Image from 'next/image'
import Link from 'next/link'
import date from 'date-and-time'
import { MessageWithMember } from '../../../../models/Message'
import { API_URL } from '../../../../tools/api'
import { MessageContent } from './MessageContent'
export interface MessageProps {
message: MessageWithMember
}
export const Message: React.FC<MessageProps> = (props) => {
const { message } = props
return (
<div className='p-4 flex transition hover:bg-gray-200 dark:hover:bg-gray-900'>
<Link href={`/application/users/${message.member.user.id}`}>
<a>
<div className='w-12 h-12 mr-4 flex flex-shrink-0 items-center justify-center'>
<div className='w-10 h-10 drop-shadow-md'>
<Image
className='rounded-full'
src={
message.member.user.logo == null
? '/images/data/user-default.png'
: API_URL + message.member.user.logo
}
alt={"Users's profil picture"}
width={50}
height={50}
draggable={false}
/>
</div>
</div>
</a>
</Link>
<div className='w-full'>
<div className='w-max flex items-center'>
<Link href={`/application/users/${message.member.user.id}`}>
<a>
<span
data-cy='message-member-user-name'
className='font-bold text-gray-900 dark:text-gray-200'
>
{message.member.user.name}
</span>
</a>
</Link>
<span
data-cy='message-date'
className='text-gray-500 dark:text-gray-200 text-xs ml-4 select-none'
>
{date.format(new Date(message.createdAt), 'DD/MM/YYYY - HH:mm:ss')}
</span>
</div>
<MessageContent message={message} />
</div>
</div>
)
}

View File

@ -0,0 +1,46 @@
import { useMemo } from 'react'
import ReactMarkdown from 'react-markdown'
import gfm from 'remark-gfm'
import remarkBreaks from 'remark-breaks'
import { Emoji, emojiPlugin, isStringWithOnlyOneEmoji } from '../../../../Emoji'
import { MessageWithMember } from '../../../../../models/Message'
export interface MessageContentProps {
message: MessageWithMember
}
export const MessageContent: React.FC<MessageContentProps> = (props) => {
const { message } = props
const isMessageWithOnlyOneEmoji = useMemo(() => {
return isStringWithOnlyOneEmoji(message.value)
}, [message.value])
if (isMessageWithOnlyOneEmoji) {
return (
<div>
<p>
<Emoji value={message.value} size={40} />
</p>
</div>
)
}
return (
<ReactMarkdown
disallowedElements={['table']}
unwrapDisallowed
remarkPlugins={[[gfm], [remarkBreaks]]}
rehypePlugins={[emojiPlugin]}
linkTarget='_blank'
components={{
emoji: (props) => {
return <Emoji value={props.value} size={20} />
}
}}
>
{message.value}
</ReactMarkdown>
)
}

View File

@ -0,0 +1 @@
export * from './MessageContent'

View File

@ -0,0 +1 @@
export * from './Message'

View File

@ -1,12 +0,0 @@
import { Meta, Story } from '@storybook/react'
import { Messages as Component } from './'
const Stories: Meta = {
title: 'Messages',
component: Component
}
export default Stories
export const Messages: Story = (arguments_) => <Component {...arguments_} />

View File

@ -1,82 +1,45 @@
import Image from 'next/image' import InfiniteScroll from 'react-infinite-scroll-component'
import TextareaAutosize from 'react-textarea-autosize'
import { Loader } from 'components/design/Loader'
import { Message } from './Message'
import { useMessages } from 'contexts/Messages'
import { Emoji } from 'components/Emoji'
export const Messages: React.FC = () => { export const Messages: React.FC = () => {
return ( const { messages, hasMore, nextPage } = useMessages()
<>
<div className='w-full scrollbar-firefox-support overflow-y-auto transition-all'> if (messages.length === 0) {
{new Array(20).fill(null).map((_, index) => {
return ( return (
<div <div
key={index} id='messages'
className='p-4 flex transition hover:bg-gray-200 dark:hover:bg-gray-900' className='w-full scrollbar-firefox-support overflow-y-auto transition-all flex-1 flex flex-col text-center mt-8 text-lg'
> >
<div className='w-12 h-12 mr-4 flex flex-shrink-0 items-center justify-center'>
<div className='w-10 h-10 drop-shadow-md'>
<Image
className='rounded-full'
src='/images/data/user-default.png'
alt='logo'
width={50}
height={50}
/>
</div>
</div>
<div className='w-full'>
<div className='w-max flex items-center'>
<span className='font-bold text-gray-900 dark:text-gray-200'>
Divlo
</span>
<span className='text-gray-500 dark:text-gray-200 text-xs ml-4 select-none'>
06/04/2021 - 22:28:40
</span>
</div>
<div className='text-gray-800 dark:text-gray-300 font-paragraph mt-1 break-words'>
<p>Message {index}</p>
<p> <p>
Lorem ipsum dolor sit, amet consectetur adipisicing elit. Nothing to show here! <Emoji value=':ghost:' size={20} />
Eum debitis voluptatum itaque quaerat. Nemo optio voluptas
quas mollitia rerum commodi laboriosam voluptates et sit
quo. Repudiandae eius at inventore magnam. Voluptas nisi
maxime laborum architecto fuga a consequuntur reiciendis
rerum beatae hic possimus, omnis dolorum libero, illo
dolorem assumenda. Repellat, ad!
</p> </p>
<p>Start chatting to kill this Ghost!</p>
</div>
)
}
return (
<div
id='messages'
className='w-full scrollbar-firefox-support overflow-y-auto transition-all flex-1 flex flex-col-reverse'
>
<InfiniteScroll
scrollableTarget='messages'
className='messages-list'
dataLength={messages.length}
next={nextPage}
inverse
hasMore={hasMore}
loader={<Loader />}
>
{messages.map((message) => {
return <Message key={message.id} message={message} />
})}
</InfiniteScroll>
</div> </div>
</div>
</div>
)
})}
</div>
<div className='p-6 pb-4'>
<div className='w-full h-full py-1 flex rounded-lg bg-gray-200 dark:bg-gray-800 text-gray-600 dark:text-gray-200'>
<form className='w-full h-full flex items-center'>
<TextareaAutosize
className='w-full scrollbar-firefox-support p-2 px-6 my-2 bg-transparent outline-none font-paragraph tracking-wide resize-none'
placeholder='Write a message...'
wrap='soft'
maxRows={6}
/>
</form>
<div className='h-full flex items-center justify-around pr-6'>
<button className='w-full h-full flex items-center justify-center p-1 text-2xl transition hover:-translate-y-1'>
🙂
</button>
<button className='relative w-full h-full flex items-center justify-center p-1 text-green-800 dark:text-green-400 transition hover:-translate-y-1'>
<input
type='file'
className='absolute w-full h-full opacity-0 cursor-pointer'
/>
<svg width='25' height='25' viewBox='0 0 22 22'>
<path
d='M11 0C4.925 0 0 4.925 0 11C0 17.075 4.925 22 11 22C17.075 22 22 17.075 22 11C22 4.925 17.075 0 11 0ZM12 15C12 15.2652 11.8946 15.5196 11.7071 15.7071C11.5196 15.8946 11.2652 16 11 16C10.7348 16 10.4804 15.8946 10.2929 15.7071C10.1054 15.5196 10 15.2652 10 15V12H7C6.73478 12 6.48043 11.8946 6.29289 11.7071C6.10536 11.5196 6 11.2652 6 11C6 10.7348 6.10536 10.4804 6.29289 10.2929C6.48043 10.1054 6.73478 10 7 10H10V7C10 6.73478 10.1054 6.48043 10.2929 6.29289C10.4804 6.10536 10.7348 6 11 6C11.2652 6 11.5196 6.10536 11.7071 6.29289C11.8946 6.48043 12 6.73478 12 7V10H15C15.2652 10 15.5196 10.1054 15.7071 10.2929C15.8946 10.4804 16 10.7348 16 11C16 11.2652 15.8946 11.5196 15.7071 11.7071C15.5196 11.8946 15.2652 12 15 12H12V15Z'
fill='currentColor'
/>
</svg>
</button>
</div>
</div>
</div>
</>
) )
} }

View File

@ -1,3 +1,4 @@
import useTranslation from 'next-translate/useTranslation'
import { PlusSmIcon, ArrowDownIcon } from '@heroicons/react/solid' import { PlusSmIcon, ArrowDownIcon } from '@heroicons/react/solid'
import classNames from 'classnames' import classNames from 'classnames'
import Image from 'next/image' import Image from 'next/image'
@ -10,6 +11,8 @@ export interface PopupGuildProps {
export const PopupGuild: React.FC<PopupGuildProps> = (props) => { export const PopupGuild: React.FC<PopupGuildProps> = (props) => {
const { className } = props const { className } = props
const { t } = useTranslation()
return ( return (
<div <div
className={classNames( className={classNames(
@ -21,16 +24,16 @@ export const PopupGuild: React.FC<PopupGuildProps> = (props) => {
image={ image={
<Image <Image
src='/images/svg/design/create-guild.svg' src='/images/svg/design/create-guild.svg'
alt='Create a guild' alt={t('application:create-a-guild')}
draggable='false' draggable='false'
width={230} width={230}
height={230} height={230}
/> />
} }
description='Create your own guild and manage everything within a few clicks !' description={t('application:create-a-guild-description')}
link={{ link={{
icon: <PlusSmIcon className='w-8 h-8 mr-2' />, icon: <PlusSmIcon className='w-8 h-8 mr-2' />,
text: 'Create a Guild', text: t('application:create-a-guild'),
href: '/application/guilds/create' href: '/application/guilds/create'
}} }}
/> />
@ -38,16 +41,16 @@ export const PopupGuild: React.FC<PopupGuildProps> = (props) => {
image={ image={
<Image <Image
src='/images/svg/design/join-guild.svg' src='/images/svg/design/join-guild.svg'
alt='Join a Guild' alt={t('application:join-a-guild')}
draggable='false' draggable='false'
width={200} width={200}
height={200} height={200}
/> />
} }
description='Talk, meet and have fun with new friends by joining any interesting guild !' description={t('application:join-a-guild-description')}
link={{ link={{
icon: <ArrowDownIcon className='w-6 h-6 mr-2' />, icon: <ArrowDownIcon className='w-6 h-6 mr-2' />,
text: 'Join a Guild', text: t('application:join-a-guild'),
href: '/application/guilds/join' href: '/application/guilds/join'
}} }}
/> />

View File

@ -19,7 +19,9 @@ export const PopupGuildCard: React.FC<PopupGuildCardProps> = (props) => {
{image} {image}
</div> </div>
<div className='flex justify-between flex-col h-1/2 w-full bg-gray-700 rounded-b-2xl mt-2 shadow-sm'> <div className='flex justify-between flex-col h-1/2 w-full bg-gray-700 rounded-b-2xl mt-2 shadow-sm'>
<p className='text-gray-200 mt-6 text-center px-8'>{description}</p> <p className='text-gray-200 text-sm mt-6 text-center px-8'>
{description}
</p>
<Link href={link.href}> <Link href={link.href}>
<a className='flex justify-center items-center w-4/5 h-10 rounded-2xl transition duration-200 ease-in-out text-white font-bold tracking-wide bg-green-400 self-center mb-6 hover:bg-green-600'> <a className='flex justify-center items-center w-4/5 h-10 rounded-2xl transition duration-200 ease-in-out text-white font-bold tracking-wide bg-green-400 self-center mb-6 hover:bg-green-600'>
{link.icon} {link.icon}

View File

@ -1,14 +1,15 @@
import { Meta, Story } from '@storybook/react' import { Meta, Story } from '@storybook/react'
import { Members as Component } from './Members' import { SendMessage as Component } from './SendMessage'
const Stories: Meta = { const Stories: Meta = {
title: 'Members', title: 'SendMessage',
component: Component component: Component
} }
export default Stories export default Stories
export const Members: Story = (arguments_) => { export const SendMessage: Story = (arguments_) => {
return <Component {...arguments_} /> return <Component {...arguments_} />
} }
SendMessage.args = {}

View File

@ -1,10 +1,10 @@
import { render } from '@testing-library/react' import { render } from '@testing-library/react'
import { Messages } from './' import { SendMessage } from './SendMessage'
describe('<Messages />', () => { describe('<SendMessage />', () => {
it('should render successfully', () => { it('should render successfully', () => {
const { baseElement } = render(<Messages />) const { baseElement } = render(<SendMessage />)
expect(baseElement).toBeTruthy() expect(baseElement).toBeTruthy()
}) })
}) })

View File

@ -0,0 +1,38 @@
import useTranslation from 'next-translate/useTranslation'
import TextareaAutosize from 'react-textarea-autosize'
export const SendMessage: React.FC = () => {
const { t } = useTranslation()
return (
<div className='p-6 pb-4'>
<div className='w-full h-full py-1 flex rounded-lg bg-gray-200 dark:bg-gray-800 text-gray-600 dark:text-gray-200'>
<form className='w-full h-full flex items-center'>
<TextareaAutosize
className='w-full scrollbar-firefox-support p-2 px-6 my-2 bg-transparent outline-none font-paragraph tracking-wide resize-none'
placeholder={t('application:write-a-message')}
wrap='soft'
maxRows={6}
/>
</form>
<div className='h-full flex items-center justify-around pr-6'>
<button className='w-full h-full flex items-center justify-center p-1 text-2xl transition hover:-translate-y-1'>
🙂
</button>
<button className='relative w-full h-full flex items-center justify-center p-1 text-green-800 dark:text-green-400 transition hover:-translate-y-1'>
<input
type='file'
className='absolute w-full h-full opacity-0 cursor-pointer'
/>
<svg width='25' height='25' viewBox='0 0 22 22'>
<path
d='M11 0C4.925 0 0 4.925 0 11C0 17.075 4.925 22 11 22C17.075 22 22 17.075 22 11C22 4.925 17.075 0 11 0ZM12 15C12 15.2652 11.8946 15.5196 11.7071 15.7071C11.5196 15.8946 11.2652 16 11 16C10.7348 16 10.4804 15.8946 10.2929 15.7071C10.1054 15.5196 10 15.2652 10 15V12H7C6.73478 12 6.48043 11.8946 6.29289 11.7071C6.10536 11.5196 6 11.2652 6 11C6 10.7348 6.10536 10.4804 6.29289 10.2929C6.48043 10.1054 6.73478 10 7 10H10V7C10 6.73478 10.1054 6.48043 10.2929 6.29289C10.4804 6.10536 10.7348 6 11 6C11.2652 6 11.5196 6.10536 11.7071 6.29289C11.8946 6.48043 12 6.73478 12 7V10H15C15.2652 10 15.5196 10.1054 15.7071 10.2929C15.8946 10.4804 16 10.7348 16 11C16 11.2652 15.8946 11.5196 15.7071 11.7071C15.5196 11.8946 15.2652 12 15 12H12V15Z'
fill='currentColor'
/>
</svg>
</button>
</div>
</div>
</div>
)
}

View File

@ -0,0 +1 @@
export * from './SendMessage'

View File

@ -1,5 +1,8 @@
import { Meta, Story } from '@storybook/react' import { Meta, Story } from '@storybook/react'
import { user, userSettings } from '../../../cypress/fixtures/users/user' import {
userExample,
userSettingsExample
} from '../../../cypress/fixtures/users/user'
import { UserProfile as Component, UserProfileProps } from './UserProfile' import { UserProfile as Component, UserProfileProps } from './UserProfile'
@ -15,7 +18,7 @@ export const UserProfile: Story<UserProfileProps> = (arguments_) => {
} }
UserProfile.args = { UserProfile.args = {
user: { user: {
...user, ...userExample,
settings: userSettings settings: userSettingsExample
} }
} }

View File

@ -11,11 +11,11 @@ import { Button } from '../design/Button'
import { FormState } from '../design/FormState' import { FormState } from '../design/FormState'
import { AuthenticationForm } from './' import { AuthenticationForm } from './'
import { userSchema } from '../../models/User' import { userSchema } from '../../models/User'
import { api } from 'utils/api' import { api } from 'tools/api'
import { import {
Tokens, Tokens,
Authentication as AuthenticationClass Authentication as AuthenticationClass
} from '../../utils/authentication' } from '../../tools/authentication'
import { useForm, HandleSubmitCallback } from '../../hooks/useForm' import { useForm, HandleSubmitCallback } from '../../hooks/useForm'
export interface AuthenticationProps { export interface AuthenticationProps {

View File

@ -0,0 +1,15 @@
import { Meta, Story } from '@storybook/react'
import { Emoji as Component, EmojiProps } from './Emoji'
const Stories: Meta = {
title: 'Emoji',
component: Component
}
export default Stories
export const Emoji: Story<EmojiProps> = (arguments_) => {
return <Component {...arguments_} />
}
Emoji.args = { value: ':wave:', size: 20 }

View File

@ -0,0 +1,24 @@
import { Emoji as EmojiMart } from 'emoji-mart'
import { EMOJI_SET } from './emojiPlugin'
export interface EmojiProps {
value: string
size: number
}
export const Emoji: React.FC<EmojiProps> = (props) => {
const { value, size } = props
return (
<EmojiMart
set={EMOJI_SET}
emoji={value}
size={size}
tooltip
fallback={() => {
return <>{value}</>
}}
/>
)
}

View File

@ -0,0 +1,15 @@
import { Meta, Story } from '@storybook/react'
import { EmojiPicker as Component, EmojiPickerProps } from './EmojiPicker'
const Stories: Meta = {
title: 'EmojiPicker',
component: Component
}
export default Stories
export const EmojiPicker: Story<EmojiPickerProps> = (arguments_) => {
return <Component {...arguments_} />
}
EmojiPicker.args = { onClick: (emoji, event) => console.log(emoji, event) }

View File

@ -0,0 +1,28 @@
import 'emoji-mart/css/emoji-mart.css'
import { EmojiData, Picker } from 'emoji-mart'
import { useTheme } from 'next-themes'
import { EMOJI_SET } from '../emojiPlugin'
export type EmojiPickerOnClick = (
emoji: EmojiData,
event: React.MouseEvent<HTMLElement, MouseEvent>
) => void
export interface EmojiPickerProps {
onClick?: EmojiPickerOnClick
}
export const EmojiPicker: React.FC<EmojiPickerProps> = (props) => {
const { theme } = useTheme()
return (
<Picker
set={EMOJI_SET}
theme={theme as 'light' | 'dark' | 'auto'}
onClick={props.onClick}
showPreview={false}
showSkinTones={false}
/>
)
}

View File

@ -0,0 +1 @@
export * from './EmojiPicker'

View File

@ -0,0 +1,71 @@
import { visit } from 'unist-util-visit'
import { Plugin, Transformer } from 'unified'
import { Literal, Parent } from 'unist'
import type { ElementContent } from 'hast'
import type { EmojiSet } from 'emoji-mart'
import { emojiRegex } from './isStringWithOnlyOneEmoji'
export const EMOJI_SET: EmojiSet = 'twitter'
const extractText = (
string: string,
start: number,
end: number
): ElementContent => {
return {
type: 'text',
value: string.slice(start, end)
}
}
export const emojiPlugin: Plugin<[], Literal<string>> = () => {
const transformer: Transformer<Literal<string>> = (tree) => {
visit<Literal<string>, string>(
tree,
'text',
(node, position, parent: Parent<ElementContent> | null) => {
if (typeof node.value !== 'string') {
return
}
position = position ?? 0
const definition: ElementContent[] = []
let lastIndex = 0
const match = emojiRegex.exec(node.value)
if (match != null) {
const value = match[0]
if (match.index !== lastIndex) {
definition.push(extractText(node.value, lastIndex, match.index))
}
definition.push({
type: 'element',
tagName: 'emoji',
properties: { value },
children: []
})
lastIndex = match.index + value.length
if (lastIndex !== node.value.length) {
definition.push(
extractText(node.value, lastIndex, node.value.length)
)
}
if (parent != null) {
const last = parent.children.slice(position + 1)
parent.children = parent.children.slice(0, position)
parent.children = parent.children.concat(definition)
parent.children = parent.children.concat(last)
}
}
}
)
}
return transformer
}
declare global {
namespace JSX {
interface IntrinsicElements {
emoji: { value: string }
}
}
}

View File

@ -0,0 +1,4 @@
export * from './Emoji'
export * from './EmojiPicker'
export * from './emojiPlugin'
export * from './isStringWithOnlyOneEmoji'

View File

@ -0,0 +1,18 @@
import { isStringWithOnlyOneEmoji } from './isStringWithOnlyOneEmoji'
describe('components/Emoji/isStringWithOnlyOneEmoji', () => {
it('returns true with a string with only one emoji', () => {
expect(isStringWithOnlyOneEmoji(':wave:')).toBeTruthy()
expect(isStringWithOnlyOneEmoji(':smile:')).toBeTruthy()
})
it('returns false with a string with multiple emoji or with text', () => {
expect(isStringWithOnlyOneEmoji(':wave: :smile:')).toBeFalsy()
expect(isStringWithOnlyOneEmoji(':wave: some text')).toBeFalsy()
expect(isStringWithOnlyOneEmoji('some text :wave:')).toBeFalsy()
})
it('returns false with a string without emoji', () => {
expect(isStringWithOnlyOneEmoji('some text')).toBeFalsy()
})
})

View File

@ -0,0 +1,6 @@
export const emojiRegex = /:\+1:|:-1:|:[\w-]+:/
export const isStringWithOnlyOneEmoji = (value: string): boolean => {
const result = emojiRegex.exec(value)
return result != null && result.input === result[0]
}

View File

@ -1,7 +1,7 @@
import Link from 'next/link' import Link from 'next/link'
import useTranslation from 'next-translate/useTranslation' import useTranslation from 'next-translate/useTranslation'
import { API_VERSION } from '../../utils/api' import { API_VERSION } from '../../tools/api'
import { VersionLink } from './VersionLink' import { VersionLink } from './VersionLink'
export interface FooterProps { export interface FooterProps {

View File

@ -5,15 +5,18 @@ export interface IconLinkProps {
selected?: boolean selected?: boolean
href: string href: string
title?: string title?: string
className?: string
} }
export const IconLink: React.FC<IconLinkProps> = (props) => { export const IconLink: React.FC<IconLinkProps> = (props) => {
const { children, selected, href, title } = props const { children, selected, href, title, className } = props
return ( return (
<div className='w-full flex justify-center group'>
<Link href={href}> <Link href={href}>
<a className='w-full flex justify-center relative group' title={title}> <a className='w-full flex justify-center relative group' title={title}>
<div
className={classNames('w-full flex justify-center group', className)}
>
{children} {children}
<div className='absolute flex items-center w-3 h-12 left-0'> <div className='absolute flex items-center w-3 h-12 left-0'>
<span <span
@ -25,8 +28,8 @@ export const IconLink: React.FC<IconLinkProps> = (props) => {
)} )}
></span> ></span>
</div> </div>
</div>
</a> </a>
</Link> </Link>
</div>
) )
} }

View File

@ -1,15 +1,54 @@
export interface ChannelType { import { createContext, useContext, useEffect } from 'react'
id: number
name: string import { NextPage, usePagination } from 'hooks/usePagination'
description: string import { useAuthentication } from 'tools/authentication'
createdAt: string import { Channel } from 'models/Channel'
updatedAt: string import { GuildsChannelsPath } from 'components/Application'
export interface Channels {
channels: Channel[]
hasMore: boolean
nextPage: NextPage
} }
export const channelExample: ChannelType = { const defaultChannelsContext = {} as any
id: 4, const ChannelsContext = createContext<Channels>(defaultChannelsContext)
name: 'Channel 4',
description: '', export interface ChannelsProviderProps {
createdAt: '', path: GuildsChannelsPath
updatedAt: '' }
export const ChannelsProvider: React.FC<ChannelsProviderProps> = (props) => {
const { path, children } = props
const { authentication } = useAuthentication()
const {
items: channels,
hasMore,
nextPage,
resetPagination
} = usePagination<Channel>({
api: authentication.api,
url: `/guilds/${path.guildId}/channels`
})
useEffect(() => {
resetPagination()
nextPage()
}, [nextPage, resetPagination])
return (
<ChannelsContext.Provider value={{ channels, hasMore, nextPage }}>
{children}
</ChannelsContext.Provider>
)
}
export const useChannels = (): Channels => {
const channels = useContext(ChannelsContext)
if (channels === defaultChannelsContext) {
throw new Error('useChannels must be used within ChannelsProvider')
}
return channels
} }

51
contexts/GuildMember.tsx Normal file
View File

@ -0,0 +1,51 @@
import { createContext, useContext, useEffect, useState } from 'react'
import { Guild } from 'models/Guild'
import { Member } from 'models/Member'
import { GuildsChannelsPath } from 'components/Application'
import { useAuthentication } from 'tools/authentication'
export interface GuildMember {
guild: Guild
member: Member
}
export interface GuildMemberProps {
guildMember: GuildMember
path: GuildsChannelsPath
}
const defaultGuildMemberContext = {} as any
const GuildMemberContext = createContext<GuildMember>(defaultGuildMemberContext)
export const GuildMemberProvider: React.FC<GuildMemberProps> = (props) => {
const [guildMember, setGuildMember] = useState(props.guildMember)
const { authentication } = useAuthentication()
useEffect(() => {
const fetchGuildMember = async (): Promise<void> => {
const { data } = await authentication.api.get(
`/guilds/${props.path.guildId}`
)
setGuildMember(data)
}
fetchGuildMember().catch((error) => {
console.error(error)
})
}, [props.path, authentication.api])
return (
<GuildMemberContext.Provider value={guildMember}>
{props.children}
</GuildMemberContext.Provider>
)
}
export const useGuildMember = (): GuildMember => {
const guildMember = useContext(GuildMemberContext)
if (guildMember === defaultGuildMemberContext) {
throw new Error('useGuildMember must be used within GuildMemberProvider')
}
return guildMember
}

49
contexts/Guilds.tsx Normal file
View File

@ -0,0 +1,49 @@
import { createContext, useContext, useEffect } from 'react'
import { NextPage, usePagination } from 'hooks/usePagination'
import { useAuthentication } from 'tools/authentication'
import { GuildWithDefaultChannelId } from 'models/Guild'
export interface Guilds {
guilds: GuildWithDefaultChannelId[]
hasMore: boolean
nextPage: NextPage
}
const defaultGuildsContext = {} as any
const GuildsContext = createContext<Guilds>(defaultGuildsContext)
export const GuildsProvider: React.FC = (props) => {
const { children } = props
const { authentication } = useAuthentication()
const {
items: guilds,
hasMore,
nextPage,
resetPagination
} = usePagination<GuildWithDefaultChannelId>({
api: authentication.api,
url: '/guilds'
})
useEffect(() => {
resetPagination()
nextPage()
}, [nextPage, resetPagination])
return (
<GuildsContext.Provider value={{ guilds, hasMore, nextPage }}>
{children}
</GuildsContext.Provider>
)
}
export const useGuilds = (): Guilds => {
const guilds = useContext(GuildsContext)
if (guilds === defaultGuildsContext) {
throw new Error('useGuilds must be used within GuildsProvider')
}
return guilds
}

54
contexts/Members.tsx Normal file
View File

@ -0,0 +1,54 @@
import { createContext, useContext, useEffect } from 'react'
import { NextPage, usePagination } from 'hooks/usePagination'
import { useAuthentication } from 'tools/authentication'
import { MemberWithPublicUser } from 'models/Member'
import { GuildsChannelsPath } from 'components/Application'
export interface Members {
members: MemberWithPublicUser[]
hasMore: boolean
nextPage: NextPage
}
const defaultMembersContext = {} as any
const MembersContext = createContext<Members>(defaultMembersContext)
export interface MembersProviderProps {
path: GuildsChannelsPath
}
export const MembersProviders: React.FC<MembersProviderProps> = (props) => {
const { children, path } = props
const { authentication } = useAuthentication()
const {
items: members,
hasMore,
nextPage,
resetPagination
} = usePagination<MemberWithPublicUser>({
api: authentication.api,
url: `/guilds/${path.guildId}/members`
})
useEffect(() => {
resetPagination()
nextPage()
}, [nextPage, resetPagination])
return (
<MembersContext.Provider value={{ members, hasMore, nextPage }}>
{children}
</MembersContext.Provider>
)
}
export const useMembers = (): Members => {
const members = useContext(MembersContext)
if (members === defaultMembersContext) {
throw new Error('useMembers must be used within MembersProvider')
}
return members
}

60
contexts/Messages.tsx Normal file
View File

@ -0,0 +1,60 @@
import { createContext, useContext, useEffect } from 'react'
import { NextPage, usePagination } from 'hooks/usePagination'
import { useAuthentication } from 'tools/authentication'
import { MessageWithMember } from 'models/Message'
import { GuildsChannelsPath } from 'components/Application'
export interface Messages {
messages: MessageWithMember[]
hasMore: boolean
nextPage: NextPage
}
const defaultMessagesContext = {} as any
const MessagesContext = createContext<Messages>(defaultMessagesContext)
export interface MessagesProviderProps {
path: GuildsChannelsPath
}
export const MessagesProvider: React.FC<MessagesProviderProps> = (props) => {
const { path, children } = props
const { authentication } = useAuthentication()
const {
items: messages,
hasMore,
nextPage,
resetPagination
} = usePagination<MessageWithMember>({
api: authentication.api,
url: `/channels/${path.channelId}/messages`,
inverse: true
})
useEffect(() => {
resetPagination()
nextPage(undefined, () => {
const messagesDiv = window.document.getElementById(
'messages'
) as HTMLDivElement
messagesDiv.scrollTo(0, messagesDiv.scrollHeight)
})
}, [nextPage, resetPagination])
return (
<MessagesContext.Provider value={{ messages, hasMore, nextPage }}>
{children}
</MessagesContext.Provider>
)
}
export const useMessages = (): Messages => {
const messages = useContext(MessagesContext)
if (messages === defaultMessagesContext) {
throw new Error('useMessages must be used within a MessagesProvider')
}
return messages
}

View File

@ -0,0 +1,14 @@
import { Handler } from '../../handler'
import { channelExample } from '../channel'
export const getChannelWithChannelIdHandler: Handler = {
method: 'GET',
url: `/channels/${channelExample.id}`,
response: {
statusCode: 200,
body: {
channel: channelExample
}
}
}

View File

@ -0,0 +1,15 @@
import { Handler } from '../../../handler'
import {
messageExampleComplete,
messageExampleComplete2
} from '../../../messages/message'
import { channelExample } from '../../channel'
export const getMessagesWithChannelIdHandler: Handler = {
method: 'GET',
url: `/channels/${channelExample.id}/messages`,
response: {
statusCode: 200,
body: [messageExampleComplete, messageExampleComplete2]
}
}

View File

@ -1,9 +1,15 @@
import { guild } from '../guilds/guild' import { guildExample } from '../guilds/guild'
export const channel = { export const channelExample = {
id: 1, id: 1,
name: 'general', name: 'general',
guildId: guild.id, guildId: guildExample.id,
createdAt: new Date().toISOString(), createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString() updatedAt: new Date().toISOString()
} }
export const channelExample2 = {
...channelExample,
id: 2,
name: 'general2'
}

View File

@ -0,0 +1,12 @@
import { guildExample } from '../../guild'
import { Handler } from '../../../handler'
import { channelExample, channelExample2 } from '../../../channels/channel'
export const getChannelsWithGuildIdHandler: Handler = {
method: 'GET',
url: `/guilds/${guildExample.id}/channels`,
response: {
statusCode: 200,
body: [channelExample, channelExample2]
}
}

View File

@ -0,0 +1,16 @@
import { Handler } from '../../handler'
import { guildExample } from '../guild'
import { memberExampleComplete } from '../../members/member'
export const getGuildMemberWithGuildIdHandler: Handler = {
method: 'GET',
url: `/guilds/${guildExample.id}`,
response: {
statusCode: 200,
body: {
guild: guildExample,
member: memberExampleComplete
}
}
}

View File

@ -0,0 +1,12 @@
import { guildExample } from '../../guild'
import { Handler } from '../../../handler'
import { memberExampleComplete } from '../../../members/member'
export const getMembersWithGuildIdHandler: Handler = {
method: 'GET',
url: `/guilds/${guildExample.id}/members`,
response: {
statusCode: 200,
body: [memberExampleComplete]
}
}

View File

@ -0,0 +1,15 @@
import { Handler } from '../handler'
import { guildExample, guildExample2 } from './guild'
export const getGuildsHandler: Handler = {
method: 'GET',
url: '/guilds',
response: {
statusCode: 200,
body: [
{ ...guildExample, defaultChannelId: 1 },
{ ...guildExample2, defaultChannelId: 2 }
]
}
}

View File

@ -1,4 +1,6 @@
export const guild = { import { Guild } from '../../../models/Guild'
export const guildExample: Guild = {
id: 1, id: 1,
name: 'GuildExample', name: 'GuildExample',
description: 'guild example.', description: 'guild example.',
@ -7,7 +9,8 @@ export const guild = {
updatedAt: new Date().toISOString() updatedAt: new Date().toISOString()
} }
export const guild2 = { export const guildExample2: Guild = {
...guild, ...guildExample,
id: 2,
name: 'app' name: 'app'
} }

View File

@ -1,8 +1,8 @@
import { Handler } from '../handler' import { Handler } from '../handler'
import { guild } from './guild' import { guildExample } from './guild'
import { channel } from '../channels/channel' import { channelExample } from '../channels/channel'
import { memberComplete } from '../members/member' import { memberExampleComplete } from '../members/member'
export const postGuildsHandler: Handler = { export const postGuildsHandler: Handler = {
method: 'POST', method: 'POST',
@ -11,9 +11,9 @@ export const postGuildsHandler: Handler = {
statusCode: 201, statusCode: 201,
body: { body: {
guild: { guild: {
...guild, ...guildExample,
channels: [channel], channels: [channelExample],
members: [memberComplete] members: [memberExampleComplete]
} }
} }
} }

View File

@ -1,6 +1,6 @@
import { Handler } from '../../handler' import { Handler } from '../../handler'
import { guild, guild2 } from '../guild' import { guildExample, guildExample2 } from '../guild'
export const getGuildsPublicEmptyHandler: Handler = { export const getGuildsPublicEmptyHandler: Handler = {
method: 'GET', method: 'GET',
@ -17,8 +17,8 @@ export const getGuildsPublicHandler: Handler = {
response: { response: {
statusCode: 200, statusCode: 200,
body: [ body: [
{ ...guild, membersCount: 1 }, { ...guildExample, membersCount: 1 },
{ ...guild2, membersCount: 1 } { ...guildExample2, membersCount: 1 }
] ]
} }
} }
@ -28,6 +28,6 @@ export const getGuildsPublicSearchHandler: Handler = {
url: '/guilds/public', url: '/guilds/public',
response: { response: {
statusCode: 200, statusCode: 200,
body: [{ ...guild2, membersCount: 1 }] body: [{ ...guildExample2, membersCount: 1 }]
} }
} }

View File

@ -3,7 +3,7 @@ import { postUsersRefreshTokenHandler } from './users/refresh-token/post'
export interface Handler { export interface Handler {
method: 'GET' | 'POST' | 'PUT' | 'DELETE' method: 'GET' | 'POST' | 'PUT' | 'DELETE'
url: string url: `/${string}`
response: { response: {
body: any body: any
statusCode: number statusCode: number

View File

@ -1,16 +1,16 @@
import { guild } from '../guilds/guild' import { guildExample } from '../guilds/guild'
import { user } from '../users/user' import { userExample } from '../users/user'
export const member = { export const memberExample = {
id: 1, id: 1,
isOwner: true, isOwner: true,
userId: user.id, userId: userExample.id,
guildId: guild.id, guildId: guildExample.id,
createdAt: new Date().toISOString(), createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString() updatedAt: new Date().toISOString()
} }
export const memberComplete = { export const memberExampleComplete = {
...member, ...memberExample,
user user: userExample
} }

View File

@ -0,0 +1,25 @@
import { channelExample } from '../channels/channel'
import { memberExampleComplete } from '../members/member'
export const messageExample = {
id: 1,
value: 'Hello, world!',
type: 'text' as 'text' | 'file',
mimetype: 'text/plain',
memberId: memberExampleComplete.id,
channelId: channelExample.id,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString()
}
export const messageExampleComplete = {
...messageExample,
member: memberExampleComplete
}
export const messageExampleComplete2 = {
...messageExample,
id: 2,
value: 'Second message',
member: memberExampleComplete
}

View File

@ -1,6 +1,6 @@
import { Handler } from '../../handler' import { Handler } from '../../handler'
import { user, userSettings } from '../user' import { userExample, userSettingsExample } from '../user'
export const getUsersCurrentHandler: Handler = { export const getUsersCurrentHandler: Handler = {
method: 'GET', method: 'GET',
@ -9,8 +9,8 @@ export const getUsersCurrentHandler: Handler = {
statusCode: 200, statusCode: 200,
body: { body: {
user: { user: {
...user, ...userExample,
settings: userSettings, settings: userSettingsExample,
currentStrategy: 'local', currentStrategy: 'local',
strategies: ['local'] strategies: ['local']
} }

View File

@ -1,6 +1,6 @@
import { Handler } from '../../handler' import { Handler } from '../../handler'
import { user, userSettings } from '../user' import { userExample, userSettingsExample } from '../user'
export const postUsersSignupHandler: Handler = { export const postUsersSignupHandler: Handler = {
method: 'POST', method: 'POST',
@ -9,8 +9,8 @@ export const postUsersSignupHandler: Handler = {
statusCode: 201, statusCode: 201,
body: { body: {
user: { user: {
...user, ...userExample,
settings: userSettings settings: userSettingsExample
} }
} }
} }

View File

@ -1,7 +1,7 @@
import { UserSettings } from '../../../models/UserSettings' import { UserSettings } from '../../../models/UserSettings'
import { User } from '../../../models/User' import { User } from '../../../models/User'
export const user: User = { export const userExample: User = {
id: 1, id: 1,
name: 'Divlo', name: 'Divlo',
email: 'contact@divlo.fr', email: 'contact@divlo.fr',
@ -17,7 +17,7 @@ export const user: User = {
updatedAt: '2021-10-20T20:59:08.485Z' updatedAt: '2021-10-20T20:59:08.485Z'
} }
export const userSettings: UserSettings = { export const userSettingsExample: UserSettings = {
id: 1, id: 1,
language: 'en', language: 'en',
theme: 'dark', theme: 'dark',

View File

@ -0,0 +1,43 @@
import { channelExample } from '../../../fixtures/channels/channel'
import { guildExample } from '../../../fixtures/guilds/guild'
import { userExample } from '../../../fixtures/users/user'
import { getGuildsHandler } from '../../../fixtures/guilds/get'
import { authenticationHandlers } from '../../../fixtures/handler'
import { getGuildMemberWithGuildIdHandler } from '../../../fixtures/guilds/[guildId]/get'
import { getChannelWithChannelIdHandler } from '../../../fixtures/channels/[channelId]/get'
const applicationPaths = [
'/application',
`/application/users/${userExample.id}`,
'/application/guilds/create',
'/application/guilds/join',
`/application/${guildExample.id}/${channelExample.id}`
]
describe('Common > application/authentication', () => {
beforeEach(() => {
cy.task('stopMockServer')
})
it('should redirect the user to `/authentication/signin` if not signed in', () => {
for (const applicationPath of applicationPaths) {
cy.visit(applicationPath)
.location('pathname')
.should('eq', '/authentication/signin')
}
})
it('should not redirect the user if signed in', () => {
cy.task('startMockServer', [
...authenticationHandlers,
getGuildsHandler,
getGuildMemberWithGuildIdHandler,
getChannelWithChannelIdHandler
]).setCookie('refreshToken', 'refresh-token')
for (const applicationPath of applicationPaths) {
cy.visit(applicationPath)
.location('pathname')
.should('eq', applicationPath)
}
})
})

View File

@ -1,10 +1,143 @@
import date from 'date-and-time'
import {
channelExample,
channelExample2
} from '../../../../fixtures/channels/channel'
import { guildExample } from '../../../../fixtures/guilds/guild'
import { getGuildMemberWithGuildIdHandler } from '../../../../fixtures/guilds/[guildId]/get'
import { getChannelWithChannelIdHandler } from '../../../../fixtures/channels/[channelId]/get'
import { authenticationHandlers } from '../../../../fixtures/handler' import { authenticationHandlers } from '../../../../fixtures/handler'
import { getGuildsHandler } from '../../../../fixtures/guilds/get'
import { getChannelsWithGuildIdHandler } from '../../../../fixtures/guilds/[guildId]/channels/get'
import { getMessagesWithChannelIdHandler } from '../../../../fixtures/channels/[channelId]/messages/get'
import {
messageExampleComplete,
messageExampleComplete2
} from '../../../../fixtures/messages/message'
import { getMembersWithGuildIdHandler } from '../../../../fixtures/guilds/[guildId]/members/get'
import { memberExampleComplete } from '../../../../fixtures/members/member'
import { API_URL } from '../../../../../tools/api'
describe('Pages > /application/[guildId]/[channelId]', () => { describe('Pages > /application/[guildId]/[channelId]', () => {
beforeEach(() => { beforeEach(() => {
cy.task('stopMockServer') cy.task('stopMockServer')
}) })
it('should succeeds and display the guilds in left sidebar correctly', () => {
cy.task('startMockServer', [
...authenticationHandlers,
getGuildMemberWithGuildIdHandler,
getChannelWithChannelIdHandler,
getGuildsHandler
]).setCookie('refreshToken', 'refresh-token')
cy.intercept(`${API_URL}${getGuildsHandler.url}*`).as('getGuildsHandler')
cy.intercept(`/_next/*`).as('nextStaticAndImages')
cy.visit(`/application/${guildExample.id}/${channelExample.id}`)
cy.wait(['@getGuildsHandler', '@nextStaticAndImages']).then(() => {
cy.get('[data-cy=application-title]').should(
'have.text',
`# ${channelExample.name}`
)
cy.get('[data-cy=guild-left-sidebar-title]').should(
'have.text',
guildExample.name
)
cy.get('.guilds-list').children().should('have.length', 2)
})
})
it('should succeeds and display the channels in left sidebar correctly', () => {
cy.task('startMockServer', [
...authenticationHandlers,
getGuildMemberWithGuildIdHandler,
getChannelWithChannelIdHandler,
getChannelsWithGuildIdHandler
]).setCookie('refreshToken', 'refresh-token')
cy.intercept(`${API_URL}${getChannelsWithGuildIdHandler.url}*`).as(
'getChannelsWithGuildIdHandler'
)
cy.intercept(`/_next/*`).as('nextStaticAndImages')
cy.visit(`/application/${guildExample.id}/${channelExample.id}`)
cy.wait(['@getChannelsWithGuildIdHandler', '@nextStaticAndImages']).then(
() => {
cy.get('.channels-list').children().should('have.length', 2)
cy.get('.channels-list [data-cy=channel-name]:first').should(
'have.text',
`# ${channelExample.name}`
)
cy.get('.channels-list [data-cy=channel-name]:last').should(
'have.text',
`# ${channelExample2.name}`
)
}
)
})
it('should succeeds and display the messages correctly', () => {
cy.task('startMockServer', [
...authenticationHandlers,
getGuildMemberWithGuildIdHandler,
getChannelWithChannelIdHandler,
getMessagesWithChannelIdHandler
]).setCookie('refreshToken', 'refresh-token')
cy.intercept(`${API_URL}${getMessagesWithChannelIdHandler.url}*`).as(
'getMessagesWithChannelIdHandler'
)
cy.intercept(`/_next/*`).as('nextStaticAndImages')
cy.visit(`/application/${guildExample.id}/${channelExample.id}`)
cy.wait(['@getMessagesWithChannelIdHandler', '@nextStaticAndImages']).then(
() => {
cy.get('.messages-list').children().should('have.length', 2)
cy.get('.messages-list p:first').should(
'have.text',
messageExampleComplete.value
)
cy.get(
'.messages-list [data-cy=message-member-user-name]:first'
).should('have.text', messageExampleComplete.member.user.name)
cy.get('.messages-list [data-cy=message-date]:first').should(
'have.text',
date.format(
new Date(messageExampleComplete.createdAt),
'DD/MM/YYYY - HH:mm:ss'
)
)
cy.get('.messages-list p:last').should(
'have.text',
messageExampleComplete2.value
)
}
)
})
it('should succeeds and display the members in right sidebar correctly', () => {
cy.task('startMockServer', [
...authenticationHandlers,
getGuildMemberWithGuildIdHandler,
getChannelWithChannelIdHandler,
getMembersWithGuildIdHandler
]).setCookie('refreshToken', 'refresh-token')
cy.intercept(`${API_URL}${getMembersWithGuildIdHandler.url}*`).as(
'getMembersWithGuildIdHandler'
)
cy.intercept(`/_next/*`).as('nextStaticAndImages')
cy.visit(`/application/${guildExample.id}/${channelExample.id}`)
cy.wait(['@getMembersWithGuildIdHandler', '@nextStaticAndImages']).then(
() => {
cy.get('.members-list').should('not.be.visible')
cy.get('[data-cy=icon-button-right-sidebar-members]').click()
cy.get('.members-list').should('be.visible')
cy.get('[data-cy=members-title]').should('have.text', 'Member(s)')
cy.get('.members-list').children().should('have.length', 1)
cy.get('.members-list [data-cy=member-user-name]:first').should(
'have.text',
memberExampleComplete.user.name
)
}
)
})
it('should redirect the user to `/application` if `guildId` or `channelId` are not numbers', () => { it('should redirect the user to `/application` if `guildId` or `channelId` are not numbers', () => {
cy.task('startMockServer', authenticationHandlers).setCookie( cy.task('startMockServer', authenticationHandlers).setCookie(
'refreshToken', 'refreshToken',
@ -14,4 +147,26 @@ describe('Pages > /application/[guildId]/[channelId]', () => {
.location('pathname') .location('pathname')
.should('eq', '/application') .should('eq', '/application')
}) })
it("should redirect the user to `/404` if `guildId` doesn't exist", () => {
cy.task('startMockServer', [
...authenticationHandlers,
getChannelWithChannelIdHandler
]).setCookie('refreshToken', 'refresh-token')
cy.visit(`/application/123/${channelExample.id}`, {
failOnStatusCode: false
})
.location('pathname')
.should('eq', '/404')
})
it("should redirect the user to `/404` if `channelId` doesn't exist", () => {
cy.task('startMockServer', [
...authenticationHandlers,
getGuildMemberWithGuildIdHandler
]).setCookie('refreshToken', 'refresh-token')
cy.visit(`/application/${guildExample.id}/123`, { failOnStatusCode: false })
.location('pathname')
.should('eq', '/404')
})
}) })

View File

@ -1,5 +1,8 @@
import { channel } from '../../../../fixtures/channels/channel' import { getChannelWithChannelIdHandler } from '../../../../fixtures/channels/[channelId]/get'
import { guild } from '../../../../fixtures/guilds/guild' import { getGuildsHandler } from '../../../../fixtures/guilds/get'
import { getGuildMemberWithGuildIdHandler } from '../../../../fixtures/guilds/[guildId]/get'
import { channelExample } from '../../../../fixtures/channels/channel'
import { guildExample } from '../../../../fixtures/guilds/guild'
import { postGuildsHandler } from '../../../../fixtures/guilds/post' import { postGuildsHandler } from '../../../../fixtures/guilds/post'
import { authenticationHandlers } from '../../../../fixtures/handler' import { authenticationHandlers } from '../../../../fixtures/handler'
@ -11,15 +14,19 @@ describe('Pages > /application/guilds/create', () => {
it('should succeeds and create the guild', () => { it('should succeeds and create the guild', () => {
cy.task('startMockServer', [ cy.task('startMockServer', [
...authenticationHandlers, ...authenticationHandlers,
postGuildsHandler postGuildsHandler,
getGuildsHandler,
getGuildMemberWithGuildIdHandler,
getChannelWithChannelIdHandler
]).setCookie('refreshToken', 'refresh-token') ]).setCookie('refreshToken', 'refresh-token')
cy.visit('/application/guilds/create') cy.visit('/application/guilds/create')
cy.get('[data-cy=application-title]').should('have.text', 'Create a Guild')
cy.get('#error-name').should('not.exist') cy.get('#error-name').should('not.exist')
cy.get('[data-cy=input-name]').type(guild.name) cy.get('[data-cy=input-name]').type(guildExample.name)
cy.get('[data-cy=submit]').click() cy.get('[data-cy=submit]').click()
cy.location('pathname').should( cy.location('pathname').should(
'eq', 'eq',
`/application/${guild.id}/${channel.id}` `/application/${guildExample.id}/${channelExample.id}`
) )
}) })
@ -30,7 +37,7 @@ describe('Pages > /application/guilds/create', () => {
) )
cy.visit('/application/guilds/create') cy.visit('/application/guilds/create')
cy.get('#error-name').should('not.exist') cy.get('#error-name').should('not.exist')
cy.get('[data-cy=input-name]').type(guild.name) cy.get('[data-cy=input-name]').type(guildExample.name)
cy.get('[data-cy=submit]').click() cy.get('[data-cy=submit]').click()
cy.get('#message').should('have.text', 'Error: Internal Server Error.') cy.get('#message').should('have.text', 'Error: Internal Server Error.')
}) })

View File

@ -1,45 +1,39 @@
import { guildExample, guildExample2 } from '../../../../fixtures/guilds/guild'
import { import {
getGuildsPublicEmptyHandler, getGuildsPublicEmptyHandler,
getGuildsPublicHandler, getGuildsPublicHandler,
getGuildsPublicSearchHandler getGuildsPublicSearchHandler
} from '../../../../fixtures/guilds/public/get' } from '../../../../fixtures/guilds/public/get'
import { authenticationHandlers } from '../../../../fixtures/handler' import { authenticationHandlers } from '../../../../fixtures/handler'
import { API_URL } from '../../../../../tools/api'
describe('Pages > /application/guilds/join', () => { describe('Pages > /application/guilds/join', () => {
beforeEach(() => { beforeEach(() => {
cy.task('stopMockServer') cy.task('stopMockServer')
}) })
it('should shows no guild if there are no public guilds', () => {
cy.task('startMockServer', [
...authenticationHandlers,
getGuildsPublicEmptyHandler
]).setCookie('refreshToken', 'refresh-token')
cy.visit('/application/guilds/join')
cy.get('.guilds-list').children().should('have.length', 0)
})
it('should shows loader with internal api server error', () => {
cy.task('startMockServer', [...authenticationHandlers]).setCookie(
'refreshToken',
'refresh-token'
)
cy.visit('/application/guilds/join')
cy.get('.guilds-list').children().should('have.length', 1)
cy.get('[data-testid=progress-spinner]').should('be.visible')
})
it('should shows all the guilds', () => { it('should shows all the guilds', () => {
cy.task('startMockServer', [ cy.task('startMockServer', [
...authenticationHandlers, ...authenticationHandlers,
getGuildsPublicHandler getGuildsPublicHandler
]).setCookie('refreshToken', 'refresh-token') ]).setCookie('refreshToken', 'refresh-token')
cy.visit('/application/guilds/join') cy.intercept(`${API_URL}${getGuildsPublicHandler.url}*`).as(
cy.get('.guilds-list').children().should('have.length', 2) 'getGuildsPublicHandler'
cy.get('.guilds-list [data-cy=guild-name]:first').should(
'have.text',
'GuildExample'
) )
cy.intercept(`/_next/*`).as('nextStaticAndImages')
cy.visit('/application/guilds/join')
cy.wait(['@getGuildsPublicHandler', '@nextStaticAndImages']).then(() => {
cy.get('[data-cy=application-title]').should('have.text', 'Join a Guild')
cy.get('.guilds-public-list').children().should('have.length', 2)
cy.get('.guilds-public-list [data-cy=guild-name]:first').should(
'have.text',
guildExample.name
)
cy.get('.guilds-public-list [data-cy=guild-name]:last').should(
'have.text',
guildExample2.name
)
})
}) })
it('should shows the searched guild', () => { it('should shows the searched guild', () => {
@ -48,8 +42,41 @@ describe('Pages > /application/guilds/join', () => {
getGuildsPublicSearchHandler getGuildsPublicSearchHandler
]).setCookie('refreshToken', 'refresh-token') ]).setCookie('refreshToken', 'refresh-token')
cy.visit('/application/guilds/join') cy.visit('/application/guilds/join')
cy.get('[data-cy=search-guild-input]').type('app') cy.intercept(`${API_URL}${getGuildsPublicHandler.url}*`).as(
cy.get('.guilds-list').children().should('have.length', 1) 'getGuildsPublicHandler'
cy.get('.guilds-list [data-cy=guild-name]:first').should('have.text', 'app') )
cy.intercept(`/_next/*`).as('nextStaticAndImages')
cy.wait(['@getGuildsPublicHandler', '@nextStaticAndImages']).then(() => {
cy.get('[data-cy=search-guild-input]').type(guildExample2.name)
cy.get('.guilds-public-list').children().should('have.length', 1)
cy.get('.guilds-public-list [data-cy=guild-name]:first').should(
'have.text',
guildExample2.name
)
})
})
it('should shows no guild if there are no public guilds', () => {
cy.task('startMockServer', [
...authenticationHandlers,
getGuildsPublicEmptyHandler
]).setCookie('refreshToken', 'refresh-token')
cy.intercept(`${API_URL}${getGuildsPublicEmptyHandler.url}*`).as(
'getGuildsPublicEmptyHandler'
)
cy.visit('/application/guilds/join')
cy.wait('@getGuildsPublicEmptyHandler').then(() => {
cy.get('.guilds-public-list').children().should('have.length', 0)
})
})
it('should shows loader with internal api server error', () => {
cy.task('startMockServer', [...authenticationHandlers]).setCookie(
'refreshToken',
'refresh-token'
)
cy.visit('/application/guilds/join')
cy.get('.guilds-public-list').children().should('have.length', 1)
cy.get('[data-testid=progress-spinner]').should('be.visible')
}) })
}) })

View File

@ -1,35 +1,32 @@
import { authenticationHandlers } from '../../../fixtures/handler' import { authenticationHandlers } from '../../../fixtures/handler'
const applicationPaths = [
'/application',
'/application/users/0',
'/application/guilds/create',
'/application/guilds/join',
'/application/0/0'
]
describe('Pages > /application', () => { describe('Pages > /application', () => {
beforeEach(() => { beforeEach(() => {
cy.task('stopMockServer') cy.task('stopMockServer')
}) })
it('should redirect the user to `/authentication/signin` if not signed in', () => { it('should redirect user to `/application/guilds/create` on click on "Create a Guild"', () => {
for (const applicationPath of applicationPaths) { cy.task('startMockServer', [...authenticationHandlers]).setCookie(
cy.visit(applicationPath)
.location('pathname')
.should('eq', '/authentication/signin')
}
})
it('should not redirect the user if signed in', () => {
cy.task('startMockServer', authenticationHandlers).setCookie(
'refreshToken', 'refreshToken',
'refresh-token' 'refresh-token'
) )
for (const applicationPath of applicationPaths) { cy.visit('/application')
cy.visit(applicationPath) cy.get('[data-cy=application-title]').should('have.text', 'Application')
cy.get('a[href="/application/guilds/create"]')
.click()
.location('pathname') .location('pathname')
.should('eq', applicationPath) .should('eq', '/application/guilds/create')
} })
it('should redirect user to `/application/guilds/join` on click on "Join a Guild"', () => {
cy.task('startMockServer', [...authenticationHandlers]).setCookie(
'refreshToken',
'refresh-token'
)
cy.visit('/application')
cy.get('a[href="/application/guilds/join"]')
.click()
.location('pathname')
.should('eq', '/application/guilds/join')
}) })
}) })

View File

@ -1,5 +1,5 @@
import { postUsersResetPasswordHandler } from '../../../fixtures/users/reset-password/post' import { postUsersResetPasswordHandler } from '../../../fixtures/users/reset-password/post'
import { user } from '../../../fixtures/users/user' import { userExample } from '../../../fixtures/users/user'
describe('Pages > /authentication/forgot-password', () => { describe('Pages > /authentication/forgot-password', () => {
beforeEach(() => { beforeEach(() => {
@ -10,7 +10,7 @@ describe('Pages > /authentication/forgot-password', () => {
it('should succeeds and sends a password-reset request', () => { it('should succeeds and sends a password-reset request', () => {
cy.task('startMockServer', [postUsersResetPasswordHandler]) cy.task('startMockServer', [postUsersResetPasswordHandler])
cy.get('#message').should('not.exist') cy.get('#message').should('not.exist')
cy.get('[data-cy=input-email]').type(user.email) cy.get('[data-cy=input-email]').type(userExample.email)
cy.get('[data-cy=submit]').click() cy.get('[data-cy=submit]').click()
cy.get('#message').should( cy.get('#message').should(
'have.text', 'have.text',
@ -20,7 +20,7 @@ describe('Pages > /authentication/forgot-password', () => {
it('should fails with unreachable api server', () => { it('should fails with unreachable api server', () => {
cy.get('#message').should('not.exist') cy.get('#message').should('not.exist')
cy.get('[data-cy=input-email]').type(user.email) cy.get('[data-cy=input-email]').type(userExample.email)
cy.get('[data-cy=submit]').click() cy.get('[data-cy=submit]').click()
cy.get('#message').should('have.text', 'Error: Internal Server Error.') cy.get('#message').should('have.text', 'Error: Internal Server Error.')
}) })

View File

@ -3,7 +3,7 @@ import {
postUsersSigninHandler, postUsersSigninHandler,
postUsersSigninInvalidCredentialsHandler postUsersSigninInvalidCredentialsHandler
} from 'cypress/fixtures/users/signin/post' } from 'cypress/fixtures/users/signin/post'
import { user } from '../../../fixtures/users/user' import { userExample } from '../../../fixtures/users/user'
describe('Pages > /authentication/signin', () => { describe('Pages > /authentication/signin', () => {
beforeEach(() => { beforeEach(() => {
@ -18,7 +18,7 @@ describe('Pages > /authentication/signin', () => {
]) ])
cy.get('#error-email').should('not.exist') cy.get('#error-email').should('not.exist')
cy.get('#error-password').should('not.exist') cy.get('#error-password').should('not.exist')
cy.get('[data-cy=input-email]').type(user.email) cy.get('[data-cy=input-email]').type(userExample.email)
cy.get('[data-cy=input-password]').type('randompassword') cy.get('[data-cy=input-password]').type('randompassword')
cy.get('[data-cy=submit]').click() cy.get('[data-cy=submit]').click()
cy.location('pathname').should('eq', '/application') cy.location('pathname').should('eq', '/application')
@ -27,7 +27,7 @@ describe('Pages > /authentication/signin', () => {
it('should fails with unreachable api server', () => { it('should fails with unreachable api server', () => {
cy.get('#error-email').should('not.exist') cy.get('#error-email').should('not.exist')
cy.get('#error-password').should('not.exist') cy.get('#error-password').should('not.exist')
cy.get('[data-cy=input-email]').type(user.email) cy.get('[data-cy=input-email]').type(userExample.email)
cy.get('[data-cy=input-password]').type('randompassword') cy.get('[data-cy=input-password]').type('randompassword')
cy.get('[data-cy=submit]').click() cy.get('[data-cy=submit]').click()
cy.get('#message').should('have.text', 'Error: Internal Server Error.') cy.get('#message').should('have.text', 'Error: Internal Server Error.')
@ -42,7 +42,7 @@ describe('Pages > /authentication/signin', () => {
]) ])
cy.get('#error-email').should('not.exist') cy.get('#error-email').should('not.exist')
cy.get('#error-password').should('not.exist') cy.get('#error-password').should('not.exist')
cy.get('[data-cy=input-email]').type(user.email) cy.get('[data-cy=input-email]').type(userExample.email)
cy.get('[data-cy=input-password]').type('randompassword') cy.get('[data-cy=input-password]').type('randompassword')
cy.get('[data-cy=submit]').click() cy.get('[data-cy=submit]').click()
cy.get('#message').should( cy.get('#message').should(

View File

@ -1,4 +1,4 @@
import { user } from '../../../fixtures/users/user' import { userExample } from '../../../fixtures/users/user'
import { import {
postUsersSignupHandler, postUsersSignupHandler,
postUsersSignupAlreadyUsedHandler postUsersSignupAlreadyUsedHandler
@ -15,8 +15,8 @@ describe('Pages > /authentication/signup', () => {
cy.get('#error-name').should('not.exist') cy.get('#error-name').should('not.exist')
cy.get('#error-email').should('not.exist') cy.get('#error-email').should('not.exist')
cy.get('#error-password').should('not.exist') cy.get('#error-password').should('not.exist')
cy.get('[data-cy=input-name]').type(user.name) cy.get('[data-cy=input-name]').type(userExample.name)
cy.get('[data-cy=input-email]').type(user.email) cy.get('[data-cy=input-email]').type(userExample.email)
cy.get('[data-cy=input-password]').type('randompassword') cy.get('[data-cy=input-password]').type('randompassword')
cy.get('[data-cy=submit]').click() cy.get('[data-cy=submit]').click()
cy.get('#message').should( cy.get('#message').should(
@ -30,8 +30,8 @@ describe('Pages > /authentication/signup', () => {
cy.get('#error-name').should('not.exist') cy.get('#error-name').should('not.exist')
cy.get('#error-email').should('not.exist') cy.get('#error-email').should('not.exist')
cy.get('#error-password').should('not.exist') cy.get('#error-password').should('not.exist')
cy.get('[data-cy=input-name]').type(user.name) cy.get('[data-cy=input-name]').type(userExample.name)
cy.get('[data-cy=input-email]').type(user.email) cy.get('[data-cy=input-email]').type(userExample.email)
cy.get('[data-cy=input-password]').type('randompassword') cy.get('[data-cy=input-password]').type('randompassword')
cy.get('[data-cy=submit]').click() cy.get('[data-cy=submit]').click()
cy.get('#message').should('have.text', 'Error: Name or Email already used.') cy.get('#message').should('have.text', 'Error: Name or Email already used.')
@ -44,8 +44,8 @@ describe('Pages > /authentication/signup', () => {
cy.get('#error-name').should('not.exist') cy.get('#error-name').should('not.exist')
cy.get('#error-email').should('not.exist') cy.get('#error-email').should('not.exist')
cy.get('#error-password').should('not.exist') cy.get('#error-password').should('not.exist')
cy.get('[data-cy=input-name]').type(user.name) cy.get('[data-cy=input-name]').type(userExample.name)
cy.get('[data-cy=input-email]').type(user.email) cy.get('[data-cy=input-email]').type(userExample.email)
cy.get('[data-cy=input-password]').type('randompassword') cy.get('[data-cy=input-password]').type('randompassword')
cy.get('[data-cy=submit]').click() cy.get('[data-cy=submit]').click()
cy.get('#message').should('have.text', 'Error: Internal Server Error.') cy.get('#message').should('have.text', 'Error: Internal Server Error.')

View File

@ -1,5 +1,7 @@
import { getLocal } from 'mockttp' import { getLocal } from 'mockttp'
import { API_DEFAULT_PORT } from '../../tools/api'
/// <reference types="cypress" /> /// <reference types="cypress" />
/** @type {import('mockttp').Mockttp | null} */ /** @type {import('mockttp').Mockttp | null} */
@ -17,7 +19,7 @@ module.exports = (on, config) => {
server = getLocal({ server = getLocal({
cors: true cors: true
}) })
await server.start(8080) await server.start(API_DEFAULT_PORT)
for (const handler of handlers) { for (const handler of handlers) {
await server[handler.method.toLowerCase()](handler.url).thenJson( await server[handler.method.toLowerCase()](handler.url).thenJson(
handler.response.statusCode, handler.response.statusCode,

View File

@ -5,7 +5,7 @@ import type { FormDataObject, HandleForm } from 'react-component-form'
import type { ErrorObject } from 'ajv' import type { ErrorObject } from 'ajv'
import { FetchState, useFetchState } from '../useFetchState' import { FetchState, useFetchState } from '../useFetchState'
import { ajv } from '../../utils/ajv' import { ajv } from '../../tools/ajv'
import { getErrorTranslationKey } from './getErrorTranslationKey' import { getErrorTranslationKey } from './getErrorTranslationKey'
interface Errors { interface Errors {

85
hooks/usePagination.ts Normal file
View File

@ -0,0 +1,85 @@
import { useState, useRef, useCallback } from 'react'
import { AxiosInstance } from 'axios'
import { FetchState } from './useFetchState'
export interface Query {
[key: string]: string
}
export type NextPageAsync = (query?: Query) => Promise<void>
export type NextPage = (query?: Query, callback?: () => void) => void
export interface UsePaginationOptions {
api: AxiosInstance
url: string
inverse?: boolean
}
export interface UsePaginationResult<T> {
items: T[]
nextPage: NextPage
resetPagination: () => void
hasMore: boolean
}
export const usePagination = <T extends { id: number }>(
options: UsePaginationOptions
): UsePaginationResult<T> => {
const { api, url, inverse = false } = options
const [items, setItems] = useState<T[]>([])
const [hasMore, setHasMore] = useState(true)
const fetchState = useRef<FetchState>('idle')
const afterId = useRef<number | null>(null)
const nextPageAsync: NextPageAsync = useCallback(
async (query) => {
if (fetchState.current !== 'idle') {
return
}
fetchState.current = 'loading'
const searchParameters = new URLSearchParams(query)
searchParameters.append('limit', '20')
if (afterId.current != null) {
searchParameters.append('after', afterId.current.toString())
}
const { data: newItems } = await api.get<T[]>(
`${url}?${searchParameters.toString()}`
)
if (!inverse) {
afterId.current =
newItems.length > 0 ? newItems[newItems.length - 1].id : null
} else {
afterId.current = newItems.length > 0 ? newItems[0].id : null
}
setItems((oldItems) => {
return inverse ? [...newItems, ...oldItems] : [...oldItems, ...newItems]
})
setHasMore(newItems.length > 0)
fetchState.current = 'idle'
},
[api, url, inverse]
)
const nextPage: NextPage = useCallback(
(query, callback) => {
nextPageAsync(query)
.then(() => {
if (callback != null) {
callback()
}
})
.catch((error) => {
console.error(error)
})
},
[nextPageAsync]
)
const resetPagination = useCallback((): void => {
afterId.current = null
setItems([])
}, [])
return { items, hasMore, nextPage, resetPagination }
}

View File

@ -13,6 +13,7 @@
"/application/users/[userId]": ["application", "errors"], "/application/users/[userId]": ["application", "errors"],
"/application/guilds/create": ["application", "errors"], "/application/guilds/create": ["application", "errors"],
"/application/guilds/join": ["application", "errors"], "/application/guilds/join": ["application", "errors"],
"/application": ["application", "errors"] "/application": ["application", "errors"],
"/application/[guildId]/[channelId]": ["application", "errors"]
} }
} }

View File

@ -1,5 +1,11 @@
{ {
"website": "Website", "website": "Website",
"create": "Create", "create": "Create",
"create-a-guild": "Create a Guild" "create-a-guild": "Create a Guild",
"create-a-guild-description": "Create your own guild and manage everything.",
"join-a-guild": "Join a Guild",
"join-a-guild-description": "Talk, collaborate, share and have fun with your friends by joining an already existing guild!",
"members": "member(s)",
"search": "Search",
"write-a-message": "Write a message..."
} }

View File

@ -1,5 +1,11 @@
{ {
"website": "Site web", "website": "Site web",
"create": "Crée", "create": "Créer",
"create-a-guild": "Crée une Guilde" "create-a-guild": "Créer une Guilde",
"create-a-guild-description": "Créez votre propre guilde et gérez tout.",
"join-a-guild": "Rejoindre une Guilde",
"join-a-guild-description": "Discutez, collaborez, partagez et amusez-vous avec vos amis en rejoignant une guilde déjà existante!",
"members": "membre(s)",
"search": "Rechercher",
"write-a-message": "Écrire un message..."
} }

View File

@ -1,9 +1,7 @@
import { Type } from '@sinclair/typebox' import { Type, Static } from '@sinclair/typebox'
import { date, id } from './utils' import { date, id } from './utils'
export const types = [Type.Literal('text')]
export const channelSchema = { export const channelSchema = {
id, id,
name: Type.String({ minLength: 1, maxLength: 20 }), name: Type.String({ minLength: 1, maxLength: 20 }),
@ -11,3 +9,5 @@ export const channelSchema = {
updatedAt: date.updatedAt, updatedAt: date.updatedAt,
guildId: id guildId: id
} }
const channelObjectSchema = Type.Object(channelSchema)
export type Channel = Static<typeof channelObjectSchema>

View File

@ -12,20 +12,30 @@ export const guildSchema = {
createdAt: date.createdAt, createdAt: date.createdAt,
updatedAt: date.updatedAt updatedAt: date.updatedAt
} }
export const guildObjectSchema = Type.Object(guildSchema)
export type Guild = Static<typeof guildObjectSchema>
export const guildWithDefaultChannelIdSchema = {
...guildSchema,
defaultChannelId: id
}
export const guildWithDefaultChannelObjectSchema = Type.Object(
guildWithDefaultChannelIdSchema
)
export type GuildWithDefaultChannelId = Static<
typeof guildWithDefaultChannelObjectSchema
>
export const guildCompleteSchema = { export const guildCompleteSchema = {
...guildSchema, ...guildSchema,
channels: Type.Array(Type.Object(channelSchema)), channels: Type.Array(Type.Object(channelSchema)),
members: Type.Array(Type.Object(memberSchema)) members: Type.Array(Type.Object(memberSchema))
} }
export const guildCompleteObjectSchema = Type.Object(guildCompleteSchema)
export type GuildComplete = Static<typeof guildCompleteObjectSchema>
export const guildPublicObjectSchema = Type.Object({ export const guildPublicObjectSchema = Type.Object({
...guildSchema, ...guildSchema,
membersCount: Type.Integer({ min: 1 }) membersCount: Type.Integer({ min: 1 })
}) })
export const guildCompleteObjectSchema = Type.Object(guildCompleteSchema)
export type GuildComplete = Static<typeof guildCompleteObjectSchema>
export type GuildPublic = Static<typeof guildPublicObjectSchema> export type GuildPublic = Static<typeof guildPublicObjectSchema>

View File

@ -1,6 +1,7 @@
import { Type } from '@sinclair/typebox' import { Type, Static } from '@sinclair/typebox'
import { date, id } from './utils' import { date, id } from './utils'
import { UserPublicWithoutSettings } from './User'
export const memberSchema = { export const memberSchema = {
id, id,
@ -10,3 +11,9 @@ export const memberSchema = {
userId: id, userId: id,
guildId: id guildId: id
} }
const memberObjectSchema = Type.Object(memberSchema)
export type Member = Static<typeof memberObjectSchema>
export interface MemberWithPublicUser extends Member {
user: UserPublicWithoutSettings
}

View File

@ -1,6 +1,7 @@
import { Type } from '@sinclair/typebox' import { Type, Static } from '@sinclair/typebox'
import { date, id } from './utils' import { date, id } from './utils'
import { MemberWithPublicUser } from './Member'
export const types = [Type.Literal('text'), Type.Literal('file')] export const types = [Type.Literal('text'), Type.Literal('file')]
@ -21,3 +22,9 @@ export const messageSchema = {
memberId: id, memberId: id,
channelId: id channelId: id
} }
const messageObjectSchema = Type.Object(messageSchema)
export type Message = Static<typeof messageObjectSchema>
export interface MessageWithMember extends Message {
member: MemberWithPublicUser
}

View File

@ -50,6 +50,9 @@ export const userPublicWithoutSettingsSchema = {
createdAt: date.createdAt, createdAt: date.createdAt,
updatedAt: date.updatedAt updatedAt: date.updatedAt
} }
export const userPublicWithoutSettingsObjectSchema = Type.Object(
userPublicWithoutSettingsSchema
)
export const userPublicSchema = { export const userPublicSchema = {
...userPublicWithoutSettingsSchema, ...userPublicWithoutSettingsSchema,
@ -66,4 +69,7 @@ export const userCurrentSchema = Type.Object({
export type User = Static<typeof userObjectSchema> export type User = Static<typeof userObjectSchema>
export type UserPublic = Static<typeof userPublicObjectSchema> export type UserPublic = Static<typeof userPublicObjectSchema>
export type UserPublicWithoutSettings = Static<
typeof userPublicWithoutSettingsObjectSchema
>
export type UserCurrent = Static<typeof userCurrentSchema> export type UserCurrent = Static<typeof userCurrentSchema>

View File

@ -2,6 +2,7 @@ const nextPWA = require('next-pwa')
const nextTranslate = require('next-translate') const nextTranslate = require('next-translate')
const { createSecureHeaders } = require('next-secure-headers') const { createSecureHeaders } = require('next-secure-headers')
/** @type {import("next").NextConfig} */
module.exports = nextTranslate( module.exports = nextTranslate(
nextPWA({ nextPWA({
images: { images: {

5656
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -43,6 +43,7 @@
"axios": "0.24.0", "axios": "0.24.0",
"classnames": "2.3.1", "classnames": "2.3.1",
"date-and-time": "2.0.1", "date-and-time": "2.0.1",
"emoji-mart": "3.0.1",
"next": "12.0.7", "next": "12.0.7",
"next-pwa": "5.4.4", "next-pwa": "5.4.4",
"next-themes": "0.0.15", "next-themes": "0.0.15",
@ -51,12 +52,17 @@
"react-component-form": "2.0.0", "react-component-form": "2.0.0",
"react-dom": "17.0.2", "react-dom": "17.0.2",
"react-infinite-scroll-component": "6.1.0", "react-infinite-scroll-component": "6.1.0",
"react-markdown": "7.1.1",
"react-responsive": "8.2.0", "react-responsive": "8.2.0",
"react-swipeable": "6.2.0", "react-swipeable": "6.2.0",
"react-textarea-autosize": "8.3.3", "react-textarea-autosize": "8.3.3",
"read-pkg": "7.0.0", "read-pkg": "7.0.0",
"remark-breaks": "3.0.2",
"remark-gfm": "3.0.1",
"sharp": "0.29.3", "sharp": "0.29.3",
"socket.io-client": "4.4.0", "socket.io-client": "4.4.0",
"unified": "10.1.1",
"unist-util-visit": "4.1.0",
"universal-cookie": "4.0.4" "universal-cookie": "4.0.4"
}, },
"devDependencies": { "devDependencies": {
@ -73,17 +79,20 @@
"@testing-library/jest-dom": "5.16.1", "@testing-library/jest-dom": "5.16.1",
"@testing-library/react": "12.1.2", "@testing-library/react": "12.1.2",
"@types/date-and-time": "0.13.0", "@types/date-and-time": "0.13.0",
"@types/jest": "27.0.3", "@types/emoji-mart": "3.0.9",
"@types/hast": "2.3.4",
"@types/jest": "27.4.0",
"@types/node": "17.0.5", "@types/node": "17.0.5",
"@types/react": "17.0.38", "@types/react": "17.0.38",
"@types/react-responsive": "8.0.5", "@types/react-responsive": "8.0.5",
"@types/unist": "2.0.6",
"@typescript-eslint/eslint-plugin": "4.33.0", "@typescript-eslint/eslint-plugin": "4.33.0",
"autoprefixer": "10.4.0", "autoprefixer": "10.4.1",
"cypress": "9.2.0", "cypress": "9.2.0",
"dockerfilelint": "1.8.0", "dockerfilelint": "1.8.0",
"editorconfig-checker": "4.0.2", "editorconfig-checker": "4.0.2",
"eslint": "7.32.0", "eslint": "7.32.0",
"eslint-config-next": "11.1.2", "eslint-config-next": "12.0.7",
"eslint-config-prettier": "8.3.0", "eslint-config-prettier": "8.3.0",
"eslint-config-standard-with-typescript": "21.0.1", "eslint-config-standard-with-typescript": "21.0.1",
"eslint-plugin-import": "2.25.3", "eslint-plugin-import": "2.25.3",
@ -91,7 +100,7 @@
"eslint-plugin-prettier": "4.0.0", "eslint-plugin-prettier": "4.0.0",
"eslint-plugin-promise": "5.1.1", "eslint-plugin-promise": "5.1.1",
"eslint-plugin-storybook": "0.5.5", "eslint-plugin-storybook": "0.5.5",
"eslint-plugin-unicorn": "39.0.0", "eslint-plugin-unicorn": "40.0.0",
"husky": "7.0.4", "husky": "7.0.4",
"jest": "27.4.5", "jest": "27.4.5",
"lint-staged": "12.1.4", "lint-staged": "12.1.4",
@ -105,8 +114,8 @@
"serve": "13.0.2", "serve": "13.0.2",
"start-server-and-test": "1.14.0", "start-server-and-test": "1.14.0",
"storybook-tailwind-dark-mode": "1.0.11", "storybook-tailwind-dark-mode": "1.0.11",
"tailwindcss": "3.0.7", "tailwindcss": "3.0.8",
"typescript": "4.5.4", "typescript": "4.4.4",
"vercel": "23.1.2", "vercel": "23.1.2",
"webpack": "5.65.0" "webpack": "5.65.0"
} }

Some files were not shown because too many files have changed in this diff Show More