Compare commits

...
69 Commits
Author SHA1 Message Date
Maksim Eltyshev 90c8b4d548 chore: Update version 2022-12-15 01:39:00 +01:00
Maksim Eltyshev 4941b51fa4 fix: Prevent popup from overflowing 2022-12-15 01:38:03 +01:00
Maksim Eltyshev a858419f3c fix: Fix router synchronization with redux store 2022-12-15 01:34:48 +01:00
Maksim Eltyshev e28c06b85a fix: Fix type of omit flag 2022-11-30 03:15:02 +01:00
Maksim Eltyshev fa2d73a2c5 chore: Update version 2022-11-30 02:55:25 +01:00
Maksim Eltyshev 80706bbdd5 meta: Update link to container registry 2022-11-30 02:53:06 +01:00
Maksim Eltyshev db4e5a89f9 fix: Improve building 2022-11-30 02:50:38 +01:00
Maksim Eltyshev 0266e4bfe2 meta: Import from Trello 2022-11-28 14:07:55 +01:00
Maksim Eltyshev 02c26cfb4d ci: Update alpine version 2022-11-21 20:11:45 +01:00
Maksim Eltyshev ef096499a2 chore: Update alpine and vips version 2022-11-21 18:11:00 +01:00
Maksim Eltyshev a007e1d065 chore: Update version 2022-11-21 17:13:11 +01:00
Maksim Eltyshev c181731870 fix: Fix card repositioning
Closes #340
2022-11-21 17:07:51 +01:00
dependabot[bot] 54c57a08c3 chore(deps): Bump minimatch and recursive-readdir in /client (#338)
Bumps [minimatch](https://github.com/isaacs/minimatch) and [recursive-readdir](https://github.com/jergason/recursive-readdir). These dependencies needed to be updated together.

Updates `minimatch` from 3.0.4 to 3.1.2
- [Release notes](https://github.com/isaacs/minimatch/releases)
- [Commits](https://github.com/isaacs/minimatch/compare/v3.0.4...v3.1.2)

Updates `recursive-readdir` from 2.2.2 to 2.2.3
- [Release notes](https://github.com/jergason/recursive-readdir/releases)
- [Changelog](https://github.com/jergason/recursive-readdir/blob/master/CHANGELOG.md)
- [Commits](https://github.com/jergason/recursive-readdir/commits/v2.2.3)

---
updated-dependencies:
- dependency-name: minimatch
  dependency-type: indirect
- dependency-name: recursive-readdir
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2022-11-21 05:23:28 +05:00
dependabot[bot] 65abd7c9ab chore(deps): Bump socket.io-parser from 3.3.2 to 3.3.3 in /client (#326)
Bumps [socket.io-parser](https://github.com/socketio/socket.io-parser) from 3.3.2 to 3.3.3.
- [Release notes](https://github.com/socketio/socket.io-parser/releases)
- [Changelog](https://github.com/socketio/socket.io-parser/blob/main/CHANGELOG.md)
- [Commits](https://github.com/socketio/socket.io-parser/compare/3.3.2...3.3.3)

---
updated-dependencies:
- dependency-name: socket.io-parser
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2022-11-21 05:23:17 +05:00
dependabot[bot] 5fc811d2ef chore(deps): Bump socket.io-parser from 3.3.2 to 3.3.3 in /server (#322)
Bumps [socket.io-parser](https://github.com/socketio/socket.io-parser) from 3.3.2 to 3.3.3.
- [Release notes](https://github.com/socketio/socket.io-parser/releases)
- [Changelog](https://github.com/socketio/socket.io-parser/blob/main/CHANGELOG.md)
- [Commits](https://github.com/socketio/socket.io-parser/compare/3.3.2...3.3.3)

---
updated-dependencies:
- dependency-name: socket.io-parser
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2022-11-21 05:23:04 +05:00
Maksim Eltyshev 5afd4af897 ref: Little refactoring 2022-11-21 01:12:32 +01:00
Maksim Eltyshev ac0a3d9ae7 fix: Fix client build 2022-11-21 01:06:41 +01:00
Maksim Eltyshev b0d23373c0 chore: Update dependencies 2022-11-21 00:54:05 +01:00
Rafly Maulana 5381d45e50 meta: Share global eslint config, move prettier config (#339) 2022-11-21 00:22:01 +05:00
Maksim Eltyshev a43af8eac5 chore: Update version 2022-11-20 15:34:04 +01:00
Maksim Eltyshev 409a7cde6b fix: Fix notifications popup styles 2022-11-20 15:30:07 +01:00
Maksim Eltyshev f62cba9bcd fix: Fix board actions for mobile 2022-11-20 15:14:49 +01:00
Maksim Eltyshev 9495234066 ref: Little refactoring 2022-11-20 15:05:21 +01:00
Rafly Maulana 5dcbad7b5a feat: Add markdown editor in card description (#333) 2022-11-20 18:48:42 +05:00
Maksim Eltyshev 064b37050c fix: Hide label actions based on role 2022-11-10 15:28:43 +01:00
Rafly Maulana bf8b8474ec meta: Fix documentation for installing Planka without Docker (#324) 2022-11-10 18:56:55 +05:00
Rafly Maulana 37a9c9bb42 meta: Add documentation for installing Planka without Docker (#323) 2022-11-10 18:48:38 +05:00
Maksim Eltyshev 00fccbaa3d fix: Fix z-indexes 2022-11-02 21:54:30 +01:00
Maksim Eltyshev b3323308c1 fix: Lock postgres version
Closes #312
2022-10-20 12:22:35 +02:00
Jacob Weisz 9108dc5b3e meta: Fix readme typo (#309) 2022-10-12 22:46:00 +05:00
Maksim Eltyshev 513f9a8ee5 chore: Update version 2022-10-03 13:13:59 +02:00
Maksim Eltyshev e051f624a8 ref: Little change for consistency 2022-10-03 13:12:16 +02:00
Maksim Eltyshev 172f4fcf34 fix: Fix missing board memberships loading 2022-10-03 13:04:52 +02:00
Maksim Eltyshev 9f16881965 fix: Socket bug fixes and improvements 2022-10-03 12:11:19 +02:00
Connor Lanigan 4e876922f8 feat: Prefill label creation with search term (#307) 2022-10-03 14:08:39 +05:00
Connor Lanigan d82d928e29 fix: Update German translation (#306) 2022-10-03 14:06:51 +05:00
Maksim Eltyshev 94dc83706c chore: Update version 2022-09-30 11:52:19 +02:00
Jacques Lorentz b08e731419 fix: Front-end base url with path (#303)
Closes #43, closes #111, closes #272
2022-09-30 14:48:58 +05:00
Maksim Eltyshev 1ce0fac281 chore: Update version 2022-09-28 18:46:37 +02:00
Maksim Eltyshev e3d00c8791 fix: Disable quick timer actions for viewers 2022-09-28 18:38:03 +02:00
Maksim Eltyshev e1a0b635ea ref: Little refactoring 2022-09-28 18:30:03 +02:00
Maksim Eltyshev cc7959bb15 chore: Update version 2022-09-16 15:08:51 +05:00
Maksim Eltyshev 56044e6d8a ref: Fix filename 2022-09-16 15:08:09 +05:00
Maksim Eltyshev e5bd73f33e fix: Prevent popup from leaving window 2022-09-16 14:39:41 +05:00
Maksim Eltyshev e65ab6ece7 fix: Fix flag for English 2022-09-15 09:07:57 +05:00
Maksim Eltyshev 14cc55a8c0 feat: Highlight active timer, add quick actions 2022-09-15 00:20:05 +05:00
Maksim Eltyshev b4306d0a4b fix: Fix font of timer numbers 2022-09-14 21:22:08 +05:00
Maksim Eltyshev 283c850ebf feat: Add about pane 2022-09-14 16:52:50 +05:00
Maksim Eltyshev 62de71bd97 fix: Visually highlight card cover 2022-09-13 16:55:22 +05:00
Maksim Eltyshev 447f88880d chore: Update version 2022-09-07 18:43:32 +05:00
Maksim Eltyshev 8109936ce2 feat: Invalidate access token on logout 2022-09-07 18:39:33 +05:00
Maksim Eltyshev 640908320a ref: Remove unused style 2022-09-03 23:03:34 +05:00
Maksim Eltyshev 236c1020b3 chore: Update version 2022-09-03 22:48:19 +05:00
Maksim Eltyshev 3df07c10fa fix: Use password strength estimator
Closes #294
2022-09-03 22:47:06 +05:00
Steven Correia 543a992d98 fix: Fix proxy forwarding (#295) 2022-09-03 20:00:23 +05:00
Maksim Eltyshev 9cde4e63f9 chore: Update version 2022-09-02 18:03:44 +05:00
Maksim Eltyshev 95738ee7d3 fix: Add missing validation for password 2022-09-02 17:59:12 +05:00
Maksim Eltyshev bb7cdf5dd4 chore: Update version 2022-09-02 17:05:48 +05:00
Maksim Eltyshev 253af55790 fix: Add password requirements when creating user
Closes #293
2022-09-02 17:04:51 +05:00
Maksim Eltyshev 62c3cca9a7 fix: Fix password length validation 2022-08-30 15:13:17 +02:00
Maksim Eltyshev b476a19b79 chore: Update version 2022-08-30 13:50:45 +02:00
Maksim Eltyshev 3379c65a87 fix: Prevent tabnabbing in markdown 2022-08-30 13:47:50 +02:00
Maksim Eltyshev 1a6fc40804 chore: Update version 2022-08-26 19:13:10 +02:00
Maksim Eltyshev 42092efcd2 fix: Remove dots in translations 2022-08-26 19:11:32 +02:00
Maksim Eltyshev 5c91bddfe7 feat: Stronger password policy 2022-08-26 18:59:44 +02:00
Maksim Eltyshev fd8f40f1b0 chore: Update version 2022-08-26 16:05:47 +02:00
Maksim Eltyshev 5b64d465e2 fix: Use move-file instead of rename 2022-08-26 16:02:47 +02:00
Maksim Eltyshev 212fb8ac8c fix: Add missing border radius 2022-08-26 02:47:43 +02:00
Maksim Eltyshev cece2254d7 fix: Change mechanics of file uploading 2022-08-26 02:45:27 +02:00
131 changed files with 16045 additions and 16731 deletions
+23 -5
View File
@@ -1,11 +1,29 @@
*/node_modules
*/.gitignore
*/README.md
*/.gitignore
*/node_modules
**/.DS_Store
**/.gitkeep
server/**/.gitkeep
server/.env
server/.editorconfig
server/.eslintignore
server/.npmrc
server/test
server/logs
server/.tmp
server/views/*
server/public/*
server/views/*
server/logs
!server/public/user-avatars
server/public/user-avatars/*
!server/public/project-background-images
server/public/project-background-images/*
server/private/*
!server/private/attachments
server/private/attachments/*
client/build
@@ -9,7 +9,7 @@ on:
workflow_dispatch:
env:
ALPINE_VERSION: 3.15
ALPINE_VERSION: 3.16
jobs:
build-and-push-docker-base-image:
+7
View File
@@ -3,3 +3,10 @@ docker-compose.override.yml
.idea
.DS_Store
# Prevent another lockfile than package-lock.json (npm) from being created
# If some case you are using pnpm or yarn, don't forget to generate npm lockfile
# before commiting your code by running:
# `npm i --package-lock-only`
pnpm-lock.yaml
yarn.lock
+22 -14
View File
@@ -1,32 +1,40 @@
FROM node:lts AS client-builder
FROM ghcr.io/plankanban/planka:base-latest as server-dependencies
WORKDIR /app
COPY client/package.json client/package-lock.json ./
COPY server/package.json server/package-lock.json .
RUN npm install npm@latest --global \
&& npm install
&& npm clean-install --omit=dev
FROM node:lts AS client
WORKDIR /app
COPY client/package.json client/package-lock.json .
RUN npm install npm@latest --global \
&& npm clean-install --omit=dev
COPY client .
RUN npm run build
RUN DISABLE_ESLINT_PLUGIN=true npm run build
FROM ghcr.io/plankanban/planka:base-latest
RUN apk del vips-dependencies --purge
USER node
WORKDIR /app
COPY server/.npmrc server/package.json server/package-lock.json ./
COPY --chown=node:node docker-start.sh start.sh
COPY --chown=node:node server .
RUN npm install npm@latest --global \
&& npm install --production \
&& apk del vips-dependencies --purge
RUN mv .env.sample .env
COPY docker-start.sh start.sh
COPY server .
COPY --from=server-dependencies --chown=node:node /app/node_modules node_modules
RUN cp .env.sample .env
COPY --from=client-builder /app/build public
COPY --from=client-builder /app/build/index.html views
COPY --from=client --chown=node:node /app/build public
COPY --from=client --chown=node:node /app/build/index.html views/index.ejs
VOLUME /app/public/user-avatars
VOLUME /app/public/project-background-images
+2 -2
View File
@@ -1,7 +1,7 @@
FROM node:lts-alpine
ARG ALPINE_VERSION=3.15
ARG VIPS_VERSION=8.12.2
ARG ALPINE_VERSION=3.16
ARG VIPS_VERSION=8.13.3
RUN apk -U upgrade \
&& apk add \
+149 -3
View File
@@ -21,9 +21,14 @@ A Trello-like kanban board built with React and Redux.
## Deploy
### Docker Compose
There are 2 types of installation:
1. [Dockerized](#1-docker-compose)
2. [Without Docker](#2-without-docker)
[![](https://d207aa93qlcgug.cloudfront.net/1.95.5.qa/img/nav/docker-logo-loggedout.png)](https://hub.docker.com/r/meltyshev/planka)
### 1. Docker Compose
[![](https://d207aa93qlcgug.cloudfront.net/1.95.5.qa/img/nav/docker-logo-loggedout.png)](https://github.com/plankanban/planka/pkgs/container/planka)
- Make sure you have [Docker](https://docs.docker.com/install/) and [Docker Compose](https://docs.docker.com/compose/install/) installed and operational.
- Create `docker-compose.yml` based on [the example](https://raw.githubusercontent.com/plankanban/planka/master/docker-compose.yml). This is the ONLY file you will need. You can create this file on your own machine by copy and pasting the content.
@@ -44,9 +49,146 @@ docker-compose up -d
Demo user: demo@demo.demo demo
### 2. Without Docker
Installing without Docker is a bit more complicated, here's what you need to do:
1. Clone this repository into a directory of your choice. (e.g. `/var/www/planka`)
```bash
mkdir -p /var/www/planka
cd /var/www/planka
git clone https://github.com/plankanban/planka.git .
```
2. Install dependencies for client and build it.
```bash
cd client
npm install
npm run build
```
**Note**: You can use `yarn` or `pnpm` instead of `npm`.
3. Copy the `build` directory to the `server/public` directory.
```bash
cp -r build ../server/public
cp build/index.html ../server/views/index.ejs
```
4. Install dependencies for server.
```bash
cd ../server
npm install
```
5. Configure environment variables.
```bash
cp .env.sample .env
# Edit .env file (You could use nano, vim, etc.)
nano .env
```
**Note**: Before continuing, make sure you have your selected database created and running.
6. Copy start script from the root directory to the `server` directory.
```bash
cp ../docker-start.sh start.sh
```
7. Start the server.
```bash
./start.sh
```
**Note**: You can use `pm2` or `systemd` to run the server in the background.
## Additional information
### Nginx configuration
Here is an example of Nginx configuration for Planka, make sure to replace `<domain>` with your domain name, and make sure to configure the SSL.
```nginx
upstream planka {
server localhost:1337;
keepalive 32;
}
server {
listen 443 ssl http2;
listen [::]:443 ssl http2;
server_name <domain>;
access_log /var/log/nginx/planka-access.log;
error_log /var/log/nginx/planka-error.log error;
# SSL Configuration - Replace the example <domain> with your domain
ssl_certificate /etc/letsencrypt/live/<domain>/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/<domain>/privkey.pem;
ssl_session_cache shared:SSL:10m;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers "ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384";
ssl_prefer_server_ciphers on;
client_max_body_size 120M;
add_header Access-Control-Allow-Origin *;
add_header Access-Control-Max-Age 3600;
add_header Access-Control-Expose-Headers Content-Length;
add_header Access-Control-Allow-Headers Range;
# Make sure to allow socket.io connections
location ~* \.io {
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
client_max_body_size 50M;
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Frame-Options SAMEORIGIN;
proxy_buffers 256 16k;
proxy_buffer_size 16k;
client_body_timeout 60;
send_timeout 300;
lingering_timeout 5;
proxy_connect_timeout 1d;
proxy_send_timeout 1d;
proxy_read_timeout 1d;
proxy_pass http://planka;
}
location / {
client_max_body_size 50M;
proxy_set_header Connection "";
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Frame-Options SAMEORIGIN;
proxy_buffers 256 16k;
proxy_buffer_size 16k;
proxy_read_timeout 600s;
proxy_cache_revalidate on;
proxy_cache_min_uses 2;
proxy_cache_use_stale timeout;
proxy_cache_lock on;
proxy_http_version 1.1;
proxy_pass http://planka;
}
}
```
### Logging
Planka currently allows you to expose the applciation's logfile directory to the host machine via a shared volume. This feature is not enabled by default.
Planka currently allows you to expose the application's logfile directory to the host machine via a shared volume. This feature is not enabled by default.
To expose the logfile director to the host machine, add the item `./logs/:/app/logs/` under `services.planka.volumes`.
@@ -117,6 +259,10 @@ Restart the fail2ban service. You can check the status of your Planka jail by ru
fail2ban-client status planka
```
### Import from Trello
It is not yet implemented inside Planka, but for now you can use the great tool [trello2planka](https://github.com/christophenne/trello2planka) to do the import.
## Development
Clone the repository and install dependencies:
+56
View File
@@ -0,0 +1,56 @@
const fs = require('fs');
const path = require('path');
const BASE_URL_PLACEHOLDER = 'BASE_URL_PLACEHOLDER';
const replaceInFile = (file, search, replace) => {
fs.readFile(file, 'utf8', (readError, data) => {
if (readError) {
throw new Error(`${readError}`);
}
const res = data.replaceAll(search, replace);
fs.writeFile(file, res, 'utf8', (writeError) => {
if (writeError) {
throw new Error(`${writeError}`);
}
});
});
};
const replaceBaseUrl = (compiler) => {
compiler.hooks.assetEmitted.tap('ReplaceBaseUrlPlaceholder', (file, info) => {
if (info.content.indexOf(BASE_URL_PLACEHOLDER) >= 0) {
if (/\.css$/.exec(info.targetPath)) {
// For CSS 'url(...)' import we can use relative import
const relPath = path.relative(path.dirname(info.targetPath), info.outputPath);
replaceInFile(info.targetPath, BASE_URL_PLACEHOLDER, `${relPath}/`);
} else if (/\.js$/.exec(info.targetPath)) {
// For JS 'import ... from "some-asset"' we can get the variable injected in the window object
// eslint-disable-next-line no-template-curly-in-string
replaceInFile(info.targetPath, `"${BASE_URL_PLACEHOLDER}"`, '`${window.BASE_URL}/`');
} else if (/index\.html$/.exec(info.targetPath)) {
// For the main html file, we set a placeholder for sails to inject the correct value as runtime
replaceInFile(info.targetPath, BASE_URL_PLACEHOLDER, '<%= BASE_URL %>');
}
}
});
};
module.exports = function override(config, env) {
if (env === 'production') {
const plugins = config.plugins.map((plugin) => {
if (plugin.constructor.name === 'InterpolateHtmlPlugin') {
const newPlugin = plugin;
newPlugin.replacements.PUBLIC_URL = BASE_URL_PLACEHOLDER;
return newPlugin;
}
return plugin;
});
return {
...config,
output: { ...config.output, publicPath: BASE_URL_PLACEHOLDER },
plugins: [...plugins, { apply: replaceBaseUrl }],
};
}
return config;
};
+11404 -14566
View File
File diff suppressed because it is too large Load Diff
+38 -47
View File
@@ -2,11 +2,11 @@
"name": "planka-client",
"private": true,
"scripts": {
"build": "react-scripts build",
"build": "react-app-rewired build",
"eject": "react-scripts eject",
"lint": "eslint --ext js,jsx src",
"start": "react-scripts start",
"test": "react-scripts test"
"lint": "eslint --ext js,jsx src config-overrides.js",
"start": "react-app-rewired start",
"test": "react-app-rewired test"
},
"browserslist": {
"production": [
@@ -34,15 +34,13 @@
},
"requireConfigFile": false
},
"plugins": [
"prettier"
],
"extends": [
"airbnb",
"airbnb/hooks",
"plugin:prettier/recommended"
"../package.json"
],
"rules": {
"no-unused-vars": "warn",
"import/no-extraneous-dependencies": [
"error",
{
@@ -51,73 +49,66 @@
"**/*.test.js"
]
}
],
"prettier/prettier": [
"error",
{
"printWidth": 100,
"singleQuote": true,
"trailingComma": "all"
}
]
}
},
"dependencies": {
"classnames": "^2.3.1",
"connected-react-router": "^6.9.3",
"date-fns": "^2.29.1",
"@juggle/resize-observer": "^3.4.0",
"classnames": "^2.3.2",
"date-fns": "^2.29.3",
"dequal": "^2.0.3",
"history": "^4.10.1",
"i18next": "^21.8.14",
"i18next-browser-languagedetector": "^6.1.4",
"easymde": "^2.18.0",
"history": "^5.3.0",
"i18next": "^22.0.6",
"i18next-browser-languagedetector": "^7.0.1",
"initials": "^3.1.2",
"js-cookie": "^3.0.1",
"jwt-decode": "^3.1.2",
"lodash": "^4.17.21",
"node-sass": "^7.0.1",
"photoswipe": "^5.3.0",
"node-sass": "^8.0.0",
"photoswipe": "^5.3.3",
"prop-types": "^15.8.1",
"react": "^17.0.2",
"react-beautiful-dnd": "^13.1.0",
"react": "^18.2.0",
"react-app-rewired": "^2.2.1",
"react-beautiful-dnd": "^13.1.1",
"react-datepicker": "^4.8.0",
"react-dom": "^17.0.2",
"react-dropzone": "^14.2.2",
"react-i18next": "^11.18.1",
"react-dom": "^18.2.0",
"react-dropzone": "^14.2.3",
"react-i18next": "^12.0.0",
"react-input-mask": "^2.0.4",
"react-markdown": "^8.0.3",
"react-photoswipe-gallery": "^2.2.1",
"react-redux": "^7.2.8",
"react-router-dom": "^5.3.1",
"react-photoswipe-gallery": "^2.2.2",
"react-redux": "^8.0.5",
"react-router-dom": "^6.4.3",
"react-scripts": "5.0.1",
"react-textarea-autosize": "^8.3.4",
"react-simplemde-editor": "^5.2.0",
"react-textarea-autosize": "^8.4.0",
"redux": "^4.2.0",
"redux-logger": "^3.0.6",
"redux-orm": "^0.16.2",
"redux-saga": "^1.1.3",
"redux-saga": "^1.2.1",
"remark-breaks": "^3.0.2",
"remark-gfm": "^3.0.1",
"reselect": "^4.1.6",
"reselect": "^4.1.7",
"sails.io.js": "^1.2.1",
"semantic-ui-react": "^2.1.3",
"socket.io-client": "^2.3.1",
"socket.io-client": "^2.5.0",
"validator": "^13.7.0",
"whatwg-fetch": "^3.6.2"
"whatwg-fetch": "^3.6.2",
"zxcvbn": "^4.4.2"
},
"devDependencies": {
"@wojtekmaj/enzyme-adapter-react-17": "^0.6.7",
"@testing-library/jest-dom": "^5.16.5",
"@testing-library/react": "^13.4.0",
"@testing-library/user-event": "^13.5.0",
"babel-preset-airbnb": "^5.0.0",
"chai": "^4.3.6",
"enzyme": "^3.11.0",
"eslint": "^8.20.0",
"chai": "^4.3.7",
"eslint": "^8.28.0",
"eslint-config-airbnb": "^19.0.4",
"eslint-config-prettier": "^8.5.0",
"eslint-plugin-import": "^2.26.0",
"eslint-plugin-jsx-a11y": "^6.6.1",
"eslint-plugin-prettier": "^4.2.1",
"eslint-plugin-react": "^7.30.1",
"eslint-plugin-react": "^7.31.11",
"eslint-plugin-react-hooks": "^4.6.0",
"jest-enzyme": "^7.1.2",
"prettier": "2.7.1",
"react-test-renderer": "^17.0.2"
"react-test-renderer": "^18.2.0"
}
}
+1
View File
@@ -26,6 +26,7 @@
-->
<title>Planka</title>
</head>
<script>window.BASE_URL = "%PUBLIC_URL%";</script>
<body id="app">
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
+5
View File
@@ -44,6 +44,11 @@ const logout = () => ({
payload: {},
});
logout.invalidateAccessToken = () => ({
type: ActionTypes.LOGOUT__ACCESS_TOKEN_INVALIDATE,
payload: {},
});
export default {
initializeCore,
logout,
+2 -1
View File
@@ -103,10 +103,11 @@ const updateUserPassword = (id, data) => ({
},
});
updateUserPassword.success = (user) => ({
updateUserPassword.success = (user, accessToken) => ({
type: ActionTypes.USER_PASSWORD_UPDATE__SUCCESS,
payload: {
user,
accessToken,
},
});
+5
View File
@@ -1,9 +1,14 @@
import http from './http';
import socket from './socket';
/* Actions */
const createAccessToken = (data, headers) => http.post('/access-tokens', data, headers);
const deleteCurrentAccessToken = (headers) =>
socket.delete('/access-tokens/me', undefined, headers);
export default {
createAccessToken,
deleteCurrentAccessToken,
};
+2 -1
View File
@@ -5,7 +5,7 @@ import Config from '../constants/Config';
const io = sailsIOClient(socketIOClient);
io.sails.url = Config.SERVER_BASE_URL;
io.sails.url = Config.SERVER_HOST_NAME;
io.sails.autoConnect = false;
io.sails.reconnection = true;
io.sails.useCORSRouteToGetCookie = false;
@@ -13,6 +13,7 @@ io.sails.environment = process.env.NODE_ENV;
const { socket } = io;
socket.path = `${Config.BASE_PATH}/socket.io`;
socket.connect = socket._connect; // eslint-disable-line no-underscore-dangle
['GET', 'POST', 'PUT', 'PATCH', 'DELETE'].forEach((method) => {
Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

@@ -14,6 +14,7 @@ const BoardActions = React.memo(
filterUsers,
filterLabels,
allUsers,
canEdit,
canEditMemberships,
onMembershipCreate,
onMembershipUpdate,
@@ -27,32 +28,35 @@ const BoardActions = React.memo(
onLabelDelete,
}) => {
return (
<div className={styles.actions}>
<div className={styles.action}>
<Memberships
items={memberships}
allUsers={allUsers}
permissionsSelectStep={BoardMembershipPermissionsSelectStep}
canEdit={canEditMemberships}
onCreate={onMembershipCreate}
onUpdate={onMembershipUpdate}
onDelete={onMembershipDelete}
/>
</div>
<div className={styles.action}>
<Filters
users={filterUsers}
labels={filterLabels}
allBoardMemberships={memberships}
allLabels={labels}
onUserAdd={onUserToFilterAdd}
onUserRemove={onUserFromFilterRemove}
onLabelAdd={onLabelToFilterAdd}
onLabelRemove={onLabelFromFilterRemove}
onLabelCreate={onLabelCreate}
onLabelUpdate={onLabelUpdate}
onLabelDelete={onLabelDelete}
/>
<div className={styles.wrapper}>
<div className={styles.actions}>
<div className={styles.action}>
<Memberships
items={memberships}
allUsers={allUsers}
permissionsSelectStep={BoardMembershipPermissionsSelectStep}
canEdit={canEditMemberships}
onCreate={onMembershipCreate}
onUpdate={onMembershipUpdate}
onDelete={onMembershipDelete}
/>
</div>
<div className={styles.action}>
<Filters
users={filterUsers}
labels={filterLabels}
allBoardMemberships={memberships}
allLabels={labels}
canEdit={canEdit}
onUserAdd={onUserToFilterAdd}
onUserRemove={onUserFromFilterRemove}
onLabelAdd={onLabelToFilterAdd}
onLabelRemove={onLabelFromFilterRemove}
onLabelCreate={onLabelCreate}
onLabelUpdate={onLabelUpdate}
onLabelDelete={onLabelDelete}
/>
</div>
</div>
</div>
);
@@ -67,6 +71,7 @@ BoardActions.propTypes = {
filterLabels: PropTypes.array.isRequired,
allUsers: PropTypes.array.isRequired,
/* eslint-enable react/forbid-prop-types */
canEdit: PropTypes.bool.isRequired,
canEditMemberships: PropTypes.bool.isRequired,
onMembershipCreate: PropTypes.func.isRequired,
onMembershipUpdate: PropTypes.func.isRequired,
@@ -1,5 +1,6 @@
:global(#app) {
.action {
flex: 0 0 auto;
margin-right: 20px;
}
@@ -8,4 +9,15 @@
display: flex;
margin: 20px 20px;
}
.wrapper {
overflow-x: auto;
overflow-y: hidden;
-ms-overflow-style: none;
scrollbar-width: none;
&::-webkit-scrollbar {
display: none;
}
}
}
@@ -15,6 +15,7 @@ const Filters = React.memo(
labels,
allBoardMemberships,
allLabels,
canEdit,
onUserAdd,
onUserRemove,
onLabelAdd,
@@ -74,6 +75,7 @@ const Filters = React.memo(
title={t('common.filterByLabels', {
context: 'title',
})}
canEdit={canEdit}
onSelect={onLabelAdd}
onDeselect={onLabelRemove}
onCreate={onLabelCreate}
@@ -108,6 +110,7 @@ Filters.propTypes = {
allBoardMemberships: PropTypes.array.isRequired,
allLabels: PropTypes.array.isRequired,
/* eslint-enable react/forbid-prop-types */
canEdit: PropTypes.bool.isRequired,
onUserAdd: PropTypes.func.isRequired,
onUserRemove: PropTypes.func.isRequired,
onLabelAdd: PropTypes.func.isRequired,
@@ -19,7 +19,7 @@ const BoardMembershipPermissionsSelectStep = React.memo(
...defaultData,
}));
const handleRoleSelectClick = useCallback((role) => {
const handleSelectRoleClick = useCallback((role) => {
setData((prevData) => ({
...prevData,
role,
@@ -50,7 +50,7 @@ const BoardMembershipPermissionsSelectStep = React.memo(
<Menu secondary vertical className={styles.menu}>
<Menu.Item
active={data.role === BoardMembershipRoles.EDITOR}
onClick={() => handleRoleSelectClick(BoardMembershipRoles.EDITOR)}
onClick={() => handleSelectRoleClick(BoardMembershipRoles.EDITOR)}
>
<div className={styles.menuItemTitle}>{t('common.editor')}</div>
<div className={styles.menuItemDescription}>
@@ -59,7 +59,7 @@ const BoardMembershipPermissionsSelectStep = React.memo(
</Menu.Item>
<Menu.Item
active={data.role === BoardMembershipRoles.VIEWER}
onClick={() => handleRoleSelectClick(BoardMembershipRoles.VIEWER)}
onClick={() => handleSelectRoleClick(BoardMembershipRoles.VIEWER)}
>
<div className={styles.menuItemTitle}>{t('common.viewer')}</div>
<div className={styles.menuItemDescription}>{t('common.canOnlyViewBoard')}</div>
+19 -4
View File
@@ -5,6 +5,7 @@ import { Button, Icon } from 'semantic-ui-react';
import { Link } from 'react-router-dom';
import { Draggable } from 'react-beautiful-dnd';
import { startTimer, stopTimer } from '../../utils/timer';
import Paths from '../../constants/Paths';
import Tasks from './Tasks';
import NameEdit from './NameEdit';
@@ -57,6 +58,17 @@ const Card = React.memo(
}
}, []);
const handleToggleTimerClick = useCallback(
(event) => {
event.preventDefault();
onUpdate({
timer: timer.startedAt ? stopTimer(timer) : startTimer(timer),
});
},
[timer, onUpdate],
);
const handleNameUpdate = useCallback(
(newName) => {
onUpdate({
@@ -108,7 +120,13 @@ const Card = React.memo(
)}
{timer && (
<span className={classNames(styles.attachment, styles.attachmentLeft)}>
<Timer startedAt={timer.startedAt} total={timer.total} size="tiny" />
<Timer
as="span"
startedAt={timer.startedAt}
total={timer.total}
size="tiny"
onClick={canEdit ? handleToggleTimerClick : undefined}
/>
</span>
)}
</span>
@@ -148,14 +166,11 @@ const Card = React.memo(
{canEdit && (
<ActionsPopup
card={{
id,
name,
dueDate,
timer,
boardId,
listId,
projectId,
isPersisted,
}}
projectsToLists={allProjectsToLists}
boardMemberships={allBoardMemberships}
@@ -78,7 +78,9 @@
.cover {
border-radius: 3px 3px 0 0;
margin-bottom: 2px;
padding: 2px 2px 0;
vertical-align: middle;
width: 100%;
}
.details {
@@ -10,7 +10,7 @@
position: absolute;
text-align: center;
width: 100%;
z-index: 2000;
z-index: 2001;
}
.wrapper {
@@ -29,7 +29,6 @@
padding: 6px 32px 6px 128px;
margin: 0;
min-height: 80px;
z-index: 0;
}
.extension {
@@ -95,7 +94,6 @@
text-decoration: none;
top: 50%;
width: 112px;
z-index: 1;
}
.thumbnailLabel {
@@ -5,6 +5,7 @@ import { useTranslation } from 'react-i18next';
import { Button, Grid, Icon, Modal } from 'semantic-ui-react';
import { Markdown } from '../../lib/custom-ui';
import { startTimer, stopTimer } from '../../utils/timer';
import NameField from './NameField';
import DescriptionEdit from './DescriptionEdit';
import Tasks from './Tasks';
@@ -80,6 +81,12 @@ const CardModal = React.memo(
const isGalleryOpened = useRef(false);
const handleToggleTimerClick = useCallback(() => {
onUpdate({
timer: timer.startedAt ? stopTimer(timer) : startTimer(timer),
});
}, [timer, onUpdate]);
const handleNameUpdate = useCallback(
(newName) => {
onUpdate({
@@ -291,6 +298,19 @@ const CardModal = React.memo(
<Timer startedAt={timer.startedAt} total={timer.total} />
)}
</span>
{canEdit && (
<button
onClick={handleToggleTimerClick}
type="button"
className={classNames(styles.attachment, styles.dueDate)}
>
<Icon
name={timer.startedAt ? 'pause' : 'play'}
size="small"
className={styles.addAttachment}
/>
</button>
)}
</div>
)}
</div>
@@ -1,19 +1,15 @@
import React, { useCallback, useEffect, useImperativeHandle, useRef, useState } from 'react';
import React, { useCallback, useImperativeHandle, useMemo, useState } from 'react';
import PropTypes from 'prop-types';
import { useTranslation } from 'react-i18next';
import TextareaAutosize from 'react-textarea-autosize';
import { Button, Form, TextArea } from 'semantic-ui-react';
import { useClosableForm, useField } from '../../hooks';
import { Button, Form } from 'semantic-ui-react';
import SimpleMDE from 'react-simplemde-editor';
import styles from './DescriptionEdit.module.scss';
const DescriptionEdit = React.forwardRef(({ children, defaultValue, onUpdate }, ref) => {
const [t] = useTranslation();
const [isOpened, setIsOpened] = useState(false);
const [value, handleFieldChange, setValue] = useField(null);
const field = useRef(null);
const [value, setValue] = useState(null);
const open = useCallback(() => {
setIsOpened(true);
@@ -55,20 +51,37 @@ const DescriptionEdit = React.forwardRef(({ children, defaultValue, onUpdate },
[close],
);
const [handleFieldBlur, handleControlMouseOver, handleControlMouseOut] = useClosableForm(
close,
isOpened,
);
const handleSubmit = useCallback(() => {
close();
}, [close]);
useEffect(() => {
if (isOpened) {
field.current.ref.current.focus();
}
}, [isOpened]);
const mdEditorOptions = useMemo(
() => ({
autofocus: true,
spellChecker: false,
status: false,
toolbar: [
'bold',
'italic',
'heading',
'strikethrough',
'|',
'quote',
'unordered-list',
'ordered-list',
'table',
'|',
'link',
'image',
'|',
'undo',
'redo',
'|',
'guide',
],
}),
[],
);
if (!isOpened) {
return React.cloneElement(children, {
@@ -78,26 +91,17 @@ const DescriptionEdit = React.forwardRef(({ children, defaultValue, onUpdate },
return (
<Form onSubmit={handleSubmit}>
<TextArea
ref={field}
as={TextareaAutosize}
<SimpleMDE
value={value}
options={mdEditorOptions}
placeholder={t('common.enterDescription')}
minRows={3}
spellCheck={false}
className={styles.field}
onKeyDown={handleFieldKeyDown}
onChange={handleFieldChange}
onBlur={handleFieldBlur}
onChange={setValue}
/>
<div className={styles.controls}>
{/* eslint-disable-next-line jsx-a11y/mouse-events-have-key-events */}
<Button
positive
content={t('action.save')}
onMouseOver={handleControlMouseOver}
onMouseOut={handleControlMouseOut}
/>
<Button positive content={t('action.save')} />
</div>
</Form>
);
@@ -6,15 +6,12 @@
.field {
background: #fff;
border: 1px solid rgba(9, 30, 66, 0.13);
border-radius: 3px;
color: #17394d;
display: block;
font-size: 14px;
line-height: 1.5;
margin-bottom: 4px;
overflow: hidden;
padding: 8px 12px;
resize: none;
&:focus {
@@ -2,5 +2,6 @@
.wrapper {
position: fixed;
width: 100%;
z-index: 1;
}
}
+7 -1
View File
@@ -15,6 +15,7 @@ const Header = React.memo(
project,
user,
notifications,
isLogouting,
canEditProject,
canEditUsers,
onProjectSettingsClick,
@@ -75,7 +76,11 @@ const Header = React.memo(
)}
</Menu.Item>
</NotificationsPopup>
<UserPopup onSettingsClick={onUserSettingsClick} onLogout={onLogout}>
<UserPopup
isLogouting={isLogouting}
onSettingsClick={onUserSettingsClick}
onLogout={onLogout}
>
<Menu.Item className={classNames(styles.item, styles.itemHoverable)}>
{user.name}
</Menu.Item>
@@ -93,6 +98,7 @@ Header.propTypes = {
user: PropTypes.object.isRequired,
notifications: PropTypes.array.isRequired,
/* eslint-enable react/forbid-prop-types */
isLogouting: PropTypes.bool.isRequired,
canEditProject: PropTypes.bool.isRequired,
canEditUsers: PropTypes.bool.isRequired,
onProjectSettingsClick: PropTypes.func.isRequired,
@@ -7,7 +7,8 @@
background: none;
}
&:active, &:hover {
&:active,
&:hover {
background: transparent;
color: rgba(255, 255, 255, 0.9);
}
@@ -42,7 +43,6 @@
height: 50px;
margin: 0;
width: 100%;
z-index: 1;
}
.notification {
@@ -1,3 +1,4 @@
import truncate from 'lodash/truncate';
import React, { useCallback } from 'react';
import PropTypes from 'prop-types';
import { useTranslation, Trans } from 'react-i18next';
@@ -47,23 +48,26 @@ const NotificationsStep = React.memo(({ items, onDelete, onClose }) => {
{activity.data.toList.name}
</Trans>
);
case ActivityTypes.COMMENT_CARD:
case ActivityTypes.COMMENT_CARD: {
const commentText = truncate(activity.data.text);
return (
<Trans
i18nKey="common.userLeftNewCommentToCard"
values={{
user: activity.user.name,
comment: activity.data.text,
comment: commentText,
card: card.name,
}}
>
{activity.user.name}
{` left a new comment «${activity.data.text}» to `}
{` left a new comment «${commentText}» to `}
<Link to={Paths.CARDS.replace(':id', card.id)} onClick={onClose}>
{card.name}
</Link>
</Trans>
);
}
default:
}
@@ -80,9 +84,10 @@ const NotificationsStep = React.memo(({ items, onDelete, onClose }) => {
})}
</Popup.Header>
<Popup.Content>
{items.length > 0
? items.map((item) => (
<div key={item.id} className={styles.wrapper}>
{items.length > 0 ? (
<div className={styles.wrapper}>
{items.map((item) => (
<div key={item.id} className={styles.item}>
{item.card && item.activity ? (
<>
<User
@@ -90,20 +95,23 @@ const NotificationsStep = React.memo(({ items, onDelete, onClose }) => {
avatarUrl={item.activity.user.avatarUrl}
size="large"
/>
<span className={styles.content}>{renderItemContent(item)}</span>
<span className={styles.itemContent}>{renderItemContent(item)}</span>
</>
) : (
<div className={styles.deletedContent}>{t('common.cardOrActionAreDeleted')}</div>
<div className={styles.itemDeleted}>{t('common.cardOrActionAreDeleted')}</div>
)}
<Button
type="button"
icon="close"
className={styles.button}
icon="trash alternate outline"
className={styles.itemButton}
onClick={() => handleDelete(item.id)}
/>
</div>
))
: t('common.noUnreadNotifications')}
))}
</div>
) : (
t('common.noUnreadNotifications')
)}
</Popup.Content>
</>
);
@@ -1,5 +1,13 @@
:global(#app) {
.button {
.item {
padding: 12px;
&:hover {
background: #f0f0f0;
}
}
.itemButton {
background: transparent;
box-shadow: none;
float: right;
@@ -7,7 +15,7 @@
line-height: 20px;
margin: 0;
min-height: auto;
padding: 5px 0;
padding: 0;
transition: background 0.3s ease;
width: 20px;
@@ -16,7 +24,7 @@
}
}
.content {
.itemContent {
display: inline-block;
font-size: 12px;
min-height: 36px;
@@ -27,7 +35,7 @@
word-break: break-word;
}
.deletedContent {
.itemDeleted {
display: inline-block;
line-height: 20px;
min-height: 20px;
@@ -38,10 +46,21 @@
.wrapper {
margin: 0 -12px;
padding: 12px;
max-height: 60vh;
overflow-x: hidden;
overflow-y: auto;
scrollbar-width: thin;
&:hover {
background: #f0f0f0;
&::-webkit-scrollbar {
width: 5px;
}
&::-webkit-scrollbar-track {
background: transparent;
}
&::-webkit-scrollbar-thumb {
border-radius: 3px;
}
}
}
+3 -1
View File
@@ -10,12 +10,13 @@ import Editor from './Editor';
import styles from './AddStep.module.scss';
const AddStep = React.memo(({ onCreate, onBack }) => {
const AddStep = React.memo(({ defaultData, onCreate, onBack }) => {
const [t] = useTranslation();
const [data, handleFieldChange] = useForm(() => ({
name: '',
color: LabelColors[0],
...defaultData,
}));
const handleSubmit = useCallback(() => {
@@ -46,6 +47,7 @@ const AddStep = React.memo(({ onCreate, onBack }) => {
});
AddStep.propTypes = {
defaultData: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types
onCreate: PropTypes.func.isRequired,
onBack: PropTypes.func.isRequired,
};
+37 -31
View File
@@ -8,45 +8,51 @@ import { Button } from 'semantic-ui-react';
import styles from './Item.module.scss';
import globalStyles from '../../styles.module.scss';
const Item = React.memo(({ name, color, isPersisted, isActive, onSelect, onDeselect, onEdit }) => {
const handleToggleClick = useCallback(() => {
if (isActive) {
onDeselect();
} else {
onSelect();
}
}, [isActive, onSelect, onDeselect]);
const Item = React.memo(
({ name, color, isPersisted, isActive, canEdit, onSelect, onDeselect, onEdit }) => {
const handleToggleClick = useCallback(() => {
if (isActive) {
onDeselect();
} else {
onSelect();
}
}, [isActive, onSelect, onDeselect]);
return (
<div className={styles.wrapper}>
<Button
fluid
content={name}
active={isActive}
disabled={!isPersisted}
className={classNames(
styles.labelButton,
isActive && styles.labelButtonActive,
globalStyles[`background${upperFirst(camelCase(color))}`],
return (
<div className={styles.wrapper}>
<Button
fluid
content={name}
active={isActive}
disabled={!isPersisted}
className={classNames(
styles.labelButton,
isActive && styles.labelButtonActive,
globalStyles[`background${upperFirst(camelCase(color))}`],
)}
onClick={handleToggleClick}
/>
{canEdit && (
<Button
icon="pencil"
size="small"
floated="right"
disabled={!isPersisted}
className={styles.editButton}
onClick={onEdit}
/>
)}
onClick={handleToggleClick}
/>
<Button
icon="pencil"
size="small"
disabled={!isPersisted}
className={styles.editButton}
onClick={onEdit}
/>
</div>
);
});
</div>
);
},
);
Item.propTypes = {
name: PropTypes.string,
color: PropTypes.string.isRequired,
isPersisted: PropTypes.bool.isRequired,
isActive: PropTypes.bool.isRequired,
canEdit: PropTypes.bool.isRequired,
onSelect: PropTypes.func.isRequired,
onDeselect: PropTypes.func.isRequired,
onEdit: PropTypes.func.isRequired,
@@ -4,7 +4,6 @@
box-shadow: none;
flex: 0 0 auto;
font-weight: normal;
margin: 0;
padding: 8px 10px;
text-decoration: underline;
@@ -18,6 +17,7 @@
flex: 1 1 auto;
font-size: 14px;
font-weight: bold;
margin-right: 0;
overflow: hidden;
padding: 8px 32px 8px 10px;
position: relative;
@@ -18,7 +18,18 @@ const StepTypes = {
};
const LabelsStep = React.memo(
({ items, currentIds, title, onSelect, onDeselect, onCreate, onUpdate, onDelete, onBack }) => {
({
items,
currentIds,
title,
canEdit,
onSelect,
onDeselect,
onCreate,
onUpdate,
onDelete,
onBack,
}) => {
const [t] = useTranslation();
const [step, openStep, handleBack] = useSteps();
const [search, handleSearchChange] = useField('');
@@ -84,7 +95,15 @@ const LabelsStep = React.memo(
if (step) {
switch (step.type) {
case StepTypes.ADD:
return <AddStep onCreate={onCreate} onBack={handleBack} />;
return (
<AddStep
defaultData={{
name: search,
}}
onCreate={onCreate}
onBack={handleBack}
/>
);
case StepTypes.EDIT: {
const currentItem = items.find((item) => item.id === step.params.id);
@@ -132,6 +151,7 @@ const LabelsStep = React.memo(
color={item.color}
isPersisted={item.isPersisted}
isActive={currentIds.includes(item.id)}
canEdit={canEdit}
onSelect={() => handleSelect(item.id)}
onDeselect={() => handleDeselect(item.id)}
onEdit={() => handleEdit(item.id)}
@@ -139,12 +159,14 @@ const LabelsStep = React.memo(
))}
</div>
)}
<Button
fluid
content={t('action.createNewLabel')}
className={styles.addButton}
onClick={handleAddClick}
/>
{canEdit && (
<Button
fluid
content={t('action.createNewLabel')}
className={styles.addButton}
onClick={handleAddClick}
/>
)}
</Popup.Content>
</>
);
@@ -157,6 +179,7 @@ LabelsStep.propTypes = {
currentIds: PropTypes.array.isRequired,
/* eslint-enable react/forbid-prop-types */
title: PropTypes.string,
canEdit: PropTypes.bool,
onSelect: PropTypes.func.isRequired,
onDeselect: PropTypes.func.isRequired,
onCreate: PropTypes.func.isRequired,
@@ -167,6 +190,7 @@ LabelsStep.propTypes = {
LabelsStep.defaultProps = {
title: 'common.labels',
canEdit: true,
onBack: undefined,
};
+13 -12
View File
@@ -1,8 +1,8 @@
import React from 'react';
import PropTypes from 'prop-types';
import { Provider } from 'react-redux';
import { Route, Switch } from 'react-router-dom';
import { ConnectedRouter } from 'connected-react-router';
import { Route, Routes } from 'react-router-dom';
import { ReduxRouter } from '../lib/redux-router';
import Paths from '../constants/Paths';
import LoginContainer from '../containers/LoginContainer';
@@ -11,6 +11,7 @@ import NotFound from './NotFound';
import 'react-datepicker/dist/react-datepicker.css';
import 'photoswipe/dist/photoswipe.css';
import 'easymde/dist/easymde.min.css';
import '../lib/custom-ui/styles.css';
import '../styles.module.scss';
@@ -18,16 +19,16 @@ import '../styles.module.scss';
function Root({ store, history }) {
return (
<Provider store={store}>
<ConnectedRouter history={history}>
<Switch>
<Route exact path={Paths.LOGIN} component={LoginContainer} />
<Route exact path={Paths.ROOT} component={CoreWrapperContainer} />
<Route exact path={Paths.PROJECTS} component={CoreWrapperContainer} />
<Route exact path={Paths.BOARDS} component={CoreWrapperContainer} />
<Route exact path={Paths.CARDS} component={CoreWrapperContainer} />
<Route path="*" component={NotFound} />
</Switch>
</ConnectedRouter>
<ReduxRouter history={history}>
<Routes>
<Route path={Paths.LOGIN} element={<LoginContainer />} />
<Route path={Paths.ROOT} element={<CoreWrapperContainer />} />
<Route path={Paths.PROJECTS} element={<CoreWrapperContainer />} />
<Route path={Paths.BOARDS} element={<CoreWrapperContainer />} />
<Route path={Paths.CARDS} element={<CoreWrapperContainer />} />
<Route path="*" element={<NotFound />} />
</Routes>
</ReduxRouter>
</Provider>
);
}
+8 -3
View File
@@ -14,7 +14,7 @@ const SIZES = {
MEDIUM: 'medium',
};
const Timer = React.memo(({ startedAt, total, size, isDisabled, onClick }) => {
const Timer = React.memo(({ as, startedAt, total, size, isDisabled, onClick }) => {
const prevStartedAt = usePrevious(startedAt);
const forceUpdate = useForceUpdate();
@@ -52,6 +52,7 @@ const Timer = React.memo(({ startedAt, total, size, isDisabled, onClick }) => {
className={classNames(
styles.wrapper,
styles[`wrapper${upperFirst(size)}`],
startedAt && styles.wrapperActive,
onClick && styles.wrapperHoverable,
)}
>
@@ -59,16 +60,19 @@ const Timer = React.memo(({ startedAt, total, size, isDisabled, onClick }) => {
</span>
);
const ElementType = as;
return onClick ? (
<button type="button" disabled={isDisabled} className={styles.button} onClick={onClick}>
<ElementType type="button" disabled={isDisabled} className={styles.button} onClick={onClick}>
{contentNode}
</button>
</ElementType>
) : (
contentNode
);
});
Timer.propTypes = {
as: PropTypes.elementType,
startedAt: PropTypes.instanceOf(Date),
total: PropTypes.number.isRequired, // eslint-disable-line react/no-unused-prop-types
size: PropTypes.oneOf(Object.values(SIZES)),
@@ -77,6 +81,7 @@ Timer.propTypes = {
};
Timer.defaultProps = {
as: 'button',
startedAt: undefined,
size: SIZES.MEDIUM,
isDisabled: false,
+13 -3
View File
@@ -9,17 +9,28 @@
}
.wrapper {
background: #f3f4f6;
background: #dce0e4;
border: none;
border-radius: 3px;
color: #7e8ea3;
color: #6a808b;
display: inline-block;
font-variant-numeric: tabular-nums;
outline: none;
text-align: left;
transition: background 0.3s ease;
vertical-align: top;
}
.wrapperActive {
background: #21ba45;
color: #fff;
&.wrapperHoverable:hover {
background: #16ab39;
color: #fff;
}
}
.wrapperHoverable:hover {
background: #d2d8dc;
color: #17394d;
@@ -42,6 +53,5 @@
.wrapperMedium {
line-height: 20px;
padding: 6px 12px;
text-decoration: underline;
}
}
@@ -8,7 +8,7 @@ import { withPopup } from '../../lib/popup';
import { Input, Popup } from '../../lib/custom-ui';
import { useForm } from '../../hooks';
import { isUsername } from '../../utils/validator';
import { isPassword, isUsername } from '../../utils/validator';
import styles from './UserAddPopup.module.scss';
@@ -69,7 +69,7 @@ const UserAddStep = React.memo(
return;
}
if (!cleanData.password) {
if (!cleanData.password || !isPassword(cleanData.password)) {
passwordField.current.focus();
return;
}
@@ -143,6 +143,7 @@ const UserAddStep = React.memo(
/>
<div className={styles.text}>{t('common.password')}</div>
<Input.Password
withStrengthBar
fluid
ref={passwordField}
name="password"
@@ -7,6 +7,7 @@ import { useDidUpdate, usePrevious, useToggle } from '../../lib/hooks';
import { Input, Popup } from '../../lib/custom-ui';
import { useForm } from '../../hooks';
import { isPassword } from '../../utils/validator';
import styles from './UserPasswordEditStep.module.scss';
@@ -56,7 +57,7 @@ const UserPasswordEditStep = React.memo(
const currentPasswordField = useRef(null);
const handleSubmit = useCallback(() => {
if (!data.password) {
if (!data.password || !isPassword(data.password)) {
passwordField.current.select();
return;
}
@@ -113,6 +114,7 @@ const UserPasswordEditStep = React.memo(
<Form onSubmit={handleSubmit}>
<div className={styles.text}>{t('common.newPassword')}</div>
<Input.Password
withStrengthBar
fluid
ref={passwordField}
name="password"
+19 -3
View File
@@ -1,13 +1,13 @@
import React, { useCallback } from 'react';
import PropTypes from 'prop-types';
import { useTranslation } from 'react-i18next';
import { Menu } from 'semantic-ui-react';
import { Button, Menu } from 'semantic-ui-react';
import { withPopup } from '../../lib/popup';
import { Popup } from '../../lib/custom-ui';
import styles from './UserPopup.module.scss';
const UserStep = React.memo(({ onSettingsClick, onLogout, onClose }) => {
const UserStep = React.memo(({ isLogouting, onSettingsClick, onLogout, onClose }) => {
const [t] = useTranslation();
const handleSettingsClick = useCallback(() => {
@@ -15,6 +15,17 @@ const UserStep = React.memo(({ onSettingsClick, onLogout, onClose }) => {
onClose();
}, [onSettingsClick, onClose]);
let logoutMenuItemProps;
if (isLogouting) {
logoutMenuItemProps = {
as: Button,
fluid: true,
basic: true,
loading: true,
disabled: true,
};
}
return (
<>
<Popup.Header>
@@ -29,7 +40,11 @@ const UserStep = React.memo(({ onSettingsClick, onLogout, onClose }) => {
context: 'title',
})}
</Menu.Item>
<Menu.Item className={styles.menuItem} onClick={onLogout}>
<Menu.Item
{...logoutMenuItemProps} // eslint-disable-line react/jsx-props-no-spreading
className={styles.menuItem}
onClick={onLogout}
>
{t('action.logOut', {
context: 'title',
})}
@@ -41,6 +56,7 @@ const UserStep = React.memo(({ onSettingsClick, onLogout, onClose }) => {
});
UserStep.propTypes = {
isLogouting: PropTypes.bool.isRequired,
onSettingsClick: PropTypes.func.isRequired,
onLogout: PropTypes.func.isRequired,
onClose: PropTypes.func.isRequired,
@@ -0,0 +1,19 @@
import React from 'react';
// import { useTranslation } from 'react-i18next';
import { Image, Tab } from 'semantic-ui-react';
import logo from '../../assets/images/logo.png';
import styles from './AboutPane.module.scss';
const AboutPane = React.memo(() => {
// const [t] = useTranslation();
return (
<Tab.Pane attached={false} className={styles.wrapper}>
<Image centered src={logo} size="large" className={styles.logo} />
</Tab.Pane>
);
});
export default AboutPane;
@@ -0,0 +1,10 @@
:global(#app) {
/* .logo {
margin-bottom: 16px;
} */
.wrapper {
border: none;
box-shadow: none;
}
}
@@ -1,6 +1,7 @@
:global(#app) {
.action {
border: none;
border-radius: 0.28571429rem;
display: inline-block;
height: 36px;
overflow: hidden;
@@ -5,6 +5,7 @@ import { Modal, Tab } from 'semantic-ui-react';
import AccountPane from './AccountPane';
import PreferencesPane from './PreferencesPane';
import AboutPane from './AboutPane';
const UserSettingsModal = React.memo(
({
@@ -71,6 +72,10 @@ const UserSettingsModal = React.memo(
<PreferencesPane subscribeToOwnCards={subscribeToOwnCards} onUpdate={onUpdate} />
),
},
{
menuItem: 'About Planka',
render: () => <AboutPane />,
},
];
return (
+1
View File
@@ -21,6 +21,7 @@ export default {
CORE_INITIALIZE: 'CORE_INITIALIZE',
LOGOUT: 'LOGOUT',
LOGOUT__ACCESS_TOKEN_INVALIDATE: 'LOGOUT__ACCESS_TOKEN_INVALIDATE',
/* Modals */
+8 -1
View File
@@ -1,6 +1,11 @@
const { BASE_URL } = window;
const BASE_PATH = BASE_URL.replace(/^.*\/\/[^/]*(.*)[^?#]*.*$/, '$1');
const SERVER_BASE_URL =
process.env.REACT_APP_SERVER_BASE_URL ||
(process.env.NODE_ENV === 'production' ? '' : 'http://localhost:1337');
(process.env.NODE_ENV === 'production' ? BASE_URL : 'http://localhost:1337');
const SERVER_HOST_NAME = SERVER_BASE_URL.replace(/^(.*\/\/[^/?#]*).*$/, '$1');
const ACCESS_TOKEN_KEY = 'accessToken';
const ACCESS_TOKEN_VERSION_KEY = 'accessTokenVersion';
@@ -10,7 +15,9 @@ const POSITION_GAP = 65535;
const ACTIVITIES_LIMIT = 50;
export default {
BASE_PATH,
SERVER_BASE_URL,
SERVER_HOST_NAME,
ACCESS_TOKEN_KEY,
ACCESS_TOKEN_VERSION_KEY,
ACCESS_TOKEN_VERSION,
+7 -5
View File
@@ -1,8 +1,10 @@
const ROOT = '/';
const LOGIN = '/login';
const PROJECTS = '/projects/:id';
const BOARDS = '/boards/:id';
const CARDS = '/cards/:id';
import Config from './Config';
const ROOT = `${Config.BASE_PATH}/`;
const LOGIN = `${Config.BASE_PATH}/login`;
const PROJECTS = `${Config.BASE_PATH}/projects/:id`;
const BOARDS = `${Config.BASE_PATH}/boards/:id`;
const CARDS = `${Config.BASE_PATH}/cards/:id`;
export default {
ROOT,
@@ -3,6 +3,7 @@ import { connect } from 'react-redux';
import selectors from '../selectors';
import entryActions from '../entry-actions';
import { BoardMembershipRoles } from '../constants/Enums';
import BoardActions from '../components/BoardActions';
const mapStateToProps = (state) => {
@@ -12,6 +13,7 @@ const mapStateToProps = (state) => {
const labels = selectors.selectLabelsForCurrentBoard(state);
const filterUsers = selectors.selectFilterUsersForCurrentBoard(state);
const filterLabels = selectors.selectFilterLabelsForCurrentBoard(state);
const currentUserMembership = selectors.selectCurrentUserMembershipForCurrentBoard(state);
return {
memberships,
@@ -19,6 +21,7 @@ const mapStateToProps = (state) => {
filterUsers,
filterLabels,
allUsers,
canEdit: !!currentUserMembership && currentUserMembership.role === BoardMembershipRoles.EDITOR,
canEditMemberships: isCurrentUserManager,
};
};
+1 -1
View File
@@ -1,7 +1,7 @@
import { bindActionCreators } from 'redux';
import { connect } from 'react-redux';
import { push } from 'connected-react-router';
import omit from 'lodash/omit';
import { push } from '../lib/redux-router';
import selectors from '../selectors';
import entryActions from '../entry-actions';
+2
View File
@@ -6,6 +6,7 @@ import entryActions from '../entry-actions';
import Header from '../components/Header';
const mapStateToProps = (state) => {
const isLogouting = selectors.selectIsLogouting(state);
const currentUser = selectors.selectCurrentUser(state);
const currentProject = selectors.selectCurrentProject(state);
const notifications = selectors.selectNotificationsForCurrentUser(state);
@@ -13,6 +14,7 @@ const mapStateToProps = (state) => {
return {
notifications,
isLogouting,
project: currentProject,
user: currentUser,
canEditProject: isCurrentUserManager,
+3 -2
View File
@@ -1,5 +1,5 @@
import React from 'react';
import ReactDOM from 'react-dom';
import ReactDOM from 'react-dom/client';
import store from './store';
import history from './history';
@@ -7,4 +7,5 @@ import Root from './components/Root';
import './i18n';
ReactDOM.render(React.createElement(Root, { store, history }), document.getElementById('root'));
const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(React.createElement(Root, { store, history }));
@@ -1,22 +1,74 @@
import React, { useCallback } from 'react';
import { Icon, Input } from 'semantic-ui-react';
import zxcvbn from 'zxcvbn';
import React, { useCallback, useMemo } from 'react';
import PropTypes from 'prop-types';
import { Icon, Input, Progress } from 'semantic-ui-react';
import { useToggle } from '../../../hooks';
const InputPassword = React.forwardRef((props, ref) => {
const [isVisible, toggleVisible] = useToggle();
import styles from './InputPassword.module.css';
const handleToggleClick = useCallback(() => {
toggleVisible();
}, [toggleVisible]);
const STRENGTH_SCORE_COLORS = ['red', 'orange', 'yellow', 'olive', 'green'];
return (
<Input
{...props} // eslint-disable-line react/jsx-props-no-spreading
ref={ref}
type={isVisible ? 'text' : 'password'}
icon={<Icon link name={isVisible ? 'eye' : 'eye slash'} onClick={handleToggleClick} />}
/>
);
});
const InputPassword = React.forwardRef(
({ value, withStrengthBar, minStrengthScore, className, ...props }, ref) => {
const [isVisible, toggleVisible] = useToggle();
const strengthScore = useMemo(() => {
if (!withStrengthBar) {
return undefined;
}
return zxcvbn(value).score;
}, [value, withStrengthBar]);
const handleToggleClick = useCallback(() => {
toggleVisible();
}, [toggleVisible]);
const inputProps = {
...props,
ref,
type: isVisible ? 'text' : 'password',
icon: <Icon link name={isVisible ? 'eye' : 'eye slash'} onClick={handleToggleClick} />,
};
if (!withStrengthBar) {
return (
<Input
{...inputProps} // eslint-disable-line react/jsx-props-no-spreading
className={className}
/>
);
}
return (
<div className={className}>
<Input
{...inputProps} // eslint-disable-line react/jsx-props-no-spreading
error={!!value && strengthScore < minStrengthScore}
/>
<Progress
value={value ? strengthScore + 1 : 0}
total={5}
color={STRENGTH_SCORE_COLORS[strengthScore]}
size="tiny"
className={styles.strengthBar}
/>
</div>
);
},
);
InputPassword.propTypes = {
value: PropTypes.string.isRequired,
withStrengthBar: PropTypes.bool,
minStrengthScore: PropTypes.number,
className: PropTypes.string,
};
InputPassword.defaultProps = {
withStrengthBar: false,
minStrengthScore: 2,
className: undefined,
};
export default React.memo(InputPassword);
@@ -0,0 +1,4 @@
.strengthBar {
margin: 4px 0 0 !important;
opacity: 0.5;
}
@@ -6,6 +6,8 @@ import remarkBreaks from 'remark-breaks';
import './Markdown.module.scss'; // FIXME: import as styles?
const ABSOLUTE_URL_REGEX = /^(?:https?:)?\/\//i;
const Markdown = React.memo(({ linkStopPropagation, ...props }) => {
const handleLinkClick = useCallback((event) => {
event.stopPropagation();
@@ -16,25 +18,30 @@ const Markdown = React.memo(({ linkStopPropagation, ...props }) => {
jsx-a11y/click-events-have-key-events,
jsx-a11y/no-static-element-interactions,
react/jsx-props-no-spreading */
({ node, ...linkProps }) => <a {...linkProps} onClick={handleLinkClick} />,
({ node, ...linkProps }) => (
<a
{...linkProps}
rel={
ABSOLUTE_URL_REGEX.test(linkProps.href) && linkProps.target === '_blank'
? 'noreferrer'
: undefined
}
onClick={linkStopPropagation ? handleLinkClick : undefined}
/>
),
/* eslint-enable jsx-a11y/anchor-has-content,
jsx-a11y/click-events-have-key-events,
jsx-a11y/no-static-element-interactions,
react/jsx-props-no-spreading */
[handleLinkClick],
[linkStopPropagation, handleLinkClick],
);
let components;
if (linkStopPropagation) {
components = {
a: linkRenderer,
};
}
return (
<ReactMarkdown
{...props} // eslint-disable-line react/jsx-props-no-spreading
components={components}
components={{
a: linkRenderer,
}}
remarkPlugins={[remarkGfm, remarkBreaks]}
className="markdown-body"
/>
+5 -5
View File
@@ -14453,11 +14453,11 @@ img.ui.bordered.image {
---------------------*/
.ui.input.error > input {
background-color: #fff6f6;
border-color: #e0b4b4;
color: #9f3a38;
-webkit-box-shadow: none;
box-shadow: none;
background-color: #fff6f6 !important;
border-color: #e0b4b4 !important;
color: #9f3a38 !important;
-webkit-box-shadow: none !important;
box-shadow: none !important;
}
/* Error Placeholder */
+37 -5
View File
@@ -1,4 +1,5 @@
import React, { useCallback, useState } from 'react';
import { ResizeObserver } from '@juggle/resize-observer';
import React, { useCallback, useRef, useState } from 'react';
import PropTypes from 'prop-types';
import { Button, Popup as SemanticUIPopup } from 'semantic-ui-react';
@@ -8,6 +9,9 @@ export default (WrappedComponent, defaultProps) => {
const Popup = React.memo(({ children, ...props }) => {
const [isOpened, setIsOpened] = useState(false);
const wrapper = useRef(null);
const resizeObserver = useRef(null);
const handleOpen = useCallback(() => {
setIsOpened(true);
}, []);
@@ -37,6 +41,29 @@ export default (WrappedComponent, defaultProps) => {
[children],
);
const handleContentRef = useCallback((element) => {
if (resizeObserver.current) {
resizeObserver.current.disconnect();
}
if (!element) {
resizeObserver.current = null;
return;
}
resizeObserver.current = new ResizeObserver(() => {
if (resizeObserver.current.isInitial) {
resizeObserver.current.isInitial = false;
return;
}
wrapper.current.positionUpdate();
});
resizeObserver.current.isInitial = true;
resizeObserver.current.observe(element);
}, []);
const tigger = React.cloneElement(children, {
onClick: handleTriggerClick,
});
@@ -45,6 +72,7 @@ export default (WrappedComponent, defaultProps) => {
<SemanticUIPopup
basic
wide
ref={wrapper}
trigger={tigger}
on="click"
open={isOpened}
@@ -52,8 +80,10 @@ export default (WrappedComponent, defaultProps) => {
popperModifiers={[
{
name: 'preventOverflow',
enabled: true,
options: {
boundariesElement: 'window',
altAxis: true,
padding: 20,
},
},
]}
@@ -64,9 +94,11 @@ export default (WrappedComponent, defaultProps) => {
onClick={handleClick}
{...defaultProps} // eslint-disable-line react/jsx-props-no-spreading
>
<Button icon="close" onClick={handleClose} className={styles.closeButton} />
{/* eslint-disable-next-line react/jsx-props-no-spreading */}
<WrappedComponent {...props} onClose={handleClose} />
<div ref={handleContentRef}>
<Button icon="close" onClick={handleClose} className={styles.closeButton} />
{/* eslint-disable-next-line react/jsx-props-no-spreading */}
<WrappedComponent {...props} onClose={handleClose} />
</div>
</SemanticUIPopup>
);
});
@@ -0,0 +1,46 @@
import React, { useLayoutEffect } from 'react';
import PropTypes from 'prop-types';
import { useDispatch, useSelector } from 'react-redux';
import { Router } from 'react-router-dom';
import { handleLocationChange } from './actions';
function ReduxRouter({ children, history, selector, basename }) {
const state = useSelector(selector);
const dispatch = useDispatch();
useLayoutEffect(() => {
const unlisten = history.listen((nextState) => {
dispatch(handleLocationChange(nextState.location, nextState.action));
});
dispatch(handleLocationChange(history.location, history.action, true));
return unlisten;
}, [history, dispatch]);
return (
<Router
basename={basename}
location={state.location}
navigationType={state.action}
navigator={history}
>
{children}
</Router>
);
}
ReduxRouter.propTypes = {
children: PropTypes.element.isRequired,
history: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types
selector: PropTypes.func,
basename: PropTypes.string,
};
ReduxRouter.defaultProps = {
selector: ({ router }) => router,
basename: undefined,
};
export default ReduxRouter;
+28
View File
@@ -0,0 +1,28 @@
export const LOCATION_CHANGE_HANDLE = '@@router/LOCATION_CHANGE_HANDLE';
export const handleLocationChange = (location, action, isFirstRendering = false) => ({
type: LOCATION_CHANGE_HANDLE,
payload: {
location,
action,
isFirstRendering,
},
});
export const HISTORY_METHOD_CALL = '@@router/HISTORY_METHOD_CALL';
const createHistoryMethodCaller = (method) => {
return (...args) => ({
type: HISTORY_METHOD_CALL,
payload: {
method,
args,
},
});
};
export const push = createHistoryMethodCaller('push');
export const replace = createHistoryMethodCaller('replace');
export const go = createHistoryMethodCaller('go');
export const back = createHistoryMethodCaller('back');
export const forward = createHistoryMethodCaller('forward');
@@ -0,0 +1,18 @@
import { HISTORY_METHOD_CALL } from './actions';
const createRouterMiddleware = (history) => {
// eslint-disable-next-line consistent-return
return () => (next) => (action) => {
if (action.type !== HISTORY_METHOD_CALL) {
return next(action);
}
const {
payload: { method, args },
} = action;
history[method](...args);
};
};
export default createRouterMiddleware;
@@ -0,0 +1,28 @@
import { LOCATION_CHANGE_HANDLE } from './actions';
const createRouterReducer = (history) => {
const initialState = {
location: history.location,
action: history.action,
};
return (state = initialState, { type, payload } = {}) => {
if (type === LOCATION_CHANGE_HANDLE) {
const { location, action, isFirstRendering } = payload;
if (isFirstRendering) {
return state;
}
return {
...state,
location,
action,
};
}
return state;
};
};
export default createRouterReducer;
+12
View File
@@ -0,0 +1,12 @@
export {
LOCATION_CHANGE_HANDLE,
HISTORY_METHOD_CALL,
push,
replace,
go,
back,
forward,
} from './actions';
export { default as createRouterReducer } from './create-router-reducer';
export { default as createRouterMiddleware } from './create-router-middleware';
export { default as ReduxRouter } from './ReduxRouter';
+1 -1
View File
@@ -117,7 +117,7 @@ export default {
phone: 'Telefon',
preferences: 'Volby',
pressPasteShortcutToAddAttachmentFromClipboard:
'Tip: dejte Ctrl-V (Cmd-V na Mac) pro vložení přílohy ze schránky.',
'Tip: dejte Ctrl-V (Cmd-V na Mac) pro vložení přílohy ze schránky',
project: 'Projekt',
projectNotFound_title: 'Projekt nenalezen',
removeManager_title: 'Odstranit vedoucího',
+18 -17
View File
@@ -4,11 +4,11 @@ export default {
dateFns,
format: {
date: 'd/M/yyyy',
date: 'd.MM.yyyy',
time: 'p',
dateTime: '$t(format:date) $t(format:time)',
longDate: 'MMM d',
longDateTime: "d MMMM 'at' p",
longDate: 'd. MMM',
longDateTime: "d. MMMM 'um' p",
},
translation: {
@@ -23,7 +23,7 @@ export default {
administrator: 'Administrator',
all: 'Alle',
allChangesWillBeAutomaticallySavedAfterConnectionRestored:
'Alle Änderungen werden automatisch gespeichert,<br />sobald die Verbindung wiederhergestellt wurde.',
'Alle Änderungen werden automatisch gespeichert, sobald die Verbindung wiederhergestellt wurde.',
areYouSureYouWantToDeleteThisAttachment:
'Sind Sie sicher, dass Sie diesen Anhang löschen möchten?',
areYouSureYouWantToDeleteThisBoard: 'Sind Sie sicher, dass Sie dieses Board löschen möchten?',
@@ -55,10 +55,11 @@ export default {
color: 'Farbe',
createBoard_title: 'Board erstellen',
createLabel_title: 'Label erstellen',
createNewOneOrSelectExistingOne: 'Ein Neues erstellen, oder <br />ein Bestehendes auswählen',
createNewOneOrSelectExistingOne:
'Erstellen Sie ein neues oder wählen Sie ein bestehendes aus.',
createProject_title: 'Projekt erstellen',
createTextFile_title: 'Textdatei erstellen',
currentPassword: 'Derzeitiges Password',
currentPassword: 'Derzeitiges Passwort',
dangerZone_title: 'Gefährlicher Bereich',
date: 'Datum',
dueDate_title: 'Fälligkeitsdatum',
@@ -73,18 +74,18 @@ export default {
deleteUser_title: 'Benutzer löschen',
description: 'Beschreibung',
detectAutomatically: 'Automatische Erkennung',
dropFileToUpload: 'Datei ablegen, um hochzuladen',
dropFileToUpload: 'Datei ablegen, um sie hochzuladen',
editAttachment_title: 'Anhang bearbieten',
editAvatar_title: 'Avatar bearbeiten',
editBoard_title: 'Board bearbeiten',
editDueDate_title: 'Fälligkeitsdatum bearbeiten',
editEmail_title: 'E-Mail bearbeiten',
editEmail_title: 'E-Mail-Adresse bearbeiten',
editLabel_title: 'Label bearbeiten',
editPassword_title: 'Passwort ändern',
editTimer_title: 'Timer bearbeiten',
editUsername_title: 'Benutzername ändern',
email: 'E-Mail',
emailAlreadyInUse: 'E-Mail bereits in Verwendung',
email: 'E-Mail-Adresse',
emailAlreadyInUse: 'E-Mail-Adresse bereits in Verwendung',
enterCardTitle: 'Kartentitel eingeben...',
enterDescription: 'Beschreibung eingeben...',
enterFilename: 'Dateiname eingeben',
@@ -108,7 +109,7 @@ export default {
minutes: 'Minuten',
moveCard_title: 'Karte verschieben',
name: 'Name',
newEmail: 'Neue E-Mail',
newEmail: 'Neue E-Mail-Adresse',
newPassword: 'Neues Passwort',
newUsername: 'Neuer Benutzername',
noConnectionToServer: 'Keine Verbindung zum Server',
@@ -123,7 +124,7 @@ export default {
phone: 'Telefon',
preferences: 'Voreinstellungen',
pressPasteShortcutToAddAttachmentFromClipboard:
'Tipp: Drücken Sie STRG-V (Cmd-V auf Mac), um einen Anhang aus der Zwischenablage hinzuzufügen.',
'Tipp: Drücken Sie STRG-V (Cmd-V auf Mac), um einen Anhang aus der Zwischenablage hinzuzufügen',
project: 'Projekt',
projectNotFound_title: 'Projekt nicht gefunden',
removeManager_title: 'Manager entfernen',
@@ -140,10 +141,10 @@ export default {
time: 'Zeit',
timer: 'Timer',
title: 'Titel',
userActions_title: 'Benutzerraktionen',
userActions_title: 'Benutzeraktionen',
userAddedThisCardToList: '<0>{{user}}</0><1> hat diese Karte hinzugefügt zu {{list}}</1>',
userLeftNewCommentToCard:
'{{user}} hat einen neuen Kommentar verfasst «{{comment}}» to <2>{{card}}</2>',
'{{user}} hat einen neuen Kommentar verfasst: «{{comment}}» in <2>{{card}}</2>',
userMovedCardFromListToList:
'{{user}} bewegte <2>{{card}}</2> von {{fromList}} nach {{toList}}',
userMovedThisCardFromListToList:
@@ -190,7 +191,7 @@ export default {
edit: 'Bearbeiten',
editDueDate_title: 'Fälligkeitsdatum bearbeiten',
editDescription_title: 'Beschreibung ändern',
editEmail_title: 'E-Mail bearbeiten',
editEmail_title: 'E-Mail-Adresse bearbeiten',
editPassword_title: 'Passwort ändern',
editTimer_title: 'Timer bearbeiten',
editTitle_title: 'Titel bearbeiten',
@@ -199,7 +200,7 @@ export default {
leaveBoard: 'Board verlassen',
leaveProject: 'Projekt verlassen',
logOut_title: 'Ausloggen',
makeCover_title: 'als Vorschau festlegen',
makeCover_title: 'Als Vorschau festlegen',
move: 'Verschieben',
moveCard_title: 'Karte bewegen',
remove: 'Löschen',
@@ -216,7 +217,7 @@ export default {
start: 'Start',
stop: 'Stopp',
subscribe: 'Abonnieren',
unsubscribe: 'Deabonnieren',
unsubscribe: 'De-abonnieren',
uploadNewAvatar: 'Neuen Avatar hochladen',
uploadNewImage: 'Neues Bild hochladen',
},
+3 -3
View File
@@ -1,14 +1,14 @@
export default {
translation: {
common: {
emailOrUsername: 'E-mail oder Benutzername',
invalidEmailOrUsername: 'Ungültige E-mail oder Benutzername',
emailOrUsername: 'E-Mail-Adresse oder Benutzername',
invalidEmailOrUsername: 'Ungültige E-Mail-Adresse oder Benutzername',
invalidPassword: 'Ungültiges Passwort',
logInToPlanka: 'Einloggen',
noInternetConnection: 'Keine Internetverbindung',
pageNotFound_title: 'Seite nicht gefunden',
password: 'Passwort',
projectManagement: 'Projekt Management',
projectManagement: 'Projekt-Management',
serverConnectionFailed: 'Serververbindung fehlgeschlagen',
unknownError: 'Unbekannter Fehler, bitte später erneut versuchen',
},
+1 -1
View File
@@ -121,7 +121,7 @@ export default {
phone: 'Phone',
preferences: 'Preferences',
pressPasteShortcutToAddAttachmentFromClipboard:
'Tip: press Ctrl-V (Cmd-V on Mac) to add an attachment from the clipboard.',
'Tip: press Ctrl-V (Cmd-V on Mac) to add an attachment from the clipboard',
project: 'Project',
projectNotFound_title: 'Project Not Found',
removeManager_title: 'Remove Manager',
+1 -1
View File
@@ -5,7 +5,7 @@ import core from './core';
export default {
language: 'en',
country: 'us',
country: 'gb',
name: 'English',
embeddedLocale: merge(login, core),
};
+2 -2
View File
@@ -106,7 +106,7 @@ export default {
phone: 'Teléfono',
preferences: 'Preferencias',
pressPasteShortcutToAddAttachmentFromClipboard:
'Tip: presiona Ctrl-V (Cmd-V en Mac) para añadir adjuntos desde el portapapeles.',
'Tip: presiona Ctrl-V (Cmd-V en Mac) para añadir adjuntos desde el portapapeles',
project: 'Proyecto',
projectNotFound_title: 'Proyecto no encontrado',
removeMember_title: 'Remover Miembro',
@@ -115,7 +115,7 @@ export default {
selectList: 'Seleccionar Lista',
selectProject: 'Seleccionar Proyecto',
settings: 'Configuraciones',
subscribeToMyOwnCardsByDefault: 'Suscribirme a mis propias tarjetas por defecto.',
subscribeToMyOwnCardsByDefault: 'Suscribirme a mis propias tarjetas por defecto',
taskActions_title: 'Acciones de Tareas',
tasks: 'Tareas',
time: 'Tiempo',
+1 -1
View File
@@ -121,7 +121,7 @@ export default {
phone: 'Telefono',
preferences: 'Preferenze',
pressPasteShortcutToAddAttachmentFromClipboard:
'Tip: prmi Ctrl-V (Cmd-V on Mac) per aggiungere un allegato dalla clipboard.',
'Tip: prmi Ctrl-V (Cmd-V on Mac) per aggiungere un allegato dalla clipboard',
project: 'Progetto',
projectNotFound_title: 'Progetto non trovato',
removeManager_title: 'Rimuovi Manager',
+1 -1
View File
@@ -117,7 +117,7 @@ export default {
phone: '電話番号',
preferences: '環境設定',
pressPasteShortcutToAddAttachmentFromClipboard:
'ヒント: Ctrl-V(MacではCmd-V)を押して、クリップボードから添付ファイルを追加します。',
'ヒント: Ctrl-V(MacではCmd-V)を押して、クリップボードから添付ファイルを追加します',
project: 'プロジェクト',
projectNotFound_title: 'プロジェクトがありません',
removeManager_title: 'マネージャーを削除',
+1 -1
View File
@@ -106,7 +106,7 @@ export default {
phone: 'Telefon',
preferences: 'Preferencje',
pressPasteShortcutToAddAttachmentFromClipboard:
'Podpowiedź: naciśnij Ctrl-V (Cmd-V na Macu) aby dodać załącznik ze schowka.',
'Podpowiedź: naciśnij Ctrl-V (Cmd-V na Macu) aby dodać załącznik ze schowka',
project: 'Projekt',
projectNotFound_title: 'Projekt Nie Znaleziony',
removeMember_title: 'Usuń Członka',
+1 -1
View File
@@ -116,7 +116,7 @@ export default {
phone: 'Телефон',
preferences: 'Предпочтения',
pressPasteShortcutToAddAttachmentFromClipboard:
'Совет: нажмите Ctrl-V (Cmd-V на Mac), чтобы добавить вложение из буфера обмена.',
'Совет: нажмите Ctrl-V (Cmd-V на Mac), чтобы добавить вложение из буфера обмена',
project: 'Проект',
projectActions: 'Действия с проектом',
projectNotFound: 'Проект не найден',
+1 -1
View File
@@ -118,7 +118,7 @@ export default {
phone: 'Telefón',
preferences: 'Voľby',
pressPasteShortcutToAddAttachmentFromClipboard:
'Tip: stlačte Ctrl-V (Cmd-V na Mac) pre vloženie prílohy zo schránky.',
'Tip: stlačte Ctrl-V (Cmd-V na Mac) pre vloženie prílohy zo schránky',
project: 'Projekt',
projectNotFound_title: 'Projekt neexistuje',
removeManager_title: 'Odstrániť správcu',
+1 -1
View File
@@ -119,7 +119,7 @@ export default {
phone: 'Telefon',
preferences: 'Preferenser',
pressPasteShortcutToAddAttachmentFromClipboard:
'Tips: tryck på Ctrl-V (Cmd-V på Mac) för att lägga till en bilaga från urklipp.',
'Tips: tryck på Ctrl-V (Cmd-V på Mac) för att lägga till en bilaga från urklipp',
project: 'Projekt',
projectNotFound_title: 'Projekt Ej Funnet',
removeManager_title: 'Ta Bort Projektledare',
+1 -1
View File
@@ -112,7 +112,7 @@ export default {
phone: 'Telefon',
preferences: 'Afzalliklar',
pressPasteShortcutToAddAttachmentFromClipboard:
"Tip: Buferdan ilova qo'shish uchun Ctrl-V (Mac da Cmd-V) ni bosing.",
"Tip: Buferdan ilova qo'shish uchun Ctrl-V (Mac da Cmd-V) ni bosing",
project: 'Loyiha',
projectNotFound_title: 'Loyiha Topilmadi',
removeManager_title: "Boshqaruvchini O'chirish",
+1 -1
View File
@@ -110,7 +110,7 @@ export default {
phone: '电话',
preferences: '偏好',
pressPasteShortcutToAddAttachmentFromClipboard:
'提示: 点击 Ctrl-V (Mac: Cmd-V) 从剪切板添加附件.',
'提示: 点击 Ctrl-V (Mac: Cmd-V) 从剪切板添加附件',
project: '项目',
projectNotFound_title: '项目未找到',
removeManager_title: '删除管理员',
+16
View File
@@ -1,18 +1,34 @@
import { getAccessToken } from '../utils/access-token-storage';
import ActionTypes from '../constants/ActionTypes';
const initialState = {
accessToken: getAccessToken(),
userId: null,
};
// eslint-disable-next-line default-param-last
export default (state = initialState, { type, payload }) => {
switch (type) {
case ActionTypes.AUTHENTICATE__SUCCESS:
return {
...state,
accessToken: payload.accessToken,
};
case ActionTypes.SOCKET_RECONNECT_HANDLE:
case ActionTypes.CORE_INITIALIZE:
return {
...state,
userId: payload.user.id,
};
case ActionTypes.USER_PASSWORD_UPDATE__SUCCESS:
if (payload.accessToken) {
return {
...state,
accessToken: payload.accessToken,
};
}
return state;
default:
return state;
}
+8 -2
View File
@@ -1,17 +1,18 @@
import { LOCATION_CHANGE } from 'connected-react-router';
import { LOCATION_CHANGE_HANDLE } from '../lib/redux-router';
import ActionTypes from '../constants/ActionTypes';
import ModalTypes from '../constants/ModalTypes';
const initialState = {
isInitializing: true,
isLogouting: false,
currentModal: null,
};
// eslint-disable-next-line default-param-last
export default (state = initialState, { type, payload }) => {
switch (type) {
case LOCATION_CHANGE:
case LOCATION_CHANGE_HANDLE:
case ActionTypes.MODAL_CLOSE:
return {
...state,
@@ -22,6 +23,11 @@ export default (state = initialState, { type, payload }) => {
...state,
isInitializing: false,
};
case ActionTypes.LOGOUT__ACCESS_TOKEN_INVALIDATE:
return {
...state,
isLogouting: true,
};
case ActionTypes.MODAL_OPEN:
return {
...state,
+2 -2
View File
@@ -1,5 +1,5 @@
import { connectRouter } from 'connected-react-router';
import { createRouterReducer } from '../lib/redux-router';
import history from '../history';
export default connectRouter(history);
export default createRouterReducer(history);
+1 -3
View File
@@ -1,9 +1,8 @@
import { all, apply, call, fork, take } from 'redux-saga/effects';
import { all, apply, fork, take } from 'redux-saga/effects';
import watchers from './watchers';
import services from './services';
import { socket } from '../../api';
import { removeAccessToken } from '../../utils/access-token-storage';
import ActionTypes from '../../constants/ActionTypes';
import Paths from '../../constants/Paths';
@@ -15,6 +14,5 @@ export default function* coreSaga() {
yield take(ActionTypes.LOGOUT);
yield call(removeAccessToken);
window.location.href = Paths.LOGIN;
}
+5 -3
View File
@@ -1,7 +1,8 @@
import { call, fork, join, put, take } from 'redux-saga/effects';
import { call, fork, join, put, select, take } from 'redux-saga/effects';
import selectors from '../../selectors';
import actions from '../../actions';
import { getAccessToken } from '../../utils/access-token-storage';
import { removeAccessToken } from '../../utils/access-token-storage';
import ErrorCodes from '../../constants/ErrorCodes';
let lastRequestTask;
@@ -13,7 +14,7 @@ function* queueRequest(method, ...args) {
} catch {} // eslint-disable-line no-empty
}
const accessToken = yield call(getAccessToken);
const accessToken = yield select(selectors.selectAccessToken);
try {
return yield call(method, ...args, {
@@ -21,6 +22,7 @@ function* queueRequest(method, ...args) {
});
} catch (error) {
if (error.code === ErrorCodes.UNAUTHORIZED) {
yield call(removeAccessToken);
yield put(actions.logout()); // TODO: next url
yield take();
}
+2 -2
View File
@@ -23,9 +23,9 @@ export function* fetchBoardByCurrentPath() {
if (pathsMatch) {
let boardId;
if (pathsMatch.path === Paths.BOARDS) {
if (pathsMatch.pattern.path === Paths.BOARDS) {
boardId = pathsMatch.params.id;
} else if (pathsMatch.path === Paths.CARDS) {
} else if (pathsMatch.pattern.path === Paths.CARDS) {
({
item: card,
item: { boardId },
+14 -1
View File
@@ -1,8 +1,11 @@
import { call, put, take } from 'redux-saga/effects';
import request from '../request';
import requests from '../requests';
import actions from '../../../actions';
import api from '../../../api';
import i18n from '../../../i18n';
import { removeAccessToken } from '../../../utils/access-token-storage';
export function* initializeCore() {
const {
@@ -60,7 +63,17 @@ export function* changeCoreLanguage(language) {
}
}
export function* logout() {
export function* logout(invalidateAccessToken = true) {
yield call(removeAccessToken);
if (invalidateAccessToken) {
yield put(actions.logout.invalidateAccessToken());
try {
yield call(request, api.deleteCurrentAccessToken);
} catch (error) {} // eslint-disable-line no-empty
}
yield put(actions.logout());
yield take();
}
+4 -4
View File
@@ -1,5 +1,5 @@
import { call, put, select, take } from 'redux-saga/effects';
import { push } from 'connected-react-router';
import { push } from '../../../lib/redux-router';
import request from '../request';
import selectors from '../../../selectors';
@@ -31,7 +31,7 @@ export function* handleLocationChange() {
return;
}
switch (pathsMatch.path) {
switch (pathsMatch.pattern.path) {
case Paths.LOGIN:
yield call(goToRoot);
@@ -58,7 +58,7 @@ export function* handleLocationChange() {
let attachments;
let deletedNotifications;
switch (pathsMatch.path) {
switch (pathsMatch.pattern.path) {
case Paths.BOARDS:
case Paths.CARDS: {
const currentBoard = yield select(selectors.selectCurrentBoard);
@@ -85,7 +85,7 @@ export function* handleLocationChange() {
} catch (error) {} // eslint-disable-line no-empty
}
if (pathsMatch.path === Paths.CARDS) {
if (pathsMatch.pattern.path === Paths.CARDS) {
const notificationIds = yield select(selectors.selectNotificationIdsForCurrentCard);
if (notificationIds && notificationIds.length > 0) {
+6 -4
View File
@@ -124,11 +124,13 @@ export function* updateUserPassword(id, data) {
return;
}
if (accessTokens && accessTokens[0]) {
yield call(setAccessToken, accessTokens[0]);
const accessToken = accessTokens && accessTokens[0];
if (accessToken) {
yield call(setAccessToken, accessToken);
}
yield put(actions.updateUserPassword.success(user));
yield put(actions.updateUserPassword.success(user, accessToken));
}
export function* updateCurrentUserPassword(data) {
@@ -215,7 +217,7 @@ export function* handleUserDelete(user) {
const currentUserId = yield select(selectors.selectCurrentUserId);
if (user.id === currentUserId) {
yield call(logout);
yield call(logout, false);
}
yield put(actions.handleUserDelete(user));
+2 -2
View File
@@ -1,8 +1,8 @@
import { takeEvery } from 'redux-saga/effects';
import { LOCATION_CHANGE } from 'connected-react-router';
import { LOCATION_CHANGE_HANDLE } from '../../../lib/redux-router';
import services from '../services';
export default function* routerWatchers() {
yield takeEvery(LOCATION_CHANGE, () => services.handleLocationChange());
yield takeEvery(LOCATION_CHANGE_HANDLE, () => services.handleLocationChange());
}
+1 -6
View File
@@ -2,18 +2,13 @@ import { all, call, cancel, fork, take } from 'redux-saga/effects';
import watchers from './watchers';
import services from './services';
import { setAccessToken } from '../../utils/access-token-storage';
import ActionTypes from '../../constants/ActionTypes';
export default function* loginSaga() {
const watcherTasks = yield all(watchers.map((watcher) => fork(watcher)));
const {
payload: { accessToken },
} = yield take(ActionTypes.AUTHENTICATE__SUCCESS);
yield take(ActionTypes.AUTHENTICATE__SUCCESS);
yield cancel(watcherTasks);
yield call(setAccessToken, accessToken);
yield call(services.goToRoot);
}
+2
View File
@@ -2,6 +2,7 @@ import { call, put } from 'redux-saga/effects';
import actions from '../../../actions';
import api from '../../../api';
import { setAccessToken } from '../../../utils/access-token-storage';
export function* authenticate(data) {
yield put(actions.authenticate(data));
@@ -14,6 +15,7 @@ export function* authenticate(data) {
return;
}
yield call(setAccessToken, accessToken);
yield put(actions.authenticate.success(accessToken));
}
+2 -2
View File
@@ -1,5 +1,5 @@
import { call, put, select } from 'redux-saga/effects';
import { push } from 'connected-react-router';
import { push } from '../../../lib/redux-router';
import selectors from '../../../selectors';
import Paths from '../../../constants/Paths';
@@ -19,7 +19,7 @@ export function* handleLocationChange() {
return;
}
switch (pathsMatch.path) {
switch (pathsMatch.pattern.path) {
case Paths.ROOT:
case Paths.PROJECTS:
case Paths.BOARDS:
+2 -2
View File
@@ -1,8 +1,8 @@
import { takeEvery } from 'redux-saga/effects';
import { LOCATION_CHANGE } from 'connected-react-router';
import { LOCATION_CHANGE_HANDLE } from '../../../lib/redux-router';
import services from '../services';
export default function* routerWatchers() {
yield takeEvery(LOCATION_CHANGE, () => services.handleLocationChange());
yield takeEvery(LOCATION_CHANGE_HANDLE, () => services.handleLocationChange());
}
-5
View File
@@ -1,5 +0,0 @@
export const selectAccessToken = ({ auth: { accessToken } }) => accessToken;
export default {
selectAccessToken,
};
+6
View File
@@ -4,8 +4,12 @@ import isUndefined from 'lodash/isUndefined';
import orm from '../orm';
import Config from '../constants/Config';
export const selectAccessToken = ({ auth: { accessToken } }) => accessToken;
export const selectIsCoreInitializing = ({ core: { isInitializing } }) => isInitializing;
export const selectIsLogouting = ({ core: { isLogouting } }) => isLogouting;
const nextPosition = (items, index, excludedId) => {
const filteredItems = isUndefined(excludedId)
? items
@@ -94,7 +98,9 @@ export const selectNextTaskPosition = createSelector(
);
export default {
selectAccessToken,
selectIsCoreInitializing,
selectIsLogouting,
selectNextBoardPosition,
selectNextListPosition,
selectNextCardPosition,
-2
View File
@@ -1,5 +1,4 @@
import router from './router';
import auth from './auth';
import core from './core';
import modals from './modals';
import users from './users';
@@ -14,7 +13,6 @@ import attachments from './attachments';
export default {
...router,
...auth,
...core,
...modals,
...users,
+1 -1
View File
@@ -22,7 +22,7 @@ export const selectPath = createReduxOrmSelector(
(state) => selectCurrentUserId(state),
({ Project, Board, Card }, pathsMatch, currentUserId) => {
if (pathsMatch) {
switch (pathsMatch.path) {
switch (pathsMatch.pattern.path) {
case Paths.PROJECTS: {
const projectModel = Project.withId(pathsMatch.params.id);
+5 -6
View File
@@ -1,6 +1,5 @@
import { configure } from 'enzyme';
import Adapter from '@wojtekmaj/enzyme-adapter-react-17';
import 'jest-enzyme';
configure({ adapter: new Adapter() });
// jest-dom adds custom jest matchers for asserting on DOM nodes.
// allows you to do things like:
// expect(element).toHaveTextContent(/react/i)
// learn more: https://github.com/testing-library/jest-dom
import '@testing-library/jest-dom';
+2 -2
View File
@@ -1,6 +1,6 @@
import { applyMiddleware, legacy_createStore as createStore, compose as reduxCompose } from 'redux';
import createSagaMiddleware from 'redux-saga';
import { routerMiddleware } from 'connected-react-router';
import { createRouterMiddleware } from './lib/redux-router';
import rootReducer from './reducers';
import rootSaga from './sagas';
@@ -8,7 +8,7 @@ import history from './history';
const sagaMiddleware = createSagaMiddleware();
const middlewares = [sagaMiddleware, routerMiddleware(history)];
const middlewares = [sagaMiddleware, createRouterMiddleware(history)];
let compose = reduxCompose;
+7 -4
View File
@@ -2,10 +2,13 @@ import { matchPath } from 'react-router-dom';
export default (pathname, paths) => {
for (let i = 0; i < paths.length; i += 1) {
const match = matchPath(pathname, {
path: paths[i],
exact: true,
});
const match = matchPath(
{
path: paths[i],
end: true,
},
pathname,
);
if (match) {
return match;
+6 -4
View File
@@ -1,6 +1,8 @@
import zxcvbn from 'zxcvbn';
const USERNAME_REGEX = /^[a-zA-Z0-9]+((_|\.)?[a-zA-Z0-9])*$/;
// eslint-disable-next-line import/prefer-default-export
export const isUsername = (string) => {
return string.length >= 3 && string.length <= 16 && USERNAME_REGEX.test(string);
};
export const isPassword = (string) => zxcvbn(string).score >= 2; // TODO: move to config
export const isUsername = (string) =>
string.length >= 3 && string.length <= 16 && USERNAME_REGEX.test(string);
+1 -1
View File
@@ -27,7 +27,7 @@ services:
- postgres
postgres:
image: postgres:alpine
image: postgres:14-alpine
restart: unless-stopped
volumes:
- db-data:/var/lib/postgresql/data
+1843 -254
View File
File diff suppressed because it is too large Load Diff

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