diff --git a/src/app/article/index.js b/src/app/article/index.js
index 54f037b64..50f681f48 100644
--- a/src/app/article/index.js
+++ b/src/app/article/index.js
@@ -10,10 +10,13 @@ import Spinner from '../../components/spinner';
import ArticleCard from '../../components/article-card';
import LocaleSelect from '../../containers/locale-select';
import TopHead from '../../containers/top-head';
-import { useDispatch, useSelector } from 'react-redux';
+import { useDispatch, useSelector as useReduxSelector } from 'react-redux';
import shallowequal from 'shallowequal';
import articleActions from '../../store-redux/article/actions';
+import commentsActions from '../../store-redux/comments/actions';
import HeadLayout from '../../components/head-layout';
+import Comments from '../../containers/comments';
+import useSelector from '../../hooks/use-selector';
function Article() {
const store = useStore();
@@ -24,18 +27,24 @@ function Article() {
const params = useParams();
useInit(() => {
- //store.actions.article.load(params.id);
dispatch(articleActions.load(params.id));
+ dispatch(commentsActions.loadAll(params.id));
}, [params.id]);
- const select = useSelector(
+ const select = useReduxSelector(
state => ({
article: state.article.data,
waiting: state.article.waiting,
+ comments: state.comments.data,
}),
shallowequal,
); // Нужно указать функцию для сравнения свойства объекта, так как хуком вернули объект
+ const profile = useSelector(state => ({
+ isAuth: state.session.exists,
+ userId: state.session.user._id,
+ }));
+
const { t } = useTranslate();
const callbacks = {
@@ -55,6 +64,12 @@ function Article() {
+
>
diff --git a/src/components/comment/index.js b/src/components/comment/index.js
new file mode 100644
index 000000000..fcde33dc7
--- /dev/null
+++ b/src/components/comment/index.js
@@ -0,0 +1,30 @@
+import CommentsForm from '../comments-form';
+import './style.css';
+
+export const Comment = ({
+ by,
+ text,
+ isMine,
+ date,
+ isFormOpen,
+ toggleForm,
+ onSubmit,
+ value,
+ onChange,
+}) => {
+ return (
+
+
+
+ {by}
+
+ {date}
+
+
+
+ {isFormOpen &&
}
+
+ );
+};
diff --git a/src/components/comment/style.css b/src/components/comment/style.css
new file mode 100644
index 000000000..6e24e1b83
--- /dev/null
+++ b/src/components/comment/style.css
@@ -0,0 +1,36 @@
+.Comment {
+ font-size: 12px;
+ line-height: 18px;
+ font-weight: 400;
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+ align-items: flex-start;
+}
+
+.Comment-subtitle-block {
+ display: flex;
+ gap: 12px;
+}
+
+.Comment-userName {
+ font-weight: 700;
+
+ &[data-mine-comment='isMine'] {
+ color: #4b5563;
+ }
+}
+
+.Comment-date {
+ color: #666666;
+}
+
+.Comment-text {
+ font-size: 14px;
+ line-height: 20px;
+}
+
+.Comment-answer {
+ color: var(--primary);
+ padding: 0;
+}
diff --git a/src/components/comments-form/index.js b/src/components/comments-form/index.js
new file mode 100644
index 000000000..e49fe1c21
--- /dev/null
+++ b/src/components/comments-form/index.js
@@ -0,0 +1,20 @@
+import { memo } from 'react';
+import Button from '../button';
+import Input from '../input';
+import './style.css';
+
+const CommentsForm = ({ onSubmit, value, onChange }) => {
+ const onSubmitHandler = () => {
+ onSubmit();
+ };
+
+ return (
+
+ Новый комментарий
+
+
+
+ );
+};
+
+export default memo(CommentsForm);
diff --git a/src/components/comments-form/style.css b/src/components/comments-form/style.css
new file mode 100644
index 000000000..46c5fbf57
--- /dev/null
+++ b/src/components/comments-form/style.css
@@ -0,0 +1,12 @@
+.Comments-form {
+ display: flex;
+ flex-direction: column;
+ gap: 16px;
+ width: 100%;
+}
+
+.Comments-form-title {
+ font-weight: 700;
+ font-size: 16px;
+ line-height: 22px;
+}
diff --git a/src/components/input/style.css b/src/components/input/style.css
index 18dd7b7a0..3730fafec 100644
--- a/src/components/input/style.css
+++ b/src/components/input/style.css
@@ -17,3 +17,8 @@
.Input_theme_small {
width: 233px;
}
+
+.Input_theme_full {
+ width: 100%;
+ height: 88px;
+}
diff --git a/src/components/smart-comment/index.js b/src/components/smart-comment/index.js
new file mode 100644
index 000000000..a829462bb
--- /dev/null
+++ b/src/components/smart-comment/index.js
@@ -0,0 +1,66 @@
+import { useDispatch, useSelector as useReduxSelector } from 'react-redux';
+import shallowEqual from 'shallowequal';
+import commentsActions from '../../store-redux/comments/actions';
+import useInit from '../../hooks/use-init';
+import { Comment } from '../comment';
+
+export const SmartComment = ({
+ comment,
+ userId,
+ openFormForId,
+ handleToggleForm,
+ onSubmit,
+ value,
+ onChange,
+}) => {
+ const dispatch = useDispatch();
+
+ useInit(() => {
+ dispatch(commentsActions.getAuthor(comment.author._id));
+ }, []);
+
+ const select = useReduxSelector(
+ state => ({
+ author: state.comments.authors[comment.author._id]?.profile.name,
+ }),
+ shallowEqual,
+ );
+
+ const formattedDate = new Intl.DateTimeFormat('ru-RU', {
+ dateStyle: 'long',
+ timeStyle: 'short',
+ }).format(new Date(comment.dateCreate));
+
+ return (
+
+ {comment && (
+
handleToggleForm(comment._id)}
+ onSubmit={onSubmit}
+ value={value}
+ onChange={onChange}
+ />
+ )}
+
+ {comment &&
+ comment.children?.map(comment => (
+
+ ))}
+
+
+ );
+};
diff --git a/src/containers/comments/index.js b/src/containers/comments/index.js
new file mode 100644
index 000000000..b3cdca57c
--- /dev/null
+++ b/src/containers/comments/index.js
@@ -0,0 +1,60 @@
+import { memo, useEffect, useState } from 'react';
+import { Link } from 'react-router-dom';
+import './style.css';
+import CommentsForm from '../../components/comments-form';
+import commentsActions from '../../store-redux/comments/actions';
+import useInit from '../../hooks/use-init';
+import { useDispatch, useSelector as useReduxSelector } from 'react-redux';
+import shallowEqual from 'shallowequal';
+import { Comment } from '../../components/comment';
+import { SmartComment } from '../../components/smart-comment';
+
+const Comments = ({ isAuth, comments, userId, articleId }) => {
+ const [openFormForId, setOpenFormForId] = useState(null);
+ const [commentText, setCommentText] = useState('');
+ const dispatch = useDispatch();
+
+ const handleToggleForm = id => {
+ setOpenFormForId(prev => (prev === id ? null : id));
+ };
+
+ const handleSubmit = () => {
+ dispatch(commentsActions.create(commentText, openFormForId, articleId));
+ setCommentText('');
+ setOpenFormForId(null);
+ };
+
+ return (
+
+
Комментарии ({comments.length})
+ {!isAuth && (
+
+
+ Войдите
+
+ , чтобы иметь возможность комментировать
+
+ )}
+
+ {comments.length > 0 &&
+ comments.map(comment => (
+
+ ))}
+
+ {!isAuth || openFormForId ? null : (
+
+ )}
+
+ );
+};
+
+export default memo(Comments);
diff --git a/src/containers/comments/style.css b/src/containers/comments/style.css
new file mode 100644
index 000000000..01569ccc3
--- /dev/null
+++ b/src/containers/comments/style.css
@@ -0,0 +1,35 @@
+.Comments {
+ display: flex;
+ flex-direction: column;
+ gap: 24px;
+}
+
+.Comments-title {
+ font-family: var(--second-font-family);
+ font-size: 24px;
+ font-weight: bold;
+}
+
+.Comments-link {
+ font-weight: 400;
+ font-size: 16px;
+ line-height: 22px;
+}
+
+.Comments-link-title {
+ color: var(--primary);
+ text-decoration: none;
+ &:hover {
+ text-decoration: underline;
+ }
+}
+
+.Smart-Comments {
+ display: flex;
+ flex-direction: column;
+ gap: 16px;
+}
+
+.Smart-Comment {
+ padding-left: 40px;
+}
diff --git a/src/store-redux/article/actions.js b/src/store-redux/article/actions.js
index 04f1c4b1d..50465691f 100644
--- a/src/store-redux/article/actions.js
+++ b/src/store-redux/article/actions.js
@@ -10,13 +10,17 @@ export default {
dispatch({ type: 'article/load-start' });
try {
+ const params = new URLSearchParams({
+ fields: '*,madeIn(title,code),category(title)',
+ });
const res = await services.api.request({
- url: `/api/v1/articles/${id}?fields=*,madeIn(title,code),category(title)`,
+ url: `/api/v1/articles/${id}?${params}`,
});
// Товар загружен успешно
dispatch({ type: 'article/load-success', payload: { data: res.data.result } });
} catch (e) {
//Ошибка загрузки
+ console.error(e);
dispatch({ type: 'article/load-error' });
}
};
diff --git a/src/store-redux/comments/actions.js b/src/store-redux/comments/actions.js
new file mode 100644
index 000000000..9fe93afbf
--- /dev/null
+++ b/src/store-redux/comments/actions.js
@@ -0,0 +1,92 @@
+import listToTree from '../../utils/list-to-tree';
+
+export default {
+ loadAll: id => {
+ return async (dispatch, getState, services) => {
+ dispatch({ type: 'comments/load-start' });
+
+ try {
+ const params = new URLSearchParams({
+ fields: '*,madeIn(title,code),category(title)',
+ 'search[parent]': id,
+ sort: '-dateCreate',
+ limit: 50,
+ });
+
+ const res = await services.api.request({
+ url: `/api/v1/comments?${params}`,
+ });
+
+ const data = listToTree(res.data.result.items);
+
+ dispatch({
+ type: 'comments/load-success',
+ payload: { data },
+ });
+ } catch (e) {
+ console.error(e);
+ dispatch({ type: 'comments/load-error' });
+ }
+ };
+ },
+
+ getAuthor: id => {
+ return async (dispatch, getState, services) => {
+ dispatch({ type: 'comments/load-author-start' });
+
+ if (getState().comments.authors[id]) {
+ console.log('author already loaded');
+ return;
+ }
+
+ dispatch({ type: 'comments/load-author-start' });
+
+ try {
+ const res = await services.api.request({
+ url: `/api/v1/users/${id}`,
+ });
+
+ const data = res.data.result;
+
+ dispatch({
+ type: 'comments/load-author-success',
+ payload: { id, data },
+ });
+ } catch (e) {
+ console.error(e);
+ dispatch({ type: 'comments/load-author-error' });
+ }
+ };
+ },
+ create: function (text, parentId, articleId) {
+ return async (dispatch, getState, services) => {
+ dispatch({ type: 'comments/create-start' });
+
+ try {
+ const res = await services.api.request({
+ url: `/api/v1/comments`,
+ method: 'POST',
+ body: JSON.stringify({
+ text,
+ parent: {
+ _id: parentId ?? articleId,
+ _type: parentId ? 'comment' : 'article',
+ },
+ }),
+ });
+
+ const newComment = res.data.result;
+
+ dispatch({
+ type: 'comments/create-success',
+ payload: { comment: newComment },
+ });
+
+ dispatch(this.loadAll(articleId));
+ } catch (e) {
+ console.error(e);
+ dispatch({ type: 'comments/create-error' });
+ }
+ };
+ },
+};
diff --git a/src/store-redux/comments/reducer.js b/src/store-redux/comments/reducer.js
new file mode 100644
index 000000000..0c8ac0115
--- /dev/null
+++ b/src/store-redux/comments/reducer.js
@@ -0,0 +1,75 @@
+// Начальное состояние
+export const initialState = {
+ data: [],
+ authors: {},
+ waiting: false, // признак ожидания загрузки
+};
+
+// Обработчик действий
+function reducer(state = initialState, action) {
+ switch (action.type) {
+ case 'comments/load-start':
+ return { ...state, data: [], waiting: true };
+
+ case 'comments/load-success':
+ return { ...state, data: action.payload.data, waiting: false };
+
+ case 'comments/load-error':
+ return { ...state, data: [], waiting: false }; //@todo текст ошибки сохранять?
+
+ case 'comments/load-author-start': {
+ return { ...state, waiting: true };
+ }
+
+ case 'comments/load-author-success': {
+ const newstate = {
+ ...state,
+ authors: { ...state.authors, [action.payload.id]: action.payload.data },
+ waiting: false,
+ };
+ return newstate;
+ }
+ case 'comments/load-author-error': {
+ return { ...state, data: [], waiting: false };
+ }
+
+ case 'comments/create-start':
+ return { ...state, waiting: true };
+
+ case 'comments/create-success': {
+ // TODO разобраться почему не происходит перерендер при добавлении коммента
+ const comment = action.payload.comment;
+
+ if (comment.parent?._id) {
+ return {
+ ...state,
+ data: state.data.map(item => {
+ if (item._id === comment.parent._id) {
+ return {
+ ...item,
+ children: [...(item.children || []), comment],
+ };
+ }
+ return item;
+ }),
+ waiting: false,
+ };
+ }
+
+ return {
+ ...state,
+ data: [...state.data, comment],
+ waiting: false,
+ };
+ }
+
+ case 'comments/create-error':
+ return { ...state, waiting: false };
+
+ default:
+ // Нет изменений
+ return state;
+ }
+}
+
+export default reducer;
diff --git a/src/store-redux/exports.js b/src/store-redux/exports.js
index 1a0a3d742..c7f1c588b 100644
--- a/src/store-redux/exports.js
+++ b/src/store-redux/exports.js
@@ -1,2 +1,3 @@
export { default as article } from './article/reducer';
export { default as modals } from './modals/reducer';
+export { default as comments } from './comments/reducer';
diff --git a/src/utils/list-to-tree/index.js b/src/utils/list-to-tree/index.js
index fb4d20a66..7b0cb08f6 100644
--- a/src/utils/list-to-tree/index.js
+++ b/src/utils/list-to-tree/index.js
@@ -5,31 +5,21 @@
* @returns {Array} Корневые узлы
*/
export default function listToTree(list, key = '_id') {
- let trees = {};
- let roots = {};
+ const map = new Map();
+ const roots = [];
+
for (const item of list) {
- // Добавление элемента в индекс узлов и создание свойства children
- if (!trees[item[key]]) {
- trees[item[key]] = item;
- trees[item[key]].children = [];
- // Ещё никто не ссылался, поэтому пока считаем корнем
- roots[item[key]] = trees[item[key]];
- } else {
- trees[item[key]] = Object.assign(trees[item[key]], item);
- }
+ map.set(item._id, { ...item, children: [] });
+ }
- // Если элемент имеет родителя, то добавляем его в подчиненные родителя
- if (item.parent?.[key]) {
- // Если родителя ещё нет в индексе, то индекс создаётся, ведь _id родителя известен
- if (!trees[item.parent[key]]) {
- trees[item.parent[key]] = { children: [] };
- roots[item.parent[key]] = trees[item.parent[key]];
- }
- // Добавления в подчиненные родителя
- trees[item.parent[key]].children.push(trees[item[key]]);
- // Так как элемент добавлен к родителю, то он уже не является корневым
- if (roots[item[key]]) delete roots[item[key]];
+ for (const item of list) {
+ const parentId = item.parent?.[key];
+ if (parentId && map.has(parentId)) {
+ map.get(parentId).children.push(map.get(item[key]));
+ } else {
+ roots.push(map.get(item[key]));
}
}
- return Object.values(roots);
+
+ return roots;
}