1
0
Fork 0
mirror of https://github.com/plankanban/planka.git synced 2025-07-27 09:09:46 +02:00

Initial commit

This commit is contained in:
Maksim Eltyshev 2019-08-31 04:07:25 +05:00
commit 5ffef61fe7
613 changed files with 91659 additions and 0 deletions

View file

@ -0,0 +1,101 @@
import React, { useCallback } from 'react';
import PropTypes from 'prop-types';
import { useTranslation } from 'react-i18next';
import {
Comment, Icon, Loader, Visibility,
} from 'semantic-ui-react';
import { ActionTypes } from '../../../constants/Enums';
import AddComment from './AddComment';
import Item from './Item';
import styles from './Actions.module.css';
const Actions = React.memo(
({
items,
isFetching,
isAllFetched,
isEditable,
onFetch,
onCommentCreate,
onCommentUpdate,
onCommentDelete,
}) => {
const [t] = useTranslation();
const handleCommentUpdate = useCallback(
(id, data) => {
onCommentUpdate(id, data);
},
[onCommentUpdate],
);
const handleCommentDelete = useCallback(
(id) => {
onCommentDelete(id);
},
[onCommentDelete],
);
return (
<>
<div className={styles.contentModule}>
<div className={styles.moduleWrapper}>
<Icon name="comment outline" className={styles.moduleIcon} />
<div className={styles.moduleHeader}>{t('common.addComment')}</div>
<AddComment onCreate={onCommentCreate} />
</div>
</div>
<div className={styles.contentModule}>
<div className={styles.moduleWrapper}>
<Icon name="list ul" className={styles.moduleIcon} />
<div className={styles.moduleHeader}>{t('common.actions')}</div>
<div className={styles.wrapper}>
<Comment.Group>
{items.map((item) => (item.type === ActionTypes.COMMENT_CARD ? (
<Item.Comment
key={item.id}
data={item.data}
createdAt={item.createdAt}
isPersisted={item.isPersisted}
user={item.user}
isEditable={isEditable}
onUpdate={(data) => handleCommentUpdate(item.id, data)}
onDelete={() => handleCommentDelete(item.id)}
/>
) : (
<Item
key={item.id}
type={item.type}
data={item.data}
createdAt={item.createdAt}
user={item.user}
/>
)))}
</Comment.Group>
</div>
{isFetching ? (
<Loader active inverted inline="centered" size="small" className={styles.loader} />
) : (
!isAllFetched && <Visibility fireOnMount onOnScreen={onFetch} />
)}
</div>
</div>
</>
);
},
);
Actions.propTypes = {
items: PropTypes.array.isRequired, // eslint-disable-line react/forbid-prop-types
isFetching: PropTypes.bool.isRequired,
isAllFetched: PropTypes.bool.isRequired,
isEditable: PropTypes.bool.isRequired,
onFetch: PropTypes.func.isRequired,
onCommentCreate: PropTypes.func.isRequired,
onCommentUpdate: PropTypes.func.isRequired,
onCommentDelete: PropTypes.func.isRequired,
};
export default Actions;

View file

@ -0,0 +1,38 @@
.contentModule {
margin-bottom: 24px;
}
.loader {
margin-top: 10px !important;
}
.moduleHeader {
color: #17394d;
font-size: 16px;
font-weight: bold;
line-height: 20px;
margin: 0 0 4px;
padding: 8px 0;
}
.moduleIcon {
color: #17394d;
font-size: 17px !important;
height: 32px !important;
left: -40px;
line-height: 32px;
margin-right: 0 !important;
position: absolute;
top: 2px;
width: 32px !important;
}
.moduleWrapper {
margin: 0 0 0 40px;
position: relative;
}
.wrapper {
margin-left: -40px;
margin-top: 12px;
}

View file

@ -0,0 +1,75 @@
import React, { useCallback, useRef } from 'react';
import PropTypes from 'prop-types';
import { useTranslation } from 'react-i18next';
import TextareaAutosize from 'react-textarea-autosize';
import { Button, Form, TextArea } from 'semantic-ui-react';
import { useDeepCompareCallback, useForm } from '../../../hooks';
import styles from './AddComment.module.css';
const DEFAULT_DATA = {
text: '',
};
const AddComment = React.memo(({ onCreate }) => {
const [t] = useTranslation();
const [data, handleFieldChange, setData] = useForm(DEFAULT_DATA);
const textField = useRef(null);
const submit = useDeepCompareCallback(() => {
const cleanData = {
...data,
text: data.text.trim(),
};
if (!cleanData.text) {
textField.current.ref.current.select();
return;
}
onCreate(cleanData);
setData(DEFAULT_DATA);
}, [onCreate, data, setData]);
const handleFieldKeyDown = useCallback(
(event) => {
if (event.ctrlKey && event.key === 'Enter') {
submit();
}
},
[submit],
);
const handleSubmit = useCallback(() => {
submit();
}, [submit]);
return (
<Form onSubmit={handleSubmit}>
<TextArea
ref={textField}
as={TextareaAutosize}
name="text"
value={data.text}
placeholder={t('common.writeComment')}
minRows={3}
spellCheck={false}
className={styles.field}
onKeyDown={handleFieldKeyDown}
onChange={handleFieldChange}
/>
<div className={styles.controls}>
<Button positive content={t('action.addComment')} disabled={!data.text} />
</div>
</Form>
);
});
AddComment.propTypes = {
onCreate: PropTypes.func.isRequired,
};
export default AddComment;

View file

@ -0,0 +1,23 @@
.controls {
clear: both;
margin-top: 6px;
}
.field {
background: #fff !important;
border: 0 !important;
box-sizing: border-box;
color: #333 !important;
display: block;
line-height: 1.5 !important;
font-size: 14px !important;
margin-bottom: 6px !important;
overflow: hidden;
padding: 8px 12px !important;
resize: none !important;
width: 100% !important;
}
.field:focus {
outline: none;
}

View file

@ -0,0 +1,122 @@
import dequal from 'dequal';
import React, {
useCallback, useEffect, useImperativeHandle, useRef, useState,
} from 'react';
import PropTypes from 'prop-types';
import { useTranslation } from 'react-i18next';
import TextareaAutosize from 'react-textarea-autosize';
import { Button, Form, TextArea } from 'semantic-ui-react';
import { useClosableForm, useDeepCompareCallback, useForm } from '../../../hooks';
import styles from './EditComment.module.css';
const EditComment = React.forwardRef(({ children, defaultData, onUpdate }, ref) => {
const [t] = useTranslation();
const [isOpened, setIsOpened] = useState(false);
const [data, handleFieldChange, setData] = useForm(null);
const textField = useRef(null);
const open = useDeepCompareCallback(() => {
setIsOpened(true);
setData({
text: '',
...defaultData,
});
}, [defaultData, setData]);
const close = useCallback(() => {
setIsOpened(false);
setData(null);
}, [setData]);
const submit = useDeepCompareCallback(() => {
const cleanData = {
...data,
text: data.text.trim(),
};
if (!cleanData.text) {
textField.current.ref.current.select();
return;
}
if (!dequal(cleanData, defaultData)) {
onUpdate(cleanData);
}
close();
}, [defaultData, onUpdate, data, close]);
useImperativeHandle(
ref,
() => ({
open,
close,
}),
[open, close],
);
const handleFieldKeyDown = useCallback(
(event) => {
if (event.ctrlKey && event.key === 'Enter') {
submit();
}
},
[submit],
);
const [handleFieldBlur, handleControlMouseOver, handleControlMouseOut] = useClosableForm(
isOpened,
close,
);
const handleSubmit = useCallback(() => {
submit();
}, [submit]);
useEffect(() => {
if (isOpened) {
textField.current.ref.current.select();
}
}, [isOpened]);
if (!isOpened) {
return children;
}
return (
<Form onSubmit={handleSubmit}>
<TextArea
ref={textField}
as={TextareaAutosize}
name="text"
value={data.text}
minRows={3}
spellCheck={false}
className={styles.field}
onKeyDown={handleFieldKeyDown}
onChange={handleFieldChange}
onBlur={handleFieldBlur}
/>
<div className={styles.controls}>
{/* eslint-disable-next-line jsx-a11y/mouse-events-have-key-events */}
<Button
positive
content={t('action.save')}
onMouseOver={handleControlMouseOver}
onMouseOut={handleControlMouseOut}
/>
</div>
</Form>
);
});
EditComment.propTypes = {
children: PropTypes.element.isRequired,
defaultData: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types
onUpdate: PropTypes.func.isRequired,
};
export default React.memo(EditComment);

View file

@ -0,0 +1,24 @@
.controls {
clear: both;
margin-top: 6px;
}
.field {
background: #fff !important;
border: 1px solid rgba(9, 45, 66, 0.13) !important;
border-radius: 3px !important;
box-sizing: border-box;
color: #333 !important;
display: block;
line-height: 1.4 !important;
font-size: 14px !important;
margin-bottom: 4px !important;
overflow: hidden;
padding: 8px 12px !important;
resize: none !important;
width: 100% !important;
}
.field:focus {
outline: none;
}

View file

@ -0,0 +1,91 @@
import React from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import { useTranslation, Trans } from 'react-i18next';
import { Comment } from 'semantic-ui-react';
import { ActionTypes } from '../../../constants/Enums';
import ItemComment from './ItemComment';
import User from '../../User';
import styles from './Item.module.css';
const Item = React.memo(({
type, data, createdAt, user,
}) => {
const [t] = useTranslation();
let contentNode;
switch (type) {
case ActionTypes.CREATE_CARD:
contentNode = (
<Trans
i18nKey="common.userAddedThisCardToList"
values={{
user: user.name,
list: data.list.name,
}}
>
<span className={styles.author}>{user.name}</span>
<span className={styles.text}>
{' added this card to '}
{data.list.name}
</span>
</Trans>
);
break;
case ActionTypes.MOVE_CARD:
contentNode = (
<Trans
i18nKey="common.userMovedThisCardFromListToList"
values={{
user: user.name,
fromList: data.fromList.name,
toList: data.toList.name,
}}
>
<span className={styles.author}>{user.name}</span>
<span className={styles.text}>
{' moved this card from '}
{data.fromList.name}
{' to '}
{data.toList.name}
</span>
</Trans>
);
break;
default:
contentNode = null;
}
return (
<Comment>
<span className={styles.user}>
<User name={user.name} avatar={user.avatar} />
</span>
<div className={classNames(styles.content)}>
<div>{contentNode}</div>
<span className={styles.date}>
{t('format:longDateTime', {
postProcess: 'formatDate',
value: createdAt,
})}
</span>
</div>
</Comment>
);
});
Item.Comment = ItemComment;
Item.propTypes = {
type: PropTypes.string.isRequired,
data: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types
createdAt: PropTypes.instanceOf(Date).isRequired,
user: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types
};
export default Item;

View file

@ -0,0 +1,31 @@
.author {
color: #17394d;
display: inline-block;
font-weight: bold;
line-height: 20px;
}
.content {
border-bottom: 1px solid #092d4221;
display: inline-block;
padding-bottom: 14px;
vertical-align: top;
width: calc(100% - 40px);
}
.date {
color: #6b808c;
display: inline-block;
font-size: 12px;
line-height: 20px;
}
.text {
line-height: 20px;
}
.user {
display: inline-block;
padding: 4px 8px 0 0;
vertical-align: top;
}

View file

@ -0,0 +1,87 @@
import React, { useCallback, useRef } from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import { useTranslation } from 'react-i18next';
import { Comment } from 'semantic-ui-react';
import EditComment from './EditComment';
import User from '../../User';
import DeletePopup from '../../DeletePopup';
import styles from './ItemComment.module.css';
const ItemComment = React.memo(
({
data, createdAt, isPersisted, user, isEditable, onUpdate, onDelete,
}) => {
const [t] = useTranslation();
const editComment = useRef(null);
const handleEditClick = useCallback(() => {
editComment.current.open();
}, []);
return (
<Comment>
<span className={styles.user}>
<User name={user.name} avatar={user.avatar} />
</span>
<div className={classNames(styles.content)}>
<div className={styles.title}>
<span className={styles.author}>{user.name}</span>
<span className={styles.date}>
{t('format:longDateTime', {
postProcess: 'formatDate',
value: createdAt,
})}
</span>
</div>
<EditComment ref={editComment} defaultData={data} onUpdate={onUpdate}>
<>
<p className={styles.text}>{data.text}</p>
<Comment.Actions>
{user.isCurrent && (
<Comment.Action
as="button"
content={t('action.edit')}
disabled={!isPersisted}
onClick={handleEditClick}
/>
)}
{(user.isCurrent || isEditable) && (
<DeletePopup
title={t('common.deleteComment', {
context: 'title',
})}
content={t('common.areYouSureYouWantToDeleteThisComment')}
buttonContent={t('action.deleteComment')}
onConfirm={onDelete}
>
<Comment.Action
as="button"
content={t('action.delete')}
disabled={!isPersisted}
/>
</DeletePopup>
)}
</Comment.Actions>
</>
</EditComment>
</div>
</Comment>
);
},
);
ItemComment.propTypes = {
data: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types
createdAt: PropTypes.instanceOf(Date).isRequired,
isPersisted: PropTypes.bool.isRequired,
user: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types
isEditable: PropTypes.bool.isRequired,
onUpdate: PropTypes.func.isRequired,
onDelete: PropTypes.func.isRequired,
};
export default ItemComment;

View file

@ -0,0 +1,49 @@
.author {
color: #17394d;
display: inline-block;
font-weight: bold;
line-height: 20px;
margin-right: 8px;
}
.content {
border-bottom: 1px solid #092d4221;
display: inline-block;
padding-bottom: 14px;
vertical-align: top;
width: calc(100% - 40px);
}
.date {
color: #6b808c;
display: inline-block;
font-size: 12px;
line-height: 20px;
}
.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-sizing: border-box;
color: #17394d;
display: inline-block;
margin: 1px 2px 4px 1px;
max-width: 100%;
overflow: hidden;
padding: 8px 12px;
text-overflow: ellipsis;
white-space: pre-line;
word-break: break-word;
}
.title {
padding-bottom: 4px;
}
.user {
display: inline-block;
padding: 4px 8px 0 0;
vertical-align: top;
}

View file

@ -0,0 +1,3 @@
import Actions from './Actions';
export default Actions;

View file

@ -0,0 +1,388 @@
import React, { useCallback } from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import { useTranslation } from 'react-i18next';
import {
Button, Grid, Icon, Modal,
} from 'semantic-ui-react';
import NameField from './NameField';
import EditDescription from './EditDescription';
import Tasks from './Tasks';
import Actions from './Actions';
import User from '../User';
import Label from '../Label';
import Deadline from '../Deadline';
import Timer from '../Timer';
import ProjectMembershipsPopup from '../ProjectMembershipsPopup';
import LabelsPopup from '../LabelsPopup';
import EditDeadlinePopup from '../EditDeadlinePopup';
import EditTimerPopup from '../EditTimerPopup';
import DeletePopup from '../DeletePopup';
import styles from './CardModal.module.css';
const CardModal = React.memo(
({
name,
description,
deadline,
timer,
isSubscribed,
isActionsFetching,
isAllActionsFetched,
users,
labels,
tasks,
actions,
allProjectMemberships,
allLabels,
isEditable,
onUpdate,
onDelete,
onUserAdd,
onUserRemove,
onLabelAdd,
onLabelRemove,
onLabelCreate,
onLabelUpdate,
onLabelDelete,
onTaskCreate,
onTaskUpdate,
onTaskDelete,
onActionsFetch,
onCommentActionCreate,
onCommentActionUpdate,
onCommentActionDelete,
onClose,
}) => {
const [t] = useTranslation();
const handleNameUpdate = useCallback(
(newName) => {
onUpdate({
name: newName,
});
},
[onUpdate],
);
const handleDescriptionUpdate = useCallback(
(newDescription) => {
onUpdate({
description: newDescription,
});
},
[onUpdate],
);
const handleDeadlineUpdate = useCallback(
(newDeadline) => {
onUpdate({
deadline: newDeadline,
});
},
[onUpdate],
);
const handleTimerUpdate = useCallback(
(newTimer) => {
onUpdate({
timer: newTimer,
});
},
[onUpdate],
);
const handleToggleSubscribeClick = useCallback(() => {
onUpdate({
isSubscribed: !isSubscribed,
});
}, [isSubscribed, onUpdate]);
const userIds = users.map((user) => user.id);
const labelIds = labels.map((label) => label.id);
return (
<Modal
open
closeIcon
size="small"
centered={false}
className={styles.wrapper}
onClose={onClose}
>
<Grid className={styles.grid}>
<Grid.Row className={styles.headerPadding}>
<Grid.Column width={16} className={styles.headerPadding}>
<div className={styles.headerWrapper}>
<Icon name="list alternate outline" className={styles.moduleIcon} />
<div className={styles.headerTitle}>
<NameField defaultValue={name} onUpdate={handleNameUpdate} />
</div>
</div>
</Grid.Column>
</Grid.Row>
<Grid.Row className={styles.modalPadding}>
<Grid.Column width={12} className={styles.contentPadding}>
{(users.length > 0 || labels.length > 0 || deadline || timer) && (
<div className={styles.moduleWrapper}>
{users.length > 0 && (
<div className={styles.attachments}>
<div className={styles.text}>
{t('common.members', {
context: 'title',
})}
</div>
{users.map((user) => (
<span key={user.id} className={styles.attachment}>
<ProjectMembershipsPopup
items={allProjectMemberships}
currentUserIds={userIds}
onUserSelect={onUserAdd}
onUserDeselect={onUserRemove}
>
<User name={user.name} avatar={user.avatar} />
</ProjectMembershipsPopup>
</span>
))}
<ProjectMembershipsPopup
items={allProjectMemberships}
currentUserIds={userIds}
onUserSelect={onUserAdd}
onUserDeselect={onUserRemove}
>
<button
type="button"
className={classNames(styles.attachment, styles.deadline)}
>
<Icon name="add" size="small" className={styles.addAttachment} />
</button>
</ProjectMembershipsPopup>
</div>
)}
{labels.length > 0 && (
<div className={styles.attachments}>
<div className={styles.text}>
{t('common.labels', {
context: 'title',
})}
</div>
{labels.map((label) => (
<span key={label.id} className={styles.attachment}>
<LabelsPopup
key={label.id}
items={allLabels}
currentIds={labelIds}
onSelect={onLabelAdd}
onDeselect={onLabelRemove}
onCreate={onLabelCreate}
onUpdate={onLabelUpdate}
onDelete={onLabelDelete}
>
<Label name={label.name} color={label.color} />
</LabelsPopup>
</span>
))}
<LabelsPopup
items={allLabels}
currentIds={labelIds}
onSelect={onLabelAdd}
onDeselect={onLabelRemove}
onCreate={onLabelCreate}
onUpdate={onLabelUpdate}
onDelete={onLabelDelete}
>
<button
type="button"
className={classNames(styles.attachment, styles.deadline)}
>
<Icon name="add" size="small" className={styles.addAttachment} />
</button>
</LabelsPopup>
</div>
)}
{deadline && (
<div className={styles.attachments}>
<div className={styles.text}>
{t('common.deadline', {
context: 'title',
})}
</div>
<span className={styles.attachment}>
<EditDeadlinePopup defaultValue={deadline} onUpdate={handleDeadlineUpdate}>
<Deadline value={deadline} />
</EditDeadlinePopup>
</span>
</div>
)}
{timer && (
<div className={styles.attachments}>
<div className={styles.text}>
{t('common.timer', {
context: 'title',
})}
</div>
<span className={styles.attachment}>
<EditTimerPopup defaultValue={timer} onUpdate={handleTimerUpdate}>
<Timer startedAt={timer.startedAt} total={timer.total} />
</EditTimerPopup>
</span>
</div>
)}
</div>
)}
<div className={styles.contentModule}>
<div className={styles.moduleWrapper}>
<Icon name="align justify" className={styles.moduleIcon} />
<div className={styles.moduleHeader}>{t('common.description')}</div>
<EditDescription defaultValue={description} onUpdate={handleDescriptionUpdate}>
{description ? (
<button type="button" className={styles.descriptionText}>
{description}
</button>
) : (
<button type="button" className={styles.descriptionButton}>
<span className={styles.descriptionButtonText}>
{t('action.addMoreDetailedDescription')}
</span>
</button>
)}
</EditDescription>
</div>
</div>
<div className={styles.contentModule}>
<div className={styles.moduleWrapper}>
<Icon name="check square outline" className={styles.moduleIcon} />
<div className={styles.moduleHeader}>{t('common.tasks')}</div>
<Tasks
items={tasks}
onCreate={onTaskCreate}
onUpdate={onTaskUpdate}
onDelete={onTaskDelete}
/>
</div>
</div>
<Actions
items={actions}
isFetching={isActionsFetching}
isAllFetched={isAllActionsFetched}
isEditable={isEditable}
onFetch={onActionsFetch}
onCommentCreate={onCommentActionCreate}
onCommentUpdate={onCommentActionUpdate}
onCommentDelete={onCommentActionDelete}
/>
</Grid.Column>
<Grid.Column width={4} className={styles.sidebarPadding}>
<div className={styles.actions}>
<span className={styles.actionsTitle}>{t('action.addToCard')}</span>
<ProjectMembershipsPopup
items={allProjectMemberships}
currentUserIds={userIds}
onUserSelect={onUserAdd}
onUserDeselect={onUserRemove}
>
<Button fluid className={styles.actionButton}>
<Icon name="user outline" className={styles.actionIcon} />
{t('common.members')}
</Button>
</ProjectMembershipsPopup>
<LabelsPopup
items={allLabels}
currentIds={labelIds}
onSelect={onLabelAdd}
onDeselect={onLabelRemove}
onCreate={onLabelCreate}
onUpdate={onLabelUpdate}
onDelete={onLabelDelete}
>
<Button fluid className={styles.actionButton}>
<Icon name="bookmark outline" className={styles.actionIcon} />
{t('common.labels')}
</Button>
</LabelsPopup>
<EditDeadlinePopup defaultValue={deadline} onUpdate={handleDeadlineUpdate}>
<Button fluid className={styles.actionButton}>
<Icon name="calendar check outline" className={styles.actionIcon} />
{t('common.deadline')}
</Button>
</EditDeadlinePopup>
<EditTimerPopup defaultValue={timer} onUpdate={handleTimerUpdate}>
<Button fluid className={styles.actionButton}>
<Icon name="clock outline" className={styles.actionIcon} />
{t('common.timer')}
</Button>
</EditTimerPopup>
</div>
<div className={styles.actions}>
<span className={styles.actionsTitle}>{t('common.actions')}</span>
<Button fluid className={styles.actionButton} onClick={handleToggleSubscribeClick}>
<Icon name="paper plane outline" className={styles.actionIcon} />
{isSubscribed ? t('action.unsubscribe') : t('action.subscribe')}
</Button>
<DeletePopup
title={t('common.deleteCard', {
context: 'title',
})}
content={t('common.areYouSureYouWantToDeleteThisCard')}
buttonContent={t('action.deleteCard')}
onConfirm={onDelete}
>
<Button fluid className={styles.actionButton}>
<Icon name="trash alternate outline" className={styles.actionIcon} />
{t('action.delete')}
</Button>
</DeletePopup>
</div>
</Grid.Column>
</Grid.Row>
</Grid>
</Modal>
);
},
);
CardModal.propTypes = {
name: PropTypes.string.isRequired,
description: PropTypes.string,
deadline: PropTypes.instanceOf(Date),
timer: PropTypes.object, // eslint-disable-line react/forbid-prop-types
isSubscribed: PropTypes.bool.isRequired,
isActionsFetching: PropTypes.bool.isRequired,
isAllActionsFetched: PropTypes.bool.isRequired,
/* eslint-disable react/forbid-prop-types */
users: PropTypes.array.isRequired,
labels: PropTypes.array.isRequired,
tasks: PropTypes.array.isRequired,
actions: PropTypes.array.isRequired,
allProjectMemberships: PropTypes.array.isRequired,
allLabels: PropTypes.array.isRequired,
/* eslint-enable react/forbid-prop-types */
isEditable: PropTypes.bool.isRequired,
onUpdate: PropTypes.func.isRequired,
onDelete: PropTypes.func.isRequired,
onUserAdd: PropTypes.func.isRequired,
onUserRemove: PropTypes.func.isRequired,
onLabelAdd: PropTypes.func.isRequired,
onLabelRemove: PropTypes.func.isRequired,
onLabelCreate: PropTypes.func.isRequired,
onLabelUpdate: PropTypes.func.isRequired,
onLabelDelete: PropTypes.func.isRequired,
onTaskCreate: PropTypes.func.isRequired,
onTaskUpdate: PropTypes.func.isRequired,
onTaskDelete: PropTypes.func.isRequired,
onActionsFetch: PropTypes.func.isRequired,
onCommentActionCreate: PropTypes.func.isRequired,
onCommentActionUpdate: PropTypes.func.isRequired,
onCommentActionDelete: PropTypes.func.isRequired,
onClose: PropTypes.func.isRequired,
};
CardModal.defaultProps = {
description: undefined,
deadline: undefined,
timer: undefined,
};
export default CardModal;

View file

@ -0,0 +1,211 @@
.actionButton {
background: #ebeef0 !important;
box-shadow: 0 1px 0 0 rgba(9, 45, 66, 0.13) !important;
color: #444 !important;
margin-top: 8px !important;
padding: 6px 8px 6px 18px !important;
text-align: left !important;
transition: background 85ms ease !important;
}
.actionButton:hover {
background: #dfe3e6 !important;
box-shadow: 0 1px 0 0 rgba(9, 45, 66, 0.25) !important;
color: #4c4c4c !important;
}
.actionIcon {
color: #17394d !important;
margin-right: 8px !important;
}
.actions {
margin-bottom: 24px;
}
.actionsTitle {
color: #8c8c8c;
font-size: 12px;
font-weight: 500;
letter-spacing: 0.04em;
margin-top: 16px;
text-transform: uppercase;
line-height: 20px;
margin-bottom: -4px;
}
.addAttachment {
margin: 0 -4.3px !important;
text-decoration: none !important;
}
.attachment {
cursor: pointer;
display: inline-block;
margin: 0 4px 4px 0;
max-width: 100%;
}
.attachments {
display: inline-block;
margin: 0 8px 8px 0;
max-width: 100%;
vertical-align: top;
}
.contentModule {
margin-bottom: 24px;
}
.contentPadding {
padding: 8px 8px 0 16px !important;
}
.deadline {
background: #dce0e4;
border: none;
border-radius: 3px;
color: #6b808c;
line-height: 20px;
outline: none;
padding: 6px 14px;
text-align: left;
text-decoration: underline;
transition: background 0.3s ease;
vertical-align: top;
}
.deadline:hover {
background: #d2d8dc;
color: #17394d;
}
.descriptionButton {
background: rgba(9, 45, 66, 0.08);
border: none;
border-radius: 3px;
display: block;
color: #6b808c;
cursor: pointer;
min-height: 54px;
outline: none;
padding: 8px 12px;
position: relative;
text-align: left;
text-decoration: none;
width: 100%;
}
.descriptionButton:hover {
background-color: rgba(9, 45, 66, 0.13);
color: #092d42;
}
.descriptionButtonText {
position: absolute;
top: 12px;
}
.descriptionText {
background: transparent;
border: none;
color: #17394d;
cursor: pointer;
line-height: 1.5;
font-size: 15px;
margin-bottom: 8px;
outline: none;
overflow: hidden;
overflow-wrap: break-word;
padding: 0;
text-align: left;
white-space: pre-line;
width: 100%;
}
.grid {
background: #f5f6f7;
margin: 0 !important;
}
.headerPadding {
padding: 0 !important;
}
.headerTitle {
margin: 4px 0;
padding: 6px 0 0;
}
.headerWrapper {
margin: 12px 48px 12px 56px;
position: relative;
}
.labels {
border: none;
border-radius: 3px;
box-sizing: border-box;
color: #fff;
display: inline-block;
font-size: 12px;
font-weight: 600;
line-height: 32px;
margin: 0 4px 4px 0;
max-width: 100%;
min-width: 40px;
outline: none;
overflow: hidden;
padding: 0 12px;
text-overflow: ellipsis;
text-shadow: rgba(0, 0, 0, 0.2) 1px 1px 0;
white-space: nowrap;
}
.modalPadding {
padding: 0px !important;
}
.moduleHeader {
color: #17394d;
font-size: 16px;
font-weight: bold;
line-height: 20px;
margin: 0 0 4px;
padding: 8px 0;
}
.moduleIcon {
color: #17394d;
font-size: 17px !important;
height: 32px !important;
left: -40px;
line-height: 32px;
margin-right: 0 !important;
position: absolute;
top: 2px;
width: 32px !important;
}
.moduleWrapper {
margin: 0 0 0 40px;
position: relative;
}
.text {
color: #6b808c;
font-size: 12px;
font-weight: bold;
letter-spacing: 0.3px;
line-height: 20px;
margin: 0 8px 4px 0;
text-transform: uppercase;
}
.sidebarPadding {
padding: 8px 16px 0 8px !important;
}
.wrapper {
width: 768px !important;
}

View file

@ -0,0 +1,122 @@
import React, {
useCallback, useEffect, useImperativeHandle, useRef, useState,
} from 'react';
import PropTypes from 'prop-types';
import { useTranslation } from 'react-i18next';
import TextareaAutosize from 'react-textarea-autosize';
import { Button, Form, TextArea } from 'semantic-ui-react';
import { useClosableForm, useField } from '../../hooks';
import styles from './EditDescription.module.css';
const EditDescription = React.forwardRef(({ children, defaultValue, onUpdate }, ref) => {
const [t] = useTranslation();
const [isOpened, setIsOpened] = useState(false);
const [value, handleFieldChange, setValue] = useField(null);
const field = useRef(null);
const open = useCallback(() => {
setIsOpened(true);
setValue(defaultValue || '');
}, [defaultValue, setValue]);
const close = useCallback(() => {
setIsOpened(false);
setValue(null);
}, [setValue]);
const submit = useCallback(() => {
const cleanValue = value.trim() || null;
if (cleanValue !== defaultValue) {
onUpdate(cleanValue);
}
close();
}, [defaultValue, onUpdate, value, close]);
useImperativeHandle(
ref,
() => ({
open,
close,
}),
[open, close],
);
const handleChildrenClick = useCallback(() => {
open();
}, [open]);
const handleFieldKeyDown = useCallback(
(event) => {
if (event.key === 'Enter') {
event.preventDefault();
submit();
}
},
[submit],
);
const [handleFieldBlur, handleControlMouseOver, handleControlMouseOut] = useClosableForm(
isOpened,
close,
);
const handleSubmit = useCallback(() => {
submit();
}, [submit]);
useEffect(() => {
if (isOpened) {
field.current.ref.current.select();
}
}, [isOpened]);
if (!isOpened) {
return React.cloneElement(children, {
onClick: handleChildrenClick,
});
}
return (
<Form onSubmit={handleSubmit}>
<TextArea
ref={field}
as={TextareaAutosize}
value={value}
placeholder={t('common.enterDescription')}
minRows={3}
spellCheck={false}
className={styles.field}
onKeyDown={handleFieldKeyDown}
onChange={handleFieldChange}
onBlur={handleFieldBlur}
/>
<div className={styles.controls}>
{/* eslint-disable-next-line jsx-a11y/mouse-events-have-key-events */}
<Button
positive
content={t('action.save')}
onMouseOver={handleControlMouseOver}
onMouseOut={handleControlMouseOut}
/>
</div>
</Form>
);
});
EditDescription.propTypes = {
children: PropTypes.element.isRequired,
defaultValue: PropTypes.string,
onUpdate: PropTypes.func.isRequired,
};
EditDescription.defaultProps = {
defaultValue: undefined,
};
export default React.memo(EditDescription);

View file

@ -0,0 +1,22 @@
.controls {
clear: both !important;
margin-top: 6px !important;
}
.field {
background: #fff !important;
border: 1px solid rgba(9, 45, 66, 0.13) !important;
border-radius: 3px !important;
color: #17394d !important;
display: block !important;
font-size: 14px !important;
line-height: 1.5 !important;
margin-bottom: 4px !important;
overflow: hidden;
padding: 8px 12px !important;
resize: none !important;
}
.field:focus {
outline: none !important;
}

View file

@ -0,0 +1,67 @@
import React, { useCallback, useRef } from 'react';
import PropTypes from 'prop-types';
import TextareaAutosize from 'react-textarea-autosize';
import { TextArea } from 'semantic-ui-react';
import { useDidUpdate, useField, usePrevious } from '../../hooks';
import styles from './NameField.module.css';
const NameField = React.memo(({ defaultValue, onUpdate }) => {
const prevDefaultValue = usePrevious(defaultValue);
const [value, handleChange, setValue] = useField(defaultValue);
const isFocused = useRef(false);
const handleFocus = useCallback(() => {
isFocused.current = true;
}, []);
const handleKeyDown = useCallback((event) => {
if (event.key === 'Enter') {
event.preventDefault();
event.target.blur();
}
}, []);
const handleBlur = useCallback(() => {
isFocused.current = false;
const cleanValue = value.trim();
if (cleanValue) {
if (cleanValue !== defaultValue) {
onUpdate(cleanValue);
}
} else {
setValue(defaultValue);
}
}, [defaultValue, onUpdate, value, setValue]);
useDidUpdate(() => {
if (!isFocused.current && defaultValue !== prevDefaultValue) {
setValue(defaultValue);
}
}, [defaultValue, prevDefaultValue, setValue]);
return (
<TextArea
as={TextareaAutosize}
value={value}
spellCheck={false}
className={styles.field}
onFocus={handleFocus}
onKeyDown={handleKeyDown}
onChange={handleChange}
onBlur={handleBlur}
/>
);
});
NameField.propTypes = {
defaultValue: PropTypes.string.isRequired,
onUpdate: PropTypes.func.isRequired,
};
export default NameField;

View file

@ -0,0 +1,22 @@
.field {
background: transparent;
border: 1px solid transparent;
border-radius: 3px;
box-shadow: none;
color: #17394d;
font-size: 20px;
font-weight: 700;
line-height: 24px;
margin: -5px;
overflow: hidden;
padding: 4px;
resize: none;
width: 100%;
}
.field:focus {
background: #fff;
border-color: #5ba4cf;
box-shadow: 0 0 2px 0 #5ba4cf;
outline: 0;
}

View file

@ -0,0 +1,75 @@
import React, { useCallback } from 'react';
import PropTypes from 'prop-types';
import { useTranslation } from 'react-i18next';
import { Menu } from 'semantic-ui-react';
import { withPopup } from '../../../lib/popup';
import { Popup } from '../../../lib/custom-ui';
import { useSteps } from '../../../hooks';
import DeleteStep from '../../DeleteStep';
import styles from './ActionsPopup.module.css';
const StepTypes = {
DELETE: 'DELETE',
};
const ActionsStep = React.memo(({ onNameEdit, onDelete, onClose }) => {
const [t] = useTranslation();
const [step, openStep, handleBack] = useSteps();
const handleNameEditClick = useCallback(() => {
onNameEdit();
onClose();
}, [onNameEdit, onClose]);
const handleDeleteClick = useCallback(() => {
openStep(StepTypes.DELETE);
}, [openStep]);
if (step && step.type === StepTypes.DELETE) {
return (
<DeleteStep
title={t('common.deleteTask', {
context: 'title',
})}
content={t('common.areYouSureYouWantToDeleteThisTask')}
buttonContent={t('action.deleteTask')}
onConfirm={onDelete}
onBack={handleBack}
/>
);
}
return (
<>
<Popup.Header>
{t('common.taskActions', {
context: 'title',
})}
</Popup.Header>
<Popup.Content>
<Menu secondary vertical className={styles.menu}>
<Menu.Item className={styles.menuItem} onClick={handleNameEditClick}>
{t('action.editDescription', {
context: 'title',
})}
</Menu.Item>
<Menu.Item className={styles.menuItem} onClick={handleDeleteClick}>
{t('action.deleteTask', {
context: 'title',
})}
</Menu.Item>
</Menu>
</Popup.Content>
</>
);
});
ActionsStep.propTypes = {
onNameEdit: PropTypes.func.isRequired,
onDelete: PropTypes.func.isRequired,
onClose: PropTypes.func.isRequired,
};
export default withPopup(ActionsStep);

View file

@ -0,0 +1,9 @@
.menu {
margin: -7px -12px -5px !important;
width: calc(100% + 24px) !important;
}
.menuItem {
margin: 0 !important;
padding-left: 14px !important;
}

View file

@ -0,0 +1,138 @@
import React, {
useCallback, useEffect, useImperativeHandle, useRef, useState,
} from 'react';
import PropTypes from 'prop-types';
import { useTranslation } from 'react-i18next';
import TextareaAutosize from 'react-textarea-autosize';
import { Button, Form, TextArea } from 'semantic-ui-react';
import {
useClosableForm,
useDeepCompareCallback,
useDidUpdate,
useForm,
useToggle,
} from '../../../hooks';
import styles from './Add.module.css';
const DEFAULT_DATA = {
name: '',
};
const Add = React.forwardRef(({ children, onCreate }, ref) => {
const [t] = useTranslation();
const [isOpened, setIsOpened] = useState(false);
const [data, handleFieldChange, setData] = useForm(DEFAULT_DATA);
const [selectNameFieldState, selectNameField] = useToggle();
const nameField = useRef(null);
const open = useCallback(() => {
setIsOpened(true);
}, []);
const close = useCallback(() => {
setIsOpened(false);
}, []);
const submit = useDeepCompareCallback(() => {
const cleanData = {
...data,
name: data.name.trim(),
};
if (!cleanData.name) {
nameField.current.ref.current.select();
return;
}
onCreate(cleanData);
setData(DEFAULT_DATA);
selectNameField();
}, [onCreate, data, setData, selectNameField]);
useImperativeHandle(
ref,
() => ({
open,
close,
}),
[open, close],
);
const handleChildrenClick = useCallback(() => {
open();
}, [open]);
const handleFieldKeyDown = useCallback(
(event) => {
if (event.key === 'Enter') {
event.preventDefault();
submit();
}
},
[submit],
);
const [handleFieldBlur, handleControlMouseOver, handleControlMouseOut] = useClosableForm(
isOpened,
close,
);
const handleSubmit = useCallback(() => {
submit();
}, [submit]);
useEffect(() => {
if (isOpened) {
nameField.current.ref.current.select();
}
}, [isOpened]);
useDidUpdate(() => {
nameField.current.ref.current.select();
}, [selectNameFieldState]);
if (!isOpened) {
return React.cloneElement(children, {
onClick: handleChildrenClick,
});
}
return (
<Form className={styles.wrapper} onSubmit={handleSubmit}>
<TextArea
ref={nameField}
as={TextareaAutosize}
name="name"
value={data.name}
placeholder={t('common.enterTaskDescription')}
minRows={2}
spellCheck={false}
className={styles.field}
onKeyDown={handleFieldKeyDown}
onChange={handleFieldChange}
onBlur={handleFieldBlur}
/>
<div className={styles.controls}>
{/* eslint-disable-next-line jsx-a11y/mouse-events-have-key-events */}
<Button
positive
content={t('action.addTask')}
onMouseOver={handleControlMouseOver}
onMouseOut={handleControlMouseOut}
/>
</div>
</Form>
);
});
Add.propTypes = {
children: PropTypes.element.isRequired,
onCreate: PropTypes.func.isRequired,
};
export default React.memo(Add);

View file

@ -0,0 +1,23 @@
.controls {
clear: both !important;
margin-top: 6px !important;
}
.field {
background: #fff !important;
border: 1px solid rgba(9, 45, 66, 0.13) !important;
border-radius: 3px !important;
color: #17394d !important;
display: block !important;
line-height: 1.5 !important;
font-size: 14px !important;
margin-bottom: 4px !important;
overflow: hidden;
padding: 8px 12px !important;
resize: none !important;
}
.wrapper {
margin-top: 6px !important;
padding-bottom: 8px !important;
}

View file

@ -0,0 +1,116 @@
import React, {
useCallback, useEffect, useImperativeHandle, useRef, useState,
} from 'react';
import PropTypes from 'prop-types';
import { useTranslation } from 'react-i18next';
import TextareaAutosize from 'react-textarea-autosize';
import { Button, Form, TextArea } from 'semantic-ui-react';
import { useClosableForm, useField } from '../../../hooks';
import styles from './EditName.module.css';
const EditName = React.forwardRef(({ children, defaultValue, onUpdate }, ref) => {
const [t] = useTranslation();
const [isOpened, setIsOpened] = useState(false);
const [value, handleFieldChange, setValue] = useField(null);
const field = useRef(null);
const open = useCallback(() => {
setIsOpened(true);
setValue(defaultValue);
}, [defaultValue, setValue]);
const close = useCallback(() => {
setIsOpened(false);
setValue(null);
}, [setValue]);
const submit = useCallback(() => {
const cleanValue = value.trim();
if (!cleanValue) {
field.current.ref.current.select();
return;
}
if (cleanValue !== defaultValue) {
onUpdate(cleanValue);
}
close();
}, [defaultValue, onUpdate, value, close]);
useImperativeHandle(
ref,
() => ({
open,
close,
}),
[open, close],
);
const handleFieldKeyDown = useCallback(
(event) => {
if (event.key === 'Enter') {
event.preventDefault();
submit();
}
},
[submit],
);
const [handleFieldBlur, handleControlMouseOver, handleControlMouseOut] = useClosableForm(
isOpened,
close,
);
const handleSubmit = useCallback(() => {
submit();
}, [submit]);
useEffect(() => {
if (isOpened) {
field.current.ref.current.select();
}
}, [isOpened]);
if (!isOpened) {
return children;
}
return (
<Form onSubmit={handleSubmit} className={styles.wrapper}>
<TextArea
ref={field}
as={TextareaAutosize}
value={value}
minRows={2}
spellCheck={false}
className={styles.field}
onKeyDown={handleFieldKeyDown}
onChange={handleFieldChange}
onBlur={handleFieldBlur}
/>
<div className={styles.controls}>
{/* eslint-disable-next-line jsx-a11y/mouse-events-have-key-events */}
<Button
positive
content={t('action.save')}
onMouseOver={handleControlMouseOver}
onMouseOut={handleControlMouseOut}
/>
</div>
</Form>
);
});
EditName.propTypes = {
children: PropTypes.element.isRequired,
defaultValue: PropTypes.string.isRequired,
onUpdate: PropTypes.func.isRequired,
};
export default React.memo(EditName);

View file

@ -0,0 +1,26 @@
.controls {
clear: both;
margin-top: 6px !important;
}
.field {
background: #fff !important;
border: 1px solid rgba(9, 45, 66, 0.13) !important;
border-radius: 3px !important;
box-sizing: border-box !important;
color: #17394d !important;
display: block !important;
font-size: 14px !important;
line-height: 1.5 !important;
overflow: hidden !important;
padding: 8px 12px !important;
resize: none !important;
}
.field:focus {
outline: none;
}
.wrapper {
padding: 9px 32px 16px 40px;
}

View file

@ -0,0 +1,83 @@
import React, { useCallback, useRef } from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import { Button, Checkbox, Icon } from 'semantic-ui-react';
import EditName from './EditName';
import ActionsPopup from './ActionsPopup';
import styles from './Item.module.css';
const Item = React.memo(({
name, isCompleted, isPersisted, onUpdate, onDelete,
}) => {
const editName = useRef(null);
const handleClick = useCallback(() => {
if (isPersisted) {
editName.current.open();
}
}, [isPersisted]);
const handleNameUpdate = useCallback(
(newName) => {
onUpdate({
name: newName,
});
},
[onUpdate],
);
const handleToggleChange = useCallback(() => {
onUpdate({
isCompleted: !isCompleted,
});
}, [isCompleted, onUpdate]);
const handleNameEdit = useCallback(() => {
editName.current.open();
}, []);
return (
<div className={styles.wrapper}>
<span className={styles.checkboxWrapper}>
<Checkbox
checked={isCompleted}
disabled={!isPersisted}
className={styles.checkbox}
onChange={handleToggleChange}
/>
</span>
<EditName ref={editName} defaultValue={name} onUpdate={handleNameUpdate}>
<div className={styles.content}>
{/* eslint-disable jsx-a11y/click-events-have-key-events,
jsx-a11y/no-static-element-interactions */}
<span className={styles.text} onClick={handleClick}>
{/* eslint-enable jsx-a11y/click-events-have-key-events,
jsx-a11y/no-static-element-interactions */}
<span className={classNames(styles.task, isCompleted && styles.taskCompleted)}>
{name}
</span>
</span>
{isPersisted && (
<ActionsPopup onNameEdit={handleNameEdit} onDelete={onDelete}>
<Button className={classNames(styles.button, styles.target)}>
<Icon fitted name="pencil" size="small" />
</Button>
</ActionsPopup>
)}
</div>
</EditName>
</div>
);
});
Item.propTypes = {
name: PropTypes.string.isRequired,
isCompleted: PropTypes.bool.isRequired,
isPersisted: PropTypes.bool.isRequired,
onUpdate: PropTypes.func.isRequired,
onDelete: PropTypes.func.isRequired,
};
export default Item;

View file

@ -0,0 +1,73 @@
.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, 45, 66, 0.13) !important;
}
.checkboxWrapper {
display: inline-block;
padding: 10px 15px 0px 8px;
position: absolute;
text-align: center;
top: 0;
left: 0;
vertical-align: top;
z-index: 2000;
line-height: 1;
height: 32px;
}
.content:hover {
background-color: rgba(9, 45, 66, 0.08);
}
.content:hover .target {
opacity: 1;
}
.task {
display: inline-block;
overflow: hidden;
overflow-wrap: break-word;
padding: 8px 0;
vertical-align: top;
width: 100%;
}
.taskCompleted {
text-decoration: line-through;
}
.text {
background: transparent;
border-radius: 3px;
color: #17394d;
cursor: pointer;
display: inline-block;
font-size: 15px;
line-height: 1.5;
min-height: 32px;
padding: 0 32px 0 40px;
width: 100%;
}
.wrapper {
border-radius: 3px;
margin-left: -40px;
min-height: 32px;
position: relative;
transition: all 0.14s ease-in;
width: calc(100% + 40px);
}

View file

@ -0,0 +1,72 @@
import React, { useCallback } from 'react';
import PropTypes from 'prop-types';
import { useTranslation } from 'react-i18next';
import { Progress } from 'semantic-ui-react';
import Item from './Item';
import Add from './Add';
import styles from './Tasks.module.css';
const Tasks = React.memo(({
items, onCreate, onUpdate, onDelete,
}) => {
const [t] = useTranslation();
const handleUpdate = useCallback(
(id, data) => {
onUpdate(id, data);
},
[onUpdate],
);
const handleDelete = useCallback(
(id) => {
onDelete(id);
},
[onDelete],
);
const completedItems = items.filter((item) => item.isCompleted);
return (
<>
{items.length > 0 && (
<Progress
autoSuccess
value={completedItems.length}
total={items.length}
color="blue"
size="tiny"
className={styles.progress}
/>
)}
{items.map((item) => (
<Item
key={item.id}
name={item.name}
isCompleted={item.isCompleted}
isPersisted={item.isPersisted}
onUpdate={(data) => handleUpdate(item.id, data)}
onDelete={() => handleDelete(item.id)}
/>
))}
<Add onCreate={onCreate}>
<button type="button" className={styles.taskButton}>
<span className={styles.taskButtonText}>
{items.length > 0 ? t('action.addAnotherTask') : t('action.addTask')}
</span>
</button>
</Add>
</>
);
});
Tasks.propTypes = {
items: PropTypes.array.isRequired, // eslint-disable-line react/forbid-prop-types
onCreate: PropTypes.func.isRequired,
onUpdate: PropTypes.func.isRequired,
onDelete: PropTypes.func.isRequired,
};
export default Tasks;

View file

@ -0,0 +1,30 @@
.progress {
margin: 0 0 16px !important;
}
.taskButton {
background: transparent;
border: none;
border-radius: 3px;
color: #6b808c;
cursor: pointer;
display: block;
margin-top: 6px;
min-height: 54px;
outline: none;
padding: 8px 12px;
position: relative;
text-align: left;
text-decoration: none;
width: 100%;
}
.taskButton:hover {
background-color: rgba(9, 45, 66, 0.13);
color: #092d42;
}
.taskButtonText {
position: absolute;
top: 12px;
}

View file

@ -0,0 +1,3 @@
import Task from './Tasks';
export default Task;

View file

@ -0,0 +1,3 @@
import CardModal from './CardModal';
export default CardModal;