Compare commits

...

51 Commits

Author SHA1 Message Date
Maksim Eltyshev
617246ec40 chore: Update version 2024-11-22 17:14:57 +01:00
Maksim Eltyshev
e6ab870827 chore: Update dependencies 2024-11-22 17:13:17 +01:00
dependabot[bot]
fee35918d1 chore(deps): Bump cross-spawn from 7.0.3 to 7.0.6 (#957)
Bumps [cross-spawn](https://github.com/moxystudio/node-cross-spawn) from 7.0.3 to 7.0.6.
- [Changelog](https://github.com/moxystudio/node-cross-spawn/blob/master/CHANGELOG.md)
- [Commits](https://github.com/moxystudio/node-cross-spawn/compare/v7.0.3...v7.0.6)

---
updated-dependencies:
- dependency-name: cross-spawn
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-11-22 16:22:26 +01:00
dependabot[bot]
38ec9bd5cc chore(deps): Bump cross-spawn from 7.0.3 to 7.0.6 in /client (#951)
Bumps [cross-spawn](https://github.com/moxystudio/node-cross-spawn) from 7.0.3 to 7.0.6.
- [Changelog](https://github.com/moxystudio/node-cross-spawn/blob/master/CHANGELOG.md)
- [Commits](https://github.com/moxystudio/node-cross-spawn/compare/v7.0.3...v7.0.6)

---
updated-dependencies:
- dependency-name: cross-spawn
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-11-22 16:17:53 +01:00
Maksim Eltyshev
54a969ec31 fix: Disable text selection when drag-scrolling 2024-11-22 16:17:14 +01:00
PolarniMeda
d0a8c8b61a feat: Add Serbian translation (#952) 2024-11-19 13:47:44 +01:00
knif3
1d11d9e35c fix: Fix path to apple-touch-icon (#948) 2024-11-18 22:43:28 +01:00
Derzsi Dániel
2632edb44c fix: Lazy initialize OIDC client (#947) 2024-11-18 22:38:52 +01:00
Maksim Eltyshev
96956e1268 chore: Update version 2024-11-18 00:00:32 +01:00
Maksim Eltyshev
5a3c3bb39b fix: Disable pointer events when dragging 2024-11-17 23:45:29 +01:00
Maksim Eltyshev
1a70b2b7e6 fix: Prevent list with multiline title from overflowing board view
Closes #377
2024-11-17 23:37:43 +01:00
Maksim Eltyshev
71d0815891 fix: Consider language specifics when parsing time
Closes #946
2024-11-16 18:13:13 +01:00
Maksim Eltyshev
b700c307c3 chore: Update version 2024-11-12 19:14:28 +01:00
Maksim Eltyshev
9794919fd2 ref: Rename folder to dir for consistency 2024-11-12 17:07:04 +01:00
Maksim Eltyshev
917dcf31cf Merge branch 'master' of https://github.com/plankanban/planka 2024-11-12 15:58:27 +01:00
Maksim Eltyshev
850f6df0ac fix: Secure S3 attachments, bump SDK, refactoring
Closes #673
2024-11-12 15:58:22 +01:00
Jan Welslau
242d415142 fix: Use planka.fullname for postgres secret name in Helm (#939)
Closes #704
2024-11-12 14:18:36 +01:00
Nguyễn Hải Quang
950a070589 feat: Add S3 support for uploads (#938) 2024-11-11 14:59:18 +01:00
Maksim Eltyshev
f6ea10df97 chore: Update version 2024-10-31 14:58:29 +01:00
Maksim Eltyshev
d9e8c24c3f fix: Save description when clicking outside 2024-10-31 14:56:11 +01:00
Maksim Eltyshev
f75b0237d3 fix: Include previous data state in webhook
Closes #809
2024-10-31 00:48:49 +01:00
Maksim Eltyshev
38bc4cb0a0 ref: Refactoring 2024-10-30 22:28:25 +01:00
Elllone
cc95032e74 feat: Telegram bot notifications (#928) 2024-10-30 22:11:52 +01:00
Maksim Eltyshev
1d2193c381 chore: Update version 2024-10-27 22:05:18 +01:00
dependabot[bot]
70f40e26af chore(deps): Bump http-proxy-middleware from 2.0.6 to 2.0.7 in /client (#922)
Bumps [http-proxy-middleware](https://github.com/chimurai/http-proxy-middleware) from 2.0.6 to 2.0.7.
- [Release notes](https://github.com/chimurai/http-proxy-middleware/releases)
- [Changelog](https://github.com/chimurai/http-proxy-middleware/blob/v2.0.7/CHANGELOG.md)
- [Commits](https://github.com/chimurai/http-proxy-middleware/compare/v2.0.6...v2.0.7)

---
updated-dependencies:
- dependency-name: http-proxy-middleware
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-10-27 21:18:12 +01:00
Holden Hu
814b5810ac feat: Improve UX of comment actions (#924)
Closes #915
2024-10-27 21:17:08 +01:00
Zananok
f372113def feat: Add multiple task creation with Ctrl+Enter (#921) 2024-10-27 21:03:59 +01:00
leroyloren
14dff96434 fix: Update Czech translation (#920) 2024-10-24 15:39:20 +02:00
Maksim Eltyshev
7580afdb1c chore: Update version 2024-10-22 21:41:07 +02:00
Maksim Eltyshev
2458709031 feat: Attachments icon on front of cards
Closes #225
2024-10-22 21:38:04 +02:00
Maksim Eltyshev
e0d5d7f4ff feat: Description icon on front of cards
Closes #563
2024-10-22 21:22:46 +02:00
Maksim Eltyshev
bedd1f6e2a chore: Bump sails version 2024-10-22 21:00:37 +02:00
Maksim Eltyshev
d9464dc3f9 docs: Add invitation to TestFlight 2024-10-22 20:37:29 +02:00
Maksim Eltyshev
87c1eeb5f8 chore: Bump postgres version to 16 in compose files 2024-10-22 20:22:23 +02:00
Holden Hu
98413d759a chore: Pin postgres version for docker-compose-db (#919)
Closes #918
2024-10-22 20:16:26 +02:00
Maksim Eltyshev
fa3b1d7b28 fix: Format-agnostic time parsing
Closes #916
2024-10-21 14:11:26 +02:00
Maksim Eltyshev
23e5f1a326 ci: Add lint workflow 2024-10-17 23:51:47 +02:00
dependabot[bot]
de44b520af chore(deps): Bump cookie and express in /client (#912)
Bumps [cookie](https://github.com/jshttp/cookie) and [express](https://github.com/expressjs/express). These dependencies needed to be updated together.

Updates `cookie` from 0.6.0 to 0.7.1
- [Release notes](https://github.com/jshttp/cookie/releases)
- [Commits](https://github.com/jshttp/cookie/compare/v0.6.0...v0.7.1)

Updates `express` from 4.21.0 to 4.21.1
- [Release notes](https://github.com/expressjs/express/releases)
- [Changelog](https://github.com/expressjs/express/blob/4.21.1/History.md)
- [Commits](https://github.com/expressjs/express/compare/4.21.0...4.21.1)

---
updated-dependencies:
- dependency-name: cookie
  dependency-type: indirect
- dependency-name: express
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-10-17 22:15:39 +02:00
Nalem7
e7d7425768 test: Add BDD UI tests using Playwright (#911) 2024-10-17 22:06:48 +02:00
Amrita
df7746f896 test: Setup UI test using BDD approach (#152) 2024-10-17 20:18:31 +02:00
Maksim Eltyshev
01a7e3a57d ci: Omit version completely from prebuild asset 2024-10-09 14:53:16 +02:00
Maksim Eltyshev
234bd1e8b3 ci: Fix naming of prebuild asset 2024-10-09 14:40:22 +02:00
Maksim Eltyshev
2173c3657c chore: Update version 2024-10-09 14:25:19 +02:00
Maksim Eltyshev
a2b81f6d59 feat: Add British English translation 2024-10-09 14:22:22 +02:00
Maksim Eltyshev
1948bd4fbe feat: Ability to upload multiple attachments at once
Closes #908
2024-10-09 12:38:18 +02:00
Maksim Eltyshev
52f8abc1f8 docs: Add information about mobile app 2024-10-03 19:07:59 +02:00
Maksim Eltyshev
dd7574f134 ci: Omit version from prebuild asset label 2024-10-02 15:20:10 +02:00
Maksim Eltyshev
19935e52af chore: Update version 2024-10-02 14:18:54 +02:00
Maksim Eltyshev
89292356db feat: Ability to disable SMTP certificate verification
Closes #744
2024-10-02 14:10:31 +02:00
dependabot[bot]
5f6528fa42 chore(deps): Bump rollup from 2.79.1 to 2.79.2 in /client (#898)
Bumps [rollup](https://github.com/rollup/rollup) from 2.79.1 to 2.79.2.
- [Release notes](https://github.com/rollup/rollup/releases)
- [Changelog](https://github.com/rollup/rollup/blob/master/CHANGELOG.md)
- [Commits](https://github.com/rollup/rollup/compare/v2.79.1...v2.79.2)

---
updated-dependencies:
- dependency-name: rollup
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-10-02 12:32:10 +02:00
JoeKer1
2081b44874 feat: Support for service annotations in Helm (#903)
Closes #902
2024-10-02 12:30:55 +02:00
107 changed files with 6813 additions and 3984 deletions

View File

@@ -4,7 +4,6 @@ on:
release:
types: [created]
jobs:
build-and-publish-release-package:
runs-on: ubuntu-latest
@@ -48,10 +47,10 @@ jobs:
- name: Dist create .zip file
run: |
mv dist/ planka/
zip -r planka-prebuild-${{ github.event.release.tag_name }}.zip planka
zip -r planka-prebuild.zip planka
- name: Dist upload assets
run: |
gh release upload ${{ github.event.release.tag_name }} planka-prebuild-${{ github.event.release.tag_name }}.zip
gh release upload ${{ github.event.release.tag_name }} planka-prebuild.zip
env:
GH_TOKEN: ${{ github.token }}

73
.github/workflows/build-and-test.yml vendored Normal file
View File

@@ -0,0 +1,73 @@
name: Build and test
on:
pull_request:
branches:
- master
push:
branches:
- master
jobs:
setup:
runs-on: ubuntu-latest
env:
POSTGRES_DB: planka_db
POSTGRES_USER: user
POSTGRES_PASSWORD: password
steps:
- name: Checkout
uses: actions/checkout@v3
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 18
cache: 'npm'
- name: Setup PostgreSQL
uses: ikalnytskyi/action-setup-postgres@v5
with:
database: ${{ env.POSTGRES_DB }}
username: ${{ env.POSTGRES_USER }}
password: ${{ env.POSTGRES_PASSWORD }}
- name: Cache Node.js modules
uses: actions/cache@v3
with:
path: client/node_modules
key: ${{ runner.os }}-node-${{ hashFiles('client/package-lock.json') }}
restore-keys: |
${{ runner.os }}-node-
- name: Install dependencies
run: |
npm install
cd client
npm run build
- name: Setup server
env:
DEFAULT_ADMIN_EMAIL: demo@demo.demo
DEFAULT_ADMIN_PASSWORD: demo
DEFAULT_ADMIN_NAME: Demo Demo
DEFAULT_ADMIN_USERNAME: demo
run: |
client/tests/setup-symlinks.sh
cd server
cp .env.sample .env
sed -i "s|^DATABASE_URL=.*|DATABASE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@localhost/${POSTGRES_DB}|" .env
npm run db:init
npm start --prod &
- name: Wait for development server
run: |
sudo apt-get install wait-for-it -y
wait-for-it -h localhost -p 1337 -t 10
- name: Run UI tests
run: |
cd client
npm install
npx playwright install chromium
npm run test:acceptance tests

33
.github/workflows/lint.yml vendored Normal file
View File

@@ -0,0 +1,33 @@
name: Lint
on:
pull_request:
branches:
- master
jobs:
setup:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 18
cache: 'npm'
- name: Cache Node.js modules
uses: actions/cache@v3
with:
path: client/node_modules
key: ${{ runner.os }}-node-${{ hashFiles('client/package-lock.json') }}
restore-keys: |
${{ runner.os }}-node-
- name: Install dependencies
run: npm install
- name: Run linter
run: npm run lint

View File

@@ -25,6 +25,14 @@ There are many ways to install Planka, [check them out](https://docs.planka.clou
For configuration, please see the [configuration section](https://docs.planka.cloud/docs/category/configuration).
## Mobile app
Here is the [mobile app repository](https://github.com/LouisHDev/planka_app) maintained by the community, where you can build an app for iOS and Android.
Alternatively, you can download the [Android APK](https://github.com/LouisHDev/planka_app/releases/latest/download/app-release.apk) directly.
If you have an iOS device and would like to test the app, you can join [TestFlight](https://testflight.apple.com/join/Uwn41eY4) (limited to 200 participants).
## Contact
- If you want to get a hosted version of Planka, you can contact us via email contact@planka.cloud

View File

@@ -15,13 +15,13 @@ type: application
# This is the chart version. This version number should be incremented each time you make changes
# to the chart and its templates, including the app version.
# Versions are expected to follow Semantic Versioning (https://semver.org/)
version: 0.2.8
version: 0.2.17
# This is the version number of the application being deployed. This version number should be
# incremented each time you make changes to the application. Versions are not expected to
# follow Semantic Versioning. They should reflect the version the application is using.
# It is recommended to use it with quotes.
appVersion: "1.22.0"
appVersion: "1.24.2"
dependencies:
- alias: postgresql

View File

@@ -82,7 +82,7 @@ spec:
- name: DATABASE_URL
valueFrom:
secretKeyRef:
name: planka-postgresql-svcbind-custom-user
name: {{ include "planka.fullname" . }}-postgresql-svcbind-custom-user
key: uri
{{- end }}
- name: BASE_URL

View File

@@ -4,6 +4,10 @@ metadata:
name: {{ include "planka.fullname" . }}
labels:
{{- include "planka.labels" . | nindent 4 }}
{{- with .Values.service.annotations }}
annotations:
{{- toYaml . | nindent 4 }}
{{- end }}
spec:
type: {{ .Values.service.type }}
ports:

View File

@@ -54,6 +54,7 @@ securityContext: {}
# runAsUser: 1000
service:
annotations: {}
type: ClusterIP
port: 1337
## @param service.containerPort Planka HTTP container port

5531
client/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -6,7 +6,8 @@
"eject": "react-scripts eject",
"lint": "eslint --ext js,jsx src config-overrides.js",
"start": "react-app-rewired start",
"test": "react-app-rewired test"
"test": "react-app-rewired test",
"test:acceptance": "cucumber-js --require tests/acceptance/cucumber.conf.js --require tests/acceptance/stepDefinitions/**/*.js --format @cucumber/pretty-formatter"
},
"browserslist": {
"production": [
@@ -65,15 +66,15 @@
"dequal": "^2.0.3",
"easymde": "^2.18.0",
"history": "^5.3.0",
"i18next": "^23.15.1",
"i18next": "^23.16.8",
"i18next-browser-languagedetector": "^8.0.0",
"initials": "^3.1.2",
"js-cookie": "^3.0.5",
"jwt-decode": "^4.0.0",
"linkify-react": "^4.1.3",
"linkifyjs": "^4.1.3",
"linkify-react": "^4.1.4",
"linkifyjs": "^4.1.4",
"lodash": "^4.17.21",
"nanoid": "^5.0.7",
"nanoid": "^5.0.8",
"node-sass": "^9.0.0",
"photoswipe": "^5.4.4",
"prop-types": "^15.8.1",
@@ -82,16 +83,16 @@
"react-beautiful-dnd": "^13.1.1",
"react-datepicker": "^4.25.0",
"react-dom": "18.2.0",
"react-dropzone": "^14.2.3",
"react-i18next": "^15.0.2",
"react-dropzone": "^14.3.5",
"react-i18next": "^15.1.1",
"react-input-mask": "^2.0.4",
"react-markdown": "^8.0.7",
"react-photoswipe-gallery": "^2.2.7",
"react-redux": "^8.1.3",
"react-router-dom": "^6.26.2",
"react-router-dom": "^6.28.0",
"react-scripts": "5.0.1",
"react-simplemde-editor": "^5.2.0",
"react-textarea-autosize": "^8.5.3",
"react-textarea-autosize": "^8.5.5",
"redux": "^4.2.1",
"redux-logger": "^3.0.6",
"redux-orm": "^0.16.2",
@@ -108,17 +109,22 @@
},
"devDependencies": {
"@babel/plugin-proposal-private-property-in-object": "^7.21.11",
"@testing-library/jest-dom": "^6.5.0",
"@cucumber/cucumber": "^7.3.2",
"@cucumber/pretty-formatter": "^1.0.1",
"@playwright/test": "^1.49.0",
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^15.0.7",
"@testing-library/user-event": "^14.5.2",
"axios": "^1.6.2",
"babel-preset-airbnb": "^5.0.0",
"chai": "^4.5.0",
"eslint": "^8.57.0",
"eslint": "8.57.0",
"eslint-config-airbnb": "^19.0.4",
"eslint-plugin-import": "^2.30.0",
"eslint-plugin-jsx-a11y": "^6.10.0",
"eslint-plugin-react": "^7.36.1",
"eslint-plugin-import": "^2.31.0",
"eslint-plugin-jsx-a11y": "^6.10.2",
"eslint-plugin-react": "^7.37.2",
"eslint-plugin-react-hooks": "^4.6.2",
"playwright": "^1.49.0",
"react-test-renderer": "18.2.0"
}
}

View File

@@ -9,7 +9,7 @@
name="description"
content="Planka is an open source project management software"
/>
<link rel="apple-touch-icon" href="logo192.png" />
<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
<!--
manifest.json provides metadata used when your web app is installed on a
user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/

View File

@@ -11,6 +11,7 @@ import ListAdd from './ListAdd';
import { ReactComponent as PlusMathIcon } from '../../assets/images/plus-math-icon.svg';
import styles from './Board.module.scss';
import globalStyles from '../../styles.module.scss';
const parseDndId = (dndId) => dndId.split(':')[1];
@@ -31,11 +32,14 @@ const Board = React.memo(
}, []);
const handleDragStart = useCallback(() => {
document.body.classList.add(globalStyles.dragging);
closePopup();
}, []);
const handleDragEnd = useCallback(
({ draggableId, type, source, destination }) => {
document.body.classList.remove(globalStyles.dragging);
if (
!destination ||
(source.droppableId === destination.droppableId && source.index === destination.index)
@@ -72,13 +76,16 @@ const Board = React.memo(
}
prevPosition.current = event.clientX;
window.getSelection().removeAllRanges();
document.body.classList.add(globalStyles.dragScrolling);
},
[wrapper],
);
const handleWindowMouseMove = useCallback(
(event) => {
if (!prevPosition.current) {
if (prevPosition.current === null) {
return;
}
@@ -93,8 +100,13 @@ const Board = React.memo(
[prevPosition],
);
const handleWindowMouseUp = useCallback(() => {
const handleWindowMouseRelease = useCallback(() => {
if (prevPosition.current === null) {
return;
}
prevPosition.current = null;
document.body.classList.remove(globalStyles.dragScrolling);
}, [prevPosition]);
useEffect(() => {
@@ -112,14 +124,20 @@ const Board = React.memo(
}, [listIds, isListAddOpened]);
useEffect(() => {
window.addEventListener('mouseup', handleWindowMouseUp);
window.addEventListener('mousemove', handleWindowMouseMove);
window.addEventListener('mouseup', handleWindowMouseRelease);
window.addEventListener('blur', handleWindowMouseRelease);
window.addEventListener('contextmenu', handleWindowMouseRelease);
return () => {
window.removeEventListener('mouseup', handleWindowMouseUp);
window.removeEventListener('mousemove', handleWindowMouseMove);
window.removeEventListener('mouseup', handleWindowMouseRelease);
window.removeEventListener('blur', handleWindowMouseRelease);
window.removeEventListener('contextmenu', handleWindowMouseRelease);
};
}, [handleWindowMouseUp, handleWindowMouseMove]);
}, [handleWindowMouseMove, handleWindowMouseRelease]);
return (
<>

View File

@@ -13,6 +13,7 @@ import AddStep from './AddStep';
import EditStep from './EditStep';
import styles from './Boards.module.scss';
import globalStyles from '../../styles.module.scss';
const Boards = React.memo(({ items, currentId, canEdit, onCreate, onUpdate, onMove, onDelete }) => {
const tabsWrapper = useRef(null);
@@ -24,11 +25,14 @@ const Boards = React.memo(({ items, currentId, canEdit, onCreate, onUpdate, onMo
}, []);
const handleDragStart = useCallback(() => {
document.body.classList.add(globalStyles.dragging);
closePopup();
}, []);
const handleDragEnd = useCallback(
({ draggableId, source, destination }) => {
document.body.classList.remove(globalStyles.dragging);
if (!destination || source.index === destination.index) {
return;
}

View File

@@ -23,6 +23,7 @@ const Card = React.memo(
id,
index,
name,
description,
dueDate,
isDueDateCompleted,
stopwatch,
@@ -31,6 +32,7 @@ const Card = React.memo(
listId,
projectId,
isPersisted,
attachmentsTotal,
notificationsTotal,
users,
labels,
@@ -106,7 +108,11 @@ const Card = React.memo(
)}
<div className={styles.name}>{name}</div>
{tasks.length > 0 && <Tasks items={tasks} />}
{(dueDate || stopwatch || notificationsTotal > 0) && (
{(description ||
dueDate ||
stopwatch ||
attachmentsTotal > 0 ||
notificationsTotal > 0) && (
<span className={styles.attachments}>
{notificationsTotal > 0 && (
<span
@@ -135,6 +141,21 @@ const Card = React.memo(
/>
</span>
)}
{description && (
<span className={classNames(styles.attachment, styles.attachmentLeft)}>
<span className={styles.attachmentContent}>
<Icon name="align left" />
</span>
</span>
)}
{attachmentsTotal > 0 && (
<span className={classNames(styles.attachment, styles.attachmentLeft)}>
<span className={styles.attachmentContent}>
<Icon name="attach" />
{attachmentsTotal}
</span>
</span>
)}
</span>
)}
{users.length > 0 && (
@@ -221,6 +242,7 @@ Card.propTypes = {
id: PropTypes.string.isRequired,
index: PropTypes.number.isRequired,
name: PropTypes.string.isRequired,
description: PropTypes.string,
dueDate: PropTypes.instanceOf(Date),
isDueDateCompleted: PropTypes.bool,
stopwatch: PropTypes.object, // eslint-disable-line react/forbid-prop-types
@@ -229,6 +251,7 @@ Card.propTypes = {
listId: PropTypes.string.isRequired,
projectId: PropTypes.string.isRequired,
isPersisted: PropTypes.bool.isRequired,
attachmentsTotal: PropTypes.number.isRequired,
notificationsTotal: PropTypes.number.isRequired,
/* eslint-disable react/forbid-prop-types */
users: PropTypes.array.isRequired,
@@ -256,6 +279,7 @@ Card.propTypes = {
};
Card.defaultProps = {
description: undefined,
dueDate: undefined,
isDueDateCompleted: undefined,
stopwatch: undefined,

View File

@@ -39,6 +39,13 @@
vertical-align: top;
}
.attachmentContent {
color: #6a808b;
font-size: 12px;
line-height: 20px;
padding: 0px 3px;
}
.attachmentLeft {
margin-right: 4px;
}

View File

@@ -10,7 +10,7 @@ import { focusEnd } from '../../../utils/element-helpers';
import styles from './CommentEdit.module.scss';
const CommentEdit = React.forwardRef(({ children, defaultData, onUpdate }, ref) => {
const CommentEdit = React.forwardRef(({ defaultData, onUpdate, text, actions }, ref) => {
const [t] = useTranslation();
const [isOpened, setIsOpened] = useState(false);
const [data, handleFieldChange, setData] = useForm(null);
@@ -76,7 +76,12 @@ const CommentEdit = React.forwardRef(({ children, defaultData, onUpdate }, ref)
}, [isOpened]);
if (!isOpened) {
return children;
return (
<>
{actions}
{text}
</>
);
}
return (
@@ -101,9 +106,10 @@ const CommentEdit = React.forwardRef(({ children, defaultData, onUpdate }, ref)
});
CommentEdit.propTypes = {
children: PropTypes.element.isRequired,
defaultData: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types
onUpdate: PropTypes.func.isRequired,
text: PropTypes.element.isRequired,
actions: PropTypes.element.isRequired,
};
export default React.memo(CommentEdit);

View File

@@ -31,44 +31,51 @@ const ItemComment = React.memo(
<User name={user.name} avatarUrl={user.avatarUrl} />
</span>
<div className={classNames(styles.content)}>
<div className={styles.title}>
<span className={styles.author}>{user.name}</span>
<span className={styles.date}>
{t(`format:${getDateFormat(createdAt)}`, {
postProcess: 'formatDate',
value: createdAt,
})}
</span>
</div>
<CommentEdit ref={commentEdit} defaultData={data} onUpdate={onUpdate}>
<>
<CommentEdit
ref={commentEdit}
defaultData={data}
onUpdate={onUpdate}
text={
<div className={styles.text}>
<Markdown linkTarget="_blank">{data.text}</Markdown>
</div>
{canEdit && (
<Comment.Actions>
<Comment.Action
as="button"
content={t('action.edit')}
disabled={!isPersisted}
onClick={handleEditClick}
/>
<DeletePopup
title="common.deleteComment"
content="common.areYouSureYouWantToDeleteThisComment"
buttonContent="action.deleteComment"
onConfirm={onDelete}
>
}
actions={
<div className={styles.title}>
<span>
<span className={styles.author}>{user.name}</span>
<span className={styles.date}>
{t(`format:${getDateFormat(createdAt)}`, {
postProcess: 'formatDate',
value: createdAt,
})}
</span>
</span>
{canEdit && (
<Comment.Actions>
<Comment.Action
as="button"
content={t('action.delete')}
content={t('action.edit')}
disabled={!isPersisted}
onClick={handleEditClick}
/>
</DeletePopup>
</Comment.Actions>
)}
</>
</CommentEdit>
<DeletePopup
title="common.deleteComment"
content="common.areYouSureYouWantToDeleteThisComment"
buttonContent="action.deleteComment"
onConfirm={onDelete}
>
<Comment.Action
as="button"
content={t('action.delete')}
disabled={!isPersisted}
/>
</DeletePopup>
</Comment.Actions>
)}
</div>
}
/>
</div>
</Comment>
);

View File

@@ -38,6 +38,12 @@
.title {
padding-bottom: 4px;
display: flex;
align-items: center;
justify-content: space-between;
position: sticky;
top: -1em;
background: #f5f6f7;
}
.user {

View File

@@ -28,7 +28,7 @@ const AttachmentAddStep = React.memo(({ onCreate, onClose }) => {
</Popup.Header>
<Popup.Content>
<Menu secondary vertical className={styles.menu}>
<FilePicker onSelect={handleFileSelect}>
<FilePicker multiple onSelect={handleFileSelect}>
<Menu.Item className={styles.menuItem}>
{t('common.fromComputer', {
context: 'title',

View File

@@ -5,6 +5,7 @@ import { useTranslation } from 'react-i18next';
import { closePopup } from '../../../lib/popup';
import { useModal } from '../../../hooks';
import { isActiveTextElement } from '../../../utils/element-helpers';
import TextFileAddModal from './TextFileAddModal';
import styles from './AttachmentAddZone.module.scss';
@@ -24,13 +25,14 @@ const AttachmentAddZone = React.memo(({ children, onCreate }) => {
const handleDropAccepted = useCallback(
(files) => {
submit(files[0]);
files.forEach((file) => {
submit(file);
});
},
[submit],
);
const { getRootProps, getInputProps, isDragActive } = useDropzone({
multiple: false,
noClick: true,
noKeyboard: true,
onDropAccepted: handleDropAccepted,
@@ -49,38 +51,43 @@ const AttachmentAddZone = React.memo(({ children, onCreate }) => {
return;
}
const file = event.clipboardData.files[0];
const { files, items } = event.clipboardData;
if (file) {
submit(file);
return;
}
const item = event.clipboardData.items[0];
if (!item) {
return;
}
if (item.kind === 'file') {
submit(item.getAsFile());
return;
}
if (
['input', 'textarea'].includes(event.target.tagName.toLowerCase()) &&
event.target === document.activeElement
) {
return;
}
closePopup();
event.preventDefault();
item.getAsString((content) => {
openModal({
content,
if (files.length > 0) {
[...files].forEach((file) => {
submit(file);
});
return;
}
if (items.length === 0) {
return;
}
if (items[0].kind === 'string') {
if (isActiveTextElement(event.target)) {
return;
}
closePopup();
event.preventDefault();
items[0].getAsString((content) => {
openModal({
content,
});
});
return;
}
[...items].forEach((item) => {
if (item.kind !== 'file') {
return;
}
submit(item.getAsFile());
});
};

View File

@@ -369,7 +369,7 @@ const CardModal = React.memo(
{(description || canEdit) && (
<div className={styles.contentModule}>
<div className={styles.moduleWrapper}>
<Icon name="align justify" className={styles.moduleIcon} />
<Icon name="align left" className={styles.moduleIcon} />
<div className={styles.moduleHeader}>{t('common.description')}</div>
{canEdit ? (
<DescriptionEdit defaultValue={description} onUpdate={handleDescriptionUpdate}>

View File

@@ -1,8 +1,11 @@
import React, { useCallback, useImperativeHandle, useMemo, useState } from 'react';
import React, { useCallback, useImperativeHandle, useMemo, useRef, useState } from 'react';
import PropTypes from 'prop-types';
import { useTranslation } from 'react-i18next';
import { Button, Form } from 'semantic-ui-react';
import SimpleMDE from 'react-simplemde-editor';
import { useClickAwayListener } from '../../lib/hooks';
import { useNestedRef } from '../../hooks';
import styles from './DescriptionEdit.module.scss';
@@ -11,6 +14,10 @@ const DescriptionEdit = React.forwardRef(({ children, defaultValue, onUpdate },
const [isOpened, setIsOpened] = useState(false);
const [value, setValue] = useState(null);
const editorWrapperRef = useRef(null);
const codemirrorRef = useRef(null);
const [buttonRef, handleButtonRef] = useNestedRef();
const open = useCallback(() => {
setIsOpened(true);
setValue(defaultValue || '');
@@ -37,7 +44,7 @@ const DescriptionEdit = React.forwardRef(({ children, defaultValue, onUpdate },
);
const handleChildrenClick = useCallback(() => {
if (!getSelection().toString()) {
if (!window.getSelection().toString()) {
open();
}
}, [open]);
@@ -55,6 +62,28 @@ const DescriptionEdit = React.forwardRef(({ children, defaultValue, onUpdate },
close();
}, [close]);
const handleAwayClick = useCallback(() => {
if (!isOpened) {
return;
}
close();
}, [isOpened, close]);
const handleClickAwayCancel = useCallback(() => {
codemirrorRef.current.focus();
}, []);
const clickAwayProps = useClickAwayListener(
[editorWrapperRef, buttonRef],
handleAwayClick,
handleClickAwayCancel,
);
const handleGetCodemirrorInstance = useCallback((codemirror) => {
codemirrorRef.current = codemirror;
}, []);
const mdEditorOptions = useMemo(
() => ({
autoDownloadFontAwesome: false,
@@ -92,16 +121,20 @@ const DescriptionEdit = React.forwardRef(({ children, defaultValue, onUpdate },
return (
<Form onSubmit={handleSubmit}>
<SimpleMDE
value={value}
options={mdEditorOptions}
placeholder={t('common.enterDescription')}
className={styles.field}
onKeyDown={handleFieldKeyDown}
onChange={setValue}
/>
{/* eslint-disable-next-line react/jsx-props-no-spreading */}
<div {...clickAwayProps} ref={editorWrapperRef}>
<SimpleMDE
value={value}
options={mdEditorOptions}
placeholder={t('common.enterDescription')}
className={styles.field}
getCodemirrorInstance={handleGetCodemirrorInstance}
onKeyDown={handleFieldKeyDown}
onChange={setValue}
/>
</div>
<div className={styles.controls}>
<Button positive content={t('action.save')} />
<Button positive ref={handleButtonRef} content={t('action.save')} />
</div>
</Form>
);

View File

@@ -13,6 +13,8 @@ const DEFAULT_DATA = {
name: '',
};
const MULTIPLE_REGEX = /\s*\r?\n\s*/;
const Add = React.forwardRef(({ children, onCreate }, ref) => {
const [t] = useTranslation();
const [isOpened, setIsOpened] = useState(false);
@@ -29,22 +31,34 @@ const Add = React.forwardRef(({ children, onCreate }, ref) => {
setIsOpened(false);
}, []);
const submit = useCallback(() => {
const cleanData = {
...data,
name: data.name.trim(),
};
const submit = useCallback(
(isMultiple = false) => {
const cleanData = {
...data,
name: data.name.trim(),
};
if (!cleanData.name) {
nameField.current.ref.current.select();
return;
}
if (!cleanData.name) {
nameField.current.ref.current.select();
return;
}
onCreate(cleanData);
if (isMultiple) {
cleanData.name.split(MULTIPLE_REGEX).forEach((name) => {
onCreate({
...cleanData,
name,
});
});
} else {
onCreate(cleanData);
}
setData(DEFAULT_DATA);
focusNameField();
}, [onCreate, data, setData, focusNameField]);
setData(DEFAULT_DATA);
focusNameField();
},
[onCreate, data, setData, focusNameField],
);
useImperativeHandle(
ref,
@@ -63,8 +77,7 @@ const Add = React.forwardRef(({ children, onCreate }, ref) => {
(event) => {
if (event.key === 'Enter') {
event.preventDefault();
submit();
submit(event.ctrlKey);
}
},
[submit],

View File

@@ -10,16 +10,20 @@ import Item from './Item';
import Add from './Add';
import styles from './Tasks.module.scss';
import globalStyles from '../../../styles.module.scss';
const Tasks = React.memo(({ items, canEdit, onCreate, onUpdate, onMove, onDelete }) => {
const [t] = useTranslation();
const handleDragStart = useCallback(() => {
document.body.classList.add(globalStyles.dragging);
closePopup();
}, []);
const handleDragEnd = useCallback(
({ draggableId, source, destination }) => {
document.body.classList.remove(globalStyles.dragging);
if (!destination || source.index === destination.index) {
return;
}

View File

@@ -7,6 +7,7 @@ import { useDidUpdate, useToggle } from '../../lib/hooks';
import { Input, Popup } from '../../lib/custom-ui';
import { useForm } from '../../hooks';
import parseTime from '../../utils/parse-time';
import styles from './DueDateEditStep.module.scss';
@@ -66,14 +67,18 @@ const DueDateEditStep = React.memo(({ defaultValue, onUpdate, onBack, onClose })
return;
}
const value = t('format:dateTime', {
let value = t('format:dateTime', {
postProcess: 'parseDate',
value: `${data.date} ${data.time}`,
});
if (Number.isNaN(value.getTime())) {
timeField.current.select();
return;
value = parseTime(data.time, nullableDate);
if (Number.isNaN(value.getTime())) {
timeField.current.select();
return;
}
}
if (!defaultValue || value.getTime() !== defaultValue.getTime()) {

View File

@@ -13,6 +13,7 @@ import EditStep from './EditStep';
import Item from './Item';
import styles from './LabelsStep.module.scss';
import globalStyles from '../../styles.module.scss';
const StepTypes = {
ADD: 'ADD',
@@ -77,8 +78,14 @@ const LabelsStep = React.memo(
[onDeselect],
);
const handleDragStart = useCallback(() => {
document.body.classList.add(globalStyles.dragging);
}, []);
const handleDragEnd = useCallback(
({ draggableId, source, destination }) => {
document.body.classList.remove(globalStyles.dragging);
if (!destination || source.index === destination.index) {
return;
}
@@ -159,7 +166,7 @@ const LabelsStep = React.memo(
onChange={handleSearchChange}
/>
{filteredItems.length > 0 && (
<DragDropContext onDragEnd={handleDragEnd}>
<DragDropContext onDragStart={handleDragStart} onDragEnd={handleDragEnd}>
<Droppable droppableId="labels" type={DroppableTypes.LABEL}>
{({ innerRef, droppableProps, placeholder }) => (
<div

View File

@@ -32,7 +32,7 @@ const List = React.memo(
const [isAddCardOpened, setIsAddCardOpened] = useState(false);
const nameEdit = useRef(null);
const listWrapper = useRef(null);
const cardsWrapper = useRef(null);
const handleHeaderClick = useCallback(() => {
if (isPersisted && canEdit) {
@@ -67,7 +67,7 @@ const List = React.memo(
useEffect(() => {
if (isAddCardOpened) {
listWrapper.current.scrollTop = listWrapper.current.scrollHeight;
cardsWrapper.current.scrollTop = cardsWrapper.current.scrollHeight;
}
}, [cardIds, isAddCardOpened]);
@@ -133,13 +133,7 @@ const List = React.memo(
</ActionsPopup>
)}
</div>
<div
ref={listWrapper}
className={classNames(
styles.cardsInnerWrapper,
(isAddCardOpened || !canEdit) && styles.cardsInnerWrapperFull,
)}
>
<div ref={cardsWrapper} className={styles.cardsInnerWrapper}>
<div className={styles.cardsOuterWrapper}>{cardsNode}</div>
</div>
{!isAddCardOpened && canEdit && (

View File

@@ -40,7 +40,6 @@
}
.cardsInnerWrapper {
max-height: calc(100vh - 268px);
overflow-x: hidden;
overflow-y: auto;
width: 290px;
@@ -62,10 +61,6 @@
}
}
.cardsInnerWrapperFull {
max-height: calc(100vh - 232px);
}
.cardsOuterWrapper {
padding: 0 8px;
white-space: normal;
@@ -140,6 +135,9 @@
.outerWrapper {
background: #dfe3e6;
border-radius: 3px;
display: flex;
flex-direction: column;
max-height: calc(100vh - 198px);
overflow: hidden;
}
}

View File

@@ -20,12 +20,22 @@ const makeMapStateToProps = () => {
const allLabels = selectors.selectLabelsForCurrentBoard(state);
const currentUserMembership = selectors.selectCurrentUserMembershipForCurrentBoard(state);
const { name, dueDate, isDueDateCompleted, stopwatch, coverUrl, boardId, listId, isPersisted } =
selectCardById(state, id);
const {
name,
description,
dueDate,
isDueDateCompleted,
stopwatch,
coverUrl,
boardId,
listId,
isPersisted,
} = selectCardById(state, id);
const users = selectUsersByCardId(state, id);
const labels = selectLabelsByCardId(state, id);
const tasks = selectTasksByCardId(state, id);
const attachmentsTotal = selectors.selectAttachmentsTotalByCardId(state, id);
const notificationsTotal = selectNotificationsTotalByCardId(state, id);
const isCurrentUserEditor =
@@ -35,6 +45,7 @@ const makeMapStateToProps = () => {
id,
index,
name,
description,
dueDate,
isDueDateCompleted,
stopwatch,
@@ -43,6 +54,7 @@ const makeMapStateToProps = () => {
listId,
projectId,
isPersisted,
attachmentsTotal,
notificationsTotal,
users,
labels,

View File

@@ -1,7 +1,8 @@
import useNestedRef from './use-nested-ref';
import useField from './use-field';
import useForm from './use-form';
import useSteps from './use-steps';
import useModal from './use-modal';
import useClosableForm from './use-closable-form';
export { useField, useForm, useSteps, useModal, useClosableForm };
export { useNestedRef, useField, useForm, useSteps, useModal, useClosableForm };

View File

@@ -0,0 +1,14 @@
import { useCallback, useRef } from 'react';
export default (nestedRefName = 'ref') => {
const ref = useRef(null);
const handleRef = useCallback(
(element) => {
ref.current = element?.[nestedRefName].current;
},
[nestedRefName],
);
return [ref, handleRef];
};

View File

@@ -3,7 +3,7 @@ import PropTypes from 'prop-types';
import styles from './FilePicker.module.css';
const FilePicker = React.memo(({ children, accept, onSelect }) => {
const FilePicker = React.memo(({ children, accept, multiple, onSelect }) => {
const field = useRef(null);
const handleTriggerClick = useCallback(() => {
@@ -12,11 +12,11 @@ const FilePicker = React.memo(({ children, accept, onSelect }) => {
const handleFieldChange = useCallback(
({ target }) => {
if (target.files[0]) {
onSelect(target.files[0]);
[...target.files].forEach((file) => {
onSelect(file);
});
target.value = null; // eslint-disable-line no-param-reassign
}
target.value = null; // eslint-disable-line no-param-reassign
},
[onSelect],
);
@@ -32,6 +32,7 @@ const FilePicker = React.memo(({ children, accept, onSelect }) => {
ref={field}
type="file"
accept={accept}
multiple={multiple}
className={styles.field}
onChange={handleFieldChange}
/>
@@ -42,11 +43,13 @@ const FilePicker = React.memo(({ children, accept, onSelect }) => {
FilePicker.propTypes = {
children: PropTypes.element.isRequired,
accept: PropTypes.string,
multiple: PropTypes.bool,
onSelect: PropTypes.func.isRequired,
};
FilePicker.defaultProps = {
accept: undefined,
multiple: false,
};
export default FilePicker;

View File

@@ -2,5 +2,6 @@ import usePrevious from './use-previous';
import useToggle from './use-toggle';
import useForceUpdate from './use-force-update';
import useDidUpdate from './use-did-update';
import useClickAwayListener from './use-click-away-listener';
export { usePrevious, useToggle, useForceUpdate, useDidUpdate };
export { usePrevious, useToggle, useForceUpdate, useDidUpdate, useClickAwayListener };

View File

@@ -0,0 +1,45 @@
import { useCallback, useEffect, useMemo, useRef } from 'react';
export default (elementRefs, onAwayClick, onCancel) => {
const pressedElement = useRef(null);
const handlePress = useCallback((event) => {
pressedElement.current = event.target;
}, []);
useEffect(() => {
const handleEvent = (event) => {
const element = elementRefs.find(({ current }) => current?.contains(event.target))?.current;
if (element) {
if (!pressedElement.current || pressedElement.current !== element) {
onCancel();
}
} else if (pressedElement.current) {
onCancel();
} else {
onAwayClick();
}
pressedElement.current = null;
};
document.addEventListener('mouseup', handleEvent, true);
document.addEventListener('touchend', handleEvent, true);
return () => {
document.removeEventListener('mouseup', handleEvent, true);
document.removeEventListener('touchend', handleEvent, true);
};
}, [onAwayClick, onCancel]); // eslint-disable-line react-hooks/exhaustive-deps
const props = useMemo(
() => ({
onMouseDown: handlePress,
onTouchStart: handlePress,
}),
[handlePress],
);
return props;
};

View File

@@ -184,6 +184,7 @@ export default {
addTask: 'Přidat úkol',
addToCard: 'Přidat na kartu',
addUser: 'Přidat uživatele',
copyLink_title: 'Zkopírovat odkaz',
createBoard: 'Vytvořit tabuli',
createFile: 'Vytvořit soubor',
createLabel: 'Vytvořit štítek',

View File

@@ -3,6 +3,7 @@ export default {
common: {
emailOrUsername: 'E-mail nebo uživatelské jméno',
invalidEmailOrUsername: 'Nesprávný e-mail nebo uživatelské jméno',
invalidCredentials: 'Neplatné přihlašovací údaje',
invalidPassword: 'Nesprávné heslo',
logInToPlanka: 'Přihlásit se do Planka',
noInternetConnection: 'Bez připojení k internetu',
@@ -11,6 +12,7 @@ export default {
projectManagement: 'Správa projektu',
serverConnectionFailed: 'Připojení k serveru selhalo',
unknownError: 'Neznámá chyba, zkuste to později',
useSingleSignOn: 'Použít jednorázové přihlášení',
},
action: {

View File

@@ -0,0 +1,253 @@
import dateFns from 'date-fns/locale/en-GB';
export default {
dateFns,
format: {
date: 'P',
time: 'p',
dateTime: '$t(format:date) $t(format:time)',
longDate: 'd MMM',
longDateTime: "d MMMM 'at' p",
fullDate: 'd MMM y',
fullDateTime: "d MMMM y 'at' p",
},
translation: {
common: {
aboutPlanka: 'About Planka',
account: 'Account',
actions: 'Actions',
addAttachment_title: 'Add Attachment',
addComment: 'Add comment',
addManager_title: 'Add Manager',
addMember_title: 'Add Member',
addUser_title: 'Add User',
administrator: 'Administrator',
all: 'All',
allChangesWillBeAutomaticallySavedAfterConnectionRestored:
'All changes will be automatically saved<br />after connection restored.',
areYouSureYouWantToDeleteThisAttachment: 'Are you sure you want to delete this attachment?',
areYouSureYouWantToDeleteThisBoard: 'Are you sure you want to delete this board?',
areYouSureYouWantToDeleteThisCard: 'Are you sure you want to delete this card?',
areYouSureYouWantToDeleteThisComment: 'Are you sure you want to delete this comment?',
areYouSureYouWantToDeleteThisLabel: 'Are you sure you want to delete this label?',
areYouSureYouWantToDeleteThisList: 'Are you sure you want to delete this list?',
areYouSureYouWantToDeleteThisProject: 'Are you sure you want to delete this project?',
areYouSureYouWantToDeleteThisTask: 'Are you sure you want to delete this task?',
areYouSureYouWantToDeleteThisUser: 'Are you sure you want to delete this user?',
areYouSureYouWantToLeaveBoard: 'Are you sure you want to leave the board?',
areYouSureYouWantToLeaveProject: 'Are you sure you want to leave the project?',
areYouSureYouWantToRemoveThisManagerFromProject:
'Are you sure you want to remove this manager from the project?',
areYouSureYouWantToRemoveThisMemberFromBoard:
'Are you sure you want to remove this member from the board?',
attachment: 'Attachment',
attachments: 'Attachments',
authentication: 'Authentication',
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.',
color: 'Color',
copy_inline: 'copy',
createBoard_title: 'Create Board',
createLabel_title: 'Create Label',
createNewOneOrSelectExistingOne: 'Create a new one or select<br />an existing one.',
createProject_title: 'Create Project',
createTextFile_title: 'Create Text File',
currentPassword: 'Current password',
dangerZone_title: 'Danger Zone',
date: 'Date',
dueDate: 'Due date',
dueDate_title: 'Due Date',
deleteAttachment_title: 'Delete Attachment',
deleteBoard_title: 'Delete Board',
deleteCard_title: 'Delete Card',
deleteComment_title: 'Delete Comment',
deleteLabel_title: 'Delete Label',
deleteList_title: 'Delete List',
deleteProject_title: 'Delete Project',
deleteTask_title: 'Delete Task',
deleteUser_title: 'Delete User',
description: 'Description',
detectAutomatically: 'Detect automatically',
dropFileToUpload: 'Drop file to upload',
editor: 'Editor',
editAttachment_title: 'Edit Attachment',
editAvatar_title: 'Edit Avatar',
editBoard_title: 'Edit Board',
editDueDate_title: 'Edit Due Date',
editEmail_title: 'Edit E-mail',
editInformation_title: 'Edit Information',
editLabel_title: 'Edit Label',
editPassword_title: 'Edit Password',
editPermissions_title: 'Edit Permissions',
editStopwatch_title: 'Edit Stopwatch',
editUsername_title: 'Edit Username',
email: 'E-mail',
emailAlreadyInUse: 'E-mail already in use',
enterCardTitle: 'Enter card title... [Ctrl+Enter] to auto-open.',
enterDescription: 'Enter description...',
enterFilename: 'Enter filename',
enterListTitle: 'Enter list title...',
enterProjectTitle: 'Enter project title',
enterTaskDescription: 'Enter task description...',
filterByLabels_title: 'Filter By Labels',
filterByMembers_title: 'Filter By Members',
fromComputer_title: 'From Computer',
fromTrello: 'From Trello',
general: 'General',
hours: 'Hours',
importBoard_title: 'Import Board',
invalidCurrentPassword: 'Invalid current password',
labels: 'Labels',
language: 'Language',
leaveBoard_title: 'Leave Board',
leaveProject_title: 'Leave Project',
linkIsCopied: 'Link is copied',
list: 'List',
listActions_title: 'List Actions',
managers: 'Managers',
managerActions_title: 'Manager Actions',
members: 'Members',
memberActions_title: 'Member Actions',
minutes: 'Minutes',
moveCard_title: 'Move Card',
name: 'Name',
newestFirst: 'Newest first',
newEmail: 'New e-mail',
newPassword: 'New password',
newUsername: 'New username',
noConnectionToServer: 'No connection to server',
noBoards: 'No boards',
noLists: 'No lists',
noProjects: 'No projects',
notifications: 'Notifications',
noUnreadNotifications: 'No unread notifications.',
oldestFirst: 'Oldest first',
openBoard_title: 'Open Board',
optional_inline: 'optional',
organization: 'Organization',
phone: 'Phone',
preferences: 'Preferences',
pressPasteShortcutToAddAttachmentFromClipboard:
'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...',
searchCards: 'Search cards...',
seconds: 'Seconds',
selectBoard: 'Select board',
selectList: 'Select list',
selectPermissions_title: 'Select Permissions',
selectProject: 'Select project',
settings: 'Settings',
sortList_title: 'Sort List',
stopwatch: 'Stopwatch',
subscribeToMyOwnCardsByDefault: 'Subscribe to my own cards by default',
taskActions_title: 'Task Actions',
tasks: 'Tasks',
thereIsNoPreviewAvailableForThisAttachment:
'There is no preview available for this attachment.',
time: 'Time',
title: 'Title',
userActions_title: 'User Actions',
userAddedThisCardToList: '<0>{{user}}</0><1> added this card to {{list}}</1>',
userLeftNewCommentToCard: '{{user}} left a new comment «{{comment}}» to <2>{{card}}</2>',
userMovedCardFromListToList: '{{user}} moved <2>{{card}}</2> from {{fromList}} to {{toList}}',
userMovedThisCardFromListToList:
'<0>{{user}}</0><1> moved this card from {{fromList}} to {{toList}}</1>',
username: 'Username',
usernameAlreadyInUse: 'Username already in use',
users: 'Users',
version: 'Version',
viewer: 'Viewer',
writeComment: 'Write a comment...',
},
action: {
addAnotherCard: 'Add another card',
addAnotherList: 'Add another list',
addAnotherTask: 'Add another task',
addCard: 'Add card',
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',
addUser: 'Add user',
copyLink_title: 'Copy Link',
createBoard: 'Create board',
createFile: 'Create file',
createLabel: 'Create label',
createNewLabel: 'Create new label',
createProject: 'Create project',
delete: 'Delete',
deleteAttachment: 'Delete attachment',
deleteAvatar: 'Delete avatar',
deleteBoard: 'Delete board',
deleteCard: 'Delete card',
deleteCard_title: 'Delete Card',
deleteComment: 'Delete comment',
deleteImage: 'Delete image',
deleteLabel: 'Delete label',
deleteList: 'Delete list',
deleteList_title: 'Delete List',
deleteProject: 'Delete project',
deleteProject_title: 'Delete Project',
deleteTask: 'Delete task',
deleteTask_title: 'Delete Task',
deleteUser: 'Delete user',
duplicate: 'Duplicate',
duplicateCard_title: 'Duplicate Card',
edit: 'Edit',
editDueDate_title: 'Edit Due Date',
editDescription_title: 'Edit Description',
editEmail_title: 'Edit E-mail',
editInformation_title: 'Edit Information',
editPassword_title: 'Edit Password',
editPermissions: 'Edit permissions',
editStopwatch_title: 'Edit Stopwatch',
editTitle_title: 'Edit Title',
editUsername_title: 'Edit Username',
hideDetails: 'Hide details',
import: 'Import',
leaveBoard: 'Leave board',
leaveProject: 'Leave project',
logOut_title: 'Log Out',
makeCover_title: 'Make Cover',
move: 'Move',
moveCard_title: 'Move Card',
remove: 'Remove',
removeBackground: 'Remove background',
removeCover_title: 'Remove Cover',
removeFromBoard: 'Remove from board',
removeFromProject: 'Remove from project',
removeManager: 'Remove manager',
removeMember: 'Remove member',
save: 'Save',
showAllAttachments: 'Show all attachments ({{hidden}} hidden)',
showDetails: 'Show details',
showFewerAttachments: 'Show fewer attachments',
sortList_title: 'Sort List',
start: 'Start',
stop: 'Stop',
subscribe: 'Subscribe',
unsubscribe: 'Unsubscribe',
uploadNewAvatar: 'Upload new avatar',
uploadNewImage: 'Upload new image',
},
},
};

View File

@@ -0,0 +1,8 @@
import login from './login';
export default {
language: 'en-GB',
country: 'gb',
name: 'English',
embeddedLocale: login,
};

View File

@@ -0,0 +1,23 @@
export default {
translation: {
common: {
emailOrUsername: 'E-mail or username',
invalidEmailOrUsername: 'Invalid e-mail or username',
invalidCredentials: 'Invalid credentials',
invalidPassword: 'Invalid password',
logInToPlanka: 'Log in to Planka',
noInternetConnection: 'No internet connection',
pageNotFound_title: 'Page Not Found',
password: 'Password',
projectManagement: 'Project management',
serverConnectionFailed: 'Server connection failed',
unknownError: 'Unknown error, try again later',
useSingleSignOn: 'Use single sign-on',
},
action: {
logIn: 'Log in',
logInWithSSO: 'Log in with SSO',
},
},
};

View File

@@ -3,6 +3,7 @@ import bgBG from './bg-BG';
import csCZ from './cs-CZ';
import daDK from './da-DK';
import deDE from './de-DE';
import enGB from './en-GB';
import enUS from './en-US';
import esES from './es-ES';
import faIR from './fa-IR';
@@ -18,6 +19,8 @@ import ptBR from './pt-BR';
import roRO from './ro-RO';
import ruRU from './ru-RU';
import skSK from './sk-SK';
import srCyrlCS from './sr-Cyrl-CS';
import srLatnCS from './sr-Latn-CS';
import svSE from './sv-SE';
import trTR from './tr-TR';
import ukUA from './uk-UA';
@@ -31,6 +34,7 @@ const locales = [
csCZ,
daDK,
deDE,
enGB,
enUS,
esES,
faIR,
@@ -46,6 +50,8 @@ const locales = [
roRO,
ruRU,
skSK,
srCyrlCS,
srLatnCS,
svSE,
trTR,
ukUA,

View File

@@ -0,0 +1,253 @@
import dateFns from 'date-fns/locale/sr';
export default {
dateFns,
format: {
date: 'd.M.yyyy.',
time: 'p',
dateTime: '$t(format:date) $t(format:time)',
longDate: 'd. MMM',
longDateTime: "d. MMMM 'u' p",
fullDate: 'd. MMM y',
fullDateTime: "d. MMMM y 'u' p",
},
translation: {
common: {
aboutPlanka: 'O Planka',
account: 'Налог',
actions: 'Радње',
addAttachment_title: 'Додај прилог',
addComment: 'Додај коментар',
addManager_title: 'Додај руководиоца',
addMember_title: 'Додај члана',
addUser_title: 'Додај корисника',
administrator: 'Администратор',
all: 'Све',
allChangesWillBeAutomaticallySavedAfterConnectionRestored:
'Све промене ће аутоматски бити сачуване<br />након успостављања конекције.',
areYouSureYouWantToDeleteThisAttachment: 'Да ли заиста желите да обришете овај прилог?',
areYouSureYouWantToDeleteThisBoard: 'Да ли заиста желите да обришете ову таблу?',
areYouSureYouWantToDeleteThisCard: 'Да ли заиста желите да обришете ову картицу?',
areYouSureYouWantToDeleteThisComment: 'Да ли заиста желите да обришете овај коментар?',
areYouSureYouWantToDeleteThisLabel: 'Да ли заиста желите да обришете ову ознаку?',
areYouSureYouWantToDeleteThisList: 'Да ли заиста желите да обришете овај списак?',
areYouSureYouWantToDeleteThisProject: 'Да ли заиста желите да обришете овај пројекат?',
areYouSureYouWantToDeleteThisTask: 'Да ли заиста желите да обришете овај задатак?',
areYouSureYouWantToDeleteThisUser: 'Да ли заиста желите да обришете овог корисника?',
areYouSureYouWantToLeaveBoard: 'Да ли заиста желите да напустите ову таблу?',
areYouSureYouWantToLeaveProject: 'Да ли заиста желите да напустите овај пројекат?',
areYouSureYouWantToRemoveThisManagerFromProject:
'Да ли заиста желите да уклоните овог руководиоца из овог пројекта?',
areYouSureYouWantToRemoveThisMemberFromBoard:
'Да ли заиста желите да уклоните овог члана из ове табле?',
attachment: 'Прилог',
attachments: 'Прилози',
authentication: 'Аутентификација',
background: 'Позадина',
board: 'Табла',
boardNotFound_title: 'Табла није пронађена',
canComment: 'Може да коментарише',
canEditContentOfBoard: 'Може да уређује садржај табле.',
canOnlyViewBoard: 'Може само да прегледа таблу.',
cardActions_title: 'Радње над картицом',
cardNotFound_title: 'Картица није пронађена',
cardOrActionAreDeleted: 'Картица или радња су обрисане.',
color: 'Боја',
copy_inline: 'копија',
createBoard_title: 'Направи таблу',
createLabel_title: 'Направи ознаку',
createNewOneOrSelectExistingOne: 'Направи нову или изабери<br />постојећу.',
createProject_title: 'Направи пројекат',
createTextFile_title: 'Направи текстуалну датотеку',
currentPassword: 'Тренутна лозинка',
dangerZone_title: 'Опасна зона',
date: 'Датум',
dueDate: 'Рок',
dueDate_title: 'Рок',
deleteAttachment_title: 'Обриши прилог',
deleteBoard_title: 'Обриши таблу',
deleteCard_title: 'Обриши картицу',
deleteComment_title: 'Обриши коментар',
deleteLabel_title: 'Обриши ознаку',
deleteList_title: 'Обриши списак',
deleteProject_title: 'Обриши пројекат',
deleteTask_title: 'Обриши задатак',
deleteUser_title: 'Обриши корисника',
description: 'Опис',
detectAutomatically: 'Детектуј аутоматски',
dropFileToUpload: 'Превуци датотеку за слање',
editor: 'Уређивач',
editAttachment_title: 'Уреди прилог',
editAvatar_title: 'Уреди аватара',
editBoard_title: 'Уреди таблу',
editDueDate_title: 'Уреди рок',
editEmail_title: 'Уреди е-пошту',
editInformation_title: 'Уреди информације',
editLabel_title: 'Уреди ознаку',
editPassword_title: 'Измени лозинку',
editPermissions_title: 'Уреди овлашћења',
editStopwatch_title: 'Уреди штоперицу',
editUsername_title: 'Измени корисничко име',
email: 'Е-пошта',
emailAlreadyInUse: 'Е-пошта је већ у употреби',
enterCardTitle: 'Унеси наслов картице... [Ctrl+Enter] да се аутоматски отвори.',
enterDescription: 'Унеси опис...',
enterFilename: 'Унеси назив датотеке',
enterListTitle: 'Унеси наслов списка...',
enterProjectTitle: 'Унеси наслов пројекта',
enterTaskDescription: 'Унеси опис задатка...',
filterByLabels_title: 'Филтрирај према ознакама',
filterByMembers_title: 'Филтрирај према члановима',
fromComputer_title: 'Са рачунара',
fromTrello: 'Са Trello-а',
general: 'Опште',
hours: 'Сати',
importBoard_title: 'Увези таблу',
invalidCurrentPassword: 'Неисправна тренутна лозинка',
labels: 'Ознаке',
language: 'Језик',
leaveBoard_title: 'Напусти таблу',
leaveProject_title: 'Напусти пројекат',
linkIsCopied: 'Веза је ископирана',
list: 'Списак',
listActions_title: 'Радње над списком',
managers: 'Руководиоци',
managerActions_title: 'Радње над руководиоцима',
members: 'Чланови',
memberActions_title: 'Радње над члановима',
minutes: 'Минути',
moveCard_title: 'Премести картицу',
name: 'Име',
newestFirst: 'Прво најновије',
newEmail: 'Нова е-пошта',
newPassword: 'Нова лозинка',
newUsername: 'Ново корисничко име',
noConnectionToServer: 'Нема конекције са сервером',
noBoards: 'Нема табли',
noLists: 'Нема спискова',
noProjects: 'Нема пројеката',
notifications: 'Обавештења',
noUnreadNotifications: 'Нема непрочитаних обавештења.',
oldestFirst: 'Прво најстарије',
openBoard_title: 'Отвори таблу',
optional_inline: 'опционо',
organization: 'Организација',
phone: 'Телефон',
preferences: 'Својства',
pressPasteShortcutToAddAttachmentFromClipboard:
'Савет: притисни Ctrl-V (Cmd-V на Меку) да би додао прилог са бележнице.',
project: 'Пројекат',
projectNotFound_title: 'Пројекат није пронађен',
removeManager_title: 'Уклони руководиоца',
removeMember_title: 'Уклони члана',
searchLabels: 'Претражи ознаке...',
searchMembers: 'Претражи чланове...',
searchUsers: 'Претражи кориснике...',
searchCards: 'Претражи картице...',
seconds: 'Секунде',
selectBoard: 'Изабери таблу',
selectList: 'Изабери списак',
selectPermissions_title: 'Изабери одобрења',
selectProject: 'Изабери пројекат',
settings: 'Подешавања',
sortList_title: 'Сложи списак',
stopwatch: 'Штоперица',
subscribeToMyOwnCardsByDefault: 'Подразумевано се претплати на сопствене картице',
taskActions_title: 'Радње над задатком',
tasks: 'Задаци',
thereIsNoPreviewAvailableForThisAttachment: 'Нема прегледа доступног за овај прилог.',
time: 'Време',
title: 'Наслов',
userActions_title: 'Корисничке радње',
userAddedThisCardToList: '<0>{{user}}</0><1> је додао ову картицу на {{list}}</1>',
userLeftNewCommentToCard: '{{user}} је оставио нови коментар «{{comment}}» у <2>{{card}}</2>',
userMovedCardFromListToList:
'{{user}} је преместио <2>{{card}}</2> са {{fromList}} у {{toList}}',
userMovedThisCardFromListToList:
'<0>{{user}}</0><1> је преместио ову картицу са {{fromList}} на {{toList}}</1>',
username: 'Корисничко име',
usernameAlreadyInUse: 'Корисничко име је већ у употреби',
users: 'Корисници',
version: 'Верзија',
viewer: 'Прегледач',
writeComment: 'Напиши коментар...',
},
action: {
addAnotherCard: 'Додај још једну картицу',
addAnotherList: 'Додај још један списак',
addAnotherTask: 'Додај још један задатак',
addCard: 'Додај картицу',
addCard_title: 'Додај картицу',
addComment: 'Додај коментар',
addList: 'Додај списак',
addMember: 'Додај члана',
addMoreDetailedDescription: 'Додај детаљнији опис',
addTask: 'Додај задатак',
addToCard: 'Додај на картицу',
addUser: 'Додај корисника',
copyLink_title: 'Копирај везу',
createBoard: 'Направи таблу',
createFile: 'Направи датотеку',
createLabel: 'Направи ознаку',
createNewLabel: 'Направи нову ознаку',
createProject: 'Направи пројекат',
delete: 'Обриши',
deleteAttachment: 'Обриши прилог',
deleteAvatar: 'Обриши аватара',
deleteBoard: 'Обриши таблу',
deleteCard: 'Обриши картицу',
deleteCard_title: 'Обриши картицу',
deleteComment: 'Обриши коментар',
deleteImage: 'Обриши слику',
deleteLabel: 'Обриши ознаку',
deleteList: 'Обриши списак',
deleteList_title: 'Обриши списак',
deleteProject: 'Обриши пројекат',
deleteProject_title: 'Обриши пројекат',
deleteTask: 'Обриши задатак',
deleteTask_title: 'Обриши задатак',
deleteUser: 'Обриши корисника',
duplicate: 'Клонирај',
duplicateCard_title: 'Клонирај картицу',
edit: 'Измени',
editDueDate_title: 'Измени рок',
editDescription_title: 'Измени опис',
editEmail_title: 'Измени е-пошту',
editInformation_title: 'Измени информације',
editPassword_title: 'Измени лозинку',
editPermissions: 'Измени одобрења',
editStopwatch_title: 'Измени штоперицу',
editTitle_title: 'Измени наслов',
editUsername_title: 'Измени корисничко име',
hideDetails: 'Сакриј детаље',
import: 'Увези',
leaveBoard: 'Напусти таблу',
leaveProject: 'Напусти пројекат',
logOut_title: 'Одјава',
makeCover_title: 'Направи омот',
move: 'Премести',
moveCard_title: 'Премести картицу',
remove: 'Уклони',
removeBackground: 'Уклони позадину',
removeCover_title: 'Уклони омот',
removeFromBoard: 'Уклони са табле',
removeFromProject: 'Уклони из пројекта',
removeManager: 'Уклони руководиоца',
removeMember: 'Уклони члана',
save: 'Сачувај',
showAllAttachments: 'Прикажи све ({{hidden}} сакривене прилоге)',
showDetails: 'Прикажи детаље',
showFewerAttachments: 'Прикажи мање прилога',
sortList_title: 'Сложи списак',
start: 'Почни',
stop: 'Заустави',
subscribe: 'Претплати се',
unsubscribe: 'Укини претплату',
uploadNewAvatar: 'Постави нови аватар',
uploadNewImage: 'Постави нову слику',
},
},
};

View File

@@ -0,0 +1,8 @@
import login from './login';
export default {
language: 'sr-Cyrl-CS',
country: 'rs',
name: 'Српски (ћирилица)',
embeddedLocale: login,
};

View File

@@ -0,0 +1,23 @@
export default {
translation: {
common: {
emailOrUsername: 'Е-пошта или корисничко име',
invalidEmailOrUsername: 'Неисправна е-пошта или корисничко име',
invalidCredentials: 'Неисправни акредитиви',
invalidPassword: 'Неисправна лозинка',
logInToPlanka: 'Пријавите се у Planka',
noInternetConnection: 'Нема конекције са интернетом',
pageNotFound_title: 'Страница није пронађена',
password: 'Лозинка',
projectManagement: 'Управљање пројектима',
serverConnectionFailed: 'Неуспешна конекција са сервером',
unknownError: 'Непозната грешка, покушајте поново касније',
useSingleSignOn: 'Користи универзалну пријаву',
},
action: {
logIn: 'Пријава',
logInWithSSO: 'Пријава са УП',
},
},
};

View File

@@ -0,0 +1,253 @@
import dateFns from 'date-fns/locale/sr-Latn';
export default {
dateFns,
format: {
date: 'd.M.yyyy.',
time: 'p',
dateTime: '$t(format:date) $t(format:time)',
longDate: 'd. MMM',
longDateTime: "d. MMMM 'u' p",
fullDate: 'd. MMM y',
fullDateTime: "d. MMMM y 'u' p",
},
translation: {
common: {
aboutPlanka: 'O Planka',
account: 'Nalog',
actions: 'Radnje',
addAttachment_title: 'Dodaj prilog',
addComment: 'Dodaj komentar',
addManager_title: 'Dodaj rukovodioca',
addMember_title: 'Dodaj člana',
addUser_title: 'Dodaj korisnika',
administrator: 'Administrator',
all: 'Sve',
allChangesWillBeAutomaticallySavedAfterConnectionRestored:
'Sve promene će automatski biti sačuvane<br />nakon uspostavljanja konekcije.',
areYouSureYouWantToDeleteThisAttachment: 'Da li zaista želite da obrišete ovaj prilog?',
areYouSureYouWantToDeleteThisBoard: 'Da li zaista želite da obrišete ovu tablu?',
areYouSureYouWantToDeleteThisCard: 'Da li zaista želite da obrišete ovu karticu?',
areYouSureYouWantToDeleteThisComment: 'Da li zaista želite da obrišete ovaj komentar?',
areYouSureYouWantToDeleteThisLabel: 'Da li zaista želite da obrišete ovu oznaku?',
areYouSureYouWantToDeleteThisList: 'Da li zaista želite da obrišete ovaj spisak?',
areYouSureYouWantToDeleteThisProject: 'Da li zaista želite da obrišete ovaj projekat?',
areYouSureYouWantToDeleteThisTask: 'Da li zaista želite da obrišete ovaj zadatak?',
areYouSureYouWantToDeleteThisUser: 'Da li zaista želite da obrišete ovog korisnika?',
areYouSureYouWantToLeaveBoard: 'Da li zaista želite da napustite ovu tablu?',
areYouSureYouWantToLeaveProject: 'Da li zaista želite da napustite ovaj projekat?',
areYouSureYouWantToRemoveThisManagerFromProject:
'Da li zaista želite da uklonite ovog rukovodioca iz ovog projekta?',
areYouSureYouWantToRemoveThisMemberFromBoard:
'Da li zaista želite da uklonite ovog člana iz ove table?',
attachment: 'Prilog',
attachments: 'Prilozi',
authentication: 'Autentifikacija',
background: 'Pozadina',
board: 'Tabla',
boardNotFound_title: 'Tabla nije pronađena',
canComment: 'Može da komentariše',
canEditContentOfBoard: 'Može da uređuje sadržaj table.',
canOnlyViewBoard: 'Može samo da pregleda tablu.',
cardActions_title: 'Radnje nad karticom',
cardNotFound_title: 'Kartica nije pronađena',
cardOrActionAreDeleted: 'Kartica ili radnja su obrisane.',
color: 'Boja',
copy_inline: 'kopija',
createBoard_title: 'Napravi tablu',
createLabel_title: 'Napravi oznaku',
createNewOneOrSelectExistingOne: 'Napravi novu ili izaberi<br />postojeću.',
createProject_title: 'Napravi projekat',
createTextFile_title: 'Napravi tekstualnu datoteku',
currentPassword: 'Trenutna lozinka',
dangerZone_title: 'Opasna zona',
date: 'Datum',
dueDate: 'Rok',
dueDate_title: 'Rok',
deleteAttachment_title: 'Obriši prilog',
deleteBoard_title: 'Obriši tablu',
deleteCard_title: 'Obriši karticu',
deleteComment_title: 'Obriši komentar',
deleteLabel_title: 'Obriši oznaku',
deleteList_title: 'Obriši spisak',
deleteProject_title: 'Obriši projekat',
deleteTask_title: 'Obriši zadatak',
deleteUser_title: 'Obriši korisnika',
description: 'Opis',
detectAutomatically: 'Detektuj automatski',
dropFileToUpload: 'Prevuci datoteku za slanje',
editor: 'Uređivač',
editAttachment_title: 'Uredi prilog',
editAvatar_title: 'Uredi avatara',
editBoard_title: 'Uredi tablu',
editDueDate_title: 'Uredi rok',
editEmail_title: 'Uredi e-poštu',
editInformation_title: 'Uredi informacije',
editLabel_title: 'Uredi oznaku',
editPassword_title: 'Izmeni lozinku',
editPermissions_title: 'Uredi ovlašćenja',
editStopwatch_title: 'Uredi štopericu',
editUsername_title: 'Izmeni korisničko ime',
email: 'E-pošta',
emailAlreadyInUse: 'E-pošta je već u upotrebi',
enterCardTitle: 'Unesi naslov kartice... [Ctrl+Enter] da se automatski otvori.',
enterDescription: 'Unesi opis...',
enterFilename: 'Unesi naziv datoteke',
enterListTitle: 'Unesi naslov spiska...',
enterProjectTitle: 'Unesi naslov projekta',
enterTaskDescription: 'Unesi opis zadatka...',
filterByLabels_title: 'Filtriraj prema oznakama',
filterByMembers_title: 'Filtriraj prema članovima',
fromComputer_title: 'Sa računara',
fromTrello: 'Sa Trello-a',
general: 'Opšte',
hours: 'Sati',
importBoard_title: 'Uvezi tablu',
invalidCurrentPassword: 'Neispravna trenutna lozinka',
labels: 'Oznake',
language: 'Jezik',
leaveBoard_title: 'Napusti tablu',
leaveProject_title: 'Napusti projekat',
linkIsCopied: 'Veza je iskopirana',
list: 'Spisak',
listActions_title: 'Radnje nad spiskom',
managers: 'Rukovodioci',
managerActions_title: 'Radnje nad rukovodiocima',
members: 'Članovi',
memberActions_title: 'Radnje nad članovima',
minutes: 'Minuti',
moveCard_title: 'Premesti karticu',
name: 'Ime',
newestFirst: 'Prvo najnovije',
newEmail: 'Nova e-pošta',
newPassword: 'Nova lozinka',
newUsername: 'Novo korisničko ime',
noConnectionToServer: 'Nema konekcije sa serverom',
noBoards: 'Nema tabli',
noLists: 'Nema spiskova',
noProjects: 'Nema projekata',
notifications: 'Obaveštenja',
noUnreadNotifications: 'Nema nepročitanih obaveštenja.',
oldestFirst: 'Prvo najstarije',
openBoard_title: 'Otvori tablu',
optional_inline: 'opciono',
organization: 'Organizacija',
phone: 'Telefon',
preferences: 'Svojstva',
pressPasteShortcutToAddAttachmentFromClipboard:
'Savet: pritisni Ctrl-V (Cmd-V na Meku) da bi dodao prilog sa beležnice.',
project: 'Projekat',
projectNotFound_title: 'Projekat nije pronađen',
removeManager_title: 'Ukloni rukovodioca',
removeMember_title: 'Ukloni člana',
searchLabels: 'Pretraži oznake...',
searchMembers: 'Pretraži članove...',
searchUsers: 'Pretraži korisnike...',
searchCards: 'Pretraži kartice...',
seconds: 'Sekunde',
selectBoard: 'Izaberi tablu',
selectList: 'Izaberi spisak',
selectPermissions_title: 'Izaberi odobrenja',
selectProject: 'Izaberi projekat',
settings: 'Podešavanja',
sortList_title: 'Složi spisak',
stopwatch: 'Štoperica',
subscribeToMyOwnCardsByDefault: 'Podrazumevano se pretplati na sopstvene kartice',
taskActions_title: 'Radnje nad zadatkom',
tasks: 'Zadaci',
thereIsNoPreviewAvailableForThisAttachment: 'Nema pregleda dostupnog za ovaj prilog.',
time: 'Vreme',
title: 'Naslov',
userActions_title: 'Korisničke radnje',
userAddedThisCardToList: '<0>{{user}}</0><1> je dodao ovu karticu na {{list}}</1>',
userLeftNewCommentToCard: '{{user}} je ostavio novi komentar «{{comment}}» u <2>{{card}}</2>',
userMovedCardFromListToList:
'{{user}} je premestio <2>{{card}}</2> sa {{fromList}} u {{toList}}',
userMovedThisCardFromListToList:
'<0>{{user}}</0><1> je premestio ovu karticu sa {{fromList}} na {{toList}}</1>',
username: 'Korisničko ime',
usernameAlreadyInUse: 'Korisničko ime je već u upotrebi',
users: 'Korisnici',
version: 'Verzija',
viewer: 'Pregledač',
writeComment: 'Napiši komentar...',
},
action: {
addAnotherCard: 'Dodaj još jednu karticu',
addAnotherList: 'Dodaj još jedan spisak',
addAnotherTask: 'Dodaj još jedan zadatak',
addCard: 'Dodaj karticu',
addCard_title: 'Dodaj karticu',
addComment: 'Dodaj komentar',
addList: 'Dodaj spisak',
addMember: 'Dodaj člana',
addMoreDetailedDescription: 'Dodaj detaljniji opis',
addTask: 'Dodaj zadatak',
addToCard: 'Dodaj na karticu',
addUser: 'Dodaj korisnika',
copyLink_title: 'Kopiraj vezu',
createBoard: 'Napravi tablu',
createFile: 'Napravi datoteku',
createLabel: 'Napravi oznaku',
createNewLabel: 'Napravi novu oznaku',
createProject: 'Napravi projekat',
delete: 'Obriši',
deleteAttachment: 'Obriši prilog',
deleteAvatar: 'Obriši avatara',
deleteBoard: 'Obriši tablu',
deleteCard: 'Obriši karticu',
deleteCard_title: 'Obriši karticu',
deleteComment: 'Obriši komentar',
deleteImage: 'Obriši sliku',
deleteLabel: 'Obriši oznaku',
deleteList: 'Obriši spisak',
deleteList_title: 'Obriši spisak',
deleteProject: 'Obriši projekat',
deleteProject_title: 'Obriši projekat',
deleteTask: 'Obriši zadatak',
deleteTask_title: 'Obriši zadatak',
deleteUser: 'Obriši korisnika',
duplicate: 'Kloniraj',
duplicateCard_title: 'Kloniraj karticu',
edit: 'Izmeni',
editDueDate_title: 'Izmeni rok',
editDescription_title: 'Izmeni opis',
editEmail_title: 'Izmeni e-poštu',
editInformation_title: 'Izmeni informacije',
editPassword_title: 'Izmeni lozinku',
editPermissions: 'Izmeni odobrenja',
editStopwatch_title: 'Izmeni štopericu',
editTitle_title: 'Izmeni naslov',
editUsername_title: 'Izmeni korisničko ime',
hideDetails: 'Sakrij detalje',
import: 'Uvezi',
leaveBoard: 'Napusti tablu',
leaveProject: 'Napusti projekat',
logOut_title: 'Odjava',
makeCover_title: 'Napravi omot',
move: 'Premesti',
moveCard_title: 'Premesti karticu',
remove: 'Ukloni',
removeBackground: 'Ukloni pozadinu',
removeCover_title: 'Ukloni omot',
removeFromBoard: 'Ukloni sa table',
removeFromProject: 'Ukloni iz projekta',
removeManager: 'Ukloni rukovodioca',
removeMember: 'Ukloni člana',
save: 'Sačuvaj',
showAllAttachments: 'Prikaži sve ({{hidden}} sakrivene priloge)',
showDetails: 'Prikaži detalje',
showFewerAttachments: 'Prikaži manje priloga',
sortList_title: 'Složi spisak',
start: 'Počni',
stop: 'Zaustavi',
subscribe: 'Pretplati se',
unsubscribe: 'Ukini pretplatu',
uploadNewAvatar: 'Postavi novi avatar',
uploadNewImage: 'Postavi novu sliku',
},
},
};

View File

@@ -0,0 +1,8 @@
import login from './login';
export default {
language: 'sr-Latn-CS',
country: 'rs',
name: 'Srpski (latinica)',
embeddedLocale: login,
};

View File

@@ -0,0 +1,23 @@
export default {
translation: {
common: {
emailOrUsername: 'E-pošta ili korisničko ime',
invalidEmailOrUsername: 'Neispravna e-pošta ili korisničko ime',
invalidCredentials: 'Neispravni akreditivi',
invalidPassword: 'Neispravna lozinka',
logInToPlanka: 'Prijavite se u Planka',
noInternetConnection: 'Nema konekcije sa internetom',
pageNotFound_title: 'Stranica nije pronađena',
password: 'Lozinka',
projectManagement: 'Upravljanje projektima',
serverConnectionFailed: 'Neuspešna konekcija sa serverom',
unknownError: 'Nepoznata greška, pokušajte ponovo kasnije',
useSingleSignOn: 'Koristi univerzalnu prijavu',
},
action: {
logIn: 'Prijava',
logInWithSSO: 'Prijava sa UP',
},
},
};

View File

@@ -115,6 +115,23 @@ export const makeSelectTasksByCardId = () =>
export const selectTasksByCardId = makeSelectTasksByCardId();
export const makeSelectAttachmentsTotalByCardId = () =>
createSelector(
orm,
(_, id) => id,
({ Card }, id) => {
const cardModel = Card.withId(id);
if (!cardModel) {
return cardModel;
}
return cardModel.attachments.count();
},
);
export const selectAttachmentsTotalByCardId = makeSelectAttachmentsTotalByCardId();
export const makeSelectLastActivityIdByCardId = () =>
createSelector(
orm,
@@ -334,6 +351,8 @@ export default {
selectTaskIdsByCardId,
makeSelectTasksByCardId,
selectTasksByCardId,
makeSelectAttachmentsTotalByCardId,
selectAttachmentsTotalByCardId,
makeSelectLastActivityIdByCardId,
selectLastActivityIdByCardId,
makeSelectNotificationsByCardId,

View File

@@ -142,6 +142,15 @@
}
:global(#app) {
&.dragging>* {
pointer-events: none;
}
&.dragScrolling>* {
pointer-events: none;
user-select: none;
}
/* Backgrounds */
.backgroundBerryRed {

View File

@@ -1,5 +1,8 @@
// eslint-disable-next-line import/prefer-default-export
export const focusEnd = (element) => {
element.focus();
element.setSelectionRange(element.value.length + 1, element.value.length + 1);
};
export const isActiveTextElement = (element) =>
['input', 'textarea'].includes(element.tagName.toLowerCase()) &&
element === document.activeElement;

View File

@@ -0,0 +1,102 @@
import parseDate from 'date-fns/parse';
const TIME_REGEX =
/^((\d{1,2})((:|\.)?(\d{1,2}))?)(a|p|(am|a\.m\.|midnight|mi|pm|p\.m\.|noon|n))?$/;
const ALTERNATIVE_AM_MERIDIEMS_SET = new Set(['am', 'a.m.', 'midnight', 'mi']);
const ALTERNATIVE_PM_MERIDIEMS_SET = new Set(['pm', 'p.m.', 'noon', 'n']);
const TimeFormats = {
TWENTY_FOUR_HOUR: 'twentyFourHour',
TWELVE_HOUR: 'twelveHour',
};
const PATTERNS_GROUPS_BY_TIME_FORMAT = {
[TimeFormats.TWENTY_FOUR_HOUR]: {
byNumbersTotal: {
1: ['H'],
2: ['HH'],
3: ['Hmm'],
4: ['HHmm'],
},
withDelimiter: ['H:m', 'H:mm', 'HH:m', 'HH:mm'],
},
[TimeFormats.TWELVE_HOUR]: {
byNumbersTotal: {
1: ['haaaaa'],
2: ['hhaaaaa'],
3: ['hmmaaaaa'],
4: ['hhmmaaaaa'],
},
withDelimiter: ['h:maaaaa', 'h:mmaaaaa', 'hh:maaaaa', 'hh:mmaaaaa'],
},
};
const INVALID_DATE = new Date('invalid-date');
const normalizeDelimeter = (delimeter) => (delimeter === '.' ? ':' : delimeter);
const normalizeMeridiem = (meridiem, alternativeMeridiem) => {
if (meridiem && alternativeMeridiem) {
if (ALTERNATIVE_AM_MERIDIEMS_SET.has(alternativeMeridiem)) {
return 'a';
}
if (ALTERNATIVE_PM_MERIDIEMS_SET.has(alternativeMeridiem)) {
return 'p';
}
}
return meridiem;
};
const makeTimeString = (hours, minutes, delimeter, meridiem) => {
let timeString = hours;
if (delimeter) {
timeString += delimeter;
}
if (minutes) {
timeString += minutes;
}
if (meridiem) {
timeString += meridiem;
}
return timeString;
};
export default (string, referenceDate) => {
const match = string.replace(/\s/g, '').toLowerCase().match(TIME_REGEX);
if (!match) {
return INVALID_DATE;
}
const [, hoursAndMinutes, hours, , delimeter, minutes, meridiem, alternativeMeridiem] = match;
const normalizedDelimeter = normalizeDelimeter(delimeter);
const normalizedMeridiem = normalizeMeridiem(meridiem, alternativeMeridiem);
const timeString = makeTimeString(hours, minutes, normalizedDelimeter, normalizedMeridiem);
const timeFormat = meridiem ? TimeFormats.TWELVE_HOUR : TimeFormats.TWENTY_FOUR_HOUR;
const patternsGroups = PATTERNS_GROUPS_BY_TIME_FORMAT[timeFormat];
const patterns = delimeter
? patternsGroups.withDelimiter
: patternsGroups.byNumbersTotal[hoursAndMinutes.length];
if (!referenceDate) {
referenceDate = new Date(); // eslint-disable-line no-param-reassign
}
for (let i = 0; i < patterns.length; i += 1) {
const parsedDate = parseDate(timeString, patterns[i], referenceDate);
if (!Number.isNaN(parsedDate.getTime())) {
return parsedDate;
}
}
return INVALID_DATE;
};

View File

@@ -1 +1 @@
export default '1.23.0';
export default '1.24.2';

View File

@@ -0,0 +1,12 @@
module.exports = {
// environment
adminUser: {
email: 'demo@demo.demo',
password: 'demo',
},
baseUrl: process.env.BASE_URL ?? 'http://localhost:1337/',
// playwright
slowMo: parseInt(process.env.SLOW_MO, 10) || 1000,
timeout: parseInt(process.env.TIMEOUT, 10) || 6000,
headless: process.env.HEADLESS !== 'true',
};

View File

@@ -0,0 +1,35 @@
// cucumber.conf.js file
const { Before, BeforeAll, AfterAll, After, setDefaultTimeout } = require('@cucumber/cucumber');
const { chromium } = require('playwright');
const { deleteProject } = require('./testHelpers/apiHelpers');
const config = require('./config');
setDefaultTimeout(config.timeout);
// launch the browser
BeforeAll(async function () {
global.browser = await chromium.launch({
// makes true for CI
headless: config.headless,
slowMo: config.slowMo,
});
});
// close the browser
AfterAll(async function () {
await global.browser.close();
});
// Create a new browser context and page per scenario
Before(async function () {
global.context = await global.browser.newContext();
global.page = await global.context.newPage();
});
// Cleanup after each scenario
After(async function () {
await deleteProject();
await global.page.close();
await global.context.close();
});

View File

@@ -0,0 +1,10 @@
Feature: dashboard
As a admin
I want to create a project
So that I can manage project
Scenario: create a new project
Given user has browsed to the login page
And user has logged in with email "demo@demo.demo" and password "demo"
When the user creates a project with name "testproject" using the webUI
Then the created project "testproject" should be opened

View File

@@ -0,0 +1,27 @@
Feature: login
As a admin
I want to log in
So that I can manage project
Scenario: User logs in with valid credentials
Given user has browsed to the login page
When user logs in with username "demo@demo.demo" and password "demo" using the webUI
Then the user should be in dashboard page
Scenario Outline: login with invalid username and invalid password
Given user has browsed to the login page
When user logs in with username "<username>" and password "<password>" using the webUI
Then user should see the error message "<message>"
Examples:
| username | password | message |
| spiderman | spidy123 | Invalid credentials |
| ironman | iron123 | Invalid credentials |
| aquaman | aqua123 | Invalid credentials |
Scenario: User can log out
Given user has logged in with email "demo@demo.demo" and password "demo"
When user logs out using the webUI
Then the user should be in the login page

View File

@@ -0,0 +1,16 @@
class DashboardPage {
constructor() {
this.createProjectIconSelector = `.Projects_addTitle__tXhB4`;
this.projectTitleInputSelector = `input[name="name"]`;
this.createProjectButtonSelector = `//button[text()="Create project"]`;
this.projectTitleSelector = `//div[@class="item Header_item__OOEY7 Header_title__l+wMf"][text()="%s"]`;
}
async createProject(project) {
await page.click(this.createProjectIconSelector);
await page.fill(this.projectTitleInputSelector, project);
await page.click(this.createProjectButtonSelector);
}
}
module.exports = DashboardPage;

View File

@@ -0,0 +1,38 @@
const config = require(`../config`);
class LoginPage {
constructor() {
// url
this.homeUrl = config.baseUrl;
this.loginUrl = `${this.homeUrl}login`;
// selectors
this.loginButtonSelector = `//i[@class="right arrow icon"]`;
this.usernameSelector = `//input[@name='emailOrUsername']`;
this.passwordSelector = `//input[@name='password']`;
this.errorMessageSelector = `//div[@class='ui error visible message']`;
this.userActionSelector = `//span[@class="User_initials__9Wp90"]`;
this.logOutSelector = `//a[@class="item UserStep_menuItem__5pvtT"][contains(text(),'Log Out')]`;
}
async goToLoginUrl() {
await page.goto(this.loginUrl);
}
async logOut() {
await page.click(this.userActionSelector);
await page.click(this.logOutSelector);
}
async login(username, password) {
await page.fill(this.usernameSelector, username);
await page.fill(this.passwordSelector, password);
await page.click(this.loginButtonSelector);
}
async getErrorMessage() {
return page.innerText(this.errorMessageSelector);
}
}
module.exports = LoginPage;

View File

@@ -0,0 +1,17 @@
const { When, Then } = require('@cucumber/cucumber');
const util = require('util');
const { expect } = require('playwright/test');
const DashboardPage = require('../pageObjects/DashboardPage');
const dashboardPage = new DashboardPage();
When('the user creates a project with name {string} using the webUI', async function (project) {
await dashboardPage.createProject(project);
});
Then('the created project {string} should be opened', async function (project) {
expect(
await page.locator(util.format(dashboardPage.projectTitleSelector, project)),
).toBeVisible();
});

View File

@@ -0,0 +1,53 @@
const { Given, When, Then } = require('@cucumber/cucumber');
// import expect for assertion
const { expect } = require('@playwright/test');
// import assert
const assert = require('assert');
const LoginPage = require('../pageObjects/LoginPage');
const loginPage = new LoginPage();
Given('user has browsed to the login page', async function () {
await loginPage.goToLoginUrl();
await expect(page).toHaveURL(loginPage.loginUrl);
});
Given(
'user has logged in with email {string} and password {string}',
async function (username, password) {
await loginPage.goToLoginUrl();
await loginPage.login(username, password);
await expect(page).toHaveURL(loginPage.homeUrl);
},
);
When(
'user logs in with username {string} and password {string} using the webUI',
async function (username, password) {
await loginPage.login(username, password);
},
);
Then('the user should be in dashboard page', async function () {
await expect(page).toHaveURL(loginPage.homeUrl);
});
Then('user should see the error message {string}', async function (errorMessage) {
const actualErrorMessage = await loginPage.getErrorMessage();
assert.equal(
actualErrorMessage,
errorMessage,
`Expected message to be "${errorMessage}" but receive "${actualErrorMessage}"`,
);
});
When('user logs out using the webUI', async function () {
await loginPage.logOut();
});
Then('the user should be in the login page', async function () {
await expect(page).toHaveURL(loginPage.loginUrl);
});

View File

@@ -0,0 +1,57 @@
const axios = require('axios');
const config = require('../config');
async function getXauthToken() {
try {
const res = await axios.post(
`${config.baseUrl}api/access-tokens`,
{
emailOrUsername: config.adminUser.email,
password: config.adminUser.password,
},
{
headers: {
'Content-Type': 'application/json',
},
},
);
return res.data.item;
} catch (error) {
return `Error requesting access token: ${error.message}`;
}
}
async function getProjectIDs() {
try {
const res = await axios.get(`${config.baseUrl}api/projects`, {
headers: {
Authorization: `Bearer ${await getXauthToken()}`,
},
});
return res.data.items.map((project) => project.id);
} catch (error) {
return `Error requesting projectIDs: ${error.message}`;
}
}
async function deleteProject() {
try {
const projectIDs = await getProjectIDs();
await Promise.all(
projectIDs.map(async (project) => {
await axios.delete(`${config.baseUrl}api/projects/${project}`, {
headers: {
Authorization: `Bearer ${await getXauthToken()}`,
},
});
}),
);
return true;
} catch (error) {
return `Error deleting project: ${error.message}`;
}
}
module.exports = {
deleteProject,
};

23
client/tests/setup-symlinks.sh Executable file
View File

@@ -0,0 +1,23 @@
#!/bin/bash
# This script sets up symbolic links between the client build files and the server directories,
# Navigate to the root directory of the git repository
cd "$(git rev-parse --show-toplevel)" || { echo "Failed to navigate to the git repository root"; exit 1; }
# Store paths for the client build, server public directory, and server views directory
CLIENT_PATH=$(pwd)/client/build
SERVER_PUBLIC_PATH=$(pwd)/server/public
SERVER_VIEWS_PATH=$(pwd)/server/views
# Create symbolic links for the necessary client assets in the server's public and views directories
ln -s ${CLIENT_PATH}/asset-manifest.json ${SERVER_PUBLIC_PATH}/asset-manifest.json && echo "Linked asset-manifest.json successfully"
ln -s ${CLIENT_PATH}/favicon.ico ${SERVER_PUBLIC_PATH}/favicon.ico && echo "Linked favicon.ico successfully"
ln -s ${CLIENT_PATH}/logo192.png ${SERVER_PUBLIC_PATH}/logo192.png && echo "Linked logo192.png successfully"
ln -s ${CLIENT_PATH}/logo512.png ${SERVER_PUBLIC_PATH}/logo512.png && echo "Linked logo512.png successfully"
ln -s ${CLIENT_PATH}/manifest.json ${SERVER_PUBLIC_PATH}/manifest.json && echo "Linked manifest.json successfully"
ln -s ${CLIENT_PATH}/robots.txt ${SERVER_PUBLIC_PATH}/robots.txt && echo "Linked robots.txt successfully"
ln -s ${CLIENT_PATH}/static ${SERVER_PUBLIC_PATH}/static && echo "Linked static folder successfully"
ln -s ${CLIENT_PATH}/index.html ${SERVER_VIEWS_PATH}/index.ejs && echo "Linked index.html to index.ejs successfully"
echo "Setup symbolic links completed successfully."

View File

@@ -2,7 +2,7 @@ version: '3'
services:
postgres:
image: postgres:alpine
image: postgres:16-alpine
restart: unless-stopped
volumes:
- db-data:/var/lib/postgresql/data

View File

@@ -25,9 +25,15 @@ services:
# - KNEX_REJECT_UNAUTHORIZED_SSL_CERTIFICATE=false
# - SHOW_DETAILED_AUTH_ERRORS=false # Set to true to show more detailed authentication error messages. It should not be enabled without a rate limiter for security reasons.
# - ALLOW_ALL_TO_CREATE_PROJECTS=true
# - S3_ENDPOINT=
# - S3_REGION=
# - S3_ACCESS_KEY_ID=
# - S3_SECRET_ACCESS_KEY=
# - S3_BUCKET=
# - S3_FORCE_PATH_STYLE=true
# - OIDC_ISSUER=
# - OIDC_CLIENT_ID=
# - OIDC_CLIENT_SECRET=
@@ -54,6 +60,7 @@ services:
# - SMTP_USER=
# - SMTP_PASSWORD=
# - SMTP_FROM="Demo Demo" <demo@demo.demo>
# - SMTP_TLS_REJECT_UNAUTHORIZED=false
# Optional fields: accessToken, events, excludedEvents
# - |
@@ -66,6 +73,12 @@ services:
# - SLACK_BOT_TOKEN=
# - SLACK_CHANNEL_ID=
# - GOOGLE_CHAT_WEBHOOK_URL=
# - TELEGRAM_BOT_TOKEN=
# - TELEGRAM_CHAT_ID=
# - TELEGRAM_THREAD_ID=
working_dir: /app
command: ["sh", "-c", "npm run start"]
depends_on:
@@ -109,7 +122,7 @@ services:
condition: service_healthy
postgres:
image: postgres:latest
image: postgres:16-alpine
volumes:
- db-data:/var/lib/postgresql/data
environment:

View File

@@ -32,9 +32,15 @@ services:
# - DEFAULT_ADMIN_USERNAME=demo
# - SHOW_DETAILED_AUTH_ERRORS=false # Set to true to show more detailed authentication error messages. It should not be enabled without a rate limiter for security reasons.
# - ALLOW_ALL_TO_CREATE_PROJECTS=true
# - S3_ENDPOINT=
# - S3_REGION=
# - S3_ACCESS_KEY_ID=
# - S3_SECRET_ACCESS_KEY=
# - S3_BUCKET=
# - S3_FORCE_PATH_STYLE=true
# - OIDC_ISSUER=
# - OIDC_CLIENT_ID=
# - OIDC_CLIENT_SECRET=
@@ -61,6 +67,7 @@ services:
# - SMTP_USER=
# - SMTP_PASSWORD=
# - SMTP_FROM="Demo Demo" <demo@demo.demo>
# - SMTP_TLS_REJECT_UNAUTHORIZED=false
# Optional fields: accessToken, events, excludedEvents
# - |
@@ -73,13 +80,18 @@ services:
# - SLACK_BOT_TOKEN=
# - SLACK_CHANNEL_ID=
# - GOOGLE_CHAT_WEBHOOK_URL=
# - TELEGRAM_BOT_TOKEN=
# - TELEGRAM_CHAT_ID=
# - TELEGRAM_THREAD_ID=
depends_on:
postgres:
condition: service_healthy
postgres:
image: postgres:14-alpine
image: postgres:16-alpine
restart: on-failure
volumes:
- db-data:/var/lib/postgresql/data

104
package-lock.json generated
View File

@@ -1,31 +1,31 @@
{
"name": "planka",
"version": "1.23.0",
"version": "1.24.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "planka",
"version": "1.23.0",
"version": "1.24.2",
"hasInstallScript": true,
"license": "AGPL-3.0",
"dependencies": {
"concurrently": "^8.2.2",
"genversion": "^3.2.0",
"husky": "^9.1.6",
"husky": "^9.1.7",
"lint-staged": "^15.2.10"
},
"devDependencies": {
"eslint": "^8.57.0",
"eslint": "^8.57.1",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-prettier": "^5.2.1",
"prettier": "^3.3.3"
}
},
"node_modules/@babel/runtime": {
"version": "7.25.6",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.25.6.tgz",
"integrity": "sha512-VBj9MYyDb9tuLq7yzqjgzt6Q+IBQLrGZfdjOekyEirZPHxXWoTSGUTMrpsfi58Up73d13NfYLv8HT9vmznjzhQ==",
"version": "7.26.0",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.26.0.tgz",
"integrity": "sha512-FDSOghenHTiToteC/QRlv2q3DhPZ/oOXTBoirfWNx1Cx3TMVcGWQtMMmQcSvb/JjpNeGzx8Pq/b4fKEJuWm1sw==",
"dependencies": {
"regenerator-runtime": "^0.14.0"
},
@@ -34,24 +34,27 @@
}
},
"node_modules/@eslint-community/eslint-utils": {
"version": "4.4.0",
"resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.4.0.tgz",
"integrity": "sha512-1/sA4dwrzBAyeUoQ6oxahHKmrZvsnLCg4RfxW3ZFGGmQkSNQPFNLV9CUEFQP1x9EYXHTo5p6xdhZM1Ne9p/AfA==",
"version": "4.4.1",
"resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.4.1.tgz",
"integrity": "sha512-s3O3waFUrMV8P/XaF/+ZTp1X9XBZW1a4B97ZnjQF2KYWaFD2A8KyFBsrsfSjEmjn3RGWAIuvlneuZm3CUK3jbA==",
"dev": true,
"dependencies": {
"eslint-visitor-keys": "^3.3.0"
"eslint-visitor-keys": "^3.4.3"
},
"engines": {
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
},
"funding": {
"url": "https://opencollective.com/eslint"
},
"peerDependencies": {
"eslint": "^6.0.0 || ^7.0.0 || >=8.0.0"
}
},
"node_modules/@eslint-community/regexpp": {
"version": "4.11.1",
"resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.11.1.tgz",
"integrity": "sha512-m4DVN9ZqskZoLU5GlWZadwDnYo3vAEydiUayB9widCl9ffWx2IvPnp6n3on5rJmziJSw9Bv+Z3ChDVdMwXCY8Q==",
"version": "4.12.1",
"resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.1.tgz",
"integrity": "sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ==",
"dev": true,
"engines": {
"node": "^12.0.0 || ^14.0.0 || >=16.0.0"
@@ -81,22 +84,22 @@
}
},
"node_modules/@eslint/js": {
"version": "8.57.0",
"resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.57.0.tgz",
"integrity": "sha512-Ys+3g2TaW7gADOJzPt83SJtCDhMjndcDMFVQ/Tj9iA1BfJzFKD9mAUXT3OenpuPHbI6P/myECxRJrofUsDx/5g==",
"version": "8.57.1",
"resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.57.1.tgz",
"integrity": "sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q==",
"dev": true,
"engines": {
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
}
},
"node_modules/@humanwhocodes/config-array": {
"version": "0.11.14",
"resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.14.tgz",
"integrity": "sha512-3T8LkOmg45BV5FICb15QQMsyUSWrQ8AygVfC7ZG32zOalnqrilm018ZVCw0eapXux8FtA33q8PSRSstjee3jSg==",
"version": "0.13.0",
"resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.13.0.tgz",
"integrity": "sha512-DZLEEqFWQFiyK6h5YIeynKx7JlvCYWL0cImfSRXZ9l4Sg2efkFGTuFf6vzXjK1cq6IYkU+Eg/JizXw+TD2vRNw==",
"deprecated": "Use @eslint/config-array instead",
"dev": true,
"dependencies": {
"@humanwhocodes/object-schema": "^2.0.2",
"@humanwhocodes/object-schema": "^2.0.3",
"debug": "^4.3.1",
"minimatch": "^3.0.5"
},
@@ -178,9 +181,9 @@
"dev": true
},
"node_modules/acorn": {
"version": "8.12.1",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.12.1.tgz",
"integrity": "sha512-tcpGyI9zbizT9JbV6oYE477V6mTlXvvi0T0G3SNIYE2apm/G5huBa1+K89VGeovbg+jycCrfhl3ADxErOuO6Jg==",
"version": "8.14.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.0.tgz",
"integrity": "sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA==",
"dev": true,
"bin": {
"acorn": "bin/acorn"
@@ -466,9 +469,9 @@
}
},
"node_modules/cross-spawn": {
"version": "7.0.3",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz",
"integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==",
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
"integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
"dependencies": {
"path-key": "^3.1.0",
"shebang-command": "^2.0.0",
@@ -578,16 +581,17 @@
}
},
"node_modules/eslint": {
"version": "8.57.0",
"resolved": "https://registry.npmjs.org/eslint/-/eslint-8.57.0.tgz",
"integrity": "sha512-dZ6+mexnaTIbSBZWgou51U6OmzIhYM2VcNdtiTtI7qPNZm35Akpr0f6vtw3w1Kmn5PYo+tZVfh13WrhpS6oLqQ==",
"version": "8.57.1",
"resolved": "https://registry.npmjs.org/eslint/-/eslint-8.57.1.tgz",
"integrity": "sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==",
"deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.",
"dev": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.2.0",
"@eslint-community/regexpp": "^4.6.1",
"@eslint/eslintrc": "^2.1.4",
"@eslint/js": "8.57.0",
"@humanwhocodes/config-array": "^0.11.14",
"@eslint/js": "8.57.1",
"@humanwhocodes/config-array": "^0.13.0",
"@humanwhocodes/module-importer": "^1.0.1",
"@nodelib/fs.walk": "^1.2.8",
"@ungap/structured-clone": "^1.2.0",
@@ -910,9 +914,9 @@
}
},
"node_modules/flatted": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.1.tgz",
"integrity": "sha512-X8cqMLLie7KsNUDSdzeN8FYK9rEt4Dt67OsG/DNGnYTSDBG4uFAJFBnUeiV+zCVAvwFy56IjM9sH51jVaEhNxw==",
"version": "3.3.2",
"resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.2.tgz",
"integrity": "sha512-AiwGJM8YcNOaobumgtng+6NHuOqC3A7MixFeDafM3X9cIUM+xUXoS5Vfgf+OihAYe20fxqNM9yPBXJzRtZ/4eA==",
"dev": true
},
"node_modules/fs.realpath": {
@@ -946,9 +950,9 @@
}
},
"node_modules/get-east-asian-width": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.2.0.tgz",
"integrity": "sha512-2nk+7SIVb14QrgXFHcm84tD4bKQz0RxPuMT8Ag5KPOq7J5fEmAg0UbXdTOSHqNuHSU28k55qnceesxXRZGzKWA==",
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.3.0.tgz",
"integrity": "sha512-vpeMIQKxczTD/0s2CdEWHcb0eeJe6TFjxb+J5xgX7hScxqrGuyjmv4c1D4A/gelKfyox0gJJwIHF+fLjeaM8kQ==",
"engines": {
"node": ">=18"
},
@@ -1038,9 +1042,9 @@
}
},
"node_modules/husky": {
"version": "9.1.6",
"resolved": "https://registry.npmjs.org/husky/-/husky-9.1.6.tgz",
"integrity": "sha512-sqbjZKK7kf44hfdE94EoX8MZNk0n7HeW37O4YrVGCF4wzgQjp+akPAkfUK5LZ6KuR/6sqeAVuXHji+RzQgOn5A==",
"version": "9.1.7",
"resolved": "https://registry.npmjs.org/husky/-/husky-9.1.7.tgz",
"integrity": "sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA==",
"bin": {
"husky": "bin.js"
},
@@ -1293,9 +1297,9 @@
}
},
"node_modules/listr2": {
"version": "8.2.4",
"resolved": "https://registry.npmjs.org/listr2/-/listr2-8.2.4.tgz",
"integrity": "sha512-opevsywziHd3zHCVQGAj8zu+Z3yHNkkoYhWIGnq54RrCVwLz0MozotJEDnKsIBLvkfLGN6BLOyAeRrYI0pKA4g==",
"version": "8.2.5",
"resolved": "https://registry.npmjs.org/listr2/-/listr2-8.2.5.tgz",
"integrity": "sha512-iyAZCeyD+c1gPyE9qpFu8af0Y+MRtmKOncdGoA2S5EY8iFq99dmmvkNnHiWo+pj0s7yH7l3KPIgee77tKpXPWQ==",
"dependencies": {
"cli-truncate": "^4.0.0",
"colorette": "^2.0.20",
@@ -1994,9 +1998,9 @@
}
},
"node_modules/synckit": {
"version": "0.9.1",
"resolved": "https://registry.npmjs.org/synckit/-/synckit-0.9.1.tgz",
"integrity": "sha512-7gr8p9TQP6RAHusBOSLs46F4564ZrjV8xFmw5zCmgmhGUcw2hxsShhJ6CEiHQMgPDwAQ1fWHPM0ypc4RMAig4A==",
"version": "0.9.2",
"resolved": "https://registry.npmjs.org/synckit/-/synckit-0.9.2.tgz",
"integrity": "sha512-vrozgXDQwYO72vHjUb/HnFbQx1exDjoKzqx23aXEg2a9VIg2TSFZ8FmeZpTjUCFMYw7mpX4BE2SFu8wI7asYsw==",
"dev": true,
"dependencies": {
"@pkgr/core": "^0.1.0",
@@ -2035,9 +2039,9 @@
}
},
"node_modules/tslib": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.7.0.tgz",
"integrity": "sha512-gLXCKdN1/j47AiHiOkJN69hJmcbGTHI0ImLmbYLHykhgeN0jVGola9yVjFgzCUklsZQMW55o+dW7IXv3RCXDzA=="
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="
},
"node_modules/type-check": {
"version": "0.4.0",

View File

@@ -1,6 +1,6 @@
{
"name": "planka",
"version": "1.23.0",
"version": "1.24.2",
"private": true,
"homepage": "https://plankanban.github.io/planka",
"repository": {
@@ -16,7 +16,7 @@
"client:test": "npm test --prefix client",
"docker:build": "docker build -t ghcr.io/plankanban/planka:local -f Dockerfile .",
"docker:build:base": "docker build -t ghcr.io/plankanban/planka:base-local -f Dockerfile.base .",
"gv": "genversion --source ./ --template client/version-template.ejs client/src/version.js",
"gv": "npm i --package-lock-only --ignore-scripts && genversion --source ./ --template client/version-template.ejs client/src/version.js",
"postinstall": "(cd server && npm i && cd ../client && npm i)",
"lint": "npm run server:lint && npm run client:lint",
"prepare": "husky",
@@ -32,7 +32,7 @@
"test": "npm run server:test && npm run client:test"
},
"lint-staged": {
"client/**/*.{js,jsx}": [
"client/src/**/*.{js,jsx}": [
"npm run client:lint"
],
"server/**/*.js": [
@@ -59,11 +59,11 @@
"dependencies": {
"concurrently": "^8.2.2",
"genversion": "^3.2.0",
"husky": "^9.1.6",
"husky": "^9.1.7",
"lint-staged": "^15.2.10"
},
"devDependencies": {
"eslint": "^8.57.0",
"eslint": "^8.57.1",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-prettier": "^5.2.1",
"prettier": "^3.3.3"

View File

@@ -25,9 +25,15 @@ SECRET_KEY=notsecretkey
# DEFAULT_ADMIN_USERNAME=demo
# SHOW_DETAILED_AUTH_ERRORS=false # Set to true to show more detailed authentication error messages. It should not be enabled without a rate limiter for security reasons.
# ALLOW_ALL_TO_CREATE_PROJECTS=true
# S3_ENDPOINT=
# S3_REGION=
# S3_ACCESS_KEY_ID=
# S3_SECRET_ACCESS_KEY=
# S3_BUCKET=
# S3_FORCE_PATH_STYLE=true
# OIDC_ISSUER=
# OIDC_CLIENT_ID=
# OIDC_CLIENT_SECRET=
@@ -53,6 +59,7 @@ SECRET_KEY=notsecretkey
# SMTP_USER=
# SMTP_PASSWORD=
# SMTP_FROM="Demo Demo" <demo@demo.demo>
# SMTP_TLS_REJECT_UNAUTHORIZED=false
# Optional fields: accessToken, events, excludedEvents
# WEBHOOKS='[{
@@ -64,8 +71,13 @@ SECRET_KEY=notsecretkey
# SLACK_BOT_TOKEN=
# SLACK_CHANNEL_ID=
# GOOGLE_CHAT_WEBHOOK_URL=
# TELEGRAM_BOT_TOKEN=
# TELEGRAM_CHAT_ID=
# TELEGRAM_THREAD_ID=
## Do not edit this
TZ=UTC

View File

@@ -3,6 +3,9 @@ const { v4: uuid } = require('uuid');
const { getRemoteAddress } = require('../../../utils/remoteAddress');
const Errors = {
INVALID_OIDC_CONFIGURATION: {
invalidOIDCConfiguration: 'Invalid OIDC configuration',
},
INVALID_CODE_OR_NONCE: {
invalidCodeOrNonce: 'Invalid code or nonce',
},
@@ -37,6 +40,9 @@ module.exports = {
},
exits: {
invalidOIDCConfiguration: {
responseType: 'serverError',
},
invalidCodeOrNonce: {
responseType: 'unauthorized',
},
@@ -63,6 +69,7 @@ module.exports = {
sails.log.warn(`Invalid code or nonce! (IP: ${remoteAddress})`);
return Errors.INVALID_CODE_OR_NONCE;
})
.intercept('invalidOIDCConfiguration', () => Errors.INVALID_OIDC_CONFIGURATION)
.intercept('invalidUserinfoConfiguration', () => Errors.INVALID_USERINFO_CONFIGURATION)
.intercept('emailAlreadyInUse', () => Errors.EMAIL_ALREADY_IN_USE)
.intercept('usernameAlreadyInUse', () => Errors.USERNAME_ALREADY_IN_USE)

View File

@@ -1,6 +1,3 @@
const fs = require('fs');
const path = require('path');
const Errors = {
ATTACHMENT_NOT_FOUND: {
attachmentNotFound: 'Attachment not found',
@@ -46,20 +43,20 @@ module.exports = {
throw Errors.ATTACHMENT_NOT_FOUND;
}
const filePath = path.join(
sails.config.custom.attachmentsPath,
attachment.dirname,
'thumbnails',
`cover-256.${attachment.image.thumbnailsExtension}`,
);
const fileManager = sails.hooks['file-manager'].getInstance();
if (!fs.existsSync(filePath)) {
let readStream;
try {
readStream = await fileManager.read(
`${sails.config.custom.attachmentsPathSegment}/${attachment.dirname}/thumbnails/cover-256.${attachment.image.thumbnailsExtension}`,
);
} catch (error) {
throw Errors.ATTACHMENT_NOT_FOUND;
}
this.res.type('image/jpeg');
this.res.set('Cache-Control', 'private, max-age=900'); // TODO: move to config
return exits.success(fs.createReadStream(filePath));
return exits.success(readStream);
},
};

View File

@@ -1,4 +1,3 @@
const fs = require('fs');
const path = require('path');
const Errors = {
@@ -42,13 +41,14 @@ module.exports = {
}
}
const filePath = path.join(
sails.config.custom.attachmentsPath,
attachment.dirname,
attachment.filename,
);
const fileManager = sails.hooks['file-manager'].getInstance();
if (!fs.existsSync(filePath)) {
let readStream;
try {
readStream = await fileManager.read(
`${sails.config.custom.attachmentsPathSegment}/${attachment.dirname}/${attachment.filename}`,
);
} catch (error) {
throw Errors.ATTACHMENT_NOT_FOUND;
}
@@ -58,6 +58,6 @@ module.exports = {
}
this.res.set('Cache-Control', 'private, max-age=900'); // TODO: move to config
return exits.success(fs.createReadStream(filePath));
return exits.success(readStream);
},
};

View File

@@ -1,8 +1,26 @@
const Errors = {
INVALID_OIDC_CONFIGURATION: {
invalidOidcConfiguration: 'Invalid OIDC configuration',
},
};
module.exports = {
fn() {
exits: {
invalidOidcConfiguration: {
responseType: 'serverError',
},
},
async fn() {
let oidc = null;
if (sails.hooks.oidc.isActive()) {
const oidcClient = sails.hooks.oidc.getClient();
let oidcClient;
try {
oidcClient = await sails.hooks.oidc.getClient();
} catch (error) {
sails.log.warn(`Error while initializing OIDC client: ${error}`);
throw Errors.INVALID_OIDC_CONFIGURATION;
}
const authorizationUrlParams = {
scope: sails.config.custom.oidcScopes,

View File

@@ -14,7 +14,10 @@ const valuesValidator = (value) => {
return true;
};
const buildAndSendMessage = async (card, action, actorUser, send) => {
const truncateString = (string, maxLength = 30) =>
string.length > maxLength ? `${string.substring(0, 30)}...` : string;
const buildAndSendMarkdownMessage = async (card, action, actorUser, send) => {
const cardLink = `<${sails.config.custom.baseUrl}/cards/${card.id}|${card.name}>`;
let markdown;
@@ -28,6 +31,7 @@ const buildAndSendMessage = async (card, action, actorUser, send) => {
break;
case Action.Types.COMMENT_CARD:
// TODO: truncate text?
markdown = `*${actorUser.name}* commented on ${cardLink}:\n>${action.data.text}`;
break;
@@ -38,6 +42,31 @@ const buildAndSendMessage = async (card, action, actorUser, send) => {
await send(markdown);
};
const buildAndSendHtmlMessage = async (card, action, actorUser, send) => {
const cardLink = `<a href="${sails.config.custom.baseUrl}/cards/${card.id}">${card.name}</a>`;
let html;
switch (action.type) {
case Action.Types.CREATE_CARD:
html = `${cardLink} was created by ${actorUser.name} in <b>${action.data.list.name}</b>`;
break;
case Action.Types.MOVE_CARD:
html = `${cardLink} was moved by ${actorUser.name} to <b>${action.data.toList.name}</b>`;
break;
case Action.Types.COMMENT_CARD: {
html = `<b>${actorUser.name}</b> commented on ${cardLink}:\n<i>${truncateString(action.data.text)}</i>`;
break;
}
default:
return;
}
await send(html);
};
module.exports = {
inputs: {
values: {
@@ -116,17 +145,32 @@ module.exports = {
);
if (sails.config.custom.slackBotToken) {
buildAndSendMessage(values.card, action, values.user, sails.helpers.utils.sendSlackMessage);
buildAndSendMarkdownMessage(
values.card,
action,
values.user,
sails.helpers.utils.sendSlackMessage,
);
}
if (sails.config.custom.googleChatWebhookUrl) {
buildAndSendMessage(
buildAndSendMarkdownMessage(
values.card,
action,
values.user,
sails.helpers.utils.sendGoogleChatMessage,
);
}
if (sails.config.custom.telegramBotToken) {
buildAndSendHtmlMessage(
values.card,
action,
values.user,
sails.helpers.utils.sendTelegramMessage,
);
}
return action;
},
};

View File

@@ -59,6 +59,9 @@ module.exports = {
cards: [inputs.card],
},
},
prevData: {
item: inputs.record,
},
user: inputs.actorUser,
});
}

View File

@@ -1,6 +1,3 @@
const path = require('path');
const rimraf = require('rimraf');
module.exports = {
inputs: {
record: {
@@ -50,8 +47,12 @@ module.exports = {
const attachment = await Attachment.archiveOne(inputs.record.id);
if (attachment) {
const fileManager = sails.hooks['file-manager'].getInstance();
try {
rimraf.sync(path.join(sails.config.custom.attachmentsPath, attachment.dirname));
await fileManager.deleteDir(
`${sails.config.custom.attachmentsPathSegment}/${attachment.dirname}`,
);
} catch (error) {
console.warn(error.stack); // eslint-disable-line no-console
}

View File

@@ -1,7 +1,4 @@
const fs = require('fs');
const path = require('path');
const rimraf = require('rimraf');
const moveFile = require('move-file');
const { rimraf } = require('rimraf');
const { v4: uuid } = require('uuid');
const sharp = require('sharp');
@@ -16,17 +13,19 @@ module.exports = {
},
async fn(inputs) {
const dirname = uuid();
const fileManager = sails.hooks['file-manager'].getInstance();
const dirname = uuid();
const dirPathSegment = `${sails.config.custom.attachmentsPathSegment}/${dirname}`;
const filename = filenamify(inputs.file.filename);
const rootPath = path.join(sails.config.custom.attachmentsPath, dirname);
const filePath = path.join(rootPath, filename);
const filePath = await fileManager.move(
inputs.file.fd,
`${dirPathSegment}/${filename}`,
inputs.file.type,
);
fs.mkdirSync(rootPath);
await moveFile(inputs.file.fd, filePath);
let image = sharp(filePath, {
let image = sharp(filePath || inputs.file.fd, {
animated: true,
});
@@ -43,9 +42,6 @@ module.exports = {
};
if (metadata && !['svg', 'pdf'].includes(metadata.format)) {
const thumbnailsPath = path.join(rootPath, 'thumbnails');
fs.mkdirSync(thumbnailsPath);
let { width, pageHeight: height = metadata.height } = metadata;
if (metadata.orientation && metadata.orientation > 4) {
[image, width, height] = [image.rotate(), height, width];
@@ -55,7 +51,7 @@ module.exports = {
const thumbnailsExtension = metadata.format === 'jpeg' ? 'jpg' : metadata.format;
try {
await image
const resizeBuffer = await image
.resize(
256,
isPortrait ? 320 : undefined,
@@ -65,19 +61,29 @@ module.exports = {
}
: undefined,
)
.toFile(path.join(thumbnailsPath, `cover-256.${thumbnailsExtension}`));
.toBuffer();
await fileManager.save(
`${dirPathSegment}/thumbnails/cover-256.${thumbnailsExtension}`,
resizeBuffer,
inputs.file.type,
);
fileData.image = {
width,
height,
thumbnailsExtension,
};
} catch (error1) {
try {
rimraf.sync(thumbnailsPath);
} catch (error2) {
console.warn(error2.stack); // eslint-disable-line no-console
}
} catch (error) {
console.warn(error.stack); // eslint-disable-line no-console
}
}
if (!filePath) {
try {
await rimraf(inputs.file.fd);
} catch (error) {
console.warn(error.stack); // eslint-disable-line no-console
}
}

View File

@@ -59,6 +59,9 @@ module.exports = {
cards: [inputs.card],
},
},
prevData: {
item: inputs.record,
},
user: inputs.actorUser,
});
}

View File

@@ -62,6 +62,9 @@ module.exports = {
boards: [inputs.board],
},
},
prevData: {
item: inputs.record,
},
user: inputs.actorUser,
});
}

View File

@@ -1,5 +1,5 @@
const fs = require('fs').promises;
const rimraf = require('rimraf');
const fs = require('fs');
const { rimraf } = require('rimraf');
module.exports = {
inputs: {
@@ -14,7 +14,7 @@ module.exports = {
},
async fn(inputs) {
const content = await fs.readFile(inputs.file.fd);
const content = await fs.promises.readFile(inputs.file.fd);
const trelloBoard = JSON.parse(content);
if (
@@ -28,7 +28,7 @@ module.exports = {
}
try {
rimraf.sync(inputs.file.fd);
await rimraf(inputs.file.fd);
} catch (error) {
console.warn(error.stack); // eslint-disable-line no-console
}

View File

@@ -100,6 +100,9 @@ module.exports = {
projects: [inputs.project],
},
},
prevData: {
item: inputs.record,
},
user: inputs.actorUser,
});
}

View File

@@ -1,7 +1,11 @@
const buildAndSendMessage = async (card, actorUser, send) => {
const buildAndSendMarkdownMessage = async (card, actorUser, send) => {
await send(`*${card.name}* was deleted by ${actorUser.name}`);
};
const buildAndSendHtmlMessage = async (card, actorUser, send) => {
await send(`<b>${card.name}</b> was deleted by ${actorUser.name}`);
};
module.exports = {
inputs: {
record: {
@@ -56,11 +60,19 @@ module.exports = {
});
if (sails.config.custom.slackBotToken) {
buildAndSendMessage(card, inputs.actorUser, sails.helpers.utils.sendSlackMessage);
buildAndSendMarkdownMessage(card, inputs.actorUser, sails.helpers.utils.sendSlackMessage);
}
if (sails.config.custom.googleChatWebhookUrl) {
buildAndSendMessage(card, inputs.actorUser, sails.helpers.utils.sendGoogleChatMessage);
buildAndSendMarkdownMessage(
card,
inputs.actorUser,
sails.helpers.utils.sendGoogleChatMessage,
);
}
if (sails.config.custom.telegramBotToken) {
buildAndSendHtmlMessage(card, inputs.actorUser, sails.helpers.utils.sendTelegramMessage);
}
}

View File

@@ -264,6 +264,9 @@ module.exports = {
lists: [list],
},
},
prevData: {
item: inputs.record,
},
user: inputs.actorUser,
});

View File

@@ -91,6 +91,9 @@ module.exports = {
boards: [inputs.board],
},
},
prevData: {
item: inputs.record,
},
user: inputs.actorUser,
});
}

View File

@@ -91,6 +91,9 @@ module.exports = {
boards: [inputs.board],
},
},
prevData: {
item: inputs.record,
},
user: inputs.actorUser,
});
}

View File

@@ -48,6 +48,7 @@ module.exports = {
inputs.request,
);
// TODO: with prevData?
sails.helpers.utils.sendWebhooks.with({
event: 'notificationUpdate',
data: {

View File

@@ -1,6 +1,4 @@
const fs = require('fs');
const path = require('path');
const rimraf = require('rimraf');
const { rimraf } = require('rimraf');
const { v4: uuid } = require('uuid');
const sharp = require('sharp');
@@ -32,10 +30,10 @@ module.exports = {
throw 'fileIsNotImage';
}
const dirname = uuid();
const rootPath = path.join(sails.config.custom.projectBackgroundImagesPath, dirname);
const fileManager = sails.hooks['file-manager'].getInstance();
fs.mkdirSync(rootPath);
const dirname = uuid();
const dirPathSegment = `${sails.config.custom.projectBackgroundImagesPathSegment}/${dirname}`;
let { width, pageHeight: height = metadata.height } = metadata;
if (metadata.orientation && metadata.orientation > 4) {
@@ -45,9 +43,15 @@ module.exports = {
const extension = metadata.format === 'jpeg' ? 'jpg' : metadata.format;
try {
await image.toFile(path.join(rootPath, `original.${extension}`));
const originalBuffer = await image.toBuffer();
await image
await fileManager.save(
`${dirPathSegment}/original.${extension}`,
originalBuffer,
inputs.file.type,
);
const cover336Buffer = await image
.resize(
336,
200,
@@ -57,10 +61,18 @@ module.exports = {
}
: undefined,
)
.toFile(path.join(rootPath, `cover-336.${extension}`));
.toBuffer();
await fileManager.save(
`${dirPathSegment}/cover-336.${extension}`,
cover336Buffer,
inputs.file.type,
);
} catch (error1) {
console.warn(error1.stack); // eslint-disable-line no-console
try {
rimraf.sync(rootPath);
fileManager.deleteDir(dirPathSegment);
} catch (error2) {
console.warn(error2.stack); // eslint-disable-line no-console
}
@@ -69,7 +81,7 @@ module.exports = {
}
try {
rimraf.sync(inputs.file.fd);
await rimraf(inputs.file.fd);
} catch (error) {
console.warn(error.stack); // eslint-disable-line no-console
}

View File

@@ -1,6 +1,3 @@
const path = require('path');
const rimraf = require('rimraf');
const valuesValidator = (value) => {
if (!_.isPlainObject(value)) {
return false;
@@ -86,12 +83,11 @@ module.exports = {
(!project.backgroundImage ||
project.backgroundImage.dirname !== inputs.record.backgroundImage.dirname)
) {
const fileManager = sails.hooks['file-manager'].getInstance();
try {
rimraf.sync(
path.join(
sails.config.custom.projectBackgroundImagesPath,
inputs.record.backgroundImage.dirname,
),
await fileManager.deleteDir(
`${sails.config.custom.projectBackgroundImagesPathSegment}/${inputs.record.backgroundImage.dirname}`,
);
} catch (error) {
console.warn(error.stack); // eslint-disable-line no-console
@@ -118,6 +114,9 @@ module.exports = {
data: {
item: project,
},
prevData: {
item: inputs.record,
},
user: inputs.actorUser,
});
}

View File

@@ -101,6 +101,9 @@ module.exports = {
cards: [inputs.card],
},
},
prevData: {
item: inputs.record,
},
user: inputs.actorUser,
});
}

View File

@@ -11,6 +11,7 @@ module.exports = {
},
exits: {
invalidOIDCConfiguration: {},
invalidCodeOrNonce: {},
invalidUserinfoConfiguration: {},
missingValues: {},
@@ -19,7 +20,13 @@ module.exports = {
},
async fn(inputs) {
const client = sails.hooks.oidc.getClient();
let client;
try {
client = await sails.hooks.oidc.getClient();
} catch (error) {
sails.log.warn(`Error while initializing OIDC client: ${error}`);
throw 'invalidOIDCConfiguration';
}
let tokenSet;
try {

View File

@@ -1,6 +1,4 @@
const fs = require('fs');
const path = require('path');
const rimraf = require('rimraf');
const { rimraf } = require('rimraf');
const { v4: uuid } = require('uuid');
const sharp = require('sharp');
@@ -32,10 +30,10 @@ module.exports = {
throw 'fileIsNotImage';
}
const dirname = uuid();
const rootPath = path.join(sails.config.custom.userAvatarsPath, dirname);
const fileManager = sails.hooks['file-manager'].getInstance();
fs.mkdirSync(rootPath);
const dirname = uuid();
const dirPathSegment = `${sails.config.custom.userAvatarsPathSegment}/${dirname}`;
let { width, pageHeight: height = metadata.height } = metadata;
if (metadata.orientation && metadata.orientation > 4) {
@@ -45,9 +43,15 @@ module.exports = {
const extension = metadata.format === 'jpeg' ? 'jpg' : metadata.format;
try {
await image.toFile(path.join(rootPath, `original.${extension}`));
const originalBuffer = await image.toBuffer();
await image
await fileManager.save(
`${dirPathSegment}/original.${extension}`,
originalBuffer,
inputs.file.type,
);
const square100Buffer = await image
.resize(
100,
100,
@@ -57,10 +61,18 @@ module.exports = {
}
: undefined,
)
.toFile(path.join(rootPath, `square-100.${extension}`));
.toBuffer();
await fileManager.save(
`${dirPathSegment}/square-100.${extension}`,
square100Buffer,
inputs.file.type,
);
} catch (error1) {
console.warn(error1.stack); // eslint-disable-line no-console
try {
rimraf.sync(rootPath);
fileManager.deleteDir(dirPathSegment);
} catch (error2) {
console.warn(error2.stack); // eslint-disable-line no-console
}
@@ -69,7 +81,7 @@ module.exports = {
}
try {
rimraf.sync(inputs.file.fd);
await rimraf(inputs.file.fd);
} catch (error) {
console.warn(error.stack); // eslint-disable-line no-console
}

View File

@@ -1,6 +1,4 @@
const path = require('path');
const bcrypt = require('bcrypt');
const rimraf = require('rimraf');
const { v4: uuid } = require('uuid');
const valuesValidator = (value) => {
@@ -101,8 +99,12 @@ module.exports = {
inputs.record.avatar &&
(!user.avatar || user.avatar.dirname !== inputs.record.avatar.dirname)
) {
const fileManager = sails.hooks['file-manager'].getInstance();
try {
rimraf.sync(path.join(sails.config.custom.userAvatarsPath, inputs.record.avatar.dirname));
await fileManager.deleteDir(
`${sails.config.custom.userAvatarsPathSegment}/${inputs.record.avatar.dirname}`,
);
} catch (error) {
console.warn(error.stack); // eslint-disable-line no-console
}
@@ -159,6 +161,9 @@ module.exports = {
data: {
item: user,
},
prevData: {
item: inputs.record,
},
user: inputs.actorUser,
});
}

View File

@@ -4,7 +4,7 @@ const { v4: uuid } = require('uuid');
async function doUpload(paramName, req, options) {
const uploadOptions = {
...options,
dirname: options.dirname || sails.config.custom.fileUploadTmpDir,
dirname: options.dirname || sails.config.custom.uploadsTempPath,
};
const upload = util.promisify((opts, callback) => {
return req.file(paramName).upload(opts, (error, files) => callback(error, files));
@@ -33,7 +33,7 @@ module.exports = {
exits.success(
await doUpload(inputs.paramName, inputs.req, {
saveAs: uuid(),
dirname: sails.config.custom.fileUploadTmpDir,
dirname: sails.config.custom.uploadsTempPath,
maxBytes: null,
}),
);

View File

@@ -0,0 +1,44 @@
const buildSendMessageApiUrl = (telegramBotToken) =>
`https://api.telegram.org/bot${telegramBotToken}/sendMessage`;
module.exports = {
inputs: {
html: {
type: 'string',
required: true,
},
},
async fn(inputs) {
const headers = {
'Content-Type': 'application/json; charset=utf-8',
};
const body = {
chat_id: sails.config.custom.telegramChatId,
text: inputs.html,
parse_mode: 'HTML',
};
if (sails.config.custom.telegramThreadId) {
body.message_thread_id = sails.config.custom.telegramThreadId;
}
let response;
try {
response = await fetch(buildSendMessageApiUrl(sails.config.custom.telegramBotToken), {
headers,
method: 'POST',
body: JSON.stringify(body),
});
} catch (error) {
sails.log.error(`Error sending to Telegram: ${error}`);
return;
}
if (!response.ok) {
const responseJson = await response.json();
sails.log.error(`Error sending to Telegram: ${responseJson.description}`);
}
},
};

View File

@@ -97,10 +97,11 @@ const jsonifyData = (data) => {
* @param {*} webhook - Webhook configuration.
* @param {string} event - The event (see {@link EVENT_TYPES}).
* @param {Data} data - The data object containing event data and optionally included data.
* @param {Data} [prevData] - The data object containing previous state of data (optional).
* @param {ref} user - User object associated with the event.
* @returns {Promise<void>}
*/
async function sendWebhook(webhook, event, data, user) {
async function sendWebhook(webhook, event, data, prevData, user) {
const headers = {
'Content-Type': 'application/json',
'User-Agent': `planka (+${sails.config.custom.baseUrl})`,
@@ -113,6 +114,7 @@ async function sendWebhook(webhook, event, data, user) {
const body = JSON.stringify({
event,
data: jsonifyData(data),
prevData: prevData && jsonifyData(prevData),
user: sails.helpers.utils.jsonifyRecord(user),
});
@@ -148,6 +150,9 @@ module.exports = {
type: 'ref',
required: true,
},
prevData: {
type: 'ref',
},
user: {
type: 'ref',
required: true,
@@ -172,7 +177,7 @@ module.exports = {
return;
}
sendWebhook(webhook, inputs.event, inputs.data, inputs.user);
sendWebhook(webhook, inputs.event, inputs.data, inputs.prevData, inputs.user);
});
},
};

View File

@@ -0,0 +1,51 @@
const fs = require('fs');
const fse = require('fs-extra');
const path = require('path');
const { rimraf } = require('rimraf');
const PATH_SEGMENT_TO_URL_REPLACE_REGEX = /(public|private)\//;
const buildPath = (pathSegment) => path.join(sails.config.custom.uploadsBasePath, pathSegment);
class LocalFileManager {
// eslint-disable-next-line class-methods-use-this
async move(sourceFilePath, filePathSegment) {
const { dir, base } = path.parse(filePathSegment);
const dirPath = buildPath(dir);
const filePath = path.join(dirPath, base);
await fs.promises.mkdir(dirPath);
await fse.move(sourceFilePath, filePath);
return filePath;
}
// eslint-disable-next-line class-methods-use-this
async save(filePathSegment, buffer) {
await fse.outputFile(buildPath(filePathSegment), buffer);
}
// eslint-disable-next-line class-methods-use-this
read(filePathSegment) {
const filePath = buildPath(filePathSegment);
if (!fs.existsSync(filePath)) {
throw new Error('File does not exist');
}
return fs.createReadStream(filePath);
}
// eslint-disable-next-line class-methods-use-this
async deleteDir(dirPathSegment) {
await rimraf(buildPath(dirPathSegment));
}
// eslint-disable-next-line class-methods-use-this
buildUrl(filePathSegment) {
return `${sails.config.custom.baseUrl}/${filePathSegment.replace(PATH_SEGMENT_TO_URL_REPLACE_REGEX, '')}`;
}
}
module.exports = LocalFileManager;

View File

@@ -0,0 +1,76 @@
const fs = require('fs');
const {
DeleteObjectsCommand,
GetObjectCommand,
ListObjectsV2Command,
PutObjectCommand,
} = require('@aws-sdk/client-s3');
class S3FileManager {
constructor(client) {
this.client = client;
}
async move(sourceFilePath, filePathSegment, contentType) {
const command = new PutObjectCommand({
Bucket: sails.config.custom.s3Bucket,
Key: filePathSegment,
Body: fs.createReadStream(sourceFilePath),
ContentType: contentType,
});
await this.client.send(command);
return null;
}
async save(filePathSegment, buffer, contentType) {
const command = new PutObjectCommand({
Bucket: sails.config.custom.s3Bucket,
Key: filePathSegment,
Body: buffer,
ContentType: contentType,
});
await this.client.send(command);
}
async read(filePathSegment) {
const command = new GetObjectCommand({
Bucket: sails.config.custom.s3Bucket,
Key: filePathSegment,
});
const result = await this.client.send(command);
return result.Body;
}
async deleteDir(dirPathSegment) {
const listObjectsCommand = new ListObjectsV2Command({
Bucket: sails.config.custom.s3Bucket,
Prefix: dirPathSegment,
});
const result = await this.client.send(listObjectsCommand);
if (!result.Contents || result.Contents.length === 0) {
return;
}
const deleteObjectsCommand = new DeleteObjectsCommand({
Bucket: sails.config.custom.s3Bucket,
Delete: {
Objects: result.Contents.map(({ Key }) => ({ Key })),
},
});
await this.client.send(deleteObjectsCommand);
}
// eslint-disable-next-line class-methods-use-this
buildUrl(filePathSegment) {
return `${sails.hooks.s3.getBaseUrl()}/${filePathSegment}`;
}
}
module.exports = S3FileManager;

View File

@@ -0,0 +1,41 @@
const LocalFileManager = require('./LocalFileManager');
const S3FileManager = require('./S3FileManager');
/**
* file-manager hook
*
* @description :: A hook definition. Extends Sails by adding shadow routes, implicit actions,
* and/or initialization logic.
* @docs :: https://sailsjs.com/docs/concepts/extending-sails/hooks
*/
module.exports = function defineFileManagerHook(sails) {
let instance = null;
const createInstance = () => {
instance = sails.hooks.s3.isActive()
? new S3FileManager(sails.hooks.s3.getClient())
: new LocalFileManager();
};
return {
/**
* Runs when this Sails app loads/lifts.
*/
async initialize() {
sails.log.info('Initializing custom hook (`file-manager`)');
return new Promise((resolve) => {
sails.after('hook:s3:loaded', () => {
createInstance();
resolve();
});
});
},
getInstance() {
return instance;
},
};
};

View File

@@ -15,37 +15,40 @@ module.exports = function defineOidcHook(sails) {
/**
* Runs when this Sails app loads/lifts.
*/
async initialize() {
if (!sails.config.custom.oidcIssuer) {
if (!this.isActive()) {
return;
}
sails.log.info('Initializing custom hook (`oidc`)');
const issuer = await openidClient.Issuer.discover(sails.config.custom.oidcIssuer);
const metadata = {
client_id: sails.config.custom.oidcClientId,
client_secret: sails.config.custom.oidcClientSecret,
redirect_uris: [sails.config.custom.oidcRedirectUri],
response_types: ['code'],
userinfo_signed_response_alg: sails.config.custom.oidcUserinfoSignedResponseAlg,
};
if (sails.config.custom.oidcIdTokenSignedResponseAlg) {
metadata.id_token_signed_response_alg = sails.config.custom.oidcIdTokenSignedResponseAlg;
}
client = new issuer.Client(metadata);
},
getClient() {
async getClient() {
if (client === null && this.isActive()) {
sails.log.info('Initializing OIDC client');
const issuer = await openidClient.Issuer.discover(sails.config.custom.oidcIssuer);
const metadata = {
client_id: sails.config.custom.oidcClientId,
client_secret: sails.config.custom.oidcClientSecret,
redirect_uris: [sails.config.custom.oidcRedirectUri],
response_types: ['code'],
userinfo_signed_response_alg: sails.config.custom.oidcUserinfoSignedResponseAlg,
};
if (sails.config.custom.oidcIdTokenSignedResponseAlg) {
metadata.id_token_signed_response_alg = sails.config.custom.oidcIdTokenSignedResponseAlg;
}
client = new issuer.Client(metadata);
}
return client;
},
isActive() {
return client !== null;
return sails.config.custom.oidcIssuer !== undefined;
},
};
};

View File

@@ -0,0 +1,64 @@
const { URL } = require('url');
const { S3Client } = require('@aws-sdk/client-s3');
/**
* s3 hook
*
* @description :: A hook definition. Extends Sails by adding shadow routes, implicit actions,
* and/or initialization logic.
* @docs :: https://sailsjs.com/docs/concepts/extending-sails/hooks
*/
module.exports = function defineS3Hook(sails) {
let client = null;
return {
/**
* Runs when this Sails app loads/lifts.
*/
async initialize() {
if (!sails.config.custom.s3Endpoint && !sails.config.custom.s3Region) {
return;
}
sails.log.info('Initializing custom hook (`s3`)');
client = new S3Client({
endpoint: sails.config.custom.s3Endpoint,
region: sails.config.custom.s3Region || '-',
credentials: {
accessKeyId: sails.config.custom.s3AccessKeyId,
secretAccessKey: sails.config.custom.s3SecretAccessKey,
},
forcePathStyle: sails.config.custom.s3ForcePathStyle,
});
},
getClient() {
return client;
},
getBaseUrl() {
if (sails.config.custom.s3Endpoint) {
const { protocol, host } = new URL(sails.config.custom.s3Endpoint);
if (sails.config.custom.s3ForcePathStyle) {
return `${protocol}//${host}/${sails.config.custom.s3Bucket}`;
}
return `${protocol}//${sails.config.custom.s3Bucket}.${host}`;
}
if (sails.config.custom.s3ForcePathStyle) {
return `https://s3.${sails.config.custom.s3Region}.amazonaws.com/${sails.config.custom.s3Bucket}`;
}
return `https://${sails.config.custom.s3Bucket}.s3.${sails.config.custom.s3Region}.amazonaws.com`;
},
isActive() {
return client !== null;
},
};
};

View File

@@ -33,6 +33,9 @@ module.exports = function defineSmtpHook(sails) {
user: sails.config.custom.smtpUser,
pass: sails.config.custom.smtpPassword,
},
tls: {
rejectUnauthorized: sails.config.custom.smtpTlsRejectUnauthorized,
},
});
},

View File

@@ -50,9 +50,9 @@ module.exports = {
customToJSON() {
return {
..._.omit(this, ['dirname', 'filename', 'image.thumbnailsExtension']),
url: `${sails.config.custom.attachmentsUrl}/${this.id}/download/${this.filename}`,
url: `${sails.config.custom.baseUrl}/attachments/${this.id}/download/${this.filename}`,
coverUrl: this.image
? `${sails.config.custom.attachmentsUrl}/${this.id}/download/thumbnails/cover-256.${this.image.thumbnailsExtension}`
? `${sails.config.custom.baseUrl}/attachments/${this.id}/download/thumbnails/cover-256.${this.image.thumbnailsExtension}`
: null,
};
},

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