diff --git a/src/app/article/index.js b/src/app/article/index.js
index 54f037b64..f6ed6f373 100644
--- a/src/app/article/index.js
+++ b/src/app/article/index.js
@@ -14,6 +14,7 @@ import { useDispatch, useSelector } from 'react-redux';
import shallowequal from 'shallowequal';
import articleActions from '../../store-redux/article/actions';
import HeadLayout from '../../components/head-layout';
+import Comments from '../../containers/comments';
function Article() {
const store = useStore();
@@ -55,6 +56,7 @@ function Article() {
+
>
diff --git a/src/components/comment-form/index.js b/src/components/comment-form/index.js
new file mode 100644
index 000000000..49e362780
--- /dev/null
+++ b/src/components/comment-form/index.js
@@ -0,0 +1,57 @@
+import { memo, useEffect, useState } from 'react';
+import PropTypes from 'prop-types';
+import { cn as bem } from '@bem-react/classname';
+import './style.css';
+import Button from '../button';
+import { useDispatch } from 'react-redux';
+import commentsActions from '../../store-redux/comments/actions';
+
+function CommentForm({ parentId, parentType = 'article', replyToUserId }) {
+ const [text, setText] = useState('');
+ const dispatch = useDispatch();
+ const cn = bem('CommentForm');
+
+ // При монтировании компонента добавляем префикс с адресатом
+ useEffect(() => {
+ if (parentType === 'comment' && replyToUserId) {
+ setText(`Мой ответ для User №${replyToUserId}\n`);
+ }
+ }, [parentType, replyToUserId]);
+
+ const onSubmit = e => {
+ e.preventDefault();
+ if (text.trim()) {
+ dispatch(commentsActions.add(text, parentId, parentType));
+ setText('');
+ }
+ };
+
+ const onCancel = () => {
+ dispatch(commentsActions.setReply(null));
+ };
+
+ return (
+
+ );
+}
+
+CommentForm.propTypes = {
+ parentId: PropTypes.string.isRequired,
+ parentType: PropTypes.string,
+};
+
+export default memo(CommentForm);
diff --git a/src/components/comment-form/style.css b/src/components/comment-form/style.css
new file mode 100644
index 000000000..d29c64f43
--- /dev/null
+++ b/src/components/comment-form/style.css
@@ -0,0 +1,27 @@
+.CommentForm {
+ margin-top: 24px;
+ margin-bottom: 24px;
+ display: flex;
+ flex-direction: column;
+ gap: 16px;
+
+ > h2 {
+ font-size: 16px;
+ line-height: 22px;
+ }
+}
+
+.CommentForm-input {
+ width: 100%;
+ min-height: 88px;
+ padding: 8px;
+ border: 1px solid var(--filter-border);
+ border-radius: 4px;
+ font-family: var(--font-family);
+ resize: vertical;
+}
+
+.CommentForm-buttons {
+ display: flex;
+ gap: 16px;
+}
diff --git a/src/components/comment/index.js b/src/components/comment/index.js
new file mode 100644
index 000000000..f95849083
--- /dev/null
+++ b/src/components/comment/index.js
@@ -0,0 +1,52 @@
+import { memo } from 'react';
+import PropTypes from 'prop-types';
+import { cn as bem } from '@bem-react/classname';
+import './style.css';
+import Button from '../button';
+
+function Comment({ data, onReply, isAuth }) {
+ const cn = bem('Comment');
+
+ // Форматируем дату в нужный формат
+ const formatDate = dateString => {
+ const date = new Date(dateString);
+ const day = date.getDate();
+ const month = date.toLocaleString('ru', { month: 'long' });
+ const year = date.getFullYear();
+ const hours = date.getHours().toString().padStart(2, '0');
+ const minutes = date.getMinutes().toString().padStart(2, '0');
+
+ return `${day} ${month} ${year} в ${hours}:${minutes}`;
+ };
+
+ return (
+
+
+ User №{data.author?._id}
+ {formatDate(data.dateCreate)}
+
+
{data.text}
+ {isAuth && (
+
+
+ )}
+
+ );
+}
+
+Comment.propTypes = {
+ data: PropTypes.shape({
+ _id: PropTypes.string,
+ text: PropTypes.string,
+ dateCreate: PropTypes.string,
+ author: PropTypes.shape({
+ _id: PropTypes.string,
+ }),
+ }).isRequired,
+ onReply: PropTypes.func,
+ showReplyForm: PropTypes.string,
+ isAuth: PropTypes.bool,
+};
+
+export default memo(Comment);
diff --git a/src/components/comment/style.css b/src/components/comment/style.css
new file mode 100644
index 000000000..cf63c11e4
--- /dev/null
+++ b/src/components/comment/style.css
@@ -0,0 +1,37 @@
+.Comment {
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+ margin-bottom: 16px;
+}
+
+.Comment-header {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+}
+
+.Comment-author {
+ font-weight: bold;
+ color: var(--main-text);
+}
+
+.Comment-date {
+ color: var(--close);
+ font-size: 12px;
+}
+
+.Comment-text {
+ color: var(--main-text);
+ font-size: 14px;
+ line-height: 20px;
+}
+
+.Comment-actions button {
+ color: var(--primary);
+ font-size: 12px;
+}
+
+.Comments-replies {
+ margin-left: 40px;
+}
diff --git a/src/containers/comments/index.js b/src/containers/comments/index.js
new file mode 100644
index 000000000..85642344e
--- /dev/null
+++ b/src/containers/comments/index.js
@@ -0,0 +1,99 @@
+import { memo, useCallback, useEffect } from 'react';
+import { useDispatch, useSelector as useReduxSelector } from 'react-redux';
+import { Link } from 'react-router-dom';
+import commentsActions from '../../store-redux/comments/actions';
+import Comment from '../../components/comment';
+import CommentForm from '../../components/comment-form';
+import { cn as bem } from '@bem-react/classname';
+import './style.css';
+import useStore from '../../hooks/use-store';
+import useSelector from '../../hooks/use-selector';
+
+function Comments({ articleId }) {
+ const dispatch = useDispatch();
+ const store = useStore();
+ const cn = bem('Comments');
+
+ const { items, replyTo } = useReduxSelector(state => state.comments);
+ const select = useSelector(state => ({
+ isAuth: state.session.exists,
+ }));
+
+ useEffect(() => {
+ dispatch(commentsActions.load(articleId));
+ }, [articleId]);
+
+ const onReply = useCallback(commentId => {
+ dispatch(commentsActions.setReply(commentId));
+ }, []);
+
+ // Строим дерево комментариев
+ const buildCommentsTree = comments => {
+ const tree = [];
+ const commentMap = {};
+
+ // Создаем мапу комментариев и добавляем информацию о том, кому отвечаем
+ comments.forEach(comment => {
+ const parentComment = comments.find(c => c._id === comment.parent._id);
+ commentMap[comment._id] = {
+ ...comment,
+ children: [],
+ replyTo: comment.parent._type === 'comment' ? `User №${parentComment?.author?._id}` : null,
+ };
+ });
+
+ comments.forEach(comment => {
+ if (comment.parent._type === 'comment') {
+ const parentComment = commentMap[comment.parent._id];
+ if (parentComment) {
+ parentComment.children.push(commentMap[comment._id]);
+ }
+ } else {
+ tree.push(commentMap[comment._id]);
+ }
+ });
+
+ return tree;
+ };
+
+ const renderComments = comments => {
+ return comments.map(comment => (
+
+
+ {comment.children?.length > 0 && (
+
{renderComments(comment.children)}
+ )}
+ {replyTo === comment._id && (
+
+
+
+ )}
+
+ ));
+ };
+
+ const commentsTree = buildCommentsTree(items);
+
+ return (
+
+
Комментарии ({items.length})
+
{renderComments(commentsTree)}
+ {select.isAuth ? (
+ !replyTo &&
+ ) : (
+
+
+ Войдите
+
+ , чтобы иметь возможность комментировать
+
+ )}
+
+ );
+}
+
+export default memo(Comments);
diff --git a/src/containers/comments/style.css b/src/containers/comments/style.css
new file mode 100644
index 000000000..c284e52ed
--- /dev/null
+++ b/src/containers/comments/style.css
@@ -0,0 +1,28 @@
+.Comments {
+ margin-top: 16px;
+}
+
+.Comments-title {
+ font-size: 24px;
+ font-weight: bold;
+ margin-bottom: 24px;
+ color: var(--main-text);
+}
+
+.Comments-auth-message {
+ text-align: left;
+ color: var(--main-text);
+}
+
+.Comments-auth-link {
+ color: var(--primary);
+ text-decoration: none;
+
+ &:hover {
+ text-decoration: underline;
+ }
+}
+
+.Comments-list {
+ margin-bottom: 24px;
+}
diff --git a/src/hooks/use-translate.js b/src/hooks/use-translate.js
index bdcbf1071..f8b3a2c12 100644
--- a/src/hooks/use-translate.js
+++ b/src/hooks/use-translate.js
@@ -1,9 +1,23 @@
-import { useCallback, useContext } from 'react';
-import { I18nContext } from '../i18n/context';
+import { useCallback, useEffect, useState } from 'react';
+import useServices from './use-services';
/**
* Хук возвращает функцию для локализации текстов, код языка и функцию его смены
*/
export default function useTranslate() {
- return useContext(I18nContext);
+ const services = useServices();
+ const [lang, setLang] = useState(services.i18n.getLang());
+
+ useEffect(() => {
+ return services.i18n.subscribe(newLang => setLang(newLang));
+ }, [services.i18n]);
+
+ const t = useCallback(
+ (text, plural) => services.i18n.translate(text, plural),
+ [services.i18n, lang],
+ );
+
+ const changeLang = useCallback(newLang => services.i18n.setLang(newLang), [services.i18n]);
+
+ return { t, lang, setLang: changeLang };
}
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/service.js b/src/i18n/service.js
new file mode 100644
index 000000000..c06045947
--- /dev/null
+++ b/src/i18n/service.js
@@ -0,0 +1,47 @@
+import * as translations from './translations';
+
+class I18nService {
+ constructor(services, config = {}) {
+ this.services = services;
+ this.config = config;
+ this.lang = 'ru';
+ this.listeners = new Set();
+ }
+
+ subscribe(listener) {
+ this.listeners.add(listener);
+ return () => this.listeners.delete(listener);
+ }
+
+ notify() {
+ this.listeners.forEach(listener => listener(this.lang));
+ }
+
+ getLang() {
+ return this.lang;
+ }
+
+ setLang(lang) {
+ if (this.lang !== lang) {
+ this.lang = lang;
+ this.services.api.setHeader('Accept-Language', lang);
+ this.services.api.setHeader('X-Lang', lang);
+ this.notify();
+ }
+ }
+
+ translate(text, plural, lang = this.lang) {
+ 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;
+ }
+}
+
+export default I18nService;
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..88ee38b3c 100644
--- a/src/services.js
+++ b/src/services.js
@@ -1,4 +1,5 @@
import APIService from './api';
+import I18nService from './i18n/service';
import Store from './store';
import createStoreRedux from './store-redux';
@@ -29,6 +30,13 @@ class Services {
return this._store;
}
+ get i18n() {
+ if (!this._i18n) {
+ this._i18n = new I18nService(this, this.config.i18n);
+ }
+ return this._i18n;
+ }
+
/**
* Redux store
*/
diff --git a/src/store-redux/comments/actions.js b/src/store-redux/comments/actions.js
new file mode 100644
index 000000000..491b4bd2e
--- /dev/null
+++ b/src/store-redux/comments/actions.js
@@ -0,0 +1,55 @@
+export default {
+ load: articleId => {
+ return async (dispatch, services) => {
+ dispatch({ type: 'comments/load-start' });
+
+ try {
+ const res = await services.api.request({
+ url: `/api/v1/comments?fields=items(_id,text,dateCreate,author(_id),parent(_id,_type)),count&limit=*&search[parent]=${articleId}`,
+ });
+
+ dispatch({
+ type: 'comments/load-success',
+ payload: res.data.result,
+ });
+ } catch (e) {
+ dispatch({
+ type: 'comments/load-error',
+ payload: e.message,
+ });
+ }
+ };
+ },
+
+ add: (text, parentId, parentType = 'article') => {
+ return async (dispatch, getState, services) => {
+ try {
+ await services.api.request({
+ url: '/api/v1/comments',
+ method: 'POST',
+ body: JSON.stringify({
+ text,
+ parent: {
+ _id: parentId,
+ _type: parentType,
+ },
+ }),
+ });
+
+ const articleId = parentType === 'article' ? parentId : getState().article.data._id;
+ dispatch(this.load(articleId));
+ dispatch(this.setReply(null));
+ } catch (e) {
+ dispatch({
+ type: 'comments/add-error',
+ payload: e.message,
+ });
+ }
+ };
+ },
+
+ setReply: commentId => ({
+ type: 'comments/set-reply',
+ payload: commentId,
+ }),
+};
diff --git a/src/store-redux/comments/reducer.js b/src/store-redux/comments/reducer.js
new file mode 100644
index 000000000..d7b1f2568
--- /dev/null
+++ b/src/store-redux/comments/reducer.js
@@ -0,0 +1,39 @@
+const initialState = {
+ items: [],
+ count: 0,
+ waiting: false,
+ replyTo: null, // id комментария, на который отвечаем
+ error: null,
+};
+
+export default function reducer(state = initialState, action) {
+ switch (action.type) {
+ case 'comments/load-start':
+ return { ...state, waiting: true };
+
+ case 'comments/load-success':
+ return {
+ ...state,
+ items: action.payload.items,
+ count: action.payload.count,
+ waiting: false,
+ };
+
+ case 'comments/load-error':
+ return { ...state, error: action.payload, waiting: false };
+
+ case 'comments/set-reply':
+ return { ...state, replyTo: action.payload };
+
+ case 'comments/add-success':
+ return {
+ ...state,
+ items: [action.payload, ...state.items],
+ count: state.count + 1,
+ replyTo: null,
+ };
+
+ default:
+ return state;
+ }
+}
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';