feat(pages): add /application/[guildId]/[channelId]
(#4)
This commit is contained in:
@ -1,13 +1,6 @@
|
||||
import { useState, useEffect, useMemo } from 'react'
|
||||
import { useState, useEffect } from 'react'
|
||||
import Image from 'next/image'
|
||||
import useTranslation from 'next-translate/useTranslation'
|
||||
import {
|
||||
CogIcon,
|
||||
PlusIcon,
|
||||
MenuIcon,
|
||||
UsersIcon,
|
||||
XIcon
|
||||
} from '@heroicons/react/solid'
|
||||
import { PlusIcon, MenuIcon, UsersIcon, XIcon } from '@heroicons/react/solid'
|
||||
import classNames from 'classnames'
|
||||
import { useMediaQuery } from 'react-responsive'
|
||||
import { useSwipeable } from 'react-swipeable'
|
||||
@ -15,31 +8,33 @@ import { useSwipeable } from 'react-swipeable'
|
||||
import { Sidebar, DirectionSidebar } from './Sidebar'
|
||||
import { IconButton } from 'components/design/IconButton'
|
||||
import { IconLink } from 'components/design/IconLink'
|
||||
import { Channels } from './Channels'
|
||||
import { Guilds } from './Guilds/Guilds'
|
||||
import { Divider } from '../design/Divider'
|
||||
import { Members } from './Members'
|
||||
import { useAuthentication } from 'utils/authentication'
|
||||
import { API_URL } from 'utils/api'
|
||||
import { useAuthentication } from 'tools/authentication'
|
||||
import { API_URL } from 'tools/api'
|
||||
|
||||
export interface GuildsChannelsPath {
|
||||
guildId: number
|
||||
channelId: number
|
||||
}
|
||||
|
||||
export type ApplicationPath =
|
||||
| '/application'
|
||||
| '/application/guilds/join'
|
||||
| '/application/guilds/create'
|
||||
| `/application/users/${number}`
|
||||
| GuildsChannelsPath
|
||||
|
||||
export interface ApplicationProps {
|
||||
path:
|
||||
| '/application'
|
||||
| '/application/guilds/join'
|
||||
| '/application/guilds/create'
|
||||
| '/application/users/[userId]'
|
||||
| GuildsChannelsPath
|
||||
path: ApplicationPath
|
||||
guildLeftSidebar?: React.ReactNode
|
||||
title: string
|
||||
}
|
||||
|
||||
export const Application: React.FC<ApplicationProps> = (props) => {
|
||||
const { children, path } = props
|
||||
const { children, path, guildLeftSidebar, title } = props
|
||||
|
||||
const { t } = useTranslation()
|
||||
const { user } = useAuthentication()
|
||||
|
||||
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(() => {
|
||||
setMounted(true)
|
||||
}, [])
|
||||
@ -163,12 +141,16 @@ export const Application: React.FC<ApplicationProps> = (props) => {
|
||||
>
|
||||
{!visibleSidebars.left ? <MenuIcon /> : <XIcon />}
|
||||
</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}
|
||||
</div>
|
||||
<div className='flex space-x-2'>
|
||||
{title.startsWith('#') && (
|
||||
<IconButton
|
||||
data-cy='icon-button-right-sidebar-members'
|
||||
className='p-2 h-10 w-10'
|
||||
onClick={() => handleToggleSidebars('right')}
|
||||
>
|
||||
@ -201,7 +183,8 @@ export const Application: React.FC<ApplicationProps> = (props) => {
|
||||
? '/images/data/user-default.png'
|
||||
: API_URL + user.logo
|
||||
}
|
||||
alt='logo'
|
||||
alt={"Users's profil picture"}
|
||||
draggable={false}
|
||||
width={48}
|
||||
height={48}
|
||||
/>
|
||||
@ -217,26 +200,7 @@ export const Application: React.FC<ApplicationProps> = (props) => {
|
||||
<Guilds path={path} />
|
||||
</div>
|
||||
|
||||
{typeof path !== 'string' && (
|
||||
<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>
|
||||
)}
|
||||
{guildLeftSidebar}
|
||||
</Sidebar>
|
||||
|
||||
<div
|
||||
@ -252,13 +216,15 @@ export const Application: React.FC<ApplicationProps> = (props) => {
|
||||
{children}
|
||||
</div>
|
||||
|
||||
<Sidebar
|
||||
direction='right'
|
||||
visible={visibleSidebars.right}
|
||||
isMobile={isMobile}
|
||||
>
|
||||
<Members />
|
||||
</Sidebar>
|
||||
{typeof path !== 'string' && (
|
||||
<Sidebar
|
||||
direction='right'
|
||||
visible={visibleSidebars.right}
|
||||
isMobile={isMobile}
|
||||
>
|
||||
<Members />
|
||||
</Sidebar>
|
||||
)}
|
||||
</main>
|
||||
</>
|
||||
)
|
||||
|
16
components/Application/Channels/Channel/Channel.stories.tsx
Normal file
16
components/Application/Channels/Channel/Channel.stories.tsx
Normal 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 }
|
13
components/Application/Channels/Channel/Channel.test.tsx
Normal file
13
components/Application/Channels/Channel/Channel.test.tsx
Normal 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()
|
||||
})
|
||||
})
|
32
components/Application/Channels/Channel/Channel.tsx
Normal file
32
components/Application/Channels/Channel/Channel.tsx
Normal 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>
|
||||
)
|
||||
}
|
1
components/Application/Channels/Channel/index.ts
Normal file
1
components/Application/Channels/Channel/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from './Channel'
|
@ -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 } }
|
@ -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()
|
||||
})
|
||||
})
|
@ -1,7 +1,9 @@
|
||||
import Link from 'next/link'
|
||||
import classNames from 'classnames'
|
||||
import InfiniteScroll from 'react-infinite-scroll-component'
|
||||
|
||||
import { GuildsChannelsPath } from '../Application'
|
||||
import { Loader } from 'components/design/Loader'
|
||||
import { Channel } from './Channel'
|
||||
import { useChannels } from 'contexts/Channels'
|
||||
|
||||
export interface ChannelsProps {
|
||||
path: GuildsChannelsPath
|
||||
@ -10,27 +12,33 @@ export interface ChannelsProps {
|
||||
export const Channels: React.FC<ChannelsProps> = (props) => {
|
||||
const { path } = props
|
||||
|
||||
const { channels, hasMore, nextPage } = useChannels()
|
||||
|
||||
return (
|
||||
<nav className='w-full'>
|
||||
{new Array(100).fill(null).map((_, index) => {
|
||||
return (
|
||||
<Link key={index} href={`/application/${path.guildId}/${index}`}>
|
||||
<a
|
||||
className={classNames(
|
||||
'hover:bg-gray-100 group flex items-center justify-between text-sm py-2 my-3 mx-3 transition-colors dark:hover:bg-gray-600 duration-200 rounded-lg',
|
||||
{
|
||||
'text-green-800 dark:text-green-400 font-semibold':
|
||||
typeof path !== 'string' && path.channelId === index,
|
||||
'text-gray-600 dark:text-gray-400 hover:text-gray-800 dark:hover:text-white font-normal':
|
||||
typeof path === 'string'
|
||||
}
|
||||
)}
|
||||
>
|
||||
<span className='ml-2 mr-4'># Channel {index}</span>
|
||||
</a>
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</nav>
|
||||
<div
|
||||
id='channels'
|
||||
className='scrollbar-firefox-support overflow-y-auto flex-1 flex flex-col'
|
||||
>
|
||||
<InfiniteScroll
|
||||
className='w-full channels-list'
|
||||
scrollableTarget='channels'
|
||||
dataLength={channels.length}
|
||||
next={nextPage}
|
||||
hasMore={hasMore}
|
||||
loader={<Loader />}
|
||||
>
|
||||
{channels.map((channel) => {
|
||||
const selected = channel.id === path.channelId
|
||||
return (
|
||||
<Channel
|
||||
key={channel.id}
|
||||
channel={channel}
|
||||
path={path}
|
||||
selected={selected}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</InfiniteScroll>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
@ -4,7 +4,7 @@ import { Form } from 'react-component-form'
|
||||
import TextareaAutosize from 'react-textarea-autosize'
|
||||
import { AxiosResponse } from 'axios'
|
||||
|
||||
import { useAuthentication } from '../../../utils/authentication'
|
||||
import { useAuthentication } from '../../../tools/authentication'
|
||||
import { HandleSubmitCallback, useForm } from '../../../hooks/useForm'
|
||||
import { GuildComplete, guildSchema } from '../../../models/Guild'
|
||||
import { Input } from '../../design/Input'
|
||||
|
38
components/Application/GuildLeftSidebar/GuildLeftSidebar.tsx
Normal file
38
components/Application/GuildLeftSidebar/GuildLeftSidebar.tsx
Normal 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>
|
||||
)
|
||||
}
|
1
components/Application/GuildLeftSidebar/index.ts
Normal file
1
components/Application/GuildLeftSidebar/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from './GuildLeftSidebar'
|
@ -1,6 +1,7 @@
|
||||
import { Meta, Story } from '@storybook/react'
|
||||
|
||||
import { Guild as Component, GuildProps } from './Guild'
|
||||
import { guildExample } from '../../../../cypress/fixtures/guilds/guild'
|
||||
|
||||
const Stories: Meta = {
|
||||
title: 'Guild',
|
||||
@ -14,12 +15,7 @@ export const Guild: Story<GuildProps> = (arguments_) => {
|
||||
}
|
||||
Guild.args = {
|
||||
guild: {
|
||||
id: 1,
|
||||
name: 'GuildExample',
|
||||
description: 'guild example.',
|
||||
icon: null,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
membersCount: 1
|
||||
...guildExample,
|
||||
defaultChannelId: 1
|
||||
}
|
||||
}
|
@ -1,19 +1,15 @@
|
||||
import { render } from '@testing-library/react'
|
||||
|
||||
import { Guild } from './Guild'
|
||||
import { guildExample } from '../../../../cypress/fixtures/guilds/guild'
|
||||
|
||||
describe('<Guild />', () => {
|
||||
it('should render successfully', () => {
|
||||
const { baseElement } = render(
|
||||
<Guild
|
||||
guild={{
|
||||
id: 1,
|
||||
name: 'GuildExample',
|
||||
description: 'guild example.',
|
||||
icon: null,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
membersCount: 1
|
||||
...guildExample,
|
||||
defaultChannelId: 1
|
||||
}}
|
||||
/>
|
||||
)
|
35
components/Application/Guilds/Guild/Guild.tsx
Normal file
35
components/Application/Guilds/Guild/Guild.tsx
Normal 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>
|
||||
)
|
||||
}
|
@ -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 } }
|
@ -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()
|
||||
})
|
||||
})
|
@ -1,34 +1,37 @@
|
||||
import Image from 'next/image'
|
||||
import InfiniteScroll from 'react-infinite-scroll-component'
|
||||
|
||||
import { ApplicationProps } from '../Application'
|
||||
import { IconLink } from '../../design/IconLink'
|
||||
import { Loader } from 'components/design/Loader'
|
||||
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) => {
|
||||
const { path } = props
|
||||
|
||||
const { guilds, hasMore, nextPage } = useGuilds()
|
||||
|
||||
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'>
|
||||
{new Array(100).fill(null).map((_, index) => {
|
||||
return (
|
||||
<IconLink
|
||||
key={index}
|
||||
href={`/application/${index}/0`}
|
||||
selected={typeof path !== 'string' && path.guildId === index}
|
||||
title='Guild Name'
|
||||
>
|
||||
<div className='pl-[6px]'>
|
||||
<Image
|
||||
src='/images/icons/Thream.png'
|
||||
alt='logo'
|
||||
width={48}
|
||||
height={48}
|
||||
/>
|
||||
</div>
|
||||
</IconLink>
|
||||
)
|
||||
})}
|
||||
<div
|
||||
id='guilds-list'
|
||||
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'
|
||||
>
|
||||
<InfiniteScroll
|
||||
className='guilds-list'
|
||||
dataLength={guilds.length}
|
||||
next={nextPage}
|
||||
hasMore={hasMore}
|
||||
scrollableTarget='guilds-list'
|
||||
loader={<Loader />}
|
||||
>
|
||||
{guilds.map((guild) => {
|
||||
const selected = typeof path !== 'string' && path.guildId === guild.id
|
||||
return <Guild key={guild.id} guild={guild} selected={selected} />
|
||||
})}
|
||||
</InfiniteScroll>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
@ -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
|
||||
}
|
||||
}
|
@ -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()
|
||||
})
|
||||
})
|
@ -1,19 +1,19 @@
|
||||
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 {
|
||||
guild: GuildPublic
|
||||
export interface GuildPublicProps {
|
||||
guild: GuildPublicType
|
||||
}
|
||||
|
||||
export const Guild: React.FC<GuildProps> = (props) => {
|
||||
export const GuildPublic: React.FC<GuildPublicProps> = (props) => {
|
||||
const { guild } = props
|
||||
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<div
|
||||
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'
|
||||
>
|
||||
<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'>
|
||||
<Image
|
||||
className='rounded-full'
|
||||
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>
|
||||
</div>
|
||||
<p className='flex flex-col text-green-800 dark:text-green-400 mt-4'>
|
||||
{guild.membersCount} members
|
||||
{guild.membersCount} {t('application:members')}
|
||||
</p>
|
||||
</div>
|
||||
)
|
@ -0,0 +1 @@
|
||||
export * from './GuildPublic'
|
@ -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 { useAuthentication } from 'utils/authentication'
|
||||
import { GuildPublic } from 'models/Guild'
|
||||
import { useAuthentication } from 'tools/authentication'
|
||||
import { GuildPublic as GuildPublicType } from 'models/Guild'
|
||||
import { Loader } from 'components/design/Loader'
|
||||
import { useFetchState } from 'hooks/useFetchState'
|
||||
import { Guild } from './Guild'
|
||||
import { GuildPublic } from './GuildPublic'
|
||||
import { usePagination } from 'hooks/usePagination'
|
||||
|
||||
export const JoinGuildsPublic: React.FC = () => {
|
||||
const [guilds, setGuilds] = useState<GuildPublic[]>([])
|
||||
const [hasMore, setHasMore] = useState(true)
|
||||
const [inputSearch, setInputSearch] = useState('')
|
||||
const [fetchState, setFetchState] = useFetchState('idle')
|
||||
const afterId = useRef<number | null>(null)
|
||||
|
||||
const [search, setSearch] = useState('')
|
||||
const { authentication } = useAuthentication()
|
||||
const { t } = useTranslation()
|
||||
|
||||
const fetchGuilds = useCallback(async (): Promise<void> => {
|
||||
if (fetchState !== 'idle') {
|
||||
return
|
||||
}
|
||||
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]
|
||||
const { items, hasMore, nextPage, resetPagination } =
|
||||
usePagination<GuildPublicType>({
|
||||
api: authentication.api,
|
||||
url: '/guilds/public'
|
||||
})
|
||||
setHasMore(data.length > 0)
|
||||
setFetchState('idle')
|
||||
}, [authentication, fetchState, setFetchState, inputSearch])
|
||||
|
||||
useEffect(() => {
|
||||
afterId.current = null
|
||||
setGuilds([])
|
||||
fetchGuilds().catch((error) => {
|
||||
console.error(error)
|
||||
})
|
||||
}, [inputSearch]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
resetPagination()
|
||||
nextPage({ search })
|
||||
}, [resetPagination, nextPage, search])
|
||||
|
||||
const handleChange = (event: React.ChangeEvent<HTMLInputElement>): void => {
|
||||
setInputSearch(event.target.value)
|
||||
setSearch(event.target.value)
|
||||
}
|
||||
|
||||
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'
|
||||
type='search'
|
||||
name='search-guild'
|
||||
placeholder='🔎 Search...'
|
||||
placeholder={`🔎 ${t('application:search')}...`}
|
||||
/>
|
||||
<div className='w-full flex items-center justify-center p-12'>
|
||||
<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'
|
||||
dataLength={guilds.length}
|
||||
next={fetchGuilds}
|
||||
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={items.length}
|
||||
next={nextPage}
|
||||
scrollableTarget='application-page-content'
|
||||
hasMore={hasMore}
|
||||
loader={<Loader />}
|
||||
>
|
||||
{guilds.map((guild) => {
|
||||
return <Guild guild={guild} key={guild.id} />
|
||||
{items.map((guild) => {
|
||||
return <GuildPublic guild={guild} key={guild.id} />
|
||||
})}
|
||||
</InfiniteScroll>
|
||||
</div>
|
||||
|
16
components/Application/Members/Member/Member.stories.tsx
Normal file
16
components/Application/Members/Member/Member.stories.tsx
Normal 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 }
|
11
components/Application/Members/Member/Member.test.tsx
Normal file
11
components/Application/Members/Member/Member.test.tsx
Normal 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()
|
||||
})
|
||||
})
|
45
components/Application/Members/Member/Member.tsx
Normal file
45
components/Application/Members/Member/Member.tsx
Normal 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>
|
||||
)
|
||||
}
|
1
components/Application/Members/Member/index.ts
Normal file
1
components/Application/Members/Member/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from './Member'
|
@ -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()
|
||||
})
|
||||
})
|
@ -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 { 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 = () => {
|
||||
const { members, hasMore, nextPage } = useMembers()
|
||||
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<>
|
||||
<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 />
|
||||
</div>
|
||||
<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 border-2 border-green-500'>
|
||||
<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'>
|
||||
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>
|
||||
)
|
||||
})}
|
||||
<InfiniteScroll
|
||||
className='members-list'
|
||||
dataLength={members.length}
|
||||
next={nextPage}
|
||||
hasMore={hasMore}
|
||||
loader={<Loader />}
|
||||
>
|
||||
{members.map((member) => {
|
||||
return <Member key={member.id} member={member} />
|
||||
})}
|
||||
</InfiniteScroll>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
16
components/Application/Messages/Message/Message.stories.tsx
Normal file
16
components/Application/Messages/Message/Message.stories.tsx
Normal 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 }
|
61
components/Application/Messages/Message/Message.tsx
Normal file
61
components/Application/Messages/Message/Message.tsx
Normal 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>
|
||||
)
|
||||
}
|
@ -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>
|
||||
)
|
||||
}
|
@ -0,0 +1 @@
|
||||
export * from './MessageContent'
|
1
components/Application/Messages/Message/index.ts
Normal file
1
components/Application/Messages/Message/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from './Message'
|
@ -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_} />
|
@ -1,82 +1,45 @@
|
||||
import Image from 'next/image'
|
||||
import TextareaAutosize from 'react-textarea-autosize'
|
||||
import InfiniteScroll from 'react-infinite-scroll-component'
|
||||
|
||||
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 = () => {
|
||||
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 (
|
||||
<>
|
||||
<div className='w-full scrollbar-firefox-support overflow-y-auto transition-all'>
|
||||
{new Array(20).fill(null).map((_, index) => {
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className='p-4 flex transition hover:bg-gray-200 dark:hover:bg-gray-900'
|
||||
>
|
||||
<div className='w-12 h-12 mr-4 flex flex-shrink-0 items-center justify-center'>
|
||||
<div className='w-10 h-10 drop-shadow-md'>
|
||||
<Image
|
||||
className='rounded-full'
|
||||
src='/images/data/user-default.png'
|
||||
alt='logo'
|
||||
width={50}
|
||||
height={50}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className='w-full'>
|
||||
<div className='w-max flex items-center'>
|
||||
<span className='font-bold text-gray-900 dark:text-gray-200'>
|
||||
Divlo
|
||||
</span>
|
||||
<span className='text-gray-500 dark:text-gray-200 text-xs ml-4 select-none'>
|
||||
06/04/2021 - 22:28:40
|
||||
</span>
|
||||
</div>
|
||||
<div className='text-gray-800 dark:text-gray-300 font-paragraph mt-1 break-words'>
|
||||
<p>Message {index}</p>
|
||||
<p>
|
||||
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
|
||||
id='messages'
|
||||
className='w-full scrollbar-firefox-support overflow-y-auto transition-all flex-1 flex flex-col-reverse'
|
||||
>
|
||||
<InfiniteScroll
|
||||
scrollableTarget='messages'
|
||||
className='messages-list'
|
||||
dataLength={messages.length}
|
||||
next={nextPage}
|
||||
inverse
|
||||
hasMore={hasMore}
|
||||
loader={<Loader />}
|
||||
>
|
||||
{messages.map((message) => {
|
||||
return <Message key={message.id} message={message} />
|
||||
})}
|
||||
</div>
|
||||
<div className='p-6 pb-4'>
|
||||
<div className='w-full h-full py-1 flex rounded-lg bg-gray-200 dark:bg-gray-800 text-gray-600 dark:text-gray-200'>
|
||||
<form className='w-full h-full flex items-center'>
|
||||
<TextareaAutosize
|
||||
className='w-full scrollbar-firefox-support p-2 px-6 my-2 bg-transparent outline-none font-paragraph tracking-wide resize-none'
|
||||
placeholder='Write a message...'
|
||||
wrap='soft'
|
||||
maxRows={6}
|
||||
/>
|
||||
</form>
|
||||
<div className='h-full flex items-center justify-around pr-6'>
|
||||
<button className='w-full h-full flex items-center justify-center p-1 text-2xl transition hover:-translate-y-1'>
|
||||
🙂
|
||||
</button>
|
||||
<button className='relative w-full h-full flex items-center justify-center p-1 text-green-800 dark:text-green-400 transition hover:-translate-y-1'>
|
||||
<input
|
||||
type='file'
|
||||
className='absolute w-full h-full opacity-0 cursor-pointer'
|
||||
/>
|
||||
<svg width='25' height='25' viewBox='0 0 22 22'>
|
||||
<path
|
||||
d='M11 0C4.925 0 0 4.925 0 11C0 17.075 4.925 22 11 22C17.075 22 22 17.075 22 11C22 4.925 17.075 0 11 0ZM12 15C12 15.2652 11.8946 15.5196 11.7071 15.7071C11.5196 15.8946 11.2652 16 11 16C10.7348 16 10.4804 15.8946 10.2929 15.7071C10.1054 15.5196 10 15.2652 10 15V12H7C6.73478 12 6.48043 11.8946 6.29289 11.7071C6.10536 11.5196 6 11.2652 6 11C6 10.7348 6.10536 10.4804 6.29289 10.2929C6.48043 10.1054 6.73478 10 7 10H10V7C10 6.73478 10.1054 6.48043 10.2929 6.29289C10.4804 6.10536 10.7348 6 11 6C11.2652 6 11.5196 6.10536 11.7071 6.29289C11.8946 6.48043 12 6.73478 12 7V10H15C15.2652 10 15.5196 10.1054 15.7071 10.2929C15.8946 10.4804 16 10.7348 16 11C16 11.2652 15.8946 11.5196 15.7071 11.7071C15.5196 11.8946 15.2652 12 15 12H12V15Z'
|
||||
fill='currentColor'
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
</InfiniteScroll>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
@ -1,3 +1,4 @@
|
||||
import useTranslation from 'next-translate/useTranslation'
|
||||
import { PlusSmIcon, ArrowDownIcon } from '@heroicons/react/solid'
|
||||
import classNames from 'classnames'
|
||||
import Image from 'next/image'
|
||||
@ -10,6 +11,8 @@ export interface PopupGuildProps {
|
||||
export const PopupGuild: React.FC<PopupGuildProps> = (props) => {
|
||||
const { className } = props
|
||||
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classNames(
|
||||
@ -21,16 +24,16 @@ export const PopupGuild: React.FC<PopupGuildProps> = (props) => {
|
||||
image={
|
||||
<Image
|
||||
src='/images/svg/design/create-guild.svg'
|
||||
alt='Create a guild'
|
||||
alt={t('application:create-a-guild')}
|
||||
draggable='false'
|
||||
width={230}
|
||||
height={230}
|
||||
/>
|
||||
}
|
||||
description='Create your own guild and manage everything within a few clicks !'
|
||||
description={t('application:create-a-guild-description')}
|
||||
link={{
|
||||
icon: <PlusSmIcon className='w-8 h-8 mr-2' />,
|
||||
text: 'Create a Guild',
|
||||
text: t('application:create-a-guild'),
|
||||
href: '/application/guilds/create'
|
||||
}}
|
||||
/>
|
||||
@ -38,16 +41,16 @@ export const PopupGuild: React.FC<PopupGuildProps> = (props) => {
|
||||
image={
|
||||
<Image
|
||||
src='/images/svg/design/join-guild.svg'
|
||||
alt='Join a Guild'
|
||||
alt={t('application:join-a-guild')}
|
||||
draggable='false'
|
||||
width={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={{
|
||||
icon: <ArrowDownIcon className='w-6 h-6 mr-2' />,
|
||||
text: 'Join a Guild',
|
||||
text: t('application:join-a-guild'),
|
||||
href: '/application/guilds/join'
|
||||
}}
|
||||
/>
|
||||
|
@ -19,7 +19,9 @@ export const PopupGuildCard: React.FC<PopupGuildCardProps> = (props) => {
|
||||
{image}
|
||||
</div>
|
||||
<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}>
|
||||
<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}
|
||||
|
@ -1,14 +1,15 @@
|
||||
import { Meta, Story } from '@storybook/react'
|
||||
|
||||
import { Members as Component } from './Members'
|
||||
import { SendMessage as Component } from './SendMessage'
|
||||
|
||||
const Stories: Meta = {
|
||||
title: 'Members',
|
||||
title: 'SendMessage',
|
||||
component: Component
|
||||
}
|
||||
|
||||
export default Stories
|
||||
|
||||
export const Members: Story = (arguments_) => {
|
||||
export const SendMessage: Story = (arguments_) => {
|
||||
return <Component {...arguments_} />
|
||||
}
|
||||
SendMessage.args = {}
|
@ -1,10 +1,10 @@
|
||||
import { render } from '@testing-library/react'
|
||||
|
||||
import { Messages } from './'
|
||||
import { SendMessage } from './SendMessage'
|
||||
|
||||
describe('<Messages />', () => {
|
||||
describe('<SendMessage />', () => {
|
||||
it('should render successfully', () => {
|
||||
const { baseElement } = render(<Messages />)
|
||||
const { baseElement } = render(<SendMessage />)
|
||||
expect(baseElement).toBeTruthy()
|
||||
})
|
||||
})
|
38
components/Application/SendMessage/SendMessage.tsx
Normal file
38
components/Application/SendMessage/SendMessage.tsx
Normal 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>
|
||||
)
|
||||
}
|
1
components/Application/SendMessage/index.ts
Normal file
1
components/Application/SendMessage/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from './SendMessage'
|
@ -1,5 +1,8 @@
|
||||
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'
|
||||
|
||||
@ -15,7 +18,7 @@ export const UserProfile: Story<UserProfileProps> = (arguments_) => {
|
||||
}
|
||||
UserProfile.args = {
|
||||
user: {
|
||||
...user,
|
||||
settings: userSettings
|
||||
...userExample,
|
||||
settings: userSettingsExample
|
||||
}
|
||||
}
|
||||
|
@ -11,11 +11,11 @@ import { Button } from '../design/Button'
|
||||
import { FormState } from '../design/FormState'
|
||||
import { AuthenticationForm } from './'
|
||||
import { userSchema } from '../../models/User'
|
||||
import { api } from 'utils/api'
|
||||
import { api } from 'tools/api'
|
||||
import {
|
||||
Tokens,
|
||||
Authentication as AuthenticationClass
|
||||
} from '../../utils/authentication'
|
||||
} from '../../tools/authentication'
|
||||
import { useForm, HandleSubmitCallback } from '../../hooks/useForm'
|
||||
|
||||
export interface AuthenticationProps {
|
||||
|
15
components/Emoji/Emoji.stories.tsx
Normal file
15
components/Emoji/Emoji.stories.tsx
Normal 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 }
|
24
components/Emoji/Emoji.tsx
Normal file
24
components/Emoji/Emoji.tsx
Normal 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}</>
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
15
components/Emoji/EmojiPicker/EmojiPicker.stories.tsx
Normal file
15
components/Emoji/EmojiPicker/EmojiPicker.stories.tsx
Normal 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) }
|
28
components/Emoji/EmojiPicker/EmojiPicker.tsx
Normal file
28
components/Emoji/EmojiPicker/EmojiPicker.tsx
Normal 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}
|
||||
/>
|
||||
)
|
||||
}
|
1
components/Emoji/EmojiPicker/index.ts
Normal file
1
components/Emoji/EmojiPicker/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from './EmojiPicker'
|
71
components/Emoji/emojiPlugin.ts
Normal file
71
components/Emoji/emojiPlugin.ts
Normal 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 }
|
||||
}
|
||||
}
|
||||
}
|
4
components/Emoji/index.ts
Normal file
4
components/Emoji/index.ts
Normal file
@ -0,0 +1,4 @@
|
||||
export * from './Emoji'
|
||||
export * from './EmojiPicker'
|
||||
export * from './emojiPlugin'
|
||||
export * from './isStringWithOnlyOneEmoji'
|
18
components/Emoji/isStringWithOnlyOneEmoji.test.ts
Normal file
18
components/Emoji/isStringWithOnlyOneEmoji.test.ts
Normal 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()
|
||||
})
|
||||
})
|
6
components/Emoji/isStringWithOnlyOneEmoji.ts
Normal file
6
components/Emoji/isStringWithOnlyOneEmoji.ts
Normal 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]
|
||||
}
|
@ -1,7 +1,7 @@
|
||||
import Link from 'next/link'
|
||||
import useTranslation from 'next-translate/useTranslation'
|
||||
|
||||
import { API_VERSION } from '../../utils/api'
|
||||
import { API_VERSION } from '../../tools/api'
|
||||
import { VersionLink } from './VersionLink'
|
||||
|
||||
export interface FooterProps {
|
||||
|
@ -5,15 +5,18 @@ export interface IconLinkProps {
|
||||
selected?: boolean
|
||||
href: string
|
||||
title?: string
|
||||
className?: string
|
||||
}
|
||||
|
||||
export const IconLink: React.FC<IconLinkProps> = (props) => {
|
||||
const { children, selected, href, title } = props
|
||||
const { children, selected, href, title, className } = props
|
||||
|
||||
return (
|
||||
<div className='w-full flex justify-center group'>
|
||||
<Link href={href}>
|
||||
<a className='w-full flex justify-center relative group' title={title}>
|
||||
<Link href={href}>
|
||||
<a className='w-full flex justify-center relative group' title={title}>
|
||||
<div
|
||||
className={classNames('w-full flex justify-center group', className)}
|
||||
>
|
||||
{children}
|
||||
<div className='absolute flex items-center w-3 h-12 left-0'>
|
||||
<span
|
||||
@ -25,8 +28,8 @@ export const IconLink: React.FC<IconLinkProps> = (props) => {
|
||||
)}
|
||||
></span>
|
||||
</div>
|
||||
</a>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
Reference in New Issue
Block a user