mirror of
https://github.com/plankanban/planka.git
synced 2025-08-04 21:15:25 +02:00
Initial commit
This commit is contained in:
commit
5ffef61fe7
613 changed files with 91659 additions and 0 deletions
101
client/src/components/CardModal/Actions/Actions.jsx
Executable file
101
client/src/components/CardModal/Actions/Actions.jsx
Executable 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;
|
38
client/src/components/CardModal/Actions/Actions.module.css
Normal file
38
client/src/components/CardModal/Actions/Actions.module.css
Normal 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;
|
||||
}
|
75
client/src/components/CardModal/Actions/AddComment.jsx
Executable file
75
client/src/components/CardModal/Actions/AddComment.jsx
Executable 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;
|
|
@ -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;
|
||||
}
|
122
client/src/components/CardModal/Actions/EditComment.jsx
Executable file
122
client/src/components/CardModal/Actions/EditComment.jsx
Executable 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);
|
|
@ -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;
|
||||
}
|
91
client/src/components/CardModal/Actions/Item.jsx
Executable file
91
client/src/components/CardModal/Actions/Item.jsx
Executable 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;
|
31
client/src/components/CardModal/Actions/Item.module.css
Normal file
31
client/src/components/CardModal/Actions/Item.module.css
Normal 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;
|
||||
}
|
87
client/src/components/CardModal/Actions/ItemComment.jsx
Executable file
87
client/src/components/CardModal/Actions/ItemComment.jsx
Executable 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;
|
|
@ -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;
|
||||
}
|
3
client/src/components/CardModal/Actions/index.js
Executable file
3
client/src/components/CardModal/Actions/index.js
Executable file
|
@ -0,0 +1,3 @@
|
|||
import Actions from './Actions';
|
||||
|
||||
export default Actions;
|
Loading…
Add table
Add a link
Reference in a new issue