From de9aacbbfea5d5dadadc2c46c5463be5e1d5816d Mon Sep 17 00:00:00 2001 From: Kornull Date: Fri, 18 Apr 2025 03:19:03 +0300 Subject: [PATCH 01/11] feat: add comments --- package-lock.json | 68 ++++++-- package.json | 1 + src/app/article/index.js | 161 ++++++++++++++++-- src/app/article/mock-response.js | 98 +++++++++++ src/app/login/index.js | 1 - src/components/article-auth-message/index.js | 14 ++ src/components/article-auth-message/style.css | 11 ++ src/components/article-comments/index.js | 70 ++++++++ src/components/article-comments/style.css | 71 ++++++++ src/components/article-form/index.js | 39 +++++ src/components/article-form/style.css | 41 +++++ src/components/button/index.js | 5 +- src/components/button/style.css | 6 + src/components/textarea/index.js | 35 ++++ src/containers/catalog-filter/index.js | 1 - src/store-redux/comments/actions.js | 45 +++++ src/store-redux/comments/reducer.js | 37 ++++ src/store-redux/exports.js | 1 + src/store-redux/index.js | 3 +- src/utils/date-format.js | 15 ++ src/utils/get-last-comment-children-id.js | 15 ++ src/utils/texts-tree-to-list/index.js | 15 ++ 22 files changed, 716 insertions(+), 37 deletions(-) create mode 100644 src/app/article/mock-response.js create mode 100644 src/components/article-auth-message/index.js create mode 100644 src/components/article-auth-message/style.css create mode 100644 src/components/article-comments/index.js create mode 100644 src/components/article-comments/style.css create mode 100644 src/components/article-form/index.js create mode 100644 src/components/article-form/style.css create mode 100644 src/components/textarea/index.js create mode 100644 src/store-redux/comments/actions.js create mode 100644 src/store-redux/comments/reducer.js create mode 100644 src/utils/date-format.js create mode 100644 src/utils/get-last-comment-children-id.js create mode 100644 src/utils/texts-tree-to-list/index.js diff --git a/package-lock.json b/package-lock.json index 59bb3583a..f16abdd88 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "license": "ISC", "dependencies": { "@bem-react/classname": "^1.6.0", + "@redux-devtools/extension": "^3.3.0", "lodash.debounce": "^4.0.8", "lodash.throttle": "^4.1.1", "prop-types": "^15.8.1", @@ -1849,12 +1850,12 @@ "dev": true }, "node_modules/@babel/runtime": { - "version": "7.21.5", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.21.5.tgz", - "integrity": "sha512-8jI69toZqqcsnqGGqwGS4Qb1VwLOEp4hz+CXPywcvjs60u3B4Pom/U/7rm4W8tMOYEB+E9wgD0mW1l3r8qlI9Q==", - "dev": true, + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.27.0.tgz", + "integrity": "sha512-VtPOkrdPHZsKc/clNqyi9WUA8TINkZ4cGk63UUE3u4pmB2k+ZMQRDuIOagv8UVd6j7k0T3+RRIb7beKTebNbcw==", + "license": "MIT", "dependencies": { - "regenerator-runtime": "^0.13.11" + "regenerator-runtime": "^0.14.0" }, "engines": { "node": ">=6.9.0" @@ -2719,6 +2720,19 @@ "integrity": "sha512-Vo+PSpZG2/fmgmiNzYK9qWRh8h/CHrwD0mo1h1DzL4yzHNSfWYujGTYsWGreD000gcgmZ7K4Ys6Tx9TxtsKdDw==", "dev": true }, + "node_modules/@redux-devtools/extension": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/@redux-devtools/extension/-/extension-3.3.0.tgz", + "integrity": "sha512-X34S/rC8S/M1BIrkYD1mJ5f8vlH0BDqxXrs96cvxSBo4FhMdbhU+GUGsmNYov1xjSyLMHgo8NYrUG8bNX7525g==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.23.2", + "immutable": "^4.3.4" + }, + "peerDependencies": { + "redux": "^3.1.0 || ^4.0.0 || ^5.0.0" + } + }, "node_modules/@remix-run/router": { "version": "1.19.1", "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.19.1.tgz", @@ -5990,6 +6004,12 @@ "postcss": "^8.1.0" } }, + "node_modules/immutable": { + "version": "4.3.7", + "resolved": "https://registry.npmjs.org/immutable/-/immutable-4.3.7.tgz", + "integrity": "sha512-1hqclzwYwjRDFLjcFxOM5AYkkG0rpFPpr1RLPMEuGczoS7YA8gLhy8SWXYRAA/XwfEHpfo3cw5JGioS32fnMRw==", + "license": "MIT" + }, "node_modules/import-fresh": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", @@ -9301,10 +9321,10 @@ } }, "node_modules/regenerator-runtime": { - "version": "0.13.11", - "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz", - "integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==", - "dev": true + "version": "0.14.1", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz", + "integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==", + "license": "MIT" }, "node_modules/regenerator-transform": { "version": "0.15.2", @@ -12271,12 +12291,11 @@ "dev": true }, "@babel/runtime": { - "version": "7.21.5", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.21.5.tgz", - "integrity": "sha512-8jI69toZqqcsnqGGqwGS4Qb1VwLOEp4hz+CXPywcvjs60u3B4Pom/U/7rm4W8tMOYEB+E9wgD0mW1l3r8qlI9Q==", - "dev": true, + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.27.0.tgz", + "integrity": "sha512-VtPOkrdPHZsKc/clNqyi9WUA8TINkZ4cGk63UUE3u4pmB2k+ZMQRDuIOagv8UVd6j7k0T3+RRIb7beKTebNbcw==", "requires": { - "regenerator-runtime": "^0.13.11" + "regenerator-runtime": "^0.14.0" } }, "@babel/template": { @@ -12924,6 +12943,15 @@ "integrity": "sha512-Vo+PSpZG2/fmgmiNzYK9qWRh8h/CHrwD0mo1h1DzL4yzHNSfWYujGTYsWGreD000gcgmZ7K4Ys6Tx9TxtsKdDw==", "dev": true }, + "@redux-devtools/extension": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/@redux-devtools/extension/-/extension-3.3.0.tgz", + "integrity": "sha512-X34S/rC8S/M1BIrkYD1mJ5f8vlH0BDqxXrs96cvxSBo4FhMdbhU+GUGsmNYov1xjSyLMHgo8NYrUG8bNX7525g==", + "requires": { + "@babel/runtime": "^7.23.2", + "immutable": "^4.3.4" + } + }, "@remix-run/router": { "version": "1.19.1", "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.19.1.tgz", @@ -15343,6 +15371,11 @@ "dev": true, "requires": {} }, + "immutable": { + "version": "4.3.7", + "resolved": "https://registry.npmjs.org/immutable/-/immutable-4.3.7.tgz", + "integrity": "sha512-1hqclzwYwjRDFLjcFxOM5AYkkG0rpFPpr1RLPMEuGczoS7YA8gLhy8SWXYRAA/XwfEHpfo3cw5JGioS32fnMRw==" + }, "import-fresh": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", @@ -17739,10 +17772,9 @@ } }, "regenerator-runtime": { - "version": "0.13.11", - "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz", - "integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==", - "dev": true + "version": "0.14.1", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz", + "integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==" }, "regenerator-transform": { "version": "0.15.2", diff --git a/package.json b/package.json index 5da2f1714..66102875e 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ "homepage": "https://github.com/ylabio/react-webinar-3#readme", "dependencies": { "@bem-react/classname": "^1.6.0", + "@redux-devtools/extension": "^3.3.0", "lodash.debounce": "^4.0.8", "lodash.throttle": "^4.1.1", "prop-types": "^15.8.1", diff --git a/src/app/article/index.js b/src/app/article/index.js index 54f037b64..428c76622 100644 --- a/src/app/article/index.js +++ b/src/app/article/index.js @@ -1,46 +1,139 @@ -import { memo, useCallback } from 'react'; +import { memo, useCallback, useMemo, useState } from 'react'; import { useParams } from 'react-router-dom'; +import { useDispatch, useSelector as useSelectorRedux } from 'react-redux'; + +import shallowequal from 'shallowequal'; + import useStore from '../../hooks/use-store'; import useTranslate from '../../hooks/use-translate'; import useInit from '../../hooks/use-init'; +import useSelector from '../../hooks/use-selector'; + +import articleActions from '../../store-redux/article/actions'; +import commentsActions from '../../store-redux/comments/actions'; + import PageLayout from '../../components/page-layout'; import Head from '../../components/head'; -import Navigation from '../../containers/navigation'; -import Spinner from '../../components/spinner'; import ArticleCard from '../../components/article-card'; +import Spinner from '../../components/spinner'; +import HeadLayout from '../../components/head-layout'; +import ArticleComments from '../../components/article-comments'; +import ArticleForm from '../../components/article-form'; +import ArticleAuthMessage from '../../components/article-auth-message'; +import Textarea from '../../components/textarea'; + +import Navigation from '../../containers/navigation'; import LocaleSelect from '../../containers/locale-select'; import TopHead from '../../containers/top-head'; -import { useDispatch, useSelector } from 'react-redux'; -import shallowequal from 'shallowequal'; -import articleActions from '../../store-redux/article/actions'; -import HeadLayout from '../../components/head-layout'; + +import { textsTreeToList } from '../../utils/texts-tree-to-list'; +import listToTree from '../../utils/list-to-tree'; +import { getLastCommentChildrenId } from '../../utils/get-last-comment-children-id'; function Article() { const store = useStore(); const dispatch = useDispatch(); - // Параметры из пути /articles/:id const params = useParams(); useInit(() => { - //store.actions.article.load(params.id); + dispatch(commentsActions.load(params.id)); dispatch(articleActions.load(params.id)); }, [params.id]); - const select = useSelector( + const selectArticle = useSelectorRedux( state => ({ article: state.article.data, waiting: state.article.waiting, }), shallowequal, - ); // Нужно указать функцию для сравнения свойства объекта, так как хуком вернули объект + ); + + const selectComments = useSelectorRedux( + state => ({ + comments: state.comments.data, + waiting: state.comments.waiting, + }), + shallowequal, + ); + + const selectUser = useSelector(state => ({ + isUserAuth: state.session.exists, + userId: state.session.user._id, + token: state.session.token + })); const { t } = useTranslate(); + const [userFormData, setUserFormData] = useState({ + parentId: '', + isOpenInComments: false, + parentAuthor: '', + lastId: '', + }); + + const [comment, setComment] = useState(''); + const callbacks = { // Добавление в корзину addToBasket: useCallback(_id => store.actions.basket.addToBasket(_id), [store]), + onOpenCommentForm: useCallback( + (author, id, listItems) => { + const { lastItemId, lastChild } = getLastCommentChildrenId(id, listItems); + return setUserFormData(prevData => ({ + parentId: lastItemId, + lastId: lastChild, + isOpenInComments: true, + parentAuthor: author, + })); + }, + [userFormData], + ), + onCloseCommentForm: useCallback(() => { + setUserFormData(prevData => ({ + parentAuthor: '', + parentId: '', + lastId: '', + isOpenInComments: false, + })); + setComment(''); + }, [userFormData]), + onChangeMessage: useCallback( + value => { + setComment(prevtext => value.trim()); + }, + [comment], + ), + onSubmit: useCallback( async (e) => { + e.preventDefault(); + const data = { + _id: selectUser.userId, + text: comment, + parent: { + _id: userFormData.parentId || selectArticle.article._id, + _type: userFormData.isOpenInComments ? 'comment' : 'article', + }, + token: selectUser.token, + articleId: selectArticle.article._id, + }; + dispatch(commentsActions.addComment(data)) + callbacks.onCloseCommentForm(); + }, + [comment], + ), + }; + + const options = { + comments: useMemo( + () => [ + ...textsTreeToList(listToTree(selectComments.comments.items || []), (item, count) => ({ + ...item, + paddingL: `${Math.floor(40 * count)}px`, + })), + ], + [selectComments.comments], + ), }; return ( @@ -48,13 +141,53 @@ function Article() { - + - - + + + + + {selectUser.isUserAuth ? ( + + + ); +} + +export default Textarea; diff --git a/src/containers/catalog-filter/index.js b/src/containers/catalog-filter/index.js index 2eeca7564..7a0795383 100644 --- a/src/containers/catalog-filter/index.js +++ b/src/containers/catalog-filter/index.js @@ -62,7 +62,6 @@ function CatalogFilter() { }; const { t } = useTranslate(); - return ( ); } +Textarea.propTypes = { + placeholder: PropTypes.string, + value: PropTypes.string, +}; + export default Textarea; diff --git a/src/containers/catalog-filter/index.js b/src/containers/catalog-filter/index.js index 7a0795383..fbf51cff8 100644 --- a/src/containers/catalog-filter/index.js +++ b/src/containers/catalog-filter/index.js @@ -12,6 +12,8 @@ import Button from '../../components/button'; function CatalogFilter() { const store = useStore(); + const { t, lang } = useTranslate(); + const select = useSelector(state => ({ sort: state.catalog.params.sort, query: state.catalog.params.query, @@ -41,27 +43,27 @@ function CatalogFilter() { // Варианты сортировок sort: useMemo( () => [ - { value: 'order', title: 'По порядку' }, - { value: 'title.ru', title: 'По именованию' }, - { value: '-price', title: 'Сначала дорогие' }, - { value: 'edition', title: 'Древние' }, + { value: 'order', title: t( "search.filter-order") }, + { value: 'title.ru', title: t( "search.filter-name") }, + { value: '-price', title: t("search.filter-expensive") }, + { value: 'edition', title: t("search.filter-ancient") }, ], - [], + [lang], ), // Категории для фильтра categories: useMemo( () => [ - { value: '', title: 'Все' }, + { value: '', title: t('categories.all') }, ...treeToList(listToTree(select.categories), (item, level) => ({ value: item._id, title: '- '.repeat(level) + item.title, })), ], - [select.categories], + [select.categories, lang], ), }; - const { t } = useTranslate(); + return ( ); } Textarea.propTypes = { - placeholder: PropTypes.string, value: PropTypes.string, }; diff --git a/src/i18n/translations/en.json b/src/i18n/translations/en.json index 2ee1602bf..049bf4dad 100644 --- a/src/i18n/translations/en.json +++ b/src/i18n/translations/en.json @@ -15,7 +15,7 @@ }, "auth.message-link": "Log in", "auth.message-text": " to be able to comment", - "answer.for": "Answer for", + "answer.for": "My answer for", "answer.reply": "reply", "article.add": "Add", "article.country": "Manufacturer Country", diff --git a/src/i18n/translations/ru.json b/src/i18n/translations/ru.json index 44494336f..365cb8e97 100644 --- a/src/i18n/translations/ru.json +++ b/src/i18n/translations/ru.json @@ -17,7 +17,7 @@ }, "auth.message-link": "Войдите", "auth.message-text": ", чтобы иметь возможность комментировать", - "answer.for": "Ответ для", + "answer.for": "Мой ответ для", "answer.reply": "ответить", "article.add": "Добавить", "article.country": "Страна производитель", diff --git a/src/store-redux/index.js b/src/store-redux/index.js index 6107d9ceb..41f1a6b01 100644 --- a/src/store-redux/index.js +++ b/src/store-redux/index.js @@ -1,5 +1,4 @@ import { applyMiddleware, combineReducers, createStore } from 'redux'; -import { composeWithDevTools } from '@redux-devtools/extension'; import * as reducers from './exports'; import { thunk, withExtraArgument } from 'redux-thunk'; @@ -8,6 +7,6 @@ export default function createStoreRedux(services, config = {}) { return createStore( combineReducers(reducers), undefined, - composeWithDevTools(applyMiddleware(withExtraArgument(services))), + applyMiddleware(withExtraArgument(services)), ); -} +} \ No newline at end of file diff --git a/src/store-redux/user-comment/actions.js b/src/store-redux/user-comment/actions.js index 7ee0be954..1efa9eace 100644 --- a/src/store-redux/user-comment/actions.js +++ b/src/store-redux/user-comment/actions.js @@ -9,17 +9,12 @@ export default { dispatch({ type: 'user-comment/load-success' }); }; }, - setCommentsData: (parentId, lastTreeId, typeComment, author, isFormInComments) => { + setCommentsData: commentsOpenForData => { return dispatch => { - dispatch({ type: 'user-comment/load-start' }); dispatch({ type: 'user-comment/update-data', payload: { - _id: parentId, - lastId: lastTreeId, - _type: typeComment, - _author: author, - formPlace: isFormInComments, + data: commentsOpenForData, }, }); dispatch({ type: 'user-comment/load-success' }); @@ -27,9 +22,12 @@ export default { }, setUserMessage: value => { return dispatch => { - dispatch({ type: 'user-comment/load-start' }); dispatch({ type: 'user-comment/upload-comment', payload: { text: value } }); - dispatch({ type: 'user-comment/load-success' }); + }; + }, + resetForm: () => { + return dispatch => { + dispatch({ type: 'user-comment/reset-form' }); }; }, resetUserCommentStore: () => { diff --git a/src/store-redux/user-comment/reducer.js b/src/store-redux/user-comment/reducer.js index 0b5de902c..c21591cae 100644 --- a/src/store-redux/user-comment/reducer.js +++ b/src/store-redux/user-comment/reducer.js @@ -28,14 +28,7 @@ function reducer(state = initialState, action) { case 'user-comment/update-data': return { ...state, - parent: { - ...state.parent, - _id: action.payload._id, - _type: action.payload._type, - }, - lastIdFromCommentTree: action.payload.lastId, - isOpenFormInComments: action.payload.formPlace, - commentAuthorNick: action.payload._author, + ...action.payload.data, }; case 'user-comment/upload-comment': return { @@ -52,6 +45,12 @@ function reducer(state = initialState, action) { ...state, waiting: false, }; + case 'user-comment/reset-form': + return { + ...state, + isOpenFormInComments: false, + userComment: '', + }; case 'user-comment/reset': return { ...initialState,