feat: design applications and first api calls
Co-authored-by: Walid <87608619+WalidKorchi@users.noreply.github.com>
This commit is contained in:
parent
33bd2bb6bf
commit
a0fa66e8f5
@ -1,2 +1,3 @@
|
||||
COMPOSE_PROJECT_NAME=thream-website
|
||||
NEXT_PUBLIC_API_URL=http://localhost:8080
|
||||
PORT=3000
|
||||
|
21
.github/workflows/release.yml
vendored
21
.github/workflows/release.yml
vendored
@ -8,10 +8,20 @@ jobs:
|
||||
release:
|
||||
runs-on: 'ubuntu-latest'
|
||||
steps:
|
||||
- uses: 'actions/checkout@v2.3.5'
|
||||
- uses: 'actions/checkout@v2.3.4'
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
- name: 'Import GPG key'
|
||||
uses: 'crazy-max/ghaction-import-gpg@v4'
|
||||
with:
|
||||
gpg_private_key: ${{ secrets.GPG_PRIVATE_KEY }}
|
||||
git_user_signingkey: true
|
||||
git_commit_gpgsign: true
|
||||
|
||||
- name: 'Use Node.js'
|
||||
uses: 'actions/setup-node@v2.4.1'
|
||||
uses: 'actions/setup-node@v2.4.0'
|
||||
with:
|
||||
node-version: '16.x'
|
||||
cache: 'npm'
|
||||
@ -19,6 +29,13 @@ jobs:
|
||||
- name: 'Install'
|
||||
run: 'npm install'
|
||||
|
||||
- name: 'Release'
|
||||
run: 'npm run release'
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GH_TOKEN }}
|
||||
GIT_COMMITTER_NAME: ${{ secrets.GIT_NAME }}
|
||||
GIT_COMMITTER_EMAIL: ${{ secrets.GIT_EMAIL }}
|
||||
|
||||
- name: 'Deploy to Vercel'
|
||||
run: 'npm run deploy -- --token="${VERCEL_TOKEN}" --prod'
|
||||
env:
|
||||
|
@ -4,7 +4,12 @@
|
||||
"startServerCommand": "npm run start",
|
||||
"startServerReadyPattern": "ready on",
|
||||
"startServerReadyTimeout": 20000,
|
||||
"url": ["http://localhost:3000/"],
|
||||
"url": [
|
||||
"http://localhost:3000/",
|
||||
"http://localhost:3000/authentication/signin",
|
||||
"http://localhost:3000/authentication/signup",
|
||||
"http://localhost:3000/authentication/forgot-password"
|
||||
],
|
||||
"numberOfRuns": 1
|
||||
},
|
||||
"assert": {
|
||||
|
37
.releaserc.json
Normal file
37
.releaserc.json
Normal file
@ -0,0 +1,37 @@
|
||||
{
|
||||
"branches": ["master"],
|
||||
"plugins": [
|
||||
[
|
||||
"@semantic-release/commit-analyzer",
|
||||
{
|
||||
"preset": "conventionalcommits"
|
||||
}
|
||||
],
|
||||
[
|
||||
"@semantic-release/release-notes-generator",
|
||||
{
|
||||
"preset": "conventionalcommits"
|
||||
}
|
||||
],
|
||||
[
|
||||
"@semantic-release/npm",
|
||||
{
|
||||
"npmPublish": false
|
||||
}
|
||||
],
|
||||
[
|
||||
"@semantic-release/git",
|
||||
{
|
||||
"assets": ["package.json", "package-lock.json"],
|
||||
"message": "chore(release): ${nextRelease.version} [skip ci]"
|
||||
}
|
||||
],
|
||||
"@semantic-release/github",
|
||||
[
|
||||
"@saithodev/semantic-release-backmerge",
|
||||
{
|
||||
"backmergeStrategy": "merge"
|
||||
}
|
||||
]
|
||||
]
|
||||
}
|
@ -3,6 +3,8 @@ import I18nProvider from 'next-translate/I18nProvider'
|
||||
|
||||
import i18n from '../i18n.json'
|
||||
import common from '../locales/en/common.json'
|
||||
import authentication from '../locales/en/authentication.json'
|
||||
import application from '../locales/en/application.json'
|
||||
|
||||
import '../styles/global.css'
|
||||
|
||||
@ -18,7 +20,9 @@ addDecorator((story) => (
|
||||
<I18nProvider
|
||||
lang='en'
|
||||
namespaces={{
|
||||
common
|
||||
common,
|
||||
authentication,
|
||||
application
|
||||
}}
|
||||
config={i18n}
|
||||
>
|
||||
|
@ -11,6 +11,7 @@
|
||||
<a href="https://github.com/Thream/website/actions/workflows/test.yml"><img src="https://github.com/Thream/website/actions/workflows/test.yml/badge.svg?branch=develop" /></a>
|
||||
<br />
|
||||
<a href="https://conventionalcommits.org"><img src="https://img.shields.io/badge/Conventional%20Commits-1.0.0-yellow.svg" alt="Conventional Commits" /></a>
|
||||
<a href="https://github.com/semantic-release/semantic-release"><img src="https://img.shields.io/badge/%20%20%F0%9F%93%A6%F0%9F%9A%80-semantic--release-e10079.svg" alt="semantic-release" /></a>
|
||||
<a href="https://dependabot.com/"><img src="https://badgen.net/github/dependabot/Thream/website?icon=dependabot" alt="Dependabot badge" /></a>
|
||||
</p>
|
||||
|
||||
@ -20,12 +21,14 @@ Thream's website to stay close with your friends and communities.
|
||||
|
||||
This project was bootstrapped with [create-fullstack-app](https://github.com/Divlo/create-fullstack-app).
|
||||
|
||||
Using [Thream/api](https://github.com/Thream/api) v1.0.0.
|
||||
|
||||
## ⚙️ Getting Started
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- [Node.js](https://nodejs.org/) >= 14.0.0
|
||||
- [npm](https://www.npmjs.com/) >= 7.0.0
|
||||
- [Node.js](https://nodejs.org/) >= 16.0.0
|
||||
- [npm](https://www.npmjs.com/) >= 8.0.0
|
||||
|
||||
### Installation
|
||||
|
||||
|
257
components/Application/Application.tsx
Normal file
257
components/Application/Application.tsx
Normal file
@ -0,0 +1,257 @@
|
||||
import { useState, useEffect, useMemo } from 'react'
|
||||
import Image from 'next/image'
|
||||
import {
|
||||
CogIcon,
|
||||
PlusIcon,
|
||||
MenuIcon,
|
||||
UsersIcon,
|
||||
XIcon
|
||||
} from '@heroicons/react/solid'
|
||||
import classNames from 'classnames'
|
||||
import { useMediaQuery } from 'react-responsive'
|
||||
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'
|
||||
|
||||
export interface GuildsChannelsPath {
|
||||
guildId: number
|
||||
channelId: number
|
||||
}
|
||||
|
||||
export interface ApplicationProps {
|
||||
path:
|
||||
| '/application'
|
||||
| '/application/guilds/join'
|
||||
| '/application/guilds/create'
|
||||
| '/application/users/[userId]'
|
||||
| GuildsChannelsPath
|
||||
}
|
||||
|
||||
export const Application: React.FC<ApplicationProps> = (props) => {
|
||||
const { children, path } = props
|
||||
|
||||
const { user } = useAuthentication()
|
||||
|
||||
const [visibleSidebars, setVisibleSidebars] = useState({
|
||||
left: true,
|
||||
right: false
|
||||
})
|
||||
|
||||
const [mounted, setMounted] = useState(false)
|
||||
|
||||
const isMobile = useMediaQuery({
|
||||
query: '(max-width: 900px)'
|
||||
})
|
||||
|
||||
const handleToggleSidebars = (direction: DirectionSidebar): void => {
|
||||
if (!isMobile) {
|
||||
if (direction === 'left') {
|
||||
return setVisibleSidebars({
|
||||
...visibleSidebars,
|
||||
left: !visibleSidebars.left
|
||||
})
|
||||
}
|
||||
if (direction === 'right') {
|
||||
return setVisibleSidebars({
|
||||
...visibleSidebars,
|
||||
right: !visibleSidebars.right
|
||||
})
|
||||
}
|
||||
} else {
|
||||
if (direction === 'right' && visibleSidebars.left) {
|
||||
return setVisibleSidebars({
|
||||
left: false,
|
||||
right: true
|
||||
})
|
||||
}
|
||||
if (direction === 'left' && visibleSidebars.right) {
|
||||
return setVisibleSidebars({
|
||||
left: true,
|
||||
right: false
|
||||
})
|
||||
}
|
||||
if (direction === 'left' && !visibleSidebars.right) {
|
||||
return setVisibleSidebars({
|
||||
...visibleSidebars,
|
||||
left: !visibleSidebars.left
|
||||
})
|
||||
}
|
||||
if (direction === 'right' && !visibleSidebars.left) {
|
||||
return setVisibleSidebars({
|
||||
...visibleSidebars,
|
||||
right: !visibleSidebars.right
|
||||
})
|
||||
}
|
||||
}
|
||||
handleCloseSidebars()
|
||||
}
|
||||
|
||||
const handleCloseSidebars = (): void => {
|
||||
if (isMobile && (visibleSidebars.left || visibleSidebars.right)) {
|
||||
setVisibleSidebars({
|
||||
left: false,
|
||||
right: false
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const swipeableHandlers = useSwipeable({
|
||||
trackMouse: false,
|
||||
trackTouch: true,
|
||||
preventDefaultTouchmoveEvent: true,
|
||||
onSwipedRight: () => {
|
||||
if (visibleSidebars.right) {
|
||||
return setVisibleSidebars({ ...visibleSidebars, right: false })
|
||||
}
|
||||
setVisibleSidebars({
|
||||
...visibleSidebars,
|
||||
left: true
|
||||
})
|
||||
},
|
||||
onSwipedLeft: () => {
|
||||
if (visibleSidebars.left) {
|
||||
return setVisibleSidebars({ ...visibleSidebars, left: false })
|
||||
}
|
||||
setVisibleSidebars({
|
||||
...visibleSidebars,
|
||||
right: true
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
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 'Create a Guild'
|
||||
}
|
||||
return 'Application'
|
||||
}, [path])
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true)
|
||||
}, [])
|
||||
|
||||
if (!mounted) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<header className='flex bg-gray-200 dark:bg-gray-800 h-16 px-2 py-3 justify-between items-center shadow-lg z-50'>
|
||||
<IconButton
|
||||
className='p-2 h-10 w-10'
|
||||
onClick={() => handleToggleSidebars('left')}
|
||||
>
|
||||
{!visibleSidebars.left ? <MenuIcon /> : <XIcon />}
|
||||
</IconButton>
|
||||
<div className='text-md text-green-800 dark:text-green-400 font-semibold'>
|
||||
{title}
|
||||
</div>
|
||||
<div className='flex space-x-2'>
|
||||
{title.startsWith('#') && (
|
||||
<IconButton
|
||||
className='p-2 h-10 w-10'
|
||||
onClick={() => handleToggleSidebars('right')}
|
||||
>
|
||||
{!visibleSidebars.right ? <UsersIcon /> : <XIcon />}
|
||||
</IconButton>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main
|
||||
className='relative flex h-full-without-header overflow-hidden'
|
||||
onClick={handleCloseSidebars}
|
||||
{...swipeableHandlers}
|
||||
>
|
||||
<Sidebar
|
||||
direction='left'
|
||||
visible={visibleSidebars.left}
|
||||
isMobile={isMobile}
|
||||
>
|
||||
<div className='flex flex-col min-w-[92px] top-0 left-0 z-50 bg-gray-200 dark:bg-gray-800 border-r-2 border-gray-500 dark:border-white/20 py-2 space-y-2'>
|
||||
<IconLink
|
||||
href={`/application/users/${user.id}`}
|
||||
selected={path === `/application/users/${user.id}`}
|
||||
title='Settings'
|
||||
>
|
||||
<Image
|
||||
className='rounded-full'
|
||||
src='/images/data/divlo.png'
|
||||
alt='logo'
|
||||
width={48}
|
||||
height={48}
|
||||
/>
|
||||
</IconLink>
|
||||
<IconLink
|
||||
href='/application'
|
||||
selected={path === '/application'}
|
||||
title='Join or create a Guild'
|
||||
>
|
||||
<PlusIcon className='w-12 h-12 text-green-800 dark:text-green-400' />
|
||||
</IconLink>
|
||||
<Divider />
|
||||
<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>
|
||||
)}
|
||||
</Sidebar>
|
||||
|
||||
<div
|
||||
className={classNames(
|
||||
'top-0 h-full-without-header flex flex-col flex-1 z-0 overflow-y-auto transition',
|
||||
{
|
||||
'absolute opacity-20':
|
||||
isMobile && (visibleSidebars.left || visibleSidebars.right)
|
||||
}
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
|
||||
<Sidebar
|
||||
direction='right'
|
||||
visible={visibleSidebars.right}
|
||||
isMobile={isMobile}
|
||||
>
|
||||
<Members />
|
||||
</Sidebar>
|
||||
</main>
|
||||
</>
|
||||
)
|
||||
}
|
15
components/Application/Channels/Channels.stories.tsx
Normal file
15
components/Application/Channels/Channels.stories.tsx
Normal file
@ -0,0 +1,15 @@
|
||||
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 } }
|
12
components/Application/Channels/Channels.test.tsx
Normal file
12
components/Application/Channels/Channels.test.tsx
Normal file
@ -0,0 +1,12 @@
|
||||
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()
|
||||
})
|
||||
})
|
36
components/Application/Channels/Channels.tsx
Normal file
36
components/Application/Channels/Channels.tsx
Normal file
@ -0,0 +1,36 @@
|
||||
import Link from 'next/link'
|
||||
import classNames from 'classnames'
|
||||
|
||||
import { GuildsChannelsPath } from '../Application'
|
||||
|
||||
export interface ChannelsProps {
|
||||
path: GuildsChannelsPath
|
||||
}
|
||||
|
||||
export const Channels: React.FC<ChannelsProps> = (props) => {
|
||||
const { path } = props
|
||||
|
||||
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>
|
||||
)
|
||||
}
|
1
components/Application/Channels/index.ts
Normal file
1
components/Application/Channels/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from './Channels'
|
15
components/Application/Guilds/Guilds.stories.tsx
Normal file
15
components/Application/Guilds/Guilds.stories.tsx
Normal file
@ -0,0 +1,15 @@
|
||||
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 } }
|
12
components/Application/Guilds/Guilds.test.tsx
Normal file
12
components/Application/Guilds/Guilds.test.tsx
Normal file
@ -0,0 +1,12 @@
|
||||
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()
|
||||
})
|
||||
})
|
34
components/Application/Guilds/Guilds.tsx
Normal file
34
components/Application/Guilds/Guilds.tsx
Normal file
@ -0,0 +1,34 @@
|
||||
import Image from 'next/image'
|
||||
|
||||
import { ApplicationProps } from '../Application'
|
||||
import { IconLink } from '../../design/IconLink'
|
||||
|
||||
export interface GuildsProps extends ApplicationProps {}
|
||||
|
||||
export const Guilds: React.FC<GuildsProps> = (props) => {
|
||||
const { path } = props
|
||||
|
||||
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>
|
||||
)
|
||||
}
|
1
components/Application/Guilds/index.ts
Normal file
1
components/Application/Guilds/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from './Guilds'
|
14
components/Application/Members/Members.stories.tsx
Normal file
14
components/Application/Members/Members.stories.tsx
Normal file
@ -0,0 +1,14 @@
|
||||
import { Meta, Story } from '@storybook/react'
|
||||
|
||||
import { Members as Component } from './Members'
|
||||
|
||||
const Stories: Meta = {
|
||||
title: 'Members',
|
||||
component: Component
|
||||
}
|
||||
|
||||
export default Stories
|
||||
|
||||
export const Members: Story = (arguments_) => {
|
||||
return <Component {...arguments_} />
|
||||
}
|
10
components/Application/Members/Members.test.tsx
Normal file
10
components/Application/Members/Members.test.tsx
Normal file
@ -0,0 +1,10 @@
|
||||
import { render } from '@testing-library/react'
|
||||
|
||||
import { Members } from './Members'
|
||||
|
||||
describe('<Members />', () => {
|
||||
it('should render successfully', () => {
|
||||
const { baseElement } = render(<Members />)
|
||||
expect(baseElement).toBeTruthy()
|
||||
})
|
||||
})
|
57
components/Application/Members/Members.tsx
Normal file
57
components/Application/Members/Members.tsx
Normal file
@ -0,0 +1,57 @@
|
||||
import Image from 'next/image'
|
||||
|
||||
import { Divider } from '../../design/Divider'
|
||||
|
||||
export const Members: React.FC = () => {
|
||||
return (
|
||||
<>
|
||||
<div className='mb-2'>
|
||||
<h1 className='text-center pt-2 my-2 text-xl'>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>
|
||||
)
|
||||
})}
|
||||
</>
|
||||
)
|
||||
}
|
1
components/Application/Members/index.ts
Normal file
1
components/Application/Members/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from './Members'
|
12
components/Application/Messages/Messages.stories.tsx
Normal file
12
components/Application/Messages/Messages.stories.tsx
Normal file
@ -0,0 +1,12 @@
|
||||
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_} />
|
10
components/Application/Messages/Messages.test.tsx
Normal file
10
components/Application/Messages/Messages.test.tsx
Normal file
@ -0,0 +1,10 @@
|
||||
import { render } from '@testing-library/react'
|
||||
|
||||
import { Messages } from './'
|
||||
|
||||
describe('<Messages />', () => {
|
||||
it('should render successfully', () => {
|
||||
const { baseElement } = render(<Messages />)
|
||||
expect(baseElement).toBeTruthy()
|
||||
})
|
||||
})
|
82
components/Application/Messages/Messages.tsx
Normal file
82
components/Application/Messages/Messages.tsx
Normal file
@ -0,0 +1,82 @@
|
||||
import Image from 'next/image'
|
||||
import TextareaAutosize from 'react-textarea-autosize'
|
||||
|
||||
export const Messages: React.FC = () => {
|
||||
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/divlo.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>
|
||||
<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>
|
||||
</>
|
||||
)
|
||||
}
|
1
components/Application/Messages/index.ts
Normal file
1
components/Application/Messages/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from './Messages'
|
15
components/Application/PopupGuild/PopupGuild.stories.tsx
Normal file
15
components/Application/PopupGuild/PopupGuild.stories.tsx
Normal file
@ -0,0 +1,15 @@
|
||||
import { Meta, Story } from '@storybook/react'
|
||||
|
||||
import { PopupGuild as Component, PopupGuildProps } from './PopupGuild'
|
||||
|
||||
const Stories: Meta = {
|
||||
title: 'PopupGuild',
|
||||
component: Component
|
||||
}
|
||||
|
||||
export default Stories
|
||||
|
||||
export const PopupGuild: Story<PopupGuildProps> = (arguments_) => {
|
||||
return <Component {...arguments_} />
|
||||
}
|
||||
PopupGuild.args = {}
|
10
components/Application/PopupGuild/PopupGuild.test.tsx
Normal file
10
components/Application/PopupGuild/PopupGuild.test.tsx
Normal file
@ -0,0 +1,10 @@
|
||||
import { render } from '@testing-library/react'
|
||||
|
||||
import { PopupGuild } from './PopupGuild'
|
||||
|
||||
describe('<PopupGuild />', () => {
|
||||
it('should render successfully', () => {
|
||||
const { baseElement } = render(<PopupGuild />)
|
||||
expect(baseElement).toBeTruthy()
|
||||
})
|
||||
})
|
56
components/Application/PopupGuild/PopupGuild.tsx
Normal file
56
components/Application/PopupGuild/PopupGuild.tsx
Normal file
@ -0,0 +1,56 @@
|
||||
import { PlusSmIcon, ArrowDownIcon } from '@heroicons/react/solid'
|
||||
import classNames from 'classnames'
|
||||
import Image from 'next/image'
|
||||
|
||||
import { PopupGuildCard } from './PopupGuildCard/PopupGuildCard'
|
||||
export interface PopupGuildProps {
|
||||
className?: string
|
||||
}
|
||||
|
||||
export const PopupGuild: React.FC<PopupGuildProps> = (props) => {
|
||||
const { className } = props
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classNames(
|
||||
className,
|
||||
'flex p-8 flex-wrap justify-center items-center overflow-y-auto h-full-without-header min-w-full'
|
||||
)}
|
||||
>
|
||||
<PopupGuildCard
|
||||
image={
|
||||
<Image
|
||||
src='/images/svg/design/create-guild.svg'
|
||||
alt='Create a guild'
|
||||
draggable='false'
|
||||
width={230}
|
||||
height={230}
|
||||
/>
|
||||
}
|
||||
description='Create your own guild and manage everything within a few clicks !'
|
||||
link={{
|
||||
icon: <PlusSmIcon className='w-8 h-8 mr-2' />,
|
||||
text: 'Create a Guild',
|
||||
href: '/application/guilds/create'
|
||||
}}
|
||||
/>
|
||||
<PopupGuildCard
|
||||
image={
|
||||
<Image
|
||||
src='/images/svg/design/join-guild.svg'
|
||||
alt='Join a Guild'
|
||||
draggable='false'
|
||||
width={200}
|
||||
height={200}
|
||||
/>
|
||||
}
|
||||
description='Talk, meet and have fun with new friends by joining any interesting guild !'
|
||||
link={{
|
||||
icon: <ArrowDownIcon className='w-6 h-6 mr-2' />,
|
||||
text: 'Join a Guild',
|
||||
href: '/application/guilds/join'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
@ -0,0 +1,36 @@
|
||||
import { Meta, Story } from '@storybook/react'
|
||||
import { PlusSmIcon } from '@heroicons/react/solid'
|
||||
import Image from 'next/image'
|
||||
|
||||
import {
|
||||
PopupGuildCard as Component,
|
||||
PopupGuildCardProps
|
||||
} from './PopupGuildCard'
|
||||
|
||||
const Stories: Meta = {
|
||||
title: 'PopupGuildCard',
|
||||
component: Component
|
||||
}
|
||||
|
||||
export default Stories
|
||||
|
||||
export const PopupGuildCard: Story<PopupGuildCardProps> = (arguments_) => {
|
||||
return <Component {...arguments_} />
|
||||
}
|
||||
PopupGuildCard.args = {
|
||||
image: (
|
||||
<Image
|
||||
src='/images/svg/design/create-server.svg'
|
||||
alt=''
|
||||
width={230}
|
||||
height={230}
|
||||
/>
|
||||
),
|
||||
description:
|
||||
'Create your own guild and manage everything within a few clicks !',
|
||||
link: {
|
||||
icon: <PlusSmIcon className='w-8 h-8 mr-2' />,
|
||||
text: 'Create a server',
|
||||
href: '/application/guilds/create'
|
||||
}
|
||||
}
|
@ -0,0 +1,29 @@
|
||||
import { render } from '@testing-library/react'
|
||||
import { PlusSmIcon } from '@heroicons/react/solid'
|
||||
import Image from 'next/image'
|
||||
|
||||
import { PopupGuildCard } from './PopupGuildCard'
|
||||
|
||||
describe('<PopupGuildCard />', () => {
|
||||
it('should render successfully', () => {
|
||||
const { baseElement } = render(
|
||||
<PopupGuildCard
|
||||
image={
|
||||
<Image
|
||||
src='/images/svg/design/create-server.svg'
|
||||
alt=''
|
||||
width={230}
|
||||
height={230}
|
||||
/>
|
||||
}
|
||||
description='Create your own guild and manage everything within a few clicks !'
|
||||
link={{
|
||||
icon: <PlusSmIcon className='w-8 h-8 mr-2' />,
|
||||
text: 'Create a server',
|
||||
href: '/application/guilds/create'
|
||||
}}
|
||||
/>
|
||||
)
|
||||
expect(baseElement).toBeTruthy()
|
||||
})
|
||||
})
|
@ -0,0 +1,32 @@
|
||||
import Link from 'next/link'
|
||||
|
||||
export interface PopupGuildCardProps {
|
||||
image: JSX.Element
|
||||
description: string
|
||||
link: {
|
||||
href: string
|
||||
text: string
|
||||
icon: JSX.Element
|
||||
}
|
||||
}
|
||||
|
||||
export const PopupGuildCard: React.FC<PopupGuildCardProps> = (props) => {
|
||||
const { image, description, link } = props
|
||||
|
||||
return (
|
||||
<div className='w-80 h-96 m-8 rounded-2xl bg-gray-800'>
|
||||
<div className='flex justify-center items-center h-1/2 w-full'>
|
||||
{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>
|
||||
<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}
|
||||
{link.text}
|
||||
</a>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
@ -0,0 +1 @@
|
||||
export * from './PopupGuildCard'
|
1
components/Application/PopupGuild/index.ts
Normal file
1
components/Application/PopupGuild/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from './PopupGuild'
|
14
components/Application/Sidebar/Sidebar.stories.tsx
Normal file
14
components/Application/Sidebar/Sidebar.stories.tsx
Normal file
@ -0,0 +1,14 @@
|
||||
import { Meta, Story } from '@storybook/react'
|
||||
|
||||
import { Sidebar as Component, SidebarProps } from './Sidebar'
|
||||
|
||||
const Stories: Meta = {
|
||||
title: 'Sidebar',
|
||||
component: Component
|
||||
}
|
||||
|
||||
export default Stories
|
||||
|
||||
export const Sidebar: Story<SidebarProps> = (arguments_) => {
|
||||
return <Component {...arguments_} />
|
||||
}
|
12
components/Application/Sidebar/Sidebar.test.tsx
Normal file
12
components/Application/Sidebar/Sidebar.test.tsx
Normal file
@ -0,0 +1,12 @@
|
||||
import { render } from '@testing-library/react'
|
||||
|
||||
import { Sidebar } from './Sidebar'
|
||||
|
||||
describe('<Sidebar />', () => {
|
||||
it('should render successfully', () => {
|
||||
const { baseElement } = render(
|
||||
<Sidebar direction='left' visible={true} isMobile={false} />
|
||||
)
|
||||
expect(baseElement).toBeTruthy()
|
||||
})
|
||||
})
|
36
components/Application/Sidebar/Sidebar.tsx
Normal file
36
components/Application/Sidebar/Sidebar.tsx
Normal file
@ -0,0 +1,36 @@
|
||||
import classNames from 'classnames'
|
||||
|
||||
import { ApplicationProps } from '..'
|
||||
|
||||
export type DirectionSidebar = 'left' | 'right'
|
||||
|
||||
export interface SidebarProps {
|
||||
direction: DirectionSidebar
|
||||
visible: boolean
|
||||
path?: ApplicationProps
|
||||
isMobile: boolean
|
||||
}
|
||||
|
||||
export const Sidebar: React.FC<SidebarProps> = (props) => {
|
||||
const { direction, visible, children, path, isMobile } = props
|
||||
|
||||
return (
|
||||
<nav
|
||||
className={classNames(
|
||||
'h-full-without-header flex z-50 drop-shadow-2xl bg-gray-200 dark:bg-gray-800 transition-all',
|
||||
{
|
||||
'top-0 right-0 scrollbar-firefox-support overflow-y-auto flex-col space-y-1':
|
||||
direction === 'right',
|
||||
'w-64': direction === 'right' && visible,
|
||||
'w-0 opacity-0': !visible,
|
||||
'w-80': direction === 'left' && visible,
|
||||
'max-w-max': typeof path !== 'string' && direction === 'left',
|
||||
'top-0 right-0': direction === 'right' && isMobile,
|
||||
absolute: isMobile
|
||||
}
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</nav>
|
||||
)
|
||||
}
|
1
components/Application/Sidebar/index.ts
Normal file
1
components/Application/Sidebar/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from './Sidebar'
|
1
components/Application/index.ts
Normal file
1
components/Application/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from './Application'
|
194
components/Authentication/Authentication.tsx
Normal file
194
components/Authentication/Authentication.tsx
Normal file
@ -0,0 +1,194 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useRouter } from 'next/router'
|
||||
import Link from 'next/link'
|
||||
import useTranslation from 'next-translate/useTranslation'
|
||||
import { useTheme } from 'next-themes'
|
||||
import { Type } from '@sinclair/typebox'
|
||||
import type { ErrorObject } from 'ajv'
|
||||
import type { HandleForm } from 'react-component-form'
|
||||
import axios from 'axios'
|
||||
|
||||
import { SocialMediaButton } from '../design/SocialMediaButton'
|
||||
import { Main } from '../design/Main'
|
||||
import { Input } from '../design/Input'
|
||||
import { Button } from '../design/Button'
|
||||
import { FormState } from '../design/FormState'
|
||||
import { useFormState } from '../../hooks/useFormState'
|
||||
import { AuthenticationForm } from './'
|
||||
import { userSchema } from '../../models/User'
|
||||
import { ajv } from '../../utils/ajv'
|
||||
import { api } from 'utils/api'
|
||||
import {
|
||||
Tokens,
|
||||
Authentication as AuthenticationClass
|
||||
} from '../../utils/authentication'
|
||||
import { getErrorTranslationKey } from './getErrorTranslationKey'
|
||||
|
||||
interface Errors {
|
||||
[key: string]: ErrorObject<string, any> | null | undefined
|
||||
}
|
||||
|
||||
const findError = (
|
||||
field: string
|
||||
): ((value: ErrorObject, index: number, object: ErrorObject[]) => boolean) => {
|
||||
return (validationError) => validationError.instancePath === field
|
||||
}
|
||||
|
||||
export interface AuthenticationProps {
|
||||
mode: 'signup' | 'signin'
|
||||
}
|
||||
|
||||
export const Authentication: React.FC<AuthenticationProps> = (props) => {
|
||||
const { mode } = props
|
||||
|
||||
const router = useRouter()
|
||||
const { lang, t } = useTranslation()
|
||||
const { theme } = useTheme()
|
||||
const [formState, setFormState] = useFormState()
|
||||
const [messageTranslationKey, setMessageTranslationKey] = useState<
|
||||
string | undefined
|
||||
>(undefined)
|
||||
const [errors, setErrors] = useState<Errors>({
|
||||
name: null,
|
||||
email: null,
|
||||
password: null
|
||||
})
|
||||
|
||||
const validateSchema = useMemo(() => {
|
||||
return Type.Object({
|
||||
...(mode === 'signup' && { name: userSchema.name }),
|
||||
email: userSchema.email,
|
||||
password: userSchema.password
|
||||
})
|
||||
}, [mode])
|
||||
|
||||
const validate = useMemo(() => {
|
||||
return ajv.compile(validateSchema)
|
||||
}, [validateSchema])
|
||||
|
||||
const getErrorTranslation = (error?: ErrorObject | null): string | null => {
|
||||
if (error != null) {
|
||||
return t(getErrorTranslationKey(error)).replace(
|
||||
'{expected}',
|
||||
error?.params?.limit
|
||||
)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const handleSubmit: HandleForm = async (formData, formElement) => {
|
||||
const isValid = validate(formData)
|
||||
if (!isValid) {
|
||||
setFormState('error')
|
||||
const nameError = validate?.errors?.find(findError('/name'))
|
||||
const emailError = validate?.errors?.find(findError('/email'))
|
||||
const passwordError = validate?.errors?.find(findError('/password'))
|
||||
setErrors({
|
||||
name: nameError,
|
||||
email: emailError,
|
||||
password: passwordError
|
||||
})
|
||||
} else {
|
||||
setErrors({})
|
||||
setFormState('loading')
|
||||
if (mode === 'signup') {
|
||||
try {
|
||||
await api.post(
|
||||
`/users/signup?redirectURI=${window.location.origin}/authentication/signin`,
|
||||
{ ...formData, language: lang, theme }
|
||||
)
|
||||
formElement.reset()
|
||||
setFormState('success')
|
||||
setMessageTranslationKey('authentication:success-signup')
|
||||
} catch (error) {
|
||||
setFormState('error')
|
||||
if (axios.isAxiosError(error) && error.response?.status === 400) {
|
||||
setMessageTranslationKey('authentication:alreadyUsed')
|
||||
} else {
|
||||
setMessageTranslationKey('errors:server-error')
|
||||
}
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
const { data } = await api.post<Tokens>('/users/signin', formData)
|
||||
const authentication = new AuthenticationClass(data)
|
||||
authentication.signin()
|
||||
await router.push('/application')
|
||||
} catch (error) {
|
||||
setFormState('error')
|
||||
if (axios.isAxiosError(error) && error.response?.status === 400) {
|
||||
setMessageTranslationKey('authentication:wrong-credentials')
|
||||
} else {
|
||||
setMessageTranslationKey('errors:server-error')
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Main>
|
||||
<section className='flex flex-col sm:items-center sm:w-full'>
|
||||
<div className='flex flex-col items-center justify-center space-y-6 sm:w-4/6 sm:flex-row sm:space-x-6 sm:space-y-0'>
|
||||
<SocialMediaButton socialMedia='Google' />
|
||||
<SocialMediaButton socialMedia='GitHub' />
|
||||
<SocialMediaButton socialMedia='Discord' />
|
||||
</div>
|
||||
</section>
|
||||
<section className='text-center text-lg font-paragraph pt-8'>
|
||||
{t('authentication:or')}
|
||||
</section>
|
||||
<AuthenticationForm onSubmit={handleSubmit}>
|
||||
{mode === 'signup' && (
|
||||
<Input
|
||||
type='text'
|
||||
placeholder={t('authentication:name')}
|
||||
name='name'
|
||||
label={t('authentication:name')}
|
||||
error={getErrorTranslation(errors.name)}
|
||||
/>
|
||||
)}
|
||||
<Input
|
||||
type='email'
|
||||
placeholder='Email'
|
||||
name='email'
|
||||
label='Email'
|
||||
error={getErrorTranslation(errors.email)}
|
||||
/>
|
||||
<Input
|
||||
type='password'
|
||||
placeholder={t('authentication:password')}
|
||||
name='password'
|
||||
label={t('authentication:password')}
|
||||
showForgotPassword={mode === 'signin'}
|
||||
error={getErrorTranslation(errors.password)}
|
||||
/>
|
||||
<Button data-cy='submit' className='w-full mt-6' type='submit'>
|
||||
{t('authentication:submit')}
|
||||
</Button>
|
||||
<p className='mt-3 font-headline text-sm text-green-800 dark:text-green-400 hover:underline'>
|
||||
<Link
|
||||
href={
|
||||
mode === 'signup'
|
||||
? '/authentication/signin'
|
||||
: '/authentication/signup'
|
||||
}
|
||||
>
|
||||
<a>
|
||||
{mode === 'signup'
|
||||
? t('authentication:already-have-an-account')
|
||||
: t('authentication:dont-have-an-account')}
|
||||
</a>
|
||||
</Link>
|
||||
</p>
|
||||
</AuthenticationForm>
|
||||
<FormState
|
||||
id='message'
|
||||
state={formState}
|
||||
message={
|
||||
messageTranslationKey != null ? t(messageTranslationKey) : null
|
||||
}
|
||||
/>
|
||||
</Main>
|
||||
)
|
||||
}
|
16
components/Authentication/AuthenticationForm.tsx
Normal file
16
components/Authentication/AuthenticationForm.tsx
Normal file
@ -0,0 +1,16 @@
|
||||
import classNames from 'classnames'
|
||||
import { Form, FormProps } from 'react-component-form'
|
||||
|
||||
export const AuthenticationForm: React.FC<FormProps> = (props) => {
|
||||
const { className, children, ...rest } = props
|
||||
|
||||
return (
|
||||
<Form
|
||||
className={classNames('w-4/6 max-w-xs', className)}
|
||||
noValidate
|
||||
{...rest}
|
||||
>
|
||||
{children}
|
||||
</Form>
|
||||
)
|
||||
}
|
71
components/Authentication/getErrorTranslationKey.test.ts
Normal file
71
components/Authentication/getErrorTranslationKey.test.ts
Normal file
@ -0,0 +1,71 @@
|
||||
import type { ErrorObject } from 'ajv'
|
||||
|
||||
import { getErrorTranslationKey } from './getErrorTranslationKey'
|
||||
|
||||
const errorObject: ErrorObject = {
|
||||
instancePath: '/path',
|
||||
keyword: 'keyword',
|
||||
params: {},
|
||||
schemaPath: '/path'
|
||||
}
|
||||
|
||||
describe('Authentication/getErrorTranslationKey', () => {
|
||||
it('returns `errors:invalid` with unknown keyword', async () => {
|
||||
expect(
|
||||
getErrorTranslationKey({
|
||||
...errorObject,
|
||||
keyword: 'unknownkeyword'
|
||||
})
|
||||
).toEqual('errors:invalid')
|
||||
})
|
||||
|
||||
it('returns `errors:invalid` with format != email', () => {
|
||||
expect(
|
||||
getErrorTranslationKey({
|
||||
...errorObject,
|
||||
keyword: 'format',
|
||||
params: { format: 'email' }
|
||||
})
|
||||
).toEqual('errors:email')
|
||||
})
|
||||
|
||||
it('returns `errors:email` with format = email', () => {
|
||||
expect(
|
||||
getErrorTranslationKey({
|
||||
...errorObject,
|
||||
keyword: 'format',
|
||||
params: { format: 'email' }
|
||||
})
|
||||
).toEqual('errors:email')
|
||||
})
|
||||
|
||||
it('returns `errors:required` with minLength and limit = 1', () => {
|
||||
expect(
|
||||
getErrorTranslationKey({
|
||||
...errorObject,
|
||||
keyword: 'minLength',
|
||||
params: { limit: 1 }
|
||||
})
|
||||
).toEqual('errors:required')
|
||||
})
|
||||
|
||||
it('returns `errors:minLength` with minLength and limit > 1', () => {
|
||||
expect(
|
||||
getErrorTranslationKey({
|
||||
...errorObject,
|
||||
keyword: 'minLength',
|
||||
params: { limit: 5 }
|
||||
})
|
||||
).toEqual('errors:minLength')
|
||||
})
|
||||
|
||||
it('returns `errors:maxLength` with maxLength', () => {
|
||||
expect(
|
||||
getErrorTranslationKey({
|
||||
...errorObject,
|
||||
keyword: 'maxLength',
|
||||
params: { limit: 5 }
|
||||
})
|
||||
).toEqual('errors:maxLength')
|
||||
})
|
||||
})
|
19
components/Authentication/getErrorTranslationKey.ts
Normal file
19
components/Authentication/getErrorTranslationKey.ts
Normal file
@ -0,0 +1,19 @@
|
||||
import type { ErrorObject } from 'ajv'
|
||||
|
||||
const knownErrorKeywords = ['minLength', 'maxLength', 'format']
|
||||
|
||||
export const getErrorTranslationKey = (error: ErrorObject): string => {
|
||||
if (knownErrorKeywords.includes(error?.keyword)) {
|
||||
if (error.keyword === 'minLength' && error.params.limit === 1) {
|
||||
return 'errors:required'
|
||||
}
|
||||
if (error.keyword === 'format') {
|
||||
if (error.params.format === 'email') {
|
||||
return 'errors:email'
|
||||
}
|
||||
return 'errors:invalid'
|
||||
}
|
||||
return `errors:${error.keyword}`
|
||||
}
|
||||
return 'errors:invalid'
|
||||
}
|
2
components/Authentication/index.ts
Normal file
2
components/Authentication/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export * from './Authentication'
|
||||
export * from './AuthenticationForm'
|
15
components/Footer/Footer.stories.tsx
Normal file
15
components/Footer/Footer.stories.tsx
Normal file
@ -0,0 +1,15 @@
|
||||
import { Meta, Story } from '@storybook/react'
|
||||
|
||||
import { Footer as Component, FooterProps } from './'
|
||||
|
||||
const Stories: Meta = {
|
||||
title: 'Footer',
|
||||
component: Component
|
||||
}
|
||||
|
||||
export default Stories
|
||||
|
||||
export const Footer: Story<FooterProps> = (arguments_) => (
|
||||
<Component {...arguments_} />
|
||||
)
|
||||
Footer.args = { version: '1.0.0' }
|
16
components/Footer/Footer.test.tsx
Normal file
16
components/Footer/Footer.test.tsx
Normal file
@ -0,0 +1,16 @@
|
||||
import { render } from '@testing-library/react'
|
||||
|
||||
import { Footer } from './'
|
||||
|
||||
describe('<Footer />', () => {
|
||||
it('should render with appropriate link tag version', async () => {
|
||||
const version = '1.0.0'
|
||||
const { getByText } = render(<Footer version={version} />)
|
||||
const versionLink = getByText(`website v${version}`) as HTMLAnchorElement
|
||||
expect(getByText('Thream')).toBeInTheDocument()
|
||||
expect(versionLink).toBeInTheDocument()
|
||||
expect(versionLink.href).toEqual(
|
||||
`https://github.com/Thream/website/releases/tag/v${version}`
|
||||
)
|
||||
})
|
||||
})
|
@ -1,8 +1,16 @@
|
||||
import Link from 'next/link'
|
||||
import useTranslation from 'next-translate/useTranslation'
|
||||
|
||||
export const Footer: React.FC = () => {
|
||||
import { API_VERSION } from '../../utils/api'
|
||||
import { VersionLink } from './VersionLink'
|
||||
|
||||
export interface FooterProps {
|
||||
version: string
|
||||
}
|
||||
|
||||
export const Footer: React.FC<FooterProps> = (props) => {
|
||||
const { t } = useTranslation()
|
||||
const { version } = props
|
||||
|
||||
return (
|
||||
<footer className='bg-white flex flex-col items-center justify-center py-6 text-lg border-t-2 border-gray-600 dark:border-gray-400 dark:bg-black'>
|
||||
@ -14,6 +22,10 @@ export const Footer: React.FC = () => {
|
||||
</Link>{' '}
|
||||
| {t('common:all-rights-reserved')}
|
||||
</p>
|
||||
<p className='mt-1'>
|
||||
<VersionLink repository='website' version={version} /> |{' '}
|
||||
<VersionLink repository='api' version={API_VERSION} />
|
||||
</p>
|
||||
</footer>
|
||||
)
|
||||
}
|
||||
|
19
components/Footer/VersionLink.tsx
Normal file
19
components/Footer/VersionLink.tsx
Normal file
@ -0,0 +1,19 @@
|
||||
export interface VersionLinkProps {
|
||||
version: string
|
||||
repository: 'website' | 'api'
|
||||
}
|
||||
|
||||
export const VersionLink: React.FC<VersionLinkProps> = (props) => {
|
||||
const { version, repository } = props
|
||||
|
||||
return (
|
||||
<a
|
||||
className='hover:underline text-green-800 dark:text-green-400'
|
||||
href={`https://github.com/Thream/${repository}/releases/tag/v${version}`}
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
>
|
||||
{repository} v{version}
|
||||
</a>
|
||||
)
|
||||
}
|
21
components/UserProfile/UserProfile.stories.tsx
Normal file
21
components/UserProfile/UserProfile.stories.tsx
Normal file
@ -0,0 +1,21 @@
|
||||
import { Meta, Story } from '@storybook/react'
|
||||
import { user, userSettings } from '../../cypress/fixtures/users/user'
|
||||
|
||||
import { UserProfile as Component, UserProfileProps } from './UserProfile'
|
||||
|
||||
const Stories: Meta = {
|
||||
title: 'UserProfile',
|
||||
component: Component
|
||||
}
|
||||
|
||||
export default Stories
|
||||
|
||||
export const UserProfile: Story<UserProfileProps> = (arguments_) => {
|
||||
return <Component {...arguments_} />
|
||||
}
|
||||
UserProfile.args = {
|
||||
user: {
|
||||
...user,
|
||||
settings: userSettings
|
||||
}
|
||||
}
|
14
components/UserProfile/UserProfile.test.tsx
Normal file
14
components/UserProfile/UserProfile.test.tsx
Normal file
@ -0,0 +1,14 @@
|
||||
import { render } from '@testing-library/react'
|
||||
|
||||
import { user, userSettings } from '../../cypress/fixtures/users/user'
|
||||
|
||||
import { UserProfile } from './UserProfile'
|
||||
|
||||
describe('<UserProfile />', () => {
|
||||
it('should render successfully', () => {
|
||||
const { baseElement } = render(
|
||||
<UserProfile user={{ ...user, settings: userSettings }} />
|
||||
)
|
||||
expect(baseElement).toBeTruthy()
|
||||
})
|
||||
})
|
191
components/UserProfile/UserProfile.tsx
Normal file
191
components/UserProfile/UserProfile.tsx
Normal file
@ -0,0 +1,191 @@
|
||||
import Image from 'next/image'
|
||||
import { PencilIcon, PhotographIcon } from '@heroicons/react/solid'
|
||||
import classNames from 'classnames'
|
||||
import useTranslation from 'next-translate/useTranslation'
|
||||
import date from 'date-and-time'
|
||||
|
||||
import { UserPublic } from '../../models/User'
|
||||
|
||||
export interface UserProfileProps {
|
||||
className?: string
|
||||
isOwner?: boolean
|
||||
user: UserPublic
|
||||
}
|
||||
|
||||
export const UserProfile: React.FC<UserProfileProps> = (props) => {
|
||||
const { user, isOwner = false } = props
|
||||
|
||||
console.log(user)
|
||||
|
||||
const { t } = useTranslation()
|
||||
|
||||
const handleSubmitChanges = (
|
||||
event: React.FormEvent<HTMLFormElement>
|
||||
): void => {
|
||||
event.preventDefault()
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='h-full flex flex-col items-center justify-center'>
|
||||
<div className='min-w-[1080px]'>
|
||||
<div className='flex justify-between items-center'>
|
||||
<div className='w-max flex items-center'>
|
||||
<div
|
||||
className={classNames(
|
||||
'relative flex justify-center items-center rounded-full overflow-hidden transition-all shadow-lg',
|
||||
{
|
||||
'after:absolute after:w-full after:h-full border-4 border-white cursor-pointer':
|
||||
isOwner
|
||||
}
|
||||
)}
|
||||
>
|
||||
{isOwner && (
|
||||
<div className='absolute w-full h-full z-50'>
|
||||
<button className='relative w-full h-full flex items-center justify-center transition hover:-translate-y-1'>
|
||||
<input
|
||||
type='file'
|
||||
className='absolute w-full h-full opacity-0 cursor-pointer'
|
||||
/>
|
||||
<PhotographIcon className='w-14 h-14' />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<Image
|
||||
className={classNames('rounded-full', {
|
||||
'opacity-30': isOwner
|
||||
})}
|
||||
src='/images/data/divlo.png'
|
||||
alt={'Profil Picture'}
|
||||
draggable='false'
|
||||
height={125}
|
||||
width={125}
|
||||
/>
|
||||
</div>
|
||||
<div className='flex flex-col ml-10'>
|
||||
<div className='flex items-center mb-2 border'>
|
||||
<form onSubmit={handleSubmitChanges}>
|
||||
<input
|
||||
type='text'
|
||||
value={user.name}
|
||||
className='min-w-[10px] border bg-transparent text-3xl font-bold space tracking-wide text-white'
|
||||
disabled
|
||||
/>
|
||||
</form>
|
||||
{isOwner && (
|
||||
<button className='ml-2 text-gray-500'>
|
||||
<PencilIcon className='w-6 h-6' />
|
||||
</button>
|
||||
)}
|
||||
<span className='h-5 w-5 bg-error shadow-error ml-4 rounded-full'>
|
||||
{''}
|
||||
</span>
|
||||
<p className='ml-8 text-sm tracking-widest text-white opacity-40 select-none'>
|
||||
{date.format(new Date(user.createdAt), 'DD/MM/YYYY')}
|
||||
</p>
|
||||
</div>
|
||||
<div className='text-left my-2'>
|
||||
{user.website != null && (
|
||||
<p className='font-bold'>
|
||||
{t('application:website')}:{' '}
|
||||
<a
|
||||
href={user.website}
|
||||
className='relative ml-2 opacity-80 hover:opacity-100 transition-all no-underline font-normal tracking-wide after:absolute after:left-0 after:bottom-[-1px] after:bg-black dark:after:bg-white after:h-[1px] after:w-0 after:transition-all hover:after:w-full'
|
||||
>
|
||||
{user.website}
|
||||
</a>
|
||||
</p>
|
||||
)}
|
||||
{user.email != null && (
|
||||
<p className='font-bold'>
|
||||
Email:{' '}
|
||||
<a
|
||||
href={`mailto:${user.email}`}
|
||||
target='_blank'
|
||||
className='relative ml-2 opacity-80 hover:opacity-100 transition-all no-underline font-normal tracking-wide after:absolute after:left-0 after:bottom-[-1px] after:bg-black dark:after:bg-white after:h-[1px] after:w-0 after:transition-all hover:after:w-full'
|
||||
rel='noreferrer'
|
||||
>
|
||||
{user.email}
|
||||
</a>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className='flex -space-x-7'>
|
||||
<div className='flex justify-center items-center rounded-full filter drop-shadow-lg'>
|
||||
<Image
|
||||
className='rounded-full'
|
||||
src='/images/guilds/Guild_1.svg'
|
||||
alt={'Profil Picture'}
|
||||
draggable='false'
|
||||
height={60}
|
||||
width={60}
|
||||
/>
|
||||
</div>
|
||||
<div className='flex justify-center items-center rounded-full filter drop-shadow-lg'>
|
||||
<Image
|
||||
className='rounded-full'
|
||||
src='/images/guilds/Guild_2.svg'
|
||||
alt={'Profil Picture'}
|
||||
draggable='false'
|
||||
height={60}
|
||||
width={60}
|
||||
/>
|
||||
</div>
|
||||
<div className='flex justify-center items-center rounded-full filter drop-shadow-lg'>
|
||||
<Image
|
||||
className='rounded-full'
|
||||
src='/images/guilds/Guild_3.svg'
|
||||
alt={'Profil Picture'}
|
||||
draggable='false'
|
||||
height={60}
|
||||
width={60}
|
||||
/>
|
||||
</div>
|
||||
<div className='flex justify-center items-center rounded-full filter drop-shadow-lg'>
|
||||
<Image
|
||||
className='rounded-full'
|
||||
src='/images/guilds/Guild_4.svg'
|
||||
alt={'Profil Picture'}
|
||||
draggable='false'
|
||||
height={60}
|
||||
width={60}
|
||||
/>
|
||||
</div>
|
||||
<div className='flex justify-center items-center rounded-full filter drop-shadow-lg'>
|
||||
<Image
|
||||
className='rounded-full'
|
||||
src='/images/guilds/Guild_5.svg'
|
||||
alt={'Profil Picture'}
|
||||
draggable='false'
|
||||
height={60}
|
||||
width={60}
|
||||
/>
|
||||
</div>
|
||||
<div className='flex justify-center items-center rounded-full filter drop-shadow-lg'>
|
||||
<Image
|
||||
className='rounded-full'
|
||||
src='/images/guilds/Guild_6.svg'
|
||||
alt={'Profil Picture'}
|
||||
draggable='false'
|
||||
height={60}
|
||||
width={60}
|
||||
/>
|
||||
</div>
|
||||
{/* End of the guilds list */}
|
||||
<div className='w-[60px] h-[60px] flex justify-center items-center rounded-full filter drop-shadow-lg bg-gray-300 dark:bg-gray-800 z-10'>
|
||||
<span className='font-bold text-black dark:text-white text-xl select-none'>
|
||||
+4
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className='mt-7'>
|
||||
{user.biography != null && (
|
||||
<p className='w-[45%]'>{user.biography}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
1
components/UserProfile/index.ts
Normal file
1
components/UserProfile/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from './UserProfile'
|
12
components/design/Divider/Divider.stories.tsx
Normal file
12
components/design/Divider/Divider.stories.tsx
Normal file
@ -0,0 +1,12 @@
|
||||
import { Meta, Story } from '@storybook/react'
|
||||
|
||||
import { Divider as Component } from './'
|
||||
|
||||
const Stories: Meta = {
|
||||
title: 'Divider',
|
||||
component: Component
|
||||
}
|
||||
|
||||
export default Stories
|
||||
|
||||
export const Divider: Story = (arguments_) => <Component {...arguments_} />
|
10
components/design/Divider/Divider.test.tsx
Normal file
10
components/design/Divider/Divider.test.tsx
Normal file
@ -0,0 +1,10 @@
|
||||
import { render } from '@testing-library/react'
|
||||
|
||||
import { Divider } from './'
|
||||
|
||||
describe('<Divider />', () => {
|
||||
it('should render successfully', () => {
|
||||
const { baseElement } = render(<Divider />)
|
||||
expect(baseElement).toBeTruthy()
|
||||
})
|
||||
})
|
7
components/design/Divider/Divider.tsx
Normal file
7
components/design/Divider/Divider.tsx
Normal file
@ -0,0 +1,7 @@
|
||||
export const Divider: React.FC = () => {
|
||||
return (
|
||||
<div className='relative flex justify-center h-[2px] w-full mb-3'>
|
||||
<div className='absolute h-[2px] w-8/12 bg-gray-600 dark:bg-white/20 rounded-full'></div>
|
||||
</div>
|
||||
)
|
||||
}
|
1
components/design/Divider/index.ts
Normal file
1
components/design/Divider/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from './Divider'
|
34
components/design/FormState/FormState.test.tsx
Normal file
34
components/design/FormState/FormState.test.tsx
Normal file
@ -0,0 +1,34 @@
|
||||
import { render } from '@testing-library/react'
|
||||
|
||||
import { FormState } from './'
|
||||
|
||||
describe('<FormState />', () => {
|
||||
it('should return nothing if the state is idle', async () => {
|
||||
const { container } = render(<FormState state='idle' />)
|
||||
expect(container.innerHTML.length).toEqual(0)
|
||||
})
|
||||
|
||||
it('should return nothing if the message is null', async () => {
|
||||
const { container } = render(<FormState state='error' />)
|
||||
expect(container.innerHTML.length).toEqual(0)
|
||||
})
|
||||
|
||||
it('should render the <Loader /> if state is loading', async () => {
|
||||
const { getByTestId } = render(<FormState state='loading' />)
|
||||
expect(getByTestId('loader')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render the success message if state is success', async () => {
|
||||
const message = 'Success Message'
|
||||
const { getByText } = render(
|
||||
<FormState state='success' message={message} />
|
||||
)
|
||||
expect(getByText(message)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render the error message if state is error', async () => {
|
||||
const message = 'Error Message'
|
||||
const { getByText } = render(<FormState state='error' message={message} />)
|
||||
expect(getByText(message)).toBeInTheDocument()
|
||||
})
|
||||
})
|
56
components/design/FormState/FormState.tsx
Normal file
56
components/design/FormState/FormState.tsx
Normal file
@ -0,0 +1,56 @@
|
||||
import classNames from 'classnames'
|
||||
import useTranslation from 'next-translate/useTranslation'
|
||||
|
||||
import { FormState as FormStateType } from 'hooks/useFormState'
|
||||
import { Loader } from '../Loader'
|
||||
|
||||
export interface FormStateProps {
|
||||
state: FormStateType
|
||||
message?: string | null
|
||||
id?: string
|
||||
}
|
||||
|
||||
export const FormState: React.FC<FormStateProps> = (props) => {
|
||||
const { state, message, id } = props
|
||||
const { t } = useTranslation()
|
||||
|
||||
if (state === 'loading') {
|
||||
return (
|
||||
<div data-testid='loader' className='mt-8 flex justify-center'>
|
||||
<Loader />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (state === 'idle' || message == null) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={classNames(
|
||||
'mt-6 relative flex flex-row items-center font-medium text-center max-w-xl',
|
||||
{
|
||||
'text-red-800 dark:text-red-400': state === 'error',
|
||||
'text-green-800 dark:text-green-400': state === 'success'
|
||||
}
|
||||
)}
|
||||
>
|
||||
<div className='thumbnail bg-cover absolute top-0 inline-block font-headline'></div>
|
||||
<span id={id} className={classNames({ 'pl-6': state === 'error' })}>
|
||||
<b>{t(`errors:${state}`)}:</b> {message}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<style jsx>{`
|
||||
.thumbnail {
|
||||
min-width: 20px;
|
||||
width: 20px;
|
||||
height: ${state === 'error' ? '20px' : '25px'};
|
||||
background-image: url('/images/svg/icons/input/${state}.svg');
|
||||
}
|
||||
`}</style>
|
||||
</>
|
||||
)
|
||||
}
|
1
components/design/FormState/index.ts
Normal file
1
components/design/FormState/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from './FormState'
|
15
components/design/IconButton/IconButton.test.tsx
Normal file
15
components/design/IconButton/IconButton.test.tsx
Normal file
@ -0,0 +1,15 @@
|
||||
import { render } from '@testing-library/react'
|
||||
import { CogIcon } from '@heroicons/react/solid'
|
||||
|
||||
import { IconButton } from './IconButton'
|
||||
|
||||
describe('<IconButton />', () => {
|
||||
it('should render successfully', () => {
|
||||
const { baseElement } = render(
|
||||
<IconButton className='h-10 w-10'>
|
||||
<CogIcon />
|
||||
</IconButton>
|
||||
)
|
||||
expect(baseElement).toBeTruthy()
|
||||
})
|
||||
})
|
20
components/design/IconButton/IconButton.tsx
Normal file
20
components/design/IconButton/IconButton.tsx
Normal file
@ -0,0 +1,20 @@
|
||||
import classNames from 'classnames'
|
||||
|
||||
export interface IconButtonProps
|
||||
extends React.ComponentPropsWithoutRef<'button'> {}
|
||||
|
||||
export const IconButton: React.FC<IconButtonProps> = (props) => {
|
||||
const { children, className, ...rest } = props
|
||||
|
||||
return (
|
||||
<button
|
||||
className={classNames(
|
||||
'text-center flex items-center justify-center text-green-800 dark:text-green-400 focus:outline-none focus:animate-pulse hover:animate-pulse',
|
||||
className
|
||||
)}
|
||||
{...rest}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
}
|
1
components/design/IconButton/index.ts
Normal file
1
components/design/IconButton/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from './IconButton'
|
10
components/design/IconLink/IconLink.test.tsx
Normal file
10
components/design/IconLink/IconLink.test.tsx
Normal file
@ -0,0 +1,10 @@
|
||||
import { render } from '@testing-library/react'
|
||||
|
||||
import { IconLink } from './IconLink'
|
||||
|
||||
describe('<IconLink />', () => {
|
||||
it('should render successfully', () => {
|
||||
const { baseElement } = render(<IconLink href='' />)
|
||||
expect(baseElement).toBeTruthy()
|
||||
})
|
||||
})
|
32
components/design/IconLink/IconLink.tsx
Normal file
32
components/design/IconLink/IconLink.tsx
Normal file
@ -0,0 +1,32 @@
|
||||
import Link from 'next/link'
|
||||
import classNames from 'classnames'
|
||||
|
||||
export interface IconLinkProps {
|
||||
selected?: boolean
|
||||
href: string
|
||||
title?: string
|
||||
}
|
||||
|
||||
export const IconLink: React.FC<IconLinkProps> = (props) => {
|
||||
const { children, selected, href, title } = props
|
||||
|
||||
return (
|
||||
<div className='w-full flex justify-center group'>
|
||||
<Link href={href}>
|
||||
<a className='w-full flex justify-center relative group' title={title}>
|
||||
{children}
|
||||
<div className='absolute flex items-center w-3 h-12 left-0'>
|
||||
<span
|
||||
className={classNames(
|
||||
'absolute w-4/12 bg-green-700 rounded-r-lg group-hover:h-5',
|
||||
{
|
||||
'h-full': selected
|
||||
}
|
||||
)}
|
||||
></span>
|
||||
</div>
|
||||
</a>
|
||||
</Link>
|
||||
</div>
|
||||
)
|
||||
}
|
1
components/design/IconLink/index.ts
Normal file
1
components/design/IconLink/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from './IconLink'
|
30
components/design/Input/Input.stories.tsx
Normal file
30
components/design/Input/Input.stories.tsx
Normal file
@ -0,0 +1,30 @@
|
||||
import { Meta, Story } from '@storybook/react'
|
||||
|
||||
import { Input, InputProps } from './Input'
|
||||
import { AuthenticationForm } from '../../Authentication/AuthenticationForm'
|
||||
|
||||
const Stories: Meta = {
|
||||
title: 'Input',
|
||||
component: Input
|
||||
}
|
||||
|
||||
export default Stories
|
||||
|
||||
const Template: Story<InputProps> = (arguments_) => (
|
||||
<AuthenticationForm>
|
||||
<Input {...arguments_} />
|
||||
</AuthenticationForm>
|
||||
)
|
||||
|
||||
export const Text = Template.bind({})
|
||||
Text.args = { label: 'Text', name: 'text', type: 'text' }
|
||||
|
||||
export const Password = Template.bind({})
|
||||
Password.args = { label: 'Password', name: 'password', type: 'password' }
|
||||
|
||||
export const Error = Template.bind({})
|
||||
Error.args = {
|
||||
label: 'Error',
|
||||
type: 'text',
|
||||
error: 'Oops, this field is required 🙈.'
|
||||
}
|
52
components/design/Input/Input.test.tsx
Normal file
52
components/design/Input/Input.test.tsx
Normal file
@ -0,0 +1,52 @@
|
||||
import { render, fireEvent } from '@testing-library/react'
|
||||
|
||||
import { Input, getInputType } from './'
|
||||
|
||||
describe('<Input />', () => {
|
||||
it('should render the label', async () => {
|
||||
const labelContent = 'label content'
|
||||
const { getByText } = render(<Input label={labelContent} />)
|
||||
expect(getByText(labelContent)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render forgot password link', async () => {
|
||||
const { queryByTestId } = render(
|
||||
<Input type='text' label='content' showForgotPassword />
|
||||
)
|
||||
const forgotPasswordLink = queryByTestId('forgot-password-link')
|
||||
expect(forgotPasswordLink).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render forgot password link', async () => {
|
||||
const { queryByTestId } = render(
|
||||
<Input type='password' label='content' showForgotPassword />
|
||||
)
|
||||
const forgotPasswordLink = queryByTestId('forgot-password-link')
|
||||
expect(forgotPasswordLink).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render the eye icon if the input is not of type "password"', async () => {
|
||||
const { queryByTestId } = render(<Input type='text' label='content' />)
|
||||
const passwordEye = queryByTestId('password-eye')
|
||||
expect(passwordEye).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handlePassword with eye icon', async () => {
|
||||
const { findByTestId } = render(<Input type='password' label='content' />)
|
||||
const passwordEye = await findByTestId('password-eye')
|
||||
const input = await findByTestId('input')
|
||||
expect(input).toHaveAttribute('type', 'password')
|
||||
fireEvent.click(passwordEye)
|
||||
expect(input).toHaveAttribute('type', 'text')
|
||||
})
|
||||
})
|
||||
|
||||
describe('getInputType', () => {
|
||||
it('should return `text`', async () => {
|
||||
expect(getInputType('password')).toEqual('text')
|
||||
})
|
||||
|
||||
it('should return `password`', async () => {
|
||||
expect(getInputType('text')).toEqual('password')
|
||||
})
|
||||
})
|
90
components/design/Input/Input.tsx
Normal file
90
components/design/Input/Input.tsx
Normal file
@ -0,0 +1,90 @@
|
||||
import { useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
import useTranslation from 'next-translate/useTranslation'
|
||||
import { FormState } from '../FormState'
|
||||
|
||||
export interface InputProps extends React.ComponentPropsWithRef<'input'> {
|
||||
label: string
|
||||
error?: string | null
|
||||
showForgotPassword?: boolean
|
||||
}
|
||||
|
||||
export const getInputType = (inputType: string): string => {
|
||||
return inputType === 'password' ? 'text' : 'password'
|
||||
}
|
||||
|
||||
export const Input: React.FC<InputProps> = (props) => {
|
||||
const {
|
||||
label,
|
||||
name,
|
||||
type = 'text',
|
||||
showForgotPassword = false,
|
||||
error,
|
||||
...rest
|
||||
} = props
|
||||
const { t } = useTranslation()
|
||||
const [inputType, setInputType] = useState(type)
|
||||
|
||||
const handlePassword = (): void => {
|
||||
const oppositeType = getInputType(inputType)
|
||||
setInputType(oppositeType)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className='flex flex-col'>
|
||||
<div className='flex justify-between mt-6 mb-2'>
|
||||
<label className='pl-1' htmlFor={name}>
|
||||
{label}
|
||||
</label>
|
||||
{type === 'password' && showForgotPassword ? (
|
||||
<Link href='/authentication/forgot-password'>
|
||||
<a
|
||||
className='font-headline text-center text-xs sm:text-sm text-green-800 dark:text-green-400 hover:underline'
|
||||
data-testid='forgot-password-link'
|
||||
>
|
||||
{t('authentication:forgot-password')}
|
||||
</a>
|
||||
</Link>
|
||||
) : null}
|
||||
</div>
|
||||
<div className='mt-0 relative'>
|
||||
<input
|
||||
data-testid='input'
|
||||
data-cy={`input-${name ?? 'name'}`}
|
||||
className='h-11 leading-10 px-3 rounded-lg bg-[#f1f1f1] text-[#2a2a2a] caret-green-600 font-paragraph w-full focus:border focus:outline-none focus:shadow-green border-0'
|
||||
{...rest}
|
||||
id={name}
|
||||
name={name}
|
||||
type={inputType}
|
||||
/>
|
||||
{type === 'password' && (
|
||||
<div
|
||||
data-testid='password-eye'
|
||||
onClick={handlePassword}
|
||||
className='password-eye absolute cursor-pointer bg-cover bg-[#f1f1f1]'
|
||||
/>
|
||||
)}
|
||||
<FormState
|
||||
id={`error-${name ?? 'input'}`}
|
||||
state={error == null ? 'idle' : 'error'}
|
||||
message={error}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style jsx>
|
||||
{`
|
||||
.password-eye {
|
||||
top: 12px;
|
||||
right: 16px;
|
||||
z-index: 1;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
background-image: url('/images/svg/icons/input/${inputType}.svg');
|
||||
}
|
||||
`}
|
||||
</style>
|
||||
</>
|
||||
)
|
||||
}
|
1
components/design/Input/index.ts
Normal file
1
components/design/Input/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from './Input'
|
14
components/design/Loader/Loader.stories.tsx
Normal file
14
components/design/Loader/Loader.stories.tsx
Normal file
@ -0,0 +1,14 @@
|
||||
import { Meta, Story } from '@storybook/react'
|
||||
|
||||
import { Loader as Component, LoaderProps } from './Loader'
|
||||
|
||||
const Stories: Meta = {
|
||||
title: 'Loader',
|
||||
component: Component
|
||||
}
|
||||
|
||||
export default Stories
|
||||
|
||||
export const Loader: Story<LoaderProps> = (arguments_) => (
|
||||
<Component {...arguments_} />
|
||||
)
|
20
components/design/Loader/Loader.test.tsx
Normal file
20
components/design/Loader/Loader.test.tsx
Normal file
@ -0,0 +1,20 @@
|
||||
import { render } from '@testing-library/react'
|
||||
|
||||
import { Loader } from './'
|
||||
|
||||
describe('<Loader />', () => {
|
||||
it('should render with correct width and height', async () => {
|
||||
const size = 20
|
||||
const { findByTestId } = render(<Loader width={size} height={size} />)
|
||||
const progressSpinner = await findByTestId('progress-spinner')
|
||||
expect(progressSpinner).toHaveStyle(`width: ${size}px`)
|
||||
expect(progressSpinner).toHaveStyle(`height: ${size}px`)
|
||||
})
|
||||
|
||||
it('should render with default width and height', async () => {
|
||||
const { findByTestId } = render(<Loader />)
|
||||
const progressSpinner = await findByTestId('progress-spinner')
|
||||
expect(progressSpinner).toHaveStyle('width: 50px')
|
||||
expect(progressSpinner).toHaveStyle('height: 50px')
|
||||
})
|
||||
})
|
81
components/design/Loader/Loader.tsx
Normal file
81
components/design/Loader/Loader.tsx
Normal file
@ -0,0 +1,81 @@
|
||||
export interface LoaderProps {
|
||||
width?: number
|
||||
height?: number
|
||||
}
|
||||
|
||||
export const Loader: React.FC<LoaderProps> = (props) => {
|
||||
const { width = 50, height = 50 } = props
|
||||
|
||||
return (
|
||||
<>
|
||||
<div data-testid='progress-spinner' className='progress-spinner'>
|
||||
<svg className='progress-spinner-svg' viewBox='25 25 50 50'>
|
||||
<circle
|
||||
className='progress-spinner-circle'
|
||||
cx='50'
|
||||
cy='50'
|
||||
r='20'
|
||||
fill='none'
|
||||
strokeWidth='2'
|
||||
strokeMiterlimit='10'
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<style jsx>
|
||||
{`
|
||||
.progress-spinner {
|
||||
position: relative;
|
||||
margin: 0 auto;
|
||||
width: ${width}px;
|
||||
height: ${height}px;
|
||||
display: inline-block;
|
||||
}
|
||||
.progress-spinner::before {
|
||||
content: '';
|
||||
display: block;
|
||||
padding-top: 100%;
|
||||
}
|
||||
.progress-spinner-svg {
|
||||
animation: progress-spinner-rotate 2s linear infinite;
|
||||
height: 100%;
|
||||
transform-origin: center center;
|
||||
width: 100%;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
margin: auto;
|
||||
}
|
||||
.progress-spinner-circle {
|
||||
stroke-dasharray: 89, 200;
|
||||
stroke-dashoffset: 0;
|
||||
stroke: #27b05e;
|
||||
animation: progress-spinner-dash 1.5s ease-in-out infinite;
|
||||
stroke-linecap: round;
|
||||
}
|
||||
@keyframes progress-spinner-rotate {
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
@keyframes progress-spinner-dash {
|
||||
0% {
|
||||
stroke-dasharray: 1, 200;
|
||||
stroke-dashoffset: 0;
|
||||
}
|
||||
50% {
|
||||
stroke-dasharray: 89, 200;
|
||||
stroke-dashoffset: -35px;
|
||||
}
|
||||
100% {
|
||||
stroke-dasharray: 89, 200;
|
||||
stroke-dashoffset: -124px;
|
||||
}
|
||||
}
|
||||
`}
|
||||
</style>
|
||||
</>
|
||||
)
|
||||
}
|
1
components/design/Loader/index.ts
Normal file
1
components/design/Loader/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from './Loader'
|
15
contexts/Channels.tsx
Normal file
15
contexts/Channels.tsx
Normal file
@ -0,0 +1,15 @@
|
||||
export interface ChannelType {
|
||||
id: number
|
||||
name: string
|
||||
description: string
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export const channelExample: ChannelType = {
|
||||
id: 4,
|
||||
name: 'Channel 4',
|
||||
description: '',
|
||||
createdAt: '',
|
||||
updatedAt: ''
|
||||
}
|
@ -1,8 +1,6 @@
|
||||
{
|
||||
"baseUrl": "http://localhost:3000",
|
||||
"pluginsFile": false,
|
||||
"supportFile": false,
|
||||
"fixturesFolder": false,
|
||||
"video": false,
|
||||
"screenshotOnRunFailure": false
|
||||
}
|
||||
|
18
cypress/fixtures/handler.ts
Normal file
18
cypress/fixtures/handler.ts
Normal file
@ -0,0 +1,18 @@
|
||||
import { getUsersCurrentHandler } from './users/current/get'
|
||||
import { postUsersRefreshTokenHandler } from './users/refresh-token/post'
|
||||
|
||||
export interface Handler {
|
||||
method: 'GET' | 'POST' | 'PUT' | 'DELETE'
|
||||
url: string
|
||||
response: {
|
||||
body: any
|
||||
statusCode: number
|
||||
}
|
||||
}
|
||||
|
||||
export type Handlers = Handler[]
|
||||
|
||||
export const authenticationHandlers = [
|
||||
getUsersCurrentHandler,
|
||||
postUsersRefreshTokenHandler
|
||||
]
|
19
cypress/fixtures/users/current/get.ts
Normal file
19
cypress/fixtures/users/current/get.ts
Normal file
@ -0,0 +1,19 @@
|
||||
import { Handler } from '../../handler'
|
||||
|
||||
import { user, userSettings } from '../user'
|
||||
|
||||
export const getUsersCurrentHandler: Handler = {
|
||||
method: 'GET',
|
||||
url: '/users/current',
|
||||
response: {
|
||||
statusCode: 200,
|
||||
body: {
|
||||
user: {
|
||||
...user,
|
||||
settings: userSettings,
|
||||
currentStrategy: 'local',
|
||||
strategies: ['local']
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
14
cypress/fixtures/users/refresh-token/post.ts
Normal file
14
cypress/fixtures/users/refresh-token/post.ts
Normal file
@ -0,0 +1,14 @@
|
||||
import { Handler } from '../../handler'
|
||||
|
||||
export const postUsersRefreshTokenHandler: Handler = {
|
||||
method: 'POST',
|
||||
url: '/users/refresh-token',
|
||||
response: {
|
||||
statusCode: 200,
|
||||
body: {
|
||||
accessToken: 'access-token',
|
||||
expiresIn: 3600000,
|
||||
type: 'Bearer'
|
||||
}
|
||||
}
|
||||
}
|
10
cypress/fixtures/users/reset-password/post.ts
Normal file
10
cypress/fixtures/users/reset-password/post.ts
Normal file
@ -0,0 +1,10 @@
|
||||
import { Handler } from '../../handler'
|
||||
|
||||
export const postUsersResetPasswordHandler: Handler = {
|
||||
method: 'POST',
|
||||
url: '/users/reset-password',
|
||||
response: {
|
||||
statusCode: 200,
|
||||
body: 'Password-reset request successful, please check your emails!'
|
||||
}
|
||||
}
|
23
cypress/fixtures/users/reset-password/put.ts
Normal file
23
cypress/fixtures/users/reset-password/put.ts
Normal file
@ -0,0 +1,23 @@
|
||||
import { Handler } from '../../handler'
|
||||
|
||||
export const putUsersResetPasswordHandler: Handler = {
|
||||
method: 'PUT',
|
||||
url: '/users/reset-password',
|
||||
response: {
|
||||
statusCode: 200,
|
||||
body: 'The new password has been saved!'
|
||||
}
|
||||
}
|
||||
|
||||
export const putUsersResetPasswordInvalidTemporaryTokenHandler: Handler = {
|
||||
method: 'PUT',
|
||||
url: '/users/reset-password',
|
||||
response: {
|
||||
statusCode: 400,
|
||||
body: {
|
||||
statusCode: 400,
|
||||
error: 'Bad Request',
|
||||
message: '"tempToken" is invalid'
|
||||
}
|
||||
}
|
||||
}
|
28
cypress/fixtures/users/signin/post.ts
Normal file
28
cypress/fixtures/users/signin/post.ts
Normal file
@ -0,0 +1,28 @@
|
||||
import { Handler } from '../../handler'
|
||||
|
||||
export const postUsersSigninHandler: Handler = {
|
||||
method: 'POST',
|
||||
url: '/users/signin',
|
||||
response: {
|
||||
statusCode: 200,
|
||||
body: {
|
||||
accessToken: 'access-token',
|
||||
refreshToken: 'refresh-token',
|
||||
expiresIn: 3600000,
|
||||
type: 'Bearer'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const postUsersSigninInvalidCredentialsHandler: Handler = {
|
||||
method: 'POST',
|
||||
url: '/users/signin',
|
||||
response: {
|
||||
statusCode: 400,
|
||||
body: {
|
||||
statusCode: 400,
|
||||
error: 'Bad Request',
|
||||
message: 'Invalid credentials.'
|
||||
}
|
||||
}
|
||||
}
|
30
cypress/fixtures/users/signup/post.ts
Normal file
30
cypress/fixtures/users/signup/post.ts
Normal file
@ -0,0 +1,30 @@
|
||||
import { Handler } from '../../handler'
|
||||
|
||||
import { user, userSettings } from '../user'
|
||||
|
||||
export const postUsersSignupHandler: Handler = {
|
||||
method: 'POST',
|
||||
url: '/users/signup',
|
||||
response: {
|
||||
statusCode: 201,
|
||||
body: {
|
||||
user: {
|
||||
...user,
|
||||
settings: userSettings
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const postUsersSignupAlreadyUsedHandler: Handler = {
|
||||
method: 'POST',
|
||||
url: '/users/signup',
|
||||
response: {
|
||||
statusCode: 400,
|
||||
body: {
|
||||
statusCode: 400,
|
||||
error: 'Bad Request',
|
||||
message: 'body.email or body.name already taken.'
|
||||
}
|
||||
}
|
||||
}
|
26
cypress/fixtures/users/user.ts
Normal file
26
cypress/fixtures/users/user.ts
Normal file
@ -0,0 +1,26 @@
|
||||
import { UserSettings } from '../../../models/UserSettings'
|
||||
import { UserPublic } from '../../../models/User'
|
||||
|
||||
export const user: UserPublic = {
|
||||
id: 1,
|
||||
name: 'Divlo',
|
||||
email: 'contact@divlo.fr',
|
||||
logo: undefined,
|
||||
status: undefined,
|
||||
biography: undefined,
|
||||
website: 'https://divlo.fr',
|
||||
isConfirmed: true,
|
||||
createdAt: '2021-10-20T20:30:51.595Z',
|
||||
updatedAt: '2021-10-20T20:59:08.485Z'
|
||||
}
|
||||
|
||||
export const userSettings: UserSettings = {
|
||||
id: 1,
|
||||
language: 'en',
|
||||
theme: 'dark',
|
||||
isPublicEmail: false,
|
||||
isPublicGuilds: false,
|
||||
createdAt: '2021-10-20T20:30:51.605Z',
|
||||
updatedAt: '2021-10-22T07:22:07.956Z',
|
||||
userId: 1
|
||||
}
|
@ -0,0 +1,17 @@
|
||||
import { authenticationHandlers } from '../../../../fixtures/handler'
|
||||
|
||||
describe('Pages > /application/[guildId]/[channelId]', () => {
|
||||
beforeEach(() => {
|
||||
cy.task('stopMockServer')
|
||||
})
|
||||
|
||||
it('should redirect the user to `/application` if `guildId` or `channelId` are not numbers', () => {
|
||||
cy.task('startMockServer', authenticationHandlers).setCookie(
|
||||
'refreshToken',
|
||||
'refresh-token'
|
||||
)
|
||||
cy.visit('/application/abc/abc')
|
||||
.location('pathname')
|
||||
.should('eq', '/application')
|
||||
})
|
||||
})
|
35
cypress/integration/pages/application/index.spec.ts
Normal file
35
cypress/integration/pages/application/index.spec.ts
Normal file
@ -0,0 +1,35 @@
|
||||
import { authenticationHandlers } from '../../../fixtures/handler'
|
||||
|
||||
const applicationPaths = [
|
||||
'/application',
|
||||
'/application/users/0',
|
||||
'/application/guilds/create',
|
||||
'/application/guilds/join',
|
||||
'/application/0/0'
|
||||
]
|
||||
|
||||
describe('Pages > /application', () => {
|
||||
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).setCookie(
|
||||
'refreshToken',
|
||||
'refresh-token'
|
||||
)
|
||||
for (const applicationPath of applicationPaths) {
|
||||
cy.visit(applicationPath)
|
||||
.location('pathname')
|
||||
.should('eq', applicationPath)
|
||||
}
|
||||
})
|
||||
})
|
@ -0,0 +1,37 @@
|
||||
import { postUsersResetPasswordHandler } from '../../../fixtures/users/reset-password/post'
|
||||
import { user } from '../../../fixtures/users/user'
|
||||
|
||||
describe('Pages > /authentication/forgot-password', () => {
|
||||
beforeEach(() => {
|
||||
cy.task('stopMockServer')
|
||||
cy.visit('/authentication/forgot-password')
|
||||
})
|
||||
|
||||
it('should succeeds and sends a password-reset request', () => {
|
||||
cy.task('startMockServer', [postUsersResetPasswordHandler])
|
||||
cy.get('#message').should('not.exist')
|
||||
cy.get('[data-cy=input-email]').type(user.email)
|
||||
cy.get('[data-cy=submit]').click()
|
||||
cy.get('#message').should(
|
||||
'have.text',
|
||||
'Success: Password-reset request successful, please check your emails!'
|
||||
)
|
||||
})
|
||||
|
||||
it('should fails with unreachable api server', () => {
|
||||
cy.get('#message').should('not.exist')
|
||||
cy.get('[data-cy=input-email]').type(user.email)
|
||||
cy.get('[data-cy=submit]').click()
|
||||
cy.get('#message').should('have.text', 'Error: Internal Server Error.')
|
||||
})
|
||||
|
||||
it('should fails with wrong email format', () => {
|
||||
cy.get('#message').should('not.exist')
|
||||
cy.get('[data-cy=input-email]').type('test')
|
||||
cy.get('[data-cy=submit]').click()
|
||||
cy.get('#message').should(
|
||||
'have.text',
|
||||
'Error: Mmm… It seems that this email is not valid 🤔.'
|
||||
)
|
||||
})
|
||||
})
|
@ -0,0 +1,45 @@
|
||||
import {
|
||||
putUsersResetPasswordHandler,
|
||||
putUsersResetPasswordInvalidTemporaryTokenHandler
|
||||
} from '../../../fixtures/users/reset-password/put'
|
||||
|
||||
describe('Pages > /authentication/reset-password', () => {
|
||||
beforeEach(() => {
|
||||
cy.task('stopMockServer')
|
||||
})
|
||||
|
||||
it('should succeeds and redirect user to sign in page', () => {
|
||||
cy.task('startMockServer', [putUsersResetPasswordHandler])
|
||||
cy.visit('/authentication/reset-password?temporaryToken=abcdefg')
|
||||
cy.get('#message').should('not.exist')
|
||||
cy.get('[data-cy=input-password]').type('somepassword')
|
||||
cy.get('[data-cy=submit]').click()
|
||||
cy.location('pathname').should('eq', '/authentication/signin')
|
||||
})
|
||||
|
||||
it('should fails with invalid `temporaryToken`', () => {
|
||||
cy.task('startMockServer', [
|
||||
putUsersResetPasswordInvalidTemporaryTokenHandler
|
||||
])
|
||||
cy.visit('/authentication/reset-password')
|
||||
cy.get('#message').should('not.exist')
|
||||
cy.get('[data-cy=input-password]').type('somepassword')
|
||||
cy.get('[data-cy=submit]').click()
|
||||
cy.get('#message').should('have.text', 'Error: Invalid value.')
|
||||
})
|
||||
|
||||
it('should fails with unreachable api server', () => {
|
||||
cy.visit('/authentication/reset-password')
|
||||
cy.get('#message').should('not.exist')
|
||||
cy.get('[data-cy=input-password]').type('randompassword')
|
||||
cy.get('[data-cy=submit]').click()
|
||||
cy.get('#message').should('have.text', 'Error: Internal Server Error.')
|
||||
})
|
||||
|
||||
it('should fails with empty password value', () => {
|
||||
cy.visit('/authentication/reset-password')
|
||||
cy.get('#message').should('not.exist')
|
||||
cy.get('[data-cy=submit]').click()
|
||||
cy.get('#message').should('have.text', 'Error: Invalid value.')
|
||||
})
|
||||
})
|
55
cypress/integration/pages/authentication/signin.spec.ts
Normal file
55
cypress/integration/pages/authentication/signin.spec.ts
Normal file
@ -0,0 +1,55 @@
|
||||
import { authenticationHandlers } from '../../../fixtures/handler'
|
||||
import {
|
||||
postUsersSigninHandler,
|
||||
postUsersSigninInvalidCredentialsHandler
|
||||
} from 'cypress/fixtures/users/signin/post'
|
||||
import { user } from '../../../fixtures/users/user'
|
||||
|
||||
describe('Pages > /authentication/signin', () => {
|
||||
beforeEach(() => {
|
||||
cy.task('stopMockServer')
|
||||
cy.visit('/authentication/signin')
|
||||
})
|
||||
|
||||
it('should succeeds and sign in the user', () => {
|
||||
cy.task('startMockServer', [
|
||||
...authenticationHandlers,
|
||||
postUsersSigninHandler
|
||||
])
|
||||
cy.get('#error-email').should('not.exist')
|
||||
cy.get('#error-password').should('not.exist')
|
||||
cy.get('[data-cy=input-email]').type(user.email)
|
||||
cy.get('[data-cy=input-password]').type('randompassword')
|
||||
cy.get('[data-cy=submit]').click()
|
||||
cy.location('pathname').should('eq', '/application')
|
||||
})
|
||||
|
||||
it('should fails with unreachable api server', () => {
|
||||
cy.get('#error-email').should('not.exist')
|
||||
cy.get('#error-password').should('not.exist')
|
||||
cy.get('[data-cy=input-email]').type(user.email)
|
||||
cy.get('[data-cy=input-password]').type('randompassword')
|
||||
cy.get('[data-cy=submit]').click()
|
||||
cy.get('#message').should('have.text', 'Error: Internal Server Error.')
|
||||
cy.get('#error-email').should('not.exist')
|
||||
cy.get('#error-password').should('not.exist')
|
||||
})
|
||||
|
||||
it('should fails with invalid credentials', () => {
|
||||
cy.task('startMockServer', [
|
||||
...authenticationHandlers,
|
||||
postUsersSigninInvalidCredentialsHandler
|
||||
])
|
||||
cy.get('#error-email').should('not.exist')
|
||||
cy.get('#error-password').should('not.exist')
|
||||
cy.get('[data-cy=input-email]').type(user.email)
|
||||
cy.get('[data-cy=input-password]').type('randompassword')
|
||||
cy.get('[data-cy=submit]').click()
|
||||
cy.get('#message').should(
|
||||
'have.text',
|
||||
'Error: Invalid credentials. Please try again.'
|
||||
)
|
||||
cy.get('#error-email').should('not.exist')
|
||||
cy.get('#error-password').should('not.exist')
|
||||
})
|
||||
})
|
85
cypress/integration/pages/authentication/signup.spec.ts
Normal file
85
cypress/integration/pages/authentication/signup.spec.ts
Normal file
@ -0,0 +1,85 @@
|
||||
import { user } from '../../../fixtures/users/user'
|
||||
import {
|
||||
postUsersSignupHandler,
|
||||
postUsersSignupAlreadyUsedHandler
|
||||
} from '../../../fixtures/users/signup/post'
|
||||
|
||||
describe('Pages > /authentication/signup', () => {
|
||||
beforeEach(() => {
|
||||
cy.task('stopMockServer')
|
||||
cy.visit('/authentication/signup')
|
||||
})
|
||||
|
||||
it('should succeeds and sign up the user', () => {
|
||||
cy.task('startMockServer', [postUsersSignupHandler])
|
||||
cy.get('#error-name').should('not.exist')
|
||||
cy.get('#error-email').should('not.exist')
|
||||
cy.get('#error-password').should('not.exist')
|
||||
cy.get('[data-cy=input-name]').type(user.name)
|
||||
cy.get('[data-cy=input-email]').type(user.email)
|
||||
cy.get('[data-cy=input-password]').type('randompassword')
|
||||
cy.get('[data-cy=submit]').click()
|
||||
cy.get('#message').should(
|
||||
'have.text',
|
||||
"Success: You're almost there, please check your emails to confirm registration."
|
||||
)
|
||||
})
|
||||
|
||||
it('should fails with name or email already used', () => {
|
||||
cy.task('startMockServer', [postUsersSignupAlreadyUsedHandler])
|
||||
cy.get('#error-name').should('not.exist')
|
||||
cy.get('#error-email').should('not.exist')
|
||||
cy.get('#error-password').should('not.exist')
|
||||
cy.get('[data-cy=input-name]').type(user.name)
|
||||
cy.get('[data-cy=input-email]').type(user.email)
|
||||
cy.get('[data-cy=input-password]').type('randompassword')
|
||||
cy.get('[data-cy=submit]').click()
|
||||
cy.get('#message').should('have.text', 'Error: Name or Email already used.')
|
||||
cy.get('#error-name').should('not.exist')
|
||||
cy.get('#error-email').should('not.exist')
|
||||
cy.get('#error-password').should('not.exist')
|
||||
})
|
||||
|
||||
it('should fails with unreachable api server', () => {
|
||||
cy.get('#error-name').should('not.exist')
|
||||
cy.get('#error-email').should('not.exist')
|
||||
cy.get('#error-password').should('not.exist')
|
||||
cy.get('[data-cy=input-name]').type(user.name)
|
||||
cy.get('[data-cy=input-email]').type(user.email)
|
||||
cy.get('[data-cy=input-password]').type('randompassword')
|
||||
cy.get('[data-cy=submit]').click()
|
||||
cy.get('#message').should('have.text', 'Error: Internal Server Error.')
|
||||
cy.get('#error-name').should('not.exist')
|
||||
cy.get('#error-email').should('not.exist')
|
||||
cy.get('#error-password').should('not.exist')
|
||||
})
|
||||
|
||||
it('should fails with all inputs as required with error messages and update error messages when updating language (translation)', () => {
|
||||
const requiredErrorMessage = {
|
||||
en: 'Error: Oops, this field is required 🙈.',
|
||||
fr: 'Erreur: Oups, ce champ est obligatoire 🙈.'
|
||||
}
|
||||
cy.get('#error-name').should('not.exist')
|
||||
cy.get('#error-email').should('not.exist')
|
||||
cy.get('#error-password').should('not.exist')
|
||||
cy.get('[data-cy=submit]').click()
|
||||
cy.get('#error-name').should('have.text', requiredErrorMessage.en)
|
||||
cy.get('#error-email').should('have.text', requiredErrorMessage.en)
|
||||
cy.get('#error-password').should('have.text', requiredErrorMessage.en)
|
||||
cy.get('[data-cy=language-click]').click()
|
||||
cy.get('[data-cy=languages-list] > li:first-child').contains('FR').click()
|
||||
cy.get('#error-name').should('have.text', requiredErrorMessage.fr)
|
||||
cy.get('#error-email').should('have.text', requiredErrorMessage.fr)
|
||||
cy.get('#error-password').should('have.text', requiredErrorMessage.fr)
|
||||
})
|
||||
|
||||
it('should fails with wrong email format', () => {
|
||||
cy.get('#error-email').should('not.exist')
|
||||
cy.get('[data-cy=input-email]').type('test')
|
||||
cy.get('[data-cy=submit]').click()
|
||||
cy.get('#error-email').should(
|
||||
'have.text',
|
||||
'Error: Mmm… It seems that this email is not valid 🤔.'
|
||||
)
|
||||
})
|
||||
})
|
12
cypress/integration/pages/index.spec.ts
Normal file
12
cypress/integration/pages/index.spec.ts
Normal file
@ -0,0 +1,12 @@
|
||||
describe('Page > /', () => {
|
||||
beforeEach(() => {
|
||||
cy.visit('/')
|
||||
})
|
||||
|
||||
it('should redirect the user to signup page when clicking "Get started"', () => {
|
||||
cy.get('[data-cy=get-started]')
|
||||
.click()
|
||||
.location('pathname')
|
||||
.should('eq', '/authentication/signup')
|
||||
})
|
||||
})
|
40
cypress/plugins/index.js
Normal file
40
cypress/plugins/index.js
Normal file
@ -0,0 +1,40 @@
|
||||
import { getLocal } from 'mockttp'
|
||||
|
||||
/// <reference types="cypress" />
|
||||
|
||||
/** @type {import('mockttp').Mockttp | null} */
|
||||
let server = null
|
||||
|
||||
/**
|
||||
* @type {Cypress.PluginConfig}
|
||||
*/
|
||||
module.exports = (on, config) => {
|
||||
on('task', {
|
||||
/**
|
||||
* @param {import('../fixtures/handler').Handlers} handlers
|
||||
*/
|
||||
async startMockServer(handlers) {
|
||||
server = getLocal({
|
||||
cors: true
|
||||
})
|
||||
await server.start(8080)
|
||||
for (const handler of handlers) {
|
||||
await server[handler.method.toLowerCase()](handler.url).thenJson(
|
||||
handler.response.statusCode,
|
||||
handler.response.body
|
||||
)
|
||||
}
|
||||
return null
|
||||
},
|
||||
|
||||
async stopMockServer() {
|
||||
if (server != null) {
|
||||
await server.stop()
|
||||
server = null
|
||||
}
|
||||
return null
|
||||
}
|
||||
})
|
||||
|
||||
return config
|
||||
}
|
15
hooks/useFormState.tsx
Normal file
15
hooks/useFormState.tsx
Normal file
@ -0,0 +1,15 @@
|
||||
import { useState } from 'react'
|
||||
|
||||
export const formState = ['idle', 'loading', 'error', 'success'] as const
|
||||
|
||||
export type FormState = typeof formState[number]
|
||||
|
||||
export const useFormState = (
|
||||
initialFormState: FormState = 'idle'
|
||||
): [
|
||||
formState: FormState,
|
||||
setFormState: React.Dispatch<React.SetStateAction<FormState>>
|
||||
] => {
|
||||
const [formState, setFormState] = useState<FormState>(initialFormState)
|
||||
return [formState, setFormState]
|
||||
}
|
@ -5,6 +5,11 @@
|
||||
"*": ["common"],
|
||||
"/": ["home"],
|
||||
"/404": ["errors"],
|
||||
"/500": ["errors"]
|
||||
"/500": ["errors"],
|
||||
"/authentication/forgot-password": ["authentication", "errors"],
|
||||
"/authentication/reset-password": ["authentication", "errors"],
|
||||
"/authentication/signup": ["authentication", "errors"],
|
||||
"/authentication/signin": ["authentication", "errors"],
|
||||
"/application/users/[userId]": ["application"]
|
||||
}
|
||||
}
|
||||
|
3
locales/en/application.json
Normal file
3
locales/en/application.json
Normal file
@ -0,0 +1,3 @@
|
||||
{
|
||||
"website": "Website"
|
||||
}
|
17
locales/en/authentication.json
Normal file
17
locales/en/authentication.json
Normal file
@ -0,0 +1,17 @@
|
||||
{
|
||||
"or": "OR",
|
||||
"password": "Password",
|
||||
"name": "Name",
|
||||
"already-have-an-account": "Already have an account?",
|
||||
"dont-have-an-account": "Don't have an account?",
|
||||
"submit": "Submit",
|
||||
"forgot-password": "Forgot your password?",
|
||||
"already-know-password": "Already know your password?",
|
||||
"signup": "Signup",
|
||||
"signin": "Signin",
|
||||
"reset-password": "Reset Password",
|
||||
"success-signup": "You're almost there, please check your emails to confirm registration.",
|
||||
"success-forgot-password": "Password-reset request successful, please check your emails!",
|
||||
"wrong-credentials": "Invalid credentials. Please try again.",
|
||||
"alreadyUsed": "Name or Email already used."
|
||||
}
|
@ -3,5 +3,10 @@
|
||||
"success": "Success",
|
||||
"page-not-found": "This page could not be found.",
|
||||
"server-error": "Internal Server Error.",
|
||||
"return-to-home-page": "Return to the home page?"
|
||||
"return-to-home-page": "Return to the home page?",
|
||||
"required": "Oops, this field is required 🙈.",
|
||||
"minLength": "The field must contain at least {expected} characters.",
|
||||
"maxLength": "The field must contain at most {expected} characters.",
|
||||
"email": "Mmm… It seems that this email is not valid 🤔.",
|
||||
"invalid": "Invalid value."
|
||||
}
|
||||
|
@ -1,4 +1,4 @@
|
||||
{
|
||||
"get-started": "Coming soon",
|
||||
"get-started": "Get started",
|
||||
"description": "Your <0>open source</0> platform to stay close with your friends and communities, <0>talk</0>, chat, <0>collaborate</0>, share and <0>have fun</0>."
|
||||
}
|
||||
|
3
locales/fr/application.json
Normal file
3
locales/fr/application.json
Normal file
@ -0,0 +1,3 @@
|
||||
{
|
||||
"website": "Site web"
|
||||
}
|
17
locales/fr/authentication.json
Normal file
17
locales/fr/authentication.json
Normal file
@ -0,0 +1,17 @@
|
||||
{
|
||||
"or": "OU",
|
||||
"password": "Mot de passe",
|
||||
"name": "Nom",
|
||||
"already-have-an-account": "Vous avez déjà un compte ?",
|
||||
"dont-have-an-account": "Vous n'avez pas de compte ?",
|
||||
"submit": "Soumettre",
|
||||
"forgot-password": "Mot de passe oublié ?",
|
||||
"already-know-password": "Vous connaissez déjà votre mot de passe ?",
|
||||
"signup": "S'inscrire",
|
||||
"signin": "Se connecter",
|
||||
"reset-password": "Réinitialiser le mot de passe",
|
||||
"success-signup": "Vous y êtes presque, veuillez vérifier vos emails pour confirmer votre inscription.",
|
||||
"success-forgot-password": "Demande de réinitialisation du mot de passe réussie, veuillez vérifier vos emails!",
|
||||
"wrong-credentials": "Informations d'identification invalides. Veuillez réessayer.",
|
||||
"alreadyUsed": "Nom ou Email déjà utilisé."
|
||||
}
|
@ -3,5 +3,10 @@
|
||||
"success": "Succès",
|
||||
"page-not-found": "Cette page est introuvable.",
|
||||
"server-error": "Erreur interne du serveur.",
|
||||
"return-to-home-page": "Revenir à la page d'accueil ?"
|
||||
"return-to-home-page": "Revenir à la page d'accueil ?",
|
||||
"required": "Oups, ce champ est obligatoire 🙈.",
|
||||
"minLength": "Le champ doit contenir au moins {expected} caractères.",
|
||||
"maxLength": "Le champ doit contenir au plus {expected} caractères.",
|
||||
"email": "Mmm… Il semblerait que cet email ne soit pas valide 🤔.",
|
||||
"invalid": "Valeur invalide."
|
||||
}
|
||||
|
@ -1,4 +1,4 @@
|
||||
{
|
||||
"get-started": "Bientôt disponible",
|
||||
"get-started": "Lancez-vous",
|
||||
"description": "Votre plateforme <0>open source</0> pour rester proche de vos amis et communautés, <0>parler</0>, discuter, <0>collaborer</0>, partager et <0>amusez-vous</0>."
|
||||
}
|
||||
|
13
models/Channel.ts
Normal file
13
models/Channel.ts
Normal file
@ -0,0 +1,13 @@
|
||||
import { Type } from '@sinclair/typebox'
|
||||
|
||||
import { date, id } from './utils'
|
||||
|
||||
export const types = [Type.Literal('text')]
|
||||
|
||||
export const channelSchema = {
|
||||
id,
|
||||
name: Type.String({ maxLength: 255 }),
|
||||
createdAt: date.createdAt,
|
||||
updatedAt: date.updatedAt,
|
||||
guildId: id
|
||||
}
|
12
models/Guild.ts
Normal file
12
models/Guild.ts
Normal file
@ -0,0 +1,12 @@
|
||||
import { Type } from '@sinclair/typebox'
|
||||
|
||||
import { date, id } from './utils'
|
||||
|
||||
export const guildSchema = {
|
||||
id,
|
||||
name: Type.String({ minLength: 3, maxLength: 30 }),
|
||||
icon: Type.String({ format: 'uri-reference' }),
|
||||
description: Type.String({ maxLength: 160 }),
|
||||
createdAt: date.createdAt,
|
||||
updatedAt: date.updatedAt
|
||||
}
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user