chore: initial commit
This commit is contained in:
commit
21123c4477
12
.babelrc.json
Normal file
12
.babelrc.json
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"presets": [
|
||||||
|
[
|
||||||
|
"next/babel",
|
||||||
|
{
|
||||||
|
"styled-jsx": {
|
||||||
|
"plugins": ["@styled-jsx/plugin-sass"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
}
|
1
.commitlintrc.json
Normal file
1
.commitlintrc.json
Normal file
@ -0,0 +1 @@
|
|||||||
|
{ "extends": ["@commitlint/config-conventional"] }
|
11
.dockerignore
Normal file
11
.dockerignore
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
.vscode
|
||||||
|
.git
|
||||||
|
.next
|
||||||
|
build
|
||||||
|
coverage
|
||||||
|
dist
|
||||||
|
node_modules
|
||||||
|
out
|
||||||
|
**/workbox-*.js
|
||||||
|
**/sw.js
|
||||||
|
**/__test__/**
|
11
.editorconfig
Normal file
11
.editorconfig
Normal 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
3
.env.example
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
COMPOSE_PROJECT_NAME=thream-website
|
||||||
|
PORT=3000
|
||||||
|
NEXT_PUBLIC_API_URL=http://localhost:8080
|
34
.gitignore
vendored
Normal file
34
.gitignore
vendored
Normal 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
1
.husky/.gitignore
vendored
Normal file
@ -0,0 +1 @@
|
|||||||
|
_
|
4
.husky/commit-msg
Executable file
4
.husky/commit-msg
Executable file
@ -0,0 +1,4 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
. "$(dirname "$0")/_/husky.sh"
|
||||||
|
|
||||||
|
npm run lint:commit -- --edit
|
7
.husky/pre-commit
Executable file
7
.husky/pre-commit
Executable 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
34
.lighthouserc.json
Normal 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
7
.markdownlint.json
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"default": true,
|
||||||
|
"MD013": false,
|
||||||
|
"MD024": false,
|
||||||
|
"MD033": false,
|
||||||
|
"MD041": false
|
||||||
|
}
|
132
CODE_OF_CONDUCT.md
Normal file
132
CODE_OF_CONDUCT.md
Normal 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
54
CONTRIBUTING.md
Normal 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
10
Dockerfile
Normal 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
31
Dockerfile.production
Normal 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
21
LICENSE
Normal 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
72
README.md
Normal 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)
|
22
components/Application/Main.tsx
Normal file
22
components/Application/Main.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
19
components/Application/Sidebar/SidebarItem.tsx
Normal file
19
components/Application/Sidebar/SidebarItem.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
})
|
28
components/Application/Sidebar/SidebarList.tsx
Normal file
28
components/Application/Sidebar/SidebarList.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
10
components/Application/Sidebar/__test__/SidebarItem.test.tsx
Normal file
10
components/Application/Sidebar/__test__/SidebarItem.test.tsx
Normal 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()
|
||||||
|
})
|
||||||
|
})
|
10
components/Application/Sidebar/__test__/SidebarList.test.tsx
Normal file
10
components/Application/Sidebar/__test__/SidebarList.test.tsx
Normal 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()
|
||||||
|
})
|
||||||
|
})
|
98
components/Application/Sidebar/index.tsx
Normal file
98
components/Application/Sidebar/index.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
10
components/Application/__test__/Main.test.tsx
Normal file
10
components/Application/__test__/Main.test.tsx
Normal 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()
|
||||||
|
})
|
||||||
|
})
|
32
components/Application/index.tsx
Normal file
32
components/Application/index.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
99
components/Authentication/AuthenticationForm.tsx
Normal file
99
components/Authentication/AuthenticationForm.tsx
Normal 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} />
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
53
components/Authentication/AuthenticationFormLayout.tsx
Normal file
53
components/Authentication/AuthenticationFormLayout.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
109
components/Authentication/AuthenticationSocialMedia.tsx
Normal file
109
components/Authentication/AuthenticationSocialMedia.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
76
components/Authentication/ErrorMessage.tsx
Normal file
76
components/Authentication/ErrorMessage.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
104
components/Authentication/FormState.tsx
Normal file
104
components/Authentication/FormState.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
14
components/Authentication/Success.tsx
Normal file
14
components/Authentication/Success.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
16
components/Authentication/__test__/ErrorMessage.test.tsx
Normal file
16
components/Authentication/__test__/ErrorMessage.test.tsx
Normal 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()
|
||||||
|
})
|
||||||
|
})
|
33
components/Authentication/__test__/FormState.test.tsx
Normal file
33
components/Authentication/__test__/FormState.test.tsx
Normal 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()
|
||||||
|
})
|
||||||
|
})
|
10
components/Authentication/__test__/Success.test.tsx
Normal file
10
components/Authentication/__test__/Success.test.tsx
Normal 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()
|
||||||
|
})
|
||||||
|
})
|
49
components/Authentication/index.tsx
Normal file
49
components/Authentication/index.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
22
components/Emoji/Emoji.tsx
Normal file
22
components/Emoji/Emoji.tsx
Normal 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}</>}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
28
components/Emoji/EmojiPicker.tsx
Normal file
28
components/Emoji/EmojiPicker.tsx
Normal 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}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
63
components/Emoji/emojiPlugin.tsx
Normal file
63
components/Emoji/emojiPlugin.tsx
Normal 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
|
||||||
|
}
|
3
components/Emoji/index.ts
Normal file
3
components/Emoji/index.ts
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
export * from './Emoji'
|
||||||
|
export * from './EmojiPicker'
|
||||||
|
export * from './emojiPlugin'
|
62
components/ErrorPage.tsx
Normal file
62
components/ErrorPage.tsx
Normal 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
54
components/Head.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
20
components/Header/Language/Arrow.tsx
Normal file
20
components/Header/Language/Arrow.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
31
components/Header/Language/LanguageFlag.tsx
Normal file
31
components/Header/Language/LanguageFlag.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
105
components/Header/Language/index.tsx
Normal file
105
components/Header/Language/index.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
114
components/Header/SwitchTheme.tsx
Normal file
114
components/Header/SwitchTheme.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
94
components/Header/index.tsx
Normal file
94
components/Header/index.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
114
components/Messages/Message/MessageContent/MessageFile.tsx
Normal file
114
components/Messages/Message/MessageContent/MessageFile.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
62
components/Messages/Message/MessageContent/MessageText.tsx
Normal file
62
components/Messages/Message/MessageContent/MessageText.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
40
components/Messages/Message/MessageContent/index.tsx
Normal file
40
components/Messages/Message/MessageContent/index.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
48
components/Messages/Message/MessageHeader.tsx
Normal file
48
components/Messages/Message/MessageHeader.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
34
components/Messages/Message/UserAvatar.tsx
Normal file
34
components/Messages/Message/UserAvatar.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
40
components/Messages/Message/index.tsx
Normal file
40
components/Messages/Message/index.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
})
|
58
components/Messages/index.tsx
Normal file
58
components/Messages/index.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
187
components/SendMessage/index.tsx
Normal file
187
components/SendMessage/index.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
15
components/__test__/ErrorPage.test.tsx
Normal file
15
components/__test__/ErrorPage.test.tsx
Normal 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()
|
||||||
|
})
|
||||||
|
})
|
17
components/design/Avatar.tsx
Normal file
17
components/design/Avatar.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
40
components/design/Button.tsx
Normal file
40
components/design/Button.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
24
components/design/Container.tsx
Normal file
24
components/design/Container.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
38
components/design/Divider.tsx
Normal file
38
components/design/Divider.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
65
components/design/IconButton.tsx
Normal file
65
components/design/IconButton.tsx
Normal 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
128
components/design/Input.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
})
|
80
components/design/Loader.tsx
Normal file
80
components/design/Loader.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
80
components/design/SocialMediaButton.tsx
Normal file
80
components/design/SocialMediaButton.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
})
|
106
components/design/Tooltip.tsx
Normal file
106
components/design/Tooltip.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
13
components/design/__test__/Avatar.test.tsx
Normal file
13
components/design/__test__/Avatar.test.tsx
Normal 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()
|
||||||
|
})
|
||||||
|
})
|
10
components/design/__test__/Button.test.tsx
Normal file
10
components/design/__test__/Button.test.tsx
Normal 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()
|
||||||
|
})
|
||||||
|
})
|
10
components/design/__test__/Container.test.tsx
Normal file
10
components/design/__test__/Container.test.tsx
Normal 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()
|
||||||
|
})
|
||||||
|
})
|
11
components/design/__test__/Divider.test.tsx
Normal file
11
components/design/__test__/Divider.test.tsx
Normal 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()
|
||||||
|
})
|
||||||
|
})
|
13
components/design/__test__/IconButton.test.tsx
Normal file
13
components/design/__test__/IconButton.test.tsx
Normal 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`)
|
||||||
|
})
|
||||||
|
})
|
26
components/design/__test__/Input.test.tsx
Normal file
26
components/design/__test__/Input.test.tsx
Normal 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')
|
||||||
|
})
|
||||||
|
})
|
20
components/design/__test__/Loader.test.tsx
Normal file
20
components/design/__test__/Loader.test.tsx
Normal 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')
|
||||||
|
})
|
||||||
|
})
|
23
components/design/__test__/SocialMediaButton.test.tsx
Normal file
23
components/design/__test__/SocialMediaButton.test.tsx
Normal 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')
|
||||||
|
})
|
||||||
|
})
|
11
components/design/__test__/Tooltip.test.tsx
Normal file
11
components/design/__test__/Tooltip.test.tsx
Normal 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
58
contexts/Guilds.tsx
Normal 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
72
contexts/Messages.tsx
Normal 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
51
contexts/Theme.tsx
Normal 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)
|
||||||
|
}
|
12
docker-compose.production.yml
Normal file
12
docker-compose.production.yml
Normal 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
13
docker-compose.yml
Normal 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'
|
130
hooks/useFastestValidator.ts
Normal file
130
hooks/useFastestValidator.ts
Normal 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
118
hooks/useForm.ts
Normal 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
15
hooks/useFormState.ts
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
|
||||||
|
export const formState = ['idle', 'loading', 'error', 'success'] as const
|
||||||
|
|
||||||
|
export type FormState = typeof formState[number]
|
||||||
|
|
||||||
|
export const useFormState = (
|
||||||
|
initialFormState: FormState = 'idle'
|
||||||
|
): [
|
||||||
|
formState: FormState,
|
||||||
|
setFormState: React.Dispatch<React.SetStateAction<FormState>>
|
||||||
|
] => {
|
||||||
|
const [formState, setFormState] = useState<FormState>(initialFormState)
|
||||||
|
return [formState, setFormState]
|
||||||
|
}
|
76
hooks/usePagination.ts
Normal file
76
hooks/usePagination.ts
Normal 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
16
i18n.json
Normal 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"]
|
||||||
|
}
|
||||||
|
}
|
4
locales/en/application.json
Normal file
4
locales/en/application.json
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"add-guild": "Add a Guild",
|
||||||
|
"settings": "Settings"
|
||||||
|
}
|
17
locales/en/authentication.json
Normal file
17
locales/en/authentication.json
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"or": "OR",
|
||||||
|
"password": "Password",
|
||||||
|
"name": "Name",
|
||||||
|
"already-have-an-account": "Already have an account?",
|
||||||
|
"dont-have-an-account": "Don't have an account?",
|
||||||
|
"submit": "Submit",
|
||||||
|
"forgot-password": "Forgot your password?",
|
||||||
|
"already-know-password": "Already know your password?",
|
||||||
|
"signup": "Signup",
|
||||||
|
"signin": "Signin",
|
||||||
|
"reset-password": "Reset Password",
|
||||||
|
"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
3
locales/en/common.json
Normal 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
9
locales/en/errors.json
Normal 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
4
locales/en/home.json
Normal 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>."
|
||||||
|
}
|
4
locales/fr/application.json
Normal file
4
locales/fr/application.json
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"add-guild": "Ajouter une Guilde",
|
||||||
|
"settings": "Paramètres"
|
||||||
|
}
|
17
locales/fr/authentication.json
Normal file
17
locales/fr/authentication.json
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"or": "OU",
|
||||||
|
"password": "Mot de passe",
|
||||||
|
"name": "Nom",
|
||||||
|
"already-have-an-account": "Vous avez déjà un compte ?",
|
||||||
|
"dont-have-an-account": "Vous n'avez pas de compte ?",
|
||||||
|
"submit": "Soumettre",
|
||||||
|
"forgot-password": "Mot de passe oublié ?",
|
||||||
|
"already-know-password": "Vous connaissez déjà votre mot de passe ?",
|
||||||
|
"signup": "S'inscrire",
|
||||||
|
"signin": "Se connecter",
|
||||||
|
"reset-password": "Réinitialiser le mot de passe",
|
||||||
|
"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
3
locales/fr/common.json
Normal 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
9
locales/fr/errors.json
Normal 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
4
locales/fr/home.json
Normal 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
4
next-env.d.ts
vendored
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
/* eslint-disable */
|
||||||
|
|
||||||
|
/// <reference types="next" />
|
||||||
|
/// <reference types="next/types/global" />
|
17
next.config.js
Normal file
17
next.config.js
Normal 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
43545
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
161
package.json
Normal file
161
package.json
Normal 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
23
pages/404.tsx
Normal 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
23
pages/500.tsx
Normal 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
Reference in New Issue
Block a user