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; }