feat: coming soon
This commit is contained in:
parent
21123c4477
commit
33bd2bb6bf
@ -3,8 +3,10 @@
|
||||
[
|
||||
"next/babel",
|
||||
{
|
||||
"styled-jsx": {
|
||||
"plugins": ["@styled-jsx/plugin-sass"]
|
||||
"preset-env": {
|
||||
"targets": {
|
||||
"browsers": ">1%, not ie 11, not dead"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
|
4
.devcontainer/Dockerfile
Normal file
4
.devcontainer/Dockerfile
Normal file
@ -0,0 +1,4 @@
|
||||
# See here for image contents: https://github.com/microsoft/vscode-dev-containers/tree/v0.163.1/containers/javascript-node/.devcontainer/base.Dockerfile
|
||||
|
||||
ARG VARIANT="16-bullseye"
|
||||
FROM mcr.microsoft.com/vscode/devcontainers/javascript-node:0-${VARIANT}
|
23
.devcontainer/devcontainer.json
Normal file
23
.devcontainer/devcontainer.json
Normal file
@ -0,0 +1,23 @@
|
||||
{
|
||||
"name": "@thream/website",
|
||||
"dockerComposeFile": "./docker-compose.yml",
|
||||
"service": "workspace",
|
||||
"workspaceFolder": "/workspace",
|
||||
"settings": {
|
||||
"remote.autoForwardPorts": false
|
||||
},
|
||||
"extensions": [
|
||||
"editorconfig.editorconfig",
|
||||
"esbenp.prettier-vscode",
|
||||
"dbaeumer.vscode-eslint",
|
||||
"divlo.vscode-styled-jsx-syntax",
|
||||
"divlo.vscode-styled-jsx-languageserver",
|
||||
"bradlc.vscode-tailwindcss",
|
||||
"mikestead.dotenv",
|
||||
"davidanson.vscode-markdownlint",
|
||||
"ms-azuretools.vscode-docker"
|
||||
],
|
||||
"forwardPorts": [3000],
|
||||
"postAttachCommand": ["npm", "install"],
|
||||
"remoteUser": "node"
|
||||
}
|
10
.devcontainer/docker-compose.yml
Normal file
10
.devcontainer/docker-compose.yml
Normal file
@ -0,0 +1,10 @@
|
||||
version: '3.0'
|
||||
|
||||
services:
|
||||
workspace:
|
||||
build:
|
||||
context: './'
|
||||
dockerfile: './Dockerfile'
|
||||
volumes:
|
||||
- '..:/workspace:cached'
|
||||
command: 'sleep infinity'
|
@ -1,11 +1,12 @@
|
||||
.vscode
|
||||
.git
|
||||
.next
|
||||
.env
|
||||
build
|
||||
.next
|
||||
coverage
|
||||
dist
|
||||
node_modules
|
||||
out
|
||||
**/workbox-*.js
|
||||
**/sw.js
|
||||
**/__test__/**
|
||||
tmp
|
||||
temp
|
||||
.DS_Store
|
||||
.lighthouseci
|
||||
storybook-static
|
||||
|
@ -1,3 +1,2 @@
|
||||
COMPOSE_PROJECT_NAME=thream-website
|
||||
PORT=3000
|
||||
NEXT_PUBLIC_API_URL=http://localhost:8080
|
||||
|
8
.eslintignore
Normal file
8
.eslintignore
Normal file
@ -0,0 +1,8 @@
|
||||
.next
|
||||
.lighthouseci
|
||||
storybook-static
|
||||
coverage
|
||||
node_modules
|
||||
next-env.d.ts
|
||||
**/workbox-*.js
|
||||
**/sw.js
|
31
.eslintrc.json
Normal file
31
.eslintrc.json
Normal file
@ -0,0 +1,31 @@
|
||||
{
|
||||
"extends": [
|
||||
"standard-with-typescript",
|
||||
"next",
|
||||
"next/core-web-vitals",
|
||||
"prettier"
|
||||
],
|
||||
"plugins": ["unicorn", "prettier"],
|
||||
"parserOptions": {
|
||||
"project": "./tsconfig.json"
|
||||
},
|
||||
"env": {
|
||||
"node": true,
|
||||
"browser": true,
|
||||
"jest": true
|
||||
},
|
||||
"rules": {
|
||||
"prettier/prettier": "error",
|
||||
"unicorn/prefer-node-protocol": "error",
|
||||
"unicorn/prevent-abbreviations": [
|
||||
"error",
|
||||
{
|
||||
"replacements": {
|
||||
"props": {
|
||||
"properties": false
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
20
.github/ISSUE_TEMPLATE/BUG.md
vendored
Normal file
20
.github/ISSUE_TEMPLATE/BUG.md
vendored
Normal file
@ -0,0 +1,20 @@
|
||||
---
|
||||
name: '🐛 Bug Report'
|
||||
about: 'Report an unexpected problem or unintended behavior.'
|
||||
title: '[Bug]'
|
||||
labels: 'bug'
|
||||
---
|
||||
|
||||
<!--
|
||||
Please provide a clear and concise description of what the bug is. Include
|
||||
screenshots if needed. Please make sure your issue has not already been fixed.
|
||||
-->
|
||||
|
||||
## Steps To Reproduce
|
||||
|
||||
1. Step 1
|
||||
2. Step 2
|
||||
|
||||
## The current behavior
|
||||
|
||||
## The expected behavior
|
18
.github/ISSUE_TEMPLATE/DOCUMENTATION.md
vendored
Normal file
18
.github/ISSUE_TEMPLATE/DOCUMENTATION.md
vendored
Normal file
@ -0,0 +1,18 @@
|
||||
---
|
||||
name: '📜 Documentation'
|
||||
about: 'Correct spelling errors, improvements or additions to documentation files (README, CONTRIBUTING...).'
|
||||
title: '[Documentation]'
|
||||
labels: 'documentation'
|
||||
---
|
||||
|
||||
<!-- Please make sure your issue has not already been fixed. -->
|
||||
|
||||
## Documentation
|
||||
|
||||
<!-- Please uncomment the type of documentation problem this issue address -->
|
||||
|
||||
<!-- Documentation is Missing -->
|
||||
<!-- Documentation is Confusing -->
|
||||
<!-- Documentation has Typo errors -->
|
||||
|
||||
## Proposal
|
20
.github/ISSUE_TEMPLATE/FEATURE_REQUEST.md
vendored
Normal file
20
.github/ISSUE_TEMPLATE/FEATURE_REQUEST.md
vendored
Normal file
@ -0,0 +1,20 @@
|
||||
---
|
||||
name: '✨ Feature Request'
|
||||
about: 'Suggest a new feature idea.'
|
||||
title: '[Feature]'
|
||||
labels: 'feature request'
|
||||
---
|
||||
|
||||
<!-- Please make sure your issue has not already been fixed. -->
|
||||
|
||||
## Description
|
||||
|
||||
<!-- A clear and concise description of the problem or missing capability... -->
|
||||
|
||||
## Describe the solution you'd like
|
||||
|
||||
<!-- If you have a solution in mind, please describe it. -->
|
||||
|
||||
## Describe alternatives you've considered
|
||||
|
||||
<!-- Have you considered any alternative solutions or workarounds? -->
|
20
.github/ISSUE_TEMPLATE/IMPROVEMENT.md
vendored
Normal file
20
.github/ISSUE_TEMPLATE/IMPROVEMENT.md
vendored
Normal file
@ -0,0 +1,20 @@
|
||||
---
|
||||
name: '🔧 Improvement'
|
||||
about: 'Improve structure/format/performance/refactor/tests of the code.'
|
||||
title: '[Improvement]'
|
||||
labels: 'improvement'
|
||||
---
|
||||
|
||||
<!-- Please make sure your issue has not already been fixed. -->
|
||||
|
||||
## Type of Improvement
|
||||
|
||||
<!-- Please uncomment the type of improvements this issue address -->
|
||||
|
||||
<!-- Files and Folders Structure -->
|
||||
<!-- Performance -->
|
||||
<!-- Refactoring code -->
|
||||
<!-- Tests -->
|
||||
<!-- Not Sure? -->
|
||||
|
||||
## Proposal
|
8
.github/ISSUE_TEMPLATE/QUESTION.md
vendored
Normal file
8
.github/ISSUE_TEMPLATE/QUESTION.md
vendored
Normal file
@ -0,0 +1,8 @@
|
||||
---
|
||||
name: '🙋 Question'
|
||||
about: 'Further information is requested.'
|
||||
title: '[Question]'
|
||||
labels: 'question'
|
||||
---
|
||||
|
||||
### Question
|
7
.github/PULL_REQUEST_TEMPLATE.md
vendored
Normal file
7
.github/PULL_REQUEST_TEMPLATE.md
vendored
Normal file
@ -0,0 +1,7 @@
|
||||
<!-- Please first discuss the change you wish to make via issue before making a change. It might avoid a waste of your time. -->
|
||||
|
||||
## What changes this PR introduce?
|
||||
|
||||
## List any relevant issue numbers
|
||||
|
||||
## Is there anything you'd like reviewers to focus on?
|
27
.github/workflows/analyze.yml
vendored
Normal file
27
.github/workflows/analyze.yml
vendored
Normal file
@ -0,0 +1,27 @@
|
||||
name: 'Analyze'
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [master, develop]
|
||||
pull_request:
|
||||
branches: [master, develop]
|
||||
|
||||
jobs:
|
||||
analyze:
|
||||
runs-on: 'ubuntu-latest'
|
||||
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
language: ['javascript']
|
||||
|
||||
steps:
|
||||
- uses: 'actions/checkout@v2.3.4'
|
||||
|
||||
- name: 'Initialize CodeQL'
|
||||
uses: 'github/codeql-action/init@v1'
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
|
||||
- name: 'Perform CodeQL Analysis'
|
||||
uses: 'github/codeql-action/analyze@v1'
|
28
.github/workflows/build.yml
vendored
Normal file
28
.github/workflows/build.yml
vendored
Normal file
@ -0,0 +1,28 @@
|
||||
name: 'Build'
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [master, develop]
|
||||
pull_request:
|
||||
branches: [master, develop]
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: 'ubuntu-latest'
|
||||
steps:
|
||||
- uses: 'actions/checkout@v2'
|
||||
|
||||
- name: 'Use Node.js'
|
||||
uses: 'actions/setup-node@v2.4.1'
|
||||
with:
|
||||
node-version: '16.x'
|
||||
cache: 'npm'
|
||||
|
||||
- name: 'Install'
|
||||
run: 'npm install'
|
||||
|
||||
- name: 'Build'
|
||||
run: 'npm run build'
|
||||
|
||||
- name: 'Build Storybook'
|
||||
run: 'npm run storybook:build'
|
33
.github/workflows/lint.yml
vendored
Normal file
33
.github/workflows/lint.yml
vendored
Normal file
@ -0,0 +1,33 @@
|
||||
name: 'Lint'
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [master, develop]
|
||||
pull_request:
|
||||
branches: [master, develop]
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
runs-on: 'ubuntu-latest'
|
||||
steps:
|
||||
- uses: 'actions/checkout@v2'
|
||||
|
||||
- name: 'Use Node.js'
|
||||
uses: 'actions/setup-node@v2.4.1'
|
||||
with:
|
||||
node-version: '16.x'
|
||||
cache: 'npm'
|
||||
|
||||
- name: 'Install'
|
||||
run: 'npm install'
|
||||
|
||||
- run: 'npm run lint:commit -- --to "${{ github.sha }}"'
|
||||
- run: 'npm run lint:editorconfig'
|
||||
- run: 'npm run lint:markdown'
|
||||
- run: 'npm run lint:docker'
|
||||
- run: 'npm run lint:typescript'
|
||||
|
||||
- name: 'dotenv-linter'
|
||||
uses: 'dotenv-linter/action-dotenv-linter@v2'
|
||||
with:
|
||||
github_token: ${{ secrets.github_token }}
|
27
.github/workflows/release.yml
vendored
Normal file
27
.github/workflows/release.yml
vendored
Normal file
@ -0,0 +1,27 @@
|
||||
name: 'Release'
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [master]
|
||||
|
||||
jobs:
|
||||
release:
|
||||
runs-on: 'ubuntu-latest'
|
||||
steps:
|
||||
- uses: 'actions/checkout@v2.3.5'
|
||||
|
||||
- name: 'Use Node.js'
|
||||
uses: 'actions/setup-node@v2.4.1'
|
||||
with:
|
||||
node-version: '16.x'
|
||||
cache: 'npm'
|
||||
|
||||
- name: 'Install'
|
||||
run: 'npm install'
|
||||
|
||||
- name: 'Deploy to Vercel'
|
||||
run: 'npm run deploy -- --token="${VERCEL_TOKEN}" --prod'
|
||||
env:
|
||||
VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }}
|
||||
VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }}
|
||||
VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }}
|
67
.github/workflows/test.yml
vendored
Normal file
67
.github/workflows/test.yml
vendored
Normal file
@ -0,0 +1,67 @@
|
||||
name: 'Test'
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [master, develop]
|
||||
pull_request:
|
||||
branches: [master, develop]
|
||||
|
||||
jobs:
|
||||
test-unit:
|
||||
runs-on: 'ubuntu-latest'
|
||||
steps:
|
||||
- uses: 'actions/checkout@v2.3.4'
|
||||
|
||||
- name: 'Use Node.js'
|
||||
uses: 'actions/setup-node@v2.4.1'
|
||||
with:
|
||||
node-version: '16.x'
|
||||
cache: 'npm'
|
||||
|
||||
- name: 'Install'
|
||||
run: 'npm install'
|
||||
|
||||
- name: 'Unit Test'
|
||||
run: 'npm run test:unit'
|
||||
|
||||
test-lighthouse:
|
||||
runs-on: 'ubuntu-latest'
|
||||
steps:
|
||||
- uses: 'actions/checkout@v2.3.4'
|
||||
|
||||
- name: 'Use Node.js'
|
||||
uses: 'actions/setup-node@v2.4.0'
|
||||
with:
|
||||
node-version: '16.x'
|
||||
cache: 'npm'
|
||||
|
||||
- name: 'Install'
|
||||
run: 'npm install'
|
||||
|
||||
- name: 'Build'
|
||||
run: 'npm run build'
|
||||
|
||||
- name: 'Lighthouse'
|
||||
run: 'npm run test:lighthouse'
|
||||
env:
|
||||
LHCI_GITHUB_APP_TOKEN: ${{ secrets.LHCI_GITHUB_APP_TOKEN }}
|
||||
|
||||
test-e2e:
|
||||
runs-on: 'ubuntu-latest'
|
||||
steps:
|
||||
- uses: 'actions/checkout@v2.3.4'
|
||||
|
||||
- name: 'Use Node.js'
|
||||
uses: 'actions/setup-node@v2.4.0'
|
||||
with:
|
||||
node-version: '16.x'
|
||||
cache: 'npm'
|
||||
|
||||
- name: 'Install'
|
||||
run: 'npm install'
|
||||
|
||||
- name: 'Build'
|
||||
run: 'npm run build'
|
||||
|
||||
- name: 'End To End (e2e) Test'
|
||||
run: 'npm run test:e2e'
|
26
.gitignore
vendored
26
.gitignore
vendored
@ -1,3 +1,5 @@
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
node_modules
|
||||
.npm
|
||||
@ -12,6 +14,9 @@ dist
|
||||
|
||||
# testing
|
||||
coverage
|
||||
cypress/screenshots
|
||||
cypress/videos
|
||||
cypress/downloads
|
||||
|
||||
# PWA
|
||||
**/workbox-*.js
|
||||
@ -24,11 +29,24 @@ coverage
|
||||
# debug
|
||||
npm-debug.log*
|
||||
|
||||
# editors
|
||||
.vscode
|
||||
.theia
|
||||
.idea
|
||||
# IDEs and editors
|
||||
/.idea
|
||||
.project
|
||||
.classpath
|
||||
.c9/
|
||||
*.launch
|
||||
.settings/
|
||||
*.sublime-workspace
|
||||
|
||||
# IDE - VSCode
|
||||
.vscode/*
|
||||
!.vscode/settings.json
|
||||
!.vscode/tasks.json
|
||||
!.vscode/launch.json
|
||||
!.vscode/extensions.json
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
.lighthouseci
|
||||
storybook-static
|
||||
.vercel
|
||||
|
1
.husky/.gitignore
vendored
1
.husky/.gitignore
vendored
@ -1 +0,0 @@
|
||||
_
|
@ -1,7 +1,4 @@
|
||||
#!/bin/sh
|
||||
. "$(dirname "$0")/_/husky.sh"
|
||||
|
||||
npm run lint:docker
|
||||
npm run lint:editorconfig
|
||||
npm run lint:markdown
|
||||
npm run lint:typescript
|
||||
npm run lint:staged
|
||||
|
@ -4,26 +4,15 @@
|
||||
"startServerCommand": "npm run start",
|
||||
"startServerReadyPattern": "ready on",
|
||||
"startServerReadyTimeout": 20000,
|
||||
"url": ["http://localhost:3000/", "http://localhost:3000/authentication/signup"],
|
||||
"numberOfRuns": 3,
|
||||
"settings": {
|
||||
"chromeFlags": "--no-sandbox"
|
||||
}
|
||||
"url": ["http://localhost:3000/"],
|
||||
"numberOfRuns": 1
|
||||
},
|
||||
"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"
|
||||
"csp-xss": "warning",
|
||||
"non-composited-animations": "warning",
|
||||
"unused-javascript": "warning"
|
||||
}
|
||||
},
|
||||
"upload": {
|
||||
|
11
.lintstagedrc.json
Normal file
11
.lintstagedrc.json
Normal file
@ -0,0 +1,11 @@
|
||||
{
|
||||
"*": ["editorconfig-checker"],
|
||||
"*.{js,ts,jsx,tsx}": [
|
||||
"prettier --write",
|
||||
"eslint --fix",
|
||||
"jest --findRelatedTests"
|
||||
],
|
||||
"*.{css,yml,json}": ["prettier --write"],
|
||||
"*.{md}": ["prettier --write", "markdownlint --dot --fix"],
|
||||
"./Dockerfile": ["dockerfilelint"]
|
||||
}
|
@ -1,7 +1,6 @@
|
||||
{
|
||||
"default": true,
|
||||
"MD013": false,
|
||||
"MD024": false,
|
||||
"MD033": false,
|
||||
"MD041": false
|
||||
}
|
||||
|
8
.prettierignore
Normal file
8
.prettierignore
Normal file
@ -0,0 +1,8 @@
|
||||
.next
|
||||
.lighthouseci
|
||||
storybook-static
|
||||
coverage
|
||||
node_modules
|
||||
next-env.d.ts
|
||||
**/workbox-*.js
|
||||
**/sw.js
|
6
.prettierrc.json
Normal file
6
.prettierrc.json
Normal file
@ -0,0 +1,6 @@
|
||||
{
|
||||
"singleQuote": true,
|
||||
"jsxSingleQuote": true,
|
||||
"semi": false,
|
||||
"trailingComma": "none"
|
||||
}
|
27
.storybook/main.js
Normal file
27
.storybook/main.js
Normal file
@ -0,0 +1,27 @@
|
||||
const path = require('path')
|
||||
|
||||
module.exports = {
|
||||
stories: ['../components/**/*.stories.@(ts|tsx|js|jsx)'],
|
||||
addons: [
|
||||
'@storybook/addon-links',
|
||||
'@storybook/addon-essentials',
|
||||
'@storybook/addon-postcss',
|
||||
'storybook-tailwind-dark-mode'
|
||||
],
|
||||
webpackFinal: async (config) => {
|
||||
config.module.rules.push({
|
||||
test: /\,css&/,
|
||||
use: [
|
||||
{
|
||||
loader: 'postcss-loader',
|
||||
options: {
|
||||
ident: 'postcss',
|
||||
plugins: [require('tailwindcss'), require('autoprefixer')]
|
||||
}
|
||||
}
|
||||
],
|
||||
include: path.resolve(__dirname, '../')
|
||||
})
|
||||
return config
|
||||
}
|
||||
}
|
34
.storybook/preview.js
Normal file
34
.storybook/preview.js
Normal file
@ -0,0 +1,34 @@
|
||||
import { addDecorator } from '@storybook/react'
|
||||
import I18nProvider from 'next-translate/I18nProvider'
|
||||
|
||||
import i18n from '../i18n.json'
|
||||
import common from '../locales/en/common.json'
|
||||
|
||||
import '../styles/global.css'
|
||||
|
||||
import '@fontsource/montserrat/400.css'
|
||||
import '@fontsource/montserrat/500.css'
|
||||
import '@fontsource/montserrat/600.css'
|
||||
import '@fontsource/montserrat/700.css'
|
||||
|
||||
import '@fontsource/roboto/400.css'
|
||||
import '@fontsource/roboto/700.css'
|
||||
|
||||
addDecorator((story) => (
|
||||
<I18nProvider
|
||||
lang='en'
|
||||
namespaces={{
|
||||
common
|
||||
}}
|
||||
config={i18n}
|
||||
>
|
||||
<div id='preview-storybook'>{story()}</div>
|
||||
</I18nProvider>
|
||||
))
|
||||
|
||||
import * as nextImage from 'next/image'
|
||||
|
||||
Object.defineProperty(nextImage, 'default', {
|
||||
configurable: true,
|
||||
value: (props) => <img {...props} />
|
||||
})
|
13
.vscode/extensions.json
vendored
Normal file
13
.vscode/extensions.json
vendored
Normal file
@ -0,0 +1,13 @@
|
||||
{
|
||||
"recommendations": [
|
||||
"editorconfig.editorconfig",
|
||||
"esbenp.prettier-vscode",
|
||||
"dbaeumer.vscode-eslint",
|
||||
"divlo.vscode-styled-jsx-syntax",
|
||||
"divlo.vscode-styled-jsx-languageserver",
|
||||
"bradlc.vscode-tailwindcss",
|
||||
"mikestead.dotenv",
|
||||
"davidanson.vscode-markdownlint",
|
||||
"ms-azuretools.vscode-docker"
|
||||
]
|
||||
}
|
49
.vscode/settings.json
vendored
Normal file
49
.vscode/settings.json
vendored
Normal file
@ -0,0 +1,49 @@
|
||||
{
|
||||
"typescript.tsdk": "node_modules/typescript/lib",
|
||||
"prettier.configPath": ".prettierrc.json",
|
||||
"editor.bracketPairColorization.enabled": true,
|
||||
"editor.formatOnSave": true,
|
||||
"editor.codeActionsOnSave": {
|
||||
"source.fixAll": true
|
||||
},
|
||||
"[css]": {
|
||||
"editor.autoClosingBrackets": "always",
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||
},
|
||||
"[sass]": {
|
||||
"editor.autoClosingBrackets": "always",
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||
},
|
||||
"[scss]": {
|
||||
"editor.autoClosingBrackets": "always",
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||
},
|
||||
"[markdown]": {
|
||||
"editor.autoClosingBrackets": "always",
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||
},
|
||||
"[json]": {
|
||||
"editor.autoClosingBrackets": "always",
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||
},
|
||||
"[jsonc]": {
|
||||
"editor.autoClosingBrackets": "always",
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||
},
|
||||
"[typescript]": {
|
||||
"editor.autoClosingBrackets": "always",
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||
},
|
||||
"[typescriptreact]": {
|
||||
"editor.autoClosingBrackets": "always",
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||
},
|
||||
"[javascript]": {
|
||||
"editor.autoClosingBrackets": "always",
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||
},
|
||||
"[javascriptreact]": {
|
||||
"editor.autoClosingBrackets": "always",
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||
}
|
||||
}
|
@ -14,14 +14,14 @@ All work on **Thream/website** happens directly on [GitHub](https://github.com/T
|
||||
|
||||
- Reporting a bug.
|
||||
- Suggest a new feature idea.
|
||||
- Correct spelling errors, improvements or additions to documentation files (README, CONTRIBUTING...).
|
||||
- Correct spelling errors, improvements or additions to documentation files.
|
||||
- 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.
|
||||
- **Please first discuss** the change you wish to make via issues.
|
||||
|
||||
- Ensure your code respect [Typescript Standard Style](https://www.npmjs.com/package/ts-standard).
|
||||
- Ensure your code respect `eslint` and `prettier`.
|
||||
|
||||
- Make sure your **code passes the tests**.
|
||||
|
||||
@ -29,7 +29,9 @@ 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.
|
||||
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
|
||||
|
||||
@ -52,3 +54,10 @@ Types define which kind of changes you made to the project.
|
||||
### Scopes
|
||||
|
||||
Scopes define what part of the code changed.
|
||||
|
||||
### Examples
|
||||
|
||||
```sh
|
||||
git commit -m "feat(components): add Button"
|
||||
git commit -m "docs(readme): update installation process"
|
||||
```
|
||||
|
29
Dockerfile
29
Dockerfile
@ -1,10 +1,23 @@
|
||||
FROM node:14.16.1
|
||||
RUN npm install --global npm@7
|
||||
|
||||
WORKDIR /website
|
||||
|
||||
FROM node:16.11.0 AS dependencies
|
||||
WORKDIR /usr/src/app
|
||||
COPY ./package*.json ./
|
||||
RUN npm install
|
||||
COPY ./ ./
|
||||
RUN npm clean-install
|
||||
|
||||
CMD ["npm", "run", "dev", "--", "--port", "${PORT}"]
|
||||
FROM node:16.11.0 AS builder
|
||||
WORKDIR /usr/src/app
|
||||
COPY ./ ./
|
||||
COPY --from=dependencies /usr/src/app/node_modules ./node_modules
|
||||
RUN npm run build
|
||||
|
||||
FROM node:16.11.0 AS runner
|
||||
WORKDIR /usr/src/app
|
||||
ENV NODE_ENV=production
|
||||
COPY --from=builder /usr/src/app/next.config.js ./next.config.js
|
||||
COPY --from=builder /usr/src/app/public ./public
|
||||
COPY --from=builder /usr/src/app/.next ./.next
|
||||
COPY --from=builder /usr/src/app/i18n.json ./i18n.json
|
||||
COPY --from=builder /usr/src/app/locales ./locales
|
||||
COPY --from=builder /usr/src/app/pages ./pages
|
||||
COPY --from=builder /usr/src/app/node_modules ./node_modules
|
||||
RUN npx next telemetry disable
|
||||
CMD ["node_modules/.bin/next", "start", "--port", "${PORT}"]
|
||||
|
@ -1,31 +0,0 @@
|
||||
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
README.md
21
README.md
@ -1,19 +1,22 @@
|
||||
<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="./CONTRIBUTING.md"><img src="https://img.shields.io/badge/PRs-welcome-brightgreen.svg?style=flat" /></a>
|
||||
<a href="./LICENSE"><img src="https://img.shields.io/badge/licence-MIT-blue.svg" alt="Licence MIT"/></a>
|
||||
<a href="./CODE_OF_CONDUCT.md"><img src="https://img.shields.io/badge/Contributor%20Covenant-v2.0%20adopted-ff69b4.svg" alt="Contributor Covenant" /></a>
|
||||
<br />
|
||||
<a href="https://github.com/Thream/website/actions/workflows/analyze.yml"><img src="https://github.com/Thream/website/actions/workflows/analyze.yml/badge.svg?branch=develop" /></a>
|
||||
<a href="https://github.com/Thream/website/actions/workflows/build.yml"><img src="https://github.com/Thream/website/actions/workflows/build.yml/badge.svg?branch=develop" /></a>
|
||||
<a href="https://github.com/Thream/website/actions/workflows/lint.yml"><img src="https://github.com/Thream/website/actions/workflows/lint.yml/badge.svg?branch=develop" /></a>
|
||||
<a href="https://github.com/Thream/website/actions/workflows/test.yml"><img src="https://github.com/Thream/website/actions/workflows/test.yml/badge.svg?branch=develop" /></a>
|
||||
<br />
|
||||
<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>
|
||||
<a href="https://dependabot.com/"><img src="https://badgen.net/github/dependabot/Thream/website?icon=dependabot" alt="Dependabot badge" /></a>
|
||||
</p>
|
||||
|
||||
## 📜 About
|
||||
|
||||
Thream's website to stay close with your friends and communities. It relies on [Thream/api](https://github.com/Thream/api/).
|
||||
Thream's website to stay close with your friends and communities.
|
||||
|
||||
This project was bootstrapped with [create-fullstack-app](https://github.com/Divlo/create-fullstack-app).
|
||||
|
||||
@ -21,8 +24,8 @@ This project was bootstrapped with [create-fullstack-app](https://github.com/Div
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- [Node.js](https://nodejs.org/) >= 16
|
||||
- [npm](https://www.npmjs.com/) >= 7
|
||||
- [Node.js](https://nodejs.org/) >= 14.0.0
|
||||
- [npm](https://www.npmjs.com/) >= 7.0.0
|
||||
|
||||
### Installation
|
||||
|
||||
|
@ -1,22 +0,0 @@
|
||||
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>
|
||||
</>
|
||||
)
|
||||
}
|
@ -1,19 +0,0 @@
|
||||
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>
|
||||
</>
|
||||
)
|
||||
})
|
@ -1,28 +0,0 @@
|
||||
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>
|
||||
</>
|
||||
)
|
||||
}
|
@ -1,10 +0,0 @@
|
||||
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()
|
||||
})
|
||||
})
|
@ -1,10 +0,0 @@
|
||||
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()
|
||||
})
|
||||
})
|
@ -1,98 +0,0 @@
|
||||
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>
|
||||
</>
|
||||
)
|
||||
}
|
@ -1,10 +0,0 @@
|
||||
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()
|
||||
})
|
||||
})
|
@ -1,32 +0,0 @@
|
||||
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>
|
||||
)
|
||||
}
|
@ -1,99 +0,0 @@
|
||||
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} />
|
||||
</>
|
||||
)
|
||||
}
|
@ -1,53 +0,0 @@
|
||||
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>
|
||||
</>
|
||||
)
|
||||
}
|
@ -1,109 +0,0 @@
|
||||
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>
|
||||
</>
|
||||
)
|
||||
}
|
@ -1,76 +0,0 @@
|
||||
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>
|
||||
</>
|
||||
)
|
||||
}
|
@ -1,104 +0,0 @@
|
||||
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>
|
||||
</>
|
||||
)
|
||||
}
|
@ -1,14 +0,0 @@
|
||||
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>
|
||||
)
|
||||
}
|
@ -1,16 +0,0 @@
|
||||
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()
|
||||
})
|
||||
})
|
@ -1,33 +0,0 @@
|
||||
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()
|
||||
})
|
||||
})
|
@ -1,10 +0,0 @@
|
||||
import { render } from '@testing-library/react'
|
||||
|
||||
import { Success } from '../Success'
|
||||
|
||||
describe('<Success />', () => {
|
||||
it('should render', async () => {
|
||||
const { getByTestId } = render(<Success />)
|
||||
expect(getByTestId('success')).toBeInTheDocument()
|
||||
})
|
||||
})
|
@ -1,49 +0,0 @@
|
||||
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>
|
||||
</>
|
||||
)
|
||||
}
|
@ -1,22 +0,0 @@
|
||||
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}</>}
|
||||
/>
|
||||
)
|
||||
}
|
@ -1,28 +0,0 @@
|
||||
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}
|
||||
/>
|
||||
)
|
||||
}
|
@ -1,63 +0,0 @@
|
||||
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
|
||||
}
|
@ -1,3 +0,0 @@
|
||||
export * from './Emoji'
|
||||
export * from './EmojiPicker'
|
||||
export * from './emojiPlugin'
|
@ -1,62 +0,0 @@
|
||||
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>
|
||||
</>
|
||||
)
|
||||
}
|
15
components/ErrorPage/ErrorPage.stories.tsx
Normal file
15
components/ErrorPage/ErrorPage.stories.tsx
Normal file
@ -0,0 +1,15 @@
|
||||
import { Meta, Story } from '@storybook/react'
|
||||
|
||||
import { ErrorPage as Component, ErrorPageProps } from './ErrorPage'
|
||||
|
||||
const Stories: Meta = {
|
||||
title: 'ErrorPage',
|
||||
component: Component
|
||||
}
|
||||
|
||||
export default Stories
|
||||
|
||||
export const ErrorPage: Story<ErrorPageProps> = (arguments_) => {
|
||||
return <Component {...arguments_} />
|
||||
}
|
||||
ErrorPage.args = { message: 'message content', statusCode: 404 }
|
@ -3,13 +3,13 @@ import { render } from '@testing-library/react'
|
||||
import { ErrorPage } from '../ErrorPage'
|
||||
|
||||
describe('<ErrorPage />', () => {
|
||||
it('should render with message and statusCode', async () => {
|
||||
const message = 'Error'
|
||||
it('should render the message and statusCode', async () => {
|
||||
const messageContent = 'message content'
|
||||
const statusCode = 404
|
||||
const { getByText } = render(
|
||||
<ErrorPage statusCode={statusCode} message={message} />
|
||||
<ErrorPage statusCode={statusCode} message={messageContent} />
|
||||
)
|
||||
expect(getByText(message)).toBeInTheDocument()
|
||||
expect(getByText(messageContent)).toBeInTheDocument()
|
||||
expect(getByText(statusCode)).toBeInTheDocument()
|
||||
})
|
||||
})
|
53
components/ErrorPage/ErrorPage.tsx
Normal file
53
components/ErrorPage/ErrorPage.tsx
Normal file
@ -0,0 +1,53 @@
|
||||
import useTranslation from 'next-translate/useTranslation'
|
||||
import Link from 'next/link'
|
||||
|
||||
export interface ErrorPageProps {
|
||||
statusCode: number
|
||||
message: string
|
||||
}
|
||||
|
||||
export const ErrorPage: React.FC<ErrorPageProps> = (props) => {
|
||||
const { message, statusCode } = props
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<>
|
||||
<h1 className='my-6 font-semibold text-4xl'>
|
||||
{t('errors:error')}{' '}
|
||||
<span
|
||||
className='text-green-800 dark:text-green-400'
|
||||
data-cy='status-code'
|
||||
>
|
||||
{statusCode}
|
||||
</span>
|
||||
</h1>
|
||||
<p className='text-center text-lg'>
|
||||
{message}{' '}
|
||||
<Link href='/'>
|
||||
<a className='text-green-800 dark:text-green-400 hover:underline'>
|
||||
{t('errors:return-to-home-page')}
|
||||
</a>
|
||||
</Link>
|
||||
</p>
|
||||
|
||||
<style jsx global>
|
||||
{`
|
||||
main {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-width: 100vw;
|
||||
flex: 1;
|
||||
}
|
||||
#__next {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding-top: 0;
|
||||
height: 100vh;
|
||||
}
|
||||
`}
|
||||
</style>
|
||||
</>
|
||||
)
|
||||
}
|
1
components/ErrorPage/index.ts
Normal file
1
components/ErrorPage/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from './ErrorPage'
|
19
components/Footer/Footer.tsx
Normal file
19
components/Footer/Footer.tsx
Normal file
@ -0,0 +1,19 @@
|
||||
import Link from 'next/link'
|
||||
import useTranslation from 'next-translate/useTranslation'
|
||||
|
||||
export const Footer: React.FC = () => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<footer className='bg-white flex flex-col items-center justify-center py-6 text-lg border-t-2 border-gray-600 dark:border-gray-400 dark:bg-black'>
|
||||
<p>
|
||||
<Link href='/'>
|
||||
<a className='hover:underline text-green-800 dark:text-green-400'>
|
||||
Thream
|
||||
</a>
|
||||
</Link>{' '}
|
||||
| {t('common:all-rights-reserved')}
|
||||
</p>
|
||||
</footer>
|
||||
)
|
||||
}
|
1
components/Footer/index.ts
Normal file
1
components/Footer/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from './Footer'
|
@ -1,4 +1,4 @@
|
||||
import HeadTag from 'next/head'
|
||||
import NextHead from 'next/head'
|
||||
import useTranslation from 'next-translate/useTranslation'
|
||||
|
||||
interface HeadProps {
|
||||
@ -19,14 +19,14 @@ export const Head: React.FC<HeadProps> = (props) => {
|
||||
} = props
|
||||
|
||||
return (
|
||||
<HeadTag>
|
||||
<NextHead>
|
||||
<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='viewport' content='width=device-width, initial-scale=1.0' />
|
||||
<meta name='description' content={description} />
|
||||
<meta name='Language' content='en' />
|
||||
<meta name='Language' content='fr, en' />
|
||||
<meta name='theme-color' content='#27B05E' />
|
||||
|
||||
{/* Open Graph Metadata */}
|
||||
@ -35,7 +35,7 @@ export const Head: React.FC<HeadProps> = (props) => {
|
||||
<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:locale' content='fr_FR, en_US' />
|
||||
<meta property='og:site_name' content={title} />
|
||||
|
||||
{/* Twitter card Metadata */}
|
||||
@ -49,6 +49,6 @@ export const Head: React.FC<HeadProps> = (props) => {
|
||||
<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>
|
||||
</NextHead>
|
||||
)
|
||||
}
|
||||
|
12
components/Header/Header.stories.tsx
Normal file
12
components/Header/Header.stories.tsx
Normal file
@ -0,0 +1,12 @@
|
||||
import { Meta, Story } from '@storybook/react'
|
||||
|
||||
import { Header as Component } from './'
|
||||
|
||||
const Stories: Meta = {
|
||||
title: 'Header',
|
||||
component: Component
|
||||
}
|
||||
|
||||
export default Stories
|
||||
|
||||
export const Header: Story = (arguments_) => <Component {...arguments_} />
|
10
components/Header/Header.test.tsx
Normal file
10
components/Header/Header.test.tsx
Normal file
@ -0,0 +1,10 @@
|
||||
import { render } from '@testing-library/react'
|
||||
|
||||
import { Header } from './'
|
||||
|
||||
describe('<Header />', () => {
|
||||
it('should render', async () => {
|
||||
const { getByText } = render(<Header />)
|
||||
expect(getByText('Thream')).toBeInTheDocument()
|
||||
})
|
||||
})
|
33
components/Header/Header.tsx
Normal file
33
components/Header/Header.tsx
Normal file
@ -0,0 +1,33 @@
|
||||
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='bg-white flex justify-center sticky top-0 z-50 w-full px-6 py-2 border-b-2 border-gray-600 dark:border-gray-400 dark:bg-black'>
|
||||
<div className='container flex justify-between'>
|
||||
<Link href='/'>
|
||||
<a>
|
||||
<div className='flex items-center justify-center'>
|
||||
<Image
|
||||
width={60}
|
||||
height={60}
|
||||
src='/images/icons/Thream.png'
|
||||
alt='Thream'
|
||||
/>
|
||||
<span className='ml-1 font-medium font-headline hidden xs:block text-green-800 dark:text-green-400'>
|
||||
Thream
|
||||
</span>
|
||||
</div>
|
||||
</a>
|
||||
</Link>
|
||||
<div className='flex justify-between'>
|
||||
<Language />
|
||||
<SwitchTheme />
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
)
|
||||
}
|
@ -1,8 +1,4 @@
|
||||
import { useTheme } from 'contexts/Theme'
|
||||
|
||||
export const Arrow: React.FC = () => {
|
||||
const { theme } = useTheme()
|
||||
|
||||
return (
|
||||
<svg
|
||||
width='12'
|
||||
@ -12,8 +8,8 @@ export const Arrow: React.FC = () => {
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
>
|
||||
<path
|
||||
className='fill-current text-black dark:text-white'
|
||||
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>
|
||||
)
|
||||
|
@ -1,9 +1,7 @@
|
||||
import Image from 'next/image'
|
||||
|
||||
import { Language } from 'utils/authentication'
|
||||
|
||||
export interface LanguageFlagProps {
|
||||
language: Language
|
||||
language: string
|
||||
}
|
||||
|
||||
export const LanguageFlag: React.FC<LanguageFlagProps> = (props) => {
|
||||
@ -17,15 +15,9 @@ export const LanguageFlag: React.FC<LanguageFlagProps> = (props) => {
|
||||
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>
|
||||
<p data-cy='language-flag-text' className='mx-2 text-base'>
|
||||
{language.toUpperCase()}
|
||||
</p>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
@ -1,15 +1,20 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import useTranslation from 'next-translate/useTranslation'
|
||||
import setLanguage from 'next-translate/setLanguage'
|
||||
import classNames from 'classnames'
|
||||
|
||||
import { Arrow } from './Arrow'
|
||||
import { languages, Language as LanguageType } from 'utils/authentication'
|
||||
import { LanguageFlag } from './LanguageFlag'
|
||||
import i18n from '../../../i18n.json'
|
||||
|
||||
export const Language: React.FC = () => {
|
||||
const { lang: currentLanguage } = useTranslation()
|
||||
const [hiddenMenu, setHiddenMenu] = useState(true)
|
||||
|
||||
const handleHiddenMenu = useCallback(() => {
|
||||
setHiddenMenu(!hiddenMenu)
|
||||
}, [hiddenMenu])
|
||||
|
||||
useEffect(() => {
|
||||
if (!hiddenMenu) {
|
||||
window.document.addEventListener('click', handleHiddenMenu)
|
||||
@ -20,86 +25,46 @@ export const Language: React.FC = () => {
|
||||
return () => {
|
||||
window.document.removeEventListener('click', handleHiddenMenu)
|
||||
}
|
||||
}, [hiddenMenu])
|
||||
}, [hiddenMenu, handleHiddenMenu])
|
||||
|
||||
const handleLanguage = async (language: LanguageType): Promise<void> => {
|
||||
const handleLanguage = async (language: string): 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 className='flex flex-col justify-center items-center cursor-pointer'>
|
||||
<div
|
||||
data-cy='language-click'
|
||||
className='flex items-center mr-5'
|
||||
onClick={handleHiddenMenu}
|
||||
>
|
||||
<LanguageFlag language={currentLanguage} />
|
||||
<Arrow />
|
||||
</div>
|
||||
|
||||
<style jsx>
|
||||
{`
|
||||
.language-menu {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
<ul
|
||||
data-cy='languages-list'
|
||||
className={classNames(
|
||||
'flex flex-col justify-center items-center absolute p-0 top-14 z-10 w-24 mt-3 mr-4 rounded-lg list-none shadow-light dark:shadow-dark bg-white dark:bg-black',
|
||||
{ hidden: hiddenMenu }
|
||||
)}
|
||||
>
|
||||
{i18n.locales.map((language, index) => {
|
||||
if (language === currentLanguage) {
|
||||
return null
|
||||
}
|
||||
.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>
|
||||
</>
|
||||
return (
|
||||
<li
|
||||
key={index}
|
||||
className='flex items-center justify-center w-full h-12 hover:bg-[#4f545c] hover:bg-opacity-20 pl-2'
|
||||
onClick={async () => await handleLanguage(language)}
|
||||
>
|
||||
<LanguageFlag language={language} />
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
@ -1,25 +1,54 @@
|
||||
import { useTheme } from 'contexts/Theme'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useTheme } from 'next-themes'
|
||||
|
||||
export const SwitchTheme: React.FC = () => {
|
||||
const { handleToggleTheme, theme } = useTheme()
|
||||
const [mounted, setMounted] = useState(false)
|
||||
const { theme, setTheme } = useTheme()
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true)
|
||||
}, [])
|
||||
|
||||
if (!mounted) {
|
||||
return null
|
||||
}
|
||||
|
||||
const handleClick = (): void => {
|
||||
setTheme(theme === 'dark' ? 'light' : 'dark')
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className='toggle-button' onClick={handleToggleTheme}>
|
||||
<div className='toggle-theme-button'>
|
||||
<div
|
||||
className='flex items-center'
|
||||
data-cy='switch-theme-click'
|
||||
onClick={handleClick}
|
||||
>
|
||||
<div className='toggle-theme-button relative cursor-pointer bg-transparent inline-block'>
|
||||
<div className='toggle-track'>
|
||||
<div className='toggle-track-check'>
|
||||
<span className='toggle_Dark'>🌜</span>
|
||||
<div
|
||||
data-cy='switch-theme-dark'
|
||||
className='toggle-track-check absolute'
|
||||
>
|
||||
<span className='toggle_Dark flex justify-center items-center relative'>
|
||||
🌜
|
||||
</span>
|
||||
</div>
|
||||
<div className='toggle-track-x'>
|
||||
<span className='toggle_Light'>🌞</span>
|
||||
<div
|
||||
data-cy='switch-theme-light'
|
||||
className='toggle-track-x absolute'
|
||||
>
|
||||
<span className='toggle_Light flex justify-center items-center relative'>
|
||||
🌞
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className='toggle-thumb' />
|
||||
<div className='toggle-thumb absolute' />
|
||||
<input
|
||||
data-cy='switch-theme-input'
|
||||
type='checkbox'
|
||||
aria-label='Dark mode toggle'
|
||||
className='toggle-screenreader-only'
|
||||
className='toggle-screenreader-only absolute overflow-hidden'
|
||||
defaultChecked
|
||||
/>
|
||||
</div>
|
||||
@ -27,16 +56,8 @@ export const SwitchTheme: React.FC = () => {
|
||||
|
||||
<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;
|
||||
@ -51,7 +72,6 @@ export const SwitchTheme: React.FC = () => {
|
||||
color: #fff;
|
||||
}
|
||||
.toggle-track-check {
|
||||
position: absolute;
|
||||
width: 14px;
|
||||
height: 10px;
|
||||
top: 0;
|
||||
@ -64,7 +84,6 @@ export const SwitchTheme: React.FC = () => {
|
||||
transition: opacity 0.25s ease;
|
||||
}
|
||||
.toggle-track-x {
|
||||
position: absolute;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
top: 0;
|
||||
@ -77,15 +96,10 @@ export const SwitchTheme: React.FC = () => {
|
||||
}
|
||||
.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;
|
||||
@ -102,9 +116,7 @@ export const SwitchTheme: React.FC = () => {
|
||||
clip: rect(0 0 0 0);
|
||||
height: 1px;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
padding: 0;
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
}
|
||||
`}
|
1
components/Header/SwitchTheme/index.ts
Normal file
1
components/Header/SwitchTheme/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from './SwitchTheme'
|
1
components/Header/index.ts
Normal file
1
components/Header/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from './Header'
|
@ -1,94 +0,0 @@
|
||||
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>
|
||||
</>
|
||||
)
|
||||
}
|
@ -1,114 +0,0 @@
|
||||
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>
|
||||
</>
|
||||
)
|
||||
}
|
@ -1,62 +0,0 @@
|
||||
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>
|
||||
</>
|
||||
)
|
||||
}
|
@ -1,40 +0,0 @@
|
||||
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>
|
||||
</>
|
||||
)
|
||||
}
|
@ -1,48 +0,0 @@
|
||||
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>
|
||||
</>
|
||||
)
|
||||
}
|
@ -1,34 +0,0 @@
|
||||
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>
|
||||
</>
|
||||
)
|
||||
}
|
@ -1,40 +0,0 @@
|
||||
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>
|
||||
</>
|
||||
)
|
||||
})
|
@ -1,58 +0,0 @@
|
||||
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>
|
||||
</>
|
||||
)
|
||||
}
|
10
components/ScrollableBody/ScrollableBody.test.tsx
Normal file
10
components/ScrollableBody/ScrollableBody.test.tsx
Normal file
@ -0,0 +1,10 @@
|
||||
import { render } from '@testing-library/react'
|
||||
|
||||
import { ScrollableBody } from './ScrollableBody'
|
||||
|
||||
describe('<ScrollableBody />', () => {
|
||||
it('should render successfully', () => {
|
||||
const { baseElement } = render(<ScrollableBody />)
|
||||
expect(baseElement).toBeTruthy()
|
||||
})
|
||||
})
|
18
components/ScrollableBody/ScrollableBody.tsx
Normal file
18
components/ScrollableBody/ScrollableBody.tsx
Normal file
@ -0,0 +1,18 @@
|
||||
export const ScrollableBody: React.FC = (props) => {
|
||||
const { children } = props
|
||||
|
||||
return (
|
||||
<>
|
||||
{children}
|
||||
<style jsx global>{`
|
||||
body {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--scroll-bar-color) var(--scroll-bar-bg-color);
|
||||
z-index: 1000;
|
||||
height: calc(100vh - 64px);
|
||||
overflow-y: auto;
|
||||
}
|
||||
`}</style>
|
||||
</>
|
||||
)
|
||||
}
|
1
components/ScrollableBody/index.ts
Normal file
1
components/ScrollableBody/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from './ScrollableBody'
|
@ -1,187 +0,0 @@
|
||||
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>
|
||||
</>
|
||||
)
|
||||
}
|
@ -1,17 +0,0 @@
|
||||
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>
|
||||
</>
|
||||
)
|
||||
}
|
@ -1,40 +0,0 @@
|
||||
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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
)
|
15
components/design/Button/Button.stories.tsx
Normal file
15
components/design/Button/Button.stories.tsx
Normal file
@ -0,0 +1,15 @@
|
||||
import { Meta, Story } from '@storybook/react'
|
||||
|
||||
import { Button as Component, ButtonProps } from './Button'
|
||||
|
||||
const Stories: Meta = {
|
||||
title: 'Button',
|
||||
component: Component
|
||||
}
|
||||
|
||||
export default Stories
|
||||
|
||||
export const Button: Story<ButtonProps> = (arguments_) => (
|
||||
<Component {...arguments_} />
|
||||
)
|
||||
Button.args = { children: 'Get started' }
|
@ -1,6 +1,6 @@
|
||||
import { render } from '@testing-library/react'
|
||||
|
||||
import { Button } from '../Button'
|
||||
import { Button } from './'
|
||||
|
||||
describe('<Button />', () => {
|
||||
it('should render', async () => {
|
19
components/design/Button/Button.tsx
Normal file
19
components/design/Button/Button.tsx
Normal file
@ -0,0 +1,19 @@
|
||||
import classNames from 'classnames'
|
||||
|
||||
export interface ButtonProps extends React.ComponentPropsWithoutRef<'button'> {}
|
||||
|
||||
export const Button: React.FC<ButtonProps> = (props) => {
|
||||
const { children, className, ...rest } = props
|
||||
|
||||
return (
|
||||
<button
|
||||
className={classNames(
|
||||
'py-2 px-6 font-paragraph rounded-lg bg-transparent border border-green-800 dark:border-green-400 text-green-800 dark:text-green-400 hover:bg-green-800 hover:text-white dark:hover:bg-green-400 dark:hover:text-black fill-current stroke-current transform transition-colors duration-300 ease-in-out focus:outline-none focus:bg-green-800 focus:text-white dark:focus:bg-green-400 dark:focus:text-black',
|
||||
className
|
||||
)}
|
||||
{...rest}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
}
|
1
components/design/Button/index.ts
Normal file
1
components/design/Button/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from './Button'
|
@ -1,24 +0,0 @@
|
||||
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>
|
||||
</>
|
||||
)
|
||||
}
|
@ -1,38 +0,0 @@
|
||||
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>
|
||||
</>
|
||||
)
|
||||
}
|
@ -1,65 +0,0 @@
|
||||
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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
)
|
@ -1,128 +0,0 @@
|
||||
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>
|
||||
</>
|
||||
)
|
||||
})
|
@ -1,80 +0,0 @@
|
||||
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>
|
||||
</>
|
||||
)
|
||||
}
|
10
components/design/Main/Main.test.tsx
Normal file
10
components/design/Main/Main.test.tsx
Normal file
@ -0,0 +1,10 @@
|
||||
import { render } from '@testing-library/react'
|
||||
|
||||
import { Main } from './'
|
||||
|
||||
describe('<Main />', () => {
|
||||
it('should render successfully', () => {
|
||||
const { baseElement } = render(<Main />)
|
||||
expect(baseElement).toBeTruthy()
|
||||
})
|
||||
})
|
20
components/design/Main/Main.tsx
Normal file
20
components/design/Main/Main.tsx
Normal file
@ -0,0 +1,20 @@
|
||||
import classNames from 'classnames'
|
||||
|
||||
export interface MainProps {
|
||||
className?: string
|
||||
}
|
||||
|
||||
export const Main: React.FC<MainProps> = (props) => {
|
||||
const { children, className } = props
|
||||
|
||||
return (
|
||||
<main
|
||||
className={classNames(
|
||||
'flex flex-1 flex-col justify-center items-center py-8',
|
||||
className
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</main>
|
||||
)
|
||||
}
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user