Compare commits

..

No commits in common. "develop" and "v1.0.1" have entirely different histories.

330 changed files with 37688 additions and 13846 deletions

View File

@ -1 +1,2 @@
FROM mcr.microsoft.com/vscode/devcontainers/javascript-node:20 ARG VARIANT="16"
FROM mcr.microsoft.com/vscode/devcontainers/javascript-node:0-${VARIANT}

View File

@ -1,9 +0,0 @@
services:
workspace:
build:
context: "./"
dockerfile: "./Dockerfile"
volumes:
- "..:/workspace:cached"
command: "sleep infinity"
network_mode: "host"

View File

@ -1,24 +1,23 @@
{ {
"name": "@thream/website", "name": "@thream/website",
"dockerComposeFile": "./compose.yaml", "dockerComposeFile": "./docker-compose.yml",
"service": "workspace", "service": "workspace",
"workspaceFolder": "/workspace", "workspaceFolder": "/workspace",
"customizations": {
"vscode": {
"settings": { "settings": {
"remote.autoForwardPorts": false, "remote.autoForwardPorts": false
"remote.localPortHost": "allInterfaces"
}, },
"extensions": [ "extensions": [
"editorconfig.editorconfig", "editorconfig.editorconfig",
"esbenp.prettier-vscode", "esbenp.prettier-vscode",
"dbaeumer.vscode-eslint", "dbaeumer.vscode-eslint",
"divlo.vscode-styled-jsx-syntax",
"divlo.vscode-styled-jsx-languageserver",
"bradlc.vscode-tailwindcss", "bradlc.vscode-tailwindcss",
"mikestead.dotenv", "mikestead.dotenv",
"davidanson.vscode-markdownlint", "davidanson.vscode-markdownlint",
"ms-azuretools.vscode-docker" "ms-azuretools.vscode-docker"
] ],
} "forwardPorts": [3000],
}, "postAttachCommand": ["npm", "install"],
"remoteUser": "node" "remoteUser": "node"
} }

View File

@ -0,0 +1,10 @@
version: '3.0'
services:
workspace:
build:
context: './'
dockerfile: './Dockerfile'
volumes:
- '..:/workspace:cached'
command: 'sleep infinity'

View File

@ -1,4 +1,12 @@
.vscode
.git
.env
build build
.next .next
coverage coverage
node_modules node_modules
tmp
temp
.DS_Store
.lighthouseci
.vercel

View File

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

7
.eslintignore Normal file
View File

@ -0,0 +1,7 @@
.next
.lighthouseci
coverage
node_modules
next-env.d.ts
**/workbox-*.js
**/sw.js

View File

@ -4,14 +4,13 @@
"parserOptions": { "parserOptions": {
"project": "./tsconfig.json" "project": "./tsconfig.json"
}, },
"env": {
"node": true,
"browser": true,
"jest": true
},
"rules": { "rules": {
"prettier/prettier": "error", "prettier/prettier": "error",
"@next/next/no-img-element": "off" "@next/next/no-img-element": "off"
},
"overrides": [
{
"files": ["*.ts", "*.tsx"],
"parser": "@typescript-eslint/parser"
} }
]
} }

View File

@ -1,8 +1,8 @@
--- ---
name: "🐛 Bug Report" name: '🐛 Bug Report'
about: "Report an unexpected problem or unintended behavior." about: 'Report an unexpected problem or unintended behavior.'
title: "[Bug]" title: '[Bug]'
labels: "bug" labels: 'bug'
--- ---
<!-- <!--

View File

@ -1,8 +1,8 @@
--- ---
name: "📜 Documentation" name: '📜 Documentation'
about: "Correct spelling errors, improvements or additions to documentation files (README, CONTRIBUTING...)." about: 'Correct spelling errors, improvements or additions to documentation files (README, CONTRIBUTING...).'
title: "[Documentation]" title: '[Documentation]'
labels: "documentation" labels: 'documentation'
--- ---
<!-- Please make sure your issue has not already been fixed. --> <!-- Please make sure your issue has not already been fixed. -->

View File

@ -1,8 +1,8 @@
--- ---
name: "✨ Feature Request" name: '✨ Feature Request'
about: "Suggest a new feature idea." about: 'Suggest a new feature idea.'
title: "[Feature]" title: '[Feature]'
labels: "feature request" labels: 'feature request'
--- ---
<!-- Please make sure your issue has not already been fixed. --> <!-- Please make sure your issue has not already been fixed. -->

View File

@ -1,8 +1,8 @@
--- ---
name: "🔧 Improvement" name: '🔧 Improvement'
about: "Improve structure/format/performance/refactor/tests of the code." about: 'Improve structure/format/performance/refactor/tests of the code.'
title: "[Improvement]" title: '[Improvement]'
labels: "improvement" labels: 'improvement'
--- ---
<!-- Please make sure your issue has not already been fixed. --> <!-- Please make sure your issue has not already been fixed. -->

View File

@ -1,8 +1,8 @@
--- ---
name: "🙋 Question" name: '🙋 Question'
about: "Further information is requested." about: 'Further information is requested.'
title: "[Question]" title: '[Question]'
labels: "question" labels: 'question'
--- ---
### Question ### Question

View File

@ -1,6 +1,6 @@
<!-- Please first discuss the change you wish to make via issue before making a change. It might avoid a waste of your time. --> <!-- 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? ## What changes this PR introduce?
## List any relevant issue numbers ## List any relevant issue numbers

View File

@ -1,4 +1,4 @@
name: "Analyze" name: 'Analyze'
on: on:
push: push:
@ -8,20 +8,20 @@ on:
jobs: jobs:
analyze: analyze:
runs-on: "ubuntu-latest" runs-on: 'ubuntu-latest'
strategy: strategy:
fail-fast: false fail-fast: false
matrix: matrix:
language: ["javascript"] language: ['javascript']
steps: steps:
- uses: "actions/checkout@v4.0.0" - uses: 'actions/checkout@v2.3.4'
- name: "Initialize CodeQL" - name: 'Initialize CodeQL'
uses: "github/codeql-action/init@v2" uses: 'github/codeql-action/init@v1'
with: with:
languages: ${{ matrix.language }} languages: ${{ matrix.language }}
- name: "Perform CodeQL Analysis" - name: 'Perform CodeQL Analysis'
uses: "github/codeql-action/analyze@v2" uses: 'github/codeql-action/analyze@v1'

View File

@ -1,4 +1,4 @@
name: "Build" name: 'Build'
on: on:
push: push:
@ -8,18 +8,18 @@ on:
jobs: jobs:
build: build:
runs-on: "ubuntu-latest" runs-on: 'ubuntu-latest'
steps: steps:
- uses: "actions/checkout@v4.0.0" - uses: 'actions/checkout@v2'
- name: "Setup Node.js" - name: 'Use Node.js'
uses: "actions/setup-node@v3.8.1" uses: 'actions/setup-node@v2.4.1'
with: with:
node-version: "20.x" node-version: '16.x'
cache: "npm" cache: 'npm'
- name: "Install dependencies" - name: 'Install'
run: "npm clean-install" run: 'npm install'
- name: "Build" - name: 'Build'
run: "npm run build" run: 'npm run build'

View File

@ -1,4 +1,4 @@
name: "Lint" name: 'Lint'
on: on:
push: push:
@ -8,30 +8,40 @@ on:
jobs: jobs:
lint: lint:
runs-on: "ubuntu-latest" runs-on: 'ubuntu-latest'
steps: steps:
- uses: "actions/checkout@v4.0.0" - uses: 'actions/checkout@v2'
- name: "Setup Node.js" - name: 'Use Node.js'
uses: "actions/setup-node@v3.8.1" uses: 'actions/setup-node@v2.4.1'
with: with:
node-version: "20.x" node-version: '16.x'
cache: "npm" cache: 'npm'
- name: "Install dependencies" - name: 'Install'
run: "npm clean-install" run: 'npm install'
- name: "lint:commit" - name: 'lint:commit'
run: 'npm run lint:commit -- --to "${{ github.sha }}"' run: 'npm run lint:commit -- --to "${{ github.sha }}"'
- name: "lint:editorconfig" - name: 'lint:editorconfig'
run: "npm run lint:editorconfig" run: 'npm run lint:editorconfig'
- name: "lint:markdown" - name: 'lint:markdown'
run: "npm run lint:markdown" run: 'npm run lint:markdown'
- name: "lint:eslint" - name: 'lint:typescript'
run: "npm run lint:eslint" run: 'npm run lint:typescript'
- name: "lint:prettier" - name: 'lint:prettier'
run: "npm run lint:prettier" run: 'npm run lint:prettier'
- name: 'lint:dotenv'
uses: 'dotenv-linter/action-dotenv-linter@v2'
with:
github_token: ${{ secrets.github_token }}
- name: 'lint:docker'
uses: 'hadolint/hadolint-action@v1.6.0'
with:
dockerfile: './Dockerfile'

View File

@ -1,4 +1,4 @@
name: "Release" name: 'Release'
on: on:
push: push:
@ -6,32 +6,39 @@ on:
jobs: jobs:
release: release:
runs-on: "ubuntu-latest" runs-on: 'ubuntu-latest'
steps: steps:
- uses: "actions/checkout@v4.0.0" - uses: 'actions/checkout@v2.3.4'
with: with:
fetch-depth: 0 fetch-depth: 0
persist-credentials: false persist-credentials: false
- name: "Import GPG key" - name: 'Import GPG key'
uses: "crazy-max/ghaction-import-gpg@v6.0.0" uses: 'crazy-max/ghaction-import-gpg@v4'
with: with:
gpg_private_key: ${{ secrets.GPG_PRIVATE_KEY }} gpg_private_key: ${{ secrets.GPG_PRIVATE_KEY }}
git_user_signingkey: true git_user_signingkey: true
git_commit_gpgsign: true git_commit_gpgsign: true
- name: "Setup Node.js" - name: 'Use Node.js'
uses: "actions/setup-node@v3.8.1" uses: 'actions/setup-node@v2.4.0'
with: with:
node-version: "20.x" node-version: '16.x'
cache: "npm" cache: 'npm'
- name: "Install dependencies" - name: 'Install'
run: "npm clean-install" run: 'npm install'
- name: "Release" - name: 'Release'
run: "npm run release" run: 'npm run release'
env: env:
GH_TOKEN: ${{ secrets.GH_TOKEN }} GH_TOKEN: ${{ secrets.GH_TOKEN }}
GIT_COMMITTER_NAME: ${{ secrets.GIT_NAME }} GIT_COMMITTER_NAME: ${{ secrets.GIT_NAME }}
GIT_COMMITTER_EMAIL: ${{ secrets.GIT_EMAIL }} GIT_COMMITTER_EMAIL: ${{ secrets.GIT_EMAIL }}
- 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 }}

View File

@ -1,4 +1,4 @@
name: "Test" name: 'Test'
on: on:
push: push:
@ -8,41 +8,63 @@ on:
jobs: jobs:
test-unit: test-unit:
runs-on: "ubuntu-latest" runs-on: 'ubuntu-latest'
steps: steps:
- uses: "actions/checkout@v4.0.0" - uses: 'actions/checkout@v2.3.4'
- name: "Setup Node.js" - name: 'Use Node.js'
uses: "actions/setup-node@v3.8.1" uses: 'actions/setup-node@v2.4.1'
with: with:
node-version: "20.x" node-version: '16.x'
cache: "npm" cache: 'npm'
- name: "Install dependencies" - name: 'Install'
run: "npm clean-install" run: 'npm install'
- name: "Unit Test" - name: 'Unit Test'
run: "npm run test:unit" 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: 'html-w3c-validator'
run: 'npm run test:html-w3c-validator'
- name: 'Lighthouse'
run: 'npm run test:lighthouse'
env:
LHCI_GITHUB_APP_TOKEN: ${{ secrets.LHCI_GITHUB_APP_TOKEN }}
test-e2e: test-e2e:
runs-on: "ubuntu-latest" runs-on: 'ubuntu-latest'
steps: steps:
- uses: "actions/checkout@v4.0.0" - uses: 'actions/checkout@v2.3.4'
- name: "Setup Node.js" - name: 'Use Node.js'
uses: "actions/setup-node@v3.8.1" uses: 'actions/setup-node@v2.4.0'
with: with:
node-version: "20.x" node-version: '16.x'
cache: "npm" cache: 'npm'
- name: "Install dependencies" - name: 'Install'
run: "npm clean-install" run: 'npm install'
- name: "Build" - name: 'Build'
run: "npm run build" run: 'npm run build'
- name: "html-w3c-validator" - name: 'End To End (e2e) Test'
run: "npm run test:html-w3c-validator" run: 'npm run test:e2e'
- name: "End To End (e2e) Test"
run: "npm run test:e2e"

11
.gitignore vendored
View File

@ -18,6 +18,10 @@ cypress/screenshots
cypress/videos cypress/videos
cypress/downloads cypress/downloads
# PWA
**/workbox-*.js
**/sw.js
# envs # envs
.env .env
.env.production .env.production
@ -43,8 +47,5 @@ npm-debug.log*
# misc # misc
.DS_Store .DS_Store
*.hbs .lighthouseci
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts

View File

@ -1,9 +1,9 @@
{ {
"urls": [ "urls": [
"http://127.0.0.1:3000/", "http://localhost:3000/",
"http://127.0.0.1:3000/authentication/forgot-password", "http://localhost:3000/authentication/forgot-password",
"http://127.0.0.1:3000/authentication/reset-password", "http://localhost:3000/authentication/reset-password",
"http://127.0.0.1:3000/authentication/signin", "http://localhost:3000/authentication/signin",
"http://127.0.0.1:3000/authentication/signup" "http://localhost:3000/authentication/signup"
] ]
} }

31
.lighthouserc.json Normal file
View File

@ -0,0 +1,31 @@
{
"ci": {
"collect": {
"startServerCommand": "npm run start",
"startServerReadyPattern": "ready on",
"startServerReadyTimeout": 20000,
"url": [
"http://localhost:3000/",
"http://localhost:3000/authentication/forgot-password",
"http://localhost:3000/authentication/reset-password",
"http://localhost:3000/authentication/signin",
"http://localhost:3000/authentication/signup"
],
"numberOfRuns": 1
},
"assert": {
"preset": "lighthouse:recommended",
"assertions": {
"image-size-responsive": "warning",
"unsized-images": "warning",
"csp-xss": "warning",
"non-composited-animations": "warning",
"unused-javascript": "warning"
}
},
"upload": {
"target": "temporary-public-storage"
},
"server": {}
}
}

View File

@ -1,6 +1,10 @@
{ {
"*": ["editorconfig-checker"], "*": ["editorconfig-checker"],
"*.{js,jsx,ts,tsx}": ["prettier --write", "eslint --fix"], "*.{js,jsx,ts,tsx}": [
"prettier --write",
"eslint --fix",
"jest --findRelatedTests"
],
"*.{css,scss,sass,json,jsonc,yml,yaml}": ["prettier --write"], "*.{css,scss,sass,json,jsonc,yml,yaml}": ["prettier --write"],
"*.{md,mdx}": ["prettier --write", "markdownlint-cli2 --fix"] "*.md": ["prettier --write", "markdownlint --dot --fix"]
} }

View File

@ -1,11 +0,0 @@
{
"config": {
"extends": "markdownlint/style/prettier",
"relative-links": true,
"default": true,
"MD033": false
},
"globs": ["**/*.{md,mdx}"],
"ignores": ["**/node_modules"],
"customRules": ["markdownlint-rule-relative-links"]
}

6
.markdownlint.json Normal file
View File

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

1
.npmrc
View File

@ -1,2 +1 @@
save-exact=true save-exact=true
legacy-peer-deps=true

7
.prettierignore Normal file
View File

@ -0,0 +1,7 @@
.next
.lighthouseci
coverage
node_modules
**/workbox-*.js
**/sw.js
*.hbs

View File

@ -1,3 +1,6 @@
{ {
"semi": false "singleQuote": true,
"jsxSingleQuote": true,
"semi": false,
"trailingComma": "none"
} }

View File

@ -3,6 +3,8 @@
"editorconfig.editorconfig", "editorconfig.editorconfig",
"esbenp.prettier-vscode", "esbenp.prettier-vscode",
"dbaeumer.vscode-eslint", "dbaeumer.vscode-eslint",
"divlo.vscode-styled-jsx-syntax",
"divlo.vscode-styled-jsx-languageserver",
"bradlc.vscode-tailwindcss", "bradlc.vscode-tailwindcss",
"mikestead.dotenv", "mikestead.dotenv",
"davidanson.vscode-markdownlint", "davidanson.vscode-markdownlint",

View File

@ -5,10 +5,6 @@
"editor.bracketPairColorization.enabled": true, "editor.bracketPairColorization.enabled": true,
"editor.formatOnSave": true, "editor.formatOnSave": true,
"editor.codeActionsOnSave": { "editor.codeActionsOnSave": {
"source.fixAll": "explicit" "source.fixAll": true
}, }
"eslint.options": {
"ignorePath": ".gitignore"
},
"prettier.ignorePath": ".gitignore"
} }

View File

@ -60,7 +60,7 @@ representative at an online or offline event.
Instances of abusive, harassing, or otherwise unacceptable behavior may be Instances of abusive, harassing, or otherwise unacceptable behavior may be
reported to the community leaders responsible for enforcement at reported to the community leaders responsible for enforcement at
<contact@theoludwig.fr>. contact@divlo.fr.
All complaints will be reviewed and investigated promptly and fairly. All complaints will be reviewed and investigated promptly and fairly.
All community leaders are obligated to respect the privacy and security of the All community leaders are obligated to respect the privacy and security of the

View File

@ -16,7 +16,6 @@ All work on **Thream/website** happens directly on [GitHub](https://github.com/T
- Suggest a new feature idea. - Suggest a new feature idea.
- Correct spelling errors, improvements or additions to documentation files. - Correct spelling errors, improvements or additions to documentation files.
- Improve structure/format/performance/refactor/tests of the code. - Improve structure/format/performance/refactor/tests of the code.
- [Add translations](#add-a-translation).
## Pull Requests ## Pull Requests
@ -30,7 +29,31 @@ If you're adding new features to **Thream/website**, please include tests.
## Commits ## Commits
The commit message guidelines adheres to [Conventional Commits](https://www.conventionalcommits.org/) 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
Types define which kind of changes you made to the project.
| Types | Description |
| -------- | ------------------------------------------------------------------------------------------------------------ |
| feat | A new feature. |
| fix | A bug fix. |
| docs | Documentation only changes. |
| style | Changes that do not affect the meaning of the code (white-space, formatting, missing semi-colons, etc). |
| refactor | A code change that neither fixes a bug nor adds a feature. |
| perf | A code change that improves performance. |
| test | Adding missing tests or correcting existing tests. |
| build | Changes that affect the build system or external dependencies (example scopes: gulp, broccoli, npm). |
| ci | Changes to our CI configuration files and scripts (example scopes: Travis, Circle, BrowserStack, SauceLabs). |
| chore | Other changes that don't modify src or test files. |
| revert | Reverts a previous commit. |
### Scopes
Scopes define what part of the code changed.
### Examples ### Examples
@ -38,15 +61,3 @@ The commit message guidelines adheres to [Conventional Commits](https://www.conv
git commit -m "feat(components): add Button" git commit -m "feat(components): add Button"
git commit -m "docs(readme): update installation process" git commit -m "docs(readme): update installation process"
``` ```
## Add a translation
[Reference issue](https://github.com/Thream/website/issues/24)
Feel free to contribute to **Thream** and add new languages, we would appreciate your help!
To add a new language:
- `npm clean-install`
- `npm run generate`
- Start editing JSON files with the translation in `locales/{{locale}}` (e.g: `locales/en`)

View File

@ -1,23 +1,23 @@
FROM node:20.9.0 AS builder-dependencies FROM node:16.14.2 AS dependencies
WORKDIR /usr/src/application WORKDIR /usr/src/app
COPY ./.npmrc ./
COPY ./package*.json ./ COPY ./package*.json ./
RUN npm clean-install RUN npm install
FROM node:20.9.0 AS builder FROM node:16.14.2 AS builder
WORKDIR /usr/src/application WORKDIR /usr/src/app
COPY --from=builder-dependencies /usr/src/application/node_modules ./node_modules
COPY ./ ./ COPY ./ ./
COPY --from=dependencies /usr/src/app/node_modules ./node_modules
RUN npm run build RUN npm run build
FROM gcr.io/distroless/nodejs20-debian12:latest AS runner FROM node:16.14.2 AS runner
WORKDIR /usr/src/application WORKDIR /usr/src/app
ENV NODE_ENV=production ENV NODE_ENV=production
ENV HOSTNAME=0.0.0.0 COPY --from=builder /usr/src/app/next.config.js ./next.config.js
ENV NEXT_TELEMETRY_DISABLED=1 COPY --from=builder /usr/src/app/public ./public
COPY --from=builder /usr/src/application/.next/standalone ./ COPY --from=builder /usr/src/app/.next ./.next
COPY --from=builder /usr/src/application/.next/static ./.next/static COPY --from=builder /usr/src/app/i18n.json ./i18n.json
COPY --from=builder /usr/src/application/public ./public COPY --from=builder /usr/src/app/locales ./locales
COPY --from=builder /usr/src/application/locales ./locales COPY --from=builder /usr/src/app/pages ./pages
COPY --from=builder /usr/src/application/next.config.js ./next.config.js COPY --from=builder /usr/src/app/node_modules ./node_modules
CMD ["./server.js"] RUN npx next telemetry disable
CMD ["node_modules/.bin/next", "start", "--port", "${PORT}"]

View File

@ -1,8 +1,4 @@
<h1 align="center"><a href="https://thream.theoludwig.fr/">Thream/website</a></h1> <h1 align="center"><a href="https://thream.divlo.fr/">Thream/website</a></h1>
<p align="center">
<strong>⚠️ This project is not maintained anymore, you can still use the code as you wish and fork it to maintain it yourself.</strong>
</p>
<p align="center"> <p align="center">
<a href="./CONTRIBUTING.md"><img src="https://img.shields.io/badge/PRs-welcome-brightgreen.svg?style=flat" /></a> <a href="./CONTRIBUTING.md"><img src="https://img.shields.io/badge/PRs-welcome-brightgreen.svg?style=flat" /></a>
@ -22,7 +18,7 @@
Thream's website to stay close with your friends and communities. Thream's website to stay close with your friends and communities.
It uses [Thream/api](https://github.com/Thream/api) [v1.2.10](https://github.com/Thream/api/releases/tag/v1.2.10). It uses [Thream/api](https://github.com/Thream/api) [v1.0.1](https://github.com/Thream/api/releases/tag/v1.0.1).
## ⚙️ Getting Started ## ⚙️ Getting Started
@ -35,7 +31,7 @@ It uses [Thream/api](https://github.com/Thream/api) [v1.2.10](https://github.com
```sh ```sh
# Clone the repository # Clone the repository
git clone git@github.com:Thream/website.git git clone https://github.com/Thream/website.git
# Go to the project root # Go to the project root
cd website cd website
@ -44,7 +40,7 @@ cd website
cp .env.example .env cp .env.example .env
# Install # Install
npm clean-install npm install
``` ```
You will need to configure the environment variables by creating an `.env` file at You will need to configure the environment variables by creating an `.env` file at
@ -60,12 +56,12 @@ npm run dev
```sh ```sh
# Setup and run all the services for you # Setup and run all the services for you
docker compose up --build docker-compose up --build
``` ```
#### Services started #### Services started
- `website`: <http://127.0.0.1:3000> - website : `http://localhost:3000`
## 💡 Contributing ## 💡 Contributing

View File

@ -1,18 +1,17 @@
import { useState, useEffect } from "react" import { useState, useEffect } from 'react'
import Image from "next/image" import Image from 'next/image'
import { PlusIcon, MenuIcon, UsersIcon, XIcon } from "@heroicons/react/solid" import { PlusIcon, MenuIcon, UsersIcon, XIcon } from '@heroicons/react/solid'
import classNames from "clsx" import classNames from 'classnames'
import { useMediaQuery } from "react-responsive" import { useMediaQuery } from 'react-responsive'
import { useSwipeable } from "react-swipeable" import { useSwipeable } from 'react-swipeable'
import type { DirectionSidebar } from "./Sidebar" import { Sidebar, DirectionSidebar } from './Sidebar'
import { Sidebar } from "./Sidebar" import { IconButton } from '../design/IconButton'
import { IconButton } from "../design/IconButton" import { IconLink } from '../design/IconLink'
import { IconLink } from "../design/IconLink" import { Guilds } from './Guilds/Guilds'
import { Guilds } from "./Guilds/Guilds" import { Divider } from '../design/Divider'
import { Divider } from "../design/Divider" import { Members } from './Members'
import { Members } from "./Members" import { useAuthentication } from '../../tools/authentication'
import { useAuthentication } from "../../tools/authentication"
export interface ChannelsPath { export interface ChannelsPath {
channelId: number channelId: number
@ -27,9 +26,9 @@ const isGuildsChannelsPath = (path: any): path is GuildsChannelsPath => {
} }
export type ApplicationPath = export type ApplicationPath =
| "/application" | '/application'
| "/application/guilds/join" | '/application/guilds/join'
| "/application/guilds/create" | '/application/guilds/create'
| `/application/users/${number}` | `/application/users/${number}`
| `/application/users/settings` | `/application/users/settings`
| GuildsChannelsPath | GuildsChannelsPath
@ -41,9 +40,7 @@ export interface ApplicationProps {
title: string title: string
} }
export const Application: React.FC< export const Application: React.FC<ApplicationProps> = (props) => {
React.PropsWithChildren<ApplicationProps>
> = (props) => {
const { children, path, guildLeftSidebar, title } = props const { children, path, guildLeftSidebar, title } = props
const { user } = useAuthentication() const { user } = useAuthentication()
@ -51,51 +48,51 @@ export const Application: React.FC<
const [mounted, setMounted] = useState(false) const [mounted, setMounted] = useState(false)
const isMobile = useMediaQuery({ const isMobile = useMediaQuery({
query: "(max-width: 900px)", query: '(max-width: 900px)'
}) })
const [visibleSidebars, setVisibleSidebars] = useState({ const [visibleSidebars, setVisibleSidebars] = useState({
left: !isMobile, left: !isMobile,
right: false, right: false
}) })
const handleToggleSidebars = (direction: DirectionSidebar): void => { const handleToggleSidebars = (direction: DirectionSidebar): void => {
if (!isMobile) { if (!isMobile) {
if (direction === "left") { if (direction === 'left') {
return setVisibleSidebars({ return setVisibleSidebars({
...visibleSidebars, ...visibleSidebars,
left: !visibleSidebars.left, left: !visibleSidebars.left
}) })
} }
if (direction === "right") { if (direction === 'right') {
return setVisibleSidebars({ return setVisibleSidebars({
...visibleSidebars, ...visibleSidebars,
right: !visibleSidebars.right, right: !visibleSidebars.right
}) })
} }
} else { } else {
if (direction === "right" && visibleSidebars.left) { if (direction === 'right' && visibleSidebars.left) {
return setVisibleSidebars({ return setVisibleSidebars({
left: false, left: false,
right: true, right: true
}) })
} }
if (direction === "left" && visibleSidebars.right) { if (direction === 'left' && visibleSidebars.right) {
return setVisibleSidebars({ return setVisibleSidebars({
left: true, left: true,
right: false, right: false
}) })
} }
if (direction === "left" && !visibleSidebars.right) { if (direction === 'left' && !visibleSidebars.right) {
return setVisibleSidebars({ return setVisibleSidebars({
...visibleSidebars, ...visibleSidebars,
left: !visibleSidebars.left, left: !visibleSidebars.left
}) })
} }
if (direction === "right" && !visibleSidebars.left) { if (direction === 'right' && !visibleSidebars.left) {
return setVisibleSidebars({ return setVisibleSidebars({
...visibleSidebars, ...visibleSidebars,
right: !visibleSidebars.right, right: !visibleSidebars.right
}) })
} }
} }
@ -105,7 +102,7 @@ export const Application: React.FC<
if (isMobile && (visibleSidebars.left || visibleSidebars.right)) { if (isMobile && (visibleSidebars.left || visibleSidebars.right)) {
return setVisibleSidebars({ return setVisibleSidebars({
left: false, left: false,
right: false, right: false
}) })
} }
} }
@ -113,27 +110,25 @@ export const Application: React.FC<
const swipeableHandlers = useSwipeable({ const swipeableHandlers = useSwipeable({
trackMouse: false, trackMouse: false,
trackTouch: true, trackTouch: true,
preventScrollOnSwipe: true, preventDefaultTouchmoveEvent: true,
onSwipedRight: () => { onSwipedRight: () => {
if (visibleSidebars.right) { if (visibleSidebars.right) {
return setVisibleSidebars({ ...visibleSidebars, right: false }) return setVisibleSidebars({ ...visibleSidebars, right: false })
} }
setVisibleSidebars({ setVisibleSidebars({
...visibleSidebars, ...visibleSidebars,
left: true, left: true
}) })
}, },
onSwipedLeft: () => { onSwipedLeft: () => {
if (isGuildsChannelsPath(path)) {
if (visibleSidebars.left) { if (visibleSidebars.left) {
return setVisibleSidebars({ ...visibleSidebars, left: false }) return setVisibleSidebars({ ...visibleSidebars, left: false })
} }
setVisibleSidebars({ setVisibleSidebars({
...visibleSidebars, ...visibleSidebars,
right: true, right: true
}) })
} }
},
}) })
useEffect(() => { useEffect(() => {
@ -146,29 +141,25 @@ export const Application: React.FC<
return ( return (
<> <>
<header className="z-50 flex h-16 items-center justify-between bg-gray-200 px-2 py-3 shadow-lg dark:bg-gray-800"> <header className='z-50 flex h-16 items-center justify-between bg-gray-200 px-2 py-3 shadow-lg dark:bg-gray-800'>
<IconButton <IconButton
className="h-10 w-10 p-2" className='h-10 w-10 p-2'
onClick={() => { onClick={() => handleToggleSidebars('left')}
return handleToggleSidebars("left")
}}
> >
{!visibleSidebars.left ? <MenuIcon /> : <XIcon />} {!visibleSidebars.left ? <MenuIcon /> : <XIcon />}
</IconButton> </IconButton>
<div <div
data-cy="application-title" data-cy='application-title'
className="text-md font-semibold text-green-800 dark:text-green-400" className='text-md font-semibold text-green-800 dark:text-green-400'
> >
{title} {title}
</div> </div>
<div className="flex space-x-2"> <div className='flex space-x-2'>
{title.startsWith("#") && ( {title.startsWith('#') && (
<IconButton <IconButton
data-cy="icon-button-right-sidebar-members" data-cy='icon-button-right-sidebar-members'
className="h-10 w-10 p-2" className='h-10 w-10 p-2'
onClick={() => { onClick={() => handleToggleSidebars('right')}
return handleToggleSidebars("right")
}}
> >
{!visibleSidebars.right ? <UsersIcon /> : <XIcon />} {!visibleSidebars.right ? <UsersIcon /> : <XIcon />}
</IconButton> </IconButton>
@ -177,26 +168,26 @@ export const Application: React.FC<
</header> </header>
<main <main
className="h-full-without-header relative flex overflow-hidden" className='h-full-without-header relative flex overflow-hidden'
{...swipeableHandlers} {...swipeableHandlers}
> >
<Sidebar <Sidebar
direction="left" direction='left'
visible={visibleSidebars.left} visible={visibleSidebars.left}
isMobile={isMobile} isMobile={isMobile}
> >
<div className="left-0 top-0 z-50 flex min-w-[92px] flex-col space-y-4 border-r-2 border-gray-500 bg-gray-200 py-2 dark:border-white/20 dark:bg-gray-800"> <div className='top-0 left-0 z-50 flex min-w-[92px] flex-col space-y-4 border-r-2 border-gray-500 bg-gray-200 py-2 dark:border-white/20 dark:bg-gray-800'>
<IconLink <IconLink
href={`/application/users/settings`} href={`/application/users/settings`}
selected={path === `/application/users/settings`} selected={path === `/application/users/settings`}
title="Settings" title='Settings'
> >
<Image <Image
quality={100} quality={100}
className="rounded-full" className='rounded-full'
src={ src={
user.logo == null user.logo == null
? "/images/data/user-default.png" ? '/images/data/user-default.png'
: user.logo : user.logo
} }
alt={"Users's profil picture"} alt={"Users's profil picture"}
@ -206,11 +197,11 @@ export const Application: React.FC<
/> />
</IconLink> </IconLink>
<IconLink <IconLink
href="/application" href='/application'
selected={path === "/application"} selected={path === '/application'}
title="Join or create a Guild" title='Join or create a Guild'
> >
<PlusIcon className="h-12 w-12 text-green-800 dark:text-green-400" /> <PlusIcon className='h-12 w-12 text-green-800 dark:text-green-400' />
</IconLink> </IconLink>
<Divider /> <Divider />
<Guilds path={path} /> <Guilds path={path} />
@ -219,14 +210,14 @@ export const Application: React.FC<
</Sidebar> </Sidebar>
<div <div
id="application-page-content" id='application-page-content'
onClick={handleCloseSidebars} onClick={handleCloseSidebars}
className={classNames( className={classNames(
"h-full-without-header relative top-0 z-0 flex w-full flex-1 flex-col overflow-y-auto transition", 'h-full-without-header top-0 z-0 flex w-full flex-1 flex-col overflow-y-auto transition',
{ {
"absolute opacity-20": 'absolute opacity-20':
isMobile && (visibleSidebars.left || visibleSidebars.right), isMobile && (visibleSidebars.left || visibleSidebars.right)
}, }
)} )}
> >
{children} {children}
@ -234,7 +225,7 @@ export const Application: React.FC<
{isGuildsChannelsPath(path) && ( {isGuildsChannelsPath(path) && (
<Sidebar <Sidebar
direction="right" direction='right'
visible={visibleSidebars.right} visible={visibleSidebars.right}
isMobile={isMobile} isMobile={isMobile}
> >

View File

@ -1,27 +1,19 @@
import { useRouter } from "next/router" import { useRouter } from 'next/router'
import { useState } from "react" import { useState } from 'react'
import { Form, useForm } from "react-component-form" import { Form } from 'react-component-form'
import useTranslation from "next-translate/useTranslation" import useTranslation from 'next-translate/useTranslation'
import classNames from "clsx"
import axios from "axios"
import type { HandleUseFormCallback } from "react-component-form"
import { FormState } from "../../design/FormState" import { HandleSubmitCallback, useForm } from '../../../hooks/useForm'
import { useGuildMember } from "../../../contexts/GuildMember" import { FormState } from '../../design/FormState'
import { Input } from "../../design/Input" import { useGuildMember } from '../../../contexts/GuildMember'
import { Button } from "../../design/Button" import { Input } from '../../design/Input'
import { useAuthentication } from "../../../tools/authentication" import { Button } from '../../design/Button'
import type { import { useAuthentication } from '../../../tools/authentication'
import {
Channel, Channel,
ChannelWithDefaultChannelId, channelSchema,
} from "../../../models/Channel" ChannelWithDefaultChannelId
import { channelSchema } from "../../../models/Channel" } from '../../../models/Channel'
import { ConfirmPopup } from "../ConfirmPopup"
import { useFormTranslation } from "../../../hooks/useFormTranslation"
const schema = {
name: channelSchema.name,
}
export interface ChannelSettingsProps { export interface ChannelSettingsProps {
channel: Channel channel: Channel
@ -36,35 +28,35 @@ export const ChannelSettings: React.FC<ChannelSettingsProps> = (props) => {
const { channel } = props const { channel } = props
const [inputValues, setInputValues] = useState({ const [inputValues, setInputValues] = useState({
name: channel.name, name: channel.name
}) })
const [confirmation, setConfirmation] = useState(false)
const handleConfirmation = (): void => {
return setConfirmation(!confirmation)
}
const { const {
handleUseForm,
fetchState, fetchState,
message, message,
errors, errors,
getErrorTranslation,
handleSubmit,
setFetchState, setFetchState,
setMessage, setMessageTranslationKey
} = useForm(schema) } = useForm({
const { getFirstErrorTranslation } = useFormTranslation() validateSchema: {
name: channelSchema.name
},
replaceEmptyStringToNull: true,
resetOnSuccess: false
})
const onSubmit: HandleUseFormCallback<typeof schema> = async (formData) => { const onSubmit: HandleSubmitCallback = async (formData) => {
try { try {
await authentication.api.put(`/channels/${channel.id}`, formData) await authentication.api.put(`/channels/${channel.id}`, formData)
setInputValues(formData) setInputValues(formData as any)
await router.push(`/application/${guild.id}/${channel.id}`) await router.push(`/application/${guild.id}/${channel.id}`)
return null return null
} catch (error) { } catch (error) {
return { return {
type: "error", type: 'error',
message: "errors:server-error", value: 'errors:server-error'
} }
} }
} }
@ -75,7 +67,7 @@ export const ChannelSettings: React.FC<ChannelSettingsProps> = (props) => {
setInputValues((oldInputValues) => { setInputValues((oldInputValues) => {
return { return {
...oldInputValues, ...oldInputValues,
[event.target.name]: event.target.value, [event.target.name]: event.target.value
} }
}) })
} }
@ -84,74 +76,53 @@ export const ChannelSettings: React.FC<ChannelSettingsProps> = (props) => {
try { try {
const { data } = const { data } =
await authentication.api.delete<ChannelWithDefaultChannelId>( await authentication.api.delete<ChannelWithDefaultChannelId>(
`/channels/${channel.id}`, `/channels/${channel.id}`
) )
await router.push(`/application/${guild.id}/${data.defaultChannelId}`) await router.push(`/application/${guild.id}/${data.defaultChannelId}`)
} catch (error) { } catch (error) {
setFetchState("error") setFetchState('error')
if (axios.isAxiosError(error) && error.response?.status === 400) { setMessageTranslationKey('errors:server-error')
setMessage("application:delete-channel-only-one")
} else {
setMessage("errors:server-error")
}
} }
} }
return ( return (
<>
<Form <Form
onSubmit={handleUseForm(onSubmit)} onSubmit={handleSubmit(onSubmit)}
className="my-auto flex flex-col items-center justify-center py-12" className='my-auto flex flex-col items-center justify-center py-12'
> >
<div className="flex w-full flex-col items-center justify-center sm:w-fit lg:flex-row"> <div className='flex w-full flex-col items-center justify-center sm:w-fit lg:flex-row'>
<div className=" flex w-full flex-wrap items-center justify-center px-6 sm:w-max"> <div className=' flex w-full flex-wrap items-center justify-center px-6 sm:w-max'>
<div className="mx-12 flex flex-col"> <div className='mx-12 flex flex-col'>
<Input <Input
name="name" name='name'
label={t("common:name")} label={t('common:name')}
placeholder={t("common:name")} placeholder={t('common:name')}
className="!mt-0" className='!mt-0'
onChange={onChange} onChange={onChange}
value={inputValues.name} value={inputValues.name}
error={getFirstErrorTranslation(errors.name)} error={getErrorTranslation(errors.name)}
data-cy="channel-name-input" data-cy='channel-name-input'
/> />
</div> </div>
</div> </div>
</div> </div>
<div className="mt-12 flex flex-col items-center justify-center sm:w-fit"> <div className='mt-12 flex flex-col items-center justify-center sm:w-fit'>
<div className="space-x-6"> <div className='space-x-6'>
<Button type="submit" data-cy="button-save-channel-settings"> <Button type='submit' data-cy='button-save-channel-settings'>
{t("application:save")} {t('application:save')}
</Button> </Button>
<Button <Button
type="button" type='button'
color="red" color='red'
onClick={handleConfirmation} onClick={handleDelete}
data-cy="button-delete-channel-settings" data-cy='button-delete-channel-settings'
> >
{t("application:delete")} {t('application:delete')}
</Button> </Button>
</div> </div>
<FormState state={fetchState} message={message} /> <FormState state={fetchState} message={message} />
</div> </div>
</Form> </Form>
<div
className={classNames(
"pointer-events-none invisible absolute z-50 flex h-full w-full items-center justify-center bg-black bg-opacity-90 opacity-0 backdrop-blur-md transition-all",
{ "pointer-events-auto !visible !opacity-100": confirmation },
)}
>
<ConfirmPopup
className={classNames("relative top-8 transition-all", {
"!top-0": confirmation,
})}
handleYes={handleDelete}
handleNo={handleConfirmation}
title={`${t("application:delete-the-channel")} ?`}
/>
</div>
</>
) )
} }

View File

@ -1 +1 @@
export * from "./ChannelSettings" export * from './ChannelSettings'

View File

@ -1,54 +0,0 @@
import { memo } from "react"
import classNames from "clsx"
import Link from "next/link"
import { useRouter } from "next/router"
import { CogIcon } from "@heroicons/react/solid"
import type { GuildsChannelsPath } from "../Application"
import type { Channel as ChannelType } from "../../../models/Channel"
import { useGuildMember } from "../../../contexts/GuildMember"
import { IconButton } from "../../design/IconButton"
export interface ChannelProps {
path: GuildsChannelsPath
channel: ChannelType
selected?: boolean
}
const ChannelMemo: React.FC<ChannelProps> = (props) => {
const { channel, path, selected = false } = props
const router = useRouter()
const { member } = useGuildMember()
return (
<Link
href={`/application/${path.guildId}/${channel.id}`}
className={classNames(
"group relative mx-3 my-3 flex items-center justify-between overflow-hidden rounded-lg py-2 text-sm transition-all duration-200 hover:bg-gray-100 dark:hover:bg-gray-600",
{
"font-semibold text-green-800 dark:text-green-400": selected,
},
)}
>
<span className="max-[315px] ml-2 mr-4 break-all" data-cy="channel-name">
# {channel.name}
</span>
{member.isOwner && (
<IconButton
onClick={async () => {
await router.push(
`/application/${channel.guildId}/${channel.id}/settings`,
)
}}
className="bg-unherit absolute -right-10 h-full w-8 transition-all group-hover:right-0 group-hover:shadow-lg dark:group-hover:bg-gray-600"
title="Settings"
>
<CogIcon height={20} width={20} />
</IconButton>
)}
</Link>
)
}
export const Channel = memo(ChannelMemo)

View File

@ -0,0 +1,55 @@
import classNames from 'classnames'
import Link from 'next/link'
import { useRouter } from 'next/router'
import { CogIcon } from '@heroicons/react/solid'
import { GuildsChannelsPath } from '../../Application'
import { Channel as ChannelType } from '../../../../models/Channel'
import { useGuildMember } from '../../../../contexts/GuildMember'
import { IconButton } from '../../../design/IconButton'
export interface ChannelProps {
path: GuildsChannelsPath
channel: ChannelType
selected?: boolean
}
export const Channel: React.FC<ChannelProps> = (props) => {
const { channel, path, selected = false } = props
const router = useRouter()
const { member } = useGuildMember()
return (
<Link href={`/application/${path.guildId}/${channel.id}`}>
<a
className={classNames(
'group relative my-3 mx-3 flex items-center justify-between overflow-hidden rounded-lg py-2 text-sm transition-all duration-200 hover:bg-gray-100 dark:hover:bg-gray-600',
{
'font-semibold text-green-800 dark:text-green-400': selected
}
)}
>
<span
className='max-[315px] ml-2 mr-4 break-all'
data-cy='channel-name'
>
# {channel.name}
</span>
{member.isOwner && (
<IconButton
onClick={async () => {
await router.push(
`/application/${channel.guildId}/${channel.id}/settings`
)
}}
className='bg-unherit absolute -right-10 h-full w-8 transition-all group-hover:right-0 group-hover:shadow-lg dark:group-hover:bg-gray-600'
title='Settings'
>
<CogIcon height={20} width={20} />
</IconButton>
)}
</a>
</Link>
)
}

View File

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

View File

@ -1,9 +1,9 @@
import InfiniteScroll from "react-infinite-scroll-component" import InfiniteScroll from 'react-infinite-scroll-component'
import { useChannels } from "../../../contexts/Channels" import { useChannels } from '../../../contexts/Channels'
import type { GuildsChannelsPath } from "../Application" import { GuildsChannelsPath } from '../Application'
import { Loader } from "../../design/Loader" import { Loader } from '../../design/Loader'
import { Channel } from "./Channel" import { Channel } from './Channel'
export interface ChannelsProps { export interface ChannelsProps {
path: GuildsChannelsPath path: GuildsChannelsPath
@ -16,12 +16,12 @@ export const Channels: React.FC<ChannelsProps> = (props) => {
return ( return (
<div <div
id="channels" id='channels'
className="scrollbar-firefox-support flex flex-1 flex-col overflow-y-auto" className='scrollbar-firefox-support flex flex-1 flex-col overflow-y-auto'
> >
<InfiniteScroll <InfiniteScroll
className="channels-list w-full" className='channels-list w-full'
scrollableTarget="channels" scrollableTarget='channels'
dataLength={channels.length} dataLength={channels.length}
next={nextPage} next={nextPage}
hasMore={hasMore} hasMore={hasMore}

View File

@ -1 +1 @@
export * from "./Channels" export * from './Channels'

View File

@ -0,0 +1,69 @@
import Image from 'next/image'
import { useState } from 'react'
import useTranslation from 'next-translate/useTranslation'
import classNames from 'classnames'
import { Loader } from '../../design/Loader'
export interface ConfirmGuildJoinProps {
className?: string
handleYes: () => void | Promise<void>
handleNo: () => void | Promise<void>
}
export const ConfirmGuildJoin: React.FC<ConfirmGuildJoinProps> = ({
...props
}) => {
const { t } = useTranslation()
const [isLoading, setIsLoading] = useState(false)
const handleYesLoading = async (): Promise<void> => {
setIsLoading((isLoading) => !isLoading)
await props.handleYes()
}
return (
<div className={props.className}>
<Loader
className={classNames('absolute scale-0 transition', {
'scale-100': isLoading
})}
/>
<div
className={classNames(
'visible flex flex-col items-center opacity-100 transition-all',
{
'invisible opacity-0': isLoading
}
)}
>
<Image
quality={100}
src='/images/svg/design/join-guild.svg'
alt='Join Guild Illustration'
height={150}
width={150}
/>
<div className='mt-8 flex flex-col'>
<h1 className='mb-6 text-center text-xl'>
{t('application:join-the-guild')} ?
</h1>
<div className='flex gap-7'>
<button
className='rounded-3xl bg-success px-8 py-2 text-white transition hover:brightness-125 dark:text-black hover:dark:brightness-75'
onClick={handleYesLoading}
>
{t('common:yes')}
</button>
<button
className='rounded-3xl bg-error px-8 py-2 text-white transition hover:brightness-125 dark:text-black hover:dark:brightness-75'
onClick={props.handleNo}
>
{t('common:no')}
</button>
</div>
</div>
</div>
</div>
)
}

View File

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

View File

@ -1,73 +0,0 @@
import Image from "next/image"
import { useState } from "react"
import useTranslation from "next-translate/useTranslation"
import classNames from "clsx"
import { Loader } from "../../design/Loader"
export interface ConfirmPopupProps {
className?: string
title: string
handleYes: () => void | Promise<void>
handleNo: () => void | Promise<void>
}
export const ConfirmPopup: React.FC<ConfirmPopupProps> = ({ ...props }) => {
const { t } = useTranslation()
const [isLoading, setIsLoading] = useState(false)
const handleYesLoading = async (): Promise<void> => {
setIsLoading((isLoading) => {
return !isLoading
})
await props.handleYes()
}
return (
<div className={props.className}>
<Loader
className={classNames(
"absolute left-1/2 top-1/2 scale-0 transition-all",
{
"scale-100": isLoading,
},
)}
/>
<div
className={classNames(
"visible flex flex-col items-center opacity-100 transition-all",
{
"invisible opacity-0": isLoading,
},
)}
>
<Image
quality={100}
src="/images/svg/design/join-guild.svg"
alt="Illustration"
height={150}
width={150}
/>
<div className="mt-8 flex flex-col">
<h1 className="mb-6 text-center text-xl">{props.title}</h1>
<div className="flex gap-7">
<button
className="rounded-3xl bg-success px-8 py-2 text-white transition hover:brightness-125 dark:text-black hover:dark:brightness-75"
onClick={handleYesLoading}
data-cy="confirm-popup-yes-button"
>
{t("common:yes")}
</button>
<button
className="rounded-3xl bg-error px-8 py-2 text-white transition hover:brightness-125 dark:text-black hover:dark:brightness-75"
onClick={props.handleNo}
data-cy="confirm-popup-no-button"
>
{t("common:no")}
</button>
</div>
</div>
</div>
</div>
)
}

View File

@ -1 +0,0 @@
export * from "./ConfirmPopup"

View File

@ -1,68 +1,67 @@
import { useRouter } from "next/router" import { useRouter } from 'next/router'
import useTranslation from "next-translate/useTranslation" import useTranslation from 'next-translate/useTranslation'
import { Form, useForm } from "react-component-form" import { Form } from 'react-component-form'
import type { HandleUseFormCallback } from "react-component-form"
import { useAuthentication } from "../../../tools/authentication" import { useAuthentication } from '../../../tools/authentication'
import { Input } from "../../design/Input" import { HandleSubmitCallback, useForm } from '../../../hooks/useForm'
import { Main } from "../../design/Main" import { Input } from '../../design/Input'
import { Button } from "../../design/Button" import { Main } from '../../design/Main'
import { FormState } from "../../design/FormState" import { Button } from '../../design/Button'
import type { Channel } from "../../../models/Channel" import { FormState } from '../../design/FormState'
import { channelSchema } from "../../../models/Channel" import { Channel, channelSchema } from '../../../models/Channel'
import { useGuildMember } from "../../../contexts/GuildMember" import { useGuildMember } from '../../../contexts/GuildMember'
import { useFormTranslation } from "../../../hooks/useFormTranslation"
const schema = {
name: channelSchema.name,
}
export const CreateChannel: React.FC = () => { export const CreateChannel: React.FC = () => {
const { t } = useTranslation() const { t } = useTranslation()
const router = useRouter() const router = useRouter()
const { guild } = useGuildMember() const { guild } = useGuildMember()
const { handleUseForm, fetchState, message, errors } = useForm(schema) const { fetchState, message, errors, getErrorTranslation, handleSubmit } =
const { getFirstErrorTranslation } = useFormTranslation() useForm({
validateSchema: {
name: channelSchema.name
},
resetOnSuccess: true
})
const { authentication } = useAuthentication() const { authentication } = useAuthentication()
const onSubmit: HandleUseFormCallback<typeof schema> = async (formData) => { const onSubmit: HandleSubmitCallback = async (formData) => {
try { try {
const { data: channel } = await authentication.api.post<Channel>( const { data: channel } = await authentication.api.post<Channel>(
`/guilds/${guild.id}/channels`, `/guilds/${guild.id}/channels`,
formData, formData
) )
await router.push(`/application/${guild.id}/${channel.id}`) await router.push(`/application/${guild.id}/${channel.id}`)
return null return null
} catch (error) { } catch (error) {
return { return {
type: "error", type: 'error',
message: "errors:server-error", value: 'errors:server-error'
} }
} }
} }
return ( return (
<Main> <Main>
<Form className="w-4/6 max-w-xs" onSubmit={handleUseForm(onSubmit)}> <Form className='w-4/6 max-w-xs' onSubmit={handleSubmit(onSubmit)}>
<Input <Input
type="text" type='text'
placeholder={t("common:name")} placeholder={t('common:name')}
name="name" name='name'
label={t("common:name")} label={t('common:name')}
error={getFirstErrorTranslation(errors.name)} error={getErrorTranslation(errors.name)}
data-cy="channel-name-input" data-cy='channel-name-input'
/> />
<Button <Button
className="mt-6 w-full" className='mt-6 w-full'
type="submit" type='submit'
data-cy="button-create-channel" data-cy='button-create-channel'
> >
{t("application:create")} {t('application:create')}
</Button> </Button>
</Form> </Form>
<FormState id="message" state={fetchState} message={message} /> <FormState id='message' state={fetchState} message={message} />
</Main> </Main>
) )
} }

View File

@ -1 +1 @@
export * from "./CreateChannel" export * from './CreateChannel'

View File

@ -1,79 +1,70 @@
import { useRouter } from "next/router" import { useRouter } from 'next/router'
import useTranslation from "next-translate/useTranslation" import useTranslation from 'next-translate/useTranslation'
import { Form, useForm } from "react-component-form" import { Form } from 'react-component-form'
import type { AxiosResponse } from "axios" import { AxiosResponse } from 'axios'
import type { HandleUseFormCallback } from "react-component-form"
import { useAuthentication } from "../../../tools/authentication" import { useAuthentication } from '../../../tools/authentication'
import type { GuildComplete } from "../../../models/Guild" import { HandleSubmitCallback, useForm } from '../../../hooks/useForm'
import { guildSchema } from "../../../models/Guild" import { GuildComplete, guildSchema } from '../../../models/Guild'
import { Input } from "../../design/Input" import { Input } from '../../design/Input'
import { Main } from "../../design/Main" import { Main } from '../../design/Main'
import { Button } from "../../design/Button" import { Button } from '../../design/Button'
import { FormState } from "../../design/FormState" import { FormState } from '../../design/FormState'
import { Textarea } from "../../design/Textarea" import { Textarea } from '../../design/Textarea'
import { useFormTranslation } from "../../../hooks/useFormTranslation"
const schema = {
name: guildSchema.name,
description: guildSchema.description,
}
export const CreateGuild: React.FC = () => { export const CreateGuild: React.FC = () => {
const { t } = useTranslation() const { t } = useTranslation()
const router = useRouter() const router = useRouter()
const { handleUseForm, fetchState, message, errors } = useForm(schema) const { fetchState, message, errors, getErrorTranslation, handleSubmit } =
const { getFirstErrorTranslation } = useFormTranslation() useForm({
validateSchema: {
name: guildSchema.name,
description: guildSchema.description
},
resetOnSuccess: true
})
const { authentication } = useAuthentication() const { authentication } = useAuthentication()
const onSubmit: HandleUseFormCallback<typeof schema> = async (formData) => { const onSubmit: HandleSubmitCallback = async (formData) => {
try { try {
const { data } = await authentication.api.post< const { data } = await authentication.api.post<
any, any,
AxiosResponse<{ guild: GuildComplete }> AxiosResponse<{ guild: GuildComplete }>
>("/guilds", { name: formData.name, description: formData.description }) >('/guilds', { name: formData.name, description: formData.description })
const guildId = data.guild.id const guildId = data.guild.id
const channel = data.guild.channels[0] const channelId = data.guild.channels[0].id
if (channel == null) {
throw new Error("No channel found")
}
const channelId = channel.id
await router.push(`/application/${guildId}/${channelId}`) await router.push(`/application/${guildId}/${channelId}`)
return null return null
} catch (error) { } catch (error) {
return { return {
type: "error", type: 'error',
message: "errors:server-error", value: 'errors:server-error'
} }
} }
} }
return ( return (
<Main> <Main>
<Form className="w-4/6 max-w-xs" onSubmit={handleUseForm(onSubmit)}> <Form className='w-4/6 max-w-xs' onSubmit={handleSubmit(onSubmit)}>
<Input <Input
type="text" type='text'
placeholder={t("common:name")} placeholder={t('common:name')}
name="name" name='name'
label={t("common:name")} label={t('common:name')}
error={getFirstErrorTranslation(errors.name)} error={getErrorTranslation(errors.name)}
/> />
<Textarea <Textarea
label="Description" label='Description'
placeholder="Description" placeholder='Description'
id="description" id='description'
/> />
<Button className="mt-6 w-full" type="submit" data-cy="submit"> <Button className='mt-6 w-full' type='submit' data-cy='submit'>
{t("application:create")} {t('application:create')}
</Button> </Button>
</Form> </Form>
<FormState <FormState id='message' state={fetchState} message={message} />
id="message"
state={fetchState}
message={message != null ? t(message) : undefined}
/>
</Main> </Main>
) )
} }

View File

@ -1 +1 @@
export * from "./CreateGuild" export * from './CreateGuild'

View File

@ -1,11 +1,11 @@
import Link from "next/link" import Link from 'next/link'
import { CogIcon, PlusIcon } from "@heroicons/react/solid" import { CogIcon, PlusIcon } from '@heroicons/react/solid'
import { useGuildMember } from "../../../contexts/GuildMember" import { useGuildMember } from '../../../contexts/GuildMember'
import { Divider } from "../../design/Divider" import { Divider } from '../../design/Divider'
import { Channels } from "../Channels" import { Channels } from '../Channels'
import { IconButton } from "../../design/IconButton" import { IconButton } from '../../design/IconButton'
import type { GuildsChannelsPath } from ".." import { GuildsChannelsPath } from '..'
export interface GuildLeftSidebarProps { export interface GuildLeftSidebarProps {
path: GuildsChannelsPath path: GuildsChannelsPath
@ -17,35 +17,31 @@ export const GuildLeftSidebar: React.FC<GuildLeftSidebarProps> = (props) => {
const { guild, member } = useGuildMember() const { guild, member } = useGuildMember()
return ( return (
<div className="mt-2 flex w-full flex-col justify-between"> <div className='mt-2 flex w-full flex-col justify-between'>
<div className="mx-8 mt-2 p-2 text-center"> <div className='mx-8 mt-2 p-2 text-center'>
<h2 data-cy="guild-left-sidebar-title" className="text-xl"> <h2 data-cy='guild-left-sidebar-title' className='text-xl'>
{guild.name} {guild.name}
</h2> </h2>
</div> </div>
<Divider /> <Divider />
<Channels path={path} /> <Channels path={path} />
<Divider /> <Divider />
<div className="mb-1 flex items-center justify-center space-x-6 p-2"> <div className='mb-1 flex items-center justify-center space-x-6 p-2'>
{member.isOwner && ( {member.isOwner && (
<Link <Link href={`/application/${path.guildId}/channels/create`} passHref>
href={`/application/${path.guildId}/channels/create`} <a data-cy='link-add-channel'>
passHref <IconButton className='h-10 w-10' title='Add a Channel'>
data-cy="link-add-channel"
>
<IconButton className="h-10 w-10" title="Add a Channel">
<PlusIcon /> <PlusIcon />
</IconButton> </IconButton>
</a>
</Link> </Link>
)} )}
<Link <Link href={`/application/${path.guildId}/settings`} passHref>
href={`/application/${path.guildId}/settings`} <a data-cy='link-settings-guild'>
passHref <IconButton className='h-7 w-7' title='Settings'>
data-cy="link-settings-guild"
>
<IconButton className="h-7 w-7" title="Settings">
<CogIcon /> <CogIcon />
</IconButton> </IconButton>
</a>
</Link> </Link>
</div> </div>
</div> </div>

View File

@ -1 +1 @@
export * from "./GuildLeftSidebar" export * from './GuildLeftSidebar'

View File

@ -1,27 +1,19 @@
import Image from "next/image" import Image from 'next/image'
import { useRouter } from "next/router" import { useRouter } from 'next/router'
import { useState } from "react" import { useState } from 'react'
import { Type } from "@sinclair/typebox" import { Type } from '@sinclair/typebox'
import { PhotographIcon } from "@heroicons/react/solid" import { PhotographIcon } from '@heroicons/react/solid'
import { Form, useForm } from "react-component-form" import { Form } from 'react-component-form'
import useTranslation from "next-translate/useTranslation" import useTranslation from 'next-translate/useTranslation'
import classNames from "clsx"
import type { HandleUseFormCallback } from "react-component-form"
import { guildSchema } from "../../../models/Guild" import { HandleSubmitCallback, useForm } from '../../../hooks/useForm'
import { FormState } from "../../design/FormState" import { guildSchema } from '../../../models/Guild'
import { useGuildMember } from "../../../contexts/GuildMember" import { FormState } from '../../design/FormState'
import { Textarea } from "../../design/Textarea" import { useGuildMember } from '../../../contexts/GuildMember'
import { Input } from "../../design/Input" import { Textarea } from '../../design/Textarea'
import { Button } from "../../design/Button" import { Input } from '../../design/Input'
import { useAuthentication } from "../../../tools/authentication" import { Button } from '../../design/Button'
import { ConfirmPopup } from "../ConfirmPopup" import { useAuthentication } from '../../../tools/authentication'
import { useFormTranslation } from "../../../hooks/useFormTranslation"
const schema = {
name: guildSchema.name,
description: Type.Optional(guildSchema.description),
}
export const GuildSettings: React.FC = () => { export const GuildSettings: React.FC = () => {
const { t } = useTranslation() const { t } = useTranslation()
@ -31,37 +23,38 @@ export const GuildSettings: React.FC = () => {
const [inputValues, setInputValues] = useState({ const [inputValues, setInputValues] = useState({
name: guild.name, name: guild.name,
description: guild.description, description: guild.description
}) })
const [confirmation, setConfirmation] = useState(false)
const handleConfirmation = (): void => {
return setConfirmation(!confirmation)
}
const { const {
handleUseForm,
fetchState, fetchState,
message, message,
errors, errors,
getErrorTranslation,
handleSubmit,
setFetchState, setFetchState,
setMessage, setMessageTranslationKey
} = useForm(schema) } = useForm({
const { getFirstErrorTranslation } = useFormTranslation() validateSchema: {
name: guildSchema.name,
description: Type.Optional(guildSchema.description)
},
replaceEmptyStringToNull: true,
resetOnSuccess: false
})
const onSubmit: HandleUseFormCallback<typeof schema> = async (formData) => { const onSubmit: HandleSubmitCallback = async (formData) => {
try { try {
await authentication.api.put(`/guilds/${guild.id}`, formData) await authentication.api.put(`/guilds/${guild.id}`, formData)
setInputValues(formData as unknown as any) setInputValues(formData as any)
return { return {
type: "success", type: 'success',
message: "application:saved-information", value: 'application:saved-information'
} }
} catch (error) { } catch (error) {
return { return {
type: "error", type: 'error',
message: "errors:server-error", value: 'errors:server-error'
} }
} }
} }
@ -72,30 +65,26 @@ export const GuildSettings: React.FC = () => {
setInputValues((oldInputValues) => { setInputValues((oldInputValues) => {
return { return {
...oldInputValues, ...oldInputValues,
[event.target.name]: event.target.value, [event.target.name]: event.target.value
} }
}) })
} }
const handleFileChange: React.ChangeEventHandler<HTMLInputElement> = async ( const handleFileChange: React.ChangeEventHandler<HTMLInputElement> = async (
event, event
) => { ) => {
setFetchState("loading") setFetchState('loading')
const files = event?.target?.files const files = event?.target?.files
if (files != null && files.length === 1 && files[0] != null) { if (files != null && files.length === 1) {
const file = files[0] const file = files[0]
const formData = new FormData() const formData = new FormData()
formData.append("icon", file) formData.append('icon', file)
try { try {
await authentication.api.put(`/guilds/${guild.id}/icon`, formData, { await authentication.api.put(`/guilds/${guild.id}/icon`, formData)
headers: { setFetchState('idle')
"Content-Type": "multipart/form-data",
},
})
setFetchState("idle")
} catch (error) { } catch (error) {
setFetchState("error") setFetchState('error')
setMessage("errors:server-error") setMessageTranslationKey('errors:server-error')
} }
} }
} }
@ -104,132 +93,111 @@ export const GuildSettings: React.FC = () => {
try { try {
await authentication.api.delete(`/guilds/${guild.id}`) await authentication.api.delete(`/guilds/${guild.id}`)
} catch (error) { } catch (error) {
setFetchState("error") setFetchState('error')
setMessage("errors:server-error") setMessageTranslationKey('errors:server-error')
} }
} }
const handleLeave = async (): Promise<void> => { const handleLeave = async (): Promise<void> => {
try { try {
await authentication.api.delete(`/guilds/${guild.id}/members/leave`) await authentication.api.delete(`/guilds/${guild.id}/members/leave`)
await router.push("/application") await router.push('/application')
} catch (error) { } catch (error) {
setFetchState("error") setFetchState('error')
setMessage("errors:server-error") setMessageTranslationKey('errors:server-error')
} }
} }
return ( return (
<>
<Form <Form
onSubmit={handleUseForm(onSubmit)} onSubmit={handleSubmit(onSubmit)}
className="my-auto flex flex-col items-center justify-center py-12" className='my-auto flex flex-col items-center justify-center py-12'
> >
{member.isOwner && ( {member.isOwner && (
<div className="flex w-full flex-col items-center justify-center sm:w-fit lg:flex-row"> <div className='flex w-full flex-col items-center justify-center sm:w-fit lg:flex-row'>
<div className=" flex w-full flex-wrap items-center justify-center px-6 sm:w-max"> <div className=' flex w-full flex-wrap items-center justify-center px-6 sm:w-max'>
<div className="relative"> <div className='relative'>
<div className="absolute z-50 h-full w-full"> <div className='absolute z-50 h-full w-full'>
<button className="relative flex h-full w-full items-center justify-center transition hover:scale-110"> <button className='relative flex h-full w-full items-center justify-center transition hover:scale-110'>
<input <input
type="file" type='file'
className="absolute h-full w-full cursor-pointer opacity-0" className='absolute h-full w-full cursor-pointer opacity-0'
onChange={handleFileChange} onChange={handleFileChange}
/> />
<PhotographIcon color="white" className="h-8 w-8" /> <PhotographIcon color='white' className='h-8 w-8' />
</button> </button>
</div> </div>
<div className="flex items-center justify-center rounded-full bg-black shadow-xl"> <div className='flex items-center justify-center rounded-full bg-black shadow-xl'>
<Image <Image
quality={100} quality={100}
className="rounded-full opacity-50" className='rounded-full opacity-50'
src={ src={
guild.icon == null guild.icon == null
? "/images/data/guild-default.png" ? '/images/data/guild-default.png'
: guild.icon : guild.icon
} }
alt="Profil Picture" alt='Profil Picture'
draggable="false" draggable='false'
height={125} height={125}
width={125} width={125}
/> />
</div> </div>
</div> </div>
<div className="mx-12 flex flex-col"> <div className='mx-12 flex flex-col'>
<Input <Input
name="name" name='name'
label={t("common:name")} label={t('common:name')}
placeholder={t("common:name")} placeholder={t('common:name')}
className="!mt-0" className='!mt-0'
onChange={onChange} onChange={onChange}
value={inputValues.name} value={inputValues.name}
error={getFirstErrorTranslation(errors.name)} error={getErrorTranslation(errors.name)}
data-cy="guild-name-input" data-cy='guild-name-input'
/> />
<Textarea <Textarea
name="description" name='description'
label={"Description"} label={'Description'}
placeholder={"Description"} placeholder={'Description'}
id="textarea-description" id='textarea-description'
onChange={onChange} onChange={onChange}
value={inputValues.description ?? ""} value={inputValues.description ?? ''}
data-cy="guild-description-input" data-cy='guild-description-input'
/> />
</div> </div>
</div> </div>
</div> </div>
)} )}
<div className="mt-12 flex flex-col items-center justify-center sm:w-fit"> <div className='mt-12 flex flex-col items-center justify-center sm:w-fit'>
<div className="space-x-6"> <div className='space-x-6'>
{member.isOwner ? ( {member.isOwner ? (
<> <>
<Button type="submit" data-cy="button-save-guild-settings"> <Button type='submit' data-cy='button-save-guild-settings'>
{t("application:save")} {t('application:save')}
</Button> </Button>
<Button <Button
type="button" type='button'
color="red" color='red'
onClick={handleConfirmation} onClick={handleDelete}
data-cy="button-delete-guild-settings" data-cy='button-delete-guild-settings'
> >
{t("application:delete")} {t('application:delete')}
</Button> </Button>
</> </>
) : ( ) : (
<Button <Button
color="red" color='red'
onClick={handleLeave} onClick={handleLeave}
data-cy="button-leave-guild-settings" data-cy='button-leave-guild-settings'
> >
{t("application:leave")} {guild.name} {t('application:leave')} {guild.name}
</Button> </Button>
)} )}
</div> </div>
<FormState <FormState
state={fetchState} state={fetchState}
message={ message={getErrorTranslation(errors.description) ?? message}
message != null
? t(message)
: getFirstErrorTranslation(errors.name)
}
/> />
</div> </div>
</Form> </Form>
<div
className={classNames(
"pointer-events-none invisible absolute z-50 flex h-full w-full items-center justify-center bg-black bg-opacity-90 opacity-0 backdrop-blur-md transition-all",
{ "pointer-events-auto !visible !opacity-100": confirmation },
)}
>
<ConfirmPopup
className={classNames("relative top-8 transition-all", {
"!top-0": confirmation,
})}
handleYes={handleDelete}
handleNo={handleConfirmation}
title={`${t("application:delete-the-guild")} ?`}
/>
</div>
</>
) )
} }

View File

@ -1 +1 @@
export * from "./GuildSettings" export * from './GuildSettings'

View File

@ -1,39 +0,0 @@
import { memo } from "react"
import Image from "next/image"
import type { GuildWithDefaultChannelId } from "../../../models/Guild"
import { IconLink } from "../../design/IconLink"
export interface GuildProps {
guild: GuildWithDefaultChannelId
selected?: boolean
}
const GuildMemo: React.FC<GuildProps> = (props) => {
const { guild, selected } = props
return (
<IconLink
className="mt-2"
href={`/application/${guild.id}/${guild.defaultChannelId}`}
selected={selected}
title={guild.name}
>
<div className="pl-[6px]">
<Image
quality={100}
className="rounded-full"
src={
guild.icon != null ? guild.icon : "/images/data/guild-default.png"
}
alt="logo"
width={48}
height={48}
draggable={false}
/>
</div>
</IconLink>
)
}
export const Guild = memo(GuildMemo)

View File

@ -0,0 +1,18 @@
import { render } from '@testing-library/react'
import { Guild } from './Guild'
import { guildExample } from '../../../../cypress/fixtures/guilds/guild'
describe('<Guild />', () => {
it('should render successfully', () => {
const { baseElement } = render(
<Guild
guild={{
...guildExample,
defaultChannelId: 1
}}
/>
)
expect(baseElement).toBeTruthy()
})
})

View File

@ -0,0 +1,36 @@
import Image from 'next/image'
import { GuildWithDefaultChannelId } from '../../../../models/Guild'
import { IconLink } from '../../../design/IconLink'
export interface GuildProps {
guild: GuildWithDefaultChannelId
selected?: boolean
}
export const Guild: React.FC<GuildProps> = (props) => {
const { guild, selected } = props
return (
<IconLink
className='mt-2'
href={`/application/${guild.id}/${guild.defaultChannelId}`}
selected={selected}
title={guild.name}
>
<div className='pl-[6px]'>
<Image
quality={100}
className='rounded-full'
src={
guild.icon != null ? guild.icon : '/images/data/guild-default.png'
}
alt='logo'
width={48}
height={48}
draggable={false}
/>
</div>
</IconLink>
)
}

View File

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

View File

@ -1,9 +1,9 @@
import InfiniteScroll from "react-infinite-scroll-component" import InfiniteScroll from 'react-infinite-scroll-component'
import { Loader } from "../../design/Loader" import { Loader } from '../../design/Loader'
import { useGuilds } from "../../../contexts/Guilds" import { useGuilds } from '../../../contexts/Guilds'
import type { GuildsPath } from ".." import { GuildsPath } from '..'
import { Guild } from "./Guild" import { Guild } from './Guild'
export interface GuildsProps { export interface GuildsProps {
path: GuildsPath | string path: GuildsPath | string
@ -16,19 +16,19 @@ export const Guilds: React.FC<GuildsProps> = (props) => {
return ( return (
<div <div
id="guilds-list" id='guilds-list'
className="border-r-1 scrollbar-firefox-support mt-[130px] h-full min-w-[92px] space-y-2 overflow-y-auto border-gray-500 pt-2 dark:border-white/20" className='border-r-1 scrollbar-firefox-support mt-[130px] h-full min-w-[92px] space-y-2 overflow-y-auto border-gray-500 pt-2 dark:border-white/20'
> >
<InfiniteScroll <InfiniteScroll
className="guilds-list" className='guilds-list'
dataLength={guilds.length} dataLength={guilds.length}
next={nextPage} next={nextPage}
hasMore={hasMore} hasMore={hasMore}
scrollableTarget="guilds-list" scrollableTarget='guilds-list'
loader={<Loader />} loader={<Loader />}
> >
{guilds.map((guild) => { {guilds.map((guild) => {
const selected = typeof path !== "string" && path.guildId === guild.id const selected = typeof path !== 'string' && path.guildId === guild.id
return <Guild key={guild.id} guild={guild} selected={selected} /> return <Guild key={guild.id} guild={guild} selected={selected} />
})} })}
</InfiniteScroll> </InfiniteScroll>

View File

@ -1 +1 @@
export * from "./Guilds" export * from './Guilds'

View File

@ -1,109 +0,0 @@
import Image from "next/image"
import { useRouter } from "next/router"
import { useState } from "react"
import useTranslation from "next-translate/useTranslation"
import classNames from "clsx"
import axios from "axios"
import { Emoji } from "../../Emoji"
import { ConfirmPopup } from "../ConfirmPopup"
import type {
GuildPublic as GuildPublicType,
GuildWithDefaultChannelId,
} from "../../../models/Guild"
import { useAuthentication } from "../../../tools/authentication"
export interface GuildPublicProps {
guild: GuildPublicType
}
export const GuildPublic: React.FC<GuildPublicProps> = (props) => {
const { guild } = props
const router = useRouter()
const { authentication } = useAuthentication()
const [isConfirmed, setIsConfirmed] = useState(false)
const { t } = useTranslation()
const handleIsConfirmed = (): void => {
setIsConfirmed((isConfirmed) => {
return !isConfirmed
})
}
const handleYes = async (): Promise<void> => {
try {
const { data } = await authentication.api.post<{
guild: GuildWithDefaultChannelId
}>(`/guilds/${guild.id}/members/join`)
await router.push(
`/application/${guild.id}/${data.guild.defaultChannelId}`,
)
} catch (error) {
if (
axios.isAxiosError(error) &&
error.response?.status === 400 &&
typeof error?.response?.data.defaultChannelId === "number"
) {
const defaultChannelId = error.response.data.defaultChannelId as number
await router.push(`/application/${guild.id}/${defaultChannelId}`)
} else {
await router.push("/application")
}
}
}
return (
<div className="relative h-80 overflow-hidden rounded border border-gray-500 shadow-lg transition duration-200 ease-in-out hover:-translate-y-2 hover:shadow-none dark:border-gray-700">
<div
className={classNames(
"flex h-full cursor-pointer flex-col items-center justify-center p-4 pt-8 transition duration-200 ease-in-out",
{ "-translate-x-full": isConfirmed },
)}
onClick={handleIsConfirmed}
>
<Image
quality={100}
className="rounded-full"
src={
guild.icon != null ? guild.icon : "/images/data/guild-default.png"
}
alt="logo"
width={80}
height={80}
/>
<div className="m-2 mt-6 w-full px-4 text-center">
<h3
data-cy="guild-name"
className="center mb-2 w-full truncate text-xl font-bold"
>
{guild.name}
</h3>
<p className="break-words">
{guild.description != null ? (
guild.description
) : (
<span className="flex h-full items-center justify-center opacity-40 dark:opacity-20">
<Emoji value=":eyes:" size={25} />
<span className="ml-2">{t("application:nothing-here")}</span>
</span>
)}
</p>
</div>
<p className="mt-auto flex flex-col text-green-800 dark:text-green-400">
{guild.membersCount} {t("application:members")}
</p>
</div>
<ConfirmPopup
title={`${t("application:join-the-guild")} ?`}
className={classNames(
"w-ful h-ful translate-x- absolute left-full top-1/2 flex h-full w-full -translate-y-1/2 flex-col items-center justify-center rounded-2xl transition-all",
{
"!left-0": isConfirmed,
},
)}
handleYes={handleYes}
handleNo={handleIsConfirmed}
/>
</div>
)
}

View File

@ -0,0 +1,106 @@
import Image from 'next/image'
import { useRouter } from 'next/router'
import { useState } from 'react'
import useTranslation from 'next-translate/useTranslation'
import classNames from 'classnames'
import axios from 'axios'
import { Emoji } from '../../../Emoji'
import { ConfirmGuildJoin } from '../../ConfirmGuildJoin'
import {
GuildPublic as GuildPublicType,
GuildWithDefaultChannelId
} from '../../../../models/Guild'
import { useAuthentication } from '../../../../tools/authentication'
export interface GuildPublicProps {
guild: GuildPublicType
}
export const GuildPublic: React.FC<GuildPublicProps> = (props) => {
const { guild } = props
const router = useRouter()
const { authentication } = useAuthentication()
const [isConfirmed, setIsConfirmed] = useState(false)
const { t } = useTranslation()
const handleIsConfirmed = (): void => {
setIsConfirmed((isConfirmed) => !isConfirmed)
}
const handleYes = async (): Promise<void> => {
try {
const { data } = await authentication.api.post<{
guild: GuildWithDefaultChannelId
}>(`/guilds/${guild.id}/members/join`)
await router.push(
`/application/${guild.id}/${data.guild.defaultChannelId}`
)
} catch (error) {
if (
axios.isAxiosError(error) &&
error.response?.status === 400 &&
typeof error.response?.data.defaultChannelId === 'number'
) {
const defaultChannelId = error.response.data.defaultChannelId as number
await router.push(`/application/${guild.id}/${defaultChannelId}`)
} else {
await router.push('/application')
}
}
}
return (
<div className='relative h-80 overflow-hidden rounded border border-gray-500 shadow-lg transition duration-200 ease-in-out hover:-translate-y-2 hover:shadow-none dark:border-gray-700'>
<div
className={classNames(
'flex h-full cursor-pointer flex-col items-center justify-center p-4 pt-8 transition duration-200 ease-in-out',
{ '-translate-x-full': isConfirmed }
)}
onClick={handleIsConfirmed}
>
<Image
quality={100}
className='rounded-full'
src={
guild.icon != null ? guild.icon : '/images/data/guild-default.png'
}
alt='logo'
width={80}
height={80}
/>
<div className='m-2 mt-6 w-full px-4 text-center'>
<h3
data-cy='guild-name'
className='center mb-2 w-full truncate text-xl font-bold'
>
{guild.name}
</h3>
<p className='break-words'>
{guild.description != null ? (
guild.description
) : (
<span className='flex h-full items-center justify-center opacity-40 dark:opacity-20'>
<Emoji value=':eyes:' size={25} />
<span className='ml-2'>{t('application:nothing-here')}</span>
</span>
)}
</p>
</div>
<p className='mt-auto flex flex-col text-green-800 dark:text-green-400'>
{guild.membersCount} {t('application:members')}
</p>
</div>
<ConfirmGuildJoin
className={classNames(
'w-ful h-ful translate-x- absolute top-1/2 left-full flex h-full w-full -translate-y-1/2 flex-col items-center justify-center rounded-2xl transition-all',
{
'!left-0': isConfirmed
}
)}
handleYes={handleYes}
handleNo={handleIsConfirmed}
/>
</div>
)
}

View File

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

View File

@ -1,17 +1,16 @@
import useTranslation from "next-translate/useTranslation" import useTranslation from 'next-translate/useTranslation'
import { useEffect, useState } from "react" import { useEffect, useState } from 'react'
import InfiniteScroll from "react-infinite-scroll-component" import InfiniteScroll from 'react-infinite-scroll-component'
import { useAuthentication } from "../../../tools/authentication" import { useAuthentication } from '../../../tools/authentication'
import type { GuildPublic as GuildPublicType } from "../../../models/Guild" import { GuildPublic as GuildPublicType } from '../../../models/Guild'
import { Loader } from "../../design/Loader" import { Loader } from '../../design/Loader'
import { GuildPublic } from "./GuildPublic" import { GuildPublic } from './GuildPublic'
import { usePagination } from "../../../hooks/usePagination" import { usePagination } from '../../../hooks/usePagination'
import type { SocketData } from "../../../tools/handleSocketData" import { SocketData, handleSocketData } from '../../../tools/handleSocketData'
import { handleSocketData } from "../../../tools/handleSocketData"
export const JoinGuildsPublic: React.FC = () => { export const JoinGuildsPublic: React.FC = () => {
const [search, setSearch] = useState("") const [search, setSearch] = useState('')
const { authentication } = useAuthentication() const { authentication } = useAuthentication()
const { t } = useTranslation() const { t } = useTranslation()
@ -19,19 +18,16 @@ export const JoinGuildsPublic: React.FC = () => {
const { items, hasMore, nextPage, resetPagination, setItems } = const { items, hasMore, nextPage, resetPagination, setItems } =
usePagination<GuildPublicType>({ usePagination<GuildPublicType>({
api: authentication.api, api: authentication.api,
url: "/guilds/public", url: '/guilds/public'
}) })
useEffect(() => { useEffect(() => {
authentication?.socket?.on( authentication.socket.on('guilds', (data: SocketData<GuildPublicType>) => {
"guilds",
(data: SocketData<GuildPublicType>) => {
handleSocketData({ data, setItems }) handleSocketData({ data, setItems })
}, })
)
return () => { return () => {
authentication?.socket?.off("guilds") authentication.socket.off('guilds')
} }
}, [authentication.socket, setItems]) }, [authentication.socket, setItems])
@ -45,23 +41,23 @@ export const JoinGuildsPublic: React.FC = () => {
} }
return ( return (
<div className="flex h-full w-full flex-col transition-all"> <div className='flex h-full w-full flex-col transition-all'>
<input <input
data-cy="search-guild-input" data-cy='search-guild-input'
onChange={handleChange} onChange={handleChange}
className="mx-auto my-6 mt-16 w-10/12 rounded-md border border-gray-500 bg-white p-3 dark:border-gray-700 dark:bg-[#3B3B3B] sm:w-8/12 md:w-6/12 lg:w-5/12" className='my-6 mx-auto mt-16 w-10/12 rounded-md border border-gray-500 bg-white p-3 dark:border-gray-700 dark:bg-[#3B3B3B] sm:w-8/12 md:w-6/12 lg:w-5/12'
type="search" type='search'
name="search-guild" name='search-guild'
placeholder={`🔎 ${t("application:search")}...`} placeholder={`🔎 ${t('application:search')}...`}
/> />
<div className="w-full p-12"> <div className='w-full p-12'>
<InfiniteScroll <InfiniteScroll
className="guilds-public-list relative mx-auto grid max-w-[1400px] grid-cols-[repeat(auto-fill,_minmax(20em,_1fr))] gap-8 !overflow-visible" className='guilds-public-list relative mx-auto grid max-w-[1400px] grid-cols-[repeat(auto-fill,_minmax(20em,_1fr))] gap-8 !overflow-visible'
dataLength={items.length} dataLength={items.length}
next={nextPage} next={nextPage}
scrollableTarget="application-page-content" scrollableTarget='application-page-content'
hasMore={hasMore} hasMore={hasMore}
loader={<Loader className="absolute left-1/2 -translate-x-1/2" />} loader={<Loader className='absolute left-1/2 -translate-x-1/2' />}
> >
{items.map((guild) => { {items.map((guild) => {
return <GuildPublic guild={guild} key={guild.id} /> return <GuildPublic guild={guild} key={guild.id} />

View File

@ -1 +1 @@
export * from "./JoinGuildsPublic" export * from './JoinGuildsPublic'

View File

@ -1,48 +0,0 @@
import { memo } from "react"
import Image from "next/image"
import Link from "next/link"
import type { MemberWithPublicUser } from "../../../models/Member"
import { Emoji } from "../../Emoji"
export interface MemberProps {
member: MemberWithPublicUser
}
const MemberMemo: React.FC<MemberProps> = (props) => {
const { member } = props
return (
<Link href={`/application/users/${member.user.id}`}>
<div className="flex cursor-pointer items-center overflow-hidden px-6 py-2 pr-10 hover:bg-gray-300 dark:hover:bg-gray-900">
<div className="flex min-w-[50px] rounded-full">
<Image
src={
member.user.logo == null
? "/images/data/user-default.png"
: member.user.logo
}
alt={"Users's profil picture"}
height={50}
width={50}
draggable={false}
className="rounded-full"
/>
</div>
<div className="ml-5">
<p data-cy="member-user-name" className="flex truncate font-bold">
{member.user.name}
{member.isOwner && (
<span className="ml-4">
<Emoji value=":crown:" size={18} />
</span>
)}
</p>
{member.user.status != null && member.user.status}
</div>
</div>
</Link>
)
}
export const Member = memo(MemberMemo)

View File

@ -0,0 +1,11 @@
import { render } from '@testing-library/react'
import { Member } from './Member'
import { memberExampleComplete } from '../../../../cypress/fixtures/members/member'
describe('<Member />', () => {
it('should render successfully', () => {
const { baseElement } = render(<Member member={memberExampleComplete} />)
expect(baseElement).toBeTruthy()
})
})

View File

@ -0,0 +1,41 @@
import Image from 'next/image'
import Link from 'next/link'
import { MemberWithPublicUser } from '../../../../models/Member'
export interface MemberProps {
member: MemberWithPublicUser
}
export const Member: React.FC<MemberProps> = (props) => {
const { member } = props
return (
<Link href={`/application/users/${member.user.id}`}>
<a>
<div className='flex cursor-pointer items-center overflow-hidden py-2 px-6 pr-10 hover:bg-gray-300 dark:hover:bg-gray-900'>
<div className='flex min-w-[50px] rounded-full'>
<Image
src={
member.user.logo == null
? '/images/data/user-default.png'
: member.user.logo
}
alt={"Users's profil picture"}
height={50}
width={50}
draggable={false}
className='rounded-full'
/>
</div>
<div className='ml-5'>
<p data-cy='member-user-name' className='truncate font-bold'>
{member.user.name}
</p>
{member.user.status != null && member.user.status}
</div>
</div>
</a>
</Link>
)
}

View File

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

View File

@ -1,11 +1,11 @@
import useTranslation from "next-translate/useTranslation" import useTranslation from 'next-translate/useTranslation'
import InfiniteScroll from "react-infinite-scroll-component" import InfiniteScroll from 'react-infinite-scroll-component'
import { Divider } from "../../design/Divider" import { Divider } from '../../design/Divider'
import { Loader } from "../../design/Loader" import { Loader } from '../../design/Loader'
import { useMembers } from "../../../contexts/Members" import { useMembers } from '../../../contexts/Members'
import { Member } from "./Member" import { Member } from './Member'
import { capitalize } from "../../../tools/utils/capitalize" import { capitalize } from '../../../tools/utils/capitalize'
export const Members: React.FC = () => { export const Members: React.FC = () => {
const { members, hasMore, nextPage } = useMembers() const { members, hasMore, nextPage } = useMembers()
@ -14,14 +14,14 @@ export const Members: React.FC = () => {
return ( return (
<> <>
<div className="mb-2"> <div className='mb-2'>
<h1 data-cy="members-title" className="my-2 pt-2 text-center text-xl"> <h1 data-cy='members-title' className='my-2 pt-2 text-center text-xl'>
{capitalize(t("application:members"))} {capitalize(t('application:members'))}
</h1> </h1>
<Divider /> <Divider />
</div> </div>
<InfiniteScroll <InfiniteScroll
className="members-list" className='members-list'
dataLength={members.length} dataLength={members.length}
next={nextPage} next={nextPage}
hasMore={hasMore} hasMore={hasMore}

View File

@ -1 +1 @@
export * from "./Members" export * from './Members'

View File

@ -1,48 +0,0 @@
import useTranslation from "next-translate/useTranslation"
import TextareaAutosize from "react-textarea-autosize"
import type { MessageProps } from "../Message"
export interface EditMessageProps extends MessageProps {
handleEdit: () => Promise<void>
handleKeyDown: React.KeyboardEventHandler<HTMLFormElement>
textareaRef: React.RefObject<HTMLTextAreaElement>
}
export const EditMessage: React.FC<
React.PropsWithChildren<EditMessageProps>
> = ({ handleEdit, handleKeyDown, textareaRef, message }) => {
const { t } = useTranslation()
const handleEditSubmit: React.FormEventHandler<HTMLFormElement> = async (
event,
) => {
event.preventDefault()
await handleEdit()
}
return (
<form
className="flex h-full w-full items-center"
onSubmit={handleEditSubmit}
onKeyDown={handleKeyDown}
>
<TextareaAutosize
className="scrollbar-firefox-support w-full resize-none bg-transparent p-2 tracking-wide outline-none"
placeholder={t("application:write-a-message")}
wrap="soft"
maxRows={6}
name="message"
defaultValue={message.value}
ref={textareaRef}
autoFocus
onFocus={(event) => {
event.currentTarget.setSelectionRange(
event.currentTarget.value.length,
event.currentTarget.value.length,
)
}}
/>
</form>
)
}

View File

@ -1 +0,0 @@
export * from "./EditMessage"

View File

@ -1,15 +1,11 @@
import { useState, useRef } from "react" import Image from 'next/image'
import Image from "next/image" import Link from 'next/link'
import Link from "next/link" import date from 'date-and-time'
import date from "date-and-time"
import type { MessageWithMember } from "../../../../models/Message" import { MessageWithMember } from '../../../../models/Message'
import { MessageText } from "./MessageText" import { MessageText } from './MessageText'
import { Loader } from "../../../design/Loader" import { Loader } from '../../../design/Loader'
import { MessageFile } from "./MessageFile" import { MessageFile } from './MessageFile'
import { useAuthentication } from "../../../../tools/authentication"
import { MessageOptions } from "./MessageOptions"
import { EditMessage } from "./EditMessage"
export interface MessageProps { export interface MessageProps {
message: MessageWithMember message: MessageWithMember
@ -18,57 +14,21 @@ export interface MessageProps {
export const Message: React.FC<MessageProps> = (props) => { export const Message: React.FC<MessageProps> = (props) => {
const { message } = props const { message } = props
const textareaReference = useRef<HTMLTextAreaElement>(null)
const [isEditing, setIsEditing] = useState(false)
const { authentication, user } = useAuthentication()
const handleTextareaKeyDown: React.KeyboardEventHandler<HTMLFormElement> = (
event,
) => {
if (event.key === "Enter" && !event.shiftKey) {
event.preventDefault()
event.currentTarget.dispatchEvent(
new Event("submit", { cancelable: true, bubbles: true }),
)
}
}
const handleEdit = async (): Promise<void> => {
const newMessage = textareaReference.current?.value ?? message.value
if (
typeof newMessage === "string" &&
newMessage.length > 0 &&
newMessage !== message.value
) {
try {
await authentication.api.put(`/messages/${message.id}`, {
value: newMessage,
})
} catch {}
}
handleEditMode()
}
const handleEditMode = (): void => {
setIsEditing((oldIsEditing) => {
return !oldIsEditing
})
}
return ( return (
<div <div
className='flex p-4 transition hover:bg-gray-200 dark:hover:bg-gray-900'
data-cy={`message-${message.id}`} data-cy={`message-${message.id}`}
className="group flex w-full p-4 transition hover:bg-gray-200 dark:hover:bg-gray-900"
> >
<Link href={`/application/users/${message.member.user.id}`}> <Link href={`/application/users/${message.member.user.id}`}>
<div className="mr-4 flex h-12 w-12 flex-shrink-0 items-center justify-center"> <a>
<div className="h-10 w-10 drop-shadow-md"> <div className='mr-4 flex h-12 w-12 flex-shrink-0 items-center justify-center'>
<div className='h-10 w-10 drop-shadow-md'>
<Image <Image
quality={100} quality={100}
className="rounded-full" className='rounded-full'
src={ src={
message.member.user.logo == null message.member.user.logo == null
? "/images/data/user-default.png" ? '/images/data/user-default.png'
: message.member.user.logo : message.member.user.logo
} }
alt={"Users's profil picture"} alt={"Users's profil picture"}
@ -78,47 +38,30 @@ export const Message: React.FC<MessageProps> = (props) => {
/> />
</div> </div>
</div> </div>
</a>
</Link> </Link>
<div className="relative w-full whitespace-pre-wrap break-words break-all"> <div className='w-full'>
<div className="flex w-max items-center"> <div className='flex w-max items-center'>
<Link href={`/application/users/${message.member.user.id}`}> <Link href={`/application/users/${message.member.user.id}`}>
<a>
<span <span
data-cy="message-member-user-name" data-cy='message-member-user-name'
className="font-bold text-gray-900 dark:text-gray-200" className='font-bold text-gray-900 dark:text-gray-200'
> >
{message.member.user.name} {message.member.user.name}
</span> </span>
</a>
</Link> </Link>
<span <span
data-cy="message-date" data-cy='message-date'
className="ml-4 select-none text-xs text-gray-500 dark:text-gray-200" className='ml-4 select-none text-xs text-gray-500 dark:text-gray-200'
> >
{date.format(new Date(message.createdAt), "DD/MM/YYYY - HH:mm:ss")} {date.format(new Date(message.createdAt), 'DD/MM/YYYY - HH:mm:ss')}
</span> </span>
</div> </div>
{message.type === 'text' ? (
{message.member.userId === user.id && (
<MessageOptions
message={message}
editMode={isEditing ? ":white_check_mark:" : ":pencil2:"}
handleEdit={isEditing ? handleEdit : handleEditMode}
/>
)}
{message.type === "text" ? (
<>
{isEditing ? (
<EditMessage
message={message}
textareaRef={textareaReference}
handleEdit={handleEdit}
handleKeyDown={handleTextareaKeyDown}
/>
) : (
<MessageText message={message} /> <MessageText message={message} />
)} ) : message.type === 'file' ? (
</>
) : message.type === "file" ? (
<MessageFile message={message} /> <MessageFile message={message} />
) : ( ) : (
<Loader /> <Loader />

View File

@ -1,14 +1,14 @@
export const FileIcon: React.FC = () => { export const FileIcon: React.FC = () => {
return ( return (
<svg <svg
className="fill-current text-black dark:text-white" className='fill-current text-black dark:text-white'
width="21" width='21'
height="26" height='26'
viewBox="0 0 21 26" viewBox='0 0 21 26'
fill="none" fill='none'
xmlns="http://www.w3.org/2000/svg" xmlns='http://www.w3.org/2000/svg'
> >
<path d="M2.625 0C1.92881 0 1.26113 0.273928 0.768845 0.761522C0.276562 1.24912 0 1.91044 0 2.6V23.4C0 24.0896 0.276562 24.7509 0.768845 25.2385C1.26113 25.7261 1.92881 26 2.625 26H18.375C19.0712 26 19.7389 25.7261 20.2312 25.2385C20.7234 24.7509 21 24.0896 21 23.4V7.8L13.125 0H2.625ZM13.125 9.1H11.8125V2.6L18.375 9.1H13.125Z" /> <path d='M2.625 0C1.92881 0 1.26113 0.273928 0.768845 0.761522C0.276562 1.24912 0 1.91044 0 2.6V23.4C0 24.0896 0.276562 24.7509 0.768845 25.2385C1.26113 25.7261 1.92881 26 2.625 26H18.375C19.0712 26 19.7389 25.7261 20.2312 25.2385C20.7234 24.7509 21 24.0896 21 23.4V7.8L13.125 0H2.625ZM13.125 9.1H11.8125V2.6L18.375 9.1H13.125Z' />
</svg> </svg>
) )
} }

View File

@ -1,18 +1,18 @@
import { useState, useEffect } from "react" import { useState, useEffect } from 'react'
import axios from "axios" import axios from 'axios'
import prettyBytes from "pretty-bytes" import prettyBytes from 'pretty-bytes'
import { DownloadIcon } from "@heroicons/react/solid" import { DownloadIcon } from '@heroicons/react/solid'
import type { MessageWithMember } from "../../../../../models/Message" import { MessageWithMember } from '../../../../../models/Message'
import { Loader } from "../../../../design/Loader" import { Loader } from '../../../../design/Loader'
import { FileIcon } from "./FileIcon" import { FileIcon } from './FileIcon'
import { api } from "../../../../../tools/api" import { api } from '../../../../../tools/api'
const supportedImageMimetype = [ const supportedImageMimetype = [
"image/png", 'image/png',
"image/jpg", 'image/jpg',
"image/jpeg", 'image/jpeg',
"image/gif", 'image/gif'
] ]
export interface FileData { export interface FileData {
@ -34,8 +34,8 @@ export const MessageFile: React.FC<MessageContentProps> = (props) => {
const fetchData = async (): Promise<void> => { const fetchData = async (): Promise<void> => {
const { data } = await api.get(message.value, { const { data } = await api.get(message.value, {
responseType: "blob", responseType: 'blob',
cancelToken: ourRequest.token, cancelToken: ourRequest.token
}) })
const fileURL = URL.createObjectURL(data) const fileURL = URL.createObjectURL(data)
setFile({ blob: data, url: fileURL }) setFile({ blob: data, url: fileURL })
@ -52,27 +52,27 @@ export const MessageFile: React.FC<MessageContentProps> = (props) => {
} }
if (supportedImageMimetype.includes(message.mimetype)) { if (supportedImageMimetype.includes(message.mimetype)) {
return ( return (
<a href={file.url} target="_blank" rel="noreferrer"> <a href={file.url} target='_blank' rel='noreferrer'>
<img <img
data-cy={`message-file-image-${message.id}`} data-cy={`message-file-image-${message.id}`}
className="max-h-80 sm:max-w-xs" className='max-h-80 sm:max-w-xs'
src={file.url} src={file.url}
alt={message.value} alt={message.value}
/> />
</a> </a>
) )
} }
if (message.mimetype.startsWith("audio/")) { if (message.mimetype.startsWith('audio/')) {
return ( return (
<audio controls data-cy={`message-file-audio-${message.id}`}> <audio controls data-cy={`message-file-audio-${message.id}`}>
<source src={file.url} type={message.mimetype} /> <source src={file.url} type={message.mimetype} />
</audio> </audio>
) )
} }
if (message.mimetype.startsWith("video/")) { if (message.mimetype.startsWith('video/')) {
return ( return (
<video <video
className="max-h-80 max-w-xs" className='max-h-80 max-w-xs'
controls controls
data-cy={`message-file-video-${message.id}`} data-cy={`message-file-video-${message.id}`}
> >
@ -82,17 +82,17 @@ export const MessageFile: React.FC<MessageContentProps> = (props) => {
} }
return ( return (
<a href={file.url} download data-cy={`message-file-download-${message.id}`}> <a href={file.url} download data-cy={`message-file-download-${message.id}`}>
<div className="flex items-center"> <div className='flex items-center'>
<div className="flex items-center"> <div className='flex items-center'>
<div> <div>
<FileIcon /> <FileIcon />
</div> </div>
<div className="ml-4"> <div className='ml-4'>
<p>{file.blob.type}</p> <p>{file.blob.type}</p>
<p className="mt-1">{prettyBytes(file.blob.size)}</p> <p className='mt-1'>{prettyBytes(file.blob.size)}</p>
</div> </div>
</div> </div>
<DownloadIcon className="ml-4 h-8 w-8" /> <DownloadIcon className='ml-4 h-8 w-8' />
</div> </div>
</a> </a>
) )

View File

@ -1 +1 @@
export * from "./MessageFile" export * from './MessageFile'

View File

@ -1,44 +0,0 @@
import useTranslation from "next-translate/useTranslation"
import { useAuthentication } from "../../../../../tools/authentication"
import { Emoji } from "../../../../Emoji"
import type { MessageProps } from "../Message"
interface MessageOptionsProps extends MessageProps {
handleEdit: () => void
editMode: ":white_check_mark:" | ":pencil2:"
}
export const MessageOptions: React.FC<
React.PropsWithChildren<MessageOptionsProps>
> = ({ handleEdit, editMode, message }) => {
const { t } = useTranslation()
const { authentication } = useAuthentication()
const handleDeleteMessage = async (): Promise<void> => {
try {
await authentication.api.delete(`/messages/${message.id}`)
} catch {}
}
return (
<div className="absolute -top-8 right-6 flex opacity-0 transition-opacity group-hover:opacity-100">
{message.type === "text" && (
<div
className="message-options rounded-l-lg border-l-slate-600"
title={t("application:edit")}
onClick={handleEdit}
>
<Emoji value={editMode} size={18} />
</div>
)}
<div
className="message-options rounded-r-lg border-r-slate-600"
title={t("application:delete")}
onClick={handleDeleteMessage}
>
<Emoji value=":wastebasket:" size={18} />
</div>
</div>
)
}

View File

@ -1 +0,0 @@
export * from "./MessageOptions"

View File

@ -1,16 +1,14 @@
import { useMemo } from "react" import { useMemo } from 'react'
import ReactMarkdown from "react-markdown" import ReactMarkdown from 'react-markdown'
import gfm from "remark-gfm" import gfm from 'remark-gfm'
import remarkBreaks from "remark-breaks" import remarkBreaks from 'remark-breaks'
import remarkMath from "remark-math" import remarkMath from 'remark-math'
import rehypeKatex from "rehype-katex" import rehypeKatex from 'rehype-katex'
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter"
import { vscDarkPlus } from "react-syntax-highlighter/dist/cjs/styles/prism"
import "katex/dist/katex.min.css" import 'katex/dist/katex.min.css'
import { Emoji, emojiPlugin, isStringWithOnlyOneEmoji } from "../../../../Emoji" import { Emoji, emojiPlugin, isStringWithOnlyOneEmoji } from '../../../../Emoji'
import type { MessageWithMember } from "../../../../../models/Message" import { MessageWithMember } from '../../../../../models/Message'
export interface MessageContentProps { export interface MessageContentProps {
message: MessageWithMember message: MessageWithMember
@ -27,7 +25,7 @@ export const MessageText: React.FC<MessageContentProps> = (props) => {
return ( return (
<div> <div>
<p> <p>
<Emoji value={message.value} size={40} tooltip /> <Emoji value={message.value} size={40} />
</p> </p>
</div> </div>
) )
@ -35,42 +33,15 @@ export const MessageText: React.FC<MessageContentProps> = (props) => {
return ( return (
<ReactMarkdown <ReactMarkdown
disallowedElements={["table"]} disallowedElements={['table']}
unwrapDisallowed unwrapDisallowed
remarkPlugins={[[gfm], [remarkBreaks], [remarkMath]]} remarkPlugins={[[gfm], [remarkBreaks], [remarkMath]]}
rehypePlugins={[[emojiPlugin], [rehypeKatex]]} rehypePlugins={[[emojiPlugin], [rehypeKatex]]}
linkTarget="_blank" linkTarget='_blank'
components={{ components={{
a: (props) => {
return (
<a
className="text-green-800 hover:underline dark:text-green-400"
{...props}
/>
)
},
emoji: (props) => { emoji: (props) => {
const { value } = props return <Emoji value={props.value} size={20} />
return <Emoji value={value} size={20} tooltip /> }
},
code: (properties) => {
const { inline, className, children, ...props } = properties
const match = /language-(\w+)/.exec(className ?? "")
return !(inline as boolean) && match != null ? (
<SyntaxHighlighter
style={vscDarkPlus as any}
language={match[1]}
PreTag="div"
{...props}
>
{String(children).replace(/\n$/, "")}
</SyntaxHighlighter>
) : (
<code className={className} {...props}>
{children}
</code>
)
},
}} }}
> >
{message.value} {message.value}

View File

@ -1 +1 @@
export * from "./MessageText" export * from './MessageText'

View File

@ -1 +1 @@
export * from "./Message" export * from './Message'

View File

@ -1,10 +1,10 @@
import useTranslation from "next-translate/useTranslation" import useTranslation from 'next-translate/useTranslation'
import InfiniteScroll from "react-infinite-scroll-component" import InfiniteScroll from 'react-infinite-scroll-component'
import { Loader } from "../../design/Loader" import { Loader } from '../../design/Loader'
import { Message } from "./Message" import { Message } from './Message'
import { useMessages } from "../../../contexts/Messages" import { useMessages } from '../../../contexts/Messages'
import { Emoji } from "../../Emoji" import { Emoji } from '../../Emoji'
export const Messages: React.FC = () => { export const Messages: React.FC = () => {
const { t } = useTranslation() const { t } = useTranslation()
@ -13,25 +13,25 @@ export const Messages: React.FC = () => {
if (messages.length === 0) { if (messages.length === 0) {
return ( return (
<div <div
id="messages" id='messages'
className="scrollbar-firefox-support mt-8 flex w-full flex-1 flex-col overflow-y-auto text-center text-lg transition-all" className='scrollbar-firefox-support mt-8 flex w-full flex-1 flex-col overflow-y-auto text-center text-lg transition-all'
> >
<p> <p>
{t("application:nothing-here")} <Emoji value=":ghost:" size={20} /> {t('application:nothing-here')} <Emoji value=':ghost:' size={20} />
</p> </p>
<p>{t("application:start-chatting-kill-ghost")}</p> <p>{t('application:start-chatting-kill-ghost')}</p>
</div> </div>
) )
} }
return ( return (
<div <div
id="messages" id='messages'
className="scrollbar-firefox-support flex w-full flex-1 flex-col-reverse overflow-y-auto transition-all" className='scrollbar-firefox-support flex w-full flex-1 flex-col-reverse overflow-y-auto transition-all'
> >
<InfiniteScroll <InfiniteScroll
scrollableTarget="messages" scrollableTarget='messages'
className="messages-list !overflow-x-hidden" className='messages-list !overflow-x-hidden'
dataLength={messages.length} dataLength={messages.length}
next={nextPage} next={nextPage}
inverse inverse

View File

@ -1 +1 @@
export * from "./Messages" export * from './Messages'

View File

@ -0,0 +1,10 @@
import { render } from '@testing-library/react'
import { PopupGuild } from './PopupGuild'
describe('<PopupGuild />', () => {
it('should render successfully', () => {
const { baseElement } = render(<PopupGuild />)
expect(baseElement).toBeTruthy()
})
})

View File

@ -1,10 +1,9 @@
import useTranslation from "next-translate/useTranslation" import useTranslation from 'next-translate/useTranslation'
import { PlusSmIcon, ArrowDownIcon } from "@heroicons/react/solid" import { PlusSmIcon, ArrowDownIcon } from '@heroicons/react/solid'
import classNames from "clsx" import classNames from 'classnames'
import Image from "next/image" import Image from 'next/image'
import { PopupGuildCard } from "./PopupGuildCard"
import { PopupGuildCard } from './PopupGuildCard/PopupGuildCard'
export interface PopupGuildProps { export interface PopupGuildProps {
className?: string className?: string
} }
@ -18,43 +17,43 @@ export const PopupGuild: React.FC<PopupGuildProps> = (props) => {
<div <div
className={classNames( className={classNames(
className, className,
"h-full-without-header flex min-w-full flex-wrap items-center justify-center overflow-y-auto p-8", 'h-full-without-header flex min-w-full flex-wrap items-center justify-center overflow-y-auto p-8'
)} )}
> >
<PopupGuildCard <PopupGuildCard
image={ image={
<Image <Image
quality={100} quality={100}
src="/images/svg/design/create-guild.svg" src='/images/svg/design/create-guild.svg'
alt={t("application:create-a-guild")} alt={t('application:create-a-guild')}
draggable="false" draggable='false'
width={230} width={230}
height={230} height={230}
/> />
} }
description={t("application:create-a-guild-description")} description={t('application:create-a-guild-description')}
link={{ link={{
icon: <PlusSmIcon className="mr-2 h-8 w-8" />, icon: <PlusSmIcon className='mr-2 h-8 w-8' />,
text: t("application:create-a-guild"), text: t('application:create-a-guild'),
href: "/application/guilds/create", href: '/application/guilds/create'
}} }}
/> />
<PopupGuildCard <PopupGuildCard
image={ image={
<Image <Image
quality={100} quality={100}
src="/images/svg/design/join-guild.svg" src='/images/svg/design/join-guild.svg'
alt={t("application:join-a-guild")} alt={t('application:join-a-guild')}
draggable="false" draggable='false'
width={200} width={200}
height={200} height={200}
/> />
} }
description={t("application:join-a-guild-description")} description={t('application:join-a-guild-description')}
link={{ link={{
icon: <ArrowDownIcon className="mr-2 h-6 w-6" />, icon: <ArrowDownIcon className='mr-2 h-6 w-6' />,
text: t("application:join-a-guild"), text: t('application:join-a-guild'),
href: "/application/guilds/join", href: '/application/guilds/join'
}} }}
/> />
</div> </div>

View File

@ -1,36 +0,0 @@
import React from "react"
import Link from "next/link"
export interface PopupGuildCardProps {
image: JSX.Element
description: string
link: {
href: string
text: string
icon: JSX.Element
}
}
export const PopupGuildCard: React.FC<PopupGuildCardProps> = (props) => {
const { image, description, link } = props
return (
<div className="m-8 h-96 w-80 rounded-2xl bg-gray-800">
<div className="flex h-1/2 w-full items-center justify-center">
{image}
</div>
<div className="mt-2 flex h-1/2 w-full flex-col justify-between rounded-b-2xl bg-gray-700 shadow-sm">
<p className="mt-6 px-8 text-center text-sm text-gray-200">
{description}
</p>
<Link
href={link.href}
className="mb-6 flex h-10 w-4/5 items-center justify-center self-center rounded-2xl bg-green-400 font-bold tracking-wide text-white transition duration-200 ease-in-out hover:bg-green-600"
>
{link.icon}
{link.text}
</Link>
</div>
</div>
)
}

View File

@ -0,0 +1,30 @@
import { render } from '@testing-library/react'
import { PlusSmIcon } from '@heroicons/react/solid'
import Image from 'next/image'
import { PopupGuildCard } from './PopupGuildCard'
describe('<PopupGuildCard />', () => {
it('should render successfully', () => {
const { baseElement } = render(
<PopupGuildCard
image={
<Image
quality={100}
src='/images/svg/design/create-server.svg'
alt=''
width={230}
height={230}
/>
}
description='Create your own guild and manage everything within a few clicks !'
link={{
icon: <PlusSmIcon className='mr-2 h-8 w-8' />,
text: 'Create a server',
href: '/application/guilds/create'
}}
/>
)
expect(baseElement).toBeTruthy()
})
})

View File

@ -0,0 +1,35 @@
import React from 'react'
import Link from 'next/link'
export interface PopupGuildCardProps {
image: JSX.Element
description: string
link: {
href: string
text: string
icon: JSX.Element
}
}
export const PopupGuildCard: React.FC<PopupGuildCardProps> = (props) => {
const { image, description, link } = props
return (
<div className='m-8 h-96 w-80 rounded-2xl bg-gray-800'>
<div className='flex h-1/2 w-full items-center justify-center'>
{image}
</div>
<div className='mt-2 flex h-1/2 w-full flex-col justify-between rounded-b-2xl bg-gray-700 shadow-sm'>
<p className='mt-6 px-8 text-center text-sm text-gray-200'>
{description}
</p>
<Link href={link.href}>
<a className='mb-6 flex h-10 w-4/5 items-center justify-center self-center rounded-2xl bg-green-400 font-bold tracking-wide text-white transition duration-200 ease-in-out hover:bg-green-600'>
{link.icon}
{link.text}
</a>
</Link>
</div>
</div>
)
}

View File

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

View File

@ -1 +1 @@
export * from "./PopupGuild" export * from './PopupGuild'

View File

@ -1,12 +1,11 @@
import { useState, useRef } from "react" import { useState, useRef } from 'react'
import useTranslation from "next-translate/useTranslation" import useTranslation from 'next-translate/useTranslation'
import TextareaAutosize from "react-textarea-autosize" import TextareaAutosize from 'react-textarea-autosize'
import classNames from "clsx" import classNames from 'classnames'
import type { GuildsChannelsPath } from ".." import { GuildsChannelsPath } from '..'
import { useAuthentication } from "../../../tools/authentication" import { useAuthentication } from '../../../tools/authentication'
import type { EmojiPickerOnClick } from "../../Emoji" import { EmojiPicker, EmojiPickerOnClick } from '../../Emoji'
import { EmojiPicker } from "../../Emoji"
export interface SendMessageProps { export interface SendMessageProps {
path: GuildsChannelsPath path: GuildsChannelsPath
@ -18,66 +17,59 @@ export const SendMessage: React.FC<SendMessageProps> = (props) => {
const { authentication } = useAuthentication() const { authentication } = useAuthentication()
const [isVisibleEmojiPicker, setIsVisibleEmojiPicker] = useState(false) const [isVisibleEmojiPicker, setIsVisibleEmojiPicker] = useState(false)
const [message, setMessage] = useState("") const [message, setMessage] = useState('')
const textareaReference = useRef<HTMLTextAreaElement>(null) const textareaReference = useRef<HTMLTextAreaElement>(null)
const handleTextareaKeyDown: React.KeyboardEventHandler<HTMLFormElement> = ( const handleTextareaKeyDown: React.KeyboardEventHandler<HTMLFormElement> = (
event, event
) => { ) => {
if (event.key === "Enter" && !event.shiftKey) { if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault() event.preventDefault()
event.currentTarget.dispatchEvent( event.currentTarget.dispatchEvent(
new Event("submit", { cancelable: true, bubbles: true }), new Event('submit', { cancelable: true, bubbles: true })
) )
} }
} }
const handleSubmit: React.FormEventHandler<HTMLFormElement> = async ( const handleSubmit: React.FormEventHandler<HTMLFormElement> = async (
event, event
) => { ) => {
event.preventDefault() event.preventDefault()
if (typeof message === "string" && message.length > 0) { if (typeof message === 'string' && message.length > 0) {
await authentication.api.post(`/channels/${path.channelId}/messages`, { await authentication.api.post(`/channels/${path.channelId}/messages`, {
value: message, value: message
}) })
setMessage("") setMessage('')
} }
} }
const handleTextareaChange: React.ChangeEventHandler<HTMLTextAreaElement> = ( const handleTextareaChange: React.ChangeEventHandler<HTMLTextAreaElement> = (
event, event
) => { ) => {
setMessage(event.target.value) setMessage(event.target.value)
} }
const handleFileChange: React.ChangeEventHandler<HTMLInputElement> = async ( const handleFileChange: React.ChangeEventHandler<HTMLInputElement> = async (
event, event
) => { ) => {
const files = event?.target?.files const files = event?.target?.files
if (files != null && files.length === 1 && files[0] != null) { if (files != null && files.length === 1) {
const file = files[0] const file = files[0]
const formData = new FormData() const formData = new FormData()
formData.append("file", file) formData.append('file', file)
await authentication.api.post( await authentication.api.post(
`/channels/${path.channelId}/messages/uploads`, `/channels/${path.channelId}/messages/uploads`,
formData, formData
{
headers: {
"Content-Type": "multipart/form-data",
},
},
) )
} }
} }
const handleVisibleEmojiPicker = (): void => { const handleVisibleEmojiPicker = (): void => {
setIsVisibleEmojiPicker((isVisible) => { setIsVisibleEmojiPicker((isVisible) => !isVisible)
return !isVisible
})
} }
const handleEmojiPicker: EmojiPickerOnClick = (emoji) => { const handleEmojiPicker: EmojiPickerOnClick = (emoji) => {
const emojiColons = emoji.colons ?? "" const emojiColons = emoji.colons ?? ''
setMessage((oldMessage) => { setMessage((oldMessage) => {
return oldMessage + emojiColons return oldMessage + emojiColons
}) })
@ -86,51 +78,51 @@ export const SendMessage: React.FC<SendMessageProps> = (props) => {
} }
return ( return (
<div className="relative p-6 pb-4"> <div className='relative p-6 pb-4'>
<div <div
className={classNames( className={classNames(
"absolute bottom-24 right-20 z-50 w-fit transition-all duration-100", 'absolute bottom-24 right-20 z-50 w-fit transition-all duration-100',
{ {
"invisible translate-y-5 opacity-0": !isVisibleEmojiPicker, 'invisible translate-y-5 opacity-0': !isVisibleEmojiPicker
}, }
)} )}
> >
<EmojiPicker onClick={handleEmojiPicker} /> <EmojiPicker onClick={handleEmojiPicker} />
</div> </div>
<div className="flex h-full w-full rounded-lg bg-gray-200 py-1 text-gray-600 dark:bg-gray-800 dark:text-gray-200"> <div className='flex h-full w-full rounded-lg bg-gray-200 py-1 text-gray-600 dark:bg-gray-800 dark:text-gray-200'>
<form <form
className="flex h-full w-full items-center" className='flex h-full w-full items-center'
onSubmit={handleSubmit} onSubmit={handleSubmit}
onKeyDown={handleTextareaKeyDown} onKeyDown={handleTextareaKeyDown}
> >
<TextareaAutosize <TextareaAutosize
className="scrollbar-firefox-support my-2 w-full resize-none bg-transparent p-2 px-6 tracking-wide outline-none" className='scrollbar-firefox-support my-2 w-full resize-none bg-transparent p-2 px-6 font-paragraph tracking-wide outline-none'
placeholder={t("application:write-a-message")} placeholder={t('application:write-a-message')}
wrap="soft" wrap='soft'
maxRows={6} maxRows={6}
name="message" name='message'
onChange={handleTextareaChange} onChange={handleTextareaChange}
value={message} value={message}
ref={textareaReference} ref={textareaReference}
/> />
</form> </form>
<div className="flex h-full items-center justify-around pr-6"> <div className='flex h-full items-center justify-around pr-6'>
<button <button
className="flex h-full w-full items-center justify-center p-1 text-2xl transition hover:-translate-y-1" className='flex h-full w-full items-center justify-center p-1 text-2xl transition hover:-translate-y-1'
onClick={handleVisibleEmojiPicker} onClick={handleVisibleEmojiPicker}
> >
🙂 🙂
</button> </button>
<button className="relative flex h-full w-full cursor-pointer items-center justify-center p-1 text-green-800 transition hover:-translate-y-1 dark:text-green-400"> <button className='relative flex h-full w-full cursor-pointer items-center justify-center p-1 text-green-800 transition hover:-translate-y-1 dark:text-green-400'>
<input <input
type="file" type='file'
className="absolute h-full w-full cursor-pointer opacity-0" className='absolute h-full w-full cursor-pointer opacity-0'
onChange={handleFileChange} onChange={handleFileChange}
/> />
<svg width="25" height="25" viewBox="0 0 22 22"> <svg width='25' height='25' viewBox='0 0 22 22'>
<path <path
d="M11 0C4.925 0 0 4.925 0 11C0 17.075 4.925 22 11 22C17.075 22 22 17.075 22 11C22 4.925 17.075 0 11 0ZM12 15C12 15.2652 11.8946 15.5196 11.7071 15.7071C11.5196 15.8946 11.2652 16 11 16C10.7348 16 10.4804 15.8946 10.2929 15.7071C10.1054 15.5196 10 15.2652 10 15V12H7C6.73478 12 6.48043 11.8946 6.29289 11.7071C6.10536 11.5196 6 11.2652 6 11C6 10.7348 6.10536 10.4804 6.29289 10.2929C6.48043 10.1054 6.73478 10 7 10H10V7C10 6.73478 10.1054 6.48043 10.2929 6.29289C10.4804 6.10536 10.7348 6 11 6C11.2652 6 11.5196 6.10536 11.7071 6.29289C11.8946 6.48043 12 6.73478 12 7V10H15C15.2652 10 15.5196 10.1054 15.7071 10.2929C15.8946 10.4804 16 10.7348 16 11C16 11.2652 15.8946 11.5196 15.7071 11.7071C15.5196 11.8946 15.2652 12 15 12H12V15Z" d='M11 0C4.925 0 0 4.925 0 11C0 17.075 4.925 22 11 22C17.075 22 22 17.075 22 11C22 4.925 17.075 0 11 0ZM12 15C12 15.2652 11.8946 15.5196 11.7071 15.7071C11.5196 15.8946 11.2652 16 11 16C10.7348 16 10.4804 15.8946 10.2929 15.7071C10.1054 15.5196 10 15.2652 10 15V12H7C6.73478 12 6.48043 11.8946 6.29289 11.7071C6.10536 11.5196 6 11.2652 6 11C6 10.7348 6.10536 10.4804 6.29289 10.2929C6.48043 10.1054 6.73478 10 7 10H10V7C10 6.73478 10.1054 6.48043 10.2929 6.29289C10.4804 6.10536 10.7348 6 11 6C11.2652 6 11.5196 6.10536 11.7071 6.29289C11.8946 6.48043 12 6.73478 12 7V10H15C15.2652 10 15.5196 10.1054 15.7071 10.2929C15.8946 10.4804 16 10.7348 16 11C16 11.2652 15.8946 11.5196 15.7071 11.7071C15.5196 11.8946 15.2652 12 15 12H12V15Z'
fill="currentColor" fill='currentColor'
/> />
</svg> </svg>
</button> </button>

View File

@ -1 +1 @@
export * from "./SendMessage" export * from './SendMessage'

View File

@ -0,0 +1,12 @@
import { render } from '@testing-library/react'
import { Sidebar } from './Sidebar'
describe('<Sidebar />', () => {
it('should render successfully', () => {
const { baseElement } = render(
<Sidebar direction='left' visible={true} isMobile={false} />
)
expect(baseElement).toBeTruthy()
})
})

View File

@ -1,8 +1,8 @@
import classNames from "clsx" import classNames from 'classnames'
import type { ApplicationProps } from ".." import { ApplicationProps } from '..'
export type DirectionSidebar = "left" | "right" export type DirectionSidebar = 'left' | 'right'
export interface SidebarProps { export interface SidebarProps {
direction: DirectionSidebar direction: DirectionSidebar
@ -11,25 +11,23 @@ export interface SidebarProps {
isMobile: boolean isMobile: boolean
} }
export const Sidebar: React.FC<React.PropsWithChildren<SidebarProps>> = ( export const Sidebar: React.FC<SidebarProps> = (props) => {
props,
) => {
const { direction, visible, children, path, isMobile } = props const { direction, visible, children, path, isMobile } = props
return ( return (
<nav <nav
className={classNames( className={classNames(
"h-full-without-header visible z-50 flex bg-gray-200 drop-shadow-2xl transition-all dark:bg-gray-800", 'h-full-without-header visible z-50 flex bg-gray-200 drop-shadow-2xl transition-all dark:bg-gray-800',
{ {
"scrollbar-firefox-support right-0 top-0 flex-col space-y-1 overflow-y-auto": 'scrollbar-firefox-support top-0 right-0 flex-col space-y-1 overflow-y-auto':
direction === "right", direction === 'right',
"w-72": direction === "right" && visible, 'w-72': direction === 'right' && visible,
"invisible w-0 opacity-0": !visible, 'invisible w-0 opacity-0': !visible,
"w-80": direction === "left" && visible, 'w-80': direction === 'left' && visible,
"max-w-max": typeof path !== "string" && direction === "left", 'max-w-max': typeof path !== 'string' && direction === 'left',
"right-0 top-0": direction === "right" && isMobile, 'top-0 right-0': direction === 'right' && isMobile,
absolute: isMobile, absolute: isMobile
}, }
)} )}
> >
{children} {children}

View File

@ -1 +1 @@
export * from "./Sidebar" export * from './Sidebar'

View File

@ -0,0 +1,23 @@
import { render } from '@testing-library/react'
import {
guildExample,
guildExample2
} from '../../../cypress/fixtures/guilds/guild'
import {
userExample,
userSettingsExample
} from '../../../cypress/fixtures/users/user'
import { UserProfile } from './UserProfile'
describe('<UserProfile />', () => {
it('should render successfully', () => {
const { baseElement } = render(
<UserProfile
user={{ ...userExample, settings: userSettingsExample }}
guilds={[guildExample, guildExample2]}
/>
)
expect(baseElement).toBeTruthy()
})
})

View File

@ -1,9 +1,9 @@
import Image from "next/image" import Image from 'next/image'
import date from "date-and-time" import date from 'date-and-time'
import useTranslation from "next-translate/useTranslation" import useTranslation from 'next-translate/useTranslation'
import type { UserPublic } from "../../../models/User" import { UserPublic } from '../../../models/User'
import type { Guild } from "../../../models/Guild" import { Guild } from '../../../models/Guild'
export interface UserProfileProps { export interface UserProfileProps {
className?: string className?: string
@ -16,73 +16,71 @@ export const UserProfile: React.FC<UserProfileProps> = (props) => {
const { t } = useTranslation() const { t } = useTranslation()
return ( return (
<div className="relative flex h-full flex-col items-center justify-center"> <div className='relative flex h-full flex-col items-center justify-center'>
<div className="transition"> <div className='transition'>
<div className="max-w-[1000px] px-12"> <div className='max-w-[1000px] px-12'>
<div className="flex items-center justify-between"> <div className='flex items-center justify-between'>
<div className="flex w-max flex-col items-center gap-7 md:flex-row"> <div className='flex w-max items-center'>
<div className="relative flex items-center justify-center overflow-hidden rounded-full shadow-lg transition-all"> <div className='relative flex items-center justify-center overflow-hidden rounded-full shadow-lg transition-all'>
<Image <Image
quality={100} quality={100}
className="rounded-full" className='rounded-full'
src={ src={
user.logo != null user.logo != null
? user.logo ? user.logo
: "/images/data/user-default.png" : '/images/data/user-default.png'
} }
alt="Profil Picture" alt='Profil Picture'
draggable="false" draggable='false'
height={125} height={125}
width={125} width={125}
/> />
</div> </div>
<div className="ml-10 flex flex-col"> <div className='ml-10 flex flex-col'>
<div className="mb-2 flex items-center"> <div className='mb-2 flex items-center'>
<p <p
className="space text-dark text-3xl font-bold tracking-wide dark:text-white" className='space text-dark text-3xl font-bold tracking-wide dark:text-white'
data-cy="user-name" data-cy='user-name'
> >
{user.name} {user.name}
</p> </p>
<p <p
className="ml-8 select-none text-sm tracking-widest text-white opacity-40" className='ml-8 select-none text-sm tracking-widest text-white opacity-40'
data-cy="user-createdAt" data-cy='user-createdAt'
> >
{date.format(new Date(user.createdAt), "DD/MM/YYYY")} {date.format(new Date(user.createdAt), 'DD/MM/YYYY')}
</p> </p>
</div> </div>
<div className="my-2 text-left"> <div className='my-2 text-left'>
{user.email != null && ( {user.email != null && (
<p className="font-bold"> <p className='font-bold'>
Email:{" "} Email:{' '}
<a <a
href={`mailto:${user.email}`} href={`mailto:${user.email}`}
target="_blank" target='_blank'
className="relative ml-2 font-normal tracking-wide no-underline opacity-80 transition-all after:absolute after:bottom-[-1px] after:left-0 after:h-[1px] after:w-0 after:bg-black after:transition-all hover:opacity-100 hover:after:w-full dark:after:bg-white" className='relative ml-2 font-normal tracking-wide no-underline opacity-80 transition-all after:absolute after:left-0 after:bottom-[-1px] after:h-[1px] after:w-0 after:bg-black after:transition-all hover:opacity-100 hover:after:w-full dark:after:bg-white'
rel="noreferrer" rel='noreferrer'
data-cy="user-email" data-cy='user-email'
> >
{user.email} {user.email}
</a> </a>
</p> </p>
)} )}
{user.website != null && ( {user.website != null && (
<p className="font-bold"> <p className='font-bold'>
{t("application:website")}:{" "} {t('application:website')}:{' '}
<a <a
target="_blank"
rel="noreferrer"
href={user.website} href={user.website}
className="relative ml-2 font-normal tracking-wide no-underline opacity-80 transition-all after:absolute after:bottom-[-2px] after:left-0 after:h-[1px] after:w-0 after:bg-black after:transition-all hover:opacity-100 hover:after:w-full dark:after:bg-white" className='relative ml-2 font-normal tracking-wide no-underline opacity-80 transition-all after:absolute after:left-0 after:bottom-[-2px] after:h-[1px] after:w-0 after:bg-black after:transition-all hover:opacity-100 hover:after:w-full dark:after:bg-white'
> >
{user.website} {user.website}
</a> </a>
</p> </p>
)} )}
{user.status != null && ( {user.status != null && (
<p className="flex font-bold"> <p className='flex font-bold'>
{t("application:status")}:{" "} {t('application:status')}:{' '}
<span className="ml-2 font-normal tracking-wide"> <span className='ml-2 font-normal tracking-wide'>
{user.status} {user.status}
</span> </span>
</p> </p>
@ -91,11 +89,9 @@ export const UserProfile: React.FC<UserProfileProps> = (props) => {
</div> </div>
</div> </div>
</div> </div>
{user.biography != null && ( <div className='mt-7'>
<div className="mt-7 text-center"> {user.biography != null && <p>{user.biography}</p>}
<p>{user.biography}</p>
</div> </div>
)}
</div> </div>
</div> </div>
</div> </div>

View File

@ -1 +1 @@
export * from "./UserProfile" export * from './UserProfile'

View File

@ -1,37 +1,25 @@
import Image from "next/image" import Image from 'next/image'
import useTranslation from "next-translate/useTranslation" import useTranslation from 'next-translate/useTranslation'
import { useState, useMemo } from "react" import { useState, useMemo } from 'react'
import { Form, useForm } from "react-component-form" import { Form } from 'react-component-form'
import { EyeIcon, PhotographIcon } from "@heroicons/react/solid" import { EyeIcon, PhotographIcon } from '@heroicons/react/solid'
import { Type } from "@sinclair/typebox" import { Type } from '@sinclair/typebox'
import axios from "axios" import axios from 'axios'
import Link from "next/link" import Link from 'next/link'
import type { HandleUseFormCallback } from "react-component-form"
import { Input } from "../../design/Input" import { Input } from '../../design/Input'
import { Checkbox } from "../../design/Checkbox" import { Checkbox } from '../../design/Checkbox'
import { Textarea } from "../../design/Textarea" import { Textarea } from '../../design/Textarea'
import { SocialMediaButton } from "../../design/SocialMediaButton" import { SocialMediaButton } from '../../design/SocialMediaButton'
import { SwitchTheme } from "../../Header/SwitchTheme" import { SwitchTheme } from '../../Header/SwitchTheme'
import { Language } from "../../Header/Language" import { Language } from '../../Header/Language'
import { useAuthentication } from "../../../tools/authentication" import { useAuthentication } from '../../../tools/authentication'
import { Button } from "../../design/Button" import { Button } from '../../design/Button'
import { FormState } from "../../design/FormState" import { FormState } from '../../design/FormState'
import { userSchema } from "../../../models/User" import { useForm, HandleSubmitCallback } from '../../../hooks/useForm'
import { userSettingsSchema } from "../../../models/UserSettings" import { userSchema } from '../../../models/User'
import type { ProviderOAuth } from "../../../models/OAuth" import { userSettingsSchema } from '../../../models/UserSettings'
import { providers } from "../../../models/OAuth" import { ProviderOAuth, providers } from '../../../models/OAuth'
import { useFormTranslation } from "../../../hooks/useFormTranslation"
const schema = {
name: userSchema.name,
status: Type.Optional(userSchema.status),
email: Type.Optional(Type.Union([userSchema.email, Type.Null()])),
website: Type.Optional(userSchema.website),
biography: Type.Optional(userSchema.biography),
isPublicGuilds: userSettingsSchema.isPublicGuilds,
isPublicEmail: userSettingsSchema.isPublicEmail,
}
export const UserSettings: React.FC = () => { export const UserSettings: React.FC = () => {
const { user, setUser, authentication } = useAuthentication() const { user, setUser, authentication } = useAuthentication()
@ -43,44 +31,54 @@ export const UserSettings: React.FC = () => {
website: user.website, website: user.website,
biography: user.biography, biography: user.biography,
isPublicGuilds: user.settings.isPublicGuilds, isPublicGuilds: user.settings.isPublicGuilds,
isPublicEmail: user.settings.isPublicEmail, isPublicEmail: user.settings.isPublicEmail
}) })
const { const {
handleUseForm,
fetchState, fetchState,
setFetchState, setFetchState,
message, message,
setMessage, setMessageTranslationKey,
errors, errors,
} = useForm(schema) getErrorTranslation,
const { getFirstErrorTranslation } = useFormTranslation() handleSubmit
} = useForm({
validateSchema: {
name: userSchema.name,
status: Type.Optional(userSchema.status),
email: Type.Optional(Type.Union([userSchema.email, Type.Null()])),
website: Type.Optional(userSchema.website),
biography: Type.Optional(userSchema.biography),
isPublicGuilds: userSettingsSchema.isPublicGuilds,
isPublicEmail: userSettingsSchema.isPublicEmail
},
replaceEmptyStringToNull: true,
resetOnSuccess: false
})
const hasAllProviders = useMemo(() => { const hasAllProviders = useMemo(() => {
return providers.every((provider) => { return providers.every((provider) => user.strategies.includes(provider))
return user.strategies.includes(provider)
})
}, [user.strategies]) }, [user.strategies])
const onSubmit: HandleUseFormCallback<typeof schema> = async (formData) => { const onSubmit: HandleSubmitCallback = async (formData) => {
try { try {
const { isPublicGuilds, isPublicEmail, ...userData } = formData const { isPublicGuilds, isPublicEmail, ...userData } = formData
const userSettings = { isPublicEmail, isPublicGuilds } const userSettings = { isPublicEmail, isPublicGuilds }
const { data: userCurrentData } = await authentication.api.put( const { data: userCurrentData } = await authentication.api.put(
`/users/current?redirectURI=${window.location.origin}/authentication/signin`, `/users/current?redirectURI=${window.location.origin}/authentication/signin`,
userData, userData
) )
setInputValues(formData as unknown as any) setInputValues(formData as any)
const hasEmailChanged = user.email !== userCurrentData.user.email const hasEmailChanged = user.email !== userCurrentData.user.email
if (hasEmailChanged) { if (hasEmailChanged) {
return { return {
type: "success", type: 'success',
message: "application:success-email-changed", value: 'application:success-email-changed'
} }
} }
const { data: userCurrentSettings } = await authentication.api.put( const { data: userCurrentSettings } = await authentication.api.put(
"/users/current/settings", '/users/current/settings',
userSettings, userSettings
) )
setUser((oldUser) => { setUser((oldUser) => {
return { return {
@ -88,36 +86,36 @@ export const UserSettings: React.FC = () => {
...userCurrentData, ...userCurrentData,
settings: { settings: {
...oldUser.settings, ...oldUser.settings,
...userCurrentSettings.settings, ...userCurrentSettings.settings
}, }
} }
}) })
return { return {
type: "success", type: 'success',
message: "application:saved-information", value: 'application:saved-information'
} }
} catch (error) { } catch (error) {
if (axios.isAxiosError(error) && error.response?.status === 400) { if (axios.isAxiosError(error) && error.response?.status === 400) {
const message = error.response.data.message as string const message = error.response.data.message as string
if (message.endsWith("already taken.")) { if (message.endsWith('already taken.')) {
return { return {
type: "error", type: 'error',
message: "authentication:already-used", value: 'authentication:already-used'
} }
} else if (message.endsWith("email to sign in.")) { } else if (message.endsWith('email to sign in.')) {
return { return {
type: "error", type: 'error',
message: "authentication:email-required-to-sign-in", value: 'authentication:email-required-to-sign-in'
} }
} }
return { return {
type: "error", type: 'error',
message: "errors:server-error", value: 'errors:server-error'
} }
} }
return { return {
type: "error", type: 'error',
message: "errors:server-error", value: 'errors:server-error'
} }
} }
} }
@ -128,95 +126,90 @@ export const UserSettings: React.FC = () => {
setInputValues((oldInputValues) => { setInputValues((oldInputValues) => {
return { return {
...oldInputValues, ...oldInputValues,
[event.target.name]: event.target.value, [event.target.name]: event.target.value
} }
}) })
} }
const onChangeCheckbox: React.ChangeEventHandler<HTMLInputElement> = ( const onChangeCheckbox: React.ChangeEventHandler<HTMLInputElement> = (
event, event
) => { ) => {
setInputValues((oldInputValues) => { setInputValues((oldInputValues) => {
return { return {
...oldInputValues, ...oldInputValues,
[event.target.name]: event.target.checked, [event.target.name]: event.target.checked
} }
}) })
} }
const handleFileChange: React.ChangeEventHandler<HTMLInputElement> = async ( const handleFileChange: React.ChangeEventHandler<HTMLInputElement> = async (
event, event
) => { ) => {
setFetchState("loading") setFetchState('loading')
const files = event?.target?.files const files = event?.target?.files
if (files != null && files.length === 1 && files[0] != null) { if (files != null && files.length === 1) {
const file = files[0] const file = files[0]
const formData = new FormData() const formData = new FormData()
formData.append("logo", file) formData.append('logo', file)
try { try {
const { data } = await authentication.api.put( const { data } = await authentication.api.put(
`/users/current/logo`, `/users/current/logo`,
formData, formData
{
headers: {
"Content-Type": "multipart/form-data",
},
},
) )
setUser((oldUser) => { setUser((oldUser) => {
return { return {
...oldUser, ...oldUser,
logo: data.user.logo, logo: data.user.logo
} }
}) })
setFetchState("idle") setFetchState('idle')
} catch (error) { } catch (error) {
setFetchState("error") setFetchState('error')
setMessage("errors:server-error") setMessageTranslationKey('errors:server-error')
} }
} }
} }
const handleSignout = async (): Promise<void> => { const handleSignout = async (): Promise<void> => {
setFetchState("loading") try {
setFetchState('loading')
await authentication.signoutServerSide() await authentication.signoutServerSide()
} catch (error) {
setFetchState('error')
setMessageTranslationKey('errors:server-error')
} }
const handleSignoutAllDevices = async (): Promise<void> => {
setFetchState("loading")
await authentication.signoutAllDevicesServerSide()
} }
const handleDeletionProvider = ( const handleDeletionProvider = (
provider: ProviderOAuth, provider: ProviderOAuth
): (() => Promise<void>) => { ): (() => Promise<void>) => {
return async () => { return async () => {
try { try {
setFetchState("loading") setFetchState('loading')
await authentication.api.delete(`/users/oauth2/${provider}`) await authentication.api.delete(`/users/oauth2/${provider}`)
setUser((oldUser) => { setUser((oldUser) => {
return { return {
...oldUser, ...oldUser,
strategies: oldUser.strategies.filter((strategy) => { strategies: oldUser.strategies.filter(
return strategy !== provider (strategy) => strategy !== provider
}), )
} }
}) })
setMessage("application:success-deleted-provider") setMessageTranslationKey('application:success-deleted-provider')
} catch (error) { } catch (error) {
setFetchState("error") setFetchState('error')
setMessage("errors:server-error") setMessageTranslationKey('errors:server-error')
} }
} }
} }
const handleAddProvider = ( const handleAddProvider = (
provider: ProviderOAuth, provider: ProviderOAuth
): (() => Promise<void>) => { ): (() => Promise<void>) => {
return async () => { return async () => {
const redirect = window.location.href.replace(location.search, "") const redirect = window.location.href.replace(location.search, '')
const { data: url } = await authentication.api.get( const { data: url } = await authentication.api.get(
`/users/oauth2/${provider.toLowerCase()}/add-strategy?redirectURI=${redirect}`, `/users/oauth2/${provider.toLowerCase()}/add-strategy?redirectURI=${redirect}`
) )
window.location.href = url window.location.href = url
} }
@ -224,117 +217,118 @@ export const UserSettings: React.FC = () => {
return ( return (
<Form <Form
onSubmit={handleUseForm(onSubmit)} onSubmit={handleSubmit(onSubmit)}
className="my-auto flex flex-col items-center justify-center py-12 lg:min-w-[875px]" className='my-auto flex flex-col items-center justify-center py-12 lg:min-w-[875px]'
> >
<div className="flex w-full flex-col items-center justify-center sm:w-fit lg:flex-row"> <div className='flex w-full flex-col items-center justify-center sm:w-fit lg:flex-row'>
<div className=" flex w-full flex-wrap items-center justify-center px-6 sm:w-max"> <div className=' flex w-full flex-wrap items-center justify-center px-6 sm:w-max'>
<div className="relative"> <div className='relative'>
<div className="absolute z-50 h-full w-full"> <div className='absolute z-50 h-full w-full'>
<button className="relative flex h-full w-full items-center justify-center transition hover:scale-110"> <button className='relative flex h-full w-full items-center justify-center transition hover:scale-110'>
<input <input
type="file" type='file'
className="absolute h-full w-full cursor-pointer opacity-0" className='absolute h-full w-full cursor-pointer opacity-0'
onChange={handleFileChange} onChange={handleFileChange}
/> />
<PhotographIcon color="white" className="h-8 w-8" /> <PhotographIcon color='white' className='h-8 w-8' />
</button> </button>
</div> </div>
<div className="flex items-center justify-center rounded-full bg-black shadow-xl"> <div className='flex items-center justify-center rounded-full bg-black shadow-xl'>
<Image <Image
quality={100} quality={100}
className="rounded-full opacity-50" className='rounded-full opacity-50'
src={ src={
user.logo != null user.logo != null
? user.logo ? user.logo
: "/images/data/user-default.png" : '/images/data/user-default.png'
} }
alt="Profil Picture" alt='Profil Picture'
draggable="false" draggable='false'
height={125} height={125}
width={125} width={125}
/> />
</div> </div>
</div> </div>
<div className="mx-12 flex flex-col"> <div className='mx-12 flex flex-col'>
<Input <Input
name="name" name='name'
label={t("common:name")} label={t('common:name')}
placeholder={t("common:name")} placeholder={t('common:name')}
className="!mt-0" className='!mt-0'
onChange={onChange} onChange={onChange}
value={inputValues.name ?? ""} value={inputValues.name ?? ''}
error={getFirstErrorTranslation(errors.name)} error={getErrorTranslation(errors.name)}
/> />
<Input <Input
name="status" name='status'
label={t("application:status")} label={t('application:status')}
placeholder={t("application:status")} placeholder={t('application:status')}
className="!mt-4" className='!mt-4'
onChange={onChange} onChange={onChange}
value={inputValues.status ?? ""} value={inputValues.status ?? ''}
error={getFirstErrorTranslation(errors.status)} error={getErrorTranslation(errors.status)}
/> />
</div> </div>
</div> </div>
</div> </div>
<div className="mt-12 flex w-full flex-col items-center justify-between sm:w-fit lg:flex-row"> <div className='mt-12 flex w-full flex-col items-center justify-between sm:w-fit lg:flex-row'>
<div className="w-4/5 pr-0 sm:w-[450px] lg:border-r-[1px] lg:border-neutral-700 lg:pr-12"> <div className='w-4/5 pr-0 sm:w-[450px] lg:border-r-[1px] lg:border-neutral-700 lg:pr-12'>
<Input <Input
name="email" name='email'
label="Email" label='Email'
placeholder="Email" placeholder='Email'
onChange={onChange} onChange={onChange}
value={inputValues.email ?? ""} value={inputValues.email ?? ''}
error={getFirstErrorTranslation(errors.email)} error={getErrorTranslation(errors.email)}
/> />
<Checkbox <Checkbox
name="isPublicEmail" name='isPublicEmail'
label={t("application:label-checkbox-email")} label={t('application:label-checkbox-email')}
id="checkbox-email-visibility" id='checkbox-email-visibility'
onChange={onChangeCheckbox} onChange={onChangeCheckbox}
checked={inputValues.isPublicEmail} checked={inputValues.isPublicEmail}
/> />
<Input <Input
name="website" name='website'
label={t("application:website")} label={t('application:website')}
placeholder={t("application:website")} placeholder={t('application:website')}
onChange={onChange} onChange={onChange}
value={inputValues.website ?? ""} value={inputValues.website ?? ''}
error={getFirstErrorTranslation(errors.website)} error={getErrorTranslation(errors.website)}
/> />
<Textarea <Textarea
name="biography" name='biography'
label={t("application:biography")} label={t('application:biography')}
placeholder={t("application:biography")} placeholder={t('application:biography')}
id="textarea-biography" id='textarea-biography'
onChange={onChange} onChange={onChange}
value={inputValues.biography ?? ""} value={inputValues.biography ?? ''}
/> />
</div> </div>
<div className="flex h-full w-4/5 flex-col items-center justify-between pr-0 sm:w-[415px] lg:pl-12"> <div className='flex h-full w-4/5 flex-col items-center justify-between pr-0 sm:w-[415px] lg:pl-12'>
<div className="flex w-full items-center pt-10 lg:pt-0"> <div className='flex w-full items-center pt-10 lg:pt-0'>
<Language className="!top-12" /> <Language className='!top-12' />
<div className="ml-auto flex"> <div className='ml-auto flex'>
<SwitchTheme /> <SwitchTheme />
<Link <Link href={`/application/users/${user.id}`}>
href={`/application/users/${user.id}`} <a
className="group ml-3 flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg bg-slate-200 transition-colors hover:bg-slate-300 dark:bg-slate-700 hover:dark:bg-slate-800" className='group ml-3 flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg bg-slate-200 transition-colors hover:bg-slate-300 dark:bg-slate-700 hover:dark:bg-slate-800'
title="Preview Public Profile" title='Preview Public Profile'
> >
<EyeIcon <EyeIcon
height={20} height={20}
className="opacity-50 transition-opacity group-hover:opacity-100" className='opacity-50 transition-opacity group-hover:opacity-100'
/> />
</a>
</Link> </Link>
</div> </div>
</div> </div>
<div className="mt-14 flex w-full flex-col gap-4"> <div className='mt-14 flex w-full flex-col gap-4'>
{!hasAllProviders ? ( {!hasAllProviders ? (
<div className="flex w-full flex-col gap-4"> <div className='flex w-full flex-col gap-4'>
<h3 className="text-center"> <h3 className='text-center'>
{t("application:signin-with-an-account")} {t('application:signin-with-an-account')}
</h3> </h3>
{providers.map((provider, index) => { {providers.map((provider, index) => {
if (!user.strategies.includes(provider)) { if (!user.strategies.includes(provider)) {
@ -342,7 +336,7 @@ export const UserSettings: React.FC = () => {
<SocialMediaButton <SocialMediaButton
key={index} key={index}
socialMedia={provider} socialMedia={provider}
className="w-full justify-center" className='w-full justify-center'
onClick={handleAddProvider(provider)} onClick={handleAddProvider(provider)}
/> />
) )
@ -352,9 +346,9 @@ export const UserSettings: React.FC = () => {
</div> </div>
) : null} ) : null}
{user.strategies.length !== 1 && ( {user.strategies.length !== 1 && (
<div className="mt-4 flex w-full flex-col gap-4"> <div className='mt-4 flex w-full flex-col gap-4'>
<h3 className="text-center"> <h3 className='text-center'>
{t("application:signout-with-an-account")} {t('application:signout-with-an-account')}
</h3> </h3>
{providers.map((provider, index) => { {providers.map((provider, index) => {
if (user.strategies.includes(provider)) { if (user.strategies.includes(provider)) {
@ -362,7 +356,7 @@ export const UserSettings: React.FC = () => {
<SocialMediaButton <SocialMediaButton
key={index} key={index}
socialMedia={provider} socialMedia={provider}
className="w-full justify-center" className='w-full justify-center'
onClick={handleDeletionProvider(provider)} onClick={handleDeletionProvider(provider)}
/> />
) )
@ -375,22 +369,14 @@ export const UserSettings: React.FC = () => {
</div> </div>
</div> </div>
<div className="mt-12 flex flex-col items-center justify-center sm:w-fit"> <div className='mt-12 flex flex-col items-center justify-center sm:w-fit'>
<div className="space-x-6"> <div className='space-x-6'>
<Button type="submit">{t("application:save")}</Button> <Button type='submit'>{t('application:save')}</Button>
<Button type="button" color="red" onClick={handleSignout}> <Button type='button' color='red' onClick={handleSignout}>
{t("application:signout")} {t('application:signout')}
</Button> </Button>
</div> </div>
<div className="mt-4"> <FormState state={fetchState} message={message} />
<Button type="button" color="red" onClick={handleSignoutAllDevices}>
{t("application:signout-all-devices")}
</Button>
</div>
<FormState
state={fetchState}
message={message != null ? t(message) : undefined}
/>
</div> </div>
</Form> </Form>
) )

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