mirror of
https://github.com/plankanban/planka.git
synced 2025-12-12 17:23:14 +03:00
Compare commits
27 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7580afdb1c | ||
|
|
2458709031 | ||
|
|
e0d5d7f4ff | ||
|
|
bedd1f6e2a | ||
|
|
d9464dc3f9 | ||
|
|
87c1eeb5f8 | ||
|
|
98413d759a | ||
|
|
fa3b1d7b28 | ||
|
|
23e5f1a326 | ||
|
|
de44b520af | ||
|
|
e7d7425768 | ||
|
|
df7746f896 | ||
|
|
01a7e3a57d | ||
|
|
234bd1e8b3 | ||
|
|
2173c3657c | ||
|
|
a2b81f6d59 | ||
|
|
1948bd4fbe | ||
|
|
52f8abc1f8 | ||
|
|
dd7574f134 | ||
|
|
19935e52af | ||
|
|
89292356db | ||
|
|
5f6528fa42 | ||
|
|
2081b44874 | ||
|
|
0b729bf4b3 | ||
|
|
368ead982e | ||
|
|
1217969e22 | ||
|
|
e410e21363 |
@@ -4,7 +4,6 @@ on:
|
||||
release:
|
||||
types: [created]
|
||||
|
||||
|
||||
jobs:
|
||||
build-and-publish-release-package:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -48,10 +47,10 @@ jobs:
|
||||
- name: Dist create .zip file
|
||||
run: |
|
||||
mv dist/ planka/
|
||||
zip -r planka-prebuild-${{ github.event.release.tag_name }}.zip planka
|
||||
zip -r planka-prebuild.zip planka
|
||||
|
||||
- name: Dist upload assets
|
||||
run: |
|
||||
gh release upload ${{ github.event.release.tag_name }} planka-prebuild-${{ github.event.release.tag_name }}.zip
|
||||
gh release upload ${{ github.event.release.tag_name }} planka-prebuild.zip
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
73
.github/workflows/build-and-test.yml
vendored
Normal file
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
|
||||
@@ -25,6 +25,14 @@ There are many ways to install Planka, [check them out](https://docs.planka.clou
|
||||
|
||||
For configuration, please see the [configuration section](https://docs.planka.cloud/docs/category/configuration).
|
||||
|
||||
## Mobile app
|
||||
|
||||
Here is the [mobile app repository](https://github.com/LouisHDev/planka_app) maintained by the community, where you can build an app for iOS and Android.
|
||||
|
||||
Alternatively, you can download the [Android APK](https://github.com/LouisHDev/planka_app/releases/latest/download/app-release.apk) directly.
|
||||
|
||||
If you have an iOS device and would like to test the app, you can join [TestFlight](https://testflight.apple.com/join/Uwn41eY4) (limited to 200 participants).
|
||||
|
||||
## Contact
|
||||
|
||||
- If you want to get a hosted version of Planka, you can contact us via email contact@planka.cloud
|
||||
|
||||
@@ -15,13 +15,13 @@ type: application
|
||||
# This is the chart version. This version number should be incremented each time you make changes
|
||||
# to the chart and its templates, including the app version.
|
||||
# Versions are expected to follow Semantic Versioning (https://semver.org/)
|
||||
version: 0.2.8
|
||||
version: 0.2.12
|
||||
|
||||
# This is the version number of the application being deployed. This version number should be
|
||||
# incremented each time you make changes to the application. Versions are not expected to
|
||||
# follow Semantic Versioning. They should reflect the version the application is using.
|
||||
# It is recommended to use it with quotes.
|
||||
appVersion: "1.22.0"
|
||||
appVersion: "1.23.3"
|
||||
|
||||
dependencies:
|
||||
- alias: postgresql
|
||||
|
||||
@@ -4,6 +4,10 @@ metadata:
|
||||
name: {{ include "planka.fullname" . }}
|
||||
labels:
|
||||
{{- include "planka.labels" . | nindent 4 }}
|
||||
{{- with .Values.service.annotations }}
|
||||
annotations:
|
||||
{{- toYaml . | nindent 4 }}
|
||||
{{- end }}
|
||||
spec:
|
||||
type: {{ .Values.service.type }}
|
||||
ports:
|
||||
|
||||
@@ -54,6 +54,7 @@ securityContext: {}
|
||||
# runAsUser: 1000
|
||||
|
||||
service:
|
||||
annotations: {}
|
||||
type: ClusterIP
|
||||
port: 1337
|
||||
## @param service.containerPort Planka HTTP container port
|
||||
|
||||
941
client/package-lock.json
generated
941
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": [
|
||||
@@ -108,9 +109,13 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/plugin-proposal-private-property-in-object": "^7.21.11",
|
||||
"@cucumber/cucumber": "^7.3.1",
|
||||
"@cucumber/pretty-formatter": "^1.0.1",
|
||||
"@playwright/test": "^1.46.1",
|
||||
"@testing-library/jest-dom": "^6.5.0",
|
||||
"@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",
|
||||
@@ -119,6 +124,7 @@
|
||||
"eslint-plugin-jsx-a11y": "^6.10.0",
|
||||
"eslint-plugin-react": "^7.36.1",
|
||||
"eslint-plugin-react-hooks": "^4.6.2",
|
||||
"playwright": "^1.46.1",
|
||||
"react-test-renderer": "18.2.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@ const AttachmentAddStep = React.memo(({ onCreate, onClose }) => {
|
||||
</Popup.Header>
|
||||
<Popup.Content>
|
||||
<Menu secondary vertical className={styles.menu}>
|
||||
<FilePicker onSelect={handleFileSelect}>
|
||||
<FilePicker multiple onSelect={handleFileSelect}>
|
||||
<Menu.Item className={styles.menuItem}>
|
||||
{t('common.fromComputer', {
|
||||
context: 'title',
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useTranslation } from 'react-i18next';
|
||||
import { closePopup } from '../../../lib/popup';
|
||||
|
||||
import { useModal } from '../../../hooks';
|
||||
import { isActiveTextElement } from '../../../utils/element-helpers';
|
||||
import TextFileAddModal from './TextFileAddModal';
|
||||
|
||||
import styles from './AttachmentAddZone.module.scss';
|
||||
@@ -24,13 +25,14 @@ const AttachmentAddZone = React.memo(({ children, onCreate }) => {
|
||||
|
||||
const handleDropAccepted = useCallback(
|
||||
(files) => {
|
||||
submit(files[0]);
|
||||
files.forEach((file) => {
|
||||
submit(file);
|
||||
});
|
||||
},
|
||||
[submit],
|
||||
);
|
||||
|
||||
const { getRootProps, getInputProps, isDragActive } = useDropzone({
|
||||
multiple: false,
|
||||
noClick: true,
|
||||
noKeyboard: true,
|
||||
onDropAccepted: handleDropAccepted,
|
||||
@@ -49,38 +51,43 @@ const AttachmentAddZone = React.memo(({ children, onCreate }) => {
|
||||
return;
|
||||
}
|
||||
|
||||
const file = event.clipboardData.files[0];
|
||||
const { files, items } = event.clipboardData;
|
||||
|
||||
if (file) {
|
||||
submit(file);
|
||||
return;
|
||||
}
|
||||
|
||||
const item = event.clipboardData.items[0];
|
||||
|
||||
if (!item) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (item.kind === 'file') {
|
||||
submit(item.getAsFile());
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
['input', 'textarea'].includes(event.target.tagName.toLowerCase()) &&
|
||||
event.target === document.activeElement
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
closePopup();
|
||||
event.preventDefault();
|
||||
|
||||
item.getAsString((content) => {
|
||||
openModal({
|
||||
content,
|
||||
if (files.length > 0) {
|
||||
[...files].forEach((file) => {
|
||||
submit(file);
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (items.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (items[0].kind === 'string') {
|
||||
if (isActiveTextElement(event.target)) {
|
||||
return;
|
||||
}
|
||||
|
||||
closePopup();
|
||||
event.preventDefault();
|
||||
|
||||
items[0].getAsString((content) => {
|
||||
openModal({
|
||||
content,
|
||||
});
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
[...items].forEach((item) => {
|
||||
if (item.kind !== 'file') {
|
||||
return;
|
||||
}
|
||||
|
||||
submit(item.getAsFile());
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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,10 +67,7 @@ const DueDateEditStep = React.memo(({ defaultValue, onUpdate, onBack, onClose })
|
||||
return;
|
||||
}
|
||||
|
||||
const value = t('format:dateTime', {
|
||||
postProcess: 'parseDate',
|
||||
value: `${data.date} ${data.time}`,
|
||||
});
|
||||
const value = parseTime(data.time, nullableDate);
|
||||
|
||||
if (Number.isNaN(value.getTime())) {
|
||||
timeField.current.select();
|
||||
@@ -81,7 +79,7 @@ const DueDateEditStep = React.memo(({ defaultValue, onUpdate, onBack, onClose })
|
||||
}
|
||||
|
||||
onClose();
|
||||
}, [defaultValue, onUpdate, onClose, data, nullableDate, t]);
|
||||
}, [defaultValue, onUpdate, onClose, data, nullableDate]);
|
||||
|
||||
const handleClearClick = useCallback(() => {
|
||||
if (defaultValue) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -3,7 +3,7 @@ import PropTypes from 'prop-types';
|
||||
|
||||
import styles from './FilePicker.module.css';
|
||||
|
||||
const FilePicker = React.memo(({ children, accept, onSelect }) => {
|
||||
const FilePicker = React.memo(({ children, accept, multiple, onSelect }) => {
|
||||
const field = useRef(null);
|
||||
|
||||
const handleTriggerClick = useCallback(() => {
|
||||
@@ -12,11 +12,11 @@ const FilePicker = React.memo(({ children, accept, onSelect }) => {
|
||||
|
||||
const handleFieldChange = useCallback(
|
||||
({ target }) => {
|
||||
if (target.files[0]) {
|
||||
onSelect(target.files[0]);
|
||||
[...target.files].forEach((file) => {
|
||||
onSelect(file);
|
||||
});
|
||||
|
||||
target.value = null; // eslint-disable-line no-param-reassign
|
||||
}
|
||||
target.value = null; // eslint-disable-line no-param-reassign
|
||||
},
|
||||
[onSelect],
|
||||
);
|
||||
@@ -32,6 +32,7 @@ const FilePicker = React.memo(({ children, accept, onSelect }) => {
|
||||
ref={field}
|
||||
type="file"
|
||||
accept={accept}
|
||||
multiple={multiple}
|
||||
className={styles.field}
|
||||
onChange={handleFieldChange}
|
||||
/>
|
||||
@@ -42,11 +43,13 @@ const FilePicker = React.memo(({ children, accept, onSelect }) => {
|
||||
FilePicker.propTypes = {
|
||||
children: PropTypes.element.isRequired,
|
||||
accept: PropTypes.string,
|
||||
multiple: PropTypes.bool,
|
||||
onSelect: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
FilePicker.defaultProps = {
|
||||
accept: undefined,
|
||||
multiple: false,
|
||||
};
|
||||
|
||||
export default FilePicker;
|
||||
|
||||
252
client/src/locales/ar-YE/core.js
Normal file
252
client/src/locales/ar-YE/core.js
Normal file
@@ -0,0 +1,252 @@
|
||||
import dateFns from 'date-fns/locale/ar';
|
||||
|
||||
export default {
|
||||
dateFns,
|
||||
|
||||
format: {
|
||||
date: 'M/d/yyyy',
|
||||
time: 'p',
|
||||
dateTime: '$t(format:date) $t(format:time)',
|
||||
longDate: 'MMM d',
|
||||
longDateTime: "MMMM d 'at' p",
|
||||
fullDate: 'MMM d, y',
|
||||
fullDateTime: "MMMM d, y 'at' p",
|
||||
},
|
||||
|
||||
translation: {
|
||||
common: {
|
||||
aboutPlanka: 'حول 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 على Mac) لإضافة مرفق من الحافظة.',
|
||||
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}} hidden)',
|
||||
showDetails: 'إظهار التفاصيل',
|
||||
showFewerAttachments: 'عرض مرفقات أقل',
|
||||
sortList_title: 'فرز القائمة',
|
||||
start: 'ابدأ',
|
||||
stop: 'توقف',
|
||||
subscribe: 'اشترك',
|
||||
unsubscribe: 'إلغاء الاشتراك',
|
||||
uploadNewAvatar: 'رفع صورة رمزية جديدة',
|
||||
uploadNewImage: 'رفع صورة جديدة',
|
||||
},
|
||||
},
|
||||
};
|
||||
8
client/src/locales/ar-YE/index.js
Normal file
8
client/src/locales/ar-YE/index.js
Normal file
@@ -0,0 +1,8 @@
|
||||
import login from './login';
|
||||
|
||||
export default {
|
||||
language: 'ar-YE',
|
||||
country: 'ye',
|
||||
name: 'العربية',
|
||||
embeddedLocale: login,
|
||||
};
|
||||
23
client/src/locales/ar-YE/login.js
Normal file
23
client/src/locales/ar-YE/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: 'تسجيل الدخول باستخدام SSO',
|
||||
},
|
||||
},
|
||||
};
|
||||
253
client/src/locales/en-GB/core.js
Normal file
253
client/src/locales/en-GB/core.js
Normal file
@@ -0,0 +1,253 @@
|
||||
import dateFns from 'date-fns/locale/en-GB';
|
||||
|
||||
export default {
|
||||
dateFns,
|
||||
|
||||
format: {
|
||||
date: 'P',
|
||||
time: 'p',
|
||||
dateTime: '$t(format:date) $t(format:time)',
|
||||
longDate: 'd MMM',
|
||||
longDateTime: "d MMMM 'at' p",
|
||||
fullDate: 'd MMM y',
|
||||
fullDateTime: "d MMMM y 'at' p",
|
||||
},
|
||||
|
||||
translation: {
|
||||
common: {
|
||||
aboutPlanka: 'About Planka',
|
||||
account: 'Account',
|
||||
actions: 'Actions',
|
||||
addAttachment_title: 'Add Attachment',
|
||||
addComment: 'Add comment',
|
||||
addManager_title: 'Add Manager',
|
||||
addMember_title: 'Add Member',
|
||||
addUser_title: 'Add User',
|
||||
administrator: 'Administrator',
|
||||
all: 'All',
|
||||
allChangesWillBeAutomaticallySavedAfterConnectionRestored:
|
||||
'All changes will be automatically saved<br />after connection restored.',
|
||||
areYouSureYouWantToDeleteThisAttachment: 'Are you sure you want to delete this attachment?',
|
||||
areYouSureYouWantToDeleteThisBoard: 'Are you sure you want to delete this board?',
|
||||
areYouSureYouWantToDeleteThisCard: 'Are you sure you want to delete this card?',
|
||||
areYouSureYouWantToDeleteThisComment: 'Are you sure you want to delete this comment?',
|
||||
areYouSureYouWantToDeleteThisLabel: 'Are you sure you want to delete this label?',
|
||||
areYouSureYouWantToDeleteThisList: 'Are you sure you want to delete this list?',
|
||||
areYouSureYouWantToDeleteThisProject: 'Are you sure you want to delete this project?',
|
||||
areYouSureYouWantToDeleteThisTask: 'Are you sure you want to delete this task?',
|
||||
areYouSureYouWantToDeleteThisUser: 'Are you sure you want to delete this user?',
|
||||
areYouSureYouWantToLeaveBoard: 'Are you sure you want to leave the board?',
|
||||
areYouSureYouWantToLeaveProject: 'Are you sure you want to leave the project?',
|
||||
areYouSureYouWantToRemoveThisManagerFromProject:
|
||||
'Are you sure you want to remove this manager from the project?',
|
||||
areYouSureYouWantToRemoveThisMemberFromBoard:
|
||||
'Are you sure you want to remove this member from the board?',
|
||||
attachment: 'Attachment',
|
||||
attachments: 'Attachments',
|
||||
authentication: 'Authentication',
|
||||
background: 'Background',
|
||||
board: 'Board',
|
||||
boardNotFound_title: 'Board Not Found',
|
||||
canComment: 'Can comment',
|
||||
canEditContentOfBoard: 'Can edit the content of the board.',
|
||||
canOnlyViewBoard: 'Can only view the board.',
|
||||
cardActions_title: 'Card Actions',
|
||||
cardNotFound_title: 'Card Not Found',
|
||||
cardOrActionAreDeleted: 'Card or action are deleted.',
|
||||
color: 'Color',
|
||||
copy_inline: 'copy',
|
||||
createBoard_title: 'Create Board',
|
||||
createLabel_title: 'Create Label',
|
||||
createNewOneOrSelectExistingOne: 'Create a new one or select<br />an existing one.',
|
||||
createProject_title: 'Create Project',
|
||||
createTextFile_title: 'Create Text File',
|
||||
currentPassword: 'Current password',
|
||||
dangerZone_title: 'Danger Zone',
|
||||
date: 'Date',
|
||||
dueDate: 'Due date',
|
||||
dueDate_title: 'Due Date',
|
||||
deleteAttachment_title: 'Delete Attachment',
|
||||
deleteBoard_title: 'Delete Board',
|
||||
deleteCard_title: 'Delete Card',
|
||||
deleteComment_title: 'Delete Comment',
|
||||
deleteLabel_title: 'Delete Label',
|
||||
deleteList_title: 'Delete List',
|
||||
deleteProject_title: 'Delete Project',
|
||||
deleteTask_title: 'Delete Task',
|
||||
deleteUser_title: 'Delete User',
|
||||
description: 'Description',
|
||||
detectAutomatically: 'Detect automatically',
|
||||
dropFileToUpload: 'Drop file to upload',
|
||||
editor: 'Editor',
|
||||
editAttachment_title: 'Edit Attachment',
|
||||
editAvatar_title: 'Edit Avatar',
|
||||
editBoard_title: 'Edit Board',
|
||||
editDueDate_title: 'Edit Due Date',
|
||||
editEmail_title: 'Edit E-mail',
|
||||
editInformation_title: 'Edit Information',
|
||||
editLabel_title: 'Edit Label',
|
||||
editPassword_title: 'Edit Password',
|
||||
editPermissions_title: 'Edit Permissions',
|
||||
editStopwatch_title: 'Edit Stopwatch',
|
||||
editUsername_title: 'Edit Username',
|
||||
email: 'E-mail',
|
||||
emailAlreadyInUse: 'E-mail already in use',
|
||||
enterCardTitle: 'Enter card title... [Ctrl+Enter] to auto-open.',
|
||||
enterDescription: 'Enter description...',
|
||||
enterFilename: 'Enter filename',
|
||||
enterListTitle: 'Enter list title...',
|
||||
enterProjectTitle: 'Enter project title',
|
||||
enterTaskDescription: 'Enter task description...',
|
||||
filterByLabels_title: 'Filter By Labels',
|
||||
filterByMembers_title: 'Filter By Members',
|
||||
fromComputer_title: 'From Computer',
|
||||
fromTrello: 'From Trello',
|
||||
general: 'General',
|
||||
hours: 'Hours',
|
||||
importBoard_title: 'Import Board',
|
||||
invalidCurrentPassword: 'Invalid current password',
|
||||
labels: 'Labels',
|
||||
language: 'Language',
|
||||
leaveBoard_title: 'Leave Board',
|
||||
leaveProject_title: 'Leave Project',
|
||||
linkIsCopied: 'Link is copied',
|
||||
list: 'List',
|
||||
listActions_title: 'List Actions',
|
||||
managers: 'Managers',
|
||||
managerActions_title: 'Manager Actions',
|
||||
members: 'Members',
|
||||
memberActions_title: 'Member Actions',
|
||||
minutes: 'Minutes',
|
||||
moveCard_title: 'Move Card',
|
||||
name: 'Name',
|
||||
newestFirst: 'Newest first',
|
||||
newEmail: 'New e-mail',
|
||||
newPassword: 'New password',
|
||||
newUsername: 'New username',
|
||||
noConnectionToServer: 'No connection to server',
|
||||
noBoards: 'No boards',
|
||||
noLists: 'No lists',
|
||||
noProjects: 'No projects',
|
||||
notifications: 'Notifications',
|
||||
noUnreadNotifications: 'No unread notifications.',
|
||||
oldestFirst: 'Oldest first',
|
||||
openBoard_title: 'Open Board',
|
||||
optional_inline: 'optional',
|
||||
organization: 'Organization',
|
||||
phone: 'Phone',
|
||||
preferences: 'Preferences',
|
||||
pressPasteShortcutToAddAttachmentFromClipboard:
|
||||
'Tip: press Ctrl-V (Cmd-V on Mac) to add an attachment from the clipboard.',
|
||||
project: 'Project',
|
||||
projectNotFound_title: 'Project Not Found',
|
||||
removeManager_title: 'Remove Manager',
|
||||
removeMember_title: 'Remove Member',
|
||||
searchLabels: 'Search labels...',
|
||||
searchMembers: 'Search members...',
|
||||
searchUsers: 'Search users...',
|
||||
searchCards: 'Search cards...',
|
||||
seconds: 'Seconds',
|
||||
selectBoard: 'Select board',
|
||||
selectList: 'Select list',
|
||||
selectPermissions_title: 'Select Permissions',
|
||||
selectProject: 'Select project',
|
||||
settings: 'Settings',
|
||||
sortList_title: 'Sort List',
|
||||
stopwatch: 'Stopwatch',
|
||||
subscribeToMyOwnCardsByDefault: 'Subscribe to my own cards by default',
|
||||
taskActions_title: 'Task Actions',
|
||||
tasks: 'Tasks',
|
||||
thereIsNoPreviewAvailableForThisAttachment:
|
||||
'There is no preview available for this attachment.',
|
||||
time: 'Time',
|
||||
title: 'Title',
|
||||
userActions_title: 'User Actions',
|
||||
userAddedThisCardToList: '<0>{{user}}</0><1> added this card to {{list}}</1>',
|
||||
userLeftNewCommentToCard: '{{user}} left a new comment «{{comment}}» to <2>{{card}}</2>',
|
||||
userMovedCardFromListToList: '{{user}} moved <2>{{card}}</2> from {{fromList}} to {{toList}}',
|
||||
userMovedThisCardFromListToList:
|
||||
'<0>{{user}}</0><1> moved this card from {{fromList}} to {{toList}}</1>',
|
||||
username: 'Username',
|
||||
usernameAlreadyInUse: 'Username already in use',
|
||||
users: 'Users',
|
||||
version: 'Version',
|
||||
viewer: 'Viewer',
|
||||
writeComment: 'Write a comment...',
|
||||
},
|
||||
|
||||
action: {
|
||||
addAnotherCard: 'Add another card',
|
||||
addAnotherList: 'Add another list',
|
||||
addAnotherTask: 'Add another task',
|
||||
addCard: 'Add card',
|
||||
addCard_title: 'Add Card',
|
||||
addComment: 'Add comment',
|
||||
addList: 'Add list',
|
||||
addMember: 'Add member',
|
||||
addMoreDetailedDescription: 'Add more detailed description',
|
||||
addTask: 'Add task',
|
||||
addToCard: 'Add to card',
|
||||
addUser: 'Add user',
|
||||
copyLink_title: 'Copy Link',
|
||||
createBoard: 'Create board',
|
||||
createFile: 'Create file',
|
||||
createLabel: 'Create label',
|
||||
createNewLabel: 'Create new label',
|
||||
createProject: 'Create project',
|
||||
delete: 'Delete',
|
||||
deleteAttachment: 'Delete attachment',
|
||||
deleteAvatar: 'Delete avatar',
|
||||
deleteBoard: 'Delete board',
|
||||
deleteCard: 'Delete card',
|
||||
deleteCard_title: 'Delete Card',
|
||||
deleteComment: 'Delete comment',
|
||||
deleteImage: 'Delete image',
|
||||
deleteLabel: 'Delete label',
|
||||
deleteList: 'Delete list',
|
||||
deleteList_title: 'Delete List',
|
||||
deleteProject: 'Delete project',
|
||||
deleteProject_title: 'Delete Project',
|
||||
deleteTask: 'Delete task',
|
||||
deleteTask_title: 'Delete Task',
|
||||
deleteUser: 'Delete user',
|
||||
duplicate: 'Duplicate',
|
||||
duplicateCard_title: 'Duplicate Card',
|
||||
edit: 'Edit',
|
||||
editDueDate_title: 'Edit Due Date',
|
||||
editDescription_title: 'Edit Description',
|
||||
editEmail_title: 'Edit E-mail',
|
||||
editInformation_title: 'Edit Information',
|
||||
editPassword_title: 'Edit Password',
|
||||
editPermissions: 'Edit permissions',
|
||||
editStopwatch_title: 'Edit Stopwatch',
|
||||
editTitle_title: 'Edit Title',
|
||||
editUsername_title: 'Edit Username',
|
||||
hideDetails: 'Hide details',
|
||||
import: 'Import',
|
||||
leaveBoard: 'Leave board',
|
||||
leaveProject: 'Leave project',
|
||||
logOut_title: 'Log Out',
|
||||
makeCover_title: 'Make Cover',
|
||||
move: 'Move',
|
||||
moveCard_title: 'Move Card',
|
||||
remove: 'Remove',
|
||||
removeBackground: 'Remove background',
|
||||
removeCover_title: 'Remove Cover',
|
||||
removeFromBoard: 'Remove from board',
|
||||
removeFromProject: 'Remove from project',
|
||||
removeManager: 'Remove manager',
|
||||
removeMember: 'Remove member',
|
||||
save: 'Save',
|
||||
showAllAttachments: 'Show all attachments ({{hidden}} hidden)',
|
||||
showDetails: 'Show details',
|
||||
showFewerAttachments: 'Show fewer attachments',
|
||||
sortList_title: 'Sort List',
|
||||
start: 'Start',
|
||||
stop: 'Stop',
|
||||
subscribe: 'Subscribe',
|
||||
unsubscribe: 'Unsubscribe',
|
||||
uploadNewAvatar: 'Upload new avatar',
|
||||
uploadNewImage: 'Upload new image',
|
||||
},
|
||||
},
|
||||
};
|
||||
8
client/src/locales/en-GB/index.js
Normal file
8
client/src/locales/en-GB/index.js
Normal file
@@ -0,0 +1,8 @@
|
||||
import login from './login';
|
||||
|
||||
export default {
|
||||
language: 'en-GB',
|
||||
country: 'gb',
|
||||
name: 'English',
|
||||
embeddedLocale: login,
|
||||
};
|
||||
23
client/src/locales/en-GB/login.js
Normal file
23
client/src/locales/en-GB/login.js
Normal file
@@ -0,0 +1,23 @@
|
||||
export default {
|
||||
translation: {
|
||||
common: {
|
||||
emailOrUsername: 'E-mail or username',
|
||||
invalidEmailOrUsername: 'Invalid e-mail or username',
|
||||
invalidCredentials: 'Invalid credentials',
|
||||
invalidPassword: 'Invalid password',
|
||||
logInToPlanka: 'Log in to Planka',
|
||||
noInternetConnection: 'No internet connection',
|
||||
pageNotFound_title: 'Page Not Found',
|
||||
password: 'Password',
|
||||
projectManagement: 'Project management',
|
||||
serverConnectionFailed: 'Server connection failed',
|
||||
unknownError: 'Unknown error, try again later',
|
||||
useSingleSignOn: 'Use single sign-on',
|
||||
},
|
||||
|
||||
action: {
|
||||
logIn: 'Log in',
|
||||
logInWithSSO: 'Log in with SSO',
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -1,7 +1,9 @@
|
||||
import arYE from './ar-YE';
|
||||
import bgBG from './bg-BG';
|
||||
import csCZ from './cs-CZ';
|
||||
import daDK from './da-DK';
|
||||
import deDE from './de-DE';
|
||||
import enGB from './en-GB';
|
||||
import enUS from './en-US';
|
||||
import esES from './es-ES';
|
||||
import faIR from './fa-IR';
|
||||
@@ -25,10 +27,12 @@ import zhCN from './zh-CN';
|
||||
import zhTW from './zh-TW';
|
||||
|
||||
const locales = [
|
||||
arYE,
|
||||
bgBG,
|
||||
csCZ,
|
||||
daDK,
|
||||
deDE,
|
||||
enGB,
|
||||
enUS,
|
||||
esES,
|
||||
faIR,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
// eslint-disable-next-line import/prefer-default-export
|
||||
export const focusEnd = (element) => {
|
||||
element.focus();
|
||||
element.setSelectionRange(element.value.length + 1, element.value.length + 1);
|
||||
};
|
||||
|
||||
export const isActiveTextElement = (element) =>
|
||||
['input', 'textarea'].includes(element.tagName.toLowerCase()) &&
|
||||
element === document.activeElement;
|
||||
|
||||
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.22.0';
|
||||
export default '1.23.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."
|
||||
@@ -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
|
||||
|
||||
@@ -37,6 +37,7 @@ services:
|
||||
# - OIDC_RESPONSE_MODE=fragment
|
||||
# - OIDC_USE_DEFAULT_RESPONSE_MODE=true
|
||||
# - OIDC_ADMIN_ROLES=admin
|
||||
# - OIDC_CLAIMS_SOURCE=userinfo
|
||||
# - OIDC_EMAIL_ATTRIBUTE=email
|
||||
# - OIDC_NAME_ATTRIBUTE=name
|
||||
# - OIDC_USERNAME_ATTRIBUTE=preferred_username
|
||||
@@ -53,6 +54,7 @@ services:
|
||||
# - SMTP_USER=
|
||||
# - SMTP_PASSWORD=
|
||||
# - SMTP_FROM="Demo Demo" <demo@demo.demo>
|
||||
# - SMTP_TLS_REJECT_UNAUTHORIZED=false
|
||||
|
||||
# Optional fields: accessToken, events, excludedEvents
|
||||
# - |
|
||||
@@ -108,7 +110,7 @@ services:
|
||||
condition: service_healthy
|
||||
|
||||
postgres:
|
||||
image: postgres:latest
|
||||
image: postgres:16-alpine
|
||||
volumes:
|
||||
- db-data:/var/lib/postgresql/data
|
||||
environment:
|
||||
|
||||
@@ -44,6 +44,7 @@ services:
|
||||
# - OIDC_RESPONSE_MODE=fragment
|
||||
# - OIDC_USE_DEFAULT_RESPONSE_MODE=true
|
||||
# - OIDC_ADMIN_ROLES=admin
|
||||
# - OIDC_CLAIMS_SOURCE=userinfo
|
||||
# - OIDC_EMAIL_ATTRIBUTE=email
|
||||
# - OIDC_NAME_ATTRIBUTE=name
|
||||
# - OIDC_USERNAME_ATTRIBUTE=preferred_username
|
||||
@@ -60,6 +61,7 @@ services:
|
||||
# - SMTP_USER=
|
||||
# - SMTP_PASSWORD=
|
||||
# - SMTP_FROM="Demo Demo" <demo@demo.demo>
|
||||
# - SMTP_TLS_REJECT_UNAUTHORIZED=false
|
||||
|
||||
# Optional fields: accessToken, events, excludedEvents
|
||||
# - |
|
||||
@@ -78,7 +80,7 @@ services:
|
||||
condition: service_healthy
|
||||
|
||||
postgres:
|
||||
image: postgres:14-alpine
|
||||
image: postgres:16-alpine
|
||||
restart: on-failure
|
||||
volumes:
|
||||
- db-data:/var/lib/postgresql/data
|
||||
|
||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "planka",
|
||||
"version": "1.22.0",
|
||||
"version": "1.23.3",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "planka",
|
||||
"version": "1.22.0",
|
||||
"version": "1.23.3",
|
||||
"hasInstallScript": true,
|
||||
"license": "AGPL-3.0",
|
||||
"dependencies": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "planka",
|
||||
"version": "1.22.0",
|
||||
"version": "1.23.3",
|
||||
"private": true,
|
||||
"homepage": "https://plankanban.github.io/planka",
|
||||
"repository": {
|
||||
@@ -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": [
|
||||
|
||||
@@ -6,6 +6,8 @@ SECRET_KEY=notsecretkey
|
||||
|
||||
## Optional
|
||||
|
||||
# LOG_FILE=
|
||||
|
||||
# TRUST_PROXY=0
|
||||
# TOKEN_EXPIRES_IN=365 # In days
|
||||
|
||||
@@ -35,6 +37,7 @@ SECRET_KEY=notsecretkey
|
||||
# OIDC_RESPONSE_MODE=fragment
|
||||
# OIDC_USE_DEFAULT_RESPONSE_MODE=true
|
||||
# OIDC_ADMIN_ROLES=admin
|
||||
# OIDC_CLAIMS_SOURCE=userinfo
|
||||
# OIDC_EMAIL_ATTRIBUTE=email
|
||||
# OIDC_NAME_ATTRIBUTE=name
|
||||
# OIDC_USERNAME_ATTRIBUTE=preferred_username
|
||||
@@ -50,6 +53,7 @@ SECRET_KEY=notsecretkey
|
||||
# SMTP_USER=
|
||||
# SMTP_PASSWORD=
|
||||
# SMTP_FROM="Demo Demo" <demo@demo.demo>
|
||||
# SMTP_TLS_REJECT_UNAUTHORIZED=false
|
||||
|
||||
# Optional fields: accessToken, events, excludedEvents
|
||||
# WEBHOOKS='[{
|
||||
|
||||
@@ -6,8 +6,8 @@ const Errors = {
|
||||
INVALID_CODE_OR_NONCE: {
|
||||
invalidCodeOrNonce: 'Invalid code or nonce',
|
||||
},
|
||||
INVALID_USERINFO_SIGNATURE: {
|
||||
invalidUserinfoSignature: 'Invalid signature on userinfo due to client misconfiguration',
|
||||
INVALID_USERINFO_CONFIGURATION: {
|
||||
invalidUserinfoConfiguration: 'Invalid userinfo configuration',
|
||||
},
|
||||
EMAIL_ALREADY_IN_USE: {
|
||||
emailAlreadyInUse: 'Email already in use',
|
||||
@@ -40,7 +40,7 @@ module.exports = {
|
||||
invalidCodeOrNonce: {
|
||||
responseType: 'unauthorized',
|
||||
},
|
||||
invalidUserinfoSignature: {
|
||||
invalidUserinfoConfiguration: {
|
||||
responseType: 'unauthorized',
|
||||
},
|
||||
emailAlreadyInUse: {
|
||||
@@ -63,7 +63,7 @@ module.exports = {
|
||||
sails.log.warn(`Invalid code or nonce! (IP: ${remoteAddress})`);
|
||||
return Errors.INVALID_CODE_OR_NONCE;
|
||||
})
|
||||
.intercept('invalidUserinfoSignature', () => Errors.INVALID_USERINFO_SIGNATURE)
|
||||
.intercept('invalidUserinfoConfiguration', () => Errors.INVALID_USERINFO_CONFIGURATION)
|
||||
.intercept('emailAlreadyInUse', () => Errors.EMAIL_ALREADY_IN_USE)
|
||||
.intercept('usernameAlreadyInUse', () => Errors.USERNAME_ALREADY_IN_USE)
|
||||
.intercept('missingValues', () => Errors.MISSING_VALUES);
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
const util = require('util');
|
||||
const { v4: uuid } = require('uuid');
|
||||
|
||||
const Errors = {
|
||||
NOT_ENOUGH_RIGHTS: {
|
||||
notEnoughRights: 'Not enough rights',
|
||||
@@ -61,16 +58,9 @@ module.exports = {
|
||||
throw Errors.NOT_ENOUGH_RIGHTS;
|
||||
}
|
||||
|
||||
const upload = util.promisify((options, callback) =>
|
||||
this.req.file('file').upload(options, (error, files) => callback(error, files)),
|
||||
);
|
||||
|
||||
let files;
|
||||
try {
|
||||
files = await upload({
|
||||
saveAs: uuid(),
|
||||
maxBytes: null,
|
||||
});
|
||||
files = await sails.helpers.utils.receiveFile('file', this.req);
|
||||
} catch (error) {
|
||||
return exits.uploadError(error.message); // TODO: add error
|
||||
}
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
const util = require('util');
|
||||
const { v4: uuid } = require('uuid');
|
||||
|
||||
const Errors = {
|
||||
PROJECT_NOT_FOUND: {
|
||||
projectNotFound: 'Project not found',
|
||||
@@ -69,16 +66,9 @@ module.exports = {
|
||||
|
||||
let boardImport;
|
||||
if (inputs.importType && Object.values(Board.ImportTypes).includes(inputs.importType)) {
|
||||
const upload = util.promisify((options, callback) =>
|
||||
this.req.file('importFile').upload(options, (error, files) => callback(error, files)),
|
||||
);
|
||||
|
||||
let files;
|
||||
try {
|
||||
files = await upload({
|
||||
saveAs: uuid(),
|
||||
maxBytes: null,
|
||||
});
|
||||
files = await sails.helpers.utils.receiveFile('importFile', this.req);
|
||||
} catch (error) {
|
||||
return exits.uploadError(error.message); // TODO: add error
|
||||
}
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
const util = require('util');
|
||||
const rimraf = require('rimraf');
|
||||
const { v4: uuid } = require('uuid');
|
||||
|
||||
const Errors = {
|
||||
PROJECT_NOT_FOUND: {
|
||||
@@ -53,16 +51,9 @@ module.exports = {
|
||||
throw Errors.PROJECT_NOT_FOUND; // Forbidden
|
||||
}
|
||||
|
||||
const upload = util.promisify((options, callback) =>
|
||||
this.req.file('file').upload(options, (error, files) => callback(error, files)),
|
||||
);
|
||||
|
||||
let files;
|
||||
try {
|
||||
files = await upload({
|
||||
saveAs: uuid(),
|
||||
maxBytes: null,
|
||||
});
|
||||
files = await sails.helpers.utils.receiveFile('file', this.req);
|
||||
} catch (error) {
|
||||
return exits.uploadError(error.message); // TODO: add error
|
||||
}
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
const util = require('util');
|
||||
const rimraf = require('rimraf');
|
||||
const { v4: uuid } = require('uuid');
|
||||
|
||||
const Errors = {
|
||||
USER_NOT_FOUND: {
|
||||
@@ -54,16 +52,9 @@ module.exports = {
|
||||
user = currentUser;
|
||||
}
|
||||
|
||||
const upload = util.promisify((options, callback) =>
|
||||
this.req.file('file').upload(options, (error, files) => callback(error, files)),
|
||||
);
|
||||
|
||||
let files;
|
||||
try {
|
||||
files = await upload({
|
||||
saveAs: uuid(),
|
||||
maxBytes: null,
|
||||
});
|
||||
files = await sails.helpers.utils.receiveFile('file', this.req);
|
||||
} catch (error) {
|
||||
return exits.uploadError(error.message); // TODO: add error
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ module.exports = {
|
||||
|
||||
exits: {
|
||||
invalidCodeOrNonce: {},
|
||||
invalidUserinfoSignature: {},
|
||||
invalidUserinfoConfiguration: {},
|
||||
missingValues: {},
|
||||
emailAlreadyInUse: {},
|
||||
usernameAlreadyInUse: {},
|
||||
@@ -21,9 +21,9 @@ module.exports = {
|
||||
async fn(inputs) {
|
||||
const client = sails.hooks.oidc.getClient();
|
||||
|
||||
let userInfo;
|
||||
let tokenSet;
|
||||
try {
|
||||
const tokenSet = await client.callback(
|
||||
tokenSet = await client.callback(
|
||||
sails.config.custom.oidcRedirectUri,
|
||||
{
|
||||
iss: sails.config.custom.oidcIssuer,
|
||||
@@ -33,23 +33,36 @@ module.exports = {
|
||||
nonce: inputs.nonce,
|
||||
},
|
||||
);
|
||||
userInfo = await client.userinfo(tokenSet);
|
||||
} catch (e) {
|
||||
if (
|
||||
e instanceof SyntaxError &&
|
||||
e.message.includes('Unexpected token e in JSON at position 0')
|
||||
) {
|
||||
sails.log.warn('Error while exchanging OIDC code: userinfo response is signed');
|
||||
throw 'invalidUserinfoSignature';
|
||||
}
|
||||
|
||||
sails.log.warn(`Error while exchanging OIDC code: ${e}`);
|
||||
} catch (error) {
|
||||
sails.log.warn(`Error while exchanging OIDC code: ${error}`);
|
||||
throw 'invalidCodeOrNonce';
|
||||
}
|
||||
|
||||
let claims;
|
||||
if (sails.config.custom.oidcClaimsSource === 'id_token') {
|
||||
claims = tokenSet.claims();
|
||||
} else {
|
||||
try {
|
||||
claims = await client.userinfo(tokenSet);
|
||||
} catch (error) {
|
||||
let errorText;
|
||||
if (
|
||||
error instanceof SyntaxError &&
|
||||
error.message.includes('Unexpected token e in JSON at position 0')
|
||||
) {
|
||||
errorText = 'response is signed';
|
||||
} else {
|
||||
errorText = error.toString();
|
||||
}
|
||||
|
||||
sails.log.warn(`Error while fetching OIDC userinfo: ${errorText}`);
|
||||
throw 'invalidUserinfoConfiguration';
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
!userInfo[sails.config.custom.oidcEmailAttribute] ||
|
||||
!userInfo[sails.config.custom.oidcNameAttribute]
|
||||
!claims[sails.config.custom.oidcEmailAttribute] ||
|
||||
!claims[sails.config.custom.oidcNameAttribute]
|
||||
) {
|
||||
throw 'missingValues';
|
||||
}
|
||||
@@ -58,23 +71,23 @@ module.exports = {
|
||||
if (sails.config.custom.oidcAdminRoles.includes('*')) {
|
||||
isAdmin = true;
|
||||
} else {
|
||||
const roles = userInfo[sails.config.custom.oidcRolesAttribute];
|
||||
const roles = claims[sails.config.custom.oidcRolesAttribute];
|
||||
if (Array.isArray(roles)) {
|
||||
// Use a Set here to avoid quadratic time complexity
|
||||
const userRoles = new Set(userInfo[sails.config.custom.oidcRolesAttribute]);
|
||||
const userRoles = new Set(claims[sails.config.custom.oidcRolesAttribute]);
|
||||
isAdmin = sails.config.custom.oidcAdminRoles.findIndex((role) => userRoles.has(role)) > -1;
|
||||
}
|
||||
}
|
||||
|
||||
const values = {
|
||||
isAdmin,
|
||||
email: userInfo[sails.config.custom.oidcEmailAttribute],
|
||||
email: claims[sails.config.custom.oidcEmailAttribute],
|
||||
isSso: true,
|
||||
name: userInfo[sails.config.custom.oidcNameAttribute],
|
||||
name: claims[sails.config.custom.oidcNameAttribute],
|
||||
subscribeToOwnCards: false,
|
||||
};
|
||||
if (!sails.config.custom.oidcIgnoreUsername) {
|
||||
values.username = userInfo[sails.config.custom.oidcUsernameAttribute];
|
||||
values.username = claims[sails.config.custom.oidcUsernameAttribute];
|
||||
}
|
||||
|
||||
let user;
|
||||
@@ -84,7 +97,7 @@ module.exports = {
|
||||
// concurrently with logging in via OIDC.
|
||||
let identityProviderUser = await IdentityProviderUser.findOne({
|
||||
issuer: sails.config.custom.oidcIssuer,
|
||||
sub: userInfo.sub,
|
||||
sub: claims.sub,
|
||||
});
|
||||
|
||||
if (identityProviderUser) {
|
||||
@@ -108,7 +121,7 @@ module.exports = {
|
||||
identityProviderUser = await IdentityProviderUser.create({
|
||||
userId: user.id,
|
||||
issuer: sails.config.custom.oidcIssuer,
|
||||
sub: userInfo.sub,
|
||||
sub: claims.sub,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
41
server/api/helpers/utils/receive-file.js
Normal file
41
server/api/helpers/utils/receive-file.js
Normal file
@@ -0,0 +1,41 @@
|
||||
const util = require('util');
|
||||
const { v4: uuid } = require('uuid');
|
||||
|
||||
async function doUpload(paramName, req, options) {
|
||||
const uploadOptions = {
|
||||
...options,
|
||||
dirname: options.dirname || sails.config.custom.fileUploadTmpDir,
|
||||
};
|
||||
const upload = util.promisify((opts, callback) => {
|
||||
return req.file(paramName).upload(opts, (error, files) => callback(error, files));
|
||||
});
|
||||
return upload(uploadOptions);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
friendlyName: 'Receive uploaded file from request',
|
||||
description:
|
||||
"Store a file uploaded from a MIME-multipart request part. The request part name must be 'file'; the resulting file will have a unique UUID-based name with the same extension.",
|
||||
inputs: {
|
||||
paramName: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
description: 'The MIME multi-part parameter containing the file to receive.',
|
||||
},
|
||||
req: {
|
||||
type: 'ref',
|
||||
required: true,
|
||||
description: 'The request to receive the file from.',
|
||||
},
|
||||
},
|
||||
|
||||
fn: async function modFn(inputs, exits) {
|
||||
exits.success(
|
||||
await doUpload(inputs.paramName, inputs.req, {
|
||||
saveAs: uuid(),
|
||||
dirname: sails.config.custom.fileUploadTmpDir,
|
||||
maxBytes: null,
|
||||
}),
|
||||
);
|
||||
},
|
||||
};
|
||||
@@ -130,8 +130,8 @@ async function sendWebhook(webhook, event, data, user) {
|
||||
`Webhook ${webhook.url} failed with status ${response.status} and message: ${message}`,
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
sails.log.error(`Webhook ${webhook.url} failed with error message: ${e.message}`);
|
||||
} catch (error) {
|
||||
sails.log.error(`Webhook ${webhook.url} failed with error: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -33,6 +33,9 @@ module.exports = function defineSmtpHook(sails) {
|
||||
user: sails.config.custom.smtpUser,
|
||||
pass: sails.config.custom.smtpPassword,
|
||||
},
|
||||
tls: {
|
||||
rejectUnauthorized: sails.config.custom.smtpTlsRejectUnauthorized,
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
|
||||
@@ -6,10 +6,12 @@
|
||||
*/
|
||||
|
||||
const LANGUAGES = [
|
||||
'ar-YE',
|
||||
'bg-BG',
|
||||
'cs-CZ',
|
||||
'da-DK',
|
||||
'de-DE',
|
||||
'en-GB',
|
||||
'en-US',
|
||||
'es-ES',
|
||||
'fa-IR',
|
||||
|
||||
@@ -27,6 +27,9 @@ 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,
|
||||
|
||||
userAvatarsPath: path.join(sails.config.paths.public, 'user-avatars'),
|
||||
userAvatarsUrl: `${process.env.BASE_URL}/user-avatars`,
|
||||
|
||||
@@ -52,6 +55,7 @@ module.exports.custom = {
|
||||
oidcResponseMode: process.env.OIDC_RESPONSE_MODE || 'fragment',
|
||||
oidcUseDefaultResponseMode: process.env.OIDC_USE_DEFAULT_RESPONSE_MODE === 'true',
|
||||
oidcAdminRoles: process.env.OIDC_ADMIN_ROLES ? process.env.OIDC_ADMIN_ROLES.split(',') : [],
|
||||
oidcClaimsSource: process.env.OIDC_CLAIMS_SOURCE || 'userinfo',
|
||||
oidcEmailAttribute: process.env.OIDC_EMAIL_ATTRIBUTE || 'email',
|
||||
oidcNameAttribute: process.env.OIDC_NAME_ATTRIBUTE || 'name',
|
||||
oidcUsernameAttribute: process.env.OIDC_USERNAME_ATTRIBUTE || 'preferred_username',
|
||||
@@ -72,6 +76,7 @@ module.exports.custom = {
|
||||
smtpUser: process.env.SMTP_USER,
|
||||
smtpPassword: process.env.SMTP_PASSWORD,
|
||||
smtpFrom: process.env.SMTP_FROM,
|
||||
smtpTlsRejectUnauthorized: process.env.SMTP_TLS_REJECT_UNAUTHORIZED !== 'false',
|
||||
|
||||
webhooks: JSON.parse(process.env.WEBHOOKS || '[]'), // TODO: validate structure
|
||||
|
||||
|
||||
@@ -1,3 +1,56 @@
|
||||
const serveStatic = require('serve-static');
|
||||
const sails = require('sails');
|
||||
const path = require('path');
|
||||
|
||||
// Remove prefix from urlPath, assuming completely matches a subpath of
|
||||
// urlPath. The result preserves query params and fragment if present
|
||||
//
|
||||
// Examples:
|
||||
// '/foo', '/foo/bar' -> '/bar'
|
||||
// '/foo', '/foo' -> '/'
|
||||
// '/foo', '/foo?baz=bux' -> '/?baz=bux'
|
||||
// '/foo', '/foobar' -> '/foobar'
|
||||
function removeRoutePrefix(prefix, urlPath) {
|
||||
if (urlPath.startsWith(prefix)) {
|
||||
const subpath = urlPath.substring(prefix.length);
|
||||
if (subpath.startsWith('/')) {
|
||||
// Prefix matched a complete set of path segments, with a valid path
|
||||
// remaining.
|
||||
return subpath;
|
||||
}
|
||||
|
||||
if (subpath.length === 0 || subpath.startsWith('?') || subpath.startsWith('#')) {
|
||||
// Prefix matched a complete set of path segments, but there is no path
|
||||
// remaining. Add '/'.
|
||||
return `/${subpath}`;
|
||||
}
|
||||
}
|
||||
|
||||
// Either the prefix didn't match at all, or it wasn't a complete path match
|
||||
// (e.g. we don't want to treat '/foo' as a prefix of '/foobar'). Leave the
|
||||
// path as-is.
|
||||
return urlPath;
|
||||
}
|
||||
|
||||
function staticDirServer(prefix, dirFn) {
|
||||
return function handleReq(req, res, next) {
|
||||
// Custom config properties are not available when the routes config is
|
||||
// loaded, so resolve the target value just before serving the request.
|
||||
const dir = dirFn();
|
||||
const staticServer = serveStatic(dir, { index: false });
|
||||
|
||||
const reqPath = req.url;
|
||||
if (reqPath.startsWith(prefix)) {
|
||||
// serve-static treats the request url as a sub-path of
|
||||
// static root; remove the leading route prefix so the static root
|
||||
// doesn't have to include the prefix as a subdirectory.
|
||||
req.url = removeRoutePrefix(prefix, req.url);
|
||||
return staticServer(req, res, next);
|
||||
}
|
||||
return next();
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Route Mappings
|
||||
* (sails.config.routes)
|
||||
@@ -81,6 +134,18 @@ module.exports.routes = {
|
||||
'GET /api/notifications/:id': 'notifications/show',
|
||||
'PATCH /api/notifications/:ids': 'notifications/update',
|
||||
|
||||
'GET /user-avatars/*': {
|
||||
fn: staticDirServer('/user-avatars', () => path.resolve(sails.config.custom.userAvatarsPath)),
|
||||
skipAssets: false,
|
||||
},
|
||||
|
||||
'GET /project-background-images/*': {
|
||||
fn: staticDirServer('/project-background-images', () =>
|
||||
path.resolve(sails.config.custom.projectBackgroundImagesPath),
|
||||
),
|
||||
skipAssets: false,
|
||||
},
|
||||
|
||||
'GET /attachments/:id/download/:filename': {
|
||||
action: 'attachments/download',
|
||||
skipAssets: false,
|
||||
|
||||
13905
server/package-lock.json
generated
13905
server/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -8,7 +8,6 @@
|
||||
"db:migrate": "knex migrate:latest --cwd db",
|
||||
"db:seed": "knex seed:run --cwd db",
|
||||
"lint": "eslint . --max-warnings=0 --report-unused-disable-directives && echo '✔ Your .js files look good.'",
|
||||
"preinstall": "npx npm-force-resolutions",
|
||||
"start": "nodemon",
|
||||
"start:prod": "node app.js --prod",
|
||||
"test": "mocha test/lifecycle.test.js test/integration/**/*.test.js test/utils/**/*.test.js"
|
||||
@@ -39,10 +38,11 @@
|
||||
"nodemailer": "^6.9.15",
|
||||
"openid-client": "^5.7.0",
|
||||
"rimraf": "^5.0.10",
|
||||
"sails": "^1.5.11",
|
||||
"sails": "^1.5.12",
|
||||
"sails-hook-orm": "^4.0.3",
|
||||
"sails-hook-sockets": "^3.0.1",
|
||||
"sails-postgresql": "^5.0.1",
|
||||
"serve-static": "^1.13.1",
|
||||
"sharp": "^0.33.5",
|
||||
"stream-to-array": "^2.3.0",
|
||||
"uuid": "^9.0.1",
|
||||
@@ -58,11 +58,6 @@
|
||||
"mocha": "^10.7.3",
|
||||
"nodemon": "^3.1.4"
|
||||
},
|
||||
"resolutions": {
|
||||
"body-parser": "1.20.3",
|
||||
"express": "4.21.0",
|
||||
"send": "0.19.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
|
||||
@@ -6,7 +6,8 @@ const winston = require('winston');
|
||||
*/
|
||||
const defaultLogTimestampFormat = 'YYYY-MM-DD HH:mm:ss';
|
||||
|
||||
const logfile = `${process.cwd()}/logs/planka.log`;
|
||||
const logfile =
|
||||
'LOG_FILE' in process.env ? process.env.LOG_FILE : `${process.cwd()}/logs/planka.log`;
|
||||
|
||||
/**
|
||||
* Log level for both console and file log sinks.
|
||||
|
||||
Reference in New Issue
Block a user