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/api/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ class APIService {
*/
async request({ url, method = 'GET', headers = {}, ...options }) {
if (!url.match(/^(http|\/\/)/)) url = this.config.baseUrl + url;

const res = await fetch(url, {
method,
headers: { ...this.defaultHeaders, ...headers },
Expand All @@ -41,6 +42,7 @@ class APIService {
delete this.defaultHeaders[name];
}
}

}

export default APIService;
29 changes: 27 additions & 2 deletions src/app/article/index.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { memo, useCallback } from 'react';
import { memo, useCallback, useEffect } from 'react';
import { useParams } from 'react-router-dom';
import useStore from '../../hooks/use-store';
import useTranslate from '../../hooks/use-translate';
Expand All @@ -13,25 +13,37 @@ 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/comments/actions';
import HeadLayout from '../../components/head-layout';
import CommentList from '../../components/comments-tree';
import useSelectorStore from '../../hooks/use-selector';
import useServices from '../../hooks/use-services';

function Article() {
const store = useStore();
const selectStore = useSelectorStore(state => ({
user: state.session.user,
exists: state.session.exists,
}));

const dispatch = useDispatch();
// Параметры из пути /articles/:id

// Параметры из пути /articles/:id
const params = useParams();



useInit(() => {
//store.actions.article.load(params.id);
dispatch(articleActions.load(params.id));
dispatch(commentsActions.load(params.id));
}, [params.id]);

const select = useSelector(
state => ({
article: state.article.data,
waiting: state.article.waiting,
comments: state.comments.data,
}),
shallowequal,
); // Нужно указать функцию для сравнения свойства объекта, так как хуком вернули объект
Expand All @@ -43,6 +55,18 @@ function Article() {
addToBasket: useCallback(_id => store.actions.basket.addToBasket(_id), [store]),
};

// Подписка на изменения языка
const services = useServices();
useEffect(() => {
const unsubscribe = services.i18n.subscribe(() => {
// Повторно загружаем статью и комментарии на новом языке
dispatch(articleActions.load(params.id));
dispatch(commentsActions.load(params.id));
});

return () => unsubscribe(); // отписка при размонтировании
}, [params.id, dispatch, services.i18n]);

return (
<>
<HeadLayout>
Expand All @@ -55,6 +79,7 @@ function Article() {
<Navigation />
<Spinner active={select.waiting}>
<ArticleCard article={select.article} onAdd={callbacks.addToBasket} t={t} />
<CommentList comments={select.comments} productId={params.id} user={selectStore.user} isAuthorized={selectStore.exists} />
</Spinner>
</PageLayout>
</>
Expand Down
56 changes: 56 additions & 0 deletions src/components/comment-form/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { useState } from 'react';
import PropTypes from 'prop-types';
import { useDispatch, useSelector } from 'react-redux';
import commentsActions from '../../store-redux/comments/actions';
import Button from '../button';
import { cn as bem } from '@bem-react/classname';
import './style.css';

function CommentForm({ parentId = null, parentName = null, onCancel = () => { } }) {
const dispatch = useDispatch();
const [text, setText] = useState('');
const productId = useSelector(state => state.article.data?._id);

const handleSubmit = e => {
e.preventDefault();
if (!text.trim()) return;

dispatch(commentsActions.add({
text,
parent: {
_id: parentId || productId,
_type: parentId ? 'comment' : 'article',
},
}));

setText('');
if (onCancel) onCancel(); // закрыть форму ответа
};

const cn = bem('CommentForm');
return (
<form className={cn()} onSubmit={handleSubmit}>
<div className={cn('title')}>Новый {parentId ? 'ответ' : 'комментарий'}</div>
<textarea
className={cn('textarea')}
value={text}
onChange={e => setText(e.target.value)}
placeholder={parentId && `Мой ответ для ${parentName}`}
/>
<div className={cn('actions')}>
<Button title="Отправить" style="primary" type="submit" />
{parentId && (
<Button onClick={onCancel} title="Отмена" style="outline" type="button" />
)}
</div>
</form>
);
}

CommentForm.propTypes = {
parentId: PropTypes.string,
parentName: PropTypes.string,
onCancel: PropTypes.func,
};

export default CommentForm;
31 changes: 31 additions & 0 deletions src/components/comment-form/style.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
.CommentForm {
width: 100%;
display: flex;
flex-direction: column;
gap: 16px;
}

.CommentForm-title {
padding: 0;
font-family: var(--font-family);
font-weight: 700;
font-size: 16px;
line-height: 22px;
}

.CommentForm-textarea {
width: 100%;
min-height: 88px;
border: 1px solid var(--filter-border);
padding: 8px 12px;
font-family: var(--font-family);
font-weight: 400;
font-size: 14px;
line-height: 130%;
}

.CommentForm-actions {
display: flex;
flex-direction: row;
gap: 16px;
}
43 changes: 43 additions & 0 deletions src/components/comment-item/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { memo } from 'react';
import PropTypes from 'prop-types';
import formatDate from '../../utils/format-date';
import { cn as bem } from '@bem-react/classname';
import './style.css';

function CommentItem({ comment = {}, onReplyClick = () => { }, user = {}, isAuthorized = false }) {
const cn = bem('CommentItem');
return (
<div className={cn()}>
<div className={cn('header')}>
<span className={cn('author')}
style={isAuthorized && comment.author?._id === user._id ? { color: '#4B5563' } : undefined}>
{comment.author?.profile?.name || user?.profile?.name}</span>
<span className={cn('date')}>{formatDate(comment.dateCreate)}</span>
</div>
<div className={cn('text')}>{comment.text}</div>
<button className={cn('reply')} onClick={() => onReplyClick(comment._id)}>Ответить</button>
</div>
);
}

CommentItem.propTypes = {
comment: PropTypes.shape({
_id: PropTypes.string,
text: PropTypes.string,
author: PropTypes.shape({
profile: PropTypes.shape({
name: PropTypes.string,
}),
}),
date: PropTypes.string,
}),
onReplyClick: PropTypes.func,
user: PropTypes.shape({
profile: PropTypes.shape({
name: PropTypes.string,
}),
}),
isAuthorized: PropTypes.bool,
};

export default memo(CommentItem);
56 changes: 56 additions & 0 deletions src/components/comment-item/style.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
.CommentItem {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 6px;
}

.CommentItem-header {
display: flex;
align-items: center;
gap: 12px;
}

.CommentItem-author {
font-family: var(--font-family);
font-weight: 700;
font-size: 12px;
line-height: 18px;
}

.CommentItem-date {
font-family: var(--font-family);
font-weight: 400;
font-size: 12px;
line-height: 18px;
color: #666666;
}

.CommentItem-text {
font-family: var(--font-family);
font-weight: 400;
font-size: 14px;
line-height: 20px;
overflow-wrap: break-word;
word-break: break-word;
}

.CommentItem-reply {
padding: 0;
font-family: var(--second-font-family);
font-weight: 700;
font-size: 12px;
line-height: 18px;
color: var(--primary);
}

.CommentItem-enter {
margin: 18px 0;
font-family: var(--font-family);
font-weight: 400;
font-size: 16px;
line-height: 22px;
}
.CommentItem-enter a {
color: var(--primary);
}
117 changes: 117 additions & 0 deletions src/components/comments-tree/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
import React from 'react';
import CommentItem from '../comment-item';
import CommentForm from '../comment-form';
import { Link, useLocation } from 'react-router-dom';
import listToTree from '../../utils/list-to-tree';
import treeToList from '../../utils/tree-to-list';
import { useState, useMemo, useEffect, useRef } from 'react';
import { cn as bem } from '@bem-react/classname';
import './style.css';

const MAX_LEVEL = 10;// максимальный отображаемый уровень вложенности комментариев, чтобы не было слишком много отступов


export default function CommentList({ comments = [], productId = null, user = {}, isAuthorized = false }) {
const [replyingId, setReplyingId] = useState(null);

const tree = useMemo(() => {
const updatedComments = [...comments];

// Добавляем фиктивный комментарий для формы ответа
if (replyingId) {
const replyingComment = updatedComments.find((comment) => comment._id === replyingId);
if (replyingComment) {
updatedComments.push({
_id: 'reply-form',
isReplyingForm: true,
parent: {
_id: replyingComment._id,
_type: 'comment',
},
parentName: replyingComment.author?.profile?.name || user?.profile?.name,
});
}
}

return [
...treeToList(listToTree(updatedComments), (comment, level) => ({
comment: comment,
level: level,
})),
];
}, [comments, replyingId]);

const count = tree.length;

const handleReplyClick = (id) => {
setReplyingId((prev) => (prev === id ? null : id));
};

const replyFormRef = useRef(null);

// Скролл к форме ответа
useEffect(() => {
if (replyingId && replyFormRef.current) {
replyFormRef.current.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
}, [replyingId]);

const location = useLocation();

const cn = bem('CommentList');
return (
<div className={cn()}>
<h2 className={cn('title')}>Комментарии ({count > 0 ? count : 0})</h2>
<div className={cn('wrapper')}>
{tree.map((item) => {
const { comment, level } = item;
const isReplyingForm = comment.isReplyingForm;
const marginStyle = level <= MAX_LEVEL
? { marginLeft: `${(level) * 40}px` }
: { marginLeft: `${(MAX_LEVEL) * 40}px` };

// Если это форма ответа, то рендерим ее. К ней добавлен ref, чтобы скроллить к ней
if (isReplyingForm) return (
<div style={marginStyle} ref={replyFormRef}>
{isAuthorized ? (
<CommentForm
parentId={comment.parent._id}
parentName={comment.parentName}
onCancel={() => setReplyingId(null)}
/>
) : (
<p className={cn('enter')}>
<Link to="/login">Войдите</Link>, чтобы иметь возможность комментировать
</p>
)}
</div>
);

return (
<div key={comment._id}>
<div style={marginStyle}>
<CommentItem
comment={comment}
onReplyClick={handleReplyClick}
replyingId={replyingId}
user={user}
isAuthorized={isAuthorized}
/>
</div>
</div>
);
})}
</div>

{replyingId === null && (
isAuthorized ? (
<CommentForm productId={productId} />
) : (
<p className={cn('enter')}>
<Link to="/login" state={{ back: location.pathname }}>Войдите</Link>, чтобы иметь возможность комментировать
</p>
)
)}
</div>
);
}
Loading