diff --git a/client/package-lock.json b/client/package-lock.json index 5c956949..36b6938f 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -2198,6 +2198,11 @@ "resolved": "https://registry.npmjs.org/atob/-/atob-2.1.2.tgz", "integrity": "sha512-Wm6ukoaOGJi/73p/cl2GvLjTI5JM1k/O14isD73YML8StrH/7/lRFgmg8nICZgD3bZZvjwCGxtMOD3wWNAu8cg==" }, + "attr-accept": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/attr-accept/-/attr-accept-2.1.0.tgz", + "integrity": "sha512-sLzVM3zCCmmDtDNhI0i96k6PUztkotSOXqE4kDGQt/6iDi5M+H0srjeF+QC6jN581l4X/Zq3Zu/tgcErEssavg==" + }, "autoprefixer": { "version": "9.7.5", "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-9.7.5.tgz", @@ -5566,6 +5571,14 @@ "schema-utils": "^2.5.0" } }, + "file-selector": { + "version": "0.1.12", + "resolved": "https://registry.npmjs.org/file-selector/-/file-selector-0.1.12.tgz", + "integrity": "sha512-Kx7RTzxyQipHuiqyZGf+Nz4vY9R1XGxuQl/hLoJwq+J4avk/9wxxgZyHKtbyIPJmbD4A66DWGYfyykWNpcYutQ==", + "requires": { + "tslib": "^1.9.0" + } + }, "file-uri-to-path": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", @@ -11207,6 +11220,16 @@ "scheduler": "^0.19.1" } }, + "react-dropzone": { + "version": "11.0.1", + "resolved": "https://registry.npmjs.org/react-dropzone/-/react-dropzone-11.0.1.tgz", + "integrity": "sha512-x/6wqRHaR8jsrNiu/boVMIPYuoxb83Vyfv77hO7/3ZRn8Pr+KH5onsCsB8MLBa3zdJl410C5FXPUINbu16XIzw==", + "requires": { + "attr-accept": "^2.0.0", + "file-selector": "^0.1.12", + "prop-types": "^15.7.2" + } + }, "react-error-overlay": { "version": "6.0.7", "resolved": "https://registry.npmjs.org/react-error-overlay/-/react-error-overlay-6.0.7.tgz", diff --git a/client/package.json b/client/package.json index 23d9de50..c8bac967 100755 --- a/client/package.json +++ b/client/package.json @@ -60,6 +60,7 @@ "react-beautiful-dnd": "^13.0.0", "react-datepicker": "^2.14.1", "react-dom": "^16.13.1", + "react-dropzone": "^11.0.1", "react-i18next": "^11.3.4", "react-input-mask": "^2.0.4", "react-markdown": "^4.3.1", diff --git a/client/public/favicon.ico b/client/public/favicon.ico index 366beb9d..a04182fa 100644 Binary files a/client/public/favicon.ico and b/client/public/favicon.ico differ diff --git a/client/public/logo192.png b/client/public/logo192.png old mode 100644 new mode 100755 index 266b2683..078f229d Binary files a/client/public/logo192.png and b/client/public/logo192.png differ diff --git a/client/public/logo512.png b/client/public/logo512.png old mode 100644 new mode 100755 index 9193a2a3..693046e7 Binary files a/client/public/logo512.png and b/client/public/logo512.png differ diff --git a/client/src/components/CardModal/AddAttachment.jsx b/client/src/components/CardModal/AddAttachment.jsx new file mode 100644 index 00000000..b71926da --- /dev/null +++ b/client/src/components/CardModal/AddAttachment.jsx @@ -0,0 +1,23 @@ +import React, { useCallback } from 'react'; +import PropTypes from 'prop-types'; +import { FilePicker } from '../../lib/custom-ui'; + +const AddAttachment = React.memo(({ children, onCreate }) => { + const handleFileSelect = useCallback( + (file) => { + onCreate({ + file, + }); + }, + [onCreate], + ); + + return {children}; +}); + +AddAttachment.propTypes = { + children: PropTypes.element.isRequired, + onCreate: PropTypes.func.isRequired, +}; + +export default AddAttachment; diff --git a/client/src/components/CardModal/AddAttachmentZone/AddAttachmentZone.jsx b/client/src/components/CardModal/AddAttachmentZone/AddAttachmentZone.jsx new file mode 100644 index 00000000..de469268 --- /dev/null +++ b/client/src/components/CardModal/AddAttachmentZone/AddAttachmentZone.jsx @@ -0,0 +1,108 @@ +import React, { useCallback, useEffect } from 'react'; +import PropTypes from 'prop-types'; +import { useDropzone } from 'react-dropzone'; +import { useTranslation } from 'react-i18next'; +import { closePopup } from '../../../lib/popup'; + +import { useModal } from '../../../hooks'; +import AddTextFileModal from './AddTextFileModal'; + +import styles from './AddAttachmentZone.module.css'; + +const AddAttachmentZone = React.memo(({ children, onCreate }) => { + const [t] = useTranslation(); + const [modal, openModal, handleModalClose] = useModal(); + + const submit = useCallback( + (file) => { + onCreate({ + file, + }); + }, + [onCreate], + ); + + const handleDropAccepted = useCallback( + (files) => { + submit(files[0]); + }, + [submit], + ); + + const { getRootProps, getInputProps, isDragActive } = useDropzone({ + multiple: false, + noClick: true, + noKeyboard: true, + onDropAccepted: handleDropAccepted, + }); + + const handleFileCreate = useCallback( + (file) => { + submit(file); + }, + [submit], + ); + + useEffect(() => { + const handlePaste = (event) => { + const item = event.clipboardData && 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, + }); + }); + }; + + window.addEventListener('paste', handlePaste); + + return () => { + window.removeEventListener('paste', handlePaste); + }; + }, [openModal, submit]); + + return ( + <> + {/* eslint-disable-next-line react/jsx-props-no-spreading */} +
+ {isDragActive &&
{t('common.dropFileToUpload')}
} + {children} +
+ {modal && ( + + )} + {/* eslint-disable-next-line react/jsx-props-no-spreading */} + + + ); +}); + +AddAttachmentZone.propTypes = { + children: PropTypes.element.isRequired, + onCreate: PropTypes.func.isRequired, +}; + +export default AddAttachmentZone; diff --git a/client/src/components/CardModal/AddAttachmentZone/AddAttachmentZone.module.css b/client/src/components/CardModal/AddAttachmentZone/AddAttachmentZone.module.css new file mode 100644 index 00000000..6b995385 --- /dev/null +++ b/client/src/components/CardModal/AddAttachmentZone/AddAttachmentZone.module.css @@ -0,0 +1,13 @@ +.dropzone { + background: white; + font-size: 20px; + font-weight: 700; + height: 100%; + line-height: 30px; + opacity: 0.7; + padding: 200px 50px; + position: absolute; + text-align: center; + width: 100%; + z-index: 1; +} diff --git a/client/src/components/CardModal/AddAttachmentZone/AddTextFileModal.jsx b/client/src/components/CardModal/AddAttachmentZone/AddTextFileModal.jsx new file mode 100644 index 00000000..b7aaacb6 --- /dev/null +++ b/client/src/components/CardModal/AddAttachmentZone/AddTextFileModal.jsx @@ -0,0 +1,83 @@ +import React, { useCallback, useEffect, useRef } from 'react'; +import PropTypes from 'prop-types'; +import { useTranslation } from 'react-i18next'; +import { Button, Form, Header, Modal } from 'semantic-ui-react'; +import { Input } from '../../../lib/custom-ui'; + +import { useForm } from '../../../hooks'; + +import styles from './AddTextFileModal.module.css'; + +const AddTextFileModal = React.memo(({ content, onCreate, onClose }) => { + const [t] = useTranslation(); + + const [data, handleFieldChange] = useForm(() => ({ + name: '', + })); + + const nameField = useRef(null); + + const handleSubmit = useCallback(() => { + const cleanData = { + ...data, + name: data.name.trim(), + }; + + if (!cleanData.name) { + nameField.current.select(); + return; + } + + const file = new File([content], `${cleanData.name}.txt`, { + type: 'plain/text', + }); + + onCreate(file); + onClose(); + }, [content, onCreate, onClose, data]); + + useEffect(() => { + nameField.current.select(); + }, []); + + return ( + + +
+ {t('common.createTextFile', { + context: 'title', + })} +
+

{t('common.enterFilename')}

+
+ +
- - - )} - {labels.length > 0 && ( -
-
- {t('common.labels', { - context: 'title', - })} -
- {labels.map((label) => ( - - - - - ))} - - - -
- )} - {dueDate && ( -
-
- {t('common.dueDate', { - context: 'title', - })} + + +
- - - - - -
- )} - {timer && ( -
-
- {t('common.timer', { - context: 'title', - })} -
- - - - - -
- )} - - )} -
-
- -
{t('common.description')}
- - {description ? ( - - ) : ( - )} - -
-
-
-
- -
{t('common.tasks')}
- -
-
- {attachments.length > 0 && ( + {labels.length > 0 && ( +
+
+ {t('common.labels', { + context: 'title', + })} +
+ {labels.map((label) => ( + + + + + ))} + + + +
+ )} + {dueDate && ( +
+
+ {t('common.dueDate', { + context: 'title', + })} +
+ + + + + +
+ )} + {timer && ( +
+
+ {t('common.timer', { + context: 'title', + })} +
+ + + + + +
+ )} + + )}
- -
{t('common.attachments')}
- +
{t('common.description')}
+ + {description ? ( + + ) : ( + + )} + +
+
+
+
+ +
{t('common.tasks')}
+
- )} - - - -
- {t('action.addToCard')} - - + + + + + + + + + + + + + +
+
+ {t('common.actions')} + - - - - - - - - - - - - - -
-
- {t('common.actions')} - - - - -
-
- - + + + + + + + +
); }, diff --git a/client/src/components/Login/Login.jsx b/client/src/components/Login/Login.jsx index bd787afa..acff2dd8 100755 --- a/client/src/components/Login/Login.jsx +++ b/client/src/components/Login/Login.jsx @@ -1,8 +1,8 @@ +import isEmail from 'validator/lib/isEmail'; import React, { useCallback, useEffect, useMemo, useRef } from 'react'; import PropTypes from 'prop-types'; import classNames from 'classnames'; import { useTranslation } from 'react-i18next'; -import isEmail from 'validator/lib/isEmail'; import { Form, Grid, Header, Message } from 'semantic-ui-react'; import { useDidUpdate, usePrevious, useToggle } from '../../lib/hooks'; import { Input } from '../../lib/custom-ui'; diff --git a/client/src/hooks/index.js b/client/src/hooks/index.js index b30bb981..77aee264 100644 --- a/client/src/hooks/index.js +++ b/client/src/hooks/index.js @@ -1,6 +1,7 @@ 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, useClosableForm }; +export { useField, useForm, useSteps, useModal, useClosableForm }; diff --git a/client/src/hooks/use-modal.js b/client/src/hooks/use-modal.js new file mode 100644 index 00000000..08067034 --- /dev/null +++ b/client/src/hooks/use-modal.js @@ -0,0 +1,15 @@ +import { useCallback, useState } from 'react'; + +export default (initialParams) => { + const [modal, setModal] = useState(() => initialParams); + + const open = useCallback((params) => { + setModal(params); + }, []); + + const handleClose = useCallback(() => { + setModal(null); + }, []); + + return [modal, open, handleClose]; +}; diff --git a/client/src/hooks/use-steps.js b/client/src/hooks/use-steps.js index b0b2505c..a1b60562 100644 --- a/client/src/hooks/use-steps.js +++ b/client/src/hooks/use-steps.js @@ -14,7 +14,7 @@ const createStep = (type, params = {}) => { export default (initialType, initialParams) => { const [step, setStep] = useState(() => createStep(initialType, initialParams)); - const openStep = useCallback((type, params) => { + const open = useCallback((type, params) => { setStep(createStep(type, params)); }, []); @@ -22,5 +22,5 @@ export default (initialType, initialParams) => { setStep(null); }, []); - return [step, openStep, handleBack]; + return [step, open, handleBack]; }; diff --git a/client/src/lib/custom-ui/index.css b/client/src/lib/custom-ui/index.css index e42ac12d..e50747be 100644 --- a/client/src/lib/custom-ui/index.css +++ b/client/src/lib/custom-ui/index.css @@ -34814,11 +34814,11 @@ select.ui.dropdown { overflow: hidden; } -.scrolling.dimmable > .dimmer { +/* .scrolling.dimmable > .dimmer { -webkit-box-pack: start; -ms-flex-pack: start; justify-content: flex-start; -} +} */ .scrolling.dimmable.dimmed > .dimmer { overflow: auto; diff --git a/client/src/locales/en/app.js b/client/src/locales/en/app.js index d2074906..eb2d68be 100644 --- a/client/src/locales/en/app.js +++ b/client/src/locales/en/app.js @@ -42,6 +42,7 @@ export default { createLabel_title: 'Create Label', createNewOneOrSelectExistingOne: 'Create a new one or select
an existing one', createProject_title: 'Create Project', + createTextFile_title: 'Create Text File', currentPassword: 'Current password', date: 'Date', dueDate: 'Due date', @@ -55,6 +56,7 @@ export default { deleteTask_title: 'Delete Task', deleteUser_title: 'Delete User', description: 'Description', + dropFileToUpload: 'Drop file to upload', editAttachment_title: 'Edit Attachment', editAvatar_title: 'Edit Avatar', editBoard_title: 'Edit Board', @@ -69,6 +71,7 @@ export default { emailAlreadyInUse: 'E-mail already in use', enterCardTitle: 'Enter card title...', enterDescription: 'Enter description...', + enterFilename: 'Enter filename', enterListTitle: 'Enter list title...', enterProjectTitle: 'Enter project title', enterTaskDescription: 'Enter task description...', @@ -129,6 +132,7 @@ export default { addToCard: 'Add to card', addUser: 'Add user', createBoard: 'Create board', + createFile: 'Create file', createLabel: 'Create label', createNewLabel: 'Create new label', createProject: 'Create project', diff --git a/client/src/locales/ru/app.js b/client/src/locales/ru/app.js index 725e2597..8219f827 100644 --- a/client/src/locales/ru/app.js +++ b/client/src/locales/ru/app.js @@ -46,6 +46,7 @@ export default { createLabel: 'Создание метки', createNewOneOrSelectExistingOne: 'Создайте новую или выберите
уже существующую', createProject: 'Создание проекта', + createTextFile: 'Создание текстового файла', currentPassword: 'Текущий пароль', date: 'Дата', dueDate: 'Срок', @@ -59,6 +60,7 @@ export default { deleteTask: 'Удаление задачи', deleteUser: 'Удаление пользователя', description: 'Описание', + dropFileToUpload: 'Перетяните файл, чтобы загрузить', editAttachment: 'Изменение вложения', editAvatar: 'Изменение аватара', editBoard: 'Изменение доски', @@ -73,6 +75,7 @@ export default { emailAlreadyInUse: 'E-mail уже занят', enterCardTitle: 'Введите заголовок для этой карточки...', enterDescription: 'Введите описание...', + enterFilename: 'Введите название файла', enterListTitle: 'Введите заголовок списка...', enterProjectTitle: 'Введите название проекта', enterTaskDescription: 'Введите описание задачи...', @@ -133,6 +136,7 @@ export default { addToCard: 'Добавить на карточку', addUser: 'Добавить пользователя', createBoard: 'Создать доску', + createFile: 'Создать файл', createLabel: 'Создать метку', createNewLabel: 'Создать новую метку', createProject: 'Создать проект', diff --git a/server/api/controllers/attachments/create.js b/server/api/controllers/attachments/create.js index 6ab255a0..9f3d77ad 100644 --- a/server/api/controllers/attachments/create.js +++ b/server/api/controllers/attachments/create.js @@ -60,7 +60,7 @@ module.exports = { dirname: file.extra.dirname, filename: file.filename, isImage: file.extra.isImage, - name: file.filename, + name: file.extra.name, }, inputs.requestId, this.req, diff --git a/server/api/helpers/create-attachment-receiver.js b/server/api/helpers/create-attachment-receiver.js index 2fcc9156..ecdb4de8 100644 --- a/server/api/helpers/create-attachment-receiver.js +++ b/server/api/helpers/create-attachment-receiver.js @@ -3,6 +3,7 @@ const path = require('path'); const util = require('util'); const stream = require('stream'); const streamToArray = require('stream-to-array'); +const filenamify = require('filenamify'); const { v4: uuid } = require('uuid'); const sharp = require('sharp'); @@ -33,10 +34,13 @@ module.exports = { try { const dirname = uuid(); + // FIXME: https://github.com/sindresorhus/filenamify/issues/13 + const filename = filenamify(file.filename); + const rootPath = path.join(sails.config.custom.attachmentsPath, dirname); fs.mkdirSync(rootPath); - await writeFile(path.join(rootPath, file.filename), buffer); + await writeFile(path.join(rootPath, filename), buffer); const image = sharp(buffer); let imageMetadata; @@ -68,8 +72,12 @@ module.exports = { file.extra = { dirname, isImage: !!imageMetadata, + name: file.filename, }; + // eslint-disable-next-line no-param-reassign + file.filename = filename; + return done(); } catch (error) { return done(error); diff --git a/server/package-lock.json b/server/package-lock.json index fdb80057..8c402120 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -2386,6 +2386,21 @@ "resolved": "https://registry.npmjs.org/filename-regex/-/filename-regex-2.0.1.tgz", "integrity": "sha1-wcS5vuPglyXdsQa3XB4wH+LxiyY=" }, + "filename-reserved-regex": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/filename-reserved-regex/-/filename-reserved-regex-2.0.0.tgz", + "integrity": "sha1-q/c9+rc10EVECr/qLZHzieu/oik=" + }, + "filenamify": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/filenamify/-/filenamify-4.1.0.tgz", + "integrity": "sha512-KQV/uJDI9VQgN7sHH1Zbk6+42cD6mnQ2HONzkXUfPJ+K2FC8GZ1dpewbbHw0Sz8Tf5k3EVdHVayM4DoAwWlmtg==", + "requires": { + "filename-reserved-regex": "^2.0.0", + "strip-outer": "^1.0.1", + "trim-repeated": "^1.0.0" + } + }, "fill-range": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-4.0.0.tgz", @@ -6450,6 +6465,14 @@ "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", "integrity": "sha1-PFMZQukIwml8DsNEhYwobHygpgo=" }, + "strip-outer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/strip-outer/-/strip-outer-1.0.1.tgz", + "integrity": "sha512-k55yxKHwaXnpYGsOzg4Vl8+tDrWylxDEpknGjhTiZB8dFRU5rTo9CAzeycivxV3s+zlTKwrs6WxMxR95n26kwg==", + "requires": { + "escape-string-regexp": "^1.0.2" + } + }, "supports-color": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-2.0.0.tgz", @@ -6674,6 +6697,14 @@ } } }, + "trim-repeated": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/trim-repeated/-/trim-repeated-1.0.0.tgz", + "integrity": "sha1-42RqLqTokTEr9+rObPsFOAvAHCE=", + "requires": { + "escape-string-regexp": "^1.0.2" + } + }, "tslib": { "version": "1.11.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.11.1.tgz", diff --git a/server/package.json b/server/package.json index c2924540..9e778d50 100644 --- a/server/package.json +++ b/server/package.json @@ -42,6 +42,7 @@ "bcrypt": "^4.0.1", "dotenv": "^8.2.0", "dotenv-cli": "^3.1.0", + "filenamify": "^4.1.0", "jsonwebtoken": "^8.5.1", "knex": "^0.20.13", "lodash": "^4.17.15",