mirror of
https://github.com/plankanban/planka.git
synced 2025-12-25 01:11:49 +03:00
Compare commits
48 Commits
planka-0.2
...
planka-0.2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ca3b0a75d3 | ||
|
|
9eee486356 | ||
|
|
6698437727 | ||
|
|
a3f101a139 | ||
|
|
843b44cea7 | ||
|
|
8d245e8722 | ||
|
|
617246ec40 | ||
|
|
e6ab870827 | ||
|
|
fee35918d1 | ||
|
|
38ec9bd5cc | ||
|
|
54a969ec31 | ||
|
|
d0a8c8b61a | ||
|
|
1d11d9e35c | ||
|
|
2632edb44c | ||
|
|
96956e1268 | ||
|
|
5a3c3bb39b | ||
|
|
1a70b2b7e6 | ||
|
|
71d0815891 | ||
|
|
b700c307c3 | ||
|
|
9794919fd2 | ||
|
|
917dcf31cf | ||
|
|
850f6df0ac | ||
|
|
242d415142 | ||
|
|
950a070589 | ||
|
|
f6ea10df97 | ||
|
|
d9e8c24c3f | ||
|
|
f75b0237d3 | ||
|
|
38bc4cb0a0 | ||
|
|
cc95032e74 | ||
|
|
1d2193c381 | ||
|
|
70f40e26af | ||
|
|
814b5810ac | ||
|
|
f372113def | ||
|
|
14dff96434 | ||
|
|
7580afdb1c | ||
|
|
2458709031 | ||
|
|
e0d5d7f4ff | ||
|
|
bedd1f6e2a | ||
|
|
d9464dc3f9 | ||
|
|
87c1eeb5f8 | ||
|
|
98413d759a | ||
|
|
fa3b1d7b28 | ||
|
|
23e5f1a326 | ||
|
|
de44b520af | ||
|
|
e7d7425768 | ||
|
|
df7746f896 | ||
|
|
01a7e3a57d | ||
|
|
234bd1e8b3 |
@@ -47,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#planka-prebuild.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
73
.github/workflows/build-and-test.yml
vendored
Normal 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
33
.github/workflows/lint.yml
vendored
Normal 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
|
||||
@@ -31,6 +31,8 @@ Here is the [mobile app repository](https://github.com/LouisHDev/planka_app) mai
|
||||
|
||||
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
|
||||
|
||||
@@ -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.11
|
||||
version: 0.2.19
|
||||
|
||||
# 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.23.2"
|
||||
appVersion: "1.24.3"
|
||||
|
||||
dependencies:
|
||||
- alias: postgresql
|
||||
|
||||
@@ -67,6 +67,20 @@ spec:
|
||||
resources:
|
||||
{{- toYaml .Values.resources | nindent 12 }}
|
||||
env:
|
||||
{{- if .Values.extraEnv }}
|
||||
{{- range .Values.extraEnv }}
|
||||
- name: {{ .name }}
|
||||
{{- if .value }}
|
||||
value: {{ .value | quote}}
|
||||
{{- end }}
|
||||
{{- if .valueFrom }}
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: {{ .valueFrom.secretName }}
|
||||
key: {{ .valueFrom.key }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- if not .Values.postgresql.enabled }}
|
||||
{{- if .Values.existingDburlSecret }}
|
||||
- name: DATABASE_URL
|
||||
@@ -82,7 +96,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
|
||||
|
||||
@@ -69,7 +69,7 @@ ingress:
|
||||
# kubernetes.io/ingress.class: nginx
|
||||
# kubernetes.io/tls-acme: "true"
|
||||
hosts:
|
||||
# Used to set planka BASE_URL if no `baseurl` is provided.
|
||||
# Used to set planka BASE_URL if no `baseurl` is provided.
|
||||
- host: planka.local
|
||||
paths:
|
||||
- path: /
|
||||
@@ -197,3 +197,16 @@ oidc:
|
||||
##
|
||||
roles: []
|
||||
# - planka-admin
|
||||
|
||||
## Extra environment variables for planka deployment
|
||||
## Supports hard coded and getting values from a k8s secret
|
||||
## - name: test
|
||||
## value: valuetest
|
||||
## - name: another
|
||||
## value: another
|
||||
## - name: test-secret
|
||||
## valueFrom:
|
||||
## secretName: k8s-secret-name
|
||||
## key: key-inside-the-secret
|
||||
##
|
||||
extraEnv: []
|
||||
|
||||
5517
client/package-lock.json
generated
5517
client/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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.15.2",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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/
|
||||
|
||||
@@ -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 (
|
||||
<>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -39,6 +39,13 @@
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.attachmentContent {
|
||||
color: #6a808b;
|
||||
font-size: 12px;
|
||||
line-height: 20px;
|
||||
padding: 0px 3px;
|
||||
}
|
||||
|
||||
.attachmentLeft {
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -38,6 +38,12 @@
|
||||
|
||||
.title {
|
||||
padding-bottom: 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
position: sticky;
|
||||
top: -1em;
|
||||
background: #f5f6f7;
|
||||
}
|
||||
|
||||
.user {
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 };
|
||||
|
||||
14
client/src/hooks/use-nested-ref.js
Normal file
14
client/src/hooks/use-nested-ref.js
Normal 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];
|
||||
};
|
||||
@@ -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 };
|
||||
|
||||
45
client/src/lib/hooks/use-click-away-listener.js
Normal file
45
client/src/lib/hooks/use-click-away-listener.js
Normal 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;
|
||||
};
|
||||
@@ -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',
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -19,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';
|
||||
@@ -48,6 +50,8 @@ const locales = [
|
||||
roRO,
|
||||
ruRU,
|
||||
skSK,
|
||||
srCyrlCS,
|
||||
srLatnCS,
|
||||
svSE,
|
||||
trTR,
|
||||
ukUA,
|
||||
|
||||
@@ -3,6 +3,7 @@ export default {
|
||||
common: {
|
||||
emailOrUsername: 'E-mail или имя пользователя',
|
||||
invalidEmailOrUsername: 'Неверный e-mail или имя пользователя',
|
||||
invalidCredentials: 'Недействительные учетные данные',
|
||||
invalidPassword: 'Неверный пароль',
|
||||
logInToPlanka: 'Вход в Planka',
|
||||
noInternetConnection: 'Нет соединения',
|
||||
|
||||
253
client/src/locales/sr-Cyrl-CS/core.js
Normal file
253
client/src/locales/sr-Cyrl-CS/core.js
Normal 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: 'Постави нову слику',
|
||||
},
|
||||
},
|
||||
};
|
||||
8
client/src/locales/sr-Cyrl-CS/index.js
Normal file
8
client/src/locales/sr-Cyrl-CS/index.js
Normal file
@@ -0,0 +1,8 @@
|
||||
import login from './login';
|
||||
|
||||
export default {
|
||||
language: 'sr-Cyrl-CS',
|
||||
country: 'rs',
|
||||
name: 'Српски (ћирилица)',
|
||||
embeddedLocale: login,
|
||||
};
|
||||
23
client/src/locales/sr-Cyrl-CS/login.js
Normal file
23
client/src/locales/sr-Cyrl-CS/login.js
Normal 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: 'Пријава са УП',
|
||||
},
|
||||
},
|
||||
};
|
||||
253
client/src/locales/sr-Latn-CS/core.js
Normal file
253
client/src/locales/sr-Latn-CS/core.js
Normal 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',
|
||||
},
|
||||
},
|
||||
};
|
||||
8
client/src/locales/sr-Latn-CS/index.js
Normal file
8
client/src/locales/sr-Latn-CS/index.js
Normal file
@@ -0,0 +1,8 @@
|
||||
import login from './login';
|
||||
|
||||
export default {
|
||||
language: 'sr-Latn-CS',
|
||||
country: 'rs',
|
||||
name: 'Srpski (latinica)',
|
||||
embeddedLocale: login,
|
||||
};
|
||||
23
client/src/locales/sr-Latn-CS/login.js
Normal file
23
client/src/locales/sr-Latn-CS/login.js
Normal 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',
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -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,
|
||||
|
||||
@@ -142,6 +142,15 @@
|
||||
}
|
||||
|
||||
:global(#app) {
|
||||
&.dragging>* {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
&.dragScrolling>* {
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* Backgrounds */
|
||||
|
||||
.backgroundBerryRed {
|
||||
|
||||
102
client/src/utils/parse-time.js
Normal file
102
client/src/utils/parse-time.js
Normal 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;
|
||||
};
|
||||
@@ -1 +1 @@
|
||||
export default '1.23.2';
|
||||
export default '1.24.3';
|
||||
|
||||
12
client/tests/acceptance/config.js
Normal file
12
client/tests/acceptance/config.js
Normal 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',
|
||||
};
|
||||
35
client/tests/acceptance/cucumber.conf.js
Normal file
35
client/tests/acceptance/cucumber.conf.js
Normal 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();
|
||||
});
|
||||
@@ -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
|
||||
27
client/tests/acceptance/features/webUILogin/login.feature
Normal file
27
client/tests/acceptance/features/webUILogin/login.feature
Normal 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
|
||||
16
client/tests/acceptance/pageObjects/DashboardPage.js
Normal file
16
client/tests/acceptance/pageObjects/DashboardPage.js
Normal 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;
|
||||
38
client/tests/acceptance/pageObjects/LoginPage.js
Normal file
38
client/tests/acceptance/pageObjects/LoginPage.js
Normal 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;
|
||||
17
client/tests/acceptance/stepDefinitions/dashBoardContext.js
Normal file
17
client/tests/acceptance/stepDefinitions/dashBoardContext.js
Normal 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();
|
||||
});
|
||||
53
client/tests/acceptance/stepDefinitions/loginContext.js
Normal file
53
client/tests/acceptance/stepDefinitions/loginContext.js
Normal 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);
|
||||
});
|
||||
57
client/tests/acceptance/testHelpers/apiHelpers.js
Normal file
57
client/tests/acceptance/testHelpers/apiHelpers.js
Normal 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
23
client/tests/setup-symlinks.sh
Executable 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."
|
||||
@@ -9,36 +9,36 @@ PLANKA_DOCKER_CONTAINER_PLANKA="planka_planka_1"
|
||||
|
||||
# Create Temporary folder
|
||||
BACKUP_DATETIME=$(date --utc +%FT%H-%M-%SZ)
|
||||
mkdir -p $BACKUP_DATETIME-backup
|
||||
mkdir -p "$BACKUP_DATETIME-backup"
|
||||
|
||||
# Dump DB into SQL File
|
||||
echo -n "Exporting postgres database ... "
|
||||
docker exec -t $PLANKA_DOCKER_CONTAINER_POSTGRES pg_dumpall -c -U postgres > $BACKUP_DATETIME-backup/postgres.sql
|
||||
docker exec -t "$PLANKA_DOCKER_CONTAINER_POSTGRES" pg_dumpall -c -U postgres > "$BACKUP_DATETIME-backup/postgres.sql"
|
||||
echo "Success!"
|
||||
|
||||
# Export Docker Voumes
|
||||
echo -n "Exporting user-avatars ... "
|
||||
docker run --rm --volumes-from $PLANKA_DOCKER_CONTAINER_PLANKA -v $(pwd)/$BACKUP_DATETIME-backup:/backup ubuntu cp -r /app/public/user-avatars /backup/user-avatars
|
||||
docker run --rm --volumes-from "$PLANKA_DOCKER_CONTAINER_PLANKA" -v "$(pwd)/$BACKUP_DATETIME-backup:/backup" ubuntu cp -r /app/public/user-avatars /backup/user-avatars
|
||||
echo "Success!"
|
||||
echo -n "Exporting project-background-images ... "
|
||||
docker run --rm --volumes-from $PLANKA_DOCKER_CONTAINER_PLANKA -v $(pwd)/$BACKUP_DATETIME-backup:/backup ubuntu cp -r /app/public/project-background-images /backup/project-background-images
|
||||
docker run --rm --volumes-from "$PLANKA_DOCKER_CONTAINER_PLANKA" -v "$(pwd)/$BACKUP_DATETIME-backup:/backup" ubuntu cp -r /app/public/project-background-images /backup/project-background-images
|
||||
echo "Success!"
|
||||
echo -n "Exporting attachments ... "
|
||||
docker run --rm --volumes-from $PLANKA_DOCKER_CONTAINER_PLANKA -v $(pwd)/$BACKUP_DATETIME-backup:/backup ubuntu cp -r /app/private/attachments /backup/attachments
|
||||
docker run --rm --volumes-from "$PLANKA_DOCKER_CONTAINER_PLANKA" -v "$(pwd)/$BACKUP_DATETIME-backup:/backup" ubuntu cp -r /app/private/attachments /backup/attachments
|
||||
echo "Success!"
|
||||
|
||||
# Create tgz
|
||||
echo -n "Creating final tarball $BACKUP_DATETIME-backup.tgz ... "
|
||||
tar -czf $BACKUP_DATETIME-backup.tgz \
|
||||
$BACKUP_DATETIME-backup/postgres.sql \
|
||||
$BACKUP_DATETIME-backup/user-avatars \
|
||||
$BACKUP_DATETIME-backup/project-background-images \
|
||||
$BACKUP_DATETIME-backup/attachments
|
||||
tar -czf "$BACKUP_DATETIME-backup.tgz" \
|
||||
"$BACKUP_DATETIME-backup/postgres.sql" \
|
||||
"$BACKUP_DATETIME-backup/user-avatars" \
|
||||
"$BACKUP_DATETIME-backup/project-background-images" \
|
||||
"$BACKUP_DATETIME-backup/attachments"
|
||||
echo "Success!"
|
||||
|
||||
#Remove source files
|
||||
echo -n "Cleaning up temporary files and folders ... "
|
||||
rm -rf $BACKUP_DATETIME-backup
|
||||
rm -rf "$BACKUP_DATETIME-backup"
|
||||
echo "Success!"
|
||||
|
||||
echo "Backup Complete!"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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=
|
||||
@@ -67,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:
|
||||
@@ -110,7 +122,7 @@ services:
|
||||
condition: service_healthy
|
||||
|
||||
postgres:
|
||||
image: postgres:latest
|
||||
image: postgres:16-alpine
|
||||
volumes:
|
||||
- db-data:/var/lib/postgresql/data
|
||||
environment:
|
||||
|
||||
@@ -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=
|
||||
@@ -74,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
|
||||
|
||||
@@ -9,29 +9,29 @@ PLANKA_DOCKER_CONTAINER_PLANKA="planka_planka_1"
|
||||
|
||||
# Extract tgz archive
|
||||
PLANKA_BACKUP_ARCHIVE_TGZ=$1
|
||||
PLANKA_BACKUP_ARCHIVE=$(basename $PLANKA_BACKUP_ARCHIVE_TGZ .tgz)
|
||||
PLANKA_BACKUP_ARCHIVE=$(basename "$PLANKA_BACKUP_ARCHIVE_TGZ" .tgz)
|
||||
echo -n "Extracting tarball $PLANKA_BACKUP_ARCHIVE_TGZ ... "
|
||||
tar -xzf $PLANKA_BACKUP_ARCHIVE_TGZ
|
||||
tar -xzf "$PLANKA_BACKUP_ARCHIVE_TGZ"
|
||||
echo "Success!"
|
||||
|
||||
# Import Database
|
||||
echo -n "Importing postgres database ... "
|
||||
cat $PLANKA_BACKUP_ARCHIVE/postgres.sql | docker exec -i $PLANKA_DOCKER_CONTAINER_POSTGRES psql -U postgres
|
||||
cat "$PLANKA_BACKUP_ARCHIVE/postgres.sql" | docker exec -i "$PLANKA_DOCKER_CONTAINER_POSTGRES" psql -U postgres
|
||||
echo "Success!"
|
||||
|
||||
# Restore Docker Volumes
|
||||
echo -n "Importing user-avatars ... "
|
||||
docker run --rm --volumes-from $PLANKA_DOCKER_CONTAINER_PLANKA -v $(pwd)/$PLANKA_BACKUP_ARCHIVE:/backup ubuntu cp -rf /backup/user-avatars /app/public/
|
||||
docker run --rm --volumes-from "$PLANKA_DOCKER_CONTAINER_PLANKA" -v "$(pwd)/$PLANKA_BACKUP_ARCHIVE:/backup" ubuntu cp -rf /backup/user-avatars /app/public/
|
||||
echo "Success!"
|
||||
echo -n "Importing project-background-images ... "
|
||||
docker run --rm --volumes-from $PLANKA_DOCKER_CONTAINER_PLANKA -v $(pwd)/$PLANKA_BACKUP_ARCHIVE:/backup ubuntu cp -rf /backup/project-background-images /app/public/
|
||||
docker run --rm --volumes-from "$PLANKA_DOCKER_CONTAINER_PLANKA" -v "$(pwd)/$PLANKA_BACKUP_ARCHIVE:/backup" ubuntu cp -rf /backup/project-background-images /app/public/
|
||||
echo "Success!"
|
||||
echo -n "Importing attachments ... "
|
||||
docker run --rm --volumes-from $PLANKA_DOCKER_CONTAINER_PLANKA -v $(pwd)/$PLANKA_BACKUP_ARCHIVE:/backup ubuntu cp -rf /backup/attachments /app/private/
|
||||
docker run --rm --volumes-from "$PLANKA_DOCKER_CONTAINER_PLANKA" -v "$(pwd)/$PLANKA_BACKUP_ARCHIVE:/backup" ubuntu cp -rf /backup/attachments /app/private/
|
||||
echo "Success!"
|
||||
|
||||
echo -n "Cleaning up temporary files and folders ... "
|
||||
rm -r $PLANKA_BACKUP_ARCHIVE
|
||||
rm -r "$PLANKA_BACKUP_ARCHIVE"
|
||||
echo "Success!"
|
||||
|
||||
echo "Restore complete!"
|
||||
|
||||
104
package-lock.json
generated
104
package-lock.json
generated
@@ -1,31 +1,31 @@
|
||||
{
|
||||
"name": "planka",
|
||||
"version": "1.23.2",
|
||||
"version": "1.24.3",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "planka",
|
||||
"version": "1.23.2",
|
||||
"version": "1.24.3",
|
||||
"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",
|
||||
|
||||
10
package.json
10
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "planka",
|
||||
"version": "1.23.2",
|
||||
"version": "1.24.3",
|
||||
"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"
|
||||
|
||||
@@ -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=
|
||||
@@ -65,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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -59,6 +59,9 @@ module.exports = {
|
||||
cards: [inputs.card],
|
||||
},
|
||||
},
|
||||
prevData: {
|
||||
item: inputs.record,
|
||||
},
|
||||
user: inputs.actorUser,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -59,6 +59,9 @@ module.exports = {
|
||||
cards: [inputs.card],
|
||||
},
|
||||
},
|
||||
prevData: {
|
||||
item: inputs.record,
|
||||
},
|
||||
user: inputs.actorUser,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -62,6 +62,9 @@ module.exports = {
|
||||
boards: [inputs.board],
|
||||
},
|
||||
},
|
||||
prevData: {
|
||||
item: inputs.record,
|
||||
},
|
||||
user: inputs.actorUser,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -100,6 +100,9 @@ module.exports = {
|
||||
projects: [inputs.project],
|
||||
},
|
||||
},
|
||||
prevData: {
|
||||
item: inputs.record,
|
||||
},
|
||||
user: inputs.actorUser,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -264,6 +264,9 @@ module.exports = {
|
||||
lists: [list],
|
||||
},
|
||||
},
|
||||
prevData: {
|
||||
item: inputs.record,
|
||||
},
|
||||
user: inputs.actorUser,
|
||||
});
|
||||
|
||||
|
||||
@@ -91,6 +91,9 @@ module.exports = {
|
||||
boards: [inputs.board],
|
||||
},
|
||||
},
|
||||
prevData: {
|
||||
item: inputs.record,
|
||||
},
|
||||
user: inputs.actorUser,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -91,6 +91,9 @@ module.exports = {
|
||||
boards: [inputs.board],
|
||||
},
|
||||
},
|
||||
prevData: {
|
||||
item: inputs.record,
|
||||
},
|
||||
user: inputs.actorUser,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -48,6 +48,7 @@ module.exports = {
|
||||
inputs.request,
|
||||
);
|
||||
|
||||
// TODO: with prevData?
|
||||
sails.helpers.utils.sendWebhooks.with({
|
||||
event: 'notificationUpdate',
|
||||
data: {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -101,6 +101,9 @@ module.exports = {
|
||||
cards: [inputs.card],
|
||||
},
|
||||
},
|
||||
prevData: {
|
||||
item: inputs.record,
|
||||
},
|
||||
user: inputs.actorUser,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
);
|
||||
|
||||
44
server/api/helpers/utils/send-telegram-message.js
Normal file
44
server/api/helpers/utils/send-telegram-message.js
Normal 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}`);
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -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);
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
51
server/api/hooks/file-manager/LocalFileManager.js
Normal file
51
server/api/hooks/file-manager/LocalFileManager.js
Normal 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;
|
||||
76
server/api/hooks/file-manager/S3FileManager.js
Normal file
76
server/api/hooks/file-manager/S3FileManager.js
Normal 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;
|
||||
41
server/api/hooks/file-manager/index.js
Normal file
41
server/api/hooks/file-manager/index.js
Normal 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;
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
64
server/api/hooks/s3/index.js
Normal file
64
server/api/hooks/s3/index.js
Normal 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;
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
},
|
||||
|
||||
@@ -79,11 +79,13 @@ module.exports = {
|
||||
},
|
||||
|
||||
customToJSON() {
|
||||
const fileManager = sails.hooks['file-manager'].getInstance();
|
||||
|
||||
return {
|
||||
..._.omit(this, ['backgroundImage']),
|
||||
backgroundImage: this.backgroundImage && {
|
||||
url: `${sails.config.custom.projectBackgroundImagesUrl}/${this.backgroundImage.dirname}/original.${this.backgroundImage.extension}`,
|
||||
coverUrl: `${sails.config.custom.projectBackgroundImagesUrl}/${this.backgroundImage.dirname}/cover-336.${this.backgroundImage.extension}`,
|
||||
url: `${fileManager.buildUrl(`${sails.config.custom.projectBackgroundImagesPathSegment}/${this.backgroundImage.dirname}/original.${this.backgroundImage.extension}`)}`,
|
||||
coverUrl: `${fileManager.buildUrl(`${sails.config.custom.projectBackgroundImagesPathSegment}/${this.backgroundImage.dirname}/cover-336.${this.backgroundImage.extension}`)}`,
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
@@ -27,6 +27,8 @@ const LANGUAGES = [
|
||||
'ro-RO',
|
||||
'ru-RU',
|
||||
'sk-SK',
|
||||
'sr-Cyrl-CS',
|
||||
'sr-Latn-CS',
|
||||
'sv-SE',
|
||||
'tr-TR',
|
||||
'uk-UA',
|
||||
@@ -147,6 +149,7 @@ module.exports = {
|
||||
tableName: 'user_account',
|
||||
|
||||
customToJSON() {
|
||||
const fileManager = sails.hooks['file-manager'].getInstance();
|
||||
const isDefaultAdmin = this.email === sails.config.custom.defaultAdminEmail;
|
||||
|
||||
return {
|
||||
@@ -157,7 +160,7 @@ module.exports = {
|
||||
isDeletionLocked: isDefaultAdmin,
|
||||
avatarUrl:
|
||||
this.avatar &&
|
||||
`${sails.config.custom.userAvatarsUrl}/${this.avatar.dirname}/square-100.${this.avatar.extension}`,
|
||||
`${fileManager.buildUrl(`${sails.config.custom.userAvatarsPathSegment}/${this.avatar.dirname}/square-100.${this.avatar.extension}`)}`,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
@@ -8,11 +8,10 @@
|
||||
* https://sailsjs.com/config/custom
|
||||
*/
|
||||
|
||||
const url = require('url');
|
||||
const path = require('path');
|
||||
const { URL } = require('url');
|
||||
const sails = require('sails');
|
||||
|
||||
const parsedBasedUrl = new url.URL(process.env.BASE_URL);
|
||||
const parsedBasedUrl = new URL(process.env.BASE_URL);
|
||||
|
||||
module.exports.custom = {
|
||||
/**
|
||||
@@ -28,24 +27,26 @@ module.exports.custom = {
|
||||
tokenExpiresIn: parseInt(process.env.TOKEN_EXPIRES_IN, 10) || 365,
|
||||
|
||||
// Location to receive uploaded files in. Default (non-string value) is a Sails-specific location.
|
||||
fileUploadTmpDir: null,
|
||||
uploadsTempPath: null,
|
||||
uploadsBasePath: sails.config.appPath,
|
||||
|
||||
userAvatarsPath: path.join(sails.config.paths.public, 'user-avatars'),
|
||||
userAvatarsUrl: `${process.env.BASE_URL}/user-avatars`,
|
||||
|
||||
projectBackgroundImagesPath: path.join(sails.config.paths.public, 'project-background-images'),
|
||||
projectBackgroundImagesUrl: `${process.env.BASE_URL}/project-background-images`,
|
||||
|
||||
attachmentsPath: path.join(sails.config.appPath, 'private', 'attachments'),
|
||||
attachmentsUrl: `${process.env.BASE_URL}/attachments`,
|
||||
userAvatarsPathSegment: 'public/user-avatars',
|
||||
projectBackgroundImagesPathSegment: 'public/project-background-images',
|
||||
attachmentsPathSegment: 'private/attachments',
|
||||
|
||||
defaultAdminEmail:
|
||||
process.env.DEFAULT_ADMIN_EMAIL && process.env.DEFAULT_ADMIN_EMAIL.toLowerCase(),
|
||||
|
||||
showDetailedAuthErrors: process.env.SHOW_DETAILED_AUTH_ERRORS === 'true',
|
||||
|
||||
allowAllToCreateProjects: process.env.ALLOW_ALL_TO_CREATE_PROJECTS === 'true',
|
||||
|
||||
s3Endpoint: process.env.S3_ENDPOINT,
|
||||
s3Region: process.env.S3_REGION,
|
||||
s3AccessKeyId: process.env.S3_ACCESS_KEY_ID,
|
||||
s3SecretAccessKey: process.env.S3_SECRET_ACCESS_KEY,
|
||||
s3Bucket: process.env.S3_BUCKET,
|
||||
s3ForcePathStyle: process.env.S3_FORCE_PATH_STYLE === 'true',
|
||||
|
||||
oidcIssuer: process.env.OIDC_ISSUER,
|
||||
oidcClientId: process.env.OIDC_CLIENT_ID,
|
||||
oidcClientSecret: process.env.OIDC_CLIENT_SECRET,
|
||||
@@ -82,5 +83,10 @@ module.exports.custom = {
|
||||
|
||||
slackBotToken: process.env.SLACK_BOT_TOKEN,
|
||||
slackChannelId: process.env.SLACK_CHANNEL_ID,
|
||||
|
||||
googleChatWebhookUrl: process.env.GOOGLE_CHAT_WEBHOOK_URL,
|
||||
|
||||
telegramBotToken: process.env.TELEGRAM_BOT_TOKEN,
|
||||
telegramChatId: process.env.TELEGRAM_CHAT_ID,
|
||||
telegramThreadId: process.env.TELEGRAM_THREAD_ID,
|
||||
};
|
||||
|
||||
4
server/config/env/production.js
vendored
4
server/config/env/production.js
vendored
@@ -19,11 +19,11 @@
|
||||
* https://sailsjs.com/docs/concepts/deployment
|
||||
*/
|
||||
|
||||
const url = require('url');
|
||||
const { URL } = require('url');
|
||||
|
||||
const { customLogger } = require('../../utils/logger');
|
||||
|
||||
const parsedBasedUrl = new url.URL(process.env.BASE_URL);
|
||||
const parsedBasedUrl = new URL(process.env.BASE_URL);
|
||||
|
||||
module.exports = {
|
||||
/**
|
||||
|
||||
@@ -135,13 +135,21 @@ module.exports.routes = {
|
||||
'PATCH /api/notifications/:ids': 'notifications/update',
|
||||
|
||||
'GET /user-avatars/*': {
|
||||
fn: staticDirServer('/user-avatars', () => path.resolve(sails.config.custom.userAvatarsPath)),
|
||||
fn: staticDirServer('/user-avatars', () =>
|
||||
path.join(
|
||||
path.resolve(sails.config.custom.uploadsBasePath),
|
||||
sails.config.custom.userAvatarsPathSegment,
|
||||
),
|
||||
),
|
||||
skipAssets: false,
|
||||
},
|
||||
|
||||
'GET /project-background-images/*': {
|
||||
fn: staticDirServer('/project-background-images', () =>
|
||||
path.resolve(sails.config.custom.projectBackgroundImagesPath),
|
||||
path.join(
|
||||
path.resolve(sails.config.custom.uploadsBasePath),
|
||||
sails.config.custom.projectBackgroundImagesPathSegment,
|
||||
),
|
||||
),
|
||||
skipAssets: false,
|
||||
},
|
||||
|
||||
2469
server/package-lock.json
generated
2469
server/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user