Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/app/article/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -55,6 +56,7 @@ function Article() {
<Navigation />
<Spinner active={select.waiting}>
<ArticleCard article={select.article} onAdd={callbacks.addToBasket} t={t} />
<Comments articleId={params.id} />
</Spinner>
</PageLayout>
</>
Expand Down
57 changes: 57 additions & 0 deletions src/components/comment-form/index.js
Original file line number Diff line number Diff line change
@@ -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 (
<form className={cn()} onSubmit={onSubmit}>
<h2>{parentType === 'article' ? 'Новый комментарий' : 'Новый ответ'}</h2>
<textarea
className={cn('input')}
value={text}
onChange={e => setText(e.target.value)}
placeholder="Введите комментарий..."
/>
<div className={cn('buttons')}>
<Button style="primary" type="submit" title="Отправить" />
{parentType === 'comment' && (
<Button style="outline" type="button" onClick={onCancel} title="Отмена" />
)}
</div>
</form>
);
}

CommentForm.propTypes = {
parentId: PropTypes.string.isRequired,
parentType: PropTypes.string,
};

export default memo(CommentForm);
27 changes: 27 additions & 0 deletions src/components/comment-form/style.css
Original file line number Diff line number Diff line change
@@ -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;
}
52 changes: 52 additions & 0 deletions src/components/comment/index.js
Original file line number Diff line number Diff line change
@@ -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 (
<div className={cn()}>
<div className={cn('header')}>
<span className={cn('author')}>User №{data.author?._id}</span>
<span className={cn('date')}>{formatDate(data.dateCreate)}</span>
</div>
<div className={cn('text')}>{data.text}</div>
{isAuth && (
<div className={cn('actions')}>
<Button style="text" onClick={() => onReply(data._id)} title="Ответить" />
</div>
)}
</div>
);
}

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);
37 changes: 37 additions & 0 deletions src/components/comment/style.css
Original file line number Diff line number Diff line change
@@ -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;
}
99 changes: 99 additions & 0 deletions src/containers/comments/index.js
Original file line number Diff line number Diff line change
@@ -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 => (
<div key={comment._id}>
<Comment data={comment} onReply={onReply} showReplyForm={replyTo} isAuth={select.isAuth} />
{comment.children?.length > 0 && (
<div className={cn('replies')}>{renderComments(comment.children)}</div>
)}
{replyTo === comment._id && (
<div className={cn('reply-form')}>
<CommentForm
parentId={comment._id}
parentType="comment"
replyToUserId={comment.author?._id}
/>
</div>
)}
</div>
));
};

const commentsTree = buildCommentsTree(items);

return (
<div className={cn()}>
<h2 className={cn('title')}>Комментарии ({items.length})</h2>
<div className={cn('list')}>{renderComments(commentsTree)}</div>
{select.isAuth ? (
!replyTo && <CommentForm parentId={articleId} parentType="article" />
) : (
<div className={cn('auth-message')}>
<Link to="/login" className={cn('auth-link')}>
Войдите
</Link>
, чтобы иметь возможность комментировать
</div>
)}
</div>
);
}

export default memo(Comments);
28 changes: 28 additions & 0 deletions src/containers/comments/style.css
Original file line number Diff line number Diff line change
@@ -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;
}
20 changes: 17 additions & 3 deletions src/hooks/use-translate.js
Original file line number Diff line number Diff line change
@@ -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 };
}
30 changes: 0 additions & 30 deletions src/i18n/context.js

This file was deleted.

Loading