Add file attachments
parent
87a3cf751a
commit
f743f4ea8b
@ -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,
|
||||||
|
},
|
||||||
|
});
|
||||||
@ -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,
|
||||||
|
},
|
||||||
|
});
|
||||||
@ -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,
|
||||||
|
};
|
||||||
@ -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;
|
||||||
@ -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;
|
||||||
|
}
|
||||||
@ -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;
|
||||||
@ -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);
|
||||||
|
}
|
||||||
@ -0,0 +1,3 @@
|
|||||||
|
import Attachments from './Attachments';
|
||||||
|
|
||||||
|
export default Attachments;
|
||||||
@ -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;
|
||||||
|
}
|
||||||
@ -0,0 +1,3 @@
|
|||||||
|
import FilePicker from './FilePicker';
|
||||||
|
|
||||||
|
export default FilePicker;
|
||||||
@ -1,6 +1,7 @@
|
|||||||
import Input from './components/Input';
|
import Input from './components/Input';
|
||||||
import Popup from './components/Popup';
|
import Popup from './components/Popup';
|
||||||
import Markdown from './components/Markdown';
|
import Markdown from './components/Markdown';
|
||||||
|
import FilePicker from './components/FilePicker';
|
||||||
import DragScroller from './components/DragScroller';
|
import DragScroller from './components/DragScroller';
|
||||||
|
|
||||||
export { Input, Popup, Markdown, DragScroller };
|
export { Input, Popup, Markdown, FilePicker, DragScroller };
|
||||||
|
|||||||
@ -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:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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);
|
||||||
|
}
|
||||||
@ -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),
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
}
|
||||||
@ -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(),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
@ -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,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
@ -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,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
@ -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,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
@ -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,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@ -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);
|
||||||
|
},
|
||||||
|
};
|
||||||
@ -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);
|
||||||
|
},
|
||||||
|
};
|
||||||
@ -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);
|
||||||
|
},
|
||||||
|
};
|
||||||
@ -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);
|
||||||
|
},
|
||||||
|
};
|
||||||
@ -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,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
@ -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);
|
||||||
|
},
|
||||||
|
};
|
||||||
@ -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);
|
||||||
|
},
|
||||||
|
};
|
||||||
@ -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);
|
||||||
|
},
|
||||||
|
};
|
||||||
@ -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,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
@ -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');
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue