2
2
mirror of https://github.com/Thream/website.git synced 2024-07-21 09:28:32 +02:00

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 type ApplicationPath =
| '/application'
| '/application/guilds/join'
| '/application/guilds/create'
| `/application/users/${number}`
| GuildsChannelsPath
export interface ApplicationProps { export interface ApplicationProps {
path: path: ApplicationPath
| '/application' guildLeftSidebar?: React.ReactNode
| '/application/guilds/join' title: string
| '/application/guilds/create'
| '/application/users/[userId]'
| GuildsChannelsPath
} }
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,13 +216,15 @@ export const Application: React.FC<ApplicationProps> = (props) => {
{children} {children}
</div> </div>
<Sidebar {typeof path !== 'string' && (
direction='right' <Sidebar
visible={visibleSidebars.right} direction='right'
isMobile={isMobile} visible={visibleSidebars.right}
> isMobile={isMobile}
<Members /> >
</Sidebar> <Members />
</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 <InfiniteScroll
className={classNames( className='w-full channels-list'
'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', scrollableTarget='channels'
{ dataLength={channels.length}
'text-green-800 dark:text-green-400 font-semibold': next={nextPage}
typeof path !== 'string' && path.channelId === index, hasMore={hasMore}
'text-gray-600 dark:text-gray-400 hover:text-gray-800 dark:hover:text-white font-normal': loader={<Loader />}
typeof path === 'string' >
} {channels.map((channel) => {
)} const selected = channel.id === path.channelId
> return (
<span className='ml-2 mr-4'># Channel {index}</span> <Channel
</a> key={channel.id}
</Link> 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} <InfiniteScroll
href={`/application/${index}/0`} className='guilds-list'
selected={typeof path !== 'string' && path.guildId === index} dataLength={guilds.length}
title='Guild Name' next={nextPage}
> hasMore={hasMore}
<div className='pl-[6px]'> scrollableTarget='guilds-list'
<Image loader={<Loader />}
src='/images/icons/Thream.png' >
alt='logo' {guilds.map((guild) => {
width={48} const selected = typeof path !== 'string' && path.guildId === guild.id
height={48} return <Guild key={guild.id} guild={guild} selected={selected} />
/> })}
</div> </InfiniteScroll>
</IconLink>
)
})}
</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' {members.map((member) => {
className='rounded-full' return <Member key={member.id} member={member} />
/> })}
</div> </InfiniteScroll>
<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'>
<Image
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>
)
})}
</> </>
) )
} }

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 = () => {
const { messages, hasMore, nextPage } = useMessages()
if (messages.length === 0) {
return (
<div
id='messages'
className='w-full scrollbar-firefox-support overflow-y-auto transition-all flex-1 flex flex-col text-center mt-8 text-lg'
>
<p>
Nothing to show here! <Emoji value=':ghost:' size={20} />
</p>
<p>Start chatting to kill this Ghost!</p>
</div>
)
}
return ( return (
<> <div
<div className='w-full scrollbar-firefox-support overflow-y-auto transition-all'> id='messages'
{new Array(20).fill(null).map((_, index) => { className='w-full scrollbar-firefox-support overflow-y-auto transition-all flex-1 flex flex-col-reverse'
return ( >
<div <InfiniteScroll
key={index} scrollableTarget='messages'
className='p-4 flex transition hover:bg-gray-200 dark:hover:bg-gray-900' className='messages-list'
> dataLength={messages.length}
<div className='w-12 h-12 mr-4 flex flex-shrink-0 items-center justify-center'> next={nextPage}
<div className='w-10 h-10 drop-shadow-md'> inverse
<Image hasMore={hasMore}
className='rounded-full' loader={<Loader />}
src='/images/data/user-default.png' >
alt='logo' {messages.map((message) => {
width={50} return <Message key={message.id} message={message} />
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>
Lorem ipsum dolor sit, amet consectetur adipisicing elit.
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>
</div>
</div>
</div>
)
})} })}
</div> </InfiniteScroll>
<div className='p-6 pb-4'> </div>
<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>
</a> </div>
</Link> </a>
</div> </Link>
) )
} }

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')
.location('pathname') cy.get('a[href="/application/guilds/create"]')
.should('eq', applicationPath) .click()
} .location('pathname')
.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: {

5658
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