Compare commits

..
82 Commits
Author SHA1 Message Date
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
Maksim Eltyshev c940805f14 chore: Fix version 2022-08-24 15:44:01 +02:00
Maksim Eltyshev 052ab7f653 chore: Update version 2022-08-24 15:37:27 +02:00
Maksim Eltyshev 5a38805588 feat: Add Italian translation. Thanks to @belf88 2022-08-24 15:27:12 +02:00
Maksim Eltyshev 2dbbead2e9 fix: Add missing border radiuses 2022-08-23 21:19:43 +02:00
Maksim Eltyshev 039eee0aac ref: Little refactoring 2022-08-23 21:06:50 +02:00
Maksim Eltyshev 07867fc0b2 fix: Use custom logger only for production 2022-08-23 20:45:42 +02:00
Maksim Eltyshev 55d07b4560 meta: Fixes in readme 2022-08-23 01:14:11 +02:00
Maksim Eltyshev 289d2bf5ae meta: Fix ignore files for logs 2022-08-23 00:51:33 +02:00
Steven Correia 6429e22d59 feat: Modify logger to log to file that supports fail2ban (#284) 2022-08-23 03:42:56 +05:00
Maksim Eltyshev 21ef850913 fix: Fix missed function name 2022-08-22 16:27:14 +02:00
Maksim Eltyshev e4769220da chore: Update version 2022-08-19 14:04:26 +02:00
Maksim Eltyshev 378e02c0a4 Merge branch 'master' of https://github.com/plankanban/planka 2022-08-19 14:01:24 +02:00
Maksim Eltyshev 51fa7df69c feat: Permissions for board members
Closes #262
2022-08-19 14:00:40 +02:00
Maksim Eltyshev 68806b9080 meta: Add sponsor button 2022-08-18 16:51:59 +05:00
Maksim Eltyshev d80a538857 fix: Little improvements in users modal 2022-08-17 02:00:24 +02:00
Maksim Eltyshev 78669da01d feat: Add search by users, members and labels 2022-08-17 00:53:08 +02:00
201 changed files with 18011 additions and 16944 deletions
+23 -4
View File
@@ -1,10 +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/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
+13
View File
@@ -0,0 +1,13 @@
# These are supported funding model platforms
github: meltyshev
patreon: # Replace with a single Patreon username
open_collective: # Replace with a single Open Collective username
ko_fi: # Replace with a single Ko-fi username
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
liberapay: # Replace with a single Liberapay username
issuehunt: # Replace with a single IssueHunt username
otechie: # Replace with a single Otechie username
lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry
custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']
@@ -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 \
+221 -2
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,6 +49,220 @@ 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 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`.
Note that the directory to the left of the semicolon is regarding the host machine while the directory to the right of the semicolon is regarding the Docker container.
For example, in the above step, `./logs/:/app/logs/` will create the folder `logs` in the same directory where the `docker-compose.yml` file lives.
### Rotating Logs
Logrotate is designed to ease administration of systems that generate large numbers of log files. It allows automatic rotation, compression, removal, and mailing of log files. Each log file may be handled daily, weekly, monthly, or when it grows too large.
#### Setup logrotate for Planka logs
Create a file in `/etc/logrotate.d` named `planka` with the following contents:
```
/path/to/planka/logs/planka.log {
daily
missingok
rotate 14
compress
delaycompress
notifempty
create 640 root adm
sharedscripts
}
```
Ensure to replace logfile directory with your installation’s `/logs/planka.log` location.
Restart the logrotate service.
### Fail2ban
Fail2ban is a service that uses iptables to automatically drop connections for a pre-defined amount of time from IPs that continuously failed to authenticate to the configured services.
#### Setup a filter and a jail for Planka
A filter defines regex rules to identify when users fail to authenticate on Planka's user interface.
Create a file in `/etc/fail2ban/filter.d` named `planka.conf` with the following contents:
```conf
[Definition]
failregex = ^(.*) Invalid (email or username:|password!) (\"(.*)\"!)? ?\(IP: <ADDR>\)$
ignoreregex =
```
The jail file defines how to handle the failed authentication attempts found by the Planka filter.
Create a file in `/etc/fail2ban/jail.d` named `planka.local` with the following contents:
```conf
[planka]
enabled = true
port = http,https
filter = planka
logpath = /path/to/planka/logs/planka.log
maxretry = 5
bantime = 900
```
Ensure to replace `logpath`'s value with your installation’s `/logs/planka.log` location. If you are using ports other than 80 and 443 for your Web server you should replace those too. The bantime and findtime are defined in seconds.
Restart the fail2ban service. You can check the status of your Planka jail by running:
```bash
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;
};
+11421 -14566
View File
File diff suppressed because it is too large Load Diff
+40 -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,68 @@
"**/*.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": "^6.4.3",
"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-first-history": "^5.1.1",
"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>
+32
View File
@@ -69,6 +69,36 @@ handleBoardMembershipCreate.fetchProject = (id, currentUserId, currentBoardId) =
},
});
const updateBoardMembership = (id, data) => ({
type: ActionTypes.BOARD_MEMBERSHIP_UPDATE,
payload: {
id,
data,
},
});
updateBoardMembership.success = (boardMembership) => ({
type: ActionTypes.BOARD_MEMBERSHIP_UPDATE__SUCCESS,
payload: {
boardMembership,
},
});
updateBoardMembership.failure = (id, error) => ({
type: ActionTypes.BOARD_MEMBERSHIP_UPDATE__FAILURE,
payload: {
id,
error,
},
});
const handleBoardMembershipUpdate = (boardMembership) => ({
type: ActionTypes.BOARD_MEMBERSHIP_UPDATE_HANDLE,
payload: {
boardMembership,
},
});
const deleteBoardMembership = (id) => ({
type: ActionTypes.BOARD_MEMBERSHIP_DELETE,
payload: {
@@ -101,6 +131,8 @@ const handleBoardMembershipDelete = (boardMembership) => ({
export default {
createBoardMembership,
handleBoardMembershipCreate,
updateBoardMembership,
handleBoardMembershipUpdate,
deleteBoardMembership,
handleBoardMembershipDelete,
};
+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,
};
+4
View File
@@ -5,10 +5,14 @@ import socket from './socket';
const createBoardMembership = (boardId, data, headers) =>
socket.post(`/boards/${boardId}/memberships`, data, headers);
const updateBoardMembership = (id, data, headers) =>
socket.patch(`/board-memberships/${id}`, data, headers);
const deleteBoardMembership = (id, headers) =>
socket.delete(`/board-memberships/${id}`, undefined, headers);
export default {
createBoardMembership,
updateBoardMembership,
deleteBoardMembership,
};
+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

@@ -3,6 +3,7 @@ import PropTypes from 'prop-types';
import Filters from './Filters';
import Memberships from '../Memberships';
import BoardMembershipPermissionsSelectStep from '../BoardMembershipPermissionsSelectStep';
import styles from './BoardActions.module.scss';
@@ -13,8 +14,10 @@ const BoardActions = React.memo(
filterUsers,
filterLabels,
allUsers,
canEdit,
canEditMemberships,
onMembershipCreate,
onMembershipUpdate,
onMembershipDelete,
onUserToFilterAdd,
onUserFromFilterRemove,
@@ -25,30 +28,35 @@ const BoardActions = React.memo(
onLabelDelete,
}) => {
return (
<div className={styles.actions}>
<div className={styles.action}>
<Memberships
items={memberships}
allUsers={allUsers}
canEdit={canEditMemberships}
onCreate={onMembershipCreate}
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>
);
@@ -63,8 +71,10 @@ 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,
onMembershipDelete: PropTypes.func.isRequired,
onUserToFilterAdd: PropTypes.func.isRequired,
onUserFromFilterRemove: 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,
@@ -0,0 +1,101 @@
import omit from 'lodash/omit';
import React, { useCallback, useState } from 'react';
import PropTypes from 'prop-types';
import { useTranslation } from 'react-i18next';
import { Button, Form, Menu, Radio, Segment } from 'semantic-ui-react';
import { Popup } from '../../lib/custom-ui';
import { BoardMembershipRoles } from '../../constants/Enums';
import styles from './BoardMembershipPermissionsSelectStep.module.scss';
const BoardMembershipPermissionsSelectStep = React.memo(
({ defaultData, title, buttonContent, onSelect, onBack }) => {
const [t] = useTranslation();
const [data, setData] = useState(() => ({
role: BoardMembershipRoles.EDITOR,
canComment: null,
...defaultData,
}));
const handleSelectRoleClick = useCallback((role) => {
setData((prevData) => ({
...prevData,
role,
canComment: role === BoardMembershipRoles.EDITOR ? null : !!prevData.canComment,
}));
}, []);
const handleSettingChange = useCallback((_, { name: fieldName, checked: value }) => {
setData((prevData) => ({
...prevData,
[fieldName]: value,
}));
}, []);
const handleSubmit = useCallback(() => {
onSelect(data.role === BoardMembershipRoles.EDITOR ? omit(data, 'canComment') : data);
}, [onSelect, data]);
return (
<>
<Popup.Header onBack={onBack}>
{t(title, {
context: 'title',
})}
</Popup.Header>
<Popup.Content>
<Form onSubmit={handleSubmit}>
<Menu secondary vertical className={styles.menu}>
<Menu.Item
active={data.role === BoardMembershipRoles.EDITOR}
onClick={() => handleSelectRoleClick(BoardMembershipRoles.EDITOR)}
>
<div className={styles.menuItemTitle}>{t('common.editor')}</div>
<div className={styles.menuItemDescription}>
{t('common.canEditContentOfBoard')}
</div>
</Menu.Item>
<Menu.Item
active={data.role === BoardMembershipRoles.VIEWER}
onClick={() => handleSelectRoleClick(BoardMembershipRoles.VIEWER)}
>
<div className={styles.menuItemTitle}>{t('common.viewer')}</div>
<div className={styles.menuItemDescription}>{t('common.canOnlyViewBoard')}</div>
</Menu.Item>
</Menu>
{data.role !== BoardMembershipRoles.EDITOR && (
<Segment basic className={styles.settings}>
<Radio
toggle
name="canComment"
checked={data.canComment}
label={t('common.canComment')}
onChange={handleSettingChange}
/>
</Segment>
)}
<Button positive content={t(buttonContent)} />
</Form>
</Popup.Content>
</>
);
},
);
BoardMembershipPermissionsSelectStep.propTypes = {
defaultData: PropTypes.object, // eslint-disable-line react/forbid-prop-types
title: PropTypes.string,
buttonContent: PropTypes.string,
onSelect: PropTypes.func.isRequired,
onBack: PropTypes.func.isRequired,
};
BoardMembershipPermissionsSelectStep.defaultProps = {
defaultData: undefined,
title: 'common.selectPermissions',
buttonContent: 'action.selectPermissions',
};
export default BoardMembershipPermissionsSelectStep;
@@ -0,0 +1,18 @@
:global(#app) {
.menu {
margin: 0 auto 8px;
width: 100%;
}
.menuItemDescription {
opacity: 0.5;
}
.menuItemTitle {
margin-bottom: 8px;
}
.settings {
margin: 0 0 8px;
}
}
@@ -0,0 +1,3 @@
import BoardMembershipPermissionsSelectStep from './BoardMembershipPermissionsSelectStep';
export default BoardMembershipPermissionsSelectStep;
@@ -1,9 +1,10 @@
import React, { useCallback } from 'react';
import React, { useCallback, useEffect, useMemo, useRef } from 'react';
import PropTypes from 'prop-types';
import { useTranslation } from 'react-i18next';
import { Menu } from 'semantic-ui-react';
import { Popup } from '../../lib/custom-ui';
import { Input, Popup } from '../../lib/custom-ui';
import { useField } from '../../hooks';
import Item from './Item';
import styles from './BoardMembershipsStep.module.scss';
@@ -11,6 +12,21 @@ import styles from './BoardMembershipsStep.module.scss';
const BoardMembershipsStep = React.memo(
({ items, currentUserIds, title, onUserSelect, onUserDeselect, onBack }) => {
const [t] = useTranslation();
const [search, handleSearchChange] = useField('');
const cleanSearch = useMemo(() => search.trim().toLowerCase(), [search]);
const filteredItems = useMemo(
() =>
items.filter(
({ user }) =>
user.email.includes(cleanSearch) ||
user.name.toLowerCase().includes(cleanSearch) ||
(user.username && user.username.includes(cleanSearch)),
),
[items, cleanSearch],
);
const searchField = useRef(null);
const handleUserSelect = useCallback(
(id) => {
@@ -26,22 +42,40 @@ const BoardMembershipsStep = React.memo(
[onUserDeselect],
);
useEffect(() => {
searchField.current.focus();
}, []);
return (
<>
<Popup.Header onBack={onBack}>{t(title)}</Popup.Header>
<Popup.Header onBack={onBack}>
{t(title, {
context: 'title',
})}
</Popup.Header>
<Popup.Content>
<Menu secondary vertical className={styles.menu}>
{items.map((item) => (
<Item
key={item.id}
isPersisted={item.isPersisted}
isActive={currentUserIds.includes(item.user.id)}
user={item.user}
onUserSelect={() => handleUserSelect(item.user.id)}
onUserDeselect={() => handleUserDeselect(item.user.id)}
/>
))}
</Menu>
<Input
fluid
ref={searchField}
value={search}
placeholder={t('common.searchMembers')}
icon="search"
onChange={handleSearchChange}
/>
{filteredItems.length > 0 && (
<Menu secondary vertical className={styles.menu}>
{filteredItems.map((item) => (
<Item
key={item.id}
isPersisted={item.isPersisted}
isActive={currentUserIds.includes(item.user.id)}
user={item.user}
onUserSelect={() => handleUserSelect(item.user.id)}
onUserDeselect={() => handleUserDeselect(item.user.id)}
/>
))}
</Menu>
)}
</Popup.Content>
</>
);
@@ -1,6 +1,22 @@
:global(#app) {
.menu {
margin: -7px auto -5px;
margin: 8px auto 0;
max-height: 320px;
overflow-x: hidden;
overflow-y: auto;
scrollbar-width: thin;
width: 100%;
&::-webkit-scrollbar {
width: 5px;
}
&::-webkit-scrollbar-track {
background: transparent;
}
&::-webkit-scrollbar-thumb {
border-radius: 3px;
}
}
}
@@ -2,6 +2,7 @@
.menuItem {
display: block;
margin: 0;
overflow: hidden;
padding: 4px;
}
+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 {
@@ -13,7 +13,7 @@ import styles from './Attachments.module.scss';
const INITIALLY_VISIBLE = 4;
const Attachments = React.memo(
({ items, onUpdate, onDelete, onCoverUpdate, onGalleryOpen, onGalleryClose }) => {
({ items, canEdit, onUpdate, onDelete, onCoverUpdate, onGalleryOpen, onGalleryClose }) => {
const [t] = useTranslation();
const [isAllVisible, toggleAllVisible] = useToggle();
@@ -99,6 +99,7 @@ const Attachments = React.memo(
createdAt={item.createdAt}
isCover={item.isCover}
isPersisted={item.isPersisted}
canEdit={canEdit}
onClick={item.image || isPdf ? open : undefined}
onCoverSelect={() => handleCoverSelect(item.id)}
onCoverDeselect={handleCoverDeselect}
@@ -151,6 +152,7 @@ const Attachments = React.memo(
Attachments.propTypes = {
items: PropTypes.array.isRequired, // eslint-disable-line react/forbid-prop-types
canEdit: PropTypes.bool.isRequired,
onUpdate: PropTypes.func.isRequired,
onDelete: PropTypes.func.isRequired,
onCoverUpdate: PropTypes.func.isRequired,
@@ -17,6 +17,7 @@ const Item = React.forwardRef(
createdAt,
isCover,
isPersisted,
canEdit,
onCoverSelect,
onCoverDeselect,
onClick,
@@ -96,7 +97,7 @@ const Item = React.forwardRef(
value: createdAt,
})}
</span>
{coverUrl && (
{coverUrl && canEdit && (
<span className={styles.options}>
<button type="button" className={styles.option} onClick={handleToggleCoverClick}>
<Icon
@@ -118,17 +119,19 @@ const Item = React.forwardRef(
</span>
)}
</div>
<EditPopup
defaultData={{
name,
}}
onUpdate={onUpdate}
onDelete={onDelete}
>
<Button className={classNames(styles.button, styles.target)}>
<Icon fitted name="pencil" size="small" />
</Button>
</EditPopup>
{canEdit && (
<EditPopup
defaultData={{
name,
}}
onUpdate={onUpdate}
onDelete={onDelete}
>
<Button className={classNames(styles.button, styles.target)}>
<Icon fitted name="pencil" size="small" />
</Button>
</EditPopup>
)}
</div>
);
},
@@ -141,6 +144,7 @@ Item.propTypes = {
createdAt: PropTypes.instanceOf(Date),
isCover: PropTypes.bool.isRequired,
isPersisted: PropTypes.bool.isRequired,
canEdit: PropTypes.bool.isRequired,
onClick: PropTypes.func,
onCoverSelect: PropTypes.func.isRequired,
onCoverDeselect: PropTypes.func.isRequired,
@@ -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 {
+28 -2
View File
@@ -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';
@@ -48,6 +49,7 @@ const CardModal = React.memo(
allBoardMemberships,
allLabels,
canEdit,
canEditCommentActivities,
canEditAllCommentActivities,
onUpdate,
onMove,
@@ -79,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({
@@ -290,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>
@@ -302,7 +323,10 @@ const CardModal = React.memo(
{canEdit ? (
<DescriptionEdit defaultValue={description} onUpdate={handleDescriptionUpdate}>
{description ? (
<button type="button" className={styles.descriptionText}>
<button
type="button"
className={classNames(styles.descriptionText, styles.cursorPointer)}
>
<Markdown linkStopPropagation linkTarget="_blank">
{description}
</Markdown>
@@ -348,6 +372,7 @@ const CardModal = React.memo(
<div className={styles.moduleHeader}>{t('common.attachments')}</div>
<Attachments
items={attachments}
canEdit={canEdit}
onUpdate={onAttachmentUpdate}
onDelete={onAttachmentDelete}
onCoverUpdate={handleCoverUpdate}
@@ -363,7 +388,7 @@ const CardModal = React.memo(
isAllFetched={isAllActivitiesFetched}
isDetailsVisible={isActivitiesDetailsVisible}
isDetailsFetching={isActivitiesDetailsFetching}
canEdit={canEdit}
canEdit={canEditCommentActivities}
canEditAllComments={canEditAllCommentActivities}
onFetch={onActivitiesFetch}
onDetailsToggle={onActivitiesDetailsToggle}
@@ -508,6 +533,7 @@ CardModal.propTypes = {
allLabels: PropTypes.array.isRequired,
/* eslint-enable react/forbid-prop-types */
canEdit: PropTypes.bool.isRequired,
canEditCommentActivities: PropTypes.bool.isRequired,
canEditAllCommentActivities: PropTypes.bool.isRequired,
onUpdate: PropTypes.func.isRequired,
onMove: PropTypes.func.isRequired,
@@ -64,6 +64,10 @@
padding: 8px 8px 0 16px;
}
.cursorPointer {
cursor: pointer;
}
.dueDate {
background: rgba(9, 30, 66, 0.04);
border: none;
@@ -114,7 +118,6 @@
background: transparent;
border: none;
color: #17394d;
cursor: pointer;
font-size: 15px;
margin-bottom: 8px;
outline: none;
@@ -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 {
@@ -36,7 +36,7 @@ const CardMoveStep = React.memo(
[selectedBoard, path.listId],
);
const handleBoardIdFieldChange = useCallback(
const handleBoardIdChange = useCallback(
(event, data) => {
if (selectedProject.boards.find((board) => board.id === data.value).isFetching === null) {
onBoardFetch(data.value);
@@ -102,7 +102,7 @@ const CardMoveStep = React.memo(
}
disabled={selectedProject.boards.length === 0}
className={styles.field}
onChange={handleBoardIdFieldChange}
onChange={handleBoardIdChange}
/>
</>
)}
@@ -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:
}
@@ -74,11 +78,16 @@ const NotificationsStep = React.memo(({ items, onDelete, onClose }) => {
return (
<>
<Popup.Header>{t('common.notifications')}</Popup.Header>
<Popup.Header>
{t('common.notifications', {
context: 'title',
})}
</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
@@ -86,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;
+80 -22
View File
@@ -1,11 +1,11 @@
import pick from 'lodash/pick';
import React, { useCallback } from 'react';
import React, { useCallback, useEffect, useMemo, useRef } from 'react';
import PropTypes from 'prop-types';
import { useTranslation } from 'react-i18next';
import { Button } from 'semantic-ui-react';
import { Popup } from '../../lib/custom-ui';
import { Input, Popup } from '../../lib/custom-ui';
import { useSteps } from '../../hooks';
import { useField, useSteps } from '../../hooks';
import AddStep from './AddStep';
import EditStep from './EditStep';
import Item from './Item';
@@ -18,9 +18,34 @@ 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('');
const cleanSearch = useMemo(() => search.trim().toLowerCase(), [search]);
const filteredItems = useMemo(
() =>
items.filter(
(label) =>
(label.name && label.name.toLowerCase().includes(cleanSearch)) ||
label.color.includes(cleanSearch),
),
[items, cleanSearch],
);
const searchField = useRef(null);
const handleAddClick = useCallback(() => {
openStep(StepTypes.ADD);
@@ -63,10 +88,22 @@ const LabelsStep = React.memo(
[onDelete],
);
useEffect(() => {
searchField.current.focus();
}, []);
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);
@@ -91,26 +128,45 @@ const LabelsStep = React.memo(
return (
<>
<Popup.Header onBack={onBack}>{t(title)}</Popup.Header>
<Popup.Header onBack={onBack}>
{t(title, {
context: 'title',
})}
</Popup.Header>
<Popup.Content>
{items.map((item) => (
<Item
key={item.id}
name={item.name}
color={item.color}
isPersisted={item.isPersisted}
isActive={currentIds.includes(item.id)}
onSelect={() => handleSelect(item.id)}
onDeselect={() => handleDeselect(item.id)}
onEdit={() => handleEdit(item.id)}
/>
))}
<Button
<Input
fluid
content={t('action.createNewLabel')}
className={styles.addButton}
onClick={handleAddClick}
ref={searchField}
value={search}
placeholder={t('common.searchLabels')}
icon="search"
onChange={handleSearchChange}
/>
{filteredItems.length > 0 && (
<div className={styles.items}>
{filteredItems.map((item) => (
<Item
key={item.id}
name={item.name}
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)}
/>
))}
</div>
)}
{canEdit && (
<Button
fluid
content={t('action.createNewLabel')}
className={styles.addButton}
onClick={handleAddClick}
/>
)}
</Popup.Content>
</>
);
@@ -123,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,
@@ -133,6 +190,7 @@ LabelsStep.propTypes = {
LabelsStep.defaultProps = {
title: 'common.labels',
canEdit: true,
onBack: undefined,
};
@@ -14,4 +14,24 @@
background: #e9e9e9;
}
}
.items {
margin-top: 8px;
max-height: 320px;
overflow-x: hidden;
overflow-y: auto;
scrollbar-width: thin;
&::-webkit-scrollbar {
width: 5px;
}
&::-webkit-scrollbar-track {
background: transparent;
}
&::-webkit-scrollbar-thumb {
border-radius: 3px;
}
}
}
@@ -1,3 +1,4 @@
import pick from 'lodash/pick';
import React, { useCallback } from 'react';
import PropTypes from 'prop-types';
import { useTranslation } from 'react-i18next';
@@ -11,12 +12,14 @@ import DeleteStep from '../DeleteStep';
import styles from './ActionsPopup.module.scss';
const StepTypes = {
EDIT_PERMISSIONS: 'EDIT_PERMISSIONS',
DELETE: 'DELETE',
};
const ActionsStep = React.memo(
({
user,
membership,
permissionsSelectStep,
leaveButtonContent,
leaveConfirmationTitle,
leaveConfirmationContent,
@@ -26,63 +29,115 @@ const ActionsStep = React.memo(
deleteConfirmationContent,
deleteConfirmationButtonContent,
canLeave,
canDelete,
canEdit,
onUpdate,
onDelete,
onClose,
}) => {
const [t] = useTranslation();
const [step, openStep, handleBack] = useSteps();
const handleEditPermissionsClick = useCallback(() => {
openStep(StepTypes.EDIT_PERMISSIONS);
}, [openStep]);
const handleDeleteClick = useCallback(() => {
openStep(StepTypes.DELETE);
}, [openStep]);
if (step && step.type === StepTypes.DELETE) {
return (
<DeleteStep
title={t(user.isCurrent ? leaveConfirmationTitle : deleteConfirmationTitle, {
context: 'title',
})}
content={t(user.isCurrent ? leaveConfirmationContent : deleteConfirmationContent)}
buttonContent={t(
user.isCurrent ? leaveConfirmationButtonContent : deleteConfirmationButtonContent,
)}
onConfirm={onDelete}
onBack={handleBack}
/>
);
const handleRoleSelect = useCallback(
(data) => {
if (onUpdate) {
onUpdate(data);
}
onClose();
},
[onUpdate, onClose],
);
if (step) {
switch (step.type) {
case StepTypes.EDIT_PERMISSIONS: {
const PermissionsSelectStep = permissionsSelectStep;
return (
<PermissionsSelectStep
defaultData={pick(membership, ['role', 'canComment'])}
title="common.editPermissions"
buttonContent="action.save"
onSelect={handleRoleSelect}
onBack={handleBack}
/>
);
}
case StepTypes.DELETE:
return (
<DeleteStep
title={t(
membership.user.isCurrent ? leaveConfirmationTitle : deleteConfirmationTitle,
{
context: 'title',
},
)}
content={t(
membership.user.isCurrent ? leaveConfirmationContent : deleteConfirmationContent,
)}
buttonContent={t(
membership.user.isCurrent
? leaveConfirmationButtonContent
: deleteConfirmationButtonContent,
)}
onConfirm={onDelete}
onBack={handleBack}
/>
);
default:
}
}
return (
<>
<span className={styles.user}>
<User name={user.name} avatarUrl={user.avatarUrl} size="large" />
<User name={membership.user.name} avatarUrl={membership.user.avatarUrl} size="large" />
</span>
<span className={styles.content}>
<div className={styles.name}>{user.name}</div>
<div className={styles.email}>{user.email}</div>
{user.isCurrent
? canLeave && (
<Button
content={t(leaveButtonContent)}
className={styles.deleteButton}
onClick={handleDeleteClick}
/>
)
: canDelete && (
<Button
content={t(deleteButtonContent)}
className={styles.deleteButton}
onClick={handleDeleteClick}
/>
)}
<div className={styles.name}>{membership.user.name}</div>
<div className={styles.email}>{membership.user.email}</div>
</span>
{permissionsSelectStep && canEdit && (
<Button
fluid
content={t('action.editPermissions')}
className={styles.button}
onClick={handleEditPermissionsClick}
/>
)}
{membership.user.isCurrent
? canLeave && (
<Button
fluid
content={t(leaveButtonContent)}
className={styles.button}
onClick={handleDeleteClick}
/>
)
: canEdit && (
<Button
fluid
content={t(deleteButtonContent)}
className={styles.button}
onClick={handleDeleteClick}
/>
)}
</>
);
},
);
ActionsStep.propTypes = {
user: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types
membership: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types
permissionsSelectStep: PropTypes.elementType,
leaveButtonContent: PropTypes.string,
leaveConfirmationTitle: PropTypes.string,
leaveConfirmationContent: PropTypes.string,
@@ -92,11 +147,14 @@ ActionsStep.propTypes = {
deleteConfirmationContent: PropTypes.string,
deleteConfirmationButtonContent: PropTypes.string,
canLeave: PropTypes.bool.isRequired,
canDelete: PropTypes.bool.isRequired,
canEdit: PropTypes.bool.isRequired,
onUpdate: PropTypes.func,
onDelete: PropTypes.func.isRequired,
onClose: PropTypes.func.isRequired,
};
ActionsStep.defaultProps = {
permissionsSelectStep: undefined,
leaveButtonContent: 'action.leaveBoard',
leaveConfirmationTitle: 'common.leaveBoard',
leaveConfirmationContent: 'common.areYouSureYouWantToLeaveBoard',
@@ -105,6 +163,7 @@ ActionsStep.defaultProps = {
deleteConfirmationTitle: 'common.removeMember',
deleteConfirmationContent: 'common.areYouSureYouWantToRemoveThisMemberFromBoard',
deleteConfirmationButtonContent: 'action.removeMember',
onUpdate: undefined,
};
export default withPopup(ActionsStep);
@@ -1,28 +1,31 @@
:global(#app) {
.button {
background: transparent;
box-shadow: none;
color: #6b808c;
font-weight: normal;
margin-top: 8px;
padding: 6px 11px;
text-align: left;
text-decoration: underline;
transition: none;
&:hover {
background: rgba(9, 30, 66, 0.08);
color: #092d42;
}
}
.content {
display: inline-block;
width: calc(100% - 44px);
}
.deleteButton {
background: transparent;
box-shadow: none;
flex: 0 0 auto;
font-weight: normal;
margin: 0 0 0 -10px;
padding: 11px 10px;
text-decoration: underline;
&:hover {
background: #e9e9e9;
}
}
.email {
color: #888888;
font-size: 14px;
line-height: 1.2;
padding: 4px 0 4px 2px;
padding: 2px 0 2px 2px;
}
.name {
@@ -36,7 +39,7 @@
.user {
display: inline-block;
padding-right: 8px;
padding-top: 8px;
padding-top: 10px;
vertical-align: top;
}
}
@@ -1,62 +1,144 @@
import React, { useCallback } from 'react';
import React, { useCallback, useEffect, useMemo, useRef } from 'react';
import PropTypes from 'prop-types';
import { useTranslation } from 'react-i18next';
import { withPopup } from '../../../lib/popup';
import { Popup } from '../../../lib/custom-ui';
import { Input, Popup } from '../../../lib/custom-ui';
import { useField, useSteps } from '../../../hooks';
import UserItem from './UserItem';
import styles from './AddPopup.module.scss';
const AddStep = React.memo(({ users, currentUserIds, title, onCreate, onClose }) => {
const [t] = useTranslation();
const StepTypes = {
SELECT_PERMISSIONS: 'SELECT_PERMISSIONS',
};
const handleUserSelect = useCallback(
(id) => {
onCreate({
userId: id,
});
const AddStep = React.memo(
({ users, currentUserIds, permissionsSelectStep, title, onCreate, onClose }) => {
const [t] = useTranslation();
const [step, openStep, handleBack] = useSteps();
const [search, handleSearchChange] = useField('');
const cleanSearch = useMemo(() => search.trim().toLowerCase(), [search]);
onClose();
},
[onCreate, onClose],
);
const filteredUsers = useMemo(
() =>
users.filter(
(user) =>
user.email.includes(cleanSearch) ||
user.name.toLowerCase().includes(cleanSearch) ||
(user.username && user.username.includes(cleanSearch)),
),
[users, cleanSearch],
);
return (
<>
<Popup.Header>
{t(title, {
context: 'title',
})}
</Popup.Header>
<Popup.Content>
<div className={styles.menu}>
{users.map((user) => (
<UserItem
key={user.id}
name={user.name}
avatarUrl={user.avatarUrl}
isActive={currentUserIds.includes(user.id)}
onSelect={() => handleUserSelect(user.id)}
/>
))}
</div>
</Popup.Content>
</>
);
});
const searchField = useRef(null);
const handleUserSelect = useCallback(
(id) => {
if (permissionsSelectStep) {
openStep(StepTypes.SELECT_PERMISSIONS, {
userId: id,
});
} else {
onCreate({
userId: id,
});
onClose();
}
},
[permissionsSelectStep, onCreate, onClose, openStep],
);
const handleRoleSelect = useCallback(
(data) => {
onCreate({
userId: step.params.userId,
...data,
});
onClose();
},
[onCreate, onClose, step],
);
useEffect(() => {
searchField.current.focus();
}, []);
if (step) {
switch (step.type) {
case StepTypes.SELECT_PERMISSIONS: {
const currentUser = users.find((user) => user.id === step.params.userId);
if (currentUser) {
const PermissionsSelectStep = permissionsSelectStep;
return (
<PermissionsSelectStep
buttonContent="action.addMember"
onSelect={handleRoleSelect}
onBack={handleBack}
/>
);
}
openStep(null);
break;
}
default:
}
}
return (
<>
<Popup.Header>
{t(title, {
context: 'title',
})}
</Popup.Header>
<Popup.Content>
<Input
fluid
ref={searchField}
value={search}
placeholder={t('common.searchUsers')}
icon="search"
onChange={handleSearchChange}
/>
{filteredUsers.length > 0 && (
<div className={styles.users}>
{filteredUsers.map((user) => (
<UserItem
key={user.id}
name={user.name}
avatarUrl={user.avatarUrl}
isActive={currentUserIds.includes(user.id)}
onSelect={() => handleUserSelect(user.id)}
/>
))}
</div>
)}
</Popup.Content>
</>
);
},
);
AddStep.propTypes = {
/* eslint-disable react/forbid-prop-types */
users: PropTypes.array.isRequired,
currentUserIds: PropTypes.array.isRequired,
/* eslint-disable react/forbid-prop-types */
permissionsSelectStep: PropTypes.elementType,
title: PropTypes.string,
onCreate: PropTypes.func.isRequired,
onClose: PropTypes.func.isRequired,
};
AddStep.defaultProps = {
permissionsSelectStep: undefined,
title: 'common.addMember',
};
@@ -1,7 +1,21 @@
:global(#app) {
.menu {
border: none;
margin: -7px auto -5px;
width: 100%;
.users {
margin-top: 8px;
max-height: 320px;
overflow-x: hidden;
overflow-y: auto;
scrollbar-width: thin;
&::-webkit-scrollbar {
width: 5px;
}
&::-webkit-scrollbar-track {
background: transparent;
}
&::-webkit-scrollbar-thumb {
border-radius: 3px;
}
}
}
@@ -2,10 +2,12 @@
.menuItem {
background: transparent;
border: none;
border-radius: 0.28571429rem;
cursor: pointer;
display: block;
margin: 0;
outline: 0;
overflow: hidden;
padding: 4px;
text-align: left;
width: 100%;
@@ -12,6 +12,7 @@ const Memberships = React.memo(
({
items,
allUsers,
permissionsSelectStep,
addTitle,
leaveButtonContent,
leaveConfirmationTitle,
@@ -24,6 +25,7 @@ const Memberships = React.memo(
canEdit,
canLeaveIfLast,
onCreate,
onUpdate,
onDelete,
}) => {
return (
@@ -32,7 +34,8 @@ const Memberships = React.memo(
{items.map((item) => (
<span key={item.id} className={styles.user}>
<ActionsPopup
user={item.user}
membership={item}
permissionsSelectStep={permissionsSelectStep}
leaveButtonContent={leaveButtonContent}
leaveConfirmationTitle={leaveConfirmationTitle}
leaveConfirmationContent={leaveConfirmationContent}
@@ -42,7 +45,8 @@ const Memberships = React.memo(
deleteConfirmationContent={deleteConfirmationContent}
deleteConfirmationButtonContent={deleteConfirmationButtonContent}
canLeave={items.length > 1 || canLeaveIfLast}
canDelete={canEdit}
canEdit={canEdit}
onUpdate={(data) => onUpdate(item.id, data)}
onDelete={() => onDelete(item.id)}
>
<User
@@ -59,6 +63,7 @@ const Memberships = React.memo(
<AddPopup
users={allUsers}
currentUserIds={items.map((item) => item.user.id)}
permissionsSelectStep={permissionsSelectStep}
title={addTitle}
onCreate={onCreate}
>
@@ -75,6 +80,7 @@ Memberships.propTypes = {
items: PropTypes.array.isRequired,
allUsers: PropTypes.array.isRequired,
/* eslint-enable react/forbid-prop-types */
permissionsSelectStep: PropTypes.elementType,
addTitle: PropTypes.string,
leaveButtonContent: PropTypes.string,
leaveConfirmationTitle: PropTypes.string,
@@ -87,10 +93,12 @@ Memberships.propTypes = {
canEdit: PropTypes.bool,
canLeaveIfLast: PropTypes.bool,
onCreate: PropTypes.func.isRequired,
onUpdate: PropTypes.func,
onDelete: PropTypes.func.isRequired,
};
Memberships.defaultProps = {
permissionsSelectStep: undefined,
addTitle: undefined,
leaveButtonContent: undefined,
leaveConfirmationTitle: undefined,
@@ -102,6 +110,7 @@ Memberships.defaultProps = {
deleteConfirmationButtonContent: undefined,
canEdit: true,
canLeaveIfLast: true,
onUpdate: undefined,
};
export default Memberships;
@@ -1,6 +1,7 @@
:global(#app) {
.action {
border: none;
border-radius: 0.28571429rem;
display: inline-block;
height: 36px;
overflow: hidden;
@@ -1,6 +1,7 @@
:global(#app) {
.action {
border: none;
border-radius: 0.28571429rem;
display: inline-block;
height: 36px;
overflow: hidden;
+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 { HistoryRouter as Router } from 'redux-first-history/rr6';
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>
<Router 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>
</Router>
</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;
@@ -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 (
@@ -3,6 +3,7 @@ import PropTypes from 'prop-types';
import { Button, Icon, Radio, Table } from 'semantic-ui-react';
import ActionsPopup from './ActionsPopup';
import User from '../../User';
import styles from './Item.module.scss';
@@ -11,6 +12,7 @@ const Item = React.memo(
email,
username,
name,
avatarUrl,
organization,
phone,
isAdmin,
@@ -34,13 +36,16 @@ const Item = React.memo(
return (
<Table.Row>
<Table.Cell>
<User name={name} avatarUrl={avatarUrl} />
</Table.Cell>
<Table.Cell>{name}</Table.Cell>
<Table.Cell>{username || '-'}</Table.Cell>
<Table.Cell>{email}</Table.Cell>
<Table.Cell collapsing>
<Table.Cell>
<Radio toggle checked={isAdmin} onChange={handleIsAdminChange} />
</Table.Cell>
<Table.Cell collapsing>
<Table.Cell textAlign="right">
<ActionsPopup
user={{
email,
@@ -76,6 +81,7 @@ Item.propTypes = {
email: PropTypes.string.isRequired,
username: PropTypes.string,
name: PropTypes.string.isRequired,
avatarUrl: PropTypes.string,
organization: PropTypes.string,
phone: PropTypes.string,
isAdmin: PropTypes.bool.isRequired,
@@ -96,6 +102,7 @@ Item.propTypes = {
Item.defaultProps = {
username: undefined,
avatarUrl: undefined,
organization: undefined,
phone: undefined,
};
@@ -84,10 +84,11 @@ const UsersModal = React.memo(
context: 'title',
})}
</Modal.Header>
<Modal.Content>
<Table basic="very">
<Modal.Content scrolling>
<Table unstackable basic="very">
<Table.Header>
<Table.Row>
<Table.HeaderCell />
<Table.HeaderCell width={4}>{t('common.name')}</Table.HeaderCell>
<Table.HeaderCell width={4}>{t('common.username')}</Table.HeaderCell>
<Table.HeaderCell width={4}>{t('common.email')}</Table.HeaderCell>
@@ -102,6 +103,7 @@ const UsersModal = React.memo(
email={item.email}
username={item.username}
name={item.name}
avatarUrl={item.avatarUrl}
organization={item.organization}
phone={item.phone}
isAdmin={item.isAdmin}
+5
View File
@@ -21,6 +21,7 @@ export default {
CORE_INITIALIZE: 'CORE_INITIALIZE',
LOGOUT: 'LOGOUT',
LOGOUT__ACCESS_TOKEN_INVALIDATE: 'LOGOUT__ACCESS_TOKEN_INVALIDATE',
/* Modals */
@@ -123,6 +124,10 @@ export default {
BOARD_MEMBERSHIP_CREATE__FAILURE: 'BOARD_MEMBERSHIP_CREATE__FAILURE',
BOARD_MEMBERSHIP_CREATE_HANDLE: 'BOARD_MEMBERSHIP_CREATE_HANDLE',
BOARD_MEMBERSHIP_CREATE_HANDLE__PROJECT_FETCH: 'BOARD_MEMBERSHIP_CREATE_HANDLE__PROJECT_FETCH',
BOARD_MEMBERSHIP_UPDATE: 'BOARD_MEMBERSHIP_UPDATE',
BOARD_MEMBERSHIP_UPDATE__SUCCESS: 'BOARD_MEMBERSHIP_UPDATE__SUCCESS',
BOARD_MEMBERSHIP_UPDATE__FAILURE: 'BOARD_MEMBERSHIP_UPDATE__FAILURE',
BOARD_MEMBERSHIP_UPDATE_HANDLE: 'BOARD_MEMBERSHIP_UPDATE_HANDLE',
BOARD_MEMBERSHIP_DELETE: 'BOARD_MEMBERSHIP_DELETE',
BOARD_MEMBERSHIP_DELETE__SUCCESS: 'BOARD_MEMBERSHIP_DELETE__SUCCESS',
BOARD_MEMBERSHIP_DELETE__FAILURE: 'BOARD_MEMBERSHIP_DELETE__FAILURE',
+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,
+2
View File
@@ -88,6 +88,8 @@ export default {
MEMBERSHIP_IN_CURRENT_BOARD_CREATE: `${PREFIX}/MEMBERSHIP_IN_CURRENT_BOARD_CREATE`,
BOARD_MEMBERSHIP_CREATE_HANDLE: `${PREFIX}/BOARD_MEMBERSHIP_CREATE_HANDLE`,
BOARD_MEMBERSHIP_UPDATE: `${PREFIX}/BOARD_MEMBERSHIP_UPDATE`,
BOARD_MEMBERSHIP_UPDATE_HANDLE: `${PREFIX}/BOARD_MEMBERSHIP_UPDATE_HANDLE`,
BOARD_MEMBERSHIP_DELETE: `${PREFIX}/BOARD_MEMBERSHIP_DELETE`,
BOARD_MEMBERSHIP_DELETE_HANDLE: `${PREFIX}/BOARD_MEMBERSHIP_DELETE_HANDLE`,
+5
View File
@@ -7,6 +7,11 @@ export const BoardTypes = {
KANBAN: 'kanban',
};
export const BoardMembershipRoles = {
EDITOR: 'editor',
VIEWER: 'viewer',
};
export const ActivityTypes = {
CREATE_CARD: 'createCard',
MOVE_CARD: 'moveCard',
+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,
};
};
@@ -27,6 +30,7 @@ const mapDispatchToProps = (dispatch) =>
bindActionCreators(
{
onMembershipCreate: entryActions.createMembershipInCurrentBoard,
onMembershipUpdate: entryActions.updateBoardMembership,
onMembershipDelete: entryActions.deleteBoardMembership,
onUserToFilterAdd: entryActions.addUserToFilterInCurrentBoard,
onUserFromFilterRemove: entryActions.removeUserFromFilterInCurrentBoard,
@@ -3,17 +3,18 @@ import { connect } from 'react-redux';
import selectors from '../selectors';
import entryActions from '../entry-actions';
import { BoardMembershipRoles } from '../constants/Enums';
import BoardKanban from '../components/BoardKanban';
const mapStateToProps = (state) => {
const { cardId } = selectors.selectPath(state);
const isCurrentUserMember = selectors.selectIsCurrentUserMemberForCurrentBoard(state);
const currentUserMembership = selectors.selectCurrentUserMembershipForCurrentBoard(state);
const listIds = selectors.selectListIdsForCurrentBoard(state);
return {
listIds,
isCardModalOpened: !!cardId,
canEdit: isCurrentUserMember,
canEdit: !!currentUserMembership && currentUserMembership.role === BoardMembershipRoles.EDITOR,
};
};
+4 -2
View File
@@ -3,6 +3,7 @@ import { connect } from 'react-redux';
import selectors from '../selectors';
import entryActions from '../entry-actions';
import { BoardMembershipRoles } from '../constants/Enums';
import Card from '../components/Card';
const makeMapStateToProps = () => {
@@ -17,7 +18,7 @@ const makeMapStateToProps = () => {
const allProjectsToLists = selectors.selectProjectsToListsForCurrentUser(state);
const allBoardMemberships = selectors.selectMembershipsForCurrentBoard(state);
const allLabels = selectors.selectLabelsForCurrentBoard(state);
const isCurrentUserMember = selectors.selectIsCurrentUserMemberForCurrentBoard(state);
const currentUserMembership = selectors.selectCurrentUserMembershipForCurrentBoard(state);
const { name, dueDate, timer, coverUrl, boardId, listId, isPersisted } = selectCardById(
state,
@@ -47,7 +48,8 @@ const makeMapStateToProps = () => {
allProjectsToLists,
allBoardMemberships,
allLabels,
canEdit: isCurrentUserMember,
canEdit:
!!currentUserMembership && currentUserMembership.role === BoardMembershipRoles.EDITOR,
};
};
};
+8 -3
View File
@@ -1,11 +1,12 @@
import { bindActionCreators } from 'redux';
import { connect } from 'react-redux';
import { push } from 'connected-react-router';
import { push } from 'redux-first-history';
import omit from 'lodash/omit';
import selectors from '../selectors';
import entryActions from '../entry-actions';
import Paths from '../constants/Paths';
import { BoardMembershipRoles } from '../constants/Enums';
import CardModal from '../components/CardModal';
const mapStateToProps = (state) => {
@@ -14,7 +15,7 @@ const mapStateToProps = (state) => {
const isCurrentUserManager = selectors.selectIsCurrentUserManagerForCurrentProject(state);
const allBoardMemberships = selectors.selectMembershipsForCurrentBoard(state);
const allLabels = selectors.selectLabelsForCurrentBoard(state);
const isCurrentUserMember = selectors.selectIsCurrentUserMemberForCurrentBoard(state);
const currentUserMembership = selectors.selectCurrentUserMembershipForCurrentBoard(state);
const {
name,
@@ -57,7 +58,11 @@ const mapStateToProps = (state) => {
allProjectsToLists,
allBoardMemberships,
allLabels,
canEdit: isCurrentUserMember,
canEdit: !!currentUserMembership && currentUserMembership.role === BoardMembershipRoles.EDITOR,
canEditCommentActivities:
!!currentUserMembership &&
(currentUserMembership.role === BoardMembershipRoles.EDITOR ||
currentUserMembership.canComment),
canEditAllCommentActivities: isCurrentUserManager,
};
};
+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,
+4 -2
View File
@@ -3,6 +3,7 @@ import { connect } from 'react-redux';
import selectors from '../selectors';
import entryActions from '../entry-actions';
import { BoardMembershipRoles } from '../constants/Enums';
import List from '../components/List';
const makeMapStateToProps = () => {
@@ -12,7 +13,7 @@ const makeMapStateToProps = () => {
return (state, { id, index }) => {
const { name, isPersisted } = selectListById(state, id);
const cardIds = selectCardIdsByListId(state, id);
const isCurrentUserMember = selectors.selectIsCurrentUserMemberForCurrentBoard(state);
const currentUserMembership = selectors.selectCurrentUserMembershipForCurrentBoard(state);
return {
id,
@@ -20,7 +21,8 @@ const makeMapStateToProps = () => {
name,
isPersisted,
cardIds,
canEdit: isCurrentUserMember,
canEdit:
!!currentUserMembership && currentUserMembership.role === BoardMembershipRoles.EDITOR,
};
};
};
@@ -14,6 +14,21 @@ const handleBoardMembershipCreate = (boardMembership) => ({
},
});
const updateBoardMembership = (id, data) => ({
type: EntryActionTypes.BOARD_MEMBERSHIP_UPDATE,
payload: {
id,
data,
},
});
const handleBoardMembershipUpdate = (boardMembership) => ({
type: EntryActionTypes.BOARD_MEMBERSHIP_UPDATE_HANDLE,
payload: {
boardMembership,
},
});
const deleteBoardMembership = (id) => ({
type: EntryActionTypes.BOARD_MEMBERSHIP_DELETE,
payload: {
@@ -31,6 +46,8 @@ const handleBoardMembershipDelete = (boardMembership) => ({
export default {
createMembershipInCurrentBoard,
handleBoardMembershipCreate,
updateBoardMembership,
handleBoardMembershipUpdate,
deleteBoardMembership,
handleBoardMembershipDelete,
};
-3
View File
@@ -1,3 +0,0 @@
import { createBrowserHistory } from 'history';
export default createBrowserHistory();
+4 -4
View File
@@ -1,10 +1,10 @@
import React from 'react';
import ReactDOM from 'react-dom';
import ReactDOM from 'react-dom/client';
import store from './store';
import history from './history';
import store, { history } from './store';
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 */
+34 -4
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}
@@ -64,9 +92,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>
);
});
+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',
},
+13 -1
View File
@@ -41,6 +41,9 @@ export default {
background: 'Background',
board: 'Board',
boardNotFound_title: 'Board Not Found',
canComment: 'Can comment',
canEditContentOfBoard: 'Can edit the content of the board',
canOnlyViewBoard: 'Can only view the board',
cardActions_title: 'Card Actions',
cardNotFound_title: 'Card Not Found',
cardOrActionAreDeleted: 'Card or action are deleted',
@@ -66,6 +69,7 @@ export default {
description: 'Description',
detectAutomatically: 'Detect automatically',
dropFileToUpload: 'Drop file to upload',
editor: 'Editor',
editAttachment_title: 'Edit Attachment',
editAvatar_title: 'Edit Avatar',
editBoard_title: 'Edit Board',
@@ -74,6 +78,7 @@ export default {
editInformation_title: 'Edit Information',
editLabel_title: 'Edit Label',
editPassword_title: 'Edit Password',
editPermissions_title: 'Edit Permissions',
editTimer_title: 'Edit Timer',
editUsername_title: 'Edit Username',
email: 'E-mail',
@@ -116,14 +121,18 @@ 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',
removeMember_title: 'Remove Member',
searchLabels: 'Search labels...',
searchMembers: 'Search members...',
searchUsers: 'Search users...',
seconds: 'Seconds',
selectBoard: 'Select board',
selectList: 'Select list',
selectPermissions_title: 'Select Permissions',
selectProject: 'Select project',
settings: 'Settings',
subscribeToMyOwnCardsByDefault: 'Subscribe to my own cards by default',
@@ -143,6 +152,7 @@ export default {
username: 'Username',
usernameAlreadyInUse: 'Username already in use',
users: 'Users',
viewer: 'Viewer',
writeComment: 'Write a comment...',
},
@@ -154,6 +164,7 @@ export default {
addCard_title: 'Add Card',
addComment: 'Add comment',
addList: 'Add list',
addMember: 'Add member',
addMoreDetailedDescription: 'Add more detailed description',
addTask: 'Add task',
addToCard: 'Add to card',
@@ -185,6 +196,7 @@ export default {
editEmail_title: 'Edit E-mail',
editInformation_title: 'Edit Information',
editPassword_title: 'Edit Password',
editPermissions: 'Edit permissions',
editTimer_title: 'Edit Timer',
editTitle_title: 'Edit Title',
editUsername_title: 'Edit Username',
+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',
+2 -1
View File
@@ -4,6 +4,7 @@ import de from './de';
import en from './en';
import es from './es';
import fr from './fr';
import it from './it';
import ja from './ja';
import ko from './ko';
import pl from './pl';
@@ -13,7 +14,7 @@ import sv from './sv';
import uz from './uz';
import zh from './zh';
const locales = [cs, da, de, en, es, fr, ja, ko, pl, ru, sk, sv, uz, zh];
const locales = [cs, da, de, en, es, fr, it, ja, ko, pl, ru, sk, sv, uz, zh];
export default locales;
+230
View File
@@ -0,0 +1,230 @@
export default {
format: {
date: 'd/M/yyyy',
time: 'p',
dateTime: '$t(format:date) $t(format:time)',
longDate: 'MMM d',
longDateTime: "MMMM d 'at' p",
},
translation: {
common: {
account: 'Account',
actions: 'Azioni',
addAttachment_title: 'Aggiungi Allegato',
addComment: 'Aggiungi commento',
addManager_title: 'Aggiungi Manager',
addMember_title: 'Aggiungi Membro',
addUser_title: 'Aggiungi Utente',
administrator: 'Amministratore',
all: 'tutto',
allChangesWillBeAutomaticallySavedAfterConnectionRestored:
'All changes will be automatically saved<br />after connection restored',
areYouSureYouWantToDeleteThisAttachment: 'Sei sicuro di voler eliminare questo allegato?',
areYouSureYouWantToDeleteThisBoard: 'Sei sicuro di voler eliminare questa bacheca?',
areYouSureYouWantToDeleteThisCard: 'Sei sicuro di voler eliminare questa card?',
areYouSureYouWantToDeleteThisComment: 'Sei sicuro di voler eliminare questo commento?',
areYouSureYouWantToDeleteThisLabel: 'Sei sicuro di voler eliminare questa etichetta?',
areYouSureYouWantToDeleteThisList: 'Sei sicuro di voler eliminare questo elenco?',
areYouSureYouWantToDeleteThisProject: 'Sei sicuro di voler eliminare questo progetto?',
areYouSureYouWantToDeleteThisTask: 'Sei sicuro di voler eliminare questo task?',
areYouSureYouWantToDeleteThisUser: 'Sei sicuro di voler eliminare questo utente?',
areYouSureYouWantToLeaveBoard: 'Sei sicuro di voler abbandonare la bacheca?',
areYouSureYouWantToLeaveProject: 'Sei sicuro di voler abbandonare il progetto?',
areYouSureYouWantToRemoveThisManagerFromProject:
'Sei sicuro di voler rimuovere questo amministratore dal progetto?',
areYouSureYouWantToRemoveThisMemberFromBoard:
'Sei sicuro di voler rimuovere questo membro dalla bacheca?',
attachment: 'Allegato',
attachments: 'Allegati',
authentication: 'Autenticazione',
background: 'Background',
board: 'Bacheca',
boardNotFound_title: 'Bacheca non trovata',
canComment: 'Può commentare',
canEditContentOfBoard: 'Può modificare il contenuto della bacheca',
canOnlyViewBoard: 'Può solo vedere la bacheca',
cardActions_title: 'Card Azioni',
cardNotFound_title: 'Card non trovata',
cardOrActionAreDeleted: "La card o l'azione vengono eliminate",
color: 'Colore',
createBoard_title: 'Crea Bacheca',
createLabel_title: 'Crea Etichetta',
createNewOneOrSelectExistingOne: 'Crea nuovo o seleziona <br /> esistente',
createProject_title: 'Crea Progetto',
createTextFile_title: 'Create File di Testo',
currentPassword: 'Password corrente',
dangerZone_title: 'Danger Zone',
date: 'Data',
dueDate_title: 'Data di scadenza',
deleteAttachment_title: 'Elimina Allegato',
deleteBoard_title: 'Elimina Bacheca',
deleteCard_title: 'Elimina card',
deleteComment_title: 'Elimina Commento',
deleteLabel_title: 'Elimina Etichetta',
deleteList_title: 'Elimina Elenco',
deleteProject_title: 'Elimina Progetto',
deleteTask_title: 'Elimina Task',
deleteUser_title: 'Elimina Utente',
description: 'Descrizione',
detectAutomatically: 'Rileva automaticamente',
dropFileToUpload: 'Trascina il file da caricare',
editor: 'Editor',
editAttachment_title: 'Modifica Allegato',
editAvatar_title: 'Modifica Avatar',
editBoard_title: 'Modifica Board',
editDueDate_title: 'Modifica Data di Scadenza',
editEmail_title: 'Modifica E-mail',
editInformation_title: 'Modifica Informazioni',
editLabel_title: 'Modifica Etichetta',
editPassword_title: 'Modifica Password',
editPermissions_title: 'Modifica Permessi',
editTimer_title: 'Modifica Timer',
editUsername_title: 'Modifica Username',
email: 'E-mail',
emailAlreadyInUse: 'E-mail già in uso',
enterCardTitle: 'Inserire titolo della card...',
enterDescription: 'Inserire descrizione...',
enterFilename: 'Inserire nome del file',
enterListTitle: 'Inserire titolo della lista...',
enterProjectTitle: 'Inserire titolo del progetto',
enterTaskDescription: 'Inserire descrizione del task...',
filterByLabels_title: 'Filtra per Etichetta',
filterByMembers_title: 'Filtra per Membro',
fromComputer_title: 'Dal Computer',
general: 'Generale',
hours: 'Ore',
invalidCurrentPassword: 'Password corrente non valida',
labels: 'Etichette',
language: 'Lingua',
leaveBoard_title: 'Lascia Bacheca',
leaveProject_title: 'Lascia Progetto',
list: 'Lista',
listActions_title: 'Lista delle Azioni',
managers: 'Managers',
members: 'Membri',
minutes: 'Minuti',
moveCard_title: 'Sposta card',
name: 'Nome',
newEmail: 'Nuova e-mail',
newPassword: 'Nuova password',
newUsername: 'Nuovo username',
noConnectionToServer: 'Nessuna connessione al server',
noBoards: 'No bacheche',
noLists: 'No liste',
noProjects: 'No progetti',
notifications: 'Notifiche',
noUnreadNotifications: 'Nessuna notifica non letta',
openBoard_title: 'Bacheca aperta',
optional_inline: 'opzionale',
organization: 'Organizazzione',
phone: 'Telefono',
preferences: 'Preferenze',
pressPasteShortcutToAddAttachmentFromClipboard:
'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',
removeMember_title: 'Rimuovi Membro',
searchLabels: 'Cerca etichette...',
searchMembers: 'Cerca membri...',
searchUsers: 'Cerca utenti...',
seconds: 'Secondi',
selectBoard: 'Seleziona bacheca',
selectList: 'Seleziona lista',
selectPermissions_title: 'Seleziona Permessi',
selectProject: 'Seleziona progetto',
settings: 'Impostazioni',
subscribeToMyOwnCardsByDefault: 'Abbonati alle mie card per impostazione predefinita',
taskActions_title: 'Task Azioni',
tasks: 'Tasks',
thereIsNoPreviewAvailableForThisAttachment:
'Non è disponibile alcuna anteprima per questo allegato',
time: 'Tempo',
timer: 'Timer',
title: 'Titolo',
userActions_title: 'Azioni Utente',
userAddedThisCardToList: '<0>{{user}}</0><1> ha aggiunto questa card a {{list}}</1>',
userLeftNewCommentToCard:
'{{user}} ha lasciato un nuovo commento «{{comment}}» a <2>{{card}}</2>',
userMovedCardFromListToList: '{{user}} ha mosso <2>{{card}}</2> da {{fromList}} a {{toList}}',
userMovedThisCardFromListToList:
'<0>{{user}}</0><1> ha mosso questa card da {{fromList}} a {{toList}}</1>',
username: 'Username',
usernameAlreadyInUse: 'Username già in uso',
users: 'Utenti',
viewer: 'Viewer',
writeComment: 'Scrivi un commento...',
},
action: {
addAnotherCard: "Aggiungi un'altra card",
addAnotherList: "Aggiungi un'altra lista",
addAnotherTask: 'Aggiungi un altro task',
addCard: 'Aggiungi card',
addCard_title: 'Aggiungi Card',
addComment: 'Aggiungi commento',
addList: 'Aggiungi lista',
addMember: 'Add membro',
addMoreDetailedDescription: 'Aggiungi una descrizione più dettagliata',
addTask: 'Aggiungi task',
addToCard: 'Aggiungi alla card',
addUser: 'Aggiungi utente',
createBoard: 'Crea bacheca',
createFile: 'Crea file',
createLabel: 'Crea etichetta',
createNewLabel: 'Crea nuova etichetta',
createProject: 'Crea progetto',
delete: 'Elimina',
deleteAttachment: 'Elimina allegato',
deleteAvatar: 'Elimina avatar',
deleteBoard: 'Elimina bacheca',
deleteCard: 'Elimina card',
deleteCard_title: 'Elimina Card',
deleteComment: 'Elimina commento',
deleteImage: 'Elimina immagine',
deleteLabel: 'Elimina etichetta',
deleteList: 'Elimina lista',
deleteList_title: 'Elimina Lista',
deleteProject: 'Elimina progetto',
deleteProject_title: 'Elimina Progetto',
deleteTask: 'Elimina task',
deleteTask_title: 'Elimina Task',
deleteUser: 'Elimina utente',
edit: 'Modifica',
editDueDate_title: 'Modifica data di scadenza',
editDescription_title: 'Modifica Descrizione',
editEmail_title: 'Modifica E-mail',
editInformation_title: 'Modifca Informazioni',
editPassword_title: 'Modifica Password',
editPermissions: 'Modifica permessi',
editTimer_title: 'Modifica Timer',
editTitle_title: 'Modifica Titolo',
editUsername_title: 'Modifica Username',
hideDetails: 'Nascondi dettagli',
leaveBoard: 'Lascia bacheca',
leaveProject: 'Lascia progetto',
logOut_title: 'Log Out',
makeCover_title: 'Crea Cover',
move: 'Muovi',
moveCard_title: 'Muovi Card',
remove: 'Rimuovi',
removeBackground: 'Rimuovi background',
removeCover_title: 'Rimuovi Cover',
removeFromBoard: 'Rimuovi dalla bacheca',
removeFromProject: 'Rimuovi dal Progetto',
removeManager: 'Rimuovi manager',
removeMember: 'Rimuovi membro',
save: 'Salva',
showAllAttachments: 'Mostra tutti gli allegati ({{hidden}} hidden)',
showDetails: 'Mostra dettagli',
showFewerAttachments: 'Mostra meno allegati',
start: 'Start',
stop: 'Stop',
subscribe: 'Subscribe',
unsubscribe: 'Unsubscribe',
uploadNewAvatar: 'Carica nuovo avatar',
uploadNewImage: 'Carica nuova immagine',
},
},
};
+8
View File
@@ -0,0 +1,8 @@
import login from './login';
export default {
language: 'it',
country: 'it',
name: 'Italiano',
embeddedLocale: login,
};
+20
View File
@@ -0,0 +1,20 @@
export default {
translation: {
common: {
emailOrUsername: 'E-mail o username',
invalidEmailOrUsername: 'E-mail or username invalido',
invalidPassword: 'Password non valida',
logInToPlanka: 'Log in Planka',
noInternetConnection: 'Nessuna connessione internet',
pageNotFound_title: 'Pagina non trovata',
password: 'Password',
projectManagement: 'Project management',
serverConnectionFailed: 'Connesione al server fallita',
unknownError: 'Errore sconosciuto, prova ancora',
},
action: {
logIn: 'Log in',
},
},
};
+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',

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