feat: design applications and first api calls

Co-authored-by: Walid <87608619+WalidKorchi@users.noreply.github.com>
This commit is contained in:
Divlo 2021-10-24 06:09:43 +02:00
parent 33bd2bb6bf
commit a0fa66e8f5
No known key found for this signature in database
GPG Key ID: 6F24DA54DA3967CF
136 changed files with 14787 additions and 1668 deletions

View File

@ -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

View File

@ -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:

View File

@ -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
View 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"
}
]
]
}

View File

@ -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}
> >

View File

@ -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

View 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>
</>
)
}

View 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 } }

View 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()
})
})

View 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>
)
}

View File

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

View 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 } }

View 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()
})
})

View 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>
)
}

View File

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

View 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_} />
}

View 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()
})
})

View 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>
)
})}
</>
)
}

View File

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

View 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_} />

View 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()
})
})

View 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>
</>
)
}

View File

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

View 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 = {}

View 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()
})
})

View 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>
)
}

View File

@ -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'
}
}

View File

@ -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()
})
})

View File

@ -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>
)
}

View File

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

View File

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

View 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_} />
}

View 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()
})
})

View 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>
)
}

View File

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

View File

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

View 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>
)
}

View 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>
)
}

View 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')
})
})

View 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'
}

View File

@ -0,0 +1,2 @@
export * from './Authentication'
export * from './AuthenticationForm'

View 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' }

View 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}`
)
})
})

View File

@ -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>
) )
} }

View 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>
)
}

View 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
}
}

View 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()
})
})

View 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>
)
}

View File

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

View 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_} />

View 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()
})
})

View 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>
)
}

View File

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

View 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()
})
})

View 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>
</>
)
}

View File

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

View 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()
})
})

View 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>
)
}

View File

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

View 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()
})
})

View 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>
)
}

View File

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

View 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 🙈.'
}

View 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')
})
})

View 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>
</>
)
}

View File

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

View 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_} />
)

View 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')
})
})

View 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>
</>
)
}

View File

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

15
contexts/Channels.tsx Normal file
View 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: ''
}

View File

@ -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
} }

View 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
]

View 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']
}
}
}
}

View 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'
}
}
}

View 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!'
}
}

View 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'
}
}
}

View 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.'
}
}
}

View 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.'
}
}
}

View 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
}

View File

@ -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')
})
})

View 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)
}
})
})

View File

@ -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 🤔.'
)
})
})

View File

@ -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.')
})
})

View 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')
})
})

View 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 🤔.'
)
})
})

View 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
View 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
View 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]
}

View File

@ -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"]
} }
} }

View File

@ -0,0 +1,3 @@
{
"website": "Website"
}

View 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."
}

View File

@ -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."
} }

View File

@ -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>."
} }

View File

@ -0,0 +1,3 @@
{
"website": "Site web"
}

View 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é."
}

View File

@ -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."
} }

View File

@ -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
View 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
View 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