diff --git a/package-lock.json b/package-lock.json index 59bb3583a..bd722c59d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11389,8 +11389,7 @@ "version": "7.21.0-placeholder-for-preset-env.2", "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-private-property-in-object/-/plugin-proposal-private-property-in-object-7.21.0-placeholder-for-preset-env.2.tgz", "integrity": "sha512-SOSkfJDddaM7mak6cPEpswyTRnuRltl429hMraQEglW+OkovnCzsiszTmsrlY//qLFjCpQDFRvjdm2wA5pPm9w==", - "dev": true, - "requires": {} + "dev": true }, "@babel/plugin-syntax-async-generators": { "version": "7.8.4", @@ -12896,8 +12895,7 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/@jsonjoy.com/base64/-/base64-1.1.2.tgz", "integrity": "sha512-q6XAnWQDIMA3+FTiOYajoYqySkO+JSat0ytXGSuRdq9uXE7o92gzuQwQM14xaCRlBLGq3v5miDGC4vkVTn54xA==", - "dev": true, - "requires": {} + "dev": true }, "@jsonjoy.com/json-pack": { "version": "1.1.0", @@ -12915,8 +12913,7 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/@jsonjoy.com/util/-/util-1.3.0.tgz", "integrity": "sha512-Cebt4Vk7k1xHy87kHY7KSPLT77A7Ev7IfOblyLZhtYEhrdQ6fX4EoLq3xOQ3O/DRMEh2ok5nyC180E+ABS8Wmw==", - "dev": true, - "requires": {} + "dev": true }, "@leichtgewicht/ip-codec": { "version": "2.0.5", @@ -12957,57 +12954,49 @@ "version": "8.0.0", "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-add-jsx-attribute/-/babel-plugin-add-jsx-attribute-8.0.0.tgz", "integrity": "sha512-b9MIk7yhdS1pMCZM8VeNfUlSKVRhsHZNMl5O9SfaX0l0t5wjdgu4IDzGB8bpnGBBOjGST3rRFVsaaEtI4W6f7g==", - "dev": true, - "requires": {} + "dev": true }, "@svgr/babel-plugin-remove-jsx-attribute": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-remove-jsx-attribute/-/babel-plugin-remove-jsx-attribute-8.0.0.tgz", "integrity": "sha512-BcCkm/STipKvbCl6b7QFrMh/vx00vIP63k2eM66MfHJzPr6O2U0jYEViXkHJWqXqQYjdeA9cuCl5KWmlwjDvbA==", - "dev": true, - "requires": {} + "dev": true }, "@svgr/babel-plugin-remove-jsx-empty-expression": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-remove-jsx-empty-expression/-/babel-plugin-remove-jsx-empty-expression-8.0.0.tgz", "integrity": "sha512-5BcGCBfBxB5+XSDSWnhTThfI9jcO5f0Ai2V24gZpG+wXF14BzwxxdDb4g6trdOux0rhibGs385BeFMSmxtS3uA==", - "dev": true, - "requires": {} + "dev": true }, "@svgr/babel-plugin-replace-jsx-attribute-value": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-replace-jsx-attribute-value/-/babel-plugin-replace-jsx-attribute-value-8.0.0.tgz", "integrity": "sha512-KVQ+PtIjb1BuYT3ht8M5KbzWBhdAjjUPdlMtpuw/VjT8coTrItWX6Qafl9+ji831JaJcu6PJNKCV0bp01lBNzQ==", - "dev": true, - "requires": {} + "dev": true }, "@svgr/babel-plugin-svg-dynamic-title": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-svg-dynamic-title/-/babel-plugin-svg-dynamic-title-8.0.0.tgz", "integrity": "sha512-omNiKqwjNmOQJ2v6ge4SErBbkooV2aAWwaPFs2vUY7p7GhVkzRkJ00kILXQvRhA6miHnNpXv7MRnnSjdRjK8og==", - "dev": true, - "requires": {} + "dev": true }, "@svgr/babel-plugin-svg-em-dimensions": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-svg-em-dimensions/-/babel-plugin-svg-em-dimensions-8.0.0.tgz", "integrity": "sha512-mURHYnu6Iw3UBTbhGwE/vsngtCIbHE43xCRK7kCw4t01xyGqb2Pd+WXekRRoFOBIY29ZoOhUCTEweDMdrjfi9g==", - "dev": true, - "requires": {} + "dev": true }, "@svgr/babel-plugin-transform-react-native-svg": { "version": "8.1.0", "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-transform-react-native-svg/-/babel-plugin-transform-react-native-svg-8.1.0.tgz", "integrity": "sha512-Tx8T58CHo+7nwJ+EhUwx3LfdNSG9R2OKfaIXXs5soiy5HtgoAEkDay9LIimLOcG8dJQH1wPZp/cnAv6S9CrR1Q==", - "dev": true, - "requires": {} + "dev": true }, "@svgr/babel-plugin-transform-svg-component": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-transform-svg-component/-/babel-plugin-transform-svg-component-8.0.0.tgz", "integrity": "sha512-DFx8xa3cZXTdb/k3kfPeaixecQLgKh5NVBMwD0AQxOzcZawK4oo1Jh9LbrcACUivsCA7TLG8eeWgrDXjTMhRmw==", - "dev": true, - "requires": {} + "dev": true }, "@svgr/babel-preset": { "version": "8.1.0", @@ -13541,22 +13530,19 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/@webpack-cli/configtest/-/configtest-2.1.1.tgz", "integrity": "sha512-wy0mglZpDSiSS0XHrVR+BAdId2+yxPSoJW8fsna3ZpYSlufjvxnP4YbKTCBZnNIcGN4r6ZPXV55X4mYExOfLmw==", - "dev": true, - "requires": {} + "dev": true }, "@webpack-cli/info": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/@webpack-cli/info/-/info-2.0.2.tgz", "integrity": "sha512-zLHQdI/Qs1UyT5UBdWNqsARasIA+AaF8t+4u2aS2nEpBQh2mWIVb8qAklq0eUENnC5mOItrIB4LiS9xMtph18A==", - "dev": true, - "requires": {} + "dev": true }, "@webpack-cli/serve": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/@webpack-cli/serve/-/serve-2.0.5.tgz", "integrity": "sha512-lqaoKnRYBdo1UgDX8uF24AfGMifWK19TxPmM5FHc2vAGxrJ/qtyUyFBWoY1tISZdelsQ5fBcOusifo5o5wSJxQ==", - "dev": true, - "requires": {} + "dev": true }, "@xtuc/ieee754": { "version": "1.2.0", @@ -13590,8 +13576,7 @@ "version": "1.9.5", "resolved": "https://registry.npmjs.org/acorn-import-attributes/-/acorn-import-attributes-1.9.5.tgz", "integrity": "sha512-n02Vykv5uA3eHGM/Z2dQrcD56kL8TyDb2p1+0P83PClMnC/nc+anbQRhIOWnSq4Ke/KvDPrY3C9hDtC/A3eHnQ==", - "dev": true, - "requires": {} + "dev": true }, "ajv": { "version": "6.12.6", @@ -13638,8 +13623,7 @@ "version": "3.5.2", "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-3.5.2.tgz", "integrity": "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==", - "dev": true, - "requires": {} + "dev": true }, "ansi-escapes": { "version": "4.3.2", @@ -14478,8 +14462,7 @@ "version": "1.5.3", "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.5.3.tgz", "integrity": "sha512-NHQtfOOW68WD8lgypbLA5oT+Bt0xXJhiYvoR6SmmNXZfpzOGXwdKWmcwG8N7PwVVWV3eF/68nmD9BaJSsTBhyQ==", - "dev": true, - "requires": {} + "dev": true }, "deepmerge": { "version": "4.3.1", @@ -15340,8 +15323,7 @@ "version": "5.1.0", "resolved": "https://registry.npmjs.org/icss-utils/-/icss-utils-5.1.0.tgz", "integrity": "sha512-soFhflCVWLfRNOPU3iv5Z9VUdT44xFRbzjLsEzSr5AQmgqPMTHdU3PMT1Cf1ssx8fLNJDA1juftYl+PUcv3MqA==", - "dev": true, - "requires": {} + "dev": true }, "import-fresh": { "version": "3.3.1", @@ -16241,8 +16223,7 @@ "version": "1.2.3", "resolved": "https://registry.npmjs.org/jest-pnp-resolver/-/jest-pnp-resolver-1.2.3.tgz", "integrity": "sha512-+3NpwQEnRoIBtx4fyhblQDPgJI0H1IEIkX7ShLUjPGA7TtUTvI1oiKi3SR4oBR0hQhQR80l4WAe5RrXBwWMA8w==", - "dev": true, - "requires": {} + "dev": true }, "jest-regex-util": { "version": "29.6.3", @@ -17446,8 +17427,7 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/postcss-modules-extract-imports/-/postcss-modules-extract-imports-3.1.0.tgz", "integrity": "sha512-k3kNe0aNFQDAZGbin48pL2VNidTF0w4/eASDsxlyspobzU3wZQLOGj7L9gfRe0Jo9/4uud09DsjFNH7winGv8Q==", - "dev": true, - "requires": {} + "dev": true }, "postcss-modules-local-by-default": { "version": "4.0.5", @@ -17720,8 +17700,7 @@ "redux-thunk": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz", - "integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==", - "requires": {} + "integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==" }, "regenerate": { "version": "1.4.2", @@ -18461,8 +18440,7 @@ "version": "1.21.0", "resolved": "https://registry.npmjs.org/thingies/-/thingies-1.21.0.tgz", "integrity": "sha512-hsqsJsFMsV+aD4s3CWKk85ep/3I9XzYV/IXaSouJMYIoDlgyi11cBhsqYe9/geRfB0YIikBQg6raRaM+nIMP9g==", - "dev": true, - "requires": {} + "dev": true }, "thunky": { "version": "1.1.0", @@ -18495,8 +18473,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/tree-dump/-/tree-dump-1.0.2.tgz", "integrity": "sha512-dpev9ABuLWdEubk+cIaI9cHwRNNDjkBBLXTwI4UCUFdQ5xXKqNXoK4FEciw/vxf+NQ7Cb7sGUyeUtORvHIdRXQ==", - "dev": true, - "requires": {} + "dev": true }, "tslib": { "version": "2.5.0", @@ -18582,8 +18559,7 @@ "use-sync-external-store": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.2.0.tgz", - "integrity": "sha512-eEgnFxGQ1Ife9bzYs6VLi8/4X6CObHMw9Qr9tPY43iKwsPw8xE8+EFsf/2cFZ5S3esXgpWgtSCtLNS41F+sKPA==", - "requires": {} + "integrity": "sha512-eEgnFxGQ1Ife9bzYs6VLi8/4X6CObHMw9Qr9tPY43iKwsPw8xE8+EFsf/2cFZ5S3esXgpWgtSCtLNS41F+sKPA==" }, "util-deprecate": { "version": "1.0.2", @@ -18951,8 +18927,7 @@ "version": "8.18.0", "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.0.tgz", "integrity": "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==", - "dev": true, - "requires": {} + "dev": true }, "y18n": { "version": "5.0.8", diff --git a/src/api/index.js b/src/api/index.js index a5f073295..687a8e2f7 100644 --- a/src/api/index.js +++ b/src/api/index.js @@ -10,7 +10,6 @@ class APIService { 'Content-Type': 'application/json', }; } - /** * HTTP запрос * @param url @@ -21,9 +20,14 @@ class APIService { */ async request({ url, method = 'GET', headers = {}, ...options }) { if (!url.match(/^(http|\/\/)/)) url = this.config.baseUrl + url; + + // Извлечем язык, раз у нас уже есть сервис здесь, и перед каждым запросом будем его обновлять + // а в main добавим зависимость lang + const lang = this.services.i18n.getLang(); + const res = await fetch(url, { method, - headers: { ...this.defaultHeaders, ...headers }, + headers: { ...this.defaultHeaders, ...headers, 'Accept-language': lang }, ...options, }); return { data: await res.json(), status: res.status, headers: res.headers }; diff --git a/src/app/article/index.js b/src/app/article/index.js index 54f037b64..4d49f084c 100644 --- a/src/app/article/index.js +++ b/src/app/article/index.js @@ -13,10 +13,14 @@ import TopHead from '../../containers/top-head'; import { useDispatch, useSelector } from 'react-redux'; import shallowequal from 'shallowequal'; import articleActions from '../../store-redux/article/actions'; +import commentsActions from '../../store-redux/comment/actions'; import HeadLayout from '../../components/head-layout'; +import Comments from '../../containers/comment/'; +import { useSelector as useSelectorRedux } from 'react-redux'; function Article() { const store = useStore(); + const { t, lang } = useTranslate(); const dispatch = useDispatch(); // Параметры из пути /articles/:id @@ -26,7 +30,8 @@ function Article() { useInit(() => { //store.actions.article.load(params.id); dispatch(articleActions.load(params.id)); - }, [params.id]); + dispatch(commentsActions.load(params.id)); + }, [params.id, lang]); const select = useSelector( state => ({ @@ -36,7 +41,12 @@ function Article() { shallowequal, ); // Нужно указать функцию для сравнения свойства объекта, так как хуком вернули объект - const { t } = useTranslate(); + const selectRedux = useSelectorRedux( + state => ({ + waiting: state.comment.waiting, + }), + shallowequal, + ); const callbacks = { // Добавление в корзину @@ -56,6 +66,9 @@ function Article() { + + + ); diff --git a/src/app/login/index.js b/src/app/login/index.js index 32b86daa4..41d10cb49 100644 --- a/src/app/login/index.js +++ b/src/app/login/index.js @@ -16,14 +16,14 @@ import HeadLayout from '../../components/head-layout'; import Form from '../../components/form'; function Login() { - const { t } = useTranslate(); + const { t, lang } = useTranslate(); const location = useLocation(); const navigate = useNavigate(); const store = useStore(); useInit(() => { store.actions.session.resetErrors(); - }); + }, [lang]); const select = useSelector(state => ({ waiting: state.session.waiting, diff --git a/src/app/main/index.js b/src/app/main/index.js index d9fb1c6c2..a160e0a03 100644 --- a/src/app/main/index.js +++ b/src/app/main/index.js @@ -13,17 +13,16 @@ import HeadLayout from '../../components/head-layout'; function Main() { const store = useStore(); + const { t, lang } = useTranslate(); useInit( async () => { await Promise.all([store.actions.catalog.initParams(), store.actions.categories.load()]); }, - [], + [lang], true, ); - const { t } = useTranslate(); - return ( <> diff --git a/src/app/profile/index.js b/src/app/profile/index.js index 166d127fb..ecbe7cd1f 100644 --- a/src/app/profile/index.js +++ b/src/app/profile/index.js @@ -14,18 +14,17 @@ import HeadLayout from '../../components/head-layout'; function Profile() { const store = useStore(); + const { t, lang } = useTranslate(); useInit(() => { store.actions.profile.load(); - }, []); + }, [lang]); const select = useSelector(state => ({ profile: state.profile.data, waiting: state.profile.waiting, })); - const { t } = useTranslate(); - return ( <> diff --git a/src/components/button/index.js b/src/components/button/index.js index 7d1d3d05c..cfc6fb214 100644 --- a/src/components/button/index.js +++ b/src/components/button/index.js @@ -8,7 +8,7 @@ function Button({ onClick = () => {}, title, style, type = 'button' }) { return (
- +
); } diff --git a/src/components/comment-form/index.js b/src/components/comment-form/index.js new file mode 100644 index 000000000..416063613 --- /dev/null +++ b/src/components/comment-form/index.js @@ -0,0 +1,104 @@ +import React, { useState, useEffect, useRef } from 'react'; +import PropTypes from 'prop-types'; +import { cn as bem } from '@bem-react/classname'; +import { useNavigate } from 'react-router-dom'; +import './style.css'; +import Button from '../button'; + +function FormComment({ + mode, + title, + id = '', + sendComment, + type, + exists, + setFormCommentIsActive = () => {}, + currentCommentId, + t +}) { + const cn = bem('FormComment'); + const navigate = useNavigate(); + const [text, setText] = useState(''); + const formRef = useRef(null); + + useEffect(() => { + if (formRef.current && mode === 'answer') { + formRef.current.scrollIntoView({ behavior: 'smooth' }); + } + }, []); + + function handleCansel(e) { + e.preventDefault(); + setFormCommentIsActive(); + } + function signIn() { + navigate('/login', { state: { back: location.pathname } }); + } + + function handleAnswer(e) { + e.preventDefault(); + if (!text.replace(/\s+/g, '')) { + return; + } + if (mode === 'answer') { + sendComment({ text, parent: { _id: currentCommentId, _type: type } }); + setText(''); + } else { + sendComment({ text, parent: { _id: id, _type: type } }); + setText(''); + } + } + + return ( + <> + {exists ? ( +
+
{title}
+ +
+
+
+ ) : ( + <> + {mode === 'answer' ? ( +
+ {' '} + Войдите, чтобы иметь возможность ответить. +
+ ) : ( +
+ {' '} + Войдите, чтобы иметь возможность комментировать +
+ )} + + )} + + ); +} + +FormComment.propTypes = { + mode: PropTypes.string, + title: PropTypes.string, + id: PropTypes.string, + setIdActiveAnswer: PropTypes.func, + sendComment: PropTypes.func, + type: PropTypes.string, +}; + +export default React.memo(FormComment); diff --git a/src/components/comment-form/style.css b/src/components/comment-form/style.css new file mode 100644 index 000000000..0fb5e226e --- /dev/null +++ b/src/components/comment-form/style.css @@ -0,0 +1,37 @@ +.FormComment { + display: flex; + flex-direction: column; + gap: 10px; + margin-top: 30px; +} + +.FormComment-title { + font-size: 16px; + font-weight: bold; + margin: 0; +} + +.FormComment-textarea { + margin-top: 8px; + width: 100%; + font-size: 14px; + resize: none; + border-color: rgba(211, 211, 211, 1); + border-radius: 5px; +} + +.FormComment-actions { + margin-top: 8px; + display: flex; + justify-content: flex-start; + gap: 10px; +} + +.FormComment-login { + margin-top: 30px; +} + +.FormComment-login > a { + color: var(--primary); + cursor: pointer; +} diff --git a/src/components/comment-item/index.js b/src/components/comment-item/index.js new file mode 100644 index 000000000..3d1284c4c --- /dev/null +++ b/src/components/comment-item/index.js @@ -0,0 +1,77 @@ +import React, { useMemo } from 'react'; +import PropTypes from 'prop-types'; +import { cn as bem } from '@bem-react/classname'; +import FormComment from '../comment-form'; +import './style.css'; + +function ItemComment(props) { + const cn = bem('ItemComment'); + + const computedLevel = useMemo(() => { + if (props.level > 10) return 10; + return props.level; + }, [props.level]); + + const correctDatetime = useMemo(() => { + const date = new Date(props.datetime); + const options = { + day: '2-digit', + month: 'long', + year: 'numeric', + hour: '2-digit', + minute: '2-digit', + }; + return date.toLocaleDateString('ru-RU', options).replace(' г.', ''); + }, [props.datetime]); + + function handleAnswer() { + props.setFormAnswerIsActive(props.id); + props.addForm(props.id, props.level, props.comments); + } + + return ( +
+
+
+ {props.name} +
+
{correctDatetime}
+
+
{props.text}
+ + {props.answer} + + {props.formAnswerIsActive && props.index1 === props.index2 && ( + + )} +
+ ); +} + +ItemComment.propTypes = { + name: PropTypes.string, + text: PropTypes.string, + datetime: PropTypes.string, + level: PropTypes.number, + answer: PropTypes.string, + exists: PropTypes.bool, + setIdActiveAnswer: PropTypes.func, + idActiveAnswer: PropTypes.string, + sendComment: PropTypes.func, +}; + +export default React.memo(ItemComment); diff --git a/src/components/comment-item/style.css b/src/components/comment-item/style.css new file mode 100644 index 000000000..5c0d62964 --- /dev/null +++ b/src/components/comment-item/style.css @@ -0,0 +1,84 @@ +.ItemComment { + display: flex; + flex-direction: column; + align-self: flex-start; + gap: 7px; + width: 100%; +} +.ItemComment-level-1 { + margin-left: 0; +} +.ItemComment-level-2 { + margin-left: 40px; + width: calc(100% - 40px); +} +.ItemComment-level-3 { + margin-left: 80px; + width: calc(100% - 80px); +} +.ItemComment-level-4 { + margin-left: 120px; + width: calc(100% - 120px); +} +.ItemComment-level-5 { + margin-left: 160px; + width: calc(100% - 160px); +} +.ItemComment-level-6 { + margin-left: 200px; + width: calc(100% - 200px); +} +.ItemComment-level-7 { + margin-left: 240px; + width: calc(100% - 240px); +} +.ItemComment-level-8 { + margin-left: 280px; + width: calc(100% - 280px); +} +.ItemComment-level-9 { + margin-left: 320px; + width: calc(100% - 320px); +} +.ItemComment-level-10 { + margin-left: 360px; + width: calc(100% - 360px); +} + +.ItemComment-header { + display: flex; + justify-content: flex-start; + gap: 10px; +} + +.ItemComment-user { + font-weight: bold; + font-size: 12px; +} + +.ItemComment-autho { + color: rgba(75, 85, 99, 1); +} + +.ItemComment-datetime { + font-size: 12px; + color: #666666; +} + +.ItemComment-text { + font-size: 14px; + word-wrap: break-word; +} + +.ItemComment-answer { + color: var(--primary); + font-size: 12px; + font-weight: bold; + font-family: 'Montserrat Alternates', sans-serif; + cursor: pointer; +} + +.ItemComment-login { + margin-top: 30px; +} + diff --git a/src/components/comment-layout/index.js b/src/components/comment-layout/index.js new file mode 100644 index 000000000..476f29a19 --- /dev/null +++ b/src/components/comment-layout/index.js @@ -0,0 +1,15 @@ +import React from 'react'; +import './style.css'; + +function CommentLayout({ children, count, title }) { + return ( +
+

+ {title} ({count}){' '} +

+ {children} +
+ ); +} + +export default CommentLayout; diff --git a/src/components/comment-layout/style.css b/src/components/comment-layout/style.css new file mode 100644 index 000000000..07d04fdf2 --- /dev/null +++ b/src/components/comment-layout/style.css @@ -0,0 +1,9 @@ +.CommentLayout { + margin-top: 40px; +} + +.CommentLayout-header { + font-family: 'Montserrat Alternates', sans-serif; + font-size: 24px; + font-weight: 24px; +} diff --git a/src/components/comment-list/index.js b/src/components/comment-list/index.js new file mode 100644 index 000000000..e775f8854 --- /dev/null +++ b/src/components/comment-list/index.js @@ -0,0 +1,8 @@ +import React from 'react'; +import './style.css'; + +function CommentsList({ children }) { + return
{children}
; +} + +export default CommentsList; diff --git a/src/components/comment-list/style.css b/src/components/comment-list/style.css new file mode 100644 index 000000000..974b31471 --- /dev/null +++ b/src/components/comment-list/style.css @@ -0,0 +1,6 @@ +.CommentsList { + display: flex; + flex-direction: column; + gap: 25px; + margin-top: 30px; +} diff --git a/src/containers/comment/index.js b/src/containers/comment/index.js new file mode 100644 index 000000000..acd36769b --- /dev/null +++ b/src/containers/comment/index.js @@ -0,0 +1,133 @@ +import React, { useMemo } from 'react'; +import { useCallback } from 'react'; +import { useParams } from 'react-router-dom'; +import ItemComment from '../../components/comment-item'; +import useTranslate from '../../hooks/use-translate'; +import useSelector from '../../hooks/use-selector'; +import { useSelector as useSelectorRedux } from 'react-redux'; +import { useStore as useStoreRedux } from 'react-redux'; +import { useDispatch } from 'react-redux'; +import shallowequal from 'shallowequal'; +import commentsActions from '../../store-redux/comment/actions'; +import FormComment from '../../components/comment-form'; +import treeToList from '../../utils/tree-to-list'; +import listToTree from '../../utils/list-to-tree'; +import CommentLayout from '../../components/comment-layout'; +import CommentsList from '../../components/comment-list'; + +function Comments() { + const { t } = useTranslate(); + const store = useStoreRedux(); + const dispatch = useDispatch(); + const params = useParams(); + + const select = useSelector(state => ({ + exists: state.session.exists, + name: state.session.user.profile?.name, + })); + + const selectRedux = useSelectorRedux( + state => ({ + count: state.comment.count, + comments: state.comment.data, + index: state.comment.index, + formCommentIsActive: state.comment.formCommentIsActive, + formAnswerIsActive: state.comment.formAnswerIsActive, + currentCommentId: state.comment.currentCommentId, + }), + shallowequal, + ); + + const callbacks = { + // Установка идентификатора для ответа на комментарий + setIdActiveAnswer: useCallback( + id => { + dispatch(commentsActions.setIdActiveAnswer(id)); + }, + [store], + ), + sendComment: useCallback( + (comment, name = select.name) => { + dispatch(commentsActions.send({ ...comment }, name)); + }, + [store], + ), + setFormAnswerIsActive: useCallback( + id => { + dispatch(commentsActions.setFormAnswerIsActive(id)); + }, + [store], + ), + setFormCommentIsActive: useCallback(() => { + dispatch(commentsActions.setFormCommentIsActive()); + }), + addForm: useCallback( + (id, level, comments) => { + dispatch(commentsActions.addForm(id, level, comments)); + }, + [store], + ), + }; + + const options = { + comments: useMemo( + () => [ + ...treeToList(listToTree(selectRedux.comments), (item, level) => ({ + name: item.author?.profile?.name, + datetime: item.dateCreate, + value: item._id, + text: item.text, + level: level, + mode: 'comment', + })), + ], + [selectRedux.comments], + ), + }; + + return ( + + + {options.comments.map( + (item, index) => + item.value && ( + + ), + )} + + {selectRedux.formCommentIsActive && ( + + )} + + ); +} + +export default React.memo(Comments); diff --git a/src/hooks/use-translate.js b/src/hooks/use-translate.js index bdcbf1071..62ab34dfb 100644 --- a/src/hooks/use-translate.js +++ b/src/hooks/use-translate.js @@ -1,9 +1,26 @@ -import { useCallback, useContext } from 'react'; -import { I18nContext } from '../i18n/context'; +import { useState, useEffect } from 'react'; +import useServices from '../hooks/use-services'; /** * Хук возвращает функцию для локализации текстов, код языка и функцию его смены */ export default function useTranslate() { - return useContext(I18nContext); + const { i18n } = useServices(); + const [lang, setLang] = useState(i18n.getLang()); + + // Подписываемся и отписываемся при размонтировании + useEffect(() => { + const unsubscribe = i18n.subscribe(setLang); + + return () => unsubscribe(); + }, [i18n]); + + return { + // Код локали + lang, + // Функция для смены локали + setLang: i18n.setLang.bind(i18n), + // Функция для локализации текстов с замыканием на код языка + t: i18n.translate.bind(i18n), + }; } diff --git a/src/i18n/context.js b/src/i18n/context.js deleted file mode 100644 index 3733d9bac..000000000 --- a/src/i18n/context.js +++ /dev/null @@ -1,30 +0,0 @@ -import { createContext, useMemo, useState } from 'react'; -import translate from './translate'; - -/** - * @type {React.Context<{}>} - */ -export const I18nContext = createContext({}); - -/** - * Обертка над провайдером контекста, чтобы управлять изменениями в контексте - * @param children - * @return {JSX.Element} - */ -export function I18nProvider({ children }) { - const [lang, setLang] = useState('ru'); - - const i18n = useMemo( - () => ({ - // Код локали - lang, - // Функция для смены локали - setLang, - // Функция для локализации текстов с замыканием на код языка - t: (text, number) => translate(lang, text, number), - }), - [lang], - ); - - return {children}; -} diff --git a/src/i18n/index.js b/src/i18n/index.js new file mode 100644 index 000000000..45ce521a2 --- /dev/null +++ b/src/i18n/index.js @@ -0,0 +1,49 @@ +import * as translations from './translations'; + +class i18nService { + constructor() { + this.lang = localStorage.getItem('lang') || 'ru'; + this.listeners = new Set(); + } + + // Меняем язык + setLang(lang) { + if (this.lang !== lang) { + this.lang = lang; + localStorage.setItem('lang', lang); + this.sentNotify(lang); + } + } + + // Получаем язык + getLang() { + return this.lang; + } + + // Переносим нашу функцию translate из translate.js + translate(text, lang = this.lang, plural) { + let result = translations[lang] && text in translations[lang] ? translations[lang][text] : text; + + if (typeof plural !== 'undefined') { + const key = new Intl.PluralRules(lang).select(plural); + if (key in result) { + result = result[key]; + } + } + + return result; + } + + // Добавляем в слушатели + subscribe(callback) { + this.listeners.add(callback); + return () => this.listeners.delete(callback); + } + + // Передаем слушателям новый язык при смене + sentNotify(lang) { + this.listeners.forEach(cb => cb(lang)); + } +} + +export default i18nService; diff --git a/src/i18n/translate.js b/src/i18n/translate.js deleted file mode 100644 index c4c60db93..000000000 --- a/src/i18n/translate.js +++ /dev/null @@ -1,21 +0,0 @@ -import * as translations from './translations'; - -/** - * Перевод фразу по словарю - * @param lang {String} Код языка - * @param text {String} Текст для перевода - * @param [plural] {Number} Число для плюрализации - * @returns {String} Переведенный текст - */ -export default function translate(lang, text, plural) { - let result = translations[lang] && text in translations[lang] ? translations[lang][text] : text; - - if (typeof plural !== 'undefined') { - const key = new Intl.PluralRules(lang).select(plural); - if (key in result) { - result = result[key]; - } - } - - return result; -} diff --git a/src/i18n/translations/en.json b/src/i18n/translations/en.json index 0ebbcf8f2..d1424be7e 100644 --- a/src/i18n/translations/en.json +++ b/src/i18n/translations/en.json @@ -22,5 +22,11 @@ "auth.login-placeholder": "Enter login", "auth.password-placeholder": "Enter password", "session.signIn": "Sign In", - "session.signOut": "Sign Out" + "session.signOut": "Sign Out", + "comment.title": "Comments", + "comment.btn.answer": "Answer", + "comment.newComment": "New Comment", + "comment.newAnswer": "New Answer", + "comment.btn.send": "Send", + "comment.btn.cancel": "Cancel" } diff --git a/src/i18n/translations/ru.json b/src/i18n/translations/ru.json index a18fb845e..2df8d5603 100644 --- a/src/i18n/translations/ru.json +++ b/src/i18n/translations/ru.json @@ -24,5 +24,11 @@ "auth.login-placeholder": "Введите логин", "auth.password-placeholder": "Введите пароль", "session.signIn": "Вход", - "session.signOut": "Выход" + "session.signOut": "Выход", + "comment.title": "Комментарии", + "comment.btn.answer": "Ответить", + "comment.newComment": "Новый комментарий", + "comment.newAnswer": "Новый ответ", + "comment.btn.send": "Отправить", + "comment.btn.cancel": "Отмена" } diff --git a/src/index.js b/src/index.js index f49db385a..9404d09eb 100644 --- a/src/index.js +++ b/src/index.js @@ -2,7 +2,6 @@ import { createRoot } from 'react-dom/client'; import { BrowserRouter } from 'react-router-dom'; import { Provider } from 'react-redux'; import { ServicesContext } from './context'; -import { I18nProvider } from './i18n/context'; import App from './app'; import Services from './services'; import config from './config'; @@ -16,11 +15,9 @@ const root = createRoot(document.getElementById('root')); root.render( - - - - - + + + , ); diff --git a/src/services.js b/src/services.js index a32b14d57..76ed26e8e 100644 --- a/src/services.js +++ b/src/services.js @@ -1,5 +1,6 @@ import APIService from './api'; import Store from './store'; +import i18nService from './i18n'; import createStoreRedux from './store-redux'; class Services { @@ -38,6 +39,13 @@ class Services { } return this._redux; } + + get i18n() { + if (!this._i18n) { + this._i18n = new i18nService(this, this.config.i18n); + } + return this._i18n; + } } export default Services; diff --git a/src/store-redux/comment/actions.js b/src/store-redux/comment/actions.js new file mode 100644 index 000000000..ca3bdce52 --- /dev/null +++ b/src/store-redux/comment/actions.js @@ -0,0 +1,81 @@ +export default { + + load: id => { + return async (dispatch, getState, services) => { + dispatch({ type: 'comments/load-start' }); + + try { + const res = await services.api.request({ + url: `/api/v1/comments?fields=items(_id,text,dateCreate,author(profile(name)),parent(_id,_type),isDeleted),count&limit=*&search[parent]=${id}`, + }); + + dispatch({ type: 'comments/load-success', payload: { data: res.data.result } }); + } + catch (e) { + dispatch({ type: 'comments/load-error' }); + } + }; + }, + + send: (comment, name) => { + return async (dispatch, getState, services) => { + dispatch({ type: 'comments/send-start' }); + + try { + const res = await services.api.request({ + url: '/api/v1/comments', + method: 'POST', + body: JSON.stringify(comment), + }) + const temp = { + ...res.data.result, + author: {profile: {name: name}}, + } + console.log(temp) + dispatch({ type: 'comments/send-success', payload: { temp } }); + dispatch({ type: 'comments/setFormCommentIsActive', payload: { formCommentIsActive: true, formAnswerIsActive: false } }); + } + catch (e) { + dispatch({ type: 'comments/send-error' }); + } + } + }, + + addForm: (id, level, comments) => { + return (dispatch) => { + const parentIndex = comments.findIndex(item => item.value === id); + let lastChildIndex + let res + for (let i = parentIndex + 1; i < comments.length; i++) { + if (comments[i].level <= level) { + break + } + if (comments[i].level === level + 1) { + lastChildIndex = i + } + } + + if (lastChildIndex) { + res = lastChildIndex + } + else { + res = parentIndex + } + dispatch({ type: 'comments/addForm', payload: { res: res } }); + } + }, + + setFormAnswerIsActive: (id) => { + return { + type: 'comments/setFormAnswerIsActive', + payload: {formAnswerIsActive: true, formCommentIsActive: false, id: id}, + } + }, + + setFormCommentIsActive: () => { + return { + type: 'comments/setFormCommentIsActive', + payload: {formAnswerIsActive: false, formCommentIsActive: true}, + } + }, +}; diff --git a/src/store-redux/comment/reducer.js b/src/store-redux/comment/reducer.js new file mode 100644 index 000000000..684f4c6d5 --- /dev/null +++ b/src/store-redux/comment/reducer.js @@ -0,0 +1,64 @@ +export const initialState = { + data: [], + count: 0, + waiting: false, + formCommentIsActive: true, + formAnswerIsActive: false, + index: null, + currentCommentId: null, +}; + +function reducer(state = initialState, action) { + switch (action.type) { + case 'comments/load-start': + return { ...state, data: [], count: 0, waiting: true }; + + case 'comments/load-success': + return { + ...state, + data: action.payload.data.items, + count: action.payload.data.count, + waiting: false, + }; + + case 'comments/load-error': + return { ...state, data: [], count: 0, waiting: false }; + + case 'comments/send-start': + return { ...state, waiting: true }; + + case 'comments/send-success': + return { + ...state, + data: [...state.data, action.payload.temp], + idActiveAnswer: null, + waiting: false, + }; + + case 'comments/send-error': + return { ...state, waiting: false }; + + case 'comments/setFormAnswerIsActive': + return { + ...state, + formAnswerIsActive: action.payload.formAnswerIsActive, + formCommentIsActive: action.payload.formCommentIsActive, + currentCommentId: action.payload.id, + }; + + case 'comments/setFormCommentIsActive': + return { + ...state, + formCommentIsActive: action.payload.formCommentIsActive, + formAnswerIsActive: action.payload.formAnswerIsActive, + }; + + case 'comments/addForm': + return { ...state, index: action.payload.res }; + + default: + return state; + } +} + +export default reducer; diff --git a/src/store-redux/exports.js b/src/store-redux/exports.js index 1a0a3d742..bbea750c9 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 comment } from './comment/reducer'; diff --git a/src/utils/list-to-tree/index.js b/src/utils/list-to-tree/index.js index fb4d20a66..23f98b4f8 100644 --- a/src/utils/list-to-tree/index.js +++ b/src/utils/list-to-tree/index.js @@ -4,7 +4,8 @@ * @param [key] {String} Свойство с первичным ключом * @returns {Array} Корневые узлы */ -export default function listToTree(list, key = '_id') { +export default function listToTree(list, key = '_id', type = '_type') { + let trees = {}; let roots = {}; for (const item of list) { @@ -19,7 +20,7 @@ export default function listToTree(list, key = '_id') { } // Если элемент имеет родителя, то добавляем его в подчиненные родителя - if (item.parent?.[key]) { + if (item.parent?.[key] && item.parent?.[type] !== 'article') { // Если родителя ещё нет в индексе, то индекс создаётся, ведь _id родителя известен if (!trees[item.parent[key]]) { trees[item.parent[key]] = { children: [] };