mirror of
https://github.com/plankanban/planka.git
synced 2025-07-18 20:59:44 +02:00
Add file attachments
This commit is contained in:
parent
202abacaec
commit
6a68ec9c1e
103 changed files with 1847 additions and 305 deletions
|
@ -36,7 +36,8 @@ COPY --from=client-builder /app/build/index.html views
|
|||
|
||||
ENV BASE_URL DATABASE_URL
|
||||
|
||||
VOLUME /app/public/uploads
|
||||
VOLUME /app/public/user-avatars
|
||||
VOLUME /app/public/attachments
|
||||
|
||||
EXPOSE 1337
|
||||
|
||||
|
|
|
@ -82,11 +82,11 @@ Demo user: demo@demo.demo demo
|
|||
|
||||
## Roadmap
|
||||
|
||||
- [x] File attachments
|
||||
- [ ] Member permissions
|
||||
- [ ] Fetch last data after reconnection
|
||||
- [ ] File attachments
|
||||
- [ ] Custom fields
|
||||
- [ ] Public boards
|
||||
- [ ] Member permissions
|
||||
- [ ] Automatic actions
|
||||
|
||||
## License
|
||||
|
|
117
client/src/actions/attachment.js
Normal file
117
client/src/actions/attachment.js
Normal file
|
@ -0,0 +1,117 @@
|
|||
import ActionTypes from '../constants/ActionTypes';
|
||||
|
||||
/* Actions */
|
||||
|
||||
export const createAttachment = (attachment) => ({
|
||||
type: ActionTypes.ATTACHMENT_CREATE,
|
||||
payload: {
|
||||
attachment,
|
||||
},
|
||||
});
|
||||
|
||||
export const updateAttachment = (id, data) => ({
|
||||
type: ActionTypes.ATTACHMENT_UPDATE,
|
||||
payload: {
|
||||
id,
|
||||
data,
|
||||
},
|
||||
});
|
||||
|
||||
export const deleteAttachment = (id) => ({
|
||||
type: ActionTypes.ATTACHMENT_DELETE,
|
||||
payload: {
|
||||
id,
|
||||
},
|
||||
});
|
||||
|
||||
/* Events */
|
||||
|
||||
export const createAttachmentRequested = (localId, data) => ({
|
||||
type: ActionTypes.ATTACHMENT_CREATE_REQUESTED,
|
||||
payload: {
|
||||
localId,
|
||||
data,
|
||||
},
|
||||
});
|
||||
|
||||
export const createAttachmentSucceeded = (localId, attachment) => ({
|
||||
type: ActionTypes.ATTACHMENT_CREATE_SUCCEEDED,
|
||||
payload: {
|
||||
localId,
|
||||
attachment,
|
||||
},
|
||||
});
|
||||
|
||||
export const createAttachmentFailed = (localId, error) => ({
|
||||
type: ActionTypes.ATTACHMENT_CREATE_FAILED,
|
||||
payload: {
|
||||
localId,
|
||||
error,
|
||||
},
|
||||
});
|
||||
|
||||
export const createAttachmentReceived = (attachment) => ({
|
||||
type: ActionTypes.ATTACHMENT_CREATE_RECEIVED,
|
||||
payload: {
|
||||
attachment,
|
||||
},
|
||||
});
|
||||
|
||||
export const updateAttachmentRequested = (id, data) => ({
|
||||
type: ActionTypes.ATTACHMENT_UPDATE_REQUESTED,
|
||||
payload: {
|
||||
id,
|
||||
data,
|
||||
},
|
||||
});
|
||||
|
||||
export const updateAttachmentSucceeded = (attachment) => ({
|
||||
type: ActionTypes.ATTACHMENT_UPDATE_SUCCEEDED,
|
||||
payload: {
|
||||
attachment,
|
||||
},
|
||||
});
|
||||
|
||||
export const updateAttachmentFailed = (id, error) => ({
|
||||
type: ActionTypes.ATTACHMENT_UPDATE_FAILED,
|
||||
payload: {
|
||||
id,
|
||||
error,
|
||||
},
|
||||
});
|
||||
|
||||
export const updateAttachmentReceived = (attachment) => ({
|
||||
type: ActionTypes.ATTACHMENT_UPDATE_RECEIVED,
|
||||
payload: {
|
||||
attachment,
|
||||
},
|
||||
});
|
||||
|
||||
export const deleteAttachmentRequested = (id) => ({
|
||||
type: ActionTypes.ATTACHMENT_DELETE_REQUESTED,
|
||||
payload: {
|
||||
id,
|
||||
},
|
||||
});
|
||||
|
||||
export const deleteAttachmentSucceeded = (attachment) => ({
|
||||
type: ActionTypes.ATTACHMENT_DELETE_SUCCEEDED,
|
||||
payload: {
|
||||
attachment,
|
||||
},
|
||||
});
|
||||
|
||||
export const deleteAttachmentFailed = (id, error) => ({
|
||||
type: ActionTypes.ATTACHMENT_DELETE_FAILED,
|
||||
payload: {
|
||||
id,
|
||||
error,
|
||||
},
|
||||
});
|
||||
|
||||
export const deleteAttachmentReceived = (attachment) => ({
|
||||
type: ActionTypes.ATTACHMENT_DELETE_RECEIVED,
|
||||
payload: {
|
||||
attachment,
|
||||
},
|
||||
});
|
|
@ -76,6 +76,7 @@ export const fetchBoardSucceeded = (
|
|||
cardMemberships,
|
||||
cardLabels,
|
||||
tasks,
|
||||
attachments,
|
||||
) => ({
|
||||
type: ActionTypes.BOARD_FETCH_SUCCEEDED,
|
||||
payload: {
|
||||
|
@ -86,6 +87,7 @@ export const fetchBoardSucceeded = (
|
|||
cardMemberships,
|
||||
cardLabels,
|
||||
tasks,
|
||||
attachments,
|
||||
},
|
||||
});
|
||||
|
||||
|
|
23
client/src/actions/entry/attachment.js
Normal file
23
client/src/actions/entry/attachment.js
Normal file
|
@ -0,0 +1,23 @@
|
|||
import EntryActionTypes from '../../constants/EntryActionTypes';
|
||||
|
||||
export const createAttachmentInCurrentCard = (data) => ({
|
||||
type: EntryActionTypes.ATTACHMENT_IN_CURRENT_CARD_CREATE,
|
||||
payload: {
|
||||
data,
|
||||
},
|
||||
});
|
||||
|
||||
export const updateAttachment = (id, data) => ({
|
||||
type: EntryActionTypes.ATTACHMENT_UPDATE,
|
||||
payload: {
|
||||
id,
|
||||
data,
|
||||
},
|
||||
});
|
||||
|
||||
export const deleteAttachment = (id) => ({
|
||||
type: EntryActionTypes.ATTACHMENT_DELETE,
|
||||
payload: {
|
||||
id,
|
||||
},
|
||||
});
|
|
@ -8,6 +8,7 @@ export * from './list';
|
|||
export * from './label';
|
||||
export * from './card';
|
||||
export * from './task';
|
||||
export * from './attachment';
|
||||
export * from './actions';
|
||||
export * from './comment-action';
|
||||
export * from './notification';
|
||||
|
|
|
@ -63,10 +63,10 @@ export const clearCurrentUserUsernameUpdateError = () => ({
|
|||
payload: {},
|
||||
});
|
||||
|
||||
export const uploadCurrentUserAvatar = (file) => ({
|
||||
type: EntryActionTypes.CURRENT_USER_AVATAR_UPLOAD,
|
||||
export const updateCurrentUserAvatar = (data) => ({
|
||||
type: EntryActionTypes.CURRENT_USER_AVATAR_UPDATE,
|
||||
payload: {
|
||||
file,
|
||||
data,
|
||||
},
|
||||
});
|
||||
|
||||
|
|
|
@ -14,6 +14,7 @@ export * from './card';
|
|||
export * from './card-membership';
|
||||
export * from './card-label';
|
||||
export * from './task';
|
||||
export * from './attachment';
|
||||
export * from './actions';
|
||||
export * from './action';
|
||||
export * from './comment-action';
|
||||
|
|
|
@ -233,23 +233,23 @@ export const updateUserUsernameFailed = (id, error) => ({
|
|||
},
|
||||
});
|
||||
|
||||
export const uploadUserAvatarRequested = (id) => ({
|
||||
type: ActionTypes.USER_AVATAR_UPLOAD_REQUESTED,
|
||||
export const updateUserAvatarRequested = (id) => ({
|
||||
type: ActionTypes.USER_AVATAR_UPDATE_REQUESTED,
|
||||
payload: {
|
||||
id,
|
||||
},
|
||||
});
|
||||
|
||||
export const uploadUserAvatarSucceeded = (id, avatar) => ({
|
||||
type: ActionTypes.USER_AVATAR_UPLOAD_SUCCEEDED,
|
||||
export const updateUserAvatarSucceeded = (id, avatarUrl) => ({
|
||||
type: ActionTypes.USER_AVATAR_UPDATE_SUCCEEDED,
|
||||
payload: {
|
||||
id,
|
||||
avatar,
|
||||
avatarUrl,
|
||||
},
|
||||
});
|
||||
|
||||
export const uploadUserAvatarFailed = (id, error) => ({
|
||||
type: ActionTypes.USER_AVATAR_UPLOAD_FAILED,
|
||||
export const updateUserAvatarFailed = (id, error) => ({
|
||||
type: ActionTypes.USER_AVATAR_UPDATE_FAILED,
|
||||
payload: {
|
||||
id,
|
||||
error,
|
||||
|
|
51
client/src/api/attachments.js
Executable file
51
client/src/api/attachments.js
Executable file
|
@ -0,0 +1,51 @@
|
|||
import http from './http';
|
||||
import socket from './socket';
|
||||
|
||||
/* Transformers */
|
||||
|
||||
export const transformAttachment = (attachment) => ({
|
||||
...attachment,
|
||||
createdAt: new Date(attachment.createdAt),
|
||||
});
|
||||
|
||||
/* Actions */
|
||||
|
||||
const createAttachment = (cardId, data, headers) =>
|
||||
http.post(`/cards/${cardId}/attachments`, data, headers).then((body) => ({
|
||||
...body,
|
||||
item: transformAttachment(body.item),
|
||||
}));
|
||||
|
||||
const updateAttachment = (id, data, headers) =>
|
||||
socket.patch(`/attachments/${id}`, data, headers).then((body) => ({
|
||||
...body,
|
||||
item: transformAttachment(body.item),
|
||||
}));
|
||||
|
||||
const deleteAttachment = (id, headers) =>
|
||||
socket.delete(`/attachments/${id}`, undefined, headers).then((body) => ({
|
||||
...body,
|
||||
item: transformAttachment(body.item),
|
||||
}));
|
||||
|
||||
/* Event handlers */
|
||||
|
||||
const makeHandleAttachmentCreate = (next) => (body) => {
|
||||
next({
|
||||
...body,
|
||||
item: transformAttachment(body.item),
|
||||
});
|
||||
};
|
||||
|
||||
const makeHandleAttachmentUpdate = makeHandleAttachmentCreate;
|
||||
|
||||
const makeHandleAttachmentDelete = makeHandleAttachmentCreate;
|
||||
|
||||
export default {
|
||||
createAttachment,
|
||||
updateAttachment,
|
||||
deleteAttachment,
|
||||
makeHandleAttachmentCreate,
|
||||
makeHandleAttachmentUpdate,
|
||||
makeHandleAttachmentDelete,
|
||||
};
|
|
@ -1,5 +1,6 @@
|
|||
import socket from './socket';
|
||||
import { transformCard } from './cards';
|
||||
import { transformAttachment } from './attachments';
|
||||
|
||||
/* Actions */
|
||||
|
||||
|
@ -12,6 +13,7 @@ const getBoard = (id, headers) =>
|
|||
included: {
|
||||
...body.included,
|
||||
cards: body.included.cards.map(transformCard),
|
||||
attachments: body.included.attachments.map(transformAttachment),
|
||||
},
|
||||
}));
|
||||
|
||||
|
|
|
@ -11,6 +11,7 @@ import cards from './cards';
|
|||
import cardMemberships from './card-memberships';
|
||||
import cardLabels from './card-labels';
|
||||
import tasks from './tasks';
|
||||
import attachments from './attachments';
|
||||
import actions from './actions';
|
||||
import commentActions from './comment-actions';
|
||||
import notifications from './notifications';
|
||||
|
@ -29,6 +30,7 @@ export default {
|
|||
...cardMemberships,
|
||||
...cardLabels,
|
||||
...tasks,
|
||||
...attachments,
|
||||
...actions,
|
||||
...commentActions,
|
||||
...notifications,
|
||||
|
|
|
@ -19,14 +19,8 @@ const updateUserPassword = (id, data, headers) =>
|
|||
const updateUserUsername = (id, data, headers) =>
|
||||
socket.patch(`/users/${id}/username`, data, headers);
|
||||
|
||||
const uploadUserAvatar = (id, file, headers) =>
|
||||
http.post(
|
||||
`/users/${id}/upload-avatar`,
|
||||
{
|
||||
file,
|
||||
},
|
||||
headers,
|
||||
);
|
||||
const updateUserAvatar = (id, data, headers) =>
|
||||
http.post(`/users/${id}/update-avatar`, data, headers);
|
||||
|
||||
const deleteUser = (id, headers) => socket.delete(`/users/${id}`, undefined, headers);
|
||||
|
||||
|
@ -38,6 +32,6 @@ export default {
|
|||
updateUserEmail,
|
||||
updateUserPassword,
|
||||
updateUserUsername,
|
||||
uploadUserAvatar,
|
||||
updateUserAvatar,
|
||||
deleteUser,
|
||||
};
|
||||
|
|
|
@ -60,7 +60,7 @@ const Filter = React.memo(
|
|||
<span key={user.id} className={styles.filterItem}>
|
||||
<User
|
||||
name={user.name}
|
||||
avatar={user.avatar}
|
||||
avatarUrl={user.avatarUrl}
|
||||
size="small"
|
||||
onClick={() => handleUserRemoveClick(user.id)}
|
||||
/>
|
||||
|
|
|
@ -103,7 +103,7 @@ const Card = React.memo(
|
|||
<span className={classNames(styles.attachments, styles.attachmentsRight)}>
|
||||
{users.map((user) => (
|
||||
<span key={user.id} className={classNames(styles.attachment, styles.attachmentRight)}>
|
||||
<User name={user.name} avatar={user.avatar} size="tiny" />
|
||||
<User name={user.name} avatarUrl={user.avatarUrl} size="tiny" />
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
|
|
|
@ -58,7 +58,7 @@
|
|||
|
||||
.card:hover {
|
||||
background-color: #f5f6f7;
|
||||
border-bottom-color: rgba(9, 45, 66, 0.25);
|
||||
border-bottom-color: rgba(9, 30, 66, 0.25);
|
||||
}
|
||||
|
||||
.card:hover .target {
|
||||
|
|
|
@ -5,7 +5,7 @@
|
|||
|
||||
.field {
|
||||
background: #fff !important;
|
||||
border: 1px solid rgba(9, 45, 66, 0.13) !important;
|
||||
border: 1px solid rgba(9, 30, 66, 0.13) !important;
|
||||
border-radius: 3px !important;
|
||||
box-sizing: border-box;
|
||||
color: #333 !important;
|
||||
|
|
|
@ -62,7 +62,7 @@ const Item = React.memo(({ type, data, createdAt, user }) => {
|
|||
return (
|
||||
<Comment>
|
||||
<span className={styles.user}>
|
||||
<User name={user.name} avatar={user.avatar} />
|
||||
<User name={user.name} avatarUrl={user.avatarUrl} />
|
||||
</span>
|
||||
<div className={classNames(styles.content)}>
|
||||
<div>{contentNode}</div>
|
||||
|
|
|
@ -24,7 +24,7 @@ const ItemComment = React.memo(
|
|||
return (
|
||||
<Comment>
|
||||
<span className={styles.user}>
|
||||
<User name={user.name} avatar={user.avatar} />
|
||||
<User name={user.name} avatarUrl={user.avatarUrl} />
|
||||
</span>
|
||||
<div className={classNames(styles.content)}>
|
||||
<div className={styles.title}>
|
||||
|
|
|
@ -24,8 +24,8 @@
|
|||
.text {
|
||||
background-color: #fff;
|
||||
border-radius: 0px 8px 8px;
|
||||
box-shadow: 0 1px 2px -1px rgba(9, 45, 66, 0.25),
|
||||
0 0 0 1px rgba(9, 45, 66, 0.08);
|
||||
box-shadow: 0 1px 2px -1px rgba(9, 30, 66, 0.25),
|
||||
0 0 0 1px rgba(9, 30, 66, 0.08);
|
||||
box-sizing: border-box;
|
||||
color: #17394d;
|
||||
display: inline-block;
|
||||
|
|
45
client/src/components/CardModal/Attachments/Attachments.jsx
Normal file
45
client/src/components/CardModal/Attachments/Attachments.jsx
Normal file
|
@ -0,0 +1,45 @@
|
|||
import React, { useCallback } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import Item from './Item';
|
||||
|
||||
const Attachments = React.memo(({ items, onUpdate, onDelete }) => {
|
||||
const handleUpdate = useCallback(
|
||||
(id, data) => {
|
||||
onUpdate(id, data);
|
||||
},
|
||||
[onUpdate],
|
||||
);
|
||||
|
||||
const handleDelete = useCallback(
|
||||
(id) => {
|
||||
onDelete(id);
|
||||
},
|
||||
[onDelete],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{items.map((item) => (
|
||||
<Item
|
||||
key={item.id}
|
||||
name={item.name}
|
||||
url={item.url}
|
||||
thumbnailUrl={item.thumbnailUrl}
|
||||
createdAt={item.createdAt}
|
||||
isPersisted={item.isPersisted}
|
||||
onUpdate={(data) => handleUpdate(item.id, data)}
|
||||
onDelete={() => handleDelete(item.id)}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
Attachments.propTypes = {
|
||||
items: PropTypes.array.isRequired, // eslint-disable-line react/forbid-prop-types
|
||||
onUpdate: PropTypes.func.isRequired,
|
||||
onDelete: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
export default Attachments;
|
107
client/src/components/CardModal/Attachments/EditPopup.jsx
Executable file
107
client/src/components/CardModal/Attachments/EditPopup.jsx
Executable file
|
@ -0,0 +1,107 @@
|
|||
import dequal from 'dequal';
|
||||
import React, { useCallback, useEffect, useRef } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button, Form } from 'semantic-ui-react';
|
||||
import { withPopup } from '../../../lib/popup';
|
||||
import { Input, Popup } from '../../../lib/custom-ui';
|
||||
|
||||
import { useForm, useSteps } from '../../../hooks';
|
||||
import DeleteStep from '../../DeleteStep';
|
||||
|
||||
import styles from './EditPopup.module.css';
|
||||
|
||||
const StepTypes = {
|
||||
DELETE: 'DELETE',
|
||||
};
|
||||
|
||||
const EditStep = React.memo(({ defaultData, onUpdate, onDelete, onClose }) => {
|
||||
const [t] = useTranslation();
|
||||
|
||||
const [data, handleFieldChange] = useForm(() => ({
|
||||
name: '',
|
||||
...defaultData,
|
||||
}));
|
||||
|
||||
const [step, openStep, handleBack] = useSteps();
|
||||
|
||||
const nameField = useRef(null);
|
||||
|
||||
const handleSubmit = useCallback(() => {
|
||||
const cleanData = {
|
||||
...data,
|
||||
name: data.name.trim(),
|
||||
};
|
||||
|
||||
if (!cleanData.name) {
|
||||
nameField.current.select();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!dequal(cleanData, defaultData)) {
|
||||
onUpdate(cleanData);
|
||||
}
|
||||
|
||||
onClose();
|
||||
}, [defaultData, onUpdate, onClose, data]);
|
||||
|
||||
const handleDeleteClick = useCallback(() => {
|
||||
openStep(StepTypes.DELETE);
|
||||
}, [openStep]);
|
||||
|
||||
useEffect(() => {
|
||||
nameField.current.select();
|
||||
}, []);
|
||||
|
||||
if (step && step.type === StepTypes.DELETE) {
|
||||
return (
|
||||
<DeleteStep
|
||||
title={t('common.deleteAttachment', {
|
||||
context: 'title',
|
||||
})}
|
||||
content={t('common.areYouSureYouWantToDeleteThisAttachment')}
|
||||
buttonContent={t('action.deleteAttachment')}
|
||||
onConfirm={onDelete}
|
||||
onBack={handleBack}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Popup.Header>
|
||||
{t('common.editAttachment', {
|
||||
context: 'title',
|
||||
})}
|
||||
</Popup.Header>
|
||||
<Popup.Content>
|
||||
<Form onSubmit={handleSubmit}>
|
||||
<div className={styles.text}>{t('common.title')}</div>
|
||||
<Input
|
||||
fluid
|
||||
ref={nameField}
|
||||
name="name"
|
||||
value={data.name}
|
||||
className={styles.field}
|
||||
onChange={handleFieldChange}
|
||||
/>
|
||||
<Button positive content={t('action.save')} />
|
||||
</Form>
|
||||
<Button
|
||||
content={t('action.delete')}
|
||||
className={styles.deleteButton}
|
||||
onClick={handleDeleteClick}
|
||||
/>
|
||||
</Popup.Content>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
EditStep.propTypes = {
|
||||
defaultData: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types
|
||||
onUpdate: PropTypes.func.isRequired,
|
||||
onDelete: PropTypes.func.isRequired,
|
||||
onClose: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
export default withPopup(EditStep);
|
|
@ -0,0 +1,17 @@
|
|||
.deleteButton {
|
||||
bottom: 12px;
|
||||
box-shadow: 0 1px 0 #cbcccc;
|
||||
position: absolute;
|
||||
right: 9px;
|
||||
}
|
||||
|
||||
.field {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.text {
|
||||
color: #444444;
|
||||
font-size: 12px;
|
||||
font-weight: bold;
|
||||
padding-bottom: 6px;
|
||||
}
|
85
client/src/components/CardModal/Attachments/Item.jsx
Normal file
85
client/src/components/CardModal/Attachments/Item.jsx
Normal file
|
@ -0,0 +1,85 @@
|
|||
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 EditPopup from './EditPopup';
|
||||
|
||||
import styles from './Item.module.css';
|
||||
|
||||
const Item = React.memo(
|
||||
({ name, url, thumbnailUrl, createdAt, isPersisted, onUpdate, onDelete }) => {
|
||||
const [t] = useTranslation();
|
||||
|
||||
const handleClick = useCallback(() => {
|
||||
window.open(url, '_blank');
|
||||
}, [url]);
|
||||
|
||||
if (!isPersisted) {
|
||||
return (
|
||||
<div className={classNames(styles.wrapper, styles.wrapperSubmitting)}>
|
||||
<Loader inverted />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const filename = url.split('/').pop();
|
||||
const extension = filename.slice((Math.max(0, filename.lastIndexOf('.')) || Infinity) + 1);
|
||||
|
||||
return (
|
||||
/* eslint-disable jsx-a11y/click-events-have-key-events,
|
||||
jsx-a11y/no-static-element-interactions */
|
||||
<div className={styles.wrapper} onClick={handleClick}>
|
||||
{/* eslint-enable jsx-a11y/click-events-have-key-events,
|
||||
jsx-a11y/no-static-element-interactions */}
|
||||
<div
|
||||
className={styles.thumbnail}
|
||||
style={{
|
||||
backgroundImage: thumbnailUrl && `url(${thumbnailUrl}`,
|
||||
}}
|
||||
>
|
||||
{!thumbnailUrl && <span className={styles.extension}>{extension || '-'}</span>}
|
||||
</div>
|
||||
<div className={styles.details}>
|
||||
<span className={styles.name}>{name}</span>
|
||||
<span className={styles.options}>
|
||||
{t('format:longDateTime', {
|
||||
postProcess: 'formatDate',
|
||||
value: createdAt,
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
<EditPopup
|
||||
defaultData={{
|
||||
name,
|
||||
}}
|
||||
onUpdate={onUpdate}
|
||||
onDelete={onDelete}
|
||||
>
|
||||
<Button className={classNames(styles.button, styles.target)}>
|
||||
<Icon fitted name="pencil" size="small" />
|
||||
</Button>
|
||||
</EditPopup>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
Item.propTypes = {
|
||||
name: PropTypes.string.isRequired,
|
||||
url: PropTypes.string,
|
||||
thumbnailUrl: PropTypes.string,
|
||||
createdAt: PropTypes.instanceOf(Date),
|
||||
isPersisted: PropTypes.bool.isRequired,
|
||||
onUpdate: PropTypes.func.isRequired,
|
||||
onDelete: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
Item.defaultProps = {
|
||||
url: undefined,
|
||||
thumbnailUrl: undefined,
|
||||
createdAt: undefined,
|
||||
};
|
||||
|
||||
export default Item;
|
90
client/src/components/CardModal/Attachments/Item.module.css
Normal file
90
client/src/components/CardModal/Attachments/Item.module.css
Normal file
|
@ -0,0 +1,90 @@
|
|||
.button {
|
||||
background: transparent !important;
|
||||
box-shadow: none !important;
|
||||
line-height: 28px !important;
|
||||
margin: 0 !important;
|
||||
min-height: auto !important;
|
||||
opacity: 0;
|
||||
padding: 0 !important;
|
||||
position: absolute;
|
||||
right: 2px;
|
||||
top: 2px;
|
||||
width: 28px;
|
||||
}
|
||||
|
||||
.button:hover {
|
||||
background-color: rgba(9, 30, 66, 0.08) !important;
|
||||
}
|
||||
|
||||
.details {
|
||||
box-sizing: border-box;
|
||||
padding: 8px 32px 8px 128px;
|
||||
margin: 0;
|
||||
min-height: 80px;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.extension {
|
||||
color: #5e6c84;
|
||||
display: block;
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
height: 100%;
|
||||
line-height: 80px;
|
||||
text-align: center;
|
||||
text-decoration: none;
|
||||
text-transform: uppercase;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.name {
|
||||
color: #17394d;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
line-height: 20px;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
.options {
|
||||
display: block;
|
||||
color: #6b808c;
|
||||
line-height: 20px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.thumbnail {
|
||||
border-radius: 3px;
|
||||
background-color: rgba(9, 30, 66, 0.04);
|
||||
background-position: center;
|
||||
background-repeat: no-repeat;
|
||||
background-size: cover;
|
||||
border-radius: 3px;
|
||||
height: 80px;
|
||||
left: 0;
|
||||
margin-top: -40px;
|
||||
position: absolute;
|
||||
text-align: center;
|
||||
text-decoration: none;
|
||||
top: 50%;
|
||||
width: 112px;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.wrapper {
|
||||
cursor: pointer;
|
||||
margin-bottom: 8px;
|
||||
min-height: 80px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.wrapper:hover .details {
|
||||
background-color: rgba(9, 30, 66, 0.04);
|
||||
}
|
||||
|
||||
.wrapper:hover .target {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.wrapperSubmitting {
|
||||
background-color: rgba(9, 30, 66, 0.04);
|
||||
}
|
3
client/src/components/CardModal/Attachments/index.js
Normal file
3
client/src/components/CardModal/Attachments/index.js
Normal file
|
@ -0,0 +1,3 @@
|
|||
import Attachments from './Attachments';
|
||||
|
||||
export default Attachments;
|
|
@ -3,11 +3,12 @@ import PropTypes from 'prop-types';
|
|||
import classNames from 'classnames';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button, Grid, Icon, Modal } from 'semantic-ui-react';
|
||||
import { Markdown } from '../../lib/custom-ui';
|
||||
import { FilePicker, Markdown } from '../../lib/custom-ui';
|
||||
|
||||
import NameField from './NameField';
|
||||
import EditDescription from './EditDescription';
|
||||
import Tasks from './Tasks';
|
||||
import Attachments from './Attachments';
|
||||
import Actions from './Actions';
|
||||
import User from '../User';
|
||||
import Label from '../Label';
|
||||
|
@ -33,6 +34,7 @@ const CardModal = React.memo(
|
|||
users,
|
||||
labels,
|
||||
tasks,
|
||||
attachments,
|
||||
actions,
|
||||
allProjectMemberships,
|
||||
allLabels,
|
||||
|
@ -49,6 +51,9 @@ const CardModal = React.memo(
|
|||
onTaskCreate,
|
||||
onTaskUpdate,
|
||||
onTaskDelete,
|
||||
onAttachmentCreate,
|
||||
onAttachmentUpdate,
|
||||
onAttachmentDelete,
|
||||
onActionsFetch,
|
||||
onCommentActionCreate,
|
||||
onCommentActionUpdate,
|
||||
|
@ -93,6 +98,15 @@ const CardModal = React.memo(
|
|||
[onUpdate],
|
||||
);
|
||||
|
||||
const handleAttachmentFileSelect = useCallback(
|
||||
(file) => {
|
||||
onAttachmentCreate({
|
||||
file,
|
||||
});
|
||||
},
|
||||
[onAttachmentCreate],
|
||||
);
|
||||
|
||||
const handleToggleSubscribeClick = useCallback(() => {
|
||||
onUpdate({
|
||||
isSubscribed: !isSubscribed,
|
||||
|
@ -134,7 +148,7 @@ const CardModal = React.memo(
|
|||
onUserSelect={onUserAdd}
|
||||
onUserDeselect={onUserRemove}
|
||||
>
|
||||
<User name={user.name} avatar={user.avatar} />
|
||||
<User name={user.name} avatarUrl={user.avatarUrl} />
|
||||
</ProjectMembershipsPopup>
|
||||
</span>
|
||||
))}
|
||||
|
@ -255,6 +269,19 @@ const CardModal = React.memo(
|
|||
/>
|
||||
</div>
|
||||
</div>
|
||||
{attachments.length > 0 && (
|
||||
<div className={styles.contentModule}>
|
||||
<div className={styles.moduleWrapper}>
|
||||
<Icon name="attach" className={styles.moduleIcon} />
|
||||
<div className={styles.moduleHeader}>{t('common.attachments')}</div>
|
||||
<Attachments
|
||||
items={attachments}
|
||||
onUpdate={onAttachmentUpdate}
|
||||
onDelete={onAttachmentDelete}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<Actions
|
||||
items={actions}
|
||||
isFetching={isActionsFetching}
|
||||
|
@ -306,6 +333,12 @@ const CardModal = React.memo(
|
|||
{t('common.timer')}
|
||||
</Button>
|
||||
</EditTimerPopup>
|
||||
<FilePicker onSelect={handleAttachmentFileSelect}>
|
||||
<Button fluid className={styles.actionButton}>
|
||||
<Icon name="attach" className={styles.actionIcon} />
|
||||
{t('common.attachment')}
|
||||
</Button>
|
||||
</FilePicker>
|
||||
</div>
|
||||
<div className={styles.actions}>
|
||||
<span className={styles.actionsTitle}>{t('common.actions')}</span>
|
||||
|
@ -347,6 +380,7 @@ CardModal.propTypes = {
|
|||
users: PropTypes.array.isRequired,
|
||||
labels: PropTypes.array.isRequired,
|
||||
tasks: PropTypes.array.isRequired,
|
||||
attachments: PropTypes.array.isRequired,
|
||||
actions: PropTypes.array.isRequired,
|
||||
allProjectMemberships: PropTypes.array.isRequired,
|
||||
allLabels: PropTypes.array.isRequired,
|
||||
|
@ -364,6 +398,9 @@ CardModal.propTypes = {
|
|||
onTaskCreate: PropTypes.func.isRequired,
|
||||
onTaskUpdate: PropTypes.func.isRequired,
|
||||
onTaskDelete: PropTypes.func.isRequired,
|
||||
onAttachmentCreate: PropTypes.func.isRequired,
|
||||
onAttachmentUpdate: PropTypes.func.isRequired,
|
||||
onAttachmentDelete: PropTypes.func.isRequired,
|
||||
onActionsFetch: PropTypes.func.isRequired,
|
||||
onCommentActionCreate: PropTypes.func.isRequired,
|
||||
onCommentActionUpdate: PropTypes.func.isRequired,
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
.actionButton {
|
||||
background: #ebeef0 !important;
|
||||
box-shadow: 0 1px 0 0 rgba(9, 45, 66, 0.13) !important;
|
||||
box-shadow: 0 1px 0 0 rgba(9, 30, 66, 0.13) !important;
|
||||
color: #444 !important;
|
||||
margin-top: 8px !important;
|
||||
overflow: hidden;
|
||||
|
@ -12,7 +12,7 @@
|
|||
|
||||
.actionButton:hover {
|
||||
background: #dfe3e6 !important;
|
||||
box-shadow: 0 1px 0 0 rgba(9, 45, 66, 0.25) !important;
|
||||
box-shadow: 0 1px 0 0 rgba(9, 30, 66, 0.25) !important;
|
||||
color: #4c4c4c !important;
|
||||
}
|
||||
|
||||
|
@ -65,7 +65,7 @@
|
|||
}
|
||||
|
||||
.dueDate {
|
||||
background: #dce0e4;
|
||||
background: rgba(9, 30, 66, 0.04);
|
||||
border: none;
|
||||
border-radius: 3px;
|
||||
color: #6b808c;
|
||||
|
@ -79,12 +79,12 @@
|
|||
}
|
||||
|
||||
.dueDate:hover {
|
||||
background: #d2d8dc;
|
||||
background: rgba(9, 30, 66, 0.08);
|
||||
color: #17394d;
|
||||
}
|
||||
|
||||
.descriptionButton {
|
||||
background: rgba(9, 45, 66, 0.08);
|
||||
background: rgba(9, 30, 66, 0.04);
|
||||
border: none;
|
||||
border-radius: 3px;
|
||||
display: block;
|
||||
|
@ -100,7 +100,7 @@
|
|||
}
|
||||
|
||||
.descriptionButton:hover {
|
||||
background-color: rgba(9, 45, 66, 0.13);
|
||||
background-color: rgba(9, 30, 66, 0.08);
|
||||
color: #092d42;
|
||||
}
|
||||
|
||||
|
|
|
@ -5,7 +5,7 @@
|
|||
|
||||
.field {
|
||||
background: #fff !important;
|
||||
border: 1px solid rgba(9, 45, 66, 0.13) !important;
|
||||
border: 1px solid rgba(9, 30, 66, 0.13) !important;
|
||||
border-radius: 3px !important;
|
||||
color: #17394d !important;
|
||||
display: block !important;
|
||||
|
|
|
@ -5,7 +5,7 @@
|
|||
|
||||
.field {
|
||||
background: #fff !important;
|
||||
border: 1px solid rgba(9, 45, 66, 0.13) !important;
|
||||
border: 1px solid rgba(9, 30, 66, 0.08) !important;
|
||||
border-radius: 3px !important;
|
||||
color: #17394d !important;
|
||||
display: block !important;
|
||||
|
|
|
@ -5,7 +5,7 @@
|
|||
|
||||
.field {
|
||||
background: #fff !important;
|
||||
border: 1px solid rgba(9, 45, 66, 0.13) !important;
|
||||
border: 1px solid rgba(9, 30, 66, 0.13) !important;
|
||||
border-radius: 3px !important;
|
||||
box-sizing: border-box !important;
|
||||
color: #17394d !important;
|
||||
|
|
|
@ -13,7 +13,7 @@
|
|||
}
|
||||
|
||||
.button:hover {
|
||||
background-color: rgba(9, 45, 66, 0.13) !important;
|
||||
background-color: rgba(9, 30, 66, 0.08) !important;
|
||||
}
|
||||
|
||||
.checkboxWrapper {
|
||||
|
@ -30,7 +30,7 @@
|
|||
}
|
||||
|
||||
.content:hover {
|
||||
background-color: rgba(9, 45, 66, 0.08);
|
||||
background-color: rgba(9, 30, 66, 0.04);
|
||||
}
|
||||
|
||||
.content:hover .target {
|
||||
|
|
|
@ -20,7 +20,7 @@
|
|||
}
|
||||
|
||||
.taskButton:hover {
|
||||
background-color: rgba(9, 45, 66, 0.13);
|
||||
background-color: rgba(9, 30, 66, 0.04);
|
||||
color: #092d42;
|
||||
}
|
||||
|
||||
|
|
|
@ -83,7 +83,7 @@ const NotificationsStep = React.memo(({ items, onDelete, onClose }) => {
|
|||
<>
|
||||
<User
|
||||
name={item.action.user.name}
|
||||
avatar={item.action.user.avatar}
|
||||
avatarUrl={item.action.user.avatarUrl}
|
||||
size="large"
|
||||
/>
|
||||
<span className={styles.content}>{renderItemContent(item)}</span>
|
||||
|
|
|
@ -72,7 +72,7 @@
|
|||
}
|
||||
|
||||
.headerButton:hover {
|
||||
background-color: rgba(9, 45, 66, 0.13) !important;
|
||||
background-color: rgba(9, 30, 66, 0.13) !important;
|
||||
color: #516b7a !important;
|
||||
}
|
||||
|
||||
|
|
|
@ -35,7 +35,7 @@ const AddMembershipStep = React.memo(({ users, currentUserIds, onCreate, onClose
|
|||
<UserItem
|
||||
key={user.id}
|
||||
name={user.name}
|
||||
avatar={user.avatar}
|
||||
avatarUrl={user.avatarUrl}
|
||||
isActive={currentUserIds.includes(user.id)}
|
||||
onSelect={() => handleUserSelect(user.id)}
|
||||
/>
|
||||
|
|
|
@ -6,7 +6,7 @@ import User from '../../User';
|
|||
|
||||
import styles from './UserItem.module.css';
|
||||
|
||||
const UserItem = React.memo(({ name, avatar, isActive, onSelect }) => (
|
||||
const UserItem = React.memo(({ name, avatarUrl, isActive, onSelect }) => (
|
||||
<button
|
||||
type="button"
|
||||
disabled={isActive}
|
||||
|
@ -14,7 +14,7 @@ const UserItem = React.memo(({ name, avatar, isActive, onSelect }) => (
|
|||
onClick={onSelect}
|
||||
>
|
||||
<span className={styles.user}>
|
||||
<User name={name} avatar={avatar} />
|
||||
<User name={name} avatarUrl={avatarUrl} />
|
||||
</span>
|
||||
<div className={classNames(styles.menuItemText, isActive && styles.menuItemTextActive)}>
|
||||
{name}
|
||||
|
@ -24,13 +24,13 @@ const UserItem = React.memo(({ name, avatar, isActive, onSelect }) => (
|
|||
|
||||
UserItem.propTypes = {
|
||||
name: PropTypes.string.isRequired,
|
||||
avatar: PropTypes.string,
|
||||
avatarUrl: PropTypes.string,
|
||||
isActive: PropTypes.bool.isRequired,
|
||||
onSelect: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
UserItem.defaultProps = {
|
||||
avatar: undefined,
|
||||
avatarUrl: undefined,
|
||||
};
|
||||
|
||||
export default UserItem;
|
||||
|
|
|
@ -39,7 +39,7 @@ const EditMembershipStep = React.memo(({ user, isEditable, onDelete }) => {
|
|||
return (
|
||||
<>
|
||||
<span className={styles.user}>
|
||||
<User name={user.name} avatar={user.avatar} size="large" />
|
||||
<User name={user.name} avatarUrl={user.avatarUrl} size="large" />
|
||||
</span>
|
||||
<span className={styles.content}>
|
||||
<div className={styles.name}>{user.name}</div>
|
||||
|
|
|
@ -56,7 +56,7 @@ const Project = React.memo(
|
|||
>
|
||||
<User
|
||||
name={membership.user.name}
|
||||
avatar={membership.user.avatar}
|
||||
avatarUrl={membership.user.avatarUrl}
|
||||
size="large"
|
||||
isDisabled={!membership.isPersisted}
|
||||
/>
|
||||
|
|
|
@ -24,7 +24,7 @@ const Item = React.memo(({ isPersisted, isActive, user, onUserSelect, onUserDese
|
|||
onClick={handleToggleClick}
|
||||
>
|
||||
<span className={styles.user}>
|
||||
<User name={user.name} avatar={user.avatar} />
|
||||
<User name={user.name} avatarUrl={user.avatarUrl} />
|
||||
</span>
|
||||
<div className={classNames(styles.menuItemText, isActive && styles.menuItemTextActive)}>
|
||||
{user.name}
|
||||
|
|
|
@ -72,10 +72,10 @@ const getColor = (name) => {
|
|||
return COLORS[sum % COLORS.length];
|
||||
};
|
||||
|
||||
const User = React.memo(({ name, avatar, size, isDisabled, onClick }) => {
|
||||
const User = React.memo(({ name, avatarUrl, size, isDisabled, onClick }) => {
|
||||
const style = {
|
||||
...STYLES[size],
|
||||
background: avatar ? `url("${avatar}")` : getColor(name),
|
||||
background: avatarUrl ? `url("${avatarUrl}")` : getColor(name),
|
||||
};
|
||||
|
||||
const contentNode = (
|
||||
|
@ -84,7 +84,7 @@ const User = React.memo(({ name, avatar, size, isDisabled, onClick }) => {
|
|||
className={classNames(styles.wrapper, onClick && styles.hoverable)}
|
||||
style={style}
|
||||
>
|
||||
{!avatar && <span className={styles.initials}>{initials(name)}</span>}
|
||||
{!avatarUrl && <span className={styles.initials}>{initials(name)}</span>}
|
||||
</span>
|
||||
);
|
||||
|
||||
|
@ -99,14 +99,14 @@ const User = React.memo(({ name, avatar, size, isDisabled, onClick }) => {
|
|||
|
||||
User.propTypes = {
|
||||
name: PropTypes.string.isRequired,
|
||||
avatar: PropTypes.string,
|
||||
avatarUrl: PropTypes.string,
|
||||
size: PropTypes.oneOf(Object.values(SIZES)),
|
||||
isDisabled: PropTypes.bool,
|
||||
onClick: PropTypes.func,
|
||||
};
|
||||
|
||||
User.defaultProps = {
|
||||
avatar: undefined,
|
||||
avatarUrl: undefined,
|
||||
size: SIZES.MEDIUM,
|
||||
isDisabled: false,
|
||||
onClick: undefined,
|
||||
|
|
|
@ -17,15 +17,15 @@ const AccountPane = React.memo(
|
|||
email,
|
||||
name,
|
||||
username,
|
||||
avatar,
|
||||
avatarUrl,
|
||||
phone,
|
||||
organization,
|
||||
isAvatarUploading,
|
||||
isAvatarUpdating,
|
||||
usernameUpdateForm,
|
||||
emailUpdateForm,
|
||||
passwordUpdateForm,
|
||||
onUpdate,
|
||||
onAvatarUpload,
|
||||
onAvatarUpdate,
|
||||
onUsernameUpdate,
|
||||
onUsernameUpdateMessageDismiss,
|
||||
onEmailUpdate,
|
||||
|
@ -37,18 +37,18 @@ const AccountPane = React.memo(
|
|||
|
||||
const handleAvatarDelete = useCallback(() => {
|
||||
onUpdate({
|
||||
avatar: null,
|
||||
avatarUrl: null,
|
||||
});
|
||||
}, [onUpdate]);
|
||||
|
||||
return (
|
||||
<Tab.Pane attached={false} className={styles.wrapper}>
|
||||
<EditAvatarPopup
|
||||
defaultValue={avatar}
|
||||
onUpload={onAvatarUpload}
|
||||
defaultValue={avatarUrl}
|
||||
onUpdate={onAvatarUpdate}
|
||||
onDelete={handleAvatarDelete}
|
||||
>
|
||||
<User name={name} avatar={avatar} size="massive" isDisabled={isAvatarUploading} />
|
||||
<User name={name} avatarUrl={avatarUrl} size="massive" isDisabled={isAvatarUpdating} />
|
||||
</EditAvatarPopup>
|
||||
<br />
|
||||
<br />
|
||||
|
@ -123,17 +123,17 @@ AccountPane.propTypes = {
|
|||
email: PropTypes.string.isRequired,
|
||||
name: PropTypes.string.isRequired,
|
||||
username: PropTypes.string,
|
||||
avatar: PropTypes.string,
|
||||
avatarUrl: PropTypes.string,
|
||||
phone: PropTypes.string,
|
||||
organization: PropTypes.string,
|
||||
isAvatarUploading: PropTypes.bool.isRequired,
|
||||
isAvatarUpdating: PropTypes.bool.isRequired,
|
||||
/* eslint-disable react/forbid-prop-types */
|
||||
usernameUpdateForm: PropTypes.object.isRequired,
|
||||
emailUpdateForm: PropTypes.object.isRequired,
|
||||
passwordUpdateForm: PropTypes.object.isRequired,
|
||||
/* eslint-enable react/forbid-prop-types */
|
||||
onUpdate: PropTypes.func.isRequired,
|
||||
onAvatarUpload: PropTypes.func.isRequired,
|
||||
onAvatarUpdate: PropTypes.func.isRequired,
|
||||
onUsernameUpdate: PropTypes.func.isRequired,
|
||||
onUsernameUpdateMessageDismiss: PropTypes.func.isRequired,
|
||||
onEmailUpdate: PropTypes.func.isRequired,
|
||||
|
@ -144,7 +144,7 @@ AccountPane.propTypes = {
|
|||
|
||||
AccountPane.defaultProps = {
|
||||
username: undefined,
|
||||
avatar: undefined,
|
||||
avatarUrl: undefined,
|
||||
phone: undefined,
|
||||
organization: undefined,
|
||||
};
|
||||
|
|
|
@ -3,23 +3,24 @@ import PropTypes from 'prop-types';
|
|||
import { useTranslation } from 'react-i18next';
|
||||
import { Button } from 'semantic-ui-react';
|
||||
import { withPopup } from '../../../lib/popup';
|
||||
import { Popup } from '../../../lib/custom-ui';
|
||||
import { FilePicker, Popup } from '../../../lib/custom-ui';
|
||||
|
||||
import styles from './EditAvatarPopup.module.css';
|
||||
|
||||
const EditAvatarStep = React.memo(({ defaultValue, onUpload, onDelete, onClose }) => {
|
||||
const EditAvatarStep = React.memo(({ defaultValue, onUpdate, onDelete, onClose }) => {
|
||||
const [t] = useTranslation();
|
||||
|
||||
const field = useRef(null);
|
||||
|
||||
const handleFieldChange = useCallback(
|
||||
({ target }) => {
|
||||
if (target.files[0]) {
|
||||
onUpload(target.files[0]);
|
||||
onClose();
|
||||
}
|
||||
const handleFileSelect = useCallback(
|
||||
(file) => {
|
||||
onUpdate({
|
||||
file,
|
||||
});
|
||||
|
||||
onClose();
|
||||
},
|
||||
[onUpload, onClose],
|
||||
[onUpdate, onClose],
|
||||
);
|
||||
|
||||
const handleDeleteClick = useCallback(() => {
|
||||
|
@ -39,15 +40,14 @@ const EditAvatarStep = React.memo(({ defaultValue, onUpload, onDelete, onClose }
|
|||
})}
|
||||
</Popup.Header>
|
||||
<Popup.Content>
|
||||
<div className={styles.input}>
|
||||
<Button content={t('action.uploadNewAvatar')} className={styles.customButton} />
|
||||
<input
|
||||
ref={field}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className={styles.file}
|
||||
onChange={handleFieldChange}
|
||||
/>
|
||||
<div className={styles.action}>
|
||||
<FilePicker accept="image/*" onSelect={handleFileSelect}>
|
||||
<Button
|
||||
ref={field}
|
||||
content={t('action.uploadNewAvatar')}
|
||||
className={styles.actionButton}
|
||||
/>
|
||||
</FilePicker>
|
||||
</div>
|
||||
{defaultValue && (
|
||||
<Button negative content={t('action.deleteAvatar')} onClick={handleDeleteClick} />
|
||||
|
@ -59,7 +59,7 @@ const EditAvatarStep = React.memo(({ defaultValue, onUpload, onDelete, onClose }
|
|||
|
||||
EditAvatarStep.propTypes = {
|
||||
defaultValue: PropTypes.string,
|
||||
onUpload: PropTypes.func.isRequired,
|
||||
onUpdate: PropTypes.func.isRequired,
|
||||
onDelete: PropTypes.func.isRequired,
|
||||
onClose: PropTypes.func.isRequired,
|
||||
};
|
||||
|
|
|
@ -1,25 +1,4 @@
|
|||
.customButton {
|
||||
background: transparent !important;
|
||||
color: #6b808c !important;
|
||||
font-weight: normal !important;
|
||||
height: 36px;
|
||||
line-height: 24px !important;
|
||||
padding: 6px 12px !important;
|
||||
text-align: left !important;
|
||||
text-decoration: underline !important;
|
||||
}
|
||||
|
||||
.file {
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
opacity: 0;
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.input {
|
||||
.action {
|
||||
border: none;
|
||||
display: inline-block;
|
||||
height: 36px;
|
||||
|
@ -29,6 +8,18 @@
|
|||
width: 100%;
|
||||
}
|
||||
|
||||
.input:hover {
|
||||
.action:hover {
|
||||
background: #e9e9e9 !important;
|
||||
}
|
||||
|
||||
.actionButton {
|
||||
background: transparent !important;
|
||||
color: #6b808c !important;
|
||||
font-weight: normal !important;
|
||||
height: 36px;
|
||||
line-height: 24px !important;
|
||||
padding: 6px 12px !important;
|
||||
text-align: left !important;
|
||||
text-decoration: underline !important;
|
||||
width: 100%;
|
||||
}
|
||||
|
|
|
@ -11,16 +11,16 @@ const UserSettingsModal = React.memo(
|
|||
email,
|
||||
name,
|
||||
username,
|
||||
avatar,
|
||||
avatarUrl,
|
||||
phone,
|
||||
organization,
|
||||
subscribeToOwnCards,
|
||||
isAvatarUploading,
|
||||
isAvatarUpdating,
|
||||
usernameUpdateForm,
|
||||
emailUpdateForm,
|
||||
passwordUpdateForm,
|
||||
onUpdate,
|
||||
onAvatarUpload,
|
||||
onAvatarUpdate,
|
||||
onUsernameUpdate,
|
||||
onUsernameUpdateMessageDismiss,
|
||||
onEmailUpdate,
|
||||
|
@ -41,15 +41,15 @@ const UserSettingsModal = React.memo(
|
|||
email={email}
|
||||
name={name}
|
||||
username={username}
|
||||
avatar={avatar}
|
||||
avatarUrl={avatarUrl}
|
||||
phone={phone}
|
||||
organization={organization}
|
||||
isAvatarUploading={isAvatarUploading}
|
||||
isAvatarUpdating={isAvatarUpdating}
|
||||
usernameUpdateForm={usernameUpdateForm}
|
||||
emailUpdateForm={emailUpdateForm}
|
||||
passwordUpdateForm={passwordUpdateForm}
|
||||
onUpdate={onUpdate}
|
||||
onAvatarUpload={onAvatarUpload}
|
||||
onAvatarUpdate={onAvatarUpdate}
|
||||
onUsernameUpdate={onUsernameUpdate}
|
||||
onUsernameUpdateMessageDismiss={onUsernameUpdateMessageDismiss}
|
||||
onEmailUpdate={onEmailUpdate}
|
||||
|
@ -89,18 +89,18 @@ UserSettingsModal.propTypes = {
|
|||
email: PropTypes.string.isRequired,
|
||||
name: PropTypes.string.isRequired,
|
||||
username: PropTypes.string,
|
||||
avatar: PropTypes.string,
|
||||
avatarUrl: PropTypes.string,
|
||||
phone: PropTypes.string,
|
||||
organization: PropTypes.string,
|
||||
subscribeToOwnCards: PropTypes.bool.isRequired,
|
||||
isAvatarUploading: PropTypes.bool.isRequired,
|
||||
isAvatarUpdating: PropTypes.bool.isRequired,
|
||||
/* eslint-disable react/forbid-prop-types */
|
||||
usernameUpdateForm: PropTypes.object.isRequired,
|
||||
emailUpdateForm: PropTypes.object.isRequired,
|
||||
passwordUpdateForm: PropTypes.object.isRequired,
|
||||
/* eslint-enable react/forbid-prop-types */
|
||||
onUpdate: PropTypes.func.isRequired,
|
||||
onAvatarUpload: PropTypes.func.isRequired,
|
||||
onAvatarUpdate: PropTypes.func.isRequired,
|
||||
onUsernameUpdate: PropTypes.func.isRequired,
|
||||
onUsernameUpdateMessageDismiss: PropTypes.func.isRequired,
|
||||
onEmailUpdate: PropTypes.func.isRequired,
|
||||
|
@ -112,7 +112,7 @@ UserSettingsModal.propTypes = {
|
|||
|
||||
UserSettingsModal.defaultProps = {
|
||||
username: undefined,
|
||||
avatar: undefined,
|
||||
avatarUrl: undefined,
|
||||
phone: undefined,
|
||||
organization: undefined,
|
||||
};
|
||||
|
|
|
@ -60,9 +60,9 @@ export default {
|
|||
USER_USERNAME_UPDATE_REQUESTED: 'USER_USERNAME_UPDATE_REQUESTED',
|
||||
USER_USERNAME_UPDATE_SUCCEEDED: 'USER_USERNAME_UPDATE_SUCCEEDED',
|
||||
USER_USERNAME_UPDATE_FAILED: 'USER_USERNAME_UPDATE_FAILED',
|
||||
USER_AVATAR_UPLOAD_REQUESTED: 'USER_AVATAR_UPLOAD_REQUESTED',
|
||||
USER_AVATAR_UPLOAD_SUCCEEDED: 'USER_AVATAR_UPLOAD_SUCCEEDED',
|
||||
USER_AVATAR_UPLOAD_FAILED: 'USER_AVATAR_UPLOAD_FAILED',
|
||||
USER_AVATAR_UPDATE_REQUESTED: 'USER_AVATAR_UPDATE_REQUESTED',
|
||||
USER_AVATAR_UPDATE_SUCCEEDED: 'USER_AVATAR_UPDATE_SUCCEEDED',
|
||||
USER_AVATAR_UPDATE_FAILED: 'USER_AVATAR_UPDATE_FAILED',
|
||||
USER_DELETE_REQUESTED: 'USER_DELETE_REQUESTED',
|
||||
USER_DELETE_SUCCEEDED: 'USER_DELETE_SUCCEEDED',
|
||||
USER_DELETE_FAILED: 'USER_DELETE_FAILED',
|
||||
|
@ -227,6 +227,24 @@ export default {
|
|||
TASK_DELETE_FAILED: 'TASK_DELETE_FAILED',
|
||||
TASK_DELETE_RECEIVED: 'TASK_DELETE_RECEIVED',
|
||||
|
||||
/* Task */
|
||||
|
||||
ATTACHMENT_CREATE: 'ATTACHMENT_CREATE',
|
||||
ATTACHMENT_UPDATE: 'ATTACHMENT_UPDATE',
|
||||
ATTACHMENT_DELETE: 'ATTACHMENT_DELETE',
|
||||
ATTACHMENT_CREATE_REQUESTED: 'ATTACHMENT_CREATE_REQUESTED',
|
||||
ATTACHMENT_CREATE_SUCCEEDED: 'ATTACHMENT_CREATE_SUCCEEDED',
|
||||
ATTACHMENT_CREATE_FAILED: 'ATTACHMENT_CREATE_FAILED',
|
||||
ATTACHMENT_CREATE_RECEIVED: 'ATTACHMENT_CREATE_RECEIVED',
|
||||
ATTACHMENT_UPDATE_REQUESTED: 'ATTACHMENT_UPDATE_REQUESTED',
|
||||
ATTACHMENT_UPDATE_SUCCEEDED: 'ATTACHMENT_UPDATE_SUCCEEDED',
|
||||
ATTACHMENT_UPDATE_FAILED: 'ATTACHMENT_UPDATE_FAILED',
|
||||
ATTACHMENT_UPDATE_RECEIVED: 'ATTACHMENT_UPDATE_RECEIVED',
|
||||
ATTACHMENT_DELETE_REQUESTED: 'ATTACHMENT_DELETE_REQUESTED',
|
||||
ATTACHMENT_DELETE_SUCCEEDED: 'ATTACHMENT_DELETE_SUCCEEDED',
|
||||
ATTACHMENT_DELETE_FAILED: 'ATTACHMENT_DELETE_FAILED',
|
||||
ATTACHMENT_DELETE_RECEIVED: 'ATTACHMENT_DELETE_RECEIVED',
|
||||
|
||||
/* Actions */
|
||||
|
||||
ACTIONS_FETCH_REQUESTED: 'ACTIONS_FETCH_REQUESTED',
|
||||
|
|
|
@ -26,7 +26,7 @@ export default {
|
|||
CURRENT_USER_PASSWORD_UPDATE_ERROR_CLEAR: `${PREFIX}/CURRENT_USER_PASSWORD_UPDATE_ERROR_CLEAR`,
|
||||
CURRENT_USER_USERNAME_UPDATE: `${PREFIX}/CURRENT_USER_USERNAME_UPDATE`,
|
||||
CURRENT_USER_USERNAME_UPDATE_ERROR_CLEAR: `${PREFIX}/CURRENT_USER_USERNAME_UPDATE_ERROR_CLEAR`,
|
||||
CURRENT_USER_AVATAR_UPLOAD: `${PREFIX}/CURRENT_USER_AVATAR_UPLOAD`,
|
||||
CURRENT_USER_AVATAR_UPDATE: `${PREFIX}/CURRENT_USER_AVATAR_UPDATE`,
|
||||
USER_DELETE: `${PREFIX}/USER_DELETE`,
|
||||
USER_TO_CARD_ADD: `${PREFIX}/USER_TO_CARD_ADD`,
|
||||
USER_TO_CURRENT_CARD_ADD: `${PREFIX}/USER_TO_CURRENT_CARD_ADD`,
|
||||
|
@ -87,6 +87,12 @@ export default {
|
|||
TASK_UPDATE: `${PREFIX}/TASK_UPDATE`,
|
||||
TASK_DELETE: `${PREFIX}/TASK_DELETE`,
|
||||
|
||||
/* Attachment */
|
||||
|
||||
ATTACHMENT_IN_CURRENT_CARD_CREATE: `${PREFIX}/ATTACHMENT_IN_CURRENT_CARD_CREATE`,
|
||||
ATTACHMENT_UPDATE: `${PREFIX}/ATTACHMENT_UPDATE`,
|
||||
ATTACHMENT_DELETE: `${PREFIX}/ATTACHMENT_DELETE`,
|
||||
|
||||
/* Actions */
|
||||
|
||||
ACTIONS_IN_CURRENT_CARD_FETCH: `${PREFIX}/ACTIONS_IN_CURRENT_CARD_FETCH`,
|
||||
|
|
|
@ -5,6 +5,7 @@ import omit from 'lodash/omit';
|
|||
|
||||
import {
|
||||
actionsForCurrentCardSelector,
|
||||
attachmentsForCurrentCardSelector,
|
||||
currentCardSelector,
|
||||
currentUserSelector,
|
||||
labelsForCurrentBoardSelector,
|
||||
|
@ -16,9 +17,11 @@ import {
|
|||
import {
|
||||
addLabelToCurrentCard,
|
||||
addUserToCurrentCard,
|
||||
createAttachmentInCurrentCard,
|
||||
createCommentActionInCurrentCard,
|
||||
createLabelInCurrentBoard,
|
||||
createTaskInCurrentCard,
|
||||
deleteAttachment,
|
||||
deleteCommentAction,
|
||||
deleteCurrentCard,
|
||||
deleteLabel,
|
||||
|
@ -26,6 +29,7 @@ import {
|
|||
fetchActionsInCurrentCard,
|
||||
removeLabelFromCurrentCard,
|
||||
removeUserFromCurrentCard,
|
||||
updateAttachment,
|
||||
updateCommentAction,
|
||||
updateCurrentCard,
|
||||
updateLabel,
|
||||
|
@ -53,6 +57,7 @@ const mapStateToProps = (state) => {
|
|||
const users = usersForCurrentCardSelector(state);
|
||||
const labels = labelsForCurrentCardSelector(state);
|
||||
const tasks = tasksForCurrentCardSelector(state);
|
||||
const attachments = attachmentsForCurrentCardSelector(state);
|
||||
const actions = actionsForCurrentCardSelector(state);
|
||||
|
||||
return {
|
||||
|
@ -66,6 +71,7 @@ const mapStateToProps = (state) => {
|
|||
users,
|
||||
labels,
|
||||
tasks,
|
||||
attachments,
|
||||
actions,
|
||||
allProjectMemberships,
|
||||
allLabels,
|
||||
|
@ -89,6 +95,9 @@ const mapDispatchToProps = (dispatch) =>
|
|||
onTaskCreate: createTaskInCurrentCard,
|
||||
onTaskUpdate: updateTask,
|
||||
onTaskDelete: deleteTask,
|
||||
onAttachmentCreate: createAttachmentInCurrentCard,
|
||||
onAttachmentUpdate: updateAttachment,
|
||||
onAttachmentDelete: deleteAttachment,
|
||||
onActionsFetch: fetchActionsInCurrentCard,
|
||||
onCommentActionCreate: createCommentActionInCurrentCard,
|
||||
onCommentActionUpdate: updateCommentAction,
|
||||
|
|
|
@ -8,10 +8,10 @@ import {
|
|||
clearCurrentUserUsernameUpdateError,
|
||||
closeModal,
|
||||
updateCurrentUser,
|
||||
updateCurrentUserAvatar,
|
||||
updateCurrentUserEmail,
|
||||
updateCurrentUserPassword,
|
||||
updateCurrentUserUsername,
|
||||
uploadCurrentUserAvatar,
|
||||
} from '../actions/entry';
|
||||
import UserSettingsModal from '../components/UserSettingsModal';
|
||||
|
||||
|
@ -20,11 +20,11 @@ const mapStateToProps = (state) => {
|
|||
email,
|
||||
name,
|
||||
username,
|
||||
avatar,
|
||||
avatarUrl,
|
||||
phone,
|
||||
organization,
|
||||
subscribeToOwnCards,
|
||||
isAvatarUploading,
|
||||
isAvatarUpdating,
|
||||
emailUpdateForm,
|
||||
passwordUpdateForm,
|
||||
usernameUpdateForm,
|
||||
|
@ -34,11 +34,11 @@ const mapStateToProps = (state) => {
|
|||
email,
|
||||
name,
|
||||
username,
|
||||
avatar,
|
||||
avatarUrl,
|
||||
phone,
|
||||
organization,
|
||||
subscribeToOwnCards,
|
||||
isAvatarUploading,
|
||||
isAvatarUpdating,
|
||||
emailUpdateForm,
|
||||
passwordUpdateForm,
|
||||
usernameUpdateForm,
|
||||
|
@ -49,7 +49,7 @@ const mapDispatchToProps = (dispatch) =>
|
|||
bindActionCreators(
|
||||
{
|
||||
onUpdate: updateCurrentUser,
|
||||
onAvatarUpload: uploadCurrentUserAvatar,
|
||||
onAvatarUpdate: updateCurrentUserAvatar,
|
||||
onUsernameUpdate: updateCurrentUserUsername,
|
||||
onUsernameUpdateMessageDismiss: clearCurrentUserUsernameUpdateError,
|
||||
onEmailUpdate: updateCurrentUserEmail,
|
||||
|
|
|
@ -0,0 +1,52 @@
|
|||
import React, { useCallback, useRef } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import styles from './FilePicker.module.css';
|
||||
|
||||
const FilePicker = React.memo(({ children, accept, onSelect }) => {
|
||||
const field = useRef(null);
|
||||
|
||||
const handleTriggerClick = useCallback(() => {
|
||||
field.current.click();
|
||||
}, []);
|
||||
|
||||
const handleFieldChange = useCallback(
|
||||
({ target }) => {
|
||||
if (target.files[0]) {
|
||||
onSelect(target.files[0]);
|
||||
|
||||
target.value = null; // eslint-disable-line no-param-reassign
|
||||
}
|
||||
},
|
||||
[onSelect],
|
||||
);
|
||||
|
||||
const tigger = React.cloneElement(children, {
|
||||
onClick: handleTriggerClick,
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
{tigger}
|
||||
<input
|
||||
ref={field}
|
||||
type="file"
|
||||
accept={accept}
|
||||
className={styles.field}
|
||||
onChange={handleFieldChange}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
FilePicker.propTypes = {
|
||||
children: PropTypes.element.isRequired,
|
||||
accept: PropTypes.string,
|
||||
onSelect: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
FilePicker.defaultProps = {
|
||||
accept: undefined,
|
||||
};
|
||||
|
||||
export default FilePicker;
|
|
@ -0,0 +1,3 @@
|
|||
.field {
|
||||
display: none;
|
||||
}
|
3
client/src/lib/custom-ui/components/FilePicker/index.js
Normal file
3
client/src/lib/custom-ui/components/FilePicker/index.js
Normal file
|
@ -0,0 +1,3 @@
|
|||
import FilePicker from './FilePicker';
|
||||
|
||||
export default FilePicker;
|
|
@ -1,6 +1,7 @@
|
|||
import Input from './components/Input';
|
||||
import Popup from './components/Popup';
|
||||
import Markdown from './components/Markdown';
|
||||
import FilePicker from './components/FilePicker';
|
||||
import DragScroller from './components/DragScroller';
|
||||
|
||||
export { Input, Popup, Markdown, DragScroller };
|
||||
export { Input, Popup, Markdown, FilePicker, DragScroller };
|
||||
|
|
|
@ -18,6 +18,7 @@ export default {
|
|||
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?',
|
||||
|
@ -28,6 +29,8 @@ export default {
|
|||
areYouSureYouWantToDeleteThisUser: 'Are you sure you want to delete this user?',
|
||||
areYouSureYouWantToRemoveThisMemberFromProject:
|
||||
'Are you sure you want to remove this member from project?',
|
||||
attachment: 'Attachment',
|
||||
attachments: 'Attachments',
|
||||
authentication: 'Authentication',
|
||||
boardNotFound_title: 'Board Not Found',
|
||||
cardActions_title: 'Card Actions',
|
||||
|
@ -42,6 +45,7 @@ export default {
|
|||
currentPassword: 'Current password',
|
||||
date: 'Date',
|
||||
dueDate: 'Due date',
|
||||
deleteAttachment_title: 'Delete Attachment',
|
||||
deleteBoard_title: 'Delete Board',
|
||||
deleteCard_title: 'Delete Card',
|
||||
deleteComment_title: 'Delete Comment',
|
||||
|
@ -51,6 +55,7 @@ export default {
|
|||
deleteTask_title: 'Delete Task',
|
||||
deleteUser_title: 'Delete User',
|
||||
description: 'Description',
|
||||
editAttachment_title: 'Edit Attachment',
|
||||
editAvatar_title: 'Edit Avatar',
|
||||
editBoard_title: 'Edit Board',
|
||||
editDueDate_title: 'Edit Due Date',
|
||||
|
@ -128,6 +133,7 @@ export default {
|
|||
createNewLabel: 'Create new label',
|
||||
createProject: 'Create project',
|
||||
delete: 'Delete',
|
||||
deleteAttachment: 'Delete attachment',
|
||||
deleteAvatar: 'Delete avatar',
|
||||
deleteBoard: 'Delete board',
|
||||
deleteCard: 'Delete card',
|
||||
|
|
|
@ -22,6 +22,7 @@ export default {
|
|||
all: 'Все',
|
||||
allChangesWillBeAutomaticallySavedAfterConnectionRestored:
|
||||
'Все изменения сохранятся автоматически,<br />как только подключение восстановится',
|
||||
areYouSureYouWantToDeleteThisAttachment: 'Вы уверены, что хотите удалить это вложение?',
|
||||
areYouSureYouWantToDeleteThisBoard: 'Вы уверены, что хотите удалить эту доску?',
|
||||
areYouSureYouWantToDeleteThisCard: 'Вы уверены, что хотите удалить эту карточку?',
|
||||
areYouSureYouWantToDeleteThisComment: 'Вы уверены, что хотите удалить этот комментарий?',
|
||||
|
@ -32,6 +33,8 @@ export default {
|
|||
areYouSureYouWantToDeleteThisUser: 'Вы уверены, что хотите удалить этого пользователя?',
|
||||
areYouSureYouWantToRemoveThisMemberFromProject:
|
||||
'Вы уверены, что хотите удалить этого участника из проекта?',
|
||||
attachment: 'Вложение',
|
||||
attachments: 'Вложения',
|
||||
authentication: 'Аутентификация',
|
||||
boardNotFound: 'Доска не найдена',
|
||||
cardActions: 'Действия с карточкой',
|
||||
|
@ -46,6 +49,7 @@ export default {
|
|||
currentPassword: 'Текущий пароль',
|
||||
date: 'Дата',
|
||||
dueDate: 'Срок',
|
||||
deleteAttachment: 'Удаление вложения',
|
||||
deleteBoard: 'Удаление доски',
|
||||
deleteCard: 'Удаление карточки',
|
||||
deleteComment: 'Удаление комментария',
|
||||
|
@ -55,6 +59,7 @@ export default {
|
|||
deleteTask: 'Удаление задачи',
|
||||
deleteUser: 'Удаление пользователя',
|
||||
description: 'Описание',
|
||||
editAttachment: 'Изменение вложения',
|
||||
editAvatar: 'Изменение аватара',
|
||||
editBoard: 'Изменение доски',
|
||||
editDueDate: 'Изменение срока',
|
||||
|
@ -132,6 +137,7 @@ export default {
|
|||
createNewLabel: 'Создать новую метку',
|
||||
createProject: 'Создать проект',
|
||||
delete: 'Удалить',
|
||||
deleteAttachment: 'Удалить вложение',
|
||||
deleteAvatar: 'Удалить аватар',
|
||||
deleteBoard: 'Удалить доску',
|
||||
deleteCard: 'Удалить карточку',
|
||||
|
|
57
client/src/models/Attachment.js
Normal file
57
client/src/models/Attachment.js
Normal file
|
@ -0,0 +1,57 @@
|
|||
import { Model, attr, fk } from 'redux-orm';
|
||||
|
||||
import ActionTypes from '../constants/ActionTypes';
|
||||
|
||||
export default class extends Model {
|
||||
static modelName = 'Attachment';
|
||||
|
||||
static fields = {
|
||||
id: attr(),
|
||||
url: attr(),
|
||||
thumbnailUrl: attr(),
|
||||
name: attr(),
|
||||
cardId: fk({
|
||||
to: 'Card',
|
||||
as: 'card',
|
||||
relatedName: 'attachments',
|
||||
}),
|
||||
};
|
||||
|
||||
static reducer({ type, payload }, Attachment) {
|
||||
switch (type) {
|
||||
case ActionTypes.BOARD_FETCH_SUCCEEDED:
|
||||
payload.attachments.forEach((attachment) => {
|
||||
Attachment.upsert(attachment);
|
||||
});
|
||||
|
||||
break;
|
||||
case ActionTypes.ATTACHMENT_CREATE:
|
||||
case ActionTypes.ATTACHMENT_CREATE_RECEIVED:
|
||||
Attachment.upsert(payload.attachment);
|
||||
|
||||
break;
|
||||
case ActionTypes.ATTACHMENT_UPDATE:
|
||||
Attachment.withId(payload.id).update(payload.data);
|
||||
|
||||
break;
|
||||
case ActionTypes.ATTACHMENT_DELETE:
|
||||
Attachment.withId(payload.id).delete();
|
||||
|
||||
break;
|
||||
case ActionTypes.ATTACHMENT_CREATE_SUCCEEDED:
|
||||
Attachment.withId(payload.localId).delete();
|
||||
Attachment.upsert(payload.attachment);
|
||||
|
||||
break;
|
||||
case ActionTypes.ATTACHMENT_UPDATE_RECEIVED:
|
||||
Attachment.withId(payload.attachment.id).update(payload.attachment);
|
||||
|
||||
break;
|
||||
case ActionTypes.ATTACHMENT_DELETE_RECEIVED:
|
||||
Attachment.withId(payload.attachment.id).delete();
|
||||
|
||||
break;
|
||||
default:
|
||||
}
|
||||
}
|
||||
}
|
|
@ -145,6 +145,10 @@ export default class extends Model {
|
|||
return this.tasks.orderBy('id');
|
||||
}
|
||||
|
||||
getOrderedAttachmentsQuerySet() {
|
||||
return this.attachments.orderBy('id', false);
|
||||
}
|
||||
|
||||
getOrderedInCardActionsQuerySet() {
|
||||
return this.actions.orderBy('id', false);
|
||||
}
|
||||
|
|
|
@ -36,7 +36,7 @@ export default class extends Model {
|
|||
id: attr(),
|
||||
email: attr(),
|
||||
name: attr(),
|
||||
avatar: attr(),
|
||||
avatarUrl: attr(),
|
||||
phone: attr(),
|
||||
organization: attr(),
|
||||
subscribeToOwnCards: attr(),
|
||||
|
@ -44,7 +44,7 @@ export default class extends Model {
|
|||
isAdmin: attr({
|
||||
getDefault: () => false,
|
||||
}),
|
||||
isAvatarUploading: attr({
|
||||
isAvatarUpdating: attr({
|
||||
getDefault: () => false,
|
||||
}),
|
||||
emailUpdateForm: attr({
|
||||
|
@ -228,22 +228,22 @@ export default class extends Model {
|
|||
|
||||
break;
|
||||
}
|
||||
case ActionTypes.USER_AVATAR_UPLOAD_REQUESTED:
|
||||
case ActionTypes.USER_AVATAR_UPDATE_REQUESTED:
|
||||
User.withId(payload.id).update({
|
||||
isAvatarUploading: true,
|
||||
isAvatarUpdating: true,
|
||||
});
|
||||
|
||||
break;
|
||||
case ActionTypes.USER_AVATAR_UPLOAD_SUCCEEDED:
|
||||
case ActionTypes.USER_AVATAR_UPDATE_SUCCEEDED:
|
||||
User.withId(payload.id).update({
|
||||
avatar: payload.avatar,
|
||||
isAvatarUploading: false,
|
||||
avatarUrl: payload.avatarUrl,
|
||||
isAvatarUpdating: false,
|
||||
});
|
||||
|
||||
break;
|
||||
case ActionTypes.USER_AVATAR_UPLOAD_FAILED:
|
||||
case ActionTypes.USER_AVATAR_UPDATE_FAILED:
|
||||
User.withId(payload.id).update({
|
||||
isAvatarUploading: false,
|
||||
isAvatarUpdating: false,
|
||||
});
|
||||
|
||||
break;
|
||||
|
|
|
@ -6,7 +6,20 @@ import List from './List';
|
|||
import Label from './Label';
|
||||
import Card from './Card';
|
||||
import Task from './Task';
|
||||
import Attachment from './Attachment';
|
||||
import Action from './Action';
|
||||
import Notification from './Notification';
|
||||
|
||||
export { User, Project, ProjectMembership, Board, List, Label, Card, Task, Action, Notification };
|
||||
export {
|
||||
User,
|
||||
Project,
|
||||
ProjectMembership,
|
||||
Board,
|
||||
List,
|
||||
Label,
|
||||
Card,
|
||||
Task,
|
||||
Attachment,
|
||||
Action,
|
||||
Notification,
|
||||
};
|
||||
|
|
|
@ -2,6 +2,7 @@ import { ORM } from 'redux-orm';
|
|||
|
||||
import {
|
||||
Action,
|
||||
Attachment,
|
||||
Board,
|
||||
Card,
|
||||
Label,
|
||||
|
@ -26,6 +27,7 @@ orm.register(
|
|||
Label,
|
||||
Card,
|
||||
Task,
|
||||
Attachment,
|
||||
Action,
|
||||
Notification,
|
||||
);
|
||||
|
|
92
client/src/sagas/app/requests/attachment.js
Normal file
92
client/src/sagas/app/requests/attachment.js
Normal file
|
@ -0,0 +1,92 @@
|
|||
import { call, put } from 'redux-saga/effects';
|
||||
|
||||
import request from './request';
|
||||
import {
|
||||
createAttachmentFailed,
|
||||
createAttachmentRequested,
|
||||
createAttachmentSucceeded,
|
||||
deleteAttachmentFailed,
|
||||
deleteAttachmentRequested,
|
||||
deleteAttachmentSucceeded,
|
||||
updateAttachmentFailed,
|
||||
updateAttachmentRequested,
|
||||
updateAttachmentSucceeded,
|
||||
} from '../../../actions';
|
||||
import api from '../../../api';
|
||||
|
||||
export function* createAttachmentRequest(cardId, localId, data) {
|
||||
yield put(
|
||||
createAttachmentRequested(localId, {
|
||||
...data,
|
||||
cardId,
|
||||
}),
|
||||
);
|
||||
|
||||
try {
|
||||
const { item } = yield call(request, api.createAttachment, cardId, data);
|
||||
|
||||
const action = createAttachmentSucceeded(localId, item);
|
||||
yield put(action);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
payload: action.payload,
|
||||
};
|
||||
} catch (error) {
|
||||
const action = createAttachmentFailed(localId, error);
|
||||
yield put(action);
|
||||
|
||||
return {
|
||||
success: false,
|
||||
payload: action.payload,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function* updateAttachmentRequest(id, data) {
|
||||
yield put(updateAttachmentRequested(id, data));
|
||||
|
||||
try {
|
||||
const { item } = yield call(request, api.updateAttachment, id, data);
|
||||
|
||||
const action = updateAttachmentSucceeded(item);
|
||||
yield put(action);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
payload: action.payload,
|
||||
};
|
||||
} catch (error) {
|
||||
const action = updateAttachmentFailed(error);
|
||||
yield put(action);
|
||||
|
||||
return {
|
||||
success: false,
|
||||
payload: action.payload,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function* deleteAttachmentRequest(id) {
|
||||
yield put(deleteAttachmentRequested(id));
|
||||
|
||||
try {
|
||||
const { item } = yield call(request, api.deleteAttachment, id);
|
||||
|
||||
const action = deleteAttachmentSucceeded(item);
|
||||
yield put(action);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
payload: action.payload,
|
||||
};
|
||||
} catch (error) {
|
||||
const action = deleteAttachmentFailed(error);
|
||||
yield put(action);
|
||||
|
||||
return {
|
||||
success: false,
|
||||
payload: action.payload,
|
||||
};
|
||||
}
|
||||
}
|
|
@ -55,7 +55,7 @@ export function* fetchBoardRequest(id) {
|
|||
try {
|
||||
const {
|
||||
item,
|
||||
included: { lists, labels, cards, cardMemberships, cardLabels, tasks },
|
||||
included: { lists, labels, cards, cardMemberships, cardLabels, tasks, attachments },
|
||||
} = yield call(request, api.getBoard, id);
|
||||
|
||||
const action = fetchBoardSucceeded(
|
||||
|
@ -66,6 +66,7 @@ export function* fetchBoardRequest(id) {
|
|||
cardMemberships,
|
||||
cardLabels,
|
||||
tasks,
|
||||
attachments,
|
||||
);
|
||||
yield put(action);
|
||||
|
||||
|
|
|
@ -10,6 +10,7 @@ export * from './card';
|
|||
export * from './card-membership';
|
||||
export * from './card-label';
|
||||
export * from './task';
|
||||
export * from './attachment';
|
||||
export * from './actions';
|
||||
export * from './comment-action';
|
||||
export * from './notifications';
|
||||
|
|
|
@ -11,6 +11,9 @@ import {
|
|||
fetchCurrentUserFailed,
|
||||
fetchCurrentUserRequested,
|
||||
fetchCurrentUserSucceeded,
|
||||
updateUserAvatarFailed,
|
||||
updateUserAvatarRequested,
|
||||
updateUserAvatarSucceeded,
|
||||
updateUserEmailFailed,
|
||||
updateUserEmailRequested,
|
||||
updateUserEmailSucceeded,
|
||||
|
@ -23,9 +26,6 @@ import {
|
|||
updateUserUsernameFailed,
|
||||
updateUserUsernameRequested,
|
||||
updateUserUsernameSucceeded,
|
||||
uploadUserAvatarFailed,
|
||||
uploadUserAvatarRequested,
|
||||
uploadUserAvatarSucceeded,
|
||||
} from '../../../actions';
|
||||
import api from '../../../api';
|
||||
|
||||
|
@ -173,13 +173,13 @@ export function* updateUserUsernameRequest(id, data) {
|
|||
}
|
||||
}
|
||||
|
||||
export function* uploadUserAvatarRequest(id, file) {
|
||||
yield put(uploadUserAvatarRequested(id));
|
||||
export function* updateUserAvatarRequest(id, data) {
|
||||
yield put(updateUserAvatarRequested(id));
|
||||
|
||||
try {
|
||||
const { item } = yield call(request, api.uploadUserAvatar, id, file);
|
||||
const { item } = yield call(request, api.updateUserAvatar, id, data);
|
||||
|
||||
const action = uploadUserAvatarSucceeded(id, item);
|
||||
const action = updateUserAvatarSucceeded(id, item);
|
||||
yield put(action);
|
||||
|
||||
return {
|
||||
|
@ -187,7 +187,7 @@ export function* uploadUserAvatarRequest(id, file) {
|
|||
payload: action.payload,
|
||||
};
|
||||
} catch (error) {
|
||||
const action = uploadUserAvatarFailed(id, error);
|
||||
const action = updateUserAvatarFailed(id, error);
|
||||
yield put(action);
|
||||
|
||||
return {
|
||||
|
|
40
client/src/sagas/app/services/attachment.js
Normal file
40
client/src/sagas/app/services/attachment.js
Normal file
|
@ -0,0 +1,40 @@
|
|||
import { call, put, select } from 'redux-saga/effects';
|
||||
|
||||
import {
|
||||
createAttachmentRequest,
|
||||
deleteAttachmentRequest,
|
||||
updateAttachmentRequest,
|
||||
} from '../requests';
|
||||
import { pathSelector } from '../../../selectors';
|
||||
import { createAttachment, deleteAttachment, updateAttachment } from '../../../actions';
|
||||
import { createLocalId } from '../../../utils/local-id';
|
||||
|
||||
export function* createAttachmentService(cardId, data) {
|
||||
const localId = yield call(createLocalId);
|
||||
|
||||
yield put(
|
||||
createAttachment({
|
||||
cardId,
|
||||
id: localId,
|
||||
name: data.file.name,
|
||||
}),
|
||||
);
|
||||
|
||||
yield call(createAttachmentRequest, cardId, localId, data);
|
||||
}
|
||||
|
||||
export function* createAttachmentInCurrentCardService(data) {
|
||||
const { cardId } = yield select(pathSelector);
|
||||
|
||||
yield call(createAttachmentService, cardId, data);
|
||||
}
|
||||
|
||||
export function* updateAttachmentService(id, data) {
|
||||
yield put(updateAttachment(id, data));
|
||||
yield call(updateAttachmentRequest, id, data);
|
||||
}
|
||||
|
||||
export function* deleteAttachmentService(id) {
|
||||
yield put(deleteAttachment(id));
|
||||
yield call(deleteAttachmentRequest, id);
|
||||
}
|
|
@ -11,6 +11,7 @@ export * from './list';
|
|||
export * from './label';
|
||||
export * from './card';
|
||||
export * from './task';
|
||||
export * from './attachment';
|
||||
export * from './actions';
|
||||
export * from './comment-action';
|
||||
export * from './notifications';
|
||||
|
|
|
@ -12,6 +12,7 @@ import {
|
|||
} from '../../../selectors';
|
||||
import {
|
||||
createActionReceived,
|
||||
createAttachmentReceived,
|
||||
createBoardReceived,
|
||||
createCardLabelReceived,
|
||||
createCardMembershipReceived,
|
||||
|
@ -24,6 +25,7 @@ import {
|
|||
createTaskReceived,
|
||||
createUserReceived,
|
||||
deleteActionReceived,
|
||||
deleteAttachmentReceived,
|
||||
deleteCardLabelReceived,
|
||||
deleteCardMembershipReceived,
|
||||
deleteCardReceived,
|
||||
|
@ -38,6 +40,7 @@ import {
|
|||
socketDisconnected,
|
||||
socketReconnected,
|
||||
updateActionReceived,
|
||||
updateAttachmentReceived,
|
||||
updateBoardReceived,
|
||||
updateCardReceived,
|
||||
updateLabelReceived,
|
||||
|
@ -205,6 +208,18 @@ export function* deleteTaskReceivedService(task) {
|
|||
yield put(deleteTaskReceived(task));
|
||||
}
|
||||
|
||||
export function* createAttachmentReceivedService(attachment) {
|
||||
yield put(createAttachmentReceived(attachment));
|
||||
}
|
||||
|
||||
export function* updateAttachmentReceivedService(attachment) {
|
||||
yield put(updateAttachmentReceived(attachment));
|
||||
}
|
||||
|
||||
export function* deleteAttachmentReceivedService(attachment) {
|
||||
yield put(deleteAttachmentReceived(attachment));
|
||||
}
|
||||
|
||||
export function* createActionReceivedService(action) {
|
||||
yield put(createActionReceived(action));
|
||||
}
|
||||
|
|
|
@ -5,11 +5,11 @@ import {
|
|||
createUserRequest,
|
||||
deleteCardMembershipRequest,
|
||||
deleteUserRequest,
|
||||
updateUserAvatarRequest,
|
||||
updateUserEmailRequest,
|
||||
updateUserPasswordRequest,
|
||||
updateUserRequest,
|
||||
updateUserUsernameRequest,
|
||||
uploadUserAvatarRequest,
|
||||
} from '../requests';
|
||||
import { currentUserIdSelector, pathSelector } from '../../../selectors';
|
||||
import {
|
||||
|
@ -106,14 +106,14 @@ export function* clearCurrentUserUsernameUpdateErrorService() {
|
|||
yield call(clearUserUsernameUpdateErrorService, id);
|
||||
}
|
||||
|
||||
export function* uploadUserAvatarService(id, file) {
|
||||
yield call(uploadUserAvatarRequest, id, file);
|
||||
export function* updateUserAvatarService(id, data) {
|
||||
yield call(updateUserAvatarRequest, id, data);
|
||||
}
|
||||
|
||||
export function* uploadCurrentUserAvatarService(file) {
|
||||
export function* updateCurrentUserAvatarService(data) {
|
||||
const id = yield select(currentUserIdSelector);
|
||||
|
||||
yield call(uploadUserAvatarService, id, file);
|
||||
yield call(updateUserAvatarService, id, data);
|
||||
}
|
||||
|
||||
export function* deleteUserService(id) {
|
||||
|
|
22
client/src/sagas/app/watchers/attachment.js
Normal file
22
client/src/sagas/app/watchers/attachment.js
Normal file
|
@ -0,0 +1,22 @@
|
|||
import { all, takeLatest } from 'redux-saga/effects';
|
||||
|
||||
import {
|
||||
createAttachmentInCurrentCardService,
|
||||
deleteAttachmentService,
|
||||
updateAttachmentService,
|
||||
} from '../services';
|
||||
import EntryActionTypes from '../../../constants/EntryActionTypes';
|
||||
|
||||
export default function* () {
|
||||
yield all([
|
||||
takeLatest(EntryActionTypes.ATTACHMENT_IN_CURRENT_CARD_CREATE, ({ payload: { data } }) =>
|
||||
createAttachmentInCurrentCardService(data),
|
||||
),
|
||||
takeLatest(EntryActionTypes.ATTACHMENT_UPDATE, ({ payload: { id, data } }) =>
|
||||
updateAttachmentService(id, data),
|
||||
),
|
||||
takeLatest(EntryActionTypes.ATTACHMENT_DELETE, ({ payload: { id } }) =>
|
||||
deleteAttachmentService(id),
|
||||
),
|
||||
]);
|
||||
}
|
|
@ -10,6 +10,7 @@ import list from './list';
|
|||
import label from './label';
|
||||
import card from './card';
|
||||
import task from './task';
|
||||
import attachment from './attachment';
|
||||
import actions from './actions';
|
||||
import commentAction from './comment-action';
|
||||
import notification from './notification';
|
||||
|
@ -27,6 +28,7 @@ export default [
|
|||
label,
|
||||
card,
|
||||
task,
|
||||
attachment,
|
||||
actions,
|
||||
commentAction,
|
||||
notification,
|
||||
|
|
|
@ -3,6 +3,7 @@ import { call, cancelled, take } from 'redux-saga/effects';
|
|||
|
||||
import {
|
||||
createActionReceivedService,
|
||||
createAttachmentReceivedService,
|
||||
createBoardReceivedService,
|
||||
createCardLabelReceivedService,
|
||||
createCardMembershipReceivedService,
|
||||
|
@ -15,6 +16,7 @@ import {
|
|||
createTaskReceivedService,
|
||||
createUserReceivedService,
|
||||
deleteActionReceivedService,
|
||||
deleteAttachmentReceivedService,
|
||||
deleteCardLabelReceivedService,
|
||||
deleteCardMembershipReceivedService,
|
||||
deleteCardReceivedService,
|
||||
|
@ -29,6 +31,7 @@ import {
|
|||
socketDisconnectedService,
|
||||
socketReconnectedService,
|
||||
updateActionReceivedService,
|
||||
updateAttachmentReceivedService,
|
||||
updateBoardReceivedService,
|
||||
updateCardReceivedService,
|
||||
updateLabelReceivedService,
|
||||
|
@ -153,6 +156,18 @@ const createSocketEventsChannel = () =>
|
|||
emit([deleteTaskReceivedService, item]);
|
||||
};
|
||||
|
||||
const handleAttachmentCreate = api.makeHandleAttachmentCreate(({ item }) => {
|
||||
emit([createAttachmentReceivedService, item]);
|
||||
});
|
||||
|
||||
const handleAttachmentUpdate = api.makeHandleAttachmentUpdate(({ item }) => {
|
||||
emit([updateAttachmentReceivedService, item]);
|
||||
});
|
||||
|
||||
const handleAttachmentDelete = api.makeHandleAttachmentDelete(({ item }) => {
|
||||
emit([deleteAttachmentReceivedService, item]);
|
||||
});
|
||||
|
||||
const handleActionCreate = api.makeHandleActionCreate(({ item }) => {
|
||||
emit([createActionReceivedService, item]);
|
||||
});
|
||||
|
@ -222,6 +237,10 @@ const createSocketEventsChannel = () =>
|
|||
socket.on('taskUpdate', handleTaskUpdate);
|
||||
socket.on('taskDelete', handleTaskDelete);
|
||||
|
||||
socket.on('attachmentCreate', handleAttachmentCreate);
|
||||
socket.on('attachmentUpdate', handleAttachmentUpdate);
|
||||
socket.on('attachmentDelete', handleAttachmentDelete);
|
||||
|
||||
socket.on('actionCreate', handleActionCreate);
|
||||
socket.on('actionUpdate', handleActionUpdate);
|
||||
socket.on('actionDelete', handleActionDelete);
|
||||
|
@ -270,6 +289,10 @@ const createSocketEventsChannel = () =>
|
|||
socket.off('taskUpdate', handleTaskUpdate);
|
||||
socket.off('taskDelete', handleTaskDelete);
|
||||
|
||||
socket.off('attachmentCreate', handleAttachmentCreate);
|
||||
socket.off('attachmentUpdate', handleAttachmentUpdate);
|
||||
socket.off('attachmentDelete', handleAttachmentDelete);
|
||||
|
||||
socket.off('actionCreate', handleActionCreate);
|
||||
socket.off('actionUpdate', handleActionUpdate);
|
||||
socket.off('actionDelete', handleActionDelete);
|
||||
|
|
|
@ -14,11 +14,11 @@ import {
|
|||
removeUserFromCurrentCardService,
|
||||
removeUserFromFilterInCurrentBoardService,
|
||||
updateUserService,
|
||||
updateCurrentUserAvatarService,
|
||||
updateCurrentUserEmailService,
|
||||
updateCurrentUserPasswordService,
|
||||
updateCurrentUserService,
|
||||
updateCurrentUserUsernameService,
|
||||
uploadCurrentUserAvatarService,
|
||||
} from '../services';
|
||||
import EntryActionTypes from '../../../constants/EntryActionTypes';
|
||||
|
||||
|
@ -50,8 +50,8 @@ export default function* () {
|
|||
takeLatest(EntryActionTypes.CURRENT_USER_USERNAME_UPDATE_ERROR_CLEAR, () =>
|
||||
clearCurrentUserUsernameUpdateErrorService(),
|
||||
),
|
||||
takeLatest(EntryActionTypes.CURRENT_USER_AVATAR_UPLOAD, ({ payload: { file } }) =>
|
||||
uploadCurrentUserAvatarService(file),
|
||||
takeLatest(EntryActionTypes.CURRENT_USER_AVATAR_UPDATE, ({ payload: { data } }) =>
|
||||
updateCurrentUserAvatarService(data),
|
||||
),
|
||||
takeLatest(EntryActionTypes.USER_DELETE, ({ payload: { id } }) => deleteUserService(id)),
|
||||
takeLatest(EntryActionTypes.USER_TO_CARD_ADD, ({ payload: { id, cardId } }) =>
|
||||
|
|
|
@ -336,6 +336,30 @@ export const tasksForCurrentCardSelector = createSelector(
|
|||
},
|
||||
);
|
||||
|
||||
export const attachmentsForCurrentCardSelector = createSelector(
|
||||
orm,
|
||||
(state) => pathSelector(state).cardId,
|
||||
({ Card }, id) => {
|
||||
if (!id) {
|
||||
return id;
|
||||
}
|
||||
|
||||
const cardModel = Card.withId(id);
|
||||
|
||||
if (!cardModel) {
|
||||
return cardModel;
|
||||
}
|
||||
|
||||
return cardModel
|
||||
.getOrderedAttachmentsQuerySet()
|
||||
.toRefArray()
|
||||
.map((attachment) => ({
|
||||
...attachment,
|
||||
isPersisted: !isLocalId(attachment.id),
|
||||
}));
|
||||
},
|
||||
);
|
||||
|
||||
export const actionsForCurrentCardSelector = createSelector(
|
||||
orm,
|
||||
(state) => pathSelector(state).cardId,
|
||||
|
|
|
@ -13,7 +13,8 @@ services:
|
|||
done; (exit $$s)"
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- uploads:/app/public/uploads
|
||||
- user-avatars:/app/public/user-avatars
|
||||
- attachments:/app/public/attachments
|
||||
ports:
|
||||
- 3000:1337
|
||||
environment:
|
||||
|
@ -30,7 +31,9 @@ services:
|
|||
- db-data:/var/lib/postgresql/data
|
||||
environment:
|
||||
- POSTGRES_DB=planka
|
||||
- POSTGRES_HOST_AUTH_METHOD=trust
|
||||
|
||||
volumes:
|
||||
uploads:
|
||||
user-avatars:
|
||||
attachments:
|
||||
db-data:
|
||||
|
|
9
server/.gitignore
vendored
9
server/.gitignore
vendored
|
@ -128,9 +128,12 @@ lib-cov
|
|||
*.pid
|
||||
|
||||
public/*
|
||||
!public/uploads
|
||||
public/uploads/*
|
||||
!public/uploads/.gitkeep
|
||||
!public/user-avatars
|
||||
public/user-avatars/*
|
||||
!public/user-avatars/.gitkeep
|
||||
!public/attachments
|
||||
public/attachments/*
|
||||
!public/attachments/.gitkeep
|
||||
|
||||
views/*
|
||||
!views/.gitkeep
|
||||
|
|
68
server/api/controllers/attachments/create.js
Normal file
68
server/api/controllers/attachments/create.js
Normal file
|
@ -0,0 +1,68 @@
|
|||
const Errors = {
|
||||
CARD_NOT_FOUND: {
|
||||
cardNotFound: 'Card not found',
|
||||
},
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
inputs: {
|
||||
cardId: {
|
||||
type: 'string',
|
||||
regex: /^[0-9]+$/,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
|
||||
exits: {
|
||||
cardNotFound: {
|
||||
responseType: 'notFound',
|
||||
},
|
||||
uploadError: {
|
||||
responseType: 'unprocessableEntity',
|
||||
},
|
||||
},
|
||||
|
||||
async fn(inputs, exits) {
|
||||
const { currentUser } = this.req;
|
||||
|
||||
const { card, project } = await sails.helpers
|
||||
.getCardToProjectPath(inputs.cardId)
|
||||
.intercept('pathNotFound', () => Errors.CARD_NOT_FOUND);
|
||||
|
||||
const isUserMemberForProject = await sails.helpers.isUserMemberForProject(
|
||||
project.id,
|
||||
currentUser.id,
|
||||
);
|
||||
|
||||
if (!isUserMemberForProject) {
|
||||
throw Errors.CARD_NOT_FOUND; // Forbidden
|
||||
}
|
||||
|
||||
this.req.file('file').upload(sails.helpers.createAttachmentReceiver(), async (error, files) => {
|
||||
if (error) {
|
||||
return exits.uploadError(error.message);
|
||||
}
|
||||
|
||||
if (files.length === 0) {
|
||||
return exits.uploadError('No file was uploaded');
|
||||
}
|
||||
|
||||
const file = files[0];
|
||||
|
||||
const attachment = await sails.helpers.createAttachment(
|
||||
card,
|
||||
{
|
||||
dirname: file.extra.dirname,
|
||||
filename: file.filename,
|
||||
isImage: file.extra.isImage,
|
||||
name: file.filename,
|
||||
},
|
||||
this.req,
|
||||
);
|
||||
|
||||
return exits.success({
|
||||
item: attachment.toJSON(),
|
||||
});
|
||||
});
|
||||
},
|
||||
};
|
51
server/api/controllers/attachments/delete.js
Executable file
51
server/api/controllers/attachments/delete.js
Executable file
|
@ -0,0 +1,51 @@
|
|||
const Errors = {
|
||||
ATTACHMENT_NOT_FOUND: {
|
||||
attachmentNotFound: 'Attachment not found',
|
||||
},
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
inputs: {
|
||||
id: {
|
||||
type: 'string',
|
||||
regex: /^[0-9]+$/,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
|
||||
exits: {
|
||||
attachmentNotFound: {
|
||||
responseType: 'notFound',
|
||||
},
|
||||
},
|
||||
|
||||
async fn(inputs, exits) {
|
||||
const { currentUser } = this.req;
|
||||
|
||||
const attachmentToProjectPath = await sails.helpers
|
||||
.getAttachmentToProjectPath(inputs.id)
|
||||
.intercept('pathNotFound', () => Errors.ATTACHMENT_NOT_FOUND);
|
||||
|
||||
let { attachment } = attachmentToProjectPath;
|
||||
const { board, project } = attachmentToProjectPath;
|
||||
|
||||
const isUserMemberForProject = await sails.helpers.isUserMemberForProject(
|
||||
project.id,
|
||||
currentUser.id,
|
||||
);
|
||||
|
||||
if (!isUserMemberForProject) {
|
||||
throw Errors.ATTACHMENT_NOT_FOUND; // Forbidden
|
||||
}
|
||||
|
||||
attachment = await sails.helpers.deleteAttachment(attachment, board, this.req);
|
||||
|
||||
if (!attachment) {
|
||||
throw Errors.ATTACHMENT_NOT_FOUND;
|
||||
}
|
||||
|
||||
return exits.success({
|
||||
item: attachment,
|
||||
});
|
||||
},
|
||||
};
|
57
server/api/controllers/attachments/update.js
Executable file
57
server/api/controllers/attachments/update.js
Executable file
|
@ -0,0 +1,57 @@
|
|||
const Errors = {
|
||||
ATTACHMENT_NOT_FOUND: {
|
||||
attachmentNotFound: 'Attachment not found',
|
||||
},
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
inputs: {
|
||||
id: {
|
||||
type: 'string',
|
||||
regex: /^[0-9]+$/,
|
||||
required: true,
|
||||
},
|
||||
name: {
|
||||
type: 'string',
|
||||
isNotEmptyString: true,
|
||||
},
|
||||
},
|
||||
|
||||
exits: {
|
||||
attachmentNotFound: {
|
||||
responseType: 'notFound',
|
||||
},
|
||||
},
|
||||
|
||||
async fn(inputs, exits) {
|
||||
const { currentUser } = this.req;
|
||||
|
||||
const attachmentToProjectPath = await sails.helpers
|
||||
.getAttachmentToProjectPath(inputs.id)
|
||||
.intercept('pathNotFound', () => Errors.ATTACHMENT_NOT_FOUND);
|
||||
|
||||
let { attachment } = attachmentToProjectPath;
|
||||
const { board, project } = attachmentToProjectPath;
|
||||
|
||||
const isUserMemberForProject = await sails.helpers.isUserMemberForProject(
|
||||
project.id,
|
||||
currentUser.id,
|
||||
);
|
||||
|
||||
if (!isUserMemberForProject) {
|
||||
throw Errors.ATTACHMENT_NOT_FOUND; // Forbidden
|
||||
}
|
||||
|
||||
const values = _.pick(inputs, ['name']);
|
||||
|
||||
attachment = await sails.helpers.updateAttachment(attachment, values, board, this.req);
|
||||
|
||||
if (!attachment) {
|
||||
throw Errors.ATTACHMENT_NOT_FOUND;
|
||||
}
|
||||
|
||||
return exits.success({
|
||||
item: attachment,
|
||||
});
|
||||
},
|
||||
};
|
|
@ -55,6 +55,7 @@ module.exports = {
|
|||
const cardLabels = await sails.helpers.getCardLabelsForCard(cardIds);
|
||||
|
||||
const tasks = await sails.helpers.getTasksForCard(cardIds);
|
||||
const attachments = await sails.helpers.getAttachmentsForCard(cardIds);
|
||||
|
||||
const isSubscribedByCardId = cardSubscriptions.reduce(
|
||||
(result, cardSubscription) => ({
|
||||
|
@ -80,6 +81,7 @@ module.exports = {
|
|||
cardMemberships,
|
||||
cardLabels,
|
||||
tasks,
|
||||
attachments,
|
||||
},
|
||||
});
|
||||
},
|
||||
|
|
67
server/api/controllers/users/update-avatar.js
Executable file
67
server/api/controllers/users/update-avatar.js
Executable file
|
@ -0,0 +1,67 @@
|
|||
const Errors = {
|
||||
USER_NOT_FOUND: {
|
||||
userNotFound: 'User not found',
|
||||
},
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
inputs: {
|
||||
id: {
|
||||
type: 'string',
|
||||
regex: /^[0-9]+$/,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
|
||||
exits: {
|
||||
userNotFound: {
|
||||
responseType: 'notFound',
|
||||
},
|
||||
uploadError: {
|
||||
responseType: 'unprocessableEntity',
|
||||
},
|
||||
},
|
||||
|
||||
async fn(inputs, exits) {
|
||||
const { currentUser } = this.req;
|
||||
|
||||
let user;
|
||||
if (currentUser.isAdmin) {
|
||||
user = await sails.helpers.getUser(inputs.id);
|
||||
|
||||
if (!user) {
|
||||
throw Errors.USER_NOT_FOUND;
|
||||
}
|
||||
} else if (inputs.id !== currentUser.id) {
|
||||
throw Errors.USER_NOT_FOUND; // Forbidden
|
||||
} else {
|
||||
user = currentUser;
|
||||
}
|
||||
|
||||
this.req.file('file').upload(sails.helpers.createAvatarReceiver(), async (error, files) => {
|
||||
if (error) {
|
||||
return exits.uploadError(error.message);
|
||||
}
|
||||
|
||||
if (files.length === 0) {
|
||||
return exits.uploadError('No file was uploaded');
|
||||
}
|
||||
|
||||
user = await sails.helpers.updateUser(
|
||||
user,
|
||||
{
|
||||
avatarDirname: files[0].extra.dirname,
|
||||
},
|
||||
this.req,
|
||||
);
|
||||
|
||||
if (!user) {
|
||||
throw Errors.USER_NOT_FOUND;
|
||||
}
|
||||
|
||||
return exits.success({
|
||||
item: user.toJSON().avatarUrl,
|
||||
});
|
||||
});
|
||||
},
|
||||
};
|
|
@ -18,7 +18,7 @@ module.exports = {
|
|||
type: 'string',
|
||||
isNotEmptyString: true,
|
||||
},
|
||||
avatar: {
|
||||
avatarUrl: {
|
||||
type: 'json',
|
||||
custom: (value) => _.isNull(value),
|
||||
},
|
||||
|
@ -63,7 +63,7 @@ module.exports = {
|
|||
const values = _.pick(inputs, [
|
||||
'isAdmin',
|
||||
'name',
|
||||
'avatar',
|
||||
'avatarUrl',
|
||||
'phone',
|
||||
'organization',
|
||||
'subscribeToOwnCards',
|
||||
|
|
|
@ -1,122 +0,0 @@
|
|||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const util = require('util');
|
||||
const stream = require('stream');
|
||||
const { v4: uuid } = require('uuid');
|
||||
const sharp = require('sharp');
|
||||
|
||||
const Errors = {
|
||||
USER_NOT_FOUND: {
|
||||
userNotFound: 'User not found',
|
||||
},
|
||||
};
|
||||
|
||||
const pipeline = util.promisify(stream.pipeline);
|
||||
|
||||
const createReceiver = () => {
|
||||
const receiver = stream.Writable({ objectMode: true });
|
||||
|
||||
let firstFileHandled = false;
|
||||
// eslint-disable-next-line no-underscore-dangle
|
||||
receiver._write = async (file, receiverEncoding, done) => {
|
||||
if (firstFileHandled) {
|
||||
file.pipe(
|
||||
new stream.Writable({
|
||||
write(chunk, streamEncoding, callback) {
|
||||
callback();
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
return done();
|
||||
}
|
||||
firstFileHandled = true;
|
||||
|
||||
const resize = sharp().resize(100, 100).jpeg();
|
||||
|
||||
const transform = new stream.Transform({
|
||||
transform(chunk, streamEncoding, callback) {
|
||||
callback(null, chunk);
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
await pipeline(file, resize, transform);
|
||||
|
||||
file.fd = `${uuid()}.jpg`; // eslint-disable-line no-param-reassign
|
||||
|
||||
await pipeline(
|
||||
transform,
|
||||
fs.createWriteStream(path.join(sails.config.custom.uploadsPath, file.fd)),
|
||||
);
|
||||
|
||||
return done();
|
||||
} catch (error) {
|
||||
return done(error);
|
||||
}
|
||||
};
|
||||
|
||||
return receiver;
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
inputs: {
|
||||
id: {
|
||||
type: 'string',
|
||||
regex: /^[0-9]+$/,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
|
||||
exits: {
|
||||
userNotFound: {
|
||||
responseType: 'notFound',
|
||||
},
|
||||
uploadError: {
|
||||
responseType: 'unprocessableEntity',
|
||||
},
|
||||
},
|
||||
|
||||
async fn(inputs, exits) {
|
||||
const { currentUser } = this.req;
|
||||
|
||||
let user;
|
||||
if (currentUser.isAdmin) {
|
||||
user = await sails.helpers.getUser(inputs.id);
|
||||
|
||||
if (!user) {
|
||||
throw Errors.USER_NOT_FOUND;
|
||||
}
|
||||
} else if (inputs.id !== currentUser.id) {
|
||||
throw Errors.USER_NOT_FOUND; // Forbidden
|
||||
} else {
|
||||
user = currentUser;
|
||||
}
|
||||
|
||||
this.req.file('file').upload(createReceiver(), async (error, files) => {
|
||||
if (error) {
|
||||
return exits.uploadError(error.message);
|
||||
}
|
||||
|
||||
if (files.length === 0) {
|
||||
return exits.uploadError('No file was uploaded');
|
||||
}
|
||||
|
||||
user = await sails.helpers.updateUser(
|
||||
user,
|
||||
{
|
||||
avatar: files[0].fd,
|
||||
},
|
||||
this.req,
|
||||
);
|
||||
|
||||
if (!user) {
|
||||
throw Errors.USER_NOT_FOUND;
|
||||
}
|
||||
|
||||
return exits.success({
|
||||
item: user.toJSON().avatar,
|
||||
});
|
||||
});
|
||||
},
|
||||
};
|
65
server/api/helpers/create-attachment-receiver.js
Normal file
65
server/api/helpers/create-attachment-receiver.js
Normal file
|
@ -0,0 +1,65 @@
|
|||
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 writeFile = util.promisify(fs.writeFile);
|
||||
|
||||
module.exports = {
|
||||
sync: true,
|
||||
|
||||
fn(inputs, exits) {
|
||||
const receiver = stream.Writable({
|
||||
objectMode: true,
|
||||
});
|
||||
|
||||
let firstFileHandled = false;
|
||||
// eslint-disable-next-line no-underscore-dangle
|
||||
receiver._write = async (file, receiverEncoding, done) => {
|
||||
if (firstFileHandled) {
|
||||
file.pipe(new stream.Writable());
|
||||
|
||||
return done();
|
||||
}
|
||||
firstFileHandled = true;
|
||||
|
||||
const buffer = await streamToArray(file).then((parts) =>
|
||||
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);
|
||||
|
||||
if (thumbnailBuffer) {
|
||||
await writeFile(path.join(dirPath, '240.jpg'), thumbnailBuffer);
|
||||
}
|
||||
|
||||
await writeFile(path.join(dirPath, file.filename), buffer);
|
||||
|
||||
// eslint-disable-next-line no-param-reassign
|
||||
file.extra = {
|
||||
dirname,
|
||||
isImage: !!thumbnailBuffer,
|
||||
};
|
||||
|
||||
return done();
|
||||
} catch (error) {
|
||||
return done(error);
|
||||
}
|
||||
};
|
||||
|
||||
return exits.success(receiver);
|
||||
},
|
||||
};
|
33
server/api/helpers/create-attachment.js
Normal file
33
server/api/helpers/create-attachment.js
Normal file
|
@ -0,0 +1,33 @@
|
|||
module.exports = {
|
||||
inputs: {
|
||||
card: {
|
||||
type: 'ref',
|
||||
required: true,
|
||||
},
|
||||
values: {
|
||||
type: 'json',
|
||||
required: true,
|
||||
},
|
||||
request: {
|
||||
type: 'ref',
|
||||
},
|
||||
},
|
||||
|
||||
async fn(inputs, exits) {
|
||||
const attachment = await Attachment.create({
|
||||
...inputs.values,
|
||||
cardId: inputs.card.id,
|
||||
}).fetch();
|
||||
|
||||
sails.sockets.broadcast(
|
||||
`board:${inputs.card.boardId}`,
|
||||
'attachmentCreate',
|
||||
{
|
||||
item: attachment,
|
||||
},
|
||||
inputs.request,
|
||||
);
|
||||
|
||||
return exits.success(attachment);
|
||||
},
|
||||
};
|
54
server/api/helpers/create-avatar-receiver.js
Normal file
54
server/api/helpers/create-avatar-receiver.js
Normal file
|
@ -0,0 +1,54 @@
|
|||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const util = require('util');
|
||||
const stream = require('stream');
|
||||
const { v4: uuid } = require('uuid');
|
||||
const sharp = require('sharp');
|
||||
|
||||
const pipeline = util.promisify(stream.pipeline);
|
||||
|
||||
module.exports = {
|
||||
sync: true,
|
||||
|
||||
fn(inputs, exits) {
|
||||
const receiver = stream.Writable({
|
||||
objectMode: true,
|
||||
});
|
||||
|
||||
let firstFileHandled = false;
|
||||
// eslint-disable-next-line no-underscore-dangle
|
||||
receiver._write = async (file, receiverEncoding, done) => {
|
||||
if (firstFileHandled) {
|
||||
file.pipe(new stream.Writable());
|
||||
|
||||
return done();
|
||||
}
|
||||
firstFileHandled = true;
|
||||
|
||||
const resize = sharp().resize(100, 100).jpeg();
|
||||
const passThrought = new stream.PassThrough();
|
||||
|
||||
try {
|
||||
await pipeline(file, resize, passThrought);
|
||||
|
||||
const dirname = uuid();
|
||||
const dirPath = path.join(sails.config.custom.userAvatarsPath, dirname);
|
||||
|
||||
fs.mkdirSync(dirPath);
|
||||
|
||||
await pipeline(passThrought, fs.createWriteStream(path.join(dirPath, '100.jpg')));
|
||||
|
||||
// eslint-disable-next-line no-param-reassign
|
||||
file.extra = {
|
||||
dirname,
|
||||
};
|
||||
|
||||
return done();
|
||||
} catch (error) {
|
||||
return done(error);
|
||||
}
|
||||
};
|
||||
|
||||
return exits.success(receiver);
|
||||
},
|
||||
};
|
41
server/api/helpers/delete-attachment.js
Normal file
41
server/api/helpers/delete-attachment.js
Normal file
|
@ -0,0 +1,41 @@
|
|||
const path = require('path');
|
||||
const rimraf = require('rimraf');
|
||||
|
||||
module.exports = {
|
||||
inputs: {
|
||||
record: {
|
||||
type: 'ref',
|
||||
required: true,
|
||||
},
|
||||
board: {
|
||||
type: 'ref',
|
||||
required: true,
|
||||
},
|
||||
request: {
|
||||
type: 'ref',
|
||||
},
|
||||
},
|
||||
|
||||
async fn(inputs, exits) {
|
||||
const attachment = await Attachment.archiveOne(inputs.record.id);
|
||||
|
||||
if (attachment) {
|
||||
try {
|
||||
rimraf.sync(path.join(sails.config.custom.attachmentsPath, attachment.dirname));
|
||||
} catch (error) {
|
||||
console.warn(error.stack); // eslint-disable-line no-console
|
||||
}
|
||||
|
||||
sails.sockets.broadcast(
|
||||
`board:${inputs.board.id}`,
|
||||
'attachmentDelete',
|
||||
{
|
||||
item: attachment,
|
||||
},
|
||||
inputs.request,
|
||||
);
|
||||
}
|
||||
|
||||
return exits.success(attachment);
|
||||
},
|
||||
};
|
34
server/api/helpers/get-attachment-to-project-path.js
Executable file
34
server/api/helpers/get-attachment-to-project-path.js
Executable file
|
@ -0,0 +1,34 @@
|
|||
module.exports = {
|
||||
inputs: {
|
||||
criteria: {
|
||||
type: 'json',
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
|
||||
exits: {
|
||||
pathNotFound: {},
|
||||
},
|
||||
|
||||
async fn(inputs, exits) {
|
||||
const attachment = await Attachment.findOne(inputs.criteria);
|
||||
|
||||
if (!attachment) {
|
||||
throw 'pathNotFound';
|
||||
}
|
||||
|
||||
const path = await sails.helpers
|
||||
.getCardToProjectPath(attachment.cardId)
|
||||
.intercept('pathNotFound', (nodes) => ({
|
||||
pathNotFound: {
|
||||
attachment,
|
||||
...nodes,
|
||||
},
|
||||
}));
|
||||
|
||||
return exits.success({
|
||||
attachment,
|
||||
...path,
|
||||
});
|
||||
},
|
||||
};
|
17
server/api/helpers/get-attachments-for-card.js
Normal file
17
server/api/helpers/get-attachments-for-card.js
Normal file
|
@ -0,0 +1,17 @@
|
|||
module.exports = {
|
||||
inputs: {
|
||||
id: {
|
||||
type: 'json',
|
||||
custom: (value) => _.isString(value) || _.isArray(value),
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
|
||||
async fn(inputs, exits) {
|
||||
const attachments = await sails.helpers.getAttachments({
|
||||
cardId: inputs.id,
|
||||
});
|
||||
|
||||
return exits.success(attachments);
|
||||
},
|
||||
};
|
14
server/api/helpers/get-attachments.js
Normal file
14
server/api/helpers/get-attachments.js
Normal file
|
@ -0,0 +1,14 @@
|
|||
module.exports = {
|
||||
inputs: {
|
||||
criteria: {
|
||||
type: 'json',
|
||||
custom: (value) => _.isArray(value) || _.isPlainObject(value),
|
||||
},
|
||||
},
|
||||
|
||||
async fn(inputs, exits) {
|
||||
const attachments = await Attachment.find(inputs.criteria).sort('id');
|
||||
|
||||
return exits.success(attachments);
|
||||
},
|
||||
};
|
36
server/api/helpers/update-attachment.js
Normal file
36
server/api/helpers/update-attachment.js
Normal file
|
@ -0,0 +1,36 @@
|
|||
module.exports = {
|
||||
inputs: {
|
||||
record: {
|
||||
type: 'ref',
|
||||
required: true,
|
||||
},
|
||||
values: {
|
||||
type: 'json',
|
||||
required: true,
|
||||
},
|
||||
board: {
|
||||
type: 'ref',
|
||||
required: true,
|
||||
},
|
||||
request: {
|
||||
type: 'ref',
|
||||
},
|
||||
},
|
||||
|
||||
async fn(inputs, exits) {
|
||||
const attachment = await Attachment.updateOne(inputs.record.id).set(inputs.values);
|
||||
|
||||
if (attachment) {
|
||||
sails.sockets.broadcast(
|
||||
`board:${inputs.board.id}`,
|
||||
'attachmentUpdate',
|
||||
{
|
||||
item: attachment,
|
||||
},
|
||||
inputs.request,
|
||||
);
|
||||
}
|
||||
|
||||
return exits.success(attachment);
|
||||
},
|
||||
};
|
|
@ -1,6 +1,6 @@
|
|||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const bcrypt = require('bcrypt');
|
||||
const rimraf = require('rimraf');
|
||||
|
||||
module.exports = {
|
||||
inputs: {
|
||||
|
@ -14,7 +14,8 @@ module.exports = {
|
|||
_.isPlainObject(value) &&
|
||||
(_.isUndefined(value.email) || _.isString(value.email)) &&
|
||||
(_.isUndefined(value.password) || _.isString(value.password)) &&
|
||||
(!value.username || _.isString(value.username)),
|
||||
(!value.username || _.isString(value.username)) &&
|
||||
(_.isUndefined(value.avatarUrl) || _.isNull(value.avatarUrl)),
|
||||
required: true,
|
||||
},
|
||||
request: {
|
||||
|
@ -49,6 +50,13 @@ module.exports = {
|
|||
inputs.values.username = inputs.values.username.toLowerCase();
|
||||
}
|
||||
|
||||
if (!_.isUndefined(inputs.values.avatarUrl)) {
|
||||
/* eslint-disable no-param-reassign */
|
||||
inputs.values.avatarDirname = null;
|
||||
delete inputs.values.avatarUrl;
|
||||
/* eslint-enable no-param-reassign */
|
||||
}
|
||||
|
||||
const user = await User.updateOne({
|
||||
id: inputs.record.id,
|
||||
deletedAt: null,
|
||||
|
@ -70,9 +78,9 @@ module.exports = {
|
|||
);
|
||||
|
||||
if (user) {
|
||||
if (inputs.record.avatar && user.avatar !== inputs.record.avatar) {
|
||||
if (inputs.record.avatarDirname && user.avatarDirname !== inputs.record.avatarDirname) {
|
||||
try {
|
||||
fs.unlinkSync(path.join(sails.config.custom.uploadsPath, inputs.record.avatar));
|
||||
rimraf.sync(path.join(sails.config.custom.userAvatarsPath, inputs.record.avatarDirname));
|
||||
} catch (error) {
|
||||
console.warn(error.stack); // eslint-disable-line no-console
|
||||
}
|
||||
|
|
56
server/api/models/Attachment.js
Normal file
56
server/api/models/Attachment.js
Normal file
|
@ -0,0 +1,56 @@
|
|||
/**
|
||||
* Attachment.js
|
||||
*
|
||||
* @description :: A model definition represents a database table/collection.
|
||||
* @docs :: https://sailsjs.com/docs/concepts/models-and-orm/models
|
||||
*/
|
||||
|
||||
module.exports = {
|
||||
attributes: {
|
||||
// ╔═╗╦═╗╦╔╦╗╦╔╦╗╦╦ ╦╔═╗╔═╗
|
||||
// ╠═╝╠╦╝║║║║║ ║ ║╚╗╔╝║╣ ╚═╗
|
||||
// ╩ ╩╚═╩╩ ╩╩ ╩ ╩ ╚╝ ╚═╝╚═╝
|
||||
|
||||
dirname: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
},
|
||||
filename: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
},
|
||||
isImage: {
|
||||
type: 'boolean',
|
||||
required: true,
|
||||
columnName: 'is_image',
|
||||
},
|
||||
name: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
},
|
||||
|
||||
// ╔═╗╔╦╗╔╗ ╔═╗╔╦╗╔═╗
|
||||
// ║╣ ║║║╠╩╗║╣ ║║╚═╗
|
||||
// ╚═╝╩ ╩╚═╝╚═╝═╩╝╚═╝
|
||||
|
||||
// ╔═╗╔═╗╔═╗╔═╗╔═╗╦╔═╗╔╦╗╦╔═╗╔╗╔╔═╗
|
||||
// ╠═╣╚═╗╚═╗║ ║║ ║╠═╣ ║ ║║ ║║║║╚═╗
|
||||
// ╩ ╩╚═╝╚═╝╚═╝╚═╝╩╩ ╩ ╩ ╩╚═╝╝╚╝╚═╝
|
||||
|
||||
cardId: {
|
||||
model: 'Card',
|
||||
required: true,
|
||||
columnName: 'card_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`
|
||||
: null,
|
||||
};
|
||||
},
|
||||
};
|
|
@ -69,5 +69,13 @@ module.exports = {
|
|||
collection: 'Task',
|
||||
via: 'cardId',
|
||||
},
|
||||
attachments: {
|
||||
collection: 'Attachment',
|
||||
via: 'cardId',
|
||||
},
|
||||
actions: {
|
||||
collection: 'Action',
|
||||
via: 'cardId',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
|
|
@ -37,10 +37,11 @@ module.exports = {
|
|||
regex: /^[a-zA-Z0-9]+(_?[a-zA-Z0-9])*$/,
|
||||
allowNull: true,
|
||||
},
|
||||
avatar: {
|
||||
avatarDirname: {
|
||||
type: 'string',
|
||||
isNotEmptyString: true,
|
||||
allowNull: true,
|
||||
columnName: 'avatar_dirname',
|
||||
},
|
||||
phone: {
|
||||
type: 'string',
|
||||
|
@ -91,8 +92,9 @@ module.exports = {
|
|||
|
||||
customToJSON() {
|
||||
return {
|
||||
..._.omit(this, 'password'),
|
||||
avatar: this.avatar && `${sails.config.custom.uploadsUrl}/${this.avatar}`,
|
||||
..._.omit(this, ['password', 'avatarDirname']),
|
||||
avatarUrl:
|
||||
this.avatarDirname && `${sails.config.custom.userAvatarsUrl}/${this.avatarDirname}/100.jpg`,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
|
|
@ -20,6 +20,9 @@ module.exports.custom = {
|
|||
|
||||
baseUrl: process.env.BASE_URL,
|
||||
|
||||
uploadsPath: path.join(sails.config.paths.public, 'uploads'),
|
||||
uploadsUrl: `${process.env.BASE_URL}/uploads`,
|
||||
userAvatarsPath: path.join(sails.config.paths.public, 'user-avatars'),
|
||||
userAvatarsUrl: `${process.env.BASE_URL}/user-avatars`,
|
||||
|
||||
attachmentsPath: path.join(sails.config.paths.public, 'attachments'),
|
||||
attachmentsUrl: `${process.env.BASE_URL}/attachments`,
|
||||
};
|
||||
|
|
7
server/config/env/production.js
vendored
7
server/config/env/production.js
vendored
|
@ -329,7 +329,10 @@ module.exports = {
|
|||
custom: {
|
||||
baseUrl: process.env.BASE_URL,
|
||||
|
||||
uploadsPath: path.join(sails.config.paths.public, 'uploads'),
|
||||
uploadsUrl: `${process.env.BASE_URL}/uploads`,
|
||||
userAvatarsPath: path.join(sails.config.paths.public, 'user-avatars'),
|
||||
userAvatarsUrl: `${process.env.BASE_URL}/user-avatars`,
|
||||
|
||||
attachmentsPath: path.join(sails.config.paths.public, 'attachments'),
|
||||
attachmentsUrl: `${process.env.BASE_URL}/attachments`,
|
||||
},
|
||||
};
|
||||
|
|
|
@ -18,7 +18,7 @@ module.exports.routes = {
|
|||
'PATCH /api/users/:id/email': 'users/update-email',
|
||||
'PATCH /api/users/:id/password': 'users/update-password',
|
||||
'PATCH /api/users/:id/username': 'users/update-username',
|
||||
'POST /api/users/:id/upload-avatar': 'users/upload-avatar',
|
||||
'POST /api/users/:id/update-avatar': 'users/update-avatar',
|
||||
'DELETE /api/users/:id': 'users/delete',
|
||||
|
||||
'GET /api/projects': 'projects/index',
|
||||
|
@ -55,6 +55,10 @@ module.exports.routes = {
|
|||
'PATCH /api/tasks/:id': 'tasks/update',
|
||||
'DELETE /api/tasks/:id': 'tasks/delete',
|
||||
|
||||
'POST /api/cards/:cardId/attachments': 'attachments/create',
|
||||
'PATCH /api/attachments/:id': 'attachments/update',
|
||||
'DELETE /api/attachments/:id': 'attachments/delete',
|
||||
|
||||
'GET /api/cards/:cardId/actions': 'actions/index',
|
||||
|
||||
'POST /api/cards/:cardId/comment-actions': 'comment-actions/create',
|
||||
|
|
|
@ -10,7 +10,7 @@ module.exports.up = (knex) =>
|
|||
table.boolean('is_admin').notNullable();
|
||||
table.text('name').notNullable();
|
||||
table.text('username');
|
||||
table.text('avatar');
|
||||
table.text('avatar_dirname');
|
||||
table.text('phone');
|
||||
table.text('organization');
|
||||
table.boolean('subscribe_to_own_cards').notNullable();
|
||||
|
|
22
server/db/migrations/20180722006688_create_attachment_table.js
Executable file
22
server/db/migrations/20180722006688_create_attachment_table.js
Executable file
|
@ -0,0 +1,22 @@
|
|||
module.exports.up = (knex) =>
|
||||
knex.schema.createTable('attachment', (table) => {
|
||||
/* Columns */
|
||||
|
||||
table.bigInteger('id').primary().defaultTo(knex.raw('next_id()'));
|
||||
|
||||
table.bigInteger('card_id').notNullable();
|
||||
|
||||
table.text('dirname').notNullable();
|
||||
table.text('filename').notNullable();
|
||||
table.boolean('is_image').notNullable();
|
||||
table.text('name').notNullable();
|
||||
|
||||
table.timestamp('created_at', true);
|
||||
table.timestamp('updated_at', true);
|
||||
|
||||
/* Indexes */
|
||||
|
||||
table.index('card_id');
|
||||
});
|
||||
|
||||
module.exports.down = (knex) => knex.schema.dropTable('attachment');
|
47
server/package-lock.json
generated
47
server/package-lock.json
generated
|
@ -190,6 +190,11 @@
|
|||
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-2.2.1.tgz",
|
||||
"integrity": "sha1-tDLdM1i2NM914eRmQ2gkBTPB3b4="
|
||||
},
|
||||
"any-promise": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz",
|
||||
"integrity": "sha1-q8av7tzqUugJzcA3au0845Y10X8="
|
||||
},
|
||||
"anymatch": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.1.tgz",
|
||||
|
@ -2555,6 +2560,16 @@
|
|||
"klaw": "^1.0.0",
|
||||
"path-is-absolute": "^1.0.0",
|
||||
"rimraf": "^2.2.8"
|
||||
},
|
||||
"dependencies": {
|
||||
"rimraf": {
|
||||
"version": "2.7.1",
|
||||
"resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.7.1.tgz",
|
||||
"integrity": "sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==",
|
||||
"requires": {
|
||||
"glob": "^7.1.3"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"fs-minipass": {
|
||||
|
@ -4168,6 +4183,16 @@
|
|||
"rimraf": "^2.6.1",
|
||||
"semver": "^5.3.0",
|
||||
"tar": "^4.4.2"
|
||||
},
|
||||
"dependencies": {
|
||||
"rimraf": {
|
||||
"version": "2.7.1",
|
||||
"resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.7.1.tgz",
|
||||
"integrity": "sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==",
|
||||
"requires": {
|
||||
"glob": "^7.1.3"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"nodemon": {
|
||||
|
@ -5273,9 +5298,9 @@
|
|||
"integrity": "sha1-/s5hv6DBtSoga9axgZgYS91SOjs="
|
||||
},
|
||||
"rimraf": {
|
||||
"version": "2.7.1",
|
||||
"resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.7.1.tgz",
|
||||
"integrity": "sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==",
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz",
|
||||
"integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==",
|
||||
"requires": {
|
||||
"glob": "^7.1.3"
|
||||
}
|
||||
|
@ -6357,6 +6382,14 @@
|
|||
"resolved": "https://registry.npmjs.org/statuses/-/statuses-1.3.1.tgz",
|
||||
"integrity": "sha1-+vUbnrdKrvOzrPStX2Gr8ky3uT4="
|
||||
},
|
||||
"stream-to-array": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/stream-to-array/-/stream-to-array-2.3.0.tgz",
|
||||
"integrity": "sha1-u/azn19D7DC8cbq8s3VXrOzzQ1M=",
|
||||
"requires": {
|
||||
"any-promise": "^1.1.0"
|
||||
}
|
||||
},
|
||||
"streamifier": {
|
||||
"version": "0.1.1",
|
||||
"resolved": "https://registry.npmjs.org/streamifier/-/streamifier-0.1.1.tgz",
|
||||
|
@ -6931,6 +6964,14 @@
|
|||
"version": "0.2.10",
|
||||
"resolved": "https://registry.npmjs.org/async/-/async-0.2.10.tgz",
|
||||
"integrity": "sha1-trvgsGdLnXGXCMo43owjfLUmw9E="
|
||||
},
|
||||
"rimraf": {
|
||||
"version": "2.7.1",
|
||||
"resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.7.1.tgz",
|
||||
"integrity": "sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==",
|
||||
"requires": {
|
||||
"glob": "^7.1.3"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue