chore: initial commit

This commit is contained in:
Divlo 2021-10-24 05:19:39 +02:00
commit 21123c4477
No known key found for this signature in database
GPG Key ID: 6F24DA54DA3967CF
145 changed files with 48821 additions and 0 deletions

12
.babelrc.json Normal file
View File

@ -0,0 +1,12 @@
{
"presets": [
[
"next/babel",
{
"styled-jsx": {
"plugins": ["@styled-jsx/plugin-sass"]
}
}
]
]
}

1
.commitlintrc.json Normal file
View File

@ -0,0 +1 @@
{ "extends": ["@commitlint/config-conventional"] }

11
.dockerignore Normal file
View File

@ -0,0 +1,11 @@
.vscode
.git
.next
build
coverage
dist
node_modules
out
**/workbox-*.js
**/sw.js
**/__test__/**

11
.editorconfig Normal file
View File

@ -0,0 +1,11 @@
# For more information see: https://editorconfig.org/
root = true
[*]
indent_style = space
indent_size = 2
end_of_line = lf
charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = true

3
.env.example Normal file
View File

@ -0,0 +1,3 @@
COMPOSE_PROJECT_NAME=thream-website
PORT=3000
NEXT_PUBLIC_API_URL=http://localhost:8080

34
.gitignore vendored Normal file
View File

@ -0,0 +1,34 @@
# dependencies
node_modules
.npm
# next.js
.next
out
# production
build
dist
# testing
coverage
# PWA
**/workbox-*.js
**/sw.js
# envs
.env
.env.production
# debug
npm-debug.log*
# editors
.vscode
.theia
.idea
# misc
.DS_Store
.lighthouseci

1
.husky/.gitignore vendored Normal file
View File

@ -0,0 +1 @@
_

4
.husky/commit-msg Executable file
View File

@ -0,0 +1,4 @@
#!/bin/sh
. "$(dirname "$0")/_/husky.sh"
npm run lint:commit -- --edit

7
.husky/pre-commit Executable file
View File

@ -0,0 +1,7 @@
#!/bin/sh
. "$(dirname "$0")/_/husky.sh"
npm run lint:docker
npm run lint:editorconfig
npm run lint:markdown
npm run lint:typescript

34
.lighthouserc.json Normal file
View File

@ -0,0 +1,34 @@
{
"ci": {
"collect": {
"startServerCommand": "npm run start",
"startServerReadyPattern": "ready on",
"startServerReadyTimeout": 20000,
"url": ["http://localhost:3000/", "http://localhost:3000/authentication/signup"],
"numberOfRuns": 3,
"settings": {
"chromeFlags": "--no-sandbox"
}
},
"assert": {
"preset": "lighthouse:recommended",
"assertions": {
"legacy-javascript": "off",
"unused-javascript": "off",
"uses-rel-preload": "off",
"canonical": "off",
"unsized-images": "off",
"uses-responsive-images": "off",
"bypass": "warning",
"color-contrast": "warning",
"preload-lcp-image": "warning",
"errors-in-console": "warning",
"service-worker": "warning"
}
},
"upload": {
"target": "temporary-public-storage"
},
"server": {}
}
}

7
.markdownlint.json Normal file
View File

@ -0,0 +1,7 @@
{
"default": true,
"MD013": false,
"MD024": false,
"MD033": false,
"MD041": false
}

1
.npmrc Normal file
View File

@ -0,0 +1 @@
save-exact=true

132
CODE_OF_CONDUCT.md Normal file
View File

@ -0,0 +1,132 @@
# Contributor Covenant Code of Conduct
## Our Pledge
We as members, contributors, and leaders pledge to make participation in our
community a harassment-free experience for everyone, regardless of age, body
size, visible or invisible disability, ethnicity, sex characteristics, gender
identity and expression, level of experience, education, socio-economic status,
nationality, personal appearance, race, religion, or sexual identity
and orientation.
We pledge to act and interact in ways that contribute to an open, welcoming,
diverse, inclusive, and healthy community.
## Our Standards
Examples of behavior that contributes to a positive environment for our
community include:
- Demonstrating empathy and kindness toward other people
- Being respectful of differing opinions, viewpoints, and experiences
- Giving and gracefully accepting constructive feedback
- Accepting responsibility and apologizing to those affected by our mistakes,
and learning from the experience
- Focusing on what is best not just for us as individuals, but for the
overall community
Examples of unacceptable behavior include:
- The use of sexualized language or imagery, and sexual attention or
advances of any kind
- Trolling, insulting or derogatory comments, and personal or political attacks
- Public or private harassment
- Publishing others' private information, such as a physical or email
address, without their explicit permission
- Other conduct which could reasonably be considered inappropriate in a
professional setting
## Enforcement Responsibilities
Community leaders are responsible for clarifying and enforcing our standards of
acceptable behavior and will take appropriate and fair corrective action in
response to any behavior that they deem inappropriate, threatening, offensive,
or harmful.
Community leaders have the right and responsibility to remove, edit, or reject
comments, commits, code, wiki edits, issues, and other contributions that are
not aligned to this Code of Conduct, and will communicate reasons for moderation
decisions when appropriate.
## Scope
This Code of Conduct applies within all community spaces, and also applies when
an individual is officially representing the community in public spaces.
Examples of representing our community include using an official e-mail address,
posting via an official social media account, or acting as an appointed
representative at an online or offline event.
## Enforcement
Instances of abusive, harassing, or otherwise unacceptable behavior may be
reported to the community leaders responsible for enforcement at
contact@divlo.fr.
All complaints will be reviewed and investigated promptly and fairly.
All community leaders are obligated to respect the privacy and security of the
reporter of any incident.
## Enforcement Guidelines
Community leaders will follow these Community Impact Guidelines in determining
the consequences for any action they deem in violation of this Code of Conduct:
### 1. Correction
**Community Impact**: Use of inappropriate language or other behavior deemed
unprofessional or unwelcome in the community.
**Consequence**: A private, written warning from community leaders, providing
clarity around the nature of the violation and an explanation of why the
behavior was inappropriate. A public apology may be requested.
### 2. Warning
**Community Impact**: A violation through a single incident or series
of actions.
**Consequence**: A warning with consequences for continued behavior. No
interaction with the people involved, including unsolicited interaction with
those enforcing the Code of Conduct, for a specified period of time. This
includes avoiding interactions in community spaces as well as external channels
like social media. Violating these terms may lead to a temporary or
permanent ban.
### 3. Temporary Ban
**Community Impact**: A serious violation of community standards, including
sustained inappropriate behavior.
**Consequence**: A temporary ban from any sort of interaction or public
communication with the community for a specified period of time. No public or
private interaction with the people involved, including unsolicited interaction
with those enforcing the Code of Conduct, is allowed during this period.
Violating these terms may lead to a permanent ban.
### 4. Permanent Ban
**Community Impact**: Demonstrating a pattern of violation of community
standards, including sustained inappropriate behavior, harassment of an
individual, or aggression toward or disparagement of classes of individuals.
**Consequence**: A permanent ban from any sort of public interaction within
the community.
## Attribution
This Code of Conduct is adapted from the [Contributor Covenant][homepage],
version 2.0, available at
[https://www.contributor-covenant.org/version/2/0/code_of_conduct.html][v2.0].
Community Impact Guidelines were inspired by
[Mozilla's code of conduct enforcement ladder][mozilla coc].
For answers to common questions about this code of conduct, see the FAQ at
[https://www.contributor-covenant.org/faq][faq]. Translations are available
at [https://www.contributor-covenant.org/translations][translations].
[homepage]: https://www.contributor-covenant.org
[v2.0]: https://www.contributor-covenant.org/version/2/0/code_of_conduct.html
[mozilla coc]: https://github.com/mozilla/diversity
[faq]: https://www.contributor-covenant.org/faq
[translations]: https://www.contributor-covenant.org/translations

54
CONTRIBUTING.md Normal file
View File

@ -0,0 +1,54 @@
# 💡 Contributing
Thanks a lot for your interest in contributing to **Thream/website**! 🎉
## Code of Conduct
**Thream** has adopted the [Contributor Covenant](https://www.contributor-covenant.org/) as its Code of Conduct, and we expect project participants to adhere to it. Please read [the full text](./CODE_OF_CONDUCT.md) so that you can understand what actions will and will not be tolerated.
## Open Development
All work on **Thream/website** happens directly on [GitHub](https://github.com/Thream). Both core team members and external contributors send pull requests which go through the same review process.
## Types of contributions
- Reporting a bug.
- Suggest a new feature idea.
- Correct spelling errors, improvements or additions to documentation files (README, CONTRIBUTING...).
- Improve structure/format/performance/refactor/tests of the code.
## Pull Requests
- **Please first discuss** the change you wish to make via [issue](https://github.com/Thream/website/issues) before making a change. It might avoid a waste of your time.
- Ensure your code respect [Typescript Standard Style](https://www.npmjs.com/package/ts-standard).
- Make sure your **code passes the tests**.
If you're adding new features to **Thream/website**, please include tests.
## Commits
The commit message guidelines respect [@commitlint/config-conventional](https://github.com/conventional-changelog/commitlint/tree/master/%40commitlint/config-conventional) and [Semantic Versioning](https://semver.org/) for releases.
### Types
Types define which kind of changes you made to the project.
| Types | Description |
| -------- | ------------------------------------------------------------------------------------------------------------ |
| feat | A new feature. |
| fix | A bug fix. |
| docs | Documentation only changes. |
| style | Changes that do not affect the meaning of the code (white-space, formatting, missing semi-colons, etc). |
| refactor | A code change that neither fixes a bug nor adds a feature. |
| perf | A code change that improves performance. |
| test | Adding missing tests or correcting existing tests. |
| build | Changes that affect the build system or external dependencies (example scopes: gulp, broccoli, npm). |
| ci | Changes to our CI configuration files and scripts (example scopes: Travis, Circle, BrowserStack, SauceLabs). |
| chore | Other changes that don't modify src or test files. |
| revert | Reverts a previous commit. |
### Scopes
Scopes define what part of the code changed.

10
Dockerfile Normal file
View File

@ -0,0 +1,10 @@
FROM node:14.16.1
RUN npm install --global npm@7
WORKDIR /website
COPY ./package*.json ./
RUN npm install
COPY ./ ./
CMD ["npm", "run", "dev", "--", "--port", "${PORT}"]

31
Dockerfile.production Normal file
View File

@ -0,0 +1,31 @@
ARG NODE_VERSION=14.16.1
FROM node:${NODE_VERSION} AS dependencies
RUN npm install --global npm@7
WORKDIR /website
COPY ./package*.json ./
RUN npm clean-install
FROM node:${NODE_VERSION} AS builder
WORKDIR /website
COPY ./ ./
COPY --from=dependencies /website/node_modules ./node_modules
RUN npm run build
FROM node:${NODE_VERSION} AS runner
WORKDIR /website
ENV NODE_ENV=production
COPY --from=builder /website/next.config.js ./next.config.js
COPY --from=builder /website/public ./public
COPY --from=builder /website/.next ./.next
COPY --from=builder /website/i18n.json ./i18n.json
COPY --from=builder /website/locales ./locales
COPY --from=builder /website/pages ./pages
COPY --from=builder /website/node_modules ./node_modules
RUN chown --recursive node /website/.next
USER node
RUN npx next telemetry disable
CMD ["node_modules/.bin/next", "start", "--port", "${PORT}"]

21
LICENSE Normal file
View File

@ -0,0 +1,21 @@
MIT License
Copyright (c) Thream
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

72
README.md Normal file
View File

@ -0,0 +1,72 @@
<h1 align="center"><a href="https://thream.divlo.fr/">Thream/website</a></h1>
<p align="center">
<strong>Thream's website to stay close with your friends and communities.</strong>
</p>
<p align="center">
<a href="https://www.npmjs.com/package/ts-standard"><img alt="TypeScript Standard Style" src="https://camo.githubusercontent.com/f87caadb70f384c0361ec72ccf07714ef69a5c0a/68747470733a2f2f62616467656e2e6e65742f62616467652f636f64652532307374796c652f74732d7374616e646172642f626c75653f69636f6e3d74797065736372697074"/></a>
<a href="./LICENSE"><img src="https://img.shields.io/badge/licence-MIT-blue.svg" alt="Licence MIT"/></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/Thream/Thream/blob/master/CODE_OF_CONDUCT.md"><img src="https://img.shields.io/badge/Contributor%20Covenant-v2.0%20adopted-ff69b4.svg" alt="Contributor Covenant" /></a>
</p>
## 📜 About
Thream's website to stay close with your friends and communities. It relies on [Thream/api](https://github.com/Thream/api/).
This project was bootstrapped with [create-fullstack-app](https://github.com/Divlo/create-fullstack-app).
## ⚙️ Getting Started
### Prerequisites
- [Node.js](https://nodejs.org/) >= 16
- [npm](https://www.npmjs.com/) >= 7
### Installation
```sh
# Clone the repository
git clone https://github.com/Thream/website.git
# Go to the project root
cd website
# Configure environment variables
cp .env.example .env
# Install
npm install
```
You will need to configure the environment variables by creating an `.env` file at
the root of the project (see `.env.example`).
### Local Development environment
```sh
npm run dev
```
### Production environment with [Docker](https://www.docker.com/)
```sh
# Setup and run all the services for you
docker-compose up --build
```
#### Services started
- website : `http://localhost:3000`
## 💡 Contributing
Anyone can help to improve the project, submit a Feature Request, a bug report or
even correct a simple spelling mistake.
The steps to contribute can be found in [CONTRIBUTING.md](./CONTRIBUTING.md).
## 📄 License
[MIT](./LICENSE)

View File

@ -0,0 +1,22 @@
export const Main: React.FC = (props) => {
return (
<>
<main className='main'>{props.children}</main>
<style jsx>
{`
.main {
padding: 2rem;
margin-left: var(--sidebar-width);
background-color: var(--color-background-secondary);
min-height: 100vh;
overflow: auto;
display: flex;
flex-direction: column;
justify-content: space-between;
}
`}
</style>
</>
)
}

View File

@ -0,0 +1,19 @@
import { memo } from 'react'
export const SidebarItem: React.FC = memo((props) => {
return (
<>
<li className='sidebar-item'>{props.children}</li>
<style jsx>
{`
.sidebar-item {
position: relative;
margin: 10px;
cursor: pointer;
}
`}
</style>
</>
)
})

View File

@ -0,0 +1,28 @@
export interface SidebarListProps extends React.ComponentPropsWithRef<'ul'> {}
export const SidebarList: React.FC<SidebarListProps> = (props) => {
const { children, ...rest } = props
return (
<>
<ul {...rest} className='sidebar-list'>
{children}
</ul>
<style jsx>
{`
.sidebar-list {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-flow: column wrap;
overflow-y: auto;
overflow-x: hidden;
flex-direction: row !important;
}
`}
</style>
</>
)
}

View File

@ -0,0 +1,10 @@
import { render } from '@testing-library/react'
import { SidebarItem } from '../SidebarItem'
describe('<SidebarItem />', () => {
it('should render', async () => {
const { getByText } = render(<SidebarItem>Item</SidebarItem>)
expect(getByText('Item')).toBeInTheDocument()
})
})

View File

@ -0,0 +1,10 @@
import { render } from '@testing-library/react'
import { SidebarList } from '../SidebarList'
describe('<SidebarList />', () => {
it('should render', async () => {
const { getByText } = render(<SidebarList>List Item</SidebarList>)
expect(getByText('List Item')).toBeInTheDocument()
})
})

View File

@ -0,0 +1,98 @@
import useTranslation from 'next-translate/useTranslation'
import InfiniteScroll from 'react-infinite-scroll-component'
import { IconButton } from 'components/design/IconButton'
import { Avatar } from 'components/design/Avatar'
import { SidebarItem } from './SidebarItem'
import { SidebarList } from './SidebarList'
import { API_URL } from 'utils/api'
import { useGuilds } from 'contexts/Guilds'
import { Tooltip } from 'components/design/Tooltip'
import { useAuthentication } from 'utils/authentication'
import { Loader } from 'components/design/Loader'
import Link from 'next/link'
export const Sidebar: React.FC = () => {
const { guilds, nextPage } = useGuilds()
const { t } = useTranslation()
const { user } = useAuthentication()
return (
<>
<nav className='sidebar'>
<SidebarList id='sidebar-list'>
<SidebarItem>
<Link href='/application'>
<Tooltip content={t('application:settings')} direction='right'>
<Avatar
src='/images/icons/Thream.png'
alt='Thream'
width={60}
height={60}
/>
</Tooltip>
</Link>
</SidebarItem>
<SidebarItem>
<Tooltip content={t('application:settings')} direction='right'>
<Avatar
src={`${API_URL}${user.logo}`}
alt={user.name}
width={60}
height={60}
/>
</Tooltip>
</SidebarItem>
<SidebarItem>
<Tooltip content={t('application:add-guild')} direction='right'>
<IconButton icon='add' hasBackground />
</Tooltip>
</SidebarItem>
<InfiniteScroll
dataLength={guilds.rows.length}
next={nextPage}
style={{ overflow: 'none' }}
hasMore={guilds.hasMore}
loader={<Loader />}
scrollableTarget='sidebar-list'
>
{guilds.rows.map((row) => {
return (
<SidebarItem key={row.id}>
<Link
href={`/application/${row.guildId}/${row.lastVisitedChannelId}`}
>
<Tooltip content={row.guild.name} direction='right'>
<Avatar
src={`${API_URL}${row.guild.icon}`}
alt={row.guild.name}
width={60}
height={60}
/>
</Tooltip>
</Link>
</SidebarItem>
)
})}
</InfiniteScroll>
</SidebarList>
</nav>
<style jsx>
{`
.sidebar {
display: flex;
flex-flow: column wrap;
justify-content: space-between;
align-items: center;
position: fixed;
background-color: var(--color-background-primary);
width: var(--sidebar-width);
height: 100vh;
padding: 0 15px;
}
`}
</style>
</>
)
}

View File

@ -0,0 +1,10 @@
import { render } from '@testing-library/react'
import { Main } from '../Main'
describe('<Main />', () => {
it('should render', async () => {
const { getByText } = render(<Main>Content</Main>)
expect(getByText('Content')).toBeInTheDocument()
})
})

View File

@ -0,0 +1,32 @@
import {
AuthenticationProvider,
PagePropsWithAuthentication
} from 'utils/authentication'
import { Main } from './Main'
import { Sidebar } from './Sidebar'
import { Guilds, GuildsProvider } from 'contexts/Guilds'
export interface ApplicationProps extends PagePropsWithAuthentication {
guilds: Guilds
}
export const Application: React.FC<ApplicationProps> = (props) => {
return (
<AuthenticationProvider authentication={props.authentication}>
<GuildsProvider guilds={props.guilds}>
<div className='application'>
<Sidebar />
<Main>{props.children}</Main>
</div>
<style jsx global>
{`
body {
--sidebar-width: 11rem;
}
`}
</style>
</GuildsProvider>
</AuthenticationProvider>
)
}

View File

@ -0,0 +1,99 @@
import Link from 'next/link'
import useTranslation from 'next-translate/useTranslation'
import { Input } from 'components/design/Input'
import { FormState } from 'components/Authentication/FormState'
import { ValidatorSchema } from 'hooks/useFastestValidator'
import { AuthenticationProps } from '.'
import { AuthenticationFormLayout } from './AuthenticationFormLayout'
import { useForm } from 'hooks/useForm'
export const emailSchema: ValidatorSchema = {
email: {
type: 'email',
empty: false,
trim: true
}
}
export const nameSchema: ValidatorSchema = {
name: {
type: 'string',
min: 3,
max: 30,
trim: true
}
}
export const passwordSchema: ValidatorSchema = {
password: {
type: 'string',
empty: false,
trim: true
}
}
export const AuthenticationForm: React.FC<AuthenticationProps> = (props) => {
const { mode, onSubmit } = props
const { t } = useTranslation()
const {
getErrorMessages,
formState,
message,
handleChange,
handleSubmit
} = useForm({
validatorSchema: {
...(mode === 'signup' && { ...nameSchema }),
...emailSchema,
...passwordSchema
}
})
return (
<>
<AuthenticationFormLayout
onChange={handleChange}
onSubmit={handleSubmit(onSubmit)}
link={
<p>
<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>
}
>
{mode === 'signup' && (
<Input
errors={getErrorMessages('name')}
type='text'
placeholder={t('authentication:name')}
name='name'
label={t('authentication:name')}
/>
)}
<Input
errors={getErrorMessages('email')}
type='email'
placeholder='Email'
name='email'
label='Email'
/>
<Input
errors={getErrorMessages('password')}
type='password'
placeholder={t('authentication:password')}
name='password'
label={t('authentication:password')}
showForgotPassword={mode === 'signin'}
/>
</AuthenticationFormLayout>
<FormState state={formState} message={message} />
</>
)
}

View File

@ -0,0 +1,53 @@
import Form, { HandleForm } from 'react-component-form'
import { Button } from 'components/design/Button'
import useTranslation from 'next-translate/useTranslation'
export interface AuthenticationFormLayoutProps {
onChange?: HandleForm
onSubmit?: HandleForm
link?: React.ReactNode
}
export const AuthenticationFormLayout: React.FC<AuthenticationFormLayoutProps> = (
props
) => {
const { children, onChange, onSubmit, link } = props
const { t } = useTranslation()
return (
<>
<Form onChange={onChange} onSubmit={onSubmit}>
<div className='form-container'>
<div className='form'>
{children}
<Button style={{ width: '100%' }} type='submit'>
{t('authentication:submit')}
</Button>
{link}
</div>
</div>
</Form>
<style jsx>
{`
@media (max-width: 330px) {
.form {
width: auto !important;
}
}
.form {
flex-shrink: 0;
width: 310px;
}
.form-container {
display: flex;
flex-direction: column;
align-items: center;
width: 100%;
}
`}
</style>
</>
)
}

View File

@ -0,0 +1,109 @@
import { useRouter } from 'next/router'
import {
SocialMediaButton,
SocialMedia
} from 'components/design/SocialMediaButton'
import { api } from 'utils/api'
import { Authentication, Tokens } from 'utils/authentication'
import { useEffect } from 'react'
const isTokens = (data: { [key: string]: any }): data is Tokens => {
return (
'accessToken' in data &&
'refreshToken' in data &&
'type' in data &&
'expiresIn' in data
)
}
export const AuthenticationSocialMedia: React.FC = () => {
const router = useRouter()
const handleAuthentication = async (
socialMedia: SocialMedia
): Promise<void> => {
const redirect = window.location.href
const { data: url } = await api.get(
`/users/oauth2/${socialMedia.toLowerCase()}/signin?redirectURI=${redirect}`
)
window.location.href = url
}
useEffect(() => {
const data = router.query
if (isTokens(data)) {
const authentication = new Authentication(data)
authentication.signin()
router.push('/application').catch(() => {})
}
}, [router.query])
return (
<>
<div className='social-container'>
<div className='social-buttons'>
<SocialMediaButton
onClick={async () => await handleAuthentication('Google')}
className='social-button'
socialMedia='Google'
/>
<SocialMediaButton
onClick={async () => await handleAuthentication('GitHub')}
className='social-button'
socialMedia='GitHub'
/>
<SocialMediaButton
onClick={async () => await handleAuthentication('Discord')}
className='social-button'
socialMedia='Discord'
/>
</div>
</div>
<style jsx>
{`
@media (max-width: 600px) {
:global(.social-button) {
margin-top: 15px !important;
}
.social-container {
margin-top: 20px !important;
}
.social-buttons {
height: 100% !important;
}
}
.social-container {
display: flex;
flex-direction: column;
align-items: center;
width: 100%;
}
.social-buttons {
display: flex;
justify-content: space-evenly;
width: 60%;
}
@media (max-width: 970px) {
.social-buttons {
width: 80%;
}
}
@media (max-width: 770px) {
.social-buttons {
width: 100%;
}
}
@media (max-width: 600px) {
.social-buttons {
flex-direction: column;
align-items: center;
height: 30%;
}
}
`}
</style>
</>
)
}

View File

@ -0,0 +1,76 @@
import useTranslation from 'next-translate/useTranslation'
export interface ErrorMessageProps {
errors: string[]
fontSize?: number
}
export const ErrorMessage: React.FC<ErrorMessageProps> = (props) => {
const { errors, fontSize = 14 } = props
const { t } = useTranslation()
if (errors.length === 0) {
return null
}
return (
<>
<div className='error-message'>
{errors.length === 1 && (
<>
<div className='error-thumbnail' />
<span className='error-text'>{errors[0]}</span>
</>
)}
{errors.length > 1 && (
<>
<div className='error-container'>
<div className='error-thumbnail' />
<span className='error-text'>{t('authentication:errors')} :</span>
</div>
<ul className='errors-list'>
{errors.map((error, index) => {
return <li key={index}>{error}</li>
})}
</ul>
</>
)}
</div>
<style jsx>
{`
.error-message {
position: relative;
display: ${errors.length > 1 ? 'block' : 'flex'};
flex-flow: row;
align-items: center;
margin-top: 12px;
left: -3px;
color: var(--color-error);
font-family: 'Poppins', 'Arial', 'sans-serif';
font-size: ${fontSize}px;
line-height: 21px;
}
.error-container {
display: flex;
align-items: center;
}
.errors-list {
margin: 10px 0 0 0;
}
.error-thumbnail {
display: inline-block;
min-width: 20px;
width: 20px;
height: 20px;
background-image: url(/images/svg/icons/input/error.svg);
background-size: cover;
}
.error-text {
padding-left: 5px;
}
`}
</style>
</>
)
}

View File

@ -0,0 +1,104 @@
import useTranslation from 'next-translate/useTranslation'
import { FormState as FormStateType } from 'hooks/useFormState'
import { ErrorMessage } from './ErrorMessage'
import { Loader } from 'components/design/Loader'
export interface FormStateProps {
state: FormStateType
message?: string
}
export const FormState: React.FC<FormStateProps> = (props) => {
const { state, message } = props
const { t } = useTranslation()
if (state === 'loading') {
return (
<>
<div data-testid='loader' className='loader'>
<Loader />
</div>
<style jsx>
{`
.loader {
margin-top: 30px;
display: flex;
justify-content: center;
}
`}
</style>
</>
)
}
if (state === 'idle' || message == null) {
return null
}
if (state === 'success') {
return (
<>
<div className='success'>
<div className='success-message'>
<div className='success-thumbnail' />
<span className='success-text'>
<b>{t('authentication:success')} :</b> {message}
</span>
</div>
</div>
<style jsx>
{`
.success {
margin-top: 20px;
display: flex;
justify-content: center;
}
.success-message {
position: relative;
display: flex;
flex-flow: row;
align-items: center;
justify-content: center;
margin-top: 12px;
left: -3px;
color: var(--color-success);
font-family: 'Arial', 'sans-serif';
font-size: 16px;
line-height: 21px;
}
.success-thumbnail {
display: inline-block;
width: 20px;
height: 22px;
background-image: url(/images/svg/icons/input/success.svg);
background-size: cover;
}
.success-text {
padding-left: 5px;
}
`}
</style>
</>
)
}
return (
<>
<div data-testid='error' className='error'>
<ErrorMessage fontSize={16} errors={[message]} />
</div>
<style jsx>
{`
.error {
margin-top: 20px;
display: flex;
justify-content: center;
}
`}
</style>
</>
)
}

View File

@ -0,0 +1,14 @@
import { useTheme } from 'contexts/Theme'
export const Success: React.FC = () => {
const { theme } = useTheme()
return (
<svg data-testid='success' width='25' height='25' fill='none' xmlns='http://www.w3.org/2000/svg'>
<path
d='M12.5 0C5.607 0 0 5.607 0 12.5 0 19.392 5.607 25 12.5 25 19.392 25 25 19.392 25 12.5 25 5.607 19.392 0 12.5 0zm-2.499 18.016L5.36 13.385l1.765-1.77 2.874 2.869 6.617-6.618 1.768 1.768L10 18.016z'
fill={theme === 'light' ? '#1e4620' : '#90ee90'}
/>
</svg>
)
}

View File

@ -0,0 +1,16 @@
import { render } from '@testing-library/react'
import { ErrorMessage } from '../ErrorMessage'
describe('<ErrorMessage />', () => {
it('should return nothing if there are no errors', async () => {
const { container } = render(<ErrorMessage errors={[]} />)
expect(container.innerHTML.length).toEqual(0)
})
it('should render the single error', async () => {
const errorMessage = 'Error Message'
const { getByText } = render(<ErrorMessage errors={[errorMessage]} />)
expect(getByText(errorMessage)).toBeInTheDocument()
})
})

View File

@ -0,0 +1,33 @@
import { render } from '@testing-library/react'
import { FormState } from '../FormState'
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 { getByTestId } = render(<FormState state='error' message='Error Message' />)
expect(getByTestId('error')).toBeInTheDocument()
})
})

View File

@ -0,0 +1,10 @@
import { render } from '@testing-library/react'
import { Success } from '../Success'
describe('<Success />', () => {
it('should render', async () => {
const { getByTestId } = render(<Success />)
expect(getByTestId('success')).toBeInTheDocument()
})
})

View File

@ -0,0 +1,49 @@
import useTranslation from 'next-translate/useTranslation'
import { Divider } from 'components/design/Divider'
import { Header } from 'components/Header'
import { AuthenticationForm } from 'components/Authentication/AuthenticationForm'
import { AuthenticationSocialMedia } from 'components/Authentication/AuthenticationSocialMedia'
import { Container } from 'components/design/Container'
import { HandleSubmitCallback } from 'hooks/useForm'
export interface AuthenticationProps {
mode: 'signup' | 'signin'
onSubmit: HandleSubmitCallback
}
export const Authentication: React.FC<AuthenticationProps> = (props) => {
const { mode, onSubmit } = props
const { t } = useTranslation()
return (
<>
<Header />
<Container className='container-authentication'>
<AuthenticationSocialMedia />
<div className='divider'>
<Divider content={t('authentication:or')} />
</div>
<AuthenticationForm onSubmit={onSubmit} mode={mode} />
</Container>
<style jsx>
{`
@media (max-height: 700px) {
:global(.container-authentication) {
height: auto !important;
}
}
@media (max-width: 600px) {
.divider {
margin: 20px 0 !important;
}
}
.divider {
margin: 40px 0;
}
`}
</style>
</>
)
}

View File

@ -0,0 +1,22 @@
import { Emoji as EmojiMart } from 'emoji-mart'
import { emojiSet } from './emojiPlugin'
export interface EmojiProps {
value: string
size: number
}
export const Emoji: React.FC<EmojiProps> = (props) => {
const { value, size } = props
return (
<EmojiMart
set={emojiSet}
emoji={value}
size={size}
tooltip
fallback={() => <>{value}</>}
/>
)
}

View File

@ -0,0 +1,28 @@
import 'emoji-mart/css/emoji-mart.css'
import { EmojiData, Picker } from 'emoji-mart'
import { useTheme } from 'contexts/Theme'
import { emojiSet } from './emojiPlugin'
export type EmojiPickerOnClick = (
emoji: EmojiData,
event: React.MouseEvent<HTMLElement, MouseEvent>
) => void
export interface EmojiPickerProps {
onClick: EmojiPickerOnClick
}
export const EmojiPicker: React.FC<EmojiPickerProps> = (props) => {
const { theme } = useTheme()
return (
<Picker
set={emojiSet}
theme={theme}
onClick={props.onClick}
showPreview={false}
showSkinTones={false}
/>
)
}

View File

@ -0,0 +1,63 @@
import visit from 'unist-util-visit'
import { Plugin, Transformer } from 'unified'
import { Node } from 'unist'
import { EmojiSet } from 'emoji-mart'
export const emojiSet: EmojiSet = 'twitter'
export const emojiRegex = /:\+1:|:-1:|:[\w-]+:/
export const isStringWithOnlyOneEmoji = (value: string): boolean => {
const result = emojiRegex.exec(value)
return result != null && result.input === result[0]
}
const extractText = (string: string, start: number, end: number): Node => {
const startLine = string.slice(0, start).split('\n')
const endLine = string.slice(0, end).split('\n')
return {
type: 'text',
value: string.slice(start, end),
position: {
start: {
line: startLine.length,
column: startLine[startLine.length - 1].length + 1
},
end: {
line: endLine.length,
column: endLine[endLine.length - 1].length + 1
}
}
}
}
export const emojiPlugin: Plugin = () => {
const transformer: Transformer = (tree) => {
visit(tree, 'text', (node, position, parent) => {
if (typeof node.value !== 'string') {
return
}
const definition: Node[] = []
let lastIndex = 0
const match = emojiRegex.exec(node.value)
if (match != null) {
const value = match[0]
if (match.index !== lastIndex) {
definition.push(extractText(node.value, lastIndex, match.index))
}
definition.push({ type: 'emoji', value })
lastIndex = match.index + value.length
if (lastIndex !== node.value.length) {
definition.push(extractText(node.value, lastIndex, node.value.length))
}
if (parent != null) {
const last = parent.children.slice(position + 1)
parent.children = parent.children.slice(0, position)
parent.children = parent.children.concat(definition)
parent.children = parent.children.concat(last)
}
}
})
}
return transformer
}

View File

@ -0,0 +1,3 @@
export * from './Emoji'
export * from './EmojiPicker'
export * from './emojiPlugin'

62
components/ErrorPage.tsx Normal file
View File

@ -0,0 +1,62 @@
import { Header } from 'components/Header'
interface ErrorPageProps {
message: string
statusCode: number
}
export const ErrorPage: React.FC<ErrorPageProps> = (props) => {
const { message, statusCode } = props
return (
<>
<Header />
<div className='container'>
<h1>{statusCode}</h1>
<div className='container-message'>
<h2>{message}</h2>
</div>
</div>
<style jsx global>
{`
#__next {
min-height: 100vh;
}
`}
</style>
<style jsx>
{`
.container {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
height: calc(100vh - 110px);
}
h1 {
display: inline-block;
margin: 0;
font-size: 24px;
font-weight: 500;
vertical-align: top;
}
.container-message {
display: inline-block;
text-align: left;
line-height: 49px;
height: 49px;
vertical-align: middle;
}
.container-message > h2 {
font-size: 14px;
font-weight: normal;
line-height: inherit;
margin: 0;
padding: 0;
}
`}
</style>
</>
)
}

54
components/Head.tsx Normal file
View File

@ -0,0 +1,54 @@
import HeadTag from 'next/head'
import useTranslation from 'next-translate/useTranslation'
interface HeadProps {
title?: string
image?: string
description?: string
url?: string
}
export const Head: React.FC<HeadProps> = (props) => {
const { t } = useTranslation()
const {
title = 'Thream',
image = '/images/icons/96x96.png',
description = t('common:description'),
url = 'https://thream.divlo.fr/'
} = props
return (
<HeadTag>
<title>{title}</title>
<link rel='icon' type='image/png' href={image} />
{/* Meta Tag */}
<meta name='viewport' content='width=device-width, initial-scale=1' />
<meta name='description' content={description} />
<meta name='Language' content='en' />
<meta name='theme-color' content='#27B05E' />
{/* Open Graph Metadata */}
<meta property='og:title' content={title} />
<meta property='og:type' content='website' />
<meta property='og:url' content={url} />
<meta property='og:image' content={image} />
<meta property='og:description' content={description} />
<meta property='og:locale' content='en_EN' />
<meta property='og:site_name' content={title} />
{/* Twitter card Metadata */}
<meta name='twitter:card' content='summary' />
<meta name='twitter:description' content={description} />
<meta name='twitter:title' content={title} />
<meta name='twitter:image:src' content={image} />
{/* PWA Data */}
<link rel='manifest' href='/manifest.json' />
<meta name='apple-mobile-web-app-capable' content='yes' />
<meta name='mobile-web-app-capable' content='yes' />
<link rel='apple-touch-icon' href={image} />
</HeadTag>
)
}

View File

@ -0,0 +1,20 @@
import { useTheme } from 'contexts/Theme'
export const Arrow: React.FC = () => {
const { theme } = useTheme()
return (
<svg
width='12'
height='8'
viewBox='0 0 12 8'
fill='none'
xmlns='http://www.w3.org/2000/svg'
>
<path
d='M9.8024 0.292969L5.61855 4.58597L1.43469 0.292969L0.0566406 1.70697L5.61855 7.41397L11.1805 1.70697L9.8024 0.292969Z'
fill={theme === 'dark' ? '#fff' : '#181818'}
/>
</svg>
)
}

View File

@ -0,0 +1,31 @@
import Image from 'next/image'
import { Language } from 'utils/authentication'
export interface LanguageFlagProps {
language: Language
}
export const LanguageFlag: React.FC<LanguageFlagProps> = (props) => {
const { language } = props
return (
<>
<Image
width={35}
height={35}
src={`/images/svg/languages/${language}.svg`}
alt={language}
/>
<p className='language-title'>{language.toUpperCase()}</p>
<style jsx>
{`
.language-title {
margin: 0 8px 0 10px;
}
`}
</style>
</>
)
}

View File

@ -0,0 +1,105 @@
import { useEffect, useState } from 'react'
import useTranslation from 'next-translate/useTranslation'
import setLanguage from 'next-translate/setLanguage'
import { Arrow } from './Arrow'
import { languages, Language as LanguageType } from 'utils/authentication'
import { LanguageFlag } from './LanguageFlag'
export const Language: React.FC = () => {
const { lang: currentLanguage } = useTranslation()
const [hiddenMenu, setHiddenMenu] = useState(true)
useEffect(() => {
if (!hiddenMenu) {
window.document.addEventListener('click', handleHiddenMenu)
} else {
window.document.removeEventListener('click', handleHiddenMenu)
}
return () => {
window.document.removeEventListener('click', handleHiddenMenu)
}
}, [hiddenMenu])
const handleLanguage = async (language: LanguageType): Promise<void> => {
await setLanguage(language)
handleHiddenMenu()
}
const handleHiddenMenu = (): void => {
setHiddenMenu(!hiddenMenu)
}
return (
<>
<div className='language-menu'>
<div className='selected-language' onClick={handleHiddenMenu}>
<LanguageFlag language={currentLanguage as LanguageType} />
<Arrow />
</div>
{!hiddenMenu && (
<ul>
{languages.map((language, index) => {
if (language === currentLanguage) {
return null
}
return (
<li
key={index}
onClick={async () => await handleLanguage(language)}
>
<LanguageFlag language={language} />
</li>
)
})}
</ul>
)}
</div>
<style jsx>
{`
.language-menu {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
cursor: pointer;
}
.selected-language {
display: flex;
align-items: center;
margin-right: 15px;
}
ul {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
position: absolute;
top: 60px;
width: 100px;
padding: 10px;
margin: 10px 15px 0 0px;
border-radius: 15%;
padding: 0;
box-shadow: 0px 1px 10px var(--color-shadow);
background-color: var(--color-background-primary);
z-index: 10;
}
ul > li {
list-style: none;
display: flex;
align-items: center;
justify-content: center;
height: 50px;
width: 100%;
}
ul > li:hover {
background-color: rgba(79, 84, 92, 0.16);
}
`}
</style>
</>
)
}

View File

@ -0,0 +1,114 @@
import { useTheme } from 'contexts/Theme'
export const SwitchTheme: React.FC = () => {
const { handleToggleTheme, theme } = useTheme()
return (
<>
<div className='toggle-button' onClick={handleToggleTheme}>
<div className='toggle-theme-button'>
<div className='toggle-track'>
<div className='toggle-track-check'>
<span className='toggle_Dark'>🌜</span>
</div>
<div className='toggle-track-x'>
<span className='toggle_Light'>🌞</span>
</div>
</div>
<div className='toggle-thumb' />
<input
type='checkbox'
aria-label='Dark mode toggle'
className='toggle-screenreader-only'
defaultChecked
/>
</div>
</div>
<style jsx>
{`
.toggle-button {
display: flex;
align-items: center;
}
.toggle-theme-button {
touch-action: pan-x;
display: inline-block;
position: relative;
cursor: pointer;
background-color: transparent;
border: 0;
padding: 0;
user-select: none;
}
.toggle-track {
width: 50px;
height: 24px;
padding: 0;
border-radius: 30px;
background-color: #4d4d4d;
transition: all 0.2s ease;
color: #fff;
}
.toggle-track-check {
position: absolute;
width: 14px;
height: 10px;
top: 0;
bottom: 0;
margin-top: auto;
margin-bottom: auto;
line-height: 0;
left: 8px;
opacity: ${theme === 'dark' ? 1 : 0};
transition: opacity 0.25s ease;
}
.toggle-track-x {
position: absolute;
width: 10px;
height: 10px;
top: 0;
bottom: 0;
margin-top: auto;
margin-bottom: auto;
line-height: 0;
right: 10px;
opacity: ${theme === 'dark' ? 0 : 1};
}
.toggle_Dark,
.toggle_Light {
align-items: center;
display: flex;
height: 10px;
justify-content: center;
position: relative;
width: 10px;
}
.toggle-thumb {
position: absolute;
left: ${theme === 'dark' ? '27px' : '0px'};
width: 22px;
height: 22px;
border: 1px solid #4d4d4d;
border-radius: 50%;
background-color: #fafafa;
box-sizing: border-box;
transition: all 0.25s ease;
top: 1px;
color: #fff;
}
.toggle-screenreader-only {
border: 0;
clip: rect(0 0 0 0);
height: 1px;
margin: -1px;
overflow: hidden;
padding: 0;
position: absolute;
width: 1px;
}
`}
</style>
</>
)
}

View File

@ -0,0 +1,94 @@
import Link from 'next/link'
import Image from 'next/image'
import { Language } from './Language'
import { SwitchTheme } from './SwitchTheme'
export const Header: React.FC = () => {
return (
<>
<header className='header'>
<div className='container'>
<nav className='navbar navbar-fixed-top'>
<Link href='/'>
<a className='navbar__brand-link'>
<div className='navbar__brand'>
<Image
width={60}
height={60}
src='/images/icons/Thream.png'
alt='Thream'
/>
<strong className='navbar__brand-title'>Thream</strong>
</div>
</a>
</Link>
<div className='navbar__buttons'>
<Language />
<SwitchTheme />
</div>
</nav>
</div>
</header>
<style jsx global>
{`
body {
padding: 0 32px;
}
@media (max-width: 404px) {
body {
padding: 0;
}
}
`}
</style>
<style jsx>
{`
.header {
margin-top: 20px;
}
.container {
max-width: 1280px;
width: 100%;
margin: auto;
}
.navbar {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.5rem 1rem;
}
.navbar-fixed-top {
position: sticky;
top: 0;
z-index: 200;
}
.navbar__brand-link {
color: var(--color-secondary);
text-decoration: none;
font-size: 16px;
}
.navbar__brand {
display: flex;
align-items: center;
justify-content: space-between;
}
.navbar__brand-title {
font-weight: 400;
margin-left: 10px;
}
.navbar__buttons {
display: flex;
justify-content: space-between;
}
@media (max-width: 320px) {
.navbar__brand-title {
display: none;
}
}
`}
</style>
</>
)
}

View File

@ -0,0 +1,114 @@
import { useEffect, useState } from 'react'
import prettyBytes from 'pretty-bytes'
import { useAuthentication } from 'utils/authentication'
import { MessageContentProps } from '.'
import { Loader } from 'components/design/Loader'
import { IconButton } from 'components/design/IconButton'
export interface FileData {
blob: Blob
url: string
}
export const MessageFile: React.FC<MessageContentProps> = (props) => {
const { authentication } = useAuthentication()
const [file, setFile] = useState<FileData | null>(null)
useEffect(() => {
const fetchData = async (): Promise<void> => {
const { data } = await authentication.api.get(props.value, {
responseType: 'blob'
})
const fileURL = URL.createObjectURL(data)
setFile({ blob: data, url: fileURL })
}
fetchData().catch(() => {})
}, [])
if (file == null) {
return <Loader />
}
if (props.mimetype.startsWith('image/')) {
return (
<>
<a href={file.url} target='_blank' rel='noreferrer'>
<img src={file.url} />
</a>
<style jsx>
{`
img {
max-width: 30vw;
max-height: 30vw;
}
`}
</style>
</>
)
}
if (props.mimetype.startsWith('audio/')) {
return (
<audio controls>
<source src={file.url} type={props.mimetype} />
</audio>
)
}
if (props.mimetype.startsWith('video/')) {
return (
<>
<video controls>
<source src={file.url} type={props.mimetype} />
</video>
<style jsx>
{`
video {
max-width: 250px;
max-height: 250px;
}
`}
</style>
</>
)
}
return (
<>
<div className='message-file'>
<div className='file-informations'>
<div className='file-icon'>
<img src='/images/svg/icons/file.svg' alt='file' />
</div>
<div className='file-title'>
<div className='file-name'>{file.blob.type}</div>
<div className='file-size'>{prettyBytes(file.blob.size)}</div>
</div>
</div>
<div className='download-button'>
<a href={file.url} download>
<IconButton icon='download' />
</a>
</div>
</div>
<style jsx>
{`
.message-file {
display: flex;
justify-content: space-between;
}
.file-informations {
display: flex;
}
.file-title {
margin-left: 10px;
}
.file-size {
color: var(--color-tertiary);
margin-top: 5px;
}
`}
</style>
</>
)
}

View File

@ -0,0 +1,62 @@
import { useMemo } from 'react'
import ReactMarkdown from 'react-markdown'
import gfm from 'remark-gfm'
import Tex from '@matejmazur/react-katex'
import math from 'remark-math'
import 'katex/dist/katex.min.css'
import { Emoji, emojiPlugin, isStringWithOnlyOneEmoji } from 'components/Emoji'
export interface MessageTextProps {
value: string
}
export const MessageText: React.FC<MessageTextProps> = (props) => {
const isMessageWithOnlyOneEmoji = useMemo(() => {
return isStringWithOnlyOneEmoji(props.value)
}, [props.value])
if (isMessageWithOnlyOneEmoji) {
return (
<div className='message-content'>
<p>
<Emoji value={props.value} size={40} />
</p>
</div>
)
}
return (
<>
<ReactMarkdown
disallowedTypes={['heading', 'table']}
unwrapDisallowed
plugins={[[gfm], [emojiPlugin], [math]]}
linkTarget='_blank'
renderers={{
inlineMath: ({ value }) => <Tex math={value} />,
math: ({ value }) => <Tex block math={value} />,
emoji: ({ value }) => {
return <Emoji value={value} size={20} />
}
}}
>
{props.value}
</ReactMarkdown>
<style jsx global>
{`
.message-content p {
margin: 0;
line-height: 30px;
white-space: pre-wrap;
}
.message-content .katex,
.message-content .katex-display {
text-align: initial;
}
`}
</style>
</>
)
}

View File

@ -0,0 +1,40 @@
import { Loader } from 'components/design/Loader'
import { MessageType } from 'contexts/Messages'
import { MessageFile } from './MessageFile'
import { MessageText } from './MessageText'
export interface MessageContentProps {
value: string
type: MessageType
mimetype: string
}
export const MessageContent: React.FC<MessageContentProps> = (props) => {
return (
<>
<div className='message-content'>
{props.type === 'text' ? (
<MessageText value={props.value} />
) : props.type === 'file' ? (
<MessageFile {...props} />
) : (
<Loader />
)}
</div>
<style jsx>
{`
.message-content {
font-family: 'Roboto', 'Arial', 'sans-serif';
font-size: 16px;
font-weight: 400;
position: relative;
margin-left: -75px;
padding-left: 75px;
overflow: hidden;
}
`}
</style>
</>
)
}

View File

@ -0,0 +1,48 @@
import date from 'date-and-time'
import { User } from 'utils/authentication'
export interface MessageHeaderProps {
user: User
createdAt: string
}
export const MessageHeader: React.FC<MessageHeaderProps> = (props) => {
return (
<>
<h2 className='message-header'>
<span className='username'>{props.user.name}</span>
<span className='date'>
{date.format(new Date(props.createdAt), 'DD/MM/YYYY - HH:mm:ss')}
</span>
</h2>
<style jsx>
{`
.message-header {
position: relative;
overflow: hidden;
display: block;
position: relative;
line-height: 1.375rem;
min-height: 1.375rem;
margin-bottom: 12px;
}
.username {
font-family: 'Poppins', 'Arial', 'sans-serif';
font-size: 16px;
font-weight: 600;
color: var(--color-secondary);
margin-right: 0.25rem;
}
.date {
font-family: 'Poppins', 'Arial', 'sans-serif';
font-size: 14px;
font-weight: 400;
margin-left: 1em;
color: var(--color-tertiary);
}
`}
</style>
</>
)
}

View File

@ -0,0 +1,34 @@
import { Avatar } from 'components/design/Avatar'
import { API_URL } from 'utils/api'
import { User } from 'utils/authentication'
export interface UserAvatarProps {
user: User
}
export const UserAvatar: React.FC<UserAvatarProps> = (props) => {
return (
<>
<span className='user-avatar'>
<Avatar
src={`${API_URL}${props.user.logo}`}
alt={props.user.name}
width={50}
height={50}
/>
</span>
<style jsx>
{`
.user-avatar {
cursor: pointer;
position: absolute;
flex: 0 0 auto;
left: 12px;
overflow: hidden;
}
`}
</style>
</>
)
}

View File

@ -0,0 +1,40 @@
import { memo } from 'react'
import { MessageContent } from './MessageContent'
import { MessageHeader } from './MessageHeader'
import { UserAvatar } from './UserAvatar'
import { Message as MessageProps } from 'contexts/Messages'
export const Message: React.FunctionComponent<MessageProps> = memo((props) => {
return (
<>
<div className='message'>
<UserAvatar user={props.user} />
<MessageHeader createdAt={props.createdAt} user={props.user} />
<MessageContent
value={props.value}
type={props.type}
mimetype={props.mimetype}
/>
</div>
<style jsx>
{`
.message:hover {
background-color: var(--color-background-tertiary);
}
.message {
transition: background-color 0.15s ease-in-out;
margin-top: 2.3rem;
min-height: 2.75rem;
padding-left: 72px;
position: relative;
word-wrap: break-word;
flex: 0 0 auto;
position: relative;
}
`}
</style>
</>
)
})

View File

@ -0,0 +1,58 @@
import { useEffect } from 'react'
import InfiniteScroll from 'react-infinite-scroll-component'
import { Message } from './Message'
import { Loader } from 'components/design/Loader'
import { useMessages } from 'contexts/Messages'
import { Emoji } from 'emoji-mart'
import { emojiSet } from 'components/Emoji'
export const Messages: React.FC = () => {
const { messages, nextPage } = useMessages()
useEffect(() => {
window.scrollTo(0, document.body.scrollHeight)
}, [])
if (messages.rows.length === 0) {
return (
<div id='messages'>
<p>
Nothing to show here!{' '}
<Emoji set={emojiSet} emoji=':ghost:' size={20} />
</p>
<p>Start chatting to kill this Ghost!</p>
</div>
)
}
return (
<>
<div id='messages'>
<InfiniteScroll
dataLength={messages.rows.length}
next={nextPage}
inverse
scrollableTarget='messages'
hasMore={messages.hasMore}
loader={<Loader />}
>
{messages.rows.map((message) => {
return <Message key={message.id} {...message} />
})}
</InfiniteScroll>
</div>
<style jsx>
{`
#messages {
overflow-y: scroll;
display: flex;
flex-direction: column-reverse;
height: 800px;
}
`}
</style>
</>
)
}

View File

@ -0,0 +1,187 @@
import { useEffect, useRef, useState } from 'react'
import TextareaAutosize from 'react-textarea-autosize'
import { useAuthentication } from 'utils/authentication'
import { IconButton } from 'components/design/IconButton'
import { MessageData } from 'contexts/Messages'
import { EmojiPicker, EmojiPickerOnClick } from 'components/Emoji'
const defaultMessageData: MessageData = { type: 'text', value: '' }
export interface SendMessageProps {
channelId: string
}
export const SendMessage: React.FC<SendMessageProps> = (props) => {
const { authentication } = useAuthentication()
const [messageData, setMessageData] = useState<MessageData>(
defaultMessageData
)
const [isVisibleEmojiPicker, setIsVisibleEmojiPicker] = useState(false)
const inputFile = useRef<HTMLInputElement>(null)
useEffect(() => {
window.scrollTo(0, document.body.scrollHeight)
}, [isVisibleEmojiPicker])
const handleKeyDown: React.KeyboardEventHandler<HTMLTextAreaElement> = async (
event
) => {
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault()
await sendMessage(messageData)
}
}
const handleSubmit: React.FormEventHandler<HTMLFormElement> = async (
event
) => {
event.preventDefault()
await sendMessage(messageData)
}
const handleChange: React.ChangeEventHandler<HTMLTextAreaElement> = (
event
) => {
setMessageData({
value: event.target.value,
type: 'text'
})
}
const handleVisibleEmojiPicker = (): void => {
setIsVisibleEmojiPicker((isVisible) => !isVisible)
}
const handleEmojiPicker: EmojiPickerOnClick = (emoji) => {
const emojiColons = emoji.colons ?? ''
setMessageData((message) => {
return {
value: message.value + emojiColons,
type: 'text'
}
})
handleVisibleEmojiPicker()
}
const handleUploadFile = (): void => {
inputFile.current?.click()
}
const handleSubmitFile = async (): Promise<void> => {
if (
inputFile.current?.files != null &&
inputFile.current?.files?.length > 0
) {
const file = inputFile.current.files[0]
const formData = new FormData()
formData.append('type', 'file')
formData.append('file', file)
await authentication.api.post(
`/channels/${props.channelId}/messages`,
formData
)
setMessageData(defaultMessageData)
}
}
const sendMessage = async (messageData: MessageData): Promise<void> => {
const isEmptyMessage = messageData.value.length <= 0
if (!isEmptyMessage) {
await authentication.api.post(`/channels/${props.channelId}/messages`, {
value: messageData.value,
type: messageData.type
})
setMessageData(defaultMessageData)
}
}
return (
<>
{isVisibleEmojiPicker && <EmojiPicker onClick={handleEmojiPicker} />}
<form onSubmit={handleSubmit}>
<div className='send-message'>
<div className='icons'>
<IconButton
type='button'
icon='emoji'
hasBackground
size={50}
id='emoji-picker-button'
onClick={handleVisibleEmojiPicker}
/>
<IconButton
type='button'
icon='add'
hasBackground
size={50}
style={{ marginLeft: 5 }}
onClick={handleUploadFile}
/>
<input
ref={inputFile}
type='file'
name='input-file'
id='input-file'
onChange={handleSubmitFile}
/>
</div>
<div className='message-content'>
<TextareaAutosize
name='message-value'
id='message-value'
wrap='soft'
placeholder='Write a message'
required
maxLength={50_000}
value={messageData.value}
onChange={handleChange}
onKeyDown={handleKeyDown}
/>
</div>
<IconButton type='submit' icon='send' hasBackground size={50} />
</div>
</form>
<style jsx global>
{`
.message-content textarea {
font-family: 'Roboto', 'Arial', 'sans-serif';
color: var(--color-secondary);
width: 100%;
border: none;
background-color: transparent;
resize: none;
outline: none;
line-height: 30px;
height: 30px;
padding: 0 10px;
margin: 0;
white-space: pre-wrap;
}
`}
</style>
<style jsx>
{`
.send-message {
display: flex;
justify-content: space-between;
background-color: var(--color-background-tertiary);
padding: 10px;
margin-top: 20px;
border-radius: 2%;
}
#input-file {
display: none;
}
.icons {
display: flex;
}
.message-content {
width: 100%;
}
`}
</style>
</>
)
}

View File

@ -0,0 +1,15 @@
import { render } from '@testing-library/react'
import { ErrorPage } from '../ErrorPage'
describe('<ErrorPage />', () => {
it('should render with message and statusCode', async () => {
const message = 'Error'
const statusCode = 404
const { getByText } = render(
<ErrorPage statusCode={statusCode} message={message} />
)
expect(getByText(message)).toBeInTheDocument()
expect(getByText(statusCode)).toBeInTheDocument()
})
})

View File

@ -0,0 +1,17 @@
import Image, { ImageProps } from 'next/image'
export const Avatar: React.FC<ImageProps> = (props) => {
return (
<>
<Image {...props} className='avatar-image' />
<style jsx>
{`
:global(.avatar-image) {
border-radius: 50%;
}
`}
</style>
</>
)
}

View File

@ -0,0 +1,40 @@
import { forwardRef } from 'react'
interface ButtonProps extends React.ComponentPropsWithRef<'button'> {}
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
(props, ref) => {
const { children, ...rest } = props
return (
<>
<button ref={ref} {...rest} className='button'>
{children}
</button>
<style jsx>{`
.button {
cursor: pointer;
font-size: var(--default-font-size);
font-weight: 400;
letter-spacing: 0.8px;
padding: 1rem 2rem;
transform: translateY(-3px);
background-color: transparent;
border: 1px solid var(--color-primary);
border-radius: 10px;
transition: all 0.3s ease-in;
color: var(--color-primary);
outline: 0;
margin: 0;
}
.button:hover {
background-color: var(--color-primary);
color: #fff;
}
`}
</style>
</>
)
}
)

View File

@ -0,0 +1,24 @@
interface ContainerProps extends React.ComponentPropsWithRef<'div'> {}
export const Container: React.FC<ContainerProps> = (props) => {
const { children, className } = props
return (
<>
<div className={`container ${className ?? ''}`}>
{children}
</div>
<style jsx>
{`
.container {
height: calc(100vh - 110px);
display: flex;
flex-direction: column;
justify-content: center;
}
`}
</style>
</>
)
}

View File

@ -0,0 +1,38 @@
interface DividerProps {
content: string
}
export const Divider: React.FC<DividerProps> = (props) => {
const { content } = props
return (
<>
<div className='text-divider'>{content}</div>
<style jsx>
{`
.text-divider {
--text-divider-gap: 1rem;
--color-divider: #414141;
display: flex;
align-items: center;
letter-spacing: 0.1em;
&::before,
&::after {
content: '';
height: 1px;
background-color: var(--color-divider);
flex-grow: 1;
}
&::before {
margin-right: var(--text-divider-gap);
}
&::after {
margin-left: var(--text-divider-gap);
}
}
`}
</style>
</>
)
}

View File

@ -0,0 +1,65 @@
import { forwardRef, useMemo } from 'react'
export const icons = [
'add',
'delete',
'edit',
'emoji',
'send',
'settings',
'more',
'download'
] as const
export type Icon = typeof icons[number]
interface IconButtonProps extends React.ComponentPropsWithRef<'button'> {
icon: Icon
hasBackground?: boolean
size?: number
}
export const IconButton = forwardRef<HTMLButtonElement, IconButtonProps>(
(props, ref) => {
const { icon, hasBackground = false, size = 60, ...rest } = props
const imageSize = useMemo(() => {
return size / 2.6
}, [size])
return (
<>
<button ref={ref} className='button' {...rest}>
<img src={`/images/svg/icons/${icon}.svg`} alt={icon} />
</button>
<style jsx>
{`
.button {
background: ${hasBackground
? 'var(--color-background-secondary)'
: 'none'};
border-radius: ${hasBackground ? '50%' : '0'};
width: ${hasBackground ? `${size}px` : '100%'};
height: ${hasBackground ? `${size}px` : '100%'};
border: none;
outline: none;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
}
.button:hover {
opacity: 0.9;
}
.button > img {
width: ${imageSize}px;
height: ${imageSize}px;
display: block;
}
`}
</style>
</>
)
}
)

128
components/design/Input.tsx Normal file
View File

@ -0,0 +1,128 @@
import { forwardRef, useState } from 'react'
import Link from 'next/link'
import { ErrorMessage } from '../Authentication/ErrorMessage'
import useTranslation from 'next-translate/useTranslation'
interface InputProps extends React.ComponentPropsWithRef<'input'> {
label: string
errors?: string[]
showForgotPassword?: boolean
}
export const Input = forwardRef<HTMLInputElement, InputProps>((props, ref) => {
const {
label,
name,
type = 'text',
errors = [],
showForgotPassword = false,
...rest
} = props
const { t } = useTranslation()
const [inputType, setInputType] = useState(type)
const handlePassword = (): void => {
const oppositeType = inputType === 'password' ? 'text' : 'password'
setInputType(oppositeType)
}
return (
<>
<div className='container'>
<div className='input-with-label'>
<div className='label-container'>
<label className='label' htmlFor={name}>
{label}
</label>
{type === 'password' && showForgotPassword ? (
<Link href='/authentication/forgot-password'>
<a className='label-forgot-password'>
{t('authentication:forgot-password')}
</a>
</Link>
) : null}
</div>
<div className='input-container'>
<input
data-testid='input'
className='input'
{...rest}
ref={ref}
id={name}
name={name}
type={inputType}
/>
{type === 'password' && (
<div
data-testid='password-eye'
onClick={handlePassword}
className='password-eye'
/>
)}
<ErrorMessage errors={errors} />
</div>
</div>
</div>
<style jsx>
{`
.container {
margin-bottom: 20px;
}
.input-container {
margin-top: 0;
position: relative;
}
.input-with-label {
display: flex;
flex-direction: column;
}
.label-container {
display: flex;
justify-content: space-between;
margin: 5px 0;
}
.label-forgot-password {
font-size: 12px;
}
.label {
color: var(--color-secondary);
font-size: 16px;
font-family: 'Poppins', 'Arial', 'sans-serif';
padding-left: 3px;
}
.input {
background-color: #f1f1f1;
font-family: 'Roboto', 'Arial', 'sans-serif';
width: 100%;
height: 44px;
line-height: 44px;
padding: 0 20px;
color: #2a2a2a;
border: 0;
box-shadow: ${errors.length >= 1
? '0 0 0 2px var(--color-error)'
: 'none'};
border-radius: 10px;
}
.input:focus {
outline: 0;
border-color: var(--color-primary);
box-shadow: 0 0 0 2px var(--color-primary);
}
.password-eye {
position: absolute;
top: 12px;
right: 16px;
z-index: 1;
width: 20px;
height: 20px;
background-image: url(/images/svg/icons/input/${inputType}.svg);
background-size: cover;
cursor: pointer;
}
`}
</style>
</>
)
})

View File

@ -0,0 +1,80 @@
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: var(--color-primary);
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,80 @@
import { forwardRef, useMemo } from 'react'
import Image from 'next/image'
export type SocialMedia = 'Discord' | 'GitHub' | 'Google'
type SocialMediaColors = {
[key in SocialMedia]: string
}
interface SocialMediaButtonProps extends React.ComponentPropsWithRef<'button'> {
socialMedia: SocialMedia
}
const socialMediaColors: SocialMediaColors = {
Discord: '#7289DA',
GitHub: '#24292E',
Google: '#FCFCFC'
}
export const SocialMediaButton = forwardRef<
HTMLButtonElement,
SocialMediaButtonProps
>((props, ref) => {
const { socialMedia, className, ...rest } = props
const socialMediaColor = useMemo(() => {
return socialMediaColors[socialMedia]
}, [socialMedia])
return (
<>
<button
data-testid='button'
ref={ref}
{...rest}
className={`button ${className ?? ''}`}
>
<Image
width={20}
height={20}
src={`/images/svg/web/${socialMedia}.svg`}
alt={socialMedia}
/>
<span className='social-media'>{socialMedia}</span>
</button>
<style jsx>
{`
.button {
display: inline-flex;
align-items: center;
outline: none;
font-size: var(--default-font-size);
font-family: 'Roboto', 'Arial', 'sans-serif';
margin: 0;
cursor: pointer;
letter-spacing: 0.8px;
padding: 0.9rem 2.4rem;
border: 1px solid transparent;
border-radius: 10px;
box-shadow: 0 1rem 2rem rgba(0, 0, 0, 0.2);
background: ${socialMediaColor};
color: ${socialMedia === 'Google' ? '#000' : '#fff'};
transition: all 0.3s ease-out;
}
.button:hover {
opacity: 0.85;
transition: all 0.3s ease-in;
}
.button:before {
display: none;
}
.social-media {
margin-left: 0.7rem;
}
`}
</style>
</>
)
})

View File

@ -0,0 +1,106 @@
import { forwardRef } from 'react'
interface TooltipProps extends React.ComponentPropsWithRef<'div'> {
content: string
direction?: 'top' | 'bottom' | 'right' | 'left'
}
export const Tooltip = forwardRef<HTMLDivElement, TooltipProps>(
(props, ref) => {
const { direction = 'bottom', children, content, ...rest } = props
return (
<>
<div ref={ref} {...rest} className='tooltip-wrapper'>
{children}
<div className={`tooltip ${direction}`}>{content}</div>
</div>
<style jsx>
{`
.tooltip-wrapper {
--tooltip-text-color: white;
--tooltip-background-color: black;
--tooltip-margin: 50px;
--tooltip-arrow-size: 6px;
}
.tooltip-wrapper {
display: inline-block;
}
.tooltip {
position: absolute;
border-radius: 6px;
left: 100%;
top: 50%;
transform: translateY(-50%);
padding: 10px;
color: var(--tooltip-text-color);
background: var(--tooltip-background-color);
font-size: 15px;
font-family: sans-serif;
line-height: 1;
z-index: 100;
white-space: nowrap;
opacity: 0;
visibility: hidden;
transition: all 0.15s ease-in;
}
.tooltip-wrapper ~ .tooltip-wrapper:hover .tooltip,
.tooltip-wrapper:first-child:hover .tooltip {
opacity: 1;
visibility: visible;
transition: all 0.35s ease-out;
margin: 0;
}
.tooltip::before {
content: ' ';
left: 50%;
border: solid transparent;
height: 0;
width: 0;
position: absolute;
pointer-events: none;
border-width: var(--tooltip-arrow-size);
margin-left: calc(var(--tooltip-arrow-size) * -1);
}
.tooltip.top {
top: calc(var(--tooltip-margin) * -1);
}
.tooltip.top::before {
top: 100%;
border-top-color: var(--tooltip-background-color);
}
.tooltip.right {
left: calc(100% + var(--tooltip-margin));
}
.tooltip.right::before {
left: calc(var(--tooltip-arrow-size) * -1);
border-right-color: var(--tooltip-background-color);
}
.tooltip.bottom {
bottom: calc(var(--tooltip-margin) * -1);
}
.tooltip.bottom::before {
bottom: 100%;
border-bottom-color: var(--tooltip-background-color);
}
.tooltip.left {
left: auto;
right: calc(100% + var(--tooltip-margin));
top: 50%;
transform: translateX(0) translateY(-50%);
}
.tooltip.left::before {
left: auto;
right: calc(var(--tooltip-arrow-size) * -2);
top: 50%;
transform: translateX(0) translateY(-50%);
border-left-color: var(--tooltip-background-color);
}
`}
</style>
</>
)
}
)

View File

@ -0,0 +1,13 @@
import { render } from '@testing-library/react'
import { Avatar } from '../Avatar'
describe('<Avatar />', () => {
it('should render', async () => {
const altAttribute = 'avatar'
const { getByAltText } = render(
<Avatar width={50} height={50} src='/avatar.png' alt={altAttribute} />
)
expect(getByAltText(altAttribute)).toBeInTheDocument()
})
})

View File

@ -0,0 +1,10 @@
import { render } from '@testing-library/react'
import { Button } from '../Button'
describe('<Button />', () => {
it('should render', async () => {
const { getByText } = render(<Button>Submit</Button>)
expect(getByText('Submit')).toBeInTheDocument()
})
})

View File

@ -0,0 +1,10 @@
import { render } from '@testing-library/react'
import { Container } from '../Container'
describe('<Container />', () => {
it('should render', async () => {
const { getByText } = render(<Container>Content</Container>)
expect(getByText('Content')).toBeInTheDocument()
})
})

View File

@ -0,0 +1,11 @@
import { render } from '@testing-library/react'
import { Divider } from '../Divider'
describe('<Divider />', () => {
it('should render with the content', async () => {
const content = 'divider'
const { getByText } = render(<Divider content={content} />)
expect(getByText(content)).toBeInTheDocument()
})
})

View File

@ -0,0 +1,13 @@
import { render } from '@testing-library/react'
import { Icon, IconButton } from '../IconButton'
describe('<IconButton />', () => {
it('should render with the icon', async () => {
const icon: Icon = 'add'
const { getByAltText } = render(<IconButton icon={icon} />)
const iconImage = getByAltText(icon)
expect(iconImage).toBeInTheDocument()
expect(iconImage).toHaveAttribute('src', `/images/svg/icons/${icon}.svg`)
})
})

View File

@ -0,0 +1,26 @@
import { render, fireEvent } from '@testing-library/react'
import { Input } from '../Input'
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 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')
})
})

View File

@ -0,0 +1,20 @@
import { render } from '@testing-library/react'
import { Loader } from '../Loader'
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,23 @@
import { render } from '@testing-library/react'
import { SocialMedia, SocialMediaButton } from '../SocialMediaButton'
describe('<SocialMediaButton />', () => {
it('should render the social media', async () => {
const socialMedia: SocialMedia = 'Discord'
const { findByAltText } = render(
<SocialMediaButton socialMedia={socialMedia} />
)
const socialMediaButton = await findByAltText(socialMedia)
expect(socialMediaButton).toBeInTheDocument()
})
it('should render with a black text color with Google social media', async () => {
const socialMedia: SocialMedia = 'Google'
const { findByTestId } = render(
<SocialMediaButton socialMedia={socialMedia} />
)
const button = await findByTestId('button')
expect(button).toHaveStyle('color: #000')
})
})

View File

@ -0,0 +1,11 @@
import { render } from '@testing-library/react'
import { Tooltip } from '../Tooltip'
describe('<Tooltip />', () => {
it('should render with content', async () => {
const content = 'tooltip content'
const { getByText } = render(<Tooltip content={content} />)
expect(getByText(content)).toBeInTheDocument()
})
})

58
contexts/Guilds.tsx Normal file
View File

@ -0,0 +1,58 @@
import { createContext, useContext } from 'react'
import { NextPage, PaginationData, usePagination } from 'hooks/usePagination'
import { useAuthentication } from 'utils/authentication'
export interface Guild {
id: number
name: string
description: string
icon: string
isPublic: boolean
createdAt: string
updatedAt: string
}
interface PaginationGuild {
id: number
isOwner: boolean
lastVisitedChannelId: number
userId: number
guildId: number
createdAt: string
updatedAt: string
guild: Guild
}
export type Guilds = PaginationData<PaginationGuild>
export interface GuildsValue {
guilds: Guilds
nextPage: NextPage
}
export interface GuildsProviderProps {
guilds: Guilds
}
const defaultGuildsContext: GuildsValue = {} as any
const GuildsContext = createContext<GuildsValue>(defaultGuildsContext)
export const GuildsProvider: React.FC<GuildsProviderProps> = (props) => {
const { authentication } = useAuthentication()
const { data: guilds, nextPage } = usePagination<PaginationGuild>({
api: authentication.api,
url: '/guilds',
defaultPaginationData: props.guilds
})
return (
<GuildsContext.Provider value={{ guilds, nextPage }}>
{props.children}
</GuildsContext.Provider>
)
}
export const useGuilds = (): GuildsValue => {
return useContext(GuildsContext)
}

72
contexts/Messages.tsx Normal file
View File

@ -0,0 +1,72 @@
import { createContext, useContext, useEffect } from 'react'
import { NextPage, PaginationData, usePagination } from 'hooks/usePagination'
import { useAuthentication, User } from 'utils/authentication'
import { handleSocketData, SocketData } from 'utils/handleSocketData'
export type MessageType = 'text' | 'file'
export interface MessageData {
value: string
type: MessageType
}
export interface Message extends MessageData {
id: number
mimetype: string
memberId: number
channelId: number
createdAt: string
updatedAt: string
user: User
}
export type Messages = PaginationData<Message>
export interface MessagesValue {
messages: Messages
nextPage: NextPage
}
export interface MessagesProviderProps {
messages: Messages
channelId: string
}
const defaultGuildsContext: MessagesValue = {} as any
const MessagesContext = createContext<MessagesValue>(defaultGuildsContext)
export const MessagesProvider: React.FC<MessagesProviderProps> = (props) => {
const { authentication } = useAuthentication()
const { data: messages, nextPage, setData } = usePagination<Message>({
url: `/channels/${props.channelId}/messages`,
api: authentication.api,
defaultPaginationData: props.messages,
inverse: true
})
useEffect(() => {
setData(props.messages)
}, [props.messages])
useEffect(() => {
authentication.socket.on('messages', (socketData: SocketData) => {
const isAtBottom =
window.innerHeight + window.scrollY >= document.body.offsetHeight
handleSocketData({ setData })(socketData)
if (isAtBottom) {
window.scrollTo(0, document.body.scrollHeight)
}
})
}, [])
return (
<MessagesContext.Provider value={{ messages, nextPage }}>
{props.children}
</MessagesContext.Provider>
)
}
export const useMessages = (): MessagesValue => {
return useContext(MessagesContext)
}

51
contexts/Theme.tsx Normal file
View File

@ -0,0 +1,51 @@
import { createContext, useState, useEffect, useContext } from 'react'
export const themes = ['light', 'dark'] as const
export type Theme = typeof themes[number]
export interface ThemeValue {
theme: Theme
handleToggleTheme: () => void
setTheme: React.Dispatch<React.SetStateAction<Theme>>
}
const defaultThemeContext: ThemeValue = {} as any
const ThemeContext = createContext<ThemeValue>(defaultThemeContext)
const getOppositeTheme = (theme: Theme): Theme => {
return theme === 'dark' ? 'light' : 'dark'
}
export const ThemeProvider: React.FC = (props) => {
const [theme, setTheme] = useState<Theme>('dark')
useEffect(() => {
const localTheme = localStorage.getItem('theme') as Theme
if (themes.includes(localTheme)) {
setTheme(localTheme)
}
}, [])
useEffect(() => {
const body = document.querySelector('body') as HTMLBodyElement
const oppositeTheme = getOppositeTheme(theme)
body.classList.add(`theme-${theme}`)
body.classList.remove(`theme-${oppositeTheme}`)
localStorage.setItem('theme', theme)
}, [theme])
const handleToggleTheme = (): void => {
const oppositeTheme = getOppositeTheme(theme)
setTheme(oppositeTheme)
}
return (
<ThemeContext.Provider value={{ theme, handleToggleTheme, setTheme }}>
{props.children}
</ThemeContext.Provider>
)
}
export const useTheme = (): ThemeValue => {
return useContext(ThemeContext)
}

View File

@ -0,0 +1,12 @@
version: '3.0'
services:
thream-website:
container_name: ${COMPOSE_PROJECT_NAME}
build:
context: './'
dockerfile: './Dockerfile.production'
ports:
- '${PORT}:${PORT}'
environment:
PORT: ${PORT}
env_file: './.env'

13
docker-compose.yml Normal file
View File

@ -0,0 +1,13 @@
version: '3.0'
services:
thream-website:
container_name: ${COMPOSE_PROJECT_NAME}
build:
context: './'
dockerfile: './Dockerfile'
ports:
- '${PORT}:${PORT}'
environment:
PORT: ${PORT}
volumes:
- './:/website'

View File

@ -0,0 +1,130 @@
import { useState, useMemo, useEffect } from 'react'
import Validator, {
ValidationError,
ValidationRule,
ValidatorConstructorOptions
} from 'fastest-validator'
import useTranslation from 'next-translate/useTranslation'
export type ValidationResult<T> = { [key in keyof T]: ValidationError[] }
export type AddValidationErrors = (validationErrors: ValidationError[]) => void
export type GetErrorMessages<T = any> = (key: keyof T) => string[]
export type Validate = (value: any) => boolean
export interface UseValidatorResult<T> {
validationResult: ValidationResult<T>
addValidationErrors: AddValidationErrors
getErrorMessages: GetErrorMessages<T>
validate: Validate
}
export type ValidatorSchema<T = any> = {
[key in keyof T]: ValidationRule
}
const getErrorMessage = (error: ValidationError, message?: string): string => {
if (error.message == null || message == null) {
return error.type
}
return message
.replace('{field}', error.field)
.replace('{expected}', error.expected)
}
export const useFastestValidator = <T = any>(
validatorSchema: ValidatorSchema<T>,
validatorOptions?: ValidatorConstructorOptions
): UseValidatorResult<T> => {
const fillEmptyValidation = (
result: ValidationResult<T> = {} as any
): ValidationResult<T> => {
for (const key in validatorSchema) {
if (result[key] == null) {
result[key] = []
}
}
return result
}
const { lang } = useTranslation()
const emptyValidationResult = useMemo(() => {
return fillEmptyValidation()
}, [validatorSchema])
useEffect(() => {
const result = { ...validationResult }
for (const key in result) {
result[key] = result[key].map((error) => {
if (validatorOptions?.messages != null) {
error.message = getErrorMessage(
error,
validatorOptions.messages[error.type]
)
}
return error
})
}
setValidation(result)
}, [lang])
const [validationResult, setValidation] = useState<ValidationResult<T>>(
emptyValidationResult
)
const validator = useMemo(() => {
return new Validator(validatorOptions).compile(validatorSchema)
}, [validatorOptions, validatorSchema])
const validate: Validate = (value) => {
const validationErrors = validator(value)
if (!Array.isArray(validationErrors)) {
setValidation(emptyValidationResult)
return true
}
setValidationResult(validationErrors)
return false
}
const setValidationResult = (
validationErrors: ValidationError[],
validationResult: ValidationResult<T> = {} as any
): void => {
const result: ValidationResult<T> = validationResult
validationErrors.forEach((error) => {
if (result[error.field as keyof T] == null) {
result[error.field as keyof T] = [error]
} else {
result[error.field as keyof T].push(error)
}
})
const finalResult = fillEmptyValidation(result)
setValidation(finalResult)
}
const addValidationErrors: AddValidationErrors = (validationErrors) => {
const result: ValidationResult<T> = { ...validationResult }
validationErrors.map((error) => {
if (validatorOptions?.messages != null) {
error.message = validatorOptions.messages[error.type]
}
})
setValidationResult(validationErrors, result)
}
const getErrorMessages: GetErrorMessages<T> = (key) => {
return validationResult[key].map((error) => {
return getErrorMessage(error, error.message)
})
}
return {
validationResult,
addValidationErrors,
getErrorMessages,
validate
}
}

118
hooks/useForm.ts Normal file
View File

@ -0,0 +1,118 @@
import { useMemo, useState } from 'react'
import { FormDataObject, HandleForm } from 'react-component-form'
import useTranslation from 'next-translate/useTranslation'
import { FormState, useFormState } from 'hooks/useFormState'
import {
GetErrorMessages,
useFastestValidator,
ValidatorSchema
} from 'hooks/useFastestValidator'
import { ValidationError } from 'fastest-validator'
export interface ErrorResponse {
field?: string
message: string
}
export interface UseFormOptions {
validatorSchema: ValidatorSchema
}
export type HandleSubmit = (callback: HandleSubmitCallback) => HandleForm
export type HandleSubmitCallback = (
formData: FormDataObject
) => Promise<string | null>
export interface UseFormResult {
message: string | undefined
formState: FormState
getErrorMessages: GetErrorMessages
handleChange: HandleForm
handleSubmit: HandleSubmit
}
export const useForm = (options: UseFormOptions): UseFormResult => {
const { validatorSchema } = options
const { lang, t } = useTranslation()
const errorsMessages = useMemo(() => {
return {
stringMin: t('errors:stringMin'),
stringEmpty: t('errors:required'),
emailEmpty: t('errors:required'),
required: t('errors:required'),
email: t('errors:email'),
alreadyUsed: t('errors:alreadyUsed'),
invalid: t('errors:invalid')
}
}, [lang])
const [formState, setFormState] = useFormState()
const {
validate,
getErrorMessages,
addValidationErrors
} = useFastestValidator(validatorSchema, {
messages: errorsMessages
})
const [message, setMessage] = useState<string | undefined>(undefined)
const handleChange: HandleForm = (formData) => {
if (formState !== 'error') {
setMessage(undefined)
}
const isValid = validate(formData)
setFormState(!isValid ? 'error' : 'idle')
}
const handleSubmit = (callback: HandleSubmitCallback): HandleForm => {
return async (formData, formElement) => {
const isValid = validate(formData)
if (isValid) {
setFormState('loading')
try {
const successMessage = await callback(formData)
if (successMessage != null) {
setMessage(successMessage)
setFormState('success')
formElement.reset()
}
} catch (error) {
if (error.response == null) {
setFormState('error')
setMessage(t('errors:server-error'))
return
}
const errors = error.response.data.errors as ErrorResponse[]
const validationErrors: ValidationError[] = []
for (const error of errors) {
if (error.field != null) {
if (error.message.endsWith('already used')) {
validationErrors.push({
type: 'alreadyUsed',
field: error.field
})
} else {
validationErrors.push({
type: 'invalid',
field: error.field
})
}
} else {
setFormState('error')
setMessage(error.message)
break
}
}
addValidationErrors(validationErrors)
setFormState('error')
}
} else {
setMessage(undefined)
setFormState('error')
}
}
}
return { message, formState, getErrorMessages, handleChange, handleSubmit }
}

15
hooks/useFormState.ts 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]
}

76
hooks/usePagination.ts Normal file
View File

@ -0,0 +1,76 @@
import { AxiosInstance } from 'axios'
import { useRef, useState } from 'react'
import { uniqBy } from 'lodash'
export type NextPage = () => Promise<void>
export interface PaginationData<T> {
page: number
itemsPerPage: number
totalItems: number
hasMore: boolean
rows: T[]
}
interface UsePaginationOptions {
api: AxiosInstance
url: string
defaultPaginationData?: PaginationData<any>
inverse?: boolean
}
export type SetData<T> = React.Dispatch<React.SetStateAction<PaginationData<T>>>
interface UsePaginationReturn<T> {
data: PaginationData<T>
nextPage: NextPage
setData: SetData<T>
isLoading: boolean
}
const defaultData: PaginationData<any> = {
page: 0,
itemsPerPage: 20,
totalItems: 0,
hasMore: true,
rows: []
}
export const usePagination = <T>(
options: UsePaginationOptions
): UsePaginationReturn<T> => {
const {
api,
url,
defaultPaginationData = defaultData,
inverse = false
} = options
const page = useRef(defaultPaginationData.page + 1)
const [data, setData] = useState<PaginationData<T>>(defaultPaginationData)
const [isLoading, setIsLoading] = useState(false)
const nextPage: NextPage = async () => {
if (isLoading) {
return
}
setIsLoading(true)
const { data: newData } = await api.get<PaginationData<T>>(
`${url}?itemsPerPage=${defaultPaginationData.itemsPerPage}&page=${page.current}`
)
const rows = inverse
? [...newData.rows, ...data.rows]
: [...data.rows, ...newData.rows]
setData({
page: page.current,
itemsPerPage: defaultPaginationData.itemsPerPage,
hasMore: newData.hasMore,
totalItems: newData.totalItems,
rows: uniqBy(rows, 'id')
})
setIsLoading(false)
page.current += 1
}
return { data, setData, nextPage, isLoading }
}

16
i18n.json Normal file
View File

@ -0,0 +1,16 @@
{
"locales": ["en", "fr"],
"defaultLocale": "en",
"pages": {
"*": ["common"],
"/": ["home"],
"/404": ["errors"],
"/500": ["errors"],
"/authentication/forgot-password": ["authentication", "errors"],
"/authentication/reset-password": ["authentication", "errors"],
"/authentication/signin": ["authentication", "errors"],
"/authentication/signup": ["authentication", "errors"],
"/application": ["application"],
"/application/[guildId]/[channelId]": ["application"]
}
}

View File

@ -0,0 +1,4 @@
{
"add-guild": "Add a Guild",
"settings": "Settings"
}

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",
"errors": "Errors",
"success": "Success",
"success-signup": "You're almost there, please check your emails to confirm registration.",
"success-forgot-password": "Password-reset request successful, please check your emails!"
}

3
locales/en/common.json Normal file
View File

@ -0,0 +1,3 @@
{
"description": "Stay close with your friends and communities, talk, chat, collaborate, share, and have fun."
}

9
locales/en/errors.json Normal file
View File

@ -0,0 +1,9 @@
{
"page-not-found": "This page could not be found.",
"server-error": "Internal Server Error.",
"required": "Oops, this field is required 🙈.",
"stringMin": "The field must contain at least {expected} characters.",
"email": "Mmm… It seems that this email is not valid 🤔.",
"alreadyUsed": "Already used.",
"invalid": "Invalid value."
}

4
locales/en/home.json Normal file
View File

@ -0,0 +1,4 @@
{
"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>."
}

View File

@ -0,0 +1,4 @@
{
"add-guild": "Ajouter une Guilde",
"settings": "Paramètres"
}

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",
"errors": "Erreurs",
"success": "Succès",
"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!"
}

3
locales/fr/common.json Normal file
View File

@ -0,0 +1,3 @@
{
"description": "Restez proche de vos amis et de vos communautés, parlez, collaborez, partagez et amusez-vous."
}

9
locales/fr/errors.json Normal file
View File

@ -0,0 +1,9 @@
{
"page-not-found": "Cette page est introuvable.",
"server-error": "Erreur interne du serveur.",
"required": "Oups, ce champ est obligatoire 🙈.",
"stringMin": "Le champ doit contenir au moins {expected} caractères.",
"email": "Mmm… Il semblerait que cet email ne soit pas valide 🤔.",
"alreadyUsed": "Déjà utilisé.",
"invalid": "Valeur invalide."
}

4
locales/fr/home.json Normal file
View File

@ -0,0 +1,4 @@
{
"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>."
}

4
next-env.d.ts vendored Normal file
View File

@ -0,0 +1,4 @@
/* eslint-disable */
/// <reference types="next" />
/// <reference types="next/types/global" />

17
next.config.js Normal file
View File

@ -0,0 +1,17 @@
const nextTranslate = require('next-translate')
const nextPWA = require('next-pwa')
module.exports = nextTranslate(
nextPWA({
pwa: {
disable: process.env.NODE_ENV !== 'production',
dest: 'public'
},
images: {
domains: [
'api.thream.divlo.fr',
...(process.env.NODE_ENV === 'development' ? ['localhost'] : [])
]
}
})
)

43545
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

161
package.json Normal file
View File

@ -0,0 +1,161 @@
{
"name": "@thream/website",
"version": "0.0.1",
"private": true,
"release-it": {
"git": {
"commit": false,
"push": false,
"tag": false
},
"gitlab": {
"release": false
},
"npm": {
"publish": false
},
"hooks": {
"before:init": [
"npm run lint:docker",
"npm run lint:editorconfig",
"npm run lint:markdown",
"npm run lint:typescript",
"npm run build",
"npm run test",
"npm run lighthouse"
]
},
"plugins": {
"@release-it/conventional-changelog": {
"preset": "angular",
"infile": "CHANGELOG.md"
}
}
},
"jest": {
"roots": [
"<rootDir>"
],
"transform": {
"^.+\\.(js|jsx|ts|tsx)$": "babel-jest"
},
"moduleDirectories": [
"node_modules",
"./"
],
"moduleFileExtensions": [
"ts",
"tsx",
"js",
"jsx",
"json",
"node"
],
"setupFilesAfterEnv": [
"@testing-library/jest-dom/extend-expect",
"@testing-library/react"
],
"collectCoverage": true,
"collectCoverageFrom": [
"**/*.{js,jsx,ts,tsx}",
"!**/*.d.ts",
"!**/.next/**",
"!**/node_modules/**",
"!**/next.config.js",
"!**/workbox-*.js",
"!**/sw.js"
],
"coverageDirectory": "./coverage",
"coverageReporters": [
"text",
"cobertura"
]
},
"ts-standard": {
"ignore": [
".next",
".lighthouseci",
"coverage",
"node_modules",
"next-env.d.ts",
"**/workbox-*.js",
"**/sw.js"
],
"envs": [
"node",
"browser",
"jest"
],
"report": "stylish"
},
"scripts": {
"dev": "next dev",
"start": "next start",
"build": "next build",
"export": "next export",
"lint:commit": "commitlint",
"lint:docker": "dockerfilelint './Dockerfile' && dockerfilelint './Dockerfile.production'",
"lint:editorconfig": "editorconfig-checker",
"lint:markdown": "markdownlint '**/*.md' --dot --ignore node_modules",
"lint:typescript": "ts-standard",
"test": "jest",
"lighthouse": "lhci autorun",
"release": "release-it",
"postinstall": "husky install"
},
"dependencies": {
"@fontsource/poppins": "4.2.2",
"@fontsource/roboto": "4.2.3",
"axios": "0.21.1",
"date-and-time": "1.0.0",
"emoji-mart": "3.0.1",
"fastest-validator": "1.10.1",
"katex": "0.13.2",
"lodash": "4.17.21",
"next": "10.1.3",
"next-pwa": "5.2.14",
"next-translate": "1.0.6",
"normalize.css": "8.0.1",
"pretty-bytes": "5.6.0",
"react": "17.0.2",
"react-component-form": "1.3.0",
"react-dom": "17.0.2",
"react-infinite-scroll-component": "6.0.0",
"react-markdown": "5.0.3",
"react-textarea-autosize": "8.3.2",
"remark-gfm": "1.0.0",
"remark-math": "4.0.0",
"socket.io-client": "4.0.1",
"unified": "9.2.1",
"unist-util-visit": "2.0.3",
"universal-cookie": "4.0.4"
},
"devDependencies": {
"@commitlint/cli": "12.1.1",
"@commitlint/config-conventional": "12.1.1",
"@lhci/cli": "0.7.1",
"@matejmazur/react-katex": "3.1.3",
"@release-it/conventional-changelog": "2.0.1",
"@styled-jsx/plugin-sass": "3.0.0",
"@testing-library/jest-dom": "5.11.10",
"@testing-library/react": "11.2.6",
"@types/date-and-time": "0.13.0",
"@types/emoji-mart": "3.0.4",
"@types/jest": "26.0.22",
"@types/lodash": "4.14.168",
"@types/node": "14.14.41",
"@types/react": "17.0.3",
"@types/styled-jsx": "2.2.8",
"@types/unist": "2.0.3",
"babel-jest": "26.6.3",
"dockerfilelint": "1.8.0",
"editorconfig-checker": "4.0.2",
"husky": "6.0.0",
"jest": "26.6.3",
"markdownlint-cli": "0.27.1",
"release-it": "14.6.1",
"sass": "1.32.10",
"ts-standard": "10.0.0",
"typescript": "4.2.4"
}
}

23
pages/404.tsx Normal file
View File

@ -0,0 +1,23 @@
import { GetStaticProps } from 'next'
import useTranslation from 'next-translate/useTranslation'
import { Head } from 'components/Head'
import { ErrorPage } from 'components/ErrorPage'
const Error404: React.FC = () => {
const { t } = useTranslation()
return (
<>
<Head title='Thream | 404' />
<ErrorPage message={t('errors:page-not-found')} statusCode={404} />
</>
)
}
export const getStaticProps: GetStaticProps = async () => {
return { props: {} }
}
export default Error404

23
pages/500.tsx Normal file
View File

@ -0,0 +1,23 @@
import { GetStaticProps } from 'next'
import useTranslation from 'next-translate/useTranslation'
import { Head } from 'components/Head'
import { ErrorPage } from 'components/ErrorPage'
const Error500: React.FC = () => {
const { t } = useTranslation()
return (
<>
<Head title='Thream | 500' />
<ErrorPage message={t('errors:server-error')} statusCode={500} />
</>
)
}
export const getStaticProps: GetStaticProps = async () => {
return { props: {} }
}
export default Error500

Some files were not shown because too many files have changed in this diff Show More