diff --git a/client/src/components/Card/Card.jsx b/client/src/components/Card/Card.jsx index ee1f438c..f6516fc9 100755 --- a/client/src/components/Card/Card.jsx +++ b/client/src/components/Card/Card.jsx @@ -23,6 +23,7 @@ const Card = React.memo( name, dueDate, timer, + coverUrl, isPersisted, notificationsTotal, users, @@ -63,51 +64,60 @@ const Card = React.memo( const contentNode = ( <> - {labels.length > 0 && ( - - {labels.map((label) => ( - - - ))} - - )} -
{name}
- {tasks.length > 0 && } - {(dueDate || timer || notificationsTotal > 0) && ( - - {notificationsTotal > 0 && ( - - {notificationsTotal} - - )} - {dueDate && ( - - - - )} - {timer && ( - - - - )} - - )} - {users.length > 0 && ( - - {users.map((user) => ( - - - - ))} - - )} + {coverUrl && } +
+ {labels.length > 0 && ( + + {labels.map((label) => ( + + + ))} + + )} +
{name}
+ {tasks.length > 0 && } + {(dueDate || timer || notificationsTotal > 0) && ( + + {notificationsTotal > 0 && ( + + {notificationsTotal} + + )} + {dueDate && ( + + + + )} + {timer && ( + + + + )} + + )} + {users.length > 0 && ( + + {users.map((user) => ( + + + + ))} + + )} +
); @@ -121,7 +131,7 @@ const Card = React.memo( {isPersisted ? ( <> @@ -173,6 +183,7 @@ Card.propTypes = { name: PropTypes.string.isRequired, dueDate: PropTypes.instanceOf(Date), timer: PropTypes.object, // eslint-disable-line react/forbid-prop-types + coverUrl: PropTypes.string, isPersisted: PropTypes.bool.isRequired, notificationsTotal: PropTypes.number.isRequired, /* eslint-disable react/forbid-prop-types */ @@ -196,6 +207,7 @@ Card.propTypes = { Card.defaultProps = { dueDate: undefined, timer: undefined, + coverUrl: undefined, }; export default Card; diff --git a/client/src/components/Card/Card.module.css b/client/src/components/Card/Card.module.css index 42a03fb0..9776fb63 100644 --- a/client/src/components/Card/Card.module.css +++ b/client/src/components/Card/Card.module.css @@ -68,7 +68,6 @@ .content { cursor: grab; display: block; - padding: 6px 8px 0; } .content:after { @@ -77,6 +76,15 @@ clear: both; } +.cover { + border-radius: 3px 3px 0 0; + vertical-align: middle; +} + +.details { + padding: 6px 8px 0; +} + .labels { display: block; max-width: 100%; diff --git a/client/src/components/CardModal/Attachments/Attachments.jsx b/client/src/components/CardModal/Attachments/Attachments.jsx index e3d1e656..4c95f08e 100644 --- a/client/src/components/CardModal/Attachments/Attachments.jsx +++ b/client/src/components/CardModal/Attachments/Attachments.jsx @@ -3,7 +3,18 @@ import PropTypes from 'prop-types'; import Item from './Item'; -const Attachments = React.memo(({ items, onUpdate, onDelete }) => { +const Attachments = React.memo(({ items, onUpdate, onDelete, onCoverUpdate }) => { + const handleCoverSelect = useCallback( + (id) => { + onCoverUpdate(id); + }, + [onCoverUpdate], + ); + + const handleCoverDeselect = useCallback(() => { + onCoverUpdate(null); + }, [onCoverUpdate]); + const handleUpdate = useCallback( (id, data) => { onUpdate(id, data); @@ -25,9 +36,12 @@ const Attachments = React.memo(({ items, onUpdate, onDelete }) => { key={item.id} name={item.name} url={item.url} - thumbnailUrl={item.thumbnailUrl} + coverUrl={item.coverUrl} createdAt={item.createdAt} + isCover={item.isCover} isPersisted={item.isPersisted} + onCoverSelect={() => handleCoverSelect(item.id)} + onCoverDeselect={handleCoverDeselect} onUpdate={(data) => handleUpdate(item.id, data)} onDelete={() => handleDelete(item.id)} /> @@ -40,6 +54,7 @@ Attachments.propTypes = { items: PropTypes.array.isRequired, // eslint-disable-line react/forbid-prop-types onUpdate: PropTypes.func.isRequired, onDelete: PropTypes.func.isRequired, + onCoverUpdate: PropTypes.func.isRequired, }; export default Attachments; diff --git a/client/src/components/CardModal/Attachments/Item.jsx b/client/src/components/CardModal/Attachments/Item.jsx index 9be5c25e..60293dd9 100644 --- a/client/src/components/CardModal/Attachments/Item.jsx +++ b/client/src/components/CardModal/Attachments/Item.jsx @@ -2,20 +2,44 @@ import React, { useCallback } from 'react'; import PropTypes from 'prop-types'; import classNames from 'classnames'; import { useTranslation } from 'react-i18next'; -import { Button, Icon, Loader } from 'semantic-ui-react'; +import { Button, Icon, Label, Loader } from 'semantic-ui-react'; import EditPopup from './EditPopup'; import styles from './Item.module.css'; const Item = React.memo( - ({ name, url, thumbnailUrl, createdAt, isPersisted, onUpdate, onDelete }) => { + ({ + name, + url, + coverUrl, + createdAt, + isCover, + isPersisted, + onCoverSelect, + onCoverDeselect, + onUpdate, + onDelete, + }) => { const [t] = useTranslation(); const handleClick = useCallback(() => { window.open(url, '_blank'); }, [url]); + const handleToggleCoverClick = useCallback( + (event) => { + event.stopPropagation(); + + if (isCover) { + onCoverDeselect(); + } else { + onCoverSelect(); + } + }, + [isCover, onCoverSelect, onCoverDeselect], + ); + if (!isPersisted) { return (
@@ -36,19 +60,46 @@ const Item = React.memo(
- {!thumbnailUrl && {extension || '-'}} + {coverUrl ? ( + isCover && ( +
{name} - + {t('format:longDateTime', { postProcess: 'formatDate', value: createdAt, })} + {coverUrl && ( + + + + )}
{ + onUpdate({ + coverAttachmentId: newCoverAttachmentId, + }); + }, + [onUpdate], + ); + const handleAttachmentFileSelect = useCallback( (file) => { onAttachmentCreate({ @@ -278,6 +287,7 @@ const CardModal = React.memo( items={attachments} onUpdate={onAttachmentUpdate} onDelete={onAttachmentDelete} + onCoverUpdate={handleCoverUpdate} />
diff --git a/client/src/containers/CardContainer.js b/client/src/containers/CardContainer.js index 5c0d7b15..654f9dc6 100755 --- a/client/src/containers/CardContainer.js +++ b/client/src/containers/CardContainer.js @@ -34,7 +34,7 @@ const makeMapStateToProps = () => { const allProjectMemberships = membershipsForCurrentProjectSelector(state); const allLabels = labelsForCurrentBoardSelector(state); - const { name, dueDate, timer, isPersisted } = cardByIdSelector(state, id); + const { name, dueDate, timer, coverUrl, isPersisted } = cardByIdSelector(state, id); const users = usersByCardIdSelector(state, id); const labels = labelsByCardIdSelector(state, id); @@ -47,6 +47,7 @@ const makeMapStateToProps = () => { name, dueDate, timer, + coverUrl, isPersisted, notificationsTotal, users, diff --git a/client/src/locales/en/app.js b/client/src/locales/en/app.js index d1a7d6b2..d2074906 100644 --- a/client/src/locales/en/app.js +++ b/client/src/locales/en/app.js @@ -156,7 +156,9 @@ export default { editTitle_title: 'Edit Title', editUsername_title: 'Edit Username', logOut_title: 'Log Out', + makeCover_title: 'Make Cover', remove: 'Remove', + removeCover_title: 'Remove Cover', removeFromProject: 'Remove from project', removeMember: 'Remove member', save: 'Save', diff --git a/client/src/locales/ru/app.js b/client/src/locales/ru/app.js index 1aa59ff8..725e2597 100644 --- a/client/src/locales/ru/app.js +++ b/client/src/locales/ru/app.js @@ -157,7 +157,9 @@ export default { editTitle: 'Изменить название', editUsername_title: 'Изменить имя пользователя', logOut: 'Выйти', + makeCover: 'Сделать обложкой', remove: 'Убрать', + removeCover: 'Убрать обложку', removeFromProject: 'Удалить из проекта', removeMember: 'Удалить участника', save: 'Сохранить', diff --git a/client/src/models/Attachment.js b/client/src/models/Attachment.js index a2a5b681..f1eb82fa 100644 --- a/client/src/models/Attachment.js +++ b/client/src/models/Attachment.js @@ -8,7 +8,7 @@ export default class extends Model { static fields = { id: attr(), url: attr(), - thumbnailUrl: attr(), + coverUrl: attr(), name: attr(), cardId: fk({ to: 'Card', diff --git a/client/src/models/Card.js b/client/src/models/Card.js index 91a3eabc..6ddd3005 100755 --- a/client/src/models/Card.js +++ b/client/src/models/Card.js @@ -1,4 +1,4 @@ -import { Model, attr, fk, many } from 'redux-orm'; +import { Model, attr, fk, many, oneToOne } from 'redux-orm'; import ActionTypes from '../constants/ActionTypes'; import Config from '../constants/Config'; @@ -32,6 +32,11 @@ export default class extends Model { as: 'board', relatedName: 'cards', }), + coverAttachmentId: oneToOne({ + to: 'Attachment', + as: 'coverAttachment', + relatedName: 'coveredCard', + }), users: many('User', 'cards'), labels: many('Label', 'cards'), }; diff --git a/client/src/selectors/by-id.js b/client/src/selectors/by-id.js index dede6e72..556df404 100755 --- a/client/src/selectors/by-id.js +++ b/client/src/selectors/by-id.js @@ -64,6 +64,7 @@ export const makeCardByIdSelector = () => return { ...cardModel.ref, + coverUrl: cardModel.coverAttachment && cardModel.coverAttachment.coverUrl, isPersisted: !isLocalId(id), }; }, diff --git a/client/src/selectors/current.js b/client/src/selectors/current.js index 2dbfeeb9..e59359aa 100755 --- a/client/src/selectors/current.js +++ b/client/src/selectors/current.js @@ -355,6 +355,7 @@ export const attachmentsForCurrentCardSelector = createSelector( .toRefArray() .map((attachment) => ({ ...attachment, + isCover: attachment.id === cardModel.coverAttachmentId, isPersisted: !isLocalId(attachment.id), })); }, diff --git a/server/api/controllers/attachments/create.js b/server/api/controllers/attachments/create.js index fc62afb9..90895841 100644 --- a/server/api/controllers/attachments/create.js +++ b/server/api/controllers/attachments/create.js @@ -51,6 +51,7 @@ module.exports = { const attachment = await sails.helpers.createAttachment( card, + currentUser, { dirname: file.extra.dirname, filename: file.filename, diff --git a/server/api/controllers/attachments/delete.js b/server/api/controllers/attachments/delete.js index ddd7f5dd..be2f5be4 100755 --- a/server/api/controllers/attachments/delete.js +++ b/server/api/controllers/attachments/delete.js @@ -27,7 +27,7 @@ module.exports = { .intercept('pathNotFound', () => Errors.ATTACHMENT_NOT_FOUND); let { attachment } = attachmentToProjectPath; - const { board, project } = attachmentToProjectPath; + const { card, board, project } = attachmentToProjectPath; const isUserMemberForProject = await sails.helpers.isUserMemberForProject( project.id, @@ -38,7 +38,7 @@ module.exports = { throw Errors.ATTACHMENT_NOT_FOUND; // Forbidden } - attachment = await sails.helpers.deleteAttachment(attachment, board, this.req); + attachment = await sails.helpers.deleteAttachment(attachment, card, board, this.req); if (!attachment) { throw Errors.ATTACHMENT_NOT_FOUND; diff --git a/server/api/controllers/cards/update.js b/server/api/controllers/cards/update.js index 37a63c3e..8f2c718a 100755 --- a/server/api/controllers/cards/update.js +++ b/server/api/controllers/cards/update.js @@ -20,6 +20,11 @@ module.exports = { type: 'string', regex: /^[0-9]+$/, }, + coverAttachmentId: { + type: 'string', + regex: /^[0-9]+$/, + allowNull: true, + }, position: { type: 'number', }, @@ -91,6 +96,7 @@ module.exports = { } const values = _.pick(inputs, [ + 'coverAttachmentId', 'position', 'name', 'description', diff --git a/server/api/helpers/create-attachment-receiver.js b/server/api/helpers/create-attachment-receiver.js index dcc65d4a..2fcc9156 100644 --- a/server/api/helpers/create-attachment-receiver.js +++ b/server/api/helpers/create-attachment-receiver.js @@ -30,28 +30,44 @@ module.exports = { Buffer.concat(parts.map((part) => (util.isBuffer(part) ? part : Buffer.from(part)))), ); - let thumbnailBuffer; - - try { - thumbnailBuffer = await sharp(buffer).resize(240, 240).jpeg().toBuffer(); - } catch (error) {} // eslint-disable-line no-empty - try { const dirname = uuid(); - const dirPath = path.join(sails.config.custom.attachmentsPath, dirname); - fs.mkdirSync(dirPath); + const rootPath = path.join(sails.config.custom.attachmentsPath, dirname); + fs.mkdirSync(rootPath); - if (thumbnailBuffer) { - await writeFile(path.join(dirPath, '240.jpg'), thumbnailBuffer); + await writeFile(path.join(rootPath, file.filename), buffer); + + const image = sharp(buffer); + let imageMetadata; + + try { + imageMetadata = await image.metadata(); + } catch (error) {} // eslint-disable-line no-empty + + if (imageMetadata) { + let cover256Buffer; + if (imageMetadata.height > imageMetadata.width) { + cover256Buffer = await image.resize(256, 320).jpeg().toBuffer(); + } else { + cover256Buffer = await image + .resize({ + width: 256, + }) + .jpeg() + .toBuffer(); + } + + const thumbnailsPath = path.join(rootPath, 'thumbnails'); + fs.mkdirSync(thumbnailsPath); + + await writeFile(path.join(thumbnailsPath, 'cover-256.jpg'), cover256Buffer); } - await writeFile(path.join(dirPath, file.filename), buffer); - // eslint-disable-next-line no-param-reassign file.extra = { dirname, - isImage: !!thumbnailBuffer, + isImage: !!imageMetadata, }; return done(); diff --git a/server/api/helpers/create-attachment.js b/server/api/helpers/create-attachment.js index 3ea9eeb4..1ed9b2ba 100644 --- a/server/api/helpers/create-attachment.js +++ b/server/api/helpers/create-attachment.js @@ -4,6 +4,10 @@ module.exports = { type: 'ref', required: true, }, + user: { + type: 'ref', + required: true, + }, values: { type: 'json', required: true, @@ -17,6 +21,7 @@ module.exports = { const attachment = await Attachment.create({ ...inputs.values, cardId: inputs.card.id, + userId: inputs.user.id, }).fetch(); sails.sockets.broadcast( @@ -28,6 +33,16 @@ module.exports = { inputs.request, ); + if (!inputs.card.coverAttachmentId && attachment.isImage) { + await sails.helpers.updateCard.with({ + record: inputs.card, + values: { + coverAttachmentId: attachment.id, + }, + request: inputs.request, + }); + } + return exits.success(attachment); }, }; diff --git a/server/api/helpers/create-avatar-receiver.js b/server/api/helpers/create-avatar-receiver.js index 66606b58..1f84fa4a 100644 --- a/server/api/helpers/create-avatar-receiver.js +++ b/server/api/helpers/create-avatar-receiver.js @@ -2,10 +2,11 @@ const fs = require('fs'); const path = require('path'); const util = require('util'); const stream = require('stream'); +const streamToArray = require('stream-to-array'); const { v4: uuid } = require('uuid'); const sharp = require('sharp'); -const pipeline = util.promisify(stream.pipeline); +const writeFile = util.promisify(fs.writeFile); module.exports = { sync: true, @@ -25,18 +26,20 @@ module.exports = { } firstFileHandled = true; - const resize = sharp().resize(100, 100).jpeg(); - const passThrought = new stream.PassThrough(); + const buffer = await streamToArray(file).then((parts) => + Buffer.concat(parts.map((part) => (util.isBuffer(part) ? part : Buffer.from(part)))), + ); try { - await pipeline(file, resize, passThrought); + const square100Buffer = await sharp(buffer).resize(100, 100).jpeg().toBuffer(); const dirname = uuid(); - const dirPath = path.join(sails.config.custom.userAvatarsPath, dirname); - fs.mkdirSync(dirPath); + const rootPath = path.join(sails.config.custom.userAvatarsPath, dirname); + fs.mkdirSync(rootPath); - await pipeline(passThrought, fs.createWriteStream(path.join(dirPath, '100.jpg'))); + await writeFile(path.join(rootPath, 'original.jpg'), buffer); + await writeFile(path.join(rootPath, 'square-100.jpg'), square100Buffer); // eslint-disable-next-line no-param-reassign file.extra = { diff --git a/server/api/helpers/delete-attachment.js b/server/api/helpers/delete-attachment.js index 35a9bf3e..281ef96e 100644 --- a/server/api/helpers/delete-attachment.js +++ b/server/api/helpers/delete-attachment.js @@ -7,6 +7,10 @@ module.exports = { type: 'ref', required: true, }, + card: { + type: 'ref', + required: true, + }, board: { type: 'ref', required: true, @@ -17,6 +21,16 @@ module.exports = { }, async fn(inputs, exits) { + if (inputs.record.id === inputs.card.coverAttachmentId) { + await sails.helpers.updateCard.with({ + record: inputs.card, + values: { + coverAttachmentId: null, + }, + request: inputs.request, + }); + } + const attachment = await Attachment.archiveOne(inputs.record.id); if (attachment) { diff --git a/server/api/helpers/update-card.js b/server/api/helpers/update-card.js index f82ce765..ceb8ef72 100644 --- a/server/api/helpers/update-card.js +++ b/server/api/helpers/update-card.js @@ -15,35 +15,43 @@ module.exports = { }, list: { type: 'ref', - required: true, }, user: { type: 'ref', - required: true, }, request: { type: 'ref', }, }, + exits: { + invalidParams: {}, + }, + async fn(inputs, exits) { const { isSubscribed, ...values } = inputs.values; - let listId; if (inputs.toList) { - listId = inputs.toList.id; - - if (listId !== inputs.list.id) { - values.listId = listId; - } else { - delete inputs.toList; // eslint-disable-line no-param-reassign + if (!inputs.list || !inputs.user) { + throw 'invalidParams'; } - } else { - listId = inputs.list.id; + + if (inputs.toList.id === inputs.list.id) { + delete inputs.toList; // eslint-disable-line no-param-reassign + } else { + values.listId = inputs.toList.id; + } + } + + if (!_.isUndefined(isSubscribed) && !inputs.user) { + throw 'invalidParams'; } if (!_.isUndefined(values.position)) { - const cards = await sails.helpers.getCardsForList(listId, inputs.record.id); + const cards = await sails.helpers.getCardsForList( + values.listId || inputs.record.listId, + inputs.record.id, + ); const { position, repositions } = sails.helpers.insertToPositionables(values.position, cards); diff --git a/server/api/models/Attachment.js b/server/api/models/Attachment.js index 7015d672..b7f39677 100644 --- a/server/api/models/Attachment.js +++ b/server/api/models/Attachment.js @@ -42,14 +42,19 @@ module.exports = { required: true, columnName: 'card_id', }, + userId: { + model: 'User', + required: true, + columnName: 'user_id', + }, }, customToJSON() { return { ..._.omit(this, ['dirname', 'filename', 'isImage']), url: `${sails.config.custom.attachmentsUrl}/${this.dirname}/${this.filename}`, - thumbnailUrl: this.isImage - ? `${sails.config.custom.attachmentsUrl}/${this.dirname}/240.jpg` + coverUrl: this.isImage + ? `${sails.config.custom.attachmentsUrl}/${this.dirname}/thumbnails/cover-256.jpg` : null, }; }, diff --git a/server/api/models/Card.js b/server/api/models/Card.js index c60c8bb4..6dcd7aa6 100755 --- a/server/api/models/Card.js +++ b/server/api/models/Card.js @@ -50,6 +50,10 @@ module.exports = { required: true, columnName: 'board_id', }, + coverAttachmentId: { + model: 'Attachment', + columnName: 'cover_attachment_id', + }, subscriptionUsers: { collection: 'User', via: 'cardId', diff --git a/server/api/models/User.js b/server/api/models/User.js index f9955eae..44104751 100755 --- a/server/api/models/User.js +++ b/server/api/models/User.js @@ -94,7 +94,8 @@ module.exports = { return { ..._.omit(this, ['password', 'avatarDirname']), avatarUrl: - this.avatarDirname && `${sails.config.custom.userAvatarsUrl}/${this.avatarDirname}/100.jpg`, + this.avatarDirname && + `${sails.config.custom.userAvatarsUrl}/${this.avatarDirname}/square-100.jpg`, }; }, }; diff --git a/server/db/migrations/20180722003614_create_card_table.js b/server/db/migrations/20180722003614_create_card_table.js index b9085ca1..56da488e 100755 --- a/server/db/migrations/20180722003614_create_card_table.js +++ b/server/db/migrations/20180722003614_create_card_table.js @@ -6,6 +6,7 @@ module.exports.up = (knex) => table.bigInteger('list_id').notNullable(); table.bigInteger('board_id').notNullable(); + table.bigInteger('cover_attachment_id'); table.specificType('position', 'double precision').notNullable(); table.text('name').notNullable(); diff --git a/server/db/migrations/20180722006688_create_attachment_table.js b/server/db/migrations/20180722006688_create_attachment_table.js index 47d85910..301ca0b9 100755 --- a/server/db/migrations/20180722006688_create_attachment_table.js +++ b/server/db/migrations/20180722006688_create_attachment_table.js @@ -5,6 +5,7 @@ module.exports.up = (knex) => table.bigInteger('id').primary().defaultTo(knex.raw('next_id()')); table.bigInteger('card_id').notNullable(); + table.bigInteger('user_id').notNullable(); table.text('dirname').notNullable(); table.text('filename').notNullable();