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
|
COMPOSE_PROJECT_NAME=thream-website
|
||||||
|
NEXT_PUBLIC_API_URL=http://localhost:8080
|
||||||
PORT=3000
|
PORT=3000
|
||||||
|
21
.github/workflows/release.yml
vendored
21
.github/workflows/release.yml
vendored
@ -8,10 +8,20 @@ jobs:
|
|||||||
release:
|
release:
|
||||||
runs-on: 'ubuntu-latest'
|
runs-on: 'ubuntu-latest'
|
||||||
steps:
|
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'
|
- name: 'Use Node.js'
|
||||||
uses: 'actions/setup-node@v2.4.1'
|
uses: 'actions/setup-node@v2.4.0'
|
||||||
with:
|
with:
|
||||||
node-version: '16.x'
|
node-version: '16.x'
|
||||||
cache: 'npm'
|
cache: 'npm'
|
||||||
@ -19,6 +29,13 @@ jobs:
|
|||||||
- name: 'Install'
|
- name: 'Install'
|
||||||
run: 'npm 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'
|
- name: 'Deploy to Vercel'
|
||||||
run: 'npm run deploy -- --token="${VERCEL_TOKEN}" --prod'
|
run: 'npm run deploy -- --token="${VERCEL_TOKEN}" --prod'
|
||||||
env:
|
env:
|
||||||
|
@ -4,7 +4,12 @@
|
|||||||
"startServerCommand": "npm run start",
|
"startServerCommand": "npm run start",
|
||||||
"startServerReadyPattern": "ready on",
|
"startServerReadyPattern": "ready on",
|
||||||
"startServerReadyTimeout": 20000,
|
"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
|
"numberOfRuns": 1
|
||||||
},
|
},
|
||||||
"assert": {
|
"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 i18n from '../i18n.json'
|
||||||
import common from '../locales/en/common.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'
|
import '../styles/global.css'
|
||||||
|
|
||||||
@ -18,7 +20,9 @@ addDecorator((story) => (
|
|||||||
<I18nProvider
|
<I18nProvider
|
||||||
lang='en'
|
lang='en'
|
||||||
namespaces={{
|
namespaces={{
|
||||||
common
|
common,
|
||||||
|
authentication,
|
||||||
|
application
|
||||||
}}
|
}}
|
||||||
config={i18n}
|
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>
|
<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 />
|
<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://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>
|
<a href="https://dependabot.com/"><img src="https://badgen.net/github/dependabot/Thream/website?icon=dependabot" alt="Dependabot badge" /></a>
|
||||||
</p>
|
</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).
|
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
|
## ⚙️ Getting Started
|
||||||
|
|
||||||
### Prerequisites
|
### Prerequisites
|
||||||
|
|
||||||
- [Node.js](https://nodejs.org/) >= 14.0.0
|
- [Node.js](https://nodejs.org/) >= 16.0.0
|
||||||
- [npm](https://www.npmjs.com/) >= 7.0.0
|
- [npm](https://www.npmjs.com/) >= 8.0.0
|
||||||
|
|
||||||
### Installation
|
### 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 Link from 'next/link'
|
||||||
import useTranslation from 'next-translate/useTranslation'
|
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 { t } = useTranslation()
|
||||||
|
const { version } = props
|
||||||
|
|
||||||
return (
|
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'>
|
<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>{' '}
|
</Link>{' '}
|
||||||
| {t('common:all-rights-reserved')}
|
| {t('common:all-rights-reserved')}
|
||||||
</p>
|
</p>
|
||||||
|
<p className='mt-1'>
|
||||||
|
<VersionLink repository='website' version={version} /> |{' '}
|
||||||
|
<VersionLink repository='api' version={API_VERSION} />
|
||||||
|
</p>
|
||||||
</footer>
|
</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",
|
"baseUrl": "http://localhost:3000",
|
||||||
"pluginsFile": false,
|
|
||||||
"supportFile": false,
|
"supportFile": false,
|
||||||
"fixturesFolder": false,
|
|
||||||
"video": false,
|
"video": false,
|
||||||
"screenshotOnRunFailure": 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"],
|
"*": ["common"],
|
||||||
"/": ["home"],
|
"/": ["home"],
|
||||||
"/404": ["errors"],
|
"/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",
|
"success": "Success",
|
||||||
"page-not-found": "This page could not be found.",
|
"page-not-found": "This page could not be found.",
|
||||||
"server-error": "Internal Server Error.",
|
"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>."
|
"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",
|
"success": "Succès",
|
||||||
"page-not-found": "Cette page est introuvable.",
|
"page-not-found": "Cette page est introuvable.",
|
||||||
"server-error": "Erreur interne du serveur.",
|
"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>."
|
"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